Add NKeyStorage class to retrieve and set keys in browser storage in a mostly-secure way

This commit is contained in:
Alex Gleason 2024-02-18 16:43:15 -06:00
parent b382a96d6a
commit 15ae362a8e
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
7 changed files with 159 additions and 5 deletions

View file

@ -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",

View file

@ -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<string, NostrSigner> {
#keypairs = new Map<string, Uint8Array>();
#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<string> {
for (const pubkey of this.#keypairs.keys()) {
yield pubkey;
}
}
*values(): IterableIterator<NostrSigner> {
for (const pubkey of this.#keypairs.keys()) {
yield this.get(pubkey)!;
}
}
[Symbol.iterator](): IterableIterator<[string, NostrSigner]> {
return this.entries();
}
[Symbol.toStringTag] = 'NKeyStorage';
}

View file

@ -0,0 +1,6 @@
import { NKeyStorage } from './NKeyStorage';
export const NKeys = new NKeyStorage(
localStorage,
'soapbox:nostr:keys',
);

View file

@ -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({

15
src/utils/storage.ts Normal file
View file

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

View file

@ -2,7 +2,7 @@ import * as Comlink from 'comlink';
import { nip13, type UnsignedEvent } from 'nostr-tools';
export const PowWorker = {
mine<K extends number>(event: UnsignedEvent<K>, difficulty: number) {
mine(event: UnsignedEvent, difficulty: number) {
return nip13.minePow(event, difficulty);
},
};

View file

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