From eef884c782e3aa6d3f222a6cbc3bed185f85e529 Mon Sep 17 00:00:00 2001 From: byGalax Date: Fri, 15 May 2026 21:54:57 +0200 Subject: [PATCH] feat(shared): lift recovery-code primitives into shared crypto module --- packages/shared/package.json | 2 ++ packages/shared/src/crypto/index.ts | 1 + .../shared/src/crypto/recoveryCode.test.ts | 29 +++++++++++++++++++ packages/shared/src/crypto/recoveryCode.ts | 22 ++++++++++++++ packages/shared/src/crypto/testBackend.ts | 26 +++++++++++++++++ .../src/types/libsodium-wrappers-sumo.d.ts | 9 ++++++ pnpm-lock.yaml | 8 ++++- 7 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/crypto/recoveryCode.test.ts create mode 100644 packages/shared/src/crypto/recoveryCode.ts create mode 100644 packages/shared/src/crypto/testBackend.ts create mode 100644 packages/shared/src/types/libsodium-wrappers-sumo.d.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 70e1222..759bcc3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" } diff --git a/packages/shared/src/crypto/index.ts b/packages/shared/src/crypto/index.ts index b9e87fb..3d6d61c 100644 --- a/packages/shared/src/crypto/index.ts +++ b/packages/shared/src/crypto/index.ts @@ -1,4 +1,5 @@ export * from './backend'; export * from './box'; export * from './keys'; +export * from './recoveryCode'; export * from './sessionKeys'; diff --git a/packages/shared/src/crypto/recoveryCode.test.ts b/packages/shared/src/crypto/recoveryCode.test.ts new file mode 100644 index 0000000..87a99b4 --- /dev/null +++ b/packages/shared/src/crypto/recoveryCode.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/crypto/recoveryCode.ts b/packages/shared/src/crypto/recoveryCode.ts new file mode 100644 index 0000000..5642b0c --- /dev/null +++ b/packages/shared/src/crypto/recoveryCode.ts @@ -0,0 +1,22 @@ +import { getCryptoBackend } from './backend'; + +export const RECOVERY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +export const RECOVERY_CODE_LEN = 24; + +export async function generateRecoveryCode(): Promise { + 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(''); +} diff --git a/packages/shared/src/crypto/testBackend.ts b/packages/shared/src/crypto/testBackend.ts new file mode 100644 index 0000000..d5f88c0 --- /dev/null +++ b/packages/shared/src/crypto/testBackend.ts @@ -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 { + 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), + }; +} diff --git a/packages/shared/src/types/libsodium-wrappers-sumo.d.ts b/packages/shared/src/types/libsodium-wrappers-sumo.d.ts new file mode 100644 index 0000000..48e0436 --- /dev/null +++ b/packages/shared/src/types/libsodium-wrappers-sumo.d.ts @@ -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'; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b208329..469ba1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,9 +253,15 @@ importers: specifier: ^23.16.4 version: 23.16.8 devDependencies: + '@types/libsodium-wrappers': + specifier: ^0.7.14 + version: 0.7.14 '@types/react': specifier: ^18.3.12 version: 18.3.28 + libsodium-wrappers-sumo: + specifier: 0.7.15 + version: 0.7.15 react: specifier: ^18.3.1 version: 18.3.1 @@ -1238,7 +1244,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.28': resolution: {integrity: sha512-lvt72KNitGuixYD2l3SZmRKVu2G4zJpmg5V7WfUBNpmUU5oODBw/6qmiJ6kSLAlfDozscUk+BBGknBBzxUrwrA==}