diff --git a/packages/shared/src/crypto/index.ts b/packages/shared/src/crypto/index.ts index 3d6d61c..4d267de 100644 --- a/packages/shared/src/crypto/index.ts +++ b/packages/shared/src/crypto/index.ts @@ -3,3 +3,4 @@ export * from './box'; export * from './keys'; export * from './recoveryCode'; export * from './sessionKeys'; +export * from './userKey'; diff --git a/packages/shared/src/crypto/userKey.test.ts b/packages/shared/src/crypto/userKey.test.ts new file mode 100644 index 0000000..ffa9ea5 --- /dev/null +++ b/packages/shared/src/crypto/userKey.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, beforeAll } from 'vitest'; + +import { setCryptoBackend } from './backend'; +import { makeWasmTestBackend } from './testBackend'; +import { generateUserKeyPair, sealUserKey, openUserKey, KDF_PRESET } from './userKey'; + +beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); + +describe('userKey', () => { + it('generates a 32-byte X25519 keypair', async () => { + const kp = await generateUserKeyPair(); + expect(kp.publicKey).toHaveLength(32); + expect(kp.privateKey).toHaveLength(32); + }); + + it('seals and opens a private key with the same PIN', async () => { + const kp = await generateUserKeyPair(); + const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); + const opened = await openUserKey({ + sealed: sealed.sealedPrivateKey, + pin: '123456', + salt: sealed.salt, + kdfParams: sealed.kdfParams, + }); + expect(Array.from(opened)).toEqual(Array.from(kp.privateKey)); + }); + + it('throws when opening with the wrong PIN', async () => { + const kp = await generateUserKeyPair(); + const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); + await expect( + openUserKey({ sealed: sealed.sealedPrivateKey, pin: '654321', salt: sealed.salt, kdfParams: sealed.kdfParams }), + ).rejects.toThrow(); + }); + + it('throws when opening with the wrong salt', async () => { + const kp = await generateUserKeyPair(); + const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); + const wrongSalt = new Uint8Array(sealed.salt.length); wrongSalt.fill(7); + await expect( + openUserKey({ sealed: sealed.sealedPrivateKey, pin: '123456', salt: wrongSalt, kdfParams: sealed.kdfParams }), + ).rejects.toThrow(); + }); + + it('emits the documented KDF preset', async () => { + const kp = await generateUserKeyPair(); + const sealed = await sealUserKey({ privateKey: kp.privateKey, pin: '123456' }); + expect(sealed.kdfParams.algo).toBe('argon2id'); + expect(sealed.kdfParams.preset).toBe(KDF_PRESET); + }); +}); diff --git a/packages/shared/src/crypto/userKey.ts b/packages/shared/src/crypto/userKey.ts new file mode 100644 index 0000000..67b3fa7 --- /dev/null +++ b/packages/shared/src/crypto/userKey.ts @@ -0,0 +1,82 @@ +import sodium from 'libsodium-wrappers-sumo'; + +import { getCryptoBackend } from './backend'; +import type { KeyPair } from './backend'; + +export const KDF_PRESET = 'moderate' as const; +const SALT_LEN = 16; + +export interface KdfParams { + algo: 'argon2id'; + preset: typeof KDF_PRESET; + opslimit: number; + memlimit: number; +} + +export interface SealedUserKey { + sealedPrivateKey: Uint8Array; // nonce(24) || ciphertext + salt: Uint8Array; // 16 bytes + kdfParams: KdfParams; +} + +export async function generateUserKeyPair(): Promise { + return getCryptoBackend().generateKeyPair(); +} + +async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise { + await sodium.ready; + return sodium.crypto_pwhash( + sodium.crypto_secretbox_KEYBYTES, + pin, + salt, + params.opslimit, + params.memlimit, + sodium.crypto_pwhash_ALG_ARGON2ID13, + ); +} + +function defaultKdfParams(): KdfParams { + return { + algo: 'argon2id', + preset: KDF_PRESET, + opslimit: sodium.crypto_pwhash_OPSLIMIT_MODERATE, + memlimit: sodium.crypto_pwhash_MEMLIMIT_MODERATE, + }; +} + +export async function sealUserKey(opts: { + privateKey: Uint8Array; + pin: string; + salt?: Uint8Array; + kdfParams?: KdfParams; +}): Promise { + await sodium.ready; + const backend = getCryptoBackend(); + const salt = opts.salt ?? backend.randomBytes(SALT_LEN); + const kdfParams = opts.kdfParams ?? defaultKdfParams(); + const kek = await deriveKek(opts.pin, salt, kdfParams); + try { + const nonce = backend.randomBytes(backend.secretboxNonceLength); + const cipher = backend.secretbox(opts.privateKey, nonce, kek); + const sealedPrivateKey = new Uint8Array(nonce.length + cipher.length); + sealedPrivateKey.set(nonce, 0); + sealedPrivateKey.set(cipher, nonce.length); + return { sealedPrivateKey, salt, kdfParams }; + } finally { sodium.memzero(kek); } +} + +export async function openUserKey(opts: { + sealed: Uint8Array; + pin: string; + salt: Uint8Array; + kdfParams: KdfParams; +}): Promise { + await sodium.ready; + const backend = getCryptoBackend(); + const nonceLen = backend.secretboxNonceLength; + if (opts.sealed.length <= nonceLen) throw new Error('sealed user key blob too short'); + const nonce = opts.sealed.slice(0, nonceLen); + const cipher = opts.sealed.slice(nonceLen); + const kek = await deriveKek(opts.pin, opts.salt, opts.kdfParams); + try { return backend.secretboxOpen(cipher, nonce, kek); } finally { sodium.memzero(kek); } +}