@aikdna/kdna-core 0.14.0 → 0.15.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,107 @@
1
+ # Changelog
2
+
3
+ ## v0.15.1 (2026-06-27)
4
+ - Fix: package.json `files` array now includes `CHANGELOG.md`. The
5
+ v0.15.0 tarball was published without the changelog, leaving
6
+ consumers with no way to read release notes from `npm install`.
7
+ Adding it now is a no-op for code but resolves the docs gap.
8
+
9
+ ## v0.15.0 (2026-06-27)
10
+ - B2: scrypt password profile `kdna-password-protected-v1-scrypt` (ADR-007)
11
+ - encryptProtectedEntryScrypt / decryptProtectedEntryScrypt (zero new deps, Node crypto.scryptSync)
12
+ - AES-256-GCM + AES-256-KW envelope encryption, same CEK model as RFC-0008
13
+ - scrypt N=32768 r=8 p=1, 32B KEK, 16B random salt
14
+ - planLoad inferEntitlementProfile detects scrypt profile
15
+ - decrypt dispatch routes Argon2id vs scrypt by envelope profile field
16
+ - recovery slot deferred to v0.2; v0.1: single password slot
17
+ - B5: conformance:canonical npm script (threshold #8)
18
+
19
+ ## v0.14.0 (2026-06-25)
20
+ - B1: unified container dispatcher (readAsset — v1/v2/ dir → CanonicalAssetModel)
21
+ - B4: loadAuthorized decryption orchestration (opts.password, opts.decryptEntry)
22
+ - B4: encrypted payload detection in runValidate (backward compatible)
23
+ - B8: cross-language golden vectors (test-vectors/golden-vectors.js — 8/8)
24
+ - Wave 4: canonical conformance (conformance/canonical-conformance.mjs — 13/13)
25
+ - Wave 2.5: clear deprecated registry/domains.json private entries
26
+ - Wave 3a: CQ-T2/T3 symmetric compact rendering, scenario null consistency
27
+ - Add: engines.node >=18.0.0
28
+
29
+ ## v0.13.3 (2026-06-22)
30
+ - Fix: index profile includes max_tokens_hint
31
+ - Fix: compact profile falls back to full_statement for TBD placeholders
32
+
33
+ Packages: `@aikdna/kdna-core`
34
+
35
+ ## v0.13.2 (2026-06-21)
36
+
37
+ ### Fixed
38
+ - Compact boundary projection now correctly preserves `applies_when`, `does_not_apply_when`, and `failure_risk` fields across all load profiles. These axiom governance fields are required by `kdna-loader` for domain routing decisions and were being dropped in compact mode under certain manifest configurations.
39
+ - Cross-platform test stability: Node test glob patterns are now compatible with both macOS and Linux filesystem sort orders.
40
+ - Hash library compatibility: `@noble/hashes` replaces Node-only crypto for digest operations, maintaining Node 18 compatibility without native module binding requirements.
41
+
42
+ ### Changed
43
+ - Public API boundary hardened: `loadKDNA`, `loadKDNASync`, `inspectKDNA`, and `inspectKDNASync` now enforce the asset-first contract — callers must pass `.kdna` files or asset handles, not raw directory paths.
44
+ - Examples in README and inline docs reference the packaged `.kdna` path rather than source directories.
45
+ - First-run contract tightened: exported symbols, error codes, and validation gate names are now part of the stable API surface.
46
+
47
+ ---
48
+
49
+ ## v0.13.1 (2026-06-21)
50
+
51
+ ### Fixed
52
+ - **Compact boundary projection preserved.** The `planLoad` function and `loadAuthorized` shim now pass through the full axiom governance projection (applies_when, does_not_apply_when, failure_risk) even when the load contract is not present in the manifest. Prior versions could strip these fields under compact profile, breaking domain routing for `kdna-loader`.
53
+ - Digest matching now tolerates `sha256:` prefix in both `manifest_digest` and `payload_digest` checksums entries. Previously, a leading `sha256:` prefix was treated as part of the hex digest, causing false mismatches.
54
+
55
+ ### Changed
56
+ - Public API provenance and profile split clarified: `loadKDNA` / `loadKDNASync` are the stable entry points for runtime consumers. Lower-level `readV1Layout`, `runValidate`, and `planLoad` are exported for advanced tooling but carry no backward-compatibility guarantee.
57
+ - Access enum values normalized everywhere: `public`, `licensed`, `remote`. Legacy aliases (`open` → `public`, `protected` → `licensed`, `runtime` → `remote`) are mapped transparently with an info-level issue in the LoadPlan.
58
+ - `verifyAsset` / `verifyAssetSync`, `verifyDigest` / `verifyDigestSync`, and `verifySignature` / `verifySignatureSync` are now documented as the stable verification API surface.
59
+
60
+ ---
61
+
62
+ ## v0.13.0 (2026-06-19)
63
+
64
+ ### Added
65
+ - **Authorization LoadPlan contract.** The `planLoad(inputPath, opts)` function returns a structured LoadPlan before any judgment content is decrypted or emitted. The LoadPlan reports:
66
+ - `asset_id`, `asset_uid`, `title`, `version`, `judgment_version`
67
+ - `access` model (`public` | `licensed` | `remote`)
68
+ - `entitlement_profile` (`password`, `local_receipt`, `account`, `org`, `purchase_receipt`, `device_bound`)
69
+ - `state` (`ready` | `needs_password` | `needs_license` | `needs_account` | `needs_org_auth` | `expired_grace` | `offline_grace` | `denied` | `invalid` | `needs_runtime`)
70
+ - `required_action` (`load` | `enter_password` | `install_receipt` | `sign_in_or_activate` | `renew_entitlement` | `sync` | `contact_issuer` | `connect_runtime` | `block`)
71
+ - `can_load_now` boolean — the single decision point for product consumers
72
+ - `projection_policy` (`none` | `minimal` | `remote`)
73
+ - `checks` block with per-gate validation results
74
+ - `issues` array with structured error codes (`KDNA_FORMAT_INVALID`, `KDNA_INTEGRITY_DIGEST_FAILED`, `KDNA_AUTH_PASSWORD_REQUIRED`, `KDNA_AUTH_ENTITLEMENT_REQUIRED`, `KDNA_ACCESS_MODE_UNKNOWN`, etc.)
75
+ - `input_fingerprint` capturing the source digest, has-password signal, and entitlement status for cache keying
76
+ - `source.kind` and `source.path` indicating whether the input was a source directory or `.kdna` container
77
+ - **`loadAuthorized(inputPath, opts)`** — a higher-level loader that calls `planLoad` first and blocks load when `can_load_now` is false. Throws `KDNA_LOAD_NOT_AUTHORIZED` with the first issue's code. This is the recommended entry point for agent runtimes.
78
+ - **`loadV1(inputPath, opts)`** — renders judgment content into agent-ready prompt text. Supports profiles (`index`, `compact`, `scenario`, `full`) and output formats (`json`, `prompt`). The `prompt` format produces a flat text block suitable for agent context windows with proper axiom applicability rendering.
79
+ - **KDNA Core v1 inspect module.** `inspect(inputPath)` returns a content-neutral manifest summary including `asset_id`, `asset_uid`, `kdna_version`, `payload_encrypted`, `profile`, `load_contract_default_profile`, and signature count. Output is always JSON. Banned terms (`trusted`, `recommended`, `high_quality`, `officially_approved`) are enforced by an automatic assertion.
80
+ - **KDNA Core v1 validate module.** `validate(inputPath)` runs four independent gates:
81
+ 1. **Format gate:** required entries present (`mimetype`, `kdna.json`, `payload.kdnab`), mimetype content correct, lineage is an object not an array
82
+ 2. **Schema gate:** `kdna.json` against `manifest.schema.json` via AJV 2020-12
83
+ 3. **Payload gate:** `payload.kdnab` against `payload-profile-v1.schema.json`
84
+ 4. **Checksums gate:** when `checksums.json` is present, computed digests are compared against declared values for `kdna.json`, `payload.kdnab`, and the combined asset digest
85
+ - **KDNA Core v1 pack module.** `pack(sourceDir, outputPath)` produces a deterministic `.kdna` container: fixed DOS epoch timestamps, alphabetical entry order, mimetype first (STORED, method 0). Same source → byte-identical output.
86
+ - **KDNA Core v1 unpack module.** `unpack(inputPath, outputDir)` extracts a `.kdna` container to a directory with path-traversal protection.
87
+ - **Container security hardening.** ZIP reader enforces:
88
+ - Maximum container size (25 MiB), max entries (128), max entry size (5 MiB), max total uncompressed (12 MiB)
89
+ - Max compression ratio (100:1) to prevent zip bombs
90
+ - Max JSON depth (64), max array length (10,000), max string length (1 MiB)
91
+ - Entry name normalization (NFC Unicode, no backslash separators, no absolute paths, no `..` traversal)
92
+ - Duplicate entry rejection
93
+ - Symlink and device/special file rejection
94
+ - **Digest matching verification.** When `checksums.json` includes `manifest_digest`, `payload_digest`, or `asset_digest`, the validate module computes actual SHA-256 hashes and compares them. Mismatch produces a `KDNA_INTEGRITY_DIGEST_FAILED` issue.
95
+ - **Load contract validation.** When the manifest includes a `load_contract` block, it is validated against `load-contract.schema.json`. A missing load contract is not an error — it simply means no specialization.
96
+ - **Dual CJS/ESM exports.** `src/index.js` (CommonJS) and `src/index.mjs` (ES module) with matching `exports` map. The v1 module is also available at `@aikdna/kdna-core/v1`.
97
+ - **TypeScript declarations.** `src/types.d.ts` exports type definitions for the stable public API surface.
98
+
99
+ ### Changed
100
+ - **V1 is the only active format path.** The v2 reader (`readDataMapSync`, `readDataMap`) remains in the codebase for migration compatibility but is no longer the default path. All new integrations should use the v1 API: `inspect`, `validate`, `planLoad`, `loadAuthorized`.
101
+ - `validate` and `planLoad` reject v2 containers (`application/vnd.aikdna.kdna+zip`) with a format error.
102
+ - Access model normalized to the three-tier `public` | `licensed` | `remote` enum. The LoadPlan maps legacy values transparently with an info-level issue.
103
+ - Container format detection (`detectContainerFormat`) reads only the first central-directory entry to determine v1 vs v2, preventing malicious later entries from causing format confusion.
104
+
105
+ ### Removed
106
+ - Legacy v1 compatibility shims removed. v2 is the only legacy format and is handled through the existing `asset-reader.js` module.
107
+ - `readDataMapSync` / `readDataMap` deprecated for new use. Use `planLoad` + `loadV1` for runtime loading or `validate` + `inspect` for diagnostics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-core",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "KDNA Core library for validating, planning, and loading local .kdna judgment assets.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -25,13 +25,14 @@
25
25
  "types": "src/types.d.ts",
26
26
  "scripts": {
27
27
  "test": "node ../../scripts/run-node-tests.js test/*.test.js && node -e \"const m = require('./src/index.js'); console.log('kdna-core exports:', Object.keys(m).join(', '));\"",
28
- "prepublishOnly": "npm run release:check && npm --prefix ../.. run ecosystem-gate",
28
+ "prepublishOnly": "npm run release:check",
29
29
  "release:check": "node scripts/check-release-readiness.js"
30
30
  },
31
31
  "files": [
32
32
  "src/",
33
33
  "schema/",
34
34
  "README.md",
35
+ "CHANGELOG.md",
35
36
  "LICENSE",
36
37
  "NOTICE"
37
38
  ],
@@ -420,6 +420,121 @@ function createRecoveryDecryptEntry(options = {}) {
420
420
  decryptProtectedEntry(ciphertext, { entryName, manifest, recoveryCode });
421
421
  }
422
422
 
423
+ // ── B2: scrypt-based password profile (v0.1 write profile) ─────────
424
+ // Uses Node built-in crypto.scryptSync — zero additional dependencies.
425
+ // v0.2 will promote the Argon2id profile (above) as the default write
426
+ // profile; this scrypt profile will remain a read-only legacy profile.
427
+
428
+ const PASSWORD_PROTECTED_SCRYPT_PROFILE = 'kdna-password-protected-v1-scrypt';
429
+ const SCRYPT_KDF = 'scrypt-sha256';
430
+
431
+ function derivePasswordKeyScrypt(password, params = {}) {
432
+ const { salt, N = 32768, r = 8, p = 1 } = params;
433
+ if (!salt) throw new Error('salt is required for scrypt KDF');
434
+ const saltBuf = decodeBase64(salt, 'salt');
435
+ const passwordBuf = toBuffer(password, 'password');
436
+ // N=32768, r=8, p=1 → 32 MiB memory. Default maxmem is 32 MiB exactly;
437
+ // bump to 64 MiB to avoid boundary rejection on some Node versions.
438
+ return crypto.scryptSync(passwordBuf, saltBuf, 32, { N, r, p, maxmem: 64 * 1024 * 1024 });
439
+ }
440
+
441
+ /**
442
+ * Encrypt a protected entry using the scrypt-based password profile.
443
+ *
444
+ * 1. Generate random CEK (32 bytes)
445
+ * 2. Derive KEK from password via scrypt-sha256
446
+ * 3. Encrypt content with CEK (AES-256-GCM)
447
+ * 4. Wrap CEK with KEK (AES-256-KW)
448
+ * 5. Store wrapped_key in envelope (single password slot, no recovery)
449
+ */
450
+ function encryptProtectedEntryScrypt(plaintext, options = {}) {
451
+ const { entryName, manifest = {}, password } = options;
452
+ if (!entryName) throw new Error('entryName is required');
453
+ if (!password) throw new Error('password is required for scrypt encryption');
454
+
455
+ const cek = generateCEK();
456
+
457
+ // Password slot
458
+ const salt = crypto.randomBytes(16);
459
+ const passwordKey = derivePasswordKeyScrypt(password, { salt: salt.toString('base64') });
460
+ const passwordWrappedKey = wrapCEK(cek, passwordKey);
461
+
462
+ const iv = crypto.randomBytes(12);
463
+ const cipher = crypto.createCipheriv('aes-256-gcm', cek, iv);
464
+ cipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_SCRYPT_PROFILE));
465
+ const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
466
+
467
+ return {
468
+ profile: PASSWORD_PROTECTED_SCRYPT_PROFILE,
469
+ alg: ALG,
470
+ kdf: SCRYPT_KDF,
471
+ key_wrapping: RFC_KEY_WRAPPING,
472
+ scrypt_params: {
473
+ N: 32768,
474
+ r: 8,
475
+ p: 1,
476
+ salt: salt.toString('base64'),
477
+ },
478
+ key_slots: [
479
+ {
480
+ slot: 'password',
481
+ wrap: RFC_KEY_WRAPPING,
482
+ wrapped_key: passwordWrappedKey.toString('base64'),
483
+ },
484
+ ],
485
+ iv: iv.toString('base64'),
486
+ tag: cipher.getAuthTag().toString('base64'),
487
+ ciphertext: ciphertext.toString('base64'),
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Decrypt a protected entry encoded with the scrypt-based password profile.
493
+ *
494
+ * 1. Derive KEK from password via scrypt-sha256
495
+ * 2. Unwrap CEK from password slot's wrapped_key (AES-256-KW)
496
+ * 3. Decrypt content with CEK (AES-256-GCM)
497
+ */
498
+ function decryptProtectedEntryScrypt(envelopeValue, options = {}) {
499
+ const { entryName, manifest = {}, password } = options;
500
+ if (!entryName) throw new Error('entryName is required');
501
+ if (!password) throw new Error('password is required for scrypt decryption');
502
+
503
+ const envelope = normalizeEnvelope(envelopeValue);
504
+ if (envelope.profile !== PASSWORD_PROTECTED_SCRYPT_PROFILE) {
505
+ throw new Error(
506
+ `unsupported encrypted entry profile: ${envelope.profile || 'unknown'} (expected ${PASSWORD_PROTECTED_SCRYPT_PROFILE})`,
507
+ );
508
+ }
509
+ if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
510
+ if (envelope.kdf !== SCRYPT_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
511
+ if (envelope.key_wrapping !== RFC_KEY_WRAPPING) {
512
+ throw new Error(`unsupported encrypted entry key_wrapping: ${envelope.key_wrapping}`);
513
+ }
514
+
515
+ const passwordKey = derivePasswordKeyScrypt(password, {
516
+ salt: envelope.scrypt_params.salt,
517
+ });
518
+ const passwordSlot = envelope.key_slots.find((s) => s.slot === 'password');
519
+ if (!passwordSlot) throw new Error('password slot missing from envelope');
520
+
521
+ const cek = unwrapCEK(decodeBase64(passwordSlot.wrapped_key, 'wrapped_key'), passwordKey);
522
+
523
+ const decipher = crypto.createDecipheriv('aes-256-gcm', cek, decodeBase64(envelope.iv, 'iv'));
524
+ decipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_SCRYPT_PROFILE));
525
+ decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
526
+ return Buffer.concat([
527
+ decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
528
+ decipher.final(),
529
+ ]);
530
+ }
531
+
532
+ function createPasswordDecryptEntryScrypt(options = {}) {
533
+ const { password } = options;
534
+ return ({ entryName, ciphertext, manifest }) =>
535
+ decryptProtectedEntryScrypt(ciphertext, { entryName, manifest, password });
536
+ }
537
+
423
538
  // ── Legacy / experimental profile (pre-RFC, backward compat) ───────
424
539
 
425
540
  function deriveLicensedEntryKeyLegacy(options = {}) {
@@ -504,11 +619,13 @@ module.exports = {
504
619
  LICENSED_ENTRY_PROFILE,
505
620
  LICENSED_EXPERIMENTAL_PROFILE,
506
621
  PASSWORD_PROTECTED_PROFILE,
622
+ PASSWORD_PROTECTED_SCRYPT_PROFILE,
507
623
  ALG,
508
624
  RFC_KDF,
509
625
  RFC_KEY_WRAPPING,
510
626
  LEGACY_KDF,
511
627
  PASSWORD_KDF,
628
+ SCRYPT_KDF,
512
629
 
513
630
  // RFC-0008 compliant
514
631
  deriveWrappingKey,
@@ -518,7 +635,7 @@ module.exports = {
518
635
  encryptLicensedEntryV1,
519
636
  decryptLicensedEntryV1,
520
637
 
521
- // RFC-0009 compliant
638
+ // RFC-0009 compliant (Argon2id)
522
639
  derivePasswordKey,
523
640
  generateRecoveryCode,
524
641
  decodeRecoveryCode,
@@ -527,6 +644,12 @@ module.exports = {
527
644
  createPasswordDecryptEntry,
528
645
  createRecoveryDecryptEntry,
529
646
 
647
+ // B2 scrypt password profile (v0.1 write profile)
648
+ derivePasswordKeyScrypt,
649
+ encryptProtectedEntryScrypt,
650
+ decryptProtectedEntryScrypt,
651
+ createPasswordDecryptEntryScrypt,
652
+
530
653
  // Legacy
531
654
  deriveLicensedEntryKey: deriveLicensedEntryKeyLegacy,
532
655
  encryptLicensedEntryLegacy,
package/src/v1/index.js CHANGED
@@ -1014,6 +1014,9 @@ function inferEntitlementProfile(manifest) {
1014
1014
  if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1') {
1015
1015
  return 'password';
1016
1016
  }
1017
+ if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1-scrypt') {
1018
+ return 'password';
1019
+ }
1017
1020
  if (manifest.access === 'protected') return 'password';
1018
1021
  return null;
1019
1022
  }
@@ -1609,11 +1612,20 @@ function loadV1Unsafe(inputPath, opts = {}) {
1609
1612
  throw err;
1610
1613
  }
1611
1614
  } else if (opts.password) {
1612
- // Password-based decryption via kdna-password-protected-v1 profile
1615
+ // Password-based decryption via kdna-password-protected-v1*
1616
+ // profiles. Detects Argon2id vs scrypt from the envelope profile.
1613
1617
  try {
1614
- const { decryptProtectedEntry } = require('../crypto-profile.js');
1618
+ const {
1619
+ decryptProtectedEntry,
1620
+ decryptProtectedEntryScrypt,
1621
+ PASSWORD_PROTECTED_SCRYPT_PROFILE,
1622
+ } = require('../crypto-profile.js');
1615
1623
  const encryptedEnvelope = v1.map['payload.kdnab'].toString('utf8');
1616
- const decryptedBuf = decryptProtectedEntry(encryptedEnvelope, {
1624
+ const envelope = JSON.parse(encryptedEnvelope);
1625
+ const decryptFn = (envelope.profile === PASSWORD_PROTECTED_SCRYPT_PROFILE)
1626
+ ? decryptProtectedEntryScrypt
1627
+ : decryptProtectedEntry;
1628
+ const decryptedBuf = decryptFn(encryptedEnvelope, {
1617
1629
  entryName: 'payload.kdnab',
1618
1630
  manifest: m,
1619
1631
  password: opts.password,