@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,9 +13,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
13
13
  import { debugTag } from './util.ts'
14
14
  import type { UnauthorizedContext } from './auth.ts'
15
15
 
16
- // `chainForWire` accepts the row shape from `chainFrom` (where
17
- // `keyframe` is the SQLite INTEGER 0 / 1) and returns the same fields
18
- // with `keyframe` normalised to a strict boolean for the wire.
16
+ // Wire shape `chainForWire` produces from a `chainFrom` row, with
17
+ // `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
19
18
  type WireRevision = {
20
19
  base: string | null
21
20
  id: string
@@ -88,14 +87,12 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
88
87
  send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
89
88
  }
90
89
 
91
- // Normalise `keyframe` on outbound chain entries to a strict boolean.
90
+ // Normalise `keyframe` to a strict boolean on outbound chain entries.
92
91
  // SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
93
- // raw rows; the wire contract (and the canonical signing payload)
94
- // uses strict `=== true` to mark keyframes. Forwarding the integer
95
- // shape works only because every shipping client coerces via
96
- // `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
97
- // fragile if a future client (or test harness) ever strict-compares.
98
- // Convert once on the send side.
92
+ // raw rows; the wire contract (and canonical signing payload) uses
93
+ // strict `=== true`. Convert once on the send side — forwarding the
94
+ // integer relies on clients coercing via `Boolean()` before rebuilding
95
+ // canonical bytes, fragile against any client that strict-compares.
99
96
  function chainForWire(revisions: RevisionRow[]): WireRevision[] {
100
97
  return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
101
98
  }
@@ -174,19 +171,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
174
171
  sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
175
172
  return
176
173
  }
177
- // NOTE: Earlier revisions auto-subscribed the sending socket here.
178
- // That created a replay vector — a passive observer who captured
179
- // any single valid `workspace-save` frame could replay it from any
180
- // TCP connection forever to attach as a subscriber and silently
181
- // mirror every future encrypted broadcast for the workspace,
182
- // without ever holding the seed (the duplicate-id path returns
183
- // ack-only and doesn't reject the socket). Audit round-9 H1.
184
- //
185
- // The legitimate client always sends an explicit
186
- // `workspace-subscribe` (see `trySendSubscribe` in
187
- // `client/triage-sync.js` — fires on key derivation, on socket
188
- // open, on continuity-break recovery, on dismissError). The
189
- // subscribe path remains the only way to attach as a subscriber.
174
+ // Save does NOT subscribe the sending socket — that would be a
175
+ // replay vector: a passive observer who captured one valid
176
+ // `workspace-save` frame could replay it from any TCP connection to
177
+ // attach as a subscriber and mirror every future encrypted
178
+ // broadcast, without holding the seed (the duplicate-id path returns
179
+ // ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
180
+ // (signs the per-connection challenge nonce) is the ONLY attach path.
181
+ // Audit round-9 H1.
190
182
  const baseNorm = msg.base ?? null
191
183
  // `keyframe === true` is what canonicalSave bound the signature to
192
184
  // (strict equality); the signer's intent is unambiguous here.
@@ -230,13 +222,10 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
230
222
  base: baseNorm,
231
223
  id,
232
224
  })
233
- // Carry `keyframe` as a strict boolean on the broadcast wire —
234
- // peers strict-compare `=== true` (matching the canonical-payload
235
- // contract). The previous shape emitted `keyframe ? 1 : 0` which a
236
- // strict check would treat as non-keyframe, making a replayed
237
- // keyframe look like a regular delta on broadcast paths even though
238
- // the chain-fetch path (chainFrom → SQLite integer) DID round-trip
239
- // correctly.
225
+ // Carry `keyframe` as a strict boolean on the broadcast wire — peers
226
+ // strict-compare `=== true` (matching the canonical-payload
227
+ // contract). An integer 0/1 here would make a keyframe look like a
228
+ // regular delta on broadcast paths.
240
229
  broadcast(tag, {
241
230
  type: 'workspace-state',
242
231
  workspaceTag: tag,
@@ -261,15 +250,19 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
261
250
  }
262
251
 
263
252
  async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
264
- if (typeof msg.workspaceTag !== 'string') return
265
- // Same `string | null` contract as `base` in handleSave. The signed
266
- // canonical uses `String(from)`, but the chain-lookup path
267
- // (`typeof msg.from === 'string' ? msg.from : null`) treats every
268
- // non-string as null — so a legit signer sending `from: { … }`
269
- // would silently take the keyframe-fallback path even though the
270
- // signature was over a different canonical shape. Reject at the
271
- // wire gate.
272
- if (msg.from != null && typeof msg.from !== 'string') return
253
+ // Mirror handleSave's wire gate: length-cap + base64url-alphabet on
254
+ // workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
255
+ // encodes them and verifyEd25519 base64-decodes the signature.
256
+ // Without it a peer can submit up-to-maxPayload strings and force
257
+ // O(n) encode + decode per subscribe before the 32/64-byte length
258
+ // gates reject — a CPU-DoS surface handleSave is already hardened
259
+ // against. `from` keeps the `string | null` contract (null = full-
260
+ // chain catch-up); a legit signer's `from` is a base64url revision
261
+ // id, so any non-string / over-long / wrong-alphabet value can't be
262
+ // one the signature was computed over, and the chain-lookup path
263
+ // (`typeof msg.from === 'string' ? msg.from : null`) still treats a
264
+ // null / absent `from` as the keyframe-fallback.
265
+ if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
273
266
  // The challenge nonce we issued on this socket is bound into the
274
267
  // signed canonical, blocking cross-connection replay of a captured
275
268
  // subscribe frame. A subscribe arriving before we sent the
@@ -55,23 +55,19 @@ export type WsServerDeps = PeerConnectionDeps & {
55
55
  }
56
56
 
57
57
  // Per-connection setup: create Peer, send challenge, wire message /
58
- // close / error / pong listeners. Shared between the WS connection
59
- // handler below and the SSE+POST adapter in ./sse-server.ts. The
60
- // `socket` parameter is the WebSocket-shaped surface (a real `ws`
61
- // `WebSocket`, or an `SseSession` cast through `as unknown as
62
- // WebSocket`); the function only uses the subset both expose.
58
+ // close / error / pong listeners. `socket` is the WebSocket-shaped
59
+ // surface — a real `ws` `WebSocket`, or an `SseSession` cast through
60
+ // `as unknown as WebSocket`; only the subset both expose is used.
63
61
  export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
64
62
  const {
65
63
  peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
66
64
  sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
67
65
  } = deps
68
66
  if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
69
- // One Peer holds this connection's state (challenge / authorized /
70
- // alive / inflight / tags). Created before any client frame can
71
- // arrive (`socket.on('message')` is wired below). The heartbeat
72
- // sweep flips `alive` false after each `ping()`; the `pong` listener
73
- // flips it back, and a socket still false on the next sweep is
74
- // terminated — the only thing closing FDs for an idle peer.
67
+ // One Peer holds this connection's state, created before any client
68
+ // frame can arrive (`socket.on('message')` is wired below). The
69
+ // heartbeat sweep's two-tick liveness check (see below) is the only
70
+ // thing closing FDs for an otherwise-idle peer.
75
71
  const peer = new Peer(randomId())
76
72
  peers.set(socket, peer)
77
73
  socket.on('pong', () => { peer.alive = true })
@@ -228,9 +224,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
228
224
  // Surface socket-level errors instead of swallowing — these are
229
225
  // the signals operators want under abuse / network flakiness
230
226
  // (TLS handshake failures, frame-decode errors, ws-protocol
231
- // violations). The previous `() => {}` left every per-connection
232
- // failure invisible. `close` fires after `error` and runs the
233
- // unsubscribe cleanup, so logging here doesn't risk leaking.
227
+ // violations). `close` fires after `error` and runs the unsubscribe
228
+ // cleanup, so logging here doesn't risk leaking.
234
229
  socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
235
230
  }
236
231