@reclaimprotocol/client 0.1.27 → 0.1.29

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 (73) hide show
  1. package/README.md +634 -42
  2. package/lib/api-client.d.ts +31 -0
  3. package/lib/api-client.js +112 -0
  4. package/lib/api-consts.d.ts +4 -0
  5. package/lib/api-consts.js +4 -0
  6. package/lib/api-errors.d.ts +13 -0
  7. package/lib/api-errors.js +30 -0
  8. package/lib/api.d.ts +7 -0
  9. package/lib/api.js +3 -0
  10. package/lib/attestor-proof.d.ts +6 -0
  11. package/lib/attestor-proof.js +45 -18
  12. package/lib/client.d.ts +1 -6
  13. package/lib/client.js +12 -33
  14. package/lib/consts.d.ts +1 -1
  15. package/lib/consts.js +1 -1
  16. package/lib/eip191.d.ts +4 -2
  17. package/lib/eip191.js +17 -1
  18. package/lib/errors.d.ts +38 -4
  19. package/lib/errors.js +99 -16
  20. package/lib/index.d.ts +9 -7
  21. package/lib/index.js +8 -4
  22. package/lib/jwks.d.ts +25 -1
  23. package/lib/jwks.js +69 -4
  24. package/lib/legacy-proof.d.ts +57 -0
  25. package/lib/legacy-proof.js +211 -0
  26. package/lib/proof-hash.d.ts +35 -0
  27. package/lib/proof-hash.js +216 -0
  28. package/lib/proof-recipe.d.ts +18 -0
  29. package/lib/proof-recipe.js +37 -0
  30. package/lib/proof-trust-constants.d.ts +3 -0
  31. package/lib/proof-trust-constants.js +43 -0
  32. package/lib/proof-types.d.ts +62 -0
  33. package/lib/proof-types.js +1 -0
  34. package/lib/proof-verification-error.d.ts +13 -0
  35. package/lib/proof-verification-error.js +11 -0
  36. package/lib/reclaim.d.ts +27 -5
  37. package/lib/reclaim.js +140 -14
  38. package/lib/result.d.ts +58 -6
  39. package/lib/result.js +252 -61
  40. package/lib/routes.gen.d.ts +54 -6
  41. package/lib/routes.gen.js +15 -3
  42. package/lib/tee-binding.d.ts +82 -0
  43. package/lib/tee-binding.js +112 -0
  44. package/lib/types/openapi.gen.d.ts +1343 -206
  45. package/lib/verification-clients.d.ts +17 -0
  46. package/lib/verification-clients.js +14 -0
  47. package/lib/verify-attestor-tee.d.ts +76 -0
  48. package/lib/verify-attestor-tee.js +243 -0
  49. package/lib/verify-tee.d.ts +32 -0
  50. package/lib/verify-tee.js +347 -0
  51. package/package.json +5 -1
  52. package/src/api.ts +15 -0
  53. package/src/attestor-proof.ts +57 -20
  54. package/src/client.ts +15 -38
  55. package/src/consts.ts +1 -2
  56. package/src/eip191.ts +31 -3
  57. package/src/errors.ts +132 -18
  58. package/src/index.ts +50 -5
  59. package/src/jwks.ts +128 -5
  60. package/src/legacy-proof.ts +367 -0
  61. package/src/proof-hash.ts +316 -0
  62. package/src/proof-recipe.ts +75 -0
  63. package/src/proof-trust-constants.ts +45 -0
  64. package/src/proof-types.ts +57 -0
  65. package/src/proof-verification-error.ts +13 -0
  66. package/src/reclaim.ts +206 -19
  67. package/src/result.ts +403 -68
  68. package/src/routes.gen.ts +15 -3
  69. package/src/tee-binding.ts +192 -0
  70. package/src/types/openapi.gen.ts +1343 -206
  71. package/src/verification-clients.ts +22 -0
  72. package/src/verify-attestor-tee.ts +365 -0
  73. package/src/verify-tee.ts +507 -0
@@ -0,0 +1,507 @@
1
+ import { keccak_256 } from '@noble/hashes/sha3.js'
2
+ /* eslint-disable @stylistic/max-len */
3
+ import { webcrypto } from 'node:crypto'
4
+ import { recoverEip191Address } from './eip191.ts'
5
+ import { GCP_CONFIDENTIAL_SPACE_ISSUER } from './proof-trust-constants.ts'
6
+ import type { LegacyProof as Proof, TeeAttestation } from './proof-types.ts'
7
+ import { legacyErrorClass } from './proof-verification-error.ts'
8
+ import {
9
+ deriveLegacyTeeNonce,
10
+ legacyApplicationIdFromPrivateKey
11
+ } from './tee-binding.ts'
12
+ export interface TeeAttestationConfig { appSecret: string }
13
+ const TeeVerificationError = legacyErrorClass('TeeVerificationError')
14
+
15
+ const EXPECTED_HW_MODEL = 'GCP_AMD_SEV'
16
+ const EXPECTED_TEE_PROVIDER = 'gcp'
17
+ const EXPECTED_TEE_TECHNOLOGY = 'amd-sev'
18
+ const SUPPORTED_PROOF_VERSIONS = ['v2', 'v3']
19
+ const TOKEN_CLOCK_SKEW_S = 60
20
+ const NONCE_TIMESTAMP_MAX_SKEW_MS = 10 * 60 * 1000
21
+
22
+ type JsonWebKeyLike = webcrypto.JsonWebKey & {
23
+ kid?: string
24
+ alg?: string
25
+ use?: string
26
+ }
27
+
28
+ type DecodedJwt = {
29
+ header: Record<string, unknown>
30
+ payload: Record<string, unknown>
31
+ signingInput: string
32
+ signature: Uint8Array
33
+ }
34
+
35
+ type NonceContextData = {
36
+ applicationId: string
37
+ sessionId: string
38
+ timestamp: string
39
+ }
40
+
41
+ export type TeeVerificationResult = {
42
+ isVerified: boolean
43
+ error?: string
44
+ }
45
+
46
+ const BROWSER_ENVIRONMENT_ERROR =
47
+ 'TEE attestation verification is only supported in non-browser environments. Run verifyTeeAttestation on your server or API route.'
48
+
49
+ function assert(condition: unknown, message: string): asserts condition {
50
+ if(!condition) {
51
+ throw new Error(message)
52
+ }
53
+ }
54
+
55
+ function isBrowserEnvironment(): boolean {
56
+ if('window' in globalThis || 'document' in globalThis) {
57
+ return true
58
+ }
59
+
60
+ if(typeof navigator !== 'undefined' && typeof process === 'undefined') {
61
+ return true
62
+ }
63
+
64
+ const workerGlobalScope = (globalThis as Record<string, unknown>).WorkerGlobalScope
65
+ if(
66
+ typeof workerGlobalScope === 'function' &&
67
+ globalThis instanceof workerGlobalScope
68
+ ) {
69
+ return true
70
+ }
71
+
72
+ return false
73
+ }
74
+
75
+ function assertNonBrowserEnvironment() {
76
+ if(isBrowserEnvironment()) {
77
+ throw new Error(BROWSER_ENVIRONMENT_ERROR)
78
+ }
79
+ }
80
+
81
+ function normalizeHex(value: string | undefined | null): string {
82
+ return (value || '').trim().replace(/^0x/i, '').toLowerCase()
83
+ }
84
+
85
+ function isHex(value: string): boolean {
86
+ return /^[0-9a-f]+$/i.test(value)
87
+ }
88
+
89
+ function decodeBase64Url(input: string): Uint8Array {
90
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/')
91
+ const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
92
+
93
+ if(typeof Buffer !== 'undefined') {
94
+ return new Uint8Array(Buffer.from(padded, 'base64'))
95
+ }
96
+
97
+ if(typeof atob === 'function') {
98
+ const binary = atob(padded)
99
+ const bytes = new Uint8Array(binary.length)
100
+ for(let i = 0; i < binary.length; i += 1) {
101
+ bytes[i] = binary.charCodeAt(i)
102
+ }
103
+
104
+ return bytes
105
+ }
106
+
107
+ throw new Error('Base64 decoding is not supported in this environment')
108
+ }
109
+
110
+ function decodeUtf8(bytes: Uint8Array): string {
111
+ return new TextDecoder().decode(bytes)
112
+ }
113
+
114
+ function decodeJwt(token: string): DecodedJwt {
115
+ const parts = token.split('.')
116
+ assert(parts.length === 3, 'attestation token is not a JWT')
117
+
118
+ return {
119
+ header: JSON.parse(decodeUtf8(decodeBase64Url(parts[0]))),
120
+ payload: JSON.parse(decodeUtf8(decodeBase64Url(parts[1]))),
121
+ signingInput: `${parts[0]}.${parts[1]}`,
122
+ signature: decodeBase64Url(parts[2]),
123
+ }
124
+ }
125
+
126
+ function getFetch(): typeof fetch {
127
+ const fetchFn = globalThis.fetch
128
+ assert(fetchFn, 'fetch is not available in this environment')
129
+ return fetchFn.bind(globalThis)
130
+ }
131
+
132
+ function getSubtleCrypto(): webcrypto.SubtleCrypto {
133
+ if(globalThis.crypto?.subtle) {
134
+ return globalThis.crypto.subtle
135
+ }
136
+
137
+ return webcrypto.subtle
138
+ }
139
+
140
+ async function fetchJson<T>(url: string): Promise<T> {
141
+ const response = await getFetch()(url)
142
+ if(!response.ok) {
143
+ throw new Error(`GET ${url} returned ${response.status} ${response.statusText}`)
144
+ }
145
+
146
+ // The caller supplies the expected shape and validates every field before use.
147
+ return await response.json() as T
148
+ }
149
+
150
+ async function sha256Hex(input: string): Promise<string> {
151
+ const digest = await getSubtleCrypto().digest('SHA-256', new TextEncoder().encode(input))
152
+ return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('')
153
+ }
154
+
155
+ const JWKS_CACHE_TTL_MS = 5 * 60 * 1000
156
+ let cachedJwksUri: string | null = null
157
+ let cachedJwksKeys: JsonWebKeyLike[] | null = null
158
+ let cachedJwksAt = 0
159
+
160
+ async function verifyJwtSignature(
161
+ token: string,
162
+ issuer: string,
163
+ ): Promise<Record<string, unknown>> {
164
+ const { header, payload, signingInput, signature } = decodeJwt(token)
165
+ assert(header.alg === 'RS256', `unexpected attestation signing algorithm: ${header.alg}`)
166
+ assert(typeof header.kid === 'string' && header.kid.length > 0, 'attestation token kid is missing')
167
+
168
+ const isCacheFresh = cachedJwksKeys && (Date.now() - cachedJwksAt) < JWKS_CACHE_TTL_MS
169
+
170
+ if(!isCacheFresh) {
171
+ const oidc = await fetchJson<{ jwks_uri?: unknown }>(
172
+ `${issuer}/.well-known/openid-configuration`,
173
+ )
174
+ assert(typeof oidc?.jwks_uri === 'string' && oidc.jwks_uri.length > 0, 'issuer JWKS URI is missing')
175
+ cachedJwksUri = oidc.jwks_uri
176
+
177
+ const jwks = await fetchJson<{ keys?: JsonWebKeyLike[] }>(cachedJwksUri)
178
+ cachedJwksKeys = jwks?.keys || []
179
+ cachedJwksAt = Date.now()
180
+ }
181
+
182
+ const jwk = cachedJwksKeys!.find((key: JsonWebKeyLike) => key.kid === header.kid)
183
+ assert(jwk, `no JWKS key found for kid ${header.kid}`)
184
+
185
+ const cryptoKey = await getSubtleCrypto().importKey(
186
+ 'jwk',
187
+ jwk,
188
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
189
+ false,
190
+ ['verify']
191
+ )
192
+
193
+ const isValid = await getSubtleCrypto().verify(
194
+ 'RSASSA-PKCS1-v1_5',
195
+ cryptoKey,
196
+ signature,
197
+ new TextEncoder().encode(signingInput)
198
+ )
199
+ assert(isValid, 'JWT signature verification failed')
200
+
201
+ return payload
202
+ }
203
+
204
+ function isNonceContextData(obj: unknown): obj is NonceContextData {
205
+ if(!obj || typeof obj !== 'object') {
206
+ return false
207
+ }
208
+
209
+ const o = obj as Record<string, unknown>
210
+ return typeof o.applicationId === 'string' && o.applicationId.length > 0
211
+ && typeof o.sessionId === 'string' && o.sessionId.length > 0
212
+ && typeof o.timestamp === 'string' && o.timestamp.length > 0
213
+ }
214
+
215
+ function parseProofContext(proof: Proof): { parsedContext: Record<string, unknown>, nonceDataObj: NonceContextData, expectedNonce: string } {
216
+ let parsedContext: unknown
217
+ try {
218
+ parsedContext = JSON.parse(proof.claimData.context)
219
+ } catch{
220
+ throw new Error('Malformed proof: claimData.context is not valid JSON')
221
+ }
222
+
223
+ if(!parsedContext || typeof parsedContext !== 'object') {
224
+ throw new Error('Malformed proof: claimData.context is not a JSON object')
225
+ }
226
+
227
+ const ctx = parsedContext as Record<string, unknown>
228
+ const expectedNonce = ctx.attestationNonce
229
+ assert(typeof expectedNonce === 'string' && expectedNonce.length > 0, 'Proof context is missing attestationNonce')
230
+
231
+ const nonceDataObj = ctx.attestationNonceData
232
+ assert(isNonceContextData(nonceDataObj), 'Proof context is missing or has invalid attestationNonceData (requires applicationId, sessionId, timestamp)')
233
+
234
+ return { parsedContext: ctx, nonceDataObj, expectedNonce }
235
+ }
236
+
237
+ function verifyApplicationAndSessionBinding(
238
+ proof: Proof,
239
+ parsedContext: Record<string, unknown>,
240
+ nonceDataObj: NonceContextData,
241
+ expectedApplicationId?: string
242
+ ) {
243
+ const { applicationId, sessionId, timestamp } = nonceDataObj
244
+
245
+ if(expectedApplicationId) {
246
+ assert(
247
+ applicationId.toLowerCase() === expectedApplicationId.toLowerCase(),
248
+ `Application ID Mismatch! Expected ${expectedApplicationId}, but proof context contains ${applicationId}`
249
+ )
250
+ }
251
+
252
+ let parsedParameters: Record<string, unknown> = {}
253
+ if(proof.claimData.parameters) {
254
+ try {
255
+ const parsed = JSON.parse(proof.claimData.parameters)
256
+ parsedParameters = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}
257
+ } catch{
258
+ throw new Error('Malformed proof: claimData.parameters is not valid JSON')
259
+ }
260
+ }
261
+
262
+ const contextSessionId = parsedContext?.reclaimSessionId
263
+ const parameterSessionId = parsedParameters?.proxySessionId ?? parsedParameters?.sessionId
264
+
265
+ if(contextSessionId && contextSessionId.toString() !== sessionId.toString()) {
266
+ throw new Error(`Session ID Mismatch! Expected ${sessionId}, but proof context contains reclaimSessionId=${contextSessionId}`)
267
+ }
268
+
269
+ if(parameterSessionId && parameterSessionId.toString() !== sessionId.toString()) {
270
+ throw new Error(`Session ID Mismatch! Expected ${sessionId}, but proof parameters contain ${parameterSessionId}`)
271
+ }
272
+
273
+ if(!contextSessionId && !parameterSessionId) {
274
+ throw new Error('Proof is missing reclaimSessionId and proxySessionId/sessionId for attestation nonce verification')
275
+ }
276
+
277
+ const claimTimestampMs = proof.claimData.timestampS * 1000
278
+ const nonceTimestampMs = parseInt(timestamp, 10)
279
+ const diffMs = Math.abs(claimTimestampMs - nonceTimestampMs)
280
+ if(diffMs > NONCE_TIMESTAMP_MAX_SKEW_MS) {
281
+ throw new Error(`Timestamp Skew Too Large! claimData.timestampS and attestationNonce timestamp differ by ${Math.round(diffMs / 1000)}s (limit: 600s)`)
282
+ }
283
+ }
284
+
285
+ function verifyNonceMaterial(
286
+ expectedNonce: string,
287
+ nonceDataObj: NonceContextData,
288
+ expectedAppSecret?: string
289
+ ) {
290
+ const cleanExpectedNonce = normalizeHex(expectedNonce)
291
+ const { applicationId, sessionId, timestamp } = nonceDataObj
292
+
293
+ assert(cleanExpectedNonce.length > 0, 'Proof context attestationNonce is empty')
294
+ assert(isHex(cleanExpectedNonce), 'Proof context attestationNonce is not valid hex')
295
+
296
+ if(expectedAppSecret) {
297
+ const recomputedNonce = deriveLegacyTeeNonce(expectedAppSecret, { applicationId, sessionId, timestamp })
298
+ assert(
299
+ recomputedNonce === cleanExpectedNonce,
300
+ 'Attestation nonce verification failed: app secret, application ID, session ID, or timestamp do not match'
301
+ )
302
+ return
303
+ }
304
+
305
+ if(cleanExpectedNonce.length > 74) {
306
+ const legacyNonceData = `${applicationId}:${sessionId}:${timestamp}`
307
+ const nonceMsg = keccak_256(new TextEncoder().encode(legacyNonceData))
308
+ const recoveredAddress = recoverEip191Address(nonceMsg, expectedNonce.startsWith('0x') ? expectedNonce : `0x${expectedNonce}`)
309
+
310
+ assert(
311
+ recoveredAddress.toLowerCase() === applicationId.toLowerCase(),
312
+ `Nonce signature verification failed: recovered ${recoveredAddress}, expected ${applicationId}`
313
+ )
314
+ return
315
+ }
316
+
317
+ throw new Error('App secret is required to verify hash-based attestation nonces')
318
+ }
319
+
320
+ function assertTokenFresh(claims: Record<string, unknown>) {
321
+ const now = Math.floor(Date.now() / 1000)
322
+
323
+ if(typeof claims.nbf === 'number' && now + TOKEN_CLOCK_SKEW_S < claims.nbf) {
324
+ throw new Error(`Attestation token is not valid before ${claims.nbf}`)
325
+ }
326
+
327
+ if(typeof claims.exp === 'number' && now - TOKEN_CLOCK_SKEW_S > claims.exp) {
328
+ throw new Error(`Attestation token expired at ${claims.exp}`)
329
+ }
330
+
331
+ if(typeof claims.iat === 'number' && claims.iat > now + TOKEN_CLOCK_SKEW_S) {
332
+ throw new Error(`Attestation token issued-at ${claims.iat} is in the future`)
333
+ }
334
+ }
335
+
336
+ function assertAudienceClaim(aud: unknown) {
337
+ if(typeof aud === 'string') {
338
+ assert(aud.length > 0, 'attestation token audience is empty')
339
+ return
340
+ }
341
+
342
+ if(Array.isArray(aud)) {
343
+ assert(aud.length > 0, 'attestation token audience is empty')
344
+ assert(aud.every((entry) => typeof entry === 'string' && entry.length > 0), 'attestation token audience contains invalid entries')
345
+ return
346
+ }
347
+
348
+ throw new Error('attestation token audience is missing')
349
+ }
350
+
351
+ function getProofVersion(teeAttestation: TeeAttestation): string | undefined {
352
+ return teeAttestation.proof_version ?? teeAttestation.proofVersion
353
+ }
354
+
355
+ function assertProofShape(teeAttestation: TeeAttestation) {
356
+ if(teeAttestation.error) {
357
+ throw new Error(`${teeAttestation.error.code}: ${teeAttestation.error.message}`)
358
+ }
359
+
360
+ const proofVersion = getProofVersion(teeAttestation)
361
+ assert(typeof proofVersion === 'string' && SUPPORTED_PROOF_VERSIONS.includes(proofVersion), `unexpected proof version: ${proofVersion}`)
362
+ assert(teeAttestation.tee_provider === EXPECTED_TEE_PROVIDER, `unexpected tee provider: ${teeAttestation.tee_provider}`)
363
+ assert(teeAttestation.tee_technology === EXPECTED_TEE_TECHNOLOGY, `unexpected tee technology: ${teeAttestation.tee_technology}`)
364
+ assert(typeof teeAttestation.nonce === 'string' && teeAttestation.nonce.length > 0, 'tee attestation nonce missing')
365
+ assert(typeof teeAttestation.timestamp === 'string' && teeAttestation.timestamp.length > 0, 'tee attestation timestamp missing')
366
+ assert(!Number.isNaN(Date.parse(teeAttestation.timestamp)), 'tee attestation timestamp is invalid')
367
+ assert(typeof teeAttestation.workload?.image_digest === 'string' && teeAttestation.workload.image_digest.length > 0, 'workload image digest missing')
368
+ assert(typeof teeAttestation.verifier?.image_digest === 'string' && teeAttestation.verifier.image_digest.length > 0, 'verifier image digest missing')
369
+ assert(typeof teeAttestation.attestation?.token === 'string' && teeAttestation.attestation.token.length > 0, 'attestation token missing')
370
+ }
371
+
372
+ async function computeDigestBinding(teeAttestation: TeeAttestation): Promise<string> {
373
+ const proofVersion = getProofVersion(teeAttestation)
374
+
375
+ if(proofVersion === 'v3') {
376
+ assert(typeof teeAttestation.workload.container_name === 'string' && teeAttestation.workload.container_name.length > 0, 'workload container name missing')
377
+ assert(typeof teeAttestation.verifier.container_name === 'string' && teeAttestation.verifier.container_name.length > 0, 'verifier container name missing')
378
+
379
+ return sha256Hex([
380
+ 'v3',
381
+ `workload.container_name=${teeAttestation.workload.container_name}`,
382
+ `workload.image_digest=${teeAttestation.workload.image_digest}`,
383
+ `verifier.container_name=${teeAttestation.verifier.container_name}`,
384
+ `verifier.image_digest=${teeAttestation.verifier.image_digest}`,
385
+ ].join('\n'))
386
+ }
387
+
388
+ return sha256Hex(
389
+ `${teeAttestation.workload.image_digest}\n${teeAttestation.verifier.image_digest}`
390
+ )
391
+ }
392
+
393
+ async function verifyGcpClaims(teeAttestation: TeeAttestation, expectedNonce: string) {
394
+ const claims = await verifyJwtSignature(teeAttestation.attestation.token, GCP_CONFIDENTIAL_SPACE_ISSUER)
395
+
396
+ assert(claims.iss === GCP_CONFIDENTIAL_SPACE_ISSUER, `unexpected issuer: ${claims.iss}`)
397
+ assertAudienceClaim(claims.aud)
398
+ assert(Array.isArray(claims.eat_nonce), 'eat_nonce claim missing')
399
+
400
+ const digestBinding = await computeDigestBinding(teeAttestation)
401
+
402
+ assert(claims.eat_nonce.includes(expectedNonce), 'request nonce is not present in attestation token')
403
+ assert(claims.eat_nonce.includes(digestBinding), 'digest-binding nonce is not present in attestation token')
404
+ assert(claims.hwmodel === EXPECTED_HW_MODEL, `unexpected hwmodel: ${claims.hwmodel}`)
405
+ assert(claims.secboot === true, 'secure boot claim is not true')
406
+ const submods = claims.submods
407
+ assert(
408
+ isRecord(submods) && submods.gce,
409
+ 'gce submod claim missing',
410
+ )
411
+
412
+ assertTokenFresh(claims)
413
+ }
414
+
415
+ function isRecord(value: unknown): value is Record<string, unknown> {
416
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
417
+ }
418
+
419
+ /**
420
+ * Validates the hardware TEE attestation included in the proof.
421
+ * Derives the application ID from `appSecret` and verifies the attestation
422
+ * was generated for your application.
423
+ * Returns a result object with `isVerified` and an optional `error` message.
424
+ *
425
+ * This check is stateless and does not protect against replay of a previously
426
+ * valid proof. Callers must enforce session matching and dedup `sessionId` on
427
+ * their server. See the README's "Replay Protection" section.
428
+ *
429
+ * @param proof - The proof containing TEE attestation data
430
+ * @param appSecret - Your application secret (Ethereum private key). Used to
431
+ * derive the application ID and recompute the attestation nonce.
432
+ */
433
+ export async function verifyTeeAttestation(
434
+ proof: Proof,
435
+ appSecret: string
436
+ ): Promise<TeeVerificationResult> {
437
+ assertNonBrowserEnvironment()
438
+
439
+ try {
440
+ const appId = legacyApplicationIdFromPrivateKey(appSecret)
441
+
442
+ let teeAttestation = proof.teeAttestation
443
+ if(!teeAttestation) {
444
+ throw new Error('Missing teeAttestation in proof')
445
+ }
446
+
447
+ if(typeof teeAttestation === 'string') {
448
+ teeAttestation = JSON.parse(teeAttestation) as TeeAttestation
449
+ }
450
+
451
+ assertProofShape(teeAttestation)
452
+
453
+ const { parsedContext, nonceDataObj, expectedNonce } = parseProofContext(proof)
454
+ verifyApplicationAndSessionBinding(proof, parsedContext, nonceDataObj, appId)
455
+ verifyNonceMaterial(expectedNonce, nonceDataObj, appSecret)
456
+
457
+ const cleanExpectedNonce = normalizeHex(expectedNonce)
458
+ const cleanTeeNonce = normalizeHex(teeAttestation.nonce)
459
+ assert(cleanTeeNonce.length > 0, 'TEE attestation nonce is empty')
460
+ assert(isHex(cleanTeeNonce), 'TEE attestation nonce is not valid hex')
461
+ assert(cleanTeeNonce === cleanExpectedNonce, `Nonce Mismatch! Expected ${cleanExpectedNonce}, got ${cleanTeeNonce}`)
462
+
463
+ await verifyGcpClaims(teeAttestation, cleanExpectedNonce)
464
+
465
+ return { isVerified: true }
466
+ } catch(error) {
467
+ return {
468
+ isVerified: false,
469
+ error: error instanceof Error ? error.message : String(error),
470
+ }
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Verifies TEE attestation for all proofs.
476
+ * Throws `TeeVerificationError` if any proof is missing TEE data or fails verification.
477
+ *
478
+ * @param proofs - The proofs to verify
479
+ * @param config - TEE attestation configuration containing the app secret
480
+ * @throws {TeeVerificationError} When TEE data is missing or verification fails
481
+ */
482
+ export async function runTeeVerification(proofs: Proof[], config: TeeAttestationConfig): Promise<void> {
483
+ const hasTeeData = proofs.every(proof => {
484
+ if(proof.teeAttestation) {
485
+ return true
486
+ }
487
+
488
+ try {
489
+ const context = JSON.parse(proof.claimData.context)
490
+ return !!context?.attestationNonce
491
+ } catch{
492
+ return false
493
+ }
494
+ })
495
+
496
+ if(!hasTeeData) {
497
+ throw new TeeVerificationError('TEE verification requested but one or more proofs are missing TEE attestation data')
498
+ }
499
+
500
+ const teeResults = await Promise.all(
501
+ proofs.map(proof => verifyTeeAttestation(proof, config.appSecret))
502
+ )
503
+
504
+ if(!teeResults.every(r => r.isVerified)) {
505
+ throw new TeeVerificationError('TEE attestation verification failed for one or more proofs')
506
+ }
507
+ }