@reclaimprotocol/client 0.1.35 → 0.1.36
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 +57 -10
- package/lib/errors.d.ts +15 -0
- package/lib/errors.js +44 -0
- package/lib/jwks.js +2 -1
- package/lib/request-proof.d.ts +10 -0
- package/lib/request-proof.js +17 -10
- package/lib/result.d.ts +20 -1
- package/lib/result.js +32 -7
- package/lib/routes.gen.js +1 -1
- package/lib/types/openapi.gen.d.ts +22 -1
- package/lib/types/openapi.gen.js +1 -1
- package/lib/verify-attestor-tee.d.ts +19 -4
- package/lib/verify-attestor-tee.js +10 -4
- package/package.json +1 -1
- package/src/errors.ts +52 -0
- package/src/jwks.ts +5 -1
- package/src/request-proof.ts +31 -17
- package/src/result.ts +47 -7
- package/src/routes.gen.ts +1 -1
- package/src/types/openapi.gen.ts +23 -2
- package/src/verify-attestor-tee.ts +33 -5
package/README.md
CHANGED
|
@@ -750,16 +750,18 @@ including `hasNoPii`, verifier TEE (`teeAttestation.appSecret`), and
|
|
|
750
750
|
attestor-TEE checks. The configured Builder JWKS URL supplies the
|
|
751
751
|
purpose-bound result keys, its `x-reclaim-attestors` signer list, and its
|
|
752
752
|
`x-reclaim-attestor-image-digests` Confidential Space image allowlist. When
|
|
753
|
-
the JWKS publishes image digests, `verifyResultFull`
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
`
|
|
761
|
-
|
|
762
|
-
|
|
753
|
+
the JWKS publishes image digests, `verifyResultFull` verifies any attestor TEE
|
|
754
|
+
`claimAttestation` a witness carries — its attestor address and claim
|
|
755
|
+
signature, and its `attestation_report` pinned to those digests when present.
|
|
756
|
+
Neither is required: like `@reclaimprotocol/js-sdk`, a witness is trusted by
|
|
757
|
+
its signer being in `x-reclaim-attestors`. A portal TEE proof without a bound
|
|
758
|
+
nonce has no `claimAttestation`, and production attestors sign without
|
|
759
|
+
returning a report. When nothing was attested,
|
|
760
|
+
`isAttestorTeeAttestationVerified` comes back `false`. Set
|
|
761
|
+
`proofValidation.attestorTeeAttestation` to require a `claimAttestation` on
|
|
762
|
+
every witness. If its `expectedImageDigests` is also set, the SDK uses the
|
|
763
|
+
intersection: caller policy can narrow the published trust list, but can't
|
|
764
|
+
broaden it. TEE settings preserve the same configuration objects;
|
|
763
765
|
`verifyResultFull` does not accept injected verification hooks. The surrounding
|
|
764
766
|
issuer, session, and context checks are stateless; persist accepted session IDs
|
|
765
767
|
in your application to prevent replay.
|
|
@@ -808,6 +810,51 @@ try {
|
|
|
808
810
|
A non-terminal `event` delivery is not a result and never throws. It returns
|
|
809
811
|
`{ kind: 'event', … }`.
|
|
810
812
|
|
|
813
|
+
#### Respond to a callback delivery
|
|
814
|
+
|
|
815
|
+
Builder retries a delivery until your callback route answers 2xx, and a
|
|
816
|
+
delivery that never succeeds holds back that session's later events. If
|
|
817
|
+
retrying the same body can't help, answer 2xx. If the failure is temporary,
|
|
818
|
+
answer 5xx.
|
|
819
|
+
|
|
820
|
+
`ResultVerificationError.retryable` tells you which case you're in. It's
|
|
821
|
+
`true` only when a temporary condition caused the failure:
|
|
822
|
+
Builder's JWKS or provider recipe couldn't be fetched because of a network
|
|
823
|
+
error, a timeout, an HTTP 5xx, or rate limiting (`jwks-fetch-failed`,
|
|
824
|
+
`session-resolution-failed`, or an `invalid-proof` whose proofs all failed
|
|
825
|
+
this way). It's `false` for everything about the result itself, including
|
|
826
|
+
`bad-signature`, `issuer-not-trusted`, `aud-mismatch`,
|
|
827
|
+
`reclaimSessionId-mismatch`, `expired`, and an `invalid-proof` with any
|
|
828
|
+
genuinely invalid proof.
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
app.post('/reclaim/callback', async (req, res) => {
|
|
832
|
+
try {
|
|
833
|
+
const { result } = await reclaim.results.receive(req.body, {
|
|
834
|
+
orgEthPrivateKey,
|
|
835
|
+
reclaimSessionId,
|
|
836
|
+
})
|
|
837
|
+
await markVerificationComplete(result)
|
|
838
|
+
res.sendStatus(200)
|
|
839
|
+
} catch (err) {
|
|
840
|
+
if (err instanceof ResultVerificationError) {
|
|
841
|
+
await storeFailure(err.reason, err.outcome)
|
|
842
|
+
// Retrying a bad result can't help, so only ask for a retry when the
|
|
843
|
+
// failure is temporary.
|
|
844
|
+
res.sendStatus(err.retryable ? 503 : 200)
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
throw err
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
`results.receive` can also throw an ordinary `Error` instead of
|
|
853
|
+
`ResultVerificationError`, for example when `orgEthPrivateKey` is missing or
|
|
854
|
+
doesn't match the key the result was encrypted to. Treat it as a
|
|
855
|
+
configuration problem: log it and answer 5xx, so the delivery succeeds once
|
|
856
|
+
you fix the key. Builder holds an undelivered result for 7 days.
|
|
857
|
+
|
|
811
858
|
### `reclaim.api` — escape hatch
|
|
812
859
|
|
|
813
860
|
```ts
|
package/lib/errors.d.ts
CHANGED
|
@@ -22,6 +22,21 @@ export type ProblemError = Error & {
|
|
|
22
22
|
};
|
|
23
23
|
/** Returns whether a thrown value is a Builder {@link ProblemError}. */
|
|
24
24
|
export declare function isProblemError(error: unknown): error is ProblemError;
|
|
25
|
+
/**
|
|
26
|
+
* Whether retrying the same request later can succeed: a network failure, a
|
|
27
|
+
* timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
|
|
28
|
+
* or a plain error carrying the HTTP `status`). Everything else, such as a
|
|
29
|
+
* malformed response, won't change on retry.
|
|
30
|
+
*/
|
|
31
|
+
export declare function isTransientError(error: unknown): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Runs one network step, such as a JWKS fetch or a session or provider
|
|
34
|
+
* lookup, and marks a transient failure so {@link isTransientFailure} can
|
|
35
|
+
* tell it apart from a failed content check.
|
|
36
|
+
*/
|
|
37
|
+
export declare function networkStep<T>(step: () => Promise<T> | T): Promise<T>;
|
|
38
|
+
/** Whether `error` came from a {@link networkStep} and a retry can succeed. */
|
|
39
|
+
export declare function isTransientFailure(error: unknown): boolean;
|
|
25
40
|
/** Returns whether a value follows the RFC 9457 problem-details schema. */
|
|
26
41
|
export declare function isProblemDetails(value: unknown): value is ProblemDetails;
|
|
27
42
|
/** Reads an RFC 9457 response, with a status-only fallback for other bodies. */
|
package/lib/errors.js
CHANGED
|
@@ -14,6 +14,50 @@ export function isProblemError(error) {
|
|
|
14
14
|
return isProblemDetails(error.data)
|
|
15
15
|
&& isProblemErrorOutput(error.output);
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Whether retrying the same request later can succeed: a network failure, a
|
|
19
|
+
* timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
|
|
20
|
+
* or a plain error carrying the HTTP `status`). Everything else, such as a
|
|
21
|
+
* malformed response, won't change on retry.
|
|
22
|
+
*/
|
|
23
|
+
export function isTransientError(error) {
|
|
24
|
+
// `fetch` rejects with a TypeError when the network fails.
|
|
25
|
+
if (error instanceof TypeError) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
if (error instanceof Error
|
|
29
|
+
&& (error.name === 'TimeoutError' || error.name === 'AbortError')) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
const status = isProblemError(error)
|
|
33
|
+
? error.output.statusCode
|
|
34
|
+
: isRecord(error) ? error.status : undefined;
|
|
35
|
+
return typeof status === 'number'
|
|
36
|
+
&& (status >= 500 || status === 408 || status === 429);
|
|
37
|
+
}
|
|
38
|
+
// Errors thrown by a network step and judged transient. Tracked by identity
|
|
39
|
+
// so a content check that throws, say, a TypeError is never mistaken for one.
|
|
40
|
+
const TRANSIENT_ERRORS = new WeakSet();
|
|
41
|
+
/**
|
|
42
|
+
* Runs one network step, such as a JWKS fetch or a session or provider
|
|
43
|
+
* lookup, and marks a transient failure so {@link isTransientFailure} can
|
|
44
|
+
* tell it apart from a failed content check.
|
|
45
|
+
*/
|
|
46
|
+
export async function networkStep(step) {
|
|
47
|
+
try {
|
|
48
|
+
return await step();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof Error && isTransientError(error)) {
|
|
52
|
+
TRANSIENT_ERRORS.add(error);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Whether `error` came from a {@link networkStep} and a retry can succeed. */
|
|
58
|
+
export function isTransientFailure(error) {
|
|
59
|
+
return error instanceof Error && TRANSIENT_ERRORS.has(error);
|
|
60
|
+
}
|
|
17
61
|
/** Returns whether a value follows the RFC 9457 problem-details schema. */
|
|
18
62
|
export function isProblemDetails(value) {
|
|
19
63
|
if (!isRecord(value)) {
|
package/lib/jwks.js
CHANGED
|
@@ -47,7 +47,8 @@ export async function fetchReclaimJwks(url, fetchImpl = fetch) {
|
|
|
47
47
|
assertSecureJwksUrl(url);
|
|
48
48
|
const res = await fetchImpl(url);
|
|
49
49
|
if (!res.ok) {
|
|
50
|
-
|
|
50
|
+
// `status` lets callers tell a temporary Builder outage from a bad URL.
|
|
51
|
+
throw Object.assign(new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`), { status: res.status });
|
|
51
52
|
}
|
|
52
53
|
const body = (await res.json());
|
|
53
54
|
return {
|
package/lib/request-proof.d.ts
CHANGED
|
@@ -25,9 +25,15 @@ export interface VerifyRequestProofOptions {
|
|
|
25
25
|
/** Read `providerHash` from context, while still verifying signatures. */
|
|
26
26
|
hasNoPii?: true;
|
|
27
27
|
teeAttestation?: TeeAttestationConfig;
|
|
28
|
+
/** Require every witness to carry a `claimAttestation`, and verify any
|
|
29
|
+
* `attestation_report` against `expectedImageDigests`. */
|
|
28
30
|
attestorTeeAttestation?: AttestorTeeAttestationConfig;
|
|
29
31
|
/** Explicit trusted attestor ETH addresses. */
|
|
30
32
|
attestorAddresses?: string[];
|
|
33
|
+
/** Trusted attestor image digests, like `x-reclaim-attestor-image-digests`.
|
|
34
|
+
* Pin any `attestation_report` a witness carries without requiring one,
|
|
35
|
+
* matching `@reclaimprotocol/js-sdk`'s default. */
|
|
36
|
+
attestorImageDigests?: string[];
|
|
31
37
|
/** Trusted Builder JWKS registry containing attestor-purpose keys. */
|
|
32
38
|
attestorJwksUrl?: string;
|
|
33
39
|
fetchImpl?: typeof fetch;
|
|
@@ -43,6 +49,10 @@ export interface VerifyRequestProofOptions {
|
|
|
43
49
|
export interface ReclaimRequestProofOutcome {
|
|
44
50
|
valid: boolean;
|
|
45
51
|
reason?: string;
|
|
52
|
+
/** On failure: true when a temporary condition caused it, such as a
|
|
53
|
+
* network error while resolving the session or provider. A retry can
|
|
54
|
+
* succeed. */
|
|
55
|
+
transient?: boolean;
|
|
46
56
|
proofHashes?: string[];
|
|
47
57
|
isTeeAttestationVerified?: boolean;
|
|
48
58
|
/** True when at least one witness's `attestation_report` verified; false
|
package/lib/request-proof.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSignDataForClaim } from '@reclaimprotocol/attestor-core';
|
|
2
2
|
import { recoverEip191Address } from "./eip191.js";
|
|
3
|
+
import { isTransientFailure, networkStep } from "./errors.js";
|
|
3
4
|
import { fetchReclaimJwks, trustedAttestorAddresses } from "./jwks.js";
|
|
4
5
|
import { assertValidProofHashes, getHashFromProof } from "./proof-hash.js";
|
|
5
6
|
import { runAttestorTeeVerification, } from "./verify-attestor-tee.js";
|
|
@@ -31,11 +32,13 @@ export async function verifyRequestProof(proofOrProofs, config) {
|
|
|
31
32
|
let isAttestorTeeAttestationVerified;
|
|
32
33
|
const attestorTeeAttestation = resolveAttestorTeeAttestation(effective.attestorTeeAttestation, trust.imageDigests);
|
|
33
34
|
if (attestorTeeAttestation) {
|
|
35
|
+
// Only a caller-configured check requires an envelope on every
|
|
36
|
+
// witness; registry digests alone pin the reports that exist.
|
|
34
37
|
// `verified` counts witnesses whose attestation report was checked.
|
|
35
38
|
// A proof whose witnesses carry no report still passes the address
|
|
36
39
|
// and signature bindings, but nothing was attested — say so rather
|
|
37
40
|
// than reporting a TEE attestation that never happened.
|
|
38
|
-
const summary = await runAttestorTeeVerification(proofs, attestorTeeAttestation);
|
|
41
|
+
const summary = await runAttestorTeeVerification(proofs, attestorTeeAttestation, { requireClaimAttestation: !!effective.attestorTeeAttestation });
|
|
39
42
|
isAttestorTeeAttestationVerified = summary.verified > 0;
|
|
40
43
|
}
|
|
41
44
|
return {
|
|
@@ -51,27 +54,30 @@ export async function verifyRequestProof(proofOrProofs, config) {
|
|
|
51
54
|
return {
|
|
52
55
|
valid: false,
|
|
53
56
|
reason: failureReason(error),
|
|
57
|
+
transient: isTransientFailure(error),
|
|
54
58
|
};
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
async function resolveValidation(config, proofs) {
|
|
58
62
|
let effective = config;
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const { sessionId, resolveSession } = config;
|
|
64
|
+
if (sessionId && !hasContentValidation(config)) {
|
|
65
|
+
if (!resolveSession) {
|
|
61
66
|
throw new Error('session-resolver-required');
|
|
62
67
|
}
|
|
63
68
|
effective = {
|
|
64
69
|
...config,
|
|
65
|
-
...await
|
|
70
|
+
...await networkStep(() => resolveSession(sessionId, proofs)),
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
return effective;
|
|
69
74
|
}
|
|
70
75
|
async function resolveAttestorTrust(config) {
|
|
71
76
|
const addresses = config.attestorAddresses?.map(normalized) ?? [];
|
|
72
|
-
const imageDigests = [];
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
const imageDigests = config.attestorImageDigests?.map(normalized) ?? [];
|
|
78
|
+
const { attestorJwksUrl, fetchImpl } = config;
|
|
79
|
+
if (attestorJwksUrl) {
|
|
80
|
+
const registry = await networkStep(() => (fetchReclaimJwks(attestorJwksUrl, fetchImpl)));
|
|
75
81
|
addresses.push(...(registry['x-reclaim-attestors'] ?? []), ...trustedAttestorAddresses(registry.keys));
|
|
76
82
|
imageDigests.push(...(registry['x-reclaim-attestor-image-digests'] ?? []));
|
|
77
83
|
}
|
|
@@ -131,11 +137,12 @@ async function assertContent(proofs, config) {
|
|
|
131
137
|
assertValidProofHashes(proofs, config.hashes, config.hasNoPii);
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
const { providerId, providerVersion, allowedTags, resolveProvider } = config;
|
|
141
|
+
if (providerId) {
|
|
142
|
+
if (!resolveProvider) {
|
|
136
143
|
throw new Error('provider-resolver-required');
|
|
137
144
|
}
|
|
138
|
-
const candidates = await
|
|
145
|
+
const candidates = await networkStep(() => (resolveProvider(providerId, providerVersion, allowedTags, proofs)));
|
|
139
146
|
if (!candidates.length) {
|
|
140
147
|
throw new Error('provider-not-found');
|
|
141
148
|
}
|
package/lib/result.d.ts
CHANGED
|
@@ -132,6 +132,9 @@ export interface ResultProofOutcome {
|
|
|
132
132
|
proof?: ReclaimRequestProof;
|
|
133
133
|
proofValid: boolean;
|
|
134
134
|
reason?: string;
|
|
135
|
+
/** On failure: true when a temporary condition caused it, such as a
|
|
136
|
+
* network error while resolving the provider. */
|
|
137
|
+
transient?: boolean;
|
|
135
138
|
/** @deprecated Use `attestorAddresses`; retained for compatibility. */
|
|
136
139
|
attestorAddress?: string;
|
|
137
140
|
/** Every attestor address declared by the proof. */
|
|
@@ -157,11 +160,17 @@ export interface VerifyResultFullOutcome extends VerifyResultOutcome {
|
|
|
157
160
|
}
|
|
158
161
|
export interface ResultVerificationFailure extends VerifyResultFullOutcome {
|
|
159
162
|
reason: string;
|
|
163
|
+
/** True when a temporary condition caused the failure. Set by the
|
|
164
|
+
* verifier; read it as {@link ResultVerificationError.retryable}. */
|
|
165
|
+
retryable?: boolean;
|
|
160
166
|
}
|
|
161
167
|
/**
|
|
162
168
|
* Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
|
|
163
169
|
* `reclaim.results.verify`/`receive`) when a result fails verification. Carries
|
|
164
|
-
* the full invalid outcome so callers can inspect it in a `catch
|
|
170
|
+
* the full invalid outcome so callers can inspect it in a `catch`.
|
|
171
|
+
*
|
|
172
|
+
* In a callback handler, answer 2xx when `retryable` is `false` (Builder
|
|
173
|
+
* retrying the same delivery can't help) and 5xx when it's `true`:
|
|
165
174
|
*
|
|
166
175
|
* try {
|
|
167
176
|
* const { result } = await reclaim.results.receive(body, {
|
|
@@ -172,6 +181,7 @@ export interface ResultVerificationFailure extends VerifyResultFullOutcome {
|
|
|
172
181
|
* if (err instanceof ResultVerificationError) {
|
|
173
182
|
* // for example, 'expired', { payload, … }
|
|
174
183
|
* console.error(err.reason, err.outcome)
|
|
184
|
+
* res.sendStatus(err.retryable ? 503 : 200)
|
|
175
185
|
* }
|
|
176
186
|
* }
|
|
177
187
|
*/
|
|
@@ -182,6 +192,15 @@ export declare class ResultVerificationError extends Error {
|
|
|
182
192
|
/** The full (invalid) outcome: `payload`, `reclaimSessionId`, and per-proof
|
|
183
193
|
* detail (`proofs`) when verification got that far. */
|
|
184
194
|
readonly outcome: ResultVerificationFailure;
|
|
195
|
+
/**
|
|
196
|
+
* Whether a retry can succeed. `true` only for a temporary condition on
|
|
197
|
+
* the verifier's side: Builder's JWKS or recipe couldn't be fetched
|
|
198
|
+
* (network error, timeout, 5xx, 429). `false` when the result itself is
|
|
199
|
+
* wrong (signature, issuer, audience, session, expiry, invalid proof), so
|
|
200
|
+
* redelivering the same body fails the same way. Respond 5xx to a
|
|
201
|
+
* callback only when `true`.
|
|
202
|
+
*/
|
|
203
|
+
readonly retryable: boolean;
|
|
185
204
|
constructor(outcome: ResultVerificationFailure);
|
|
186
205
|
}
|
|
187
206
|
/**
|
package/lib/result.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
14
14
|
import { decrypt as eciesDecrypt, encrypt as eciesEncrypt } from 'eciesjs';
|
|
15
15
|
import { b64url, base64urlToBytes, hexToBytes, stripTrailingSlashes, } from "./encoding.js";
|
|
16
|
+
import { isTransientError } from "./errors.js";
|
|
16
17
|
import { fetchReclaimJwks, jwkToUncompressedPublicKey, selectVerificationResultKey, } from "./jwks.js";
|
|
17
18
|
import { getHashFromProof } from "./proof-hash.js";
|
|
18
19
|
import { proofHashesFromBuilderRecipe, } from "./proof-recipe.js";
|
|
@@ -145,7 +146,10 @@ export function verifyJwsSignature(jws, signerPublicKey) {
|
|
|
145
146
|
/**
|
|
146
147
|
* Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
|
|
147
148
|
* `reclaim.results.verify`/`receive`) when a result fails verification. Carries
|
|
148
|
-
* the full invalid outcome so callers can inspect it in a `catch
|
|
149
|
+
* the full invalid outcome so callers can inspect it in a `catch`.
|
|
150
|
+
*
|
|
151
|
+
* In a callback handler, answer 2xx when `retryable` is `false` (Builder
|
|
152
|
+
* retrying the same delivery can't help) and 5xx when it's `true`:
|
|
149
153
|
*
|
|
150
154
|
* try {
|
|
151
155
|
* const { result } = await reclaim.results.receive(body, {
|
|
@@ -156,6 +160,7 @@ export function verifyJwsSignature(jws, signerPublicKey) {
|
|
|
156
160
|
* if (err instanceof ResultVerificationError) {
|
|
157
161
|
* // for example, 'expired', { payload, … }
|
|
158
162
|
* console.error(err.reason, err.outcome)
|
|
163
|
+
* res.sendStatus(err.retryable ? 503 : 200)
|
|
159
164
|
* }
|
|
160
165
|
* }
|
|
161
166
|
*/
|
|
@@ -165,6 +170,7 @@ export class ResultVerificationError extends Error {
|
|
|
165
170
|
this.name = 'ResultVerificationError';
|
|
166
171
|
this.reason = outcome.reason;
|
|
167
172
|
this.outcome = outcome;
|
|
173
|
+
this.retryable = outcome.retryable ?? false;
|
|
168
174
|
}
|
|
169
175
|
}
|
|
170
176
|
/**
|
|
@@ -230,8 +236,10 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
230
236
|
jwksAttestorImageDigests =
|
|
231
237
|
registry['x-reclaim-attestor-image-digests'] ?? [];
|
|
232
238
|
}
|
|
233
|
-
catch {
|
|
234
|
-
return fail('jwks-fetch-failed'
|
|
239
|
+
catch (error) {
|
|
240
|
+
return fail('jwks-fetch-failed', {
|
|
241
|
+
retryable: isTransientError(error),
|
|
242
|
+
});
|
|
235
243
|
}
|
|
236
244
|
const jwk = selectVerificationResultKey(keys, String(header.kid ?? ''));
|
|
237
245
|
if (!jwk) {
|
|
@@ -348,7 +356,12 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
348
356
|
else if (configuredImageDigests?.length) {
|
|
349
357
|
expectedImageDigests = configuredImageDigests;
|
|
350
358
|
}
|
|
351
|
-
|
|
359
|
+
// A caller-configured check requires a `claimAttestation` on every
|
|
360
|
+
// witness. Registry digests alone only pin the attestor reports that
|
|
361
|
+
// are present, as `@reclaimprotocol/js-sdk` does by default — a portal
|
|
362
|
+
// TEE proof without a bound nonce carries no envelope.
|
|
363
|
+
const attestorTeeAttestation = configuredAttestorTee
|
|
364
|
+
&& expectedImageDigests.length
|
|
352
365
|
? { ...configuredAttestorTee, expectedImageDigests }
|
|
353
366
|
: configuredAttestorTee;
|
|
354
367
|
const commonValidation = {
|
|
@@ -360,6 +373,9 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
360
373
|
...(attestorTeeAttestation
|
|
361
374
|
? { attestorTeeAttestation }
|
|
362
375
|
: {}),
|
|
376
|
+
...(expectedImageDigests.length
|
|
377
|
+
? { attestorImageDigests: expectedImageDigests }
|
|
378
|
+
: {}),
|
|
363
379
|
...(expectedSessionId
|
|
364
380
|
? {
|
|
365
381
|
expectedSessionId,
|
|
@@ -375,7 +391,12 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
375
391
|
}
|
|
376
392
|
}
|
|
377
393
|
if (!proofs.every((p) => p.proofValid)) {
|
|
378
|
-
|
|
394
|
+
// Retry only when every failure is transient; one genuinely invalid
|
|
395
|
+
// proof makes redelivery pointless.
|
|
396
|
+
return fail('invalid-proof', {
|
|
397
|
+
proofs,
|
|
398
|
+
retryable: proofs.every((p) => p.proofValid || p.transient),
|
|
399
|
+
});
|
|
379
400
|
}
|
|
380
401
|
return { payload, reclaimSessionId, proofs };
|
|
381
402
|
async function verifyRequestProofGroup(group, validation) {
|
|
@@ -403,6 +424,7 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
403
424
|
for (const { outcomeIndex } of group) {
|
|
404
425
|
proofs[outcomeIndex].proofValid = false;
|
|
405
426
|
proofs[outcomeIndex].reason = requestProofOutcome.reason;
|
|
427
|
+
proofs[outcomeIndex].transient = requestProofOutcome.transient;
|
|
406
428
|
}
|
|
407
429
|
}
|
|
408
430
|
async function verifyByResolvedRecipes(common, expectedSessionId) {
|
|
@@ -412,8 +434,11 @@ export async function verifyResultFull(jws, opts = {}) {
|
|
|
412
434
|
const resolved = await opts.resolveSession(expectedSessionId);
|
|
413
435
|
recipes = resolved.recipes;
|
|
414
436
|
}
|
|
415
|
-
catch {
|
|
416
|
-
return fail('session-resolution-failed', {
|
|
437
|
+
catch (error) {
|
|
438
|
+
return fail('session-resolution-failed', {
|
|
439
|
+
proofs,
|
|
440
|
+
retryable: isTransientError(error),
|
|
441
|
+
});
|
|
417
442
|
}
|
|
418
443
|
}
|
|
419
444
|
const groups = Map.groupBy(proofGroups, proofGroupKey);
|
package/lib/routes.gen.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -3104,6 +3104,7 @@ export interface paths {
|
|
|
3104
3104
|
/**
|
|
3105
3105
|
* Submit exact proofs as a managed Builder result
|
|
3106
3106
|
* @description Accepts exact `Proof` objects from a claimant-facing `api=2` client. Builder atomically records the first terminal outcome, checks it against the submitted status, signs the outer Builder JWS with its managed key, optionally encrypts it for matching callback subscriptions, and enqueues delivery. Retrying the same terminal outcome is idempotent. This is the only result-submission endpoint; Verification Clients never receive signing or encryption keys.
|
|
3107
|
+
* Before it accepts a `success` result, Builder verifies every proof with the same trust a default `@reclaimprotocol/client` consumer uses: Builder's attestor addresses and image digests, the session recipe's provider hashes, and the session binding. If a proof fails, Builder records the session as a terminal `error` with a `problem` whose `reasonCode` is `invalid-proof`, enqueues the `verification_error` delivery, and returns `422`. Don't retry a `422`; the session is already terminal.
|
|
3107
3108
|
*/
|
|
3108
3109
|
post: operations["SubmitVerificationClientResult"];
|
|
3109
3110
|
delete?: never;
|
|
@@ -6380,7 +6381,18 @@ export interface components {
|
|
|
6380
6381
|
/** @enum {string} */
|
|
6381
6382
|
status: "success" | "rejected" | "error" | "cancelled";
|
|
6382
6383
|
results: components["schemas"]["VerificationClientProviderResult"][];
|
|
6383
|
-
problem?:
|
|
6384
|
+
problem?: ({
|
|
6385
|
+
/** @description Stable machine-readable failure code. Builder records `invalid-proof` when a submitted proof fails its verification. */
|
|
6386
|
+
reasonCode?: string;
|
|
6387
|
+
retryable?: boolean;
|
|
6388
|
+
/** @description Builder-recorded proof failures, one per provider. Present with `reasonCode: invalid-proof`. */
|
|
6389
|
+
proofs?: {
|
|
6390
|
+
providerId?: string;
|
|
6391
|
+
resolvedVersion?: string;
|
|
6392
|
+
requestIds?: string[];
|
|
6393
|
+
reason?: string;
|
|
6394
|
+
}[];
|
|
6395
|
+
} & components["schemas"]["Problem"]) | null;
|
|
6384
6396
|
};
|
|
6385
6397
|
VerificationClientProviderResult: {
|
|
6386
6398
|
providerId: components["schemas"]["ProviderID"];
|
|
@@ -11563,6 +11575,15 @@ export interface operations {
|
|
|
11563
11575
|
};
|
|
11564
11576
|
400: components["responses"]["ProblemResponse"];
|
|
11565
11577
|
409: components["responses"]["ProblemResponse"];
|
|
11578
|
+
/** @description A submitted proof failed Builder verification. Builder recorded the session as a terminal `error` and enqueued `verification_error`. */
|
|
11579
|
+
422: {
|
|
11580
|
+
headers: {
|
|
11581
|
+
[name: string]: unknown;
|
|
11582
|
+
};
|
|
11583
|
+
content: {
|
|
11584
|
+
"application/problem+json": components["schemas"]["Problem"];
|
|
11585
|
+
};
|
|
11586
|
+
};
|
|
11566
11587
|
default: components["responses"]["ProblemResponse"];
|
|
11567
11588
|
};
|
|
11568
11589
|
};
|
package/lib/types/openapi.gen.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/openapi-typegen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -51,6 +51,18 @@ export type AttestorTeeAttestationConfig = {
|
|
|
51
51
|
*/
|
|
52
52
|
expectedImageDigests?: string[];
|
|
53
53
|
};
|
|
54
|
+
/** How strictly `runAttestorTeeVerification` treats witnesses without a
|
|
55
|
+
* `claimAttestation`. */
|
|
56
|
+
export interface AttestorTeeVerificationOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Default `true`: every witness must carry a `claimAttestation`. `false`
|
|
59
|
+
* counts a witness without one as unattested, which is how
|
|
60
|
+
* `@reclaimprotocol/js-sdk` verifies by default — its claim signature is
|
|
61
|
+
* still checked against the trusted attestors by the caller. Portal TEE
|
|
62
|
+
* proofs without a bound nonce have no envelope.
|
|
63
|
+
*/
|
|
64
|
+
requireClaimAttestation?: boolean;
|
|
65
|
+
}
|
|
54
66
|
/** A witness's attestation kind. Required: every client sets it explicitly. */
|
|
55
67
|
export declare function claimAttestationType(att: AttestorClaimAttestation): ClaimAttestationType;
|
|
56
68
|
/** What `runAttestorTeeVerification` actually checked, per witness. */
|
|
@@ -58,8 +70,9 @@ export interface AttestorTeeVerificationSummary {
|
|
|
58
70
|
/** Witnesses whose `attestation_report` was verified (and pinned, when
|
|
59
71
|
* `expectedImageDigests` was set). */
|
|
60
72
|
verified: number;
|
|
61
|
-
/** Witnesses that carried no `attestation_report
|
|
62
|
-
*
|
|
73
|
+
/** Witnesses that carried no `attestation_report`, or no
|
|
74
|
+
* `claimAttestation` when it isn't required. Their attestor address and
|
|
75
|
+
* claim signature were still verified. */
|
|
63
76
|
unattested: number;
|
|
64
77
|
}
|
|
65
78
|
/**
|
|
@@ -67,7 +80,8 @@ export interface AttestorTeeVerificationSummary {
|
|
|
67
80
|
* provided proof. Throws `AttestorTeeVerificationError` on the first
|
|
68
81
|
* failure.
|
|
69
82
|
*
|
|
70
|
-
* Each witness must carry a `claimAttestation
|
|
83
|
+
* Each witness must carry a `claimAttestation` unless
|
|
84
|
+
* `options.requireClaimAttestation` is `false`. For each one, this:
|
|
71
85
|
* 1. Asserts `attestor_address` matches `witness.id`.
|
|
72
86
|
* 2. Asserts `claim_signature` is present in `proof.signatures`.
|
|
73
87
|
* 3. Recovers the signer of `claim_signature` from the claim data and
|
|
@@ -89,5 +103,6 @@ export interface AttestorTeeVerificationSummary {
|
|
|
89
103
|
*
|
|
90
104
|
* @param proofs - The proofs to verify.
|
|
91
105
|
* @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
|
|
106
|
+
* @param options - See {@link AttestorTeeVerificationOptions}.
|
|
92
107
|
*/
|
|
93
|
-
export declare function runAttestorTeeVerification(proofs: Proof[], config?: AttestorTeeAttestationConfig): Promise<AttestorTeeVerificationSummary>;
|
|
108
|
+
export declare function runAttestorTeeVerification(proofs: Proof[], config?: AttestorTeeAttestationConfig, { requireClaimAttestation }?: AttestorTeeVerificationOptions): Promise<AttestorTeeVerificationSummary>;
|
|
@@ -165,7 +165,7 @@ function normalizeAttestorAddress(address) {
|
|
|
165
165
|
function normalizeSignature(sig) {
|
|
166
166
|
return sig.trim().toLowerCase();
|
|
167
167
|
}
|
|
168
|
-
async function verifyAttestorTeeForProof(proof, config, summary) {
|
|
168
|
+
async function verifyAttestorTeeForProof(proof, config, requireClaimAttestation, summary) {
|
|
169
169
|
if (!proof.witnesses || proof.witnesses.length === 0) {
|
|
170
170
|
throw new AttestorTeeVerificationError('Proof has no witnesses');
|
|
171
171
|
}
|
|
@@ -174,6 +174,10 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
|
|
|
174
174
|
const claimSignData = createSignDataForClaim(proof.claimData);
|
|
175
175
|
for (const witness of proof.witnesses) {
|
|
176
176
|
const att = witness.claimAttestation;
|
|
177
|
+
if (!att && !requireClaimAttestation) {
|
|
178
|
+
summary.unattested++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
177
181
|
if (!att) {
|
|
178
182
|
throw new AttestorTeeVerificationError(`Witness ${witness.id} is missing claimAttestation`);
|
|
179
183
|
}
|
|
@@ -233,7 +237,8 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
|
|
|
233
237
|
* provided proof. Throws `AttestorTeeVerificationError` on the first
|
|
234
238
|
* failure.
|
|
235
239
|
*
|
|
236
|
-
* Each witness must carry a `claimAttestation
|
|
240
|
+
* Each witness must carry a `claimAttestation` unless
|
|
241
|
+
* `options.requireClaimAttestation` is `false`. For each one, this:
|
|
237
242
|
* 1. Asserts `attestor_address` matches `witness.id`.
|
|
238
243
|
* 2. Asserts `claim_signature` is present in `proof.signatures`.
|
|
239
244
|
* 3. Recovers the signer of `claim_signature` from the claim data and
|
|
@@ -255,8 +260,9 @@ async function verifyAttestorTeeForProof(proof, config, summary) {
|
|
|
255
260
|
*
|
|
256
261
|
* @param proofs - The proofs to verify.
|
|
257
262
|
* @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
|
|
263
|
+
* @param options - See {@link AttestorTeeVerificationOptions}.
|
|
258
264
|
*/
|
|
259
|
-
export async function runAttestorTeeVerification(proofs, config = {}) {
|
|
265
|
+
export async function runAttestorTeeVerification(proofs, config = {}, { requireClaimAttestation = true } = {}) {
|
|
260
266
|
if (!proofs || proofs.length === 0) {
|
|
261
267
|
throw new AttestorTeeVerificationError('No proofs provided for attestor TEE verification');
|
|
262
268
|
}
|
|
@@ -266,7 +272,7 @@ export async function runAttestorTeeVerification(proofs, config = {}) {
|
|
|
266
272
|
};
|
|
267
273
|
try {
|
|
268
274
|
for (const proof of proofs) {
|
|
269
|
-
await verifyAttestorTeeForProof(proof, config, summary);
|
|
275
|
+
await verifyAttestorTeeForProof(proof, config, requireClaimAttestation, summary);
|
|
270
276
|
}
|
|
271
277
|
return summary;
|
|
272
278
|
}
|
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.36",
|
|
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,
|
package/src/errors.ts
CHANGED
|
@@ -44,6 +44,58 @@ export function isProblemError(error: unknown): error is ProblemError {
|
|
|
44
44
|
&& isProblemErrorOutput(error.output)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Whether retrying the same request later can succeed: a network failure, a
|
|
49
|
+
* timeout, or an HTTP 5xx, 408, or 429 from Builder (a {@link ProblemError}
|
|
50
|
+
* or a plain error carrying the HTTP `status`). Everything else, such as a
|
|
51
|
+
* malformed response, won't change on retry.
|
|
52
|
+
*/
|
|
53
|
+
export function isTransientError(error: unknown): boolean {
|
|
54
|
+
// `fetch` rejects with a TypeError when the network fails.
|
|
55
|
+
if(error instanceof TypeError) {
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if(
|
|
60
|
+
error instanceof Error
|
|
61
|
+
&& (error.name === 'TimeoutError' || error.name === 'AbortError')
|
|
62
|
+
) {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const status = isProblemError(error)
|
|
67
|
+
? error.output.statusCode
|
|
68
|
+
: isRecord(error) ? error.status : undefined
|
|
69
|
+
return typeof status === 'number'
|
|
70
|
+
&& (status >= 500 || status === 408 || status === 429)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Errors thrown by a network step and judged transient. Tracked by identity
|
|
74
|
+
// so a content check that throws, say, a TypeError is never mistaken for one.
|
|
75
|
+
const TRANSIENT_ERRORS = new WeakSet<object>()
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Runs one network step, such as a JWKS fetch or a session or provider
|
|
79
|
+
* lookup, and marks a transient failure so {@link isTransientFailure} can
|
|
80
|
+
* tell it apart from a failed content check.
|
|
81
|
+
*/
|
|
82
|
+
export async function networkStep<T>(step: () => Promise<T> | T): Promise<T> {
|
|
83
|
+
try {
|
|
84
|
+
return await step()
|
|
85
|
+
} catch(error) {
|
|
86
|
+
if(error instanceof Error && isTransientError(error)) {
|
|
87
|
+
TRANSIENT_ERRORS.add(error)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether `error` came from a {@link networkStep} and a retry can succeed. */
|
|
95
|
+
export function isTransientFailure(error: unknown): boolean {
|
|
96
|
+
return error instanceof Error && TRANSIENT_ERRORS.has(error)
|
|
97
|
+
}
|
|
98
|
+
|
|
47
99
|
/** Returns whether a value follows the RFC 9457 problem-details schema. */
|
|
48
100
|
export function isProblemDetails(value: unknown): value is ProblemDetails {
|
|
49
101
|
if(!isRecord(value)) {
|
package/src/jwks.ts
CHANGED
|
@@ -95,7 +95,11 @@ export async function fetchReclaimJwks(
|
|
|
95
95
|
assertSecureJwksUrl(url)
|
|
96
96
|
const res = await fetchImpl(url)
|
|
97
97
|
if(!res.ok) {
|
|
98
|
-
|
|
98
|
+
// `status` lets callers tell a temporary Builder outage from a bad URL.
|
|
99
|
+
throw Object.assign(
|
|
100
|
+
new Error(`JWKS fetch failed for ${url}: HTTP ${res.status}`),
|
|
101
|
+
{ status: res.status },
|
|
102
|
+
)
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
const body = (await res.json()) as Partial<ReclaimJwks>
|
package/src/request-proof.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSignDataForClaim } from '@reclaimprotocol/attestor-core'
|
|
2
2
|
import { recoverEip191Address } from './eip191.ts'
|
|
3
|
+
import { isTransientFailure, networkStep } from './errors.ts'
|
|
3
4
|
import { fetchReclaimJwks, trustedAttestorAddresses } from './jwks.ts'
|
|
4
5
|
import { assertValidProofHashes, getHashFromProof } from './proof-hash.ts'
|
|
5
6
|
import type {
|
|
@@ -41,9 +42,15 @@ export interface VerifyRequestProofOptions {
|
|
|
41
42
|
/** Read `providerHash` from context, while still verifying signatures. */
|
|
42
43
|
hasNoPii?: true
|
|
43
44
|
teeAttestation?: TeeAttestationConfig
|
|
45
|
+
/** Require every witness to carry a `claimAttestation`, and verify any
|
|
46
|
+
* `attestation_report` against `expectedImageDigests`. */
|
|
44
47
|
attestorTeeAttestation?: AttestorTeeAttestationConfig
|
|
45
48
|
/** Explicit trusted attestor ETH addresses. */
|
|
46
49
|
attestorAddresses?: string[]
|
|
50
|
+
/** Trusted attestor image digests, like `x-reclaim-attestor-image-digests`.
|
|
51
|
+
* Pin any `attestation_report` a witness carries without requiring one,
|
|
52
|
+
* matching `@reclaimprotocol/js-sdk`'s default. */
|
|
53
|
+
attestorImageDigests?: string[]
|
|
47
54
|
/** Trusted Builder JWKS registry containing attestor-purpose keys. */
|
|
48
55
|
attestorJwksUrl?: string
|
|
49
56
|
fetchImpl?: typeof fetch
|
|
@@ -70,6 +77,10 @@ export interface VerifyRequestProofOptions {
|
|
|
70
77
|
export interface ReclaimRequestProofOutcome {
|
|
71
78
|
valid: boolean
|
|
72
79
|
reason?: string
|
|
80
|
+
/** On failure: true when a temporary condition caused it, such as a
|
|
81
|
+
* network error while resolving the session or provider. A retry can
|
|
82
|
+
* succeed. */
|
|
83
|
+
transient?: boolean
|
|
73
84
|
proofHashes?: string[]
|
|
74
85
|
isTeeAttestationVerified?: boolean
|
|
75
86
|
/** True when at least one witness's `attestation_report` verified; false
|
|
@@ -116,6 +127,8 @@ export async function verifyRequestProof(
|
|
|
116
127
|
trust.imageDigests,
|
|
117
128
|
)
|
|
118
129
|
if(attestorTeeAttestation) {
|
|
130
|
+
// Only a caller-configured check requires an envelope on every
|
|
131
|
+
// witness; registry digests alone pin the reports that exist.
|
|
119
132
|
// `verified` counts witnesses whose attestation report was checked.
|
|
120
133
|
// A proof whose witnesses carry no report still passes the address
|
|
121
134
|
// and signature bindings, but nothing was attested — say so rather
|
|
@@ -123,6 +136,7 @@ export async function verifyRequestProof(
|
|
|
123
136
|
const summary = await runAttestorTeeVerification(
|
|
124
137
|
proofs,
|
|
125
138
|
attestorTeeAttestation,
|
|
139
|
+
{ requireClaimAttestation: !!effective.attestorTeeAttestation },
|
|
126
140
|
)
|
|
127
141
|
isAttestorTeeAttestationVerified = summary.verified > 0
|
|
128
142
|
}
|
|
@@ -139,6 +153,7 @@ export async function verifyRequestProof(
|
|
|
139
153
|
return {
|
|
140
154
|
valid: false,
|
|
141
155
|
reason: failureReason(error),
|
|
156
|
+
transient: isTransientFailure(error),
|
|
142
157
|
}
|
|
143
158
|
}
|
|
144
159
|
}
|
|
@@ -148,14 +163,15 @@ async function resolveValidation(
|
|
|
148
163
|
proofs: ReclaimRequestProof[],
|
|
149
164
|
) {
|
|
150
165
|
let effective = config
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
const { sessionId, resolveSession } = config
|
|
167
|
+
if(sessionId && !hasContentValidation(config)) {
|
|
168
|
+
if(!resolveSession) {
|
|
153
169
|
throw new Error('session-resolver-required')
|
|
154
170
|
}
|
|
155
171
|
|
|
156
172
|
effective = {
|
|
157
173
|
...config,
|
|
158
|
-
...await
|
|
174
|
+
...await networkStep(() => resolveSession(sessionId, proofs)),
|
|
159
175
|
}
|
|
160
176
|
}
|
|
161
177
|
|
|
@@ -164,12 +180,12 @@ async function resolveValidation(
|
|
|
164
180
|
|
|
165
181
|
async function resolveAttestorTrust(config: VerifyRequestProofOptions) {
|
|
166
182
|
const addresses = config.attestorAddresses?.map(normalized) ?? []
|
|
167
|
-
const imageDigests
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
)
|
|
183
|
+
const imageDigests = config.attestorImageDigests?.map(normalized) ?? []
|
|
184
|
+
const { attestorJwksUrl, fetchImpl } = config
|
|
185
|
+
if(attestorJwksUrl) {
|
|
186
|
+
const registry = await networkStep(() => (
|
|
187
|
+
fetchReclaimJwks(attestorJwksUrl, fetchImpl)
|
|
188
|
+
))
|
|
173
189
|
addresses.push(
|
|
174
190
|
...(registry['x-reclaim-attestors'] ?? []),
|
|
175
191
|
...trustedAttestorAddresses(registry.keys),
|
|
@@ -258,17 +274,15 @@ async function assertContent(
|
|
|
258
274
|
return
|
|
259
275
|
}
|
|
260
276
|
|
|
261
|
-
|
|
262
|
-
|
|
277
|
+
const { providerId, providerVersion, allowedTags, resolveProvider } = config
|
|
278
|
+
if(providerId) {
|
|
279
|
+
if(!resolveProvider) {
|
|
263
280
|
throw new Error('provider-resolver-required')
|
|
264
281
|
}
|
|
265
282
|
|
|
266
|
-
const candidates = await
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
config.allowedTags,
|
|
270
|
-
proofs,
|
|
271
|
-
)
|
|
283
|
+
const candidates = await networkStep(() => (
|
|
284
|
+
resolveProvider(providerId, providerVersion, allowedTags, proofs)
|
|
285
|
+
))
|
|
272
286
|
if(!candidates.length) {
|
|
273
287
|
throw new Error('provider-not-found')
|
|
274
288
|
}
|
package/src/result.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
hexToBytes,
|
|
21
21
|
stripTrailingSlashes,
|
|
22
22
|
} from './encoding.ts'
|
|
23
|
+
import { isTransientError } from './errors.ts'
|
|
23
24
|
import {
|
|
24
25
|
fetchReclaimJwks,
|
|
25
26
|
type Jwk,
|
|
@@ -303,6 +304,9 @@ export interface ResultProofOutcome {
|
|
|
303
304
|
proof?: ReclaimRequestProof
|
|
304
305
|
proofValid: boolean
|
|
305
306
|
reason?: string
|
|
307
|
+
/** On failure: true when a temporary condition caused it, such as a
|
|
308
|
+
* network error while resolving the provider. */
|
|
309
|
+
transient?: boolean
|
|
306
310
|
/** @deprecated Use `attestorAddresses`; retained for compatibility. */
|
|
307
311
|
attestorAddress?: string
|
|
308
312
|
/** Every attestor address declared by the proof. */
|
|
@@ -330,12 +334,18 @@ export interface VerifyResultFullOutcome extends VerifyResultOutcome {
|
|
|
330
334
|
|
|
331
335
|
export interface ResultVerificationFailure extends VerifyResultFullOutcome {
|
|
332
336
|
reason: string
|
|
337
|
+
/** True when a temporary condition caused the failure. Set by the
|
|
338
|
+
* verifier; read it as {@link ResultVerificationError.retryable}. */
|
|
339
|
+
retryable?: boolean
|
|
333
340
|
}
|
|
334
341
|
|
|
335
342
|
/**
|
|
336
343
|
* Thrown by {@link verifyResult} / {@link verifyResultFull} (and therefore
|
|
337
344
|
* `reclaim.results.verify`/`receive`) when a result fails verification. Carries
|
|
338
|
-
* the full invalid outcome so callers can inspect it in a `catch
|
|
345
|
+
* the full invalid outcome so callers can inspect it in a `catch`.
|
|
346
|
+
*
|
|
347
|
+
* In a callback handler, answer 2xx when `retryable` is `false` (Builder
|
|
348
|
+
* retrying the same delivery can't help) and 5xx when it's `true`:
|
|
339
349
|
*
|
|
340
350
|
* try {
|
|
341
351
|
* const { result } = await reclaim.results.receive(body, {
|
|
@@ -346,6 +356,7 @@ export interface ResultVerificationFailure extends VerifyResultFullOutcome {
|
|
|
346
356
|
* if (err instanceof ResultVerificationError) {
|
|
347
357
|
* // for example, 'expired', { payload, … }
|
|
348
358
|
* console.error(err.reason, err.outcome)
|
|
359
|
+
* res.sendStatus(err.retryable ? 503 : 200)
|
|
349
360
|
* }
|
|
350
361
|
* }
|
|
351
362
|
*/
|
|
@@ -356,12 +367,22 @@ export class ResultVerificationError extends Error {
|
|
|
356
367
|
/** The full (invalid) outcome: `payload`, `reclaimSessionId`, and per-proof
|
|
357
368
|
* detail (`proofs`) when verification got that far. */
|
|
358
369
|
readonly outcome: ResultVerificationFailure
|
|
370
|
+
/**
|
|
371
|
+
* Whether a retry can succeed. `true` only for a temporary condition on
|
|
372
|
+
* the verifier's side: Builder's JWKS or recipe couldn't be fetched
|
|
373
|
+
* (network error, timeout, 5xx, 429). `false` when the result itself is
|
|
374
|
+
* wrong (signature, issuer, audience, session, expiry, invalid proof), so
|
|
375
|
+
* redelivering the same body fails the same way. Respond 5xx to a
|
|
376
|
+
* callback only when `true`.
|
|
377
|
+
*/
|
|
378
|
+
readonly retryable: boolean
|
|
359
379
|
|
|
360
380
|
constructor(outcome: ResultVerificationFailure) {
|
|
361
381
|
super(`result verification failed: ${outcome.reason}`)
|
|
362
382
|
this.name = 'ResultVerificationError'
|
|
363
383
|
this.reason = outcome.reason
|
|
364
384
|
this.outcome = outcome
|
|
385
|
+
this.retryable = outcome.retryable ?? false
|
|
365
386
|
}
|
|
366
387
|
}
|
|
367
388
|
|
|
@@ -446,8 +467,10 @@ export async function verifyResultFull(
|
|
|
446
467
|
jwksAttestorAddresses = registry['x-reclaim-attestors'] ?? []
|
|
447
468
|
jwksAttestorImageDigests =
|
|
448
469
|
registry['x-reclaim-attestor-image-digests'] ?? []
|
|
449
|
-
} catch{
|
|
450
|
-
return fail('jwks-fetch-failed'
|
|
470
|
+
} catch(error) {
|
|
471
|
+
return fail('jwks-fetch-failed', {
|
|
472
|
+
retryable: isTransientError(error),
|
|
473
|
+
})
|
|
451
474
|
}
|
|
452
475
|
|
|
453
476
|
const jwk = selectVerificationResultKey(
|
|
@@ -594,7 +617,12 @@ export async function verifyResultFull(
|
|
|
594
617
|
expectedImageDigests = configuredImageDigests
|
|
595
618
|
}
|
|
596
619
|
|
|
597
|
-
|
|
620
|
+
// A caller-configured check requires a `claimAttestation` on every
|
|
621
|
+
// witness. Registry digests alone only pin the attestor reports that
|
|
622
|
+
// are present, as `@reclaimprotocol/js-sdk` does by default — a portal
|
|
623
|
+
// TEE proof without a bound nonce carries no envelope.
|
|
624
|
+
const attestorTeeAttestation = configuredAttestorTee
|
|
625
|
+
&& expectedImageDigests.length
|
|
598
626
|
? { ...configuredAttestorTee, expectedImageDigests }
|
|
599
627
|
: configuredAttestorTee
|
|
600
628
|
const commonValidation = {
|
|
@@ -606,6 +634,9 @@ export async function verifyResultFull(
|
|
|
606
634
|
...(attestorTeeAttestation
|
|
607
635
|
? { attestorTeeAttestation }
|
|
608
636
|
: {}),
|
|
637
|
+
...(expectedImageDigests.length
|
|
638
|
+
? { attestorImageDigests: expectedImageDigests }
|
|
639
|
+
: {}),
|
|
609
640
|
...(expectedSessionId
|
|
610
641
|
? {
|
|
611
642
|
expectedSessionId,
|
|
@@ -622,7 +653,12 @@ export async function verifyResultFull(
|
|
|
622
653
|
}
|
|
623
654
|
|
|
624
655
|
if(!proofs.every((p) => p.proofValid)) {
|
|
625
|
-
|
|
656
|
+
// Retry only when every failure is transient; one genuinely invalid
|
|
657
|
+
// proof makes redelivery pointless.
|
|
658
|
+
return fail('invalid-proof', {
|
|
659
|
+
proofs,
|
|
660
|
+
retryable: proofs.every((p) => p.proofValid || p.transient),
|
|
661
|
+
})
|
|
626
662
|
}
|
|
627
663
|
|
|
628
664
|
return { payload, reclaimSessionId, proofs }
|
|
@@ -666,6 +702,7 @@ export async function verifyResultFull(
|
|
|
666
702
|
for(const { outcomeIndex } of group) {
|
|
667
703
|
proofs[outcomeIndex].proofValid = false
|
|
668
704
|
proofs[outcomeIndex].reason = requestProofOutcome.reason
|
|
705
|
+
proofs[outcomeIndex].transient = requestProofOutcome.transient
|
|
669
706
|
}
|
|
670
707
|
}
|
|
671
708
|
|
|
@@ -678,8 +715,11 @@ export async function verifyResultFull(
|
|
|
678
715
|
try {
|
|
679
716
|
const resolved = await opts.resolveSession(expectedSessionId)
|
|
680
717
|
recipes = resolved.recipes
|
|
681
|
-
} catch{
|
|
682
|
-
return fail('session-resolution-failed', {
|
|
718
|
+
} catch(error) {
|
|
719
|
+
return fail('session-resolution-failed', {
|
|
720
|
+
proofs,
|
|
721
|
+
retryable: isTransientError(error),
|
|
722
|
+
})
|
|
683
723
|
}
|
|
684
724
|
}
|
|
685
725
|
|
package/src/routes.gen.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
package/src/types/openapi.gen.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/openapi-typegen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -3110,6 +3110,7 @@ export interface paths {
|
|
|
3110
3110
|
/**
|
|
3111
3111
|
* Submit exact proofs as a managed Builder result
|
|
3112
3112
|
* @description Accepts exact `Proof` objects from a claimant-facing `api=2` client. Builder atomically records the first terminal outcome, checks it against the submitted status, signs the outer Builder JWS with its managed key, optionally encrypts it for matching callback subscriptions, and enqueues delivery. Retrying the same terminal outcome is idempotent. This is the only result-submission endpoint; Verification Clients never receive signing or encryption keys.
|
|
3113
|
+
* Before it accepts a `success` result, Builder verifies every proof with the same trust a default `@reclaimprotocol/client` consumer uses: Builder's attestor addresses and image digests, the session recipe's provider hashes, and the session binding. If a proof fails, Builder records the session as a terminal `error` with a `problem` whose `reasonCode` is `invalid-proof`, enqueues the `verification_error` delivery, and returns `422`. Don't retry a `422`; the session is already terminal.
|
|
3113
3114
|
*/
|
|
3114
3115
|
post: operations["SubmitVerificationClientResult"];
|
|
3115
3116
|
delete?: never;
|
|
@@ -6386,7 +6387,18 @@ export interface components {
|
|
|
6386
6387
|
/** @enum {string} */
|
|
6387
6388
|
status: "success" | "rejected" | "error" | "cancelled";
|
|
6388
6389
|
results: components["schemas"]["VerificationClientProviderResult"][];
|
|
6389
|
-
problem?:
|
|
6390
|
+
problem?: ({
|
|
6391
|
+
/** @description Stable machine-readable failure code. Builder records `invalid-proof` when a submitted proof fails its verification. */
|
|
6392
|
+
reasonCode?: string;
|
|
6393
|
+
retryable?: boolean;
|
|
6394
|
+
/** @description Builder-recorded proof failures, one per provider. Present with `reasonCode: invalid-proof`. */
|
|
6395
|
+
proofs?: {
|
|
6396
|
+
providerId?: string;
|
|
6397
|
+
resolvedVersion?: string;
|
|
6398
|
+
requestIds?: string[];
|
|
6399
|
+
reason?: string;
|
|
6400
|
+
}[];
|
|
6401
|
+
} & components["schemas"]["Problem"]) | null;
|
|
6390
6402
|
};
|
|
6391
6403
|
VerificationClientProviderResult: {
|
|
6392
6404
|
providerId: components["schemas"]["ProviderID"];
|
|
@@ -11569,6 +11581,15 @@ export interface operations {
|
|
|
11569
11581
|
};
|
|
11570
11582
|
400: components["responses"]["ProblemResponse"];
|
|
11571
11583
|
409: components["responses"]["ProblemResponse"];
|
|
11584
|
+
/** @description A submitted proof failed Builder verification. Builder recorded the session as a terminal `error` and enqueued `verification_error`. */
|
|
11585
|
+
422: {
|
|
11586
|
+
headers: {
|
|
11587
|
+
[name: string]: unknown;
|
|
11588
|
+
};
|
|
11589
|
+
content: {
|
|
11590
|
+
"application/problem+json": components["schemas"]["Problem"];
|
|
11591
|
+
};
|
|
11592
|
+
};
|
|
11572
11593
|
default: components["responses"]["ProblemResponse"];
|
|
11573
11594
|
};
|
|
11574
11595
|
};
|
|
@@ -239,6 +239,19 @@ export type AttestorTeeAttestationConfig = {
|
|
|
239
239
|
expectedImageDigests?: string[]
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** How strictly `runAttestorTeeVerification` treats witnesses without a
|
|
243
|
+
* `claimAttestation`. */
|
|
244
|
+
export interface AttestorTeeVerificationOptions {
|
|
245
|
+
/**
|
|
246
|
+
* Default `true`: every witness must carry a `claimAttestation`. `false`
|
|
247
|
+
* counts a witness without one as unattested, which is how
|
|
248
|
+
* `@reclaimprotocol/js-sdk` verifies by default — its claim signature is
|
|
249
|
+
* still checked against the trusted attestors by the caller. Portal TEE
|
|
250
|
+
* proofs without a bound nonce have no envelope.
|
|
251
|
+
*/
|
|
252
|
+
requireClaimAttestation?: boolean
|
|
253
|
+
}
|
|
254
|
+
|
|
242
255
|
/** A witness's attestation kind. Required: every client sets it explicitly. */
|
|
243
256
|
export function claimAttestationType(
|
|
244
257
|
att: AttestorClaimAttestation,
|
|
@@ -251,8 +264,9 @@ export interface AttestorTeeVerificationSummary {
|
|
|
251
264
|
/** Witnesses whose `attestation_report` was verified (and pinned, when
|
|
252
265
|
* `expectedImageDigests` was set). */
|
|
253
266
|
verified: number
|
|
254
|
-
/** Witnesses that carried no `attestation_report
|
|
255
|
-
*
|
|
267
|
+
/** Witnesses that carried no `attestation_report`, or no
|
|
268
|
+
* `claimAttestation` when it isn't required. Their attestor address and
|
|
269
|
+
* claim signature were still verified. */
|
|
256
270
|
unattested: number
|
|
257
271
|
}
|
|
258
272
|
|
|
@@ -267,6 +281,7 @@ function normalizeSignature(sig: string): string {
|
|
|
267
281
|
async function verifyAttestorTeeForProof(
|
|
268
282
|
proof: Proof,
|
|
269
283
|
config: AttestorTeeAttestationConfig,
|
|
284
|
+
requireClaimAttestation: boolean,
|
|
270
285
|
summary: AttestorTeeVerificationSummary,
|
|
271
286
|
): Promise<void> {
|
|
272
287
|
if(!proof.witnesses || proof.witnesses.length === 0) {
|
|
@@ -282,6 +297,11 @@ async function verifyAttestorTeeForProof(
|
|
|
282
297
|
|
|
283
298
|
for(const witness of proof.witnesses) {
|
|
284
299
|
const att: AttestorClaimAttestation | undefined = witness.claimAttestation
|
|
300
|
+
if(!att && !requireClaimAttestation) {
|
|
301
|
+
summary.unattested++
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
285
305
|
if(!att) {
|
|
286
306
|
throw new AttestorTeeVerificationError(
|
|
287
307
|
`Witness ${witness.id} is missing claimAttestation`
|
|
@@ -368,7 +388,8 @@ async function verifyAttestorTeeForProof(
|
|
|
368
388
|
* provided proof. Throws `AttestorTeeVerificationError` on the first
|
|
369
389
|
* failure.
|
|
370
390
|
*
|
|
371
|
-
* Each witness must carry a `claimAttestation
|
|
391
|
+
* Each witness must carry a `claimAttestation` unless
|
|
392
|
+
* `options.requireClaimAttestation` is `false`. For each one, this:
|
|
372
393
|
* 1. Asserts `attestor_address` matches `witness.id`.
|
|
373
394
|
* 2. Asserts `claim_signature` is present in `proof.signatures`.
|
|
374
395
|
* 3. Recovers the signer of `claim_signature` from the claim data and
|
|
@@ -390,10 +411,12 @@ async function verifyAttestorTeeForProof(
|
|
|
390
411
|
*
|
|
391
412
|
* @param proofs - The proofs to verify.
|
|
392
413
|
* @param config - Optional config; see {@link AttestorTeeAttestationConfig}.
|
|
414
|
+
* @param options - See {@link AttestorTeeVerificationOptions}.
|
|
393
415
|
*/
|
|
394
416
|
export async function runAttestorTeeVerification(
|
|
395
417
|
proofs: Proof[],
|
|
396
|
-
config: AttestorTeeAttestationConfig = {}
|
|
418
|
+
config: AttestorTeeAttestationConfig = {},
|
|
419
|
+
{ requireClaimAttestation = true }: AttestorTeeVerificationOptions = {},
|
|
397
420
|
): Promise<AttestorTeeVerificationSummary> {
|
|
398
421
|
if(!proofs || proofs.length === 0) {
|
|
399
422
|
throw new AttestorTeeVerificationError('No proofs provided for attestor TEE verification')
|
|
@@ -406,7 +429,12 @@ export async function runAttestorTeeVerification(
|
|
|
406
429
|
|
|
407
430
|
try {
|
|
408
431
|
for(const proof of proofs) {
|
|
409
|
-
await verifyAttestorTeeForProof(
|
|
432
|
+
await verifyAttestorTeeForProof(
|
|
433
|
+
proof,
|
|
434
|
+
config,
|
|
435
|
+
requireClaimAttestation,
|
|
436
|
+
summary,
|
|
437
|
+
)
|
|
410
438
|
}
|
|
411
439
|
|
|
412
440
|
return summary
|