Add NKeyStorage class to retrieve and set keys in browser storage in a mostly-secure way
This commit is contained in:
parent
b382a96d6a
commit
15ae362a8e
7 changed files with 159 additions and 5 deletions
|
@ -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",
|
||||
|
|
114
src/features/nostr/NKeyStorage.ts
Normal file
114
src/features/nostr/NKeyStorage.ts
Normal 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';
|
||||
|
||||
}
|
6
src/features/nostr/keys.ts
Normal file
6
src/features/nostr/keys.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { NKeyStorage } from './NKeyStorage';
|
||||
|
||||
export const NKeys = new NKeyStorage(
|
||||
localStorage,
|
||||
'soapbox:nostr:keys',
|
||||
);
|
|
@ -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
15
src/utils/storage.ts
Normal 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 };
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
21
yarn.lock
21
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"
|
||||
|
|
Loading…
Reference in a new issue