feat(shared): lift recovery-code primitives into shared crypto module

This commit is contained in:
byGalax
2026-05-15 21:54:57 +02:00
parent d9f3c6c562
commit eef884c782
7 changed files with 96 additions and 1 deletions
+2
View File
@@ -40,7 +40,9 @@
"react-i18next": { "optional": false }
},
"devDependencies": {
"@types/libsodium-wrappers": "^0.7.14",
"@types/react": "^18.3.12",
"libsodium-wrappers-sumo": "0.7.15",
"react": "^18.3.1",
"react-i18next": "^15.1.1"
}
+1
View File
@@ -1,4 +1,5 @@
export * from './backend';
export * from './box';
export * from './keys';
export * from './recoveryCode';
export * from './sessionKeys';
@@ -0,0 +1,29 @@
import { describe, expect, it, beforeAll } from 'vitest';
import { setCryptoBackend } from './backend';
import { makeWasmTestBackend } from './testBackend';
import { generateRecoveryCode, normalizeRecoveryCode, RECOVERY_CODE_LEN } from './recoveryCode';
beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); });
describe('recoveryCode', () => {
it('generates a 24-char alphabet-restricted code grouped 4×6 with dashes', async () => {
const code = await generateRecoveryCode();
expect(code).toHaveLength(27);
const groups = code.split('-');
expect(groups).toHaveLength(4);
for (const g of groups) expect(g).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/);
});
it('normalizeRecoveryCode strips dashes/spaces and uppercases', () => {
expect(normalizeRecoveryCode(' abcdef-ghjklm-npqrst-uvwxyz ')).toBe('ABCDEFGHJKLMNPQRSTUVWXYZ');
});
it('normalizeRecoveryCode drops characters outside the alphabet', () => {
expect(normalizeRecoveryCode('AB1OD-EF0H1J')).toBe('ABDEFHJ');
});
it('RECOVERY_CODE_LEN matches alphabet length', () => {
expect(RECOVERY_CODE_LEN).toBe(24);
});
});
@@ -0,0 +1,22 @@
import { getCryptoBackend } from './backend';
export const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const RECOVERY_CODE_LEN = 24;
export async function generateRecoveryCode(): Promise<string> {
const raw = getCryptoBackend().randomBytes(RECOVERY_CODE_LEN);
let out = '';
for (let i = 0; i < raw.length; i++) {
out += RECOVERY_ALPHABET[raw[i]! % RECOVERY_ALPHABET.length];
if ((i + 1) % 6 === 0 && i !== raw.length - 1) out += '-';
}
return out;
}
export function normalizeRecoveryCode(input: string): string {
return input
.toUpperCase()
.split('')
.filter((c) => RECOVERY_ALPHABET.includes(c))
.join('');
}
+26
View File
@@ -0,0 +1,26 @@
import sodium from 'libsodium-wrappers-sumo';
import type { CryptoBackend } from './backend';
// Real libsodium WASM backend — used in unit tests so cryptographic invariants
// (length, KDF determinism) hold. Not registered for production.
export async function makeWasmTestBackend(): Promise<CryptoBackend> {
await sodium.ready;
return {
name: 'libsodium-wasm-test',
nonceLength: sodium.crypto_box_NONCEBYTES,
publicKeyLength: sodium.crypto_box_PUBLICKEYBYTES,
privateKeyLength: sodium.crypto_box_SECRETKEYBYTES,
secretboxKeyLength: sodium.crypto_secretbox_KEYBYTES,
secretboxNonceLength: sodium.crypto_secretbox_NONCEBYTES,
randomBytes: (n) => sodium.randombytes_buf(n),
generateKeyPair: () => {
const kp = sodium.crypto_box_keypair();
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
},
box: (m, n, pk, sk) => sodium.crypto_box_easy(m, n, pk, sk),
boxOpen: (c, n, pk, sk) => sodium.crypto_box_open_easy(c, n, pk, sk),
secretbox: (m, n, k) => sodium.crypto_secretbox_easy(m, n, k),
secretboxOpen: (c, n, k) => sodium.crypto_secretbox_open_easy(c, n, k),
};
}
@@ -0,0 +1,9 @@
// Shim sumo types to the non-sumo types package. sumo is an API superset of
// libsodium-wrappers; its runtime exports match the standard wrappers module
// and additionally include `crypto_pwhash` (Argon2id). Reuse the existing
// `@types/libsodium-wrappers` definitions rather than duplicating them.
declare module 'libsodium-wrappers-sumo' {
import sodium from 'libsodium-wrappers';
export default sodium;
export * from 'libsodium-wrappers';
}