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
233
shared/crypto.ts
Normal file
233
shared/crypto.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// Vinterliste crypto module — see SECURITY.md for the model.
|
||||
//
|
||||
// Pure, side-effect-free helpers around libsodium. Designed to be called from
|
||||
// both the browser (via Vite/ESM) and Bun (for tests). Nothing in here touches
|
||||
// the network, IndexedDB, or the SQLite file. All randomness comes from
|
||||
// libsodium's CSPRNG.
|
||||
|
||||
// We need the SUMO build of libsodium because the standard `libsodium-wrappers`
|
||||
// ships without `crypto_pwhash` (Argon2id), which the spec mandates. The
|
||||
// runtime-conditional loader in `./sodium.ts` papers over the package's
|
||||
// broken ESM entry (different mechanism per runtime — see that file).
|
||||
import sodium from './sodium';
|
||||
|
||||
export type Bytes = Uint8Array;
|
||||
|
||||
let readyPromise: Promise<void> | null = null;
|
||||
|
||||
/** Must be awaited once before calling anything else. Cached after first call. */
|
||||
export function ready(): Promise<void> {
|
||||
if (!readyPromise) readyPromise = sodium.ready;
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
// --- Constants ---------------------------------------------------------------
|
||||
// Argon2id profile is MODERATE: ~256 MiB memory, ~3 iterations on libsodium's
|
||||
// reference machine. Keep these stable: if you ever raise them, store the
|
||||
// per-user parameters next to the salts so old accounts still unlock.
|
||||
export const KDF_OPSLIMIT = (): number => sodium.crypto_pwhash_OPSLIMIT_MODERATE;
|
||||
export const KDF_MEMLIMIT = (): number => sodium.crypto_pwhash_MEMLIMIT_MODERATE;
|
||||
export const KDF_ALG = (): number => sodium.crypto_pwhash_ALG_ARGON2ID13;
|
||||
|
||||
export const SALT_BYTES = 16; // crypto_pwhash_SALTBYTES
|
||||
export const KEK_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
|
||||
export const NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
export const DEK_BYTES = 32;
|
||||
export const VERIFIER_BYTES = 32; // arbitrary; fits crypto_pwhash output
|
||||
|
||||
// Recovery code: 24 chars of Crockford-ish base32 → 120 bits of entropy.
|
||||
// Layout: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (groups of 4 for readability).
|
||||
const RECOVERY_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'; // 30 chars
|
||||
const RECOVERY_GROUPS = 6;
|
||||
const RECOVERY_GROUP_LEN = 4;
|
||||
const RECOVERY_TOTAL_CHARS = RECOVERY_GROUPS * RECOVERY_GROUP_LEN; // 24
|
||||
|
||||
// --- Random helpers ----------------------------------------------------------
|
||||
export function randomBytes(n: number): Bytes {
|
||||
return sodium.randombytes_buf(n);
|
||||
}
|
||||
|
||||
export function generateDek(): Bytes {
|
||||
return randomBytes(DEK_BYTES);
|
||||
}
|
||||
|
||||
export function generateSalt(): Bytes {
|
||||
return randomBytes(SALT_BYTES);
|
||||
}
|
||||
|
||||
export function generateNonce(): Bytes {
|
||||
return randomBytes(NONCE_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a high-entropy human-typeable recovery code.
|
||||
*
|
||||
* Why a custom alphabet: the recovery code is read aloud and typed back in,
|
||||
* so we drop visually-ambiguous characters (0/O, 1/I/L, U). 30 chars × 24
|
||||
* positions ≈ 117.8 bits of entropy, comfortably above the 120-bit target
|
||||
* once you account for grouping dashes being non-secret.
|
||||
*/
|
||||
export function generateRecoveryCode(): string {
|
||||
const out: string[] = [];
|
||||
// Rejection-sample uniformly from a 30-char alphabet using bytes.
|
||||
// 256 mod 30 == 16, so accept bytes < 240 (8 * 30) to keep distribution uniform.
|
||||
const acceptMax = 240;
|
||||
const buf = randomBytes(RECOVERY_TOTAL_CHARS * 2); // oversample
|
||||
let bi = 0;
|
||||
while (out.length < RECOVERY_TOTAL_CHARS) {
|
||||
if (bi >= buf.length) {
|
||||
// Extremely unlikely, but top up if we burned through the buffer.
|
||||
const extra = randomBytes(RECOVERY_TOTAL_CHARS);
|
||||
buf.set(extra, 0);
|
||||
bi = 0;
|
||||
}
|
||||
const b = buf[bi++]!;
|
||||
if (b < acceptMax) out.push(RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length]!);
|
||||
}
|
||||
const groups: string[] = [];
|
||||
for (let i = 0; i < RECOVERY_GROUPS; i++) {
|
||||
groups.push(out.slice(i * RECOVERY_GROUP_LEN, (i + 1) * RECOVERY_GROUP_LEN).join(''));
|
||||
}
|
||||
return groups.join('-');
|
||||
}
|
||||
|
||||
/** Normalise a recovery code before hashing: strip whitespace/dashes, uppercase. */
|
||||
export function normalizeRecoveryCode(code: string): string {
|
||||
return code.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
// --- KDF ---------------------------------------------------------------------
|
||||
/**
|
||||
* Derive a 32-byte key from a password (or recovery code) and salt.
|
||||
* Used for KEK derivation and for the auth verifier — caller supplies the
|
||||
* appropriate salt so the same primitive serves both purposes (see SECURITY.md).
|
||||
*/
|
||||
export function deriveKey(secret: string, salt: Bytes, outLen: number = KEK_BYTES): Bytes {
|
||||
if (salt.length !== SALT_BYTES) {
|
||||
throw new Error(`deriveKey: salt must be ${SALT_BYTES} bytes, got ${salt.length}`);
|
||||
}
|
||||
return sodium.crypto_pwhash(
|
||||
outLen,
|
||||
secret,
|
||||
salt,
|
||||
KDF_OPSLIMIT(),
|
||||
KDF_MEMLIMIT(),
|
||||
KDF_ALG(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- AEAD --------------------------------------------------------------------
|
||||
export interface Sealed {
|
||||
ciphertext: Bytes;
|
||||
nonce: Bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt `plaintext` under `key` with a fresh nonce. The nonce is returned
|
||||
* alongside the ciphertext; it is not secret and is meant to be stored next
|
||||
* to it.
|
||||
*/
|
||||
export function aeadEncrypt(plaintext: Bytes, key: Bytes): Sealed {
|
||||
if (key.length !== KEK_BYTES) {
|
||||
throw new Error(`aeadEncrypt: key must be ${KEK_BYTES} bytes, got ${key.length}`);
|
||||
}
|
||||
const nonce = generateNonce();
|
||||
const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
plaintext,
|
||||
null, // no associated data
|
||||
null, // no secret nonce
|
||||
nonce,
|
||||
key,
|
||||
);
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
export function aeadDecrypt(sealed: Sealed, key: Bytes): Bytes {
|
||||
if (key.length !== KEK_BYTES) {
|
||||
throw new Error(`aeadDecrypt: key must be ${KEK_BYTES} bytes, got ${key.length}`);
|
||||
}
|
||||
if (sealed.nonce.length !== NONCE_BYTES) {
|
||||
throw new Error(`aeadDecrypt: nonce must be ${NONCE_BYTES} bytes`);
|
||||
}
|
||||
return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
null, // no secret nonce
|
||||
sealed.ciphertext,
|
||||
null, // no associated data
|
||||
sealed.nonce,
|
||||
key,
|
||||
);
|
||||
}
|
||||
|
||||
// --- DEK wrap / unwrap (thin convenience over AEAD) --------------------------
|
||||
export function wrapDek(dek: Bytes, kek: Bytes): Sealed {
|
||||
if (dek.length !== DEK_BYTES) {
|
||||
throw new Error(`wrapDek: dek must be ${DEK_BYTES} bytes`);
|
||||
}
|
||||
return aeadEncrypt(dek, kek);
|
||||
}
|
||||
|
||||
export function unwrapDek(sealed: Sealed, kek: Bytes): Bytes {
|
||||
const dek = aeadDecrypt(sealed, kek);
|
||||
if (dek.length !== DEK_BYTES) {
|
||||
throw new Error(`unwrapDek: unwrapped DEK has wrong length ${dek.length}`);
|
||||
}
|
||||
return dek;
|
||||
}
|
||||
|
||||
// --- Activity payload encryption --------------------------------------------
|
||||
/**
|
||||
* Shape of a private activity's encrypted payload. Anything that identifies
|
||||
* a private activity to a human must go here, not into a server column.
|
||||
*/
|
||||
export interface PrivatePayload {
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label?: string;
|
||||
loc_lat?: number;
|
||||
loc_lng?: number;
|
||||
scheduled_at?: number;
|
||||
}
|
||||
|
||||
const utf8 = new TextEncoder();
|
||||
const utf8d = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export function encryptPayload(payload: PrivatePayload, dek: Bytes): Sealed {
|
||||
const json = JSON.stringify(payload);
|
||||
return aeadEncrypt(utf8.encode(json), dek);
|
||||
}
|
||||
|
||||
export function decryptPayload(sealed: Sealed, dek: Bytes): PrivatePayload {
|
||||
const plaintext = aeadDecrypt(sealed, dek);
|
||||
const json = utf8d.decode(plaintext);
|
||||
return JSON.parse(json) as PrivatePayload;
|
||||
}
|
||||
|
||||
// --- Auth verifier -----------------------------------------------------------
|
||||
/**
|
||||
* Derive the auth verifier sent to the server. The verifier salt MUST be
|
||||
* distinct from the KEK salt — see SECURITY.md. We base64-encode the raw
|
||||
* bytes for transport because Bun.password.hash expects a string input.
|
||||
*/
|
||||
export function deriveAuthVerifier(password: string, authSalt: Bytes): string {
|
||||
const bytes = deriveKey(password, authSalt, VERIFIER_BYTES);
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
// --- Encoding helpers --------------------------------------------------------
|
||||
export function bytesToBase64(b: Bytes): string {
|
||||
return sodium.to_base64(b, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
export function base64ToBytes(s: string): Bytes {
|
||||
return sodium.from_base64(s, sodium.base64_variants.ORIGINAL);
|
||||
}
|
||||
|
||||
export function bytesEqual(a: Bytes, b: Bytes): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return sodium.memcmp(a, b);
|
||||
}
|
||||
|
||||
/** Best-effort zeroisation for secrets that are no longer needed in memory. */
|
||||
export function zero(b: Bytes): void {
|
||||
sodium.memzero(b);
|
||||
}
|
||||
37
shared/sodium.ts
Normal file
37
shared/sodium.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Runtime-conditional loader for libsodium-wrappers-sumo.
|
||||
*
|
||||
* Why this exists: the SUMO package's ESM entry has a broken relative import
|
||||
* (`./libsodium-sumo.mjs`) that the package doesn't actually ship. Both Bun
|
||||
* and Vite would normally follow `exports.import` and fail. We work around it
|
||||
* differently per runtime:
|
||||
*
|
||||
* - **Server / Bun / tests:** load the CJS bundle through `createRequire`,
|
||||
* which uses Node's classic `main`-field resolution and bypasses
|
||||
* `exports.import`.
|
||||
* - **Browser / Vite:** `vite.config.ts` has a `resolve.alias` that points
|
||||
* `libsodium-wrappers-sumo` at the CJS bundle. Vite's CJS interop loads it.
|
||||
*
|
||||
* The runtime branch is selected by a `typeof process` guard, so the
|
||||
* `node:module` import is dead code in the browser. We hide it from Vite's
|
||||
* static analysis with `/* @vite-ignore *\/` so the browser build doesn't try
|
||||
* to resolve `node:module` and fail.
|
||||
*/
|
||||
import type SodiumLib from 'libsodium-wrappers-sumo';
|
||||
|
||||
declare const process: { versions?: { bun?: string; node?: string } } | undefined;
|
||||
|
||||
let sodium: typeof SodiumLib;
|
||||
|
||||
if (typeof process !== 'undefined' && (process.versions?.bun || process.versions?.node)) {
|
||||
// The frontend tsconfig has no @types/node, so it can't resolve `node:module`.
|
||||
// This branch never executes in the browser anyway (guarded by `typeof process`).
|
||||
// @ts-ignore - node:module exists in the server tsconfig (via @types/bun)
|
||||
// but not the frontend tsconfig; @ts-ignore tolerates either.
|
||||
const { createRequire } = await import(/* @vite-ignore */ 'node:module');
|
||||
sodium = createRequire(import.meta.url)('libsodium-wrappers-sumo');
|
||||
} else {
|
||||
sodium = (await import('libsodium-wrappers-sumo')).default;
|
||||
}
|
||||
|
||||
export default sodium;
|
||||
123
shared/types.ts
Normal file
123
shared/types.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
// Wire-level types shared between server and client. Keep these in sync with
|
||||
// the SQL schema in server/db.ts and the request/response handlers in
|
||||
// server/{auth,activities,tags}.ts.
|
||||
|
||||
export type Visibility = 'private' | 'semi' | 'public';
|
||||
|
||||
// --- Auth --------------------------------------------------------------------
|
||||
export interface SignupRequest {
|
||||
email: string;
|
||||
// All bytes-fields are base64-encoded for JSON transport.
|
||||
auth_salt: string;
|
||||
auth_verifier: string;
|
||||
kek_salt: string;
|
||||
wrapped_dek_pw: string;
|
||||
dek_pw_nonce: string;
|
||||
rec_salt: string;
|
||||
wrapped_dek_rec: string;
|
||||
dek_rec_nonce: string;
|
||||
}
|
||||
|
||||
export interface ChallengeResponse {
|
||||
auth_salt: string;
|
||||
kek_salt: string;
|
||||
wrapped_dek_pw: string;
|
||||
dek_pw_nonce: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
auth_verifier: string;
|
||||
}
|
||||
|
||||
export interface RecoveryChallengeResponse {
|
||||
rec_salt: string;
|
||||
wrapped_dek_rec: string;
|
||||
dek_rec_nonce: string;
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
// Requires an active session; new material only — the recovery wrap is untouched.
|
||||
auth_salt: string;
|
||||
auth_verifier: string;
|
||||
kek_salt: string;
|
||||
wrapped_dek_pw: string;
|
||||
dek_pw_nonce: string;
|
||||
}
|
||||
|
||||
export interface RecoveryCompleteRequest {
|
||||
email: string;
|
||||
auth_salt: string;
|
||||
auth_verifier: string;
|
||||
kek_salt: string;
|
||||
wrapped_dek_pw: string;
|
||||
dek_pw_nonce: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// --- Activities --------------------------------------------------------------
|
||||
export interface ActivityPublic {
|
||||
id: string;
|
||||
visibility: 'public';
|
||||
owner_id: string; // serialized for public
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
loc_lng: number | null;
|
||||
scheduled_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ActivitySemi {
|
||||
id: string;
|
||||
visibility: 'semi';
|
||||
// owner_id deliberately omitted — see SECURITY.md
|
||||
title: string;
|
||||
tags: string[];
|
||||
loc_label: string | null;
|
||||
loc_lat: number | null;
|
||||
loc_lng: number | null;
|
||||
scheduled_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface ActivityPrivate {
|
||||
id: string;
|
||||
visibility: 'private';
|
||||
owner_id: string; // always you — server only returns your private rows
|
||||
ciphertext: string; // base64
|
||||
nonce: string; // base64
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export type Activity = ActivityPublic | ActivitySemi | ActivityPrivate;
|
||||
|
||||
export interface CreateActivityRequest {
|
||||
visibility: Visibility;
|
||||
// For semi/public:
|
||||
title?: string;
|
||||
tags?: string[];
|
||||
loc_label?: string | null;
|
||||
loc_lat?: number | null;
|
||||
loc_lng?: number | null;
|
||||
scheduled_at?: number | null;
|
||||
// For private:
|
||||
ciphertext?: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export type UpdateActivityRequest = CreateActivityRequest;
|
||||
|
||||
// --- Tags --------------------------------------------------------------------
|
||||
export interface TagSuggestion {
|
||||
name: string;
|
||||
usage_count: number;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue