Add support for Amber signer

This commit is contained in:
hzrd149 2023-12-05 10:58:33 -06:00
parent 868aefeb83
commit 43faa02574
9 changed files with 230 additions and 49 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add support for Amber signer

View File

@ -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"]
}

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

@ -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>
</>
)}