@aikdna/kdna-core 0.14.0 → 0.15.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.14.0",
3
+ "version": "0.15.0",
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,7 +25,7 @@
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": [
@@ -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,