@reclaimprotocol/client 0.1.0 → 0.1.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 CHANGED
@@ -4,18 +4,19 @@ Typed SDK for the Reclaim Builder API. One package gives you:
4
4
 
5
5
  - **`createReclaim(...)`** — a small, declarative client for the consumer happy path: create verification sessions, register callbacks, and receive + verify results.
6
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.
7
+ - **Crypto helpers** — ECIES decryption with the org's eth key, result-JWS signing/verification, JWKS discovery, and nested attestor-proof verification.
8
8
 
9
9
  Works in Node, Deno, browsers, and edge runtimes (it uses WebCrypto + `@noble`, no native deps).
10
10
 
11
11
  ## The model in one paragraph
12
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.
13
+ Authentication is a single **per-org secret** (`rorg_…`, the `orgSecret`) — a bearer credential; no signatures, no per-call secrets. The org is identified by an **Ethereum (secp256k1) public key**, set as its keypair. When that keypair's `canEncryptResult` is enabled, verification **results are encrypted with ECIES** (secp256k1 + AES-256-GCM) to the org's eth public key, and you keep the matching **eth private key** (`0x`-hex — the form any wallet/ethers emits) to decrypt. Ethereum also signs the result JWS with `ES256K`, which you verify against the Builder's JWKS.
14
14
 
15
15
  ```
16
16
  ┌─ one-time setup ─────────────────────────┐
17
- reclaim.callbacks.register({ callbackUrl }) ← where results go
18
- reclaim.credential.set(orgId, { publicKeyPem }) ← optional: encrypt to your key
17
+ reclaim.callbacks.register({ callbackUrl }) ← where results go
18
+ reclaim.keypair.set(orgId, { publicKey, ← identity + (optional)
19
+ canEncryptResult: true }) encrypt to your key
19
20
  └──────────────────────────────────────────┘
20
21
  ┌─ per verification ───────────────────────┐
21
22
  reclaim.sessions.create(...) → open session.verificationUrl
@@ -36,7 +37,6 @@ Configure once with your org secret. `orgId` below is your organization's id (th
36
37
 
37
38
  ```ts
38
39
  import { createReclaim } from '@reclaimprotocol/client'
39
- import { readFileSync } from 'node:fs'
40
40
 
41
41
  const reclaim = createReclaim({
42
42
  orgSecret: process.env.RECLAIM_ORG_SECRET, // rorg_… (issued by an org owner)
@@ -54,19 +54,19 @@ await reclaim.callbacks.register({ callbackUrl: 'https://my.app/reclaim/callback
54
54
 
55
55
  ### 2. Enable encryption (optional, one-time)
56
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:
57
+ By default results are delivered as plaintext signed JWS (still verified). To have the Builder **encrypt** results so only you can read them, register your org's eth **public** key with `canEncryptResult: true`. Use any eth key (the `0x`-hex form wallets/ethers emit), or mint one with the SDK:
58
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
59
+ ```ts
60
+ import { generateEthKeypair } from '@reclaimprotocol/client'
61
+ // keep privateKeyHex secret — it decrypts results
62
+ const { privateKeyHex, publicKeyHex } = generateEthKeypair()
64
63
  ```
65
64
 
66
65
  ```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'),
66
+ // (also doable from the dashboard, which can mint the keypair in-browser)
67
+ await reclaim.keypair.set(orgId, {
68
+ publicKey: publicKeyHex, // 0x04+128 hex (or pass publicKeyJwk)
69
+ canEncryptResult: true,
70
70
  })
71
71
  ```
72
72
 
@@ -100,7 +100,7 @@ When the end-user finishes, the Builder POSTs to your `callbackUrl`. Pass the re
100
100
  ```ts
101
101
  // in your callback HTTP handler:
102
102
  const outcome = await reclaim.results.receive(requestBody, {
103
- credential: readFileSync('reclaim-x25519.pem', 'utf8'), // private PEM; omit for plaintext
103
+ credential: process.env.RECLAIM_ETH_PRIVATE_KEY, // 0x-hex eth key; omit for plaintext
104
104
  expectedReclaimSessionId: session.id,
105
105
  })
106
106
  if (outcome.kind === 'result') {
@@ -112,13 +112,13 @@ if (outcome.kind === 'result') {
112
112
 
113
113
  ### Three ways to set up
114
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:
115
+ The one-time setup (registering the callback subscription + the encryption keypair) can be done however suits you — they all hit the same API:
116
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).
117
+ - **This SDK** — `reclaim.callbacks.register(...)` + `reclaim.keypair.set(...)`, as above (good for scripting/CI).
118
+ - **The dashboard** — the org's web console has a callback-subscription form and a "Set encryption keypair" dialog that can **generate the eth keypair in your browser** and download the private key for you.
119
119
  - **The agent** (`@reclaimprotocol/agent` — the `reclaim` CLI / MCP server) — handy while building and testing a provider locally.
120
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.
121
+ Whichever you use, at runtime your backend needs only the org secret (to authenticate) and the eth private key (to decrypt) — the `results.receive(...)` step is unchanged.
122
122
 
123
123
  ---
124
124
 
@@ -126,7 +126,7 @@ Whichever you use, at runtime your backend needs only the org secret (to authent
126
126
 
127
127
  ### `createReclaim(config)`
128
128
 
129
- Returns a `Reclaim` with four namespaces (`sessions`, `callbacks`, `credential`, `results`) plus an `api` escape hatch.
129
+ Returns a `Reclaim` with four namespaces (`sessions`, `callbacks`, `keypair`, `results`) plus an `api` escape hatch.
130
130
 
131
131
  | `ReclaimConfig` field | type | required | notes |
132
132
  |---|---|---|---|
@@ -135,7 +135,7 @@ Returns a `Reclaim` with four namespaces (`sessions`, `callbacks`, `credential`,
135
135
  | `fetch` | `typeof fetch` | no | Custom fetch (tests, edge runtimes). |
136
136
  | `headers` | `Record<string,string>` | no | Extra headers merged into every request. |
137
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`.
138
+ `orgSecret` is the only config. Everything else is passed where it's used: the **`orgId`** to `keypair.set`/`get`, and the **eth private key** (`0x`-hex) to `results.receive`/`decrypt`.
139
139
 
140
140
  ### `reclaim.sessions`
141
141
 
@@ -158,20 +158,20 @@ reclaim.callbacks.remove(subscriptionId) // → void
158
158
 
159
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
160
 
161
- ### `reclaim.credential` (the org's single encryption key)
161
+ ### `reclaim.keypair` (the org's single eth encryption key)
162
162
 
163
163
  ```ts
164
- await reclaim.credential.set(orgId, { publicKeyPem, label?: 'prod' }) // → OrgCredential
165
- await reclaim.credential.get(orgId) // → OrgCredential | undefined (undefined ⇒ plaintext delivery)
164
+ await reclaim.keypair.set(orgId, { publicKey, canEncryptResult?: true, label?: 'prod' }) // → OrgKeypair
165
+ await reclaim.keypair.get(orgId) // → OrgKeypair | undefined (undefined ⇒ keyless / plaintext delivery)
166
166
  ```
167
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`.
168
+ `publicKey` is the org's eth **uncompressed public key** (`0x04` + 128 hex — the form `generateEthKeypair()`/ethers produce); alternatively pass `publicKeyJwk` (a secp256k1 JWK). `set` stores the JWK + derived eth address. There is exactly **one** keypair per org; `set` replaces it (rotation). `canEncryptResult` toggles whether results are encrypted to it (default `false` ⇒ plaintext). Keep the matching **eth private key** (`0x`-hex) and pass it as the decryption key to `results.receive`/`decrypt`.
169
169
 
170
170
  ### `reclaim.results`
171
171
 
172
172
  ```ts
173
173
  reclaim.results.receive(body, opts?) // → ReceivedDelivery ← the 90% path
174
- reclaim.results.decrypt(body, credential?) // → Jws (JWE or plaintext → signed JWS)
174
+ reclaim.results.decrypt(body, credential?) // → Jws (ECIES or plaintext → signed JWS)
175
175
  reclaim.results.verify(jws, opts?) // → VerifyResultFullOutcome
176
176
  ```
177
177
 
@@ -183,7 +183,7 @@ type ReceivedDelivery =
183
183
  | { kind: 'event'; event: VerificationEvent; eventData: Record<string, unknown> } // non-terminal: plaintext
184
184
  ```
185
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.
186
+ `opts` (`ReceiveOptions`) is `VerifyResultFullOptions` (`expectedReclaimSessionId` nonce check, `expectedAud`, attestor allowlists, `fetchImpl`, `now`) **plus** an optional `credential` — the eth decryption key as a **`0x`-hex private key string** (the key content, not a path or env name). All three methods are async; pass `credential` only when the delivery is encrypted.
187
187
 
188
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
189
 
@@ -213,13 +213,15 @@ const { data } = await reclaim.api('GetVerificationSession', { params: { session
213
213
  ## Crypto & key helpers
214
214
 
215
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)
216
+ // Eth encryption keys (consumer side)
217
+ generateEthKeypair(): { privateKeyHex, publicKeyHex, publicJwk, ethAddress } // mint an org keypair
218
+ loadDecryptionKey(hex): DecryptionKey // 0x-hex eth private key → raw scalar
219
+ uncompressedPubkeyToJwk(hex): Jwk // 0x04+128 hex → secp256k1 JWK
220
+ pubkeyJwkToEthAddress(jwk): string // secp256k1 JWK → 0x eth address
219
221
 
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
222
+ // ECIES (encryptForEthPublicKey is used by the first-party Verification Client; consumers only decrypt)
223
+ encryptForEthPublicKey(publicJwk, bytes): Promise<string> // → ECIES ciphertext (base64url)
224
+ decryptCallback(decryptionKey, body): Promise<Jws> // ECIES/plaintext → JWS
223
225
 
224
226
  // Result JWS (eth ES256K)
225
227
  signResultJws(ethPrivHex, payload, issuer): Jws // VC-side
@@ -236,7 +238,7 @@ verifyNestedProof(...): NestedProofOutcome
236
238
 
237
239
  ### Eth keypair utils (low-level)
238
240
 
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).
241
+ `generateKeypair()`, `deriveFromPrivateKey(hex)`, `addressFromPublicKey(...)`, plus the `secp` instance and `addressFromUncompressedPubkey(...)`, are exported for the Verification Client and the agent (proof-owner signing). A consumer that enables encryption holds an **eth private key** (the same key family) to decrypt — see the encryption-key helpers above. `canonicalJson` (RFC 8785) is also exported (used by content-hash binding).
240
242
 
241
243
  ## `ReclaimClient` (low-level)
242
244
 
@@ -251,6 +253,6 @@ Errors are thrown as `ProblemError` (RFC 9457). `throwProblemError`, `DEFAULT_BA
251
253
  ## Security notes
252
254
 
253
255
  - 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.
256
+ - **Encryption is ECIES over secp256k1 + AES-256-GCM** (via `eciesjs` / `@noble`) — runs in browsers, Node, Deno, and edge runtimes, and uses the same eth key family as signing.
255
257
  - **Signature verification stays secp256k1** (not routed through `jose`) so it works on WebCrypto-only runtimes that lack `ES256K`.
256
258
  - The callback delivery is **unauthenticated transport** — never trust the raw POST body; trust only the decrypted, signature-verified result that `results.receive` returns.
package/lib/enckeys.d.ts CHANGED
@@ -1,33 +1,40 @@
1
+ import { type Jwk } from './jwks.ts';
2
+ /** The Consumer's eth private key as `0x`+64 hex (a 32-byte secp256k1
3
+ * scalar). */
4
+ export type DecryptionKeyInput = string;
5
+ /** The raw 32-byte private scalar — what `eciesjs.decrypt` consumes. */
6
+ export type DecryptionKey = Uint8Array;
1
7
  /**
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).
8
+ * Validate and load an eth private key (`0x`+64 hex) into the raw scalar bytes
9
+ * `eciesjs.decrypt` needs. Pass the hex content — never a path or env-var name.
17
10
  */
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>;
11
+ export declare function loadDecryptionKey(input: DecryptionKeyInput): DecryptionKey;
12
+ /**
13
+ * Convert an uncompressed secp256k1 public key (`0x04` + 128 hex) to the public
14
+ * JWK the API stores and the Verification Client encrypts to — so callers only
15
+ * ever handle eth-native hex, never a JWK. Mirrors the `x`/`y` coordinate split
16
+ * in `jwksFromPrivateKey` (./jwks.ts).
17
+ */
18
+ export declare function uncompressedPubkeyToJwk(publicKeyHex: string): Jwk;
19
+ /** Lowercase 0x ETH address that owns a secp256k1 public JWK (the org's
20
+ * identity address). */
21
+ export declare function pubkeyJwkToEthAddress(jwk: Jwk): string;
22
+ /** A freshly generated org encryption keypair, in every form callers need:
23
+ * the eth `0x`-hex private key (kept by the Consumer to decrypt), the
24
+ * uncompressed public key hex, the public JWK (sent to the API / stored), and
25
+ * the derived eth address. */
26
+ export interface GeneratedEthKeypair {
27
+ privateKeyHex: string;
28
+ publicKeyHex: string;
29
+ publicJwk: Jwk;
30
+ ethAddress: string;
31
+ }
32
+ /** Mint a fresh secp256k1 keypair for org encryption — the in-browser dashboard
33
+ * generator, the SDK, and tests all use this so every keypair is shaped the
34
+ * same way. The private key MUST stay local; only the public JWK is sent. */
35
+ export declare function generateEthKeypair(): GeneratedEthKeypair;
36
+ /** Derive the same `GeneratedEthKeypair` shape from a private key the user
37
+ * already holds (`0x`+64 hex), so they can BYO a key instead of generating
38
+ * one. Validates the format and throws otherwise. The private key stays
39
+ * local — only the derived public key/address are ever surfaced. */
40
+ export declare function ethKeypairFromPrivateKey(input: string): GeneratedEthKeypair;
package/lib/enckeys.js CHANGED
@@ -1,14 +1,89 @@
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);
1
+ /**
2
+ * secp256k1 (Ethereum) encryption-key helpers for ECIES result delivery.
3
+ *
4
+ * The org's encryption key is its **Ethereum public key** (secp256k1). The
5
+ * Verification Client ECIES-encrypts the signed JWS to it (see ./result.ts) and
6
+ * the Consumer decrypts with the matching eth **private key**. Eth-native `0x`
7
+ * hex is used everywhere at the edges (the format MetaMask/ethers/wallets
8
+ * emit); the wire/DB store the public key as a secp256k1 JWK
9
+ * (`{kty:'EC',crv:'secp256k1',x,y}`), matching the signing JWKS shape in
10
+ * ./jwks.ts.
11
+ *
12
+ * No PEM, no DER, no WebCrypto — `@noble/secp256k1` handles hex in every
13
+ * runtime.
14
+ */
15
+ import { b64url, hexToBytes } from "./encoding.js";
16
+ import { jwkToUncompressedPublicKey } from "./jwks.js";
17
+ import { addressFromPublicKey, deriveFromPrivateKey, generateKeypair, } from "./keypair.js";
18
+ const ETH_PRIVATE_HEX = /^0x[0-9a-fA-F]{64}$/;
19
+ const UNCOMPRESSED_PUBKEY_HEX = /^0x04[0-9a-fA-F]{128}$/;
20
+ /**
21
+ * Validate and load an eth private key (`0x`+64 hex) into the raw scalar bytes
22
+ * `eciesjs.decrypt` needs. Pass the hex content — never a path or env-var name.
23
+ */
24
+ export function loadDecryptionKey(input) {
25
+ const v = input.trim().replace(/^['"]|['"]$/g, '');
26
+ const hex = v.startsWith('0x') ? v : '0x' + v;
27
+ if (!ETH_PRIVATE_HEX.test(hex)) {
28
+ throw new Error('not a valid Ethereum private key (expected a 32-byte / 0x+64-hex '
29
+ + 'scalar)');
30
+ }
31
+ return hexToBytes(hex);
32
+ }
33
+ /**
34
+ * Convert an uncompressed secp256k1 public key (`0x04` + 128 hex) to the public
35
+ * JWK the API stores and the Verification Client encrypts to — so callers only
36
+ * ever handle eth-native hex, never a JWK. Mirrors the `x`/`y` coordinate split
37
+ * in `jwksFromPrivateKey` (./jwks.ts).
38
+ */
39
+ export function uncompressedPubkeyToJwk(publicKeyHex) {
40
+ const v = publicKeyHex.trim();
41
+ const hex = v.startsWith('0x') ? v : '0x' + v;
42
+ if (!UNCOMPRESSED_PUBKEY_HEX.test(hex)) {
43
+ throw new Error('not a valid uncompressed secp256k1 public key '
44
+ + '(expected `0x04` + 128 hex)');
45
+ }
46
+ const bytes = hexToBytes(hex); // 65 bytes: 0x04 ‖ X ‖ Y
47
+ return {
48
+ kty: 'EC',
49
+ crv: 'secp256k1',
50
+ x: b64url(bytes.subarray(1, 33)),
51
+ y: b64url(bytes.subarray(33, 65)),
52
+ };
53
+ }
54
+ /** Lowercase 0x ETH address that owns a secp256k1 public JWK (the org's
55
+ * identity address). */
56
+ export function pubkeyJwkToEthAddress(jwk) {
57
+ return addressFromPublicKey(jwkToUncompressedPublicKey(jwk));
58
+ }
59
+ /** Mint a fresh secp256k1 keypair for org encryption — the in-browser dashboard
60
+ * generator, the SDK, and tests all use this so every keypair is shaped the
61
+ * same way. The private key MUST stay local; only the public JWK is sent. */
62
+ export function generateEthKeypair() {
63
+ const { privateKey, publicKey, address } = generateKeypair();
64
+ return {
65
+ privateKeyHex: privateKey,
66
+ publicKeyHex: publicKey,
67
+ publicJwk: uncompressedPubkeyToJwk(publicKey),
68
+ ethAddress: address,
69
+ };
70
+ }
71
+ /** Derive the same `GeneratedEthKeypair` shape from a private key the user
72
+ * already holds (`0x`+64 hex), so they can BYO a key instead of generating
73
+ * one. Validates the format and throws otherwise. The private key stays
74
+ * local — only the derived public key/address are ever surfaced. */
75
+ export function ethKeypairFromPrivateKey(input) {
76
+ const v = input.trim().replace(/^['"]|['"]$/g, '');
77
+ const hex = v.startsWith('0x') ? v : '0x' + v;
78
+ if (!ETH_PRIVATE_HEX.test(hex)) {
79
+ throw new Error('not a valid Ethereum private key (expected a 32-byte / 0x+64-hex '
80
+ + 'scalar)');
81
+ }
82
+ const { privateKey, publicKey, address } = deriveFromPrivateKey(hex);
83
+ return {
84
+ privateKeyHex: privateKey,
85
+ publicKeyHex: publicKey,
86
+ publicJwk: uncompressedPubkeyToJwk(publicKey),
87
+ ethAddress: address,
88
+ };
14
89
  }
package/lib/encoding.d.ts CHANGED
@@ -2,6 +2,11 @@
2
2
  * Byte / hex / base64url encoders shared across the verification crypto paths
3
3
  * (result, jwks, attestor-proof). `hexToBytes` tolerates an optional `0x`
4
4
  * prefix, matching how ETH keys/addresses are passed around.
5
+ *
6
+ * base64url uses the `atob`/`btoa` globals + `TextEncoder` rather than Node's
7
+ * `Buffer` so the same code runs in the browser (the in-page keypair
8
+ * generator) — `Buffer` is undefined there. Both globals exist in Node ≥ 16
9
+ * and every browser.
5
10
  */
6
11
  export declare function hexToBytes(hex: string): Uint8Array;
7
12
  export declare function bytesToHex(bytes: Uint8Array): string;
package/lib/encoding.js CHANGED
@@ -2,6 +2,11 @@
2
2
  * Byte / hex / base64url encoders shared across the verification crypto paths
3
3
  * (result, jwks, attestor-proof). `hexToBytes` tolerates an optional `0x`
4
4
  * prefix, matching how ETH keys/addresses are passed around.
5
+ *
6
+ * base64url uses the `atob`/`btoa` globals + `TextEncoder` rather than Node's
7
+ * `Buffer` so the same code runs in the browser (the in-page keypair
8
+ * generator) — `Buffer` is undefined there. Both globals exist in Node ≥ 16
9
+ * and every browser.
5
10
  */
6
11
  export function hexToBytes(hex) {
7
12
  const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
@@ -22,12 +27,23 @@ export function bytesToHex(bytes) {
22
27
  export function base64urlToBytes(s) {
23
28
  const b64 = s.replace(/-/g, '+').replace(/_/g, '/');
24
29
  const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : '';
25
- return Uint8Array.from(Buffer.from(b64 + pad, 'base64'));
30
+ const bin = atob(b64 + pad);
31
+ const out = new Uint8Array(bin.length);
32
+ for (let i = 0; i < bin.length; i++) {
33
+ out[i] = bin.charCodeAt(i);
34
+ }
35
+ return out;
26
36
  }
27
37
  /** Encode bytes (or a UTF-8 string) as unpadded base64url. */
28
38
  export function b64url(input) {
29
- return Buffer.from(input)
30
- .toString('base64')
39
+ const bytes = typeof input === 'string'
40
+ ? new TextEncoder().encode(input)
41
+ : input;
42
+ let bin = '';
43
+ for (const b of bytes) {
44
+ bin += String.fromCharCode(b);
45
+ }
46
+ return btoa(bin)
31
47
  .replace(/\+/g, '-')
32
48
  .replace(/\//g, '_')
33
49
  .replace(/=+$/, '');
package/lib/index.d.ts CHANGED
@@ -7,8 +7,8 @@ export { addressFromPublicKey, deriveFromPrivateKey, type EthKeypair, generateKe
7
7
  export { canonicalJson } from './canonical.ts';
8
8
  export { b64url, base64urlToBytes, bytesToHex, hexToBytes, } from './encoding.ts';
9
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';
10
+ export { type CallbackBody, decodeJwsPayload, decryptCallback, type DeliveryEnvelope, encryptForEthPublicKey, 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, generateEthKeypair, type GeneratedEthKeypair, loadDecryptionKey, pubkeyJwkToEthAddress, uncompressedPubkeyToJwk, } from './enckeys.ts';
12
+ export { createReclaim, type CreateSessionInput, type Reclaim, type ReclaimConfig, type ReceiveOptions, type ReceivedDelivery, type RegisterCallbackInput, type SetKeypairInput, TERMINAL_EVENTS, } from './reclaim.ts';
13
13
  export { fetchJwks, type Jwk, jwksFromPrivateKey, jwkToUncompressedPublicKey, selectKey, } from './jwks.ts';
14
14
  export { type NestedProofOutcome, verifyNestedProof, } from './attestor-proof.ts';
package/lib/index.js CHANGED
@@ -5,8 +5,8 @@ export { addressFromPublicKey, deriveFromPrivateKey, generateKeypair, } from "./
5
5
  export { canonicalJson } from "./canonical.js";
6
6
  export { b64url, base64urlToBytes, bytesToHex, hexToBytes, } from "./encoding.js";
7
7
  export { addressFromUncompressedPubkey, secp, } from "./secp.js";
8
- export { decodeJwsPayload, decryptCallback, encryptForPublicJwk, ResultVerificationError, signResultJws, verifyJwsSignature, verifyResult, verifyResultFull, } from "./result.js";
9
- export { loadDecryptionKey, publicPemToJwk, } from "./enckeys.js";
8
+ export { decodeJwsPayload, decryptCallback, encryptForEthPublicKey, ResultVerificationError, signResultJws, verifyJwsSignature, verifyResult, verifyResultFull, } from "./result.js";
9
+ export { generateEthKeypair, loadDecryptionKey, pubkeyJwkToEthAddress, uncompressedPubkeyToJwk, } from "./enckeys.js";
10
10
  export { createReclaim, TERMINAL_EVENTS, } from "./reclaim.js";
11
11
  export { fetchJwks, jwksFromPrivateKey, jwkToUncompressedPublicKey, selectKey, } from "./jwks.js";
12
12
  export { verifyNestedProof, } from "./attestor-proof.js";
package/lib/keypair.d.ts CHANGED
@@ -2,8 +2,9 @@ export type EthKeypair = {
2
2
  address: string;
3
3
  privateKey: string;
4
4
  /** Uncompressed secp256k1 public key (`0x04` + 128 hex). Used to derive the
5
- * ETH address and to verify ES256K JWS signatures (published via JWKS as
6
- * `x`/`y`). Result *encryption* now uses X25519 JWE, not this key. */
5
+ * ETH address, to verify ES256K JWS signatures (published via JWKS as
6
+ * `x`/`y`), and — for an org's encryption key — to ECIES-encrypt results
7
+ * (see ./enckeys.ts, ./result.ts). */
7
8
  publicKey: string;
8
9
  };
9
10
  /**
package/lib/keypair.js CHANGED
@@ -1,6 +1,20 @@
1
1
  import { keccak_256 } from '@noble/hashes/sha3.js';
2
2
  import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';
3
3
  import * as secp from '@noble/secp256k1';
4
+ /** Bytes of CSPRNG seed `@noble`'s `randomSecretKey` expects (it reduces a
5
+ * ≥48-byte seed into a valid secp256k1 scalar). */
6
+ const SECRET_KEY_SEED_LEN = 48;
7
+ /**
8
+ * Fresh CSPRNG bytes from the Web Crypto API. We read `crypto`
9
+ * (`getRandomValues`) — the WHATWG global present in browsers and Node ≥ 20
10
+ * alike — and SEED `@noble`'s key generation from it, rather than letting
11
+ * `@noble` dereference `globalThis.crypto` itself: its access is unguarded
12
+ * (`(globalThis?.crypto).getRandomValues(...)`) and throws an opaque
13
+ * TypeError wherever the global is absent.
14
+ */
15
+ function randomSeed() {
16
+ return crypto.getRandomValues(new Uint8Array(SECRET_KEY_SEED_LEN));
17
+ }
4
18
  /**
5
19
  * Generate a fresh secp256k1 key-pair and derive its lowercase
6
20
  * Ethereum address (keccak-256 of the uncompressed pubkey minus the
@@ -13,7 +27,7 @@ import * as secp from '@noble/secp256k1';
13
27
  * intermediate value used to compute the address and is not retained.
14
28
  */
15
29
  export function generateKeypair() {
16
- const priv = secp.utils.randomSecretKey();
30
+ const priv = secp.utils.randomSecretKey(randomSeed());
17
31
  const pub = secp.getPublicKey(priv, false);
18
32
  return {
19
33
  address: addressFromPubBytes(pub),
package/lib/reclaim.d.ts CHANGED
@@ -2,18 +2,18 @@
2
2
  * Declarative entry point for the consumer SDK. The ONLY config is the org
3
3
  * secret (`rorg_…`) — it authenticates every call and identifies the org
4
4
  * server-side. Everything else is passed where it's actually used: the `orgId`
5
- * to the org-scoped `credential.set`/`get`, and the X25519 decryption key to
5
+ * to the org-scoped `keypair.set`/`get`, and the eth decryption key to
6
6
  * `results.decrypt`/`receive` (only when results arrive encrypted):
7
7
  *
8
8
  * const reclaim = createReclaim({ orgSecret })
9
9
  * // one-time setup:
10
10
  * await reclaim.callbacks.register({ callbackUrl })
11
- * // enable encryption:
12
- * await reclaim.credential.set(orgId, { publicKeyJwk })
13
- * const session = await reclaim.sessions.create({ providerId, context })
11
+ * // enable encryption (eth public key, hex):
12
+ * await reclaim.keypair.set(orgId, { publicKey, canEncryptResult: true })
13
+ * const session = await reclaim.sessions.create({ providers, context })
14
14
  * // …end-user completes session.verificationUrl…
15
15
  * const outcome = await reclaim.results.receive(callbackBody, {
16
- * credential: privateJwk, // omit for plaintext
16
+ * credential: ethPrivateKeyHex, // omit for plaintext
17
17
  * expectedReclaimSessionId: session.id,
18
18
  * })
19
19
  *
@@ -29,7 +29,7 @@ type CallbackSubscription = components['schemas']['CallbackSubscription'];
29
29
  type VerificationSession = components['schemas']['VerificationSession'];
30
30
  type VerificationEvent = components['schemas']['VerificationEvent'];
31
31
  type VerificationEventRecord = components['schemas']['VerificationEventRecord'];
32
- type OrgCredential = components['schemas']['OrgCredential'];
32
+ type OrgKeypair = components['schemas']['OrgKeypair'];
33
33
  /** The three terminal events a subscription can receive. */
34
34
  export declare const TERMINAL_EVENTS: readonly ["verification_success", "verification_rejected", "verification_error"];
35
35
  export interface RegisterCallbackInput {
@@ -37,21 +37,23 @@ export interface RegisterCallbackInput {
37
37
  events: VerificationEvent[];
38
38
  }
39
39
  export interface CreateSessionInput {
40
- providerId: string;
41
- version?: string;
40
+ /** Providers to verify, in run order. The end-user proves each (one browser
41
+ * session) and one combined result is delivered; all are required. */
42
+ providers: {
43
+ providerId: string;
44
+ version?: string;
45
+ }[];
42
46
  context: Record<string, unknown>;
43
47
  /** URL with a `{sessionId}` placeholder the end-user opens. */
44
48
  verificationClientUrl: string;
45
49
  }
46
- export interface SetCredentialInput {
47
- /** The org's X25519 encryption public key as an SPKI PEM — the content of
48
- * `openssl pkey -in key.pem -pubout` (the `-----BEGIN PUBLIC KEY-----`
49
- * block). It's converted to a JWK and stored; results are JWE-encrypted to
50
- * it. Keep the matching private PEM to decrypt (pass it to
51
- * `results.receive`/`decrypt`). */
52
- publicKeyPem: string;
53
- label?: string;
54
- }
50
+ /** Input for `keypair.set`: the org's eth public key as uncompressed hex
51
+ * (`publicKey`, the form wallets/ethers emit) OR as a secp256k1 JWK
52
+ * (`publicKeyJwk`); keep the matching eth private key to decrypt results.
53
+ * `canEncryptResult` toggles ECIES encryption (default off). The two request
54
+ * shapes are the spec's `OrgKeypairRequest` union members, relaxed to optional
55
+ * so the SDK caller supplies just one key form. */
56
+ export type SetKeypairInput = Partial<components['schemas']['OrgKeypairHexRequest']> & Partial<components['schemas']['OrgKeypairJwkRequest']>;
55
57
  export interface ReclaimConfig {
56
58
  /** The org secret (`rorg_…`). Authenticates ALL API calls and identifies
57
59
  * the org server-side. The only required config. */
@@ -64,8 +66,8 @@ export interface ReclaimConfig {
64
66
  headers?: Record<string, string>;
65
67
  }
66
68
  /** Options for `results.receive` — the verification options plus an optional
67
- * X25519 decryption key (a JWK, a PKCS#8 PEM string, or `{ env }` / env-var
68
- * name). The key is needed only when the delivery is an encrypted (JWE). */
69
+ * eth decryption key (the org's eth private key as `0x`-hex). The key is
70
+ * needed only when the delivery is an ECIES ciphertext. */
69
71
  export type ReceiveOptions = VerifyResultFullOptions & {
70
72
  credential?: DecryptionKeyInput;
71
73
  };
@@ -82,25 +84,26 @@ export interface Reclaim {
82
84
  };
83
85
  callbacks: {
84
86
  /** Register a callback subscription (one-time setup). Delivery is scoped
85
- * to the token's org; results are JWE-encrypted to the org's credential
86
- * when one is set (see `credential.set`), else plaintext. `events`
87
- * defaults to all three terminal events. */
87
+ * to the token's org; results are ECIES-encrypted to the org's eth key
88
+ * when `canEncryptResult` is enabled (see `keypair.set`), else plaintext.
89
+ * `events` defaults to all three terminal events. */
88
90
  register(input: Partial<RegisterCallbackInput> & Pick<RegisterCallbackInput, 'callbackUrl'>): Promise<CallbackSubscription>;
89
91
  list(): Promise<CallbackSubscription[]>;
90
92
  remove(subscriptionId: string): Promise<void>;
91
93
  };
92
- credential: {
93
- /** Set/replace org `orgId`'s single X25519 encryption credential. Once
94
- * set, terminal results are delivered as a compact JWE encrypted to it. */
95
- set(orgId: string, input: SetCredentialInput): Promise<OrgCredential>;
96
- /** Get org `orgId`'s encryption credential, or `undefined` when none is
97
- * set (delivery is plaintext). */
98
- get(orgId: string): Promise<OrgCredential | undefined>;
94
+ keypair: {
95
+ /** Set/rotate org `orgId`'s single eth encryption keypair. When
96
+ * `canEncryptResult` is enabled, terminal results are delivered as an
97
+ * ECIES ciphertext encrypted to it. */
98
+ set(orgId: string, input: SetKeypairInput): Promise<OrgKeypair>;
99
+ /** Get org `orgId`'s encryption keypair, or `undefined` when none is set
100
+ * (the org is keyless ⇒ plaintext delivery). */
101
+ get(orgId: string): Promise<OrgKeypair | undefined>;
99
102
  };
100
103
  results: {
101
- /** Decrypt a callback body (JWE or plaintext JWS) to the signed JWS.
102
- * Pass the X25519 `credential` (JWK/PEM/env) only when the delivery is
103
- * a JWE. */
104
+ /** Decrypt a callback body (ECIES ciphertext or plaintext JWS) to the
105
+ * signed JWS. Pass the eth `credential` (`0x`-hex private key) only when
106
+ * the delivery is encrypted. */
104
107
  decrypt(body: CallbackBody, credential?: DecryptionKeyInput): Promise<Jws>;
105
108
  /** Verify a decrypted JWS: JWKS discovery, ES256K signature,
106
109
  * `reclaimSessionId`/`aud`/`exp`, nested attestor proofs. */