feat(shared): seal/open user private key with PIN-derived Argon2id KEK
This commit is contained in:
@@ -3,3 +3,4 @@ export * from './box';
|
||||
export * from './keys';
|
||||
export * from './recoveryCode';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user