@aikdna/kdna-cli 0.13.0 → 0.14.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 +1 -1
- package/src/cli.js +44 -4
- package/src/cmds/_common.js +2 -1
- package/src/cmds/domain.js +237 -0
- package/src/cmds/encrypt.js +190 -0
- package/src/cmds/license.js +178 -0
package/package.json
CHANGED
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 } = 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,12 @@ 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 verify <license.json> Verify license signature
|
|
142
|
+
license bind <license.json> Bind license to this machine
|
|
143
|
+
license show <license.json> Display license details
|
|
144
|
+
|
|
136
145
|
Flags:
|
|
137
146
|
--json Structured JSON output (machine-readable)
|
|
138
147
|
--quiet Suppress non-error output
|
|
@@ -167,13 +176,21 @@ switch (cmd) {
|
|
|
167
176
|
}
|
|
168
177
|
}
|
|
169
178
|
if (!target) error('Usage: kdna pack <path>');
|
|
170
|
-
|
|
179
|
+
if (args.includes('--encrypt')) {
|
|
180
|
+
cmdPackEncrypt(target, args);
|
|
181
|
+
} else {
|
|
182
|
+
cmdPack(target, output);
|
|
183
|
+
}
|
|
171
184
|
break;
|
|
172
185
|
}
|
|
173
186
|
case 'unpack': {
|
|
174
187
|
const target = args[1];
|
|
175
|
-
if (!target) error('Usage: kdna unpack <file.kdna>');
|
|
176
|
-
|
|
188
|
+
if (!target) error('Usage: kdna unpack <file.kdna|file.kdnae>');
|
|
189
|
+
if (target.endsWith('.kdnae')) {
|
|
190
|
+
cmdUnpackEncrypt(target, args);
|
|
191
|
+
} else {
|
|
192
|
+
cmdUnpack(target, args.includes('--force'));
|
|
193
|
+
}
|
|
177
194
|
break;
|
|
178
195
|
}
|
|
179
196
|
case 'preview': {
|
|
@@ -408,6 +425,29 @@ switch (cmd) {
|
|
|
408
425
|
cmdHistory(args);
|
|
409
426
|
break;
|
|
410
427
|
}
|
|
428
|
+
case 'license': {
|
|
429
|
+
const sub = args[1];
|
|
430
|
+
const rest = args.slice(2);
|
|
431
|
+
if (sub === 'generate') {
|
|
432
|
+
cmdLicenseGenerate(rest);
|
|
433
|
+
} else if (sub === 'verify') {
|
|
434
|
+
cmdLicenseVerify(rest);
|
|
435
|
+
} else if (sub === 'bind') {
|
|
436
|
+
cmdLicenseBind(rest);
|
|
437
|
+
} else if (sub === 'show') {
|
|
438
|
+
cmdLicenseShow(rest);
|
|
439
|
+
} else {
|
|
440
|
+
error(
|
|
441
|
+
'Usage:\n' +
|
|
442
|
+
' kdna license generate <domain> --to <email> [--expires <date>]\n' +
|
|
443
|
+
' kdna license verify <license.json>\n' +
|
|
444
|
+
' kdna license bind <license.json>\n' +
|
|
445
|
+
' kdna license show <license.json>',
|
|
446
|
+
EXIT.INPUT_ERROR,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
411
451
|
case 'identity': {
|
|
412
452
|
cmdIdentity(args);
|
|
413
453
|
break;
|
package/src/cmds/_common.js
CHANGED
|
@@ -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
|
package/src/cmds/domain.js
CHANGED
|
@@ -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,178 @@
|
|
|
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
|
+
// Try to find license in current directory
|
|
171
|
+
const local = path.join(process.cwd(), 'license.json');
|
|
172
|
+
if (fs.existsSync(local)) return cmdLicenseVerify([local, ...args.slice(1)]);
|
|
173
|
+
error('Usage: kdna license show <license.json>', EXIT.INPUT_ERROR);
|
|
174
|
+
}
|
|
175
|
+
cmdLicenseVerify(args);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { cmdLicenseGenerate, cmdLicenseVerify, cmdLicenseBind, cmdLicenseShow };
|