diff --git a/.changeset/four-grapes-beg.md b/.changeset/four-grapes-beg.md new file mode 100644 index 000000000..35a7172e9 --- /dev/null +++ b/.changeset/four-grapes-beg.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for Amber signer diff --git a/.vscode/settings.json b/.vscode/settings.json index e31fd4e93..4fc556ecd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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"] } diff --git a/src/helpers/nip19.ts b/src/helpers/nip19.ts index 073687abb..2d0f4d66d 100644 --- a/src/helpers/nip19.ts +++ b/src/helpers/nip19.ts @@ -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; diff --git a/src/services/account.ts b/src/services/account.ts index 138e5b587..3860f0617 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -8,7 +8,7 @@ export type Account = { relays?: string[]; secKey?: ArrayBuffer; iv?: Uint8Array; - connectionType?: "extension" | "serial"; + connectionType?: "extension" | "serial" | "amber"; localSettings?: AppSettings; }; diff --git a/src/services/amber-signer.ts b/src/services/amber-signer.ts new file mode 100644 index 000000000..4dc99de70 --- /dev/null +++ b/src/services/amber-signer.ts @@ -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 | 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(); + 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 { + 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 { + const data = await intentRequest(createNip04EncryptIntent(pubkey, plaintext)); + return data; +} +async function nip04Decrypt(pubkey: string, data: string): Promise { + 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; diff --git a/src/services/serial-port.ts b/src/services/serial-port.ts index 55b0a8404..cd2b1f814 100644 --- a/src/services/serial-port.ts +++ b/src/services/serial-port.ts @@ -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, }; diff --git a/src/services/signing.tsx b/src/services/signing.tsx index 3a3e0949e..9badd1b94 100644 --- a/src/services/signing.tsx +++ b/src/services/signing.tsx @@ -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(); @@ -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); diff --git a/src/types/nostr-event.ts b/src/types/nostr-event.ts index 5c05d05cb..8ce4b9374 100644 --- a/src/types/nostr-event.ts +++ b/src/types/nostr-event.ts @@ -20,7 +20,7 @@ export type CountResponse = { approximate?: boolean; }; -export type DraftNostrEvent = Omit; +export type DraftNostrEvent = Omit & { pubkey?: string; id?: string }; export type RawIncomingEvent = ["EVENT", string, NostrEvent]; export type RawIncomingNotice = ["NOTICE", string]; diff --git a/src/views/signin/start.tsx b/src/views/signin/start.tsx index 8672ca875..b230f519e 100644 --- a/src/views/signin/start.tsx +++ b/src/views/signin/start.tsx @@ -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 ; return ( @@ -97,7 +115,7 @@ export default function LoginStartView() { {serialPortService.supported && ( - )} + {amberSignerService.supported && ( + + + } + /> + + )} )}