add support for nostr signing device

This commit is contained in:
hzrd149
2023-11-29 11:47:20 -06:00
parent a796661e4b
commit d8e08d6a94
11 changed files with 385 additions and 37 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for Nostr Signing Device

View File

@@ -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",

View File

@@ -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 (
<Badge {...props} variant="solid" colorScheme="green">
extension
</Badge>
);
}
if (account.connectionType === "serial") {
return (
<Badge {...props} variant="solid" colorScheme="teal">
serial
</Badge>
);
}
if (account.secKey) {
return (
<Badge {...props} variant="solid" colorScheme="red">

View File

@@ -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";

View File

@@ -8,7 +8,7 @@ export type Account = {
relays?: string[];
secKey?: ArrayBuffer;
iv?: Uint8Array;
useExtension?: boolean;
connectionType?: 'extension'|'serial';
localSettings?: AppSettings;
};

View File

@@ -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 },
];

255
src/services/serial-port.ts Normal file
View File

@@ -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<string> | null;
export function isConnected() {
return !!writer;
}
type Callback = () => void;
type DeviceOpts = {
onConnect?: Callback;
onDisconnect?: Callback;
onError?: (err: Error) => void;
onDone?: Callback;
};
let lastCommand: Deferred<string> | 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<string>();
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<void> {
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<string>) {
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;

View File

@@ -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<string, string>();
@@ -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);

1
src/types/serial.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
import "@types/dom-serial";

View File

@@ -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() {
<Button onClick={signinWithExtension} leftIcon={<Key01 boxSize={6} />} w="sm" colorScheme="primary">
Sign in with extension
</Button>
{serialPortService.supported && (
<ButtonGroup colorScheme="purple">
<Button onClick={loginWithSerial} leftIcon={<UsbFlashDrive boxSize={6} />} w="xs">
Use Signing Device
</Button>
<IconButton
as={Link}
aria-label="What is NSD?"
title="What is NSD?"
isExternal
href="https://lnbits.github.io/nostr-signing-device/installer/"
icon={<HelpCircle boxSize={5} />}
/>
</ButtonGroup>
)}
<Button
variant="link"
onClick={advanced.onToggle}

View File

@@ -2382,6 +2382,11 @@
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39"
integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
"@noble/secp256k1@^1.7.0":
version "1.7.1"
resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c"
integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@@ -2685,6 +2690,11 @@
dependencies:
"@types/ms" "*"
"@types/dom-serial@^1.0.6":
version "1.0.6"
resolved "https://registry.yarnpkg.com/@types/dom-serial/-/dom-serial-1.0.6.tgz#48462122c0e943195d0611027ff9de979f04f43f"
integrity sha512-eUHKbc6mdMgMm75/oBLocs3wkOQkPQ/oNCT+b5OgUT6mLgIvDTp3wCCE9tYZNvDPPh6Cj9lVg2IguWfS/mDrrQ==
"@types/estree@0.0.39":
version "0.0.39"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
@@ -5556,11 +5566,6 @@ ngraph.random@^1.0.0:
resolved "https://registry.yarnpkg.com/ngraph.random/-/ngraph.random-1.1.0.tgz#5345c4bb63865c85d98ee6f13eab1395d8545a90"
integrity sha512-h25UdUN/g8U7y29TzQtRm/GvGr70lK37yQPvPKXXuVfs7gCm82WipYFZcksQfeKumtOemAzBIcT7lzzyK/edLw==
noble-secp256k1@^1.2.14:
version "1.2.14"
resolved "https://registry.yarnpkg.com/noble-secp256k1/-/noble-secp256k1-1.2.14.tgz#39429c941d51211ca40161569cee03e61d72599e"
integrity sha512-GSCXyoZBUaaPwVWdYncMEmzlSUjF9J/YeEHpklYJCyg8wPuJP3NzDx0BkiwArzINkdX2HJHvUJhL6vVWPOQQcg==
node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"