diff --git a/package.json b/package.json index 0cf19cd486..5f46b4ec76 100644 --- a/package.json +++ b/package.json @@ -133,7 +133,7 @@ "lodash": "^4.7.11", "mini-css-extract-plugin": "^2.6.0", "nostr-machina": "^0.1.0", - "nostr-tools": "^1.14.2", + "nostr-tools": "^2.3.0", "nspec": "^0.1.0", "path-browserify": "^1.0.1", "postcss": "^8.4.29", diff --git a/src/features/nostr/NKeyStorage.ts b/src/features/nostr/NKeyStorage.ts new file mode 100644 index 0000000000..e267947369 --- /dev/null +++ b/src/features/nostr/NKeyStorage.ts @@ -0,0 +1,114 @@ +import { getPublicKey, nip19 } from 'nostr-tools'; +import { NSchema as n, NostrSigner, NSecSigner } from 'nspec'; +import { z } from 'zod'; + +import { lockStorageKey } from 'soapbox/utils/storage'; + +/** + * Gets Nostr keypairs from storage and returns a `Map`-like object of signers. + * When instantiated, it will lock the storage key to prevent tampering. + * Changes to the object will sync to storage. + */ +export class NKeyStorage implements ReadonlyMap { + + #keypairs = new Map(); + #storage: Storage; + #storageKey: string; + + constructor(storage: Storage, storageKey: string) { + this.#storage = storage; + this.#storageKey = storageKey; + + const data = this.#storage.getItem(storageKey); + lockStorageKey(storageKey); + + try { + for (const nsec of this.#dataSchema().parse(data)) { + const { data: secretKey } = nip19.decode(nsec); + const pubkey = getPublicKey(secretKey); + this.#keypairs.set(pubkey, secretKey); + } + } catch (e) { + this.clear(); + } + } + + #dataSchema() { + return n.json().pipe(z.set(this.#nsecSchema())); + } + + #nsecSchema() { + return n.bech32().refine((v): v is `nsec1${string}` => v.startsWith('nsec1'), { message: 'Invalid secret key' }); + } + + #syncStorage() { + const secretKeys = [...this.#keypairs.values()].map(nip19.nsecEncode); + this.#storage.setItem(this.#storageKey, JSON.stringify(secretKeys)); + } + + get size(): number { + return this.#keypairs.size; + } + + clear(): void { + this.#keypairs.clear(); + this.#syncStorage(); + } + + delete(pubkey: string): boolean { + const result = this.#keypairs.delete(pubkey); + this.#syncStorage(); + return result; + } + + forEach(callbackfn: (signer: NostrSigner, pubkey: string, map: typeof this) => void, thisArg?: any): void { + for (const [pubkey] of this.#keypairs) { + const signer = this.get(pubkey); + if (signer) { + callbackfn.call(thisArg, signer, pubkey, this); + } + } + } + + get(pubkey: string): NostrSigner | undefined { + const secretKey = this.#keypairs.get(pubkey); + if (secretKey) { + return new NSecSigner(secretKey); + } + } + + has(pubkey: string): boolean { + return this.#keypairs.has(pubkey); + } + + add(secretKey: Uint8Array): void { + const pubkey = getPublicKey(secretKey); + this.#keypairs.set(pubkey, secretKey); + this.#syncStorage(); + } + + *entries(): IterableIterator<[string, NostrSigner]> { + for (const [pubkey] of this.#keypairs) { + yield [pubkey, this.get(pubkey)!]; + } + } + + *keys(): IterableIterator { + for (const pubkey of this.#keypairs.keys()) { + yield pubkey; + } + } + + *values(): IterableIterator { + for (const pubkey of this.#keypairs.keys()) { + yield this.get(pubkey)!; + } + } + + [Symbol.iterator](): IterableIterator<[string, NostrSigner]> { + return this.entries(); + } + + [Symbol.toStringTag] = 'NKeyStorage'; + +} \ No newline at end of file diff --git a/src/features/nostr/keys.ts b/src/features/nostr/keys.ts new file mode 100644 index 0000000000..92f9fc09f7 --- /dev/null +++ b/src/features/nostr/keys.ts @@ -0,0 +1,6 @@ +import { NKeyStorage } from './NKeyStorage'; + +export const NKeys = new NKeyStorage( + localStorage, + 'soapbox:nostr:keys', +); diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 549bd497ad..087b3f24e8 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,4 +1,4 @@ -import { verifySignature } from 'nostr-tools'; +import { verifyEvent } from 'nostr-tools'; import { z } from 'zod'; /** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ @@ -22,7 +22,7 @@ const eventSchema = eventTemplateSchema.extend({ }); /** Nostr event schema that also verifies the event's signature. */ -const signedEventSchema = eventSchema.refine(verifySignature); +const signedEventSchema = eventSchema.refine(verifyEvent); /** NIP-46 signer options. */ const signEventOptsSchema = z.object({ diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000000..a269b1504f --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,15 @@ +/** Lock a key from being accessed by `localStorage` and `sessionStorage`. */ +function lockStorageKey(key: string): void { + const proto = Object.getPrototypeOf(localStorage ?? sessionStorage); + const _getItem = proto.getItem; + + proto.getItem = function(_key: string) { + if (_key === key) { + throw new Error(`${_key} is locked`); + } else { + return _getItem.bind(this)(_key); + } + }; +} + +export { lockStorageKey }; \ No newline at end of file diff --git a/src/workers/pow.worker.ts b/src/workers/pow.worker.ts index dcfb948e5a..24c3420185 100644 --- a/src/workers/pow.worker.ts +++ b/src/workers/pow.worker.ts @@ -2,7 +2,7 @@ import * as Comlink from 'comlink'; import { nip13, type UnsignedEvent } from 'nostr-tools'; export const PowWorker = { - mine(event: UnsignedEvent, difficulty: number) { + mine(event: UnsignedEvent, difficulty: number) { return nip13.minePow(event, difficulty); }, }; diff --git a/yarn.lock b/yarn.lock index 5b6dddc398..e010e9afe6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1837,6 +1837,11 @@ resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.2.0.tgz#a12cda60f3cf1ab5d7c77068c3711d2366649ed7" integrity sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw== +"@noble/ciphers@^0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.1.tgz#292f388b69c9ed80d49dca1a5cbfd4ff06852111" + integrity sha512-aNE06lbe36ifvMbbWvmmF/8jx6EQPu2HVg70V95T+iGjOuYwPpAccwAQc2HlXO2D0aiQ3zavbMga4jjWnrpiPA== + "@noble/curves@1.1.0", "@noble/curves@~1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" @@ -6578,7 +6583,7 @@ nostr-machina@^0.1.0: nostr-tools "^1.14.0" zod "^3.21.0" -nostr-tools@^1.14.0, nostr-tools@^1.14.2: +nostr-tools@^1.14.0: version "1.16.0" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.16.0.tgz#5867f1d8bd055a5a3b27aadb199457dceb244314" integrity sha512-sx/aOl0gmkeHVoIVbyOhEQhzF88NsrBXMC8bsjhPASqA6oZ8uSOAyEGgRLMfC3SKgzQD5Gr6KvDoAahaD6xKcg== @@ -6604,6 +6609,20 @@ nostr-tools@^2.1.4: optionalDependencies: nostr-wasm v0.1.0 +nostr-tools@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.3.0.tgz#687d1af486a21e3e4805f0d4167c01221d871e65" + integrity sha512-jWD71y9JJ7DJ5/Si/DhREkjwyCWgMmY7x8qXfA9xC1HeosoGnaXuyYtspfYuiy8B8B2969C1iR6rWt6Fyf3IaA== + dependencies: + "@noble/ciphers" "^0.5.1" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + nostr-wasm@v0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94"