feat: nsecbunker

This commit is contained in:
Kieran 2023-07-10 15:40:22 +01:00
parent 64d64b29c3
commit 1cb27c1881
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
6 changed files with 214 additions and 62 deletions

View File

@ -1,15 +1,6 @@
import { useMemo } from "react";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { EventPublisher, Nip7Signer } from "@snort/system";
export default function useEventPublisher() { export default function useEventPublisher() {
const { publicKey, privateKey } = useLogin(); const { publisher } = useLogin();
return useMemo(() => { return publisher;
if (privateKey) {
return EventPublisher.privateKey(privateKey);
}
if (publicKey) {
return new EventPublisher(new Nip7Signer(), publicKey);
}
}, [publicKey, privateKey]);
} }

View File

@ -1,7 +1,7 @@
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { EmailRegex, MnemonicRegex } from "Const"; import { EmailRegex, MnemonicRegex } from "Const";
import { LoginStore } from "Login"; import { LoginSessionType, LoginStore } from "Login";
import { generateBip39Entropy, entropyToPrivateKey } from "nip6"; import { generateBip39Entropy, entropyToPrivateKey } from "nip6";
import { getNip05PubKey } from "Pages/LoginPage"; import { getNip05PubKey } from "Pages/LoginPage";
import { bech32ToHex } from "SnortUtils"; import { bech32ToHex } from "SnortUtils";
@ -28,10 +28,10 @@ export default function useLoginHandler() {
} }
} else if (key.startsWith("npub")) { } else if (key.startsWith("npub")) {
const hexKey = bech32ToHex(key); const hexKey = bech32ToHex(key);
LoginStore.loginWithPubkey(hexKey); LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.match(EmailRegex)) { } else if (key.match(EmailRegex)) {
const hexKey = await getNip05PubKey(key); const hexKey = await getNip05PubKey(key);
LoginStore.loginWithPubkey(hexKey); LoginStore.loginWithPubkey(hexKey, LoginSessionType.PublicKey);
} else if (key.match(MnemonicRegex)?.length === 24) { } else if (key.match(MnemonicRegex)?.length === 24) {
if (!hasSubtleCrypto) { if (!hasSubtleCrypto) {
throw new Error(insecureMsg); throw new Error(insecureMsg);
@ -50,7 +50,8 @@ export default function useLoginHandler() {
await nip46.init(); await nip46.init();
const loginPubkey = await nip46.getPubKey(); const loginPubkey = await nip46.getPubKey();
LoginStore.loginWithPubkey(loginPubkey); LoginStore.loginWithPubkey(loginPubkey, LoginSessionType.Nip46, undefined, nip46.relays);
nip46.close();
} else { } else {
throw new Error("INVALID PRIVATE KEY"); throw new Error("INVALID PRIVATE KEY");
} }

View File

@ -1,4 +1,4 @@
import { HexKey, RelaySettings, u256 } from "@snort/system"; import { HexKey, RelaySettings, u256, EventPublisher } from "@snort/system";
import { UserPreferences } from "Login"; import { UserPreferences } from "Login";
import { SubscriptionEvent } from "Subscription"; import { SubscriptionEvent } from "Subscription";
@ -10,7 +10,19 @@ interface Newest<T> {
timestamp: number; timestamp: number;
} }
export enum LoginSessionType {
PrivateKey = "private_key",
PublicKey = "public_key",
Nip7 = "nip7",
Nip46 = "nip46",
}
export interface LoginSession { export interface LoginSession {
/**
* Type of login session
*/
type: LoginSessionType;
/** /**
* Current user private key * Current user private key
*/ */
@ -80,4 +92,14 @@ export interface LoginSession {
* Snort subscriptions licences * Snort subscriptions licences
*/ */
subscriptions: Array<SubscriptionEvent>; subscriptions: Array<SubscriptionEvent>;
/**
* Remote signer relays (NIP-46)
*/
remoteSignerRelays?: Array<string>;
/**
* Instance event publisher
*/
publisher?: EventPublisher;
} }

View File

@ -1,15 +1,16 @@
import * as secp from "@noble/curves/secp256k1"; import * as secp from "@noble/curves/secp256k1";
import * as utils from "@noble/curves/abstract/utils"; 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 { deepClone, sanitizeRelayUrl, unwrap, ExternalStore } from "@snort/shared";
import { DefaultRelays } from "Const"; import { DefaultRelays } from "Const";
import { LoginSession } from "Login"; import { LoginSession, LoginSessionType } from "Login";
import { DefaultPreferences, UserPreferences } from "./Preferences"; import { DefaultPreferences, UserPreferences } from "./Preferences";
const AccountStoreKey = "sessions"; const AccountStoreKey = "sessions";
const LoggedOut = { const LoggedOut = {
type: "public_key",
preferences: DefaultPreferences, preferences: DefaultPreferences,
tags: { tags: {
item: [], item: [],
@ -60,7 +61,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
super(); super();
const existing = window.localStorage.getItem(AccountStoreKey); const existing = window.localStorage.getItem(AccountStoreKey);
if (existing) { if (existing) {
this.#accounts = new Map((JSON.parse(existing) as Array<LoginSession>).map(a => [unwrap(a.publicKey), a])); const logins = JSON.parse(existing);
this.#accounts = new Map((logins as Array<LoginSession>).map(a => [unwrap(a.publicKey), a]));
} else { } else {
this.#accounts = new Map(); this.#accounts = new Map();
} }
@ -68,6 +70,9 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
if (!this.#activeAccount) { if (!this.#activeAccount) {
this.#activeAccount = this.#accounts.keys().next().value; this.#activeAccount = this.#accounts.keys().next().value;
} }
for (const [, v] of this.#accounts) {
v.publisher = this.#createPublisher(v);
}
} }
getSessions() { getSessions() {
@ -85,20 +90,28 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} }
} }
loginWithPubkey(key: HexKey, relays?: Record<string, RelaySettings>) { loginWithPubkey(
key: HexKey,
type: LoginSessionType,
relays?: Record<string, RelaySettings>,
remoteSignerRelays?: Array<string>
) {
if (this.#accounts.has(key)) { if (this.#accounts.has(key)) {
throw new Error("Already logged in with this pubkey"); throw new Error("Already logged in with this pubkey");
} }
const initRelays = this.decideInitRelays(relays); const initRelays = this.decideInitRelays(relays);
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,
type,
publicKey: key, publicKey: key,
relays: { relays: {
item: initRelays, item: initRelays,
timestamp: 1, timestamp: 1,
}, },
preferences: deepClone(DefaultPreferences), preferences: deepClone(DefaultPreferences),
remoteSignerRelays,
} as LoginSession; } as LoginSession;
newSession.publisher = this.#createPublisher(newSession);
this.#accounts.set(key, newSession); this.#accounts.set(key, newSession);
this.#activeAccount = key; this.#activeAccount = key;
@ -121,6 +134,7 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries()); const initRelays = relays ?? Object.fromEntries(DefaultRelays.entries());
const newSession = { const newSession = {
...LoggedOut, ...LoggedOut,
type: LoginSessionType.PrivateKey,
privateKey: key, privateKey: key,
publicKey: pubKey, publicKey: pubKey,
generatedEntropy: entropy, generatedEntropy: entropy,
@ -130,6 +144,8 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
}, },
preferences: deepClone(DefaultPreferences), preferences: deepClone(DefaultPreferences),
} as LoginSession; } as LoginSession;
newSession.publisher = this.#createPublisher(newSession);
this.#accounts.set(pubKey, newSession); this.#accounts.set(pubKey, newSession);
this.#activeAccount = pubKey; this.#activeAccount = pubKey;
this.#save(); this.#save();
@ -157,7 +173,26 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined; const s = this.#activeAccount ? this.#accounts.get(this.#activeAccount) : undefined;
if (!s) return LoggedOut; 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() { #migrate() {
@ -203,6 +238,18 @@ export class MultiAccountStore extends ExternalStore<LoginSession> {
} }
} }
// 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) { if (didMigrate) {
console.debug("Finished migration to MultiAccountStore"); console.debug("Finished migration to MultiAccountStore");
this.#save(); this.#save();

View File

@ -3,16 +3,22 @@ import "./LoginPage.css";
import { CSSProperties, useEffect, useState } from "react"; import { CSSProperties, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useIntl, FormattedMessage } from "react-intl"; 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 ZapButton from "Element/ZapButton";
import useImgProxy from "Hooks/useImgProxy"; import useImgProxy from "Hooks/useImgProxy";
import Icon from "Icons/Icon"; import Icon from "Icons/Icon";
import useLogin from "Hooks/useLogin"; import useLogin from "Hooks/useLogin";
import { generateNewLogin, LoginStore } from "Login"; import { generateNewLogin, LoginSessionType, LoginStore } from "Login";
import AsyncButton from "Element/AsyncButton"; import AsyncButton from "Element/AsyncButton";
import useLoginHandler from "Hooks/useLoginHandler"; 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 { interface ArtworkEntry {
name: string; name: string;
@ -73,6 +79,7 @@ export default function LoginPage() {
const loginHandler = useLoginHandler(); const loginHandler = useLoginHandler();
const hasNip7 = "nostr" in window; const hasNip7 = "nostr" in window;
const hasSubtleCrypto = window.crypto.subtle !== undefined; const hasSubtleCrypto = window.crypto.subtle !== undefined;
const [nostrConnect, setNostrConnect] = useState("");
useEffect(() => { useEffect(() => {
if (login.publicKey) { if (login.publicKey) {
@ -112,7 +119,27 @@ export default function LoginPage() {
const relays = const relays =
"getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined; "getRelays" in unwrap(window.nostr) ? await unwrap(window.nostr?.getRelays).call(window.nostr) : undefined;
const pubKey = await unwrap(window.nostr).getPublicKey(); 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() { function altLogins() {
@ -121,12 +148,25 @@ export default function LoginPage() {
} }
return ( return (
<button type="button" onClick={doNip07Login}> <>
<AsyncButton type="button" onClick={doNip07Login}>
<FormattedMessage <FormattedMessage
defaultMessage="Login with Extension (NIP-07)" defaultMessage="Login with Extension (NIP-07)"
description="Login button for NIP7 key manager extension" description="Login button for NIP7 key manager extension"
/> />
</button> </AsyncButton>
<AsyncButton type="button" onClick={startNip46}>
<FormattedMessage defaultMessage="Nostr Connect (NIP-46)" description="Login button for NIP-46 signer app" />
</AsyncButton>
{nostrConnect && (
<Modal onClose={() => setNostrConnect("")}>
<div className="flex f-col">
<QrCode data={nostrConnect} />
<Copy text={nostrConnect} />
</div>
</Modal>
)}
</>
); );
} }
@ -227,9 +267,9 @@ export default function LoginPage() {
/> />
</p> </p>
<div dir="auto" className="login-actions"> <div dir="auto" className="login-actions">
<button type="button" onClick={doLogin}> <AsyncButton type="button" onClick={doLogin}>
<FormattedMessage defaultMessage="Login" description="Login button" /> <FormattedMessage defaultMessage="Login" description="Login button" />
</button> </AsyncButton>
<AsyncButton onClick={() => makeRandomKey()}> <AsyncButton onClick={() => makeRandomKey()}>
<FormattedMessage defaultMessage="Create Account" /> <FormattedMessage defaultMessage="Create Account" />
</AsyncButton> </AsyncButton>

View File

@ -27,64 +27,93 @@ interface Nip46Request {
interface Nip46Response { interface Nip46Response {
id: string id: string
result: any result: any
error?: string error: string
} }
interface QueueObj { interface QueueObj {
resolve: (o: Nip46Response) => void; resolve: (o: any) => void;
reject: (e: Error) => void; reject: (e: Error) => void;
} }
export class Nip46Signer implements EventSigner { export class Nip46Signer implements EventSigner {
#conn?: Connection; #conn?: Connection;
#relay: string; #relay: string;
#target: string; #localPubkey: string;
#remotePubkey?: string;
#token?: string; #token?: string;
#insideSigner: EventSigner; #insideSigner: EventSigner;
#commandQueue: Map<string, QueueObj> = new Map(); #commandQueue: Map<string, QueueObj> = new Map();
#log = debug("NIP-46"); #log = debug("NIP-46");
#proto: string;
#didInit: boolean = false;
constructor(config: string, insideSigner?: EventSigner) { constructor(config: string, insideSigner?: EventSigner) {
const u = new URL(config); 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) { if (u.hash.length > 1) {
this.#token = u.hash.substring(1); this.#token = u.hash.substring(1);
} }
if (this.#target.startsWith("npub")) { if (this.#localPubkey.startsWith("npub")) {
this.#target = bech32ToHex(this.#target); this.#localPubkey = bech32ToHex(this.#localPubkey);
} }
this.#relay = unwrap(u.searchParams.get("relay")); this.#relay = unwrap(u.searchParams.get("relay"));
this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey()) this.#insideSigner = insideSigner ?? new PrivateKeySigner(secp256k1.utils.randomPrivateKey())
} }
get relays() {
return [this.#relay];
}
async init() { async init() {
const isBunker = this.#proto === "bunker:";
if (isBunker) {
this.#remotePubkey = this.#localPubkey;
this.#localPubkey = await this.#insideSigner.getPubKey();
}
return await new Promise<void>((resolve, reject) => { return await new Promise<void>((resolve, reject) => {
this.#conn = new Connection(this.#relay, { read: true, write: true }); this.#conn = new Connection(this.#relay, { read: true, write: true });
this.#conn.OnEvent = async (sub, e) => { this.#conn.OnEvent = async (sub, e) => {
await this.#onReply(e); await this.#onReply(e);
} }
this.#conn.OnConnected = async () => { this.#conn.OnConnected = async () => {
const insidePubkey = await this.#insideSigner.getPubKey();
this.#conn!.QueueReq(["REQ", "reply", { this.#conn!.QueueReq(["REQ", "reply", {
kinds: [NIP46_KIND], kinds: [NIP46_KIND],
authors: [this.#target], "#p": [this.#localPubkey]
"#p": [insidePubkey]
}], () => { }); }], () => { });
const rsp = await this.#connect(insidePubkey); if (isBunker) {
if (rsp === "ack") { await this.#connect(unwrap(this.#remotePubkey));
resolve(); resolve();
} else { } else {
reject(); this.#commandQueue.set("connect", {
reject,
resolve
})
} }
} }
this.#conn.Connect(); 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<Array<string>>("describe", []);
}
async getPubKey() { async getPubKey() {
return await this.#rpc<string>("get_public_key", []); return await this.#rpc<string>("get_public_key", []);
} }
@ -98,7 +127,12 @@ export class Nip46Signer implements EventSigner {
} }
async sign(ev: NostrEvent) { async sign(ev: NostrEvent) {
return await this.#rpc<NostrEvent>("nip04_decrypt", [ev]); const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]);
return JSON.parse(evStr);
}
async #disconnect() {
return await this.#rpc("disconnect", []);
} }
async #connect(pk: string) { async #connect(pk: string) {
@ -114,10 +148,21 @@ export class Nip46Signer implements EventSigner {
throw new Error("Unknown event kind"); throw new Error("Unknown event kind");
} }
const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, this.#target); const decryptedContent = await this.#insideSigner.nip4Decrypt(e.content, e.pubkey);
const reply = JSON.parse(decryptedContent) as Nip46Response; 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) { if (!pending) {
throw new Error("No pending command found"); throw new Error("No pending command found");
} }
@ -127,32 +172,38 @@ export class Nip46Signer implements EventSigner {
} }
async #rpc<T>(method: string, params: Array<any>) { async #rpc<T>(method: string, params: Array<any>) {
if (!this.#didInit) {
await this.init();
}
if (!this.#conn) throw new Error("Connection error"); if (!this.#conn) throw new Error("Connection error");
const id = uuid();
const payload = { const payload = {
id, id: uuid(),
method, method,
params, params,
} as Nip46Request; } 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<T>((resolve, reject) => { return await new Promise<T>((resolve, reject) => {
this.#commandQueue.set(id, { this.#commandQueue.set(payload.id, {
resolve: async (o: Nip46Response) => { resolve: async (o: Nip46Response) => {
this.#log("Reply: %O", o);
resolve(o.result as T); resolve(o.result as T);
}, },
reject, 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);
}
} }