@aikdna/kdna-cli 0.19.3 → 0.20.1
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/LICENSE +1 -1
- package/README.md +42 -28
- package/package.json +5 -4
- package/skills/kdna-loader/SKILL.md +3 -1
- package/src/capsule-verify.js +70 -0
- package/src/cli.js +72 -47
- package/src/cmds/_common.js +66 -8
- package/src/cmds/domain.js +20 -1
- package/src/cmds/governance.js +1 -1
- package/src/cmds/protect.js +314 -0
- package/src/cmds/protect.js.bak +245 -0
- package/src/cmds/protocol.js +181 -0
- package/src/cmds/studio.js +7 -10
- package/src/cmds/workpack.js +875 -0
- package/src/dev-pack-v2.js +117 -0
- package/src/init.js +14 -6
- package/src/install.js +116 -2
- package/src/kdf-spec.js +42 -0
- package/src/package-store.js +29 -7
- package/src/paths.js +3 -2
- package/src/publish.js +91 -183
- package/src/registry.js +73 -3
- package/src/signature.js +39 -0
- package/src/verify.js +78 -0
- package/templates/cluster/README.md +1 -1
- package/templates/standard-domain/USAGE.md +2 -1
- package/templates/standard-domain/kdna.json +1 -1
package/src/publish.js
CHANGED
|
@@ -510,28 +510,14 @@ function cmdPublishCheck(domainPath, args = []) {
|
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
// ═══════════════════════════════════════════════════════════════════════
|
|
513
|
-
//
|
|
513
|
+
// Registry publish pipeline for existing .kdna assets.
|
|
514
514
|
// ═══════════════════════════════════════════════════════════════════════
|
|
515
515
|
|
|
516
516
|
const crypto = require('crypto');
|
|
517
|
-
const {
|
|
518
|
-
const identity = require('./identity');
|
|
519
|
-
const { fingerprint } = identity;
|
|
517
|
+
const { execFileSync } = require('child_process');
|
|
520
518
|
|
|
521
519
|
const NAME_RE = /^@([a-z][a-z0-9-]*)\/([a-z][a-z0-9_]*)$/;
|
|
522
520
|
|
|
523
|
-
function identityPaths() {
|
|
524
|
-
// Recompute each call so KDNA_IDENTITY_DIR env var can be changed at runtime
|
|
525
|
-
const dir =
|
|
526
|
-
process.env.KDNA_IDENTITY_DIR ||
|
|
527
|
-
path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna', 'identity');
|
|
528
|
-
return {
|
|
529
|
-
privateKeyPath: path.join(dir, 'kdna.key'),
|
|
530
|
-
publicKeyPath: path.join(dir, 'kdna.pub'),
|
|
531
|
-
dir,
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
|
|
535
521
|
/**
|
|
536
522
|
* Canonical signing payload: sorted (filename, sha256) pairs of all published
|
|
537
523
|
* content entries inside the .kdna ZIP, joined as `name:hex\n`.
|
|
@@ -581,38 +567,6 @@ function stableStringify(value) {
|
|
|
581
567
|
return JSON.stringify(value);
|
|
582
568
|
}
|
|
583
569
|
|
|
584
|
-
function manifestForContentDigest(manifest) {
|
|
585
|
-
const copy = { ...(manifest || {}) };
|
|
586
|
-
delete copy.signature;
|
|
587
|
-
delete copy.asset_digest;
|
|
588
|
-
delete copy.container_sha256;
|
|
589
|
-
delete copy.content_digest;
|
|
590
|
-
delete copy._source;
|
|
591
|
-
return copy;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function sourceContentDigest(srcDir) {
|
|
595
|
-
const files = listPublishEntries(srcDir).sort();
|
|
596
|
-
const parts = [];
|
|
597
|
-
for (const f of files) {
|
|
598
|
-
let buf;
|
|
599
|
-
if (f === 'mimetype') {
|
|
600
|
-
buf = Buffer.from('application/vnd.aikdna.kdna+zip');
|
|
601
|
-
} else if (f.endsWith('.json')) {
|
|
602
|
-
const obj = JSON.parse(fs.readFileSync(path.join(srcDir, f), 'utf8'));
|
|
603
|
-
const value = f === 'kdna.json' ? manifestForContentDigest(obj) : obj;
|
|
604
|
-
buf = Buffer.from(stableStringify(value));
|
|
605
|
-
} else {
|
|
606
|
-
buf = fs.readFileSync(path.join(srcDir, f));
|
|
607
|
-
}
|
|
608
|
-
parts.push(`${f}:${crypto.createHash('sha256').update(buf).digest('hex')}`);
|
|
609
|
-
}
|
|
610
|
-
return `sha256:${crypto
|
|
611
|
-
.createHash('sha256')
|
|
612
|
-
.update(Buffer.from(parts.join('\n')))
|
|
613
|
-
.digest('hex')}`;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
570
|
function listPublishEntries(domainDir) {
|
|
617
571
|
const entries = ['mimetype'];
|
|
618
572
|
const skipDirs = new Set(['.git', 'node_modules', 'dist']);
|
|
@@ -643,88 +597,32 @@ function listPublishEntries(domainDir) {
|
|
|
643
597
|
return entries;
|
|
644
598
|
}
|
|
645
599
|
|
|
646
|
-
function signPayload(payload, privateKeyPem) {
|
|
647
|
-
const privateKey = crypto.createPrivateKey(privateKeyPem);
|
|
648
|
-
const sig = crypto.sign(null, Buffer.from(payload), privateKey);
|
|
649
|
-
return sig.toString('hex');
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function loadIdentity() {
|
|
653
|
-
const { privateKeyPath, publicKeyPath, dir } = identityPaths();
|
|
654
|
-
if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
|
|
655
|
-
error(`No identity found at ${dir}. Run: kdna identity init (or set KDNA_IDENTITY_DIR)`);
|
|
656
|
-
}
|
|
657
|
-
return {
|
|
658
|
-
privateKey: fs.readFileSync(privateKeyPath, 'utf8'),
|
|
659
|
-
publicKey: fs.readFileSync(publicKeyPath, 'utf8'),
|
|
660
|
-
};
|
|
661
|
-
}
|
|
662
|
-
|
|
663
600
|
function publicKeyToScopeFormat(publicKeyPem) {
|
|
664
601
|
// The trust_pubkey in registry is stored as "ed25519:<sha256-of-PEM-hex>"
|
|
665
602
|
// because Ed25519 PEM is multi-line; the scope key is a stable fingerprint.
|
|
666
603
|
return 'ed25519:' + crypto.createHash('sha256').update(publicKeyPem).digest('hex');
|
|
667
604
|
}
|
|
668
605
|
|
|
669
|
-
function packToFile(domainDir, outPath) {
|
|
670
|
-
const files = listPublishEntries(domainDir).filter((f) => f !== 'mimetype');
|
|
671
|
-
if (!files.includes('kdna.json'))
|
|
672
|
-
error('kdna.json required in dev source directory for publish.');
|
|
673
|
-
|
|
674
|
-
const script = `import zipfile, os
|
|
675
|
-
src = ${JSON.stringify(domainDir)}
|
|
676
|
-
out = ${JSON.stringify(outPath)}
|
|
677
|
-
files = ${JSON.stringify(files)}
|
|
678
|
-
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
679
|
-
zf.writestr(zipfile.ZipInfo('mimetype'), 'application/vnd.aikdna.kdna+zip', compress_type=zipfile.ZIP_STORED)
|
|
680
|
-
for f in files:
|
|
681
|
-
zf.write(os.path.join(src, f), f)
|
|
682
|
-
`;
|
|
683
|
-
const tmpPy = `/tmp/kdna-publish-pack-${Date.now()}.py`;
|
|
684
|
-
try {
|
|
685
|
-
fs.writeFileSync(tmpPy, script);
|
|
686
|
-
execSync(`python3 ${tmpPy}`, { stdio: 'pipe' });
|
|
687
|
-
} finally {
|
|
688
|
-
try {
|
|
689
|
-
fs.unlinkSync(tmpPy);
|
|
690
|
-
} catch {
|
|
691
|
-
/* ignore */
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
function sha256File(p) {
|
|
697
|
-
return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function outputDirFromArgs(args, fallback) {
|
|
701
|
-
for (const flag of ['--output', '--out', '-o']) {
|
|
702
|
-
const idx = args.indexOf(flag);
|
|
703
|
-
if (idx >= 0) return args[idx + 1];
|
|
704
|
-
}
|
|
705
|
-
return fallback;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
606
|
/**
|
|
709
|
-
* kdna publish <
|
|
607
|
+
* kdna publish <file.kdna> — Publish an existing Studio-compiled asset.
|
|
710
608
|
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
* 3. Write signature into kdna.json (canonical payload signed with identity)
|
|
715
|
-
* 4. Pack into .kdna
|
|
716
|
-
* 5. Compute sha256
|
|
717
|
-
* 6. If --release-tag <tag> and --repo <owner/name>: upload via gh CLI
|
|
718
|
-
* 7. Print registry patch JSON
|
|
609
|
+
* Publishing no longer packs arbitrary source directories. Source directories
|
|
610
|
+
* are non-canonical dev workspaces; trusted assets come from Studio-compatible
|
|
611
|
+
* compile/export pipelines.
|
|
719
612
|
*/
|
|
720
|
-
function cmdPublish(
|
|
721
|
-
const abs = path.resolve(
|
|
722
|
-
if (!fs.existsSync(abs)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
613
|
+
function cmdPublish(assetPath, args = []) {
|
|
614
|
+
const abs = path.resolve(assetPath);
|
|
615
|
+
if (!fs.existsSync(abs)) error(`Path not found: ${abs}`, EXIT.INPUT_ERROR);
|
|
616
|
+
if (fs.statSync(abs).isDirectory()) {
|
|
617
|
+
error(
|
|
618
|
+
'kdna publish only accepts existing .kdna assets. Source directories are non-canonical; use KDNA Studio compile/export, then run kdna publish <file.kdna>.',
|
|
619
|
+
EXIT.INPUT_ERROR,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
if (!abs.endsWith('.kdna')) error('kdna publish requires a .kdna asset file.', EXIT.INPUT_ERROR);
|
|
726
623
|
|
|
727
|
-
const
|
|
624
|
+
const { readAssetManifest, assetDigest, contentDigest } = require('./package-store');
|
|
625
|
+
const manifest = readAssetManifest(abs);
|
|
728
626
|
const name = manifest.name;
|
|
729
627
|
const m = name && name.match(NAME_RE);
|
|
730
628
|
if (!m) {
|
|
@@ -732,72 +630,28 @@ function cmdPublish(domainPath, args = []) {
|
|
|
732
630
|
}
|
|
733
631
|
if (!manifest.version) error('kdna.json.version required.');
|
|
734
632
|
|
|
735
|
-
const { privateKey, publicKey } = loadIdentity();
|
|
736
|
-
const scopeKey = publicKeyToScopeFormat(publicKey);
|
|
737
|
-
|
|
738
633
|
console.log('═'.repeat(60));
|
|
739
634
|
console.log(` Publishing ${name}@${manifest.version}`);
|
|
740
635
|
console.log('═'.repeat(60));
|
|
741
636
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
for (const issue of hl.issues) {
|
|
748
|
-
console.error(` ✗ ${issue}`);
|
|
749
|
-
}
|
|
750
|
-
console.error('');
|
|
751
|
-
console.error(' Use kdna publish --check for details, or --force to override.');
|
|
752
|
-
if (!args.includes('--force')) {
|
|
753
|
-
process.exit(EXIT.HUMAN_LOCK_REQUIRED);
|
|
754
|
-
}
|
|
755
|
-
console.warn(' ⚠ --force override: publishing without Human Lock (emergency only)');
|
|
756
|
-
} else {
|
|
757
|
-
console.log(` ✓ Human Lock Gate: passed`);
|
|
637
|
+
const provenanceIssues = validateAuthoringProvenance(manifest);
|
|
638
|
+
if (provenanceIssues.length) {
|
|
639
|
+
error(
|
|
640
|
+
`Authoring provenance gate failed:\n${provenanceIssues.map((issue) => ` - ${issue}`).join('\n')}`,
|
|
641
|
+
);
|
|
758
642
|
}
|
|
759
|
-
console.log('');
|
|
760
643
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if (manifest.
|
|
768
|
-
|
|
769
|
-
`
|
|
644
|
+
const digest = assetDigest(abs);
|
|
645
|
+
const content = contentDigest(abs);
|
|
646
|
+
const size = fs.statSync(abs).size;
|
|
647
|
+
console.log(` ✓ Asset: ${abs} (${size} bytes)`);
|
|
648
|
+
console.log(` ✓ asset_digest: ${digest}`);
|
|
649
|
+
console.log(` ✓ content_digest: ${content}`);
|
|
650
|
+
if (manifest.authoring) {
|
|
651
|
+
console.log(
|
|
652
|
+
` ✓ Authoring: ${manifest.authoring.created_by} / ${manifest.authoring.compiler || '?'}`,
|
|
770
653
|
);
|
|
771
654
|
}
|
|
772
|
-
manifest.author.pubkey = scopeKey;
|
|
773
|
-
// Embed full PEM so consumers can verify the signature against author.pubkey fingerprint
|
|
774
|
-
manifest.author.public_key_pem = publicKey;
|
|
775
|
-
|
|
776
|
-
// 2. Write signature
|
|
777
|
-
delete manifest.signature;
|
|
778
|
-
delete manifest.asset_digest;
|
|
779
|
-
delete manifest.container_sha256;
|
|
780
|
-
delete manifest.content_digest;
|
|
781
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
782
|
-
manifest.content_digest = sourceContentDigest(abs);
|
|
783
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
784
|
-
const signedPayload = canonicalPayload(abs);
|
|
785
|
-
const sig = signPayload(signedPayload, privateKey);
|
|
786
|
-
manifest.signature = 'ed25519:' + sig;
|
|
787
|
-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
788
|
-
console.log(` ✓ Signed (payload covers ${listPublishEntries(abs).length} content entries)`);
|
|
789
|
-
|
|
790
|
-
// 3. Pack
|
|
791
|
-
const fileName = `${m[2]}-${manifest.version}.kdna`;
|
|
792
|
-
const outDir = outputDirFromArgs(args, path.join(abs, 'dist'));
|
|
793
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
794
|
-
const outPath = path.join(outDir, fileName);
|
|
795
|
-
packToFile(abs, outPath);
|
|
796
|
-
const sha256 = sha256File(outPath);
|
|
797
|
-
const assetDigest = `sha256:${sha256}`;
|
|
798
|
-
const size = fs.statSync(outPath).size;
|
|
799
|
-
console.log(` ✓ Packed: ${outPath} (${size} bytes)`);
|
|
800
|
-
console.log(` ✓ asset_digest: ${assetDigest}`);
|
|
801
655
|
|
|
802
656
|
// 4. Optional upload via gh CLI
|
|
803
657
|
const tagIdx = args.indexOf('--release-tag');
|
|
@@ -809,13 +663,13 @@ function cmdPublish(domainPath, args = []) {
|
|
|
809
663
|
console.log('');
|
|
810
664
|
console.log(` Uploading to ${repo} release ${tag}...`);
|
|
811
665
|
try {
|
|
812
|
-
execFileSync('gh', ['release', 'upload', tag,
|
|
666
|
+
execFileSync('gh', ['release', 'upload', tag, abs, '--repo', repo, '--clobber'], {
|
|
813
667
|
stdio: 'inherit',
|
|
814
668
|
});
|
|
815
|
-
kdnaUrl = `https://github.com/${repo}/releases/download/${tag}/${
|
|
669
|
+
kdnaUrl = `https://github.com/${repo}/releases/download/${tag}/${path.basename(abs)}`;
|
|
816
670
|
console.log(` ✓ Uploaded: ${kdnaUrl}`);
|
|
817
671
|
} catch {
|
|
818
|
-
console.warn(` ⚠ Upload failed. You can manually upload ${
|
|
672
|
+
console.warn(` ⚠ Upload failed. You can manually upload ${abs}.`);
|
|
819
673
|
}
|
|
820
674
|
}
|
|
821
675
|
|
|
@@ -825,9 +679,10 @@ function cmdPublish(domainPath, args = []) {
|
|
|
825
679
|
type: manifest.cluster ? 'cluster' : 'domain',
|
|
826
680
|
version: manifest.version,
|
|
827
681
|
asset_url: kdnaUrl,
|
|
828
|
-
asset_digest:
|
|
829
|
-
content_digest: manifest.content_digest ||
|
|
682
|
+
asset_digest: digest,
|
|
683
|
+
content_digest: manifest.content_digest || content,
|
|
830
684
|
signature: manifest.signature,
|
|
685
|
+
authoring: manifest.authoring || null,
|
|
831
686
|
release_status: kdnaUrl ? 'published_signed' : 'published_signed_local',
|
|
832
687
|
author: { ...manifest.author },
|
|
833
688
|
};
|
|
@@ -843,10 +698,63 @@ function cmdPublish(domainPath, args = []) {
|
|
|
843
698
|
);
|
|
844
699
|
}
|
|
845
700
|
|
|
701
|
+
function validateAuthoringProvenance(manifest) {
|
|
702
|
+
const issues = [];
|
|
703
|
+
const badgeRank = {
|
|
704
|
+
untested: 0,
|
|
705
|
+
tested: 1,
|
|
706
|
+
validated: 2,
|
|
707
|
+
expert_reviewed: 3,
|
|
708
|
+
production_ready: 4,
|
|
709
|
+
};
|
|
710
|
+
const badge = manifest.quality_badge || 'untested';
|
|
711
|
+
const highTrust = (badgeRank[badge] || 0) >= badgeRank.tested;
|
|
712
|
+
const authoring = manifest.authoring;
|
|
713
|
+
const studioCompatible = new Set([
|
|
714
|
+
'kdna-studio',
|
|
715
|
+
'kdna-studio-cli',
|
|
716
|
+
'kdna-studio-sdk',
|
|
717
|
+
'third-party-studio-compatible',
|
|
718
|
+
]);
|
|
719
|
+
|
|
720
|
+
if (!authoring) {
|
|
721
|
+
if (highTrust) issues.push(`quality_badge "${badge}" requires authoring provenance`);
|
|
722
|
+
return issues;
|
|
723
|
+
}
|
|
724
|
+
if (authoring.created_by === 'manual-dev-source' && highTrust) {
|
|
725
|
+
issues.push('manual-dev-source assets cannot claim tested or higher quality');
|
|
726
|
+
}
|
|
727
|
+
if (highTrust && !studioCompatible.has(authoring.created_by)) {
|
|
728
|
+
issues.push(`quality_badge "${badge}" requires Studio-compatible created_by`);
|
|
729
|
+
}
|
|
730
|
+
if (highTrust && !authoring.compiler) issues.push('trusted assets require authoring.compiler');
|
|
731
|
+
if (highTrust && !authoring.compiler_version) {
|
|
732
|
+
issues.push('trusted assets require authoring.compiler_version');
|
|
733
|
+
}
|
|
734
|
+
if (highTrust && !authoring.compiled_at)
|
|
735
|
+
issues.push('trusted assets require authoring.compiled_at');
|
|
736
|
+
for (const field of ['asset_uid', 'project_uid', 'build_id', 'domain_id', 'content_digest']) {
|
|
737
|
+
if (highTrust && !authoring[field] && !manifest[field]) {
|
|
738
|
+
issues.push(`trusted assets require ${field} in authoring provenance or manifest`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (highTrust && authoring.human_confirmed !== true) {
|
|
742
|
+
issues.push('trusted assets require authoring.human_confirmed = true');
|
|
743
|
+
}
|
|
744
|
+
if (highTrust && !Number.isInteger(authoring.human_lock_count)) {
|
|
745
|
+
issues.push('trusted assets require authoring.human_lock_count');
|
|
746
|
+
}
|
|
747
|
+
if (highTrust && Number.isInteger(authoring.human_lock_count) && authoring.human_lock_count < 1) {
|
|
748
|
+
issues.push('trusted assets require at least one Human Lock');
|
|
749
|
+
}
|
|
750
|
+
return issues;
|
|
751
|
+
}
|
|
752
|
+
|
|
846
753
|
module.exports = {
|
|
847
754
|
cmdPublishCheck,
|
|
848
755
|
cmdPublish,
|
|
849
756
|
checkHumanLock,
|
|
850
757
|
canonicalPayload,
|
|
851
758
|
publicKeyToScopeFormat,
|
|
759
|
+
validateAuthoringProvenance,
|
|
852
760
|
};
|
package/src/registry.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
const fs = require('fs');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
15
16
|
const { execFileSync } = require('child_process');
|
|
16
17
|
|
|
17
18
|
const USER_KDNA_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.kdna');
|
|
@@ -81,12 +82,75 @@ function registryTrustIssues(registry, { now = new Date() } = {}) {
|
|
|
81
82
|
return issues;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function
|
|
85
|
-
|
|
85
|
+
function verifyRegistrySignature(registry, rawPayload) {
|
|
86
|
+
const trust = registry?.trust;
|
|
87
|
+
if (!trust) return { verified: false, error: 'No trust metadata in registry' };
|
|
88
|
+
|
|
89
|
+
const rootKeys = (trust.root?.keys || []).filter((k) => k.scheme === 'ed25519');
|
|
90
|
+
if (rootKeys.length === 0)
|
|
91
|
+
return { verified: false, error: 'No Ed25519 root keys in trust metadata' };
|
|
92
|
+
|
|
93
|
+
// Check if the registry has a signature file
|
|
94
|
+
const sigUrl = CANONICAL_REGISTRY_URL.replace(/\.json$/, '.sig');
|
|
95
|
+
let signature;
|
|
96
|
+
try {
|
|
97
|
+
const sigResult = execFileSync('curl', ['-sL', '--max-time', '10', sigUrl], {
|
|
98
|
+
encoding: 'utf8',
|
|
99
|
+
timeout: 15000,
|
|
100
|
+
});
|
|
101
|
+
signature = sigResult.trim();
|
|
102
|
+
} catch {
|
|
103
|
+
// .sig file may not exist yet (pre-signing transition)
|
|
104
|
+
return { verified: false, error: 'No registry signature file found' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!rawPayload) {
|
|
108
|
+
try {
|
|
109
|
+
rawPayload = execFileSync('curl', ['-sL', '--max-time', '10', CANONICAL_REGISTRY_URL], {
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
timeout: 15000,
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
return { verified: false, error: 'Cannot fetch registry for verification' };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const key of rootKeys) {
|
|
119
|
+
try {
|
|
120
|
+
if (
|
|
121
|
+
crypto.verify(
|
|
122
|
+
null,
|
|
123
|
+
Buffer.from(rawPayload),
|
|
124
|
+
crypto.createPublicKey(key.pubkey),
|
|
125
|
+
Buffer.from(signature, 'hex'),
|
|
126
|
+
)
|
|
127
|
+
) {
|
|
128
|
+
return { verified: true, keyid: key.keyid };
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
/* try next key */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { verified: false, error: 'Signature verification failed against all root keys' };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function checkRegistryRevocations(registry, scope) {
|
|
139
|
+
const revocations = registry?.trust?.revocations || [];
|
|
140
|
+
const scopeKey = scope?.trust_pubkey || '';
|
|
141
|
+
if (!scopeKey) return [];
|
|
142
|
+
|
|
143
|
+
const active = [];
|
|
144
|
+
for (const r of revocations) {
|
|
145
|
+
if (r.scope && r.scope !== scopeKey) continue;
|
|
146
|
+
if (r.expires_at && new Date(r.expires_at) < new Date()) continue;
|
|
147
|
+
active.push(r);
|
|
148
|
+
}
|
|
149
|
+
return active;
|
|
86
150
|
}
|
|
87
151
|
|
|
88
152
|
function isEntryRevoked(registry, entry) {
|
|
89
|
-
const revocations =
|
|
153
|
+
const revocations = checkRegistryRevocations(registry, entry);
|
|
90
154
|
return (
|
|
91
155
|
revocations.find((rev) => {
|
|
92
156
|
if (rev.name && rev.name !== entry.name) return false;
|
|
@@ -220,6 +284,12 @@ class RegistryResolver {
|
|
|
220
284
|
`Registry trust check failed:\n${trustIssues.map((i) => `- ${i}`).join('\n')}`,
|
|
221
285
|
);
|
|
222
286
|
}
|
|
287
|
+
// Verify cryptographic signature
|
|
288
|
+
const sigResult = verifyRegistrySignature(data);
|
|
289
|
+
if (sigResult.error) {
|
|
290
|
+
// Non-fatal: allow operation but log warning (transitional)
|
|
291
|
+
console.error(`Warning: Registry signature verification: ${sigResult.error}`);
|
|
292
|
+
}
|
|
223
293
|
this._registries.set(scopeName, data);
|
|
224
294
|
return data;
|
|
225
295
|
}
|
package/src/signature.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Ed25519 signing and verification — registry, revocation, key management
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
function signPayload(payload, privateKeyPem) {
|
|
8
|
+
if (typeof payload === 'string') payload = Buffer.from(payload, 'utf8');
|
|
9
|
+
const sig = crypto.sign(null, payload, privateKeyPem);
|
|
10
|
+
return `ed25519:${sig.toString('hex')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function verifyPayload(payload, signature, publicKeyPem) {
|
|
14
|
+
if (typeof payload === 'string') payload = Buffer.from(payload, 'utf8');
|
|
15
|
+
if (typeof signature === 'string' && signature.startsWith('ed25519:')) {
|
|
16
|
+
signature = Buffer.from(signature.slice(8), 'hex');
|
|
17
|
+
}
|
|
18
|
+
return crypto.verify(null, payload, publicKeyPem, signature);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadIdentityKeys() {
|
|
22
|
+
const identityDir = process.env.KDNA_IDENTITY_DIR || path.join(os.homedir(), '.kdna', 'identity');
|
|
23
|
+
const privateKeyPath = path.join(identityDir, 'kdna.key');
|
|
24
|
+
const publicKeyPath = path.join(identityDir, 'kdna.pub');
|
|
25
|
+
if (!fs.existsSync(privateKeyPath) || !fs.existsSync(publicKeyPath)) {
|
|
26
|
+
throw new Error('No identity found. Run: kdna identity init --name "Your Name"');
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
privatePem: fs.readFileSync(privateKeyPath, 'utf8'),
|
|
30
|
+
publicPem: fs.readFileSync(publicKeyPath, 'utf8'),
|
|
31
|
+
publicKeyPem: fs.readFileSync(publicKeyPath, 'utf8'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function computeFingerprint(publicKeyPem) {
|
|
36
|
+
return `ed25519:${crypto.createHash('sha256').update(Buffer.from(publicKeyPem)).digest('hex').slice(0, 12)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { signPayload, verifyPayload, loadIdentityKeys, computeFingerprint };
|
package/src/verify.js
CHANGED
|
@@ -491,6 +491,84 @@ function checkJudgment(input, options = {}) {
|
|
|
491
491
|
issues.push({ severity: 'error', msg: 'kdna.json missing required field: judgment_version' });
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
+
// 7. Authoring provenance gate for trusted quality claims.
|
|
495
|
+
const badgeRank = {
|
|
496
|
+
untested: 0,
|
|
497
|
+
tested: 1,
|
|
498
|
+
validated: 2,
|
|
499
|
+
expert_reviewed: 3,
|
|
500
|
+
production_ready: 4,
|
|
501
|
+
};
|
|
502
|
+
const badge = manifest?.quality_badge || 'untested';
|
|
503
|
+
const highTrust = (badgeRank[badge] || 0) >= badgeRank.tested;
|
|
504
|
+
const authoring = manifest?.authoring;
|
|
505
|
+
const studioCompatible = new Set([
|
|
506
|
+
'kdna-studio',
|
|
507
|
+
'kdna-studio-cli',
|
|
508
|
+
'kdna-studio-sdk',
|
|
509
|
+
'third-party-studio-compatible',
|
|
510
|
+
]);
|
|
511
|
+
if (highTrust) {
|
|
512
|
+
if (!authoring) {
|
|
513
|
+
score.max += 2;
|
|
514
|
+
issues.push({
|
|
515
|
+
severity: 'error',
|
|
516
|
+
msg: `quality_badge ${badge} requires authoring provenance`,
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
const okSource = studioCompatible.has(authoring.created_by);
|
|
520
|
+
bump(1, okSource ? 1 : 0, `authoring.created_by: ${authoring.created_by || '?'}`);
|
|
521
|
+
if (!okSource) {
|
|
522
|
+
issues.push({
|
|
523
|
+
severity: 'error',
|
|
524
|
+
msg: 'trusted quality requires Studio-compatible authoring.created_by',
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
const hasCompiler = !!(
|
|
528
|
+
authoring.compiler &&
|
|
529
|
+
authoring.compiler_version &&
|
|
530
|
+
authoring.compiled_at
|
|
531
|
+
);
|
|
532
|
+
bump(1, hasCompiler ? 1 : 0, 'authoring compiler metadata present');
|
|
533
|
+
if (!hasCompiler) {
|
|
534
|
+
issues.push({
|
|
535
|
+
severity: 'error',
|
|
536
|
+
msg: 'trusted quality requires compiler, compiler_version, and compiled_at',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const hasIdentity = [
|
|
540
|
+
'asset_uid',
|
|
541
|
+
'project_uid',
|
|
542
|
+
'build_id',
|
|
543
|
+
'domain_id',
|
|
544
|
+
'content_digest',
|
|
545
|
+
].every((field) => !!(authoring[field] || manifest[field]));
|
|
546
|
+
bump(1, hasIdentity ? 1 : 0, 'authoring asset identity present');
|
|
547
|
+
if (!hasIdentity) {
|
|
548
|
+
issues.push({
|
|
549
|
+
severity: 'error',
|
|
550
|
+
msg: 'trusted quality requires asset_uid, project_uid, build_id, domain_id, and content_digest',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
const humanConfirmed =
|
|
554
|
+
authoring.human_confirmed === true && Number(authoring.human_lock_count) > 0;
|
|
555
|
+
bump(1, humanConfirmed ? 1 : 0, `Human Lock provenance (${authoring.human_lock_count || 0})`);
|
|
556
|
+
if (!humanConfirmed) {
|
|
557
|
+
issues.push({
|
|
558
|
+
severity: 'error',
|
|
559
|
+
msg: 'trusted quality requires human_confirmed=true and human_lock_count > 0',
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
} else if (!authoring) {
|
|
564
|
+
issues.push({
|
|
565
|
+
severity: 'warn',
|
|
566
|
+
msg: 'authoring provenance missing; asset cannot be promoted above untested',
|
|
567
|
+
});
|
|
568
|
+
} else if (authoring.created_by === 'manual-dev-source') {
|
|
569
|
+
passed.push('authoring provenance: manual-dev-source (untested ceiling)');
|
|
570
|
+
}
|
|
571
|
+
|
|
494
572
|
return { layer: 'judgment', issues, passed, score };
|
|
495
573
|
}
|
|
496
574
|
|
|
@@ -29,4 +29,4 @@ This copies `templates/cluster/` and `templates/minimal-domain/` into a new dire
|
|
|
29
29
|
|
|
30
30
|
## Adding sub-domains
|
|
31
31
|
|
|
32
|
-
Run `kdna
|
|
32
|
+
Run `kdna dev scaffold <cluster>/<sub_domain>` inside the cluster root (or copy `templates/minimal-domain/`). This creates a non-canonical dev source workspace; trusted `.kdna` output still requires Studio-compatible compile/export.
|
|
@@ -45,7 +45,8 @@ kdna verify ./.
|
|
|
45
45
|
|
|
46
46
|
# 7. Publish
|
|
47
47
|
KDNA_IDENTITY_DIR=~/.kdna/identity-official \
|
|
48
|
-
|
|
48
|
+
# Trusted publish starts from a Studio-compiled .kdna asset.
|
|
49
|
+
kdna publish ./dist/your-domain.kdna \
|
|
49
50
|
--release-tag v0.1.0 \
|
|
50
51
|
--repo yourname/kdna-<your_domain_id>
|
|
51
52
|
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Your Name",
|
|
20
20
|
"id": "your-id",
|
|
21
|
-
"pubkey": "[set
|
|
21
|
+
"pubkey": "[set by Studio export/signing — must match your scope trust_pubkey]"
|
|
22
22
|
},
|
|
23
23
|
"license": {
|
|
24
24
|
"type": "CC-BY-4.0",
|