From 1cb27c1881db09e4b5e4c3a9c3cc05c691196441 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 10 Jul 2023 15:40:22 +0100 Subject: [PATCH] feat: nsecbunker --- packages/app/src/Feed/EventPublisher.ts | 13 +-- packages/app/src/Hooks/useLoginHandler.tsx | 9 +- packages/app/src/Login/LoginSession.ts | 24 ++++- packages/app/src/Login/MultiAccountStore.ts | 57 +++++++++- packages/app/src/Pages/LoginPage.tsx | 64 +++++++++--- packages/system/src/impl/nip46.ts | 109 ++++++++++++++------ 6 files changed, 214 insertions(+), 62 deletions(-) diff --git a/packages/app/src/Feed/EventPublisher.ts b/packages/app/src/Feed/EventPublisher.ts index 16d30d83..f1076e8e 100644 --- a/packages/app/src/Feed/EventPublisher.ts +++ b/packages/app/src/Feed/EventPublisher.ts @@ -1,15 +1,6 @@ -import { useMemo } from "react"; import useLogin from "Hooks/useLogin"; -import { EventPublisher, Nip7Signer } from "@snort/system"; export default function useEventPublisher() { - const { publicKey, privateKey } = useLogin(); - return useMemo(() => { - if (privateKey) { - return EventPublisher.privateKey(privateKey); - } - if (publicKey) { - return new EventPublisher(new Nip7Signer(), publicKey); - } - }, [publicKey, privateKey]); + const { publisher } = useLogin(); + return publisher; } diff --git a/packages/app/src/Hooks/useLoginHandler.tsx b/packages/app/src/Hooks/useLoginHandler.tsx index 62af3f18..77870d0b 100644 --- a/packages/app/src/Hooks/useLoginHandler.tsx +++ b/packages/app/src/Hooks/useLoginHandler.tsx @@ -1,7 +1,7 @@ import { useIntl } from "react-intl"; import { EmailRegex, MnemonicRegex } from "Const"; -import { LoginStore } from "Login"; +import { LoginSessionType, LoginStore } from "Login"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { getNip05PubKey } from "Pages/LoginPage"; import { bech32ToHex } from "SnortUtils"; @@ -28,10 +28,10 @@ export default function useLoginHandler() { } } else if (key.startsWith("npub")) { const hexKey = bech32ToHex(key); - LoginStore.loginWithPubkey(hexKey); + LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); } else if (key.match(EmailRegex)) { const hexKey = await getNip05PubKey(key); - LoginStore.loginWithPubkey(hexKey); + LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey); } else if (key.match(MnemonicRegex)?.length === 24) { if (!hasSubtleCrypto) { throw new Error(insecureMsg); @@ -50,7 +50,8 @@ export default function useLoginHandler() { await nip46.init(); const loginPubkey = await nip46.getPubKey(); - LoginStore.loginWithPubkey(loginPubkey); + LoginStore.loginWithPubkey(loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays); + nip46.close(); } else { throw new Error("INVALID PRIVATE KEY"); } diff --git a/packages/app/src/Login/LoginSession.ts b/packages/app/src/Login/LoginSession.ts index 3a7fb288..a626de72 100644 --- a/packages/app/src/Login/LoginSession.ts +++ b/packages/app/src/Login/LoginSession.ts @@ -1,4 +1,4 @@ -import { HexKey, RelaySettings, u256 } from "@snort/system"; +import { HexKey, RelaySettings, u256, EventPublisher } from "@snort/system"; import { UserPreferences } from "Login"; import { SubscriptionEvent } from "Subscription"; @@ -10,7 +10,19 @@ interface Newest { timestamp: number; } +export enum LoginSessionType { + PrivateKey = "private_key", + PublicKey = "public_key", + Nip7 = "nip7", + Nip46 = "nip46", +} + export interface LoginSession { + /** + * Type of login session + */ + type: LoginSessionType; + /** * Current user private key */ @@ -80,4 +92,14 @@ export interface LoginSession { * Snort subscriptions licences */ subscriptions: Array; + + /** + * Remote signer relays (NIP-46) + */ + remoteSignerRelays?: Array; + + /** + * Instance event publisher + */ + publisher?: EventPublisher; } diff --git a/packages/app/src/Login/MultiAccountStore.ts b/packages/app/src/Login/MultiAccountStore.ts index aeccb629..e5f13d88 100644 --- a/packages/app/src/Login/MultiAccountStore.ts +++ b/packages/app/src/Login/MultiAccountStore.ts @@ -1,15 +1,16 @@ import * as secp from "@noble/curves/secp256k1"; import * as utils from "@noble/curves/abstract/utils"; -import { HexKey, RelaySettings } from "@snort/system"; +import { HexKey, RelaySettings, EventPublisher, Nip46Signer, Nip7Signer } from "@snort/system"; import { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared"; import { DefaultRelays } from "Const"; -import { LoginSession } from "Login"; +import { LoginSession, LoginSessionType } from "Login"; import { DefaultPreferences, UserPreferences } from "./Preferences"; const AccountStoreKey = "sessions"; const LoggedOut = { + type: "public_key", preferences: DefaultPreferences, tags: { item: [], @@ -60,7 +61,8 @@ export class MultiAccountStore extends ExternalStore { super(); const existing = window.localStorage.getItem(AccountStoreKey); if (existing) { - this.#accounts = new Map((JSON.parse(existing) as Array).map(a => [unwrap(a.publicKey), a])); + const logins = JSON.parse(existing); + this.#accounts = new Map((logins as Array).map(a => [unwrap(a.publicKey), a])); } else { this.#accounts = new Map(); } @@ -68,6 +70,9 @@ export class MultiAccountStore extends ExternalStore { if (!this.#activeAccount) { this.#activeAccount = this.#accounts.keys().next().value; } + for (const [, v] of this.#accounts) { + v.publisher = this.#createPublisher(v); + } } getSessions() { @@ -85,20 +90,28 @@ export class MultiAccountStore extends ExternalStore { } } - loginWithPubkey(key: HexKey, relays?: Record) { + loginWithPubkey( + key: HexKey, + type: LoginSessionType, + relays?: Record, + remoteSignerRelays?: Array + ) { if (this.#accounts.has(key)) { throw new Error("Already logged in with this pubkey"); } const initRelays = this.decideInitRelays(relays); const newSession = { ...LoggedOut, + type, publicKey: key, relays: { item: initRelays, timestamp: 1, }, preferences: deepClone(DefaultPreferences), + remoteSignerRelays, } as LoginSession; + newSession.publisher = this.#createPublisher(newSession); this.#accounts.set(key, newSession); this.#activeAccount = key; @@ -121,6 +134,7 @@ export class MultiAccountStore extends ExternalStore { const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries()); const newSession = { ...LoggedOut, + type: LoginSessionType.PrivateKey, privateKey: key, publicKey: pubKey, generatedEntropy: entropy, @@ -130,6 +144,8 @@ export class MultiAccountStore extends ExternalStore { }, preferences: deepClone(DefaultPreferences), } as LoginSession; + newSession.publisher = this.#createPublisher(newSession); + this.#accounts.set(pubKey, newSession); this.#activeAccount = pubKey; this.#save(); @@ -157,7 +173,26 @@ export class MultiAccountStore extends ExternalStore { const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined; if (!s) return LoggedOut; - return deepClone(s); + return s; + } + + #createPublisher(l: LoginSession) { + switch (l.type) { + case LoginSessionType.PrivateKey: { + return EventPublisher.privateKey(unwrap(l.privateKey)); + } + case LoginSessionType.Nip46: { + const relayArgs = (l.remoteSignerRelays ?? []).map(a => `relay=${encodeURIComponent(a)}`); + const inner = new Nip7Signer(); + const nip46 = new Nip46Signer(`bunker://${unwrap(l.publicKey)}?${[...relayArgs].join("&")}`, inner); + return new EventPublisher(nip46, unwrap(l.publicKey)); + } + default: { + if (l.publicKey) { + return new EventPublisher(new Nip7Signer(), l.publicKey); + } + } + } } #migrate() { @@ -203,6 +238,18 @@ export class MultiAccountStore extends ExternalStore { } } + // update session types + for (const [, v] of this.#accounts) { + if (v.privateKey) { + v.type = LoginSessionType.PrivateKey; + didMigrate = true; + } + if (!v.type) { + v.type = LoginSessionType.Nip7; + didMigrate = true; + } + } + if (didMigrate) { console.debug("Finished migration to MultiAccountStore"); this.#save(); diff --git a/packages/app/src/Pages/LoginPage.tsx b/packages/app/src/Pages/LoginPage.tsx index be38fea4..9b3ecf84 100644 --- a/packages/app/src/Pages/LoginPage.tsx +++ b/packages/app/src/Pages/LoginPage.tsx @@ -3,16 +3,22 @@ import "./LoginPage.css"; import { CSSProperties, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useIntl, FormattedMessage } from "react-intl"; -import { HexKey } from "@snort/system"; +import { HexKey, Nip46Signer, PrivateKeySigner } from "@snort/system"; -import { bech32ToHex, unwrap } from "SnortUtils"; +import { bech32ToHex, getPublicKey, unwrap } from "SnortUtils"; import ZapButton from "Element/ZapButton"; import useImgProxy from "Hooks/useImgProxy"; import Icon from "Icons/Icon"; import useLogin from "Hooks/useLogin"; -import { generateNewLogin, LoginStore } from "Login"; +import { generateNewLogin, LoginSessionType, LoginStore } from "Login"; import AsyncButton from "Element/AsyncButton"; import useLoginHandler from "Hooks/useLoginHandler"; +import { secp256k1 } from "@noble/curves/secp256k1"; +import { bytesToHex } from "@noble/curves/abstract/utils"; +import Modal from "Element/Modal"; +import QrCode from "Element/QrCode"; +import Copy from "Element/Copy"; +import { delay } from "SnortUtils"; interface ArtworkEntry { name: string; @@ -73,6 +79,7 @@ export default function LoginPage() { const loginHandler = useLoginHandler(); const hasNip7 = "nostr" in window; const hasSubtleCrypto = window.crypto.subtle !== undefined; + const [nostrConnect, setNostrConnect] = useState(""); useEffect(() => { if (login.publicKey) { @@ -112,7 +119,27 @@ export default function LoginPage() { const relays = "getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined; const pubKey = await unwrap(window.nostr).getPublicKey(); - LoginStore.loginWithPubkey(pubKey, relays); + LoginStore.loginWithPubkey(pubKey, LoginSessionType.Nip7, relays); + } + + async function startNip46() { + const meta = { + name: "Snort", + url: window.location.href, + }; + + const newKey = bytesToHex(secp256k1.utils.randomPrivateKey()); + const relays = ["wss://relay.damus.io"].map(a => `relay=${encodeURIComponent(a)}`); + const connectUrl = `nostrconnect://${getPublicKey(newKey)}?${[ + ...relays, + `metadata=${encodeURIComponent(JSON.stringify(meta))}`, + ].join("&")}`; + setNostrConnect(connectUrl); + + const signer = new Nip46Signer(connectUrl, new PrivateKeySigner(newKey)); + await signer.init(); + await delay(500); + await signer.describe(); } function altLogins() { @@ -121,12 +148,25 @@ export default function LoginPage() { } return ( - + <> + + + + + + + {nostrConnect && ( + setNostrConnect("")}> +
+ + +
+
+ )} + ); } @@ -227,9 +267,9 @@ export default function LoginPage() { />

- + makeRandomKey()}> diff --git a/packages/system/src/impl/nip46.ts b/packages/system/src/impl/nip46.ts index aaf0e8bb..b4cc50cc 100644 --- a/packages/system/src/impl/nip46.ts +++ b/packages/system/src/impl/nip46.ts @@ -27,64 +27,93 @@ interface Nip46Request { interface Nip46Response { id: string result: any - error?: string + error: string } interface QueueObj { - resolve: (o: Nip46Response) => void; + resolve: (o: any) => void; reject: (e: Error) => void; } export class Nip46Signer implements EventSigner { #conn?: Connection; #relay: string; - #target: string; + #localPubkey: string; + #remotePubkey?: string; #token?: string; #insideSigner: EventSigner; #commandQueue: Map = new Map(); #log = debug("NIP-46"); + #proto: string; + #didInit: boolean = false; constructor(config: string, insideSigner?: EventSigner) { const u = new URL(config); - this.#target = u.pathname.substring(2); + this.#proto = u.protocol; + this.#localPubkey = u.pathname.substring(2); if (u.hash.length > 1) { this.#token = u.hash.substring(1); } - if (this.#target.startsWith("npub")) { - this.#target = bech32ToHex(this.#target); + if (this.#localPubkey.startsWith("npub")) { + this.#localPubkey = bech32ToHex(this.#localPubkey); } this.#relay = unwrap(u.searchParams.get("relay")); this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey()) } + get relays() { + return [this.#relay]; + } + async init() { + const isBunker = this.#proto === "bunker:"; + if (isBunker) { + this.#remotePubkey = this.#localPubkey; + this.#localPubkey = await this.#insideSigner.getPubKey(); + } return await new Promise((resolve, reject) => { this.#conn = new Connection(this.#relay, { read: true, write: true }); this.#conn.OnEvent = async (sub, e) => { await this.#onReply(e); } this.#conn.OnConnected = async () => { - const insidePubkey = await this.#insideSigner.getPubKey(); this.#conn!.QueueReq(["REQ", "reply", { kinds: [NIP46_KIND], - authors: [this.#target], - "#p": [insidePubkey] + "#p": [this.#localPubkey] }], () => { }); - const rsp = await this.#connect(insidePubkey); - if (rsp === "ack") { + if (isBunker) { + await this.#connect(unwrap(this.#remotePubkey)); resolve(); } else { - reject(); + this.#commandQueue.set("connect", { + reject, + resolve + }) } } this.#conn.Connect(); + this.#didInit = true; }) } + async close() { + if (this.#conn) { + await this.#disconnect(); + this.#conn.CloseReq("reply"); + this.#conn.Close(); + this.#conn = undefined; + this.#didInit = false; + } + } + + async describe() { + return await this.#rpc>("describe", []); + } + async getPubKey() { return await this.#rpc("get_public_key", []); } @@ -98,7 +127,12 @@ export class Nip46Signer implements EventSigner { } async sign(ev: NostrEvent) { - return await this.#rpc("nip04_decrypt", [ev]); + const evStr = await this.#rpc("sign_event", [JSON.stringify(ev)]); + return JSON.parse(evStr); + } + + async #disconnect() { + return await this.#rpc("disconnect", []); } async #connect(pk: string) { @@ -114,10 +148,21 @@ export class Nip46Signer implements EventSigner { throw new Error("Unknown event kind"); } - const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, this.#target); - const reply = JSON.parse(decryptedContent) as Nip46Response; + const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, e.pubkey); + const reply = JSON.parse(decryptedContent) as Nip46Request | Nip46Response; - const pending = this.#commandQueue.get(reply.id); + let id = reply.id; + this.#log("Recv: %O", reply); + if ("method" in reply && reply.method === "connect") { + this.#remotePubkey = reply.params[0]; + await this.#sendCommand({ + id: reply.id, + result: "ack", + error: "" + }, unwrap(this.#remotePubkey)); + id = "connect"; + } + const pending = this.#commandQueue.get(id); if (!pending) { throw new Error("No pending command found"); } @@ -127,32 +172,38 @@ export class Nip46Signer implements EventSigner { } async #rpc(method: string, params: Array) { + if (!this.#didInit) { + await this.init(); + } if (!this.#conn) throw new Error("Connection error"); - const id = uuid(); const payload = { - id, + id: uuid(), method, params, } as Nip46Request; - this.#log("Request: %O", payload); - - const eb = new EventBuilder(); - eb.kind(NIP46_KIND as EventKind) - .content(await this.#insideSigner.nip4Encrypt(JSON.stringify(payload), this.#target)) - .tag(["p", this.#target]); - - const evCommand = await eb.buildAndSign(this.#insideSigner); - await this.#conn.SendAsync(evCommand); + this.#sendCommand(payload, unwrap(this.#remotePubkey)); return await new Promise((resolve, reject) => { - this.#commandQueue.set(id, { + this.#commandQueue.set(payload.id, { resolve: async (o: Nip46Response) => { - this.#log("Reply: %O", o); resolve(o.result as T); }, reject, }); }); } + + async #sendCommand(payload: Nip46Request | Nip46Response, target: string) { + if (!this.#conn) return; + + const eb = new EventBuilder(); + eb.kind(NIP46_KIND as EventKind) + .content(await this.#insideSigner.nip4Encrypt(JSON.stringify(payload), target)) + .tag(["p", target]); + + this.#log("Send: %O", payload); + const evCommand = await eb.buildAndSign(this.#insideSigner); + await this.#conn.SendAsync(evCommand); + } }