Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/spikes/726-message-franking-report.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 53 additions & 0 deletions docs/spikes/727-checkpoint-finality-report.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions docs/spikes/728-session-transfer-report.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions docs/spikes/729-webauthn-uv-tier-report.md
Original file line number Diff line number Diff line change
@@ -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).
54 changes: 54 additions & 0 deletions server/lib/finality.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 6 additions & 1 deletion server/lib/session-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
};
}
Expand Down
10 changes: 7 additions & 3 deletions server/routes/passkey-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
}
Expand Down Expand Up @@ -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' });
}
Expand All @@ -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' });
}
Expand Down
Loading