@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.
- package/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- 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
|
+
}
|