@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/dist/client.js
ADDED
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
import { generateKey, encrypt, encryptBytes, decrypt, commitmentHash, buildAad, splitKey as splitKeyFn, combineKeyShares, splitKeyPieces, combineKeyPieces, encryptFields, buildKeyMap, decryptFields, } from "./crypto.js";
|
|
2
|
+
import { fetchRailPiece, KEYHALVE_RAIL_BASE_URL, KEYHALVE_RAIL_PUBLIC_KEY_SPKI_B64, } from "./rail.js";
|
|
3
|
+
import { KeyHalveError, } from "./types.js";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
|
+
let splitKeyDeprecationEmitted = false;
|
|
6
|
+
function emitSplitKeyDeprecation() {
|
|
7
|
+
if (splitKeyDeprecationEmitted)
|
|
8
|
+
return;
|
|
9
|
+
splitKeyDeprecationEmitted = true;
|
|
10
|
+
const message = "createSplitKeyIntent() is deprecated since @keyhalve/node-sdk 0.4.0: " +
|
|
11
|
+
"createIntent() uses split-key protection by default. Call createIntent() instead.";
|
|
12
|
+
if (typeof process !== "undefined" && typeof process.emitWarning === "function") {
|
|
13
|
+
process.emitWarning(message, "DeprecationWarning");
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.warn(message);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class KeyHalveClient {
|
|
20
|
+
apiKey;
|
|
21
|
+
baseUrl;
|
|
22
|
+
timeout;
|
|
23
|
+
fetchImpl;
|
|
24
|
+
railBaseUrl;
|
|
25
|
+
railPublicKeySpki;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
if (!options.apiKey) {
|
|
28
|
+
throw new KeyHalveError("invalid_config", "apiKey is required");
|
|
29
|
+
}
|
|
30
|
+
this.apiKey = options.apiKey;
|
|
31
|
+
// No platform default: a KeyHalve SDK must be pointed at the issuing platform's
|
|
32
|
+
// API explicitly (e.g. your own API, or a platform built on the rail). The
|
|
33
|
+
// blind rail (railBaseUrl) is the only fixed KeyHalve dependency.
|
|
34
|
+
if (!options.baseUrl) {
|
|
35
|
+
throw new KeyHalveError("invalid_config", "baseUrl is required (the issuing platform's API base)");
|
|
36
|
+
}
|
|
37
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
38
|
+
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
39
|
+
this.fetchImpl = options.fetch ?? fetch;
|
|
40
|
+
this.railBaseUrl = options.railBaseUrl ?? KEYHALVE_RAIL_BASE_URL;
|
|
41
|
+
this.railPublicKeySpki = options.railPublicKeySpki ?? KEYHALVE_RAIL_PUBLIC_KEY_SPKI_B64;
|
|
42
|
+
}
|
|
43
|
+
// === Core ===
|
|
44
|
+
/**
|
|
45
|
+
* Encrypt `payload` locally and register it with the issuing platform API.
|
|
46
|
+
*
|
|
47
|
+
* Since 0.4.0 this uses **split-key protection (Patent C) by default**:
|
|
48
|
+
* the AES-256 key is split into two XOR shares — Share A is returned as
|
|
49
|
+
* `key` (embed it in the QR code exactly as before), Share B is stored
|
|
50
|
+
* on the platform server. The full decryption key never exists on any
|
|
51
|
+
* single system after this call returns. Pass `splitKey: false` for the
|
|
52
|
+
* legacy single-key flow.
|
|
53
|
+
*/
|
|
54
|
+
async createIntent(params) {
|
|
55
|
+
if (!params.documentType) {
|
|
56
|
+
throw new KeyHalveError("invalid_argument", "documentType is required");
|
|
57
|
+
}
|
|
58
|
+
validateTimeLock(params.validFrom, params.validUntil);
|
|
59
|
+
const splitKey = params.splitKey !== false;
|
|
60
|
+
const fullKey = generateKey();
|
|
61
|
+
let resultKey = fullKey;
|
|
62
|
+
let shareB;
|
|
63
|
+
if (splitKey) {
|
|
64
|
+
[resultKey, shareB] = splitKeyFn(fullKey);
|
|
65
|
+
}
|
|
66
|
+
const plaintext = JSON.stringify(params.payload);
|
|
67
|
+
// M-5: bind document_type + validity window as AAD.
|
|
68
|
+
const aad = buildAad(params.documentType, params.validFrom, params.validUntil);
|
|
69
|
+
const encrypted_payload = encrypt(plaintext, fullKey, aad);
|
|
70
|
+
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
|
|
71
|
+
const commitment_hash = commitmentHash(encrypted_payload);
|
|
72
|
+
const body = {
|
|
73
|
+
document_type: params.documentType,
|
|
74
|
+
encrypted_payload,
|
|
75
|
+
commitment_hash,
|
|
76
|
+
encryption_version: 2,
|
|
77
|
+
};
|
|
78
|
+
if (splitKey) {
|
|
79
|
+
body["split_key"] = true;
|
|
80
|
+
body["key_fragment_b"] = shareB;
|
|
81
|
+
}
|
|
82
|
+
if (params.validFrom !== undefined)
|
|
83
|
+
body["valid_from"] = params.validFrom;
|
|
84
|
+
if (params.validUntil !== undefined)
|
|
85
|
+
body["valid_until"] = params.validUntil;
|
|
86
|
+
if (params.onBehalfOf !== undefined)
|
|
87
|
+
body["on_behalf_of"] = params.onBehalfOf;
|
|
88
|
+
const data = await this.request("POST", "/v1/intent", {
|
|
89
|
+
body,
|
|
90
|
+
auth: true,
|
|
91
|
+
});
|
|
92
|
+
if (!data?.retrieval_id) {
|
|
93
|
+
throw new KeyHalveError("invalid_response", "API response missing retrieval_id", {
|
|
94
|
+
details: data,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return { retrievalId: data.retrieval_id, key: resultKey };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Seal a document with End-Cell (CVCP Layer 6B): an n-of-n XOR split across
|
|
101
|
+
* ShareA (returned as `key`, embed in the QR) + one mandatory piece per holder
|
|
102
|
+
* (default: the Keyhalve rail + the platform). The full key never exists on any
|
|
103
|
+
* single party, and no single holder can read or assemble it alone. The returned
|
|
104
|
+
* `key` is ShareA; `verifyIntent` reconstructs by XOR-ing it with the server pieces.
|
|
105
|
+
*
|
|
106
|
+
* Requires the API deployment to have End-Cell issuance enabled.
|
|
107
|
+
*/
|
|
108
|
+
async createEndCellIntent(params) {
|
|
109
|
+
if (!params.documentType) {
|
|
110
|
+
throw new KeyHalveError("invalid_argument", "documentType is required");
|
|
111
|
+
}
|
|
112
|
+
validateTimeLock(params.validFrom, params.validUntil);
|
|
113
|
+
const holders = params.holders ?? ["keyhalve", "platform"];
|
|
114
|
+
if (holders.length < 1) {
|
|
115
|
+
throw new KeyHalveError("invalid_argument", "holders must contain at least one holder");
|
|
116
|
+
}
|
|
117
|
+
if (new Set(holders).size !== holders.length) {
|
|
118
|
+
throw new KeyHalveError("invalid_argument", "holders must be unique");
|
|
119
|
+
}
|
|
120
|
+
const fullKey = generateKey();
|
|
121
|
+
// [shareA, piece_1, …, piece_m] — ShareA is the QR key; pieces go to the holders.
|
|
122
|
+
const parts = splitKeyPieces(fullKey, holders.length);
|
|
123
|
+
const shareA = parts[0];
|
|
124
|
+
const pieceList = parts.slice(1);
|
|
125
|
+
const pieces = holders.map((holder, i) => ({ holder, piece: pieceList[i] }));
|
|
126
|
+
const plaintext = JSON.stringify(params.payload);
|
|
127
|
+
const aad = buildAad(params.documentType, params.validFrom, params.validUntil);
|
|
128
|
+
const encrypted_payload = encrypt(plaintext, fullKey, aad);
|
|
129
|
+
const commitment_hash = commitmentHash(encrypted_payload);
|
|
130
|
+
const body = {
|
|
131
|
+
document_type: params.documentType,
|
|
132
|
+
encrypted_payload,
|
|
133
|
+
commitment_hash,
|
|
134
|
+
encryption_version: 2,
|
|
135
|
+
end_cell: true,
|
|
136
|
+
pieces,
|
|
137
|
+
};
|
|
138
|
+
if (params.validFrom !== undefined)
|
|
139
|
+
body["valid_from"] = params.validFrom;
|
|
140
|
+
if (params.validUntil !== undefined)
|
|
141
|
+
body["valid_until"] = params.validUntil;
|
|
142
|
+
if (params.onBehalfOf !== undefined)
|
|
143
|
+
body["on_behalf_of"] = params.onBehalfOf;
|
|
144
|
+
const data = await this.request("POST", "/v1/intent", {
|
|
145
|
+
body,
|
|
146
|
+
auth: true,
|
|
147
|
+
});
|
|
148
|
+
if (!data?.retrieval_id) {
|
|
149
|
+
throw new KeyHalveError("invalid_response", "API response missing retrieval_id", {
|
|
150
|
+
details: data,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return { retrievalId: data.retrieval_id, key: shareA };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Seal a full document file (PDF, image, DOCX, …) end-to-end (Prompt 099).
|
|
157
|
+
*
|
|
158
|
+
* Unlike {@link createIntent}, which JSON-encodes a structured payload, this
|
|
159
|
+
* AES-256-GCM-encrypts the raw `file` bytes directly and registers them with
|
|
160
|
+
* file metadata — so a verifier decrypts back the exact original bytes for a
|
|
161
|
+
* byte-for-byte match and downloads them with the correct content type.
|
|
162
|
+
* Split-key protection (Patent C) is on by default.
|
|
163
|
+
*/
|
|
164
|
+
async createFileIntent(params) {
|
|
165
|
+
if (!params.documentType) {
|
|
166
|
+
throw new KeyHalveError("invalid_argument", "documentType is required");
|
|
167
|
+
}
|
|
168
|
+
if (!(params.file instanceof Uint8Array)) {
|
|
169
|
+
throw new KeyHalveError("invalid_argument", "file must be a Uint8Array/Buffer");
|
|
170
|
+
}
|
|
171
|
+
if (params.file.length === 0) {
|
|
172
|
+
throw new KeyHalveError("invalid_argument", "file is empty");
|
|
173
|
+
}
|
|
174
|
+
validateTimeLock(params.validFrom, params.validUntil);
|
|
175
|
+
const splitKey = params.splitKey !== false;
|
|
176
|
+
const fullKey = generateKey();
|
|
177
|
+
let resultKey = fullKey;
|
|
178
|
+
let shareB;
|
|
179
|
+
if (splitKey) {
|
|
180
|
+
[resultKey, shareB] = splitKeyFn(fullKey);
|
|
181
|
+
}
|
|
182
|
+
// M-5: bind document_type + validity window as AAD, same as createIntent.
|
|
183
|
+
const aad = buildAad(params.documentType, params.validFrom, params.validUntil);
|
|
184
|
+
const encrypted_payload = encryptBytes(params.file, fullKey, aad);
|
|
185
|
+
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
|
|
186
|
+
const commitment_hash = commitmentHash(encrypted_payload);
|
|
187
|
+
const body = {
|
|
188
|
+
document_type: params.documentType,
|
|
189
|
+
encrypted_payload,
|
|
190
|
+
commitment_hash,
|
|
191
|
+
encryption_version: 2,
|
|
192
|
+
file_size_bytes: params.file.length,
|
|
193
|
+
};
|
|
194
|
+
if (splitKey) {
|
|
195
|
+
body["split_key"] = true;
|
|
196
|
+
body["key_fragment_b"] = shareB;
|
|
197
|
+
}
|
|
198
|
+
if (params.fileName !== undefined)
|
|
199
|
+
body["file_name"] = params.fileName;
|
|
200
|
+
if (params.fileContentType !== undefined) {
|
|
201
|
+
body["file_content_type"] = params.fileContentType;
|
|
202
|
+
}
|
|
203
|
+
if (params.validFrom !== undefined)
|
|
204
|
+
body["valid_from"] = params.validFrom;
|
|
205
|
+
if (params.validUntil !== undefined)
|
|
206
|
+
body["valid_until"] = params.validUntil;
|
|
207
|
+
if (params.onBehalfOf !== undefined)
|
|
208
|
+
body["on_behalf_of"] = params.onBehalfOf;
|
|
209
|
+
const data = await this.request("POST", "/v1/intent", {
|
|
210
|
+
body,
|
|
211
|
+
auth: true,
|
|
212
|
+
});
|
|
213
|
+
if (!data?.retrieval_id) {
|
|
214
|
+
throw new KeyHalveError("invalid_response", "API response missing retrieval_id", {
|
|
215
|
+
details: data,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
return { retrievalId: data.retrieval_id, key: resultKey };
|
|
219
|
+
}
|
|
220
|
+
async createIntentBatch(items) {
|
|
221
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
222
|
+
throw new KeyHalveError("invalid_argument", "items must contain at least 1 item");
|
|
223
|
+
}
|
|
224
|
+
if (items.length > 100) {
|
|
225
|
+
throw new KeyHalveError("invalid_argument", `items must contain at most 100 items (got ${items.length})`);
|
|
226
|
+
}
|
|
227
|
+
const keys = [];
|
|
228
|
+
const requestItems = [];
|
|
229
|
+
items.forEach((item, idx) => {
|
|
230
|
+
if (!item.documentType) {
|
|
231
|
+
throw new KeyHalveError("invalid_argument", `items[${idx}].documentType is required`);
|
|
232
|
+
}
|
|
233
|
+
if (!("payload" in item)) {
|
|
234
|
+
throw new KeyHalveError("invalid_argument", `items[${idx}].payload is required`);
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
validateTimeLock(item.validFrom, item.validUntil);
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (e instanceof KeyHalveError) {
|
|
241
|
+
throw new KeyHalveError("invalid_argument", `items[${idx}]: ${e.message}`);
|
|
242
|
+
}
|
|
243
|
+
throw e;
|
|
244
|
+
}
|
|
245
|
+
const k = generateKey();
|
|
246
|
+
keys.push(k);
|
|
247
|
+
const plaintext = JSON.stringify(item.payload);
|
|
248
|
+
// M-5: bind document_type + validity window as AAD per item.
|
|
249
|
+
const itemAad = buildAad(item.documentType, item.validFrom, item.validUntil);
|
|
250
|
+
const item_encrypted_payload = encrypt(plaintext, k, itemAad);
|
|
251
|
+
const req = {
|
|
252
|
+
document_type: item.documentType,
|
|
253
|
+
encrypted_payload: item_encrypted_payload,
|
|
254
|
+
// Commitment v2: hash the ciphertext, not the plaintext (C-1).
|
|
255
|
+
commitment_hash: commitmentHash(item_encrypted_payload),
|
|
256
|
+
encryption_version: 2,
|
|
257
|
+
};
|
|
258
|
+
if (item.validFrom !== undefined)
|
|
259
|
+
req["valid_from"] = item.validFrom;
|
|
260
|
+
if (item.validUntil !== undefined)
|
|
261
|
+
req["valid_until"] = item.validUntil;
|
|
262
|
+
if (item.onBehalfOf !== undefined)
|
|
263
|
+
req["on_behalf_of"] = item.onBehalfOf;
|
|
264
|
+
requestItems.push(req);
|
|
265
|
+
});
|
|
266
|
+
const data = await this.request("POST", "/v1/intent/batch", {
|
|
267
|
+
body: { intents: requestItems },
|
|
268
|
+
auth: true,
|
|
269
|
+
});
|
|
270
|
+
if (!Array.isArray(data?.results) || data.results.length !== keys.length) {
|
|
271
|
+
throw new KeyHalveError("invalid_response", "API response missing results array of expected length", { details: data });
|
|
272
|
+
}
|
|
273
|
+
return data.results.map((row, i) => {
|
|
274
|
+
if (!row?.retrieval_id) {
|
|
275
|
+
throw new KeyHalveError("invalid_response", `results[${i}] missing retrieval_id`, {
|
|
276
|
+
details: data,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return { retrievalId: row.retrieval_id, key: keys[i] };
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async verifyIntent(retrievalId, key) {
|
|
283
|
+
if (!retrievalId) {
|
|
284
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
285
|
+
}
|
|
286
|
+
if (!key) {
|
|
287
|
+
throw new KeyHalveError("invalid_argument", "key is required");
|
|
288
|
+
}
|
|
289
|
+
const data = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}`, { auth: false });
|
|
290
|
+
if (!data || typeof data !== "object") {
|
|
291
|
+
throw new KeyHalveError("invalid_response", "API response missing intent body", {
|
|
292
|
+
details: data,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (data.status === "revoked" || !data.encrypted_payload) {
|
|
296
|
+
const reasonSuffix = data.revocation_reason ? `: ${data.revocation_reason}` : "";
|
|
297
|
+
throw new KeyHalveError("intent_revoked", `Intent ${retrievalId} has been revoked${reasonSuffix}`, {
|
|
298
|
+
details: {
|
|
299
|
+
intent_id: data.intent_id,
|
|
300
|
+
status: data.status,
|
|
301
|
+
revoked_at: data.revoked_at,
|
|
302
|
+
revocation_reason: data.revocation_reason,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (data.selective_disclosure) {
|
|
307
|
+
throw new KeyHalveError("selective_disclosure_required", "This intent uses selective field disclosure. Use verifySelectiveIntent(retrievalId, key, role) instead.");
|
|
308
|
+
}
|
|
309
|
+
// Split-Key Verification (Patent C). Since 0.4.0 split-key is the
|
|
310
|
+
// default issue path, so the key the caller holds is Share A — fetch
|
|
311
|
+
// Share B from the fragment endpoint and XOR-combine, so the natural
|
|
312
|
+
// createIntent -> verifyIntent round trip keeps working.
|
|
313
|
+
let decryptionKey = key;
|
|
314
|
+
if (data.end_cell) {
|
|
315
|
+
// Custody separation: platform share(s) come from the platform API; the rail
|
|
316
|
+
// share comes from the independent KeyHalve rail (signature-verified vs the
|
|
317
|
+
// pinned key). XOR all of them with ShareA. Fails closed if either is missing.
|
|
318
|
+
const [platformPieces, railPiece] = await Promise.all([
|
|
319
|
+
this.fetchPieces(retrievalId),
|
|
320
|
+
fetchRailPiece(this.fetchImpl, this.railBaseUrl, this.railPublicKeySpki, retrievalId),
|
|
321
|
+
]);
|
|
322
|
+
decryptionKey = combineKeyPieces(key, [...platformPieces, railPiece]);
|
|
323
|
+
}
|
|
324
|
+
else if (data.split_key) {
|
|
325
|
+
decryptionKey = combineKeyShares(key, await this.fetchFragmentB(retrievalId));
|
|
326
|
+
}
|
|
327
|
+
// M-5: reconstruct the AAD for v2 intents.
|
|
328
|
+
const decrypted = decrypt(data.encrypted_payload, decryptionKey, aadFor(data));
|
|
329
|
+
const integrityVerified = checkCommitment(data);
|
|
330
|
+
let payload;
|
|
331
|
+
try {
|
|
332
|
+
payload = JSON.parse(decrypted);
|
|
333
|
+
}
|
|
334
|
+
catch (cause) {
|
|
335
|
+
throw new KeyHalveError("invalid_payload", "Decrypted payload is not valid JSON", {
|
|
336
|
+
cause,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return buildVerifyResult(data, payload, integrityVerified);
|
|
340
|
+
}
|
|
341
|
+
// === Split-key (Patent C) ===
|
|
342
|
+
/**
|
|
343
|
+
* @deprecated Since 0.4.0 `createIntent()` uses split-key protection by
|
|
344
|
+
* default, so this alias adds nothing. Call `createIntent()` instead.
|
|
345
|
+
* Kept so 0.3.x code keeps working; will be removed in 1.0.
|
|
346
|
+
*/
|
|
347
|
+
async createSplitKeyIntent(params) {
|
|
348
|
+
emitSplitKeyDeprecation();
|
|
349
|
+
return this.createIntent({ ...params, splitKey: true });
|
|
350
|
+
}
|
|
351
|
+
/** Fetch Share B from the public fragment endpoint (Patent C). */
|
|
352
|
+
async fetchFragmentB(retrievalId) {
|
|
353
|
+
const frag = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}/fragment`, { auth: false });
|
|
354
|
+
if (frag?.error) {
|
|
355
|
+
throw new KeyHalveError(frag.error, `Fragment retrieval failed: ${frag.error}`, {
|
|
356
|
+
details: frag,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
if (!frag?.fragment_b) {
|
|
360
|
+
throw new KeyHalveError("missing_fragment", "Server did not return key fragment", {
|
|
361
|
+
details: frag,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
return frag.fragment_b;
|
|
365
|
+
}
|
|
366
|
+
/** Fetch the End-Cell server pieces from the public fragment endpoint (Layer 6B).
|
|
367
|
+
* Returns the pieces in the server-advertised holder order for XOR-combining. */
|
|
368
|
+
async fetchPieces(retrievalId) {
|
|
369
|
+
const frag = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}/fragment`, { auth: false });
|
|
370
|
+
if (frag?.error) {
|
|
371
|
+
throw new KeyHalveError(frag.error, `Fragment retrieval failed: ${frag.error}`, {
|
|
372
|
+
details: frag,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
if (!frag?.pieces || Object.keys(frag.pieces).length === 0) {
|
|
376
|
+
throw new KeyHalveError("missing_fragment", "Server did not return End-Cell pieces", {
|
|
377
|
+
details: frag,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const order = frag.holders?.length ? frag.holders : Object.keys(frag.pieces);
|
|
381
|
+
const pieces = order.map((h) => frag.pieces[h]);
|
|
382
|
+
if (pieces.some((p) => !p)) {
|
|
383
|
+
throw new KeyHalveError("missing_fragment", "An End-Cell piece was missing", { details: frag });
|
|
384
|
+
}
|
|
385
|
+
return pieces;
|
|
386
|
+
}
|
|
387
|
+
async verifySplitKeyIntent(retrievalId, shareA) {
|
|
388
|
+
if (!retrievalId) {
|
|
389
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
390
|
+
}
|
|
391
|
+
if (!shareA) {
|
|
392
|
+
throw new KeyHalveError("invalid_argument", "shareA is required");
|
|
393
|
+
}
|
|
394
|
+
const data = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}`, { auth: false });
|
|
395
|
+
if (data.status === "revoked" || !data.encrypted_payload) {
|
|
396
|
+
const reasonSuffix = data.revocation_reason ? `: ${data.revocation_reason}` : "";
|
|
397
|
+
throw new KeyHalveError("intent_revoked", `Intent ${retrievalId} has been revoked${reasonSuffix}`, {
|
|
398
|
+
details: {
|
|
399
|
+
intent_id: data.intent_id,
|
|
400
|
+
status: data.status,
|
|
401
|
+
revoked_at: data.revoked_at,
|
|
402
|
+
revocation_reason: data.revocation_reason,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const fullKey = combineKeyShares(shareA, await this.fetchFragmentB(retrievalId));
|
|
407
|
+
// M-5: reconstruct the AAD for v2 intents.
|
|
408
|
+
const decrypted = decrypt(data.encrypted_payload, fullKey, aadFor(data));
|
|
409
|
+
const integrityVerified = checkCommitment(data);
|
|
410
|
+
let payload;
|
|
411
|
+
try {
|
|
412
|
+
payload = JSON.parse(decrypted);
|
|
413
|
+
}
|
|
414
|
+
catch (cause) {
|
|
415
|
+
throw new KeyHalveError("invalid_payload", "Decrypted payload is not valid JSON", {
|
|
416
|
+
cause,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return buildVerifyResult(data, payload, integrityVerified);
|
|
420
|
+
}
|
|
421
|
+
// === Selective disclosure (Patent E) ===
|
|
422
|
+
async createSelectiveIntent(params) {
|
|
423
|
+
if (!params.documentType) {
|
|
424
|
+
throw new KeyHalveError("invalid_argument", "documentType is required");
|
|
425
|
+
}
|
|
426
|
+
if (!params.payload || Object.keys(params.payload).length === 0) {
|
|
427
|
+
throw new KeyHalveError("invalid_argument", "payload must be a non-empty object");
|
|
428
|
+
}
|
|
429
|
+
if (!params.disclosurePolicy || Object.keys(params.disclosurePolicy).length === 0) {
|
|
430
|
+
throw new KeyHalveError("invalid_argument", "disclosurePolicy must be a non-empty object");
|
|
431
|
+
}
|
|
432
|
+
validateTimeLock(params.validFrom, params.validUntil);
|
|
433
|
+
for (const [role, fields] of Object.entries(params.disclosurePolicy)) {
|
|
434
|
+
if (!Array.isArray(fields)) {
|
|
435
|
+
throw new KeyHalveError("invalid_argument", `disclosurePolicy['${role}'] must be an array`);
|
|
436
|
+
}
|
|
437
|
+
for (const f of fields) {
|
|
438
|
+
if (!(f in params.payload)) {
|
|
439
|
+
throw new KeyHalveError("invalid_argument", `Field '${f}' in role '${role}' not found in payload`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const masterKey = generateKey();
|
|
444
|
+
const { encryptedFields, fieldKeys } = encryptFields(params.payload);
|
|
445
|
+
const keyMap = buildKeyMap(fieldKeys, params.disclosurePolicy);
|
|
446
|
+
const encrypted_key_map = encrypt(JSON.stringify(keyMap), masterKey);
|
|
447
|
+
const envelope = JSON.stringify(encryptedFields);
|
|
448
|
+
// Commitment v2: hash the transported ciphertext envelope, not the
|
|
449
|
+
// plaintext (C-1). Role-independent at verify time.
|
|
450
|
+
const commitment_hash = commitmentHash(envelope);
|
|
451
|
+
let qrKey = masterKey;
|
|
452
|
+
let key_fragment_b;
|
|
453
|
+
if (params.splitKey) {
|
|
454
|
+
const [shareA, shareB] = splitKeyFn(masterKey);
|
|
455
|
+
qrKey = shareA;
|
|
456
|
+
key_fragment_b = shareB;
|
|
457
|
+
}
|
|
458
|
+
const body = {
|
|
459
|
+
document_type: params.documentType,
|
|
460
|
+
encrypted_payload: envelope,
|
|
461
|
+
commitment_hash,
|
|
462
|
+
selective_disclosure: true,
|
|
463
|
+
disclosure_policy: JSON.stringify(params.disclosurePolicy),
|
|
464
|
+
encrypted_key_map,
|
|
465
|
+
split_key: !!params.splitKey,
|
|
466
|
+
};
|
|
467
|
+
if (key_fragment_b !== undefined)
|
|
468
|
+
body["key_fragment_b"] = key_fragment_b;
|
|
469
|
+
if (params.validFrom !== undefined)
|
|
470
|
+
body["valid_from"] = params.validFrom;
|
|
471
|
+
if (params.validUntil !== undefined)
|
|
472
|
+
body["valid_until"] = params.validUntil;
|
|
473
|
+
if (params.onBehalfOf !== undefined)
|
|
474
|
+
body["on_behalf_of"] = params.onBehalfOf;
|
|
475
|
+
const data = await this.request("POST", "/v1/intent", {
|
|
476
|
+
body,
|
|
477
|
+
auth: true,
|
|
478
|
+
});
|
|
479
|
+
if (!data?.retrieval_id) {
|
|
480
|
+
throw new KeyHalveError("invalid_response", "API response missing retrieval_id", {
|
|
481
|
+
details: data,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return { retrievalId: data.retrieval_id, key: qrKey };
|
|
485
|
+
}
|
|
486
|
+
async verifySelectiveIntent(retrievalId, key, role = "full") {
|
|
487
|
+
if (!retrievalId) {
|
|
488
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
489
|
+
}
|
|
490
|
+
if (!key) {
|
|
491
|
+
throw new KeyHalveError("invalid_argument", "key is required");
|
|
492
|
+
}
|
|
493
|
+
const data = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}`, { auth: false });
|
|
494
|
+
if (data.status === "revoked" || !data.encrypted_payload) {
|
|
495
|
+
const reasonSuffix = data.revocation_reason ? `: ${data.revocation_reason}` : "";
|
|
496
|
+
throw new KeyHalveError("intent_revoked", `Intent ${retrievalId} has been revoked${reasonSuffix}`, {
|
|
497
|
+
details: {
|
|
498
|
+
intent_id: data.intent_id,
|
|
499
|
+
status: data.status,
|
|
500
|
+
revoked_at: data.revoked_at,
|
|
501
|
+
revocation_reason: data.revocation_reason,
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
let masterKey = key;
|
|
506
|
+
if (data.split_key) {
|
|
507
|
+
const frag = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}/fragment`, { auth: false });
|
|
508
|
+
if (frag?.error) {
|
|
509
|
+
throw new KeyHalveError(frag.error, `Fragment retrieval failed: ${frag.error}`, {
|
|
510
|
+
details: frag,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
if (!frag?.fragment_b) {
|
|
514
|
+
throw new KeyHalveError("missing_fragment", "Server did not return key fragment", {
|
|
515
|
+
details: frag,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
masterKey = combineKeyShares(key, frag.fragment_b);
|
|
519
|
+
}
|
|
520
|
+
if (!data.encrypted_key_map) {
|
|
521
|
+
throw new KeyHalveError("invalid_response", "Selective disclosure intent missing encrypted_key_map");
|
|
522
|
+
}
|
|
523
|
+
const keyMapJson = decrypt(data.encrypted_key_map, masterKey);
|
|
524
|
+
let keyMap;
|
|
525
|
+
try {
|
|
526
|
+
keyMap = JSON.parse(keyMapJson);
|
|
527
|
+
}
|
|
528
|
+
catch (cause) {
|
|
529
|
+
throw new KeyHalveError("invalid_payload", "Decrypted key map is not valid JSON", {
|
|
530
|
+
cause,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
if (!(role in keyMap)) {
|
|
534
|
+
const available = Object.keys(keyMap).sort().join(", ");
|
|
535
|
+
throw new KeyHalveError("invalid_role", `Role '${role}' is not defined in this document's disclosure policy. Available roles: ${available}`);
|
|
536
|
+
}
|
|
537
|
+
const fieldKeys = keyMap[role];
|
|
538
|
+
let encryptedFields;
|
|
539
|
+
try {
|
|
540
|
+
encryptedFields = JSON.parse(data.encrypted_payload);
|
|
541
|
+
}
|
|
542
|
+
catch (cause) {
|
|
543
|
+
throw new KeyHalveError("invalid_payload", "Encrypted payload is not a valid JSON envelope", { cause });
|
|
544
|
+
}
|
|
545
|
+
const payload = decryptFields(encryptedFields, fieldKeys);
|
|
546
|
+
// Commitment over the ciphertext envelope (C-1) — role-independent now,
|
|
547
|
+
// so any role gets integrity verification. Legacy v1 intents skip it.
|
|
548
|
+
const integrityVerified = checkCommitment(data);
|
|
549
|
+
return buildVerifyResult(data, payload, integrityVerified);
|
|
550
|
+
}
|
|
551
|
+
// === Revocation (Patent H) ===
|
|
552
|
+
async revokeIntent(retrievalId, reason) {
|
|
553
|
+
if (!retrievalId) {
|
|
554
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
555
|
+
}
|
|
556
|
+
const data = await this.request("PATCH", `/v1/intent/${encodeURIComponent(retrievalId)}/revoke`, { body: reason ? { reason } : {}, auth: true });
|
|
557
|
+
return {
|
|
558
|
+
intentId: data?.intent_id ?? retrievalId,
|
|
559
|
+
status: data?.status ?? "revoked",
|
|
560
|
+
revokedAt: data?.revoked_at,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
async reinstateIntent(retrievalId, reason) {
|
|
564
|
+
if (!retrievalId) {
|
|
565
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
566
|
+
}
|
|
567
|
+
const data = await this.request("PATCH", `/v1/intent/${encodeURIComponent(retrievalId)}/reinstate`, { body: reason ? { reason } : {}, auth: true });
|
|
568
|
+
return {
|
|
569
|
+
intentId: data?.intent_id ?? retrievalId,
|
|
570
|
+
status: data?.status ?? "active",
|
|
571
|
+
reinstatedAt: data?.reinstated_at,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
async getRevocationHistory(retrievalId) {
|
|
575
|
+
if (!retrievalId) {
|
|
576
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
577
|
+
}
|
|
578
|
+
const data = await this.request("GET", `/v1/intent/${encodeURIComponent(retrievalId)}/revocations`, { auth: true });
|
|
579
|
+
if (!Array.isArray(data?.events))
|
|
580
|
+
return [];
|
|
581
|
+
return data.events.map((e) => ({
|
|
582
|
+
id: e.id,
|
|
583
|
+
action: e.action,
|
|
584
|
+
reason: e.reason,
|
|
585
|
+
performedAt: e.performed_at,
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
// === Audit / list (Prompt 080) ===
|
|
589
|
+
/**
|
|
590
|
+
* List the intents this API key has created. Returns metadata only —
|
|
591
|
+
* the AES payload + key are NEVER part of the response, by design.
|
|
592
|
+
* Use this for audit, reconciliation, and "did this intent get
|
|
593
|
+
* scanned?" dashboards.
|
|
594
|
+
*/
|
|
595
|
+
async listIntents(params = {}) {
|
|
596
|
+
const qs = new URLSearchParams();
|
|
597
|
+
if (params.limit !== undefined)
|
|
598
|
+
qs.set("limit", String(params.limit));
|
|
599
|
+
if (params.offset !== undefined)
|
|
600
|
+
qs.set("offset", String(params.offset));
|
|
601
|
+
if (params.since !== undefined)
|
|
602
|
+
qs.set("since", params.since);
|
|
603
|
+
if (params.until !== undefined)
|
|
604
|
+
qs.set("until", params.until);
|
|
605
|
+
if (params.status !== undefined)
|
|
606
|
+
qs.set("status", params.status);
|
|
607
|
+
if (params.documentType !== undefined)
|
|
608
|
+
qs.set("document_type", params.documentType);
|
|
609
|
+
if (params.order !== undefined)
|
|
610
|
+
qs.set("order", params.order);
|
|
611
|
+
const path = qs.size > 0 ? `/v1/intents?${qs.toString()}` : "/v1/intents";
|
|
612
|
+
const data = await this.request("GET", path, { auth: true });
|
|
613
|
+
return {
|
|
614
|
+
intents: (data?.intents ?? []).map(mapMetadata),
|
|
615
|
+
total: data?.total ?? 0,
|
|
616
|
+
limit: data?.limit ?? params.limit ?? 50,
|
|
617
|
+
offset: data?.offset ?? params.offset ?? 0,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Fetch metadata for a single intent. Distinct from `verifyIntent` —
|
|
622
|
+
* this endpoint never returns ciphertext or key material, so it's
|
|
623
|
+
* safe to call from any service that just needs status / verification
|
|
624
|
+
* counts / revocation state.
|
|
625
|
+
*/
|
|
626
|
+
async getIntent(retrievalId) {
|
|
627
|
+
if (!retrievalId) {
|
|
628
|
+
throw new KeyHalveError("invalid_argument", "retrievalId is required");
|
|
629
|
+
}
|
|
630
|
+
const data = await this.request("GET", `/v1/intents/${encodeURIComponent(retrievalId)}`, { auth: true });
|
|
631
|
+
return mapMetadata(data);
|
|
632
|
+
}
|
|
633
|
+
// === Health ===
|
|
634
|
+
async health() {
|
|
635
|
+
return this.request("GET", "/health", {
|
|
636
|
+
auth: false,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
// === HTTP ===
|
|
640
|
+
async request(method, path, opts) {
|
|
641
|
+
const url = `${this.baseUrl}${path}`;
|
|
642
|
+
const headers = { Accept: "application/json" };
|
|
643
|
+
if (opts.auth)
|
|
644
|
+
headers["Authorization"] = `Bearer ${this.apiKey}`;
|
|
645
|
+
if (opts.body !== undefined)
|
|
646
|
+
headers["Content-Type"] = "application/json";
|
|
647
|
+
const controller = new AbortController();
|
|
648
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
649
|
+
let response;
|
|
650
|
+
try {
|
|
651
|
+
response = await this.fetchImpl(url, {
|
|
652
|
+
method,
|
|
653
|
+
headers,
|
|
654
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
655
|
+
signal: controller.signal,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch (cause) {
|
|
659
|
+
throw new KeyHalveError("network_error", `Request to ${url} failed`, { cause });
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
clearTimeout(timer);
|
|
663
|
+
}
|
|
664
|
+
const text = await response.text();
|
|
665
|
+
let json = undefined;
|
|
666
|
+
if (text) {
|
|
667
|
+
try {
|
|
668
|
+
json = JSON.parse(text);
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
// leave undefined
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
const errBody = (json ?? text);
|
|
676
|
+
const code = typeof errBody === "object" && errBody && typeof errBody.error === "string"
|
|
677
|
+
? errBody.error
|
|
678
|
+
: "http_error";
|
|
679
|
+
throw new KeyHalveError(code, `platform API ${method} ${path} failed: ${response.status}`, { status: response.status, details: errBody });
|
|
680
|
+
}
|
|
681
|
+
return json;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// === Helpers ===
|
|
685
|
+
/**
|
|
686
|
+
* Version-aware commitment check (Prompt 097 C-1). v2 commitments are
|
|
687
|
+
* SHA-256(ciphertext): recompute over the received blob and compare. v1
|
|
688
|
+
* (legacy SHA-256(plaintext)) is a confirmation-oracle risk and is skipped —
|
|
689
|
+
* those documents expire naturally. Throws on a v2 mismatch.
|
|
690
|
+
*/
|
|
691
|
+
function checkCommitment(data) {
|
|
692
|
+
if (!data.commitment_hash || !data.encrypted_payload)
|
|
693
|
+
return false;
|
|
694
|
+
if ((data.commitment_version ?? 1) < 2)
|
|
695
|
+
return false;
|
|
696
|
+
const actual = commitmentHash(data.encrypted_payload);
|
|
697
|
+
if (actual !== data.commitment_hash) {
|
|
698
|
+
throw new KeyHalveError("integrity_failure", "INTEGRITY VERIFICATION FAILED — the ciphertext does not match the commitment hash recorded at issuance. This document may have been tampered with.");
|
|
699
|
+
}
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* AAD to pass to decrypt for a verify response (Prompt 097 M-5). v2 intents
|
|
704
|
+
* reconstruct it from the server-returned metadata so altered document_type
|
|
705
|
+
* or validity window fails the GCM tag check. undefined for legacy v1.
|
|
706
|
+
*/
|
|
707
|
+
function aadFor(data) {
|
|
708
|
+
if ((data.encryption_version ?? 1) < 2)
|
|
709
|
+
return undefined;
|
|
710
|
+
return buildAad(data.document_type ?? "", data.valid_from, data.valid_until);
|
|
711
|
+
}
|
|
712
|
+
function computeTimeLockStatus(validFrom, validUntil) {
|
|
713
|
+
if (!validFrom && !validUntil)
|
|
714
|
+
return null;
|
|
715
|
+
const now = Date.now();
|
|
716
|
+
if (validFrom) {
|
|
717
|
+
const t = Date.parse(validFrom);
|
|
718
|
+
if (!Number.isNaN(t) && now < t)
|
|
719
|
+
return "not_yet_valid";
|
|
720
|
+
}
|
|
721
|
+
if (validUntil) {
|
|
722
|
+
const t = Date.parse(validUntil);
|
|
723
|
+
if (!Number.isNaN(t) && now > t)
|
|
724
|
+
return "expired";
|
|
725
|
+
}
|
|
726
|
+
return "valid";
|
|
727
|
+
}
|
|
728
|
+
function validateTimeLock(validFrom, validUntil) {
|
|
729
|
+
if (validFrom !== undefined && Number.isNaN(Date.parse(validFrom))) {
|
|
730
|
+
throw new KeyHalveError("invalid_argument", `validFrom is not a valid ISO-8601: ${validFrom}`);
|
|
731
|
+
}
|
|
732
|
+
if (validUntil !== undefined && Number.isNaN(Date.parse(validUntil))) {
|
|
733
|
+
throw new KeyHalveError("invalid_argument", `validUntil is not a valid ISO-8601: ${validUntil}`);
|
|
734
|
+
}
|
|
735
|
+
if (validFrom !== undefined && validUntil !== undefined) {
|
|
736
|
+
if (Date.parse(validFrom) >= Date.parse(validUntil)) {
|
|
737
|
+
throw new KeyHalveError("invalid_argument", "validFrom must be before validUntil");
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function buildVerifyResult(data, payload, integrityVerified) {
|
|
742
|
+
return {
|
|
743
|
+
intentId: data.intent_id,
|
|
744
|
+
payload,
|
|
745
|
+
issuer: data.issuer,
|
|
746
|
+
issuerVerified: data.issuer_verified,
|
|
747
|
+
registeredAt: data.registered_at,
|
|
748
|
+
status: data.status,
|
|
749
|
+
integrityVerified,
|
|
750
|
+
validFrom: data.valid_from ?? null,
|
|
751
|
+
validUntil: data.valid_until ?? null,
|
|
752
|
+
timeLockStatus: computeTimeLockStatus(data.valid_from, data.valid_until),
|
|
753
|
+
verificationLevel: data.verification_level,
|
|
754
|
+
delegatedBy: data.delegated_by
|
|
755
|
+
? {
|
|
756
|
+
platform: data.delegated_by.platform,
|
|
757
|
+
platformLevel: data.delegated_by.platform_level,
|
|
758
|
+
}
|
|
759
|
+
: null,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function mapMetadata(raw) {
|
|
763
|
+
return {
|
|
764
|
+
retrievalId: raw.retrieval_id,
|
|
765
|
+
documentType: raw.document_type,
|
|
766
|
+
status: raw.status,
|
|
767
|
+
createdAt: raw.created_at,
|
|
768
|
+
revokedAt: raw.revoked_at,
|
|
769
|
+
revocationReason: raw.revocation_reason,
|
|
770
|
+
validFrom: raw.valid_from,
|
|
771
|
+
validUntil: raw.valid_until,
|
|
772
|
+
commitmentHash: raw.commitment_hash,
|
|
773
|
+
splitKey: raw.split_key,
|
|
774
|
+
selectiveDisclosure: raw.selective_disclosure,
|
|
775
|
+
verificationCount: raw.verification_count,
|
|
776
|
+
lastVerifiedAt: raw.last_verified_at,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
//# sourceMappingURL=client.js.map
|