@gotcos/glasses-server 7.0.7 → 7.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
> Current integration: Codex phone-approvals lane, based on server 7.0.4. No live enrollment or authority activation is implied. `COS_WEB_KEY_AUTHORITY` remains off by default. Historical 6.99.31 labels below describe the reusable WIP, not a shipped qualification. Fresh focused, final independent and on-device acceptance are required.
|
|
2
|
+
|
|
3
|
+
# Web key verifier contract (server 6.99.31, flag `COS_WEB_KEY_AUTHORITY`)
|
|
4
|
+
|
|
5
|
+
What `verify_g2_turn.py` (MU-Chief-Staff `operations/scripts/`) reads for a Control Web turn signed by
|
|
6
|
+
an enrolled phone key. The server half lives in `shared/web-turn-envelope.ts`,
|
|
7
|
+
`server/lib/web-key-*.ts`, `server/routes/web-key.ts` and `server/routes/g2-turns.ts`. When this file
|
|
8
|
+
and the code disagree, the code is wrong: fix one of them and add a line to the change log at the end.
|
|
9
|
+
|
|
10
|
+
Flag unset: none of this exists. `GET /api/g2-turns/:id` answers a web record exactly as 6.99.29
|
|
11
|
+
(`reason: "web_cookie_derivable_from_token"`, no web-key fields), and every `/api/web-key/*` route is
|
|
12
|
+
a 404 fall-through.
|
|
13
|
+
|
|
14
|
+
## 0. Encodings
|
|
15
|
+
|
|
16
|
+
| Name | Format |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| b64url | RFC 4648 base64url, no padding, alphabet `[A-Za-z0-9_-]`. Length mod 4 never 1. |
|
|
19
|
+
| hex64 | 64 lower-case hex characters (a sha256 digest). |
|
|
20
|
+
| JCS | RFC 8785. Keys sorted by UTF-16 code unit (every key in this protocol is ASCII, so code-point sort agrees), no whitespace, strings escaped as ES `JSON.stringify`, numbers are safe integers only (no floats, no exponents). Lone surrogates are refused, in keys and values. Booleans are never integers: Python must reject `True`/`False` wherever an integer is required (`kver`, `epoch`, `iat`, `exp`, `bytes`, `wordsLen`, `createdAt`, `setAt`). Python mirror: `json.dumps(v, sort_keys=True, separators=(",", ":"), ensure_ascii=False)` plus the surrogate and boolean checks. |
|
|
21
|
+
| P1363 | ECDSA P-256 / SHA-256 signature as `r||s`, exactly 64 bytes, carried b64url. Used by the phone key and the anchor. |
|
|
22
|
+
| DER sig | ECDSA signature as ASN.1 DER. Used by WebAuthn (ES256) only. |
|
|
23
|
+
| SPKI | P-256 SubjectPublicKeyInfo DER, carried b64url. Must re-export to identical bytes (no trailing data). |
|
|
24
|
+
| fingerprint | First 16 hex of sha256(SPKI DER), upper case, as `XXXX-XXXX-XXXX-XXXX`. For people. Machines compare the full `spkiSha256` (hex64). |
|
|
25
|
+
| kid | `b64url(sha256(SPKI DER))[0:22]`. |
|
|
26
|
+
|
|
27
|
+
Every signed object is signed over its JCS bytes. The server refuses bytes that are not already
|
|
28
|
+
canonical, so one message has exactly one signable form.
|
|
29
|
+
|
|
30
|
+
## 1. Origins and identity
|
|
31
|
+
|
|
32
|
+
- **Signer origin:** `https://<selfDnsName>:3144` (`WEB_KEY_SIGNER_PORT = 3144`,
|
|
33
|
+
`webKeySignerOrigin()` in `server/lib/web-key-origin.ts`). The ONLY origin a phone key or passkey
|
|
34
|
+
signs from. `envelope.origin`, the request `Origin` header, the passkey `clientDataJSON.origin`
|
|
35
|
+
and the cookie gate's extra port all use this one function. 3143 (the big app) never signs.
|
|
36
|
+
- **What 3144 serves (Phase 3, `lib/web-key-signer-app.ts`):** the signer page only (`server/web-signer/`:
|
|
37
|
+
`index.html`, `signer.js`, `signer-proof.js`, `signer-store.js`, `signer.css`), from bytes that matched
|
|
38
|
+
`server/web-signer/.manifest.json` (written at pack time like the Control Web manifest), plus an /api
|
|
39
|
+
allowlist: `GET web-key/status`, `GET web-key/keys`, `POST web-key/nonce`, `POST web-key/enroll/start`,
|
|
40
|
+
`POST web-key/enroll/prove`, `GET web-key/enroll/status`, `POST web-key/revoke`, `POST web-key/handoff/claim`,
|
|
41
|
+
`POST agent-sessions/bindings/:id/turns`, `GET agent-sessions/bindings/:id/turns/:turnId`. Everything else
|
|
42
|
+
is 404 there. An /api request naming another `Origin` is refused (403 `origin_refused`), and a write must
|
|
43
|
+
name the signer origin. Every answer carries `Content-Security-Policy` with `default-src 'none'`,
|
|
44
|
+
`script-src 'self'`, `style-src 'self'`, `frame-ancestors 'none'` (no inline script or style), plus
|
|
45
|
+
`X-Frame-Options: DENY`, COOP and CORP `same-origin`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`.
|
|
46
|
+
- **Signer-only steps:** `enroll/start`, `enroll/prove`, `nonce` and `handoff/claim` answer 403
|
|
47
|
+
`signer_origin_required` unless the request reached the 3144 listener (socket local port). A page on 3143
|
|
48
|
+
can neither make a key nor fetch a handoff.
|
|
49
|
+
- **rpId:** `selfDnsName`, the full lower-case MagicDNS host, no trailing dot (for example
|
|
50
|
+
`ukaoma-mac-studio.tail38457f.ts.net`; `ts.net` is a public suffix). The verifier derives it as
|
|
51
|
+
the host of `envelope.origin`.
|
|
52
|
+
- **aud:** `hex(sha256(anchor SPKI DER || utf8(lower(rpId))))`. The verifier computes it from ITS
|
|
53
|
+
pinned anchor, never from a key in the answer.
|
|
54
|
+
|
|
55
|
+
## 2. The anchor (B1)
|
|
56
|
+
|
|
57
|
+
The anchor is COS Control's Secure Enclave P-256 key. It reaches the server only through:
|
|
58
|
+
|
|
59
|
+
`POST /api/web-key/anchor` (flag on, loopback socket only, `X-Cos-Token` required, no proxy headers)
|
|
60
|
+
|
|
61
|
+
7.0.9: the same route also answers with `COS_WEB_KEY_ANCHOR_BOOTSTRAP` on and `COS_WEB_KEY_AUTHORITY` off,
|
|
62
|
+
with identical gates and the identical record. In that mode `GET /api/web-key/status` (token only)
|
|
63
|
+
answers `{ ok, enabled: false, mode: "bootstrap", anchorRegistered, anchorMature, anchorSetAt, aud,
|
|
64
|
+
origin, epoch }` and no other web-key route, listener or vouch exists. Nothing in this contract's
|
|
65
|
+
verdicts changes: a bootstrap-only server never vouches a web turn.
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{ "anchorPub": "<SPKI b64url>", "statement": "<b64url of JCS bytes>", "signature": "<P1363 b64url>" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Statement (the anchor signs it with itself):
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{ "v": "cos-web-anchor/1", "spkiSha256": "<hex64 of anchorPub DER>", "fingerprint": "XXXX-XXXX-XXXX-XXXX", "createdAt": <ms> }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`createdAt` must be within 10 minutes of the server clock. The server stores
|
|
78
|
+
`anchorRegistration: { statement, signature, setAt }` next to `anchorPub` in `web-keys.json`, where
|
|
79
|
+
`setAt` is the server clock at registration. Registering a different anchor revokes every key and
|
|
80
|
+
bumps the epoch.
|
|
81
|
+
|
|
82
|
+
- An `anchorPub` in the file WITHOUT a registration record whose self-signature verifies is refused
|
|
83
|
+
(`anchor_unregistered`): no enrollment, no vouch.
|
|
84
|
+
- `WEB_ANCHOR_MATURITY_MS = 86_400_000` (24 h). An anchor younger than that vouches nothing
|
|
85
|
+
(`anchor_too_new`).
|
|
86
|
+
- `setAt` is file-backed, so a same-user process can backdate it. The 24 h rule binds the server's
|
|
87
|
+
own route; **the verifier's committed pin is the real check.** The verifier must refuse when the
|
|
88
|
+
pin file is missing, uncommitted, or its `spkiSha256` differs from `anchor.spkiSha256` in the answer.
|
|
89
|
+
|
|
90
|
+
## 3. Enrollment (anchor-signed approval)
|
|
91
|
+
|
|
92
|
+
Approval object, `cos-web-enroll-approve/2`. Every value Control displays is inside these bytes
|
|
93
|
+
(W4); Control decodes `approvalJcs` and derives the SAS, fingerprint and label from it, never from a
|
|
94
|
+
separate field. `GET /api/web-key/enroll/pending` returns only `{ enrollId, startedAt, exp, approval, approvalJcs }`.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"v": "cos-web-enroll-approve/2",
|
|
99
|
+
"enrollId": "<b64url 16 bytes>",
|
|
100
|
+
"kid": "<kid>",
|
|
101
|
+
"spki": "<phone key SPKI b64url>",
|
|
102
|
+
"spki_sha256": "<hex64>",
|
|
103
|
+
"fingerprint": "XXXX-XXXX-XXXX-XXXX",
|
|
104
|
+
"label": "<1..64 chars, attacker-supplied text>",
|
|
105
|
+
"userAgent": "<0..200 chars, attacker-supplied text>",
|
|
106
|
+
"remote": "<enrolling socket address, normalized, no ::ffff: prefix>",
|
|
107
|
+
"challenge": "<b64url 32 bytes, server-chosen>",
|
|
108
|
+
"sasCommit": "<hex64 = sha256(phoneNonce bytes)>",
|
|
109
|
+
"phoneNonce": "<b64url 32 bytes, revealed at prove>",
|
|
110
|
+
"sas": "<6 digits>",
|
|
111
|
+
"aud": "<hex64>",
|
|
112
|
+
"origin": "https://<rpId>:3144",
|
|
113
|
+
"kver": 1,
|
|
114
|
+
"epoch": <int>,
|
|
115
|
+
"rotates": "<kid>" | null,
|
|
116
|
+
"passkey_credential_id": "<b64url>",
|
|
117
|
+
"passkey_spki_sha256": "<hex64 of passkey SPKI DER>",
|
|
118
|
+
"enrolledAt": <ms>,
|
|
119
|
+
"exp": <ms>
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
- **SAS (W3, commit-reveal):** the phone sends `sasCommit = sha256(r)` at start, BEFORE the server
|
|
124
|
+
picks `challenge`; it reveals `r` (`phoneNonce`) at prove.
|
|
125
|
+
`sas = uint32_be(sha256(challenge || spkiDer || r)[0:4]) mod 1_000_000`, zero-padded to 6.
|
|
126
|
+
The phone computes it locally. The server cannot grind `challenge` to match a chosen SAS because
|
|
127
|
+
`r` is unknown to it when it picks.
|
|
128
|
+
- **Proof of possession:** the phone key signs (P1363) the JCS of
|
|
129
|
+
`{ "v": "cos-web-enroll/2", "enrollId", "challenge", "aud", "origin", "kid", "phoneNonce", "passkey_credential_id", "passkey_spki_sha256" }`,
|
|
130
|
+
and the passkey asserts (section 5) with challenge `sha256(those JCS bytes)`.
|
|
131
|
+
- **Device allowlist (W6):** enrollment start is refused (`device_not_allowed`) unless the socket
|
|
132
|
+
address is in `COS_WEB_KEY_DEVICES` (comma-separated addresses). Empty list: every start refused.
|
|
133
|
+
- **Registry entry** (what the turn answer returns as `registryEntry`):
|
|
134
|
+
`{ kid, spki, passkeySpki, kver, label, approval, anchorSig, revokedAt? }`. `passkeySpki` is the
|
|
135
|
+
passkey's P-256 SPKI b64url (converted from COSE at enrollment); its sha256 must equal
|
|
136
|
+
`approval.passkey_spki_sha256`.
|
|
137
|
+
- An entry counts only when: `anchorSig` (P1363) verifies over JCS(approval) with the anchor,
|
|
138
|
+
`approval.kid == kid == kid(spki)`, `approval.spki == spki`, `approval.spki_sha256 == sha256(spki)`,
|
|
139
|
+
`approval.aud == aud`, `approval.kver == kver`, `approval.epoch == current epoch` (W5,
|
|
140
|
+
`key_epoch_stale`), `sha256(passkeySpki) == approval.passkey_spki_sha256`, no `revokedAt`.
|
|
141
|
+
`approval.rotates` (W8) names the one key the approval revoked; nothing else is revoked by it.
|
|
142
|
+
|
|
143
|
+
## 4. The envelope, `cos-web-turn/1`
|
|
144
|
+
|
|
145
|
+
Header `x-cos-web-envelope`: b64url of the JCS bytes. Header `x-cos-web-sig`: P1363 b64url by the
|
|
146
|
+
phone key over those exact bytes. Header `x-cos-web-passkey`: section 5.
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"v": "cos-web-turn/1", "aud": "<hex64>", "origin": "https://<rpId>:3144",
|
|
151
|
+
"kid": "<kid>", "kver": 1, "epoch": <int>,
|
|
152
|
+
"turnId": "^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$", "nonce": "^[A-Za-z0-9_-]{22,64}$",
|
|
153
|
+
"iat": <s>, "exp": <s>,
|
|
154
|
+
"provider": "claude" | "codex" | "cursor", "sessionId": "<1..128>", "bindingId": "<1..128>" | null,
|
|
155
|
+
"fork": false,
|
|
156
|
+
"wordsSha256": "<hex64 = sha256(utf8(words with CRLF replaced by LF, nothing else))>", "wordsLen": <utf8 byte length>,
|
|
157
|
+
"attachments": [ { "id": "<1..128>", "sha256": "<hex64>", "bytes": <int>, "name": "<0..255>" } ],
|
|
158
|
+
"scope": { "kind": "message" } | { "kind": "approve", ... }
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Exact key sets everywhere; an unknown key is refused. `exp - iat <= 900`, `iat <= now + 60`, `now <= exp`.
|
|
163
|
+
|
|
164
|
+
**Words never end in whitespace.** The footer step trims trailing ASCII whitespace
|
|
165
|
+
(`[\t\n\v\f\r ]`) before it appends the `[COS-WEB turn ...]` line, so the signer trims trailing
|
|
166
|
+
ASCII whitespace BEFORE it hashes, and the server refuses signed words that end in it
|
|
167
|
+
(`words_trailing_whitespace`). Inner whitespace is exact. The verifier hashes the words before the
|
|
168
|
+
footer as received (CRLF to LF only) and needs no trimming rule of its own.
|
|
169
|
+
|
|
170
|
+
### Scope (B4)
|
|
171
|
+
|
|
172
|
+
- `message`: **conversation only.** `MESSAGE_SCOPE_MEANING = "conversation"`. It never authorizes
|
|
173
|
+
install, publish, push (main or branch), a Hub file, or anything on the never-list, whatever the
|
|
174
|
+
words say.
|
|
175
|
+
- `approve`: `{ "kind": "approve", "action", "artifact", "version", "digest", "channel", "packetSha256" }`
|
|
176
|
+
|
|
177
|
+
| Field | Pattern |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| action | `install` \| `publish` \| `push-main` |
|
|
180
|
+
| artifact | `^[a-z0-9@][a-z0-9@._/-]{0,127}$` |
|
|
181
|
+
| version | `^[0-9]{1,5}\.[0-9]{1,5}\.[0-9]{1,6}(-[0-9A-Za-z.-]{1,32})?$` |
|
|
182
|
+
| digest | `^sha256:[0-9a-f]{64}$` or `^sha512:[0-9a-f]{128}$` or `^sha512-[A-Za-z0-9+/]{86}==$` (npm SRI) |
|
|
183
|
+
| channel | from the table below only |
|
|
184
|
+
| packetSha256 | hex64 of the JCS bytes of the packet |
|
|
185
|
+
|
|
186
|
+
| action | allowed channels |
|
|
187
|
+
| --- | --- |
|
|
188
|
+
| install | `mac-local` |
|
|
189
|
+
| publish | `npm-next`, `npm-latest`, `control-appcast`, `hub-alias`, `gotcos-site`, `control-downloads`, `release-docs` |
|
|
190
|
+
| push-main | `git-main` |
|
|
191
|
+
|
|
192
|
+
In this candidate `install` and `publish` are accepted only through exact scoped proof (`WEB_KEY_ENABLED_APPROVE_ACTIONS`). `push-main` remains refused with `approve_action_not_enabled`. A message scope never authorizes either action.
|
|
193
|
+
|
|
194
|
+
### Handoff from Control Web (3143) to the signer (3144), Phase 3
|
|
195
|
+
|
|
196
|
+
The composer never signs and never frames the signer. It posts the turn it wants approved, then navigates
|
|
197
|
+
TOP LEVEL to the signer. The words are fetched by the signer; they are never in a URL.
|
|
198
|
+
|
|
199
|
+
1. `POST /api/web-key/handoff` (web path, flag on) `{ bindingId, epoch, targetKey, clientTurnId, prompt,
|
|
200
|
+
acknowledgedRevision?, boundTo?, packetSha256? }`. The destination (`provider`, `sessionId`) comes from
|
|
201
|
+
the binding, never the body; a moved binding is `409 binding_changed`. Refused: `no_phone_key` (no
|
|
202
|
+
active key), `attachments_not_signable` (any `attachmentIds`), `words_too_long` (over 8000 chars),
|
|
203
|
+
`packet_unknown`, `packet_session_mismatch` (a packet is signable only for the session that created it).
|
|
204
|
+
Answer `201 { ok, handoffId, exp, signerUrl: "https://<rpId>:3144/#h=<handoffId>" }`. The composer
|
|
205
|
+
navigates only to that exact shape (same host, port 3144, path `/`, no query, fragment id only).
|
|
206
|
+
2. `POST /api/web-key/handoff/claim { handoffId }` (web path, 3144 listener, `Origin` = signer origin).
|
|
207
|
+
Once only (`409 handoff_claimed`), within 120 s (`410 handoff_expired`). Answer
|
|
208
|
+
`{ ok, handoff: { handoffId, exp, words, provider, sessionId, bindingId, turn: { bindingId, epoch, targetKey,
|
|
209
|
+
clientTurnId, acknowledgedRevision?, boundTo? }, scope, packet: <full packet preimage or null>, attachments: [] } }`. `scope` is `message`, or the
|
|
210
|
+
packet's `approve` scope when `packetSha256` was given.
|
|
211
|
+
3. The signer trims trailing ASCII whitespace from `words`, shows the exact result with provider, session
|
|
212
|
+
and scope, and signs only after a real tap (`isTrusted`). For an approval, it first validates the full canonical packet preimage and SHA-256, exact fields and receiver, and expiry. It displays that packet with its digest and expiry before asking for a user-verified passkey. Cancel or denied verification sends nothing. Envelope: `origin` = the page's own
|
|
213
|
+
`location.origin`; `turnId` = `turn.clientTurnId`; `aud`, `nonce`, `epoch` from `POST nonce`;
|
|
214
|
+
`iat` = now - 30 s; `exp` = `iat` + 600; `fork: false`; `attachments: []`; `kver: 1`. It signs the JCS
|
|
215
|
+
bytes with the device key, then calls `navigator.credentials.get` with challenge = sha256(those bytes)
|
|
216
|
+
and `userVerification: "required"`, then posts `{ prompt: <the shown words>, epoch, targetKey,
|
|
217
|
+
clientTurnId, acknowledgedRevision?, boundTo? }` to `POST /api/agent-sessions/bindings/:bindingId/turns`
|
|
218
|
+
with the three headers.
|
|
219
|
+
|
|
220
|
+
A handoff is not authority (any cookie holder can create one). Authority is the tap, the device key and
|
|
221
|
+
a user-verified phone passkey over the envelope the signer built and showed. WebAuthn proves user verification, not a specific biometric method; platform passkey fallback is not a claim of Face ID-only enforcement.
|
|
222
|
+
|
|
223
|
+
### Approval packets
|
|
224
|
+
|
|
225
|
+
`POST /api/web-key/packets` (token). The creating session comes from the request context headers
|
|
226
|
+
`x-cos-provider` and `x-cos-session-id`, never the body (a body `provider`/`sessionId` is refused,
|
|
227
|
+
`session_from_context_only`). Packet object, `cos-web-packet/1`:
|
|
228
|
+
`{ v, packetId, action, artifact, version, digest, channel, provider, sessionId, createdAt, exp }`.
|
|
229
|
+
An `approve` envelope whose `provider`/`sessionId` differ from the packet's creator is refused
|
|
230
|
+
(`packet_session_mismatch`). The receiver MUST provide its locally reviewed full canonical packet through `--expect-packet-file`, plus `--expect-packet` and `--expect-digest`, and validate every exact scope/receiver field and expiry before one-shot consume. It recomputes the canonical preimage hash; hash plus digest alone is insufficient. Per-session identity is not separately authenticated (every agent holds the token). The packet is an untrusted request until the phone signs it.
|
|
231
|
+
|
|
232
|
+
Packet id is canonical base64url of 12 random bytes (`^[A-Za-z0-9_-]{16}$`). `createdAt` and `exp` are safe integer Unix milliseconds; `0 < exp-createdAt <= 1800000`. Shared exports are `WEB_APPROVAL_PACKET_MAX_LIFETIME_MS`, `WEB_APPROVAL_PACKET_ID_RE`, `parseWebApprovalPacket`. Golden canonical bytes and SHA-256 are in `shared/web-approval-packets.golden.json`.
|
|
233
|
+
|
|
234
|
+
`GET /api/web-key/packets?provider=<provider>&sessionId=<id>` uses the existing authenticated web/token gate and is absent when authority is off. It lists only enabled actions and the requested receiver. Sessions and Today's session sheet use one shared explicit picker. Default is message only; choosing a packet passes its exact hash to handoff. A stale selection refuses, without falling back to a message or a broader approval. The separate 3144 signer cannot list packets: its allowlist only permits claiming the one chosen handoff.
|
|
235
|
+
|
|
236
|
+
New publish channels have disjoint, mandatory artifact names and SHA-256 digests:
|
|
237
|
+
|
|
238
|
+
| Channel | Required artifact | Digest covers / consumer responsibility |
|
|
239
|
+
| --- | --- | --- |
|
|
240
|
+
| `gotcos-site` | `gotcos-site-manifest` | Exact canonical deployment manifest: source commit, build output digests and allowlisted gotcos.com deployment target. No mutable URL or wildcard. |
|
|
241
|
+
| `control-downloads` | `cos-control-downloads-manifest` | Exact ZIP/sidecar digests, immutable asset names, latest aliases and allowed release repository. |
|
|
242
|
+
| `release-docs` | `cos-release-docs-manifest` | Exact documentation file/path set and separate deployment target. If docs are part of the same website deployment, one site manifest lists those bytes; a separate docs approval does not approve the site. |
|
|
243
|
+
|
|
244
|
+
The server validates channel/artifact/digest grammar and packet binding. The deploying consumer MUST additionally validate the locally reviewed deployment manifest bytes and target allowlists before consume; this generic server does not inspect deployment manifest contents. No channel grants a blanket matched-release approval. npm's own passkey authentication remains separate from COS authorization.
|
|
245
|
+
|
|
246
|
+
## 5. Passkey assertion (B2), required for EVERY vouched turn
|
|
247
|
+
|
|
248
|
+
Header `x-cos-web-passkey`: b64url of the JCS of
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{ "v": "cos-web-passkey/1", "credentialId": "<b64url>", "authenticatorData": "<b64url>", "clientDataJSON": "<b64url>", "signature": "<DER sig b64url>" }
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Checks (server and verifier, identically):
|
|
255
|
+
1. `credentialId == approval.passkey_credential_id`.
|
|
256
|
+
2. `clientDataJSON` parses as JSON; `type == "webauthn.get"`; `challenge == b64url(sha256(envelope JCS bytes))` exactly; `origin == envelope.origin` exactly (port included); `crossOrigin` absent or false.
|
|
257
|
+
3. `authenticatorData` at least 37 bytes; `authenticatorData[0:32] == sha256(utf8(rpId))`.
|
|
258
|
+
4. flags byte `authenticatorData[32]`: UP (`0x01`) AND UV (`0x04`) both set.
|
|
259
|
+
5. ECDSA P-256 / SHA-256 DER signature over `authenticatorData || sha256(clientDataJSON bytes)` verifies with `registryEntry.passkeySpki`, whose sha256 equals the anchored `approval.passkey_spki_sha256`.
|
|
260
|
+
|
|
261
|
+
No assertion, no vouch (`passkey_missing`, `passkey_invalid`, `passkey_origin_mismatch`, ...).
|
|
262
|
+
|
|
263
|
+
**The passkey is THE approval path (Miles, 2026-11-07).** The phone key plus a passkey with Face ID
|
|
264
|
+
on the 3144 signer page is the complete approval path for every user, including people with only a
|
|
265
|
+
phone and a Mac and no Even app. A confirmation from the COS glasses companion is optional, never
|
|
266
|
+
required, and not built. The seam stays: the server verifies the second factor in one module
|
|
267
|
+
(`server/lib/web-key-passkey.ts`), its proof travels as its own field beside the envelope (`passkey`
|
|
268
|
+
in the answer), and the phone assembles the proof in one module (`server/web-signer/signer-proof.js`
|
|
269
|
+
`assembleTurnProof`, whose signer is an interface). An optional companion signer would add a sibling
|
|
270
|
+
without changing the envelope, the anchor or this check. Nothing assumes one exists.
|
|
271
|
+
|
|
272
|
+
## 6. Turn answer: `GET /api/g2-turns/:turnId` for a web-key record (flag on)
|
|
273
|
+
|
|
274
|
+
Existing fields (`found, turnId, createdAt, originJudgedAt, ageMs, textSha256, origin, originKind,
|
|
275
|
+
verifiedOrigin, reason, sealed, target, via`) are unchanged. A web-key record adds:
|
|
276
|
+
|
|
277
|
+
```json
|
|
278
|
+
{
|
|
279
|
+
"authority": "web-key",
|
|
280
|
+
"kid": "<kid>",
|
|
281
|
+
"aud": "<hex64>",
|
|
282
|
+
"scope": { ... },
|
|
283
|
+
"scopeMeaning": "conversation" | "approve",
|
|
284
|
+
"notAuthorized": [ "<item>", ... ],
|
|
285
|
+
"envelope": "<b64url JCS>", "signature": "<P1363 b64url>",
|
|
286
|
+
"passkey": { "credentialId", "authenticatorData", "clientDataJSON", "signature" },
|
|
287
|
+
"authId": "<hex64 = sha256(phone signature bytes)>",
|
|
288
|
+
"wordsSha256": "<hex64, copied from the parsed envelope>",
|
|
289
|
+
"registryEntry": { "kid", "spki", "passkeySpki", "kver", "label", "approval", "anchorSig", "revokedAt?" },
|
|
290
|
+
"anchor": { "spkiSha256": "<hex64>", "fingerprint": "XXXX-...", "setAt": <ms>, "ageMs": <ms>, "mature": true },
|
|
291
|
+
"epoch": <int>,
|
|
292
|
+
"revoked": [ { "kid", "revokedAt" } ],
|
|
293
|
+
"revocationDigest": "<hex64 = sha256(JCS(revoked sorted by kid))>",
|
|
294
|
+
"integrity": { "enabled": true, "lastCheckOk": true, "checkedAt": <ms>, "manifestSha256": "<hex64>" | null },
|
|
295
|
+
"integrityAtAccept": { same shape, frozen when the turn was accepted },
|
|
296
|
+
"consumeKeys": [ "turn:<turnId>", "web:<aud>:<authId>" ]
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`verifiedOrigin: true` only when, besides signature and registry state: the anchor is mature, the
|
|
301
|
+
integrity check is enabled AND its last check passed now and at accept, the passkey verifies, and
|
|
302
|
+
the record matches the envelope (B3): `record.turnId == envelope.turnId`,
|
|
303
|
+
`record.target.provider == envelope.provider`, `record.target.sessionId == envelope.sessionId`,
|
|
304
|
+
and every copied proof field (`kid, kver, epoch, iat, exp, provider, sessionId, wordsSha256, scope,
|
|
305
|
+
authId`) equals the envelope. Any difference: `reason: "record_envelope_mismatch"`.
|
|
306
|
+
|
|
307
|
+
**`textSha256` covers the delivered prompt (words plus files block), normalized.** For a web-key
|
|
308
|
+
turn the verifier must NOT use it: compare `sha256(signedWordsText(words))` of the words it received
|
|
309
|
+
(before the files block and footer) with `wordsSha256` decoded from the envelope bytes, and session,
|
|
310
|
+
provider and turn id with the ENVELOPE, never the record.
|
|
311
|
+
|
|
312
|
+
**Integrity (W1):** with the signer (Phase 3), `lastCheckOk` is true only when BOTH the Control Web tree
|
|
313
|
+
(`web-control/.manifest.json`) and the signer tree (`web-signer/.manifest.json`) match; `manifestSha256`
|
|
314
|
+
is still the Control Web manifest's (shape unchanged). `/api/web-key/status` reports the signer's own
|
|
315
|
+
check under `integrity.signer`. The verifier refuses unless `integrity.enabled`, `integrity.lastCheckOk`,
|
|
316
|
+
`integrityAtAccept.enabled` and `integrityAtAccept.lastCheckOk` are all `true`.
|
|
317
|
+
|
|
318
|
+
**Epoch and revocation (W5):** the server reads them from `web-keys.json`, which a same-user process
|
|
319
|
+
can roll back (remove a `revokedAt`, restore an older file). The server alone cannot see that. The
|
|
320
|
+
verifier compares `epoch` and `revocationDigest` with a Control-held source (Control's keychain
|
|
321
|
+
mirror) and refuses on difference; `approval.epoch` and `envelope.epoch` must equal Control's epoch.
|
|
322
|
+
|
|
323
|
+
**notAuthorized** (always includes the never-list):
|
|
324
|
+
`credentials`, `payment-details`, `moving-money`, `deleting-data`, `sending-messages`,
|
|
325
|
+
`security-or-permission-settings`, `harness-permission-prompts`, `authority-rule-changes`.
|
|
326
|
+
For `message` scope it also includes `install`, `publish`, `push-main`, `push-branch`, `hub-file`.
|
|
327
|
+
For `approve` scope it also includes every action other than the signed one, and
|
|
328
|
+
`other-artifact-version-digest-or-channel`.
|
|
329
|
+
|
|
330
|
+
**Attachments:** until `shared/turn-files-block.ts` exists, the verifier refuses any non-empty
|
|
331
|
+
`envelope.attachments`.
|
|
332
|
+
|
|
333
|
+
## 7. Consume
|
|
334
|
+
|
|
335
|
+
`POST /api/g2-turns/:turnId/consume` `{ "session", "provider" }` (token, flag on). Consumes
|
|
336
|
+
`consumeKeys` (`turn:<turnId>` and `web:<aud>:<authId>`) in ONE atomic write of
|
|
337
|
+
`web-key-nonces.json`; refuses `already_consumed` if either key exists. The verifier does not call
|
|
338
|
+
this route. Its own `.g2_turns_consumed.json` spends, in one locked write, the BARE turn id (the
|
|
339
|
+
same key the G2 path spends, so G2 and web share one namespace) plus `web:<aud>:<authId>`, and
|
|
340
|
+
requires `consumeKeys` to equal exactly `["turn:<turnId>", "web:<aud>:<authId>"]`. The `turn:`
|
|
341
|
+
prefix exists only in the server's store.
|
|
342
|
+
|
|
343
|
+
## 8. Reasons the server can give (web-key)
|
|
344
|
+
|
|
345
|
+
`web_key_authority_off, signature_missing, envelope_malformed, envelope_version_unknown,
|
|
346
|
+
scope_not_allowed, envelope_not_canonical, anchor_not_registered, anchor_unregistered,
|
|
347
|
+
anchor_too_new, aud_unavailable, aud_mismatch, origin_unavailable, origin_mismatch, key_unknown,
|
|
348
|
+
key_unanchored, key_revoked, key_epoch_stale, key_version_stale, epoch_stale, signature_invalid,
|
|
349
|
+
lifetime_invalid, iat_in_future, signature_expired, fork_destination_unbound, destination_unbound,
|
|
350
|
+
destination_mismatch, turn_id_mismatch, words_changed, words_trailing_whitespace, attachments_unsealable, attachments_changed,
|
|
351
|
+
packet_unknown, packet_mismatch, packet_session_mismatch, approve_action_not_enabled,
|
|
352
|
+
passkey_missing, passkey_malformed, passkey_credential_mismatch, passkey_type_invalid,
|
|
353
|
+
passkey_challenge_mismatch, passkey_origin_mismatch, passkey_rpid_mismatch, passkey_not_user_verified,
|
|
354
|
+
passkey_invalid, integrity_off, integrity_failed, record_envelope_mismatch, nonce_unknown,
|
|
355
|
+
nonce_spent, nonce_expired, nonce_store_unavailable, web_key_check_failed`.
|
|
356
|
+
|
|
357
|
+
Route refusals added in Phase 3 (HTTP answers, never turn reasons): `signer_origin_required`,
|
|
358
|
+
`origin_refused` (3144 listener), `handoff_unavailable`, `no_phone_key`, `unknown_binding`,
|
|
359
|
+
`binding_changed`, `attachments_not_signable`, `words_too_long`, `handoff_cap_reached`,
|
|
360
|
+
`handoff_unknown`, `handoff_expired`, `handoff_claimed`.
|
|
361
|
+
|
|
362
|
+
## Change log
|
|
363
|
+
|
|
364
|
+
- 2026-11-07 17:05 CST: first version (B1 anchor bootstrap, B2 signer origin 3144 and passkey per
|
|
365
|
+
turn, B3 record-envelope match, B4 scope patterns and packet binding, W1 integrity, W3 SAS
|
|
366
|
+
commit-reveal, W4 display inside approval bytes, W5 approval epoch and revocation digest, W6
|
|
367
|
+
device allowlist and remote, W8 rotates). Field names `passkey_credential_id`,
|
|
368
|
+
`passkey_spki_sha256`, `passkeySpki`, `integrity.lastCheckOk` follow the verifier lane's request.
|
|
369
|
+
- 2026-11-07 18:05 CST: words never end in whitespace (`words_trailing_whitespace`, verifier lane
|
|
370
|
+
point 1, option a). Consume: the verifier spends the bare turn id plus `web:<aud>:<authId>`; the
|
|
371
|
+
server's own store keeps the `turn:` prefix (verifier lane point 2). Added the second-signer note
|
|
372
|
+
in section 5 (B2 step 2 is a later, separate plan). Drain re-check now also compares the parked
|
|
373
|
+
file manifest with the signed one (`attachments_changed`).
|
|
374
|
+
- 2026-11-07 18:50 CST: Phase 3 signer. 3144 serves only the signer page (its own manifest) and an /api
|
|
375
|
+
allowlist; the phone's key steps and the handoff claim answer only there (`signer_origin_required`).
|
|
376
|
+
Added the 3143 to 3144 handoff (section 4). Integrity `lastCheckOk` now covers the signer tree too
|
|
377
|
+
(shape unchanged). Section 5: the passkey with Face ID is the complete approval path for every user;
|
|
378
|
+
the companion confirm is optional and never required (Miles). No envelope, passkey or answer field changed.
|
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"COS_PERMISSION_BROKER_TIMEOUT_S",
|
|
83
83
|
"COS_PERMISSION_BROKER_AWAY_HOLD_S",
|
|
84
84
|
"COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S",
|
|
85
|
+
"COS_QUESTION_FULL_HOLD",
|
|
86
|
+
"COS_APPROVAL_FULL_HOLD",
|
|
87
|
+
"COS_WAITING_QUESTION_WORDS",
|
|
88
|
+
"COS_TURN_END_ASKS",
|
|
85
89
|
"COS_LENS_GIST_ENGINE",
|
|
86
90
|
"COS_LENS_GIST_MODEL",
|
|
87
91
|
"COS_LENS_GIST_DAILY_CAP",
|
|
@@ -115,7 +119,11 @@
|
|
|
115
119
|
"COS_PROMPT_EDIT_DAILY_CAP",
|
|
116
120
|
"COS_PROMPT_EDIT_MODEL",
|
|
117
121
|
"COS_PROMPT_EDIT_EFFORT",
|
|
118
|
-
"COS_PROMPT_EDIT_TIMEOUT_MS"
|
|
122
|
+
"COS_PROMPT_EDIT_TIMEOUT_MS",
|
|
123
|
+
"COS_WEB_KEY_AUTHORITY",
|
|
124
|
+
"COS_WEB_CONTROL_INTEGRITY",
|
|
125
|
+
"COS_WEB_KEY_DEVICES",
|
|
126
|
+
"COS_WEB_KEY_ANCHOR_BOOTSTRAP"
|
|
119
127
|
],
|
|
120
128
|
"maintenance": {
|
|
121
129
|
"scope": "cross_boot",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.27",
|
|
4
4
|
"description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"test": "vitest run --maxWorkers=1",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"mutation:gate": "node server/scripts/mutation-gate.mjs",
|
|
18
|
-
"control2:lab": "node bin/control2-foundation.cjs"
|
|
18
|
+
"control2:lab": "node bin/control2-foundation.cjs",
|
|
19
|
+
"prepack": "node server/scripts/write-web-control-manifest.mjs && node server/scripts/write-web-control-manifest.mjs server/web-signer"
|
|
19
20
|
},
|
|
20
21
|
"keywords": [
|
|
21
22
|
"even-g2",
|
|
@@ -148,7 +149,13 @@
|
|
|
148
149
|
"docs/test-plan-6.71.8.md",
|
|
149
150
|
"docs/test-plan-6.71.9.md",
|
|
150
151
|
"docs/test-plan-6.71.10.md",
|
|
151
|
-
"docs/test-plan-6.71.11.md"
|
|
152
|
+
"docs/test-plan-6.71.11.md",
|
|
153
|
+
"!server/scripts/web-key-mutation-gate.mjs",
|
|
154
|
+
"!server/scripts/web-key-signer-mutation-gate.mjs",
|
|
155
|
+
"!server/scripts/compact-session-log.ts",
|
|
156
|
+
"!server/scripts/full-hold-mutation-gate.mjs",
|
|
157
|
+
"!server/scripts/turn-end-ask-mutation-gate.mjs",
|
|
158
|
+
"docs/web-key-verifier-contract.md"
|
|
152
159
|
],
|
|
153
160
|
"author": "COS Contributors",
|
|
154
161
|
"license": "MIT",
|
package/server/bootstrap.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// slot used by the installed LaunchAgent before mutable routes or stores load.
|
|
3
3
|
import './env.js'
|
|
4
4
|
import { acquireServerInstanceLock, ServerInstanceActiveError } from './lib/server-instance-lock.js'
|
|
5
|
+
import { diskIoMeterRequested, installDiskIoMeter } from './lib/disk-io-meter.js'
|
|
6
|
+
import { startEventLoopStallMonitor } from './lib/event-loop-stall.js'
|
|
5
7
|
|
|
6
8
|
try {
|
|
7
9
|
const instanceLock = acquireServerInstanceLock()
|
|
@@ -14,3 +16,11 @@ try {
|
|
|
14
16
|
console.error('[COS API] Startup refused: single-instance lock failed.', error)
|
|
15
17
|
process.exit(74)
|
|
16
18
|
}
|
|
19
|
+
|
|
20
|
+
// S1 (2026-11-09): opt-in per-path disk I/O meter (COS_DISK_IO_METER=1, default off). Installed
|
|
21
|
+
// here, before index.ts loads its stores, so module-load writes are counted too.
|
|
22
|
+
if (diskIoMeterRequested()) installDiskIoMeter()
|
|
23
|
+
|
|
24
|
+
// S1 (2026-11-09): log when the event loop was blocked over 2 s (a line only on a stall). Settles
|
|
25
|
+
// whether a client timeout (Mac dictation's 5 s capabilities check) was the server not running JS.
|
|
26
|
+
startEventLoopStallMonitor()
|