@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,155 @@
1
+ // WebAuthn passkey assertions for the enrolled phone key (6.99.31, review B2). node:crypto only.
2
+ //
3
+ // Every vouched web turn carries a user-verified assertion whose challenge is
4
+ // base64url(sha256(envelope JCS bytes)). The passkey's credential id and public key hash are inside
5
+ // the anchor-signed enrollment approval, so a script on the signer origin that holds only the
6
+ // phone key (non-extractable but usable without a tap) cannot vouch anything without Miles's face
7
+ // or finger at that moment. Field rules: docs/web-key-verifier-contract.md section 5.
8
+
9
+ import { createHash, createPublicKey, verify, type KeyObject } from 'node:crypto'
10
+ import { canonicalBytes, fromB64url, parseP256Spki } from './web-key-envelope.js'
11
+ import { WEB_PASSKEY_ASSERTION_VERSION } from '../../shared/web-turn-envelope.js'
12
+
13
+ export const WEB_PASSKEY_HEADER = 'x-cos-web-passkey'
14
+ export const PASSKEY_FLAG_UP = 0x01
15
+ export const PASSKEY_FLAG_UV = 0x04
16
+
17
+ export interface PasskeyAssertion {
18
+ credentialId: string
19
+ authenticatorData: string
20
+ clientDataJSON: string
21
+ signature: string
22
+ }
23
+
24
+ const ASSERTION_KEYS = ['v', 'credentialId', 'authenticatorData', 'clientDataJSON', 'signature'] as const
25
+
26
+ function isB64url(s: unknown, max: number): s is string {
27
+ return typeof s === 'string' && s.length > 0 && s.length <= max && fromB64url(s) !== null
28
+ }
29
+
30
+ /** The four assertion fields from an object (`v` optional), or null. */
31
+ export function parsePasskeyAssertion(v: unknown, requireVersion = false): PasskeyAssertion | null {
32
+ if (typeof v !== 'object' || v === null || Array.isArray(v)) return null
33
+ const o = v as Record<string, unknown>
34
+ const keys = Object.keys(o)
35
+ const allowed = new Set<string>(ASSERTION_KEYS)
36
+ if (keys.some(k => !allowed.has(k))) return null
37
+ if (requireVersion || 'v' in o) { if (o.v !== WEB_PASSKEY_ASSERTION_VERSION) return null }
38
+ if (!isB64url(o.credentialId, 1024) || !isB64url(o.authenticatorData, 4096) || !isB64url(o.clientDataJSON, 4096) || !isB64url(o.signature, 256)) return null
39
+ return { credentialId: o.credentialId, authenticatorData: o.authenticatorData, clientDataJSON: o.clientDataJSON, signature: o.signature }
40
+ }
41
+
42
+ /** The `x-cos-web-passkey` header: base64url of the canonical JCS of a `cos-web-passkey/1` object. */
43
+ export function parsePasskeyHeader(header: unknown): PasskeyAssertion | null {
44
+ if (typeof header !== 'string' || header.length === 0 || header.length > 12 * 1024) return null
45
+ const bytes = fromB64url(header)
46
+ if (!bytes) return null
47
+ let parsed: unknown
48
+ try { parsed = JSON.parse(bytes.toString('utf8')) } catch { return null }
49
+ const a = parsePasskeyAssertion(parsed, true)
50
+ if (!a) return null
51
+ const canonical = canonicalBytes({ v: WEB_PASSKEY_ASSERTION_VERSION, ...a })
52
+ return canonical && canonical.equals(bytes) ? a : null
53
+ }
54
+
55
+ // ---- minimal CBOR (RFC 8949) for a COSE_Key map: small int keys, ints and byte strings only ----
56
+
57
+ function readHead(buf: Buffer, pos: number): { major: number; val: number; next: number } | null {
58
+ if (pos >= buf.length) return null
59
+ const ib = buf[pos]
60
+ const major = ib >> 5
61
+ const info = ib & 0x1f
62
+ if (info < 24) return { major, val: info, next: pos + 1 }
63
+ if (info === 24 && pos + 1 < buf.length) return { major, val: buf[pos + 1], next: pos + 2 }
64
+ if (info === 25 && pos + 2 < buf.length) return { major, val: buf.readUInt16BE(pos + 1), next: pos + 3 }
65
+ return null
66
+ }
67
+
68
+ function readInt(buf: Buffer, pos: number): { value: number; next: number } | null {
69
+ const h = readHead(buf, pos)
70
+ if (!h) return null
71
+ if (h.major === 0) return { value: h.val, next: h.next }
72
+ if (h.major === 1) return { value: -1 - h.val, next: h.next }
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * A COSE EC2 P-256 ES256 public key (base64url CBOR) as P-256 SPKI DER, or null. Exactly the map
78
+ * {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x (32 bytes), -3: y (32 bytes)}; anything else refused.
79
+ */
80
+ export function coseP256ToSpki(coseB64url: unknown): Buffer | null {
81
+ const buf = fromB64url(coseB64url)
82
+ if (!buf || buf.length > 256) return null
83
+ const head = readHead(buf, 0)
84
+ if (!head || head.major !== 5 || head.val !== 5) return null
85
+ let pos = head.next
86
+ const map = new Map<number, number | Buffer>()
87
+ for (let i = 0; i < 5; i += 1) {
88
+ const k = readInt(buf, pos)
89
+ if (!k || map.has(k.value)) return null
90
+ pos = k.next
91
+ const h = readHead(buf, pos)
92
+ if (!h) return null
93
+ if (h.major === 2) {
94
+ if (h.next + h.val > buf.length) return null
95
+ map.set(k.value, buf.subarray(h.next, h.next + h.val))
96
+ pos = h.next + h.val
97
+ } else {
98
+ const v = readInt(buf, pos)
99
+ if (!v) return null
100
+ map.set(k.value, v.value)
101
+ pos = v.next
102
+ }
103
+ }
104
+ if (pos !== buf.length) return null
105
+ const x = map.get(-2)
106
+ const y = map.get(-3)
107
+ if (map.get(1) !== 2 || map.get(3) !== -7 || map.get(-1) !== 1) return null
108
+ if (!Buffer.isBuffer(x) || !Buffer.isBuffer(y) || x.length !== 32 || y.length !== 32) return null
109
+ try {
110
+ const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: x.toString('base64url'), y: y.toString('base64url') }, format: 'jwk' })
111
+ const der = key.export({ format: 'der', type: 'spki' }) as Buffer
112
+ return parseP256Spki(der.toString('base64url')) ? der : null
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ export type PasskeyVerdict = { ok: true; signCount: number } | { ok: false; reason: 'passkey_malformed' | 'passkey_credential_mismatch' | 'passkey_type_invalid' | 'passkey_challenge_mismatch' | 'passkey_origin_mismatch' | 'passkey_rpid_mismatch' | 'passkey_not_user_verified' | 'passkey_invalid' }
119
+
120
+ /**
121
+ * Verify one WebAuthn assertion. `challenge` is the raw challenge bytes (the phone passed them to
122
+ * navigator.credentials.get, so clientDataJSON carries their base64url). Never throws.
123
+ */
124
+ export function verifyPasskeyAssertion(a: PasskeyAssertion, expect: { challenge: Buffer; origin: string; rpId: string; credentialId: string; publicKey: KeyObject }): PasskeyVerdict {
125
+ try {
126
+ if (a.credentialId !== expect.credentialId) return { ok: false, reason: 'passkey_credential_mismatch' }
127
+ const authData = fromB64url(a.authenticatorData)
128
+ const clientBytes = fromB64url(a.clientDataJSON)
129
+ const sig = fromB64url(a.signature)
130
+ if (!authData || !clientBytes || !sig || authData.length < 37) return { ok: false, reason: 'passkey_malformed' }
131
+ let client: Record<string, unknown>
132
+ try {
133
+ const parsed = JSON.parse(clientBytes.toString('utf8')) as unknown
134
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return { ok: false, reason: 'passkey_malformed' }
135
+ client = parsed as Record<string, unknown>
136
+ } catch { return { ok: false, reason: 'passkey_malformed' } }
137
+ if (client.type !== 'webauthn.get') return { ok: false, reason: 'passkey_type_invalid' }
138
+ if (client.challenge !== expect.challenge.toString('base64url')) return { ok: false, reason: 'passkey_challenge_mismatch' }
139
+ if (client.origin !== expect.origin || (client.crossOrigin !== undefined && client.crossOrigin !== false)) return { ok: false, reason: 'passkey_origin_mismatch' }
140
+ const rpIdHash = createHash('sha256').update(expect.rpId, 'utf8').digest()
141
+ if (!authData.subarray(0, 32).equals(rpIdHash)) return { ok: false, reason: 'passkey_rpid_mismatch' }
142
+ const flags = authData[32]
143
+ if ((flags & PASSKEY_FLAG_UP) === 0 || (flags & PASSKEY_FLAG_UV) === 0) return { ok: false, reason: 'passkey_not_user_verified' }
144
+ const signed = Buffer.concat([authData, createHash('sha256').update(clientBytes).digest()])
145
+ if (!verify('sha256', signed, { key: expect.publicKey, dsaEncoding: 'der' }, sig)) return { ok: false, reason: 'passkey_invalid' }
146
+ return { ok: true, signCount: authData.readUInt32BE(33) }
147
+ } catch {
148
+ return { ok: false, reason: 'passkey_invalid' }
149
+ }
150
+ }
151
+
152
+ /** The challenge a turn's passkey assertion signs: sha256 of the envelope's JCS bytes. */
153
+ export function turnPasskeyChallenge(envelopeBytes: Buffer): Buffer {
154
+ return createHash('sha256').update(envelopeBytes).digest()
155
+ }