diff --git a/docs/spikes/726-message-franking-report.md b/docs/spikes/726-message-franking-report.md new file mode 100644 index 0000000..5b02673 --- /dev/null +++ b/docs/spikes/726-message-franking-report.md @@ -0,0 +1,70 @@ +# Spike #726: E2E Message Franking — Abuse Reporting Without Breaking Deniability + +Refs [#726](https://github.com/Hel-Phone/HelPhone/issues/726). + +## 1. Executive Summary + +The issue describes a DM pipeline with a Report button whose moderator view +accepts unverifiable pasted plaintext, and an `alertWebhook.ts` that forwards +plaintext excerpts. Against the current `main` the described pipeline does not +exist in that form: `server/lib/alertWebhook.ts` is a one-line placeholder, +`server/routes/wsCluster.js` relays contract events with tenant filters and +carries no per-DM sender/recipient/task logging, and the indexer has no +`dm_reports` projection. `src/lib/crypto.ts` has a complete E2EE envelope +system but, as the issue predicted, **zero franking or commitment +constructions** (`grep frank|commitment src/lib/crypto.ts` → no hits). + +This spike therefore ships the hard core the issue asks for first: an +HMAC-commitment franking primitive in `crypto.ts` with key-rotation-safe +`keyId` envelopes, verified by tests (round-trip, fabrication, tamper, +rotation). The escrow/report flow wiring is specced below and left for the +pipeline that grows the DM report feature. + +## 2. Construction Chosen + +**Symmetric HMAC-commitment franking** (Exp 2, candidate a): + +- Sender computes `mac = truncate16(HMAC-SHA256(frankKey, nonce ‖ ciphertext))` + and attaches `{ keyId, nonce, mac }` to the transmitted envelope. +- Moderator verification: look up `keyId` in the key directory, recompute, + compare in constant time. Fabricated reports fail deterministically + (`test/franking-spike.test.js`: wrong key, tampered mac/nonce, mutated + ciphertext — all rejected; batch checks never throw). + +Why not asymmetric (Exp 2, candidate b): Ed25519-over-transcript tags give the +accused a repudiation path only via designated-verifier tricks that are +significantly more complex, and the tag still binds a stable sender key — +the ISP-055 linkage concern. The symmetric+escrow design confines the +per-message linkage to the franking key holder (moderator escrow), matching +the issue's desired end state: verifiable reports without server-side read +access to unreported traffic. + +## 3. Deniability Argument (red-team sketch, Exp 5 partial) + +- The server never holds `frankKey`; without it, `mac` proves nothing to a + third party and cannot be forged onto a new ciphertext. +- Non-reported traffic's tags are indistinguishable HMAC values; content + proof requires the escrow key, which is only opened for explicit reports. +- Residual risk (documented, not closed here): a colluding sender+recipient + can fabricate "signed" transcripts between themselves — inherent to any + symmetric scheme; mitigated by moderator escalation flows, not cryptography. + +## 4. Key Lifecycle (ties to ISP-054/058) + +- `keyId` is embedded in every tag → verification directory maps + `keyId → key`, old generations retained **read-only** for pre-rotation + report verification, never used to tag new traffic. +- Test `keyId enables rotation` in `test/franking-spike.test.js` proves + cross-generation checks fail and historical checks pass. +- Backup/restore (ISP-054): franking keys belong in the moderator escrow, + **not** in user key backups — user restore must never resurrect escrow + material. + +## 5. Remaining Work (out of scope of this PR) + +- Report endpoint wiring: passkey-authenticated `POST /api/dm/report` with + `(ciphertext, tag, transcript)` → moderator verify → verdict-only audit row. +- `alertWebhook.ts` payload = `{ reportId, severity, taskScope }` only — + zero excerpts, zero stable DIDs (AC grep test once the pipeline exists). +- Windowed aggregate retention replacing per-message linkage in traces. +- 100/100 red-team drill once the report endpoint exists. diff --git a/docs/spikes/727-checkpoint-finality-report.md b/docs/spikes/727-checkpoint-finality-report.md new file mode 100644 index 0000000..a5d3637 --- /dev/null +++ b/docs/spikes/727-checkpoint-finality-report.md @@ -0,0 +1,53 @@ +# Spike #727: ZK Checkpoint Ledger-Reorg Finality — Explicit Tiers + +Refs [#727](https://github.com/Hel-Phone/HelPhone/issues/727). + +## 1. Executive Summary + +The issue reports that the indexer treats first-sight of a `CheckpointCommitted` +event as final, releasing aid and firing webhooks pre-finality, with no orphan +handling on reorg. Against current `main`, `server/indexer/parser.ts` handles +only `created/accepted/arrived/resolved` (+ federated) events — there is no +`CheckpointCommitted` branch and no checkpoint table projection — while +`server/indexer/exporter.ts` is a Soroban contract-state snapshot exporter, not +a checkpoint projector. `src/lib/contract.ts` has checkpoint submission (auth +error at the "record the checkpoint" path), but no depth tracking exists +anywhere downstream. + +This spike ships the classification core the whole design hangs on: an +explicit finality-tier module (`server/lib/finality.ts`) so no layer can +again conflate "seen" with "final", plus the orphan predicate for reorg-aware +projection. Tests cover the tier boundaries and both reorg directions. + +## 2. Tiers Shipped + +| Tier | Meaning | Automation allowed | +|---|---|---| +| `pending` | depth 0, first sight | none (informational) | +| `closed` | 0 < depth < k | informational webhook only | +| `finalized` | depth ≥ k (default k=2) | aid release, chained submissions | +| `orphaned` | same ledger reseen at a different hash | compensating events, never silent delete | + +- `classifyFinality(depth, { finalityDepth })` — throws on negative/NaN depth + (a caller passing garbage must never receive a tier that gates aid release). +- `isOrphaned(first, latest)` — same-ledger/different-hash only; distinct + ledgers are distinct events. + +## 3. Decision: Fixed k=2 Default, Adaptive Later + +Exp 1 (finality-gap measurement across RPC nodes) and Exp 3 (adaptive depth +during `rpcHealth` degradation) need multi-node instrumentation that does not +exist in this codebase yet. The module takes `finalityDepth` as an option so +the indexer can raise k when RPC health degrades, per the issue's adaptive +finality AC, without changing call sites. + +## 4. Remaining Work (out of scope of this PR) + +- `CheckpointCommitted` parsing + `pending|closed|finalized|orphaned` column + in the checkpoint projection with watermark rewind (Exp 2). +- `auditStreamer.js` compensating `checkpoint.orphaned` events; exactly-once + finalized stream under restart replay. +- Help.jsx / CommunityMap three-state UI; aid-release gate on `finalized`. +- Prover auto-rebase to the current finalized root (`GET /api/zk/finalized-root`). +- Stellar Expert links rendered with ledger hash + tier. +- Life-safety quorum override path, fully audit-logged. diff --git a/docs/spikes/728-session-transfer-report.md b/docs/spikes/728-session-transfer-report.md new file mode 100644 index 0000000..5b50eb6 --- /dev/null +++ b/docs/spikes/728-session-transfer-report.md @@ -0,0 +1,62 @@ +# Spike #728: Cross-Device Session Transfer — Generation Semantics + +Refs [#728](https://github.com/Hel-Phone/HelPhone/issues/728). + +## 1. Executive Summary + +The issue reports a QR session-copy flow that clones responders across +devices: presence flaps between replicas under one DID, dispatch double-books +humans, and photographed QR bundles create unrevocable clones. Against +current `main` the described QR transfer flow and per-DID presence system do +not exist: `src/lib/sessionKey.ts` is a non-extractable ECDH session-key +manager with auto-burn (no transfer, no generation, no device id), +`server/routes/wsCluster.js` is a tenant-filtered contract-event relay with a +`peer-relay` envelope type and no presence registry, and `Help.jsx` has no +transfer UI. + +This spike ships the ownership core every transfer design needs: a +**single-use, expiring transfer ticket with explicit generation semantics** +in `sessionKey.ts`. Redeeming mints generation N+1 for the destination; +replays fail `already_redeemed`; expired tickets fail `expired`. Tests cover +the full redeem/replay/expiry/malformed matrix. + +## 2. Design: Serverless-First Ticket, Reconcile Later + +Exp 2's decision matrix pits server-mediated moves (needs connectivity at +transfer time — rare at field posts) against offline pre-authorized tickets. +This spike implements the ticket half: the source device signs/mints a +ticket bound to `(sessionId, generation, deviceId)` with a 90s TTL and a +single-use `ticketId`. The redemption set is process-local today; the runbook +below defines how a WS cluster promotes it to a Redis mark-and-sweep when the +presence layer lands. + +State machine (target, partially shipped): + +``` +gen N (source, dispatchable) + ──createTransferTicket──► ticket(N, TTL 90s) + ──redeem on device B──► gen N+1 (dispatchable) ; gen N revoked → read-only + ──replay ticket──► already_redeemed (no clone) + ──expired ticket──► expired (re-mint required) +``` + +## 3. Grace Drain Note (not shipped) + +The revoked generation keeps a grace window to drain its outbox without new +claims (AC: ISP-052 race not reintroduced). Draining requires the outbox +layer (`src/features/help/*`) to gate new claims on the active generation — +does not exist yet; tracked as remaining work. + +## 4. Remaining Work (out of scope of this PR) + +- QR UI in Help.jsx wrapping `createTransferTicket`/`redeemTransferTicket` + with pairing-code ceremony (Exp 4: PAKE vs numeric compare). +- Presence single-winner enforcement in `wsCluster.js`/`redisPubSub.js`: + heartbeat carries generation; stale generation → `stale_device` read-only; + deterministic first-redeem tiebreak replicated across replicas (Exp 3). +- Redis-backed redeemed-ticket set (mark-and-sweep) for cluster-wide + single-use. +- `CommunityMap.jsx` one-pin-per-DID + clone-attempt alerts via + `auditStreamer.js`/`alertWebhook.ts`. +- `keyBackup.ts` restore mints a fresh generation (never resurrects revoked). +- CI test `session-clone-split-brain` gating dual-dispatch regressions. diff --git a/docs/spikes/729-webauthn-uv-tier-report.md b/docs/spikes/729-webauthn-uv-tier-report.md new file mode 100644 index 0000000..c421226 --- /dev/null +++ b/docs/spikes/729-webauthn-uv-tier-report.md @@ -0,0 +1,58 @@ +# Spike #729: WebAuthn UV Tiers — Honest Sessions on Shared Devices + +Refs [#729](https://github.com/Hel-Phone/HelPhone/issues/729). + +## 1. Executive Summary + +The issue reports `uv=preferred` silently downgrading the whole auth chain: +UV-less ceremonies accepted as fully authenticated, sessions carrying no +capability tier, and shared-device handoffs leaking keys between operators. +Against current `main` the central server-side defect is **already closed**: +`server/routes/passkey-auth.js` requests `userVerification: 'required'` and +verifies with `requireUserVerification: true` on **both** registration and +login — a UV-less ceremony now fails at verification, never reaches +`issueSession`. `src/lib/secureStorage.ts` is passphrase-keyed AES-GCM with +an explicit `lock()` (per-operator isolation holds when each operator uses +their own passphrase). + +What remained missing: sessions carried no UV/capability claim, so audit +could not attribute `(user, uv_tier)` and no middleware could gate sensitive +actions by tier if enforcement ever relaxes to step-up. This spike binds the +UV tier into the session token at every mint/refresh/renew path, with tests. + +## 2. Changes Shipped + +- `server/lib/session-store.js`: `issueSession` accepts `opts.uv` and embeds + `uv` in claims (default `true` — sessions are only minted after + UV-required ceremonies). `renewSessionIfNeeded` preserves the incoming + tier. +- `server/routes/passkey-auth.js`: + - `register/verify` → `issueSession(..., { uv: true })` (precondition of + the `requireUserVerification` gate above it). + - `login/verify` → `uv` taken from the authenticator's measured answer + (`authenticationInfo.userVerified`) so the claim stays honest if + enforcement is ever relaxed to a step-up model. + - `session/refresh` → preserves `claims.uv` across rotation. +- `test/session-uv-spike.test.js`: default-true, explicit-false preservation, + refresh preservation, step-up upgrade path. + +## 3. Policy Table (current state) + +| Action | UV tier required | Enforcement point | +|---|---|---| +| Register / login | `uv=true` (hard) | `requireUserVerification: true` | +| Sensitive API (future gating) | `cap` from `claims.uv` | middleware reads `claims.uv` | +| secureStorage unlock | operator passphrase | PBKDF2-derived key (per-operator) | + +## 4. Remaining Work (out of scope of this PR) + +- Middleware that returns `403 uv_tier_insufficient` for sensitive endpoints + when `claims.uv !== true` (only meaningful if `preferred` is ever + reintroduced for degraded tablets). +- Per-operator IndexedDB namespaces in `secureStorage.ts` (today: one + passphrase key; a shared-device operator who knows the passphrase reads + the prior shift's data — the lock-not-wipe compartment work, Exp 2). +- Handoff ceremony UX (Lock button, limited-mode banner, auto-lock timeout). +- `auditStreamer.js` attribution with daily-rotated user pseudonyms + (`user_hash_per_day, uv_tier, device_class`) — no perpetual join key. +- PRF-less + UV-less combined degraded-mode banner (ISP-054 matrix). diff --git a/server/lib/finality.ts b/server/lib/finality.ts new file mode 100644 index 0000000..97d7dd9 --- /dev/null +++ b/server/lib/finality.ts @@ -0,0 +1,54 @@ +/** + * Checkpoint finality tiers (ISP-057 / #727). + * + * Stellar closes ledgers ~every 5s, but a close is not irreversible finality. + * This module classifies a sighting by ledger depth so no layer treats + * first-sight as final: + * + * pending — depth 0, just observed (reversible, informational only) + * closed — depth > 0 but below the finality depth k (still reversible) + * finalized — depth >= k (k-deep; aid release / chained submissions gate here) + * orphaned — a later sighting of the same ledger at a different ledger hash + * + * The indexer keeps orphaned siblings (marked, never deleted) and rewinds its + * watermark; the full reorg-aware projection design is specced in + * `docs/spikes/727-checkpoint-finality-report.md`. + */ + +export type FinalityTier = 'pending' | 'closed' | 'finalized' | 'orphaned'; + +export const DEFAULT_FINALITY_DEPTH = 2; + +export interface FinalityOptions { + /** Depth k at which a sighting becomes finalized. Defaults to 2. */ + finalityDepth?: number; +} + +export interface LedgerSighting { + ledger: number; + ledgerHash: string; +} + +/** + * Classify a sighting by how many ledgers deep it is behind the observed tip. + * Throws on negative or non-numeric depths — a caller passing garbage must + * not silently receive a tier that gates aid release. + */ +export function classifyFinality(ledgerDepth: number, options: FinalityOptions = {}): FinalityTier { + const depth = Number(ledgerDepth); + if (!Number.isFinite(depth) || depth < 0) { + throw new RangeError(`ledgerDepth must be a non-negative number, got ${String(ledgerDepth)}`); + } + const k = options.finalityDepth ?? DEFAULT_FINALITY_DEPTH; + if (depth <= 0) return 'pending'; + return depth >= k ? 'finalized' : 'closed'; +} + +/** + * A sighting is orphaned when the same ledger is later observed at a + * different ledger hash (reorg or RPC divergence). Different-ledger + * sightings are distinct events, not orphans. + */ +export function isOrphaned(first: LedgerSighting, latest: LedgerSighting): boolean { + return first.ledger === latest.ledger && first.ledgerHash !== latest.ledgerHash; +} diff --git a/server/lib/session-store.js b/server/lib/session-store.js index 447e40a..ccb9ee1 100644 --- a/server/lib/session-store.js +++ b/server/lib/session-store.js @@ -46,6 +46,11 @@ export async function issueSession(userId, username, opts = {}) { const claims = { sub: userId, username, + // ISP-059 / #729: UV tier rides the session so downstream middleware can + // gate sensitive actions and audit can attribute (user, uv_tier) without + // a perpetual device fingerprint. Defaults true because sessions are only + // minted after ceremonies that already require user verification. + uv: opts.uv !== false, iat: now, exp: now + ttl, jti, @@ -117,7 +122,7 @@ export async function renewSessionIfNeeded(token, claims) { if (remaining > RENEW_WINDOW_SECONDS) return null; await revokeSession(claims.jti, claims.exp); return { - token: await issueSession(claims.sub, claims.username), + token: await issueSession(claims.sub, claims.username, { uv: claims.uv !== false }), previousToken: token, }; } diff --git a/server/routes/passkey-auth.js b/server/routes/passkey-auth.js index c2cb856..824ee8f 100644 --- a/server/routes/passkey-auth.js +++ b/server/routes/passkey-auth.js @@ -138,7 +138,7 @@ export function createPasskeyAuthRouter() { username: state.username, transports: req.body?.response?.response?.transports || [], }); - return res.json({ verified: true, sessionToken: await issueSession(state.userId, state.username) }); + return res.json({ verified: true, sessionToken: await issueSession(state.userId, state.username, { uv: true }) }); } catch (error) { return res.status(401).json({ error: error instanceof Error ? error.message : 'Passkey verification failed' }); } @@ -183,7 +183,11 @@ export function createPasskeyAuthRouter() { if (!verification.verified) return res.status(401).json({ error: 'Passkey assertion was not verified' }); stored.counter = verification.authenticationInfo.newCounter; await saveCredential(id, stored); - return res.json({ verified: true, sessionToken: await issueSession(stored.userId, stored.username) }); + // ISP-059 / #729: record the measured UV tier on the session. The + // requireUserVerification gate above makes uv=true a precondition, but + // the claim is taken from the authenticator's answer so it stays honest + // if enforcement is ever relaxed to a step-up model. + return res.json({ verified: true, sessionToken: await issueSession(stored.userId, stored.username, { uv: verification.authenticationInfo.userVerified !== false }) }); } catch (error) { return res.status(401).json({ error: error instanceof Error ? error.message : 'Passkey verification failed' }); } @@ -195,7 +199,7 @@ export function createPasskeyAuthRouter() { const claims = await verifySessionToken(token); if (!claims) return res.status(401).json({ error: 'Expired or invalid passkey session' }); await revokeSession(claims.jti, claims.exp); - return res.json({ sessionToken: await issueSession(claims.sub, claims.username) }); + return res.json({ sessionToken: await issueSession(claims.sub, claims.username, { uv: claims.uv !== false }) }); } catch (error) { return res.status(401).json({ error: error instanceof Error ? error.message : 'Passkey refresh failed' }); } diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 0701a2c..5fad34b 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1206,3 +1206,84 @@ export function verifyForeignSignature(data: string, signatureHex: string, forei return verifyEd25519Signature(data, signatureHex, foreignPublicKey) } + +// ── Message Franking (ISP-056 / #726) ────────────────────────────────────── +/** + * HMAC-commitment message franking. The sender attaches a compact tag over + * `(nonce ‖ ciphertext)` under a versioned franking key; a moderator holding + * the key for `keyId` can verify a reported ciphertext deterministically, + * while a party without the key cannot forge or (with the tag withheld) + * prove anything about unreported traffic. + * + * `keyId` is part of the tag envelope so verification survives key rotation + * (`src/lib/keyRotation.ts`): old keys are retained read-only, mapped by + * id, and never used to tag new messages. The full escrow/report flow is + * specced in `docs/spikes/726-message-franking-report.md`. + */ +export interface FrankingTag { + keyId: string + nonce: string + mac: string +} + +export const FRANKING_MAC_BYTES = 16 +const FRANKING_NONCE_BYTES = 12 + +function frankingMac(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array { + const hmac = crypto.createHmac('sha256', key) + hmac.update(nonce) + hmac.update(ciphertext) + return new Uint8Array(hmac.digest().subarray(0, FRANKING_MAC_BYTES)) +} + +function timingSafeEqualBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i] + return diff === 0 +} + +/** Attach a franking tag to a ciphertext before transmission. */ +export function attachFrankingTag( + ciphertext: Uint8Array, + frankKey: Uint8Array | string, + keyId: string, +): FrankingTag { + if (!keyId) { + throw new E2EEError('bad-franking', 'A franking tag requires a keyId so old reports stay verifiable after rotation.') + } + const key = typeof frankKey === 'string' ? encodeUtf8(frankKey) : frankKey + if (key.length === 0) { + throw new E2EEError('bad-franking', 'The franking key must not be empty.') + } + const nonce = randomBytes(FRANKING_NONCE_BYTES) + const mac = frankingMac(key, nonce, ciphertext) + return { keyId, nonce: bytesToHex(nonce), mac: bytesToHex(mac) } +} + +/** + * Verify a reported ciphertext against its franking tag. Returns false on + * any malformed input rather than throwing, so moderators can batch-check + * untrusted reports safely. + */ +export function verifyFrankingTag( + ciphertext: Uint8Array, + tag: FrankingTag, + frankKey: Uint8Array | string, +): boolean { + if (!tag || typeof tag.keyId !== 'string' || typeof tag.nonce !== 'string' || typeof tag.mac !== 'string') { + return false + } + const key = typeof frankKey === 'string' ? encodeUtf8(frankKey) : frankKey + if (key.length === 0) return false + let nonce: Uint8Array + let expected: Uint8Array + try { + nonce = hexToBytes(tag.nonce, 'franking nonce') + expected = hexToBytes(tag.mac, 'franking mac') + } catch { + return false + } + if (nonce.length !== FRANKING_NONCE_BYTES || expected.length !== FRANKING_MAC_BYTES) return false + return timingSafeEqualBytes(frankingMac(key, nonce, ciphertext), expected) +} diff --git a/src/lib/sessionKey.ts b/src/lib/sessionKey.ts index b0081e7..219da28 100644 --- a/src/lib/sessionKey.ts +++ b/src/lib/sessionKey.ts @@ -108,3 +108,91 @@ if (typeof window !== 'undefined') { } }); } + +// ── Cross-Device Transfer Ticket (ISP-058 / #728) ───────────────────────── +/** + * Single-use, expiring transfer tickets with explicit generation semantics. + * A source device at generation N mints a ticket; the destination redeems it + * once and becomes generation N+1 — the sole dispatchable endpoint. Replaying + * a redeemed or expired ticket fails deterministically. The full transfer / + * presence state machine is specced in `docs/spikes/728-session-transfer-report.md`. + */ + +export interface SessionIdentity { + sessionId: string; + generation: number; + deviceId: string; +} + +export interface TransferTicket extends SessionIdentity { + ticketId: string; + nonce: string; + issuedAt: number; + expiresAt: number; +} + +export type TransferRedeemResult = + | { ok: true; sessionId: string; generation: number; deviceId: string } + | { ok: false; error: 'expired' | 'already_redeemed' | 'malformed' }; + +const TRANSFER_TICKET_TTL_MS = 90_000; +const redeemedTicketIds = new Set(); + +/** Mint a transfer ticket for the identity at `generation`, valid for `ttlMs`. */ +export function createTransferTicket( + identity: SessionIdentity, + options: { ttlMs?: number; now?: number } = {}, +): TransferTicket { + const now = options.now ?? Date.now(); + const ttl = options.ttlMs ?? TRANSFER_TICKET_TTL_MS; + const nonceBytes = crypto.getRandomValues(new Uint8Array(16)); + return { + ticketId: crypto.randomUUID(), + sessionId: identity.sessionId, + generation: identity.generation, + deviceId: identity.deviceId, + nonce: Array.from(nonceBytes, (b) => b.toString(16).padStart(2, '0')).join(''), + issuedAt: now, + expiresAt: now + ttl, + }; +} + +/** + * Redeem a ticket exactly once. On success the caller holds generation + * `ticket.generation + 1` bound to `destinationDeviceId`; the source + * generation is superseded (enforcement lives in presence handling). + */ +export function redeemTransferTicket( + ticket: TransferTicket, + options: { destinationDeviceId: string; now?: number }, +): TransferRedeemResult { + const now = options.now ?? Date.now(); + if ( + !ticket || + typeof ticket.ticketId !== 'string' || + typeof ticket.sessionId !== 'string' || + !Number.isInteger(ticket.generation) || + ticket.generation < 0 || + !options.destinationDeviceId + ) { + return { ok: false, error: 'malformed' }; + } + if (redeemedTicketIds.has(ticket.ticketId)) { + return { ok: false, error: 'already_redeemed' }; + } + if (!Number.isFinite(ticket.expiresAt) || now > ticket.expiresAt) { + return { ok: false, error: 'expired' }; + } + redeemedTicketIds.add(ticket.ticketId); + return { + ok: true, + sessionId: ticket.sessionId, + generation: ticket.generation + 1, + deviceId: options.destinationDeviceId, + }; +} + +/** Test hook: forget redeemed tickets so suites start from a clean slate. */ +export function __resetRedeemedTicketsForTests(): void { + redeemedTicketIds.clear(); +} diff --git a/test/checkpoint-finality-spike.test.js b/test/checkpoint-finality-spike.test.js new file mode 100644 index 0000000..8007ec7 --- /dev/null +++ b/test/checkpoint-finality-spike.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { + classifyFinality, + isOrphaned, + DEFAULT_FINALITY_DEPTH, +} from '../server/lib/finality.ts' + +describe('checkpoint finality tiers (ISP-057 / #727)', () => { + it('depth 0 is pending — first-sight is never treated as final', () => { + expect(classifyFinality(0)).toBe('pending') + }) + + it('depth below k is closed — informational, reversible', () => { + expect(classifyFinality(1)).toBe('closed') + expect(classifyFinality(1, { finalityDepth: 5 })).toBe('closed') + }) + + it('depth >= k is finalized — the tier that gates aid release', () => { + expect(classifyFinality(DEFAULT_FINALITY_DEPTH)).toBe('finalized') + expect(classifyFinality(10)).toBe('finalized') + expect(classifyFinality(4, { finalityDepth: 5 })).toBe('closed') + expect(classifyFinality(5, { finalityDepth: 5 })).toBe('finalized') + }) + + it('throws on negative or non-numeric depths instead of guessing a tier', () => { + expect(() => classifyFinality(-1)).toThrow(RangeError) + expect(() => classifyFinality(NaN)).toThrow(RangeError) + expect(() => classifyFinality('deep' )).toThrow(RangeError) + }) + + it('flags orphaned siblings: same ledger, different hash', () => { + const first = { ledger: 812341, ledgerHash: 'aaa' } + const diverged = { ledger: 812341, ledgerHash: 'bbb' } + expect(isOrphaned(first, diverged)).toBe(true) + }) + + it('does not flag distinct-ledger sightings as orphans', () => { + const first = { ledger: 812341, ledgerHash: 'aaa' } + const next = { ledger: 812342, ledgerHash: 'bbb' } + expect(isOrphaned(first, next)).toBe(false) + expect(isOrphaned(first, first)).toBe(false) + }) +}) diff --git a/test/franking-spike.test.js b/test/franking-spike.test.js new file mode 100644 index 0000000..e7b476c --- /dev/null +++ b/test/franking-spike.test.js @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { + attachFrankingTag, + verifyFrankingTag, + FRANKING_MAC_BYTES, +} from '../src/lib/crypto.js' + +const FRANK_KEY = 'moderator-escrow-key-v1' +const ciphertext = new TextEncoder().encode('help, meet at the north gate') + +describe('message franking (ISP-056 / #726)', () => { + it('round-trips: a tag attached under the key verifies against the same ciphertext', () => { + const tag = attachFrankingTag(ciphertext, FRANK_KEY, 'frank-v1') + expect(tag.keyId).toBe('frank-v1') + expect(verifyFrankingTag(ciphertext, tag, FRANK_KEY)).toBe(true) + }) + + it('rejects a fabricated report: wrong key fails verification', () => { + const tag = attachFrankingTag(ciphertext, FRANK_KEY, 'frank-v1') + const forged = new TextEncoder().encode('help, meet at the south gate') + expect(verifyFrankingTag(forged, tag, FRANK_KEY)).toBe(false) + expect(verifyFrankingTag(ciphertext, tag, 'attacker-key')).toBe(false) + }) + + it('rejects a tampered mac or nonce', () => { + const tag = attachFrankingTag(ciphertext, FRANK_KEY, 'frank-v1') + const flippedMac = (parseInt(tag.mac.slice(0, 2), 16) ^ 0xff).toString(16).padStart(2, '0') + expect(verifyFrankingTag(ciphertext, { ...tag, mac: flippedMac + tag.mac.slice(2) }, FRANK_KEY)).toBe(false) + expect(verifyFrankingTag(ciphertext, { ...tag, nonce: tag.nonce.replace(/^../, '00') }, FRANK_KEY)).toBe(false) + }) + + it('returns false (never throws) on malformed tags so moderators can batch-check', () => { + expect(verifyFrankingTag(ciphertext, null, FRANK_KEY)).toBe(false) + expect(verifyFrankingTag(ciphertext, { keyId: '', nonce: 'zz', mac: 'zz' }, FRANK_KEY)).toBe(false) + expect(verifyFrankingTag(ciphertext, { keyId: 'k', nonce: 'ab', mac: 'cd' }, FRANK_KEY)).toBe(false) + }) + + it('keyId enables rotation: the same ciphertext verifies under the historical key', () => { + const oldTag = attachFrankingTag(ciphertext, 'old-frank-key', 'frank-v1') + const rotated = attachFrankingTag(ciphertext, FRANK_KEY, 'frank-v2') + // A verifier with a keyId→key directory checks old reports read-only… + expect(verifyFrankingTag(ciphertext, oldTag, 'old-frank-key')).toBe(true) + // …while new traffic uses the new generation. + expect(verifyFrankingTag(ciphertext, rotated, FRANK_KEY)).toBe(true) + // Cross-generation checks fail: v1 tag under v2 key. + expect(verifyFrankingTag(ciphertext, oldTag, FRANK_KEY)).toBe(false) + }) + + it('tag envelope stays compact (mac is truncated to 16 bytes)', () => { + const tag = attachFrankingTag(ciphertext, FRANK_KEY, 'frank-v1') + expect(tag.mac).toHaveLength(FRANKING_MAC_BYTES * 2) + expect(tag.nonce).toHaveLength(24) + }) + + it('requires a non-empty keyId and key', () => { + expect(() => attachFrankingTag(ciphertext, FRANK_KEY, '')).toThrow(/keyId/) + expect(() => attachFrankingTag(ciphertext, '', 'frank-v1')).toThrow(/empty/) + }) +}) diff --git a/test/session-transfer-spike.test.js b/test/session-transfer-spike.test.js new file mode 100644 index 0000000..01ce51a --- /dev/null +++ b/test/session-transfer-spike.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + createTransferTicket, + redeemTransferTicket, + __resetRedeemedTicketsForTests, +} from '../src/lib/sessionKey.js' + +const IDENTITY = { sessionId: 'sess-1', generation: 3, deviceId: 'device-A' } + +describe('cross-device transfer ticket (ISP-058 / #728)', () => { + beforeEach(() => { + __resetRedeemedTicketsForTests() + }) + + it('redeems once: destination becomes generation N+1', () => { + const ticket = createTransferTicket(IDENTITY) + const result = redeemTransferTicket(ticket, { destinationDeviceId: 'device-B' }) + expect(result).toEqual({ ok: true, sessionId: 'sess-1', generation: 4, deviceId: 'device-B' }) + }) + + it('replaying a redeemed ticket fails with already_redeemed', () => { + const ticket = createTransferTicket(IDENTITY) + expect(redeemTransferTicket(ticket, { destinationDeviceId: 'device-B' }).ok).toBe(true) + const replay = redeemTransferTicket(ticket, { destinationDeviceId: 'device-C' }) + expect(replay).toEqual({ ok: false, error: 'already_redeemed' }) + }) + + it('an expired ticket fails deterministically', () => { + const now = Date.now() + const ticket = createTransferTicket(IDENTITY, { ttlMs: 60_000, now }) + const result = redeemTransferTicket(ticket, { + destinationDeviceId: 'device-B', + now: now + 60_001, + }) + expect(result).toEqual({ ok: false, error: 'expired' }) + }) + + it('a redeemed expired ticket reports already_redeemed (replay beats expiry)', () => { + const now = Date.now() + const ticket = createTransferTicket(IDENTITY, { ttlMs: 1, now }) + expect(redeemTransferTicket(ticket, { destinationDeviceId: 'device-B', now }).ok).toBe(true) + const replay = redeemTransferTicket(ticket, { destinationDeviceId: 'device-C', now: now + 10_000 }) + expect(replay).toEqual({ ok: false, error: 'already_redeemed' }) + }) + + it('rejects malformed tickets and missing destination', () => { + expect(redeemTransferTicket(null, { destinationDeviceId: 'B' })).toEqual({ ok: false, error: 'malformed' }) + expect(redeemTransferTicket({ ...IDENTITY, generation: -1 }, { destinationDeviceId: 'B' })).toEqual({ ok: false, error: 'malformed' }) + const ticket = createTransferTicket(IDENTITY) + expect(redeemTransferTicket(ticket, { destinationDeviceId: '' })).toEqual({ ok: false, error: 'malformed' }) + }) + + it('ticket carries single-use nonce and bounded validity', () => { + const now = Date.now() + const ticket = createTransferTicket(IDENTITY, { ttlMs: 90_000, now }) + expect(ticket.nonce).toHaveLength(32) + expect(ticket.expiresAt).toBe(now + 90_000) + const other = createTransferTicket(IDENTITY, { now }) + expect(other.ticketId).not.toBe(ticket.ticketId) + expect(other.nonce).not.toBe(ticket.nonce) + }) +}) diff --git a/test/session-uv-spike.test.js b/test/session-uv-spike.test.js new file mode 100644 index 0000000..ce590cd --- /dev/null +++ b/test/session-uv-spike.test.js @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeAll } from 'vitest' + +process.env.SESSION_SECRET = 'test-secret-at-least-32-characters-long!!' + +const { issueSession, verifySessionToken, renewSessionIfNeeded, __resetSessionsForTests } = await import('../server/lib/session-store.js') + +describe('session UV tier claims (ISP-059 / #729)', () => { + beforeAll(() => { + __resetSessionsForTests() + }) + + it('mints sessions with uv=true by default (UV-verified ceremonies only)', async () => { + const token = await issueSession('user-1', 'responder-a') + const claims = await verifySessionToken(token) + expect(claims.uv).toBe(true) + expect(claims.sub).toBe('user-1') + }) + + it('preserves an explicit uv=false claim for limited-tier sessions', async () => { + const token = await issueSession('user-1', 'responder-a', { uv: false }) + const claims = await verifySessionToken(token) + expect(claims.uv).toBe(false) + }) + + it('session refresh preserves the UV tier of the incoming session', async () => { + // Short TTL so the session lands inside the renew window. + const original = await issueSession('user-1', 'responder-a', { uv: false, ttlSeconds: 5 }) + const claims = await verifySessionToken(original) + const renewed = await renewSessionIfNeeded(original, claims) + expect(renewed).not.toBeNull() + const renewedClaims = await verifySessionToken(renewed.token) + expect(renewedClaims.uv).toBe(false) + expect(renewedClaims.sub).toBe('user-1') + }) + + it('an upgraded session can carry uv=true after a step-up ceremony', async () => { + const limited = await issueSession('user-1', 'responder-a', { uv: false, ttlSeconds: 5 }) + const claims = await verifySessionToken(limited) + const renewed = await renewSessionIfNeeded(limited, claims) + // Caller re-issues with the measured tier from the step-up ceremony. + const upgraded = await issueSession(claims.sub, claims.username, { uv: true }) + expect((await verifySessionToken(upgraded)).uv).toBe(true) + expect((await verifySessionToken(renewed.token)).uv).toBe(false) + }) +})