diff --git a/deno.json b/deno.json index b081f323..7df7a010 100644 --- a/deno.json +++ b/deno.json @@ -44,6 +44,7 @@ "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0", "@nostrify/policies": "jsr:@nostrify/policies@^0.35.0", "@scure/base": "npm:@scure/base@^1.1.6", + "@scure/bip32": "npm:@scure/bip32@^1.5.0", "@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs", "@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0", "@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0", diff --git a/deno.lock b/deno.lock index 7b7e7d1c..d11a5fbd 100644 --- a/deno.lock +++ b/deno.lock @@ -73,6 +73,7 @@ "npm:@noble/secp256k1@^2.0.0": "npm:@noble/secp256k1@2.1.0", "npm:@scure/base@^1.1.6": "npm:@scure/base@1.1.6", "npm:@scure/bip32@^1.4.0": "npm:@scure/bip32@1.4.0", + "npm:@scure/bip32@^1.5.0": "npm:@scure/bip32@1.5.0", "npm:@scure/bip39@^1.3.0": "npm:@scure/bip39@1.3.0", "npm:@types/node": "npm:@types/node@18.16.19", "npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1", @@ -604,6 +605,12 @@ "@noble/hashes": "@noble/hashes@1.4.0" } }, + "@noble/curves@1.6.0": { + "integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==", + "dependencies": { + "@noble/hashes": "@noble/hashes@1.5.0" + } + }, "@noble/hashes@1.3.1": { "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", "dependencies": {} @@ -616,6 +623,10 @@ "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", "dependencies": {} }, + "@noble/hashes@1.5.0": { + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "dependencies": {} + }, "@noble/secp256k1@2.1.0": { "integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==", "dependencies": {} @@ -632,6 +643,10 @@ "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", "dependencies": {} }, + "@scure/base@1.1.9": { + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "dependencies": {} + }, "@scure/bip32@1.3.1": { "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", "dependencies": { @@ -648,6 +663,14 @@ "@scure/base": "@scure/base@1.1.6" } }, + "@scure/bip32@1.5.0": { + "integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==", + "dependencies": { + "@noble/curves": "@noble/curves@1.6.0", + "@noble/hashes": "@noble/hashes@1.5.0", + "@scure/base": "@scure/base@1.1.9" + } + }, "@scure/bip39@1.2.1": { "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", "dependencies": { @@ -2132,6 +2155,7 @@ "npm:@isaacs/ttlcache@^1.4.1", "npm:@noble/secp256k1@^2.0.0", "npm:@scure/base@^1.1.6", + "npm:@scure/bip32@^1.5.0", "npm:comlink-async-generator@^0.0.1", "npm:comlink@^4.4.1", "npm:commander@12.1.0", diff --git a/src/DittoWallet.ts b/src/DittoWallet.ts new file mode 100644 index 00000000..25412f62 --- /dev/null +++ b/src/DittoWallet.ts @@ -0,0 +1,51 @@ +import { HDKey } from '@scure/bip32'; + +import { Conf } from '@/config.ts'; + +/** + * HD wallet based on the `DITTO_NSEC`. + * The wallet is used to derive keys for various purposes. + * It is a singleton with static methods, and the keys are cached. + */ +export class DittoWallet { + static #root = HDKey.fromMasterSeed(Conf.seckey); + static #keys = new Map(); + + /** Derive the key cached. */ + static derive(path: string): HDKey { + const existing = this.#keys.get(path); + if (existing) { + return existing; + } else { + const key = this.#root.derive(path); + this.#keys.set(path, key); + return key; + } + } + + /** Derive the key and return the bytes. */ + static deriveKey(path: string): Uint8Array { + const { privateKey } = this.derive(path); + + if (!privateKey) { + throw new Error('Private key not available'); + } + + return privateKey; + } + + /** Database encryption key for AES-GCM encryption of database columns. */ + static get dbKey(): Uint8Array { + return this.deriveKey(Conf.wallet.dbKeyPath); + } + + /** Captcha encryption key for encrypting answer data in AES-GCM. */ + static get captchaKey(): Uint8Array { + return this.deriveKey(Conf.wallet.captchaKeyPath); + } + + /** VAPID secret key, used for web push notifications. ES256. */ + static get vapidKey(): Uint8Array { + return this.deriveKey(Conf.wallet.vapidKeyPath); + } +} diff --git a/src/config.ts b/src/config.ts index ae841997..d471076d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -99,6 +99,25 @@ class Conf { }, }, }; + /** + * BIP-32 derivation paths for different crypto use-cases. + * The `DITTO_NSEC` is used as the seed. + * Keys can be rotated by changing the derviation path. + */ + static wallet = { + /** Private key for AES-GCM encryption in the Postgres database. */ + get dbKeyPath(): string { + return Deno.env.get('WALLET_DB_KEY_PATH') || "m/0'/1'"; + }, + /** Private key for AES-GCM encryption of captcha answer data. */ + get captchaKeyPath(): string { + return Deno.env.get('WALLET_CAPTCHA_KEY_PATH') || "m/0'/2'"; + }, + /** VAPID private key path. */ + get vapidKeyPath(): string { + return Deno.env.get('WALLET_VAPID_KEY_PATH') || "m/0'/3'"; + }, + }; /** Character limit to enforce for posts made through Mastodon API. */ static get postCharLimit(): number { return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);