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