@reclaimprotocol/client 0.1.27 → 0.1.28
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/README.md +634 -42
- package/lib/api-client.d.ts +31 -0
- package/lib/api-client.js +112 -0
- package/lib/api-consts.d.ts +4 -0
- package/lib/api-consts.js +4 -0
- package/lib/api-errors.d.ts +13 -0
- package/lib/api-errors.js +30 -0
- package/lib/api.d.ts +7 -0
- package/lib/api.js +3 -0
- package/lib/attestor-proof.d.ts +6 -0
- package/lib/attestor-proof.js +45 -18
- package/lib/client.d.ts +1 -6
- package/lib/client.js +12 -33
- package/lib/consts.d.ts +1 -1
- package/lib/consts.js +1 -1
- package/lib/eip191.d.ts +4 -2
- package/lib/eip191.js +17 -1
- package/lib/errors.d.ts +38 -4
- package/lib/errors.js +99 -16
- package/lib/index.d.ts +9 -7
- package/lib/index.js +8 -4
- package/lib/jwks.d.ts +25 -1
- package/lib/jwks.js +69 -4
- package/lib/legacy-proof.d.ts +57 -0
- package/lib/legacy-proof.js +211 -0
- package/lib/proof-hash.d.ts +35 -0
- package/lib/proof-hash.js +216 -0
- package/lib/proof-recipe.d.ts +18 -0
- package/lib/proof-recipe.js +37 -0
- package/lib/proof-trust-constants.d.ts +3 -0
- package/lib/proof-trust-constants.js +43 -0
- package/lib/proof-types.d.ts +62 -0
- package/lib/proof-types.js +1 -0
- package/lib/proof-verification-error.d.ts +13 -0
- package/lib/proof-verification-error.js +11 -0
- package/lib/reclaim.d.ts +27 -5
- package/lib/reclaim.js +140 -14
- package/lib/result.d.ts +58 -6
- package/lib/result.js +252 -61
- package/lib/routes.gen.d.ts +54 -6
- package/lib/routes.gen.js +15 -3
- package/lib/tee-binding.d.ts +82 -0
- package/lib/tee-binding.js +112 -0
- package/lib/types/openapi.gen.d.ts +1343 -206
- package/lib/verification-clients.d.ts +17 -0
- package/lib/verification-clients.js +14 -0
- package/lib/verify-attestor-tee.d.ts +76 -0
- package/lib/verify-attestor-tee.js +243 -0
- package/lib/verify-tee.d.ts +32 -0
- package/lib/verify-tee.js +347 -0
- package/package.json +5 -1
- package/src/api.ts +15 -0
- package/src/attestor-proof.ts +57 -20
- package/src/client.ts +15 -38
- package/src/consts.ts +1 -2
- package/src/eip191.ts +31 -3
- package/src/errors.ts +132 -18
- package/src/index.ts +50 -5
- package/src/jwks.ts +128 -5
- package/src/legacy-proof.ts +367 -0
- package/src/proof-hash.ts +316 -0
- package/src/proof-recipe.ts +75 -0
- package/src/proof-trust-constants.ts +45 -0
- package/src/proof-types.ts +57 -0
- package/src/proof-verification-error.ts +13 -0
- package/src/reclaim.ts +206 -19
- package/src/result.ts +403 -68
- package/src/routes.gen.ts +15 -3
- package/src/tee-binding.ts +192 -0
- package/src/types/openapi.gen.ts +1343 -206
- package/src/verification-clients.ts +22 -0
- package/src/verify-attestor-tee.ts +365 -0
- 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
|
+
}
|