@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/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/src/reclaim.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative entry point for the consumer SDK. The ONLY config is the org
|
|
3
|
+
* secret (`rorg_…`) — it authenticates every call and identifies the org
|
|
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
|
|
6
|
+
* `results.decrypt`/`receive` (only when results arrive encrypted):
|
|
7
|
+
*
|
|
8
|
+
* const reclaim = createReclaim({ orgSecret })
|
|
9
|
+
* // one-time setup:
|
|
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 })
|
|
14
|
+
* // …end-user completes session.verificationUrl…
|
|
15
|
+
* const outcome = await reclaim.results.receive(callbackBody, {
|
|
16
|
+
* credential: privateJwk, // omit for plaintext
|
|
17
|
+
* expectedReclaimSessionId: session.id,
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* Built by composition over the package's pure helpers (`decryptCallback`,
|
|
21
|
+
* `verifyResultFull`, …) — drop down to those, or to `reclaim.api`, for full
|
|
22
|
+
* control.
|
|
23
|
+
*/
|
|
24
|
+
import type { components } from './types/openapi.gen.ts'
|
|
25
|
+
import { ReclaimClient } from './client.ts'
|
|
26
|
+
import {
|
|
27
|
+
type DecryptionKey,
|
|
28
|
+
type DecryptionKeyInput,
|
|
29
|
+
loadDecryptionKey,
|
|
30
|
+
publicPemToJwk,
|
|
31
|
+
} from './enckeys.ts'
|
|
32
|
+
import {
|
|
33
|
+
type CallbackBody,
|
|
34
|
+
decryptCallback,
|
|
35
|
+
type Jws,
|
|
36
|
+
verifyResultFull,
|
|
37
|
+
type VerifyResultFullOptions,
|
|
38
|
+
type VerifyResultFullOutcome,
|
|
39
|
+
} from './result.ts'
|
|
40
|
+
|
|
41
|
+
type CallbackSubscription = components['schemas']['CallbackSubscription']
|
|
42
|
+
type VerificationSession = components['schemas']['VerificationSession']
|
|
43
|
+
type VerificationEvent = components['schemas']['VerificationEvent']
|
|
44
|
+
type VerificationEventRecord = components['schemas']['VerificationEventRecord']
|
|
45
|
+
type OrgCredential = components['schemas']['OrgCredential']
|
|
46
|
+
type JwkX25519Public = components['schemas']['JwkX25519Public']
|
|
47
|
+
|
|
48
|
+
/** The three terminal events a subscription can receive. */
|
|
49
|
+
export const TERMINAL_EVENTS = [
|
|
50
|
+
'verification_success',
|
|
51
|
+
'verification_rejected',
|
|
52
|
+
'verification_error',
|
|
53
|
+
] as const
|
|
54
|
+
|
|
55
|
+
export interface RegisterCallbackInput {
|
|
56
|
+
callbackUrl: string
|
|
57
|
+
events: VerificationEvent[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CreateSessionInput {
|
|
61
|
+
providerId: string
|
|
62
|
+
version?: string
|
|
63
|
+
context: Record<string, unknown>
|
|
64
|
+
/** URL with a `{sessionId}` placeholder the end-user opens. */
|
|
65
|
+
verificationClientUrl: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface SetCredentialInput {
|
|
69
|
+
/** The org's X25519 encryption public key as an SPKI PEM — the content of
|
|
70
|
+
* `openssl pkey -in key.pem -pubout` (the `-----BEGIN PUBLIC KEY-----`
|
|
71
|
+
* block). It's converted to a JWK and stored; results are JWE-encrypted to
|
|
72
|
+
* it. Keep the matching private PEM to decrypt (pass it to
|
|
73
|
+
* `results.receive`/`decrypt`). */
|
|
74
|
+
publicKeyPem: string
|
|
75
|
+
label?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ReclaimConfig {
|
|
79
|
+
/** The org secret (`rorg_…`). Authenticates ALL API calls and identifies
|
|
80
|
+
* the org server-side. The only required config. */
|
|
81
|
+
orgSecret: string
|
|
82
|
+
/** Backend base URL. Defaults to the production Builder. */
|
|
83
|
+
baseUrl?: string
|
|
84
|
+
/** Custom fetch implementation. */
|
|
85
|
+
fetch?: typeof fetch
|
|
86
|
+
/** Extra headers merged into every request. */
|
|
87
|
+
headers?: Record<string, string>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Options for `results.receive` — the verification options plus an optional
|
|
91
|
+
* X25519 decryption key (a JWK, a PKCS#8 PEM string, or `{ env }` / env-var
|
|
92
|
+
* name). The key is needed only when the delivery is an encrypted (JWE). */
|
|
93
|
+
export type ReceiveOptions = VerifyResultFullOptions & {
|
|
94
|
+
credential?: DecryptionKeyInput
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface Reclaim {
|
|
98
|
+
/** Typed escape hatch: `reclaim.api('OperationId', args)` for any spec
|
|
99
|
+
* operation. */
|
|
100
|
+
api: ReclaimClient['call']
|
|
101
|
+
sessions: {
|
|
102
|
+
/** Create a verification session. `verificationClientUrl` defaults to
|
|
103
|
+
* the Builder's own `/v/{sessionId}` page. */
|
|
104
|
+
create(
|
|
105
|
+
input: Omit<CreateSessionInput, 'verificationClientUrl'> &
|
|
106
|
+
Partial<Pick<CreateSessionInput, 'verificationClientUrl'>>,
|
|
107
|
+
): Promise<VerificationSession>
|
|
108
|
+
get(sessionId: string): Promise<VerificationSession>
|
|
109
|
+
listEvents(sessionId: string): Promise<VerificationEventRecord[]>
|
|
110
|
+
}
|
|
111
|
+
callbacks: {
|
|
112
|
+
/** Register a callback subscription (one-time setup). Delivery is scoped
|
|
113
|
+
* to the token's org; results are JWE-encrypted to the org's credential
|
|
114
|
+
* when one is set (see `credential.set`), else plaintext. `events`
|
|
115
|
+
* defaults to all three terminal events. */
|
|
116
|
+
register(
|
|
117
|
+
input: Partial<RegisterCallbackInput> &
|
|
118
|
+
Pick<RegisterCallbackInput, 'callbackUrl'>,
|
|
119
|
+
): Promise<CallbackSubscription>
|
|
120
|
+
list(): Promise<CallbackSubscription[]>
|
|
121
|
+
remove(subscriptionId: string): Promise<void>
|
|
122
|
+
}
|
|
123
|
+
credential: {
|
|
124
|
+
/** Set/replace org `orgId`'s single X25519 encryption credential. Once
|
|
125
|
+
* set, terminal results are delivered as a compact JWE encrypted to it. */
|
|
126
|
+
set(orgId: string, input: SetCredentialInput): Promise<OrgCredential>
|
|
127
|
+
/** Get org `orgId`'s encryption credential, or `undefined` when none is
|
|
128
|
+
* set (delivery is plaintext). */
|
|
129
|
+
get(orgId: string): Promise<OrgCredential | undefined>
|
|
130
|
+
}
|
|
131
|
+
results: {
|
|
132
|
+
/** Decrypt a callback body (JWE or plaintext JWS) to the signed JWS.
|
|
133
|
+
* Pass the X25519 `credential` (JWK/PEM/env) only when the delivery is
|
|
134
|
+
* a JWE. */
|
|
135
|
+
decrypt(body: CallbackBody, credential?: DecryptionKeyInput): Promise<Jws>
|
|
136
|
+
/** Verify a decrypted JWS: JWKS discovery, ES256K signature,
|
|
137
|
+
* `reclaimSessionId`/`aud`/`exp`, nested attestor proofs. */
|
|
138
|
+
verify(
|
|
139
|
+
jws: Jws,
|
|
140
|
+
opts?: VerifyResultFullOptions,
|
|
141
|
+
): Promise<VerifyResultFullOutcome>
|
|
142
|
+
/** Handle any callback delivery in one call — the 90% path. Branches on
|
|
143
|
+
* the envelope's `event`: a terminal event is decrypted (with
|
|
144
|
+
* `opts.credential` when encrypted) + verified into a `result`; a
|
|
145
|
+
* non-terminal event is returned as a plaintext (unsigned) `event`
|
|
146
|
+
* notification without decryption. (A raw JWE/JWS body with no envelope
|
|
147
|
+
* `event` is treated as a terminal result.) */
|
|
148
|
+
receive(
|
|
149
|
+
body: CallbackBody,
|
|
150
|
+
opts?: ReceiveOptions,
|
|
151
|
+
): Promise<ReceivedDelivery>
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Outcome of `results.receive` — a verified terminal result or a plaintext
|
|
156
|
+
* non-terminal progress notification. Branch on `kind`. */
|
|
157
|
+
export type ReceivedDelivery =
|
|
158
|
+
| { kind: 'result', result: VerifyResultFullOutcome }
|
|
159
|
+
| {
|
|
160
|
+
kind: 'event'
|
|
161
|
+
event: VerificationEvent
|
|
162
|
+
eventData: Record<string, unknown>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createReclaim(config: ReclaimConfig): Reclaim {
|
|
166
|
+
if(!config?.orgSecret) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'createReclaim requires an org secret — pass '
|
|
169
|
+
+ 'createReclaim({ orgSecret: <rorg_… org secret> })',
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const client = new ReclaimClient({
|
|
174
|
+
baseUrl: config.baseUrl,
|
|
175
|
+
token: config.orgSecret,
|
|
176
|
+
fetch: config.fetch,
|
|
177
|
+
headers: config.headers,
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// Load an X25519 decryption key on demand — `undefined` input ⇒ no key, so
|
|
181
|
+
// plaintext-only consumers never touch the loader.
|
|
182
|
+
const resolveKey = (
|
|
183
|
+
input: DecryptionKeyInput | undefined,
|
|
184
|
+
): Promise<DecryptionKey | undefined> => (
|
|
185
|
+
input === undefined
|
|
186
|
+
? Promise.resolve(undefined)
|
|
187
|
+
: loadDecryptionKey(input)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
api: client.call.bind(client),
|
|
192
|
+
sessions: {
|
|
193
|
+
async create(input) {
|
|
194
|
+
const body: CreateSessionInput = {
|
|
195
|
+
...input,
|
|
196
|
+
verificationClientUrl:
|
|
197
|
+
input.verificationClientUrl ?? `${client.baseUrl}/v/{sessionId}`,
|
|
198
|
+
}
|
|
199
|
+
const { data } = await client.call('CreateVerificationSession', {
|
|
200
|
+
body,
|
|
201
|
+
})
|
|
202
|
+
return data
|
|
203
|
+
},
|
|
204
|
+
async get(sessionId) {
|
|
205
|
+
const { data } = await client.call('GetVerificationSession', {
|
|
206
|
+
params: { sessionId },
|
|
207
|
+
})
|
|
208
|
+
return data
|
|
209
|
+
},
|
|
210
|
+
async listEvents(sessionId) {
|
|
211
|
+
const { data } = await client.call('ListVerificationEvents', {
|
|
212
|
+
params: { sessionId },
|
|
213
|
+
})
|
|
214
|
+
return data.items
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
callbacks: {
|
|
218
|
+
async register(input) {
|
|
219
|
+
const events = input.events ?? [...TERMINAL_EVENTS]
|
|
220
|
+
const { data } = await client.call('CreateCallbackSubscription', {
|
|
221
|
+
body: { callbackUrl: input.callbackUrl, events },
|
|
222
|
+
})
|
|
223
|
+
return data
|
|
224
|
+
},
|
|
225
|
+
async list() {
|
|
226
|
+
const { data } = await client.call('ListCallbackSubscriptions')
|
|
227
|
+
return data.items
|
|
228
|
+
},
|
|
229
|
+
async remove(subscriptionId) {
|
|
230
|
+
await client.call('DeleteCallbackSubscription', {
|
|
231
|
+
params: { subscriptionId },
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
credential: {
|
|
236
|
+
async set(orgId, input) {
|
|
237
|
+
// Convert the consumer's SPKI public PEM to the JWK the API stores
|
|
238
|
+
// (PEM at the edge; JWK on the wire/DB/VC stays unchanged).
|
|
239
|
+
const publicKeyJwk = (await publicPemToJwk(
|
|
240
|
+
input.publicKeyPem,
|
|
241
|
+
)) as JwkX25519Public
|
|
242
|
+
const { data } = await client.call('SetOrgCredential', {
|
|
243
|
+
params: { orgId },
|
|
244
|
+
body: { publicKeyJwk, label: input.label },
|
|
245
|
+
})
|
|
246
|
+
return data
|
|
247
|
+
},
|
|
248
|
+
async get(orgId) {
|
|
249
|
+
try {
|
|
250
|
+
const { data } = await client.call('GetOrgCredential', {
|
|
251
|
+
params: { orgId },
|
|
252
|
+
})
|
|
253
|
+
return data
|
|
254
|
+
} catch(err) {
|
|
255
|
+
// 404 ⇒ no credential set (plaintext delivery).
|
|
256
|
+
if(isNotFound(err)) {
|
|
257
|
+
return undefined
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw err
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
results: {
|
|
265
|
+
async decrypt(body, credential) {
|
|
266
|
+
return decryptCallback(await resolveKey(credential), body)
|
|
267
|
+
},
|
|
268
|
+
verify(jws, opts) {
|
|
269
|
+
return verifyResultFull(jws, { fetchImpl: config.fetch, ...opts })
|
|
270
|
+
},
|
|
271
|
+
async receive(body, opts) {
|
|
272
|
+
// A delivery envelope tagged with a non-terminal event carries a
|
|
273
|
+
// plaintext, unsigned `{ event, eventData }` payload — return it
|
|
274
|
+
// directly, no decryption/verification.
|
|
275
|
+
if(
|
|
276
|
+
body && typeof body === 'object'
|
|
277
|
+
&& !(body instanceof Uint8Array)
|
|
278
|
+
&& !(body instanceof ArrayBuffer)
|
|
279
|
+
) {
|
|
280
|
+
const env = body
|
|
281
|
+
if(
|
|
282
|
+
env.event
|
|
283
|
+
&& !(TERMINAL_EVENTS as readonly string[]).includes(env.event)
|
|
284
|
+
) {
|
|
285
|
+
const decoded = parseEventData(env.data)
|
|
286
|
+
return {
|
|
287
|
+
kind: 'event',
|
|
288
|
+
event: env.event,
|
|
289
|
+
eventData: decoded,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { credential, ...verifyOpts } = opts ?? {}
|
|
295
|
+
const key = await resolveKey(credential)
|
|
296
|
+
const jws = await decryptCallback(key, body)
|
|
297
|
+
const result = await verifyResultFull(jws, {
|
|
298
|
+
fetchImpl: config.fetch,
|
|
299
|
+
...verifyOpts,
|
|
300
|
+
})
|
|
301
|
+
return { kind: 'result', result }
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Non-terminal `data` is a base64-encoded JSON `{ event, eventData }`. */
|
|
308
|
+
function parseEventData(data: string | undefined): Record<string, unknown> {
|
|
309
|
+
if(!data) {
|
|
310
|
+
return {}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const decoded = JSON.parse(
|
|
315
|
+
Buffer.from(data, 'base64').toString('utf8'),
|
|
316
|
+
) as { eventData?: Record<string, unknown> }
|
|
317
|
+
return decoded.eventData ?? {}
|
|
318
|
+
} catch{
|
|
319
|
+
return {}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** True for a `404` thrown by {@link ReclaimClient.call} — a Boom error whose
|
|
324
|
+
* `output.statusCode` is 404 (see ./errors.ts). */
|
|
325
|
+
function isNotFound(err: unknown): boolean {
|
|
326
|
+
if(typeof err !== 'object' || err === null) {
|
|
327
|
+
return false
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const boom = err as { output?: { statusCode?: number }, statusCode?: number }
|
|
331
|
+
return boom.output?.statusCode === 404 || boom.statusCode === 404
|
|
332
|
+
}
|