@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.
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,22 @@
1
+ /**
2
+ * Stable names for the Verification Clients seeded by Builder.
3
+ *
4
+ * A name identifies a registry record; session creation still selects a
5
+ * client with that deployment's exact registered URL.
6
+ */
7
+ export const KNOWN_VERIFICATION_CLIENTS = {
8
+ builder: 'builder',
9
+ portals: 'portals',
10
+ verifierApp: 'verifier-app',
11
+ inAppSdk: 'inapp-sdk',
12
+ reclaimBrowserExtension: 'reclaim-browser-extension',
13
+ zkFetch: 'zkfetch',
14
+ } as const
15
+
16
+ export type KnownVerificationClientName =
17
+ typeof KNOWN_VERIFICATION_CLIENTS[keyof typeof KNOWN_VERIFICATION_CLIENTS]
18
+
19
+ /** Deployment-specific registered URLs keyed by a known client name. */
20
+ export type KnownVerificationClientUrlMap = Partial<
21
+ Record<KnownVerificationClientName, string>
22
+ >
@@ -0,0 +1,365 @@
1
+ import { createSignDataForClaim } from '@reclaimprotocol/attestor-core'
2
+ /* eslint-disable @stylistic/max-len */
3
+ import crypto, { X509Certificate } from 'node:crypto'
4
+ import { recoverEip191Address } from './eip191.ts'
5
+ import {
6
+ ATTESTOR_NONCE_PATTERN,
7
+ GCP_CONFIDENTIAL_SPACE_ISSUER,
8
+ GCP_CONFIDENTIAL_SPACE_ROOT_CA,
9
+ } from './proof-trust-constants.ts'
10
+ import type {
11
+ AttestorClaimAttestation,
12
+ LegacyProof as Proof
13
+ } from './proof-types.ts'
14
+ import { legacyErrorClass } from './proof-verification-error.ts'
15
+ const AttestorTeeVerificationError = legacyErrorClass('AttestorTeeVerificationError')
16
+
17
+ const BROWSER_ENVIRONMENT_ERROR =
18
+ 'Attestor TEE attestation verification is only supported in non-browser environments. Run verifyAttestorTeeAttestation on your server or API route.'
19
+
20
+ function isBrowserEnvironment(): boolean {
21
+ if('window' in globalThis || 'document' in globalThis) {
22
+ return true
23
+ }
24
+
25
+ if(typeof navigator !== 'undefined' && typeof process === 'undefined') {
26
+ return true
27
+ }
28
+
29
+ const workerGlobalScope = (globalThis as Record<string, unknown>).WorkerGlobalScope
30
+ if(
31
+ typeof workerGlobalScope === 'function' &&
32
+ globalThis instanceof workerGlobalScope
33
+ ) {
34
+ return true
35
+ }
36
+
37
+ return false
38
+ }
39
+
40
+ function assertNonBrowserEnvironment() {
41
+ if(isBrowserEnvironment()) {
42
+ throw new Error(BROWSER_ENVIRONMENT_ERROR)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Result of verifying an attestor TEE attestation.
48
+ */
49
+ export type AttestorTeeVerificationResult = {
50
+ isVerified: boolean
51
+ error?: string
52
+ /** sha256 image digest of the attestor container, on success. */
53
+ imageDigest?: string
54
+ }
55
+
56
+ const TOKEN_CLOCK_SKEW_S = 60
57
+
58
+ function decodeBase64Url(input: string): Buffer {
59
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/')
60
+ const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
61
+ return Buffer.from(padded, 'base64')
62
+ }
63
+
64
+ function normalizeAddress(address: string): string {
65
+ return address.trim().toLowerCase().replace(/^0x/, '')
66
+ }
67
+
68
+ /**
69
+ * Walks the x5c certificate chain (leaf first) and verifies each link
70
+ * up to the pinned GCP Confidential Space Root CA. Returns the leaf
71
+ * certificate's public key on success.
72
+ */
73
+ function verifyX5cChain(x5cChain: string[]): crypto.KeyObject {
74
+ if(!x5cChain || x5cChain.length === 0) {
75
+ throw new Error('Empty x5c certificate chain')
76
+ }
77
+
78
+ const certs = x5cChain.map(
79
+ (b64) => new X509Certificate(`-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----`)
80
+ )
81
+ const root = new X509Certificate(GCP_CONFIDENTIAL_SPACE_ROOT_CA)
82
+
83
+ for(let i = 0; i < certs.length - 1; i++) {
84
+ if(!certs[i].verify(certs[i + 1].publicKey)) {
85
+ throw new Error(`Certificate chain verification failed at level ${i}`)
86
+ }
87
+ }
88
+
89
+ const top = certs[certs.length - 1]
90
+ if(!top.verify(root.publicKey)) {
91
+ throw new Error('Certificate chain does not root to GCP Confidential Space Root CA')
92
+ }
93
+
94
+ return certs[0].publicKey
95
+ }
96
+
97
+ /**
98
+ * Validates a GCP Confidential Space attestation JWT produced by an
99
+ * attestor running in a Confidential Space VM, and asserts that the
100
+ * attestation binds to the given attestor address.
101
+ *
102
+ * The attestor (running inside the TEE) calls the Confidential Space
103
+ * launcher's attestation endpoint with two nonces:
104
+ * - `attestor_public_key:<eth-address>` - binds to the signing key.
105
+ * - `attestor_cert_hash:<sha256-hex>` - binds to the live TLS cert.
106
+ *
107
+ * This function only verifies the public-key nonce. The TLS cert hash
108
+ * binding is informational and not checked here. Callers that need to
109
+ * pin to a specific attestor image should compare the returned
110
+ * `imageDigest` against a known-good value.
111
+ *
112
+ * The JWT signature is verified by walking the x5c certificate chain
113
+ * to a pinned GCP Confidential Space Root CA. No outbound network
114
+ * calls are made.
115
+ *
116
+ * Node-only (uses node:crypto). Mirrors the environment restriction in
117
+ * the existing `verifyTeeAttestation` helper.
118
+ *
119
+ * @param report - the raw JWT string (header.payload.signature).
120
+ * @param expectedAttestorAddress - hex ETH address (0x-prefixed or
121
+ * unprefixed) that the attestation should be bound to.
122
+ */
123
+ export async function verifyAttestorTeeAttestation(
124
+ report: string,
125
+ expectedAttestorAddress: string
126
+ ): Promise<AttestorTeeVerificationResult> {
127
+ try {
128
+ assertNonBrowserEnvironment()
129
+
130
+ if(!report || typeof report !== 'string') {
131
+ throw new Error('attestation report is empty or not a string')
132
+ }
133
+
134
+ if(!expectedAttestorAddress || typeof expectedAttestorAddress !== 'string') {
135
+ throw new Error('expectedAttestorAddress is required')
136
+ }
137
+
138
+ const parts = report.split('.')
139
+ if(parts.length !== 3) {
140
+ throw new Error('attestation report is not a JWT (expected 3 parts)')
141
+ }
142
+
143
+ const [headerB64, payloadB64, signatureB64] = parts
144
+
145
+ const header = JSON.parse(decodeBase64Url(headerB64).toString('utf8'))
146
+ const payload = JSON.parse(decodeBase64Url(payloadB64).toString('utf8'))
147
+
148
+ if(header.alg !== 'RS256') {
149
+ throw new Error(`unexpected signing algorithm: ${header.alg}`)
150
+ }
151
+
152
+ if(!Array.isArray(header.x5c) || header.x5c.length === 0) {
153
+ throw new Error('attestation report is missing x5c certificate chain')
154
+ }
155
+
156
+ if(payload.iss !== GCP_CONFIDENTIAL_SPACE_ISSUER) {
157
+ throw new Error(`unexpected issuer: ${payload.iss}`)
158
+ }
159
+
160
+ const now = Math.floor(Date.now() / 1000)
161
+ if(typeof payload.nbf === 'number' && now + TOKEN_CLOCK_SKEW_S < payload.nbf) {
162
+ throw new Error(`attestation not yet valid (nbf=${payload.nbf})`)
163
+ }
164
+
165
+ if(typeof payload.exp === 'number' && now - TOKEN_CLOCK_SKEW_S > payload.exp) {
166
+ throw new Error(`attestation expired (exp=${payload.exp})`)
167
+ }
168
+
169
+ if(typeof payload.iat === 'number' && payload.iat > now + TOKEN_CLOCK_SKEW_S) {
170
+ throw new Error(`attestation issued in future (iat=${payload.iat})`)
171
+ }
172
+
173
+ const publicKey = verifyX5cChain(header.x5c)
174
+
175
+ const verifier = crypto.createVerify('RSA-SHA256')
176
+ verifier.update(`${headerB64}.${payloadB64}`)
177
+ if(!verifier.verify(publicKey, new Uint8Array(decodeBase64Url(signatureB64)))) {
178
+ throw new Error('attestation signature verification failed')
179
+ }
180
+
181
+ if(!payload.eat_nonce) {
182
+ throw new Error('eat_nonce claim is missing')
183
+ }
184
+
185
+ const nonces: string[] = Array.isArray(payload.eat_nonce)
186
+ ? payload.eat_nonce
187
+ : [payload.eat_nonce]
188
+
189
+ let attestedAddress: string | undefined
190
+ for(const n of nonces) {
191
+ const m = typeof n === 'string' ? n.match(ATTESTOR_NONCE_PATTERN) : null
192
+ if(m) {
193
+ attestedAddress = m[1]
194
+ break
195
+ }
196
+ }
197
+
198
+ if(!attestedAddress) {
199
+ throw new Error(
200
+ `attestor_public_key nonce not found in eat_nonce: ${JSON.stringify(payload.eat_nonce)}`
201
+ )
202
+ }
203
+
204
+ if(normalizeAddress(attestedAddress) !== normalizeAddress(expectedAttestorAddress)) {
205
+ throw new Error(
206
+ `attestor address mismatch: attestation binds to 0x${attestedAddress.toLowerCase()}, ` +
207
+ `expected ${expectedAttestorAddress}`
208
+ )
209
+ }
210
+
211
+ const imageDigest: string | undefined =
212
+ payload.submods?.container?.image_digest
213
+ ?? payload.google?.compute_engine?.image_digest
214
+
215
+ return { isVerified: true, imageDigest }
216
+ } catch(error) {
217
+ return {
218
+ isVerified: false,
219
+ error: error instanceof Error ? error.message : String(error),
220
+ }
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Configuration for verifying the attestor's TEE attestation on each
226
+ * witness of the proof.
227
+ */
228
+ export type AttestorTeeAttestationConfig = {
229
+ /**
230
+ * Optional allowlist of expected attestor container image digests
231
+ * (e.g. `"sha256:4906340f..."`). When provided, the attestation's
232
+ * `submods.container.image_digest` must be in this list.
233
+ *
234
+ * Leave undefined to skip image pinning and rely solely on the JWT
235
+ * chain rooting to the GCP Confidential Space Root CA + nonce
236
+ * binding to the attestor address.
237
+ */
238
+ expectedImageDigests?: string[]
239
+ }
240
+
241
+ function normalizeAttestorAddress(address: string): string {
242
+ return address.trim().toLowerCase()
243
+ }
244
+
245
+ function normalizeSignature(sig: string): string {
246
+ return sig.trim().toLowerCase()
247
+ }
248
+
249
+ async function verifyAttestorTeeForProof(
250
+ proof: Proof,
251
+ config: AttestorTeeAttestationConfig
252
+ ): Promise<void> {
253
+ if(!proof.witnesses || proof.witnesses.length === 0) {
254
+ throw new AttestorTeeVerificationError('Proof has no witnesses')
255
+ }
256
+
257
+ const expectedDigests = config.expectedImageDigests?.map(d => d.trim())
258
+
259
+ const proofSignatures = new Set((proof.signatures || []).map(normalizeSignature))
260
+ const claimSignData = createSignDataForClaim(proof.claimData)
261
+
262
+ for(const witness of proof.witnesses) {
263
+ const att: AttestorClaimAttestation | undefined = witness.claimAttestation
264
+ if(!att) {
265
+ throw new AttestorTeeVerificationError(
266
+ `Witness ${witness.id} is missing claimAttestation`
267
+ )
268
+ }
269
+
270
+ if(normalizeAttestorAddress(att.attestor_address) !== normalizeAttestorAddress(witness.id)) {
271
+ throw new AttestorTeeVerificationError(
272
+ `claimAttestation.attestor_address ${att.attestor_address} does not match witness id ${witness.id}`
273
+ )
274
+ }
275
+
276
+ if(!proofSignatures.has(normalizeSignature(att.claim_signature))) {
277
+ throw new AttestorTeeVerificationError(
278
+ `claimAttestation.claim_signature for witness ${witness.id} is not present in proof.signatures`
279
+ )
280
+ }
281
+
282
+ let recoveredSigner: string
283
+ try {
284
+ recoveredSigner = recoverEip191Address(claimSignData, att.claim_signature)
285
+ } catch(error) {
286
+ throw new AttestorTeeVerificationError(
287
+ `Failed to recover signer from claimAttestation.claim_signature for witness ${witness.id}`,
288
+ error
289
+ )
290
+ }
291
+
292
+ if(normalizeAttestorAddress(recoveredSigner) !== normalizeAttestorAddress(witness.id)) {
293
+ throw new AttestorTeeVerificationError(
294
+ `claim_signature recovers to ${recoveredSigner}, expected attestor ${witness.id}`
295
+ )
296
+ }
297
+
298
+ const result = await verifyAttestorTeeAttestation(att.attestation_report, witness.id)
299
+ if(!result.isVerified) {
300
+ throw new AttestorTeeVerificationError(
301
+ `Attestor TEE attestation verification failed for witness ${witness.id}: ${result.error}`
302
+ )
303
+ }
304
+
305
+ if(expectedDigests && expectedDigests.length > 0) {
306
+ if(!result.imageDigest) {
307
+ throw new AttestorTeeVerificationError(
308
+ `Attestor TEE attestation for witness ${witness.id} did not expose an image digest to check against expectedImageDigests`
309
+ )
310
+ }
311
+
312
+ if(!expectedDigests.includes(result.imageDigest)) {
313
+ throw new AttestorTeeVerificationError(
314
+ `Attestor image digest ${result.imageDigest} for witness ${witness.id} is not in expectedImageDigests`
315
+ )
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Verifies the attestor's TEE attestation for every witness of every
323
+ * provided proof. Throws `AttestorTeeVerificationError` on the first
324
+ * failure.
325
+ *
326
+ * Each witness must carry a `claimAttestation`. For each one, this:
327
+ * 1. Asserts `attestor_address` matches `witness.id`.
328
+ * 2. Asserts `claim_signature` is present in `proof.signatures`.
329
+ * 3. Recovers the signer of `claim_signature` from the claim data and
330
+ * asserts it equals `witness.id` (binds the signature to the
331
+ * attestor that the TEE attestation will cover).
332
+ * 4. Calls `verifyAttestorTeeAttestation` to validate the JWT against
333
+ * the pinned GCP Confidential Space Root CA and the attestor-key
334
+ * nonce.
335
+ * 5. If `expectedImageDigests` is provided, asserts the attestation's
336
+ * container image digest is in the allowlist.
337
+ *
338
+ * Node-only (uses node:crypto), like `verifyTeeAttestation`.
339
+ *
340
+ * @param proofs - The proofs to verify.
341
+ * @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
342
+ */
343
+ export async function runAttestorTeeVerification(
344
+ proofs: Proof[],
345
+ config: AttestorTeeAttestationConfig = {}
346
+ ): Promise<void> {
347
+ if(!proofs || proofs.length === 0) {
348
+ throw new AttestorTeeVerificationError('No proofs provided for attestor TEE verification')
349
+ }
350
+
351
+ try {
352
+ for(const proof of proofs) {
353
+ await verifyAttestorTeeForProof(proof, config)
354
+ }
355
+ } catch(error) {
356
+ if(error instanceof AttestorTeeVerificationError) {
357
+ throw error
358
+ }
359
+
360
+ throw new AttestorTeeVerificationError(
361
+ 'Attestor TEE attestation verification failed',
362
+ error
363
+ )
364
+ }
365
+ }