@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 +256 -0
- package/lib/attestor-proof.d.ts +16 -0
- package/lib/attestor-proof.js +121 -0
- package/lib/canonical.d.ts +1 -0
- package/lib/canonical.js +18 -0
- package/lib/eip712.d.ts +20 -0
- package/lib/eip712.js +108 -0
- package/lib/enckeys.d.ts +33 -0
- package/lib/enckeys.js +14 -0
- package/lib/encoding.d.ts +11 -0
- package/lib/encoding.js +34 -0
- package/lib/index.d.ts +11 -3
- package/lib/index.js +10 -2
- package/lib/jwks.d.ts +26 -0
- package/lib/jwks.js +63 -0
- package/lib/keypair.d.ts +12 -0
- package/lib/keypair.js +18 -17
- package/lib/reclaim.d.ts +128 -0
- package/lib/reclaim.js +155 -0
- package/lib/result.d.ts +132 -0
- package/lib/result.js +298 -0
- package/lib/routes.gen.d.ts +121 -21
- package/lib/routes.gen.js +32 -7
- package/lib/secp.d.ts +6 -0
- package/lib/secp.js +21 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -0
- package/lib/types/openapi.d.ts +18 -1
- package/lib/types/openapi.gen.d.ts +2657 -1148
- package/package.json +14 -5
- package/src/attestor-proof.ts +148 -0
- package/src/canonical.ts +20 -0
- package/src/enckeys.ts +46 -0
- package/src/encoding.ts +39 -0
- package/src/index.ts +60 -2
- package/src/jwks.ts +86 -0
- package/src/keypair.ts +22 -20
- package/src/reclaim.ts +332 -0
- package/src/result.ts +448 -0
- package/src/routes.gen.ts +32 -7
- package/src/secp.ts +24 -0
- package/src/types/index.ts +1 -1
- package/src/types/openapi.gen.ts +2657 -1148
- package/src/types/openapi.ts +48 -18
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;
|
package/lib/canonical.js
ADDED
|
@@ -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
|
+
}
|
package/lib/eip712.d.ts
ADDED
|
@@ -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
|
+
}
|
package/lib/enckeys.d.ts
ADDED
|
@@ -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;
|
package/lib/encoding.js
ADDED
|
@@ -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 {
|
|
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
|
|
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';
|