@reclaimprotocol/client 0.1.0-dev.1 → 0.1.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/client",
3
3
  "title": "Reclaim Client",
4
- "version": "0.1.0-dev.1",
4
+ "version": "0.1.0",
5
5
  "description": "Typed SDK client for @reclaimprotocol/app. Used by @reclaimprotocol/agent and any other consumer that needs programmatic API access without the full agent.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -10,6 +10,10 @@
10
10
  ".": {
11
11
  "types": "./lib/index.d.ts",
12
12
  "import": "./lib/index.js"
13
+ },
14
+ "./openapi": {
15
+ "types": "./lib/types/index.d.ts",
16
+ "import": "./lib/types/index.js"
13
17
  }
14
18
  },
15
19
  "files": [
@@ -28,10 +32,10 @@
28
32
  "websiteUrl": "https://docs.reclaimprotocol.org",
29
33
  "scripts": {
30
34
  "build": "tsc",
31
- "openapi:typegen": "openapi-typescript ../app/openapi.yaml --output ./src/types/openapi.gen.ts && node ../app/scripts/mcp-tools-gen.ts",
32
35
  "prepublishOnly": "npm run build",
33
36
  "dev": "tsc --watch",
34
- "test": "node --test tests/*.test.ts"
37
+ "test": "node --test tests/*.test.ts",
38
+ "check": "tsc -p tsconfig.test.json"
35
39
  },
36
40
  "keywords": [
37
41
  "sdk",
@@ -45,7 +49,12 @@
45
49
  },
46
50
  "license": "MIT",
47
51
  "dependencies": {
48
- "@hapi/boom": "^9.1.4"
52
+ "@hapi/boom": "^9.1.4",
53
+ "@noble/hashes": "^2.2.0",
54
+ "@noble/secp256k1": "^3.1.0",
55
+ "@reclaimprotocol/attestor-core": "^5.0.7",
56
+ "canonicalize": "^2.1.0",
57
+ "jose": "^5.10.0"
49
58
  },
50
59
  "devDependencies": {
51
60
  "@types/node": "^24.0.0",
@@ -53,4 +62,4 @@
53
62
  "typescript": "^5.9.0",
54
63
  "yaml": "^2.6.0"
55
64
  }
56
- }
65
+ }
@@ -0,0 +1,148 @@
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.ts'
16
+ import { secp } from './secp.ts'
17
+
18
+ const ENC = new TextEncoder()
19
+
20
+ function decodeJson(b64url: string): Record<string, unknown> {
21
+ return JSON.parse(new TextDecoder().decode(base64urlToBytes(b64url)))
22
+ }
23
+
24
+ /** The canonical string the attestor signs for a claim — delegated to
25
+ * attestor-core so the identifier hash + serialization can't drift. The
26
+ * decoded claim's fields are coerced to attestor-core's `CompleteClaimData`
27
+ * shape (context is always the JSON string the attestor signs over). */
28
+ function createSignData(claim: Record<string, unknown>): string {
29
+ const raw = claim.context
30
+ const context = typeof raw === 'string'
31
+ ? raw
32
+ : raw !== null && raw !== undefined
33
+ ? JSON.stringify(raw)
34
+ : ''
35
+ return createSignDataForClaim({
36
+ provider: String(claim.provider ?? ''),
37
+ parameters: String(claim.parameters ?? ''),
38
+ context,
39
+ owner: String(claim.owner ?? ''),
40
+ timestampS: Number(claim.timestampS ?? 0),
41
+ epoch: Number(claim.epoch ?? 0),
42
+ })
43
+ }
44
+
45
+ /** Standard EIP-191 / personal_sign digest: keccak256 of
46
+ * `0x19 ‖ "Ethereum Signed Message:\n" ‖ len ‖ data`. */
47
+ function eip191Digest(data: string): Uint8Array {
48
+ const bytes = ENC.encode(data)
49
+ const prefix = ENC.encode('\x19Ethereum Signed Message:\n')
50
+ const len = ENC.encode(String(bytes.length))
51
+ const merged = new Uint8Array(prefix.length + len.length + bytes.length)
52
+ merged.set(prefix, 0)
53
+ merged.set(len, prefix.length)
54
+ merged.set(bytes, prefix.length + len.length)
55
+ return keccak_256(merged)
56
+ }
57
+
58
+ /** Recover the lowercase ETH address from a 65-byte r‖s‖v signature. */
59
+ function recoverEthAddress(digest: Uint8Array, sig: Uint8Array): string {
60
+ const v = sig[64]
61
+ const recovery = v >= 27 ? v - 27 : v
62
+ const recovered = new Uint8Array(65)
63
+ recovered[0] = recovery
64
+ recovered.set(sig.subarray(0, 64), 1)
65
+ // digest is already the final keccak hash — don't let noble re-hash it.
66
+ const compressed = secp.recoverPublicKey(recovered, digest, {
67
+ prehash: false,
68
+ })
69
+ const uncompressed = secp.Point.fromBytes(compressed).toBytes(false)
70
+ return '0x' + bytesToHex(keccak_256(uncompressed.slice(1))).slice(-40)
71
+ }
72
+
73
+ export interface NestedProofOutcome {
74
+ valid: boolean
75
+ reason?: string
76
+ /** Recovered attestor ETH address. */
77
+ attestorAddress?: string
78
+ /** Decoded claim data. */
79
+ claim?: Record<string, unknown>
80
+ }
81
+
82
+ /**
83
+ * Verify a base64url-encoded nested attestor proof JWS: recover the attestor
84
+ * address from the claim signature and require it to match the header `kid`
85
+ * (and, if `attestorAddresses` is given, be in that allowlist).
86
+ */
87
+ export function verifyNestedProof(
88
+ proofB64: string,
89
+ opts: { attestorAddresses?: string[] } = {},
90
+ ): NestedProofOutcome {
91
+ let jws: {
92
+ payload?: string
93
+ signatures?: { protected: string, signature: string }[]
94
+ }
95
+ try {
96
+ jws = JSON.parse(new TextDecoder().decode(base64urlToBytes(proofB64)))
97
+ } catch{
98
+ return { valid: false, reason: 'proof-not-jws' }
99
+ }
100
+
101
+ const sig = jws.signatures?.[0]
102
+ if(!jws.payload || !sig) {
103
+ return { valid: false, reason: 'proof-malformed' }
104
+ }
105
+
106
+ let header: Record<string, unknown>
107
+ let claim: Record<string, unknown>
108
+ try {
109
+ header = decodeJson(sig.protected)
110
+ claim = decodeJson(jws.payload)
111
+ } catch{
112
+ return { valid: false, reason: 'proof-decode-failed' }
113
+ }
114
+
115
+ let recovered: string
116
+ try {
117
+ recovered = recoverEthAddress(
118
+ eip191Digest(createSignData(claim)),
119
+ base64urlToBytes(sig.signature),
120
+ )
121
+ } catch{
122
+ return { valid: false, reason: 'recover-failed', claim }
123
+ }
124
+
125
+ const kid = String(header.kid ?? '').toLowerCase()
126
+ if(recovered.toLowerCase() !== kid) {
127
+ return {
128
+ valid: false,
129
+ reason: 'attestor-signature-mismatch',
130
+ attestorAddress: recovered,
131
+ claim,
132
+ }
133
+ }
134
+
135
+ if(opts.attestorAddresses) {
136
+ const allow = opts.attestorAddresses.map((a) => a.toLowerCase())
137
+ if(!allow.includes(kid)) {
138
+ return {
139
+ valid: false,
140
+ reason: 'attestor-not-allowed',
141
+ attestorAddress: recovered,
142
+ claim,
143
+ }
144
+ }
145
+ }
146
+
147
+ return { valid: true, attestorAddress: recovered, claim }
148
+ }
@@ -0,0 +1,20 @@
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
+
12
+ // canonicalize ships as a CJS default export; resolve it across interop modes.
13
+ const canonicalize = ((canonicalizeMod as { default?: unknown }).default
14
+ ?? canonicalizeMod) as (input: unknown) => string | undefined
15
+
16
+ export function canonicalJson(value: unknown): string {
17
+ const out = canonicalize(value)
18
+ assert(out !== undefined, badData('value is not JSON-serializable'))
19
+ return out
20
+ }
package/src/enckeys.ts ADDED
@@ -0,0 +1,46 @@
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 { exportJWK, importPKCS8, importSPKI } from 'jose'
20
+
21
+ /** A jose-usable private key — what `importPKCS8` returns and what
22
+ * `compactDecrypt` accepts as the recipient key. */
23
+ export type DecryptionKey = Awaited<ReturnType<typeof importPKCS8>>
24
+
25
+ /** The consumer's X25519 private key as a PKCS#8 PEM string — the *content* of
26
+ * `openssl genpkey -algorithm X25519` (the `-----BEGIN PRIVATE KEY-----`
27
+ * block), not a path or env-var name. */
28
+ export type DecryptionKeyInput = string
29
+
30
+ const ALG = 'ECDH-ES'
31
+
32
+ /** Load an X25519 PKCS#8 PEM (the openssl `BEGIN PRIVATE KEY` block) as the
33
+ * `ECDH-ES` recipient key for `compactDecrypt`. Pass the PEM content. */
34
+ export async function loadDecryptionKey(
35
+ pem: DecryptionKeyInput,
36
+ ): Promise<DecryptionKey> {
37
+ return (await importPKCS8(pem.trim(), ALG))
38
+ }
39
+
40
+ /** Convert an X25519 SPKI public PEM (the openssl `BEGIN PUBLIC KEY` block) to
41
+ * the public JWK the API stores and the Verification Client encrypts to — so a
42
+ * consumer only ever handles PEM, never a JWK. */
43
+ export async function publicPemToJwk(pem: string): Promise<JWK> {
44
+ const key = await importSPKI(pem.trim(), ALG, { extractable: true })
45
+ return exportJWK(key)
46
+ }
@@ -0,0 +1,39 @@
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: string): Uint8Array {
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
+
13
+ return out
14
+ }
15
+
16
+ export function bytesToHex(bytes: Uint8Array): string {
17
+ let out = ''
18
+ for(const b of bytes) {
19
+ out += b.toString(16).padStart(2, '0')
20
+ }
21
+
22
+ return out
23
+ }
24
+
25
+ /** Decode a base64url string (with or without padding) to bytes. */
26
+ export function base64urlToBytes(s: string): Uint8Array {
27
+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
28
+ const pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''
29
+ return Uint8Array.from(Buffer.from(b64 + pad, 'base64'))
30
+ }
31
+
32
+ /** Encode bytes (or a UTF-8 string) as unpadded base64url. */
33
+ export function b64url(input: Uint8Array | string): string {
34
+ return Buffer.from(input as Uint8Array)
35
+ .toString('base64')
36
+ .replace(/\+/g, '-')
37
+ .replace(/\//g, '_')
38
+ .replace(/=+$/, '')
39
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,68 @@
1
1
  export * from './client.ts'
2
2
  export type * from './client.ts'
3
- export { ProblemError, type ProblemDetails } from './errors.ts'
3
+ export { throwProblemError } from './errors.ts'
4
+ export type { ProblemDetails, ProblemError } from './errors.ts'
4
5
  export { DEFAULT_BASE_URL, PROBLEM_CONTENT_TYPE, USER_AGENT } from './consts.ts'
5
6
  export {
7
+ addressFromPublicKey,
6
8
  deriveFromPrivateKey,
7
9
  type EthKeypair,
8
10
  generateKeypair,
9
11
  } from './keypair.ts'
10
- export * from './types/index.ts'
12
+ export { canonicalJson } from './canonical.ts'
13
+ export {
14
+ b64url,
15
+ base64urlToBytes,
16
+ bytesToHex,
17
+ hexToBytes,
18
+ } from './encoding.ts'
19
+ export {
20
+ addressFromUncompressedPubkey,
21
+ secp,
22
+ } from './secp.ts'
23
+ export {
24
+ type CallbackBody,
25
+ decodeJwsPayload,
26
+ decryptCallback,
27
+ type DeliveryEnvelope,
28
+ encryptForPublicJwk,
29
+ type Jws,
30
+ ResultVerificationError,
31
+ type ResultProofOutcome,
32
+ signResultJws,
33
+ verifyJwsSignature,
34
+ verifyResult,
35
+ verifyResultFull,
36
+ type VerifyResultFullOptions,
37
+ type VerifyResultFullOutcome,
38
+ type VerifyResultOptions,
39
+ type VerifyResultOutcome,
40
+ } from './result.ts'
41
+ export {
42
+ type DecryptionKey,
43
+ type DecryptionKeyInput,
44
+ loadDecryptionKey,
45
+ publicPemToJwk,
46
+ } from './enckeys.ts'
47
+ export {
48
+ createReclaim,
49
+ type CreateSessionInput,
50
+ type Reclaim,
51
+ type ReclaimConfig,
52
+ type ReceiveOptions,
53
+ type ReceivedDelivery,
54
+ type RegisterCallbackInput,
55
+ type SetCredentialInput,
56
+ TERMINAL_EVENTS,
57
+ } from './reclaim.ts'
58
+ export {
59
+ fetchJwks,
60
+ type Jwk,
61
+ jwksFromPrivateKey,
62
+ jwkToUncompressedPublicKey,
63
+ selectKey,
64
+ } from './jwks.ts'
65
+ export {
66
+ type NestedProofOutcome,
67
+ verifyNestedProof,
68
+ } from './attestor-proof.ts'
package/src/jwks.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * JWKS (RFC 7517) discovery for verifying result/proof signatures. Signers
3
+ * (the Verification Client, the Attestor) publish their secp256k1 public keys
4
+ * at `<iss>/.well-known/jwks.json` with `kid` = the signer's ETH address.
5
+ */
6
+ import { b64url, base64urlToBytes, bytesToHex } from './encoding.ts'
7
+ import { addressFromUncompressedPubkey, secp } from './secp.ts'
8
+
9
+ export interface Jwk {
10
+ kty: string
11
+ crv: string
12
+ x: string
13
+ y: string
14
+ kid?: string
15
+ alg?: string
16
+ use?: string
17
+ }
18
+
19
+ /**
20
+ * Build the JWK set for an ES256K signing key (the Verification Client's
21
+ * result-signing key). `kid` is the key's ETH address; `x`/`y` are the
22
+ * uncompressed public-key coordinates. Empty set when no key is configured.
23
+ */
24
+ export function jwksFromPrivateKey(privateKeyHex?: string): { keys: Jwk[] } {
25
+ if(!privateKeyHex) {
26
+ return { keys: [] }
27
+ }
28
+
29
+ const priv = privateKeyHex.replace(/^0x/, '')
30
+ const pub = secp.getPublicKey(
31
+ Uint8Array.from(Buffer.from(priv, 'hex')),
32
+ false, // uncompressed: 0x04 ‖ X ‖ Y
33
+ )
34
+ return {
35
+ keys: [
36
+ {
37
+ kty: 'EC',
38
+ crv: 'secp256k1',
39
+ alg: 'ES256K',
40
+ use: 'sig',
41
+ kid: addressFromUncompressedPubkey(pub),
42
+ x: b64url(pub.subarray(1, 33)),
43
+ y: b64url(pub.subarray(33, 65)),
44
+ },
45
+ ],
46
+ }
47
+ }
48
+
49
+ /** Fetch the JWK set published at `<iss>/.well-known/jwks.json`. */
50
+ export async function fetchJwks(
51
+ iss: string,
52
+ fetchImpl: typeof fetch = fetch,
53
+ ): Promise<Jwk[]> {
54
+ const base = iss.replace(/\/+$/, '')
55
+ const res = await fetchImpl(`${base}/.well-known/jwks.json`)
56
+ if(!res.ok) {
57
+ throw new Error(`JWKS fetch failed for ${base}: HTTP ${res.status}`)
58
+ }
59
+
60
+ const body = (await res.json()) as { keys?: Jwk[] }
61
+ return body.keys ?? []
62
+ }
63
+
64
+ /** Find the key matching `kid` (case-insensitive — kids are ETH addresses). */
65
+ export function selectKey(keys: Jwk[], kid: string): Jwk | undefined {
66
+ const want = kid.toLowerCase()
67
+ return keys.find((k) => (k.kid ?? '').toLowerCase() === want)
68
+ }
69
+
70
+ /**
71
+ * Reconstruct the uncompressed secp256k1 public key (`0x04` + X + Y) from an
72
+ * EC JWK's base64url `x`/`y` coordinates, for signature verification.
73
+ */
74
+ export function jwkToUncompressedPublicKey(jwk: Jwk): string {
75
+ const x = base64urlToBytes(jwk.x)
76
+ const y = base64urlToBytes(jwk.y)
77
+ if(x.length !== 32 || y.length !== 32) {
78
+ throw new Error('invalid EC JWK: x/y must be 32 bytes each')
79
+ }
80
+
81
+ const out = new Uint8Array(65)
82
+ out[0] = 0x04
83
+ out.set(x, 1)
84
+ out.set(y, 33)
85
+ return '0x' + bytesToHex(out)
86
+ }
package/src/keypair.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { keccak_256 } from '@noble/hashes/sha3.js'
2
+ import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
2
3
  import * as secp from '@noble/secp256k1'
3
4
 
4
5
  export type EthKeypair = {
5
6
  address: string
6
7
  privateKey: string
8
+ /** Uncompressed secp256k1 public key (`0x04` + 128 hex). Used to derive the
9
+ * ETH address and to verify ES256K JWS signatures (published via JWKS as
10
+ * `x`/`y`). Result *encryption* now uses X25519 JWE, not this key. */
11
+ publicKey: string
7
12
  }
8
13
 
9
14
  /**
@@ -21,8 +26,9 @@ export function generateKeypair(): EthKeypair {
21
26
  const priv = secp.utils.randomSecretKey()
22
27
  const pub = secp.getPublicKey(priv, false)
23
28
  return {
24
- address: '0x' + bytesToHex(keccak_256(pub.slice(1))).slice(-40),
29
+ address: addressFromPubBytes(pub),
25
30
  privateKey: '0x' + bytesToHex(priv),
31
+ publicKey: '0x' + bytesToHex(pub),
26
32
  }
27
33
  }
28
34
 
@@ -35,29 +41,25 @@ export function deriveFromPrivateKey(privateKey: string): EthKeypair {
35
41
  const priv = hexToBytes(hex)
36
42
  const pub = secp.getPublicKey(priv, false)
37
43
  return {
38
- address: '0x' + bytesToHex(keccak_256(pub.slice(1))).slice(-40),
44
+ address: addressFromPubBytes(pub),
39
45
  privateKey: '0x' + hex,
46
+ publicKey: '0x' + bytesToHex(pub),
40
47
  }
41
48
  }
42
49
 
43
- function bytesToHex(bytes: Uint8Array): string {
44
- let out = ''
45
- for(const b of bytes) {
46
- out += b.toString(16).padStart(2, '0')
47
- }
48
-
49
- return out
50
+ /**
51
+ * Derive the lowercase Ethereum address that owns an uncompressed secp256k1
52
+ * public key (`0x04` + 128 hex) — keccak-256 of the key minus the `0x04`
53
+ * prefix byte, last 20 bytes. The holder of the matching private key controls
54
+ * this address; used to scope a callback subscription to its key's owner
55
+ * without ever needing the private key.
56
+ */
57
+ export function addressFromPublicKey(publicKey: string): string {
58
+ return addressFromPubBytes(hexToBytes(publicKey.replace(/^0x/, '')))
50
59
  }
51
60
 
52
- function hexToBytes(hex: string): Uint8Array {
53
- if(hex.length % 2 !== 0) {
54
- throw new Error('private key hex must have even length')
55
- }
56
-
57
- const out = new Uint8Array(hex.length / 2)
58
- for(let i = 0; i < out.length; i++) {
59
- out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16)
60
- }
61
-
62
- return out
61
+ /** keccak-256 of the uncompressed pubkey minus its `0x04` prefix byte,
62
+ * last 20 bytes — the Ethereum address derivation, lowercase (no checksum). */
63
+ function addressFromPubBytes(pub: Uint8Array): string {
64
+ return '0x' + bytesToHex(keccak_256(pub.slice(1))).slice(-40)
63
65
  }