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() {
} w="sm" colorScheme="primary">
Sign in with extension
+ {serialPortService.supported && (
+
+ } w="xs">
+ Use Signing Device
+
+ }
+ />
+
+ )}