@gotcos/glasses-server 7.0.7 → 7.0.18

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 (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,372 @@
1
+ // Server acceptance of a signed Control Web turn (6.99.31, plan §4.5). Behind
2
+ // COS_WEB_KEY_AUTHORITY (default off). Off, nothing here is consulted and a web turn is exactly
3
+ // what 6.99.29 made it: recorded, never vouched (`web_cookie_derivable_from_token`).
4
+ //
5
+ // A web turn is vouched (`authority: 'web-key'`) only when EVERY check below holds. Any failure
6
+ // delivers the turn unvouched with the specific reason, never refuses it outright: the words were
7
+ // typed by someone holding a valid cookie, and the receiving agent still sees them as a request.
8
+ //
9
+ // - the request already passed the full web cookie path with no token and a non-local socket
10
+ // (G2TurnProvenance.originOf decides that; only then is an envelope even read)
11
+ // - the envelope is exactly `cos-web-turn/1`, canonical JCS bytes, closed scope
12
+ // - an anchor is registered; `aud` is this install; `origin` is the expected https origin and is
13
+ // the Origin the browser stamped on this very request
14
+ // - the key is enrolled with a valid anchorSig, is not revoked; `kver` and `epoch` are current
15
+ // - the P-256 signature verifies over those bytes
16
+ // - `iat`/`exp` within bounds (exp - iat <= 15 min, iat at most 60 s ahead, now <= exp)
17
+ // - not a fork, and provider/session (and binding, when signed) equal the route's real target
18
+ // - the turn id, the exact words (CRLF to LF only) and the ordered attachment manifest match
19
+ // - an `approve` scope matches an open packet byte for byte, the packet was created by the very
20
+ // session the envelope names (review B4), and the action is enabled (install or publish)
21
+ // - the anchor has a valid bootstrap record and is past its 24 h maturity (review B1)
22
+ // - served-asset integrity is enabled AND its last check passed (review W1)
23
+ // - a user-verified WebAuthn assertion by the anchored passkey over sha256(envelope JCS) (B2)
24
+ // - the single-use server nonce is unspent (spent last, so a failing envelope never burns it)
25
+
26
+ import { createHash } from 'node:crypto'
27
+ import { readSwitch } from './env-switch.js'
28
+ import { b64url, canonicalBytes, fromB64url, parseEnvelopeHeader, sha256Hex, verifyP1363 } from './web-key-envelope.js'
29
+ import type { WebKeyNonces, WebKeyPackets, WebKeyRegistry } from './web-key-registry.js'
30
+ import { webKeyRpId } from './web-key-origin.js'
31
+ import { parsePasskeyHeader, turnPasskeyChallenge, verifyPasskeyAssertion, WEB_PASSKEY_HEADER, type PasskeyAssertion } from './web-key-passkey.js'
32
+ import {
33
+ signedWordsText, WEB_TURN_IAT_SKEW_S, WEB_TURN_MAX_LIFETIME_S, type WebTurnEnvelope, type WebTurnScope,
34
+ } from '../../shared/web-turn-envelope.js'
35
+
36
+ export const WEB_ENVELOPE_HEADER = 'x-cos-web-envelope'
37
+ export const WEB_SIGNATURE_HEADER = 'x-cos-web-sig'
38
+ /** Exact scoped install/publish only. Push-main remains refused (`approve_action_not_enabled`). */
39
+ export const WEB_KEY_ENABLED_APPROVE_ACTIONS: ReadonlySet<string> = new Set(['install', 'publish'])
40
+
41
+ /** The served-asset integrity state the authority reads (review W1). Null: integrity is not running. */
42
+ export interface IntegrityReport { enabled: boolean; lastCheckOk: boolean; checkedAt: number | null; manifestSha256: string | null }
43
+
44
+ /** The ordered attachment manifest's digest, the same function the queue seal uses (g2-turn-provenance attachmentsDigest). */
45
+ export function manifestDigest(manifest: ReadonlyArray<{ id: string; sha256: string }>): string {
46
+ if (manifest.length === 0) return ''
47
+ return createHash('sha256').update(JSON.stringify(manifest.map(e => [e.id, e.sha256])), 'utf8').digest('hex')
48
+ }
49
+
50
+ export function webKeyAuthorityEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
51
+ return readSwitch(env.COS_WEB_KEY_AUTHORITY, false)
52
+ }
53
+
54
+ /**
55
+ * 7.0.9: COS_WEB_KEY_ANCHOR_BOOTSTRAP (default off, read the same way as COS_WEB_KEY_AUTHORITY).
56
+ * On with the authority off, ONLY GET /api/web-key/status (token only, its own reply shape) and
57
+ * POST /api/web-key/anchor answer, so COS Control can register the Mac's anchor and start its 24 h
58
+ * clock. Nothing else changes: enabled() stays false, so no enrollment, key list or revoke route,
59
+ * no nonce, packet or handoff, no 3144 signer listener, no extra cookie port, no integrity default
60
+ * and no vouched turn. With the authority on, this switch is irrelevant.
61
+ */
62
+ export function webKeyAnchorBootstrapEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
63
+ return readSwitch(env.COS_WEB_KEY_ANCHOR_BOOTSTRAP, false)
64
+ }
65
+
66
+ /** Which web-key surface is served: everything (`authority`), anchor status and registration only (`bootstrap`), or none (`off`). */
67
+ export type WebKeyMode = 'authority' | 'bootstrap' | 'off'
68
+
69
+ /** The raw headers of a web request that carries an envelope; judged only once the route knows its target. */
70
+ export interface WebKeyCandidate { envelope: string; signature: string | null; requestOrigin: string | null; passkey?: string | null }
71
+
72
+ /** What a vouched web-key turn carries: self-proving (the envelope bytes and the signature). */
73
+ export interface WebKeyProof {
74
+ kid: string
75
+ kver: number
76
+ epoch: number
77
+ iat: number
78
+ exp: number
79
+ authId: string
80
+ /** base64url of the exact JCS bytes the phone signed. */
81
+ envelope: string
82
+ signature: string
83
+ scope: WebTurnScope
84
+ wordsSha256: string
85
+ provider: string
86
+ sessionId: string
87
+ /** The user-verified WebAuthn assertion over sha256(envelope JCS) (review B2). */
88
+ passkey: PasskeyAssertion
89
+ /** Served-asset integrity when this turn was accepted (review W1). */
90
+ integrity: IntegrityReport
91
+ }
92
+
93
+ /** What the route knows when it judges: the real destination, the exact words and the files. */
94
+ export interface WebKeyContext {
95
+ provider: string
96
+ /** The actual receiving session. Null: unknown (a new session), never vouched. */
97
+ sessionId: string | null
98
+ bindingId?: string | null
99
+ turnId: string
100
+ /** The words exactly as received, BEFORE any files block. */
101
+ words: string
102
+ /** The ordered manifest (id + content hash). Null: the files could not be read (unvouched). */
103
+ attachments: ReadonlyArray<{ id: string; sha256: string }> | null
104
+ fork?: boolean
105
+ }
106
+
107
+ export type WebKeyVerdict = { ok: true; proof: WebKeyProof } | { ok: false; reason: string }
108
+
109
+ export function wordsDigest(words: string): { sha256: string; len: number } {
110
+ const text = Buffer.from(signedWordsText(words), 'utf8')
111
+ return { sha256: createHash('sha256').update(text).digest('hex'), len: text.length }
112
+ }
113
+
114
+ function headerValue(headers: Record<string, string | string[] | undefined>, name: string): string | null {
115
+ const raw = headers[name]
116
+ const v = Array.isArray(raw) ? raw[0] : raw
117
+ return typeof v === 'string' && v.length > 0 ? v.trim() : null
118
+ }
119
+
120
+ /** The envelope headers of a request, or null when it carries none. */
121
+ export function webKeyCandidateOf(headers: Record<string, string | string[] | undefined>): WebKeyCandidate | null {
122
+ const envelope = headerValue(headers, WEB_ENVELOPE_HEADER)
123
+ if (envelope === null) return null
124
+ return { envelope, signature: headerValue(headers, WEB_SIGNATURE_HEADER), requestOrigin: headerValue(headers, 'origin'), passkey: headerValue(headers, WEB_PASSKEY_HEADER) }
125
+ }
126
+
127
+ export interface WebKeyAuthorityDeps {
128
+ registry: WebKeyRegistry
129
+ nonces: WebKeyNonces
130
+ packets: WebKeyPackets
131
+ /** This Mac's MagicDNS name (aud is bound to it). Null: aud is unknown and nothing is vouched. */
132
+ selfDnsName: () => string | null
133
+ /** The one origin a phone signs from: webKeySignerOrigin() (`https://<selfDnsName>:3144`). Null: none. */
134
+ expectedOrigin: () => string | null
135
+ /** Served-asset integrity (review W1). Absent or null: integrity is off, so nothing is vouched. */
136
+ integrity?: () => IntegrityReport | null
137
+ enabled?: () => boolean
138
+ /** 7.0.9: the anchor bootstrap switch. Absent: COS_WEB_KEY_ANCHOR_BOOTSTRAP, read at call time. */
139
+ bootstrap?: () => boolean
140
+ now?: () => number
141
+ }
142
+
143
+ export class WebKeyAuthority {
144
+ private readonly now: () => number
145
+
146
+ constructor(readonly deps: WebKeyAuthorityDeps) {
147
+ this.now = deps.now ?? Date.now
148
+ }
149
+
150
+ enabled(): boolean {
151
+ try { return (this.deps.enabled ?? webKeyAuthorityEnabled)() } catch { return false }
152
+ }
153
+
154
+ /**
155
+ * 7.0.9: the routes' mode. The authority wins; the bootstrap switch matters only with it off.
156
+ * Never consulted by acceptance, recheck or review: those read enabled() alone.
157
+ */
158
+ mode(): WebKeyMode {
159
+ if (this.enabled()) return 'authority'
160
+ let bootstrap = false
161
+ try { bootstrap = (this.deps.bootstrap ?? webKeyAnchorBootstrapEnabled)() === true } catch { bootstrap = false }
162
+ return bootstrap ? 'bootstrap' : 'off'
163
+ }
164
+
165
+ get registry(): WebKeyRegistry { return this.deps.registry }
166
+ get nonces(): WebKeyNonces { return this.deps.nonces }
167
+ get packets(): WebKeyPackets { return this.deps.packets }
168
+
169
+ /** The integrity state now, or a refusal reason: off or failing means no vouch (review W1). */
170
+ integrityNow(): { ok: true; report: IntegrityReport } | { ok: false; reason: 'integrity_off' | 'integrity_failed'; report: IntegrityReport | null } {
171
+ let report: IntegrityReport | null = null
172
+ try { report = this.deps.integrity ? this.deps.integrity() : null } catch { report = null }
173
+ if (!report || report.enabled !== true) return { ok: false, reason: 'integrity_off', report }
174
+ if (report.lastCheckOk !== true) return { ok: false, reason: 'integrity_failed', report }
175
+ return { ok: true, report: { enabled: true, lastCheckOk: true, checkedAt: report.checkedAt ?? null, manifestSha256: report.manifestSha256 ?? null } }
176
+ }
177
+
178
+ /** Judge one candidate against the route's real target. Never throws (a throw reads as a refusal). */
179
+ accept(candidate: WebKeyCandidate, ctx: WebKeyContext): WebKeyVerdict {
180
+ try { return this.acceptInner(candidate, ctx) } catch (error) {
181
+ console.error(`[web-key] accept failed: ${error instanceof Error ? error.message : error}`)
182
+ return { ok: false, reason: 'web_key_check_failed' }
183
+ }
184
+ }
185
+
186
+ private acceptInner(candidate: WebKeyCandidate, ctx: WebKeyContext): WebKeyVerdict {
187
+ if (!this.enabled()) return { ok: false, reason: 'web_key_authority_off' }
188
+ if (!candidate.signature) return { ok: false, reason: 'signature_missing' }
189
+ const parsed = parseEnvelopeHeader(candidate.envelope)
190
+ if (!parsed.ok) return { ok: false, reason: parsed.reason }
191
+ const env: WebTurnEnvelope = parsed.envelope
192
+ const registry = this.deps.registry
193
+ const anchorRefusal = registry.anchorRefusal()
194
+ if (anchorRefusal) return { ok: false, reason: anchorRefusal }
195
+ if (!registry.anchorMature()) return { ok: false, reason: 'anchor_too_new' }
196
+ const integrity = this.integrityNow()
197
+ if (!integrity.ok) return { ok: false, reason: integrity.reason }
198
+ const selfDns = this.deps.selfDnsName()
199
+ const aud = registry.aud(selfDns)
200
+ if (!aud) return { ok: false, reason: 'aud_unavailable' }
201
+ if (env.aud !== aud) return { ok: false, reason: 'aud_mismatch' }
202
+ const origin = this.deps.expectedOrigin()
203
+ if (!origin) return { ok: false, reason: 'origin_unavailable' }
204
+ if (env.origin !== origin || candidate.requestOrigin !== origin) return { ok: false, reason: 'origin_mismatch' }
205
+ const state = registry.entryState(env.kid, selfDns)
206
+ if (!state.ok) return { ok: false, reason: state.reason }
207
+ if (env.kver !== state.entry.kver) return { ok: false, reason: 'key_version_stale' }
208
+ if (env.epoch !== registry.epoch()) return { ok: false, reason: 'epoch_stale' }
209
+ if (!verifyP1363(state.key, parsed.bytes, candidate.signature)) return { ok: false, reason: 'signature_invalid' }
210
+ // Review B2: the phone key alone vouches nothing. A user-verified passkey assertion by the
211
+ // anchored passkey, over sha256(these exact envelope bytes), from this exact origin.
212
+ if (!candidate.passkey) return { ok: false, reason: 'passkey_missing' }
213
+ const assertion = parsePasskeyHeader(candidate.passkey)
214
+ if (!assertion) return { ok: false, reason: 'passkey_malformed' }
215
+ const rpId = webKeyRpId(selfDns)
216
+ if (!rpId) return { ok: false, reason: 'aud_unavailable' }
217
+ const pk = verifyPasskeyAssertion(assertion, {
218
+ challenge: turnPasskeyChallenge(parsed.bytes), origin, rpId,
219
+ credentialId: state.entry.approval.passkey_credential_id, publicKey: state.passkeyKey,
220
+ })
221
+ if (!pk.ok) return { ok: false, reason: pk.reason }
222
+ const nowS = Math.floor(this.now() / 1000)
223
+ if (env.exp <= env.iat || env.exp - env.iat > WEB_TURN_MAX_LIFETIME_S) return { ok: false, reason: 'lifetime_invalid' }
224
+ if (env.iat > nowS + WEB_TURN_IAT_SKEW_S) return { ok: false, reason: 'iat_in_future' }
225
+ if (nowS > env.exp) return { ok: false, reason: 'signature_expired' }
226
+ // Forks and unknown destinations are delivered unvouched (the receiving child has no id yet).
227
+ if (env.fork || ctx.fork === true) return { ok: false, reason: 'fork_destination_unbound' }
228
+ if (ctx.sessionId === null) return { ok: false, reason: 'destination_unbound' }
229
+ if (env.provider !== ctx.provider || env.sessionId !== ctx.sessionId) return { ok: false, reason: 'destination_mismatch' }
230
+ if (env.bindingId !== null && env.bindingId !== (ctx.bindingId ?? null)) return { ok: false, reason: 'destination_mismatch' }
231
+ if (env.turnId !== ctx.turnId) return { ok: false, reason: 'turn_id_mismatch' }
232
+ const words = wordsDigest(ctx.words)
233
+ if (env.wordsSha256 !== words.sha256 || env.wordsLen !== words.len) return { ok: false, reason: 'words_changed' }
234
+ // The footer step trims trailing whitespace before it appends (footed()), so words ending in
235
+ // whitespace could never verify downstream. Refuse them here: the signer trims before hashing.
236
+ if (/[\t\n\v\f\r ]$/.test(signedWordsText(ctx.words))) return { ok: false, reason: 'words_trailing_whitespace' }
237
+ if (ctx.attachments === null) return { ok: false, reason: 'attachments_unsealable' }
238
+ if (env.attachments.length !== ctx.attachments.length
239
+ || env.attachments.some((a, i) => a.id !== ctx.attachments![i].id || a.sha256 !== ctx.attachments![i].sha256)) {
240
+ return { ok: false, reason: 'attachments_changed' }
241
+ }
242
+ let packetSha: string | null = null
243
+ if (env.scope.kind === 'approve') {
244
+ const scope = env.scope
245
+ const packet = this.deps.packets.get(scope.packetSha256)
246
+ if (!packet) return { ok: false, reason: 'packet_unknown' }
247
+ const bytes = canonicalBytes(packet)
248
+ if (!bytes || sha256Hex(bytes) !== scope.packetSha256) return { ok: false, reason: 'packet_mismatch' }
249
+ if (packet.action !== scope.action || packet.artifact !== scope.artifact || packet.version !== scope.version
250
+ || packet.digest !== scope.digest || packet.channel !== scope.channel) {
251
+ return { ok: false, reason: 'packet_mismatch' }
252
+ }
253
+ // Review B4: only the session that created the packet (request context, never body) may be
254
+ // the one this approval is delivered to.
255
+ if (packet.provider !== env.provider || packet.sessionId !== env.sessionId) return { ok: false, reason: 'packet_session_mismatch' }
256
+ if (!WEB_KEY_ENABLED_APPROVE_ACTIONS.has(scope.action)) return { ok: false, reason: 'approve_action_not_enabled' }
257
+ packetSha = scope.packetSha256
258
+ }
259
+ // authId = sha256(signature bytes) (plan §4.4): the one consume key for this authorization.
260
+ const authId = sha256Hex(fromB64url(candidate.signature) as Buffer)
261
+ // LAST: a failing envelope never burns its nonce, and a replay of a good one finds it spent.
262
+ const spent = this.deps.nonces.consume(env.nonce, authId)
263
+ if (spent !== 'ok') return { ok: false, reason: spent }
264
+ if (packetSha !== null) this.deps.packets.close(packetSha)
265
+ registry.noteUsed(env.kid)
266
+ return {
267
+ ok: true,
268
+ proof: {
269
+ kid: env.kid, kver: env.kver, epoch: env.epoch, iat: env.iat, exp: env.exp, authId,
270
+ envelope: b64url(parsed.bytes), signature: candidate.signature, scope: env.scope, wordsSha256: env.wordsSha256,
271
+ provider: env.provider, sessionId: env.sessionId, passkey: assertion, integrity: integrity.report,
272
+ },
273
+ }
274
+ }
275
+
276
+ /**
277
+ * At a queue drain or a Cursor Stop hand-off (plan §4.5): the parked approval still holds only
278
+ * while the flag is on, the signature has not expired (never re-dated), the key is not revoked,
279
+ * the epoch has not moved, integrity still passes, the words are still the signed words, and the
280
+ * parked files (the seal's manifest digest) are still the signed manifest. Null when it holds.
281
+ */
282
+ recheck(proof: WebKeyProof, words: string, attachmentsDigest: string): string | null {
283
+ try {
284
+ if (!this.enabled()) return 'web_key_authority_off'
285
+ if (Math.floor(this.now() / 1000) > proof.exp) return 'signature_expired'
286
+ const state = this.deps.registry.entryState(proof.kid, this.deps.selfDnsName())
287
+ if (!state.ok) return state.reason
288
+ if (proof.epoch !== this.deps.registry.epoch()) return 'epoch_stale'
289
+ const integrity = this.integrityNow()
290
+ if (!integrity.ok) return integrity.reason
291
+ if (wordsDigest(words).sha256 !== proof.wordsSha256) return 'words_changed'
292
+ const parsed = parseEnvelopeHeader(proof.envelope)
293
+ if (!parsed.ok) return parsed.reason
294
+ if (parsed.envelope.scope.kind === 'approve' && !WEB_KEY_ENABLED_APPROVE_ACTIONS.has(parsed.envelope.scope.action)) return 'approve_action_not_enabled'
295
+ // Review note "drain checks words, not files": the signed manifest must equal the parked one.
296
+ if (manifestDigest(parsed.envelope.attachments) !== attachmentsDigest) return 'attachments_changed'
297
+ return null
298
+ } catch {
299
+ return 'web_key_check_failed'
300
+ }
301
+ }
302
+
303
+ /**
304
+ * For GET /api/g2-turns/:id: re-verify a stored proof (it is self-proving, so a record read back
305
+ * from disk can still vouch), and say whether the key is revoked now. Null reason: it verifies.
306
+ * `record` is the ledger row's own fields: every one must equal the signed envelope (review B3),
307
+ * so a ledger edit cannot pair a genuine envelope with another session, turn or text.
308
+ */
309
+ review(proof: WebKeyProof, record: { turnId: string; target: { provider: string; sessionId: string } }): { reason: string | null; entry: Record<string, unknown> | null; facts: Record<string, unknown> | null } {
310
+ try {
311
+ if (!this.enabled()) return { reason: 'web_key_authority_off', entry: null, facts: null }
312
+ const parsed = parseEnvelopeHeader(proof.envelope)
313
+ if (!parsed.ok) return { reason: parsed.reason, entry: null, facts: null }
314
+ const env = parsed.envelope
315
+ const registry = this.deps.registry
316
+ const stored = registry.entry(env.kid)
317
+ const entry = stored ? {
318
+ kid: stored.kid, spki: stored.spki, passkeySpki: stored.passkeySpki, kver: stored.kver, label: stored.label,
319
+ approval: stored.approval, anchorSig: stored.anchorSig, ...(typeof stored.revokedAt === 'number' ? { revokedAt: stored.revokedAt } : {}),
320
+ } : null
321
+ const anchor = registry.anchor()
322
+ const integrity = this.integrityNow()
323
+ const at = this.now()
324
+ const facts: Record<string, unknown> = {
325
+ aud: env.aud,
326
+ wordsSha256: env.wordsSha256,
327
+ anchor: anchor ? { spkiSha256: anchor.spkiSha256, fingerprint: anchor.fingerprint, setAt: anchor.setAt, ageMs: Math.max(0, at - anchor.setAt), mature: registry.anchorMature() } : null,
328
+ epoch: registry.epoch(),
329
+ revoked: registry.revocations(),
330
+ revocationDigest: registry.revocationDigest(),
331
+ integrity: integrity.report ?? { enabled: false, lastCheckOk: false, checkedAt: null, manifestSha256: null },
332
+ integrityAtAccept: proof.integrity ?? null,
333
+ consumeKeys: [`turn:${env.turnId}`, `web:${env.aud}:${proof.authId}`],
334
+ }
335
+ const sigBytes = fromB64url(proof.signature)
336
+ const same = (a: unknown, b: unknown): boolean => canonicalBytes(a)?.equals(canonicalBytes(b) ?? Buffer.alloc(0)) === true
337
+ if (record.turnId !== env.turnId || record.target?.provider !== env.provider || record.target?.sessionId !== env.sessionId
338
+ || proof.kid !== env.kid || proof.kver !== env.kver || proof.epoch !== env.epoch || proof.iat !== env.iat || proof.exp !== env.exp
339
+ || proof.provider !== env.provider || proof.sessionId !== env.sessionId || proof.wordsSha256 !== env.wordsSha256
340
+ || !same(proof.scope, env.scope) || !sigBytes || proof.authId !== sha256Hex(sigBytes)) {
341
+ return { reason: 'record_envelope_mismatch', entry, facts }
342
+ }
343
+ const nowS = Math.floor(at / 1000)
344
+ if (env.exp <= env.iat || env.exp - env.iat > WEB_TURN_MAX_LIFETIME_S) return { reason: 'lifetime_invalid', entry, facts }
345
+ if (env.iat > nowS + WEB_TURN_IAT_SKEW_S) return { reason: 'iat_in_future', entry, facts }
346
+ if (nowS > env.exp) return { reason: 'signature_expired', entry, facts }
347
+ if (env.fork) return { reason: 'fork_destination_unbound', entry, facts }
348
+ if (env.scope.kind === 'approve' && !WEB_KEY_ENABLED_APPROVE_ACTIONS.has(env.scope.action)) return { reason: 'approve_action_not_enabled', entry, facts }
349
+ const refusal = registry.anchorRefusal()
350
+ if (refusal) return { reason: refusal, entry, facts }
351
+ if (!registry.anchorMature()) return { reason: 'anchor_too_new', entry, facts }
352
+ if (env.aud !== registry.aud(this.deps.selfDnsName())) return { reason: 'aud_mismatch', entry, facts }
353
+ if (env.origin !== this.deps.expectedOrigin()) return { reason: 'origin_mismatch', entry, facts }
354
+ const state = registry.entryState(env.kid, this.deps.selfDnsName())
355
+ if (!state.ok) return { reason: state.reason, entry, facts }
356
+ if (!verifyP1363(state.key, parsed.bytes, proof.signature)) return { reason: 'signature_invalid', entry, facts }
357
+ if (env.epoch !== registry.epoch()) return { reason: 'epoch_stale', entry, facts }
358
+ const rpId = webKeyRpId(this.deps.selfDnsName())
359
+ if (!proof.passkey || !rpId) return { reason: 'passkey_missing', entry, facts }
360
+ const pk = verifyPasskeyAssertion(proof.passkey, {
361
+ challenge: turnPasskeyChallenge(parsed.bytes), origin: env.origin, rpId,
362
+ credentialId: state.entry.approval.passkey_credential_id, publicKey: state.passkeyKey,
363
+ })
364
+ if (!pk.ok) return { reason: pk.reason, entry, facts }
365
+ if (!proof.integrity || proof.integrity.enabled !== true || proof.integrity.lastCheckOk !== true) return { reason: 'integrity_off', entry, facts }
366
+ if (!integrity.ok) return { reason: integrity.reason, entry, facts }
367
+ return { reason: null, entry, facts }
368
+ } catch {
369
+ return { reason: 'web_key_check_failed', entry: null, facts: null }
370
+ }
371
+ }
372
+ }
@@ -0,0 +1,98 @@
1
+ // Crypto for the enrolled phone key (6.99.31, plan §4.2-4.4). node:crypto only.
2
+ //
3
+ // Keys are ECDSA P-256 public keys as SubjectPublicKeyInfo DER, carried base64url. Signatures
4
+ // are IEEE P1363 (r||s, 64 bytes): what WebCrypto emits on the phone and what CryptoKit's
5
+ // `rawRepresentation` gives for the Secure Enclave anchor on the Mac. Every message signed in
6
+ // this protocol is the JCS (RFC 8785) encoding of one closed-shape object (shared/web-turn-envelope.ts).
7
+
8
+ import { createHash, createPublicKey, verify, type KeyObject } from 'node:crypto'
9
+ import { canonicalJson, parseWebTurnEnvelope, type WebTurnEnvelope } from '../../shared/web-turn-envelope.js'
10
+
11
+ const B64URL_RE = /^[A-Za-z0-9_-]*$/
12
+
13
+ export function b64url(buf: Buffer | Uint8Array): string {
14
+ return Buffer.from(buf).toString('base64url')
15
+ }
16
+
17
+ /** Strict base64url (no padding, no other alphabet). Null on anything else. */
18
+ export function fromB64url(s: unknown): Buffer | null {
19
+ if (typeof s !== 'string' || !B64URL_RE.test(s) || s.length % 4 === 1) return null
20
+ return Buffer.from(s, 'base64url')
21
+ }
22
+
23
+ export function sha256Hex(data: Buffer | string): string {
24
+ return createHash('sha256').update(data).digest('hex')
25
+ }
26
+
27
+ /** A P-256 public key from base64url SPKI DER, or null (wrong curve, not EC, garbage). */
28
+ export function parseP256Spki(spkiB64url: unknown): { key: KeyObject; der: Buffer } | null {
29
+ const der = fromB64url(spkiB64url)
30
+ if (!der || der.length < 60 || der.length > 200) return null
31
+ try {
32
+ const key = createPublicKey({ key: der, format: 'der', type: 'spki' })
33
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') return null
34
+ // Re-export so a key with trailing bytes or a non-canonical encoding is refused.
35
+ const canonical = key.export({ format: 'der', type: 'spki' }) as Buffer
36
+ if (!canonical.equals(der)) return null
37
+ return { key, der }
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /** `kid = base64url(sha256(SPKI))[0..22]` (plan §4.2). */
44
+ export function kidOfSpki(der: Buffer): string {
45
+ return b64url(createHash('sha256').update(der).digest()).slice(0, 22)
46
+ }
47
+
48
+ /** The fingerprint people compare: the first 16 hex of sha256(SPKI), upper case, 4 groups of 4. */
49
+ export function fingerprintOfSpki(der: Buffer): string {
50
+ const hex = sha256Hex(der).slice(0, 16).toUpperCase()
51
+ return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`
52
+ }
53
+
54
+ /** The 6-digit SAS both screens show: sha256(challenge || spki), first 4 bytes mod 10^6. */
55
+ export function sasOf(challenge: Buffer, spkiDer: Buffer): string {
56
+ const digest = createHash('sha256').update(challenge).update(spkiDer).digest()
57
+ return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, '0')
58
+ }
59
+
60
+ /** ECDSA P-256 / SHA-256 over `message`, signature P1363 r||s. Never throws. */
61
+ export function verifyP1363(key: KeyObject, message: Buffer | string, sigB64url: unknown): boolean {
62
+ const sig = fromB64url(sigB64url)
63
+ if (!sig || sig.length !== 64) return false
64
+ try {
65
+ return verify('sha256', typeof message === 'string' ? Buffer.from(message, 'utf8') : message, { key, dsaEncoding: 'ieee-p1363' }, sig)
66
+ } catch {
67
+ return false
68
+ }
69
+ }
70
+
71
+ /** The JCS bytes of a closed-shape object, or null when it is not signable. */
72
+ export function canonicalBytes(value: unknown): Buffer | null {
73
+ try { return Buffer.from(canonicalJson(value), 'utf8') } catch { return null }
74
+ }
75
+
76
+ /** The largest envelope header accepted (base64url of the JCS bytes). */
77
+ export const WEB_ENVELOPE_HEADER_MAX = 12 * 1024
78
+
79
+ export type EnvelopeParse =
80
+ | { ok: true; envelope: WebTurnEnvelope; bytes: Buffer }
81
+ | { ok: false; reason: 'envelope_malformed' | 'envelope_version_unknown' | 'scope_not_allowed' | 'envelope_not_canonical' }
82
+
83
+ /**
84
+ * The `x-cos-web-envelope` header: base64url of the envelope's JCS bytes. Its bytes must already
85
+ * be canonical (one message, one signable form), and its shape exactly `cos-web-turn/1`.
86
+ */
87
+ export function parseEnvelopeHeader(header: unknown): EnvelopeParse {
88
+ if (typeof header !== 'string' || header.length === 0 || header.length > WEB_ENVELOPE_HEADER_MAX) return { ok: false, reason: 'envelope_malformed' }
89
+ const bytes = fromB64url(header)
90
+ if (!bytes) return { ok: false, reason: 'envelope_malformed' }
91
+ let parsed: unknown
92
+ try { parsed = JSON.parse(bytes.toString('utf8')) } catch { return { ok: false, reason: 'envelope_malformed' } }
93
+ const shape = parseWebTurnEnvelope(parsed)
94
+ if (!shape.ok) return shape
95
+ const canonical = canonicalBytes(shape.envelope)
96
+ if (!canonical || !canonical.equals(bytes)) return { ok: false, reason: 'envelope_not_canonical' }
97
+ return { ok: true, envelope: shape.envelope, bytes }
98
+ }
@@ -0,0 +1,135 @@
1
+ // The handoff from Control Web (3143) to the phone key signer (3144) (6.99.31, review W2). Behind
2
+ // COS_WEB_KEY_AUTHORITY (default off; the routes in routes/web-key.ts fall through when it is off).
3
+ //
4
+ // HOW IT WORKS. The composer on 3143 posts the turn it wants approved (words, binding, its turn id).
5
+ // The server resolves the real destination from the binding and keeps it here under a random id for
6
+ // two minutes. The composer then NAVIGATES (top level, never a frame or a message) to
7
+ // `https://<host>:3144/#h=<id>`. The signer page claims the id once, from its own origin, and shows
8
+ // exactly what it will sign. The words never travel in a URL; the id is in the fragment, which is
9
+ // not sent to any server and not in a Referer.
10
+ //
11
+ // WHAT IT DOES NOT DO. A handoff is not authority: a cookie holder (so any token holder) can create
12
+ // one. Authority comes only from the phone's tap, its key and Face ID over the envelope the signer
13
+ // builds and shows from these exact bytes.
14
+
15
+ import { randomBytes } from 'node:crypto'
16
+ import { parseWebApprovalPacket, parseWebTurnScope, type WebApprovalPacket, type WebTurnScope } from '../../shared/web-turn-envelope.js'
17
+ import { canonicalBytes, sha256Hex } from './web-key-envelope.js'
18
+
19
+ export const WEB_KEY_HANDOFF_TTL_MS = 120_000
20
+ export const WEB_KEY_HANDOFF_MAX_OPEN = 8
21
+ /** The longest words a phone approval carries (the turn route takes more; a phone screen should not). */
22
+ export const WEB_KEY_HANDOFF_MAX_WORDS = 8_000
23
+
24
+ const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
25
+ const BINDING_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/
26
+ const OPAQUE = /^[A-Za-z0-9_.:-]{1,256}$/
27
+
28
+ /** The turn request the signer will post, minus the words (they travel beside it). */
29
+ export interface SignHandoffTurn {
30
+ bindingId: string
31
+ epoch: number
32
+ targetKey: string
33
+ clientTurnId: string
34
+ acknowledgedRevision?: string
35
+ boundTo?: string
36
+ }
37
+
38
+ export interface SignHandoffRequest {
39
+ words: string
40
+ turn: SignHandoffTurn
41
+ packetSha256: string | null
42
+ }
43
+
44
+ /** What the signer is told, exactly once. */
45
+ export interface SignHandoffView {
46
+ handoffId: string
47
+ exp: number
48
+ words: string
49
+ provider: string
50
+ sessionId: string
51
+ bindingId: string
52
+ turn: SignHandoffTurn
53
+ scope: WebTurnScope
54
+ /** Full canonical preimage of an approval packet, so the phone can verify and display it. */
55
+ packet: WebApprovalPacket | null
56
+ attachments: []
57
+ }
58
+
59
+ export type HandoffParse = { ok: true; request: SignHandoffRequest } | { ok: false; reason: 'invalid_request' | 'attachments_not_signable' | 'words_too_long' }
60
+
61
+ /** The composer's body: { bindingId, epoch, targetKey, clientTurnId, prompt, acknowledgedRevision?, boundTo?, packetSha256? }. */
62
+ export function parseHandoffRequest(body: unknown): HandoffParse {
63
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return { ok: false, reason: 'invalid_request' }
64
+ const b = body as Record<string, unknown>
65
+ // Files cannot be signed until the shared files block exists (contract section 6): refuse, never drop.
66
+ if (b.attachmentIds !== undefined && !(Array.isArray(b.attachmentIds) && b.attachmentIds.length === 0)) return { ok: false, reason: 'attachments_not_signable' }
67
+ if (typeof b.prompt !== 'string' || b.prompt.trim().length === 0) return { ok: false, reason: 'invalid_request' }
68
+ if (b.prompt.length > WEB_KEY_HANDOFF_MAX_WORDS) return { ok: false, reason: 'words_too_long' }
69
+ if (typeof b.bindingId !== 'string' || !BINDING_ID.test(b.bindingId)) return { ok: false, reason: 'invalid_request' }
70
+ if (!Number.isSafeInteger(b.epoch) || (b.epoch as number) < 1) return { ok: false, reason: 'invalid_request' }
71
+ if (typeof b.targetKey !== 'string' || b.targetKey.length === 0 || b.targetKey.length > 512) return { ok: false, reason: 'invalid_request' }
72
+ if (typeof b.clientTurnId !== 'string' || !TURN_ID.test(b.clientTurnId)) return { ok: false, reason: 'invalid_request' }
73
+ for (const k of ['acknowledgedRevision', 'boundTo'] as const) {
74
+ if (b[k] !== undefined && b[k] !== null && (typeof b[k] !== 'string' || !OPAQUE.test(b[k] as string))) return { ok: false, reason: 'invalid_request' }
75
+ }
76
+ if (b.packetSha256 !== undefined && b.packetSha256 !== null && (typeof b.packetSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(b.packetSha256))) return { ok: false, reason: 'invalid_request' }
77
+ const turn: SignHandoffTurn = { bindingId: b.bindingId, epoch: b.epoch as number, targetKey: b.targetKey, clientTurnId: b.clientTurnId }
78
+ if (typeof b.acknowledgedRevision === 'string') turn.acknowledgedRevision = b.acknowledgedRevision
79
+ if (typeof b.boundTo === 'string') turn.boundTo = b.boundTo
80
+ return { ok: true, request: { words: b.prompt, turn, packetSha256: typeof b.packetSha256 === 'string' ? b.packetSha256 : null } }
81
+ }
82
+
83
+ interface OpenHandoff { view: SignHandoffView; claimed: boolean }
84
+
85
+ export type HandoffClaim = { ok: true; handoff: SignHandoffView } | { ok: false; reason: 'handoff_unknown' | 'handoff_expired' | 'handoff_claimed' }
86
+
87
+ export class WebKeySignHandoffs {
88
+ private readonly open = new Map<string, OpenHandoff>()
89
+
90
+ constructor(private readonly now: () => number = Date.now, private readonly random: (n: number) => Buffer = randomBytes) {}
91
+
92
+ private prune(at: number): void {
93
+ // Expired ones stay a little past their life so a late claim reads `handoff_expired`, not unknown.
94
+ for (const [id, h] of this.open) if (at > h.view.exp + WEB_KEY_HANDOFF_TTL_MS) this.open.delete(id)
95
+ }
96
+
97
+ /** Keep one handoff for the resolved destination. The scope is checked here as the envelope parser checks it. */
98
+ create(request: SignHandoffRequest, target: { provider: string; sessionId: string }, scope: WebTurnScope = { kind: 'message' }, packet: WebApprovalPacket | null = null): { ok: true; handoffId: string; exp: number } | { ok: false; reason: 'handoff_cap_reached' | 'scope_not_allowed' | 'packet_mismatch' } {
99
+ const at = this.now()
100
+ this.prune(at)
101
+ if (parseWebTurnScope(scope) === null) return { ok: false, reason: 'scope_not_allowed' }
102
+ if (scope.kind === 'approve') {
103
+ const parsed = parseWebApprovalPacket(packet), encoded = parsed && canonicalBytes(parsed)
104
+ if (!parsed || !encoded || sha256Hex(encoded) !== scope.packetSha256 || at > parsed.exp || at < parsed.createdAt
105
+ || parsed.provider !== target.provider || parsed.sessionId !== target.sessionId
106
+ || ['action', 'artifact', 'version', 'digest', 'channel'].some(k => parsed[k as keyof WebApprovalPacket] !== scope[k as keyof typeof scope])) return { ok: false, reason: 'packet_mismatch' }
107
+ packet = { ...parsed }
108
+ } else if (packet !== null) return { ok: false, reason: 'packet_mismatch' }
109
+ let live = 0
110
+ for (const h of this.open.values()) if (!h.claimed && at <= h.view.exp) live += 1
111
+ if (live >= WEB_KEY_HANDOFF_MAX_OPEN) return { ok: false, reason: 'handoff_cap_reached' }
112
+ const handoffId = this.random(18).toString('base64url')
113
+ const exp = at + WEB_KEY_HANDOFF_TTL_MS
114
+ this.open.set(handoffId, {
115
+ claimed: false,
116
+ view: {
117
+ handoffId, exp, words: request.words, provider: target.provider, sessionId: target.sessionId,
118
+ bindingId: request.turn.bindingId, turn: { ...request.turn }, scope: { ...scope }, packet, attachments: [],
119
+ },
120
+ })
121
+ return { ok: true, handoffId, exp }
122
+ }
123
+
124
+ /** Once only, and only before it expires. */
125
+ claim(id: unknown): HandoffClaim {
126
+ const at = this.now()
127
+ this.prune(at)
128
+ const h = typeof id === 'string' ? this.open.get(id) : undefined
129
+ if (!h) return { ok: false, reason: 'handoff_unknown' }
130
+ if (at > h.view.exp) return { ok: false, reason: 'handoff_expired' }
131
+ if (h.claimed) return { ok: false, reason: 'handoff_claimed' }
132
+ h.claimed = true
133
+ return { ok: true, handoff: { ...h.view, turn: { ...h.view.turn }, scope: { ...h.view.scope }, packet: h.view.packet ? { ...h.view.packet } : null } }
134
+ }
135
+ }
@@ -0,0 +1,21 @@
1
+ // The ONE signing origin for the enrolled phone key and its passkey (6.99.31, review B2/W2).
2
+ // `expectedOrigin` (the envelope's `origin` and the request Origin header), the passkey's
3
+ // `clientDataJSON.origin` check, and the cookie gate's extra port all come from here, so they can
4
+ // never name different origins. The plan puts the signer on its own https origin, port 3144, apart
5
+ // from the big app on 3143 (transcripts, previews): the key never lives on the 3143 origin.
6
+
7
+ /** The signer's https port (plan §4.8). */
8
+ export const WEB_KEY_SIGNER_PORT = 3144
9
+
10
+ /** The normalized MagicDNS host (lower case, no trailing dot), or null. */
11
+ export function webKeyRpId(selfDnsName: string | null | undefined): string | null {
12
+ if (typeof selfDnsName !== 'string') return null
13
+ const host = selfDnsName.trim().toLowerCase().replace(/\.$/, '')
14
+ return host && /^[a-z0-9.-]+$/.test(host) ? host : null
15
+ }
16
+
17
+ /** `https://<host>:3144`, or null when this Mac's MagicDNS name is unknown. */
18
+ export function webKeySignerOrigin(selfDnsName: string | null | undefined): string | null {
19
+ const host = webKeyRpId(selfDnsName)
20
+ return host ? `https://${host}:${WEB_KEY_SIGNER_PORT}` : null
21
+ }