mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-03-17 21:31:43 +01:00
Add support for Amber signer
This commit is contained in:
parent
868aefeb83
commit
43faa02574
5
.changeset/four-grapes-beg.md
Normal file
5
.changeset/four-grapes-beg.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"nostrudel": minor
|
||||
---
|
||||
|
||||
Add support for Amber signer
|
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,3 +1,3 @@
|
||||
{
|
||||
"cSpell.words": ["Bech", "Chakra", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
|
||||
"cSpell.words": ["Bech", "Chakra", "damus", "lnurl", "Msat", "nostr", "noStrudel", "Npub", "pubkeys", "Sats", "webln"]
|
||||
}
|
||||
|
@ -1,10 +1,15 @@
|
||||
import { bech32 } from "bech32";
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
import { NostrEvent, Tag, isATag, isDTag, isETag, isPTag } from "../types/nostr-event";
|
||||
import { isReplaceable } from "./nostr/events";
|
||||
import { DecodeResult } from "nostr-tools/lib/types/nip19";
|
||||
import relayHintService from "../services/event-relay-hint";
|
||||
|
||||
export function isHex(str?: string) {
|
||||
if (str?.match(/^[0-9a-f]+$/i)) return true;
|
||||
return false;
|
||||
}
|
||||
export function isHexKey(key?: string) {
|
||||
if (key?.toLowerCase()?.match(/^[0-9a-f]{64}$/)) return true;
|
||||
return false;
|
||||
|
@ -8,7 +8,7 @@ export type Account = {
|
||||
relays?: string[];
|
||||
secKey?: ArrayBuffer;
|
||||
iv?: Uint8Array;
|
||||
connectionType?: "extension" | "serial";
|
||||
connectionType?: "extension" | "serial" | "amber";
|
||||
localSettings?: AppSettings;
|
||||
};
|
||||
|
||||
|
105
src/services/amber-signer.ts
Normal file
105
src/services/amber-signer.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { getEventHash, nip19, verifySignature } from "nostr-tools";
|
||||
|
||||
import createDefer, { Deferred } from "../classes/deferred";
|
||||
import { getPubkeyFromDecodeResult, isHex, isHexKey } from "../helpers/nip19";
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
|
||||
export function createGetPublicKeyIntent() {
|
||||
return `intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end`;
|
||||
}
|
||||
export function createSignEventIntent(draft: DraftNostrEvent) {
|
||||
return `intent:${encodeURIComponent(
|
||||
JSON.stringify(draft),
|
||||
)}#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=sign_event;end`;
|
||||
}
|
||||
export function createNip04EncryptIntent(pubkey: string, plainText: string) {
|
||||
return `intent:${encodeURIComponent(
|
||||
plainText,
|
||||
)}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_encrypt;end`;
|
||||
}
|
||||
export function createNip04DecryptIntent(pubkey: string, data: string) {
|
||||
return `intent:${data}#Intent;scheme=nostrsigner;S.pubKey=${pubkey};S.compressionType=none;S.returnType=signature;S.type=nip04_decrypt;end`;
|
||||
}
|
||||
|
||||
let pendingRequest: Deferred<string> | null = null;
|
||||
|
||||
function rejectPending() {
|
||||
if (pendingRequest) {
|
||||
pendingRequest.reject("Canceled");
|
||||
pendingRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
if (!pendingRequest) return;
|
||||
|
||||
// read the result from the clipboard
|
||||
setTimeout(() => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((result) => pendingRequest?.resolve(result))
|
||||
.catch((e) => pendingRequest?.reject(e));
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
async function intentRequest(intent: string) {
|
||||
rejectPending();
|
||||
const request = createDefer<string>();
|
||||
window.open(intent, "_blank");
|
||||
// NOTE: wait 100ms before setting the pending request since the visibilitychange event fires as soon as window.open is called
|
||||
setTimeout(() => {
|
||||
pendingRequest = request;
|
||||
}, 100);
|
||||
const result = await request;
|
||||
if (result.length === 0) throw new Error("Empty clipboard");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getPublicKey() {
|
||||
const result = await intentRequest(createGetPublicKeyIntent());
|
||||
if (isHexKey(result)) return result;
|
||||
else if (result.startsWith("npub") || result.startsWith("nprofile")) {
|
||||
const decode = nip19.decode(result);
|
||||
const pubkey = getPubkeyFromDecodeResult(decode);
|
||||
if (!pubkey) throw new Error("Expected npub from clipboard");
|
||||
return pubkey;
|
||||
}
|
||||
throw new Error("Expected clipboard to have pubkey");
|
||||
}
|
||||
|
||||
async function signEvent(draft: DraftNostrEvent & { pubkey: string }): Promise<NostrEvent> {
|
||||
const draftWithId = { ...draft, id: draft.id || getEventHash(draft) };
|
||||
const sig = await intentRequest(createSignEventIntent(draftWithId));
|
||||
if (!isHex(sig)) throw new Error("Expected hex signature");
|
||||
|
||||
const event: NostrEvent = { ...draftWithId, sig };
|
||||
if (!verifySignature(event)) throw new Error("Invalid signature");
|
||||
return event;
|
||||
}
|
||||
|
||||
async function nip04Encrypt(pubkey: string, plaintext: string): Promise<string> {
|
||||
const data = await intentRequest(createNip04EncryptIntent(pubkey, plaintext));
|
||||
return data;
|
||||
}
|
||||
async function nip04Decrypt(pubkey: string, data: string): Promise<string> {
|
||||
const plaintext = await intentRequest(createNip04DecryptIntent(pubkey, data));
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
const amberSignerService = {
|
||||
supported: navigator.userAgent.includes("Android"),
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
nip04Encrypt,
|
||||
nip04Decrypt,
|
||||
};
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// @ts-ignore
|
||||
window.amberSignerService = amberSignerService;
|
||||
}
|
||||
|
||||
export default amberSignerService;
|
@ -182,7 +182,7 @@ function parseResponse(value: string) {
|
||||
export const utf8Decoder = new TextDecoder("utf-8");
|
||||
export const utf8Encoder = new TextEncoder();
|
||||
|
||||
export async function encrypt(pubkey: string, text: string) {
|
||||
export async function nip04Encrypt(pubkey: string, text: string) {
|
||||
const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
const sharedSecret = hexToBytes(sharedSecretStr);
|
||||
|
||||
@ -196,7 +196,7 @@ export async function encrypt(pubkey: string, text: string) {
|
||||
return `${ctb64}?iv=${ivb64}`;
|
||||
}
|
||||
|
||||
export async function decrypt(pubkey: string, data: string) {
|
||||
export async function nip04Decrypt(pubkey: string, data: string) {
|
||||
let [ctb64, ivb64] = data.split("?iv=");
|
||||
|
||||
const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]);
|
||||
@ -235,8 +235,8 @@ const serialPortService = {
|
||||
supported: !!navigator.serial,
|
||||
signEvent,
|
||||
getPublicKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
nip04Encrypt,
|
||||
nip04Decrypt,
|
||||
callMethodOnDevice,
|
||||
connectToDevice,
|
||||
};
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { nip04, getPublicKey, finishEvent } from "nostr-tools";
|
||||
|
||||
import { DraftNostrEvent, NostrEvent } from "../types/nostr-event";
|
||||
import { Account } from "./account";
|
||||
import db from "./db";
|
||||
import serialPortService from "./serial-port";
|
||||
import amberSignerService from "./amber-signer";
|
||||
|
||||
const decryptedKeys = new Map<string, string>();
|
||||
|
||||
@ -79,21 +81,34 @@ class SigningService {
|
||||
}
|
||||
|
||||
async requestSignature(draft: DraftNostrEvent, account: Account) {
|
||||
const checkSig = (signed: NostrEvent) => {
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey");
|
||||
};
|
||||
|
||||
if (account.readonly) throw new Error("Cant sign in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
if (window.nostr) {
|
||||
const signed = await window.nostr.signEvent(draft);
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey!");
|
||||
return signed;
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
if (serialPortService.supported) {
|
||||
const signed = await serialPortService.signEvent(draft);
|
||||
if (signed.pubkey !== account.pubkey) throw new Error("Signed with the wrong pubkey!");
|
||||
return signed;
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
switch (account.connectionType) {
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
const signed = await window.nostr.signEvent(draft);
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Missing nostr extension");
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
const signed = await serialPortService.signEvent(draft);
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
const signed = await amberSignerService.signEvent({ ...draft, pubkey: account.pubkey });
|
||||
checkSig(signed);
|
||||
return signed;
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
default:
|
||||
throw new Error("Unknown connection type " + account.connectionType);
|
||||
}
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) };
|
||||
@ -106,15 +121,24 @@ class SigningService {
|
||||
async requestDecrypt(data: string, pubkey: string, account: Account) {
|
||||
if (account.readonly) throw new Error("Cant decrypt in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.decrypt(pubkey, data);
|
||||
} else throw new Error("Extension dose not support decryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
return await serialPortService.decrypt(pubkey, data);
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
switch (account.connectionType) {
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.decrypt(pubkey, data);
|
||||
} else throw new Error("Extension dose not support decryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
return await serialPortService.nip04Decrypt(pubkey, data);
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
return await amberSignerService.nip04Decrypt(pubkey, data);
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
default:
|
||||
throw new Error("Unknown connection type " + account.connectionType);
|
||||
}
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.decrypt(secKey, pubkey, data);
|
||||
@ -124,15 +148,24 @@ class SigningService {
|
||||
async requestEncrypt(text: string, pubkey: string, account: Account) {
|
||||
if (account.readonly) throw new Error("Cant encrypt in readonly mode");
|
||||
if (account.connectionType) {
|
||||
if (account.connectionType === "extension") {
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.encrypt(pubkey, text);
|
||||
} else throw new Error("Extension dose not support encryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
} else if (account.connectionType === "serial") {
|
||||
return await serialPortService.encrypt(pubkey, text);
|
||||
} else throw new Error("Unknown connection type " + account.connectionType);
|
||||
switch (account.connectionType) {
|
||||
case "extension":
|
||||
if (window.nostr) {
|
||||
if (window.nostr.nip04) {
|
||||
return await window.nostr.nip04.encrypt(pubkey, text);
|
||||
} else throw new Error("Extension dose not support encryption");
|
||||
} else throw new Error("Missing nostr extension");
|
||||
case "serial":
|
||||
if (serialPortService.supported) {
|
||||
return await serialPortService.nip04Encrypt(pubkey, text);
|
||||
} else throw new Error("Serial devices are not supported");
|
||||
case "amber":
|
||||
if (amberSignerService.supported) {
|
||||
return await amberSignerService.nip04Encrypt(pubkey, text);
|
||||
} else throw new Error("Cant use Amber on non-Android device");
|
||||
default:
|
||||
throw new Error("Unknown connection type " + account.connectionType);
|
||||
}
|
||||
} else if (account?.secKey) {
|
||||
const secKey = await this.decryptSecKey(account);
|
||||
return await nip04.encrypt(secKey, pubkey, text);
|
||||
|
@ -20,7 +20,7 @@ export type CountResponse = {
|
||||
approximate?: boolean;
|
||||
};
|
||||
|
||||
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig">;
|
||||
export type DraftNostrEvent = Omit<NostrEvent, "pubkey" | "id" | "sig"> & { pubkey?: string; id?: string };
|
||||
|
||||
export type RawIncomingEvent = ["EVENT", string, NostrEvent];
|
||||
export type RawIncomingNotice = ["NOTICE", string];
|
||||
|
@ -13,14 +13,18 @@ import {
|
||||
} from "@chakra-ui/react";
|
||||
import { Link as RouterLink, useLocation } from "react-router-dom";
|
||||
|
||||
import accountService from "../../services/account";
|
||||
import Key01 from "../../components/icons/key-01";
|
||||
import Diamond01 from "../../components/icons/diamond-01";
|
||||
import ChevronDown from "../../components/icons/chevron-down";
|
||||
import ChevronUp from "../../components/icons/chevron-up";
|
||||
import serialPortService from "../../services/serial-port";
|
||||
import UsbFlashDrive from "../../components/icons/usb-flash-drive";
|
||||
import HelpCircle from "../../components/icons/help-circle";
|
||||
|
||||
import { COMMON_CONTACT_RELAY } from "../../const";
|
||||
import accountService from "../../services/account";
|
||||
import serialPortService from "../../services/serial-port";
|
||||
import amberSignerService from "../../services/amber-signer";
|
||||
|
||||
export default function LoginStartView() {
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
@ -44,7 +48,7 @@ export default function LoginStartView() {
|
||||
}
|
||||
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"];
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, connectionType: "extension", readonly: false });
|
||||
@ -59,7 +63,7 @@ export default function LoginStartView() {
|
||||
toast({ status: "warning", title: "Cant find extension" });
|
||||
}
|
||||
};
|
||||
const loginWithSerial = async () => {
|
||||
const signinWithSerial = async () => {
|
||||
if (serialPortService.supported) {
|
||||
try {
|
||||
setLoading(true);
|
||||
@ -68,11 +72,8 @@ export default function LoginStartView() {
|
||||
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
let relays: string[] = [];
|
||||
|
||||
// TODO: maybe get relays from device
|
||||
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"];
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, connectionType: "serial", readonly: false });
|
||||
@ -88,6 +89,23 @@ export default function LoginStartView() {
|
||||
}
|
||||
};
|
||||
|
||||
const signinWithAmber = async () => {
|
||||
try {
|
||||
const pubkey = await amberSignerService.getPublicKey();
|
||||
if (!accountService.hasAccount(pubkey)) {
|
||||
let relays: string[] = [];
|
||||
if (relays.length === 0) {
|
||||
relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine", COMMON_CONTACT_RELAY];
|
||||
}
|
||||
|
||||
accountService.addAccount({ pubkey, relays, connectionType: "amber", readonly: false });
|
||||
}
|
||||
accountService.switchAccount(pubkey);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spinner />;
|
||||
|
||||
return (
|
||||
@ -97,7 +115,7 @@ export default function LoginStartView() {
|
||||
</Button>
|
||||
{serialPortService.supported && (
|
||||
<ButtonGroup colorScheme="purple">
|
||||
<Button onClick={loginWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
|
||||
<Button onClick={signinWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
|
||||
Use Signing Device
|
||||
</Button>
|
||||
<IconButton
|
||||
@ -110,6 +128,21 @@ export default function LoginStartView() {
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
{amberSignerService.supported && (
|
||||
<ButtonGroup colorScheme="orange">
|
||||
<Button onClick={signinWithAmber} leftIcon={<Diamond01 boxSize={6} />} w="xs">
|
||||
Use Amber
|
||||
</Button>
|
||||
<IconButton
|
||||
as={Link}
|
||||
aria-label="What is Amber?"
|
||||
title="What is Amber?"
|
||||
isExternal
|
||||
href="https://github.com/greenart7c3/Amber"
|
||||
icon={<HelpCircle boxSize={5} />}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={advanced.onToggle}
|
||||
@ -128,13 +161,13 @@ export default function LoginStartView() {
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./npub" state={location.state} w="sm">
|
||||
public key (npub)
|
||||
Public key (npub)
|
||||
<Badge ml="2" colorScheme="blue">
|
||||
read-only
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button as={RouterLink} to="./nsec" state={location.state} w="sm">
|
||||
secret key (nsec)
|
||||
Secret key (nsec)
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
Loading…
x
Reference in New Issue
Block a user