@reclaimprotocol/client 0.1.0-dev.1 → 0.1.0-dev.2

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 ADDED
@@ -0,0 +1,256 @@
1
+ # @reclaimprotocol/client
2
+
3
+ Typed SDK for the Reclaim Builder API. One package gives you:
4
+
5
+ - **`createReclaim(...)`** — a small, declarative client for the consumer happy path: create verification sessions, register callbacks, and receive + verify results.
6
+ - **`ReclaimClient`** — a low-level typed HTTP client (`call('OperationId', args)`) for any operation in the spec.
7
+ - **Crypto helpers** — X25519 JWE decryption (you mint the keys with openssl), result-JWS signing/verification, JWKS discovery, and nested attestor-proof verification.
8
+
9
+ Works in Node, Deno, browsers, and edge runtimes (it uses WebCrypto + `@noble`, no native deps).
10
+
11
+ ## The model in one paragraph
12
+
13
+ Authentication is a single **per-org secret** (`rorg_…`, the `orgSecret`) — a bearer credential; no signatures, no per-call secrets. Verification **results are encrypted with JWE** (`ECDH-ES + A256GCM` over **X25519**): you mint an X25519 keypair with openssl, publish the **public** SPKI PEM as the org's encryption credential, and keep the matching **private** PKCS#8 PEM to decrypt. Ethereum is used **only** to *verify* the result's signature — the Verification Client signs the result JWS with `ES256K` and you verify it against the Builder's JWKS — so you never hold an eth private key as a consumer.
14
+
15
+ ```
16
+ ┌─ one-time setup ─────────────────────────┐
17
+ reclaim.callbacks.register({ callbackUrl }) ← where results go
18
+ reclaim.credential.set(orgId, { publicKeyPem }) ← optional: encrypt to your key
19
+ └──────────────────────────────────────────┘
20
+ ┌─ per verification ───────────────────────┐
21
+ reclaim.sessions.create(...) → open session.verificationUrl
22
+ … end-user completes it …
23
+ POST → your callbackUrl → reclaim.results.receive(body) → verified payload
24
+ └──────────────────────────────────────────┘
25
+ ```
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install @reclaimprotocol/client
31
+ ```
32
+
33
+ ## Quick start (consumer)
34
+
35
+ Configure once with your org secret. `orgId` below is your organization's id (the org the secret belongs to).
36
+
37
+ ```ts
38
+ import { createReclaim } from '@reclaimprotocol/client'
39
+ import { readFileSync } from 'node:fs'
40
+
41
+ const reclaim = createReclaim({
42
+ orgSecret: process.env.RECLAIM_ORG_SECRET, // rorg_… (issued by an org owner)
43
+ })
44
+ const orgId = process.env.RECLAIM_ORG_ID // your org's id
45
+ ```
46
+
47
+ ### 1. Register a callback (one-time)
48
+
49
+ Tell the Builder where to POST verification results. This is one-time setup and can also be done from the **dashboard** or the **agent** — see [Three ways to set up](#three-ways-to-set-up).
50
+
51
+ ```ts
52
+ await reclaim.callbacks.register({ callbackUrl: 'https://my.app/reclaim/callback' })
53
+ ```
54
+
55
+ ### 2. Enable encryption (optional, one-time)
56
+
57
+ By default results are delivered as plaintext signed JWS (still verified). To have the Builder **encrypt** results so only you can read them, publish an X25519 **public** key as the org's encryption credential. Mint the keypair yourself with openssl — the SDK never generates keys:
58
+
59
+ ```bash
60
+ # private key (PKCS#8 PEM) — keep this, it decrypts results
61
+ openssl genpkey -algorithm X25519 -out reclaim-x25519.pem
62
+ # public key (SPKI PEM) — this is what you register
63
+ openssl pkey -in reclaim-x25519.pem -pubout -out reclaim-x25519.pub.pem
64
+ ```
65
+
66
+ ```ts
67
+ // (also doable from the dashboard, which can even mint the keypair for you)
68
+ await reclaim.credential.set(orgId, {
69
+ publicKeyPem: readFileSync('reclaim-x25519.pub.pem', 'utf8'),
70
+ })
71
+ ```
72
+
73
+ ### 3. Create a session (per verification)
74
+
75
+ ```ts
76
+ const session = await reclaim.sessions.create({
77
+ providerId: 'prov_…',
78
+ context: { userId: 'abc' },
79
+ })
80
+ console.log('open this:', session.verificationUrl) // hand to the end-user
81
+ ```
82
+
83
+ It's a plain authenticated POST, so you don't need the SDK — curl works too (the org secret is the only auth):
84
+
85
+ ```bash
86
+ curl -X POST "$BUILDER_URL/verifications/sessions" \
87
+ -H "Authorization: Bearer $RECLAIM_ORG_SECRET" \
88
+ -H 'Content-Type: application/json' \
89
+ -d '{
90
+ "providerId": "prov_…",
91
+ "context": { "userId": "abc" }
92
+ }'
93
+ # → 201 { "id": "…", "verificationUrl": "…", "status": "pending", "mode": "…", … }
94
+ ```
95
+
96
+ ### 4. Receive & verify the result
97
+
98
+ When the end-user finishes, the Builder POSTs to your `callbackUrl`. Pass the request body to `results.receive`. It decrypts (if encrypted) and verifies the signature + nested proofs, and **throws if the result is invalid** — so reaching the `result` branch means it's verified.
99
+
100
+ ```ts
101
+ // in your callback HTTP handler:
102
+ const outcome = await reclaim.results.receive(requestBody, {
103
+ credential: readFileSync('reclaim-x25519.pem', 'utf8'), // private PEM; omit for plaintext
104
+ expectedReclaimSessionId: session.id,
105
+ })
106
+ if (outcome.kind === 'result') {
107
+ console.log('verified!', outcome.result.payload)
108
+ } else {
109
+ console.log('progress event:', outcome.event) // non-terminal notification
110
+ }
111
+ ```
112
+
113
+ ### Three ways to set up
114
+
115
+ The one-time setup (registering the callback subscription + the encryption credential) can be done however suits you — they all hit the same API:
116
+
117
+ - **This SDK** — `reclaim.callbacks.register(...)` + `reclaim.credential.set(...)`, as above (good for scripting/CI).
118
+ - **The dashboard** — the org's web console has a callback-subscription form and a "Set encryption credential" dialog that can **generate the X25519 keypair in your browser** and download the private key for you (no openssl needed).
119
+ - **The agent** (`@reclaimprotocol/agent` — the `reclaim` CLI / MCP server) — handy while building and testing a provider locally.
120
+
121
+ Whichever you use, at runtime your backend needs only the org secret (to authenticate) and the private key (to decrypt) — the `results.receive(...)` step is unchanged.
122
+
123
+ ---
124
+
125
+ ## API reference
126
+
127
+ ### `createReclaim(config)`
128
+
129
+ Returns a `Reclaim` with four namespaces (`sessions`, `callbacks`, `credential`, `results`) plus an `api` escape hatch.
130
+
131
+ | `ReclaimConfig` field | type | required | notes |
132
+ |---|---|---|---|
133
+ | `orgSecret` | `string` | **yes** | The org secret `rorg_…`. Sent as `Authorization: Bearer`; identifies the org server-side. |
134
+ | `baseUrl` | `string` | no | Defaults to the production Builder. |
135
+ | `fetch` | `typeof fetch` | no | Custom fetch (tests, edge runtimes). |
136
+ | `headers` | `Record<string,string>` | no | Extra headers merged into every request. |
137
+
138
+ `orgSecret` is the only config. Everything else is passed where it's used: the **`orgId`** to `credential.set`/`get`, and the **X25519 private key** to `results.receive`/`decrypt`.
139
+
140
+ ### `reclaim.sessions`
141
+
142
+ ```ts
143
+ reclaim.sessions.create({ providerId, version?, context, verificationClientUrl? })
144
+ // → VerificationSession (has `id`, `verificationUrl`, `status`, `mode`, …)
145
+ // verificationClientUrl defaults to the Builder's own /v/{sessionId} page.
146
+ reclaim.sessions.get(sessionId) // → VerificationSession
147
+ reclaim.sessions.listEvents(sessionId) // → VerificationEventRecord[]
148
+ ```
149
+
150
+ ### `reclaim.callbacks`
151
+
152
+ ```ts
153
+ reclaim.callbacks.register({ callbackUrl, events? }) // events defaults to the 3 terminal events
154
+ // → CallbackSubscription. Org-scoped; no address/publicKey/secret.
155
+ reclaim.callbacks.list() // → CallbackSubscription[]
156
+ reclaim.callbacks.remove(subscriptionId) // → void
157
+ ```
158
+
159
+ `events` is any subset of `VerificationEvent`. The terminal events (`verification_success` / `verification_rejected` / `verification_error`, exported as `TERMINAL_EVENTS`) deliver the signed result; non-terminal events deliver a plaintext progress notification.
160
+
161
+ ### `reclaim.credential` (the org's single encryption key)
162
+
163
+ ```ts
164
+ await reclaim.credential.set(orgId, { publicKeyPem, label?: 'prod' }) // → OrgCredential
165
+ await reclaim.credential.get(orgId) // → OrgCredential | undefined (undefined ⇒ plaintext delivery)
166
+ ```
167
+
168
+ `publicKeyPem` is the X25519 **SPKI public PEM** (the `-----BEGIN PUBLIC KEY-----` block from `openssl pkey -pubout`) — `set` converts it to a JWK and stores it. There is exactly **one** credential per org; `set` replaces it. Keep the matching **PKCS#8 private PEM** and pass it as the decryption key to `results.receive`/`decrypt`.
169
+
170
+ ### `reclaim.results`
171
+
172
+ ```ts
173
+ reclaim.results.receive(body, opts?) // → ReceivedDelivery ← the 90% path
174
+ reclaim.results.decrypt(body, credential?) // → Jws (JWE or plaintext → signed JWS)
175
+ reclaim.results.verify(jws, opts?) // → VerifyResultFullOutcome
176
+ ```
177
+
178
+ `receive` branches on the delivery:
179
+
180
+ ```ts
181
+ type ReceivedDelivery =
182
+ | { kind: 'result'; result: VerifyResultFullOutcome } // terminal: decrypted + verified
183
+ | { kind: 'event'; event: VerificationEvent; eventData: Record<string, unknown> } // non-terminal: plaintext
184
+ ```
185
+
186
+ `opts` (`ReceiveOptions`) is `VerifyResultFullOptions` (`expectedReclaimSessionId` nonce check, `expectedAud`, attestor allowlists, `fetchImpl`, `now`) **plus** an optional `credential` — the X25519 decryption key as a **PKCS#8 PEM string** (the content of your `reclaim-x25519.pem`, not a path or env name). All three methods are async; pass `credential` only when the delivery is encrypted.
187
+
188
+ **Invalid results throw.** `receive` and `verify` (and the underlying `verifyResult` / `verifyResultFull`) never *return* a failed verdict — they throw a **`ResultVerificationError`** carrying the failure `reason` and the full `outcome` (payload, `reclaimSessionId`, per-proof detail). So the `result` branch is always verified; wrap the call to inspect failures:
189
+
190
+ ```ts
191
+ import { ResultVerificationError } from '@reclaimprotocol/client'
192
+ try {
193
+ const { result } = await reclaim.results.receive(body, { credential })
194
+ // result.payload is verified
195
+ } catch (err) {
196
+ if (err instanceof ResultVerificationError) {
197
+ console.error(err.reason, err.outcome) // e.g. 'expired' / 'bad-signature' / 'invalid-proof'
198
+ } else {
199
+ throw err
200
+ }
201
+ }
202
+ ```
203
+
204
+ A non-terminal `event` delivery is not a result and never throws — it returns `{ kind: 'event', … }`.
205
+
206
+ ### `reclaim.api` — escape hatch
207
+
208
+ ```ts
209
+ // fully typed against the OpenAPI spec — for any operation the namespaces don't wrap
210
+ const { data } = await reclaim.api('GetVerificationSession', { params: { sessionId } })
211
+ ```
212
+
213
+ ## Crypto & key helpers
214
+
215
+ ```ts
216
+ // X25519 encryption keys (consumer side) — you mint keys with openssl, not the SDK
217
+ loadDecryptionKey(pem): Promise<DecryptionKey> // PKCS#8 private PEM → recipient key
218
+ publicPemToJwk(spkiPem): Promise<JWK> // SPKI public PEM → JWK (what credential.set does for you)
219
+
220
+ // JWE (encryptForPublicJwk is used by the first-party Verification Client; consumers only decrypt)
221
+ encryptForPublicJwk(publicJwk, bytes): Promise<string> // → compact JWE
222
+ decryptCallback(decryptionKey, body): Promise<Jws> // JWE/plaintext → JWS
223
+
224
+ // Result JWS (eth ES256K)
225
+ signResultJws(ethPrivHex, payload, issuer): Jws // VC-side
226
+ verifyResult(jws, opts): VerifyResultOutcome // offline signature/nonce check (throws if invalid)
227
+ verifyResultFull(jws, opts): Promise<VerifyResultFullOutcome> // + JWKS discovery + attestor proofs (throws if invalid)
228
+ verifyJwsSignature(jws, signerPublicKey), decodeJwsPayload(jws)
229
+
230
+ // JWKS (signature-key discovery / publishing)
231
+ fetchJwks(url), jwksFromPrivateKey(ethPrivHex), selectKey(...), jwkToUncompressedPublicKey(jwk)
232
+
233
+ // Nested attestor-proof verification
234
+ verifyNestedProof(...): NestedProofOutcome
235
+ ```
236
+
237
+ ### Eth keypair utils (low-level)
238
+
239
+ `generateKeypair()`, `deriveFromPrivateKey(hex)`, `addressFromPublicKey(...)`, plus the `secp` instance and `addressFromUncompressedPubkey(...)`, are exported for the Verification Client and the agent (proof-owner signing). **Consumers don't need an eth private key** — only the X25519 decryption key above. `canonicalJson` (RFC 8785) is also exported (used by content-hash binding).
240
+
241
+ ## `ReclaimClient` (low-level)
242
+
243
+ ```ts
244
+ import { ReclaimClient } from '@reclaimprotocol/client'
245
+ const client = new ReclaimClient({ baseUrl, token, fetch, headers })
246
+ const { data, response } = await client.call('GetVerificationSession', { params: { sessionId } })
247
+ ```
248
+
249
+ Errors are thrown as `ProblemError` (RFC 9457). `throwProblemError`, `DEFAULT_BASE_URL`, `USER_AGENT`, encoding helpers (`b64url`, `hexToBytes`, …), and the generated `components` / `operations` types are also exported.
250
+
251
+ ## Security notes
252
+
253
+ - The **org secret is a bearer credential** — treat it like a password; rotate it from the dashboard (`POST /orgs/{orgId}/token`, which invalidates the prior one). The server stores only an HMAC of it.
254
+ - **Encryption is X25519 + AES-GCM** via WebCrypto — runs in browsers, Node, Deno, and edge runtimes.
255
+ - **Signature verification stays secp256k1** (not routed through `jose`) so it works on WebCrypto-only runtimes that lack `ES256K`.
256
+ - The callback delivery is **unauthenticated transport** — never trust the raw POST body; trust only the decrypted, signature-verified result that `results.receive` returns.
@@ -0,0 +1,16 @@
1
+ export interface NestedProofOutcome {
2
+ valid: boolean;
3
+ reason?: string;
4
+ /** Recovered attestor ETH address. */
5
+ attestorAddress?: string;
6
+ /** Decoded claim data. */
7
+ claim?: Record<string, unknown>;
8
+ }
9
+ /**
10
+ * Verify a base64url-encoded nested attestor proof JWS: recover the attestor
11
+ * address from the claim signature and require it to match the header `kid`
12
+ * (and, if `attestorAddresses` is given, be in that allowlist).
13
+ */
14
+ export declare function verifyNestedProof(proofB64: string, opts?: {
15
+ attestorAddresses?: string[];
16
+ }): NestedProofOutcome;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Verify a result's nested attestor proof (RFC 7515 JWS, base64url-encoded).
3
+ *
4
+ * The attestor signs the claim with a standard EIP-191 / `personal_sign`
5
+ * signature (`0x19 ‖ "Ethereum Signed Message:\n" ‖ len ‖ data`) over the
6
+ * canonical sign-data — NOT the JOSE signing input. The sign-data (and the
7
+ * claim identifier inside it) comes straight from
8
+ * `@reclaimprotocol/attestor-core`'s `createSignDataForClaim` (which calls
9
+ * `getIdentifierFromClaimInfo`), so this
10
+ * module stays in lockstep with the attestor by construction; it only adds the
11
+ * EIP-191 digest + secp256k1 address recovery and matches the JWS header `kid`.
12
+ */
13
+ import { keccak_256 } from '@noble/hashes/sha3.js';
14
+ import { createSignDataForClaim } from '@reclaimprotocol/attestor-core';
15
+ import { base64urlToBytes, bytesToHex } from "./encoding.js";
16
+ import { secp } from "./secp.js";
17
+ const ENC = new TextEncoder();
18
+ function decodeJson(b64url) {
19
+ return JSON.parse(new TextDecoder().decode(base64urlToBytes(b64url)));
20
+ }
21
+ /** The canonical string the attestor signs for a claim — delegated to
22
+ * attestor-core so the identifier hash + serialization can't drift. The
23
+ * decoded claim's fields are coerced to attestor-core's `CompleteClaimData`
24
+ * shape (context is always the JSON string the attestor signs over). */
25
+ function createSignData(claim) {
26
+ const raw = claim.context;
27
+ const context = typeof raw === 'string'
28
+ ? raw
29
+ : raw !== null && raw !== undefined
30
+ ? JSON.stringify(raw)
31
+ : '';
32
+ return createSignDataForClaim({
33
+ provider: String(claim.provider ?? ''),
34
+ parameters: String(claim.parameters ?? ''),
35
+ context,
36
+ owner: String(claim.owner ?? ''),
37
+ timestampS: Number(claim.timestampS ?? 0),
38
+ epoch: Number(claim.epoch ?? 0),
39
+ });
40
+ }
41
+ /** Standard EIP-191 / personal_sign digest: keccak256 of
42
+ * `0x19 ‖ "Ethereum Signed Message:\n" ‖ len ‖ data`. */
43
+ function eip191Digest(data) {
44
+ const bytes = ENC.encode(data);
45
+ const prefix = ENC.encode('\x19Ethereum Signed Message:\n');
46
+ const len = ENC.encode(String(bytes.length));
47
+ const merged = new Uint8Array(prefix.length + len.length + bytes.length);
48
+ merged.set(prefix, 0);
49
+ merged.set(len, prefix.length);
50
+ merged.set(bytes, prefix.length + len.length);
51
+ return keccak_256(merged);
52
+ }
53
+ /** Recover the lowercase ETH address from a 65-byte r‖s‖v signature. */
54
+ function recoverEthAddress(digest, sig) {
55
+ const v = sig[64];
56
+ const recovery = v >= 27 ? v - 27 : v;
57
+ const recovered = new Uint8Array(65);
58
+ recovered[0] = recovery;
59
+ recovered.set(sig.subarray(0, 64), 1);
60
+ // digest is already the final keccak hash — don't let noble re-hash it.
61
+ const compressed = secp.recoverPublicKey(recovered, digest, {
62
+ prehash: false,
63
+ });
64
+ const uncompressed = secp.Point.fromBytes(compressed).toBytes(false);
65
+ return '0x' + bytesToHex(keccak_256(uncompressed.slice(1))).slice(-40);
66
+ }
67
+ /**
68
+ * Verify a base64url-encoded nested attestor proof JWS: recover the attestor
69
+ * address from the claim signature and require it to match the header `kid`
70
+ * (and, if `attestorAddresses` is given, be in that allowlist).
71
+ */
72
+ export function verifyNestedProof(proofB64, opts = {}) {
73
+ let jws;
74
+ try {
75
+ jws = JSON.parse(new TextDecoder().decode(base64urlToBytes(proofB64)));
76
+ }
77
+ catch {
78
+ return { valid: false, reason: 'proof-not-jws' };
79
+ }
80
+ const sig = jws.signatures?.[0];
81
+ if (!jws.payload || !sig) {
82
+ return { valid: false, reason: 'proof-malformed' };
83
+ }
84
+ let header;
85
+ let claim;
86
+ try {
87
+ header = decodeJson(sig.protected);
88
+ claim = decodeJson(jws.payload);
89
+ }
90
+ catch {
91
+ return { valid: false, reason: 'proof-decode-failed' };
92
+ }
93
+ let recovered;
94
+ try {
95
+ recovered = recoverEthAddress(eip191Digest(createSignData(claim)), base64urlToBytes(sig.signature));
96
+ }
97
+ catch {
98
+ return { valid: false, reason: 'recover-failed', claim };
99
+ }
100
+ const kid = String(header.kid ?? '').toLowerCase();
101
+ if (recovered.toLowerCase() !== kid) {
102
+ return {
103
+ valid: false,
104
+ reason: 'attestor-signature-mismatch',
105
+ attestorAddress: recovered,
106
+ claim,
107
+ };
108
+ }
109
+ if (opts.attestorAddresses) {
110
+ const allow = opts.attestorAddresses.map((a) => a.toLowerCase());
111
+ if (!allow.includes(kid)) {
112
+ return {
113
+ valid: false,
114
+ reason: 'attestor-not-allowed',
115
+ attestorAddress: recovered,
116
+ claim,
117
+ };
118
+ }
119
+ }
120
+ return { valid: true, attestorAddress: recovered, claim };
121
+ }
@@ -0,0 +1 @@
1
+ export declare function canonicalJson(value: unknown): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * RFC 8785 (JSON Canonicalization Scheme) stringification — object keys sorted
3
+ * recursively, no whitespace, canonical number/string forms. The single source
4
+ * of truth for canonical JSON in the SDK: the attestor `contentHash` binding
5
+ * and provider `context` hashing both use it. Do not reimplement with a sorted
6
+ * `JSON.stringify`.
7
+ */
8
+ import { badData } from '@hapi/boom';
9
+ import * as canonicalizeMod from 'canonicalize';
10
+ import assert from 'node:assert';
11
+ // canonicalize ships as a CJS default export; resolve it across interop modes.
12
+ const canonicalize = (canonicalizeMod.default
13
+ ?? canonicalizeMod);
14
+ export function canonicalJson(value) {
15
+ const out = canonicalize(value);
16
+ assert(out !== undefined, badData('value is not JSON-serializable'));
17
+ return out;
18
+ }
@@ -0,0 +1,20 @@
1
+ declare const TYPES: {
2
+ readonly CreateVerificationSession: readonly [readonly ["providerId", "string"], readonly ["version", "string"], readonly ["context", "string"], readonly ["verificationClientUrl", "string"], readonly ["timestamp", "uint256"]];
3
+ readonly CreateCallbackSubscription: readonly [readonly ["callbackUrl", "string"], readonly ["events", "string"], readonly ["publicKey", "string"], readonly ["address", "string"], readonly ["timestamp", "uint256"]];
4
+ readonly VerificationAuth: readonly [readonly ["timestamp", "uint256"]];
5
+ };
6
+ export type Eip712PrimaryType = keyof typeof TYPES;
7
+ /** Deterministic JSON via RFC 8785 (JSON Canonicalization Scheme): object
8
+ * keys sorted recursively, no whitespace, canonical number/string forms.
9
+ * Both signer and verifier canonicalize `context`/`events` this way so the
10
+ * string fields hash identically. Single source of truth for canonical
11
+ * stringification in the repo — `contentHash` (result binding) uses it too;
12
+ * do not reimplement with sorted JSON.stringify. */
13
+ export declare function canonicalJson(value: unknown): string;
14
+ /** EIP-712 digest: keccak256(0x1901 ‖ domainSeparator ‖ structHash). */
15
+ export declare function eip712Digest(primaryType: Eip712PrimaryType, message: Record<string, string | bigint>): Uint8Array<ArrayBufferLike> & Uint8Array<ArrayBuffer>;
16
+ /** Recover the signer of a typed message, or null on failure. Used by the
17
+ * Builder (`packages/app`) to verify legacy typed-data signatures; the client
18
+ * itself no longer signs — all consumer auth is the org bearer token. */
19
+ export declare function recoverTypedSigner(primaryType: Eip712PrimaryType, message: Record<string, string | bigint>, signature: string): string | null;
20
+ export {};
package/lib/eip712.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * EIP-712 typed-data primitives for the legacy verification-session signature
3
+ * scheme — kept for the Builder verifier (`eip712Digest` + `recoverTypedSigner`,
4
+ * imported by `packages/app`). The consumer client no longer signs (all auth is
5
+ * the org bearer token), so there is no `buildAuthHeaders` here anymore. The
6
+ * typed data is tiny (only `string` / `uint256` / `address` fields), so it's
7
+ * hashed directly with keccak-256; recovery is standard secp256k1 over the
8
+ * digest (`prehash:false`, see ./secp.ts).
9
+ */
10
+ import { badData } from '@hapi/boom';
11
+ import * as canonicalizeMod from 'canonicalize';
12
+ import assert from 'node:assert';
13
+ import { hexToBytes } from "./encoding.js";
14
+ import { keccak_256, recoverAddress } from "./secp.js";
15
+ // canonicalize ships as a CJS default export; resolve it across interop
16
+ // modes (same dance as ajv-formats in packages/app).
17
+ const canonicalize = (canonicalizeMod.default ??
18
+ canonicalizeMod);
19
+ const DOMAIN = {
20
+ name: 'Reclaim Builder',
21
+ version: '1',
22
+ chainId: 1n,
23
+ verifyingContract: '0x0000000000000000000000000000000000000000',
24
+ };
25
+ const TYPES = {
26
+ CreateVerificationSession: [
27
+ ['providerId', 'string'],
28
+ ['version', 'string'],
29
+ ['context', 'string'],
30
+ ['verificationClientUrl', 'string'],
31
+ ['timestamp', 'uint256'],
32
+ ],
33
+ CreateCallbackSubscription: [
34
+ ['callbackUrl', 'string'],
35
+ ['events', 'string'],
36
+ ['publicKey', 'string'],
37
+ ['address', 'string'],
38
+ ['timestamp', 'uint256'],
39
+ ],
40
+ VerificationAuth: [['timestamp', 'uint256']],
41
+ };
42
+ /** Deterministic JSON via RFC 8785 (JSON Canonicalization Scheme): object
43
+ * keys sorted recursively, no whitespace, canonical number/string forms.
44
+ * Both signer and verifier canonicalize `context`/`events` this way so the
45
+ * string fields hash identically. Single source of truth for canonical
46
+ * stringification in the repo — `contentHash` (result binding) uses it too;
47
+ * do not reimplement with sorted JSON.stringify. */
48
+ export function canonicalJson(value) {
49
+ const out = canonicalize(value);
50
+ assert(out !== undefined, badData('value is not JSON-serializable'));
51
+ return out;
52
+ }
53
+ function utf8(s) {
54
+ return new TextEncoder().encode(s);
55
+ }
56
+ function uint256(value) {
57
+ const out = new Uint8Array(32);
58
+ let v = value;
59
+ for (let i = 31; i >= 0 && v > 0n; i--) {
60
+ out[i] = Number(v & 0xffn);
61
+ v >>= 8n;
62
+ }
63
+ return out;
64
+ }
65
+ function addressWord(addr) {
66
+ const out = new Uint8Array(32);
67
+ out.set(hexToBytes(addr), 12);
68
+ return out;
69
+ }
70
+ function concat(...parts) {
71
+ const total = parts.reduce((n, p) => n + p.length, 0);
72
+ const out = new Uint8Array(total);
73
+ let off = 0;
74
+ for (const p of parts) {
75
+ out.set(p, off);
76
+ off += p.length;
77
+ }
78
+ return out;
79
+ }
80
+ function typeHash(primaryType) {
81
+ const fields = TYPES[primaryType].map(([n, t]) => `${t} ${n}`).join(',');
82
+ return keccak_256(utf8(`${primaryType}(${fields})`));
83
+ }
84
+ function encodeField(type, value) {
85
+ return type === 'string'
86
+ ? keccak_256(utf8(value))
87
+ : uint256(value);
88
+ }
89
+ function structHash(primaryType, message) {
90
+ const parts = [typeHash(primaryType)];
91
+ for (const [name, type] of TYPES[primaryType]) {
92
+ parts.push(encodeField(type, message[name]));
93
+ }
94
+ return keccak_256(concat(...parts));
95
+ }
96
+ const DOMAIN_SEPARATOR = keccak_256(concat(keccak_256(utf8('EIP712Domain(string name,string version,uint256 chainId,' +
97
+ 'address verifyingContract)')), keccak_256(utf8(DOMAIN.name)), keccak_256(utf8(DOMAIN.version)), uint256(DOMAIN.chainId), addressWord(DOMAIN.verifyingContract)));
98
+ /** EIP-712 digest: keccak256(0x1901 ‖ domainSeparator ‖ structHash). */
99
+ export function eip712Digest(primaryType, message) {
100
+ return keccak_256(concat(Uint8Array.from([0x19, 0x01]), DOMAIN_SEPARATOR, structHash(primaryType, message)));
101
+ }
102
+ /** Recover the signer of a typed message, or null on failure. Used by the
103
+ * Builder (`packages/app`) to verify legacy typed-data signatures; the client
104
+ * itself no longer signs — all consumer auth is the org bearer token. */
105
+ export function recoverTypedSigner(primaryType, message, signature) {
106
+ const digest = eip712Digest(primaryType, message);
107
+ return recoverAddress(digest, hexToBytes(signature));
108
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * X25519 encryption-key helpers for JWE result delivery (RFC 7516,
3
+ * `ECDH-ES + A256GCM`), all via `jose`. Consumers mint the keypair themselves
4
+ * with openssl (we never generate keys for them):
5
+ *
6
+ * # private (PKCS#8):
7
+ * openssl genpkey -algorithm X25519 -out reclaim-x25519.pem
8
+ * # public (SPKI):
9
+ * openssl pkey -in reclaim-x25519.pem -pubout \
10
+ * -out reclaim-x25519.pub.pem
11
+ *
12
+ * Register the PUBLIC PEM as the org credential (`reclaim.credential.set` turns
13
+ * it into the JWK the API stores) and keep the PRIVATE PEM to decrypt delivered
14
+ * results (`results.receive(body, { credential: <private PEM> })`). Pass PEM
15
+ * *content* — never a file path or env var. Eth is not involved in encryption;
16
+ * it only signs/verifies the inner JWS (see ./result.ts, ./secp.ts).
17
+ */
18
+ import type { JWK } from 'jose';
19
+ import { importPKCS8 } from 'jose';
20
+ /** A jose-usable private key — what `importPKCS8` returns and what
21
+ * `compactDecrypt` accepts as the recipient key. */
22
+ export type DecryptionKey = Awaited<ReturnType<typeof importPKCS8>>;
23
+ /** The consumer's X25519 private key as a PKCS#8 PEM string — the *content* of
24
+ * `openssl genpkey -algorithm X25519` (the `-----BEGIN PRIVATE KEY-----`
25
+ * block), not a path or env-var name. */
26
+ export type DecryptionKeyInput = string;
27
+ /** Load an X25519 PKCS#8 PEM (the openssl `BEGIN PRIVATE KEY` block) as the
28
+ * `ECDH-ES` recipient key for `compactDecrypt`. Pass the PEM content. */
29
+ export declare function loadDecryptionKey(pem: DecryptionKeyInput): Promise<DecryptionKey>;
30
+ /** Convert an X25519 SPKI public PEM (the openssl `BEGIN PUBLIC KEY` block) to
31
+ * the public JWK the API stores and the Verification Client encrypts to — so a
32
+ * consumer only ever handles PEM, never a JWK. */
33
+ export declare function publicPemToJwk(pem: string): Promise<JWK>;
package/lib/enckeys.js ADDED
@@ -0,0 +1,14 @@
1
+ import { exportJWK, importPKCS8, importSPKI } from 'jose';
2
+ const ALG = 'ECDH-ES';
3
+ /** Load an X25519 PKCS#8 PEM (the openssl `BEGIN PRIVATE KEY` block) as the
4
+ * `ECDH-ES` recipient key for `compactDecrypt`. Pass the PEM content. */
5
+ export async function loadDecryptionKey(pem) {
6
+ return (await importPKCS8(pem.trim(), ALG));
7
+ }
8
+ /** Convert an X25519 SPKI public PEM (the openssl `BEGIN PUBLIC KEY` block) to
9
+ * the public JWK the API stores and the Verification Client encrypts to — so a
10
+ * consumer only ever handles PEM, never a JWK. */
11
+ export async function publicPemToJwk(pem) {
12
+ const key = await importSPKI(pem.trim(), ALG, { extractable: true });
13
+ return exportJWK(key);
14
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Byte / hex / base64url encoders shared across the verification crypto paths
3
+ * (result, jwks, attestor-proof). `hexToBytes` tolerates an optional `0x`
4
+ * prefix, matching how ETH keys/addresses are passed around.
5
+ */
6
+ export declare function hexToBytes(hex: string): Uint8Array;
7
+ export declare function bytesToHex(bytes: Uint8Array): string;
8
+ /** Decode a base64url string (with or without padding) to bytes. */
9
+ export declare function base64urlToBytes(s: string): Uint8Array;
10
+ /** Encode bytes (or a UTF-8 string) as unpadded base64url. */
11
+ export declare function b64url(input: Uint8Array | string): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Byte / hex / base64url encoders shared across the verification crypto paths
3
+ * (result, jwks, attestor-proof). `hexToBytes` tolerates an optional `0x`
4
+ * prefix, matching how ETH keys/addresses are passed around.
5
+ */
6
+ export function hexToBytes(hex) {
7
+ const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
8
+ const out = new Uint8Array(clean.length / 2);
9
+ for (let i = 0; i < out.length; i++) {
10
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
11
+ }
12
+ return out;
13
+ }
14
+ export function bytesToHex(bytes) {
15
+ let out = '';
16
+ for (const b of bytes) {
17
+ out += b.toString(16).padStart(2, '0');
18
+ }
19
+ return out;
20
+ }
21
+ /** Decode a base64url string (with or without padding) to bytes. */
22
+ export function base64urlToBytes(s) {
23
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
24
+ const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
25
+ return Uint8Array.from(Buffer.from(b64 + pad, 'base64'));
26
+ }
27
+ /** Encode bytes (or a UTF-8 string) as unpadded base64url. */
28
+ export function b64url(input) {
29
+ return Buffer.from(input)
30
+ .toString('base64')
31
+ .replace(/\+/g, '-')
32
+ .replace(/\//g, '_')
33
+ .replace(/=+$/, '');
34
+ }
package/lib/index.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  export * from './client.ts';
2
2
  export type * from './client.ts';
3
- export { ProblemError, type ProblemDetails } from './errors.ts';
3
+ export { throwProblemError } from './errors.ts';
4
+ export type { ProblemDetails, ProblemError } from './errors.ts';
4
5
  export { DEFAULT_BASE_URL, PROBLEM_CONTENT_TYPE, USER_AGENT } from './consts.ts';
5
- export { deriveFromPrivateKey, type EthKeypair, generateKeypair, } from './keypair.ts';
6
- export * from './types/index.ts';
6
+ export { addressFromPublicKey, deriveFromPrivateKey, type EthKeypair, generateKeypair, } from './keypair.ts';
7
+ export { canonicalJson } from './canonical.ts';
8
+ export { b64url, base64urlToBytes, bytesToHex, hexToBytes, } from './encoding.ts';
9
+ export { addressFromUncompressedPubkey, secp, } from './secp.ts';
10
+ export { type CallbackBody, decodeJwsPayload, decryptCallback, type DeliveryEnvelope, encryptForPublicJwk, type Jws, ResultVerificationError, type ResultProofOutcome, signResultJws, verifyJwsSignature, verifyResult, verifyResultFull, type VerifyResultFullOptions, type VerifyResultFullOutcome, type VerifyResultOptions, type VerifyResultOutcome, } from './result.ts';
11
+ export { type DecryptionKey, type DecryptionKeyInput, loadDecryptionKey, publicPemToJwk, } from './enckeys.ts';
12
+ export { createReclaim, type CreateSessionInput, type Reclaim, type ReclaimConfig, type ReceiveOptions, type ReceivedDelivery, type RegisterCallbackInput, type SetCredentialInput, TERMINAL_EVENTS, } from './reclaim.ts';
13
+ export { fetchJwks, type Jwk, jwksFromPrivateKey, jwkToUncompressedPublicKey, selectKey, } from './jwks.ts';
14
+ export { type NestedProofOutcome, verifyNestedProof, } from './attestor-proof.ts';