@preventive/triage 1.0.0-alpha.0

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
@@ -0,0 +1,168 @@
1
+ // Single-use bearer tokens for the v1.objstore REST plane.
2
+ //
3
+ // Issued from the WS plane (after the `objstore-put-begin` /
4
+ // `objstore-fetch` Ed25519 signature verifies) and presented in the
5
+ // `Authorization: Bearer …` header of the corresponding HTTP PUT /
6
+ // GET. The token IS the auth on the REST path — the workspace seed
7
+ // never reaches the HTTP request, only the (HMAC-bound) capability
8
+ // the WS handshake just minted.
9
+ //
10
+ // Format: `${b64url(payloadJson)}.${b64url(hmac)}`
11
+ // payload:
12
+ // PUT → { op: 'put', tag, res, sid, len, exp }
13
+ // GET → { op: 'get', tag, res, ver, inc, exp }
14
+ // HMAC: HMAC-SHA-256 over the base64url-encoded payload, with a
15
+ // 32-byte secret minted at server start. Restart invalidates every
16
+ // outstanding token (fine — TTL is short, clients re-handshake).
17
+ //
18
+ // PUT single-use is implicit: the staging row referenced by `sid` is
19
+ // dropped on `commitPut`, so a replayed PUT fails the row lookup.
20
+ // GET is multi-use within the TTL — the bytes are AEAD'd ciphertext
21
+ // the relay can't read, so the leak window for a captured token +
22
+ // captured ciphertext is bounded by `exp` and gives no plaintext.
23
+
24
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
25
+ import { Buffer } from 'node:buffer'
26
+
27
+ export const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000
28
+
29
+ export type PutTokenPayload = {
30
+ op: 'put'
31
+ tag: string
32
+ res: string
33
+ sid: string
34
+ len: number
35
+ exp: number
36
+ }
37
+
38
+ export type GetTokenPayload = {
39
+ op: 'get'
40
+ tag: string
41
+ res: string
42
+ ver: number
43
+ // Incarnation the live row carried when the token was minted. The
44
+ // REST GET re-checks it so a token issued for one incarnation can't
45
+ // serve a recreated incarnation that happens to share the version
46
+ // number.
47
+ inc: string
48
+ exp: number
49
+ }
50
+
51
+ export type TokenPayload = PutTokenPayload | GetTokenPayload
52
+
53
+ export type TokenSecret = Uint8Array<ArrayBuffer>
54
+
55
+ export function newTokenSecret(): TokenSecret {
56
+ // 32 bytes for HMAC-SHA-256 — matches the block / output size and
57
+ // keeps brute-force forgery ~2^256 work.
58
+ return randomBytes(32) as Uint8Array<ArrayBuffer>
59
+ }
60
+
61
+ function b64u(bytes: Buffer | Uint8Array): string {
62
+ return Buffer.from(bytes).toString('base64url')
63
+ }
64
+
65
+ function fromB64u(str: string): Buffer {
66
+ return Buffer.from(str, 'base64url')
67
+ }
68
+
69
+ function hmac(secret: TokenSecret, data: string): Buffer {
70
+ // `Uint8Array` IS `BinaryLike` per node:crypto's types — no cast
71
+ // needed. `createHmac` accepts the secret directly.
72
+ return createHmac('sha256', secret).update(data).digest()
73
+ }
74
+
75
+ export function signToken(secret: TokenSecret, payload: TokenPayload): string {
76
+ const body = b64u(Buffer.from(JSON.stringify(payload), 'utf8'))
77
+ const sig = b64u(hmac(secret, body))
78
+ return `${body}.${sig}`
79
+ }
80
+
81
+ // Returns the parsed payload on success, or null on any failure
82
+ // (malformed shape, bad HMAC, expired). Single error-reason channel
83
+ // keeps the REST handler from leaking distinguishing info to the
84
+ // client — every reject is an opaque 401.
85
+ export function verifyToken(secret: TokenSecret, token: unknown, now: number = Date.now()): TokenPayload | null {
86
+ if (typeof token !== 'string') return null
87
+ const dot = token.indexOf('.')
88
+ if (dot <= 0 || dot === token.length - 1) return null
89
+ const body = token.slice(0, dot)
90
+ const sig = token.slice(dot + 1)
91
+ let expected: Buffer
92
+ try { expected = hmac(secret, body) } catch { return null }
93
+ let provided: Buffer
94
+ try { provided = fromB64u(sig) } catch { return null }
95
+ // The HMAC output is always 32 bytes; reject any other-length
96
+ // signature outright (timingSafeEqual itself throws on
97
+ // length-mismatch). The early return on length is fine
98
+ // operationally — an attacker controls the sig length they
99
+ // submit, so leaking that bit isn't a side channel they don't
100
+ // already know.
101
+ if (provided.byteLength !== expected.byteLength) return null
102
+ if (!timingSafeEqual(provided, expected)) return null
103
+ let parsed: unknown
104
+ try { parsed = JSON.parse(fromB64u(body).toString('utf8')) } catch { return null }
105
+ if (!isValidPayload(parsed)) return null
106
+ if (parsed.exp < now) return null
107
+ return parsed
108
+ }
109
+
110
+ function isValidPayload(v: unknown): v is TokenPayload {
111
+ if (!v || typeof v !== 'object') return false
112
+ const o = v as Record<string, unknown>
113
+ if (typeof o['tag'] !== 'string' || typeof o['res'] !== 'string') return false
114
+ // `Number.isSafeInteger` over `Number.isInteger`: `exp` is compared
115
+ // to `Date.now()` (which is itself a safe int), `len` is compared
116
+ // against the Content-Length the REST layer parsed (which already
117
+ // gates on `isSafeInteger`), and `ver` is compared against SQLite's
118
+ // INTEGER (capacity 2^63). An unsafe-but-integer value in the token
119
+ // round-trips through IEEE-754 and could spoof equality with a
120
+ // different actual value, or — for `exp` — let an adversarial
121
+ // token claim a `Number.MAX_SAFE_INTEGER + 1` expiry that compares
122
+ // ambiguously near the IEEE-754 boundary. Matches the rest of the
123
+ // codebase's safe-int gates (server/objstore/sign.ts,
124
+ // server/objstore/rest.ts, server/objstore/handlers.ts).
125
+ if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
126
+ if (o['op'] === 'put') {
127
+ return typeof o['sid'] === 'string'
128
+ && typeof o['len'] === 'number'
129
+ && Number.isSafeInteger(o['len'])
130
+ && o['len'] >= 0
131
+ }
132
+ if (o['op'] === 'get') {
133
+ return typeof o['ver'] === 'number'
134
+ && Number.isSafeInteger(o['ver'])
135
+ && o['ver'] >= 0
136
+ && typeof o['inc'] === 'string'
137
+ }
138
+ return false
139
+ }
140
+
141
+ // Convenience constructors so the WS handler doesn't have to assemble
142
+ // the payload shape inline.
143
+ export function mintPutToken(
144
+ secret: TokenSecret,
145
+ tag: string, res: string, sid: string, len: number,
146
+ ttlMs: number = DEFAULT_TOKEN_TTL_MS,
147
+ ): { token: string; exp: number } {
148
+ const exp = Date.now() + ttlMs
149
+ return { token: signToken(secret, { op: 'put', tag, res, sid, len, exp }), exp }
150
+ }
151
+
152
+ export function mintGetToken(
153
+ secret: TokenSecret,
154
+ tag: string, res: string, ver: number, inc: string,
155
+ ttlMs: number = DEFAULT_TOKEN_TTL_MS,
156
+ ): { token: string; exp: number } {
157
+ const exp = Date.now() + ttlMs
158
+ return { token: signToken(secret, { op: 'get', tag, res, ver, inc, exp }), exp }
159
+ }
160
+
161
+ // `Authorization: Bearer <token>` extractor. Returns the token
162
+ // string on match, null otherwise — case-insensitive on the scheme
163
+ // but strict on whitespace shape.
164
+ export function extractBearer(header: unknown): string | null {
165
+ if (typeof header !== 'string') return null
166
+ const m = /^Bearer\s+(\S+)\s*$/iu.exec(header)
167
+ return m ? m[1]! : null
168
+ }
@@ -0,0 +1,68 @@
1
+ // Same-origin gate for the WS upgrade and REST data plane. We don't
2
+ // support cross-origin browser clients, so any Origin header present
3
+ // on an incoming request MUST match the server's own host (derived
4
+ // from `req.headers.host`, or from `X-Forwarded-Host` /
5
+ // `X-Forwarded-Proto` when a trusted reverse proxy is in front).
6
+ //
7
+ // Why "present-must-match" rather than "always required":
8
+ // - Browser WebSocket handshakes always carry Origin (RFC 6455), so a
9
+ // foreign-page session attempt always surfaces here.
10
+ // - Browser same-origin XHR/fetch may OMIT Origin; requiring it would
11
+ // break legitimate same-origin REST calls.
12
+ // - Non-browser clients (the test suite's `ws`, an admin CLI, …) may
13
+ // also omit Origin. There the trust boundary is the network / token.
14
+ //
15
+ // Reverse-proxy support is OPT-IN via `trustProxy`. When off, we ignore
16
+ // `X-Forwarded-*` and derive the expected origin from `req.headers.host`
17
+ // + `http://`. When on, the proxy headers take precedence. This guards
18
+ // a public-bind deployment (`HOST=0.0.0.0`, no proxy) from a trivial
19
+ // bypass: an attacker page would otherwise send its own
20
+ // `X-Forwarded-Host` + matching `Origin` and walk through. Default: ON
21
+ // for loopback binds (relay behind nginx on same host), OFF otherwise
22
+ // (operator must opt in when fronting a public bind with a proxy).
23
+
24
+ import type { IncomingMessage as HttpRequest } from 'node:http'
25
+
26
+ export const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
27
+
28
+ type HasHeaders = { headers: HttpRequest['headers'] }
29
+
30
+ export type OriginGate = {
31
+ // Resolved trust-proxy decision — also read by the boot-time
32
+ // misconfiguration fail-fast in index.ts.
33
+ trustProxy: boolean
34
+ isOriginAllowed(req: HasHeaders): boolean
35
+ }
36
+
37
+ function firstHeaderValue(v: string | string[] | undefined): string | null {
38
+ if (typeof v === 'string') return v.split(',')[0]!.trim() || null
39
+ if (Array.isArray(v) && v.length > 0) return String(v[0]).trim() || null
40
+ return null
41
+ }
42
+
43
+ export function createOriginGate(host: string, trustProxyEnv: string | undefined): OriginGate {
44
+ const trustProxy = trustProxyEnv == null
45
+ ? LOOPBACK_HOSTS.has(host)
46
+ : trustProxyEnv === '1' || trustProxyEnv.toLowerCase() === 'true'
47
+
48
+ function expectedOrigin(req: HasHeaders): string | null {
49
+ const xfHost = trustProxy ? firstHeaderValue(req.headers['x-forwarded-host']) : null
50
+ const xfProto = trustProxy ? firstHeaderValue(req.headers['x-forwarded-proto']) : null
51
+ const hostHeader = xfHost ?? firstHeaderValue(req.headers['host'])
52
+ if (!hostHeader) return null
53
+ const proto = xfProto ?? 'http'
54
+ return `${proto}://${hostHeader}`
55
+ }
56
+
57
+ function isOriginAllowed(req: HasHeaders): boolean {
58
+ const origin = firstHeaderValue(req.headers['origin'])
59
+ // Missing Origin → same-origin browser fetch OR non-browser client.
60
+ // Both allowed; non-browser callers' trust boundary is the network.
61
+ if (origin == null) return true
62
+ const expected = expectedOrigin(req)
63
+ if (expected == null) return false // Origin present but no Host to compare → deny
64
+ return origin === expected
65
+ }
66
+
67
+ return { trustProxy, isOriginAllowed }
68
+ }
package/server/peer.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Per-connection server state. One `Peer` per accepted WebSocket,
2
+ // replacing the former parallel per-socket WeakMaps (challenge /
3
+ // authorized / alive / inflight / tags). The connection handler
4
+ // constructs one on accept and holds it in a closure, so the hot
5
+ // paths (message dispatch, pong, close) touch fields directly; the
6
+ // few cross-function call sites look it up via `peers.get(socket)`.
7
+ //
8
+ // Held in a `WeakMap<WebSocket, Peer>` so a closed socket's state GCs
9
+ // with the socket; the close handler also `delete`s it explicitly
10
+ // because `ws` keeps the socket strongly referenced well past close.
11
+
12
+ import type { WebSocket } from 'ws'
13
+
14
+ export class Peer {
15
+ // Per-connection challenge nonce (round-9 H2), issued in a
16
+ // `challenge` frame before any client frame and bound into every
17
+ // `workspace-subscribe` signature — blocks cross-connection replay
18
+ // of a captured subscribe frame.
19
+ readonly challenge: string
20
+ // Password-gate authorization flag. Once the `authenticate`
21
+ // handshake succeeds, every subsequent first-action on this socket
22
+ // bypasses the new-workspace gate. `false` until then.
23
+ authorized = false
24
+ // Heartbeat liveness. The sweep flips it `false` after each `ping()`;
25
+ // the `pong` listener flips it back. A socket still `false` on the
26
+ // next sweep is terminated.
27
+ alive = true
28
+ // In-flight async message handlers spawned for this socket, capped by
29
+ // MAX_INFLIGHT_PER_SOCKET. Incremented at dispatch, decremented in
30
+ // the handler's `finally`.
31
+ inflight = 0
32
+ // Workspace tags this socket is subscribed to — the per-socket
33
+ // reverse index used to detach from `subscribers` on close.
34
+ readonly tags = new Set<string>()
35
+ constructor(challenge: string) { this.challenge = challenge }
36
+ }
37
+
38
+ export type PeerRegistry = WeakMap<WebSocket, Peer>
package/server/sign.ts ADDED
@@ -0,0 +1,231 @@
1
+ // Ed25519 signature verification for incoming wire messages. The
2
+ // canonical signing payload format is identical to the client's
3
+ // (see client/sync-crypto.js's `canonicalSavePayload`) so a
4
+ // signature produced by a holder of the workspace seed verifies
5
+ // here without re-derivation.
6
+ //
7
+ // Two payload types:
8
+ // save: `<domain>\n<pubkey>\n<base>\n<keyframe>\n<nonce>\n<ciphertext>`
9
+ // subscribe: `<domain>\n<pubkey>\n<from>\n<connectionNonce>`
10
+ //
11
+ // `keyframe` is `1` / `0` (string), bound into the signed bytes so
12
+ // the server can't promote/demote a revision after the fact.
13
+ // `from` is the client's last-applied revision id (or empty for a
14
+ // fresh subscribe), so a captured subscribe sig can't be replayed
15
+ // to fast-forward another peer to a different cursor.
16
+ // `connectionNonce` is a per-socket challenge the server emits in
17
+ // a `challenge` frame the moment the socket opens (round-9 H2). A
18
+ // captured subscribe frame can't be replayed from a different
19
+ // connection because the canonical bytes the captured signature
20
+ // covered include the OLD nonce; the attacker's new connection
21
+ // has a DIFFERENT nonce; signature verify fails.
22
+ //
23
+ // Domains are different so a save signature can't be replayed as
24
+ // a subscribe and vice versa.
25
+
26
+ import { Buffer } from 'node:buffer'
27
+ import { encodeUtf8 } from '../common/utf8.js'
28
+
29
+ const SAVE_DOMAIN = 'deepview-triage-sync.v1.save'
30
+ const SUBSCRIBE_DOMAIN = 'deepview-triage-sync.v1.subscribe'
31
+
32
+ // Wire-message shapes the verifiers accept. Fields land here
33
+ // post-`JSON.parse`, so every value starts life as `unknown` —
34
+ // strict type checks (`typeof x === 'string'`) inside the
35
+ // verifiers are the trust boundary, and call sites can pass any
36
+ // `Record<string, unknown>` shape without casts.
37
+ export type SaveMsg = {
38
+ workspaceTag?: unknown
39
+ base?: unknown
40
+ keyframe?: unknown
41
+ nonce?: unknown
42
+ ciphertext?: unknown
43
+ signature?: unknown
44
+ }
45
+
46
+ export type SubscribeMsg = {
47
+ workspaceTag?: unknown
48
+ from?: unknown
49
+ signature?: unknown
50
+ }
51
+
52
+ // `verifySaveSigAndCanonical` returns either the validated canonical
53
+ // bytes (for the follow-up content-id hash) or a flat reject. Modeled
54
+ // as a discriminated union so a caller pattern-matches on `ok`.
55
+ // `Uint8Array<ArrayBuffer>` (not `<ArrayBufferLike>`) so the bytes
56
+ // thread directly into `crypto.subtle.digest` — `BufferSource`
57
+ // rejects SharedArrayBuffer-backed views.
58
+ //
59
+ // NOTE: `verifySaveSigAndCanonical` is a test-friendly composition
60
+ // helper. Production `handleSave` in `server/index.ts` does NOT
61
+ // call it — it composes `canonicalSave` +
62
+ // `computeRevisionIdFromCanonical` + `verifyEd25519` separately so
63
+ // the dup-precheck (`revisionExists`) can fire BEFORE the Ed25519
64
+ // verify, closing the round-9 H1 CPU-DoS vector where a passive
65
+ // observer floods captured saves. The wrapper exists for unit
66
+ // tests that don't need the precheck ordering.
67
+ export type VerifyResult =
68
+ | { ok: true; canonical: Uint8Array<ArrayBuffer> }
69
+ | { ok: false; canonical: null }
70
+
71
+ function fromB64Url(str: string): Uint8Array<ArrayBuffer> {
72
+ // `Buffer.from(..., 'base64url')` returns `Buffer<ArrayBufferLike>`;
73
+ // WebCrypto's `BufferSource` (per @types/node:
74
+ // `NonSharedArrayBufferView | ArrayBuffer`) rejects
75
+ // SharedArrayBuffer-backed views. Node's Buffer pool is always
76
+ // regular ArrayBuffer at runtime, so narrowing the return type
77
+ // is safe — the cast is the only way to thread the value through
78
+ // `crypto.subtle.importKey` / `verify` without a redundant copy.
79
+ return Buffer.from(str, 'base64url') as Uint8Array<ArrayBuffer>
80
+ }
81
+
82
+ // Mirrors client/sync-crypto.js's `canonicalSavePayload`. `keyframe`
83
+ // is `'1'` for a keyframe revision (`=== true` exactly), `''`
84
+ // otherwise. STRICT equality, not truthy: a non-boolean truthy
85
+ // value like `keyframe: 1` (which JSON.parse couldn't have
86
+ // produced unless the sender went out of its way) hashes as `''`
87
+ // here, matching `handleSave`'s `msg.keyframe === true` storage
88
+ // rule. Without strict matching the canonical and the storage
89
+ // disagree on which inputs are keyframes, and a malformed save
90
+ // can land in the chain unreadable to peers.
91
+ export function canonicalSave(
92
+ { workspaceTag, base, keyframe, nonce, ciphertext }: SaveMsg,
93
+ ): Uint8Array<ArrayBuffer> {
94
+ // Strict input gates — defense-in-depth against a future caller
95
+ // that invokes canonicalSave without going through handleSave's
96
+ // upstream `validTagSigBase` / `validNonce` / `validCiphertextShape`
97
+ // gates. `base` is `string | null` (previous revision's id); any
98
+ // non-string non-null value (object, array, number, ...) would
99
+ // otherwise coerce via `String(...)` to canonical bytes the client
100
+ // could never reproduce, producing a verify failure with a
101
+ // confusing diff in the canonical bytes rather than a clean drop.
102
+ // Mirrors the `isSafeNonNegativeInt` rigor that the objstore
103
+ // canonical builders apply. Input-validation audit
104
+ // `server/sign.ts:88`.
105
+ if (typeof workspaceTag !== 'string') throw new TypeError('canonicalSave: workspaceTag must be string')
106
+ if (typeof nonce !== 'string') throw new TypeError('canonicalSave: nonce must be string')
107
+ if (typeof ciphertext !== 'string') throw new TypeError('canonicalSave: ciphertext must be string')
108
+ if (base != null && typeof base !== 'string') throw new TypeError('canonicalSave: base must be string or null')
109
+ return encodeUtf8([
110
+ SAVE_DOMAIN,
111
+ workspaceTag,
112
+ base ?? '',
113
+ keyframe === true ? '1' : '',
114
+ nonce,
115
+ ciphertext,
116
+ ].join('\n'))
117
+ }
118
+
119
+ function canonicalSubscribe(
120
+ { workspaceTag, from }: SubscribeMsg,
121
+ connectionNonce: string,
122
+ ): Uint8Array<ArrayBuffer> {
123
+ const fromStr = from == null ? '' : String(from)
124
+ return encodeUtf8([SUBSCRIBE_DOMAIN, workspaceTag as string, fromStr, connectionNonce].join('\n'))
125
+ }
126
+
127
+ // Exported because the v1.objstore signing module (server/objstore/sign.ts)
128
+ // reuses it for its four verifiers — same workspaceTag-as-pubkey contract,
129
+ // same domain-separated canonical bytes. Keeping the WebCrypto plumbing
130
+ // in one place avoids drift between the triage-sync and objstore verify
131
+ // implementations.
132
+ export async function verifyEd25519(
133
+ pubkeyB64Url: string,
134
+ message: Uint8Array<ArrayBuffer>,
135
+ sigB64Url: string,
136
+ ): Promise<boolean> {
137
+ const pubkeyBytes = fromB64Url(pubkeyB64Url)
138
+ const sigBytes = fromB64Url(sigB64Url)
139
+ if (pubkeyBytes.length !== 32) return false
140
+ if (sigBytes.length !== 64) return false
141
+ try {
142
+ const key = await crypto.subtle.importKey(
143
+ 'raw',
144
+ pubkeyBytes,
145
+ { name: 'Ed25519' },
146
+ false,
147
+ ['verify'],
148
+ )
149
+ return await crypto.subtle.verify('Ed25519', key, sigBytes, message)
150
+ } catch {
151
+ return false
152
+ }
153
+ }
154
+
155
+ // Test-friendly composition: verify the save signature AND return
156
+ // the canonical bytes the signature was checked against, so a
157
+ // follow-up step (computing the content-addressed revision id)
158
+ // hashes the EXACT bytes the signature covered. Returns
159
+ // `{ ok: false, canonical: null }` on bad shape / bad sig;
160
+ // `{ ok: true, canonical: <bytes> }` on success.
161
+ //
162
+ // NOT used by `server/index.ts handleSave`. Production composes
163
+ // the smaller helpers (`canonicalSave`, `computeRevisionIdFromCanonical`,
164
+ // `verifyEd25519`) separately so the dup-precheck via
165
+ // `revisionExists` can fire BEFORE Ed25519-verify and skip the
166
+ // expensive crypto work on a replayed save (round-9 H1 CPU-DoS
167
+ // defense). The helper survives because unit tests in
168
+ // `tests/server-sign.test.js` benefit from a clean single-call
169
+ // surface; the round-9 H1 ordering is exercised end-to-end in
170
+ // `tests/sync-server.test.js`.
171
+ //
172
+ // `encodeUtf8` throws on non-string or lone-surrogate input (any of
173
+ // which on the wire is already a hostile / malformed message), so
174
+ // any error in the canonical-payload path is a verification failure.
175
+ export async function verifySaveSigAndCanonical(msg: SaveMsg): Promise<VerifyResult> {
176
+ // Type-check `workspaceTag` here (alongside `signature`) so a
177
+ // non-string slips reach `fromB64Url(msg.workspaceTag)` inside
178
+ // `verifyEd25519` — `fromB64Url` is `Buffer.from(s, 'base64url')`,
179
+ // which throws TypeError on non-string non-array-like input.
180
+ // Without this gate, the throw escapes verifyEd25519 (it lives
181
+ // BEFORE the try/catch around the WebCrypto calls) and the function
182
+ // rejects with TypeError instead of honouring its `{ ok: false,
183
+ // canonical: null }` contract — a malformed wire message would
184
+ // bubble out as a connection-handler exception rather than a clean
185
+ // drop. Audit round-11.
186
+ if (typeof msg.workspaceTag !== 'string') return { ok: false, canonical: null }
187
+ if (typeof msg.signature !== 'string') return { ok: false, canonical: null }
188
+ let payload: Uint8Array<ArrayBuffer>
189
+ try { payload = canonicalSave(msg) } catch { return { ok: false, canonical: null } }
190
+ const ok = await verifyEd25519(msg.workspaceTag, payload, msg.signature)
191
+ return ok ? { ok: true, canonical: payload } : { ok: false, canonical: null }
192
+ }
193
+
194
+ // Content-addressed revision id — SHA-256 of the canonical save
195
+ // bytes (same input the signature covers), base64url no padding.
196
+ // Server doesn't get to assign ids: it derives the id from received
197
+ // content and stores under that. Mirrors the client's
198
+ // `computeRevisionId` so two ends always land on the same string.
199
+ // Takes the canonical bytes produced by `canonicalSave` (or, in
200
+ // tests, returned by `verifySaveSigAndCanonical`) so the hash is
201
+ // over EXACTLY the bytes the signature covered.
202
+ export async function computeRevisionIdFromCanonical(canonical: Uint8Array<ArrayBuffer>): Promise<string> {
203
+ const digest = await crypto.subtle.digest('SHA-256', canonical)
204
+ return Buffer.from(new Uint8Array(digest)).toString('base64url')
205
+ }
206
+
207
+ // `connectionNonce` is the per-socket challenge the server issued
208
+ // to the originating connection (see `Peer.challenge` in
209
+ // server/peer.ts). The client signs a canonical that includes the
210
+ // nonce; verifying against the SAME nonce here is what blocks
211
+ // cross-connection replay of a captured subscribe frame. Audit
212
+ // round-9 H2.
213
+ export async function verifySubscribeSig(msg: SubscribeMsg, connectionNonce: unknown): Promise<boolean> {
214
+ // `async` + `await` the verifyEd25519 result. Without `async` the
215
+ // function's verify path returned a `Promise<boolean>` while its
216
+ // type-check / canonical-throw paths returned the literal `false`
217
+ // — a caller using it as a synchronous predicate (`if
218
+ // (verifySubscribeSig(...))`) would treat the truthy Promise as
219
+ // "valid" and accept arbitrary forged signatures. Production's
220
+ // only call site already `await`s, but the inconsistency was a
221
+ // footgun for any future caller. Audit round-11.
222
+ // Same workspaceTag type-check as `verifySaveSigAndCanonical` —
223
+ // see that function for the rationale (fromB64Url throws TypeError
224
+ // on non-string and the throw escapes verifyEd25519's try/catch).
225
+ if (typeof msg.workspaceTag !== 'string') return false
226
+ if (typeof msg.signature !== 'string') return false
227
+ if (typeof connectionNonce !== 'string') return false
228
+ let payload: Uint8Array<ArrayBuffer>
229
+ try { payload = canonicalSubscribe(msg, connectionNonce) } catch { return false }
230
+ return await verifyEd25519(msg.workspaceTag, payload, msg.signature)
231
+ }