@keyhalve/node-sdk
v0.2.0
Published
KeyHalve SDK for Node.js — End-Cell blind-rail document sealing & verification: client-side AES-256-GCM, 3-share key split (QR + platform + independent KeyHalve rail), commitment hashing, QR placement. Zero required production dependencies.
Downloads
537
Maintainers
Readme
@keyhalve/node-sdk
KeyHalve SDK for Node.js — the blind-rail document-verification core. Seal a document so it can be verified later, with the decryption key split so no single party — not the platform, not KeyHalve — can read it. Payloads are encrypted on your server before anything leaves the box; the platform stores only ciphertext, KeyHalve's rail holds one blind share, and the verifier reconstructs in-browser.
- End-Cell (recommended) — a 3-share split: the QR (ShareA) ⊕ the platform ⊕ the independent KeyHalve rail. No single holder can reassemble the key.
- AES-256-GCM authenticated encryption (tampering detected on decrypt)
- SHA-256 commitment — detects any server-side tampering with the ciphertext
- Pinned-rail verification — the rail's Ed25519 signature is checked against a key shipped in the SDK; fails closed on any doubt
- Split-key (simpler 2-share) + selective disclosure, time-lock, revocation, QR placement helpers
- Zero production dependencies — Node built-in
crypto+ nativefetch; TypeScript-first, ESM-only, Node>= 20 - The encryption key is never sent to the platform API or to KeyHalve
KeyHalve is the independent blind rail. This SDK is platform-agnostic — point
baseUrlat the issuing platform's API; the KeyHalve rail is the only fixed dependency. (ValidPay's own SDK lives at@validpay/node-sdk.)
Install
npm install @keyhalve/node-sdkQuick start
import { KeyHalveClient } from "@keyhalve/node-sdk";
const client = new KeyHalveClient({ apiKey: process.env.PLATFORM_API_KEY! });
// 1. Issuer side — seal with End-Cell (recommended). The AES key is split
// THREE ways: `key` is ShareA (rides the QR); one share goes to the
// platform; one goes to the independent KeyHalve rail. No single party —
// not the platform, not KeyHalve — can read or reassemble the key.
const { retrievalId, key } = await client.createEndCellIntent({
documentType: "ssn_card",
payload: { ssn: "123-45-6789", name: "Jane Doe" },
// holders defaults to ["keyhalve", "platform"] → a 3-of-3 split with ShareA
});
// retrievalId is public (e.g. "kh_abc123def456") — embed in a QR code.
// key (ShareA) is secret — deliver it ONLY to the intended verifier, out-of-band.
// 2. Verifier side — fetch and decrypt (no API key needed). verifyIntent
// fetches the platform share + the rail share (the rail's Ed25519 signature
// is verified against a PINNED key, fail-closed), recombines in memory,
// decrypts locally, and re-checks the commitment hash.
const result = await client.verifyIntent<{ ssn: string; name: string }>(retrievalId, key);
console.log(result.payload); // { ssn: "123-45-6789", name: "Jane Doe" }
console.log(result.integrityVerified); // true — commitment hash matched
console.log(result.issuer); // "Acme Bank"
console.log(result.issuerVerified); // trueSimpler 2-share option:
createSplitKeyIntent()splits the key between the document and the platform only — no independent rail share, so the platform alone could reconstruct.createIntent()also defaults to split-key. Prefer End-Cell above when independence from the platform matters.
Building a verification URL
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:
function toBase64Url(b64: string): string {
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
const verifyUrl = `https://verify.keyhalve.com/verify/${retrievalId}#key=${toBase64Url(key)}`;
// → encode in a QR, paste in an email, scan with a phone camera.
// The /verify page reads the fragment client-side and decrypts locally.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.
Placing the QR on a document (embedQr)
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).
embedQr needs two optional peer dependencies — the core client stays dependency-free, so install them only if you use it:
npm i pdf-lib qrcodeimport { KeyHalveClient, embedQr } from "@keyhalve/node-sdk";
import { readFile, writeFile } from "node:fs/promises";
const client = new KeyHalveClient({ apiKey: process.env.PLATFORM_API_KEY! });
const original = await readFile("invoice.pdf");
const { retrievalId, key } = await client.createFileIntent({
documentType: "invoice",
file: original,
fileContentType: "application/pdf",
});
const sealed = await embedQr(original, {
retrievalId,
key,
// 90pt (1.25in) QR, 36pt in from the bottom-right corner.
placement: { anchor: "bottom-right", x: 36, y: 36, width: 90 },
});
await writeFile("invoice-sealed.pdf", sealed);The placement contract
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.
| field | meaning | default |
| -------- | ------- | ------- |
| anchor | which page corner the insets are measured from (top-left | top-right | bottom-left | bottom-right) | top-left |
| x | horizontal inset from that corner's vertical edge | — |
| y | vertical inset from that corner's horizontal edge | — |
| width | QR side length (it's square) | — |
| units | pt (1/72in) | mm | in | pt |
| page | 1-based page number | 1 |
{ 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.
If you render PDFs with a different library, the two pure helpers are exported too:
import { buildVerifyUrl, resolveQrRect } from "@keyhalve/node-sdk";
const url = buildVerifyUrl(retrievalId, key); // base64url key in the fragment
const rect = resolveQrRect(placement, pageWidthPt, pageHeightPt); // → { x, y, size } in pdf bottom-left pointsHow it works
createIntentgenerates 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 toPOST /v1/intent.- The API returns a public
retrieval_idand stores the ciphertext + commitment hash. - You hand the verifier the
retrievalIdand thekeythrough your own secure channel. - The verifier calls
verifyIntent, which fetchesGET /v1/intent/:id, decrypts the ciphertext locally, then recomputes the commitment hash and compares — any server-side tampering would change the hash.
The key is generated client-side, used client-side, and transmitted client-side. Neither the platform nor KeyHalve can read the payload.
API reference
new KeyHalveClient(options)
| Option | Type | Default | Notes |
| --------- | ------------------- | --------------------------- | ------------------------------------------- |
| apiKey | string (required) | — | Your issuing platform API key. |
| baseUrl | string (required) | — | The issuing platform's API base (e.g. your own API). No platform default. |
| timeout | number | 30000 | Request timeout (ms). |
| fetch | typeof fetch | global fetch | Inject a custom fetch (useful for testing). |
End-Cell (recommended)
client.createEndCellIntent({ documentType, payload, holders?, validFrom?, validUntil?, onBehalfOf? }) → { retrievalId, key }
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.
const { retrievalId, key } = await client.createEndCellIntent({
documentType: "ssn_card",
payload: { ssn: "123-45-6789" },
});
const result = await client.verifyIntent(retrievalId, key); // one call handles all share modelsCore
client.createIntent({ documentType, payload, validFrom?, validUntil?, splitKey?, onBehalfOf? }) → { retrievalId, key }
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.
client.createIntentBatch(items[]) → { retrievalId, key }[]
Same as createIntent for up to 100 intents in a single request. Each item gets a unique AES key; results match the input order.
client.verifyIntent<T>(retrievalId, key) → VerifyIntentResult<T>
Fetches the intent and decrypts the payload locally. Verifies the commitment hash. Throws KeyHalveError:
decryption_failed— wrong key or tampered ciphertext (GCM auth-tag failure)integrity_failure— commitment hash mismatch (server-side tampering detected)intent_revoked— the intent has been revokedsplit_key_required/selective_disclosure_required— use the specialised verify method
interface VerifyIntentResult<T> {
intentId: string;
payload: T;
issuer: string;
issuerVerified: boolean;
registeredAt: string; // ISO 8601
status: string;
integrityVerified: boolean;
validFrom?: string | null;
validUntil?: string | null;
timeLockStatus?: "valid" | "not_yet_valid" | "expired" | null;
}Split-key (Patent C) — the default
All documents created with SDK v0.4+ use split-key by default — createIntent
returns Share A and stores Share B at the API; verifyIntent detects a
split-key intent, fetches Share B from /v1/intent/:id/fragment,
XOR-combines, and decrypts:
const { retrievalId, key: shareA } = await client.createIntent({
documentType: "ssn_card",
payload: { ssn: "123-45-6789" },
});
// shareA goes in the QR; shareB stays at the API.
const result = await client.verifyIntent(retrievalId, shareA);Backward compatibility: createIntent({ ..., splitKey: false }) gives the
legacy single-key flow; createSplitKeyIntent() is a deprecated alias of
createIntent() (emits a DeprecationWarning); verifySplitKeyIntent()
still works.
Platform delegation — onBehalfOf
If you integrate as a platform and seal on behalf of the businesses you
serve, name the business on each seal. The verifier sees that business as the
issuer ("who"), attributed through your platform ("through whom"), at the
delegated trust rung. The businesses never touch the platform — no account, no
login — and the platform stays blind to the document contents.
const { retrievalId, key } = await client.createIntent({
documentType: "lease",
payload: { unit: "4B", term: "12mo" },
onBehalfOf: {
ref: "landlord_8675309", // YOUR id for this business (the dedupe key)
name: "Smith Properties LLC", // who the verifier sees
},
});
const result = await client.verifyIntent(retrievalId, key);
result.issuer; // "Smith Properties LLC"
result.verificationLevel; // "delegated"
result.delegatedBy; // { platform: "Your Platform", platformLevel: "domain" }Same ref ⇒ same tracked business (its documents and verification counts roll
up). A sub-issuer surfaces as delegated only once your platform account is
domain-verified; until then its documents show as unverified.
Selective disclosure (Patent E)
const { retrievalId, key } = await client.createSelectiveIntent({
documentType: "check",
payload: { amount: 1500, payee: "Alice", memo: "rent" },
disclosurePolicy: {
bank: ["amount"],
auditor: ["amount", "payee"],
},
});
const bankView = await client.verifySelectiveIntent(retrievalId, key, "bank");
// { amount: 1500, payee: "[REDACTED]", memo: "[REDACTED]" }
const fullView = await client.verifySelectiveIntent(retrievalId, key, "full");
// { amount: 1500, payee: "Alice", memo: "rent" }Audit + list (Prompt 080)
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.
const { intents, total } = await client.listIntents({
since: "2026-06-01T00:00:00Z",
status: "active",
limit: 100,
});
// total: 142
// intents[0]: {
// retrievalId: "kh_abc123def456",
// documentType: "check",
// status: "active",
// createdAt: "2026-06-04T15:52:25Z",
// verificationCount: 3,
// lastVerifiedAt: "2026-06-04T16:01:00Z",
// ...
// }
const meta = await client.getIntent("kh_abc123def456");
// status, verificationCount, revokedAt, etc.
// Use verifyIntent(retrievalId, key) if you want to decrypt.Filters: since / until (ISO datetime), status (active | revoked), documentType, limit (≤200), offset, order (asc | desc).
Revocation (Patent H)
await client.revokeIntent(retrievalId, "stop payment requested");
await client.reinstateIntent(retrievalId, "false alarm");
const history = await client.getRevocationHistory(retrievalId);Health
const { status, version } = await client.health();Low-level crypto helpers
import {
generateKey,
encrypt,
decrypt,
commitmentHash,
splitKey,
combineKeyShares,
encryptFields,
buildKeyMap,
decryptFields,
} from "@keyhalve/node-sdk";
const key = generateKey(); // base64 32-byte key
const blob = encrypt("hello world", key); // base64(iv[12] || authTag[16] || ciphertext)
const plain = decrypt(blob, key); // "hello world"
const hash = commitmentHash(plain); // SHA-256 hex
const [a, b] = splitKey(key);
const reconstructed = combineKeyShares(a, b); // === keyKeyHalveError
All SDK errors throw KeyHalveError with a stable code:
| Code | Meaning |
| ------------------------------- | ------------------------------------------------------------- |
| invalid_config | Missing apiKey (or other constructor options). |
| invalid_argument | Required method argument is missing or invalid. |
| invalid_key | Key is not valid base64 or not 32 bytes. |
| invalid_blob | Blob is not valid base64 or too short. |
| decryption_failed | Wrong key, or ciphertext tampered (GCM auth-tag failure). |
| integrity_failure | Commitment hash didn't match — server tampering detected. |
| intent_revoked | The intent has been revoked. |
| split_key_required | Intent uses split-key; use verifySplitKeyIntent instead. |
| selective_disclosure_required | Intent uses per-field encryption; use verifySelectiveIntent. |
| invalid_role | Role not present in the disclosure policy. |
| missing_fragment | API did not return a key fragment for a split-key intent. |
| network_error | fetch itself rejected (DNS, TCP, abort, etc.). |
| http_error | API returned non-2xx with no machine-readable error. |
| not_found | API returned 404 (e.g. unknown retrieval ID). |
| unauthorized | API returned 401 (invalid or missing API key). |
| invalid_response | API returned 2xx but response shape was unexpected. |
| invalid_payload | Decrypted bytes were not valid JSON. |
API error codes (wire format)
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.
| code | HTTP | Meaning |
| ------------------------ | ---- | ------------------------------------------------------------------------- |
| INVALID_BODY | 400 | Request body failed schema validation. details carries the field-level errors. |
| INVALID_CREDENTIALS | 401 | Wrong email or password on /v1/auth/login. |
| INVALID_API_KEY | 401 | API key is missing, malformed, or revoked. |
| MISSING_TOKEN | 401 | Endpoint requires a bearer token and didn't get one. |
| INVALID_TOKEN | 401 | Bearer token is expired or doesn't decode. |
| ACCOUNT_LOCKED | 423 | Too many failed sign-ins. message carries the retry window. |
| INSUFFICIENT_SCOPE | 403 | API key doesn't have the scope this endpoint requires. |
| INTENT_NOT_FOUND | 404 | No intent matches this retrieval ID. |
| INTENT_REVOKED | 200 | Body is intentionally empty — issuer revoked the intent. |
| DOCUMENT_LIMIT_REACHED | 402 | Free or sandbox quota exhausted. message describes the upgrade path. |
| PAYLOAD_TOO_LARGE | 413 | Encrypted payload exceeds the per-route limit (25 MB for uploads). |
| RATE_LIMIT_EXCEEDED | 429 | Per-API-key bucket exhausted. Honour the Retry-After header. |
| VALIDATION_ERROR | 422 | Domain-level rule rejected the request (e.g. valid_from > valid_until). |
| NOT_FOUND | 404 | Generic — the route exists but the resource doesn't. |
| INTERNAL_ERROR | 500 | Unhandled server error. Retry with backoff; report if it persists. |
Error codes follow the platform API's contract; consult your platform's API docs for the full list.
Rate limits
All authenticated responses carry three standard headers — read them to pace yourself before you hit a 429:
| Header | Meaning |
| ----------------------- | ---------------------------------------------------------------------- |
| X-RateLimit-Limit | Cap per API key per minute. Currently 600. |
| X-RateLimit-Remaining | Requests left in the current window. |
| X-RateLimit-Reset | UNIX timestamp (seconds) when the window resets. |
On 429 you'll also see Retry-After (seconds) — the SDK doesn't auto-retry; honour it from your caller.
Blob format
encrypt() returns a base64 string whose decoded bytes are:
[ iv (12 bytes) | authTag (16 bytes) | ciphertext (variable) ]This matches the Python SDK exactly, so blobs are interoperable in both directions.
Verdict Receipts — server-side re-check (for banks / relying parties)
A Verdict Receipt lets a relying party's OWN SERVER re-check a KeyHalve verdict offline, against the pinned KeyHalve keys already shipped in this SDK — no network call to a KeyHalve page, and zero KeyHalve-served JavaScript in the trust path. It moves provenance + freshness trust off the served page and onto locally pinned keys.
import { verifyReceipt, type VerdictReceipt } from "@keyhalve/node-sdk";
// `receipt` arrives with the document (e.g. embedded in / alongside the QR payload,
// or handed over by the counterparty). It is JSON — no live KeyHalve fetch needed.
function bankGate(receipt: VerdictReceipt) {
const result = verifyReceipt(receipt, {
expectedIntentId: "vp_wire_001", // bind to the intent you actually asked about
maxAgeSeconds: 300, // freshness TTL you're willing to accept
// iaPublicKeySpki: "<pinned IA key>" // see the TODO below until 1b lands
});
if (!result.ok) {
// Fail closed. `result.reasons` lists exactly what failed.
throw new Error(`receipt rejected: ${result.reasons.join(", ")}`);
}
// result = { ok: true, verdict: "verified",
// issuerDomain: "bankofamerica.com", reasons: [], checks: {…} }
//
// Safe to proceed with the provenance decision: a KeyHalve-attested issuer
// controlling `issuerDomain` sealed this intent, the verdict is fresh and active.
releaseWireIfBodyMatches(result.issuerDomain);
}verifyReceipt(receipt, opts) checks, fail-closed:
- Structure — every required field present and well-formed.
- Rail signature —
railSigoverkeyhalve-rail.v2\n+SHA256(canonical(bodyCore))verifies against the pinned rail Ed25519 key. - Issuer credential (unless
requireCredential: false) — the IA signature over the credential verifies against the pinned/supplied IA key, and the credential coversdomain+issuerIdand is valid atobservedAt. - Log inclusion (unless
requireLogInclusion: false) — an RFC 6962 audit path torootHash,rootHash == sth.rootHash, and a rail-signed Signed Tree Head. - Freshness —
observedAtwithinmaxAgeSeconds, not implausibly in the future. - Status / verdict —
status === "active"andverdict === "verified".
Returns { ok, verdict, issuerDomain, reasons, checks }.
⚠️ Honest limitation — this does NOT bind the visible body (wire-body substitution)
For a share-less relying party (a bank that never holds ShareA / the decryption key), a passing receipt binds provenance + freshness + a ciphertext commitment — it does NOT bind the human-visible printed/rendered BODY (amount, account, payee) to the sealed plaintext.
The commitment is over the ciphertext, and a share-less verifier cannot decrypt to compare ink↔content. So this attack is out of scope and still works against a provenance-only check:
Take a genuine issuer-sealed document (valid receipt, active status, matching commitment), keep its QR + receipt, and rewrite the human-readable amount/account. A pure provenance+freshness re-check still returns "issuer-attested, active." A bank acting on the visible body alone could wire to an attacker.
Read a passing receipt as: "this issuer, at this domain, sealed a document and
KeyHalve co-signed the verdict, recently, not revoked." Not "the numbers I see are
the numbers that were sealed." Closing the body binding for share-less parties requires
a decrypting party (holds ShareA) or the forthcoming ZK-sealed-fields round that
binds specific body fields without disclosure. Until then, do not release funds on the
receipt alone — corroborate the visible body through a decrypting surface or an
out-of-band channel. (Full red-team analysis: refute-03-receipts.md.)
Status: pinned IA key + rail v2 wire (fixtures today)
The rail v2 co-signature (railSig) and IA-signed issuer credential are being built in
parallel (SHIP-SPEC ITEM 1b). This SDK verifies the documented format today with
test fixtures. Two seams remain until 1b lands, both marked TODO(rail-v2) in
src/receipt.ts:
KEYHALVE_IA_PUBLIC_KEY_SPKI_B64isnull(deliberately not a fake pin). Until the real ceremony key is pinned, passiaPublicKeySpkiinopts, or credential verification fails closed withcredential:ia_key_not_pinned.- The transparency-log leaf preimage (
defaultLeafEntry) is our documented encoding; when the rail freezes its RFC 6962 leaf entry, confirm/adjust it (override viaopts.leafEntryBuilderif needed).
Development
npm install
npm test
npm run buildLicense
MIT — see LICENSE.
