@aikdna/kdna-core 0.20.0 → 0.22.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.
@@ -278,6 +278,18 @@ function ensureArgon2id() {
278
278
  }
279
279
  }
280
280
 
281
+ const MAX_ARGON2_MEMORY_KIB = 262144; // 256 MiB
282
+ const MAX_ARGON2_ITERATIONS = 16;
283
+ const MAX_ARGON2_PARALLELISM = 8;
284
+
285
+ function boundedArgon2Param(value, name, max) {
286
+ if (value === undefined) return undefined;
287
+ if (!Number.isSafeInteger(value) || value < 1 || value > max) {
288
+ throw new Error(`unsupported Argon2id ${name}: must be an integer between 1 and ${max}`);
289
+ }
290
+ return value;
291
+ }
292
+
281
293
  function derivePasswordKey(password, params = {}) {
282
294
  ensureArgon2id();
283
295
  const {
@@ -287,12 +299,15 @@ function derivePasswordKey(password, params = {}) {
287
299
  parallelism = 4,
288
300
  } = params;
289
301
  if (!salt) throw new Error('salt is required for Argon2id');
302
+ const boundedMemory = boundedArgon2Param(memory_kib, 'memory_kib', MAX_ARGON2_MEMORY_KIB);
303
+ const boundedIterations = boundedArgon2Param(iterations, 'iterations', MAX_ARGON2_ITERATIONS);
304
+ const boundedParallelism = boundedArgon2Param(parallelism, 'parallelism', MAX_ARGON2_PARALLELISM);
290
305
  const saltBuf = decodeBase64(salt, 'salt');
291
306
  const passwordBuf = toBuffer(password, 'password');
292
307
  const key = argon2id(passwordBuf, saltBuf, {
293
- t: iterations,
294
- m: memory_kib,
295
- p: parallelism,
308
+ t: boundedIterations,
309
+ m: boundedMemory,
310
+ p: boundedParallelism,
296
311
  dkLen: 32,
297
312
  });
298
313
  return Buffer.from(key);
@@ -7,11 +7,12 @@
7
7
  */
8
8
 
9
9
  const crypto = require('node:crypto');
10
- const path = require('node:path');
11
10
  const JsonSchema2020 = require('ajv/dist/2020');
12
11
  const addFormats = require('ajv-formats');
13
12
  const cbor = require('cbor-x');
14
13
  const { hkdfSha256, aesWrap, aesUnwrap } = require('./crypto-profile');
14
+ const externalEnvelopeSchema = require('../schema/external-grant-envelope.schema.json');
15
+ const externalKeyGrantSchema = require('../schema/external-key-grant.schema.json');
15
16
 
16
17
  const EXTERNAL_ENVELOPE_PROFILE = 'kdna.envelope.external-grant';
17
18
  const EXTERNAL_GRANT_PROFILE = 'kdna.grant.external-key';
@@ -21,15 +22,10 @@ const EXTERNAL_GRANT_CONTRACT_VERSION = '0.1.0';
21
22
  const ENVELOPE_ALG = 'A256GCM';
22
23
  const GRANT_WRAP_ALG = 'X25519-HKDF-SHA256+A256KW';
23
24
 
24
- const schemaRoot = path.join(__dirname, '..', 'schema');
25
25
  const ajv = new JsonSchema2020({ allErrors: true, strict: true });
26
26
  addFormats(ajv);
27
- const validateEnvelopeSchema = ajv.compile(
28
- require(path.join(schemaRoot, 'external-grant-envelope.schema.json')),
29
- );
30
- const validateGrantSchema = ajv.compile(
31
- require(path.join(schemaRoot, 'external-key-grant.schema.json')),
32
- );
27
+ const validateEnvelopeSchema = ajv.compile(externalEnvelopeSchema);
28
+ const validateGrantSchema = ajv.compile(externalKeyGrantSchema);
33
29
 
34
30
  const verifiedEntitlements = new WeakSet();
35
31
 
package/src/index.js CHANGED
@@ -15,9 +15,11 @@ const runtimeApi = require('./runtime-api');
15
15
  const runtimeCapsule = require('./runtime-capsule');
16
16
  const runtimeContract = require('./runtime-contract');
17
17
  const loaderCompatibility = require('./loader-compatibility');
18
+ const assetSignature = require('./signature');
18
19
 
19
20
  module.exports = {
20
21
  ...publicApi,
22
+ ...assetSignature,
21
23
  ...loader,
22
24
  ...validate,
23
25
  ...render,
package/src/index.mjs CHANGED
@@ -17,10 +17,14 @@ export {
17
17
  isKdnaSourceDir,
18
18
  detectContainerFormat,
19
19
  readLayout,
20
+ readLayoutBytes,
21
+ fullEntryBufferMap,
20
22
  inspect,
21
23
  validate,
22
24
  buildChecksums,
23
25
  pack,
26
+ packEntryMap,
27
+ signContainerBytes,
24
28
  unpack,
25
29
  FORBIDDEN_OUTPUT_TERMS,
26
30
  parseSemver,
@@ -78,12 +82,27 @@ export const verifyAsset = publicApi.verifyAsset;
78
82
  export const verifyAssetSync = publicApi.verifyAssetSync;
79
83
  export const verifyDigest = publicApi.verifyDigest;
80
84
  export const verifyDigestSync = publicApi.verifyDigestSync;
81
- export const verifySignature = publicApi.verifySignature;
82
- export const verifySignatureSync = publicApi.verifySignatureSync;
83
85
  export const matchDomain = publicApi.matchDomain;
84
86
  export const matchDomainSync = publicApi.matchDomainSync;
87
+ export const signKDNA = publicApi.signKDNA;
88
+ export const signKDNASync = publicApi.signKDNASync;
89
+ export const verifyKDNASignature = publicApi.verifyKDNASignature;
90
+ export const verifyKDNASignatureSync = publicApi.verifyKDNASignatureSync;
85
91
  export const composeKDNA = publicApi.composeKDNA;
86
92
 
93
+ import assetSignature from './signature.js';
94
+ export const KDSIG_PROFILE = assetSignature.KDSIG_PROFILE;
95
+ export const KDSIG_PROFILE_VERSION = assetSignature.KDSIG_PROFILE_VERSION;
96
+ export const KDSIG_ALGORITHM = assetSignature.KDSIG_ALGORITHM;
97
+ export const SIGNATURE_ENTRY_NAME = assetSignature.SIGNATURE_ENTRY_NAME;
98
+ export const buildSigningPayload = assetSignature.buildSigningPayload;
99
+ export const generateSigningKeyPair = assetSignature.generateSigningKeyPair;
100
+ export const keyFingerprint = assetSignature.keyFingerprint;
101
+ export const parseSignatureBundle = assetSignature.parseSignatureBundle;
102
+ export const serializeSignatureBundle = assetSignature.serializeSignatureBundle;
103
+ export const signContentDigest = assetSignature.signContentDigest;
104
+ export const verifySignatureBundle = assetSignature.verifySignatureBundle;
105
+
87
106
  export const DIGEST_PROFILE = runtimeCapsule.DIGEST_PROFILE;
88
107
  export const DIGEST_PROFILE_VERSION = runtimeCapsule.DIGEST_PROFILE_VERSION;
89
108
  export const CAPSULE_DIGEST_PROFILE = runtimeCapsule.CAPSULE_DIGEST_PROFILE;
@@ -123,6 +142,7 @@ export const validateJudgmentTrace = runtimeContract.validateJudgmentTrace;
123
142
 
124
143
  export const STANDARD_ENTRIES = assetReader.STANDARD_ENTRIES;
125
144
  export const createKdnaAssetReader = assetReader.createKdnaAssetReader;
145
+ export const contentDigestFromEntryBuffers = assetReader.contentDigestFromEntryBuffers;
126
146
 
127
147
  export const {
128
148
  LICENSED_ENTRY_PROFILE,
package/src/public-api.js CHANGED
@@ -8,9 +8,10 @@
8
8
  */
9
9
 
10
10
  const fs = require('fs');
11
- const { createKdnaAssetReader } = require('./asset-reader');
11
+ const { createKdnaAssetReader, contentDigestFromEntryBuffers } = require('./asset-reader');
12
12
  const container = require('./container');
13
13
  const runtimeApi = require('./runtime-api');
14
+ const signature = require('./signature');
14
15
 
15
16
  function readerFrom(options = {}) {
16
17
  return options.reader || createKdnaAssetReader();
@@ -63,7 +64,6 @@ async function inspectKDNA(input, options = {}) {
63
64
  entries,
64
65
  asset_digest: asset.asset_digest,
65
66
  content_digest: contentDigest,
66
- signature_valid: verification ? verification.signature_valid : null,
67
67
  ok: verification ? verification.ok : null,
68
68
  errors: verification ? verification.errors : [],
69
69
  warnings: verification ? verification.warnings : [],
@@ -84,7 +84,6 @@ function inspectKDNASync(input, options = {}) {
84
84
  entries,
85
85
  asset_digest: asset.asset_digest,
86
86
  content_digest: contentDigest,
87
- signature_valid: verification ? verification.signature_valid : null,
88
87
  ok: verification ? verification.ok : null,
89
88
  errors: verification ? verification.errors : [],
90
89
  warnings: verification ? verification.warnings : [],
@@ -138,14 +137,6 @@ function verifyDigestSync(input, expectedDigest, options = {}) {
138
137
  return verifyAssetSync(input, { ...options, asset_digest: expectedDigest });
139
138
  }
140
139
 
141
- async function verifySignature(input, options = {}) {
142
- return verifyAsset(input, { ...options, requireSignature: true });
143
- }
144
-
145
- function verifySignatureSync(input, options = {}) {
146
- return verifyAssetSync(input, { ...options, requireSignature: true });
147
- }
148
-
149
140
  function scoreMatch(input, info) {
150
141
  const haystack = String(input || '').toLowerCase();
151
142
  const terms = [
@@ -186,6 +177,55 @@ function matchDomainSync(input, candidates, options = {}) {
186
177
  return results.sort((a, b) => b.score - a.score || String(a.asset_id).localeCompare(String(b.asset_id)));
187
178
  }
188
179
 
180
+ /**
181
+ * Sign a packaged .kdna asset per RFC-0021 M1 (`kdsig.ed25519`). Returns the
182
+ * signed container bytes, the signature bundle, and the signed content
183
+ * digest. `privateKey` is the raw 32-byte Ed25519 seed as 64 lowercase hex
184
+ * characters. When `options.outputPath` is set the signed container is also
185
+ * written to that path.
186
+ */
187
+ function signKDNASync(input, privateKey, options = {}) {
188
+ const result = container.signContainerBytes(canonicalBytes(input), privateKey, options);
189
+ if (typeof options.outputPath === 'string' && options.outputPath.length > 0) {
190
+ fs.writeFileSync(options.outputPath, result.containerBytes);
191
+ }
192
+ return result;
193
+ }
194
+
195
+ async function signKDNA(input, privateKey, options = {}) {
196
+ return signKDNASync(input, privateKey, options);
197
+ }
198
+
199
+ /**
200
+ * Verify the RFC-0021 M1 (`kdsig.ed25519`) signature of a packaged .kdna
201
+ * asset offline. Fail-closed: any malformed, unsupported, or unverifiable
202
+ * bundle throws. Returns `{ state: 'absent' }` for unsigned assets unless
203
+ * `options.required` is true (then absence also throws), and the verified
204
+ * evidence otherwise. `options.expectedPublicKey` pins the signer key.
205
+ */
206
+ function verifyKDNASignatureSync(input, options = {}) {
207
+ const layout = container.readLayoutBytes(canonicalBytes(input));
208
+ const bundleBytes = layout.map[signature.SIGNATURE_ENTRY_NAME];
209
+ if (bundleBytes === undefined) {
210
+ if (options.required === true) {
211
+ const error = new Error(
212
+ `required asset signature is absent: missing entry ${signature.SIGNATURE_ENTRY_NAME}`,
213
+ );
214
+ error.code = 'KDNA_SIGNATURE_ABSENT';
215
+ throw error;
216
+ }
217
+ return { state: 'absent' };
218
+ }
219
+ const contentDigest = contentDigestFromEntryBuffers(container.fullEntryBufferMap(layout));
220
+ return signature.verifySignatureBundle(bundleBytes, contentDigest, {
221
+ expectedPublicKey: options.expectedPublicKey,
222
+ });
223
+ }
224
+
225
+ async function verifyKDNASignature(input, options = {}) {
226
+ return verifyKDNASignatureSync(input, options);
227
+ }
228
+
189
229
  async function composeKDNA(inputs, options = {}) {
190
230
  void inputs;
191
231
  void options;
@@ -211,9 +251,11 @@ module.exports = {
211
251
  verifyAssetSync,
212
252
  verifyDigest,
213
253
  verifyDigestSync,
214
- verifySignature,
215
- verifySignatureSync,
216
254
  matchDomain,
217
255
  matchDomainSync,
256
+ signKDNA,
257
+ signKDNASync,
258
+ verifyKDNASignature,
259
+ verifyKDNASignatureSync,
218
260
  composeKDNA,
219
261
  };
@@ -8,6 +8,10 @@ const addFormats = require('ajv-formats');
8
8
 
9
9
  const { createKdnaAssetReader } = require('./asset-reader');
10
10
  const container = require('./container');
11
+ const {
12
+ KDSIG_PROFILE,
13
+ KDSIG_PROFILE_VERSION,
14
+ } = require('./signature');
11
15
  const digestEvidenceSchema = require('../schema/digest-evidence.schema.json');
12
16
  const runtimeCapsuleSchema = require('../schema/runtime-capsule.schema.json');
13
17
 
@@ -433,6 +437,82 @@ function assertSuccessfulDigestEvidence(digests) {
433
437
  }
434
438
  }
435
439
 
440
+ const SIGNATURE_EVIDENCE_FIELDS = Object.freeze({
441
+ absent: ['state'],
442
+ verified: ['state', 'profile', 'profile_version', 'key_fingerprint', 'content_digest'],
443
+ });
444
+
445
+ /**
446
+ * Normalize Runtime Capsule signature evidence (RFC-0021 M1). Unsigned assets
447
+ * report `{ state: 'absent' }`; signed assets report the verified kdsig.ed25519
448
+ * evidence. Any other shape fails closed — signatures never downgrade to
449
+ * "absent" and unknown profiles are never collapsed into known ones.
450
+ */
451
+ function normalizeSignatureEvidence(signature) {
452
+ if (signature === undefined) return { state: 'absent' };
453
+ if (!signature || typeof signature !== 'object' || Array.isArray(signature)) {
454
+ fail(
455
+ 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
456
+ 'Runtime Capsule signature evidence must be an object.',
457
+ );
458
+ }
459
+ if (signature.state === 'absent') {
460
+ const extra = Object.keys(signature).filter((key) => key !== 'state');
461
+ if (extra.length) {
462
+ fail(
463
+ 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
464
+ 'Absent signature evidence must not carry extra fields.',
465
+ );
466
+ }
467
+ return { state: 'absent' };
468
+ }
469
+ if (signature.state === 'verified') {
470
+ const keys = Object.keys(signature).sort();
471
+ const expected = [...SIGNATURE_EVIDENCE_FIELDS.verified].sort();
472
+ if (keys.join(',') !== expected.join(',')) {
473
+ fail(
474
+ 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
475
+ `Verified signature evidence must carry exactly: ${SIGNATURE_EVIDENCE_FIELDS.verified.join(', ')}.`,
476
+ );
477
+ }
478
+ if (signature.profile !== KDSIG_PROFILE) {
479
+ fail(
480
+ 'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
481
+ `Signature profile ${JSON.stringify(signature.profile)} is not supported (supported: ${KDSIG_PROFILE}).`,
482
+ );
483
+ }
484
+ if (signature.profile_version !== KDSIG_PROFILE_VERSION) {
485
+ fail(
486
+ 'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
487
+ `Signature profile_version ${JSON.stringify(signature.profile_version)} is not supported (supported: ${KDSIG_PROFILE_VERSION}).`,
488
+ );
489
+ }
490
+ if (!SHA256_RE.test(signature.key_fingerprint || '')) {
491
+ fail(
492
+ 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
493
+ 'Verified signature evidence requires a sha256 key_fingerprint.',
494
+ );
495
+ }
496
+ if (!SHA256_RE.test(signature.content_digest || '')) {
497
+ fail(
498
+ 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID',
499
+ 'Verified signature evidence requires a sha256 content_digest.',
500
+ );
501
+ }
502
+ return {
503
+ state: 'verified',
504
+ profile: signature.profile,
505
+ profile_version: signature.profile_version,
506
+ key_fingerprint: signature.key_fingerprint,
507
+ content_digest: signature.content_digest,
508
+ };
509
+ }
510
+ fail(
511
+ 'KDNA_ASSET_SIGNATURE_UNSUPPORTED',
512
+ `Signature state ${JSON.stringify(signature.state)} is not supported (supported: absent, verified).`,
513
+ );
514
+ }
515
+
436
516
  function normalizeRuntimeAccess(access) {
437
517
  return access === undefined ? 'public' : access;
438
518
  }
@@ -475,7 +555,7 @@ function buildRuntimeCapsule({
475
555
  if (!['public', 'licensed', 'remote'].includes(runtimeAccess)) {
476
556
  fail('KDNA_RUNTIME_CAPSULE_BUILD_INVALID', `Unsupported Runtime access: ${runtimeAccess}.`);
477
557
  }
478
- const signatureEvidence = signature || { state: 'absent' };
558
+ const signatureEvidence = normalizeSignatureEvidence(signature);
479
559
 
480
560
  const capsule = {
481
561
  type: 'kdna.runtime-capsule',
@@ -502,6 +582,9 @@ function buildRuntimeCapsule({
502
582
  profile: projection.profile,
503
583
  },
504
584
  };
585
+ if (projection.projection_report !== undefined) {
586
+ capsule.trace.projection_report = globalThis.structuredClone(projection.projection_report);
587
+ }
505
588
  assertRuntimeCapsuleSuccess(capsule, 'KDNA_RUNTIME_CAPSULE_BUILD_INVALID');
506
589
  return capsule;
507
590
  }
@@ -0,0 +1,320 @@
1
+ /**
2
+ * KDNA asset signatures — RFC-0021 M1 (`kdsig.ed25519`).
3
+ *
4
+ * A `.kdna` container carries an optional top-level `signature.kdsig` entry:
5
+ * a JSON signature bundle whose Ed25519 signature covers a deterministic
6
+ * signing payload derived from the asset's canonical content digest
7
+ * (docs/CANONICALIZATION.md). Verification is offline and fail-closed: any
8
+ * malformed, unsupported, or unverifiable bundle rejects the asset.
9
+ *
10
+ * A signature proves integrity and provenance only. It never proves
11
+ * expertise, truthfulness, safety, or fitness for purpose.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const crypto = require('crypto');
17
+
18
+ const KDSIG_PROFILE = 'kdsig.ed25519';
19
+ const KDSIG_PROFILE_VERSION = '0.1.0';
20
+ const KDSIG_ALGORITHM = 'ed25519';
21
+ const SIGNATURE_ENTRY_NAME = 'signature.kdsig';
22
+
23
+ const RAW_PUBLIC_KEY_HEX_LENGTH = 64;
24
+ const RAW_SIGNATURE_HEX_LENGTH = 128;
25
+ const SHA256_HEX_LENGTH = 64;
26
+
27
+ // RFC 8410 DER prefixes for raw Ed25519 key material.
28
+ const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
29
+ const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
30
+
31
+ const BUNDLE_FIELDS = Object.freeze([
32
+ 'algorithm',
33
+ 'content_digest',
34
+ 'profile',
35
+ 'profile_version',
36
+ 'public_key',
37
+ 'signature',
38
+ ]);
39
+
40
+ function sha256Hex(buf) {
41
+ return crypto.createHash('sha256').update(buf).digest('hex');
42
+ }
43
+
44
+ function signatureError(code, message) {
45
+ const error = new Error(message);
46
+ error.code = code;
47
+ return error;
48
+ }
49
+
50
+ function isLowercaseHex(value, length) {
51
+ return typeof value === 'string' && value.length === length && /^[0-9a-f]+$/.test(value);
52
+ }
53
+
54
+ function assertContentDigestShape(value) {
55
+ if (
56
+ typeof value !== 'string'
57
+ || !value.startsWith('sha256:')
58
+ || !isLowercaseHex(value.slice('sha256:'.length), SHA256_HEX_LENGTH)
59
+ ) {
60
+ throw signatureError(
61
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
62
+ `signature bundle content_digest must be "sha256:<64 lowercase hex>", got ${JSON.stringify(value)}`,
63
+ );
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Build the exact bytes that Ed25519 signs. The payload is domain-separated by
69
+ * the profile coordinate and binds the asset's canonical content digest, so a
70
+ * signature can never be replayed across profiles, versions, or assets.
71
+ */
72
+ function buildSigningPayload(contentDigest) {
73
+ assertContentDigestShape(contentDigest);
74
+ return Buffer.from(`${KDSIG_PROFILE}:${KDSIG_PROFILE_VERSION}:${contentDigest}`, 'utf8');
75
+ }
76
+
77
+ function privateKeyObjectFromSeed(seedHex) {
78
+ if (!isLowercaseHex(seedHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
79
+ throw signatureError(
80
+ 'KDNA_SIGNATURE_KEY_INVALID',
81
+ 'Ed25519 private key must be a 32-byte seed encoded as 64 lowercase hex characters',
82
+ );
83
+ }
84
+ const der = Buffer.concat([PKCS8_ED25519_PREFIX, Buffer.from(seedHex, 'hex')]);
85
+ try {
86
+ return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
87
+ } catch (e) {
88
+ throw signatureError('KDNA_SIGNATURE_KEY_INVALID', `invalid Ed25519 private key: ${e.message}`);
89
+ }
90
+ }
91
+
92
+ function publicKeyObjectFromRaw(publicKeyHex) {
93
+ if (!isLowercaseHex(publicKeyHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
94
+ throw signatureError(
95
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
96
+ 'signature bundle public_key must be a 32-byte Ed25519 key encoded as 64 lowercase hex characters',
97
+ );
98
+ }
99
+ const der = Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(publicKeyHex, 'hex')]);
100
+ try {
101
+ return crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
102
+ } catch (e) {
103
+ throw signatureError(
104
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
105
+ `signature bundle public_key is not a valid Ed25519 key: ${e.message}`,
106
+ );
107
+ }
108
+ }
109
+
110
+ function rawPublicKeyHexFromObject(keyObject) {
111
+ const spki = keyObject.export({ format: 'der', type: 'spki' });
112
+ return spki.subarray(SPKI_ED25519_PREFIX.length).toString('hex');
113
+ }
114
+
115
+ /**
116
+ * Generate a new Ed25519 signing key pair. The private key is returned as the
117
+ * raw 32-byte seed in lowercase hex; custody is the signer's responsibility.
118
+ */
119
+ function generateSigningKeyPair() {
120
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
121
+ const pkcs8 = privateKey.export({ format: 'der', type: 'pkcs8' });
122
+ const seedHex = pkcs8.subarray(PKCS8_ED25519_PREFIX.length).toString('hex');
123
+ return {
124
+ algorithm: KDSIG_ALGORITHM,
125
+ private_key: seedHex,
126
+ public_key: rawPublicKeyHexFromObject(publicKey),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Fingerprint of a raw Ed25519 public key: sha256 over the 32 key bytes.
132
+ */
133
+ function keyFingerprint(publicKeyHex) {
134
+ if (!isLowercaseHex(publicKeyHex, RAW_PUBLIC_KEY_HEX_LENGTH)) {
135
+ throw signatureError(
136
+ 'KDNA_SIGNATURE_KEY_INVALID',
137
+ 'keyFingerprint expects a 32-byte Ed25519 public key encoded as 64 lowercase hex characters',
138
+ );
139
+ }
140
+ return `sha256:${sha256Hex(Buffer.from(publicKeyHex, 'hex'))}`;
141
+ }
142
+
143
+ function stableStringify(value) {
144
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
145
+ if (value && typeof value === 'object') {
146
+ return `{${Object.keys(value)
147
+ .sort()
148
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
149
+ .join(',')}}`;
150
+ }
151
+ return JSON.stringify(value);
152
+ }
153
+
154
+ /**
155
+ * Serialize a signature bundle to its canonical wire bytes (sorted keys, no
156
+ * insignificant whitespace). Verification accepts any JSON encoding of the
157
+ * same fields; canonical bytes are what signers and vectors pin.
158
+ */
159
+ function serializeSignatureBundle(bundle) {
160
+ return Buffer.from(`${stableStringify(bundle)}\n`, 'utf8');
161
+ }
162
+
163
+ function parseSignatureBundle(bytes) {
164
+ let text;
165
+ try {
166
+ text = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes);
167
+ } catch (e) {
168
+ throw signatureError('KDNA_INTEGRITY_SIGNATURE_FAILED', `signature bundle is not UTF-8: ${e.message}`);
169
+ }
170
+ let bundle;
171
+ try {
172
+ bundle = JSON.parse(text);
173
+ } catch (e) {
174
+ throw signatureError(
175
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
176
+ `signature.kdsig is not valid JSON: ${e.message}`,
177
+ );
178
+ }
179
+ if (!bundle || typeof bundle !== 'object' || Array.isArray(bundle)) {
180
+ throw signatureError('KDNA_INTEGRITY_SIGNATURE_FAILED', 'signature bundle must be a JSON object');
181
+ }
182
+ for (const field of BUNDLE_FIELDS) {
183
+ if (typeof bundle[field] !== 'string') {
184
+ throw signatureError(
185
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
186
+ `signature bundle is missing required string field: ${field}`,
187
+ );
188
+ }
189
+ }
190
+ const extra = Object.keys(bundle).filter((key) => !BUNDLE_FIELDS.includes(key));
191
+ if (extra.length) {
192
+ throw signatureError(
193
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
194
+ `signature bundle carries unsupported fields: ${extra.sort().join(', ')}`,
195
+ );
196
+ }
197
+ if (bundle.profile !== KDSIG_PROFILE) {
198
+ throw signatureError(
199
+ 'KDNA_SIGNATURE_PROFILE_UNSUPPORTED',
200
+ `signature bundle profile ${JSON.stringify(bundle.profile)} is not supported (supported: ${KDSIG_PROFILE})`,
201
+ );
202
+ }
203
+ if (bundle.profile_version !== KDSIG_PROFILE_VERSION) {
204
+ throw signatureError(
205
+ 'KDNA_SIGNATURE_VERSION_UNSUPPORTED',
206
+ `signature bundle profile_version ${JSON.stringify(bundle.profile_version)} is not supported (supported: ${KDSIG_PROFILE_VERSION})`,
207
+ );
208
+ }
209
+ if (bundle.algorithm !== KDSIG_ALGORITHM) {
210
+ throw signatureError(
211
+ 'KDNA_SIGNATURE_PROFILE_UNSUPPORTED',
212
+ `signature bundle algorithm ${JSON.stringify(bundle.algorithm)} is not supported (supported: ${KDSIG_ALGORITHM})`,
213
+ );
214
+ }
215
+ assertContentDigestShape(bundle.content_digest);
216
+ if (!isLowercaseHex(bundle.public_key, RAW_PUBLIC_KEY_HEX_LENGTH)) {
217
+ throw signatureError(
218
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
219
+ 'signature bundle public_key must be 64 lowercase hex characters',
220
+ );
221
+ }
222
+ if (!isLowercaseHex(bundle.signature, RAW_SIGNATURE_HEX_LENGTH)) {
223
+ throw signatureError(
224
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
225
+ 'signature bundle signature must be 128 lowercase hex characters',
226
+ );
227
+ }
228
+ return {
229
+ profile: bundle.profile,
230
+ profile_version: bundle.profile_version,
231
+ algorithm: bundle.algorithm,
232
+ content_digest: bundle.content_digest,
233
+ public_key: bundle.public_key,
234
+ signature: bundle.signature,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Sign a canonical content digest with an Ed25519 seed (64 lowercase hex).
240
+ * Returns the bundle object; serialize with serializeSignatureBundle().
241
+ */
242
+ function signContentDigest(contentDigest, privateKeySeedHex) {
243
+ assertContentDigestShape(contentDigest);
244
+ const privateKey = privateKeyObjectFromSeed(privateKeySeedHex);
245
+ const publicKeyHex = rawPublicKeyHexFromObject(crypto.createPublicKey(privateKey));
246
+ const signatureBytes = crypto.sign(null, buildSigningPayload(contentDigest), privateKey);
247
+ return {
248
+ algorithm: KDSIG_ALGORITHM,
249
+ content_digest: contentDigest,
250
+ profile: KDSIG_PROFILE,
251
+ profile_version: KDSIG_PROFILE_VERSION,
252
+ public_key: publicKeyHex,
253
+ signature: signatureBytes.toString('hex'),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Verify a parsed bundle against a canonical content digest. Fail-closed:
259
+ * every mismatch throws with a stable error code. Returns verification
260
+ * evidence on success.
261
+ */
262
+ function verifySignatureBundle(bundle, contentDigest, options = {}) {
263
+ const parsed = parseSignatureBundle(bundle);
264
+ assertContentDigestShape(contentDigest);
265
+ if (parsed.content_digest !== contentDigest) {
266
+ throw signatureError(
267
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
268
+ `signature bundle content_digest ${parsed.content_digest} does not match the asset content_digest ${contentDigest}`,
269
+ );
270
+ }
271
+ if (
272
+ typeof options.expectedPublicKey === 'string'
273
+ && options.expectedPublicKey.toLowerCase() !== parsed.public_key
274
+ ) {
275
+ throw signatureError(
276
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
277
+ 'signature bundle public_key does not match the expected pinned key',
278
+ );
279
+ }
280
+ const publicKey = publicKeyObjectFromRaw(parsed.public_key);
281
+ const signatureBytes = Buffer.from(parsed.signature, 'hex');
282
+ let valid;
283
+ try {
284
+ valid = crypto.verify(null, buildSigningPayload(contentDigest), publicKey, signatureBytes);
285
+ } catch (e) {
286
+ throw signatureError(
287
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
288
+ `Ed25519 verification error: ${e.message}`,
289
+ );
290
+ }
291
+ if (valid !== true) {
292
+ throw signatureError(
293
+ 'KDNA_INTEGRITY_SIGNATURE_FAILED',
294
+ 'Ed25519 signature verification failed: signature does not match the canonical signing payload',
295
+ );
296
+ }
297
+ return {
298
+ state: 'verified',
299
+ profile: parsed.profile,
300
+ profile_version: parsed.profile_version,
301
+ algorithm: parsed.algorithm,
302
+ content_digest: parsed.content_digest,
303
+ public_key: parsed.public_key,
304
+ key_fingerprint: keyFingerprint(parsed.public_key),
305
+ };
306
+ }
307
+
308
+ module.exports = {
309
+ KDSIG_PROFILE,
310
+ KDSIG_PROFILE_VERSION,
311
+ KDSIG_ALGORITHM,
312
+ SIGNATURE_ENTRY_NAME,
313
+ buildSigningPayload,
314
+ generateSigningKeyPair,
315
+ keyFingerprint,
316
+ parseSignatureBundle,
317
+ serializeSignatureBundle,
318
+ signContentDigest,
319
+ verifySignatureBundle,
320
+ };