From d8e08d6a94a839268b5704433990f6a53d11d581 Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Wed, 29 Nov 2023 11:47:20 -0600 Subject: [PATCH] add support for nostr signing device --- .changeset/quick-radios-collect.md | 5 + package.json | 3 +- src/components/account-info-badge.tsx | 9 +- src/index.tsx | 2 + src/services/account.ts | 2 +- src/services/client-relays.ts | 1 - src/services/serial-port.ts | 255 ++++++++++++++++++++++++++ src/services/signing.tsx | 59 +++--- src/types/serial.d.ts | 1 + src/views/signin/start.tsx | 70 ++++++- yarn.lock | 15 +- 11 files changed, 385 insertions(+), 37 deletions(-) create mode 100644 .changeset/quick-radios-collect.md create mode 100644 src/services/serial-port.ts create mode 100644 src/types/serial.d.ts diff --git a/.changeset/quick-radios-collect.md b/.changeset/quick-radios-collect.md new file mode 100644 index 000000000..1187a5b72 --- /dev/null +++ b/.changeset/quick-radios-collect.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add support for Nostr Signing Device diff --git a/package.json b/package.json index a5af9bad0..a8e626b53 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@emotion/styled": "^11.11.0", "@getalby/bitcoin-connect-react": "^2.4.2", "@noble/hashes": "^1.3.2", + "@noble/secp256k1": "^1.7.0", "@webscopeio/react-textarea-autocomplete": "^4.9.2", "bech32": "^2.0.0", "cheerio": "^1.0.0-rc.12", @@ -44,7 +45,6 @@ "match-sorter": "^6.3.1", "nanoid": "^5.0.2", "ngeohash": "^0.6.3", - "noble-secp256k1": "^1.2.14", "nostr-tools": "^1.17.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -69,6 +69,7 @@ "@changesets/cli": "^2.26.2", "@types/chroma-js": "^2.4.1", "@types/debug": "^4.1.8", + "@types/dom-serial": "^1.0.6", "@types/identicon.js": "^2.3.1", "@types/leaflet": "^1.9.3", "@types/leaflet.locatecontrol": "^0.74.1", diff --git a/src/components/account-info-badge.tsx b/src/components/account-info-badge.tsx index 117aae938..63c6dd7dc 100644 --- a/src/components/account-info-badge.tsx +++ b/src/components/account-info-badge.tsx @@ -2,13 +2,20 @@ import { Badge, BadgeProps } from "@chakra-ui/react"; import { Account } from "../services/account"; export default function AccountInfoBadge({ account, ...props }: BadgeProps & { account: Account }) { - if (account.useExtension) { + if (account.connectionType === "extension") { return ( extension ); } + if (account.connectionType === "serial") { + return ( + + serial + + ); + } if (account.secKey) { return ( diff --git a/src/index.tsx b/src/index.tsx index 4cb9eb773..d43de3427 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,6 +3,8 @@ import { createRoot } from "react-dom/client"; import { App } from "./app"; import { GlobalProviders } from "./providers"; +import "./services/serial-port"; + // setup dayjs import dayjs from "dayjs"; import relativeTimePlugin from "dayjs/plugin/relativeTime"; diff --git a/src/services/account.ts b/src/services/account.ts index 9a544ba10..c411b1d24 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -8,7 +8,7 @@ export type Account = { relays?: string[]; secKey?: ArrayBuffer; iv?: Uint8Array; - useExtension?: boolean; + connectionType?: 'extension'|'serial'; localSettings?: AppSettings; }; diff --git a/src/services/client-relays.ts b/src/services/client-relays.ts index fa3e8a60d..a52546d1b 100644 --- a/src/services/client-relays.ts +++ b/src/services/client-relays.ts @@ -17,7 +17,6 @@ const DEFAULT_RELAYS = [ { url: "wss://relay.damus.io", mode: RelayMode.READ }, { url: "wss://nostr.wine", mode: RelayMode.READ }, { url: "wss://relay.snort.social", mode: RelayMode.READ }, - { url: "wss://eden.nostr.land", mode: RelayMode.READ }, { url: "wss://nos.lol", mode: RelayMode.READ }, { url: "wss://purplerelay.com", mode: RelayMode.READ }, ]; diff --git a/src/services/serial-port.ts b/src/services/serial-port.ts new file mode 100644 index 000000000..55b0a8404 --- /dev/null +++ b/src/services/serial-port.ts @@ -0,0 +1,255 @@ +import { getEventHash, validateEvent } from "nostr-tools"; +import { base64 } from "@scure/base"; +import { randomBytes, hexToBytes } from "@noble/hashes/utils"; +import { Point } from "@noble/secp256k1"; + +import { logger } from "../helpers/debug"; +import { DraftNostrEvent, NostrEvent } from "../types/nostr-event"; +import createDefer, { Deferred } from "../classes/deferred"; + +const METHOD_PING = "/ping"; +// const METHOD_LOG = '/log' + +export const METHOD_SIGN_MESSAGE = "/sign-message"; +export const METHOD_SHARED_SECRET = "/shared-secret"; +export const METHOD_PUBLIC_KEY = "/public-key"; + +export const PUBLIC_METHODS = [METHOD_PUBLIC_KEY, METHOD_SIGN_MESSAGE, METHOD_SHARED_SECRET]; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +const log = logger.extend("SerialPortService"); +const deviceLog = log.extend("DeviceLog"); +let writer: WritableStreamDefaultWriter | null; + +export function isConnected() { + return !!writer; +} + +type Callback = () => void; +type DeviceOpts = { + onConnect?: Callback; + onDisconnect?: Callback; + onError?: (err: Error) => void; + onDone?: Callback; +}; + +let lastCommand: Deferred | null = null; +export async function callMethodOnDevice(method: string, params: string[], opts: DeviceOpts = {}) { + if (!writer) await connectToDevice(opts); + + // only one command can be pending at any time + // but each will only wait 6 seconds + if (lastCommand) throw new Error("Previous command to device still pending!"); + const command = createDefer(); + lastCommand = command; + + // send actual command + sendCommand(method, params); + setTimeout(() => { + command.reject(new Error("Device timeout")); + if (lastCommand === command) lastCommand = null; + }, 6000); + + return lastCommand; +} + +export async function connectToDevice({ onConnect, onDisconnect, onError, onDone }: DeviceOpts): Promise { + let port: SerialPort = await navigator.serial.requestPort(); + let reader; + + const startSerialPortReading = async () => { + // reading responses + while (port && port.readable) { + const textDecoder = new window.TextDecoderStream(); + port.readable.pipeTo(textDecoder.writable); + reader = textDecoder.readable.getReader(); + const readStringUntil = readFromSerialPort(reader); + + try { + while (true) { + const { value, done } = await readStringUntil("\n"); + if (value) { + const { method, data } = parseResponse(value); + + if (method === "/log") deviceLog(data); + if (method === "/ping") log("Pong"); + + if (PUBLIC_METHODS.indexOf(method) === -1) { + // ignore /ping, /log responses + continue; + } + + log("Received: ", method, data); + + if (lastCommand) { + lastCommand.resolve(data); + lastCommand = null; + } + } + if (done) { + lastCommand = null; + writer = null; + if (onDone) onDone(); + return; + } + } + } catch (error) { + if (error instanceof Error) { + writer = null; + if (onError) onError(error); + if (lastCommand) { + lastCommand.reject(error); + lastCommand = null; + } + throw error; + } + } + } + }; + + await port.open({ baudRate: 9600 }); + + // this `sleep()` is a hack, I know! + // but `port.onconnect` is never called. I don't know why! + await sleep(1000); + startSerialPortReading(); + + const textEncoder = new window.TextEncoderStream(); + textEncoder.readable.pipeTo(port.writable); + writer = textEncoder.writable.getWriter(); + + // send ping first + await sendCommand(METHOD_PING); + await sendCommand(METHOD_PING, [window.location.host]); + + if (onConnect) onConnect(); + + port.addEventListener("disconnect", () => { + log("Disconnected"); + lastCommand = null; + writer = null; + if (onDisconnect) onDisconnect(); + }); +} + +async function sendCommand(method: string, params: string[] = []) { + if (!writer) return; + log("Send command", method, params); + const message = [method].concat(params).join(" "); + await writer.write(message + "\n"); +} + +function readFromSerialPort(reader: ReadableStreamDefaultReader) { + let partialChunk: string | undefined; + let fulliness: string[] = []; + + const readStringUntil = async (separator = "\n") => { + if (fulliness.length) return { value: fulliness.shift()!.trim(), done: false }; + + const chunks = []; + if (partialChunk) { + // leftovers from previous read + chunks.push(partialChunk); + partialChunk = undefined; + } + while (true) { + const { value, done } = await reader.read(); + if (value) { + const values = value.split(separator); + // found one or more separators + if (values.length > 1) { + chunks.push(values.shift()); // first element + partialChunk = values.pop(); // last element + fulliness = values; // full lines + return { value: chunks.join("").trim(), done: false }; + } + chunks.push(value); + } + if (done) return { value: chunks.join("").trim(), done: true }; + } + }; + return readStringUntil; +} + +function parseResponse(value: string) { + const method = value.split(" ")[0]; + const data = value.substring(method.length).trim(); + + return { method, data }; +} + +export const utf8Decoder = new TextDecoder("utf-8"); +export const utf8Encoder = new TextEncoder(); + +export async function encrypt(pubkey: string, text: string) { + const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]); + const sharedSecret = hexToBytes(sharedSecretStr); + + let iv = Uint8Array.from(randomBytes(16)); + let plaintext = utf8Encoder.encode(text); + let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["encrypt"]); + let ciphertext = await crypto.subtle.encrypt({ name: "AES-CBC", iv }, cryptoKey, plaintext); + let ctb64 = base64.encode(new Uint8Array(ciphertext)); + let ivb64 = base64.encode(new Uint8Array(iv.buffer)); + + return `${ctb64}?iv=${ivb64}`; +} + +export async function decrypt(pubkey: string, data: string) { + let [ctb64, ivb64] = data.split("?iv="); + + const sharedSecretStr = await callMethodOnDevice(METHOD_SHARED_SECRET, [xOnlyToXY(pubkey)]); + const sharedSecret = hexToBytes(sharedSecretStr); + + let cryptoKey = await crypto.subtle.importKey("raw", sharedSecret, { name: "AES-CBC" }, false, ["decrypt"]); + let ciphertext = base64.decode(ctb64); + let iv = base64.decode(ivb64); + + let plaintext = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, cryptoKey, ciphertext); + + let text = utf8Decoder.decode(plaintext); + return text; +} + +export function xOnlyToXY(p: string) { + return Point.fromHex(p).toHex().substring(2); +} + +async function getPublicKey() { + return await callMethodOnDevice(METHOD_PUBLIC_KEY, []); +} +async function signEvent(draft: DraftNostrEvent) { + let signed = { ...draft } as NostrEvent; + + if (!signed.pubkey) signed.pubkey = await callMethodOnDevice(METHOD_PUBLIC_KEY, []); + if (!signed.created_at) signed.created_at = Math.round(Date.now() / 1000); + if (!signed.id) signed.id = getEventHash(signed); + if (!validateEvent(signed)) throw new Error("Tnvalid event"); + + signed.sig = await callMethodOnDevice(METHOD_SIGN_MESSAGE, [signed.id]); + return signed; +} + +const serialPortService = { + supported: !!navigator.serial, + signEvent, + getPublicKey, + encrypt, + decrypt, + callMethodOnDevice, + connectToDevice, +}; + +setInterval(() => { + if (writer) { + sendCommand(METHOD_PING, [window.location.host]); + } +}, 1000 * 10); + +if (import.meta.env.DEV) { + //@ts-ignore + window.serialPortService = serialPortService; +} + +export default serialPortService; diff --git a/src/services/signing.tsx b/src/services/signing.tsx index 949a7374e..3a3e0949e 100644 --- a/src/services/signing.tsx +++ b/src/services/signing.tsx @@ -2,6 +2,7 @@ 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"; const decryptedKeys = new Map(); @@ -78,13 +79,21 @@ class SigningService { } async requestSignature(draft: DraftNostrEvent, account: Account) { - if (account?.readonly) throw new Error("Cant sign in readonly mode"); - if (account?.useExtension) { - 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"); + 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); } else if (account?.secKey) { const secKey = await this.decryptSecKey(account); const tmpDraft = { ...draft, pubkey: getPublicKey(secKey) }; @@ -95,13 +104,17 @@ class SigningService { } async requestDecrypt(data: string, pubkey: string, account: Account) { - if (account?.readonly) throw new Error("Cant decrypt in readonly mode"); - if (account?.useExtension) { - 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"); + 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); } else if (account?.secKey) { const secKey = await this.decryptSecKey(account); return await nip04.decrypt(secKey, pubkey, data); @@ -109,13 +122,17 @@ class SigningService { } async requestEncrypt(text: string, pubkey: string, account: Account) { - if (account?.readonly) throw new Error("Cant encrypt in readonly mode"); - if (account?.useExtension) { - 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"); + 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); } else if (account?.secKey) { const secKey = await this.decryptSecKey(account); return await nip04.encrypt(secKey, pubkey, text); diff --git a/src/types/serial.d.ts b/src/types/serial.d.ts new file mode 100644 index 000000000..90cfa4914 --- /dev/null +++ b/src/types/serial.d.ts @@ -0,0 +1 @@ +import "@types/dom-serial"; diff --git a/src/views/signin/start.tsx b/src/views/signin/start.tsx index 90dbb322e..132087f52 100644 --- a/src/views/signin/start.tsx +++ b/src/views/signin/start.tsx @@ -1,11 +1,25 @@ import { useState } from "react"; -import { Badge, Button, Flex, Spinner, Text, useDisclosure, useToast } from "@chakra-ui/react"; +import { + Badge, + Button, + ButtonGroup, + Flex, + IconButton, + Link, + Spinner, + Text, + useDisclosure, + useToast, +} 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 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"; export default function LoginStartView() { const location = useLocation(); @@ -33,17 +47,44 @@ export default function LoginStartView() { relays = ["wss://relay.damus.io", "wss://relay.snort.social", "wss://nostr.wine"]; } - accountService.addAccount({ pubkey, relays, useExtension: true, readonly: false }); + accountService.addAccount({ pubkey, relays, connectionType: "extension", readonly: false }); } accountService.switchAccount(pubkey); - } catch (e) {} + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } setLoading(false); } else { - toast({ - status: "warning", - title: "Cant find extension", - }); + toast({ status: "warning", title: "Cant find extension" }); + } + }; + const loginWithSerial = async () => { + if (serialPortService.supported) { + try { + setLoading(true); + + const pubkey = await serialPortService.getPublicKey(); + + 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"]; + } + + accountService.addAccount({ pubkey, relays, connectionType: "serial", readonly: false }); + } + + accountService.switchAccount(pubkey); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + } else { + toast({ status: "warning", title: "Serial is not supported" }); } }; @@ -54,6 +95,21 @@ export default function LoginStartView() { + {serialPortService.supported && ( + + + } + /> + + )}