184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
|
|
import { describe, test, expect } from 'bun:test';
|
||
|
|
import { KeyManager } from '../src/crypto/key-manager.js';
|
||
|
|
import {
|
||
|
|
DEFAULT_ARGON2ID,
|
||
|
|
deriveMasterKeyArgon2id,
|
||
|
|
type Argon2idParams,
|
||
|
|
} from '../src/crypto/kdf.js';
|
||
|
|
|
||
|
|
const FAST_ARGON: Argon2idParams = { m: 256, t: 1, p: 1, dkLen: 32 };
|
||
|
|
|
||
|
|
function randBytes(n: number): Uint8Array {
|
||
|
|
const b = new Uint8Array(n);
|
||
|
|
globalThis.crypto.getRandomValues(b);
|
||
|
|
return b;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('argon2id source', () => {
|
||
|
|
const salt = new Uint8Array(16).fill(0x33);
|
||
|
|
|
||
|
|
test('deriveMasterKeyArgon2id is deterministic', async () => {
|
||
|
|
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||
|
|
const b = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||
|
|
expect(a).toEqual(b);
|
||
|
|
expect(a.length).toBe(32);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different secret → different key', async () => {
|
||
|
|
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||
|
|
const b = await deriveMasterKeyArgon2id('1235', salt, FAST_ARGON);
|
||
|
|
expect(a).not.toEqual(b);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('different salt → different key', async () => {
|
||
|
|
const a = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||
|
|
const b = await deriveMasterKeyArgon2id('1234', new Uint8Array(16).fill(0x44), FAST_ARGON);
|
||
|
|
expect(a).not.toEqual(b);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects empty secret', async () => {
|
||
|
|
await expect(deriveMasterKeyArgon2id('', salt, FAST_ARGON)).rejects.toThrow(/non-empty/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects too-short salt', async () => {
|
||
|
|
await expect(deriveMasterKeyArgon2id('p', new Uint8Array(8), FAST_ARGON))
|
||
|
|
.rejects.toThrow(/at least 16/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('KeyManager.open opens with argon2id source', async () => {
|
||
|
|
const km = await KeyManager.open({
|
||
|
|
kind: 'argon2id',
|
||
|
|
secret: '123456',
|
||
|
|
salt,
|
||
|
|
params: FAST_ARGON,
|
||
|
|
});
|
||
|
|
expect(km.fieldKey('t', 'c').length).toBe(32);
|
||
|
|
km.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('DEFAULT_ARGON2ID is exposed and sensible', () => {
|
||
|
|
expect(DEFAULT_ARGON2ID.dkLen).toBe(32);
|
||
|
|
expect(DEFAULT_ARGON2ID.m).toBeGreaterThanOrEqual(8 * 1024);
|
||
|
|
expect(DEFAULT_ARGON2ID.t).toBeGreaterThanOrEqual(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('accepts Uint8Array secret', async () => {
|
||
|
|
const secretBytes = new TextEncoder().encode('1234');
|
||
|
|
const a = await deriveMasterKeyArgon2id(secretBytes, salt, FAST_ARGON);
|
||
|
|
const b = await deriveMasterKeyArgon2id('1234', salt, FAST_ARGON);
|
||
|
|
expect(a).toEqual(b);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('composite source — multi-factor unlock', () => {
|
||
|
|
const pwSalt = new Uint8Array(16).fill(0x11);
|
||
|
|
const pinSalt = new Uint8Array(16).fill(0x22);
|
||
|
|
const FAST_SCRYPT = { N: 1 << 10, r: 8, p: 1, dkLen: 32 };
|
||
|
|
|
||
|
|
function pwSource(passphrase: string) {
|
||
|
|
return { kind: 'passphrase' as const, passphrase, salt: pwSalt, params: FAST_SCRYPT };
|
||
|
|
}
|
||
|
|
function pinSource(secret: string) {
|
||
|
|
return { kind: 'argon2id' as const, secret, salt: pinSalt, params: FAST_ARGON };
|
||
|
|
}
|
||
|
|
|
||
|
|
test('same factors → same masterKey', async () => {
|
||
|
|
const a = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('correct horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
const b = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('correct horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
expect(a.storageKeyFingerprint()).toEqual(b.storageKeyFingerprint());
|
||
|
|
a.destroy();
|
||
|
|
b.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('wrong PIN → different masterKey (same shape as wrong-passphrase)', async () => {
|
||
|
|
const right = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('correct horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
const wrongPin = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('correct horse'), pinSource('9999')],
|
||
|
|
});
|
||
|
|
expect(right.storageKeyFingerprint()).not.toEqual(wrongPin.storageKeyFingerprint());
|
||
|
|
right.destroy();
|
||
|
|
wrongPin.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('wrong passphrase → different masterKey', async () => {
|
||
|
|
const right = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('correct horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
const wrongPwd = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('wrong horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
expect(right.storageKeyFingerprint()).not.toEqual(wrongPwd.storageKeyFingerprint());
|
||
|
|
right.destroy();
|
||
|
|
wrongPwd.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('order is significant by design', async () => {
|
||
|
|
const ab = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
const ba = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pinSource('1234'), pwSource('horse')],
|
||
|
|
});
|
||
|
|
expect(ab.storageKeyFingerprint()).not.toEqual(ba.storageKeyFingerprint());
|
||
|
|
ab.destroy();
|
||
|
|
ba.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('explicit info string changes masterKey (domain separation)', async () => {
|
||
|
|
const a = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('horse'), pinSource('1234')],
|
||
|
|
});
|
||
|
|
const b = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [pwSource('horse'), pinSource('1234')],
|
||
|
|
info: 'my-app-v1',
|
||
|
|
});
|
||
|
|
expect(a.storageKeyFingerprint()).not.toEqual(b.storageKeyFingerprint());
|
||
|
|
a.destroy();
|
||
|
|
b.destroy();
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects empty source list', async () => {
|
||
|
|
await expect(KeyManager.open({ kind: 'composite', sources: [] }))
|
||
|
|
.rejects.toThrow(/at least one/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rejects nested composite', async () => {
|
||
|
|
await expect(KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [
|
||
|
|
{ kind: 'composite', sources: [pwSource('a')] },
|
||
|
|
pinSource('1234'),
|
||
|
|
],
|
||
|
|
})).rejects.toThrow(/cannot be nested/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('composite of three sources works', async () => {
|
||
|
|
const km = await KeyManager.open({
|
||
|
|
kind: 'composite',
|
||
|
|
sources: [
|
||
|
|
pwSource('horse'),
|
||
|
|
pinSource('1234'),
|
||
|
|
{ kind: 'injected', key: randBytes(32) },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
expect(km.fieldKey('t', 'c').length).toBe(32);
|
||
|
|
km.destroy();
|
||
|
|
});
|
||
|
|
});
|