@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 +2 -2
- package/src/crypto-profile.js +124 -1
- package/src/v1/index.js +15 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-core",
|
|
3
|
-
"version": "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
|
|
28
|
+
"prepublishOnly": "npm run release:check",
|
|
29
29
|
"release:check": "node scripts/check-release-readiness.js"
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
package/src/crypto-profile.js
CHANGED
|
@@ -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
|
|
1615
|
+
// Password-based decryption via kdna-password-protected-v1*
|
|
1616
|
+
// profiles. Detects Argon2id vs scrypt from the envelope profile.
|
|
1613
1617
|
try {
|
|
1614
|
-
const {
|
|
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
|
|
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,
|