@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,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.
|
|
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'
|
package/src/attestor-proof.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
154
|
+
attestorAddress: recoveredAddresses[0],
|
|
155
|
+
attestorAddresses: recoveredAddresses,
|
|
156
|
+
headers,
|
|
157
|
+
signatures,
|
|
128
158
|
claim,
|
|
129
159
|
}
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
|
|
133
|
-
return {
|
|
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
|
|
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
|
-
*
|
|
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]
|
|
67
|
-
const op = ROUTES[operationId]
|
|
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
|
|
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
|
|
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
|
|
88
|
-
search.append(key, String(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
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(
|
|
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
|
+
}
|