Scaffold Vinterliste — end-to-end encrypted winter activity list
Foundation for an E2E-encrypted activity list per winter-list-claude-code-prompt.md. Server (Bun + Hono): - bun:sqlite with WAL and the spec's schema (idempotent migration) - opaque server-stored sessions, httpOnly cookie - signup / challenge / login / logout / me / password / recovery-challenge / recovery-complete - activity CRUD with strict visibility rules: private uses ciphertext+nonce, semi never serializes owner_id, public attributes the owner - tag store with normalisation + autocomplete (semi/public only) Frontend (Svelte 5 + Vite): - libsodium-wrappers-sumo for client crypto (Argon2id + XChaCha20-Poly1305). SUMO is required because the standard build omits crypto_pwhash. - IndexedDB-backed private tag index (never leaves the browser) - in-memory DEK (no localStorage); page reload re-prompts for password - signup shows the recovery code once; tag input merges server + private sources with clear labelling - Bokmål UI Crypto module (shared/crypto.ts): - pure, runs in both Bun and the browser via a runtime-conditional loader that papers over libsodium-wrappers-sumo's broken ESM entry (createRequire on server, Vite alias in the browser) - DEK wrap/unwrap, AEAD payload encryption, recovery code generation with a visually-unambiguous alphabet Verification: - 22 crypto round-trip tests (wrap/unwrap, AEAD tamper rejection, password change preserves ciphertexts, recovery still works after rotation) - typecheck passes for server and frontend - Vite production build succeeds; libsodium SUMO chunk is ~315 KB gzipped Single-image Containerfile for podman: builds frontend in a builder stage, runs Bun in a slim runtime; one volume for the SQLite file; BUILD_DATE / GIT_REVISION baked into OCI labels and /etc/build-info. Known limitation deferred for this commit: the recovery endpoint has no server-side proof of the recovery code (anyone who knows an email can lock out the legitimate user, though they can't read any data). Closed in the next commit.
This commit is contained in:
commit
47963c9225
39 changed files with 4007 additions and 0 deletions
246
tests/crypto.test.ts
Normal file
246
tests/crypto.test.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, expect, test, beforeAll } from 'bun:test';
|
||||
import {
|
||||
ready,
|
||||
generateDek,
|
||||
generateSalt,
|
||||
generateRecoveryCode,
|
||||
normalizeRecoveryCode,
|
||||
deriveKey,
|
||||
deriveAuthVerifier,
|
||||
wrapDek,
|
||||
unwrapDek,
|
||||
encryptPayload,
|
||||
decryptPayload,
|
||||
aeadEncrypt,
|
||||
aeadDecrypt,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
bytesEqual,
|
||||
DEK_BYTES,
|
||||
SALT_BYTES,
|
||||
NONCE_BYTES,
|
||||
KEK_BYTES,
|
||||
} from '../shared/crypto';
|
||||
|
||||
beforeAll(async () => {
|
||||
await ready();
|
||||
});
|
||||
|
||||
describe('random material', () => {
|
||||
test('DEK is 32 bytes', () => {
|
||||
expect(generateDek().length).toBe(DEK_BYTES);
|
||||
});
|
||||
|
||||
test('salt is 16 bytes', () => {
|
||||
expect(generateSalt().length).toBe(SALT_BYTES);
|
||||
});
|
||||
|
||||
test('two DEKs are not equal', () => {
|
||||
expect(bytesEqual(generateDek(), generateDek())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovery code', () => {
|
||||
test('matches XXXX-XXXX-XXXX-XXXX-XXXX-XXXX shape', () => {
|
||||
const code = generateRecoveryCode();
|
||||
expect(code).toMatch(/^[A-Z2-9]{4}(-[A-Z2-9]{4}){5}$/);
|
||||
});
|
||||
|
||||
test('only uses the safe alphabet (no 0/O, 1/I/L, U)', () => {
|
||||
const safe = /^[ABCDEFGHJKMNPQRSTVWXYZ23456789-]+$/;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateRecoveryCode()).toMatch(safe);
|
||||
}
|
||||
});
|
||||
|
||||
test('two codes differ', () => {
|
||||
expect(generateRecoveryCode()).not.toBe(generateRecoveryCode());
|
||||
});
|
||||
|
||||
test('normalisation strips dashes and uppercases', () => {
|
||||
const code = generateRecoveryCode();
|
||||
expect(normalizeRecoveryCode(code.toLowerCase())).toBe(code.replace(/-/g, ''));
|
||||
expect(normalizeRecoveryCode(` ${code} `)).toBe(code.replace(/-/g, ''));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AEAD round-trip', () => {
|
||||
test('encrypt then decrypt returns the original', () => {
|
||||
const key = generateDek();
|
||||
const msg = new TextEncoder().encode('vinterliste');
|
||||
const sealed = aeadEncrypt(msg, key);
|
||||
expect(sealed.nonce.length).toBe(NONCE_BYTES);
|
||||
const back = aeadDecrypt(sealed, key);
|
||||
expect(new TextDecoder().decode(back)).toBe('vinterliste');
|
||||
});
|
||||
|
||||
test('wrong key fails to decrypt', () => {
|
||||
const key = generateDek();
|
||||
const wrong = generateDek();
|
||||
const sealed = aeadEncrypt(new Uint8Array([1, 2, 3]), key);
|
||||
expect(() => aeadDecrypt(sealed, wrong)).toThrow();
|
||||
});
|
||||
|
||||
test('tampered ciphertext fails to decrypt', () => {
|
||||
const key = generateDek();
|
||||
const sealed = aeadEncrypt(new Uint8Array([1, 2, 3, 4]), key);
|
||||
sealed.ciphertext[0] = sealed.ciphertext[0]! ^ 0xff;
|
||||
expect(() => aeadDecrypt(sealed, key)).toThrow();
|
||||
});
|
||||
|
||||
test('each encrypt uses a fresh nonce', () => {
|
||||
const key = generateDek();
|
||||
const a = aeadEncrypt(new Uint8Array([1]), key);
|
||||
const b = aeadEncrypt(new Uint8Array([1]), key);
|
||||
expect(bytesEqual(a.nonce, b.nonce)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEK wrap via password path', () => {
|
||||
const password = 'correct horse battery staple';
|
||||
|
||||
test('wrap then unwrap returns the original DEK', () => {
|
||||
const dek = generateDek();
|
||||
const kekSalt = generateSalt();
|
||||
const kek = deriveKey(password, kekSalt);
|
||||
expect(kek.length).toBe(KEK_BYTES);
|
||||
|
||||
const sealed = wrapDek(dek, kek);
|
||||
const back = unwrapDek(sealed, kek);
|
||||
expect(bytesEqual(dek, back)).toBe(true);
|
||||
});
|
||||
|
||||
test('wrong password derives a different KEK and fails to unwrap', () => {
|
||||
const dek = generateDek();
|
||||
const salt = generateSalt();
|
||||
const kek = deriveKey(password, salt);
|
||||
const sealed = wrapDek(dek, kek);
|
||||
|
||||
const wrongKek = deriveKey('wrong', salt);
|
||||
expect(() => unwrapDek(sealed, wrongKek)).toThrow();
|
||||
});
|
||||
|
||||
test('same password with a different salt derives a different KEK', () => {
|
||||
const a = deriveKey(password, generateSalt());
|
||||
const b = deriveKey(password, generateSalt());
|
||||
expect(bytesEqual(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEK wrap via recovery path', () => {
|
||||
test('recovery code round-trips the DEK', () => {
|
||||
const dek = generateDek();
|
||||
const code = generateRecoveryCode();
|
||||
const salt = generateSalt();
|
||||
const kek = deriveKey(normalizeRecoveryCode(code), salt);
|
||||
const sealed = wrapDek(dek, kek);
|
||||
|
||||
// Simulating the recovery flow: user re-types the code (with dashes, mixed case)
|
||||
const reKek = deriveKey(normalizeRecoveryCode(code.toLowerCase()), salt);
|
||||
const back = unwrapDek(sealed, reKek);
|
||||
expect(bytesEqual(dek, back)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth verifier separation', () => {
|
||||
test('auth verifier and KEK differ even with the same password', () => {
|
||||
const password = 'shared password ok';
|
||||
const kekSalt = generateSalt();
|
||||
const authSalt = generateSalt();
|
||||
|
||||
const kek = deriveKey(password, kekSalt);
|
||||
const verifier = deriveAuthVerifier(password, authSalt);
|
||||
|
||||
// Verifier is base64 of 32 bytes; if we accidentally re-used the KEK salt,
|
||||
// verifier would be base64(kek). Sanity-check they don't match.
|
||||
expect(verifier).not.toBe(bytesToBase64(kek));
|
||||
});
|
||||
|
||||
test('same password + same auth salt is deterministic', () => {
|
||||
const password = 'pw';
|
||||
const authSalt = generateSalt();
|
||||
expect(deriveAuthVerifier(password, authSalt)).toBe(
|
||||
deriveAuthVerifier(password, authSalt),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('private activity payload', () => {
|
||||
test('encrypt then decrypt returns the original payload', () => {
|
||||
const dek = generateDek();
|
||||
const payload = {
|
||||
title: 'Skitur til Sognsvann',
|
||||
tags: ['ski', 'oslomarka'],
|
||||
loc_label: 'Sognsvann',
|
||||
loc_lat: 59.9744,
|
||||
loc_lng: 10.7338,
|
||||
scheduled_at: 1739000000,
|
||||
};
|
||||
const sealed = encryptPayload(payload, dek);
|
||||
expect(decryptPayload(sealed, dek)).toEqual(payload);
|
||||
});
|
||||
|
||||
test('optional fields round-trip', () => {
|
||||
const dek = generateDek();
|
||||
const payload = { title: 'Bare lese en bok', tags: [] };
|
||||
expect(decryptPayload(encryptPayload(payload, dek), dek)).toEqual(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end signup → password change → unlock', () => {
|
||||
test('changing the password preserves activity ciphertexts', () => {
|
||||
// Signup
|
||||
const dek = generateDek();
|
||||
const oldKek = deriveKey('old password', generateSalt());
|
||||
let wrappedPw = wrapDek(dek, oldKek);
|
||||
|
||||
// Encrypt an activity under DEK (server-side stored ciphertext stays put)
|
||||
const sealedActivity = encryptPayload(
|
||||
{ title: 'Lese om vintervedlikehold', tags: ['hus'] },
|
||||
dek,
|
||||
);
|
||||
|
||||
// Password change: unwrap with old KEK, derive new KEK, re-wrap DEK
|
||||
const dekBack = unwrapDek(wrappedPw, oldKek);
|
||||
expect(bytesEqual(dek, dekBack)).toBe(true);
|
||||
const newSalt = generateSalt();
|
||||
const newKek = deriveKey('new password', newSalt);
|
||||
wrappedPw = wrapDek(dekBack, newKek);
|
||||
|
||||
// Activity ciphertext is untouched — it still decrypts under the same DEK
|
||||
const unlocked = unwrapDek(wrappedPw, newKek);
|
||||
const payload = decryptPayload(sealedActivity, unlocked);
|
||||
expect(payload.title).toBe('Lese om vintervedlikehold');
|
||||
});
|
||||
|
||||
test('recovery unlocks even after password change', () => {
|
||||
const dek = generateDek();
|
||||
const code = generateRecoveryCode();
|
||||
const recSalt = generateSalt();
|
||||
const wrappedRec = wrapDek(dek, deriveKey(normalizeRecoveryCode(code), recSalt));
|
||||
|
||||
// Password is rotated: unwrap with current KEK, re-wrap with a new KEK.
|
||||
// The recovery wrap is intentionally never touched by this flow.
|
||||
const pwSalt1 = generateSalt();
|
||||
const kek1 = deriveKey('pw1', pwSalt1);
|
||||
let wrappedPw = wrapDek(dek, kek1);
|
||||
|
||||
const dekBack = unwrapDek(wrappedPw, kek1);
|
||||
const pwSalt2 = generateSalt();
|
||||
const kek2 = deriveKey('pw2', pwSalt2);
|
||||
wrappedPw = wrapDek(dekBack, kek2);
|
||||
|
||||
// Password path still works after the change…
|
||||
expect(bytesEqual(unwrapDek(wrappedPw, kek2), dek)).toBe(true);
|
||||
// …and the recovery wrap is unaffected.
|
||||
const recovered = unwrapDek(wrappedRec, deriveKey(normalizeRecoveryCode(code), recSalt));
|
||||
expect(bytesEqual(dek, recovered)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64 helpers', () => {
|
||||
test('round-trip', () => {
|
||||
const b = new Uint8Array([0, 1, 2, 250, 255]);
|
||||
expect(bytesEqual(base64ToBytes(bytesToBase64(b)), b)).toBe(true);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue