@aikdna/kdna-core 0.21.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.
@@ -18,8 +18,9 @@
18
18
  * - mimetype must be the first entry in a .kdna container
19
19
  * - mimetype must be STORED (compression method 0) in a .kdna container
20
20
  * - the source directory must contain mimetype, kdna.json, payload.kdnab
21
- * - checksums.json and attachments/ are optional
22
- * - asset signatures are outside the current Preview contract
21
+ * - checksums.json, attachments/, and signature.kdsig are optional
22
+ * - signature.kdsig is the RFC-0021 M1 `kdsig.ed25519` bundle; when present
23
+ * it must verify fail-closed against the canonical content digest
23
24
  * - lineage must be a single object (not an array)
24
25
  * - pack output is reproducible within one pinned toolchain/compressor;
25
26
  * DEFLATE bytes may differ across compressors, zlib versions, or systems
@@ -91,7 +92,7 @@ const { readAsset } = (() => {
91
92
 
92
93
  const MIMETYPE = 'application/vnd.kdna.asset';
93
94
  const REQUIRED_DIR_ENTRIES = ['mimetype', 'kdna.json', 'payload.kdnab'];
94
- const OPTIONAL_DIR_ENTRIES = ['checksums.json', 'attachments'];
95
+ const OPTIONAL_DIR_ENTRIES = ['checksums.json', 'attachments', 'signature.kdsig'];
95
96
  const ALLOWED_TOP_LEVEL_ENTRIES = new Set([
96
97
  ...REQUIRED_DIR_ENTRIES,
97
98
  ...OPTIONAL_DIR_ENTRIES,
@@ -587,6 +588,13 @@ function readLayout(absPath) {
587
588
  if (fs.existsSync(full)) {
588
589
  if (fs.statSync(full).isFile()) {
589
590
  map[f] = fs.readFileSync(full);
591
+ } else if (f === 'attachments') {
592
+ // Attachment files join the entry map under their relative names so
593
+ // digest and signature operations see the same entry set as the
594
+ // packaged container.
595
+ for (const entry of listSourceDir(full)) {
596
+ map[`attachments/${entry.rel}`] = fs.readFileSync(entry.full);
597
+ }
590
598
  } else {
591
599
  // Optional subdirectories are recorded without becoming payloads.
592
600
  map[f] = null;
@@ -606,7 +614,8 @@ function readLayout(absPath) {
606
614
  e.name === 'mimetype' ||
607
615
  e.name === 'kdna.json' ||
608
616
  e.name === 'payload.kdnab' ||
609
- e.name === 'checksums.json'
617
+ e.name === 'checksums.json' ||
618
+ e.name === 'signature.kdsig'
610
619
  ) {
611
620
  map[e.name] = e.data;
612
621
  }
@@ -663,7 +672,8 @@ function readLayoutBytes(input) {
663
672
  entry.name === 'mimetype' ||
664
673
  entry.name === 'kdna.json' ||
665
674
  entry.name === 'payload.kdnab' ||
666
- entry.name === 'checksums.json'
675
+ entry.name === 'checksums.json' ||
676
+ entry.name === 'signature.kdsig'
667
677
  ) {
668
678
  map[entry.name] = entry.data;
669
679
  }
@@ -811,6 +821,7 @@ function buildInspectOutput(container) {
811
821
  load_contract_default_profile: m.load_contract ? m.load_contract.default_profile : null,
812
822
  };
813
823
  if (container.map['checksums.json']) out.checksums_present = true;
824
+ if (container.map['signature.kdsig']) out.signature_present = true;
814
825
  return out;
815
826
  }
816
827
 
@@ -827,6 +838,9 @@ function runValidate(layout) {
827
838
  schema_valid: true,
828
839
  payload_valid: true,
829
840
  checksums_valid: true,
841
+ signature_valid: true,
842
+ signature_state: 'absent',
843
+ signature_evidence: null,
830
844
  load_contract_valid: true,
831
845
  ...assessLoaderCompatibility(layout.manifest),
832
846
  };
@@ -981,6 +995,26 @@ function runValidate(layout) {
981
995
  }
982
996
  }
983
997
 
998
+ // signature gate — RFC-0021 M1 (`kdsig.ed25519`). An absent signature is
999
+ // not an error; a present signature must verify fail-closed.
1000
+ if (layout.map['signature.kdsig'] !== undefined) {
1001
+ try {
1002
+ const signature = require('../signature');
1003
+ const { contentDigestFromEntryBuffers } = require('../asset-reader');
1004
+ const contentDigest = contentDigestFromEntryBuffers(fullEntryBufferMap(layout));
1005
+ const evidence = signature.verifySignatureBundle(
1006
+ layout.map['signature.kdsig'],
1007
+ contentDigest,
1008
+ );
1009
+ result.signature_state = 'verified';
1010
+ result.signature_evidence = evidence;
1011
+ } catch (e) {
1012
+ result.signature_valid = false;
1013
+ result.signature_state = 'invalid';
1014
+ problems.push(`signature: ${e.message}`);
1015
+ }
1016
+ }
1017
+
984
1018
  // load_contract gate — only if manifest references a load_contract block
985
1019
  if (layout.manifest.load_contract) {
986
1020
  const lc = layout.manifest.load_contract;
@@ -1098,12 +1132,39 @@ function verifyDigests(checksums, map, problems, result) {
1098
1132
  return stillValid;
1099
1133
  }
1100
1134
 
1135
+ /**
1136
+ * Collect every file entry of a layout as name → Buffer, so digest and
1137
+ * signature operations always cover the full entry set (including
1138
+ * attachments), regardless of which entries layout.map parses.
1139
+ */
1140
+ function fullEntryBufferMap(layout) {
1141
+ const buffers = {};
1142
+ if (Array.isArray(layout.entries)) {
1143
+ for (const entry of layout.entries) {
1144
+ // Entries are either parsed ZIP entry objects ({ name, data }) or plain
1145
+ // entry names from the unified container model; both resolve through
1146
+ // layout.map.
1147
+ const name = typeof entry === 'string' ? entry : entry.name;
1148
+ const data = typeof entry === 'string' ? layout.map[entry] : entry.data;
1149
+ if (data === undefined || data === null) continue;
1150
+ buffers[name] = data;
1151
+ }
1152
+ return buffers;
1153
+ }
1154
+ for (const [name, data] of Object.entries(layout.map)) {
1155
+ if (data === null || data === undefined) continue;
1156
+ buffers[name] = data;
1157
+ }
1158
+ return buffers;
1159
+ }
1160
+
1101
1161
  function finalizeValidate(result, problems) {
1102
1162
  result.overall_valid =
1103
1163
  result.format_valid &&
1104
1164
  result.schema_valid &&
1105
1165
  result.payload_valid &&
1106
1166
  result.checksums_valid &&
1167
+ result.signature_valid &&
1107
1168
  result.load_contract_valid;
1108
1169
  result.problems = problems;
1109
1170
  return result;
@@ -1260,6 +1321,108 @@ function pack(sourceDir, outputPath) {
1260
1321
  return { outputPath, entries: order };
1261
1322
  }
1262
1323
 
1324
+ /**
1325
+ * Deterministically pack an in-memory entry map into .kdna container bytes.
1326
+ * mimetype is forced first and STORED; remaining entries are ordered by UTF-8
1327
+ * path bytes and DEFLATE-compressed; timestamps are pinned to the DOS epoch.
1328
+ */
1329
+ function packEntryMap(map) {
1330
+ for (const f of REQUIRED_DIR_ENTRIES) {
1331
+ if (!map[f]) throw new Error(`cannot pack: missing required entry ${f}`);
1332
+ }
1333
+ const mime = Buffer.isBuffer(map.mimetype) ? map.mimetype.toString('utf8') : String(map.mimetype);
1334
+ if (mime !== MIMETYPE) {
1335
+ throw new Error(`cannot pack: mimetype is "${mime}", expected "${MIMETYPE}"`);
1336
+ }
1337
+ const names = Object.keys(map).filter((name) => name !== 'mimetype');
1338
+ names.sort((a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')));
1339
+ const order = ['mimetype', ...names];
1340
+
1341
+ const localChunks = [];
1342
+ const centralChunks = [];
1343
+ let offset = 0;
1344
+ for (const rel of order) {
1345
+ const data = rel === 'mimetype' ? Buffer.from(MIMETYPE, 'utf8') : Buffer.from(map[rel]);
1346
+ const nameBytes = Buffer.from(rel, 'utf8');
1347
+ const method = rel === 'mimetype' ? 0 : 8;
1348
+ const built = buildLocalHeader(nameBytes, data, method);
1349
+ localChunks.push(built.local, built.compressed);
1350
+ centralChunks.push(
1351
+ buildCentral(
1352
+ {
1353
+ method,
1354
+ crc: built.crc,
1355
+ time: built.time,
1356
+ date: built.date,
1357
+ compressed: built.compressed,
1358
+ dataLength: built.dataLength,
1359
+ offset,
1360
+ },
1361
+ nameBytes,
1362
+ ),
1363
+ );
1364
+ offset += built.local.length + built.compressed.length;
1365
+ }
1366
+
1367
+ const centralOffset = offset;
1368
+ let centralSize = 0;
1369
+ for (const c of centralChunks) centralSize += c.length;
1370
+ const eocd = Buffer.alloc(22);
1371
+ eocd.writeUInt32LE(0x06054b50, 0);
1372
+ eocd.writeUInt16LE(0, 4);
1373
+ eocd.writeUInt16LE(0, 6);
1374
+ eocd.writeUInt16LE(order.length, 8);
1375
+ eocd.writeUInt16LE(order.length, 10);
1376
+ eocd.writeUInt32LE(centralSize, 12);
1377
+ eocd.writeUInt32LE(centralOffset, 16);
1378
+ eocd.writeUInt16LE(0, 20);
1379
+
1380
+ return Buffer.concat([...localChunks, ...centralChunks, eocd]);
1381
+ }
1382
+
1383
+ /**
1384
+ * Sign a packaged .kdna container per RFC-0021 M1 (`kdsig.ed25519`).
1385
+ *
1386
+ * The container must pass every validation gate after any pre-existing
1387
+ * signature entry is removed; signing never certifies a broken asset. The
1388
+ * returned bytes carry a fresh `signature.kdsig` bundle whose Ed25519
1389
+ * signature covers the canonical content digest. Fail-closed throughout.
1390
+ */
1391
+ function signContainerBytes(input, privateKeySeedHex, _opts = {}) {
1392
+ const signature = require('../signature');
1393
+ const { contentDigestFromEntryBuffers } = require('../asset-reader');
1394
+
1395
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
1396
+ const layout = readLayoutBytes(bytes);
1397
+
1398
+ // Rebuild the full entry map (including attachments) from the parsed
1399
+ // ZIP entries; layout.map only carries the well-known protocol entries.
1400
+ const unsignedMap = {};
1401
+ for (const entry of layout.entries) {
1402
+ if (entry.name === 'signature.kdsig') continue;
1403
+ unsignedMap[entry.name] = entry.data;
1404
+ }
1405
+ const validation = runValidate({ ...layout, map: unsignedMap });
1406
+ if (!validation.overall_valid) {
1407
+ const error = new Error(
1408
+ `cannot sign: container does not pass validation (${validation.problems.join('; ')})`,
1409
+ );
1410
+ error.code = 'KDNA_SIGNATURE_INPUT_INVALID';
1411
+ throw error;
1412
+ }
1413
+
1414
+ const contentDigest = contentDigestFromEntryBuffers(unsignedMap);
1415
+ const bundle = signature.signContentDigest(contentDigest, privateKeySeedHex);
1416
+ const bundleBytes = signature.serializeSignatureBundle(bundle);
1417
+ const signedMap = { ...unsignedMap, [signature.SIGNATURE_ENTRY_NAME]: bundleBytes };
1418
+ return {
1419
+ containerBytes: packEntryMap(signedMap),
1420
+ bundle,
1421
+ bundleBytes,
1422
+ content_digest: contentDigest,
1423
+ };
1424
+ }
1425
+
1263
1426
  // ─── unpack ────────────────────────────────────────────────────────────
1264
1427
 
1265
1428
  /**
@@ -1340,6 +1503,12 @@ function buildLoadPlanIssue(code, severity, message) {
1340
1503
 
1341
1504
  function validationProblemCode(problem) {
1342
1505
  if (/checksums?:/i.test(problem)) return 'KDNA_INTEGRITY_DIGEST_FAILED';
1506
+ if (/signature bundle profile_version .* is not supported/i.test(problem)) {
1507
+ return 'KDNA_SIGNATURE_VERSION_UNSUPPORTED';
1508
+ }
1509
+ if (/signature bundle (profile|algorithm) .* is not supported/i.test(problem)) {
1510
+ return 'KDNA_SIGNATURE_PROFILE_UNSUPPORTED';
1511
+ }
1343
1512
  if (/signature/i.test(problem)) return 'KDNA_INTEGRITY_SIGNATURE_FAILED';
1344
1513
  if (/payload:/i.test(problem)) return 'KDNA_FORMAT_INVALID';
1345
1514
  if (/manifest:/i.test(problem)) return 'KDNA_FORMAT_INVALID';
@@ -1413,9 +1582,11 @@ function baseLoadPlan(inputPath, layout, validation, opts = {}) {
1413
1582
  schema_valid: validation.schema_valid,
1414
1583
  payload_valid: validation.payload_valid,
1415
1584
  checksums_valid: validation.checksums_valid,
1585
+ signature_valid: validation.signature_valid,
1416
1586
  load_contract_valid: validation.load_contract_valid,
1417
1587
  overall_valid: validation.overall_valid,
1418
1588
  },
1589
+ signature_state: validation.signature_state,
1419
1590
  issues: [],
1420
1591
  source: {
1421
1592
  kind: layout.kind,
@@ -1481,7 +1652,8 @@ function planLoad(inputPath, opts = {}) {
1481
1652
  access: null, access_alias: null, entitlement_profile: null,
1482
1653
  state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1483
1654
  input_fingerprint: null,
1484
- checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1655
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, signature_valid: false, load_contract_valid: false, overall_valid: false },
1656
+ signature_state: 'absent',
1485
1657
  issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
1486
1658
  source: inputSource(inputPath, 'memory'),
1487
1659
  });
@@ -1505,7 +1677,8 @@ function planLoad(inputPath, opts = {}) {
1505
1677
  access: null, access_alias: null, entitlement_profile: null,
1506
1678
  state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1507
1679
  input_fingerprint: null,
1508
- checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1680
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, signature_valid: false, load_contract_valid: false, overall_valid: false },
1681
+ signature_state: 'absent',
1509
1682
  issues: [buildLoadPlanIssue(
1510
1683
  e.code === 'KDNA_FORMAT_UNKNOWN' ? 'KDNA_FORMAT_UNKNOWN' : 'KDNA_FORMAT_INVALID',
1511
1684
  'blocking', e.message,
@@ -1526,7 +1699,8 @@ function planLoad(inputPath, opts = {}) {
1526
1699
  access: null, access_alias: null, entitlement_profile: null,
1527
1700
  state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
1528
1701
  input_fingerprint: null,
1529
- checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
1702
+ checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, signature_valid: false, load_contract_valid: false, overall_valid: false },
1703
+ signature_state: 'absent',
1530
1704
  issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
1531
1705
  source: inputSource(inputPath, null),
1532
1706
  });
@@ -1979,6 +2153,8 @@ module.exports = {
1979
2153
  isKdnaSourceDir,
1980
2154
  detectContainerFormat,
1981
2155
  readLayout: readLayout,
2156
+ readLayoutBytes,
2157
+ fullEntryBufferMap,
1982
2158
  inspect,
1983
2159
  validate,
1984
2160
  planLoad,
@@ -1986,6 +2162,8 @@ module.exports = {
1986
2162
  buildChecksums: buildChecksums,
1987
2163
  computeRuntimeEntrySetDigest,
1988
2164
  pack,
2165
+ packEntryMap,
2166
+ signContainerBytes,
1989
2167
  unpack,
1990
2168
  load: loadAsset,
1991
2169
  loadAsset: loadAsset,
@@ -2116,7 +2294,8 @@ function loadAuthorized(inputPath, opts = {}) {
2116
2294
  extendsChain: plan.extends_chain || [],
2117
2295
  _validation: {
2118
2296
  schema_valid: plan.checks && plan.checks.overall_valid === true,
2119
- signature_valid: plan.signature_valid === true,
2297
+ signature_valid: plan.checks && plan.checks.signature_valid === true,
2298
+ signature_state: plan.signature_state,
2120
2299
  },
2121
2300
  _runtimeAssetBytes: inputSnapshot,
2122
2301
  _runtimeInputKind: inputKind,
@@ -2158,9 +2337,11 @@ function packagedAssetRequiredPlan(inputPath) {
2158
2337
  schema_valid: false,
2159
2338
  payload_valid: false,
2160
2339
  checksums_valid: false,
2340
+ signature_valid: false,
2161
2341
  load_contract_valid: false,
2162
2342
  overall_valid: false,
2163
2343
  },
2344
+ signature_state: 'absent',
2164
2345
  issues: [buildLoadPlanIssue(
2165
2346
  'KDNA_ASSET_FILE_REQUIRED',
2166
2347
  'blocking',
@@ -2259,7 +2440,8 @@ function loadRemoteRuntimeAssetForServer(inputPath) {
2259
2440
  extendsChain: [],
2260
2441
  _validation: {
2261
2442
  schema_valid: plan.checks.overall_valid === true,
2262
- signature_valid: plan.signature_valid === true,
2443
+ signature_valid: plan.checks.signature_valid === true,
2444
+ signature_state: plan.signature_state,
2263
2445
  },
2264
2446
  _runtimeAssetBytes: inputSnapshot,
2265
2447
  _runtimeInputKind: inputKind,
@@ -2319,6 +2501,27 @@ function hasStandardJudgmentRoleContent(role) {
2319
2501
  );
2320
2502
  }
2321
2503
 
2504
+ function projectMinimalAxiom(axiom) {
2505
+ const normalized = normalizeCompactAxiom(axiom);
2506
+ if (!normalized) return null;
2507
+ return {
2508
+ type: 'axiom_applicability',
2509
+ id: normalized.id || null,
2510
+ one_sentence: normalized.one_sentence || null,
2511
+ does_not_apply_when: normalized.does_not_apply_when || [],
2512
+ failure_risk: normalized.failure_risk || null,
2513
+ };
2514
+ }
2515
+
2516
+ function hasMinimalPromptContent(content) {
2517
+ if (!content) return false;
2518
+ return Boolean(
2519
+ content.highest_question ||
2520
+ (Array.isArray(content.axioms) && content.axioms.length > 0) ||
2521
+ (Array.isArray(content.boundaries) && content.boundaries.length > 0)
2522
+ );
2523
+ }
2524
+
2322
2525
  function hasCompactPromptContent(content) {
2323
2526
  if (!content) return false;
2324
2527
  return Boolean(
@@ -2566,6 +2769,17 @@ function loadAssetUnsafe(inputPath, opts = {}) {
2566
2769
  || (payload.reasoning && ((payload.reasoning.self_check && payload.reasoning.self_check.length > 0) || (payload.reasoning.failure_modes && payload.reasoning.failure_modes.length > 0)));
2567
2770
  profiles.push('index');
2568
2771
  if (hasJudgment) profiles.push('compact');
2772
+ // RFC-0020: minimal availability follows the manifest declaration, not
2773
+ // payload inference. An asset that declares minimal in load_contract
2774
+ // (and carries core judgment) may be projected minimally.
2775
+ if (
2776
+ hasJudgment &&
2777
+ m.load_contract &&
2778
+ m.load_contract.profiles &&
2779
+ m.load_contract.profiles.minimal
2780
+ ) {
2781
+ profiles.push('minimal');
2782
+ }
2569
2783
  if (payload.scenarios && payload.scenarios.length > 0) profiles.push('scenario');
2570
2784
  profiles.push('full');
2571
2785
  return profiles;
@@ -2657,6 +2871,34 @@ function loadAssetUnsafe(inputPath, opts = {}) {
2657
2871
  if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
2658
2872
  result.max_tokens_hint = m.load_contract.profiles.compact.max_tokens_hint;
2659
2873
  }
2874
+ } else if (profile === 'minimal') {
2875
+ const core = payload.core || {};
2876
+ // RFC-0020: minimal availability follows the manifest declaration. If the
2877
+ // asset does not declare minimal in load_contract, fail closed with an
2878
+ // empty projection rather than silently serving a projection the asset
2879
+ // did not opt into.
2880
+ if (!result.profile_available) {
2881
+ result.content = {};
2882
+ result.projection_report = buildCompactProjectionReport(payload);
2883
+ } else {
2884
+ const normalizeList = (items) => (items || []).map((item) => {
2885
+ if (typeof item === 'string') return { type: 'text', text: item };
2886
+ if (item && typeof item === 'object') return item;
2887
+ return null;
2888
+ }).filter(Boolean);
2889
+ // RFC-0020: minimal projects the core judgment surface a small model needs:
2890
+ // highest_question + boundary-friendly axioms + full boundaries. It is a
2891
+ // strict subset of compact (deterministic reduction, no inference).
2892
+ result.content = {
2893
+ highest_question: core.highest_question || null,
2894
+ axioms: (core.axioms || []).map(projectMinimalAxiom).filter(Boolean),
2895
+ boundaries: normalizeList(core.boundaries),
2896
+ };
2897
+ result.projection_report = buildCompactProjectionReport(payload);
2898
+ if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.minimal && m.load_contract.profiles.minimal.max_tokens_hint) {
2899
+ result.max_tokens_hint = m.load_contract.profiles.minimal.max_tokens_hint;
2900
+ }
2901
+ }
2660
2902
  } else if (profile === 'scenario') {
2661
2903
  result.content = { scenarios: payload.scenarios || [] };
2662
2904
  } else if (profile === 'full') {
@@ -2750,15 +2992,23 @@ function loadAssetUnsafe(inputPath, opts = {}) {
2750
2992
  const c = result.content;
2751
2993
  const compactHasNoPromptContent =
2752
2994
  result.profile === 'compact' && !hasCompactPromptContent(c);
2995
+ const minimalHasNoPromptContent =
2996
+ result.profile === 'minimal' && !hasMinimalPromptContent(c);
2753
2997
  const legacyProfileHasNoPromptContent =
2754
2998
  result.profile !== 'compact' &&
2999
+ result.profile !== 'minimal' &&
2755
3000
  c &&
2756
3001
  c.scenarios &&
2757
3002
  c.scenarios.length === 0 &&
2758
3003
  !c.highest_question &&
2759
3004
  !(c.axioms && c.axioms.length) &&
2760
3005
  !(c.boundaries && c.boundaries.length);
2761
- if (!c || compactHasNoPromptContent || legacyProfileHasNoPromptContent) {
3006
+ if (
3007
+ !c ||
3008
+ compactHasNoPromptContent ||
3009
+ minimalHasNoPromptContent ||
3010
+ legacyProfileHasNoPromptContent
3011
+ ) {
2762
3012
  return {
2763
3013
  status: result.status,
2764
3014
  profile: result.profile,
@@ -2850,6 +3100,23 @@ function wrapAsCapsule(result, layout, profile, opts) {
2850
3100
  return result;
2851
3101
  }
2852
3102
 
3103
+ function buildSignatureEvidence(layout) {
3104
+ if (!layout || !layout.map || layout.map['signature.kdsig'] === undefined) {
3105
+ return { state: 'absent' };
3106
+ }
3107
+ const signature = require('../signature');
3108
+ const { contentDigestFromEntryBuffers } = require('../asset-reader');
3109
+ const contentDigest = contentDigestFromEntryBuffers(fullEntryBufferMap(layout));
3110
+ const evidence = signature.verifySignatureBundle(layout.map['signature.kdsig'], contentDigest);
3111
+ return {
3112
+ state: 'verified',
3113
+ profile: evidence.profile,
3114
+ profile_version: evidence.profile_version,
3115
+ key_fingerprint: evidence.key_fingerprint,
3116
+ content_digest: evidence.content_digest,
3117
+ };
3118
+ }
3119
+
2853
3120
  function buildRuntimeCapsuleProjection(loadResult, layout, profile, opts = {}) {
2854
3121
  const m = layout.manifest;
2855
3122
  const val = opts._validation || {};
@@ -2861,7 +3128,7 @@ function buildRuntimeCapsuleProjection(loadResult, layout, profile, opts = {}) {
2861
3128
  projection: loadResult,
2862
3129
  manifest: m,
2863
3130
  digests,
2864
- signature: { state: 'absent' },
3131
+ signature: buildSignatureEvidence(layout),
2865
3132
  inputKind: opts._runtimeInputKind,
2866
3133
  loadedAt: opts.loadedAt,
2867
3134
  schemaValid: val.schema_valid === true,
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,
@@ -80,8 +84,25 @@ export const verifyDigest = publicApi.verifyDigest;
80
84
  export const verifyDigestSync = publicApi.verifyDigestSync;
81
85
  export const matchDomain = publicApi.matchDomain;
82
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;
83
91
  export const composeKDNA = publicApi.composeKDNA;
84
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
+
85
106
  export const DIGEST_PROFILE = runtimeCapsule.DIGEST_PROFILE;
86
107
  export const DIGEST_PROFILE_VERSION = runtimeCapsule.DIGEST_PROFILE_VERSION;
87
108
  export const CAPSULE_DIGEST_PROFILE = runtimeCapsule.CAPSULE_DIGEST_PROFILE;
@@ -121,6 +142,7 @@ export const validateJudgmentTrace = runtimeContract.validateJudgmentTrace;
121
142
 
122
143
  export const STANDARD_ENTRIES = assetReader.STANDARD_ENTRIES;
123
144
  export const createKdnaAssetReader = assetReader.createKdnaAssetReader;
145
+ export const contentDigestFromEntryBuffers = assetReader.contentDigestFromEntryBuffers;
124
146
 
125
147
  export const {
126
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();
@@ -176,6 +177,55 @@ function matchDomainSync(input, candidates, options = {}) {
176
177
  return results.sort((a, b) => b.score - a.score || String(a.asset_id).localeCompare(String(b.asset_id)));
177
178
  }
178
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
+
179
229
  async function composeKDNA(inputs, options = {}) {
180
230
  void inputs;
181
231
  void options;
@@ -203,5 +253,9 @@ module.exports = {
203
253
  verifyDigestSync,
204
254
  matchDomain,
205
255
  matchDomainSync,
256
+ signKDNA,
257
+ signKDNASync,
258
+ verifyKDNASignature,
259
+ verifyKDNASignatureSync,
206
260
  composeKDNA,
207
261
  };