@gotcos/glasses-server 7.0.7 → 7.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,348 @@
1
+ // The phone key's proof assembly (6.99.31, plan Phase 3). ONE module, no DOM, no network, no storage:
2
+ // it turns a handoff plus a server nonce into the exact envelope bytes, asks a SIGNER for the two
3
+ // proofs, and returns the three headers and the turn body. The signer is an interface:
4
+ //
5
+ // { kid, signEnvelope(bytes) -> Promise<Uint8Array(64) P1363>, assert(challengeBytes) -> Promise<{
6
+ // credentialId, authenticatorData, clientDataJSON, signature }> (all base64url) }
7
+ //
8
+ // The 3144 page (signer.js) implements it with a WebCrypto key and a passkey with Face ID. That is the
9
+ // complete approval path for every user, including people with only a phone and a Mac (Miles,
10
+ // 2026-11-07). An optional companion signer (the COS glasses app) could implement the same interface
11
+ // later and reuse everything below; nothing here assumes one exists, and none is ever required.
12
+ //
13
+ // The enrollment math lives here too (the SAS commit-reveal, the 6-digit code, the proof-of-possession
14
+ // object), so the code the phone shows is computed on the phone, from the same bytes the server uses.
15
+ // Field rules: docs/web-key-verifier-contract.md. Classic script: it defines window.COSSignerProof.
16
+ (function (root) {
17
+ 'use strict'
18
+
19
+ var ENVELOPE_VERSION = 'cos-web-turn/1'
20
+ var ENROLL_PROOF_VERSION = 'cos-web-enroll/2'
21
+ var PASSKEY_VERSION = 'cos-web-passkey/1'
22
+ /** Seconds an envelope lives. The server allows at most 900. */
23
+ var ENVELOPE_LIFETIME_S = 600
24
+ /** How far back iat is set, so a phone clock a little ahead of the Mac is still inside the 60 s skew. */
25
+ var IAT_BACKDATE_S = 30
26
+ var TRAILING_WS = /[\t\n\v\f\r ]+$/
27
+ var TURN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
28
+ var NONCE = /^[A-Za-z0-9_-]{22,64}$/
29
+ var HEX64 = /^[0-9a-f]{64}$/
30
+ var PROVIDERS = { claude: 1, codex: 1, cursor: 1 }
31
+ // P-256 SubjectPublicKeyInfo DER header for an uncompressed point (what node and WebCrypto export).
32
+ var SPKI_P256_PREFIX = [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, 0x04]
33
+
34
+ function fail(reason) { var e = new Error(reason); e.reason = reason; return e }
35
+ function bytes(v) { return v instanceof Uint8Array ? v : new Uint8Array(v && v.buffer ? v.buffer.slice(v.byteOffset || 0, (v.byteOffset || 0) + v.byteLength) : v) }
36
+ function concat() {
37
+ var parts = Array.prototype.map.call(arguments, bytes), n = 0, i
38
+ for (i = 0; i < parts.length; i++) n += parts[i].length
39
+ var out = new Uint8Array(n), at = 0
40
+ for (i = 0; i < parts.length; i++) { out.set(parts[i], at); at += parts[i].length }
41
+ return out
42
+ }
43
+ function utf8(s) { return new root.TextEncoder().encode(s) }
44
+
45
+ function b64url(v) {
46
+ var b = bytes(v), s = ''
47
+ for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
48
+ return root.btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
49
+ }
50
+ /** Strict base64url (no padding, no other alphabet), or null. */
51
+ function fromB64url(s) {
52
+ if (typeof s !== 'string' || !/^[A-Za-z0-9_-]*$/.test(s) || s.length % 4 === 1) return null
53
+ var p = s.replace(/-/g, '+').replace(/_/g, '/')
54
+ while (p.length % 4) p += '='
55
+ var bin = root.atob(p), out = new Uint8Array(bin.length)
56
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
57
+ return out
58
+ }
59
+ function hex(v) {
60
+ var b = bytes(v), s = ''
61
+ for (var i = 0; i < b.length; i++) s += (b[i] < 16 ? '0' : '') + b[i].toString(16)
62
+ return s
63
+ }
64
+ function sha256(v) {
65
+ return root.crypto.subtle.digest('SHA-256', bytes(v)).then(function (d) { return new Uint8Array(d) })
66
+ }
67
+
68
+ // ── RFC 8785 JCS, the same rules as shared/web-turn-envelope.ts canonicalJson ──
69
+ function hasLoneSurrogate(s) {
70
+ for (var i = 0; i < s.length; i++) {
71
+ var c = s.charCodeAt(i)
72
+ if (c >= 0xd800 && c <= 0xdbff) {
73
+ var n = s.charCodeAt(i + 1)
74
+ if (!(n >= 0xdc00 && n <= 0xdfff)) return true
75
+ i++
76
+ } else if (c >= 0xdc00 && c <= 0xdfff) return true
77
+ }
78
+ return false
79
+ }
80
+ function isPlain(v) {
81
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return false
82
+ var proto = Object.getPrototypeOf(v)
83
+ return proto === null || Object.getPrototypeOf(proto) === null
84
+ }
85
+ function canonicalJson(value) {
86
+ if (value === null) return 'null'
87
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
88
+ if (typeof value === 'number') {
89
+ if (!Number.isSafeInteger(value)) throw fail('not_signable')
90
+ return JSON.stringify(value)
91
+ }
92
+ if (typeof value === 'string') {
93
+ if (hasLoneSurrogate(value)) throw fail('not_signable')
94
+ return JSON.stringify(value)
95
+ }
96
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
97
+ if (isPlain(value)) {
98
+ return '{' + Object.keys(value).sort().map(function (k) {
99
+ if (hasLoneSurrogate(k)) throw fail('not_signable')
100
+ return JSON.stringify(k) + ':' + canonicalJson(value[k])
101
+ }).join(',') + '}'
102
+ }
103
+ throw fail('not_signable')
104
+ }
105
+ function jcsBytes(value) { return utf8(canonicalJson(value)) }
106
+
107
+ // ── words ──
108
+ /**
109
+ * The words exactly as they are shown, signed and sent. Trailing ASCII whitespace goes (the server
110
+ * refuses signed words that end in it, `words_trailing_whitespace`); nothing else changes.
111
+ */
112
+ function signedWords(words) { return String(words == null ? '' : words).replace(TRAILING_WS, '') }
113
+ /** sha256 and UTF-8 length of the words with CRLF as LF, the server's wordsDigest. */
114
+ function wordsDigest(words) {
115
+ var b = utf8(String(words).replace(/\r\n/g, '\n'))
116
+ return sha256(b).then(function (d) { return { sha256: hex(d), len: b.length } })
117
+ }
118
+
119
+ // ── the envelope ──
120
+ function scopeOf(s) {
121
+ if (!s || s.kind === 'message') {
122
+ if (s && Object.keys(s).length !== 1) throw fail('scope_not_allowed')
123
+ return { kind: 'message' }
124
+ }
125
+ if (s.kind === 'approve') {
126
+ var keys = ['kind', 'action', 'artifact', 'version', 'digest', 'channel', 'packetSha256']
127
+ if (Object.keys(s).length !== keys.length || keys.some(function (k) { return typeof s[k] !== 'string' })) throw fail('scope_not_allowed')
128
+ var channels = { install: ['mac-local'], publish: ['npm-next', 'npm-latest', 'control-appcast', 'hub-alias', 'gotcos-site', 'control-downloads', 'release-docs'] }
129
+ if (!Object.prototype.hasOwnProperty.call(channels, s.action) || channels[s.action].indexOf(s.channel) < 0
130
+ || !/^[a-z0-9@][a-z0-9@._/-]{0,127}$/.test(s.artifact) || !/^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$/.test(s.version)
131
+ || !/^(sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128}|sha512-[A-Za-z0-9+/]{86}==)$/.test(s.digest) || !HEX64.test(s.packetSha256)) throw fail('scope_not_allowed')
132
+ var manifests = { 'gotcos-site': 'gotcos-site-manifest', 'control-downloads': 'cos-control-downloads-manifest', 'release-docs': 'cos-release-docs-manifest' }
133
+ if (Object.prototype.hasOwnProperty.call(manifests, s.channel) && (s.artifact !== manifests[s.channel] || !/^sha256:[0-9a-f]{64}$/.test(s.digest))) throw fail('scope_not_allowed')
134
+ return { kind: 'approve', action: s.action, artifact: s.artifact, version: s.version, digest: s.digest, channel: s.channel, packetSha256: s.packetSha256 }
135
+ }
136
+ throw fail('scope_not_allowed')
137
+ }
138
+
139
+ /** Verify the full packet before display AND again before signing. A packet is still only a request. */
140
+ function validateHandoffPacket(h, nowMs) {
141
+ return Promise.resolve().then(function () {
142
+ var scope = scopeOf(h && h.scope), p = h && h.packet
143
+ if (scope.kind === 'message') { if (p != null) throw fail('packet_mismatch'); return null }
144
+ var keys = ['v', 'packetId', 'action', 'artifact', 'version', 'digest', 'channel', 'provider', 'sessionId', 'createdAt', 'exp']
145
+ if (!isPlain(p) || Object.keys(p).length !== keys.length || keys.some(function (k) { return !Object.prototype.hasOwnProperty.call(p, k) })
146
+ || p.v !== 'cos-web-packet/1' || !/^[A-Za-z0-9_-]{16}$/.test(p.packetId)
147
+ || !Number.isSafeInteger(p.createdAt) || p.createdAt < 0 || !Number.isSafeInteger(p.exp) || p.exp <= p.createdAt || p.exp - p.createdAt > 1800000
148
+ || p.provider !== h.provider || p.sessionId !== h.sessionId
149
+ || ['action', 'artifact', 'version', 'digest', 'channel'].some(function (k) { return p[k] !== scope[k] })) throw fail('packet_mismatch')
150
+ if (!Number.isSafeInteger(nowMs) || nowMs > p.exp || p.createdAt > nowMs + 60000) throw fail('packet_expired')
151
+ return sha256(jcsBytes(p)).then(function (d) { if (hex(d) !== scope.packetSha256) throw fail('packet_mismatch'); return p })
152
+ })
153
+ }
154
+
155
+ /**
156
+ * The envelope object, from the handoff (what to say, to whom), the server's nonce answer (nonce,
157
+ * aud, key epoch), the signer's kid, and THIS page's own origin. The origin is never taken from the
158
+ * server or the handoff: a page on another origin cannot get a signature that names this one.
159
+ */
160
+ function buildEnvelope(f) {
161
+ if (!f || typeof f !== 'object') throw fail('envelope_input_missing')
162
+ var words = signedWords(f.words)
163
+ if (!words.trim()) throw fail('words_empty')
164
+ if (f.attachments && f.attachments.length) throw fail('attachments_not_signable')
165
+ if (typeof f.origin !== 'string' || !/^https:\/\/[^/\s]+$/.test(f.origin)) throw fail('origin_invalid')
166
+ if (typeof f.aud !== 'string' || !HEX64.test(f.aud)) throw fail('aud_invalid')
167
+ if (typeof f.kid !== 'string' || f.kid.length !== 22) throw fail('kid_invalid')
168
+ if (!Number.isSafeInteger(f.epoch) || f.epoch < 0) throw fail('epoch_invalid')
169
+ if (typeof f.turnId !== 'string' || !TURN_ID.test(f.turnId)) throw fail('turn_id_invalid')
170
+ if (typeof f.nonce !== 'string' || !NONCE.test(f.nonce)) throw fail('nonce_invalid')
171
+ if (!PROVIDERS[f.provider]) throw fail('provider_invalid')
172
+ if (typeof f.sessionId !== 'string' || !f.sessionId || f.sessionId.length > 128) throw fail('session_invalid')
173
+ if (f.bindingId !== null && (typeof f.bindingId !== 'string' || !f.bindingId || f.bindingId.length > 128)) throw fail('binding_invalid')
174
+ var nowS = Math.floor(Number(f.nowMs) / 1000)
175
+ if (!Number.isSafeInteger(nowS) || nowS <= IAT_BACKDATE_S) throw fail('clock_invalid')
176
+ var iat = nowS - IAT_BACKDATE_S
177
+ var scope = scopeOf(f.scope)
178
+ return wordsDigest(words).then(function (d) {
179
+ return {
180
+ words: words,
181
+ envelope: {
182
+ v: ENVELOPE_VERSION, aud: f.aud, origin: f.origin, kid: f.kid, kver: 1, epoch: f.epoch,
183
+ turnId: f.turnId, nonce: f.nonce, iat: iat, exp: iat + ENVELOPE_LIFETIME_S,
184
+ provider: f.provider, sessionId: f.sessionId, bindingId: f.bindingId,
185
+ fork: false, wordsSha256: d.sha256, wordsLen: d.len, attachments: [], scope: scope,
186
+ },
187
+ }
188
+ })
189
+ }
190
+
191
+ /** The raw passkey challenge for a turn: sha256 of the envelope's JCS bytes. clientDataJSON carries its base64url. */
192
+ function passkeyChallenge(envelopeBytes) { return sha256(envelopeBytes) }
193
+
194
+ function passkeyHeader(a) {
195
+ var fields = ['credentialId', 'authenticatorData', 'clientDataJSON', 'signature']
196
+ if (!a || fields.some(function (k) { return typeof a[k] !== 'string' || !a[k] || fromB64url(a[k]) === null })) throw fail('passkey_malformed')
197
+ return b64url(jcsBytes({ v: PASSKEY_VERSION, credentialId: a.credentialId, authenticatorData: a.authenticatorData, clientDataJSON: a.clientDataJSON, signature: a.signature }))
198
+ }
199
+
200
+ /**
201
+ * THE seam (an optional second signer type may plug in here later; the page signer is complete on its own). Build the envelope, sign its JCS bytes with the
202
+ * device key, then ask for a user-verified passkey assertion over sha256 of those same bytes, in
203
+ * that order. Returns what the turn route takes: the three headers and the body.
204
+ *
205
+ * input: { handoff, nonce: { nonce, aud, epoch }, signer, origin, nowMs }
206
+ */
207
+ function assembleTurnProof(input) {
208
+ var h = input && input.handoff, n = input && input.nonce, signer = input && input.signer
209
+ if (!h || !h.turn || !n || !signer || typeof signer.signEnvelope !== 'function' || typeof signer.assert !== 'function') return Promise.reject(fail('proof_input_missing'))
210
+ var built
211
+ return validateHandoffPacket(h, input.nowMs).then(function () {
212
+ return buildEnvelope({
213
+ words: h.words, attachments: h.attachments, origin: input.origin, aud: n.aud, kid: signer.kid, epoch: n.epoch,
214
+ turnId: h.turn.clientTurnId, nonce: n.nonce, provider: h.provider, sessionId: h.sessionId,
215
+ bindingId: h.bindingId == null ? null : h.bindingId, scope: h.scope, nowMs: input.nowMs,
216
+ })
217
+ }).then(function (b) {
218
+ built = b
219
+ built.bytes = jcsBytes(b.envelope)
220
+ return signer.signEnvelope(built.bytes)
221
+ }).then(function (sig) {
222
+ var s = bytes(sig)
223
+ if (s.length !== 64) throw fail('signature_not_p1363')
224
+ built.signature = b64url(s)
225
+ return passkeyChallenge(built.bytes)
226
+ }).then(function (challenge) {
227
+ built.challenge = challenge
228
+ return signer.assert(challenge)
229
+ }).then(function (assertion) {
230
+ var body = { prompt: built.words, epoch: h.turn.epoch, targetKey: h.turn.targetKey, clientTurnId: h.turn.clientTurnId }
231
+ if (h.turn.acknowledgedRevision) body.acknowledgedRevision = h.turn.acknowledgedRevision
232
+ if (h.turn.boundTo) body.boundTo = h.turn.boundTo
233
+ return {
234
+ words: built.words,
235
+ envelope: built.envelope,
236
+ envelopeBytes: built.bytes,
237
+ challenge: built.challenge,
238
+ headers: {
239
+ 'x-cos-web-envelope': b64url(built.bytes),
240
+ 'x-cos-web-sig': built.signature,
241
+ 'x-cos-web-passkey': passkeyHeader(assertion),
242
+ },
243
+ body: body,
244
+ }
245
+ })
246
+ }
247
+
248
+ // ── enrollment math (review W3 commit-reveal; contract section 3) ──
249
+ /** hex(sha256(r)): what the phone sends at start, before the server picks its challenge. */
250
+ function sasCommit(r) { return sha256(r).then(hex) }
251
+ /** The 6-digit code: uint32_be(sha256(challenge || spkiDer || r)[0:4]) mod 1_000_000, zero-padded. Computed here, on the phone. */
252
+ function sasCode(challenge, spkiDer, r) {
253
+ return sha256(concat(challenge, spkiDer, r)).then(function (d) {
254
+ var n = ((d[0] << 24) >>> 0) + (d[1] << 16) + (d[2] << 8) + d[3]
255
+ return String(n % 1000000).padStart(6, '0')
256
+ })
257
+ }
258
+ function kidOf(spkiDer) { return sha256(spkiDer).then(function (d) { return b64url(d).slice(0, 22) }) }
259
+ function fingerprintOf(spkiDer) {
260
+ return sha256(spkiDer).then(function (d) {
261
+ var h = hex(d).slice(0, 16).toUpperCase()
262
+ return h.slice(0, 4) + '-' + h.slice(4, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16)
263
+ })
264
+ }
265
+ /** The proof-of-possession object (`cos-web-enroll/2`), exactly the server's WebKeyEnrollments.proofMessage. */
266
+ function enrollProofMessage(p) {
267
+ return {
268
+ v: ENROLL_PROOF_VERSION, enrollId: p.enrollId, challenge: p.challenge, aud: p.aud, origin: p.origin, kid: p.kid,
269
+ phoneNonce: p.phoneNonce, passkey_credential_id: p.passkeyCredentialId, passkey_spki_sha256: p.passkeySpkiSha256,
270
+ }
271
+ }
272
+
273
+ // ── passkey public key: P-256 x and y, as COSE (what the server takes) and as SPKI (what it hashes) ──
274
+ function spkiFromXY(x, y) { return concat(SPKI_P256_PREFIX, x, y) }
275
+ function xyFromSpki(spki) {
276
+ var b = bytes(spki)
277
+ if (b.length !== SPKI_P256_PREFIX.length + 64) return null
278
+ for (var i = 0; i < SPKI_P256_PREFIX.length; i++) if (b[i] !== SPKI_P256_PREFIX[i]) return null
279
+ return { x: b.slice(27, 59), y: b.slice(59, 91) }
280
+ }
281
+ /** COSE_Key {1: 2, 3: -7, -1: 1, -2: x, -3: y}, CBOR. */
282
+ function coseFromXY(x, y) {
283
+ return concat([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20], x, [0x22, 0x58, 0x20], y)
284
+ }
285
+
286
+ // A minimal CBOR reader for the attestation object and the COSE key in it (maps, arrays, ints,
287
+ // byte and text strings, simple values). Only used when the browser has no getPublicKey().
288
+ function cborRead(b, pos) {
289
+ if (pos >= b.length) throw fail('cbor')
290
+ var ib = b[pos], major = ib >> 5, info = ib & 31, val, next = pos + 1
291
+ if (info < 24) val = info
292
+ else if (info === 24) { val = b[next]; next += 1 }
293
+ else if (info === 25) { val = (b[next] << 8) | b[next + 1]; next += 2 }
294
+ else if (info === 26) { val = ((b[next] << 24) >>> 0) + (b[next + 1] << 16) + (b[next + 2] << 8) + b[next + 3]; next += 4 }
295
+ else throw fail('cbor')
296
+ if (major === 0) return { value: val, next: next }
297
+ if (major === 1) return { value: -1 - val, next: next }
298
+ if (major === 2) { if (next + val > b.length) throw fail('cbor'); return { value: b.slice(next, next + val), next: next + val } }
299
+ if (major === 3) { if (next + val > b.length) throw fail('cbor'); return { value: new root.TextDecoder().decode(b.slice(next, next + val)), next: next + val } }
300
+ if (major === 4) { var arr = []; for (var i = 0; i < val; i++) { var e = cborRead(b, next); arr.push(e.value); next = e.next } return { value: arr, next: next } }
301
+ if (major === 5) {
302
+ var map = new Map()
303
+ for (var j = 0; j < val; j++) { var k = cborRead(b, next); var v = cborRead(b, k.next); map.set(k.value, v.value); next = v.next }
304
+ return { value: map, next: next }
305
+ }
306
+ if (major === 7) return { value: info === 21 ? true : info === 20 ? false : null, next: next }
307
+ throw fail('cbor')
308
+ }
309
+ function xyFromCose(cose) {
310
+ var m = cose instanceof Map ? cose : cborRead(bytes(cose), 0).value
311
+ var x = m.get(-2), y = m.get(-3)
312
+ if (m.get(1) !== 2 || m.get(3) !== -7 || m.get(-1) !== 1 || !x || !y || x.length !== 32 || y.length !== 32) return null
313
+ return { x: bytes(x), y: bytes(y) }
314
+ }
315
+ /** The credential public key from a WebAuthn attestation object (fmt "none" or any other). */
316
+ function xyFromAttestation(attestationObject) {
317
+ var obj = cborRead(bytes(attestationObject), 0).value
318
+ var auth = obj instanceof Map ? obj.get('authData') : null
319
+ if (!auth || auth.length < 55) return null
320
+ if ((auth[32] & 0x40) === 0) return null
321
+ var credLen = (auth[53] << 8) | auth[54]
322
+ var at = 55 + credLen
323
+ if (at >= auth.length) return null
324
+ return xyFromCose(cborRead(auth, at).value)
325
+ }
326
+ /** x and y from a registration response: getPublicKey() when the browser has it, else the attestation object. */
327
+ function passkeyXY(response) {
328
+ if (response && typeof response.getPublicKey === 'function') {
329
+ var spki = response.getPublicKey()
330
+ var xy = spki ? xyFromSpki(spki) : null
331
+ if (xy) return xy
332
+ }
333
+ return response && response.attestationObject ? xyFromAttestation(response.attestationObject) : null
334
+ }
335
+
336
+ root.COSSignerProof = {
337
+ ENVELOPE_LIFETIME_S: ENVELOPE_LIFETIME_S,
338
+ IAT_BACKDATE_S: IAT_BACKDATE_S,
339
+ b64url: b64url, fromB64url: fromB64url, hex: hex, sha256: sha256, utf8: utf8,
340
+ canonicalJson: canonicalJson, jcsBytes: jcsBytes,
341
+ signedWords: signedWords, wordsDigest: wordsDigest,
342
+ buildEnvelope: buildEnvelope, passkeyChallenge: passkeyChallenge, passkeyHeader: passkeyHeader,
343
+ assembleTurnProof: assembleTurnProof, validateHandoffPacket: validateHandoffPacket,
344
+ sasCommit: sasCommit, sasCode: sasCode, kidOf: kidOf, fingerprintOf: fingerprintOf, enrollProofMessage: enrollProofMessage,
345
+ spkiFromXY: spkiFromXY, xyFromSpki: xyFromSpki, coseFromXY: coseFromXY, xyFromCose: xyFromCose,
346
+ xyFromAttestation: xyFromAttestation, passkeyXY: passkeyXY,
347
+ }
348
+ })(typeof window !== 'undefined' ? window : globalThis)
@@ -0,0 +1,39 @@
1
+ // Where the phone key lives (6.99.31, plan section 4.2): IndexedDB on the 3144 signer origin only.
2
+ // One record: the WebCrypto key pair (its private half was made with extractable false, so this page
3
+ // can use it but no script can read it out), the passkey credential id and the enrollment state.
4
+ // The big Control Web app on 3143 is another origin and cannot open this database.
5
+ // Classic script: it defines window.COSSignerStore.
6
+ (function (root) {
7
+ 'use strict'
8
+ var DB = 'cos-web-key', STORE = 'keys', ID = 'phone-key'
9
+
10
+ function open() {
11
+ return new Promise(function (resolve, reject) {
12
+ var req = root.indexedDB.open(DB, 1)
13
+ req.onupgradeneeded = function () { req.result.createObjectStore(STORE) }
14
+ req.onsuccess = function () { resolve(req.result) }
15
+ req.onerror = function () { reject(req.error || new Error('indexeddb_unavailable')) }
16
+ })
17
+ }
18
+ function run(mode, fn) {
19
+ return open().then(function (db) {
20
+ return new Promise(function (resolve, reject) {
21
+ var tx = db.transaction(STORE, mode), out
22
+ var req = fn(tx.objectStore(STORE))
23
+ req.onsuccess = function () { out = req.result }
24
+ tx.oncomplete = function () { db.close(); resolve(out) }
25
+ tx.onerror = tx.onabort = function () { db.close(); reject(tx.error || new Error('indexeddb_failed')) }
26
+ })
27
+ })
28
+ }
29
+
30
+ root.COSSignerStore = {
31
+ get: function () { return run('readonly', function (s) { return s.get(ID) }).then(function (r) { return r || null }) },
32
+ put: function (record) {
33
+ // Ask the browser not to evict the key under storage pressure. A refusal is not an error.
34
+ try { if (root.navigator && root.navigator.storage && root.navigator.storage.persist) root.navigator.storage.persist() } catch (_e) { /* best effort */ }
35
+ return run('readwrite', function (s) { return s.put(record, ID) })
36
+ },
37
+ clear: function () { return run('readwrite', function (s) { return s.delete(ID) }) },
38
+ }
39
+ })(typeof window !== 'undefined' ? window : globalThis)
@@ -0,0 +1,23 @@
1
+ :root { color-scheme: light dark; --fg: #1d1d1f; --muted: #6e6e73; --bg: #ffffff; --panel: #f5f5f7; --accent: #0a66d8; --line: #d2d2d7; }
2
+ @media (prefers-color-scheme: dark) { :root { --fg: #f5f5f7; --muted: #a1a1a6; --bg: #000000; --panel: #1c1c1e; --accent: #4c9bff; --line: #3a3a3c; } }
3
+ [hidden] { display: none !important; }
4
+ html, body { margin: 0; background: var(--bg); color: var(--fg); }
5
+ body { font: 17px/1.45 -apple-system, system-ui, sans-serif; }
6
+ main { max-width: 32rem; margin: 0 auto; padding: 24px 16px 48px; }
7
+ h1 { font-size: 1.1rem; margin: 0 0 20px; color: var(--muted); font-weight: 600; }
8
+ h2 { font-size: 1.3rem; margin: 0 0 12px; }
9
+ pre { white-space: pre-wrap; word-break: break-word; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 12px; font: inherit; margin: 0 0 16px; }
10
+ dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 12px; margin: 0 0 20px; }
11
+ dt { color: var(--muted); }
12
+ dd { margin: 0; word-break: break-all; }
13
+ .label { color: var(--muted); margin: 0 0 6px; }
14
+ .mono { font-family: ui-monospace, Menlo, monospace; font-size: 0.9em; }
15
+ .code { font: 600 2.4rem/1.2 ui-monospace, Menlo, monospace; letter-spacing: 0.08em; margin: 8px 0 16px; }
16
+ .status { color: var(--muted); min-height: 1.4em; }
17
+ .foot { margin-top: 32px; }
18
+ label { display: block; color: var(--muted); margin: 12px 0 6px; }
19
+ input { width: 100%; box-sizing: border-box; font: inherit; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--fg); }
20
+ button { display: block; width: 100%; margin: 16px 0 0; padding: 14px; font: inherit; font-weight: 600; border: 0; border-radius: 12px; background: var(--accent); color: #ffffff; }
21
+ button.quiet { background: var(--panel); color: var(--fg); border: 1px solid var(--line); }
22
+ button:disabled { opacity: 0.5; }
23
+ a { color: var(--accent); }