@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -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 +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,825 @@
1
+ // The enrolled phone key registry, enrollment ceremony, server nonces and approval packets
2
+ // (6.99.31, plan §4.2, §4.3, §4.5, §4.7). Everything here is inert unless COS_WEB_KEY_AUTHORITY
3
+ // is on (the routes and the provenance hook check the flag; this module holds state only).
4
+ //
5
+ // THE TRUST ROOT is the anchor: COS Control's Secure Enclave P-256 key, usable only after Touch ID
6
+ // (`.biometryCurrentSet`). Its public key sits in the registry file's `anchorPub`. An entry counts
7
+ // only while its `anchorSig` verifies against that anchor over the exact approval object the
8
+ // server froze at proof of possession. An entry without a valid anchorSig is ignored, so a line
9
+ // any local process writes into web-keys.json enrolls nothing.
10
+ //
11
+ // ANCHOR BOOTSTRAP (review B1). The anchor reaches this server only through POST
12
+ // /api/web-key/anchor: loopback socket, X-Cos-Token, and a self-signature by the anchor over a
13
+ // versioned statement (`cos-web-anchor/1`: spkiSha256, fingerprint, createdAt). The registry keeps
14
+ // that record next to `anchorPub` with `setAt` (server clock). An `anchorPub` in the file without
15
+ // a registration record whose self-signature verifies is refused (`anchor_unregistered`). A new or
16
+ // replaced anchor vouches nothing for WEB_ANCHOR_MATURITY_MS (24 h). `setAt` is file-backed, so a
17
+ // same-user process can backdate it: the verifier's committed pin is the real check, which is why
18
+ // the turn answer exposes the anchor's spkiSha256 and age.
19
+ //
20
+ // REVOCATION NEVER NEEDS THE ANCHOR, so a lost phone can always be revoked (by a token holder too:
21
+ // that is denial of service, logged, not forgery). Revocations in memory are never undone by a
22
+ // later read of the file: the registry is read once at boot.
23
+
24
+ import { appendFileSync, readFileSync } from 'node:fs'
25
+ import { createHash, randomBytes } from 'node:crypto'
26
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
27
+ import {
28
+ b64url, canonicalBytes, fingerprintOfSpki, fromB64url, kidOfSpki, parseP256Spki, sha256Hex, verifyP1363,
29
+ } from './web-key-envelope.js'
30
+ import {
31
+ APPROVE_ACTIONS, approveFieldsValid, canonicalJson, WEB_ANCHOR_STATEMENT_VERSION, WEB_APPROVAL_PACKET_VERSION, WEB_APPROVAL_PACKET_MAX_LIFETIME_MS, WEB_ENROLL_APPROVE_VERSION, WEB_ENROLL_PROOF_VERSION, type WebApprovalPacket,
32
+ } from '../../shared/web-turn-envelope.js'
33
+ import { coseP256ToSpki, parsePasskeyAssertion, verifyPasskeyAssertion } from './web-key-passkey.js'
34
+ import type { KeyObject } from 'node:crypto'
35
+
36
+ export const WEB_KEYS_FILE = 'web-keys.json'
37
+ export const WEB_KEY_AUDIT_FILE = 'web-key-audit.jsonl'
38
+ export const WEB_KEY_NONCES_FILE = 'web-key-nonces.json'
39
+ /** Active keys at once (plan §4.3). */
40
+ export const WEB_KEY_MAX_ACTIVE = 2
41
+ /** A pending enrollment lives this long from start. */
42
+ export const WEB_ENROLL_TTL_MS = 5 * 60_000
43
+ /** Proof-of-possession attempts before the pending request is burned. */
44
+ export const WEB_ENROLL_MAX_PROOF_ATTEMPTS = 3
45
+ /** A server nonce lives this long (the longest an envelope may live). */
46
+ export const WEB_NONCE_TTL_MS = 15 * 60_000
47
+ /** Unspent nonces held at once. A flood is refused, never evicts a live one. */
48
+ export const WEB_NONCE_MAX_OUTSTANDING = 64
49
+ /** Spent-turn tombstones (the consume endpoint) are kept this long: far past any envelope's life. */
50
+ export const WEB_CONSUMED_KEEP_MS = 7 * 24 * 3_600_000
51
+ /** An approval packet stays open this long. */
52
+ export const WEB_PACKET_TTL_MS = WEB_APPROVAL_PACKET_MAX_LIFETIME_MS
53
+ export const WEB_PACKET_MAX_OPEN = 32
54
+ const LABEL_MAX = 64
55
+ /** A new or replaced anchor vouches nothing for this long after it is registered (review B1). */
56
+ export const WEB_ANCHOR_MATURITY_MS = 24 * 3_600_000
57
+ /** How far the anchor statement's createdAt may sit from the server clock. */
58
+ export const WEB_ANCHOR_STATEMENT_SKEW_MS = 10 * 60_000
59
+
60
+ /**
61
+ * The 6-digit SAS (review W3, commit-reveal): sha256(challenge || spki || r), first 4 bytes mod 10^6.
62
+ * The phone committed to sha256(r) BEFORE the server picked the challenge, so a patched server can
63
+ * no longer grind challenges until an attacker key's SAS equals the phone's.
64
+ */
65
+ export function sasOf(challenge: Buffer, spkiDer: Buffer, phoneNonce: Buffer): string {
66
+ const digest = createHash('sha256').update(challenge).update(spkiDer).update(phoneNonce).digest()
67
+ return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, '0')
68
+ }
69
+
70
+ /** Normalize a socket address for the device allowlist and the approval (no IPv4-mapped prefix). */
71
+ export function normalizeRemote(remote: string): string {
72
+ const r = remote.trim().toLowerCase()
73
+ return r.startsWith('::ffff:') && /^\d+\.\d+\.\d+\.\d+$/.test(r.slice(7)) ? r.slice(7) : r
74
+ }
75
+
76
+ /** The device allowlist (review W6): COS_WEB_KEY_DEVICES, comma-separated addresses. Empty: none. */
77
+ export function deviceAllowlistFrom(env: NodeJS.ProcessEnv = process.env): (remote: string) => boolean {
78
+ const list = new Set((env.COS_WEB_KEY_DEVICES ?? '').split(',').map(s => normalizeRemote(s)).filter(Boolean))
79
+ return (remote: string) => list.size > 0 && list.has(normalizeRemote(remote))
80
+ }
81
+
82
+ /** The install identity the envelope's `aud` names: sha256(anchor SPKI DER || lower-case MagicDNS name), hex. */
83
+ export function audOf(anchorDer: Buffer, selfDnsName: string): string {
84
+ return sha256Hex(Buffer.concat([anchorDer, Buffer.from(selfDnsName.trim().toLowerCase(), 'utf8')]))
85
+ }
86
+
87
+ /** The exact object the anchor signs to approve one phone key (frozen by the server at proof of possession). */
88
+ /**
89
+ * Every value COS Control displays is inside these bytes (review W4): Control decodes approvalJcs
90
+ * and derives the SAS, fingerprint and label from it. `label`, `userAgent` and `remote` are text
91
+ * the enrolling request supplied; `remote` is the socket address (review W6).
92
+ */
93
+ export interface WebKeyApproval {
94
+ v: typeof WEB_ENROLL_APPROVE_VERSION
95
+ enrollId: string
96
+ kid: string
97
+ spki: string
98
+ spki_sha256: string
99
+ fingerprint: string
100
+ label: string
101
+ userAgent: string
102
+ remote: string
103
+ challenge: string
104
+ sasCommit: string
105
+ phoneNonce: string
106
+ sas: string
107
+ aud: string
108
+ origin: string
109
+ kver: number
110
+ epoch: number
111
+ /** The one key this approval revokes (review W8), or null. */
112
+ rotates: string | null
113
+ passkey_credential_id: string
114
+ passkey_spki_sha256: string
115
+ enrolledAt: number
116
+ exp: number
117
+ }
118
+
119
+ export interface WebKeyEntry {
120
+ kid: string
121
+ spki: string
122
+ label: string
123
+ enrolledAt: number
124
+ kver: number
125
+ /** The passkey's P-256 SPKI (b64url, converted from COSE at enrollment); anchored by its hash. */
126
+ passkeySpki: string
127
+ approval: WebKeyApproval
128
+ anchorSig: string
129
+ revokedAt?: number
130
+ revokeReason?: string
131
+ revokedBy?: string
132
+ lastUsedAt?: number
133
+ }
134
+
135
+ /** The anchor's bootstrap record (review B1): its self-signed statement and when it was registered. */
136
+ export interface AnchorRegistration { statement: string; signature: string; setAt: number }
137
+
138
+ interface RegistryFile { v: 1; anchorPub: string | null; anchorRegistration: AnchorRegistration | null; epoch: number; entries: WebKeyEntry[] }
139
+
140
+ export type EntryState =
141
+ | { ok: true; entry: WebKeyEntry; key: KeyObject; passkeyKey: KeyObject }
142
+ | { ok: false; reason: 'key_unknown' | 'key_unanchored' | 'key_revoked' | 'key_epoch_stale' }
143
+
144
+ export interface RegisteredAnchor { b64: string; key: KeyObject; der: Buffer; spkiSha256: string; fingerprint: string; setAt: number }
145
+
146
+ export type AnchorRegisterResult =
147
+ | { ok: true; spkiSha256: string; fingerprint: string; setAt: number; replaced: boolean }
148
+ | { ok: false; reason: 'registry_unreadable' | 'invalid_key' | 'statement_invalid' | 'statement_not_canonical' | 'statement_mismatch' | 'statement_stale' | 'anchor_signature_invalid' | 'registry_write_failed' }
149
+
150
+ /** The anchor's self-signed statement, as JCS bytes, or null when it is not exactly that shape. */
151
+ function parseAnchorStatement(bytes: Buffer): { spkiSha256: string; fingerprint: string; createdAt: number } | 'not_canonical' | null {
152
+ let v: unknown
153
+ try { v = JSON.parse(bytes.toString('utf8')) } catch { return null }
154
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return null
155
+ const o = v as Record<string, unknown>
156
+ const keys = Object.keys(o).sort()
157
+ if (keys.join(',') !== 'createdAt,fingerprint,spkiSha256,v') return null
158
+ if (o.v !== WEB_ANCHOR_STATEMENT_VERSION || typeof o.spkiSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(o.spkiSha256)) return null
159
+ if (typeof o.fingerprint !== 'string' || !/^[0-9A-F]{4}(-[0-9A-F]{4}){3}$/.test(o.fingerprint)) return null
160
+ if (typeof o.createdAt !== 'number' || !Number.isSafeInteger(o.createdAt) || o.createdAt < 0) return null
161
+ const canonical = canonicalBytes(o)
162
+ if (!canonical || !canonical.equals(bytes)) return 'not_canonical'
163
+ return { spkiSha256: o.spkiSha256, fingerprint: o.fingerprint, createdAt: o.createdAt }
164
+ }
165
+
166
+ export class WebKeyRegistry {
167
+ private data: RegistryFile = { v: 1, anchorPub: null, anchorRegistration: null, epoch: 0, entries: [] }
168
+ /** The file could not be read as a registry: everything refuses and nothing is written over it. */
169
+ readonly unreadable: boolean
170
+ private anchorCache: { key: string; anchor: RegisteredAnchor | null } | null = null
171
+
172
+ constructor(private readonly file: string, private readonly auditFile: string, private readonly now: () => number = Date.now) {
173
+ let unreadable = false
174
+ let raw: string | null = null
175
+ try { raw = readFileSync(file, 'utf8') } catch (error) {
176
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') unreadable = true
177
+ }
178
+ if (raw !== null) {
179
+ try {
180
+ const parsed = JSON.parse(raw) as Partial<RegistryFile>
181
+ if (parsed && parsed.v === 1 && Array.isArray(parsed.entries) && Number.isSafeInteger(parsed.epoch) && (parsed.anchorPub === null || typeof parsed.anchorPub === 'string')) {
182
+ const reg = parsed.anchorRegistration
183
+ const anchorRegistration = reg && typeof reg === 'object' && typeof reg.statement === 'string' && typeof reg.signature === 'string' && Number.isSafeInteger(reg.setAt)
184
+ ? { statement: reg.statement, signature: reg.signature, setAt: reg.setAt } : null
185
+ this.data = { v: 1, anchorPub: parsed.anchorPub ?? null, anchorRegistration, epoch: parsed.epoch as number, entries: parsed.entries.filter(e => e && typeof e.kid === 'string') }
186
+ } else unreadable = true
187
+ } catch { unreadable = true }
188
+ }
189
+ this.unreadable = unreadable
190
+ if (unreadable) console.error(`[web-key] registry ${file} is unreadable: every enrollment and signed turn is refused until it is repaired`)
191
+ }
192
+
193
+ /**
194
+ * The registered anchor, or null: none in the file, not a P-256 key, the registry unreadable, or
195
+ * no bootstrap record whose self-signature verifies over a statement naming this very key (B1).
196
+ */
197
+ anchor(): RegisteredAnchor | null {
198
+ if (this.unreadable || !this.data.anchorPub) return null
199
+ const reg = this.data.anchorRegistration
200
+ const cacheKey = `${this.data.anchorPub}|${reg?.statement ?? ''}|${reg?.signature ?? ''}|${reg?.setAt ?? ''}`
201
+ if (this.anchorCache?.key === cacheKey) return this.anchorCache.anchor
202
+ let anchor: RegisteredAnchor | null = null
203
+ const parsed = parseP256Spki(this.data.anchorPub)
204
+ if (parsed && reg) {
205
+ const bytes = fromB64url(reg.statement)
206
+ const stmt = bytes ? parseAnchorStatement(bytes) : null
207
+ const spkiSha256 = sha256Hex(parsed.der)
208
+ const fingerprint = fingerprintOfSpki(parsed.der)
209
+ if (bytes && stmt && stmt !== 'not_canonical' && stmt.spkiSha256 === spkiSha256 && stmt.fingerprint === fingerprint
210
+ && verifyP1363(parsed.key, bytes, reg.signature)) {
211
+ anchor = { b64: this.data.anchorPub, key: parsed.key, der: parsed.der, spkiSha256, fingerprint, setAt: reg.setAt }
212
+ }
213
+ }
214
+ this.anchorCache = { key: cacheKey, anchor }
215
+ return anchor
216
+ }
217
+
218
+ /** Why there is no usable anchor: none at all, or a key without a valid bootstrap record. Null when there is one. */
219
+ anchorRefusal(): 'anchor_not_registered' | 'anchor_unregistered' | null {
220
+ if (this.anchor()) return null
221
+ return this.data.anchorPub && !this.unreadable ? 'anchor_unregistered' : 'anchor_not_registered'
222
+ }
223
+
224
+ /** Whether the registered anchor is past WEB_ANCHOR_MATURITY_MS (a new or replaced anchor vouches nothing before). */
225
+ anchorMature(): boolean {
226
+ const a = this.anchor()
227
+ return a !== null && this.now() - a.setAt >= WEB_ANCHOR_MATURITY_MS
228
+ }
229
+
230
+ /**
231
+ * Register (or replace) the anchor from COS Control's bootstrap: the anchor's own signature over
232
+ * its `cos-web-anchor/1` statement. The ROUTE enforces loopback and the token. A replacement
233
+ * revokes every key (their approvals were signed by the old anchor) and bumps the epoch.
234
+ */
235
+ registerAnchor(input: { anchorPub: unknown; statement: unknown; signature: unknown }, by: string): AnchorRegisterResult {
236
+ if (this.unreadable) return { ok: false, reason: 'registry_unreadable' }
237
+ const parsed = parseP256Spki(input.anchorPub)
238
+ if (!parsed) return { ok: false, reason: 'invalid_key' }
239
+ const bytes = fromB64url(input.statement)
240
+ if (!bytes || bytes.length > 1024) return { ok: false, reason: 'statement_invalid' }
241
+ const stmt = parseAnchorStatement(bytes)
242
+ if (stmt === null) return { ok: false, reason: 'statement_invalid' }
243
+ if (stmt === 'not_canonical') return { ok: false, reason: 'statement_not_canonical' }
244
+ const spkiSha256 = sha256Hex(parsed.der)
245
+ const fingerprint = fingerprintOfSpki(parsed.der)
246
+ if (stmt.spkiSha256 !== spkiSha256 || stmt.fingerprint !== fingerprint) return { ok: false, reason: 'statement_mismatch' }
247
+ if (Math.abs(this.now() - stmt.createdAt) > WEB_ANCHOR_STATEMENT_SKEW_MS) return { ok: false, reason: 'statement_stale' }
248
+ if (!verifyP1363(parsed.key, bytes, input.signature)) return { ok: false, reason: 'anchor_signature_invalid' }
249
+ const before = JSON.stringify(this.data)
250
+ const previous = this.data.anchorPub
251
+ const replaced = previous !== null && previous !== b64url(parsed.der)
252
+ const setAt = this.now()
253
+ this.data.anchorPub = b64url(parsed.der)
254
+ this.data.anchorRegistration = { statement: input.statement as string, signature: input.signature as string, setAt }
255
+ if (replaced) {
256
+ for (const e of this.data.entries) if (typeof e.revokedAt !== 'number') { e.revokedAt = setAt; e.revokeReason = 'anchor_replaced'; e.revokedBy = by }
257
+ this.data.epoch += 1
258
+ }
259
+ if (!this.persist()) { this.data = JSON.parse(before) as RegistryFile; return { ok: false, reason: 'registry_write_failed' } }
260
+ this.audit('anchor_registered', { spkiSha256, fingerprint, setAt, replaced, by, epoch: this.data.epoch })
261
+ return { ok: true, spkiSha256, fingerprint, setAt, replaced }
262
+ }
263
+
264
+ /** The revoked keys, sorted by kid (review W5: the verifier compares with a Control-held list). */
265
+ revocations(): Array<{ kid: string; revokedAt: number }> {
266
+ return this.data.entries.filter(e => typeof e.revokedAt === 'number')
267
+ .map(e => ({ kid: e.kid, revokedAt: e.revokedAt as number }))
268
+ .sort((a, b) => (a.kid < b.kid ? -1 : a.kid > b.kid ? 1 : 0))
269
+ }
270
+
271
+ /** sha256 hex of the JCS of `revocations()`. */
272
+ revocationDigest(): string {
273
+ return sha256Hex(canonicalJson(this.revocations()))
274
+ }
275
+
276
+ aud(selfDnsName: string | null): string | null {
277
+ const anchor = this.anchor()
278
+ if (!anchor || typeof selfDnsName !== 'string' || selfDnsName.trim() === '') return null
279
+ return audOf(anchor.der, selfDnsName)
280
+ }
281
+
282
+ epoch(): number { return this.data.epoch }
283
+
284
+ /** Whether this entry's anchorSig verifies over its own approval, and the approval names this key and its passkey. */
285
+ private anchored(entry: WebKeyEntry, aud: string | null): { key: KeyObject; passkeyKey: KeyObject } | null {
286
+ const anchor = this.anchor()
287
+ if (!anchor || aud === null) return null
288
+ const parsed = parseP256Spki(entry.spki)
289
+ const passkey = parseP256Spki(entry.passkeySpki)
290
+ if (!parsed || !passkey) return null
291
+ const a = entry.approval
292
+ if (!a || a.v !== WEB_ENROLL_APPROVE_VERSION || a.kid !== entry.kid || a.kid !== kidOfSpki(parsed.der)) return null
293
+ if (a.spki !== entry.spki || a.spki_sha256 !== sha256Hex(parsed.der) || a.aud !== aud || a.kver !== entry.kver) return null
294
+ if (typeof a.passkey_credential_id !== 'string' || a.passkey_credential_id.length === 0 || a.passkey_spki_sha256 !== sha256Hex(passkey.der)) return null
295
+ const bytes = canonicalBytes(a)
296
+ if (!bytes || !verifyP1363(anchor.key, bytes, entry.anchorSig)) return null
297
+ return { key: parsed.key, passkeyKey: passkey.key }
298
+ }
299
+
300
+ entryState(kid: string, selfDnsName: string | null): EntryState {
301
+ if (this.unreadable) return { ok: false, reason: 'key_unknown' }
302
+ const entry = this.data.entries.find(e => e.kid === kid)
303
+ if (!entry) return { ok: false, reason: 'key_unknown' }
304
+ if (typeof entry.revokedAt === 'number') return { ok: false, reason: 'key_revoked' }
305
+ const keys = this.anchored(entry, this.aud(selfDnsName))
306
+ if (!keys) return { ok: false, reason: 'key_unanchored' }
307
+ // Review W5: an approval from another epoch never counts (a revoke-all bumps the epoch, so a
308
+ // file restored with an old key and the current epoch still refuses it).
309
+ if (entry.approval.epoch !== this.data.epoch) return { ok: false, reason: 'key_epoch_stale' }
310
+ return { ok: true, entry, ...keys }
311
+ }
312
+
313
+ activeCount(selfDnsName: string | null): number {
314
+ return this.data.entries.filter(e => this.entryState(e.kid, selfDnsName).ok).length
315
+ }
316
+
317
+ /** Every entry as Control and the phone list it. Never the anchorSig's secret (there is none), never more than this. */
318
+ list(selfDnsName: string | null): Array<Record<string, unknown>> {
319
+ return this.data.entries.map((e) => {
320
+ const der = fromB64url(e.spki)
321
+ const state = this.entryState(e.kid, selfDnsName)
322
+ return {
323
+ kid: e.kid,
324
+ label: e.label,
325
+ fingerprint: der ? fingerprintOfSpki(der) : null,
326
+ enrolledAt: e.enrolledAt,
327
+ kver: e.kver,
328
+ lastUsedAt: e.lastUsedAt ?? null,
329
+ active: state.ok,
330
+ state: state.ok ? 'active' : state.reason,
331
+ ...(typeof e.revokedAt === 'number' ? { revokedAt: e.revokedAt, revokeReason: e.revokeReason ?? null } : {}),
332
+ }
333
+ })
334
+ }
335
+
336
+ /** The stored entry (for the turn answer and the verifier): the approval and anchorSig are self-proving. */
337
+ entry(kid: string): WebKeyEntry | null {
338
+ return this.data.entries.find(e => e.kid === kid) ?? null
339
+ }
340
+
341
+ private persist(): boolean {
342
+ if (this.unreadable) return false
343
+ try {
344
+ durableAtomicWriteFileSync(this.file, `${JSON.stringify(this.data, null, 2)}\n`)
345
+ return true
346
+ } catch (error) {
347
+ console.error(`[web-key] registry write failed: ${error instanceof Error ? error.message : error}`)
348
+ return false
349
+ }
350
+ }
351
+
352
+ audit(event: string, fields: Record<string, unknown> = {}): void {
353
+ try {
354
+ appendFileSync(this.auditFile, `${JSON.stringify({ at: new Date(this.now()).toISOString(), event, ...fields })}\n`, { encoding: 'utf8', mode: 0o600 })
355
+ } catch (error) {
356
+ console.error(`[web-key] audit append failed: ${error instanceof Error ? error.message : error}`)
357
+ }
358
+ }
359
+
360
+ /** Add an approved entry. False (nothing changed) when the registry cannot be written. */
361
+ addEntry(entry: WebKeyEntry): boolean {
362
+ if (this.unreadable || this.data.entries.some(e => e.kid === entry.kid)) return false
363
+ this.data.entries.push(entry)
364
+ if (!this.persist()) { this.data.entries.pop(); return false }
365
+ return true
366
+ }
367
+
368
+ /** Revoke one key. No anchor needed. Idempotent; false for an unknown kid. */
369
+ revoke(kid: string, reason: string, by: string): boolean {
370
+ const entry = this.data.entries.find(e => e.kid === kid)
371
+ if (!entry) return false
372
+ if (typeof entry.revokedAt !== 'number') {
373
+ entry.revokedAt = this.now()
374
+ entry.revokeReason = reason.slice(0, 64)
375
+ entry.revokedBy = by
376
+ // Revoked in memory even when the write fails: it holds for this process, and the next write carries it.
377
+ this.persist()
378
+ }
379
+ this.audit('revoke', { kid, reason: entry.revokeReason, by })
380
+ return true
381
+ }
382
+
383
+ /** Revoke every key and bump the epoch, which voids every outstanding envelope. No anchor needed. */
384
+ revokeAll(by: string): number {
385
+ const at = this.now()
386
+ for (const e of this.data.entries) {
387
+ if (typeof e.revokedAt !== 'number') { e.revokedAt = at; e.revokeReason = 'revoke_all'; e.revokedBy = by }
388
+ }
389
+ this.data.epoch += 1
390
+ this.persist()
391
+ this.audit('revoke_all', { epoch: this.data.epoch, by })
392
+ return this.data.epoch
393
+ }
394
+
395
+ noteUsed(kid: string): void {
396
+ const entry = this.data.entries.find(e => e.kid === kid)
397
+ if (entry) { entry.lastUsedAt = this.now(); this.persist() }
398
+ }
399
+ }
400
+
401
+ // ---------------------------------------------------------------------------------------------
402
+ // Enrollment ceremony (plan §4.3). One pending request at a time, held in memory only.
403
+ // ---------------------------------------------------------------------------------------------
404
+
405
+ interface PendingEnrollment {
406
+ enrollId: string
407
+ challenge: Buffer
408
+ spkiB64: string
409
+ spkiDer: Buffer
410
+ kid: string
411
+ label: string
412
+ userAgent: string
413
+ remote: string
414
+ startedAt: number
415
+ exp: number
416
+ attempts: number
417
+ aud: string
418
+ origin: string
419
+ rpId: string
420
+ epoch: number
421
+ rotates: string | null
422
+ /** sha256(r) the phone committed to BEFORE the challenge was picked (review W3). */
423
+ sasCommit: string
424
+ proven: { sas: string; approval: WebKeyApproval; passkeySpki: string } | null
425
+ }
426
+
427
+ export type EnrollStartResult =
428
+ | { ok: true; enrollId: string; challenge: string; aud: string; origin: string; rpId: string; kid: string; exp: number }
429
+ | { ok: false; reason: 'anchor_not_registered' | 'anchor_unregistered' | 'aud_unavailable' | 'origin_unavailable' | 'registry_unreadable' | 'invalid_key' | 'invalid_label' | 'invalid_commit' | 'device_not_allowed' | 'enrollment_pending' | 'key_cap_reached' | 'key_already_enrolled' | 'rotate_unknown_key' }
430
+
431
+ export type EnrollProveResult =
432
+ | { ok: true; sas: string; fingerprint: string }
433
+ | { ok: false; reason: 'enroll_unknown' | 'already_proven' | 'proof_invalid' | 'enroll_burned' | 'commit_mismatch' | 'passkey_malformed' | 'passkey_invalid' }
434
+
435
+ export interface WebKeyEnrollmentOptions {
436
+ now?: () => number
437
+ random?: (n: number) => Buffer
438
+ /** The device allowlist (review W6). Absent: every start is refused. */
439
+ deviceAllowed?: (remote: string) => boolean
440
+ }
441
+
442
+ export class WebKeyEnrollments {
443
+ private pending: PendingEnrollment | null = null
444
+ private readonly outcomes = new Map<string, 'enrolled' | 'expired' | 'burned' | 'refused'>()
445
+ private readonly now: () => number
446
+ private readonly random: (n: number) => Buffer
447
+ private readonly deviceAllowed: (remote: string) => boolean
448
+
449
+ constructor(private readonly registry: WebKeyRegistry, opts: WebKeyEnrollmentOptions = {}) {
450
+ this.now = opts.now ?? Date.now
451
+ this.random = opts.random ?? randomBytes
452
+ this.deviceAllowed = opts.deviceAllowed ?? (() => false)
453
+ }
454
+
455
+ private settle(outcome: 'enrolled' | 'expired' | 'burned' | 'refused'): void {
456
+ if (!this.pending) return
457
+ this.outcomes.set(this.pending.enrollId, outcome)
458
+ while (this.outcomes.size > 32) this.outcomes.delete(this.outcomes.keys().next().value as string)
459
+ this.pending = null
460
+ }
461
+
462
+ /** The pending request, or null; an expired one is settled here. */
463
+ private live(): PendingEnrollment | null {
464
+ if (this.pending && this.now() > this.pending.exp) {
465
+ this.registry.audit('enroll_expired', { enrollId: this.pending.enrollId, kid: this.pending.kid })
466
+ this.settle('expired')
467
+ }
468
+ return this.pending
469
+ }
470
+
471
+ start(input: { spki: unknown; label: unknown; userAgent: string; remote: string; rotates?: unknown; sasCommit?: unknown }, ctx: { selfDnsName: string | null; origin: string | null }): EnrollStartResult {
472
+ if (this.registry.unreadable) return { ok: false, reason: 'registry_unreadable' }
473
+ const refusal = this.registry.anchorRefusal()
474
+ if (refusal) return { ok: false, reason: refusal }
475
+ const aud = this.registry.aud(ctx.selfDnsName)
476
+ if (!aud || !ctx.selfDnsName) return { ok: false, reason: 'aud_unavailable' }
477
+ if (!ctx.origin) return { ok: false, reason: 'origin_unavailable' }
478
+ const remote = normalizeRemote(input.remote)
479
+ // Review W6: only a device on the allowlist may start (the default, empty, refuses everyone).
480
+ if (!this.deviceAllowed(remote)) {
481
+ this.registry.audit('enroll_refused_device', { remote })
482
+ return { ok: false, reason: 'device_not_allowed' }
483
+ }
484
+ const parsed = parseP256Spki(input.spki)
485
+ if (!parsed) return { ok: false, reason: 'invalid_key' }
486
+ const label = typeof input.label === 'string' ? input.label.trim() : ''
487
+ if (label.length === 0 || label.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(label)) return { ok: false, reason: 'invalid_label' }
488
+ if (typeof input.sasCommit !== 'string' || !/^[0-9a-f]{64}$/.test(input.sasCommit)) return { ok: false, reason: 'invalid_commit' }
489
+ // One pending request at a time: a new start while one is pending is refused, never replaces it.
490
+ if (this.live()) return { ok: false, reason: 'enrollment_pending' }
491
+ const kid = kidOfSpki(parsed.der)
492
+ // An existing key is never replaced (a revoked one is not re-admitted under its old kid either).
493
+ if (this.registry.entry(kid)) return { ok: false, reason: 'key_already_enrolled' }
494
+ let rotates: string | null = null
495
+ if (input.rotates !== undefined && input.rotates !== null) {
496
+ if (typeof input.rotates !== 'string' || !this.registry.entryState(input.rotates, ctx.selfDnsName).ok) return { ok: false, reason: 'rotate_unknown_key' }
497
+ rotates = input.rotates
498
+ }
499
+ // A rotation replaces one active key, so it may start at the cap.
500
+ if (rotates === null && this.registry.activeCount(ctx.selfDnsName) >= WEB_KEY_MAX_ACTIVE) return { ok: false, reason: 'key_cap_reached' }
501
+ const at = this.now()
502
+ this.pending = {
503
+ enrollId: b64url(this.random(16)),
504
+ // Picked AFTER the phone committed to sha256(r): the server cannot steer the SAS (W3).
505
+ challenge: this.random(32),
506
+ spkiB64: b64url(parsed.der),
507
+ spkiDer: parsed.der,
508
+ kid,
509
+ label,
510
+ userAgent: input.userAgent.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 200),
511
+ remote,
512
+ startedAt: at,
513
+ exp: at + WEB_ENROLL_TTL_MS,
514
+ attempts: 0,
515
+ aud,
516
+ origin: ctx.origin,
517
+ rpId: ctx.selfDnsName,
518
+ epoch: this.registry.epoch(),
519
+ rotates,
520
+ sasCommit: input.sasCommit,
521
+ proven: null,
522
+ }
523
+ this.registry.audit('enroll_start', { enrollId: this.pending.enrollId, kid, label, remote, rotates })
524
+ return { ok: true, enrollId: this.pending.enrollId, challenge: b64url(this.pending.challenge), aud, origin: ctx.origin, rpId: ctx.selfDnsName, kid, exp: this.pending.exp }
525
+ }
526
+
527
+ /** The exact object the phone key signs to prove possession, binding its passkey and its SAS nonce. */
528
+ static proofMessage(p: { enrollId: string; challenge: string; aud: string; origin: string; kid: string; phoneNonce: string; passkeyCredentialId: string; passkeySpkiSha256: string }): Record<string, unknown> {
529
+ return {
530
+ v: WEB_ENROLL_PROOF_VERSION, enrollId: p.enrollId, challenge: p.challenge, aud: p.aud, origin: p.origin, kid: p.kid,
531
+ phoneNonce: p.phoneNonce, passkey_credential_id: p.passkeyCredentialId, passkey_spki_sha256: p.passkeySpkiSha256,
532
+ }
533
+ }
534
+
535
+ private failProof(p: PendingEnrollment, reason: 'proof_invalid' | 'commit_mismatch' | 'passkey_malformed' | 'passkey_invalid'): EnrollProveResult {
536
+ p.attempts += 1
537
+ this.registry.audit('enroll_proof_failed', { enrollId: p.enrollId, kid: p.kid, attempts: p.attempts, reason })
538
+ if (p.attempts >= WEB_ENROLL_MAX_PROOF_ATTEMPTS) {
539
+ this.registry.audit('enroll_burned', { enrollId: p.enrollId, kid: p.kid })
540
+ this.settle('burned')
541
+ return { ok: false, reason: 'enroll_burned' }
542
+ }
543
+ return { ok: false, reason }
544
+ }
545
+
546
+ /**
547
+ * Proof of possession: the phone key's signature, the SAS nonce revealed against its commitment
548
+ * (W3), and a user-verified passkey assertion over the proof bytes, whose credential id and key
549
+ * hash go into the anchor-signed approval (B2).
550
+ */
551
+ prove(input: { enrollId: unknown; signature: unknown; phoneNonce?: unknown; passkey?: unknown }): EnrollProveResult {
552
+ const p = this.live()
553
+ if (!p || p.enrollId !== input.enrollId) return { ok: false, reason: 'enroll_unknown' }
554
+ if (p.proven) return { ok: false, reason: 'already_proven' }
555
+ const r = fromB64url(input.phoneNonce)
556
+ if (!r || r.length !== 32 || sha256Hex(r) !== p.sasCommit) return this.failProof(p, 'commit_mismatch')
557
+ const pk = input.passkey && typeof input.passkey === 'object' ? input.passkey as Record<string, unknown> : null
558
+ const passkeyDer = pk ? coseP256ToSpki(pk.publicKey) : null
559
+ const assertion = pk ? parsePasskeyAssertion(pk.assertion) : null
560
+ const credentialId = pk && typeof pk.credentialId === 'string' && fromB64url(pk.credentialId) && pk.credentialId.length <= 1024 ? pk.credentialId : null
561
+ if (!passkeyDer || !assertion || !credentialId) return this.failProof(p, 'passkey_malformed')
562
+ const passkeySpkiSha256 = sha256Hex(passkeyDer)
563
+ const parsed = parseP256Spki(p.spkiB64)
564
+ const msg = canonicalBytes(WebKeyEnrollments.proofMessage({
565
+ enrollId: p.enrollId, challenge: b64url(p.challenge), aud: p.aud, origin: p.origin, kid: p.kid,
566
+ phoneNonce: b64url(r), passkeyCredentialId: credentialId, passkeySpkiSha256,
567
+ }))
568
+ if (!parsed || !msg || !verifyP1363(parsed.key, msg, input.signature)) return this.failProof(p, 'proof_invalid')
569
+ const passkeyKey = parseP256Spki(b64url(passkeyDer))
570
+ const verdict = passkeyKey ? verifyPasskeyAssertion(assertion, {
571
+ challenge: createHash('sha256').update(msg).digest(), origin: p.origin, rpId: p.rpId, credentialId, publicKey: passkeyKey.key,
572
+ }) : { ok: false as const }
573
+ if (!verdict.ok) return this.failProof(p, 'passkey_invalid')
574
+ const sas = sasOf(p.challenge, p.spkiDer, r)
575
+ p.proven = {
576
+ sas,
577
+ passkeySpki: b64url(passkeyDer),
578
+ approval: {
579
+ v: WEB_ENROLL_APPROVE_VERSION,
580
+ enrollId: p.enrollId,
581
+ kid: p.kid,
582
+ spki: p.spkiB64,
583
+ spki_sha256: sha256Hex(p.spkiDer),
584
+ fingerprint: fingerprintOfSpki(p.spkiDer),
585
+ label: p.label,
586
+ userAgent: p.userAgent,
587
+ remote: p.remote,
588
+ challenge: b64url(p.challenge),
589
+ sasCommit: p.sasCommit,
590
+ phoneNonce: b64url(r),
591
+ sas,
592
+ aud: p.aud,
593
+ origin: p.origin,
594
+ kver: 1,
595
+ epoch: p.epoch,
596
+ rotates: p.rotates,
597
+ passkey_credential_id: credentialId,
598
+ passkey_spki_sha256: passkeySpkiSha256,
599
+ enrolledAt: this.now(),
600
+ exp: p.exp,
601
+ },
602
+ }
603
+ this.registry.audit('enroll_proven', { enrollId: p.enrollId, kid: p.kid })
604
+ return { ok: true, sas, fingerprint: fingerprintOfSpki(p.spkiDer) }
605
+ }
606
+
607
+ /**
608
+ * What COS Control signs, and nothing displayable outside it (review W4): Control decodes
609
+ * `approvalJcs` and shows the SAS, fingerprint, label and device from those bytes only.
610
+ */
611
+ pendingView(): Record<string, unknown> | null {
612
+ const p = this.live()
613
+ if (!p || !p.proven) return null
614
+ const approvalBytes = canonicalBytes(p.proven.approval)
615
+ if (!approvalBytes) return null
616
+ return {
617
+ enrollId: p.enrollId,
618
+ startedAt: p.startedAt,
619
+ exp: p.exp,
620
+ approval: JSON.parse(approvalBytes.toString('utf8')) as Record<string, unknown>,
621
+ /** base64url of the exact JCS bytes the anchor must sign. */
622
+ approvalJcs: b64url(approvalBytes),
623
+ }
624
+ }
625
+
626
+ /** Control posts the anchor's signature over the frozen approval. Token alone is never enough. */
627
+ approve(enrollId: unknown, anchorSig: unknown, selfDnsName: string | null): { ok: true; kid: string } | { ok: false; reason: 'enroll_unknown' | 'not_proven' | 'anchor_not_registered' | 'anchor_unregistered' | 'anchor_signature_invalid' | 'epoch_changed' | 'key_cap_reached' | 'registry_write_failed' | 'aud_changed' } {
628
+ const p = this.live()
629
+ if (!p || p.enrollId !== enrollId) return { ok: false, reason: 'enroll_unknown' }
630
+ if (!p.proven) return { ok: false, reason: 'not_proven' }
631
+ const anchor = this.registry.anchor()
632
+ if (!anchor) return { ok: false, reason: this.registry.anchorRefusal() ?? 'anchor_not_registered' }
633
+ const bytes = canonicalBytes(p.proven.approval)
634
+ if (!bytes || !verifyP1363(anchor.key, bytes, anchorSig)) {
635
+ this.registry.audit('enroll_approve_refused', { enrollId: p.enrollId, kid: p.kid, reason: 'anchor_signature_invalid' })
636
+ return { ok: false, reason: 'anchor_signature_invalid' }
637
+ }
638
+ if (this.registry.aud(selfDnsName) !== p.aud) return { ok: false, reason: 'aud_changed' }
639
+ if (this.registry.epoch() !== p.epoch) { this.settle('refused'); return { ok: false, reason: 'epoch_changed' } }
640
+ if (p.rotates === null && this.registry.activeCount(selfDnsName) >= WEB_KEY_MAX_ACTIVE) return { ok: false, reason: 'key_cap_reached' }
641
+ const entry: WebKeyEntry = {
642
+ kid: p.kid,
643
+ spki: p.spkiB64,
644
+ label: p.label,
645
+ enrolledAt: p.proven.approval.enrolledAt,
646
+ kver: 1,
647
+ passkeySpki: p.proven.passkeySpki,
648
+ approval: p.proven.approval,
649
+ anchorSig: String(anchorSig),
650
+ }
651
+ if (!this.registry.addEntry(entry)) return { ok: false, reason: 'registry_write_failed' }
652
+ this.registry.audit('enroll_approved', { enrollId: p.enrollId, kid: p.kid, rotates: p.proven.approval.rotates })
653
+ // Review W8: the key revoked is the one the ANCHOR signed in `rotates`, never a server-side field.
654
+ const rotated = p.proven.approval.rotates
655
+ this.settle('enrolled')
656
+ if (rotated) this.registry.revoke(rotated, 'rotated', `rotation:${entry.kid}`)
657
+ return { ok: true, kid: entry.kid }
658
+ }
659
+
660
+ status(enrollId: unknown): string {
661
+ const p = this.live()
662
+ if (p && p.enrollId === enrollId) return p.proven ? 'proven' : 'started'
663
+ return (typeof enrollId === 'string' && this.outcomes.get(enrollId)) || 'unknown'
664
+ }
665
+ }
666
+
667
+ // ---------------------------------------------------------------------------------------------
668
+ // Server nonces and the spend store (plan §4.5): durable, atomic, one winner.
669
+ // ---------------------------------------------------------------------------------------------
670
+
671
+ interface NonceRow { exp: number; spentAt?: number; authId?: string }
672
+ interface ConsumedRow { at: number; session: string; provider: string }
673
+ interface NonceFile { v: 1; nonces: Record<string, NonceRow>; consumed: Record<string, ConsumedRow> }
674
+
675
+ export type NonceConsume = 'ok' | 'nonce_unknown' | 'nonce_spent' | 'nonce_expired' | 'nonce_store_unavailable'
676
+
677
+ export class WebKeyNonces {
678
+ private data: NonceFile = { v: 1, nonces: {}, consumed: {} }
679
+ /** The store could not be read: nothing is issued or accepted (fail closed on replay storage). */
680
+ readonly unavailable: boolean
681
+
682
+ constructor(private readonly file: string, private readonly now: () => number = Date.now, private readonly random: (n: number) => Buffer = randomBytes) {
683
+ let unavailable = false
684
+ let raw: string | null = null
685
+ try { raw = readFileSync(file, 'utf8') } catch (error) {
686
+ if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') unavailable = true
687
+ }
688
+ if (raw !== null) {
689
+ try {
690
+ const parsed = JSON.parse(raw) as Partial<NonceFile>
691
+ if (parsed && parsed.v === 1 && parsed.nonces && typeof parsed.nonces === 'object' && parsed.consumed && typeof parsed.consumed === 'object') {
692
+ this.data = { v: 1, nonces: { ...parsed.nonces }, consumed: { ...parsed.consumed } }
693
+ } else unavailable = true
694
+ } catch { unavailable = true }
695
+ }
696
+ this.unavailable = unavailable
697
+ if (unavailable) console.error(`[web-key] nonce store ${file} is unreadable: signed turns are refused until it is repaired`)
698
+ }
699
+
700
+ private prune(at: number): void {
701
+ // A spent nonce is kept an hour past its expiry; acceptance needs an issued, unspent row anyway.
702
+ for (const [n, row] of Object.entries(this.data.nonces)) if (at > row.exp + 3_600_000) delete this.data.nonces[n]
703
+ for (const [id, row] of Object.entries(this.data.consumed)) if (at - row.at > WEB_CONSUMED_KEEP_MS) delete this.data.consumed[id]
704
+ }
705
+
706
+ private persist(): boolean {
707
+ try {
708
+ durableAtomicWriteFileSync(this.file, `${JSON.stringify(this.data)}\n`)
709
+ return true
710
+ } catch (error) {
711
+ console.error(`[web-key] nonce store write failed: ${error instanceof Error ? error.message : error}`)
712
+ return false
713
+ }
714
+ }
715
+
716
+ issue(): { ok: true; nonce: string; exp: number } | { ok: false; reason: 'nonce_store_unavailable' | 'nonce_cap_reached' } {
717
+ if (this.unavailable) return { ok: false, reason: 'nonce_store_unavailable' }
718
+ const at = this.now()
719
+ this.prune(at)
720
+ const outstanding = Object.values(this.data.nonces).filter(r => r.spentAt === undefined && r.exp >= at).length
721
+ if (outstanding >= WEB_NONCE_MAX_OUTSTANDING) return { ok: false, reason: 'nonce_cap_reached' }
722
+ const nonce = b64url(this.random(16))
723
+ this.data.nonces[nonce] = { exp: at + WEB_NONCE_TTL_MS }
724
+ if (!this.persist()) { delete this.data.nonces[nonce]; return { ok: false, reason: 'nonce_store_unavailable' } }
725
+ return { ok: true, nonce, exp: at + WEB_NONCE_TTL_MS }
726
+ }
727
+
728
+ /**
729
+ * Spend a nonce for one authorization. Synchronous from check to set (one winner). A nonce is
730
+ * spent in memory before the write; a failed write still refuses the turn, so a restart can never
731
+ * find an unspent row for an authorization this process accepted.
732
+ */
733
+ consume(nonce: string, authId: string): NonceConsume {
734
+ if (this.unavailable) return 'nonce_store_unavailable'
735
+ const row = this.data.nonces[nonce]
736
+ if (!row) return 'nonce_unknown'
737
+ if (row.spentAt !== undefined) return 'nonce_spent'
738
+ if (this.now() > row.exp) return 'nonce_expired'
739
+ row.spentAt = this.now()
740
+ row.authId = authId
741
+ return this.persist() ? 'ok' : 'nonce_store_unavailable'
742
+ }
743
+
744
+ /**
745
+ * The consume endpoint (plan §4.5, review B3): one server-enforced winner per authorization. Every
746
+ * key (`turn:<turnId>` and `web:<aud>:<authId>`) is checked and set in ONE write, so the G2 turn
747
+ * namespace and the web authorization namespace cannot be spent apart.
748
+ */
749
+ spend(keys: readonly string[], session: string, provider: string): { ok: true } | { ok: false; reason: 'already_consumed' | 'nonce_store_unavailable' | 'invalid_keys' } {
750
+ if (this.unavailable) return { ok: false, reason: 'nonce_store_unavailable' }
751
+ if (keys.length === 0 || keys.some(k => typeof k !== 'string' || k.length === 0)) return { ok: false, reason: 'invalid_keys' }
752
+ if (keys.some(k => this.data.consumed[k])) return { ok: false, reason: 'already_consumed' }
753
+ const at = this.now()
754
+ this.prune(at)
755
+ for (const k of keys) this.data.consumed[k] = { at, session, provider }
756
+ if (!this.persist()) { for (const k of keys) delete this.data.consumed[k]; return { ok: false, reason: 'nonce_store_unavailable' } }
757
+ return { ok: true }
758
+ }
759
+ }
760
+
761
+ // ---------------------------------------------------------------------------------------------
762
+ // Approval packets (plan §4.4 `approve`). A packet is only a REQUEST: a token holder may post one.
763
+ // It authorizes nothing until the phone signs an envelope whose scope names it byte for byte.
764
+ // ---------------------------------------------------------------------------------------------
765
+
766
+ export class WebKeyPackets {
767
+ private readonly open = new Map<string, WebApprovalPacket>()
768
+
769
+ constructor(private readonly now: () => number = Date.now, private readonly random: (n: number) => Buffer = randomBytes) {}
770
+
771
+ private prune(): void {
772
+ const at = this.now()
773
+ for (const [sha, p] of this.open) if (at > p.exp) this.open.delete(sha)
774
+ }
775
+
776
+ /**
777
+ * `creator` is the session that asked, taken from the authenticated request context (the route's
778
+ * headers), never from body text (review B4). An approve envelope is accepted only for the
779
+ * session that created its packet.
780
+ */
781
+ create(input: Record<string, unknown>, creator: { provider: unknown; sessionId: unknown } | null): { ok: true; packet: WebApprovalPacket; packetSha256: string } | { ok: false; reason: 'action_not_allowed' | 'invalid_packet' | 'packet_cap_reached' | 'session_from_context_only' | 'creator_unknown' } {
782
+ // The closed action list: install, publish, push-main. Every never-list action is refused here.
783
+ if (typeof input.action !== 'string' || !APPROVE_ACTIONS.has(input.action)) return { ok: false, reason: 'action_not_allowed' }
784
+ if ('provider' in input || 'sessionId' in input) return { ok: false, reason: 'session_from_context_only' }
785
+ // Strict patterns plus the action-to-channel table (shared/web-turn-envelope.ts).
786
+ if (!approveFieldsValid(input.action, input.artifact, input.version, input.digest, input.channel)) return { ok: false, reason: 'invalid_packet' }
787
+ const provider = creator?.provider
788
+ const sessionId = creator?.sessionId
789
+ if (typeof provider !== 'string' || !['claude', 'codex', 'cursor'].includes(provider)
790
+ || typeof sessionId !== 'string' || sessionId.length === 0 || sessionId.length > 128 || /[\u0000-\u001f\u007f]/.test(sessionId)) return { ok: false, reason: 'creator_unknown' }
791
+ this.prune()
792
+ if (this.open.size >= WEB_PACKET_MAX_OPEN) return { ok: false, reason: 'packet_cap_reached' }
793
+ const at = this.now()
794
+ const packet: WebApprovalPacket = {
795
+ v: WEB_APPROVAL_PACKET_VERSION,
796
+ packetId: b64url(this.random(12)),
797
+ action: input.action as WebApprovalPacket['action'],
798
+ artifact: input.artifact as string,
799
+ version: input.version as string,
800
+ digest: input.digest as string,
801
+ channel: input.channel as string,
802
+ provider,
803
+ sessionId,
804
+ createdAt: at,
805
+ exp: at + WEB_PACKET_TTL_MS,
806
+ }
807
+ const bytes = canonicalBytes(packet)
808
+ if (!bytes) return { ok: false, reason: 'invalid_packet' }
809
+ const packetSha256 = sha256Hex(bytes)
810
+ this.open.set(packetSha256, packet)
811
+ return { ok: true, packet, packetSha256 }
812
+ }
813
+
814
+ get(packetSha256: string): WebApprovalPacket | null {
815
+ this.prune()
816
+ return this.open.get(packetSha256) ?? null
817
+ }
818
+
819
+ close(packetSha256: string): void { this.open.delete(packetSha256) }
820
+
821
+ list(): Array<{ packetSha256: string; packet: WebApprovalPacket }> {
822
+ this.prune()
823
+ return [...this.open.entries()].map(([packetSha256, packet]) => ({ packetSha256, packet }))
824
+ }
825
+ }