@aikdna/kdna-core 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikdna/kdna-core",
3
- "version": "0.7.2",
3
+ "version": "0.9.0",
4
4
  "description": "KDNA core library — pure logic for loading, validating, linting, and rendering KDNA domain cognition packages. Zero Node.js dependencies.",
5
5
  "type": "commonjs",
6
6
  "main": "src/index.js",
@@ -47,5 +47,9 @@
47
47
  "ajv-formats": {
48
48
  "optional": true
49
49
  }
50
+ },
51
+ "dependencies": {
52
+ "@noble/hashes": "^2.2.0",
53
+ "cbor-x": "^1.6.4"
50
54
  }
51
55
  }
@@ -1,27 +1,13 @@
1
1
  /**
2
2
  * KDNA Asset Reader — direct .kdna container access.
3
- *
4
- * This module intentionally uses only Node.js built-ins. It reads ZIP central
5
- * directory records directly so runtimes can inspect, verify, and load .kdna
6
- * assets without persistent extraction to a domain directory.
7
3
  */
8
4
 
9
5
  const fs = require('fs');
10
6
  const crypto = require('crypto');
11
7
  const zlib = require('zlib');
8
+ const cbor = require('cbor-x');
12
9
  const { loadDomainFromFiles, formatContext } = require('./loader');
13
10
 
14
- const STANDARD_ENTRIES = [
15
- 'kdna.json',
16
- 'KDNA_Core.json',
17
- 'KDNA_Patterns.json',
18
- 'KDNA_Scenarios.json',
19
- 'KDNA_Cases.json',
20
- 'KDNA_Reasoning.json',
21
- 'KDNA_Evolution.json',
22
- ];
23
-
24
- const JSON_ENTRY_RE = /\.json$/i;
25
11
  const KDNA_MEDIA_TYPE = 'application/vnd.aikdna.kdna+zip';
26
12
 
27
13
  function sha256Hex(buf) {
@@ -274,6 +260,53 @@ function verifyMediaType(asset, errors) {
274
260
  }
275
261
  }
276
262
 
263
+ /**
264
+ * Verify Human Lock signatures on locked axiom cards.
265
+ * Reconstructs signing payload (cardId|statement|fingerprint) and verifies
266
+ * against the creator's public key from kdna.json manifest.author.
267
+ */
268
+ function verifyHumanLockSignatures(coreData, manifest, errors, warnings) {
269
+ const publicKeyPEM = manifest?.author?.public_key_pem;
270
+ if (!publicKeyPEM) {
271
+ // Skip if no public key available (unsigned assets are valid)
272
+ return;
273
+ }
274
+ const axioms = coreData?.axioms || [];
275
+ if (!Array.isArray(axioms) || axioms.length === 0) return;
276
+
277
+ let verified = 0, missing = 0, invalid = 0;
278
+ for (const ax of axioms) {
279
+ const hl = ax.human_lock;
280
+ if (!hl || !hl.signature) {
281
+ // Only warn for locked cards without signature
282
+ if (ax.status === 'locked' || ax.status === 'tested' || ax.status === 'published') {
283
+ missing++;
284
+ }
285
+ continue;
286
+ }
287
+ try {
288
+ const payload = [ax.id, hl.statement || '', hl.judgment_fingerprint || ''].join('\n');
289
+ const sig = Buffer.from(String(hl.signature).replace(/^ed25519:/, ''), 'hex');
290
+ const key = crypto.createPublicKey(publicKeyPEM);
291
+ const ok = crypto.verify(null, Buffer.from(payload), key, sig);
292
+ if (ok) { verified++; }
293
+ else { invalid++; }
294
+ } catch {
295
+ invalid++;
296
+ }
297
+ }
298
+
299
+ if (invalid > 0) {
300
+ errors.push(`${invalid} Human Lock signature(s) failed verification — judgment may have been altered`);
301
+ }
302
+ if (missing > 0) {
303
+ warnings.push(`${missing} locked card(s) have no Human Lock signature`);
304
+ }
305
+ if (verified > 0 && invalid === 0) {
306
+ // All signed locks verified — good
307
+ }
308
+ }
309
+
277
310
  function validateManifestIdentity(manifest, errors, _warnings) {
278
311
  if (manifest.kdna_spec) {
279
312
  errors.push('kdna.json: kdna_spec is not allowed. Use spec_version.');
@@ -281,9 +314,9 @@ function validateManifestIdentity(manifest, errors, _warnings) {
281
314
  if (manifest.format && manifest.format !== 'kdna') {
282
315
  errors.push(`kdna.json.format: invalid value "${manifest.format}". Expected "kdna".`);
283
316
  }
284
- if (manifest.format_version && manifest.format_version !== '1.0') {
317
+ if (manifest.format_version && manifest.format_version !== '2.0') {
285
318
  errors.push(
286
- `kdna.json.format_version: invalid value "${manifest.format_version}". Expected "1.0".`,
319
+ `kdna.json.format_version: invalid value "${manifest.format_version}". Expected "2.0".`,
287
320
  );
288
321
  }
289
322
  if (!manifest.spec_version) errors.push('kdna.json: missing required field "spec_version"');
@@ -323,19 +356,30 @@ function readManifest(asset) {
323
356
  return parseJson(asset.readEntry('kdna.json'), 'kdna.json');
324
357
  }
325
358
 
326
- function readDataMapSync(asset, entries = STANDARD_ENTRIES, options = {}) {
359
+ function readDataMapSync(asset, options = {}) {
327
360
  const dataMap = {};
328
361
  const manifest = readManifest(asset);
329
- const encrypted = encryptedEntries(manifest).filter((entryName) => entries.includes(entryName));
330
- if (encrypted.length && typeof options.decryptEntry !== 'function') {
331
- throw new Error(`encrypted entries require decryptEntry hook: ${encrypted.join(', ')}`);
332
- }
333
- for (const entryName of entries) {
334
- if (!asset.entries.has(entryName)) continue;
335
- const buf = maybeDecryptEntrySync(asset, manifest, entryName, asset.readEntry(entryName), options);
336
- dataMap[entryName] = parseJson(buf, entryName);
362
+
363
+ if (asset.entries.has('payload.kdnab')) {
364
+ const payloadBuf = asset.readEntry('payload.kdnab');
365
+ const payload = cbor.decode(payloadBuf);
366
+ if (payload && payload.judgment) {
367
+ if (payload.judgment.core) dataMap['KDNA_Core.json'] = payload.judgment.core;
368
+ if (payload.judgment.patterns) dataMap['KDNA_Patterns.json'] = payload.judgment.patterns;
369
+ if (payload.judgment.scenarios) dataMap['KDNA_Scenarios.json'] = payload.judgment.scenarios;
370
+ if (payload.judgment.cases) dataMap['KDNA_Cases.json'] = payload.judgment.cases;
371
+ if (payload.judgment.reasoning) dataMap['KDNA_Reasoning.json'] = payload.judgment.reasoning;
372
+ if (payload.judgment.evolution) dataMap['KDNA_Evolution.json'] = payload.judgment.evolution;
373
+ }
374
+
375
+ const encrypted = encryptedEntries(manifest);
376
+ if (encrypted.length && typeof options.decryptEntry !== 'function') {
377
+ throw new Error(`encrypted entries require decryptEntry hook: ${encrypted.join(', ')}`);
378
+ }
379
+ return dataMap;
337
380
  }
338
- return dataMap;
381
+
382
+ throw new Error('Not a KDNA asset: missing payload.kdnab');
339
383
  }
340
384
 
341
385
  function verifySync(asset, options = {}) {
@@ -345,10 +389,7 @@ function verifySync(asset, options = {}) {
345
389
 
346
390
  if (!asset.entries.has('kdna.json')) errors.push('required entry missing: kdna.json');
347
391
  verifyMediaType(asset, errors);
348
- if (!asset.entries.has('KDNA_Core.json')) errors.push('required entry missing: KDNA_Core.json');
349
- if (!asset.entries.has('KDNA_Patterns.json')) {
350
- errors.push('required entry missing: KDNA_Patterns.json');
351
- }
392
+ if (!asset.entries.has('payload.kdnab')) errors.push('required entry missing: payload.kdnab');
352
393
 
353
394
  const content_digest = buildContentDigest(asset);
354
395
  const asset_digest = asset.asset_digest;
@@ -395,6 +436,15 @@ function verifySync(asset, options = {}) {
395
436
  if (options.requireSignature || manifest.signature) {
396
437
  signature_valid = verifySignature(asset, manifest, errors, warnings);
397
438
  }
439
+ // ── Human Lock signature verification ──────────────────
440
+ if (asset.entries.has('KDNA_Core.json')) {
441
+ try {
442
+ const coreData = parseJson(asset.readEntry('KDNA_Core.json'), 'KDNA_Core.json');
443
+ verifyHumanLockSignatures(coreData, manifest, errors, warnings);
444
+ } catch (e) {
445
+ warnings.push(`Human Lock signature check skipped: ${e.message}`);
446
+ }
447
+ }
398
448
  } catch (e) {
399
449
  errors.push(e.message);
400
450
  }
@@ -1,5 +1,12 @@
1
1
  const crypto = require('crypto');
2
2
 
3
+ let argon2id;
4
+ try {
5
+ ({ argon2id } = require('@noble/hashes/argon2.js'));
6
+ } catch {
7
+ // Optional: password-protected assets require @noble/hashes
8
+ }
9
+
3
10
  // ── Profile constants ──────────────────────────────────────────────
4
11
 
5
12
  /**
@@ -18,9 +25,19 @@ const LICENSED_ENTRY_PROFILE = 'kdna-licensed-entry-v1';
18
25
  */
19
26
  const LICENSED_EXPERIMENTAL_PROFILE = 'kdna-licensed-entry-experimental';
20
27
 
28
+ /**
29
+ * RFC-0009 compliant profile.
30
+ * - Argon2id password-based key derivation
31
+ * - AES-256-KW content encryption key wrapping
32
+ * - AES-256-GCM content encryption
33
+ * - Dual key slots: password + recovery
34
+ */
35
+ const PASSWORD_PROTECTED_PROFILE = 'kdna-password-protected-v1';
36
+
21
37
  const RFC_KDF = 'HKDF-SHA256';
22
38
  const RFC_KEY_WRAPPING = 'AES-256-KW';
23
39
  const LEGACY_KDF = 'scrypt-sha256';
40
+ const PASSWORD_KDF = 'Argon2id';
24
41
  const ALG = 'AES-256-GCM';
25
42
 
26
43
  // ── Helpers ───────────────────────────────────────────────────────
@@ -246,6 +263,163 @@ function decryptLicensedEntryV1(envelopeValue, options = {}) {
246
263
  ]);
247
264
  }
248
265
 
266
+ // ── RFC-0009: Password-protected encryption ───────────────────────
267
+
268
+ function ensureArgon2id() {
269
+ if (!argon2id) {
270
+ throw new Error(
271
+ 'password-protected assets require @noble/hashes. Install: npm install @noble/hashes',
272
+ );
273
+ }
274
+ }
275
+
276
+ function derivePasswordKey(password, params = {}) {
277
+ ensureArgon2id();
278
+ const {
279
+ salt,
280
+ memory_kib = 65536,
281
+ iterations = 3,
282
+ parallelism = 4,
283
+ } = params;
284
+ if (!salt) throw new Error('salt is required for Argon2id');
285
+ const saltBuf = decodeBase64(salt, 'salt');
286
+ const passwordBuf = toBuffer(password, 'password');
287
+ const key = argon2id(passwordBuf, saltBuf, {
288
+ t: iterations,
289
+ m: memory_kib,
290
+ p: parallelism,
291
+ dkLen: 32,
292
+ });
293
+ return Buffer.from(key);
294
+ }
295
+
296
+ function generateRecoveryCode() {
297
+ const raw = crypto.randomBytes(32); // 256 bits
298
+ const hex = raw.toString('hex').toUpperCase();
299
+ const groups = hex.match(/.{4}/g);
300
+ return `kdna-recover-${groups.join('-')}`;
301
+ }
302
+
303
+ function decodeRecoveryCode(code) {
304
+ if (typeof code !== 'string' || !code.startsWith('kdna-recover-')) {
305
+ throw new Error('recovery code must start with "kdna-recover-"');
306
+ }
307
+ const hex = code.slice('kdna-recover-'.length).replace(/-/g, '');
308
+ if (!/^[0-9A-Fa-f]{64}$/.test(hex)) {
309
+ throw new Error('recovery code format is invalid');
310
+ }
311
+ return Buffer.from(hex, 'hex');
312
+ }
313
+
314
+ function encryptProtectedEntry(plaintext, options = {}) {
315
+ const { entryName, manifest = {}, password, includeRecovery = true, recoveryCode } = options;
316
+ if (!entryName) throw new Error('entryName is required');
317
+ if (!password) throw new Error('password is required for protected encryption');
318
+
319
+ const cek = generateCEK();
320
+
321
+ // Password slot
322
+ const salt = crypto.randomBytes(16);
323
+ const passwordKdf = {
324
+ name: PASSWORD_KDF,
325
+ salt: salt.toString('base64'),
326
+ memory_kib: 65536,
327
+ iterations: 3,
328
+ parallelism: 4,
329
+ };
330
+ const passwordKey = derivePasswordKey(password, passwordKdf);
331
+ const passwordWrappedKey = wrapCEK(cek, passwordKey);
332
+
333
+ const keySlots = [
334
+ {
335
+ slot: 'password',
336
+ wrap: RFC_KEY_WRAPPING,
337
+ wrapped_key: passwordWrappedKey.toString('base64'),
338
+ },
339
+ ];
340
+
341
+ // Recovery slot
342
+ if (includeRecovery) {
343
+ const recoveryKey = recoveryCode ? decodeRecoveryCode(recoveryCode) : crypto.randomBytes(32);
344
+ const recoveryWrappedKey = wrapCEK(cek, recoveryKey);
345
+ keySlots.push({
346
+ slot: 'recovery',
347
+ wrap: RFC_KEY_WRAPPING,
348
+ wrapped_key: recoveryWrappedKey.toString('base64'),
349
+ });
350
+ }
351
+
352
+ const iv = crypto.randomBytes(12);
353
+ const cipher = crypto.createCipheriv('aes-256-gcm', cek, iv);
354
+ cipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
355
+ const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
356
+
357
+ return {
358
+ profile: PASSWORD_PROTECTED_PROFILE,
359
+ alg: ALG,
360
+ kdf: PASSWORD_KDF,
361
+ key_wrapping: RFC_KEY_WRAPPING,
362
+ password_kdf: passwordKdf,
363
+ key_slots: keySlots,
364
+ iv: iv.toString('base64'),
365
+ tag: cipher.getAuthTag().toString('base64'),
366
+ ciphertext: ciphertext.toString('base64'),
367
+ };
368
+ }
369
+
370
+ function decryptProtectedEntry(envelopeValue, options = {}) {
371
+ const { entryName, manifest = {}, password, recoveryCode } = options;
372
+ if (!entryName) throw new Error('entryName is required');
373
+ if (!password && !recoveryCode) {
374
+ throw new Error('password or recoveryCode is required for protected decryption');
375
+ }
376
+
377
+ const envelope = normalizeEnvelope(envelopeValue);
378
+ if (envelope.profile !== PASSWORD_PROTECTED_PROFILE) {
379
+ throw new Error(
380
+ `unsupported encrypted entry profile: ${envelope.profile || 'unknown'} (expected ${PASSWORD_PROTECTED_PROFILE})`,
381
+ );
382
+ }
383
+ if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
384
+ if (envelope.kdf !== PASSWORD_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
385
+ if (envelope.key_wrapping !== RFC_KEY_WRAPPING) {
386
+ throw new Error(`unsupported encrypted entry key_wrapping: ${envelope.key_wrapping}`);
387
+ }
388
+
389
+ let cek;
390
+ if (password) {
391
+ const passwordKey = derivePasswordKey(password, envelope.password_kdf);
392
+ const passwordSlot = envelope.key_slots.find((s) => s.slot === 'password');
393
+ if (!passwordSlot) throw new Error('password slot missing from envelope');
394
+ cek = unwrapCEK(decodeBase64(passwordSlot.wrapped_key, 'wrapped_key'), passwordKey);
395
+ } else {
396
+ const recoveryKey = decodeRecoveryCode(recoveryCode);
397
+ const recoverySlot = envelope.key_slots.find((s) => s.slot === 'recovery');
398
+ if (!recoverySlot) throw new Error('recovery slot missing from envelope');
399
+ cek = unwrapCEK(decodeBase64(recoverySlot.wrapped_key, 'wrapped_key'), recoveryKey);
400
+ }
401
+
402
+ const decipher = crypto.createDecipheriv('aes-256-gcm', cek, decodeBase64(envelope.iv, 'iv'));
403
+ decipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
404
+ decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
405
+ return Buffer.concat([
406
+ decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
407
+ decipher.final(),
408
+ ]);
409
+ }
410
+
411
+ function createPasswordDecryptEntry(options = {}) {
412
+ const { password } = options;
413
+ return ({ entryName, ciphertext, manifest }) =>
414
+ decryptProtectedEntry(ciphertext, { entryName, manifest, password });
415
+ }
416
+
417
+ function createRecoveryDecryptEntry(options = {}) {
418
+ const { recoveryCode } = options;
419
+ return ({ entryName, ciphertext, manifest }) =>
420
+ decryptProtectedEntry(ciphertext, { entryName, manifest, recoveryCode });
421
+ }
422
+
249
423
  // ── Legacy / experimental profile (pre-RFC, backward compat) ───────
250
424
 
251
425
  function deriveLicensedEntryKeyLegacy(options = {}) {
@@ -329,10 +503,12 @@ module.exports = {
329
503
  // Profiles
330
504
  LICENSED_ENTRY_PROFILE,
331
505
  LICENSED_EXPERIMENTAL_PROFILE,
506
+ PASSWORD_PROTECTED_PROFILE,
332
507
  ALG,
333
508
  RFC_KDF,
334
509
  RFC_KEY_WRAPPING,
335
510
  LEGACY_KDF,
511
+ PASSWORD_KDF,
336
512
 
337
513
  // RFC-0008 compliant
338
514
  deriveWrappingKey,
@@ -342,6 +518,15 @@ module.exports = {
342
518
  encryptLicensedEntryV1,
343
519
  decryptLicensedEntryV1,
344
520
 
521
+ // RFC-0009 compliant
522
+ derivePasswordKey,
523
+ generateRecoveryCode,
524
+ decodeRecoveryCode,
525
+ encryptProtectedEntry,
526
+ decryptProtectedEntry,
527
+ createPasswordDecryptEntry,
528
+ createRecoveryDecryptEntry,
529
+
345
530
  // Legacy
346
531
  deriveLicensedEntryKey: deriveLicensedEntryKeyLegacy,
347
532
  encryptLicensedEntryLegacy,
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ const compose = require('./compose');
9
9
  const assetReader = require('./asset-reader');
10
10
  const cryptoProfile = require('./crypto-profile');
11
11
  const publicApi = require('./public-api');
12
+ const workpackPure = require('./workpack-pure');
12
13
 
13
14
  module.exports = {
14
15
  ...publicApi,
@@ -19,4 +20,5 @@ module.exports = {
19
20
  ...compose,
20
21
  ...assetReader,
21
22
  ...cryptoProfile,
23
+ ...workpackPure,
22
24
  };
package/src/lint-pure.js CHANGED
@@ -318,9 +318,9 @@ function validateManifest(manifest) {
318
318
  if (manifest.format && manifest.format !== 'kdna') {
319
319
  errors.push(`kdna.json.format: invalid value "${manifest.format}". Expected "kdna".`);
320
320
  }
321
- if (manifest.format_version && manifest.format_version !== '1.0') {
321
+ if (manifest.format_version && manifest.format_version !== '2.0') {
322
322
  errors.push(
323
- `kdna.json.format_version: invalid value "${manifest.format_version}". Expected "1.0".`,
323
+ `kdna.json.format_version: invalid value "${manifest.format_version}". Expected "2.0".`,
324
324
  );
325
325
  }
326
326
  if (manifest.status && !VALID_STATUS.has(manifest.status)) {
package/src/types.d.ts CHANGED
@@ -204,7 +204,7 @@ export interface KDNAFileDataMap {
204
204
 
205
205
  export interface KDNAManifest {
206
206
  format: 'kdna';
207
- format_version: '1.0';
207
+ format_version: '2.0';
208
208
  spec_version: string;
209
209
  name: string;
210
210
  version: string;