@reclaimprotocol/client 0.1.0-dev.1 → 0.1.0-dev.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +256 -0
- package/lib/attestor-proof.d.ts +16 -0
- package/lib/attestor-proof.js +121 -0
- package/lib/canonical.d.ts +1 -0
- package/lib/canonical.js +18 -0
- package/lib/eip712.d.ts +20 -0
- package/lib/eip712.js +108 -0
- package/lib/enckeys.d.ts +33 -0
- package/lib/enckeys.js +14 -0
- package/lib/encoding.d.ts +11 -0
- package/lib/encoding.js +34 -0
- package/lib/index.d.ts +11 -3
- package/lib/index.js +10 -2
- package/lib/jwks.d.ts +26 -0
- package/lib/jwks.js +63 -0
- package/lib/keypair.d.ts +12 -0
- package/lib/keypair.js +18 -17
- package/lib/reclaim.d.ts +128 -0
- package/lib/reclaim.js +155 -0
- package/lib/result.d.ts +132 -0
- package/lib/result.js +298 -0
- package/lib/routes.gen.d.ts +121 -21
- package/lib/routes.gen.js +32 -7
- package/lib/secp.d.ts +6 -0
- package/lib/secp.js +21 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -0
- package/lib/types/openapi.d.ts +18 -1
- package/lib/types/openapi.gen.d.ts +2657 -1148
- package/package.json +14 -5
- package/src/attestor-proof.ts +148 -0
- package/src/canonical.ts +20 -0
- package/src/enckeys.ts +46 -0
- package/src/encoding.ts +39 -0
- package/src/index.ts +60 -2
- package/src/jwks.ts +86 -0
- package/src/keypair.ts +22 -20
- package/src/reclaim.ts +332 -0
- package/src/result.ts +448 -0
- package/src/routes.gen.ts +32 -7
- package/src/secp.ts +24 -0
- package/src/types/index.ts +1 -1
- package/src/types/openapi.gen.ts +2657 -1148
- package/src/types/openapi.ts +48 -18
package/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.
|
|
4
|
+
"version": "0.1.0-dev.2",
|
|
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
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -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
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -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 {
|
|
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
|
|
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:
|
|
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:
|
|
44
|
+
address: addressFromPubBytes(pub),
|
|
39
45
|
privateKey: '0x' + hex,
|
|
46
|
+
publicKey: '0x' + bytesToHex(pub),
|
|
40
47
|
}
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|