@keyhalve/node-sdk 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/LICENSE +21 -0
- package/README.md +419 -0
- package/dist/client.d.ts +80 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +779 -0
- package/dist/client.js.map +1 -0
- package/dist/crypto.d.ts +59 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +213 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf.d.ts +116 -0
- package/dist/pdf.d.ts.map +1 -0
- package/dist/pdf.js +172 -0
- package/dist/pdf.js.map +1 -0
- package/dist/rail.d.ts +18 -0
- package/dist/rail.d.ts.map +1 -0
- package/dist/rail.js +58 -0
- package/dist/rail.js.map +1 -0
- package/dist/types.d.ts +253 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Attestura, LLC (KeyHalve)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# @keyhalve/node-sdk
|
|
2
|
+
|
|
3
|
+
KeyHalve SDK for Node.js — the **blind-rail** document-verification core. Seal a
|
|
4
|
+
document so it can be verified later, with the decryption key split so **no single
|
|
5
|
+
party — not the platform, not KeyHalve — can read it.** Payloads are encrypted on
|
|
6
|
+
your server before anything leaves the box; the platform stores only ciphertext,
|
|
7
|
+
KeyHalve's rail holds one blind share, and the verifier reconstructs in-browser.
|
|
8
|
+
|
|
9
|
+
- **End-Cell (recommended)** — a 3-share split: the QR (ShareA) ⊕ the platform ⊕ the independent **KeyHalve rail**. No single holder can reassemble the key.
|
|
10
|
+
- **AES-256-GCM** authenticated encryption (tampering detected on decrypt)
|
|
11
|
+
- **SHA-256 commitment** — detects any server-side tampering with the ciphertext
|
|
12
|
+
- **Pinned-rail verification** — the rail's Ed25519 signature is checked against a key shipped in the SDK; fails closed on any doubt
|
|
13
|
+
- **Split-key** (simpler 2-share) + **selective disclosure**, **time-lock**, **revocation**, **QR placement** helpers
|
|
14
|
+
- **Zero production dependencies** — Node built-in `crypto` + native `fetch`; TypeScript-first, ESM-only, Node `>= 20`
|
|
15
|
+
- The encryption key is **never** sent to the platform API or to KeyHalve
|
|
16
|
+
|
|
17
|
+
> KeyHalve is the independent blind rail. This SDK is platform-agnostic — point
|
|
18
|
+
> `baseUrl` at the issuing platform's API; the KeyHalve rail is the only fixed
|
|
19
|
+
> dependency. (ValidPay's own SDK lives at `@validpay/node-sdk`.)
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @keyhalve/node-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { KeyHalveClient } from "@keyhalve/node-sdk";
|
|
31
|
+
|
|
32
|
+
const client = new KeyHalveClient({ apiKey: process.env.PLATFORM_API_KEY! });
|
|
33
|
+
|
|
34
|
+
// 1. Issuer side — seal with End-Cell (recommended). The AES key is split
|
|
35
|
+
// THREE ways: `key` is ShareA (rides the QR); one share goes to the
|
|
36
|
+
// platform; one goes to the independent KeyHalve rail. No single party —
|
|
37
|
+
// not the platform, not KeyHalve — can read or reassemble the key.
|
|
38
|
+
const { retrievalId, key } = await client.createEndCellIntent({
|
|
39
|
+
documentType: "ssn_card",
|
|
40
|
+
payload: { ssn: "123-45-6789", name: "Jane Doe" },
|
|
41
|
+
// holders defaults to ["keyhalve", "platform"] → a 3-of-3 split with ShareA
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// retrievalId is public (e.g. "vp_abc123def456") — embed in a QR code.
|
|
45
|
+
// key (ShareA) is secret — deliver it ONLY to the intended verifier, out-of-band.
|
|
46
|
+
|
|
47
|
+
// 2. Verifier side — fetch and decrypt (no API key needed). verifyIntent
|
|
48
|
+
// fetches the platform share + the rail share (the rail's Ed25519 signature
|
|
49
|
+
// is verified against a PINNED key, fail-closed), recombines in memory,
|
|
50
|
+
// decrypts locally, and re-checks the commitment hash.
|
|
51
|
+
const result = await client.verifyIntent<{ ssn: string; name: string }>(retrievalId, key);
|
|
52
|
+
|
|
53
|
+
console.log(result.payload); // { ssn: "123-45-6789", name: "Jane Doe" }
|
|
54
|
+
console.log(result.integrityVerified); // true — commitment hash matched
|
|
55
|
+
console.log(result.issuer); // "Acme Bank"
|
|
56
|
+
console.log(result.issuerVerified); // true
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> **Simpler 2-share option:** `createSplitKeyIntent()` splits the key between the
|
|
60
|
+
> document and the platform only — no independent rail share, so the platform
|
|
61
|
+
> alone could reconstruct. `createIntent()` also defaults to split-key. Prefer
|
|
62
|
+
> **End-Cell** above when independence from the platform matters.
|
|
63
|
+
|
|
64
|
+
### Building a verification URL
|
|
65
|
+
|
|
66
|
+
The `retrievalId` is public; the `key` is secret. Stamp them into a URL fragment (the `#` part — fragments are never sent to the server, even by curl) so a single link both identifies the intent and decrypts it:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
function toBase64Url(b64: string): string {
|
|
70
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const verifyUrl = `https://verify.keyhalve.com/verify/${retrievalId}#key=${toBase64Url(key)}`;
|
|
74
|
+
// → encode in a QR, paste in an email, scan with a phone camera.
|
|
75
|
+
// The /verify page reads the fragment client-side and decrypts locally.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`toBase64Url` matters because phone QR scanners + browser share-sheets mangle `+`, `/`, and `=` in URL fragments. The `/verify` page accepts both standard base64 and base64url for backward compatibility, but new links should always emit base64url.
|
|
79
|
+
|
|
80
|
+
### Placing the QR on a document (`embedQr`)
|
|
81
|
+
|
|
82
|
+
For a PDF, `embedQr` builds the verify QR and stamps it onto the page for you — so you don't have to wire up a QR library, base64url the key, or wrestle with PDF coordinates (PDFs use a bottom-left origin; everything else uses top-left).
|
|
83
|
+
|
|
84
|
+
`embedQr` needs two **optional** peer dependencies — the core client stays dependency-free, so install them only if you use it:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm i pdf-lib qrcode
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { KeyHalveClient, embedQr } from "@keyhalve/node-sdk";
|
|
92
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
93
|
+
|
|
94
|
+
const client = new KeyHalveClient({ apiKey: process.env.PLATFORM_API_KEY! });
|
|
95
|
+
|
|
96
|
+
const original = await readFile("invoice.pdf");
|
|
97
|
+
const { retrievalId, key } = await client.createFileIntent({
|
|
98
|
+
documentType: "invoice",
|
|
99
|
+
file: original,
|
|
100
|
+
fileContentType: "application/pdf",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const sealed = await embedQr(original, {
|
|
104
|
+
retrievalId,
|
|
105
|
+
key,
|
|
106
|
+
// 90pt (1.25in) QR, 36pt in from the bottom-right corner.
|
|
107
|
+
placement: { anchor: "bottom-right", x: 36, y: 36, width: 90 },
|
|
108
|
+
});
|
|
109
|
+
await writeFile("invoice-sealed.pdf", sealed);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
#### The placement contract
|
|
113
|
+
|
|
114
|
+
Coordinates read the way you think about a page, and are identical to what the **"Try it" placement tool** in the developer console emits — so position once in the UI, copy the call, and it lands in the same spot.
|
|
115
|
+
|
|
116
|
+
| field | meaning | default |
|
|
117
|
+
| -------- | ------- | ------- |
|
|
118
|
+
| `anchor` | which page **corner** the insets are measured from (`top-left` \| `top-right` \| `bottom-left` \| `bottom-right`) | `top-left` |
|
|
119
|
+
| `x` | horizontal inset from that corner's vertical edge | — |
|
|
120
|
+
| `y` | vertical inset from that corner's horizontal edge | — |
|
|
121
|
+
| `width` | QR side length (it's square) | — |
|
|
122
|
+
| `units` | `pt` (1/72in) \| `mm` \| `in` | `pt` |
|
|
123
|
+
| `page` | 1-based page number | `1` |
|
|
124
|
+
|
|
125
|
+
`{ anchor: "bottom-right", x: 36, y: 36, width: 90 }` sits 36pt in from the bottom and right edges — and stays bottom-right on any page size. Keep the QR **≥ ~72pt (1in)** so it scans reliably once printed; `embedQr` warns below that and throws if the placement runs off the page.
|
|
126
|
+
|
|
127
|
+
If you render PDFs with a different library, the two pure helpers are exported too:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
import { buildVerifyUrl, resolveQrRect } from "@keyhalve/node-sdk";
|
|
131
|
+
|
|
132
|
+
const url = buildVerifyUrl(retrievalId, key); // base64url key in the fragment
|
|
133
|
+
const rect = resolveQrRect(placement, pageWidthPt, pageHeightPt); // → { x, y, size } in pdf bottom-left points
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## How it works
|
|
137
|
+
|
|
138
|
+
1. `createIntent` generates a fresh 256-bit key, encrypts your payload locally with AES-256-GCM, computes a SHA-256 commitment hash of the plaintext, and POSTs only the ciphertext + hash to `POST /v1/intent`.
|
|
139
|
+
2. The API returns a public `retrieval_id` and stores the ciphertext + commitment hash.
|
|
140
|
+
3. You hand the verifier the `retrievalId` and the `key` through your own secure channel.
|
|
141
|
+
4. The verifier calls `verifyIntent`, which fetches `GET /v1/intent/:id`, decrypts the ciphertext locally, then recomputes the commitment hash and compares — any server-side tampering would change the hash.
|
|
142
|
+
|
|
143
|
+
The key is generated client-side, used client-side, and transmitted client-side. Neither the platform nor KeyHalve can read the payload.
|
|
144
|
+
|
|
145
|
+
## API reference
|
|
146
|
+
|
|
147
|
+
### `new KeyHalveClient(options)`
|
|
148
|
+
|
|
149
|
+
| Option | Type | Default | Notes |
|
|
150
|
+
| --------- | ------------------- | --------------------------- | ------------------------------------------- |
|
|
151
|
+
| `apiKey` | `string` (required) | — | Your issuing platform API key. |
|
|
152
|
+
| `baseUrl` | `string` (required) | — | The issuing platform's API base (e.g. your own API). No platform default. |
|
|
153
|
+
| `timeout` | `number` | `30000` | Request timeout (ms). |
|
|
154
|
+
| `fetch` | `typeof fetch` | global `fetch` | Inject a custom fetch (useful for testing). |
|
|
155
|
+
|
|
156
|
+
### End-Cell (recommended)
|
|
157
|
+
|
|
158
|
+
#### `client.createEndCellIntent({ documentType, payload, holders?, validFrom?, validUntil?, onBehalfOf? }) → { retrievalId, key }`
|
|
159
|
+
|
|
160
|
+
KeyHalve's blind-rail flow. Generates a key, encrypts `JSON.stringify(payload)`, and XOR-splits the key into **ShareA** (returned as `key`, for the QR) plus one share per holder. `holders` defaults to `["keyhalve", "platform"]` → a **3-of-3** split: the independent KeyHalve rail share + the platform share + ShareA. No single party can read or reassemble the key. Verify with `verifyIntent` (below), which fetches the platform + rail shares, verifies the rail's Ed25519 signature against a **pinned** key (fail-closed), recombines in memory, and decrypts. **The full key never exists on any single system.** Requires the API deployment to have End-Cell issuance enabled.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const { retrievalId, key } = await client.createEndCellIntent({
|
|
164
|
+
documentType: "ssn_card",
|
|
165
|
+
payload: { ssn: "123-45-6789" },
|
|
166
|
+
});
|
|
167
|
+
const result = await client.verifyIntent(retrievalId, key); // one call handles all share models
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Core
|
|
171
|
+
|
|
172
|
+
#### `client.createIntent({ documentType, payload, validFrom?, validUntil?, splitKey?, onBehalfOf? }) → { retrievalId, key }`
|
|
173
|
+
|
|
174
|
+
Generates a key, encrypts `JSON.stringify(payload)`, posts ciphertext + commitment hash to `/v1/intent`. Defaults to **split-key** (2-share): the returned `key` is Share A and Share B goes to the platform — neither alone decrypts, but there is **no independent rail share** (the platform alone could reconstruct). For independence from the platform, prefer **`createEndCellIntent`** above. Pass `splitKey: false` for the legacy single-key flow. **The full key is never sent to the API.**
|
|
175
|
+
|
|
176
|
+
#### `client.createIntentBatch(items[]) → { retrievalId, key }[]`
|
|
177
|
+
|
|
178
|
+
Same as `createIntent` for up to 100 intents in a single request. Each item gets a unique AES key; results match the input order.
|
|
179
|
+
|
|
180
|
+
#### `client.verifyIntent<T>(retrievalId, key) → VerifyIntentResult<T>`
|
|
181
|
+
|
|
182
|
+
Fetches the intent and decrypts the payload locally. Verifies the commitment hash. Throws `KeyHalveError`:
|
|
183
|
+
|
|
184
|
+
- `decryption_failed` — wrong key or tampered ciphertext (GCM auth-tag failure)
|
|
185
|
+
- `integrity_failure` — commitment hash mismatch (server-side tampering detected)
|
|
186
|
+
- `intent_revoked` — the intent has been revoked
|
|
187
|
+
- `split_key_required` / `selective_disclosure_required` — use the specialised verify method
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
interface VerifyIntentResult<T> {
|
|
191
|
+
intentId: string;
|
|
192
|
+
payload: T;
|
|
193
|
+
issuer: string;
|
|
194
|
+
issuerVerified: boolean;
|
|
195
|
+
registeredAt: string; // ISO 8601
|
|
196
|
+
status: string;
|
|
197
|
+
integrityVerified: boolean;
|
|
198
|
+
validFrom?: string | null;
|
|
199
|
+
validUntil?: string | null;
|
|
200
|
+
timeLockStatus?: "valid" | "not_yet_valid" | "expired" | null;
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Split-key (Patent C) — the default
|
|
205
|
+
|
|
206
|
+
All documents created with SDK v0.4+ use split-key by default — `createIntent`
|
|
207
|
+
returns Share A and stores Share B at the API; `verifyIntent` detects a
|
|
208
|
+
split-key intent, fetches Share B from `/v1/intent/:id/fragment`,
|
|
209
|
+
XOR-combines, and decrypts:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const { retrievalId, key: shareA } = await client.createIntent({
|
|
213
|
+
documentType: "ssn_card",
|
|
214
|
+
payload: { ssn: "123-45-6789" },
|
|
215
|
+
});
|
|
216
|
+
// shareA goes in the QR; shareB stays at the API.
|
|
217
|
+
|
|
218
|
+
const result = await client.verifyIntent(retrievalId, shareA);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Backward compatibility: `createIntent({ ..., splitKey: false })` gives the
|
|
222
|
+
legacy single-key flow; `createSplitKeyIntent()` is a deprecated alias of
|
|
223
|
+
`createIntent()` (emits a `DeprecationWarning`); `verifySplitKeyIntent()`
|
|
224
|
+
still works.
|
|
225
|
+
|
|
226
|
+
### Platform delegation — `onBehalfOf`
|
|
227
|
+
|
|
228
|
+
If you integrate as a **platform** and seal on behalf of the businesses you
|
|
229
|
+
serve, name the business on each seal. The verifier sees that business as the
|
|
230
|
+
issuer ("who"), attributed *through* your platform ("through whom"), at the
|
|
231
|
+
`delegated` trust rung. The businesses never touch the platform — no account, no
|
|
232
|
+
login — and the platform stays blind to the document contents.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const { retrievalId, key } = await client.createIntent({
|
|
236
|
+
documentType: "lease",
|
|
237
|
+
payload: { unit: "4B", term: "12mo" },
|
|
238
|
+
onBehalfOf: {
|
|
239
|
+
ref: "landlord_8675309", // YOUR id for this business (the dedupe key)
|
|
240
|
+
name: "Smith Properties LLC", // who the verifier sees
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = await client.verifyIntent(retrievalId, key);
|
|
245
|
+
result.issuer; // "Smith Properties LLC"
|
|
246
|
+
result.verificationLevel; // "delegated"
|
|
247
|
+
result.delegatedBy; // { platform: "Your Platform", platformLevel: "domain" }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Same `ref` ⇒ same tracked business (its documents and verification counts roll
|
|
251
|
+
up). A sub-issuer surfaces as `delegated` only once **your** platform account is
|
|
252
|
+
domain-verified; until then its documents show as unverified.
|
|
253
|
+
|
|
254
|
+
### Selective disclosure (Patent E)
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const { retrievalId, key } = await client.createSelectiveIntent({
|
|
258
|
+
documentType: "check",
|
|
259
|
+
payload: { amount: 1500, payee: "Alice", memo: "rent" },
|
|
260
|
+
disclosurePolicy: {
|
|
261
|
+
bank: ["amount"],
|
|
262
|
+
auditor: ["amount", "payee"],
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const bankView = await client.verifySelectiveIntent(retrievalId, key, "bank");
|
|
267
|
+
// { amount: 1500, payee: "[REDACTED]", memo: "[REDACTED]" }
|
|
268
|
+
|
|
269
|
+
const fullView = await client.verifySelectiveIntent(retrievalId, key, "full");
|
|
270
|
+
// { amount: 1500, payee: "Alice", memo: "rent" }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Audit + list (Prompt 080)
|
|
274
|
+
|
|
275
|
+
When you need to reconcile your own records against the platform — "how many intents did I create this month, and which got scanned?" — use the audit endpoints. **Metadata only; no ciphertext, no key material.**
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
const { intents, total } = await client.listIntents({
|
|
279
|
+
since: "2026-06-01T00:00:00Z",
|
|
280
|
+
status: "active",
|
|
281
|
+
limit: 100,
|
|
282
|
+
});
|
|
283
|
+
// total: 142
|
|
284
|
+
// intents[0]: {
|
|
285
|
+
// retrievalId: "vp_abc123def456",
|
|
286
|
+
// documentType: "check",
|
|
287
|
+
// status: "active",
|
|
288
|
+
// createdAt: "2026-06-04T15:52:25Z",
|
|
289
|
+
// verificationCount: 3,
|
|
290
|
+
// lastVerifiedAt: "2026-06-04T16:01:00Z",
|
|
291
|
+
// ...
|
|
292
|
+
// }
|
|
293
|
+
|
|
294
|
+
const meta = await client.getIntent("vp_abc123def456");
|
|
295
|
+
// status, verificationCount, revokedAt, etc.
|
|
296
|
+
// Use verifyIntent(retrievalId, key) if you want to decrypt.
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Filters: `since` / `until` (ISO datetime), `status` (`active` | `revoked`), `documentType`, `limit` (≤200), `offset`, `order` (`asc` | `desc`).
|
|
300
|
+
|
|
301
|
+
### Revocation (Patent H)
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
await client.revokeIntent(retrievalId, "stop payment requested");
|
|
305
|
+
await client.reinstateIntent(retrievalId, "false alarm");
|
|
306
|
+
const history = await client.getRevocationHistory(retrievalId);
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Health
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const { status, version } = await client.health();
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### Low-level crypto helpers
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
import {
|
|
319
|
+
generateKey,
|
|
320
|
+
encrypt,
|
|
321
|
+
decrypt,
|
|
322
|
+
commitmentHash,
|
|
323
|
+
splitKey,
|
|
324
|
+
combineKeyShares,
|
|
325
|
+
encryptFields,
|
|
326
|
+
buildKeyMap,
|
|
327
|
+
decryptFields,
|
|
328
|
+
} from "@keyhalve/node-sdk";
|
|
329
|
+
|
|
330
|
+
const key = generateKey(); // base64 32-byte key
|
|
331
|
+
const blob = encrypt("hello world", key); // base64(iv[12] || authTag[16] || ciphertext)
|
|
332
|
+
const plain = decrypt(blob, key); // "hello world"
|
|
333
|
+
const hash = commitmentHash(plain); // SHA-256 hex
|
|
334
|
+
|
|
335
|
+
const [a, b] = splitKey(key);
|
|
336
|
+
const reconstructed = combineKeyShares(a, b); // === key
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### `KeyHalveError`
|
|
340
|
+
|
|
341
|
+
All SDK errors throw `KeyHalveError` with a stable `code`:
|
|
342
|
+
|
|
343
|
+
| Code | Meaning |
|
|
344
|
+
| ------------------------------- | ------------------------------------------------------------- |
|
|
345
|
+
| `invalid_config` | Missing `apiKey` (or other constructor options). |
|
|
346
|
+
| `invalid_argument` | Required method argument is missing or invalid. |
|
|
347
|
+
| `invalid_key` | Key is not valid base64 or not 32 bytes. |
|
|
348
|
+
| `invalid_blob` | Blob is not valid base64 or too short. |
|
|
349
|
+
| `decryption_failed` | Wrong key, or ciphertext tampered (GCM auth-tag failure). |
|
|
350
|
+
| `integrity_failure` | Commitment hash didn't match — server tampering detected. |
|
|
351
|
+
| `intent_revoked` | The intent has been revoked. |
|
|
352
|
+
| `split_key_required` | Intent uses split-key; use `verifySplitKeyIntent` instead. |
|
|
353
|
+
| `selective_disclosure_required` | Intent uses per-field encryption; use `verifySelectiveIntent`. |
|
|
354
|
+
| `invalid_role` | Role not present in the disclosure policy. |
|
|
355
|
+
| `missing_fragment` | API did not return a key fragment for a split-key intent. |
|
|
356
|
+
| `network_error` | `fetch` itself rejected (DNS, TCP, abort, etc.). |
|
|
357
|
+
| `http_error` | API returned non-2xx with no machine-readable error. |
|
|
358
|
+
| `not_found` | API returned 404 (e.g. unknown retrieval ID). |
|
|
359
|
+
| `unauthorized` | API returned 401 (invalid or missing API key). |
|
|
360
|
+
| `invalid_response` | API returned 2xx but response shape was unexpected. |
|
|
361
|
+
| `invalid_payload` | Decrypted bytes were not valid JSON. |
|
|
362
|
+
|
|
363
|
+
### API error codes (wire format)
|
|
364
|
+
|
|
365
|
+
When the API itself rejects a request, the response body carries a canonical `code` field alongside the legacy `error` string. SDKs (this one included) surface both — use `code` for exhaustive `switch` checks because the values are stable across versions.
|
|
366
|
+
|
|
367
|
+
| `code` | HTTP | Meaning |
|
|
368
|
+
| ------------------------ | ---- | ------------------------------------------------------------------------- |
|
|
369
|
+
| `INVALID_BODY` | 400 | Request body failed schema validation. `details` carries the field-level errors. |
|
|
370
|
+
| `INVALID_CREDENTIALS` | 401 | Wrong email or password on /v1/auth/login. |
|
|
371
|
+
| `INVALID_API_KEY` | 401 | API key is missing, malformed, or revoked. |
|
|
372
|
+
| `MISSING_TOKEN` | 401 | Endpoint requires a bearer token and didn't get one. |
|
|
373
|
+
| `INVALID_TOKEN` | 401 | Bearer token is expired or doesn't decode. |
|
|
374
|
+
| `ACCOUNT_LOCKED` | 423 | Too many failed sign-ins. `message` carries the retry window. |
|
|
375
|
+
| `INSUFFICIENT_SCOPE` | 403 | API key doesn't have the scope this endpoint requires. |
|
|
376
|
+
| `INTENT_NOT_FOUND` | 404 | No intent matches this retrieval ID. |
|
|
377
|
+
| `INTENT_REVOKED` | 200 | Body is intentionally empty — issuer revoked the intent. |
|
|
378
|
+
| `DOCUMENT_LIMIT_REACHED` | 402 | Free or sandbox quota exhausted. `message` describes the upgrade path. |
|
|
379
|
+
| `PAYLOAD_TOO_LARGE` | 413 | Encrypted payload exceeds the per-route limit (25 MB for uploads). |
|
|
380
|
+
| `RATE_LIMIT_EXCEEDED` | 429 | Per-API-key bucket exhausted. Honour the `Retry-After` header. |
|
|
381
|
+
| `VALIDATION_ERROR` | 422 | Domain-level rule rejected the request (e.g. `valid_from > valid_until`). |
|
|
382
|
+
| `NOT_FOUND` | 404 | Generic — the route exists but the resource doesn't. |
|
|
383
|
+
| `INTERNAL_ERROR` | 500 | Unhandled server error. Retry with backoff; report if it persists. |
|
|
384
|
+
|
|
385
|
+
Error `code`s follow the platform API's contract; consult your platform's API docs for the full list.
|
|
386
|
+
|
|
387
|
+
### Rate limits
|
|
388
|
+
|
|
389
|
+
All authenticated responses carry three standard headers — read them to pace yourself before you hit a 429:
|
|
390
|
+
|
|
391
|
+
| Header | Meaning |
|
|
392
|
+
| ----------------------- | ---------------------------------------------------------------------- |
|
|
393
|
+
| `X-RateLimit-Limit` | Cap per API key per minute. Currently 600. |
|
|
394
|
+
| `X-RateLimit-Remaining` | Requests left in the current window. |
|
|
395
|
+
| `X-RateLimit-Reset` | UNIX timestamp (seconds) when the window resets. |
|
|
396
|
+
|
|
397
|
+
On 429 you'll also see `Retry-After` (seconds) — the SDK doesn't auto-retry; honour it from your caller.
|
|
398
|
+
|
|
399
|
+
## Blob format
|
|
400
|
+
|
|
401
|
+
`encrypt()` returns a base64 string whose decoded bytes are:
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
[ iv (12 bytes) | authTag (16 bytes) | ciphertext (variable) ]
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
This matches the Python SDK exactly, so blobs are interoperable in both directions.
|
|
408
|
+
|
|
409
|
+
## Development
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
npm install
|
|
413
|
+
npm test
|
|
414
|
+
npm run build
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
MIT — see [LICENSE](./LICENSE).
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type KeyHalveClientOptions, type CreateIntentParams, type EndCellIntentParams, type CreateFileIntentParams, type BatchIntentItem, type SelectiveIntentParams, type CreateIntentResult, type VerifyIntentResult, type RevocationResult, type RevocationEvent, type ListIntentsParams, type ListIntentsResult, type IntentMetadata } from "./types.js";
|
|
2
|
+
export declare class KeyHalveClient {
|
|
3
|
+
private readonly apiKey;
|
|
4
|
+
private readonly baseUrl;
|
|
5
|
+
private readonly timeout;
|
|
6
|
+
private readonly fetchImpl;
|
|
7
|
+
private readonly railBaseUrl;
|
|
8
|
+
private readonly railPublicKeySpki;
|
|
9
|
+
constructor(options: KeyHalveClientOptions);
|
|
10
|
+
/**
|
|
11
|
+
* Encrypt `payload` locally and register it with the issuing platform API.
|
|
12
|
+
*
|
|
13
|
+
* Since 0.4.0 this uses **split-key protection (Patent C) by default**:
|
|
14
|
+
* the AES-256 key is split into two XOR shares — Share A is returned as
|
|
15
|
+
* `key` (embed it in the QR code exactly as before), Share B is stored
|
|
16
|
+
* on the platform server. The full decryption key never exists on any
|
|
17
|
+
* single system after this call returns. Pass `splitKey: false` for the
|
|
18
|
+
* legacy single-key flow.
|
|
19
|
+
*/
|
|
20
|
+
createIntent(params: CreateIntentParams): Promise<CreateIntentResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Seal a document with End-Cell (CVCP Layer 6B): an n-of-n XOR split across
|
|
23
|
+
* ShareA (returned as `key`, embed in the QR) + one mandatory piece per holder
|
|
24
|
+
* (default: the Keyhalve rail + the platform). The full key never exists on any
|
|
25
|
+
* single party, and no single holder can read or assemble it alone. The returned
|
|
26
|
+
* `key` is ShareA; `verifyIntent` reconstructs by XOR-ing it with the server pieces.
|
|
27
|
+
*
|
|
28
|
+
* Requires the API deployment to have End-Cell issuance enabled.
|
|
29
|
+
*/
|
|
30
|
+
createEndCellIntent(params: EndCellIntentParams): Promise<CreateIntentResult>;
|
|
31
|
+
/**
|
|
32
|
+
* Seal a full document file (PDF, image, DOCX, …) end-to-end (Prompt 099).
|
|
33
|
+
*
|
|
34
|
+
* Unlike {@link createIntent}, which JSON-encodes a structured payload, this
|
|
35
|
+
* AES-256-GCM-encrypts the raw `file` bytes directly and registers them with
|
|
36
|
+
* file metadata — so a verifier decrypts back the exact original bytes for a
|
|
37
|
+
* byte-for-byte match and downloads them with the correct content type.
|
|
38
|
+
* Split-key protection (Patent C) is on by default.
|
|
39
|
+
*/
|
|
40
|
+
createFileIntent(params: CreateFileIntentParams): Promise<CreateIntentResult>;
|
|
41
|
+
createIntentBatch(items: BatchIntentItem[]): Promise<CreateIntentResult[]>;
|
|
42
|
+
verifyIntent<T = unknown>(retrievalId: string, key: string): Promise<VerifyIntentResult<T>>;
|
|
43
|
+
/**
|
|
44
|
+
* @deprecated Since 0.4.0 `createIntent()` uses split-key protection by
|
|
45
|
+
* default, so this alias adds nothing. Call `createIntent()` instead.
|
|
46
|
+
* Kept so 0.3.x code keeps working; will be removed in 1.0.
|
|
47
|
+
*/
|
|
48
|
+
createSplitKeyIntent(params: CreateIntentParams): Promise<CreateIntentResult>;
|
|
49
|
+
/** Fetch Share B from the public fragment endpoint (Patent C). */
|
|
50
|
+
private fetchFragmentB;
|
|
51
|
+
/** Fetch the End-Cell server pieces from the public fragment endpoint (Layer 6B).
|
|
52
|
+
* Returns the pieces in the server-advertised holder order for XOR-combining. */
|
|
53
|
+
private fetchPieces;
|
|
54
|
+
verifySplitKeyIntent<T = unknown>(retrievalId: string, shareA: string): Promise<VerifyIntentResult<T>>;
|
|
55
|
+
createSelectiveIntent(params: SelectiveIntentParams): Promise<CreateIntentResult>;
|
|
56
|
+
verifySelectiveIntent(retrievalId: string, key: string, role?: string): Promise<VerifyIntentResult<Record<string, unknown>>>;
|
|
57
|
+
revokeIntent(retrievalId: string, reason?: string): Promise<RevocationResult>;
|
|
58
|
+
reinstateIntent(retrievalId: string, reason?: string): Promise<RevocationResult>;
|
|
59
|
+
getRevocationHistory(retrievalId: string): Promise<RevocationEvent[]>;
|
|
60
|
+
/**
|
|
61
|
+
* List the intents this API key has created. Returns metadata only —
|
|
62
|
+
* the AES payload + key are NEVER part of the response, by design.
|
|
63
|
+
* Use this for audit, reconciliation, and "did this intent get
|
|
64
|
+
* scanned?" dashboards.
|
|
65
|
+
*/
|
|
66
|
+
listIntents(params?: ListIntentsParams): Promise<ListIntentsResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Fetch metadata for a single intent. Distinct from `verifyIntent` —
|
|
69
|
+
* this endpoint never returns ciphertext or key material, so it's
|
|
70
|
+
* safe to call from any service that just needs status / verification
|
|
71
|
+
* counts / revocation state.
|
|
72
|
+
*/
|
|
73
|
+
getIntent(retrievalId: string): Promise<IntentMetadata>;
|
|
74
|
+
health(): Promise<{
|
|
75
|
+
status: string;
|
|
76
|
+
version?: string;
|
|
77
|
+
}>;
|
|
78
|
+
private request;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAoBA,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,sBAAsB,EAC3B,KAAK,eAAe,EACpB,KAAK,qBAAqB,EAC1B,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EAMpB,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAGpB,MAAM,YAAY,CAAC;AAyBpB,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAe;IACzC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;gBAE/B,OAAO,EAAE,qBAAqB;IAoB1C;;;;;;;;;OASG;IACG,YAAY,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAgD3E;;;;;;;;OAQG;IACG,mBAAmB,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmDnF;;;;;;;;OAQG;IACG,gBAAgB,CAAC,MAAM,EAAE,sBAAsB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA0D7E,iBAAiB,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC;IA0E1E,YAAY,CAAC,CAAC,GAAG,OAAO,EAC5B,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC;IA+EjC;;;;OAIG;IACG,oBAAoB,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAKnF,kEAAkE;YACpD,cAAc;IAmB5B;sFACkF;YACpE,WAAW;IAwBnB,oBAAoB,CAAC,CAAC,GAAG,OAAO,EACpC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC;IAkD3B,qBAAqB,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA4EjF,qBAAqB,CACzB,WAAW,EAAE,MAAM,EACnB,GAAG,EAAE,MAAM,EACX,IAAI,SAAS,GACZ,OAAO,CAAC,kBAAkB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAkGjD,YAAY,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAoB7E,eAAe,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAoBhF,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAoB3E;;;;;OAKG;IACG,WAAW,CAAC,MAAM,GAAE,iBAAsB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAqB7E;;;;;OAKG;IACG,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAcvD,MAAM,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;YAQ/C,OAAO;CAgDtB"}
|