@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.
- package/LICENSE +21 -0
- package/common/save-error-reason.ts +53 -0
- package/common/utf8.d.ts +13 -0
- package/common/utf8.js +57 -0
- package/out/brotli-fallback.js +3 -0
- package/out/client-sync.js +15 -0
- package/out/graph.js +4 -0
- package/out/icon-maskable.svg +5 -0
- package/out/icon.svg +5 -0
- package/out/index.html +78 -0
- package/out/manifest.webmanifest +30 -0
- package/out/prism.js +14 -0
- package/out/terminal.js +39 -0
- package/out/view.css +1 -0
- package/out/view.html +12 -0
- package/out/view.js +138 -0
- package/package.json +129 -0
- package/server/auth.ts +99 -0
- package/server/config.example.json +3 -0
- package/server/config.ts +196 -0
- package/server/db-neon.ts +374 -0
- package/server/db-revision-sql.ts +152 -0
- package/server/db-stmt.ts +53 -0
- package/server/db.ts +577 -0
- package/server/http.ts +142 -0
- package/server/hub.ts +98 -0
- package/server/index.ts +353 -0
- package/server/lifecycle.ts +177 -0
- package/server/neon-driver.ts +26 -0
- package/server/objstore/blob-fs.ts +164 -0
- package/server/objstore/blob-vercel.ts +508 -0
- package/server/objstore/blob.ts +169 -0
- package/server/objstore/fs.ts +67 -0
- package/server/objstore/handlers.ts +235 -0
- package/server/objstore/init.ts +118 -0
- package/server/objstore/reaper.ts +199 -0
- package/server/objstore/rest.ts +484 -0
- package/server/objstore/sign.ts +164 -0
- package/server/objstore/store-neon.ts +351 -0
- package/server/objstore/store.ts +799 -0
- package/server/objstore/tokens.ts +168 -0
- package/server/origin.ts +68 -0
- package/server/peer.ts +38 -0
- package/server/sign.ts +231 -0
- package/server/static.ts +374 -0
- package/server/sync-handlers.ts +311 -0
- package/server/util.ts +27 -0
- package/server/validation.ts +36 -0
- 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
|
+
}
|
package/server/origin.ts
ADDED
|
@@ -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
|
+
}
|