feat(shared): seal/open user private key with PIN-derived Argon2id KEK

This commit is contained in:
byGalax
2026-05-15 21:58:53 +02:00
parent eef884c782
commit 1370f8794b
3 changed files with 134 additions and 0 deletions
+1
View File
@@ -3,3 +3,4 @@ export * from './box';
export * from './keys'; export * from './keys';
export * from './recoveryCode'; export * from './recoveryCode';
export * from './sessionKeys'; export * from './sessionKeys';
export * from './userKey';
@@ -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);
});
});
+82
View File
@@ -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<KeyPair> {
return getCryptoBackend().generateKeyPair();
}
async function deriveKek(pin: string, salt: Uint8Array, params: KdfParams): Promise<Uint8Array> {
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<SealedUserKey> {
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<Uint8Array> {
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); }
}