@aikdna/kdna-core 0.6.0 → 0.7.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.6.0",
3
+ "version": "0.7.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",
@@ -60,6 +60,40 @@
60
60
  "examples": ["2026.05"]
61
61
  },
62
62
 
63
+ "domain_id": {
64
+ "type": "string",
65
+ "pattern": "^[a-z][a-z0-9_]*$",
66
+ "description": "Stable semantic domain slug generated by the Studio-compatible authoring pipeline."
67
+ },
68
+
69
+ "registry_name": {
70
+ "type": "string",
71
+ "pattern": "^@[a-z][a-z0-9-]*/[a-z][a-z0-9_]*$",
72
+ "description": "Scoped registry distribution name for this asset."
73
+ },
74
+
75
+ "asset_uid": {
76
+ "type": "string",
77
+ "description": "Unique identifier for this exported .kdna asset instance. uuidv7 is recommended."
78
+ },
79
+
80
+ "project_uid": {
81
+ "type": "string",
82
+ "description": "Stable identifier for the Studio project that produced this asset. uuidv7 is recommended."
83
+ },
84
+
85
+ "build_id": {
86
+ "type": "string",
87
+ "pattern": "^build_[A-Za-z0-9_.:-]+$",
88
+ "description": "Unique identifier for the Studio compile/export run."
89
+ },
90
+
91
+ "content_digest": {
92
+ "type": "string",
93
+ "pattern": "^sha256[-:][0-9a-f]{64}$",
94
+ "description": "Canonical SHA-256 digest of the internal content tree, excluding self-referential digest and signature fields."
95
+ },
96
+
63
97
  "description": {
64
98
  "type": "string",
65
99
  "minLength": 20,
@@ -193,6 +227,56 @@
193
227
  "additionalProperties": false
194
228
  },
195
229
 
230
+ "authoring": {
231
+ "type": "object",
232
+ "description": "Studio-compatible authoring provenance. Required for trusted quality claims.",
233
+ "required": ["created_by"],
234
+ "properties": {
235
+ "created_by": {
236
+ "type": "string",
237
+ "enum": [
238
+ "kdna-studio",
239
+ "kdna-studio-cli",
240
+ "kdna-studio-sdk",
241
+ "third-party-studio-compatible",
242
+ "manual-dev-source"
243
+ ]
244
+ },
245
+ "authoring_tool": { "type": "string" },
246
+ "authoring_tool_version": { "type": "string" },
247
+ "compiler": { "type": "string" },
248
+ "compiler_version": { "type": "string" },
249
+ "asset_uid": { "type": "string" },
250
+ "project_uid": { "type": "string" },
251
+ "build_id": {
252
+ "type": "string",
253
+ "pattern": "^build_[A-Za-z0-9_.:-]+$"
254
+ },
255
+ "domain_id": {
256
+ "type": "string",
257
+ "pattern": "^[a-z][a-z0-9_]*$"
258
+ },
259
+ "registry_name": {
260
+ "type": "string",
261
+ "pattern": "^@[a-z][a-z0-9-]*/[a-z][a-z0-9_]*$"
262
+ },
263
+ "content_digest": {
264
+ "type": "string",
265
+ "pattern": "^sha256[-:][0-9a-f]{64}$"
266
+ },
267
+ "studio_project_digest": {
268
+ "type": "string",
269
+ "pattern": "^sha256[-:][0-9a-f]{64}$"
270
+ },
271
+ "human_lock_required": { "type": "boolean" },
272
+ "human_lock_count": { "type": "integer", "minimum": 0 },
273
+ "ai_assisted": { "type": "boolean" },
274
+ "human_confirmed": { "type": "boolean" },
275
+ "compiled_at": { "type": "string", "format": "date-time" }
276
+ },
277
+ "additionalProperties": false
278
+ },
279
+
196
280
  "signature": {
197
281
  "type": "string",
198
282
  "pattern": "^ed25519:[0-9a-f]{128}$",
@@ -229,9 +313,30 @@
229
313
  }
230
314
  },
231
315
  "then": {
232
- "required": ["signature"],
316
+ "required": ["signature", "authoring"],
233
317
  "properties": {
234
- "author": { "required": ["pubkey"] }
318
+ "author": { "required": ["pubkey"] },
319
+ "authoring": {
320
+ "required": [
321
+ "compiler",
322
+ "compiler_version",
323
+ "compiled_at",
324
+ "human_confirmed",
325
+ "human_lock_count"
326
+ ],
327
+ "properties": {
328
+ "created_by": {
329
+ "enum": [
330
+ "kdna-studio",
331
+ "kdna-studio-cli",
332
+ "kdna-studio-sdk",
333
+ "third-party-studio-compatible"
334
+ ]
335
+ },
336
+ "human_confirmed": { "const": true },
337
+ "human_lock_count": { "minimum": 1 }
338
+ }
339
+ }
235
340
  }
236
341
  }
237
342
  }
@@ -1,9 +1,30 @@
1
1
  const crypto = require('crypto');
2
2
 
3
+ // ── Profile constants ──────────────────────────────────────────────
4
+
5
+ /**
6
+ * RFC-0008 compliant profile.
7
+ * - HKDF-SHA256 key derivation
8
+ * - AES-256-KW (RFC 3394) content encryption key wrapping
9
+ * - AES-256-GCM content encryption
10
+ * - Random CEK per asset; license key only unwraps CEK, never touches content
11
+ */
3
12
  const LICENSED_ENTRY_PROFILE = 'kdna-licensed-entry-v1';
4
- const KDF = 'scrypt-sha256';
13
+
14
+ /**
15
+ * Pre-RFC legacy profile (now experimental).
16
+ * Uses scrypt-sha256 with concatenated secret (`licenseKey|machineFingerprint`).
17
+ * Retained for backward compatibility only.
18
+ */
19
+ const LICENSED_EXPERIMENTAL_PROFILE = 'kdna-licensed-entry-experimental';
20
+
21
+ const RFC_KDF = 'HKDF-SHA256';
22
+ const RFC_KEY_WRAPPING = 'AES-256-KW';
23
+ const LEGACY_KDF = 'scrypt-sha256';
5
24
  const ALG = 'AES-256-GCM';
6
25
 
26
+ // ── Helpers ───────────────────────────────────────────────────────
27
+
7
28
  function toBuffer(value, label) {
8
29
  if (Buffer.isBuffer(value)) return value;
9
30
  if (value instanceof Uint8Array) return Buffer.from(value);
@@ -25,21 +46,10 @@ function normalizeEnvelope(value) {
25
46
  throw new Error('encrypted entry envelope must be JSON');
26
47
  }
27
48
 
28
- function deriveLicensedEntryKey(options = {}) {
29
- const { licenseKey, machineFingerprint, salt, keyLength = 32 } = options;
30
- if (!licenseKey) throw new Error('licenseKey is required');
31
- if (!machineFingerprint) throw new Error('machineFingerprint is required');
32
- const saltBuffer = Buffer.isBuffer(salt) || salt instanceof Uint8Array
33
- ? Buffer.from(salt)
34
- : decodeBase64(salt, 'salt');
35
- const secret = `${licenseKey}|${machineFingerprint}`;
36
- return crypto.scryptSync(secret, saltBuffer, keyLength);
37
- }
38
-
39
- function encryptedEntryAad(entryName, manifest = {}) {
49
+ function encryptedEntryAad(entryName, manifest = {}, profile = LICENSED_ENTRY_PROFILE) {
40
50
  return Buffer.from(
41
51
  [
42
- LICENSED_ENTRY_PROFILE,
52
+ profile,
43
53
  manifest.name || manifest.asset_id || '',
44
54
  manifest.version || '',
45
55
  entryName,
@@ -48,19 +58,220 @@ function encryptedEntryAad(entryName, manifest = {}) {
48
58
  );
49
59
  }
50
60
 
51
- function encryptLicensedEntry(plaintext, options = {}) {
61
+ // ── HKDF-SHA256 (RFC 5869) ────────────────────────────────────────
62
+
63
+ /**
64
+ * HKDF-SHA256 extract-then-expand.
65
+ * @param {Buffer|string} ikm — input keying material (license key)
66
+ * @param {Buffer} [salt] — optional salt (defaults to 32 zero bytes)
67
+ * @param {Buffer|string} [info] — context info
68
+ * @param {number} [length] — output length (default 32)
69
+ */
70
+ function hkdfSha256(ikm, salt = null, info = '', length = 32) {
71
+ const ikmBuf = toBuffer(ikm, 'ikm');
72
+ // Extract
73
+ const saltBuf = salt || Buffer.alloc(32, 0);
74
+ const prk = crypto.createHmac('sha256', saltBuf).update(ikmBuf).digest();
75
+ // Expand (single-block expansion for length ≤ 32)
76
+ const t = crypto.createHmac('sha256', prk)
77
+ .update(Buffer.concat([
78
+ toBuffer(info, 'info'),
79
+ // Counter byte 0x01
80
+ Buffer.from([1]),
81
+ ]))
82
+ .digest();
83
+ return t.subarray(0, length);
84
+ }
85
+
86
+ // ── AES-256-KW (RFC 3394) ─────────────────────────────────────────
87
+
88
+ const AES_BLOCK = 16;
89
+ const KW_IV = Buffer.from('a6a6a6a6a6a6a6a6', 'hex'); // RFC 3394 default IV
90
+
91
+ function aesWrap(key, plaintext) {
92
+ // key = 32 bytes, plaintext = 32 bytes (single CEK)
93
+ if (key.length !== 32) throw new Error('AES-256-KW requires 32-byte key');
94
+ if (plaintext.length !== 32) throw new Error('AES-256-KW requires 32-byte plaintext');
95
+
96
+ const n = plaintext.length / 8; // = 4 for 256-bit CEK
97
+ const r = new Array(n + 1);
98
+ r[0] = KW_IV;
99
+ for (let i = 0; i < n; i++) r[i + 1] = plaintext.subarray(i * 8, (i + 1) * 8);
100
+
101
+ for (let j = 0; j <= 5; j++) {
102
+ for (let i = 1; i <= n; i++) {
103
+ const input = Buffer.concat([r[0], r[i]]); // 16 bytes
104
+ const cipher = crypto.createCipheriv('aes-256-ecb', key, null);
105
+ cipher.setAutoPadding(false);
106
+ const b = Buffer.concat([cipher.update(input), cipher.final()]);
107
+ r[0] = b.subarray(0, 8);
108
+ // XOR t = (n * j) + i as big-endian 64-bit
109
+ const t = BigInt(n) * BigInt(j) + BigInt(i);
110
+ const tBuf = Buffer.alloc(8);
111
+ tBuf.writeBigUInt64BE(t);
112
+ for (let k = 0; k < 8; k++) r[0][k] ^= tBuf[k];
113
+ r[i] = b.subarray(8, 16);
114
+ }
115
+ }
116
+
117
+ const result = Buffer.alloc((n + 1) * 8);
118
+ for (let i = 0; i <= n; i++) r[i].copy(result, i * 8);
119
+ return result; // 40 bytes
120
+ }
121
+
122
+ function aesUnwrap(key, ciphertext) {
123
+ if (key.length !== 32) throw new Error('AES-256-KW requires 32-byte key');
124
+ if (ciphertext.length !== 40) throw new Error('AES-256-KW ciphertext must be 40 bytes');
125
+
126
+ const n = (ciphertext.length / 8) - 1; // = 4
127
+ const r = new Array(n + 1);
128
+ for (let i = 0; i <= n; i++) r[i] = ciphertext.subarray(i * 8, (i + 1) * 8);
129
+
130
+ for (let j = 5; j >= 0; j--) {
131
+ for (let i = n; i >= 1; i--) {
132
+ const t = BigInt(n) * BigInt(j) + BigInt(i);
133
+ const tBuf = Buffer.alloc(8);
134
+ tBuf.writeBigUInt64BE(t);
135
+ for (let k = 0; k < 8; k++) r[0][k] ^= tBuf[k];
136
+
137
+ const input = Buffer.concat([r[0], r[i]]);
138
+ const decipher = crypto.createDecipheriv('aes-256-ecb', key, null);
139
+ decipher.setAutoPadding(false);
140
+ const b = Buffer.concat([decipher.update(input), decipher.final()]);
141
+ r[0] = b.subarray(0, 8);
142
+ r[i] = b.subarray(8, 16);
143
+ }
144
+ }
145
+
146
+ if (!r[0].equals(KW_IV)) throw new Error('AES-256-KW unwrap: integrity check failed');
147
+ const result = Buffer.alloc(n * 8);
148
+ for (let i = 1; i <= n; i++) r[i].copy(result, (i - 1) * 8);
149
+ return result; // 32 bytes
150
+ }
151
+
152
+ // ── RFC-0008 compliant encryption ─────────────────────────────────
153
+
154
+ /**
155
+ * Derive a key-wrapping key (KWK) from a license key via HKDF-SHA256.
156
+ */
157
+ function deriveWrappingKey(licenseKey, info = 'kdna-licensed-entry-v1-kwk') {
158
+ return hkdfSha256(licenseKey, null, info, 32);
159
+ }
160
+
161
+ /**
162
+ * Generate a random content encryption key (CEK).
163
+ */
164
+ function generateCEK() {
165
+ return crypto.randomBytes(32);
166
+ }
167
+
168
+ /**
169
+ * Wrap a CEK with a key-wrapping key.
170
+ */
171
+ function wrapCEK(cek, wrappingKey) {
172
+ return aesWrap(wrappingKey, cek);
173
+ }
174
+
175
+ /**
176
+ * Unwrap a CEK from its wrapped form.
177
+ */
178
+ function unwrapCEK(wrappedCek, wrappingKey) {
179
+ return aesUnwrap(wrappingKey, wrappedCek);
180
+ }
181
+
182
+ /**
183
+ * Encrypt a licensed entry using the RFC-0008 compliant model.
184
+ *
185
+ * 1. Generate random CEK
186
+ * 2. Derive KWK from license key + machine fingerprint via HKDF
187
+ * 3. Encrypt content with CEK (AES-256-GCM)
188
+ * 4. Wrap CEK with KWK (AES-256-KW)
189
+ * 5. Store wrapped_key in envelope
190
+ */
191
+ function encryptLicensedEntryV1(plaintext, options = {}) {
192
+ const { entryName, manifest = {}, licenseKey } = options;
193
+ if (!entryName) throw new Error('entryName is required');
194
+ if (!licenseKey) throw new Error('licenseKey is required for RFC-0008 encryption');
195
+
196
+ const cek = generateCEK();
197
+ const wrappingKey = deriveWrappingKey(licenseKey);
198
+ const wrappedKey = wrapCEK(cek, wrappingKey);
199
+
200
+ const iv = crypto.randomBytes(12);
201
+ const cipher = crypto.createCipheriv('aes-256-gcm', cek, iv);
202
+ cipher.setAAD(encryptedEntryAad(entryName, manifest));
203
+ const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
204
+
205
+ return {
206
+ profile: LICENSED_ENTRY_PROFILE,
207
+ alg: ALG,
208
+ kdf: RFC_KDF,
209
+ key_wrapping: RFC_KEY_WRAPPING,
210
+ wrapped_key: wrappedKey.toString('base64'),
211
+ iv: iv.toString('base64'),
212
+ tag: cipher.getAuthTag().toString('base64'),
213
+ ciphertext: ciphertext.toString('base64'),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Decrypt a licensed entry encoded with the RFC-0008 compliant profile.
219
+ *
220
+ * 1. Derive KWK from license key via HKDF
221
+ * 2. Unwrap CEK from wrapped_key (AES-256-KW)
222
+ * 3. Decrypt content with CEK (AES-256-GCM)
223
+ */
224
+ function decryptLicensedEntryV1(envelopeValue, options = {}) {
225
+ const { entryName, manifest = {}, licenseKey } = options;
226
+ if (!entryName) throw new Error('entryName is required');
227
+ if (!licenseKey) throw new Error('licenseKey is required for RFC-0008 decryption');
228
+
229
+ const envelope = normalizeEnvelope(envelopeValue);
230
+ if (envelope.profile !== LICENSED_ENTRY_PROFILE) {
231
+ throw new Error(`unsupported encrypted entry profile: ${envelope.profile || 'unknown'} (expected ${LICENSED_ENTRY_PROFILE})`);
232
+ }
233
+ if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
234
+ if (envelope.kdf !== RFC_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
235
+ if (envelope.key_wrapping !== RFC_KEY_WRAPPING) throw new Error(`unsupported encrypted entry key_wrapping: ${envelope.key_wrapping}`);
236
+
237
+ const wrappingKey = deriveWrappingKey(licenseKey);
238
+ const cek = unwrapCEK(decodeBase64(envelope.wrapped_key, 'wrapped_key'), wrappingKey);
239
+
240
+ const decipher = crypto.createDecipheriv('aes-256-gcm', cek, decodeBase64(envelope.iv, 'iv'));
241
+ decipher.setAAD(encryptedEntryAad(entryName, manifest));
242
+ decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
243
+ return Buffer.concat([
244
+ decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
245
+ decipher.final(),
246
+ ]);
247
+ }
248
+
249
+ // ── Legacy / experimental profile (pre-RFC, backward compat) ───────
250
+
251
+ function deriveLicensedEntryKeyLegacy(options = {}) {
252
+ const { licenseKey, machineFingerprint, salt, keyLength = 32 } = options;
253
+ if (!licenseKey) throw new Error('licenseKey is required');
254
+ if (!machineFingerprint) throw new Error('machineFingerprint is required');
255
+ const saltBuffer = Buffer.isBuffer(salt) || salt instanceof Uint8Array
256
+ ? Buffer.from(salt)
257
+ : decodeBase64(salt, 'salt');
258
+ const secret = `${licenseKey}|${machineFingerprint}`;
259
+ return crypto.scryptSync(secret, saltBuffer, keyLength);
260
+ }
261
+
262
+ function encryptLicensedEntryLegacy(plaintext, options = {}) {
52
263
  const { entryName, manifest = {}, licenseKey, machineFingerprint } = options;
53
264
  if (!entryName) throw new Error('entryName is required');
54
265
  const salt = crypto.randomBytes(16);
55
266
  const iv = crypto.randomBytes(12);
56
- const key = deriveLicensedEntryKey({ licenseKey, machineFingerprint, salt });
267
+ const key = deriveLicensedEntryKeyLegacy({ licenseKey, machineFingerprint, salt });
57
268
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
58
- cipher.setAAD(encryptedEntryAad(entryName, manifest));
269
+ cipher.setAAD(encryptedEntryAad(entryName, manifest, LICENSED_EXPERIMENTAL_PROFILE));
59
270
  const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
60
271
  return {
61
- profile: LICENSED_ENTRY_PROFILE,
272
+ profile: LICENSED_EXPERIMENTAL_PROFILE,
62
273
  alg: ALG,
63
- kdf: KDF,
274
+ kdf: LEGACY_KDF,
64
275
  salt: salt.toString('base64'),
65
276
  iv: iv.toString('base64'),
66
277
  tag: cipher.getAuthTag().toString('base64'),
@@ -68,22 +279,22 @@ function encryptLicensedEntry(plaintext, options = {}) {
68
279
  };
69
280
  }
70
281
 
71
- function decryptLicensedEntry(envelopeValue, options = {}) {
282
+ function decryptLicensedEntryLegacy(envelopeValue, options = {}) {
72
283
  const { entryName, manifest = {}, licenseKey, machineFingerprint } = options;
73
284
  if (!entryName) throw new Error('entryName is required');
74
285
  const envelope = normalizeEnvelope(envelopeValue);
75
- if (envelope.profile !== LICENSED_ENTRY_PROFILE) {
286
+ if (envelope.profile !== LICENSED_EXPERIMENTAL_PROFILE) {
76
287
  throw new Error(`unsupported encrypted entry profile: ${envelope.profile || 'unknown'}`);
77
288
  }
78
289
  if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
79
- if (envelope.kdf !== KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
80
- const key = deriveLicensedEntryKey({
290
+ if (envelope.kdf !== LEGACY_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
291
+ const key = deriveLicensedEntryKeyLegacy({
81
292
  licenseKey,
82
293
  machineFingerprint,
83
294
  salt: envelope.salt,
84
295
  });
85
296
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, decodeBase64(envelope.iv, 'iv'));
86
- decipher.setAAD(encryptedEntryAad(entryName, manifest));
297
+ decipher.setAAD(encryptedEntryAad(entryName, manifest, LICENSED_EXPERIMENTAL_PROFILE));
87
298
  decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
88
299
  return Buffer.concat([
89
300
  decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
@@ -91,6 +302,23 @@ function decryptLicensedEntry(envelopeValue, options = {}) {
91
302
  ]);
92
303
  }
93
304
 
305
+ // ── Unified entry points (auto-detect profile) ────────────────────
306
+
307
+ function encryptLicensedEntry(plaintext, options = {}) {
308
+ return encryptLicensedEntryV1(plaintext, options);
309
+ }
310
+
311
+ function decryptLicensedEntry(envelopeValue, options = {}) {
312
+ const envelope = normalizeEnvelope(envelopeValue);
313
+ if (envelope.profile === LICENSED_ENTRY_PROFILE) {
314
+ return decryptLicensedEntryV1(envelopeValue, options);
315
+ }
316
+ if (envelope.profile === LICENSED_EXPERIMENTAL_PROFILE) {
317
+ return decryptLicensedEntryLegacy(envelopeValue, options);
318
+ }
319
+ throw new Error(`unsupported encrypted entry profile: ${envelope.profile || 'unknown'}`);
320
+ }
321
+
94
322
  function createLicensedDecryptEntry(options = {}) {
95
323
  const { licenseKey, machineFingerprint } = options;
96
324
  return ({ entryName, ciphertext, manifest }) =>
@@ -98,9 +326,34 @@ function createLicensedDecryptEntry(options = {}) {
98
326
  }
99
327
 
100
328
  module.exports = {
329
+ // Profiles
101
330
  LICENSED_ENTRY_PROFILE,
102
- deriveLicensedEntryKey,
331
+ LICENSED_EXPERIMENTAL_PROFILE,
332
+ ALG,
333
+ RFC_KDF,
334
+ RFC_KEY_WRAPPING,
335
+ LEGACY_KDF,
336
+
337
+ // RFC-0008 compliant
338
+ deriveWrappingKey,
339
+ generateCEK,
340
+ wrapCEK,
341
+ unwrapCEK,
342
+ encryptLicensedEntryV1,
343
+ decryptLicensedEntryV1,
344
+
345
+ // Legacy
346
+ deriveLicensedEntryKey: deriveLicensedEntryKeyLegacy,
347
+ encryptLicensedEntryLegacy,
348
+ decryptLicensedEntryLegacy,
349
+
350
+ // Unified
103
351
  encryptLicensedEntry,
104
352
  decryptLicensedEntry,
105
353
  createLicensedDecryptEntry,
354
+
355
+ // Low-level
356
+ hkdfSha256,
357
+ aesWrap,
358
+ aesUnwrap,
106
359
  };