@dorigjo/besa 0.1.0-alpha.1 → 0.1.0-beta.4

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/dist/signing.js CHANGED
@@ -1,100 +1,386 @@
1
- import { randomUUID, sign as ed25519Sign, verify as ed25519Verify } from "node:crypto";
2
- import { canonicalize, hashObject, privateKeyFromDer, publicKeyFromDer, publicKeyId } from "./crypto.js";
1
+ import { randomUUID, sign as ed25519Sign, verify as ed25519Verify, } from "node:crypto";
2
+ import { canonicalize, isCanonicalBase64, privateKeyFromDer, publicKeyFromDer, publicKeyId, sha256Hex, signatureMessage, validateKeyPair, } from "./crypto.js";
3
+ import { validateManifest } from "./manifest.js";
4
+ const SHA256_HEX = /^[a-f0-9]{64}$/;
5
+ const KEY_ID = /^[a-f0-9]{64}$/;
6
+ const RECEIPT_ID = /^rcpt_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
+ const REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
8
+ const MAX_IDENTIFIER_LENGTH = 256;
9
+ function manifestSignaturePayload(signed) {
10
+ return {
11
+ artifactVersion: signed.artifactVersion,
12
+ manifest: signed.manifest,
13
+ manifestHash: signed.manifestHash,
14
+ algorithm: signed.algorithm,
15
+ publicKey: signed.publicKey,
16
+ publicKeyId: signed.publicKeyId,
17
+ signedAt: signed.signedAt,
18
+ };
19
+ }
20
+ function isObject(value) {
21
+ return value !== null && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+ function isNonEmptyString(value, maximumLength = MAX_IDENTIFIER_LENGTH) {
24
+ return (typeof value === "string" &&
25
+ value.length <= maximumLength &&
26
+ value.trim().length > 0);
27
+ }
28
+ function isIsoDate(value) {
29
+ return (typeof value === "string" &&
30
+ value.length <= 35 &&
31
+ !Number.isNaN(Date.parse(value)) &&
32
+ new Date(value).toISOString() === value);
33
+ }
34
+ function isSignature(value) {
35
+ return (typeof value === "string" &&
36
+ value.length === 88 &&
37
+ isCanonicalBase64(value) &&
38
+ Buffer.from(value, "base64").length === 64);
39
+ }
40
+ function isPublicKeyEncoding(value) {
41
+ return (typeof value === "string" &&
42
+ value.length <= 128 &&
43
+ isCanonicalBase64(value));
44
+ }
45
+ export function validateSignedManifest(value) {
46
+ if (!isObject(value)) {
47
+ return {
48
+ ok: false,
49
+ errors: ["signed manifest must be an object"],
50
+ };
51
+ }
52
+ const errors = [];
53
+ const allowedFields = new Set([
54
+ "artifactVersion",
55
+ "manifest",
56
+ "manifestHash",
57
+ "algorithm",
58
+ "publicKey",
59
+ "publicKeyId",
60
+ "signature",
61
+ "signedAt",
62
+ ]);
63
+ for (const field of Object.keys(value)) {
64
+ if (!allowedFields.has(field)) {
65
+ errors.push(`unexpected signed manifest field '${field}'`);
66
+ }
67
+ }
68
+ if (value.artifactVersion !== 1) {
69
+ errors.push("artifactVersion must be 1");
70
+ }
71
+ const manifestResult = validateManifest(value.manifest);
72
+ if (!manifestResult.ok) {
73
+ errors.push(...manifestResult.errors.map((error) => `manifest.${error}`));
74
+ }
75
+ if (typeof value.manifestHash !== "string" || !SHA256_HEX.test(value.manifestHash)) {
76
+ errors.push("manifestHash must be a lowercase SHA-256 hex digest");
77
+ }
78
+ if (value.algorithm !== "ed25519") {
79
+ errors.push("algorithm must be ed25519");
80
+ }
81
+ if (!isPublicKeyEncoding(value.publicKey)) {
82
+ errors.push("publicKey must be canonical base64");
83
+ }
84
+ if (typeof value.publicKeyId !== "string" || !KEY_ID.test(value.publicKeyId)) {
85
+ errors.push("publicKeyId must be a 64-character lowercase SHA-256 fingerprint");
86
+ }
87
+ if (!isSignature(value.signature)) {
88
+ errors.push("signature must be a canonical base64 Ed25519 signature");
89
+ }
90
+ if (!isIsoDate(value.signedAt)) {
91
+ errors.push("signedAt must be a canonical ISO-8601 timestamp");
92
+ }
93
+ if (errors.length > 0 || !manifestResult.manifest) {
94
+ return {
95
+ ok: false,
96
+ errors,
97
+ };
98
+ }
99
+ return {
100
+ ok: true,
101
+ signedManifest: value,
102
+ errors: [],
103
+ };
104
+ }
105
+ export function validateReceipt(value) {
106
+ if (!isObject(value)) {
107
+ return {
108
+ ok: false,
109
+ errors: ["receipt must be an object"],
110
+ };
111
+ }
112
+ const errors = [];
113
+ const allowedFields = new Set([
114
+ "artifactVersion",
115
+ "receiptId",
116
+ "manifestHash",
117
+ "toolName",
118
+ "decision",
119
+ "reasonCode",
120
+ "timestamp",
121
+ "requestHash",
122
+ "publicKeyId",
123
+ "algorithm",
124
+ "agentId",
125
+ "grantReasonCode",
126
+ "signature",
127
+ ]);
128
+ for (const field of Object.keys(value)) {
129
+ if (!allowedFields.has(field)) {
130
+ errors.push(`unexpected receipt field '${field}'`);
131
+ }
132
+ }
133
+ if (value.artifactVersion !== 1) {
134
+ errors.push("artifactVersion must be 1");
135
+ }
136
+ if (typeof value.receiptId !== "string" || !RECEIPT_ID.test(value.receiptId)) {
137
+ errors.push("receiptId must be rcpt_ followed by a canonical UUIDv4");
138
+ }
139
+ if (typeof value.manifestHash !== "string" || !SHA256_HEX.test(value.manifestHash)) {
140
+ errors.push("manifestHash must be a lowercase SHA-256 hex digest");
141
+ }
142
+ if (!isNonEmptyString(value.toolName)) {
143
+ errors.push("toolName must be a non-empty string");
144
+ }
145
+ if (value.decision !== "allow" && value.decision !== "deny") {
146
+ errors.push("decision must be allow or deny");
147
+ }
148
+ if (typeof value.reasonCode !== "string" || !REASON_CODE.test(value.reasonCode)) {
149
+ errors.push("reasonCode must be an uppercase machine-readable code");
150
+ }
151
+ if (!isIsoDate(value.timestamp)) {
152
+ errors.push("timestamp must be a canonical ISO-8601 timestamp");
153
+ }
154
+ if (typeof value.requestHash !== "string" || !SHA256_HEX.test(value.requestHash)) {
155
+ errors.push("requestHash must be a lowercase SHA-256 hex digest");
156
+ }
157
+ if (typeof value.publicKeyId !== "string" || !KEY_ID.test(value.publicKeyId)) {
158
+ errors.push("publicKeyId must be a 64-character lowercase SHA-256 fingerprint");
159
+ }
160
+ if (value.algorithm !== "ed25519") {
161
+ errors.push("algorithm must be ed25519");
162
+ }
163
+ if (value.agentId !== undefined && !isNonEmptyString(value.agentId)) {
164
+ errors.push("agentId must be a non-empty string when present");
165
+ }
166
+ if (value.grantReasonCode !== undefined &&
167
+ (typeof value.grantReasonCode !== "string" ||
168
+ !REASON_CODE.test(value.grantReasonCode))) {
169
+ errors.push("grantReasonCode must be a non-empty string when present");
170
+ }
171
+ if (!isSignature(value.signature)) {
172
+ errors.push("signature must be a canonical base64 Ed25519 signature");
173
+ }
174
+ if (errors.length > 0) {
175
+ return {
176
+ ok: false,
177
+ errors,
178
+ };
179
+ }
180
+ return {
181
+ ok: true,
182
+ receipt: value,
183
+ errors: [],
184
+ };
185
+ }
3
186
  export function hashManifest(manifest) {
4
- return hashObject(manifest);
187
+ return sha256Hex(`besa:manifest:v1\0${canonicalize(manifest)}`);
188
+ }
189
+ export function hashRequest(request) {
190
+ return sha256Hex(`besa:request:v1\0${canonicalize(request)}`);
5
191
  }
6
192
  export function signManifest(manifest, keypair) {
7
- const canonical = canonicalize(manifest);
8
- const signature = ed25519Sign(null, Buffer.from(canonical, "utf8"), privateKeyFromDer(keypair.privateKeyDer));
9
- return {
10
- manifest,
11
- manifestHash: hashManifest(manifest),
193
+ const validation = validateManifest(manifest);
194
+ if (!validation.ok || !validation.manifest) {
195
+ throw new Error(`Invalid manifest:\n - ${validation.errors.join("\n - ")}`);
196
+ }
197
+ if (!validateKeyPair(keypair)) {
198
+ throw new Error("invalid or mismatched Ed25519 key pair");
199
+ }
200
+ const body = {
201
+ artifactVersion: 1,
202
+ manifest: validation.manifest,
203
+ manifestHash: hashManifest(validation.manifest),
12
204
  algorithm: "ed25519",
13
205
  publicKey: keypair.publicKeyDer,
14
206
  publicKeyId: publicKeyId(keypair.publicKeyDer),
207
+ signedAt: new Date().toISOString(),
208
+ };
209
+ const signature = ed25519Sign(null, signatureMessage("signed-manifest", manifestSignaturePayload(body)), privateKeyFromDer(keypair.privateKeyDer));
210
+ return {
211
+ ...body,
15
212
  signature: signature.toString("base64"),
16
- signedAt: new Date().toISOString()
17
213
  };
18
214
  }
19
- export function verifySignedManifest(signed) {
20
- if (signed.algorithm !== "ed25519") {
215
+ export function verifySignedManifest(value) {
216
+ if (isObject(value) &&
217
+ value.artifactVersion !== undefined &&
218
+ value.artifactVersion !== 1) {
219
+ return {
220
+ valid: false,
221
+ reasonCode: "E_ARTIFACT_VERSION_UNSUPPORTED",
222
+ detail: "only signed manifest artifactVersion 1 is supported",
223
+ };
224
+ }
225
+ if (isObject(value) &&
226
+ typeof value.algorithm === "string" &&
227
+ value.algorithm !== "ed25519") {
21
228
  return {
22
229
  valid: false,
23
230
  reasonCode: "E_ALGORITHM_UNSUPPORTED",
24
- detail: "only ed25519 signed manifests are supported"
231
+ detail: "only ed25519 signed manifests are supported",
232
+ };
233
+ }
234
+ const validation = validateSignedManifest(value);
235
+ if (!validation.ok || !validation.signedManifest) {
236
+ return {
237
+ valid: false,
238
+ reasonCode: "E_SIGNED_MANIFEST_INVALID",
239
+ detail: validation.errors.join("; "),
25
240
  };
26
241
  }
27
- const canonical = canonicalize(signed.manifest);
242
+ const signed = validation.signedManifest;
28
243
  const expectedHash = hashManifest(signed.manifest);
29
244
  if (expectedHash !== signed.manifestHash) {
30
245
  return {
31
246
  valid: false,
32
247
  reasonCode: "E_MANIFEST_HASH_MISMATCH",
33
- detail: "manifest content does not match stored hash"
248
+ detail: "manifest content does not match stored hash",
34
249
  };
35
250
  }
36
251
  if (publicKeyId(signed.publicKey) !== signed.publicKeyId) {
37
252
  return {
38
253
  valid: false,
39
254
  reasonCode: "E_PUBLIC_KEY_ID_MISMATCH",
40
- detail: "publicKeyId does not match publicKey"
255
+ detail: "publicKeyId does not match publicKey",
41
256
  };
42
257
  }
43
258
  try {
44
- const valid = ed25519Verify(null, Buffer.from(canonical, "utf8"), publicKeyFromDer(signed.publicKey), Buffer.from(signed.signature, "base64"));
259
+ const valid = ed25519Verify(null, signatureMessage("signed-manifest", manifestSignaturePayload(signed)), publicKeyFromDer(signed.publicKey), Buffer.from(signed.signature, "base64"));
45
260
  if (!valid) {
46
261
  return {
47
262
  valid: false,
48
263
  reasonCode: "E_SIGNATURE_INVALID",
49
- detail: "signature does not verify against the public key"
264
+ detail: "signature does not verify against the public key",
50
265
  };
51
266
  }
52
267
  return {
53
268
  valid: true,
54
269
  reasonCode: "OK",
55
- detail: "manifest signature is valid"
270
+ detail: "manifest signature is valid",
56
271
  };
57
272
  }
58
273
  catch {
59
274
  return {
60
275
  valid: false,
61
276
  reasonCode: "E_SIGNATURE_CHECK_FAILED",
62
- detail: "signature verification failed"
277
+ detail: "signature verification failed",
63
278
  };
64
279
  }
65
280
  }
66
281
  export function createReceipt(input, keypair) {
282
+ if (!SHA256_HEX.test(input.manifestHash)) {
283
+ throw new Error("manifestHash must be a lowercase SHA-256 hex digest");
284
+ }
285
+ if (!isNonEmptyString(input.toolName)) {
286
+ throw new Error("toolName must be a non-empty string");
287
+ }
288
+ if (!REASON_CODE.test(input.reasonCode)) {
289
+ throw new Error("reasonCode must be an uppercase machine-readable code");
290
+ }
291
+ if (input.decision !== "allow" && input.decision !== "deny") {
292
+ throw new Error("decision must be allow or deny");
293
+ }
294
+ if (input.agentId !== undefined && !isNonEmptyString(input.agentId)) {
295
+ throw new Error("agentId must be a non-empty string when present");
296
+ }
297
+ if (input.grantReasonCode !== undefined &&
298
+ !REASON_CODE.test(input.grantReasonCode)) {
299
+ throw new Error("grantReasonCode must be a non-empty string when present");
300
+ }
301
+ if (!validateKeyPair(keypair)) {
302
+ throw new Error("invalid or mismatched Ed25519 key pair");
303
+ }
67
304
  const body = {
305
+ artifactVersion: 1,
68
306
  receiptId: "rcpt_" + randomUUID(),
69
307
  manifestHash: input.manifestHash,
70
308
  toolName: input.toolName,
71
309
  decision: input.decision,
72
310
  reasonCode: input.reasonCode,
73
311
  timestamp: new Date().toISOString(),
74
- requestHash: hashObject(input.request ?? {}),
75
- agentId: input.agentId,
76
- grantReasonCode: input.grantReasonCode,
312
+ requestHash: hashRequest(input.request === undefined ? {} : input.request),
313
+ ...(input.agentId === undefined ? {} : { agentId: input.agentId }),
314
+ ...(input.grantReasonCode === undefined
315
+ ? {}
316
+ : { grantReasonCode: input.grantReasonCode }),
77
317
  publicKeyId: publicKeyId(keypair.publicKeyDer),
78
- algorithm: "ed25519"
318
+ algorithm: "ed25519",
79
319
  };
80
- const signature = ed25519Sign(null, Buffer.from(canonicalize(body), "utf8"), privateKeyFromDer(keypair.privateKeyDer));
320
+ const signature = ed25519Sign(null, signatureMessage("receipt", body), privateKeyFromDer(keypair.privateKeyDer));
81
321
  return {
82
322
  ...body,
83
- signature: signature.toString("base64")
323
+ signature: signature.toString("base64"),
84
324
  };
85
325
  }
86
- export function verifyReceipt(receipt, publicKeyDer) {
87
- if (receipt.algorithm !== "ed25519") {
88
- return false;
326
+ export function verifyReceiptDetailed(value, publicKeyDer) {
327
+ if (isObject(value) &&
328
+ value.artifactVersion !== undefined &&
329
+ value.artifactVersion !== 1) {
330
+ return {
331
+ valid: false,
332
+ reasonCode: "E_ARTIFACT_VERSION_UNSUPPORTED",
333
+ detail: "only receipt artifactVersion 1 is supported",
334
+ };
89
335
  }
336
+ if (isObject(value) &&
337
+ typeof value.algorithm === "string" &&
338
+ value.algorithm !== "ed25519") {
339
+ return {
340
+ valid: false,
341
+ reasonCode: "E_ALGORITHM_UNSUPPORTED",
342
+ detail: "only ed25519 receipts are supported",
343
+ };
344
+ }
345
+ const validation = validateReceipt(value);
346
+ if (!validation.ok || !validation.receipt) {
347
+ return {
348
+ valid: false,
349
+ reasonCode: "E_RECEIPT_INVALID",
350
+ detail: validation.errors.join("; "),
351
+ };
352
+ }
353
+ const receipt = validation.receipt;
90
354
  if (publicKeyId(publicKeyDer) !== receipt.publicKeyId) {
91
- return false;
355
+ return {
356
+ valid: false,
357
+ reasonCode: "E_PUBLIC_KEY_ID_MISMATCH",
358
+ detail: "receipt publicKeyId does not match public key",
359
+ };
92
360
  }
93
361
  const { signature, ...body } = receipt;
94
362
  try {
95
- return ed25519Verify(null, Buffer.from(canonicalize(body), "utf8"), publicKeyFromDer(publicKeyDer), Buffer.from(signature, "base64"));
363
+ const valid = ed25519Verify(null, signatureMessage("receipt", body), publicKeyFromDer(publicKeyDer), Buffer.from(signature, "base64"));
364
+ return valid
365
+ ? {
366
+ valid: true,
367
+ reasonCode: "OK",
368
+ detail: "receipt signature is valid",
369
+ }
370
+ : {
371
+ valid: false,
372
+ reasonCode: "E_SIGNATURE_INVALID",
373
+ detail: "receipt signature does not verify against the public key",
374
+ };
96
375
  }
97
376
  catch {
98
- return false;
377
+ return {
378
+ valid: false,
379
+ reasonCode: "E_SIGNATURE_CHECK_FAILED",
380
+ detail: "receipt signature verification failed",
381
+ };
99
382
  }
100
383
  }
384
+ export function verifyReceipt(receipt, publicKeyDer) {
385
+ return verifyReceiptDetailed(receipt, publicKeyDer).valid;
386
+ }
@@ -0,0 +1,17 @@
1
+ import type { KeyRotation, TrustStore } from "./types.js";
2
+ import { type KeyPair } from "./crypto.js";
3
+ import { type VerifyResult } from "./signing.js";
4
+ export interface TrustStoreValidationResult {
5
+ ok: boolean;
6
+ trustStore?: TrustStore;
7
+ errors: string[];
8
+ }
9
+ export declare function validateTrustStore(value: unknown): TrustStoreValidationResult;
10
+ export declare function emptyTrustStore(): TrustStore;
11
+ export declare function addTrustAnchor(store: TrustStore, publicKey: string, addedAt?: string): TrustStore;
12
+ export declare function revokeTrustAnchor(store: TrustStore, keyId: string, revokedAt?: string): TrustStore;
13
+ export declare function createKeyRotation(previous: KeyPair, next: KeyPair, rotatedAt?: string): KeyRotation;
14
+ export declare function verifyKeyRotation(value: unknown): VerifyResult;
15
+ export declare function applyKeyRotation(store: TrustStore, rotation: KeyRotation): TrustStore;
16
+ export declare function checkTrustedKey(store: TrustStore, publicKey: string, artifactTimestamp: string, purpose?: "verify" | "admit", now?: Date): VerifyResult;
17
+ export declare function verifyTrustedSignedManifest(value: unknown, store: TrustStore, purpose?: "verify" | "admit"): VerifyResult;