@aikdna/kdna-core 0.5.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.
@@ -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
  };
package/src/index.js CHANGED
@@ -8,8 +8,10 @@ const render = require('./render');
8
8
  const compose = require('./compose');
9
9
  const assetReader = require('./asset-reader');
10
10
  const cryptoProfile = require('./crypto-profile');
11
+ const publicApi = require('./public-api');
11
12
 
12
13
  module.exports = {
14
+ ...publicApi,
13
15
  ...loader,
14
16
  ...lint,
15
17
  ...validate,
package/src/lint-pure.js CHANGED
@@ -261,11 +261,20 @@ const VALID_BADGE = new Set(['untested', 'tested', 'validated', 'expert_reviewed
261
261
  const VALID_ACCESS = new Set(['open', 'licensed', 'runtime']);
262
262
  const VALID_RISK = new Set(['R0', 'R1', 'R2', 'R3']);
263
263
  const VALID_I18N = new Set(['L0', 'L1', 'L2', 'L3']);
264
+ const VALID_PRIVACY = new Set(['public', 'private', 'sensitive', 'regulated']);
265
+ const VALID_ASSET_TYPE = new Set([
266
+ 'domain_judgment',
267
+ 'personal_judgment',
268
+ 'organization_standard',
269
+ 'team_policy',
270
+ 'creator_style',
271
+ 'risk_guard',
272
+ ]);
264
273
 
265
274
  const MANIFEST_REQUIRED = [
266
- 'kdna_spec', 'name', 'version', 'judgment_version',
267
- 'description', 'author', 'license', 'status',
268
- 'quality_badge', 'access', 'language',
275
+ 'format', 'format_version', 'spec_version', 'name', 'version',
276
+ 'judgment_version', 'description', 'author', 'license', 'status',
277
+ 'quality_badge', 'access', 'languages', 'default_language',
269
278
  ];
270
279
 
271
280
  /**
@@ -283,13 +292,15 @@ function validateManifest(manifest) {
283
292
  return { errors, warnings };
284
293
  }
285
294
 
286
- // 1. Check spec_version is NOT in domain manifest (use kdna_spec only)
287
- if ('spec_version' in manifest) {
295
+ // 1. Check disallowed pre-v1.0 manifest aliases
296
+ if ('kdna_spec' in manifest) {
288
297
  errors.push(
289
- 'kdna.json: spec_version is deprecated in domain manifests. Use kdna_spec. ' +
290
- '(spec_version is reserved for .kdna container manifests only.)',
298
+ 'kdna.json: kdna_spec is not allowed. Use spec_version.',
291
299
  );
292
300
  }
301
+ if ('language' in manifest) {
302
+ errors.push('kdna.json: language is not allowed. Use default_language and languages.');
303
+ }
293
304
 
294
305
  // 2. Check required fields
295
306
  for (const field of MANIFEST_REQUIRED) {
@@ -304,6 +315,14 @@ function validateManifest(manifest) {
304
315
  }
305
316
 
306
317
  // 4. Validate enum fields
318
+ if (manifest.format && manifest.format !== 'kdna') {
319
+ errors.push(`kdna.json.format: invalid value "${manifest.format}". Expected "kdna".`);
320
+ }
321
+ if (manifest.format_version && manifest.format_version !== '1.0') {
322
+ errors.push(
323
+ `kdna.json.format_version: invalid value "${manifest.format_version}". Expected "1.0".`,
324
+ );
325
+ }
307
326
  if (manifest.status && !VALID_STATUS.has(manifest.status)) {
308
327
  errors.push(
309
328
  `kdna.json.status: invalid value "${manifest.status}". ` +
@@ -334,6 +353,18 @@ function validateManifest(manifest) {
334
353
  `Valid: ${[...VALID_I18N].join(', ')}`,
335
354
  );
336
355
  }
356
+ if (manifest.privacy_level && !VALID_PRIVACY.has(manifest.privacy_level)) {
357
+ warnings.push(
358
+ `kdna.json.privacy_level: non-standard value "${manifest.privacy_level}". ` +
359
+ `Valid: ${[...VALID_PRIVACY].join(', ')}`,
360
+ );
361
+ }
362
+ if (manifest.asset_type && !VALID_ASSET_TYPE.has(manifest.asset_type)) {
363
+ warnings.push(
364
+ `kdna.json.asset_type: non-standard value "${manifest.asset_type}". ` +
365
+ `Valid: ${[...VALID_ASSET_TYPE].join(', ')}`,
366
+ );
367
+ }
337
368
 
338
369
  // 5. Deprecated status must have replaced_by
339
370
  if (manifest.status === 'deprecated' && !manifest.replaced_by) {
@@ -362,10 +393,10 @@ function validateManifest(manifest) {
362
393
  errors.push('kdna.json.license: missing "type"');
363
394
  }
364
395
 
365
- // 9. Validate kdna_spec value
366
- if (manifest.kdna_spec && manifest.kdna_spec !== '1.0-rc') {
396
+ // 9. Validate spec_version value
397
+ if (manifest.spec_version && manifest.spec_version !== '1.0-rc') {
367
398
  warnings.push(
368
- `kdna.json.kdna_spec: non-standard value "${manifest.kdna_spec}". Expected "1.0-rc".`,
399
+ `kdna.json.spec_version: non-standard value "${manifest.spec_version}". Expected "1.0-rc".`,
369
400
  );
370
401
  }
371
402