@aikdna/kdna-cli 0.13.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-cli",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "KDNA CLI — create, validate, install, and manage domain cognition packages for AI agents.",
5
5
  "type": "commonjs",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  const { error, EXIT, setQuiet, setExitCodeOnly } = require('./cmds/_common');
10
- const { cmdValidate, cmdPack, cmdUnpack, cmdInspect } = require('./cmds/domain');
10
+ const { cmdValidate, cmdPack, cmdPackEncrypt, cmdUnpack, cmdUnpackEncrypt, cmdInspect } = require('./cmds/domain');
11
11
  const { cmdList, cmdRegistry } = require('./cmds/registry');
12
12
  const {
13
13
  cmdCompare,
@@ -24,6 +24,7 @@ const { cmdIdentity } = require('./cmds/identity');
24
24
  const { cmdSetup } = require('./cmds/setup');
25
25
  const { cmdDoctor } = require('./cmds/doctor');
26
26
  const { cmdTrace, cmdHistory } = require('./cmds/trace');
27
+ const { cmdLicenseGenerate, cmdLicenseVerify, cmdLicenseBind, cmdLicenseShow, cmdLicenseInstall } = require('./cmds/license');
27
28
  const { cmdPreview, cmdProject, cmdEval, cmdExport, cmdDemo } = require('./cmds/legacy');
28
29
  const { cmdStudioScaffold, cmdCardsValidate, cmdLockVerify, cmdStudioCompile, cmdStudioReadiness } = require('./cmds/studio');
29
30
  const { cmdTestRun, cmdTestImport } = require('./cmds/test');
@@ -54,7 +55,9 @@ Domain Authoring:
54
55
  validate <path> Validate domain structure
55
56
  validate --schema <path> Schema-only validation
56
57
  pack <path> Pack into .kdna container
58
+ pack <path> --encrypt --license <file> Pack encrypted .kdnae container
57
59
  unpack <file> Unpack .kdna container
60
+ unpack <file> --license <file> Unpack encrypted .kdnae container
58
61
  inspect <path> Inspect domain or .kdna file
59
62
  publish <path> Pack + sign + publish
60
63
  publish --check <path> Quality gate check only
@@ -133,6 +136,13 @@ Trace & Diagnostics:
133
136
  trace [--json] [--since 7d] [--export <file>] Agent judgment trace
134
137
  history [--stats] [--domain <name>] [--agent <name>] Recent usage
135
138
 
139
+ License & Authorization:
140
+ license generate <domain> --to <email> Generate signed license
141
+ license install <license.json> Register license for auto-decrypt
142
+ license verify <license.json> Verify license signature
143
+ license bind <license.json> Bind license to this machine
144
+ license show <license.json> Display license details
145
+
136
146
  Flags:
137
147
  --json Structured JSON output (machine-readable)
138
148
  --quiet Suppress non-error output
@@ -167,13 +177,21 @@ switch (cmd) {
167
177
  }
168
178
  }
169
179
  if (!target) error('Usage: kdna pack <path>');
170
- cmdPack(target, output);
180
+ if (args.includes('--encrypt')) {
181
+ cmdPackEncrypt(target, args);
182
+ } else {
183
+ cmdPack(target, output);
184
+ }
171
185
  break;
172
186
  }
173
187
  case 'unpack': {
174
188
  const target = args[1];
175
- if (!target) error('Usage: kdna unpack <file.kdna>');
176
- cmdUnpack(target, args.includes('--force'));
189
+ if (!target) error('Usage: kdna unpack <file.kdna|file.kdnae>');
190
+ if (target.endsWith('.kdnae')) {
191
+ cmdUnpackEncrypt(target, args);
192
+ } else {
193
+ cmdUnpack(target, args.includes('--force'));
194
+ }
177
195
  break;
178
196
  }
179
197
  case 'preview': {
@@ -408,6 +426,32 @@ switch (cmd) {
408
426
  cmdHistory(args);
409
427
  break;
410
428
  }
429
+ case 'license': {
430
+ const sub = args[1];
431
+ const rest = args.slice(2);
432
+ if (sub === 'generate') {
433
+ cmdLicenseGenerate(rest);
434
+ } else if (sub === 'verify') {
435
+ cmdLicenseVerify(rest);
436
+ } else if (sub === 'bind') {
437
+ cmdLicenseBind(rest);
438
+ } else if (sub === 'show') {
439
+ cmdLicenseShow(rest);
440
+ } else if (sub === 'install') {
441
+ cmdLicenseInstall(rest);
442
+ } else {
443
+ error(
444
+ 'Usage:\n' +
445
+ ' kdna license generate <domain> --to <email> [--expires <date>]\n' +
446
+ ' kdna license install <license.json>\n' +
447
+ ' kdna license verify <license.json>\n' +
448
+ ' kdna license bind <license.json>\n' +
449
+ ' kdna license show <license.json>',
450
+ EXIT.INPUT_ERROR,
451
+ );
452
+ }
453
+ break;
454
+ }
411
455
  case 'identity': {
412
456
  cmdIdentity(args);
413
457
  break;
@@ -57,8 +57,9 @@ Usage:
57
57
  kdna validate <path> Validate a domain directory
58
58
  kdna validate --schema <path> ...with JSON Schema
59
59
  kdna pack <path> Pack a domain folder into a .kdna container
60
+ kdna pack <path> --encrypt --license <file> Pack encrypted .kdnae container
60
61
  kdna pack --output <dir> <path> Output .kdna to specific directory
61
- kdna unpack <path> Unpack a .kdna container to a folder
62
+ kdna unpack <path> Unpack a .kdna or .kdnae container to a folder
62
63
  kdna inspect <path> Inspect a domain directory or .kdna file
63
64
  kdna publish <path> Pack + sign + output registry patch
64
65
  kdna publish <path> --release-tag <tag> --repo <o/r> ...also upload to GitHub
@@ -2,6 +2,14 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { execSync } = require('child_process');
4
4
  const { error, readJson, writeJson, EXIT } = require('./_common');
5
+ const {
6
+ encrypt,
7
+ decrypt,
8
+ deriveKey,
9
+ machineFingerprint,
10
+ isEncryptable,
11
+ ENCRYPTED_FILES,
12
+ } = require('./encrypt');
5
13
 
6
14
  // ─── Validate ────────────────────────────────────────────────────────
7
15
 
@@ -707,9 +715,238 @@ function cmdInspect(dir, jsonMode = false) {
707
715
  console.log('═'.repeat(50));
708
716
  }
709
717
 
718
+ // ─── Encrypted Container (.kdnae) ─────────────────────────────────────
719
+
720
+ function cmdPackEncrypt(dir, args = []) {
721
+ const abs = path.resolve(dir);
722
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) {
723
+ error(`Not a directory: ${abs}`, EXIT.INPUT_ERROR);
724
+ }
725
+
726
+ const core = readJson(path.join(abs, 'KDNA_Core.json'));
727
+ const pat = readJson(path.join(abs, 'KDNA_Patterns.json'));
728
+ if (!core) error('KDNA_Core.json not found or invalid');
729
+ if (!pat) error('KDNA_Patterns.json not found or invalid');
730
+
731
+ const domainName = core.meta?.domain || path.basename(abs);
732
+
733
+ let manifest = readJson(path.join(abs, 'kdna.json'));
734
+ if (!manifest) {
735
+ const jsonCount = fs.readdirSync(abs).filter(f => f.endsWith('.json') && f !== 'kdna.json').length;
736
+ manifest = {
737
+ kdna_spec: '1.0-rc',
738
+ name: domainName,
739
+ version: core.meta?.version || '0.1.0',
740
+ status: 'experimental',
741
+ access: 'licensed',
742
+ language: 'en',
743
+ author: { name: '', id: '' },
744
+ license: { type: 'KCL-1.0' },
745
+ description: core.meta?.purpose || `${domainName} domain cognition`,
746
+ file_count: jsonCount,
747
+ created: new Date().toISOString().slice(0, 10),
748
+ updated: new Date().toISOString().slice(0, 10),
749
+ };
750
+ writeJson(path.join(abs, 'kdna.json'), manifest);
751
+ }
752
+
753
+ // Get encryption key
754
+ const licenseIdx = args.indexOf('--license');
755
+ const keyIdx = args.indexOf('--key');
756
+ let encKey;
757
+
758
+ if (licenseIdx >= 0) {
759
+ const licensePath = args[licenseIdx + 1];
760
+ if (!licensePath || !fs.existsSync(licensePath)) error('License file not found', EXIT.INPUT_ERROR);
761
+ const license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
762
+ const licenseKey = license.license_id;
763
+ const fp = machineFingerprint();
764
+ encKey = deriveKey(licenseKey, fp);
765
+ } else if (keyIdx >= 0) {
766
+ const rawKey = args[keyIdx + 1];
767
+ if (!rawKey) error('--key requires a value', EXIT.INPUT_ERROR);
768
+ encKey = deriveKey(rawKey, machineFingerprint());
769
+ } else {
770
+ error('Use --license <license.json> or --key <secret> to provide encryption key', EXIT.INPUT_ERROR);
771
+ }
772
+
773
+ const outputDir = args.includes('--output') ? args[args.indexOf('--output') + 1] : null;
774
+ const outName = `${domainName}.kdnae`;
775
+ const outPath = outputDir ? path.join(outputDir, outName) : path.join(process.cwd(), outName);
776
+ if (outputDir && !fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
777
+
778
+ // Build encrypted ZIP
779
+ const zlib = require('zlib');
780
+ const files = fs.readdirSync(abs).filter(f => {
781
+ if (f.startsWith('.')) return false;
782
+ const ext = path.extname(f);
783
+ return ext === '.json' || f === 'README.md' || f === 'LICENSE' || f === 'kdna.json';
784
+ });
785
+
786
+ const centralDir = [];
787
+ const fileData = [];
788
+ let offset = 0;
789
+
790
+ for (const f of files.sort()) {
791
+ let raw = fs.readFileSync(path.join(abs, f));
792
+ let storedName = f;
793
+
794
+ if (isEncryptable(f)) {
795
+ try {
796
+ const encrypted = encrypt(raw.toString('utf8'), encKey);
797
+ raw = encrypted;
798
+ storedName = f; // Keep original name, content is encrypted
799
+ } catch (err) {
800
+ error(`Failed to encrypt ${f}: ${err.message}`);
801
+ }
802
+ }
803
+
804
+ const crc = crc32(raw);
805
+ const compressed = zlib.deflateRawSync(raw);
806
+ const useStore = compressed.length >= raw.length;
807
+ const stored = useStore ? raw : compressed;
808
+
809
+ const nameBytes = Buffer.from(storedName, 'utf8');
810
+ const localHeader = Buffer.alloc(30);
811
+ localHeader.writeUInt32LE(0x04034b50, 0);
812
+ localHeader.writeUInt16LE(20, 4);
813
+ localHeader.writeUInt16LE(0x0800, 6);
814
+ localHeader.writeUInt16LE(useStore ? 0 : 8, 8);
815
+ localHeader.writeUInt32LE(crc, 14);
816
+ localHeader.writeUInt32LE(useStore ? raw.length : compressed.length, 18);
817
+ localHeader.writeUInt32LE(raw.length, 22);
818
+ localHeader.writeUInt16LE(nameBytes.length, 26);
819
+
820
+ fileData.push(Buffer.concat([localHeader, nameBytes, stored]));
821
+ offset += localHeader.length + nameBytes.length + stored.length;
822
+
823
+ const cdEntry = Buffer.alloc(46);
824
+ cdEntry.writeUInt32LE(0x02014b50, 0);
825
+ cdEntry.writeUInt16LE(20, 4);
826
+ cdEntry.writeUInt16LE(20, 6);
827
+ cdEntry.writeUInt16LE(0x0800, 8);
828
+ cdEntry.writeUInt16LE(useStore ? 0 : 8, 10);
829
+ cdEntry.writeUInt32LE(crc, 16);
830
+ cdEntry.writeUInt32LE(useStore ? raw.length : compressed.length, 20);
831
+ cdEntry.writeUInt32LE(raw.length, 24);
832
+ cdEntry.writeUInt16LE(nameBytes.length, 28);
833
+ cdEntry.writeUInt32LE(offset - stored.length - nameBytes.length - localHeader.length, 42);
834
+ centralDir.push(Buffer.concat([cdEntry, nameBytes]));
835
+ }
836
+
837
+ const cdOffset = offset;
838
+ const cdSize = centralDir.reduce((s, e) => s + e.length, 0);
839
+ const eocd = Buffer.alloc(22);
840
+ eocd.writeUInt32LE(0x06054b50, 0);
841
+ eocd.writeUInt16LE(0, 4);
842
+ eocd.writeUInt16LE(0, 6);
843
+ eocd.writeUInt16LE(files.length, 8);
844
+ eocd.writeUInt16LE(files.length, 10);
845
+ eocd.writeUInt32LE(cdSize, 12);
846
+ eocd.writeUInt32LE(cdOffset, 16);
847
+ eocd.writeUInt16LE(0, 20);
848
+
849
+ const all = Buffer.concat([...fileData, ...centralDir, eocd]);
850
+ fs.writeFileSync(outPath, all);
851
+
852
+ console.log(`✓ Encrypted pack: ${outPath}`);
853
+ console.log(` Domain: ${domainName} v${manifest.version}`);
854
+ console.log(` Files: ${files.length} (${files.filter(isEncryptable).length} encrypted, ${files.filter(f => !isEncryptable(f)).length} plaintext)`);
855
+ console.log(` Container: AES-256-GCM .kdnae`);
856
+ }
857
+
858
+ function cmdUnpackEncrypt(filePath, args = []) {
859
+ const abs = path.resolve(filePath);
860
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
861
+ error(`Not a file: ${abs}`, EXIT.INPUT_ERROR);
862
+ }
863
+ if (!abs.endsWith('.kdnae')) {
864
+ error(`Not a .kdnae file: ${abs}`, EXIT.INPUT_ERROR);
865
+ }
866
+
867
+ const licenseIdx = args.indexOf('--license');
868
+ const keyIdx = args.indexOf('--key');
869
+ let encKey;
870
+
871
+ if (licenseIdx >= 0) {
872
+ const licensePath = args[licenseIdx + 1];
873
+ if (!licensePath || !fs.existsSync(licensePath)) error('License file not found', EXIT.INPUT_ERROR);
874
+ const license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
875
+ const licenseKey = license.license_id;
876
+ const fp = machineFingerprint();
877
+ encKey = deriveKey(licenseKey, fp);
878
+ } else if (keyIdx >= 0) {
879
+ const rawKey = args[keyIdx + 1];
880
+ if (!rawKey) error('--key requires a value', EXIT.INPUT_ERROR);
881
+ encKey = deriveKey(rawKey, machineFingerprint());
882
+ } else {
883
+ error('Use --license <license.json> or --key <secret> to decrypt', EXIT.INPUT_ERROR);
884
+ }
885
+
886
+ const domainName = path.basename(abs, '.kdnae');
887
+ const outDir = path.join(path.dirname(abs), domainName);
888
+ const force = args.includes('--force');
889
+
890
+ if (fs.existsSync(outDir)) {
891
+ if (!force) error(`Directory already exists: ${outDir}\nUse --force to overwrite.`, EXIT.INPUT_ERROR);
892
+ fs.rmSync(outDir, { recursive: true, force: true });
893
+ }
894
+ fs.mkdirSync(outDir, { recursive: true });
895
+
896
+ // Extract ZIP first, then decrypt KDNA JSON files
897
+ const os = require('os');
898
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kdnae-unpack-'));
899
+
900
+ try {
901
+ const tmpPy = path.join(os.tmpdir(), `kdnae-unpack-${Date.now()}.py`);
902
+ try {
903
+ const script = `import zipfile, os
904
+ zf = zipfile.ZipFile(${JSON.stringify(abs)}, 'r')
905
+ zf.extractall(${JSON.stringify(tmpDir)})
906
+ zf.close()
907
+ `;
908
+ fs.writeFileSync(tmpPy, script);
909
+ execSync(`python3 ${tmpPy}`, { stdio: 'pipe' });
910
+ } catch {
911
+ try { execSync(`unzip -q -o "${abs}" -d "${tmpDir}"`, { stdio: 'pipe' }); }
912
+ catch { error('Cannot unpack .kdnae container'); }
913
+ } finally {
914
+ try { fs.unlinkSync(tmpPy); } catch { /* cleanup */ }
915
+ }
916
+
917
+ // Copy plaintext files, decrypt KDNA files
918
+ const extracted = fs.readdirSync(tmpDir);
919
+ for (const f of extracted) {
920
+ const src = path.join(tmpDir, f);
921
+ const dest = path.join(outDir, f);
922
+
923
+ if (ENCRYPTED_FILES.includes(f)) {
924
+ try {
925
+ const encrypted = fs.readFileSync(src);
926
+ const decrypted = decrypt(encrypted, encKey);
927
+ fs.writeFileSync(dest, decrypted);
928
+ } catch (err) {
929
+ error(`Failed to decrypt ${f}: ${err.message}. Wrong license key?`);
930
+ }
931
+ } else {
932
+ fs.copyFileSync(src, dest);
933
+ }
934
+ }
935
+ } finally {
936
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* cleanup */ }
937
+ }
938
+
939
+ console.log(`✓ Decrypted: ${outDir}`);
940
+ const files = fs.readdirSync(outDir);
941
+ console.log(` Files: ${files.length}`);
942
+ files.forEach(f => console.log(` ${f}`));
943
+ }
944
+
710
945
  module.exports = {
711
946
  cmdValidate,
712
947
  cmdPack,
948
+ cmdPackEncrypt,
713
949
  cmdUnpack,
950
+ cmdUnpackEncrypt,
714
951
  cmdInspect,
715
952
  };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * KDNA Encryption — Encrypted container format (.kdnae) for licensed domains.
3
+ *
4
+ * .kdnae extends .kdna with AES-256-GCM encryption on KDNA JSON files.
5
+ * The kdna.json manifest and license.json stay in plaintext for discovery.
6
+ *
7
+ * Encryption key is derived via PBKDF2 from:
8
+ * license_key + machine_fingerprint
9
+ *
10
+ * This module provides pure-crypto functions (no filesystem I/O).
11
+ * File operations are in domain.js (pack/unpack) and install.js.
12
+ */
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const ALGORITHM = 'aes-256-gcm';
17
+ const IV_LENGTH = 16; // 96-bit IV for GCM
18
+ const TAG_LENGTH = 16; // 128-bit auth tag
19
+ const SALT_LENGTH = 32;
20
+ const PBKDF2_ITERATIONS = 600000;
21
+ const KEY_LENGTH = 32; // AES-256
22
+
23
+ // Files in a .kdna container that get encrypted (JSON content only)
24
+ const ENCRYPTED_FILES = [
25
+ 'KDNA_Core.json',
26
+ 'KDNA_Patterns.json',
27
+ 'KDNA_Scenarios.json',
28
+ 'KDNA_Cases.json',
29
+ 'KDNA_Reasoning.json',
30
+ 'KDNA_Evolution.json',
31
+ ];
32
+
33
+ // Files that always stay in plaintext
34
+ const PLAINTEXT_FILES = ['kdna.json', 'license.json', 'README.md', 'LICENSE', 'signature.json'];
35
+
36
+ function isEncryptable(filename) {
37
+ return ENCRYPTED_FILES.includes(filename);
38
+ }
39
+
40
+ // ─── Machine Fingerprint ────────────────────────────────────────────────
41
+
42
+ function machineFingerprint() {
43
+ const os = require('os');
44
+ const parts = [os.hostname(), os.userInfo().uid.toString(), os.platform(), os.arch()];
45
+ // Try to get hardware UUID on macOS
46
+ try {
47
+ const { execSync } = require('child_process');
48
+ if (os.platform() === 'darwin') {
49
+ const uuid = execSync('ioreg -d2 -c IOPlatformExpertDevice | grep IOPlatformUUID', {
50
+ encoding: 'utf8',
51
+ timeout: 3000,
52
+ }).match(/"[A-F0-9-]{36}"/)?.[0]?.replace(/"/g, '');
53
+ if (uuid) parts.push(uuid);
54
+ }
55
+ if (os.platform() === 'linux') {
56
+ try {
57
+ const mid = require('fs').readFileSync('/etc/machine-id', 'utf8').trim();
58
+ if (mid) parts.push(mid);
59
+ } catch { /* ignore */ }
60
+ }
61
+ } catch { /* non-critical */ }
62
+ return crypto.createHash('sha256').update(parts.join('|')).digest('hex');
63
+ }
64
+
65
+ // ─── Key Derivation ─────────────────────────────────────────────────────
66
+
67
+ function deriveKey(licenseKey, fingerprint) {
68
+ const salt = crypto.createHash('sha256').update(fingerprint || machineFingerprint()).digest();
69
+ return crypto.pbkdf2Sync(licenseKey, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512');
70
+ }
71
+
72
+ // ─── Encrypt / Decrypt ──────────────────────────────────────────────────
73
+
74
+ function encrypt(plaintext, key) {
75
+ const iv = crypto.randomBytes(IV_LENGTH);
76
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
77
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
78
+ const tag = cipher.getAuthTag();
79
+ // Format: iv (16) + tag (16) + ciphertext
80
+ return Buffer.concat([iv, tag, encrypted]);
81
+ }
82
+
83
+ function decrypt(encryptedData, key) {
84
+ if (encryptedData.length < IV_LENGTH + TAG_LENGTH) {
85
+ throw new Error('Invalid encrypted data: too short');
86
+ }
87
+ const iv = encryptedData.subarray(0, IV_LENGTH);
88
+ const tag = encryptedData.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
89
+ const ciphertext = encryptedData.subarray(IV_LENGTH + TAG_LENGTH);
90
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
91
+ decipher.setAuthTag(tag);
92
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
93
+ }
94
+
95
+ // ─── Encrypted Container Detection ──────────────────────────────────────
96
+
97
+ function isEncryptedContainer(filePath) {
98
+ return filePath.endsWith('.kdnae');
99
+ }
100
+
101
+ // ─── License File ──────────────────────────────────────────────────────
102
+
103
+ function createLicense(domain, options = {}) {
104
+ const {
105
+ issuedTo = 'licensee@example.com',
106
+ expiresAt = null, // ISO date or null for perpetual
107
+ maxAgents = 1,
108
+ requireMachineBinding = true,
109
+ requireOnlineCheck = false,
110
+ offlineGraceDays = 7,
111
+ } = options;
112
+
113
+ const license = {
114
+ version: '1.0',
115
+ license_id: `lic_${crypto.randomBytes(8).toString('hex')}`,
116
+ domain,
117
+ issued_to: issuedTo,
118
+ issued_at: new Date().toISOString(),
119
+ expires_at: expiresAt,
120
+ max_agents: maxAgents,
121
+ require_machine_binding: requireMachineBinding,
122
+ require_online_check: requireOnlineCheck,
123
+ offline_grace_days: offlineGraceDays,
124
+ allowed_agents: options.allowedAgents || ['claude_code', 'codex', 'opencode'],
125
+ };
126
+
127
+ return license;
128
+ }
129
+
130
+ function verifyLicense(license, scopePubkey, fingerprint) {
131
+ const issues = [];
132
+
133
+ // Check expiration
134
+ if (license.expires_at) {
135
+ if (new Date(license.expires_at) < new Date()) {
136
+ issues.push('License has expired');
137
+ }
138
+ }
139
+
140
+ // Check machine binding
141
+ if (license.require_machine_binding) {
142
+ if (license.machine_fingerprint && license.machine_fingerprint !== fingerprint) {
143
+ issues.push('Machine fingerprint mismatch');
144
+ }
145
+ }
146
+
147
+ return {
148
+ valid: issues.length === 0,
149
+ issues,
150
+ domain: license.domain,
151
+ license_id: license.license_id,
152
+ issued_to: license.issued_to,
153
+ };
154
+ }
155
+
156
+ function signLicense(license, privateKeyPem) {
157
+ const { signature: _, ...payload } = license;
158
+ const data = JSON.stringify(payload, Object.keys(payload).sort());
159
+ // Use crypto.sign() for Ed25519 (supported via PEM keys)
160
+ const sig = crypto.sign(null, Buffer.from(data), privateKeyPem);
161
+ license.signature = `ed25519:${sig.toString('hex')}`;
162
+ return license;
163
+ }
164
+
165
+ function verifyLicenseSignature(license, publicKeyPem) {
166
+ const signature = license.signature?.replace('ed25519:', '') || '';
167
+ if (!signature) return false;
168
+ const { signature: _, ...payload } = license;
169
+ const data = JSON.stringify(payload, Object.keys(payload).sort());
170
+ try {
171
+ return crypto.verify(null, Buffer.from(data), publicKeyPem, Buffer.from(signature, 'hex'));
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ module.exports = {
178
+ encrypt,
179
+ decrypt,
180
+ deriveKey,
181
+ machineFingerprint,
182
+ isEncryptable,
183
+ isEncryptedContainer,
184
+ createLicense,
185
+ verifyLicense,
186
+ signLicense,
187
+ verifyLicenseSignature,
188
+ ENCRYPTED_FILES,
189
+ PLAINTEXT_FILES,
190
+ };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * KDNA License — Generate, verify, and manage domain licenses.
3
+ *
4
+ * Commands:
5
+ * kdna license generate <domain> --to <email> [--expires <date>]
6
+ * kdna license verify <license.json>
7
+ * kdna license bind <license.json>
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+ const { EXIT, error } = require('./_common');
14
+ const { createLicense, signLicense, verifyLicense, verifyLicenseSignature, machineFingerprint } = require('./encrypt');
15
+
16
+ const IDENTITY_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna', 'identity');
17
+ const PRIVATE_KEY_PATH = path.join(IDENTITY_DIR, 'kdna.key');
18
+ const PUBLIC_KEY_PATH = path.join(IDENTITY_DIR, 'kdna.pub');
19
+
20
+ function readIdentity() {
21
+ if (!fs.existsSync(PRIVATE_KEY_PATH) || !fs.existsSync(PUBLIC_KEY_PATH)) {
22
+ error('No identity found. Run: kdna identity init', EXIT.INPUT_ERROR);
23
+ }
24
+ return {
25
+ privateKey: fs.readFileSync(PRIVATE_KEY_PATH, 'utf8'),
26
+ publicKey: fs.readFileSync(PUBLIC_KEY_PATH, 'utf8'),
27
+ };
28
+ }
29
+
30
+ function cmdLicenseGenerate(args) {
31
+ const domain = args[0];
32
+ if (!domain) error('Usage: kdna license generate <domain> --to <email> [--expires <date>] [--max-agents <n>]', EXIT.INPUT_ERROR);
33
+
34
+ const emailIdx = args.indexOf('--to');
35
+ const email = emailIdx >= 0 ? args[emailIdx + 1] : null;
36
+ if (!email) error('--to <email> is required', EXIT.INPUT_ERROR);
37
+
38
+ const expiresIdx = args.indexOf('--expires');
39
+ const expiresAt = expiresIdx >= 0 ? args[expiresIdx + 1] : null;
40
+
41
+ const agentsIdx = args.indexOf('--max-agents');
42
+ const maxAgents = agentsIdx >= 0 ? parseInt(args[agentsIdx + 1], 10) : 1;
43
+
44
+ const bindingIdx = args.includes('--no-binding');
45
+ const requireBinding = !bindingIdx;
46
+
47
+ const { privateKey, publicKey } = readIdentity();
48
+
49
+ const license = createLicense(domain, {
50
+ issuedTo: email,
51
+ expiresAt,
52
+ maxAgents,
53
+ requireMachineBinding: requireBinding,
54
+ allowedAgents: ['claude_code', 'codex', 'opencode', 'cursor', 'gemini'],
55
+ });
56
+
57
+ const signed = signLicense(license, privateKey);
58
+
59
+ const pubkey = crypto.createHash('sha256').update(publicKey).digest('hex').substring(0, 12);
60
+ signed.scope_pubkey_fingerprint = `ed25519:${pubkey}`;
61
+
62
+ const saveIdx = args.indexOf('--save');
63
+ const savePath = saveIdx >= 0 ? args[saveIdx + 1] : null;
64
+
65
+ console.log(JSON.stringify(signed, null, 2));
66
+
67
+ if (savePath) {
68
+ fs.writeFileSync(savePath, JSON.stringify(signed, null, 2) + '\n');
69
+ console.error(`Saved to: ${savePath}`);
70
+ }
71
+
72
+ console.error('');
73
+ console.error(`License generated for ${domain}`);
74
+ console.error(` Issued to: ${email}`);
75
+ console.error(` License ID: ${signed.license_id}`);
76
+ if (expiresAt) console.error(` Expires: ${expiresAt}`);
77
+ console.error(` Machine binding: ${requireBinding ? 'required' : 'disabled'}`);
78
+ console.error('');
79
+ console.error('Save this JSON as license.json inside the .kdnae container.');
80
+ console.error('Share the license key with the licensee.');
81
+ }
82
+
83
+ function cmdLicenseVerify(args) {
84
+ const licensePath = args[0];
85
+ if (!licensePath) error('Usage: kdna license verify <license.json>', EXIT.INPUT_ERROR);
86
+
87
+ let license;
88
+ try {
89
+ license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
90
+ } catch {
91
+ error(`Cannot read license file: ${licensePath}`, EXIT.INPUT_ERROR);
92
+ }
93
+
94
+ const { publicKey } = readIdentity();
95
+ const signatureValid = verifyLicenseSignature(license, publicKey);
96
+ const fp = machineFingerprint();
97
+ const result = verifyLicense(license, publicKey, fp);
98
+
99
+ const jsonMode = args.includes('--json');
100
+
101
+ if (jsonMode) {
102
+ console.log(JSON.stringify({
103
+ domain: license.domain,
104
+ license_id: license.license_id,
105
+ issued_to: license.issued_to,
106
+ signature_valid: signatureValid,
107
+ valid: result.valid,
108
+ issues: result.issues,
109
+ fingerprint: license.require_machine_binding ? fp : 'not required',
110
+ }, null, 2));
111
+ } else {
112
+ console.log(`License: ${license.license_id}`);
113
+ console.log(`Domain: ${license.domain}`);
114
+ console.log(`Issued: ${license.issued_to}`);
115
+ console.log(`Signature: ${signatureValid ? '✓ valid' : '✗ invalid'}`);
116
+ if (license.expires_at) {
117
+ const expired = new Date(license.expires_at) < new Date();
118
+ console.log(`Expires: ${license.expires_at} ${expired ? '(EXPIRED)' : ''}`);
119
+ }
120
+ if (license.require_machine_binding) {
121
+ console.log(`Machine binding: required`);
122
+ console.log(` Current fingerprint: ${fp}`);
123
+ }
124
+ if (result.issues.length) {
125
+ console.log('');
126
+ console.log('Issues:');
127
+ result.issues.forEach(i => console.log(` ✗ ${i}`));
128
+ } else {
129
+ console.log('');
130
+ console.log('✓ License valid');
131
+ }
132
+ }
133
+
134
+ process.exit(result.valid && signatureValid ? EXIT.OK : EXIT.TRUST_FAILED);
135
+ }
136
+
137
+ function cmdLicenseBind(args) {
138
+ const licensePath = args[0];
139
+ if (!licensePath) error('Usage: kdna license bind <license.json>', EXIT.INPUT_ERROR);
140
+
141
+ let license;
142
+ try {
143
+ license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
144
+ } catch {
145
+ error(`Cannot read license file: ${licensePath}`, EXIT.INPUT_ERROR);
146
+ }
147
+
148
+ if (!license.require_machine_binding) {
149
+ console.log('License does not require machine binding. No action needed.');
150
+ process.exit(EXIT.OK);
151
+ }
152
+
153
+ const { privateKey, publicKey } = readIdentity();
154
+ const fp = machineFingerprint();
155
+
156
+ // Remove old signature before re-signing
157
+ delete license.signature;
158
+ license.machine_fingerprint = fp;
159
+ license.bound_at = new Date().toISOString();
160
+
161
+ const signed = signLicense(license, privateKey);
162
+ fs.writeFileSync(licensePath, JSON.stringify(signed, null, 2) + '\n');
163
+ console.log(`License bound to machine: ${fp}`);
164
+ console.log(`Updated: ${licensePath}`);
165
+ }
166
+
167
+ function cmdLicenseShow(args) {
168
+ const licensePath = args[0];
169
+ if (!licensePath) {
170
+ const local = path.join(process.cwd(), 'license.json');
171
+ if (fs.existsSync(local)) return cmdLicenseVerify([local, ...args.slice(1)]);
172
+ error('Usage: kdna license show <license.json>', EXIT.INPUT_ERROR);
173
+ }
174
+ cmdLicenseVerify(args);
175
+ }
176
+
177
+ function cmdLicenseInstall(args) {
178
+ const licensePath = args[0];
179
+ if (!licensePath) error('Usage: kdna license install <license.json>', EXIT.INPUT_ERROR);
180
+
181
+ let license;
182
+ try {
183
+ license = JSON.parse(fs.readFileSync(licensePath, 'utf8'));
184
+ } catch {
185
+ error(`Cannot read license file: ${licensePath}`, EXIT.INPUT_ERROR);
186
+ }
187
+
188
+ if (!license.domain) error('License missing domain field', EXIT.INPUT_ERROR);
189
+
190
+ const licenseDir = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna', 'licenses');
191
+ fs.mkdirSync(licenseDir, { recursive: true });
192
+
193
+ const safeName = license.domain.replace(/^@/, '').replace('/', '-');
194
+ const dest = path.join(licenseDir, `${safeName}.json`);
195
+
196
+ fs.writeFileSync(dest, JSON.stringify(license, null, 2) + '\n');
197
+
198
+ console.log(`License installed for ${license.domain}`);
199
+ console.log(` License ID: ${license.license_id || 'unknown'}`);
200
+ console.log(` Saved to: ${dest}`);
201
+ console.log('');
202
+ console.log(`Now install the domain: kdna install ${license.domain}`);
203
+ }
204
+
205
+ module.exports = { cmdLicenseGenerate, cmdLicenseVerify, cmdLicenseBind, cmdLicenseShow, cmdLicenseInstall };
package/src/install.js CHANGED
@@ -20,6 +20,7 @@ const crypto = require('crypto');
20
20
  const { execSync, execFileSync } = require('child_process');
21
21
  const { RegistryResolver, parseName } = require('./registry');
22
22
  const { EXIT, error } = require('./cmds/_common');
23
+ const { decrypt, deriveKey, machineFingerprint, isEncryptedContainer, ENCRYPTED_FILES } = require('./cmds/encrypt');
23
24
 
24
25
  const USER_KDNA_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
25
26
  const INSTALL_DIR = path.join(USER_KDNA_DIR, 'domains');
@@ -199,9 +200,9 @@ function warnLegacy() {
199
200
  // ─── Source parsing ─────────────────────────────────────────────────────
200
201
 
201
202
  function parseSource(input) {
202
- // Local file
203
+ // Local file (.kdna or .kdnae)
203
204
  if (
204
- input.endsWith('.kdna') &&
205
+ (input.endsWith('.kdna') || input.endsWith('.kdnae')) &&
205
206
  (input.startsWith('./') || input.startsWith('/') || input.startsWith('~/'))
206
207
  ) {
207
208
  const resolved = path.resolve(input.replace(/^~/, process.env.HOME || ''));
@@ -283,6 +284,53 @@ print('ok')
283
284
  }
284
285
  }
285
286
 
287
+ function extractAndDecrypt(kdnaPath, destDir, licenseKey) {
288
+ extractKdna(kdnaPath, destDir);
289
+ const fp = machineFingerprint();
290
+ const key = deriveKey(licenseKey, fp);
291
+
292
+ for (const f of fs.readdirSync(destDir)) {
293
+ if (ENCRYPTED_FILES.includes(f)) {
294
+ try {
295
+ const fullPath = path.join(destDir, f);
296
+ const encrypted = fs.readFileSync(fullPath);
297
+ const decrypted = decrypt(encrypted, key);
298
+ fs.writeFileSync(fullPath, decrypted);
299
+ } catch (err) {
300
+ fs.rmSync(destDir, { recursive: true, force: true });
301
+ error(`Failed to decrypt ${f}: ${err.message}. Wrong license key?`);
302
+ }
303
+ }
304
+ }
305
+ }
306
+
307
+ function findLicense(domainName) {
308
+ const licenseDir = path.join(USER_KDNA_DIR, 'licenses');
309
+ const licensePath = path.join(licenseDir, `${domainName.replace(/^@/, '').replace('/', '-')}.json`);
310
+ if (fs.existsSync(licensePath)) {
311
+ try {
312
+ return JSON.parse(fs.readFileSync(licensePath, 'utf8'));
313
+ } catch { /* invalid license */ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ function findLicenseForDomain(domainFull) {
319
+ const licenseDir = path.join(USER_KDNA_DIR, 'licenses');
320
+ if (!fs.existsSync(licenseDir)) return null;
321
+ // Try exact match: @aikdna/writing → @aikdna-writing.json
322
+ const candidates = [domainFull.replace(/^@/, '').replace('/', '-')];
323
+ // Also try just the domain name
324
+ candidates.push(domainFull.split('/').pop());
325
+ for (const c of candidates) {
326
+ const p = path.join(licenseDir, `${c}.json`);
327
+ if (fs.existsSync(p)) {
328
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { /* skip */ }
329
+ }
330
+ }
331
+ return null;
332
+ }
333
+
286
334
  // ─── Signature verification ────────────────────────────────────────────
287
335
 
288
336
  function verifySignature({ destDir, scope, entry, lenient = true }) {
@@ -613,9 +661,53 @@ function installFromLocalFile(filePath, _yes, jsonMode = false) {
613
661
  const abs = path.resolve(filePath);
614
662
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) error(`Not a file: ${abs}`);
615
663
 
664
+ const isEncrypted = isEncryptedContainer(abs);
616
665
  const tmpDir = path.join(INSTALL_DIR, '.local-tmp-' + Date.now());
617
666
  ensureDir(tmpDir);
618
- extractKdna(abs, tmpDir);
667
+
668
+ if (isEncrypted) {
669
+ // Find license for this .kdnae file
670
+ // First check the license directory, then ask for --license flag from args
671
+ const licenseFromArgs = process.argv.includes('--license')
672
+ ? process.argv[process.argv.indexOf('--license') + 1]
673
+ : null;
674
+ let licenseKey = null;
675
+
676
+ if (licenseFromArgs && fs.existsSync(licenseFromArgs)) {
677
+ try {
678
+ const lic = JSON.parse(fs.readFileSync(licenseFromArgs, 'utf8'));
679
+ licenseKey = lic.license_id;
680
+ } catch { /* invalid license file */ }
681
+ }
682
+
683
+ if (!licenseKey) {
684
+ // Try to auto-discover from ~/.kdna/licenses/
685
+ const manifest = readJson(path.join(tmpDir, 'kdna.json'));
686
+ // We need to extract just the manifest first to get the domain name
687
+ extractKdna(abs, tmpDir);
688
+ const mf = readJson(path.join(tmpDir, 'kdna.json'));
689
+ if (mf?.name) {
690
+ const lic = findLicenseForDomain(mf.name);
691
+ if (lic) licenseKey = lic.license_id;
692
+ }
693
+ if (!licenseKey) {
694
+ fs.rmSync(tmpDir, { recursive: true, force: true });
695
+ error(
696
+ `Cannot install encrypted .kdnae without a license.\n` +
697
+ `Save the license to ~/.kdna/licenses/ or use --license <file>.`,
698
+ EXIT.TRUST_FAILED,
699
+ );
700
+ }
701
+ // Re-extract for decryption
702
+ fs.rmSync(tmpDir, { recursive: true, force: true });
703
+ ensureDir(tmpDir);
704
+ }
705
+
706
+ console.log(' Decrypting .kdnae container...');
707
+ extractAndDecrypt(abs, tmpDir, licenseKey);
708
+ } else {
709
+ extractKdna(abs, tmpDir);
710
+ }
619
711
 
620
712
  const manifest = readJson(path.join(tmpDir, 'kdna.json'));
621
713
  const declared = manifest?.name;
@@ -636,6 +728,7 @@ function installFromLocalFile(filePath, _yes, jsonMode = false) {
636
728
  destManifest._source = {
637
729
  type: 'local-file',
638
730
  path: abs,
731
+ encrypted: isEncrypted,
639
732
  installed_at: new Date().toISOString(),
640
733
  };
641
734
  fs.writeFileSync(path.join(dest, 'kdna.json'), JSON.stringify(destManifest, null, 2) + '\n');
@@ -647,9 +740,10 @@ function installFromLocalFile(filePath, _yes, jsonMode = false) {
647
740
  path: dest,
648
741
  source: 'local-file',
649
742
  source_path: abs,
743
+ encrypted: isEncrypted,
650
744
  }));
651
745
  } else {
652
- console.log(`✓ Installed ${declared} from local file`);
746
+ console.log(`✓ Installed ${declared} from ${isEncrypted ? 'encrypted' : 'local'} file`);
653
747
  console.log(` Location: ${dest}`);
654
748
  }
655
749
  }