@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 +37 -35
- package/lib/enckeys.d.ts +38 -31
- package/lib/enckeys.js +88 -13
- package/lib/encoding.d.ts +5 -0
- package/lib/encoding.js +19 -3
- package/lib/index.d.ts +3 -3
- package/lib/index.js +2 -2
- package/lib/keypair.d.ts +3 -2
- package/lib/keypair.js +15 -1
- package/lib/reclaim.d.ts +35 -32
- package/lib/reclaim.js +19 -15
- package/lib/result.d.ts +20 -16
- package/lib/result.js +68 -54
- package/lib/routes.gen.d.ts +60 -12
- package/lib/routes.gen.js +16 -4
- package/lib/secp.d.ts +1 -2
- package/lib/secp.js +1 -1
- package/lib/types/openapi.d.ts +2 -2
- package/lib/types/openapi.gen.d.ts +916 -129
- package/package.json +7 -2
- package/src/enckeys.ts +114 -35
- package/src/encoding.ts +21 -3
- package/src/index.ts +6 -3
- package/src/keypair.ts +20 -3
- package/src/reclaim.ts +54 -51
- package/src/result.ts +86 -65
- package/src/routes.gen.ts +16 -4
- package/src/secp.ts +1 -1
- package/src/types/openapi.gen.ts +916 -129
- package/src/types/openapi.ts +2 -2
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** —
|
|
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.
|
|
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 })
|
|
18
|
-
reclaim.
|
|
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,
|
|
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
|
-
```
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
68
|
-
await reclaim.
|
|
69
|
-
|
|
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:
|
|
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
|
|
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.
|
|
118
|
-
- **The dashboard** — the org's web console has a callback-subscription form and a "Set encryption
|
|
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`, `
|
|
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 `
|
|
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.
|
|
161
|
+
### `reclaim.keypair` (the org's single eth encryption key)
|
|
162
162
|
|
|
163
163
|
```ts
|
|
164
|
-
await reclaim.
|
|
165
|
-
await reclaim.
|
|
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
|
-
`
|
|
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 (
|
|
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
|
|
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
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
//
|
|
221
|
-
|
|
222
|
-
decryptCallback(decryptionKey, body): Promise<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).
|
|
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
|
|
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
|
-
*
|
|
3
|
-
* `
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
export
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
.
|
|
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,
|
|
11
|
-
export { type DecryptionKey, type DecryptionKeyInput, loadDecryptionKey,
|
|
12
|
-
export { createReclaim, type CreateSessionInput, type Reclaim, type ReclaimConfig, type ReceiveOptions, type ReceivedDelivery, type RegisterCallbackInput, type
|
|
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,
|
|
9
|
-
export { loadDecryptionKey,
|
|
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
|
|
6
|
-
* `x`/`y`)
|
|
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 `
|
|
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.
|
|
13
|
-
* const session = await reclaim.sessions.create({
|
|
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:
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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
|
|
86
|
-
* when
|
|
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
|
-
|
|
93
|
-
/** Set/
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
102
|
-
* Pass the
|
|
103
|
-
*
|
|
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. */
|