diff --git a/src/App.tsx b/src/App.tsx index 7ed255c..2f369ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -122,6 +122,15 @@ function App() { }) } + async function importKey() { + call(async () => { + // @ts-ignore + const nsec = document.getElementById(`nsec`)?.value + await swicCall('importKey', nsec) + log('Key imported') + }) + } + async function fetchNewKey() { call(async () => { // @ts-ignore @@ -136,6 +145,7 @@ function App() { // subscribe to updates from the service worker swicOnRender(() => { + console.log("render") setRender(r => r + 1) }) @@ -161,9 +171,13 @@ function App() { ) })} -
+
+
+ + +
diff --git a/src/backend.ts b/src/backend.ts index e32b478..7b41bce 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,8 +1,11 @@ import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools' import { dbi, DbKey, DbPending, DbPerm } from './db' import { Keys } from './keys' -import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk' +import NDK, { IEventHandlingStrategy, NDKEvent, NDKNip46Backend, NDKPrivateKeySigner, NDKSigner } from '@nostr-dev-kit/ndk' import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS } from './consts' +//import { PrivateKeySigner } from './signer' + +//const PERF_TEST = false export interface KeyInfo { npub: string @@ -14,7 +17,7 @@ interface Key { npub: string ndk: NDK backoff: number - signer: NDKPrivateKeySigner + signer: NDKSigner backend: NDKNip46Backend } @@ -56,7 +59,7 @@ class EventHandlingStrategyWrapper implements IEventHandlingStrategy { remotePubkey: string, params: string[] ): Promise { - console.log("handle", { method: this.method, id, remotePubkey, params }) + console.log(Date.now(), "handle", { method: this.method, id, remotePubkey, params }) const allow = await this.allowCb({ npub: this.npub, id, @@ -66,10 +69,10 @@ class EventHandlingStrategyWrapper implements IEventHandlingStrategy { }) if (!allow) return undefined return this.body.handle(backend, id, remotePubkey, params) - .then(r => { - console.log("req", id, "method", this.method, "result", r) - return r - }) + .then(r => { + console.log(Date.now(), "req", id, "method", this.method, "result", r) + return r + }) } } @@ -79,6 +82,7 @@ export class NoauthBackend { private enckeys: DbKey[] = [] private keys: Key[] = [] private perms: DbPerm[] = [] + private doneReqIds: string[] = [] private confirmBuffer: Pending[] = [] private accessBuffer: DbPending[] = [] private notifCallback: (() => void) | null = null @@ -199,7 +203,7 @@ export class NoauthBackend { }, body, }) - if (r.status !== 200 && r.status != 201) { + if (r.status !== 200 && r.status !== 201) { console.log("Fetch error", url, method, r.status) throw new Error("Failed to fetch" + url) } @@ -394,6 +398,13 @@ export class NoauthBackend { params }: IAllowCallbackParams): Promise { + // same reqs usually come on reconnects + if (this.doneReqIds.includes(id)) { + console.log("request already done", id) + // FIXME maybe repeat the reply, but without the Notification? + return false + } + const appNpub = nip19.npubEncode(remotePubkey) const req: DbPending = { id, @@ -403,30 +414,34 @@ export class NoauthBackend { params: JSON.stringify(params), timestamp: Date.now() } - if (!await dbi.addPending(req)) { - console.log("request already done", id) - // FIXME maybe repeat the reply, but without the Notification? - return false - } const self = this - return new Promise((ok) => { + return new Promise(async (ok) => { // called when it's decided whether to allow this or not - const cb = async (allow: boolean, remember: boolean) => { + const onAllow = async (manual: boolean, allow: boolean, remember: boolean) => { // confirm - console.log(allow ? "allowed" : "disallowed", npub, method, params) - await dbi.confirmPending(id, allow) + console.log(Date.now(), allow ? "allowed" : "disallowed", npub, method, params) + if (manual) { + await dbi.confirmPending(id, allow) - if (!await dbi.getApp(req.appNpub)) { - await dbi.addApp({ - appNpub: req.appNpub, - npub: req.npub, - timestamp: Date.now(), - name: '', - icon: '', - url: '' + if (!await dbi.getApp(req.appNpub)) { + await dbi.addApp({ + appNpub: req.appNpub, + npub: req.npub, + timestamp: Date.now(), + name: '', + icon: '', + url: '' + }) + } + } else { + // just send to db w/o waiting for it + // if (!PERF_TEST) + dbi.addConfirmed({ + ...req, + allowed: allow }) } @@ -459,6 +474,7 @@ export class NoauthBackend { } // notify UI that it was confirmed + // if (!PERF_TEST) this.updateUI() // return to let nip46 flow proceed @@ -467,21 +483,24 @@ export class NoauthBackend { // check perms const perm = this.getPerm(req) - console.log("perm", req.id, perm) + console.log(Date.now(), "perm", req.id, perm) // have perm? if (perm) { // reply immediately - cb(perm === '1', false) + onAllow(false, perm === '1', false) } else { + // put pending req to db + await dbi.addPending(req) + // need manual confirmation console.log("need confirm", req) // put to a list of pending requests this.confirmBuffer.push({ req, - cb + cb: (allow, remember) => onAllow(true, allow, remember) }) // show notifs @@ -502,7 +521,7 @@ export class NoauthBackend { // init relay objects but dont wait until we connect ndk.connect() - const signer = new NDKPrivateKeySigner(sk) + const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner const backend = new NDKNip46Backend(ndk, sk, () => Promise.resolve(true)) this.keys.push({ npub, backend, signer, ndk, backoff }) @@ -575,6 +594,12 @@ export class NoauthBackend { return k } + private async importKey(nsec: string) { + const k = await this.addKey(nsec) + this.updateUI() + return k + } + private async saveKey(npub: string, passphrase: string) { const info = this.enckeys.find(k => k.npub === npub) if (!info) throw new Error(`Key ${npub} not found`) @@ -661,6 +686,8 @@ export class NoauthBackend { let result = undefined if (method === 'generateKey') { result = await this.generateKey() + } else if (method === 'importKey') { + result = await this.importKey(args[0]) } else if (method === 'saveKey') { result = await this.saveKey(args[0], args[1]) } else if (method === 'fetchKey') { diff --git a/src/db.ts b/src/db.ts index d91bc22..bf38cf1 100644 --- a/src/db.ts +++ b/src/db.ts @@ -186,4 +186,12 @@ export const dbi = { console.log(`db addPending error: ${error}`) } }, + addConfirmed: async (r: DbHistory) => { + try { + await db.history.add(r) + } catch (error) { + console.log(`db addConfirmed error: ${error}`) + return false + } + }, } diff --git a/src/ende.ts b/src/ende.ts new file mode 100644 index 0000000..0a5783f --- /dev/null +++ b/src/ende.ts @@ -0,0 +1,126 @@ +/* +ende stands for encryption decryption +*/ +import { secp256k1 as secp } from '@noble/curves/secp256k1' +//import * as secp from "./vendor/secp256k1.js"; + +export async function encrypt( + publicKey: string, + message: string, + privateKey: string, +): Promise { + const key = secp.getSharedSecret(privateKey, "02" + publicKey); + const normalizedKey = getNormalizedX(key); + const encoder = new TextEncoder(); + const iv = Uint8Array.from(randomBytes(16)); + const plaintext = encoder.encode(message); + const cryptoKey = await crypto.subtle.importKey( + "raw", + normalizedKey, + { name: "AES-CBC" }, + false, + ["encrypt"], + ); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-CBC", iv }, + cryptoKey, + plaintext, + ); + + const ctb64 = toBase64(new Uint8Array(ciphertext)); + const ivb64 = toBase64(new Uint8Array(iv.buffer)); + return `${ctb64}?iv=${ivb64}`; +} + +export async function decrypt( + privateKey: string, + publicKey: string, + data: string, +): Promise { + const key = secp.getSharedSecret(privateKey, "02" + publicKey); // this line is very slow + return decrypt_with_shared_secret(data, key); +} + +export async function decrypt_with_shared_secret( + data: string, + sharedSecret: Uint8Array, +): Promise { + const [ctb64, ivb64] = data.split("?iv="); + const normalizedKey = getNormalizedX(sharedSecret); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + normalizedKey, + { name: "AES-CBC" }, + false, + ["decrypt"], + ); + let ciphertext: BufferSource; + let iv: BufferSource; + try { + ciphertext = decodeBase64(ctb64); + iv = decodeBase64(ivb64); + } catch (e) { + return new Error(`failed to decode, ${e}`); + } + + try { + const plaintext = await crypto.subtle.decrypt( + { name: "AES-CBC", iv }, + cryptoKey, + ciphertext, + ); + const text = utf8Decode(plaintext); + return text; + } catch (e) { + return new Error(`failed to decrypt, ${e}`); + } +} + +export function utf8Encode(str: string) { + let encoder = new TextEncoder(); + return encoder.encode(str); +} + +export function utf8Decode(bin: Uint8Array | ArrayBuffer): string { + let decoder = new TextDecoder(); + return decoder.decode(bin); +} + +function toBase64(uInt8Array: Uint8Array) { + let strChunks = new Array(uInt8Array.length); + let i = 0; + // @ts-ignore + for (let byte of uInt8Array) { + strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string + i++; + } + return btoa(strChunks.join("")); +} + +function decodeBase64(base64String: string) { + const binaryString = atob(base64String); + const length = binaryString.length; + const bytes = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +function getNormalizedX(key: Uint8Array): Uint8Array { + return key.slice(1, 33); +} + +function randomBytes(bytesLength: number = 32) { + return crypto.getRandomValues(new Uint8Array(bytesLength)); +} + +export function utf16Encode(str: string): number[] { + let array = new Array(str.length); + for (let i = 0; i < str.length; i++) { + array[i] = str.charCodeAt(i); + } + return array; +} \ No newline at end of file diff --git a/src/nip04.ts b/src/nip04.ts new file mode 100644 index 0000000..f121e94 --- /dev/null +++ b/src/nip04.ts @@ -0,0 +1,86 @@ +import { randomBytes } from '@noble/hashes/utils' +import { secp256k1 } from '@noble/curves/secp256k1' +import { base64 } from '@scure/base' +import { getPublicKey } from 'nostr-tools' + +export const utf8Decoder = new TextDecoder('utf-8') +export const utf8Encoder = new TextEncoder() + +function toBase64(uInt8Array: Uint8Array) { + let strChunks = new Array(uInt8Array.length); + let i = 0; + // @ts-ignore + for (let byte of uInt8Array) { + strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string + i++; + } + return btoa(strChunks.join("")); +} + +function fromBase64(base64String: string) { + const binaryString = atob(base64String); + const length = binaryString.length; + const bytes = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +function getNormalizedX(key: Uint8Array): Uint8Array { + return key.slice(1, 33) +} + +export class Nip04 { + private cache = new Map() + + private async getKey(privkey: string, pubkey: string) { + const id = getPublicKey(privkey) + pubkey + let cryptoKey = this.cache.get(id) + if (cryptoKey) return cryptoKey + + const key = secp256k1.getSharedSecret(privkey, '02' + pubkey) + const normalizedKey = getNormalizedX(key) + cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt', 'decrypt']) + this.cache.set(id, cryptoKey) + return cryptoKey + } + + public async encrypt(privkey: string, pubkey: string, text: string): Promise { + const t1 = Date.now() + const cryptoKey = await this.getKey(privkey, pubkey) + const t2 = Date.now() + let iv = Uint8Array.from(randomBytes(16)) + let plaintext = utf8Encoder.encode(text) + let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext) + const t3 = Date.now() + let ctb64 = base64.encode(new Uint8Array(ciphertext)) + let ivb64 = base64.encode(new Uint8Array(iv.buffer)) + // let ctb64 = toBase64(new Uint8Array(ciphertext)) + // let ivb64 = toBase64(new Uint8Array(iv.buffer)) + + console.log("nip04_encrypt", text, "t1", t2 - t1, "t2", t3 - t2, "t3", Date.now() - t3) + + return `${ctb64}?iv=${ivb64}` + } + + public async decrypt(privkey: string, pubkey: string, data: string): Promise { + let [ctb64, ivb64] = data.split('?iv=') + + const cryptoKey = await this.getKey(privkey, pubkey) + + let ciphertext = base64.decode(ctb64) + let iv = base64.decode(ivb64) + // let ciphertext = fromBase64(ctb64) + // let iv = fromBase64(ivb64) + + let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext) + + let text = utf8Decoder.decode(plaintext) + return text + } + +} + + diff --git a/src/signer.ts b/src/signer.ts new file mode 100644 index 0000000..307a3a4 --- /dev/null +++ b/src/signer.ts @@ -0,0 +1,70 @@ +import type { UnsignedEvent } from "nostr-tools"; +import { generatePrivateKey, getPublicKey, getSignature } from "nostr-tools"; + +import type { NostrEvent } from '@nostr-dev-kit/ndk' // "./ndk-dist"; +import { NDKUser } from '@nostr-dev-kit/ndk' // "./ndk-dist"; +import type { NDKSigner } from '@nostr-dev-kit/ndk' // "./ndk-dist"; +import { Nip04 } from "./nip04"; +//import { decrypt, encrypt } from "./ende"; + +export class PrivateKeySigner implements NDKSigner { + private _user: NDKUser | undefined; + privateKey?: string; + private nip04: Nip04 + + public constructor(privateKey?: string) { + if (privateKey) { + this.privateKey = privateKey; + this._user = new NDKUser({ + hexpubkey: getPublicKey(this.privateKey), + }); + } + this.nip04 = new Nip04() + } + + public static generate() { + const privateKey = generatePrivateKey(); + return new PrivateKeySigner(privateKey); + } + + public async blockUntilReady(): Promise { + if (!this._user) { + throw new Error("NDKUser not initialized"); + } + return this._user; + } + + public async user(): Promise { + await this.blockUntilReady(); + return this._user as NDKUser; + } + + public async sign(event: NostrEvent): Promise { + if (!this.privateKey) { + throw Error("Attempted to sign without a private key"); + } + + return getSignature(event as UnsignedEvent, this.privateKey); + } + + public async encrypt(recipient: NDKUser, value: string): Promise { + if (!this.privateKey) { + throw Error("Attempted to encrypt without a private key"); + } + + const recipientHexPubKey = recipient.hexpubkey; + return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value); +// return await encrypt(recipientHexPubKey, value, this.privateKey); + } + + public async decrypt(sender: NDKUser, value: string): Promise { + if (!this.privateKey) { + throw Error("Attempted to decrypt without a private key"); + } + + const senderHexPubKey = sender.hexpubkey; +// console.log("nip04_decrypt", value) + return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value); +// return await decrypt(this.privateKey, senderHexPubKey, value) as string; + } +} \ No newline at end of file