@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,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
|
+
}
|