@aikdna/kdna-core 0.13.3 → 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 +6 -3
- package/src/container-dispatcher.js +298 -0
- package/src/crypto-profile.js +124 -1
- package/src/v1/index.js +157 -54
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
+
"engines": {
|
|
6
|
+
"node": ">=18.0.0"
|
|
7
|
+
},
|
|
5
8
|
"type": "commonjs",
|
|
6
9
|
"main": "src/index.js",
|
|
7
10
|
"module": "src/index.mjs",
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
"types": "src/types.d.ts",
|
|
23
26
|
"scripts": {
|
|
24
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(', '));\"",
|
|
25
|
-
"prepublishOnly": "npm run release:check
|
|
28
|
+
"prepublishOnly": "npm run release:check",
|
|
26
29
|
"release:check": "node scripts/check-release-readiness.js"
|
|
27
30
|
},
|
|
28
31
|
"files": [
|
|
@@ -63,4 +66,4 @@
|
|
|
63
66
|
"ajv": "^8.20.0",
|
|
64
67
|
"ajv-formats": "^3.0.1"
|
|
65
68
|
}
|
|
66
|
-
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const { createKdnaAssetReader } = require('../asset-reader.js');
|
|
8
|
+
|
|
9
|
+
const MIMETYPE_V1 = 'application/vnd.kdna.asset';
|
|
10
|
+
const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* CanonicalAssetModel — the single internal representation of any KDNA asset,
|
|
14
|
+
* regardless of whether it came from a source directory, Container v1,
|
|
15
|
+
* or Canonical Distribution Container (Container v2 / SPEC 2.0).
|
|
16
|
+
*
|
|
17
|
+
* All downstream code (planLoad, loadAuthorized, decrypt, loadV1Unsafe)
|
|
18
|
+
* operates on this model. No consumer should check container format directly.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {object} CanonicalAssetModel
|
|
21
|
+
* @property {'dir'|'file'} sourceKind — original input type
|
|
22
|
+
* @property {string} sourcePath — resolved absolute path
|
|
23
|
+
* @property {string} format — 'v1-dir' | 'v1-container' | 'v2-container'
|
|
24
|
+
* @property {string} mimetype — normalized mimetype
|
|
25
|
+
* @property {object} manifest — parsed kdna.json
|
|
26
|
+
* @property {Buffer} payloadRaw — raw payload.kdnab bytes (CBOR/JSON)
|
|
27
|
+
* @property {object|null} checksums — parsed checksums.json, or null
|
|
28
|
+
* @property {Buffer|null} assetDigest — SHA-256 of entire container (file only)
|
|
29
|
+
* @property {Buffer|null} containerBuffer — full container bytes (for ZIP inputs, may be null for dirs)
|
|
30
|
+
* @property {Map<string, Buffer>|null} entries — raw entry map (for v1 dir layout; null for v2/container)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read any KDNA asset into a CanonicalAssetModel.
|
|
35
|
+
*
|
|
36
|
+
* Handles:
|
|
37
|
+
* - Source directories (mimetype + kdna.json + payload.kdnab on disk)
|
|
38
|
+
* - Container v1 (.kdna ZIP with application/vnd.kdna.asset mimetype)
|
|
39
|
+
* - Canonical containers (.kdna ZIP with application/vnd.aikdna.kdna+zip mimetype)
|
|
40
|
+
*
|
|
41
|
+
* @param {string} inputPath - absolute path to asset file or directory
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {object} [opts.limits] - container size/entry limits
|
|
44
|
+
* @returns {CanonicalAssetModel}
|
|
45
|
+
*/
|
|
46
|
+
function readAsset(inputPath, opts = {}) {
|
|
47
|
+
const absPath = path.resolve(inputPath);
|
|
48
|
+
const stat = fs.statSync(absPath);
|
|
49
|
+
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
return readSourceDirectory(absPath, opts);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!stat.isFile()) {
|
|
55
|
+
throw Object.assign(
|
|
56
|
+
new Error(`Not a file or directory: ${absPath}`),
|
|
57
|
+
{ code: 'KDNA_PATH_NOT_FOUND' },
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const format = detectFormat(absPath);
|
|
62
|
+
if (format === 'v1-container') {
|
|
63
|
+
return readV1Container(absPath, opts);
|
|
64
|
+
}
|
|
65
|
+
if (format === 'v2-container') {
|
|
66
|
+
return readV2Container(absPath, opts);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
throw Object.assign(
|
|
70
|
+
new Error(`Unknown KDNA container format: ${absPath}`),
|
|
71
|
+
{ code: 'KDNA_FORMAT_UNKNOWN' },
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Detect the container format of a .kdna file.
|
|
77
|
+
* Returns 'v1-container', 'v2-container', or throws.
|
|
78
|
+
*/
|
|
79
|
+
function detectFormat(absPath) {
|
|
80
|
+
const buf = fs.readFileSync(absPath);
|
|
81
|
+
if (buf.length < 4) {
|
|
82
|
+
throw Object.assign(
|
|
83
|
+
new Error(`File too small to be a KDNA container: ${absPath}`),
|
|
84
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Must be a ZIP (PK signature)
|
|
89
|
+
const sig = buf.readUInt32LE(0);
|
|
90
|
+
if (sig !== 0x04034b50) {
|
|
91
|
+
throw Object.assign(
|
|
92
|
+
new Error(`Not a valid ZIP/KDNA container: ${absPath}`),
|
|
93
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read the mimetype from the first entry (central directory first entry)
|
|
98
|
+
const eocdOffset = findEocd(buf);
|
|
99
|
+
let cdOffset = buf.readUInt32LE(eocdOffset + 16);
|
|
100
|
+
const cdEntryCount = buf.readUInt16LE(eocdOffset + 10);
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < cdEntryCount; i++) {
|
|
103
|
+
const entryOffset = cdOffset;
|
|
104
|
+
const nameLen = buf.readUInt16LE(entryOffset + 28);
|
|
105
|
+
const extraLen = buf.readUInt16LE(entryOffset + 30);
|
|
106
|
+
const commentLen = buf.readUInt16LE(entryOffset + 32);
|
|
107
|
+
const name = buf.toString('utf8', entryOffset + 46, entryOffset + 46 + nameLen);
|
|
108
|
+
|
|
109
|
+
if (name !== 'mimetype') {
|
|
110
|
+
cdOffset = entryOffset + 46 + nameLen + extraLen + commentLen;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const method = buf.readUInt16LE(entryOffset + 10);
|
|
115
|
+
if (method !== 0) {
|
|
116
|
+
throw Object.assign(
|
|
117
|
+
new Error('mimetype entry must be stored (no compression)'),
|
|
118
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const localOffset = buf.readUInt32LE(entryOffset + 42);
|
|
123
|
+
const localNameLen = buf.readUInt16LE(localOffset + 26);
|
|
124
|
+
const localExtraLen = buf.readUInt16LE(localOffset + 28);
|
|
125
|
+
const dataOffset = localOffset + 30 + localNameLen + localExtraLen;
|
|
126
|
+
const compressedSize = buf.readUInt32LE(entryOffset + 20);
|
|
127
|
+
const mime = buf.toString('utf8', dataOffset, dataOffset + compressedSize).trim();
|
|
128
|
+
|
|
129
|
+
if (mime === MIMETYPE_V1) return 'v1-container';
|
|
130
|
+
if (mime === MIMETYPE_V2) return 'v2-container';
|
|
131
|
+
|
|
132
|
+
throw Object.assign(
|
|
133
|
+
new Error(`Unknown KDNA mimetype: ${mime}`),
|
|
134
|
+
{ code: 'KDNA_FORMAT_UNKNOWN' },
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw Object.assign(
|
|
139
|
+
new Error('No mimetype entry found in KDNA container'),
|
|
140
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findEocd(buf) {
|
|
145
|
+
const maxSearch = Math.min(buf.length, 65557);
|
|
146
|
+
for (let i = buf.length - 22; i >= buf.length - maxSearch && i >= 0; i--) {
|
|
147
|
+
if (buf.readUInt32LE(i) === 0x06054b50) {
|
|
148
|
+
return i;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
throw Object.assign(
|
|
152
|
+
new Error('EOCD record not found in ZIP container'),
|
|
153
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read a source directory into CanonicalAssetModel.
|
|
159
|
+
*/
|
|
160
|
+
function readSourceDirectory(absPath, _opts) {
|
|
161
|
+
const mimetypePath = path.join(absPath, 'mimetype');
|
|
162
|
+
const manifestPath = path.join(absPath, 'kdna.json');
|
|
163
|
+
const payloadPath = path.join(absPath, 'payload.kdnab');
|
|
164
|
+
const checksumsPath = path.join(absPath, 'checksums.json');
|
|
165
|
+
|
|
166
|
+
if (!fs.existsSync(mimetypePath) || !fs.existsSync(manifestPath) || !fs.existsSync(payloadPath)) {
|
|
167
|
+
throw Object.assign(
|
|
168
|
+
new Error(`Source directory missing required entries: ${absPath}`),
|
|
169
|
+
{ code: 'KDNA_FORMAT_INVALID' },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mimetype = fs.readFileSync(mimetypePath, 'utf8').trim();
|
|
174
|
+
if (![MIMETYPE_V1, MIMETYPE_V2].includes(mimetype)) {
|
|
175
|
+
throw Object.assign(
|
|
176
|
+
new Error(`Unknown mimetype in source directory: ${mimetype}`),
|
|
177
|
+
{ code: 'KDNA_FORMAT_UNKNOWN' },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let manifest;
|
|
182
|
+
try {
|
|
183
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
184
|
+
} catch (e) {
|
|
185
|
+
throw Object.assign(
|
|
186
|
+
new Error(`Invalid kdna.json in source directory: ${e.message}`),
|
|
187
|
+
{ code: 'KDNA_MANIFEST_PARSE_ERROR' },
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const payloadRaw = fs.readFileSync(payloadPath);
|
|
192
|
+
const checksums = fs.existsSync(checksumsPath)
|
|
193
|
+
? JSON.parse(fs.readFileSync(checksumsPath, 'utf8'))
|
|
194
|
+
: null;
|
|
195
|
+
|
|
196
|
+
const entries = new Map();
|
|
197
|
+
for (const entryName of fs.readdirSync(absPath)) {
|
|
198
|
+
const entryPath = path.join(absPath, entryName);
|
|
199
|
+
if (fs.statSync(entryPath).isFile()) {
|
|
200
|
+
entries.set(entryName, fs.readFileSync(entryPath));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
sourceKind: 'dir',
|
|
206
|
+
sourcePath: absPath,
|
|
207
|
+
format: 'v1-dir',
|
|
208
|
+
mimetype,
|
|
209
|
+
manifest,
|
|
210
|
+
payloadRaw,
|
|
211
|
+
checksums,
|
|
212
|
+
assetDigest: null,
|
|
213
|
+
containerBuffer: null,
|
|
214
|
+
entries,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Read a Container v1 (.kdna with v1 mimetype) into CanonicalAssetModel.
|
|
220
|
+
*/
|
|
221
|
+
function readV1Container(absPath, _opts) {
|
|
222
|
+
// Delegate to the existing v1 layout reader for validation
|
|
223
|
+
const v1Layout = require('../v1/index.js').readV1Layout(absPath);
|
|
224
|
+
|
|
225
|
+
const containerBuf = fs.readFileSync(absPath);
|
|
226
|
+
const assetDigest = crypto.createHash('sha256').update(containerBuf).digest();
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
sourceKind: 'file',
|
|
230
|
+
sourcePath: absPath,
|
|
231
|
+
format: 'v1-container',
|
|
232
|
+
mimetype: MIMETYPE_V1,
|
|
233
|
+
manifest: v1Layout.manifest,
|
|
234
|
+
payloadRaw: v1Layout.map['payload.kdnab'] || null,
|
|
235
|
+
checksums: v1Layout.map['checksums.json']
|
|
236
|
+
? (() => { try { return JSON.parse(v1Layout.map['checksums.json'].toString('utf8')); } catch { return null; } })()
|
|
237
|
+
: null,
|
|
238
|
+
assetDigest,
|
|
239
|
+
containerBuffer: containerBuf,
|
|
240
|
+
entries: new Map(Object.entries(v1Layout.map)),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Read a Canonical Container (v2 mimetype) into CanonicalAssetModel.
|
|
246
|
+
* Uses the existing KdnaAssetReader for ZIP parsing.
|
|
247
|
+
*/
|
|
248
|
+
function readV2Container(absPath, _opts) {
|
|
249
|
+
const reader = createKdnaAssetReader();
|
|
250
|
+
const asset = reader.openSync(absPath);
|
|
251
|
+
|
|
252
|
+
const manifest = reader.readManifestSync(asset);
|
|
253
|
+
const payloadRaw = reader.readEntrySync(asset, 'payload.kdnab');
|
|
254
|
+
const checksums = (() => {
|
|
255
|
+
try {
|
|
256
|
+
const raw = reader.readEntrySync(asset, 'checksums.json');
|
|
257
|
+
return raw ? JSON.parse(raw.toString('utf8')) : null;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
})();
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
sourceKind: 'file',
|
|
265
|
+
sourcePath: absPath,
|
|
266
|
+
format: 'v2-container',
|
|
267
|
+
mimetype: MIMETYPE_V2,
|
|
268
|
+
manifest,
|
|
269
|
+
payloadRaw,
|
|
270
|
+
checksums,
|
|
271
|
+
assetDigest: Buffer.from(asset.asset_digest.replace(/^sha256:/, ''), 'hex'),
|
|
272
|
+
containerBuffer: fs.readFileSync(absPath),
|
|
273
|
+
entries: null, // v2 uses lazy reads, not pre-loaded map
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Extract the mimetype from a CanonicalAssetModel.
|
|
279
|
+
*/
|
|
280
|
+
function getMimetype(asset) {
|
|
281
|
+
return asset.mimetype;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Determine if a CanonicalAssetModel represents a source directory.
|
|
286
|
+
*/
|
|
287
|
+
function isSourceDirectory(asset) {
|
|
288
|
+
return asset.sourceKind === 'dir';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
MIMETYPE_V1,
|
|
293
|
+
MIMETYPE_V2,
|
|
294
|
+
readAsset,
|
|
295
|
+
detectFormat,
|
|
296
|
+
getMimetype,
|
|
297
|
+
isSourceDirectory,
|
|
298
|
+
};
|
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
|
@@ -39,6 +39,14 @@ const path = require('node:path');
|
|
|
39
39
|
const zlib = require('node:zlib');
|
|
40
40
|
const crypto = require('node:crypto');
|
|
41
41
|
|
|
42
|
+
const { readAsset } = (() => {
|
|
43
|
+
try {
|
|
44
|
+
return require('../container-dispatcher.js');
|
|
45
|
+
} catch {
|
|
46
|
+
return { readAsset: null, MIMETYPE_V1: null, MIMETYPE_V2: null };
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
|
|
42
50
|
const MIMETYPE_V1 = 'application/vnd.kdna.asset';
|
|
43
51
|
const MIMETYPE_V2 = 'application/vnd.aikdna.kdna+zip';
|
|
44
52
|
const V1_REQUIRED_DIR_ENTRIES = ['mimetype', 'kdna.json', 'payload.kdnab'];
|
|
@@ -682,6 +690,7 @@ function runValidate(v1) {
|
|
|
682
690
|
}
|
|
683
691
|
|
|
684
692
|
// payload gate — payload.kdnab against payload-profile-v1.schema.json
|
|
693
|
+
// For actually encrypted payloads (envelopes), skip schema validation.
|
|
685
694
|
let payload;
|
|
686
695
|
try {
|
|
687
696
|
payload = parseJsonEntry('payload.kdnab', v1.map['payload.kdnab']);
|
|
@@ -690,7 +699,26 @@ function runValidate(v1) {
|
|
|
690
699
|
problems.push(`payload: not valid JSON (${e.message})`);
|
|
691
700
|
return finalizeValidate(result, problems);
|
|
692
701
|
}
|
|
693
|
-
|
|
702
|
+
|
|
703
|
+
const isEncryptedPayload = payload.profile
|
|
704
|
+
&& payload.ciphertext
|
|
705
|
+
&& (v1.manifest.payload?.encrypted || v1.manifest.encryption?.encrypted_entries?.includes('payload.kdnab'));
|
|
706
|
+
|
|
707
|
+
if (isEncryptedPayload) {
|
|
708
|
+
// Encrypted payload — verify it's a proper encryption envelope, not plaintext.
|
|
709
|
+
const encProfile = v1.manifest.encryption?.profile;
|
|
710
|
+
if (encProfile && payload.profile !== encProfile) {
|
|
711
|
+
result.payload_valid = false;
|
|
712
|
+
problems.push(`payload: encrypted envelope profile ${payload.profile || 'unknown'} does not match manifest encryption profile ${encProfile}`);
|
|
713
|
+
}
|
|
714
|
+
const hasKeyMaterial = !!payload.wrapped_key || (Array.isArray(payload.key_slots) && payload.key_slots.length > 0);
|
|
715
|
+
if (!payload.profile || !payload.ciphertext || !hasKeyMaterial) {
|
|
716
|
+
result.payload_valid = false;
|
|
717
|
+
problems.push('payload: encrypted envelope missing required fields (profile/ciphertext/key material)');
|
|
718
|
+
}
|
|
719
|
+
// Encrypted payload passes payload gate if envelope is structurally valid.
|
|
720
|
+
} else if (!validators.payload(payload)) {
|
|
721
|
+
// Plaintext payload — full schema validation.
|
|
694
722
|
result.payload_valid = false;
|
|
695
723
|
for (const err of validators.payload.errors) {
|
|
696
724
|
problems.push(`payload: ${err.instancePath || '<root>'} ${err.message}`);
|
|
@@ -986,6 +1014,9 @@ function inferEntitlementProfile(manifest) {
|
|
|
986
1014
|
if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1') {
|
|
987
1015
|
return 'password';
|
|
988
1016
|
}
|
|
1017
|
+
if (manifest.encryption && manifest.encryption.profile === 'kdna-password-protected-v1-scrypt') {
|
|
1018
|
+
return 'password';
|
|
1019
|
+
}
|
|
989
1020
|
if (manifest.access === 'protected') return 'password';
|
|
990
1021
|
return null;
|
|
991
1022
|
}
|
|
@@ -1089,6 +1120,23 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
|
|
|
1089
1120
|
return plan;
|
|
1090
1121
|
}
|
|
1091
1122
|
|
|
1123
|
+
// ─── B1: Unified container model → legacy v1 layout ─────────────────────
|
|
1124
|
+
|
|
1125
|
+
function canonicalToV1Layout(asset) {
|
|
1126
|
+
const map = {};
|
|
1127
|
+
if (asset.entries) {
|
|
1128
|
+
for (const [name, buf] of asset.entries) {
|
|
1129
|
+
map[name] = buf;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return {
|
|
1133
|
+
kind: asset.sourceKind,
|
|
1134
|
+
map,
|
|
1135
|
+
manifest: asset.manifest,
|
|
1136
|
+
entries: Object.keys(map),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1092
1140
|
/**
|
|
1093
1141
|
* Plan a KDNA v1 runtime load without decrypting or emitting judgment content.
|
|
1094
1142
|
* Product consumers such as Chat should render authorization UI from this
|
|
@@ -1096,42 +1144,49 @@ function baseLoadPlan(inputPath, v1, validation, opts = {}) {
|
|
|
1096
1144
|
*/
|
|
1097
1145
|
function planLoad(inputPath, opts = {}) {
|
|
1098
1146
|
let v1;
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
asset
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1147
|
+
|
|
1148
|
+
// Try the unified container dispatcher first (handles v1 dirs, v1 containers, v2 containers)
|
|
1149
|
+
if (readAsset !== null) {
|
|
1150
|
+
try {
|
|
1151
|
+
const asset = readAsset(path.resolve(inputPath));
|
|
1152
|
+
v1 = canonicalToV1Layout(asset);
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
// If dispatcher fails, fall back to legacy readV1Layout for backward compat
|
|
1155
|
+
if (e.code === 'KDNA_FORMAT_UNKNOWN' || e.code === 'KDNA_PATH_NOT_FOUND') {
|
|
1156
|
+
// let the legacy path handle it
|
|
1157
|
+
} else {
|
|
1158
|
+
return finalizeLoadPlan({
|
|
1159
|
+
kdna_version: null,
|
|
1160
|
+
asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
|
|
1161
|
+
access: null, access_alias: null, entitlement_profile: null,
|
|
1162
|
+
state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
|
|
1163
|
+
input_fingerprint: null,
|
|
1164
|
+
checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
|
|
1165
|
+
issues: [buildLoadPlanIssue(
|
|
1166
|
+
e.code === 'KDNA_FORMAT_UNKNOWN' ? 'KDNA_FORMAT_UNKNOWN' : 'KDNA_FORMAT_INVALID',
|
|
1167
|
+
'blocking', e.message,
|
|
1168
|
+
)],
|
|
1169
|
+
source: { kind: null, path: path.resolve(inputPath) },
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
if (!v1) {
|
|
1176
|
+
try {
|
|
1177
|
+
v1 = readV1Layout(path.resolve(inputPath));
|
|
1178
|
+
} catch (e) {
|
|
1179
|
+
return finalizeLoadPlan({
|
|
1180
|
+
kdna_version: null,
|
|
1181
|
+
asset: { asset_id: null, asset_uid: null, title: null, version: null, judgment_version: null },
|
|
1182
|
+
access: null, access_alias: null, entitlement_profile: null,
|
|
1183
|
+
state: 'invalid', required_action: 'block', can_load_now: false, projection_policy: 'none',
|
|
1184
|
+
input_fingerprint: null,
|
|
1185
|
+
checks: { format_valid: false, schema_valid: false, payload_valid: false, checksums_valid: false, load_contract_valid: false, overall_valid: false },
|
|
1186
|
+
issues: [buildLoadPlanIssue('KDNA_FORMAT_INVALID', 'blocking', e.message)],
|
|
1187
|
+
source: { kind: null, path: path.resolve(inputPath) },
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1135
1190
|
}
|
|
1136
1191
|
|
|
1137
1192
|
const validation = runValidate(v1);
|
|
@@ -1530,15 +1585,62 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1530
1585
|
|
|
1531
1586
|
let payload;
|
|
1532
1587
|
try {
|
|
1533
|
-
|
|
1588
|
+
const rawPayload = v1.map['payload.kdnab'];
|
|
1589
|
+
payload = parseJsonEntry('payload.kdnab', rawPayload);
|
|
1534
1590
|
} catch (e) {
|
|
1535
1591
|
throw new Error(`payload.kdnab is not valid JSON: ${e.message}`);
|
|
1536
1592
|
}
|
|
1537
1593
|
|
|
1538
|
-
if (payload.
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1594
|
+
if (payload.profile && payload.ciphertext && (m.payload?.encrypted || m.encryption?.encrypted_entries?.includes('payload.kdnab'))) {
|
|
1595
|
+
// B4: Attempt decryption when password or decrypt hook is available.
|
|
1596
|
+
if (opts.decryptEntry) {
|
|
1597
|
+
// Consumer-provided decrypt hook (e.g., licensed entry key)
|
|
1598
|
+
try {
|
|
1599
|
+
const decrypted = opts.decryptEntry({
|
|
1600
|
+
asset: { entries: v1.map, manifest: m },
|
|
1601
|
+
manifest: m,
|
|
1602
|
+
entryName: 'payload.kdnab',
|
|
1603
|
+
ciphertext: v1.map['payload.kdnab'],
|
|
1604
|
+
});
|
|
1605
|
+
const decryptedBuf = typeof decrypted === 'string'
|
|
1606
|
+
? Buffer.from(decrypted, 'utf8')
|
|
1607
|
+
: Buffer.from(decrypted);
|
|
1608
|
+
payload = parseJsonEntry('payload.kdnab', decryptedBuf);
|
|
1609
|
+
} catch (e) {
|
|
1610
|
+
const err = new Error(`Decryption failed: ${e.message}`);
|
|
1611
|
+
err.code = 'KDNA_DECRYPT_FAILED';
|
|
1612
|
+
throw err;
|
|
1613
|
+
}
|
|
1614
|
+
} else if (opts.password) {
|
|
1615
|
+
// Password-based decryption via kdna-password-protected-v1*
|
|
1616
|
+
// profiles. Detects Argon2id vs scrypt from the envelope profile.
|
|
1617
|
+
try {
|
|
1618
|
+
const {
|
|
1619
|
+
decryptProtectedEntry,
|
|
1620
|
+
decryptProtectedEntryScrypt,
|
|
1621
|
+
PASSWORD_PROTECTED_SCRYPT_PROFILE,
|
|
1622
|
+
} = require('../crypto-profile.js');
|
|
1623
|
+
const encryptedEnvelope = v1.map['payload.kdnab'].toString('utf8');
|
|
1624
|
+
const envelope = JSON.parse(encryptedEnvelope);
|
|
1625
|
+
const decryptFn = (envelope.profile === PASSWORD_PROTECTED_SCRYPT_PROFILE)
|
|
1626
|
+
? decryptProtectedEntryScrypt
|
|
1627
|
+
: decryptProtectedEntry;
|
|
1628
|
+
const decryptedBuf = decryptFn(encryptedEnvelope, {
|
|
1629
|
+
entryName: 'payload.kdnab',
|
|
1630
|
+
manifest: m,
|
|
1631
|
+
password: opts.password,
|
|
1632
|
+
});
|
|
1633
|
+
payload = parseJsonEntry('payload.kdnab', decryptedBuf);
|
|
1634
|
+
} catch (e) {
|
|
1635
|
+
const err = new Error(`Decryption failed with provided password: ${e.message}`);
|
|
1636
|
+
err.code = 'KDNA_DECRYPT_FAILED';
|
|
1637
|
+
throw err;
|
|
1638
|
+
}
|
|
1639
|
+
} else {
|
|
1640
|
+
const err = new Error('payload is encrypted and cannot be decrypted without a password or license key');
|
|
1641
|
+
err.code = 'KDNA_AUTH_PASSWORD_REQUIRED';
|
|
1642
|
+
throw err;
|
|
1643
|
+
}
|
|
1542
1644
|
}
|
|
1543
1645
|
|
|
1544
1646
|
// Digest verification — refuse to load if checksums.json is present and digests mismatch.
|
|
@@ -1593,23 +1695,24 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1593
1695
|
result.content = { asset_id: m.asset_id, asset_uid: m.asset_uid, title: m.title, version: m.version, judgment_version: m.judgment_version, asset_type: m.asset_type, summary: m.summary || null, language: m.language || null, keywords: m.keywords || [], profiles_available: m.load_contract ? Object.keys(m.load_contract.profiles || {}) : [], max_tokens_hint: maxTokensHint };
|
|
1594
1696
|
} else if (profile === 'compact') {
|
|
1595
1697
|
const core = payload.core || {};
|
|
1698
|
+
const normalizeList = (items) => (items || []).map((item) => {
|
|
1699
|
+
if (typeof item === 'string') return { type: 'text', text: item };
|
|
1700
|
+
if (item && typeof item === 'object') return item;
|
|
1701
|
+
return null;
|
|
1702
|
+
}).filter(Boolean);
|
|
1596
1703
|
result.content = {
|
|
1597
1704
|
highest_question: core.highest_question || null,
|
|
1598
1705
|
axioms: (core.axioms || []).map(normalizeCompactAxiom).filter(Boolean),
|
|
1599
|
-
boundaries: core.boundaries
|
|
1600
|
-
self_checks: (payload.reasoning && payload.reasoning.self_checks)
|
|
1601
|
-
failure_modes: (payload.reasoning && payload.reasoning.failure_modes)
|
|
1602
|
-
patterns: (payload.patterns
|
|
1706
|
+
boundaries: normalizeList(core.boundaries),
|
|
1707
|
+
self_checks: normalizeList(payload.reasoning && payload.reasoning.self_checks),
|
|
1708
|
+
failure_modes: normalizeList(payload.reasoning && payload.reasoning.failure_modes),
|
|
1709
|
+
patterns: normalizeList(payload.patterns).slice(0, 3),
|
|
1603
1710
|
};
|
|
1604
1711
|
if (m.load_contract && m.load_contract.profiles && m.load_contract.profiles.compact && m.load_contract.profiles.compact.max_tokens_hint) {
|
|
1605
1712
|
result.max_tokens_hint = m.load_contract.profiles.compact.max_tokens_hint;
|
|
1606
1713
|
}
|
|
1607
1714
|
} else if (profile === 'scenario') {
|
|
1608
|
-
|
|
1609
|
-
result.content = { scenarios: payload.scenarios };
|
|
1610
|
-
} else {
|
|
1611
|
-
result.content = null;
|
|
1612
|
-
}
|
|
1715
|
+
result.content = { scenarios: payload.scenarios || [] };
|
|
1613
1716
|
} else if (profile === 'full') {
|
|
1614
1717
|
result.content = { manifest: m, payload };
|
|
1615
1718
|
} else {
|
|
@@ -1618,13 +1721,13 @@ function loadV1Unsafe(inputPath, opts = {}) {
|
|
|
1618
1721
|
|
|
1619
1722
|
if (as === 'prompt') {
|
|
1620
1723
|
const c = result.content;
|
|
1621
|
-
if (c ===
|
|
1724
|
+
if (!c || (c.scenarios && c.scenarios.length === 0 && !c.highest_question && !(c.axioms && c.axioms.length) && !(c.boundaries && c.boundaries.length))) {
|
|
1622
1725
|
return {
|
|
1623
1726
|
status: result.status,
|
|
1624
1727
|
profile: result.profile,
|
|
1625
|
-
profile_available:
|
|
1728
|
+
profile_available: result.profile_available,
|
|
1626
1729
|
available_profiles: result.available_profiles,
|
|
1627
|
-
text: `KDNA Judgment Asset: ${result.title || 'untitled'}\nAsset ID: ${result.asset_id || 'unknown'}\nProfile: ${result.profile}\n\
|
|
1730
|
+
text: `KDNA Judgment Asset: ${result.title || 'untitled'}\nAsset ID: ${result.asset_id || 'unknown'}\nProfile: ${result.profile}\n\nNo content available for this profile. Available profiles: ${result.available_profiles.join(', ')}`,
|
|
1628
1731
|
};
|
|
1629
1732
|
}
|
|
1630
1733
|
let text = 'KDNA Judgment Asset: ' + (result.title || 'untitled') + '\n';
|