@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,347 @@
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.js";
5
+ import { GCP_CONFIDENTIAL_SPACE_ISSUER } from "./proof-trust-constants.js";
6
+ import { legacyErrorClass } from "./proof-verification-error.js";
7
+ import { deriveLegacyTeeNonce, legacyApplicationIdFromPrivateKey } from "./tee-binding.js";
8
+ const TeeVerificationError = legacyErrorClass('TeeVerificationError');
9
+ const EXPECTED_HW_MODEL = 'GCP_AMD_SEV';
10
+ const EXPECTED_TEE_PROVIDER = 'gcp';
11
+ const EXPECTED_TEE_TECHNOLOGY = 'amd-sev';
12
+ const SUPPORTED_PROOF_VERSIONS = ['v2', 'v3'];
13
+ const TOKEN_CLOCK_SKEW_S = 60;
14
+ const NONCE_TIMESTAMP_MAX_SKEW_MS = 10 * 60 * 1000;
15
+ const BROWSER_ENVIRONMENT_ERROR = 'TEE attestation verification is only supported in non-browser environments. Run verifyTeeAttestation on your server or API route.';
16
+ function assert(condition, message) {
17
+ if (!condition) {
18
+ throw new Error(message);
19
+ }
20
+ }
21
+ function isBrowserEnvironment() {
22
+ if ('window' in globalThis || 'document' in globalThis) {
23
+ return true;
24
+ }
25
+ if (typeof navigator !== 'undefined' && typeof process === 'undefined') {
26
+ return true;
27
+ }
28
+ const workerGlobalScope = globalThis.WorkerGlobalScope;
29
+ if (typeof workerGlobalScope === 'function' &&
30
+ globalThis instanceof workerGlobalScope) {
31
+ return true;
32
+ }
33
+ return false;
34
+ }
35
+ function assertNonBrowserEnvironment() {
36
+ if (isBrowserEnvironment()) {
37
+ throw new Error(BROWSER_ENVIRONMENT_ERROR);
38
+ }
39
+ }
40
+ function normalizeHex(value) {
41
+ return (value || '').trim().replace(/^0x/i, '').toLowerCase();
42
+ }
43
+ function isHex(value) {
44
+ return /^[0-9a-f]+$/i.test(value);
45
+ }
46
+ function decodeBase64Url(input) {
47
+ const normalized = input.replace(/-/g, '+').replace(/_/g, '/');
48
+ const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4);
49
+ if (typeof Buffer !== 'undefined') {
50
+ return new Uint8Array(Buffer.from(padded, 'base64'));
51
+ }
52
+ if (typeof atob === 'function') {
53
+ const binary = atob(padded);
54
+ const bytes = new Uint8Array(binary.length);
55
+ for (let i = 0; i < binary.length; i += 1) {
56
+ bytes[i] = binary.charCodeAt(i);
57
+ }
58
+ return bytes;
59
+ }
60
+ throw new Error('Base64 decoding is not supported in this environment');
61
+ }
62
+ function decodeUtf8(bytes) {
63
+ return new TextDecoder().decode(bytes);
64
+ }
65
+ function decodeJwt(token) {
66
+ const parts = token.split('.');
67
+ assert(parts.length === 3, 'attestation token is not a JWT');
68
+ return {
69
+ header: JSON.parse(decodeUtf8(decodeBase64Url(parts[0]))),
70
+ payload: JSON.parse(decodeUtf8(decodeBase64Url(parts[1]))),
71
+ signingInput: `${parts[0]}.${parts[1]}`,
72
+ signature: decodeBase64Url(parts[2]),
73
+ };
74
+ }
75
+ function getFetch() {
76
+ const fetchFn = globalThis.fetch;
77
+ assert(fetchFn, 'fetch is not available in this environment');
78
+ return fetchFn.bind(globalThis);
79
+ }
80
+ function getSubtleCrypto() {
81
+ if (globalThis.crypto?.subtle) {
82
+ return globalThis.crypto.subtle;
83
+ }
84
+ return webcrypto.subtle;
85
+ }
86
+ async function fetchJson(url) {
87
+ const response = await getFetch()(url);
88
+ if (!response.ok) {
89
+ throw new Error(`GET ${url} returned ${response.status} ${response.statusText}`);
90
+ }
91
+ // The caller supplies the expected shape and validates every field before use.
92
+ return await response.json();
93
+ }
94
+ async function sha256Hex(input) {
95
+ const digest = await getSubtleCrypto().digest('SHA-256', new TextEncoder().encode(input));
96
+ return Array.from(new Uint8Array(digest), (value) => value.toString(16).padStart(2, '0')).join('');
97
+ }
98
+ const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
99
+ let cachedJwksUri = null;
100
+ let cachedJwksKeys = null;
101
+ let cachedJwksAt = 0;
102
+ async function verifyJwtSignature(token, issuer) {
103
+ const { header, payload, signingInput, signature } = decodeJwt(token);
104
+ assert(header.alg === 'RS256', `unexpected attestation signing algorithm: ${header.alg}`);
105
+ assert(typeof header.kid === 'string' && header.kid.length > 0, 'attestation token kid is missing');
106
+ const isCacheFresh = cachedJwksKeys && (Date.now() - cachedJwksAt) < JWKS_CACHE_TTL_MS;
107
+ if (!isCacheFresh) {
108
+ const oidc = await fetchJson(`${issuer}/.well-known/openid-configuration`);
109
+ assert(typeof oidc?.jwks_uri === 'string' && oidc.jwks_uri.length > 0, 'issuer JWKS URI is missing');
110
+ cachedJwksUri = oidc.jwks_uri;
111
+ const jwks = await fetchJson(cachedJwksUri);
112
+ cachedJwksKeys = jwks?.keys || [];
113
+ cachedJwksAt = Date.now();
114
+ }
115
+ const jwk = cachedJwksKeys.find((key) => key.kid === header.kid);
116
+ assert(jwk, `no JWKS key found for kid ${header.kid}`);
117
+ const cryptoKey = await getSubtleCrypto().importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
118
+ const isValid = await getSubtleCrypto().verify('RSASSA-PKCS1-v1_5', cryptoKey, signature, new TextEncoder().encode(signingInput));
119
+ assert(isValid, 'JWT signature verification failed');
120
+ return payload;
121
+ }
122
+ function isNonceContextData(obj) {
123
+ if (!obj || typeof obj !== 'object') {
124
+ return false;
125
+ }
126
+ const o = obj;
127
+ return typeof o.applicationId === 'string' && o.applicationId.length > 0
128
+ && typeof o.sessionId === 'string' && o.sessionId.length > 0
129
+ && typeof o.timestamp === 'string' && o.timestamp.length > 0;
130
+ }
131
+ function parseProofContext(proof) {
132
+ let parsedContext;
133
+ try {
134
+ parsedContext = JSON.parse(proof.claimData.context);
135
+ }
136
+ catch {
137
+ throw new Error('Malformed proof: claimData.context is not valid JSON');
138
+ }
139
+ if (!parsedContext || typeof parsedContext !== 'object') {
140
+ throw new Error('Malformed proof: claimData.context is not a JSON object');
141
+ }
142
+ const ctx = parsedContext;
143
+ const expectedNonce = ctx.attestationNonce;
144
+ assert(typeof expectedNonce === 'string' && expectedNonce.length > 0, 'Proof context is missing attestationNonce');
145
+ const nonceDataObj = ctx.attestationNonceData;
146
+ assert(isNonceContextData(nonceDataObj), 'Proof context is missing or has invalid attestationNonceData (requires applicationId, sessionId, timestamp)');
147
+ return { parsedContext: ctx, nonceDataObj, expectedNonce };
148
+ }
149
+ function verifyApplicationAndSessionBinding(proof, parsedContext, nonceDataObj, expectedApplicationId) {
150
+ const { applicationId, sessionId, timestamp } = nonceDataObj;
151
+ if (expectedApplicationId) {
152
+ assert(applicationId.toLowerCase() === expectedApplicationId.toLowerCase(), `Application ID Mismatch! Expected ${expectedApplicationId}, but proof context contains ${applicationId}`);
153
+ }
154
+ let parsedParameters = {};
155
+ if (proof.claimData.parameters) {
156
+ try {
157
+ const parsed = JSON.parse(proof.claimData.parameters);
158
+ parsedParameters = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
159
+ }
160
+ catch {
161
+ throw new Error('Malformed proof: claimData.parameters is not valid JSON');
162
+ }
163
+ }
164
+ const contextSessionId = parsedContext?.reclaimSessionId;
165
+ const parameterSessionId = parsedParameters?.proxySessionId ?? parsedParameters?.sessionId;
166
+ if (contextSessionId && contextSessionId.toString() !== sessionId.toString()) {
167
+ throw new Error(`Session ID Mismatch! Expected ${sessionId}, but proof context contains reclaimSessionId=${contextSessionId}`);
168
+ }
169
+ if (parameterSessionId && parameterSessionId.toString() !== sessionId.toString()) {
170
+ throw new Error(`Session ID Mismatch! Expected ${sessionId}, but proof parameters contain ${parameterSessionId}`);
171
+ }
172
+ if (!contextSessionId && !parameterSessionId) {
173
+ throw new Error('Proof is missing reclaimSessionId and proxySessionId/sessionId for attestation nonce verification');
174
+ }
175
+ const claimTimestampMs = proof.claimData.timestampS * 1000;
176
+ const nonceTimestampMs = parseInt(timestamp, 10);
177
+ const diffMs = Math.abs(claimTimestampMs - nonceTimestampMs);
178
+ if (diffMs > NONCE_TIMESTAMP_MAX_SKEW_MS) {
179
+ throw new Error(`Timestamp Skew Too Large! claimData.timestampS and attestationNonce timestamp differ by ${Math.round(diffMs / 1000)}s (limit: 600s)`);
180
+ }
181
+ }
182
+ function verifyNonceMaterial(expectedNonce, nonceDataObj, expectedAppSecret) {
183
+ const cleanExpectedNonce = normalizeHex(expectedNonce);
184
+ const { applicationId, sessionId, timestamp } = nonceDataObj;
185
+ assert(cleanExpectedNonce.length > 0, 'Proof context attestationNonce is empty');
186
+ assert(isHex(cleanExpectedNonce), 'Proof context attestationNonce is not valid hex');
187
+ if (expectedAppSecret) {
188
+ const recomputedNonce = deriveLegacyTeeNonce(expectedAppSecret, { applicationId, sessionId, timestamp });
189
+ assert(recomputedNonce === cleanExpectedNonce, 'Attestation nonce verification failed: app secret, application ID, session ID, or timestamp do not match');
190
+ return;
191
+ }
192
+ if (cleanExpectedNonce.length > 74) {
193
+ const legacyNonceData = `${applicationId}:${sessionId}:${timestamp}`;
194
+ const nonceMsg = keccak_256(new TextEncoder().encode(legacyNonceData));
195
+ const recoveredAddress = recoverEip191Address(nonceMsg, expectedNonce.startsWith('0x') ? expectedNonce : `0x${expectedNonce}`);
196
+ assert(recoveredAddress.toLowerCase() === applicationId.toLowerCase(), `Nonce signature verification failed: recovered ${recoveredAddress}, expected ${applicationId}`);
197
+ return;
198
+ }
199
+ throw new Error('App secret is required to verify hash-based attestation nonces');
200
+ }
201
+ function assertTokenFresh(claims) {
202
+ const now = Math.floor(Date.now() / 1000);
203
+ if (typeof claims.nbf === 'number' && now + TOKEN_CLOCK_SKEW_S < claims.nbf) {
204
+ throw new Error(`Attestation token is not valid before ${claims.nbf}`);
205
+ }
206
+ if (typeof claims.exp === 'number' && now - TOKEN_CLOCK_SKEW_S > claims.exp) {
207
+ throw new Error(`Attestation token expired at ${claims.exp}`);
208
+ }
209
+ if (typeof claims.iat === 'number' && claims.iat > now + TOKEN_CLOCK_SKEW_S) {
210
+ throw new Error(`Attestation token issued-at ${claims.iat} is in the future`);
211
+ }
212
+ }
213
+ function assertAudienceClaim(aud) {
214
+ if (typeof aud === 'string') {
215
+ assert(aud.length > 0, 'attestation token audience is empty');
216
+ return;
217
+ }
218
+ if (Array.isArray(aud)) {
219
+ assert(aud.length > 0, 'attestation token audience is empty');
220
+ assert(aud.every((entry) => typeof entry === 'string' && entry.length > 0), 'attestation token audience contains invalid entries');
221
+ return;
222
+ }
223
+ throw new Error('attestation token audience is missing');
224
+ }
225
+ function getProofVersion(teeAttestation) {
226
+ return teeAttestation.proof_version ?? teeAttestation.proofVersion;
227
+ }
228
+ function assertProofShape(teeAttestation) {
229
+ if (teeAttestation.error) {
230
+ throw new Error(`${teeAttestation.error.code}: ${teeAttestation.error.message}`);
231
+ }
232
+ const proofVersion = getProofVersion(teeAttestation);
233
+ assert(typeof proofVersion === 'string' && SUPPORTED_PROOF_VERSIONS.includes(proofVersion), `unexpected proof version: ${proofVersion}`);
234
+ assert(teeAttestation.tee_provider === EXPECTED_TEE_PROVIDER, `unexpected tee provider: ${teeAttestation.tee_provider}`);
235
+ assert(teeAttestation.tee_technology === EXPECTED_TEE_TECHNOLOGY, `unexpected tee technology: ${teeAttestation.tee_technology}`);
236
+ assert(typeof teeAttestation.nonce === 'string' && teeAttestation.nonce.length > 0, 'tee attestation nonce missing');
237
+ assert(typeof teeAttestation.timestamp === 'string' && teeAttestation.timestamp.length > 0, 'tee attestation timestamp missing');
238
+ assert(!Number.isNaN(Date.parse(teeAttestation.timestamp)), 'tee attestation timestamp is invalid');
239
+ assert(typeof teeAttestation.workload?.image_digest === 'string' && teeAttestation.workload.image_digest.length > 0, 'workload image digest missing');
240
+ assert(typeof teeAttestation.verifier?.image_digest === 'string' && teeAttestation.verifier.image_digest.length > 0, 'verifier image digest missing');
241
+ assert(typeof teeAttestation.attestation?.token === 'string' && teeAttestation.attestation.token.length > 0, 'attestation token missing');
242
+ }
243
+ async function computeDigestBinding(teeAttestation) {
244
+ const proofVersion = getProofVersion(teeAttestation);
245
+ if (proofVersion === 'v3') {
246
+ assert(typeof teeAttestation.workload.container_name === 'string' && teeAttestation.workload.container_name.length > 0, 'workload container name missing');
247
+ assert(typeof teeAttestation.verifier.container_name === 'string' && teeAttestation.verifier.container_name.length > 0, 'verifier container name missing');
248
+ return sha256Hex([
249
+ 'v3',
250
+ `workload.container_name=${teeAttestation.workload.container_name}`,
251
+ `workload.image_digest=${teeAttestation.workload.image_digest}`,
252
+ `verifier.container_name=${teeAttestation.verifier.container_name}`,
253
+ `verifier.image_digest=${teeAttestation.verifier.image_digest}`,
254
+ ].join('\n'));
255
+ }
256
+ return sha256Hex(`${teeAttestation.workload.image_digest}\n${teeAttestation.verifier.image_digest}`);
257
+ }
258
+ async function verifyGcpClaims(teeAttestation, expectedNonce) {
259
+ const claims = await verifyJwtSignature(teeAttestation.attestation.token, GCP_CONFIDENTIAL_SPACE_ISSUER);
260
+ assert(claims.iss === GCP_CONFIDENTIAL_SPACE_ISSUER, `unexpected issuer: ${claims.iss}`);
261
+ assertAudienceClaim(claims.aud);
262
+ assert(Array.isArray(claims.eat_nonce), 'eat_nonce claim missing');
263
+ const digestBinding = await computeDigestBinding(teeAttestation);
264
+ assert(claims.eat_nonce.includes(expectedNonce), 'request nonce is not present in attestation token');
265
+ assert(claims.eat_nonce.includes(digestBinding), 'digest-binding nonce is not present in attestation token');
266
+ assert(claims.hwmodel === EXPECTED_HW_MODEL, `unexpected hwmodel: ${claims.hwmodel}`);
267
+ assert(claims.secboot === true, 'secure boot claim is not true');
268
+ const submods = claims.submods;
269
+ assert(isRecord(submods) && submods.gce, 'gce submod claim missing');
270
+ assertTokenFresh(claims);
271
+ }
272
+ function isRecord(value) {
273
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
274
+ }
275
+ /**
276
+ * Validates the hardware TEE attestation included in the proof.
277
+ * Derives the application ID from `appSecret` and verifies the attestation
278
+ * was generated for your application.
279
+ * Returns a result object with `isVerified` and an optional `error` message.
280
+ *
281
+ * This check is stateless and does not protect against replay of a previously
282
+ * valid proof. Callers must enforce session matching and dedup `sessionId` on
283
+ * their server. See the README's "Replay Protection" section.
284
+ *
285
+ * @param proof - The proof containing TEE attestation data
286
+ * @param appSecret - Your application secret (Ethereum private key). Used to
287
+ * derive the application ID and recompute the attestation nonce.
288
+ */
289
+ export async function verifyTeeAttestation(proof, appSecret) {
290
+ assertNonBrowserEnvironment();
291
+ try {
292
+ const appId = legacyApplicationIdFromPrivateKey(appSecret);
293
+ let teeAttestation = proof.teeAttestation;
294
+ if (!teeAttestation) {
295
+ throw new Error('Missing teeAttestation in proof');
296
+ }
297
+ if (typeof teeAttestation === 'string') {
298
+ teeAttestation = JSON.parse(teeAttestation);
299
+ }
300
+ assertProofShape(teeAttestation);
301
+ const { parsedContext, nonceDataObj, expectedNonce } = parseProofContext(proof);
302
+ verifyApplicationAndSessionBinding(proof, parsedContext, nonceDataObj, appId);
303
+ verifyNonceMaterial(expectedNonce, nonceDataObj, appSecret);
304
+ const cleanExpectedNonce = normalizeHex(expectedNonce);
305
+ const cleanTeeNonce = normalizeHex(teeAttestation.nonce);
306
+ assert(cleanTeeNonce.length > 0, 'TEE attestation nonce is empty');
307
+ assert(isHex(cleanTeeNonce), 'TEE attestation nonce is not valid hex');
308
+ assert(cleanTeeNonce === cleanExpectedNonce, `Nonce Mismatch! Expected ${cleanExpectedNonce}, got ${cleanTeeNonce}`);
309
+ await verifyGcpClaims(teeAttestation, cleanExpectedNonce);
310
+ return { isVerified: true };
311
+ }
312
+ catch (error) {
313
+ return {
314
+ isVerified: false,
315
+ error: error instanceof Error ? error.message : String(error),
316
+ };
317
+ }
318
+ }
319
+ /**
320
+ * Verifies TEE attestation for all proofs.
321
+ * Throws `TeeVerificationError` if any proof is missing TEE data or fails verification.
322
+ *
323
+ * @param proofs - The proofs to verify
324
+ * @param config - TEE attestation configuration containing the app secret
325
+ * @throws {TeeVerificationError} When TEE data is missing or verification fails
326
+ */
327
+ export async function runTeeVerification(proofs, config) {
328
+ const hasTeeData = proofs.every(proof => {
329
+ if (proof.teeAttestation) {
330
+ return true;
331
+ }
332
+ try {
333
+ const context = JSON.parse(proof.claimData.context);
334
+ return !!context?.attestationNonce;
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ });
340
+ if (!hasTeeData) {
341
+ throw new TeeVerificationError('TEE verification requested but one or more proofs are missing TEE attestation data');
342
+ }
343
+ const teeResults = await Promise.all(proofs.map(proof => verifyTeeAttestation(proof, config.appSecret)));
344
+ if (!teeResults.every(r => r.isVerified)) {
345
+ throw new TeeVerificationError('TEE attestation verification failed for one or more proofs');
346
+ }
347
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/client",
3
3
  "title": "Reclaim Client",
4
- "version": "0.1.27",
4
+ "version": "0.1.28",
5
5
  "description": "Typed SDK/HTTP client SDK for the Reclaim Protocol — the programmatic way to use Reclaim Protocol to verify **data points** about your users (a follower count, an account balance, an order). Can be directly integrated by apps to request verification for their users using Reclaim Protocol and verify the results.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -12,6 +12,10 @@
12
12
  "types": "./lib/index.d.ts",
13
13
  "import": "./lib/index.js"
14
14
  },
15
+ "./api": {
16
+ "types": "./lib/api.d.ts",
17
+ "import": "./lib/api.js"
18
+ },
15
19
  "./openapi": {
16
20
  "types": "./lib/types/index.d.ts",
17
21
  "import": "./lib/types/index.js"
package/src/api.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { ReclaimClient } from './client.ts'
2
+ export type { ReclaimClientOptions } from './client.ts'
3
+ export {
4
+ isProblemDetails,
5
+ isProblemError,
6
+ readProblemDetails,
7
+ throwProblemError,
8
+ } from './errors.ts'
9
+ export type { ProblemError } from './errors.ts'
10
+ export {
11
+ DEFAULT_BASE_URL,
12
+ PROBLEM_CONTENT_TYPE,
13
+ } from './consts.ts'
14
+ export type * from './types/openapi.ts'
15
+ export type * from './types/openapi.gen.ts'
@@ -63,6 +63,12 @@ export interface NestedProofOutcome {
63
63
  attestorAddress?: string
64
64
  /** Decoded claim data. */
65
65
  claim?: Record<string, unknown>
66
+ /** Recovered signers, in nested JWS signature order. */
67
+ attestorAddresses?: string[]
68
+ /** Decoded protected headers, in nested JWS signature order. */
69
+ headers?: Record<string, unknown>[]
70
+ /** The raw nested signatures, in hex form. */
71
+ signatures?: string[]
66
72
  }
67
73
 
68
74
  /**
@@ -84,51 +90,82 @@ export function verifyNestedProof(
84
90
  return { valid: false, reason: 'proof-not-jws' }
85
91
  }
86
92
 
87
- const sig = jws.signatures?.[0]
88
- if(!jws.payload || !sig) {
93
+ if(!jws.payload || !jws.signatures?.length) {
89
94
  return { valid: false, reason: 'proof-malformed' }
90
95
  }
91
96
 
92
- let header: Record<string, unknown>
93
97
  let claim: Record<string, unknown>
94
98
  try {
95
- header = decodeJson(sig.protected)
96
99
  claim = decodeJson(jws.payload)
97
100
  } catch{
98
101
  return { valid: false, reason: 'proof-decode-failed' }
99
102
  }
100
103
 
101
- let recovered: string
102
- try {
103
- recovered = recoverEthAddress(
104
- eip191Digest(createSignData(claim)),
105
- base64urlToBytes(sig.signature),
106
- )
107
- } catch{
108
- return { valid: false, reason: 'recover-failed', claim }
104
+ const recoveredAddresses: string[] = []
105
+ const headers: Record<string, unknown>[] = []
106
+ const signatures: string[] = []
107
+ let firstFailure: NestedProofOutcome | undefined
108
+ for(const sig of jws.signatures) {
109
+ let header: Record<string, unknown>
110
+ let recovered: string
111
+ try {
112
+ header = decodeJson(sig.protected)
113
+ recovered = recoverEthAddress(
114
+ eip191Digest(createSignData(claim)),
115
+ base64urlToBytes(sig.signature),
116
+ )
117
+ } catch{
118
+ firstFailure ||= { valid: false, reason: 'recover-failed', claim }
119
+ continue
120
+ }
121
+
122
+ const kid = String(header.kid ?? '').toLowerCase()
123
+ if(recovered.toLowerCase() !== kid) {
124
+ firstFailure ||= {
125
+ valid: false,
126
+ reason: 'attestor-signature-mismatch',
127
+ attestorAddress: recovered,
128
+ claim,
129
+ }
130
+ continue
131
+ }
132
+
133
+ recoveredAddresses.push(recovered)
134
+ headers.push(header)
135
+ signatures.push('0x' + bytesToHex(base64urlToBytes(sig.signature)))
109
136
  }
110
137
 
111
- const kid = String(header.kid ?? '').toLowerCase()
112
- if(recovered.toLowerCase() !== kid) {
138
+ if(!recoveredAddresses.length) {
113
139
  return {
114
- valid: false,
115
- reason: 'attestor-signature-mismatch',
116
- attestorAddress: recovered,
140
+ ...(firstFailure ?? { valid: false, reason: 'recover-failed' }),
117
141
  claim,
118
142
  }
119
143
  }
120
144
 
121
145
  if(opts.attestorAddresses) {
122
146
  const allow = opts.attestorAddresses.map((a) => a.toLowerCase())
123
- if(!allow.includes(kid)) {
147
+ const allowedIndex = recoveredAddresses.findIndex(
148
+ (a) => allow.includes(a.toLowerCase()),
149
+ )
150
+ if(allowedIndex < 0) {
124
151
  return {
125
152
  valid: false,
126
153
  reason: 'attestor-not-allowed',
127
- attestorAddress: recovered,
154
+ attestorAddress: recoveredAddresses[0],
155
+ attestorAddresses: recoveredAddresses,
156
+ headers,
157
+ signatures,
128
158
  claim,
129
159
  }
130
160
  }
131
161
  }
132
162
 
133
- return { valid: true, attestorAddress: recovered, claim }
163
+ return {
164
+ valid: true,
165
+ attestorAddress: recoveredAddresses[0],
166
+ attestorAddresses: recoveredAddresses,
167
+ headers,
168
+ signatures,
169
+ claim,
170
+ }
134
171
  }
package/src/client.ts CHANGED
@@ -1,16 +1,10 @@
1
- import { notFound } from '@hapi/boom'
2
- import assert from 'node:assert'
3
1
  import type {
4
2
  IOperationArgs,
5
3
  IOperationId,
6
4
  IOperationResponse,
7
5
  } from './types/openapi.ts'
8
- import { DEFAULT_BASE_URL, USER_AGENT } from './consts.ts'
6
+ import { DEFAULT_BASE_URL } from './consts.ts'
9
7
  import { throwProblemError } from './errors.ts'
10
- // `ROUTES` is generated from `packages/app/openapi.yaml` and is the
11
- // single source of method/path mapping for every operation. New
12
- // endpoints added to the spec are picked up by re-running
13
- // `npm run openapi:typegen --workspace=packages/client`.
14
8
  import { ROUTES } from './routes.gen.ts'
15
9
 
16
10
  export type ReclaimClientOptions = {
@@ -25,12 +19,7 @@ export type ReclaimClientOptions = {
25
19
  }
26
20
 
27
21
  /**
28
- * Core HTTP client. Shared by the SDK entrypoint, MCP server, and CLI —
29
- * extend behavior here rather than duplicating it across surfaces.
30
- *
31
- * Per @reclaimprotocol/app conventions, validation lives in the openapi
32
- * spec; this client forwards requests and maps RFC 9457 problem responses
33
- * to {@link ProblemError}.
22
+ * Browser-safe typed HTTP client generated from the Builder OpenAPI contract.
34
23
  */
35
24
  export class ReclaimClient {
36
25
  readonly baseUrl: string
@@ -43,7 +32,6 @@ export class ReclaimClient {
43
32
  this.#token = opts.token
44
33
  this.#fetch = opts.fetch || fetch
45
34
  this.#headers = {
46
- 'User-Agent': USER_AGENT,
47
35
  Accept: 'application/json',
48
36
  ...opts.headers,
49
37
  }
@@ -63,20 +51,17 @@ export class ReclaimClient {
63
51
  ? [args?: IOperationArgs<T>]
64
52
  : [args: IOperationArgs<T>]
65
53
  ): Promise<{ data: IOperationResponse<T>, response: Response }> {
66
- const args = argsRest[0] ?? {} as IOperationArgs<T>
67
- const op = ROUTES[operationId] as
68
- | { method: string, path: string }
69
- | undefined
70
- assert(op, notFound(`Unknown operation: ${String(operationId)}`))
54
+ const args = argsRest[0]
55
+ const op = ROUTES[operationId]
71
56
 
72
57
  let url = `${this.baseUrl}${op.path}`
73
- if(args.params) {
58
+ if(args?.params) {
74
59
  for(const [key, value] of Object.entries(args.params)) {
75
60
  url = url.replace(`{${key}}`, String(value))
76
61
  }
77
62
  }
78
63
 
79
- if(args.query && Object.keys(args.query).length > 0) {
64
+ if(args?.query && Object.keys(args.query).length > 0) {
80
65
  const search = new URLSearchParams()
81
66
  for(const [key, value] of Object.entries(args.query)) {
82
67
  if(value === undefined || value === null) {
@@ -84,8 +69,8 @@ export class ReclaimClient {
84
69
  }
85
70
 
86
71
  if(Array.isArray(value)) {
87
- for(const v of value) {
88
- search.append(key, String(v))
72
+ for(const item of value) {
73
+ search.append(key, String(item))
89
74
  }
90
75
  } else {
91
76
  search.append(key, String(value))
@@ -103,10 +88,7 @@ export class ReclaimClient {
103
88
  headers['Authorization'] = `Bearer ${this.#token}`
104
89
  }
105
90
 
106
- // Per-request header parameters (for example, `If-Match` for optimistic
107
- // concurrency). Applied last so spec-declared headers win over the
108
- // client-wide defaults, but never override Authorization.
109
- if(args.headers) {
91
+ if(args?.headers) {
110
92
  for(const [key, value] of Object.entries(args.headers)) {
111
93
  if(value === undefined || value === null) {
112
94
  continue
@@ -120,27 +102,22 @@ export class ReclaimClient {
120
102
  method: op.method.toUpperCase(),
121
103
  headers,
122
104
  }
123
- // Write methods always carry a JSON body (defaulting to `{}`) plus a
124
- // Content-Type. Some endpoints — for example, the auth-only attach session
125
- // — take an empty body, and omitting Content-Type makes the server reject
126
- // the request with `415 unsupported media type`.
127
105
  const method = op.method.toLowerCase()
128
106
  if(method !== 'get' && method !== 'head') {
129
- init.body = JSON.stringify(args.body ?? {})
107
+ init.body = JSON.stringify(args?.body ?? {})
130
108
  headers['Content-Type'] = 'application/json'
131
109
  }
132
110
 
133
111
  const res = await this.#fetch(url, init)
134
-
135
112
  if(!res.ok) {
136
113
  await throwProblemError(res)
137
114
  }
138
115
 
139
- if(res.status === 204 || res.headers.get('content-length') === '0') {
140
- return { data: undefined as IOperationResponse<T>, response: res }
141
- }
142
-
143
- const data = await res.json() as IOperationResponse<T>
116
+ const empty = res.status === 204
117
+ || res.headers.get('content-length') === '0'
118
+ const data = (
119
+ empty ? undefined : await res.json()
120
+ ) as IOperationResponse<T>
144
121
  return { data, response: res }
145
122
  }
146
123
  }
package/src/consts.ts CHANGED
@@ -1,5 +1,4 @@
1
- export const DEFAULT_BASE_URL =
2
- process.env.RECLAIM_BASE_URL || 'https://build.reclaimprotocol.org'
1
+ export const DEFAULT_BASE_URL = 'https://build.reclaimprotocol.org'
3
2
 
4
3
  export const USER_AGENT = '@reclaimprotocol/client'
5
4
 
package/src/eip191.ts CHANGED
@@ -12,8 +12,8 @@ const ENC = new TextEncoder()
12
12
 
13
13
  /** Standard EIP-191 / personal_sign digest: keccak256 of
14
14
  * `0x19 ‖ "Ethereum Signed Message:\n" ‖ len ‖ data`. */
15
- export function eip191Digest(data: string): Uint8Array {
16
- const bytes = ENC.encode(data)
15
+ export function eip191Digest(data: string | Uint8Array): Uint8Array {
16
+ const bytes = typeof data === 'string' ? ENC.encode(data) : data
17
17
  const prefix = ENC.encode('\x19Ethereum Signed Message:\n')
18
18
  const len = ENC.encode(String(bytes.length))
19
19
  const merged = new Uint8Array(prefix.length + len.length + bytes.length)
@@ -28,7 +28,10 @@ export function eip191Digest(data: string): Uint8Array {
28
28
  * `ethers.verifyMessage(message, signature)` accepts — noble's `'recovered'`
29
29
  * format prepends the recovery byte instead, so it's reordered to the end
30
30
  * here (a raw `0`/`1` recovery byte is fine there, no `+27` needed). */
31
- export function signEip191(message: string, privateKeyHex: string): string {
31
+ export function signEip191(
32
+ message: string | Uint8Array,
33
+ privateKeyHex: string,
34
+ ): string {
32
35
  const digest = eip191Digest(message)
33
36
  const privateKey = hexToBytes(privateKeyHex)
34
37
  const recovered = secp.sign(digest, privateKey, {
@@ -39,3 +42,28 @@ export function signEip191(message: string, privateKeyHex: string): string {
39
42
  const rs = recovered.slice(1)
40
43
  return '0x' + bytesToHex(rs) + recovery.toString(16).padStart(2, '0')
41
44
  }
45
+
46
+ /** Recover the lowercase Ethereum address from an EIP-191 signature. */
47
+ export function recoverEip191Address(
48
+ message: string | Uint8Array,
49
+ signatureHex: string,
50
+ ): string {
51
+ const signature = hexToBytes(signatureHex)
52
+ if(signature.length !== 65) {
53
+ throw new Error('EIP-191 signature must contain 65 bytes')
54
+ }
55
+
56
+ const recovery = signature[64] >= 27
57
+ ? signature[64] - 27
58
+ : signature[64]
59
+ const recovered = new Uint8Array(65)
60
+ recovered[0] = recovery
61
+ recovered.set(signature.subarray(0, 64), 1)
62
+ const compressed = secp.recoverPublicKey(
63
+ recovered,
64
+ eip191Digest(message),
65
+ { prehash: false },
66
+ )
67
+ const uncompressed = secp.Point.fromBytes(compressed).toBytes(false)
68
+ return '0x' + bytesToHex(keccak_256(uncompressed.slice(1))).slice(-40)
69
+ }