@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,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); }
|