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 './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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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