multimodel-dev-os 3.1.0 → 3.5.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/.ai/policies/registry-policy.yaml +29 -1
- package/.ai/registries/trusted-keys.yaml +12 -0
- package/.ai/schema/registry-manifest.schema.json +31 -2
- package/.ai/schema/registry-policy.schema.json +37 -1
- package/.ai/schema/trusted-keys.schema.json +69 -0
- package/AGENTS.md +22 -26
- package/MEMORY.md +34 -11
- package/README.md +2 -1
- package/RUNBOOK.md +28 -36
- package/TASKS.md +15 -5
- package/bin/multimodel-dev-os.js +1366 -548
- package/docs/.vitepress/config.js +3 -1
- package/docs/architecture.md +3 -1
- package/docs/index.md +5 -5
- package/docs/npm-publishing.md +5 -5
- package/docs/package-safety.md +17 -0
- package/docs/public/llms-full.txt +5 -1
- package/docs/public/llms.txt +6 -1
- package/docs/public/sitemap.xml +15 -0
- package/docs/registry-policy.md +29 -1
- package/docs/registry-security.md +73 -6
- package/docs/registry-signing.md +70 -0
- package/docs/registry-sync.md +5 -2
- package/docs/registry-trust-store.md +66 -0
- package/docs/release-policy.md +6 -5
- package/docs/security-threat-model.md +96 -0
- package/docs/testing.md +25 -2
- package/docs/trusted-registries.md +1 -1
- package/docs/v3-roadmap.md +17 -6
- package/docs/v3.5.0-readiness.md +46 -0
- package/package.json +5 -2
- package/scripts/build-cli.js +45 -3
- package/scripts/check-build-fresh.js +52 -0
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/verify.js +327 -14
- package/scripts/verify.sh +10 -0
- package/src/catalog/loader.js +117 -0
- package/src/cli/args.js +118 -0
- package/src/cli/help.js +60 -0
- package/src/cli/main.js +6263 -0
- package/src/core/globals.js +52 -0
- package/src/core/hashes.js +15 -0
- package/src/core/policy.js +44 -0
- package/src/core/security.js +61 -0
- package/src/core/yaml.js +136 -0
- package/src/plugin/manifest.js +95 -0
- package/src/registry/provenance.js +114 -0
- package/src/registry/signing.js +392 -0
- package/src/registry/sources.js +40 -0
- package/src/registry/trust-store.js +41 -0
- package/src/registry/validation.js +45 -0
- package/src/registry/verdict.js +51 -0
- package/tests/README.md +37 -0
- package/tests/fixtures/README.md +22 -0
- package/tests/fixtures/custom-template-example/README.md +10 -0
- package/tests/fixtures/proposals/approved-append-line.md +28 -0
- package/tests/fixtures/proposals/approved-create-file.md +29 -0
- package/tests/fixtures/proposals/approved-replace-text.md +30 -0
- package/tests/fixtures/proposals/existing-create-file-no-overwrite.md +29 -0
- package/tests/fixtures/proposals/no-operations.md +18 -0
- package/tests/fixtures/proposals/path-traversal.md +29 -0
- package/tests/fixtures/proposals/pending-proposal.md +29 -0
- package/tests/fixtures/proposals/protected-path.md +29 -0
- package/tests/fixtures/proposals/replace-multiple-without-allow.md +30 -0
- package/tests/fixtures/registry-overrides/README.md +20 -0
- package/tests/fixtures/signed-registries/README.md +4 -0
- package/tests/fixtures/signed-registries/revoked-key/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/revoked-key/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/revoked-key/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/tampered-manifest/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/tampered-manifest/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/tampered-manifest/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/trusted-keys.yaml +23 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/unsigned-remote-required/registry-manifest.yaml +9 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/unsupported-algorithm/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/valid-signed-registry/registry-manifest.yaml +14 -0
- package/tests/fixtures/signed-registries/wrong-key/catalog.yaml +8 -0
- package/tests/fixtures/signed-registries/wrong-key/expected-verdict.json +7 -0
- package/tests/fixtures/signed-registries/wrong-key/registry-manifest.yaml +14 -0
- package/tests/smoke/README.md +37 -0
- package/tests/smoke/cli-smoke.md +49 -0
- package/tests/unit/build-output.test.js +40 -0
- package/tests/unit/catalog-loader.test.js +44 -0
- package/tests/unit/path-safety.test.js +62 -0
- package/tests/unit/plugin-manifest.test.js +94 -0
- package/tests/unit/prepublish-guard.test.js +35 -0
- package/tests/unit/registry-e2e-signature-fixtures.test.js +288 -0
- package/tests/unit/registry-policy.test.js +52 -0
- package/tests/unit/registry-provenance.test.js +185 -0
- package/tests/unit/registry-public-signing.test.js +109 -0
- package/tests/unit/registry-signature-policy.test.js +100 -0
- package/tests/unit/registry-signing.test.js +193 -0
- package/tests/unit/registry-trust-store.test.js +133 -0
- package/tests/unit/registry-url-validation.test.js +64 -0
- package/tests/unit/yaml.test.js +92 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
loadRegistryLockfile,
|
|
6
|
+
saveRegistryLockfile,
|
|
7
|
+
updateLockfileEntry,
|
|
8
|
+
getLockfilePath
|
|
9
|
+
} from '../../src/registry/provenance.js';
|
|
10
|
+
|
|
11
|
+
const tempDir = join(process.cwd(), 'temp-provenance-test');
|
|
12
|
+
|
|
13
|
+
describe('Registry Provenance — loadRegistryLockfile', () => {
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
mkdirSync(tempDir, { recursive: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
if (existsSync(tempDir)) {
|
|
20
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns an empty well-formed structure when no lockfile exists', () => {
|
|
25
|
+
const result = loadRegistryLockfile(tempDir);
|
|
26
|
+
expect(result).toEqual({
|
|
27
|
+
lockfile_version: '1',
|
|
28
|
+
generated_at: '',
|
|
29
|
+
entries: {}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns empty structure if lockfile JSON is malformed', () => {
|
|
34
|
+
const aiDir = join(tempDir, '.ai');
|
|
35
|
+
mkdirSync(aiDir, { recursive: true });
|
|
36
|
+
writeFileSync(join(aiDir, 'registry-lock.json'), 'NOT VALID JSON', 'utf8');
|
|
37
|
+
const result = loadRegistryLockfile(tempDir);
|
|
38
|
+
expect(result.entries).toEqual({});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns empty structure if lockfile JSON has no entries field', () => {
|
|
42
|
+
const aiDir = join(tempDir, '.ai');
|
|
43
|
+
mkdirSync(aiDir, { recursive: true });
|
|
44
|
+
writeFileSync(join(aiDir, 'registry-lock.json'), JSON.stringify({ lockfile_version: '1' }), 'utf8');
|
|
45
|
+
const result = loadRegistryLockfile(tempDir);
|
|
46
|
+
expect(result.entries).toEqual({});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
describe('Registry Provenance — updateLockfileEntry', () => {
|
|
52
|
+
it('upserts a new entry into an empty lockfile', () => {
|
|
53
|
+
const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
|
|
54
|
+
const entry = {
|
|
55
|
+
url: 'https://example.com/catalog.yaml',
|
|
56
|
+
synced_at: '2026-01-01T00:00:00.000Z',
|
|
57
|
+
catalog_sha256: 'abc123',
|
|
58
|
+
manifest_sha256: null,
|
|
59
|
+
signature: null,
|
|
60
|
+
signature_alg: 'hmac-sha256'
|
|
61
|
+
};
|
|
62
|
+
updateLockfileEntry(lockfile, 'official', entry);
|
|
63
|
+
expect(lockfile.entries['official']).toBeDefined();
|
|
64
|
+
expect(lockfile.entries['official'].url).toBe('https://example.com/catalog.yaml');
|
|
65
|
+
expect(lockfile.entries['official'].catalog_sha256).toBe('abc123');
|
|
66
|
+
expect(lockfile.entries['official'].signature).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('overwrites an existing entry for the same registry name', () => {
|
|
70
|
+
const lockfile = {
|
|
71
|
+
lockfile_version: '1',
|
|
72
|
+
generated_at: '',
|
|
73
|
+
entries: {
|
|
74
|
+
official: {
|
|
75
|
+
url: 'https://old.example.com/catalog.yaml',
|
|
76
|
+
synced_at: '2025-01-01T00:00:00.000Z',
|
|
77
|
+
catalog_sha256: 'oldhash',
|
|
78
|
+
manifest_sha256: null,
|
|
79
|
+
signature: null,
|
|
80
|
+
signature_alg: 'hmac-sha256'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
updateLockfileEntry(lockfile, 'official', {
|
|
85
|
+
url: 'https://new.example.com/catalog.yaml',
|
|
86
|
+
synced_at: '2026-06-01T00:00:00.000Z',
|
|
87
|
+
catalog_sha256: 'newhash',
|
|
88
|
+
manifest_sha256: 'mhash',
|
|
89
|
+
signature: 'sig123',
|
|
90
|
+
signature_alg: 'hmac-sha256'
|
|
91
|
+
});
|
|
92
|
+
expect(lockfile.entries['official'].catalog_sha256).toBe('newhash');
|
|
93
|
+
expect(lockfile.entries['official'].signature).toBe('sig123');
|
|
94
|
+
expect(lockfile.entries['official'].url).toBe('https://new.example.com/catalog.yaml');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('stores null for optional fields when not provided', () => {
|
|
98
|
+
const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
|
|
99
|
+
updateLockfileEntry(lockfile, 'test', {
|
|
100
|
+
url: 'https://example.com/catalog.yaml',
|
|
101
|
+
catalog_sha256: 'hash1'
|
|
102
|
+
});
|
|
103
|
+
expect(lockfile.entries['test'].manifest_sha256).toBeNull();
|
|
104
|
+
expect(lockfile.entries['test'].signature).toBeNull();
|
|
105
|
+
expect(lockfile.entries['test'].signature_alg).toBe('hmac-sha256');
|
|
106
|
+
expect(lockfile.entries['test'].public_signature_status).toBeNull();
|
|
107
|
+
expect(lockfile.entries['test'].public_signature_algorithm).toBeNull();
|
|
108
|
+
expect(lockfile.entries['test'].public_signature_key_id).toBeNull();
|
|
109
|
+
expect(lockfile.entries['test'].trusted_publisher_status).toBeNull();
|
|
110
|
+
expect(lockfile.entries['test'].trust_store_path).toBeNull();
|
|
111
|
+
expect(lockfile.entries['test'].trust_verdict).toBeNull();
|
|
112
|
+
expect(lockfile.entries['test'].lockfile_verdict).toBeNull();
|
|
113
|
+
expect(lockfile.entries['test'].verification_errors).toEqual([]);
|
|
114
|
+
expect(lockfile.entries['test'].verification_warnings).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('initialises entries object if missing from lockfile', () => {
|
|
118
|
+
const lockfile = { lockfile_version: '1', generated_at: '' };
|
|
119
|
+
updateLockfileEntry(lockfile, 'test', {
|
|
120
|
+
url: 'https://example.com/catalog.yaml',
|
|
121
|
+
catalog_sha256: 'hash1'
|
|
122
|
+
});
|
|
123
|
+
expect(lockfile.entries).toBeDefined();
|
|
124
|
+
expect(lockfile.entries['test']).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Registry Provenance — saveRegistryLockfile + round-trip', () => {
|
|
129
|
+
const roundTripDir = join(process.cwd(), 'temp-provenance-roundtrip');
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
mkdirSync(roundTripDir, { recursive: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterAll(() => {
|
|
136
|
+
if (existsSync(roundTripDir)) {
|
|
137
|
+
rmSync(roundTripDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('writes and re-reads the lockfile with correct content', () => {
|
|
142
|
+
const lockfile = { lockfile_version: '1', generated_at: '', entries: {} };
|
|
143
|
+
updateLockfileEntry(lockfile, 'alpha', {
|
|
144
|
+
url: 'https://alpha.example.com/catalog.yaml',
|
|
145
|
+
synced_at: '2026-06-01T10:00:00.000Z',
|
|
146
|
+
catalog_sha256: 'deadbeef',
|
|
147
|
+
manifest_sha256: 'cafe1234',
|
|
148
|
+
signature: 'sig_hex_abc',
|
|
149
|
+
signature_alg: 'hmac-sha256'
|
|
150
|
+
});
|
|
151
|
+
saveRegistryLockfile(roundTripDir, lockfile);
|
|
152
|
+
|
|
153
|
+
const reloaded = loadRegistryLockfile(roundTripDir);
|
|
154
|
+
expect(reloaded.lockfile_version).toBe('1');
|
|
155
|
+
expect(reloaded.generated_at).toBeTruthy();
|
|
156
|
+
expect(reloaded.entries['alpha'].catalog_sha256).toBe('deadbeef');
|
|
157
|
+
expect(reloaded.entries['alpha'].signature).toBe('sig_hex_abc');
|
|
158
|
+
expect(reloaded.entries['alpha'].manifest_sha256).toBe('cafe1234');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('getLockfilePath returns the expected path', () => {
|
|
162
|
+
const expectedPath = join(roundTripDir, '.ai', 'registry-lock.json');
|
|
163
|
+
expect(getLockfilePath(roundTripDir)).toBe(expectedPath);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('round-trip is idempotent for multiple saves', () => {
|
|
167
|
+
const lockfile1 = { lockfile_version: '1', generated_at: '', entries: {} };
|
|
168
|
+
updateLockfileEntry(lockfile1, 'beta', {
|
|
169
|
+
url: 'https://beta.example.com/catalog.yaml',
|
|
170
|
+
catalog_sha256: 'hash_beta'
|
|
171
|
+
});
|
|
172
|
+
saveRegistryLockfile(roundTripDir, lockfile1);
|
|
173
|
+
|
|
174
|
+
const lockfile2 = loadRegistryLockfile(roundTripDir);
|
|
175
|
+
updateLockfileEntry(lockfile2, 'gamma', {
|
|
176
|
+
url: 'https://gamma.example.com/catalog.yaml',
|
|
177
|
+
catalog_sha256: 'hash_gamma'
|
|
178
|
+
});
|
|
179
|
+
saveRegistryLockfile(roundTripDir, lockfile2);
|
|
180
|
+
|
|
181
|
+
const reloaded = loadRegistryLockfile(roundTripDir);
|
|
182
|
+
expect(reloaded.entries['beta'].catalog_sha256).toBe('hash_beta');
|
|
183
|
+
expect(reloaded.entries['gamma'].catalog_sha256).toBe('hash_gamma');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
generateEd25519KeyPair,
|
|
4
|
+
signEd25519Payload,
|
|
5
|
+
verifyEd25519Payload,
|
|
6
|
+
createCanonicalPayload,
|
|
7
|
+
normalizePublicKey
|
|
8
|
+
} from '../../src/registry/signing.js';
|
|
9
|
+
|
|
10
|
+
describe('Registry Public Signing — Ed25519 Key Generation', () => {
|
|
11
|
+
it('generates valid PEM format keys', () => {
|
|
12
|
+
const { publicKey, privateKey } = generateEd25519KeyPair();
|
|
13
|
+
expect(publicKey).toContain('-----BEGIN PUBLIC KEY-----');
|
|
14
|
+
expect(publicKey).toContain('-----END PUBLIC KEY-----');
|
|
15
|
+
expect(privateKey).toContain('-----BEGIN PRIVATE KEY-----');
|
|
16
|
+
expect(privateKey).toContain('-----END PRIVATE KEY-----');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Registry Public Signing — Ed25519 Sign/Verify', () => {
|
|
21
|
+
it('signs and verifies a payload successfully', () => {
|
|
22
|
+
const { publicKey, privateKey } = generateEd25519KeyPair();
|
|
23
|
+
const payload = 'test-canonical-payload-data';
|
|
24
|
+
const signature = signEd25519Payload(privateKey, payload);
|
|
25
|
+
|
|
26
|
+
expect(signature).toBeTruthy();
|
|
27
|
+
expect(typeof signature).toBe('string');
|
|
28
|
+
// Base64 signature check
|
|
29
|
+
expect(signature).toMatch(/^[a-zA-Z0-9+/=]+$/);
|
|
30
|
+
|
|
31
|
+
const verified = verifyEd25519Payload(publicKey, payload, signature);
|
|
32
|
+
expect(verified).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('fails verification for a tampered payload', () => {
|
|
36
|
+
const { publicKey, privateKey } = generateEd25519KeyPair();
|
|
37
|
+
const payload = 'original-payload';
|
|
38
|
+
const signature = signEd25519Payload(privateKey, payload);
|
|
39
|
+
|
|
40
|
+
const verified = verifyEd25519Payload(publicKey, 'tampered-payload', signature);
|
|
41
|
+
expect(verified).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('fails verification for an invalid signature', () => {
|
|
45
|
+
const { publicKey } = generateEd25519KeyPair();
|
|
46
|
+
const verified = verifyEd25519Payload(publicKey, 'payload', 'not-a-valid-base64-signature!');
|
|
47
|
+
expect(verified).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('fails verification with a different key', () => {
|
|
51
|
+
const pairA = generateEd25519KeyPair();
|
|
52
|
+
const pairB = generateEd25519KeyPair();
|
|
53
|
+
const payload = 'some-payload';
|
|
54
|
+
const signature = signEd25519Payload(pairA.privateKey, payload);
|
|
55
|
+
|
|
56
|
+
const verified = verifyEd25519Payload(pairB.publicKey, payload, signature);
|
|
57
|
+
expect(verified).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Registry Public Signing — Canonical Payload Determinism', () => {
|
|
62
|
+
it('generates identical payload regardless of key insertion order', () => {
|
|
63
|
+
const obj1 = { name: 'registry-a', version: '1.0.0', catalog_hash: 'hash123' };
|
|
64
|
+
const obj2 = { catalog_hash: 'hash123', name: 'registry-a', version: '1.0.0' };
|
|
65
|
+
const fields = ['name', 'version', 'catalog_hash'];
|
|
66
|
+
|
|
67
|
+
const payload1 = createCanonicalPayload(obj1, fields);
|
|
68
|
+
const payload2 = createCanonicalPayload(obj2, fields);
|
|
69
|
+
|
|
70
|
+
expect(payload1).toBe(payload2);
|
|
71
|
+
// Keys sorted alphabetically: catalog_hash, name, version
|
|
72
|
+
const parsed = JSON.parse(payload1);
|
|
73
|
+
const keys = Object.keys(parsed);
|
|
74
|
+
expect(keys).toEqual(['catalog_hash', 'name', 'version']);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('handles nested objects deterministically by sorting their keys', () => {
|
|
78
|
+
const obj1 = {
|
|
79
|
+
name: 'registry-b',
|
|
80
|
+
metadata: { z: 3, a: 1, m: 2 }
|
|
81
|
+
};
|
|
82
|
+
const obj2 = {
|
|
83
|
+
metadata: { a: 1, m: 2, z: 3 },
|
|
84
|
+
name: 'registry-b'
|
|
85
|
+
};
|
|
86
|
+
const fields = ['name', 'metadata'];
|
|
87
|
+
|
|
88
|
+
const payload1 = createCanonicalPayload(obj1, fields);
|
|
89
|
+
const payload2 = createCanonicalPayload(obj2, fields);
|
|
90
|
+
|
|
91
|
+
expect(payload1).toBe(payload2);
|
|
92
|
+
expect(payload1).toContain('"metadata":{"a":1,"m":2,"z":3}');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('Registry Public Signing — normalizePublicKey', () => {
|
|
97
|
+
it('keeps PEM headers intact if already present', () => {
|
|
98
|
+
const raw = '-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA9vWwyE5+fY0dvEzl9S1UcvtoMkOAIDhDCzZAkP+CVNo=\n-----END PUBLIC KEY-----';
|
|
99
|
+
expect(normalizePublicKey(raw)).toBe(raw);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('wraps a raw base64 string into PEM format', () => {
|
|
103
|
+
const raw = 'MCowBQYDK2VwAyEA9vWwyE5+fY0dvEzl9S1UcvtoMkOAIDhDCzZAkP+CVNo=';
|
|
104
|
+
const normalized = normalizePublicKey(raw);
|
|
105
|
+
expect(normalized).toContain('-----BEGIN PUBLIC KEY-----');
|
|
106
|
+
expect(normalized).toContain('-----END PUBLIC KEY-----');
|
|
107
|
+
expect(normalized.replace(/\s+/g, '')).toContain(raw);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { verifySignatureBlock } from '../../src/registry/signing.js';
|
|
3
|
+
|
|
4
|
+
describe('Registry Signature Policy — Unsigned Registries', () => {
|
|
5
|
+
const manifest = {
|
|
6
|
+
registry_name: 'test-registry',
|
|
7
|
+
publisher: 'Test Publisher',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
catalog_hash: 'sha256:abc'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
it('allows unsigned bundled registry by default', () => {
|
|
13
|
+
const policy = {
|
|
14
|
+
allow_unsigned_bundled: true,
|
|
15
|
+
allow_unsigned_local: true,
|
|
16
|
+
allow_unsigned_remote: false,
|
|
17
|
+
require_signature: false
|
|
18
|
+
};
|
|
19
|
+
const source = { name: 'bundled', type: 'local' };
|
|
20
|
+
const res = verifySignatureBlock({ manifest, policy, source });
|
|
21
|
+
expect(res.verified).toBe(true);
|
|
22
|
+
expect(res.status).toBe('unsigned');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('allows unsigned local registry by default', () => {
|
|
26
|
+
const policy = {
|
|
27
|
+
allow_unsigned_bundled: true,
|
|
28
|
+
allow_unsigned_local: true,
|
|
29
|
+
allow_unsigned_remote: false,
|
|
30
|
+
require_signature: false
|
|
31
|
+
};
|
|
32
|
+
const source = { name: 'my-local-registry', type: 'local' };
|
|
33
|
+
const res = verifySignatureBlock({ manifest, policy, source });
|
|
34
|
+
expect(res.verified).toBe(true);
|
|
35
|
+
expect(res.status).toBe('unsigned');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('blocks unsigned remote registry by default if allow_unsigned_remote is false', () => {
|
|
39
|
+
const policy = {
|
|
40
|
+
allow_unsigned_bundled: true,
|
|
41
|
+
allow_unsigned_local: true,
|
|
42
|
+
allow_unsigned_remote: false,
|
|
43
|
+
require_signature: false
|
|
44
|
+
};
|
|
45
|
+
const source = { name: 'official', type: 'remote' };
|
|
46
|
+
const res = verifySignatureBlock({ manifest, policy, source });
|
|
47
|
+
expect(res.verified).toBe(false);
|
|
48
|
+
expect(res.error).toContain('Unsigned remote registries are not allowed');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('allows unsigned remote registry if explicitly permitted by policy', () => {
|
|
52
|
+
const policy = {
|
|
53
|
+
allow_unsigned_bundled: true,
|
|
54
|
+
allow_unsigned_local: true,
|
|
55
|
+
allow_unsigned_remote: true,
|
|
56
|
+
require_signature: false
|
|
57
|
+
};
|
|
58
|
+
const source = { name: 'official', type: 'remote' };
|
|
59
|
+
const res = verifySignatureBlock({ manifest, policy, source });
|
|
60
|
+
expect(res.verified).toBe(true);
|
|
61
|
+
expect(res.status).toBe('unsigned');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('blocks unsigned registry if require_signature is true', () => {
|
|
65
|
+
const policy = {
|
|
66
|
+
allow_unsigned_bundled: true,
|
|
67
|
+
allow_unsigned_local: true,
|
|
68
|
+
allow_unsigned_remote: true,
|
|
69
|
+
require_signature: true
|
|
70
|
+
};
|
|
71
|
+
const source = { name: 'bundled', type: 'local' };
|
|
72
|
+
const res = verifySignatureBlock({ manifest, policy, source });
|
|
73
|
+
expect(res.verified).toBe(false);
|
|
74
|
+
expect(res.error).toContain('Signature is required by policy but missing');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('Registry Signature Policy — Allowed Algorithms', () => {
|
|
79
|
+
const manifest = {
|
|
80
|
+
registry_name: 'test-registry',
|
|
81
|
+
publisher: 'Test Publisher',
|
|
82
|
+
version: '1.0.0',
|
|
83
|
+
catalog_hash: 'sha256:abc',
|
|
84
|
+
signature: {
|
|
85
|
+
algorithm: 'rsa-sha256', // Unsupported / not allowed algorithm
|
|
86
|
+
key_id: 'active-key',
|
|
87
|
+
signature: 'sig',
|
|
88
|
+
signed_fields: ['registry_name', 'version']
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
it('fails verification if algorithm is not allowed', () => {
|
|
93
|
+
const policy = {
|
|
94
|
+
allowed_signature_algorithms: ['ed25519', 'hmac-sha256']
|
|
95
|
+
};
|
|
96
|
+
const res = verifySignatureBlock({ manifest, policy });
|
|
97
|
+
expect(res.verified).toBe(false);
|
|
98
|
+
expect(res.errors[0]).toContain('not allowed by policy');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import {
|
|
5
|
+
loadSigningKey,
|
|
6
|
+
generateSigningKey,
|
|
7
|
+
saveSigningKey,
|
|
8
|
+
signPayload,
|
|
9
|
+
verifySignature,
|
|
10
|
+
getSigningKeyPath
|
|
11
|
+
} from '../../src/registry/signing.js';
|
|
12
|
+
|
|
13
|
+
const tempDir = join(process.cwd(), 'temp-signing-test');
|
|
14
|
+
|
|
15
|
+
describe('Registry Signing — loadSigningKey', () => {
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
mkdirSync(join(tempDir, '.ai'), { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
if (existsSync(tempDir)) {
|
|
22
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null when no signing key file exists', () => {
|
|
27
|
+
const noKeyDir = join(tempDir, 'no-key-dir');
|
|
28
|
+
mkdirSync(noKeyDir, { recursive: true });
|
|
29
|
+
const result = loadSigningKey(noKeyDir);
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns the key string when a valid key file exists', () => {
|
|
34
|
+
const validKey = 'a'.repeat(64);
|
|
35
|
+
writeFileSync(join(tempDir, '.ai', 'registry-signing-key'), validKey + '\n', 'utf8');
|
|
36
|
+
const result = loadSigningKey(tempDir);
|
|
37
|
+
expect(result).toBe(validKey);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws if the key file contains a malformed key', () => {
|
|
41
|
+
const badKeyDir = join(tempDir, 'bad-key-dir');
|
|
42
|
+
mkdirSync(join(badKeyDir, '.ai'), { recursive: true });
|
|
43
|
+
writeFileSync(join(badKeyDir, '.ai', 'registry-signing-key'), 'too-short', 'utf8');
|
|
44
|
+
expect(() => loadSigningKey(badKeyDir)).toThrow('malformed');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws if the key file contains wrong-length hex', () => {
|
|
48
|
+
const badKeyDir2 = join(tempDir, 'bad-key-dir2');
|
|
49
|
+
mkdirSync(join(badKeyDir2, '.ai'), { recursive: true });
|
|
50
|
+
// 63 hex chars — one short
|
|
51
|
+
writeFileSync(join(badKeyDir2, '.ai', 'registry-signing-key'), 'a'.repeat(63), 'utf8');
|
|
52
|
+
expect(() => loadSigningKey(badKeyDir2)).toThrow('malformed');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('getSigningKeyPath returns expected path', () => {
|
|
56
|
+
const expected = join(tempDir, '.ai', 'registry-signing-key');
|
|
57
|
+
expect(getSigningKeyPath(tempDir)).toBe(expected);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Registry Signing — generateSigningKey + saveSigningKey', () => {
|
|
62
|
+
const genDir = join(process.cwd(), 'temp-signing-gen');
|
|
63
|
+
|
|
64
|
+
beforeAll(() => {
|
|
65
|
+
mkdirSync(join(genDir, '.ai'), { recursive: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
if (existsSync(genDir)) {
|
|
70
|
+
rmSync(genDir, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('generateSigningKey returns a 64-char lowercase hex string', () => {
|
|
75
|
+
const key = generateSigningKey();
|
|
76
|
+
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('generateSigningKey returns different values each call (random)', () => {
|
|
80
|
+
const key1 = generateSigningKey();
|
|
81
|
+
const key2 = generateSigningKey();
|
|
82
|
+
expect(key1).not.toBe(key2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('saveSigningKey writes a valid key that loadSigningKey can read back', () => {
|
|
86
|
+
const key = generateSigningKey();
|
|
87
|
+
saveSigningKey(genDir, key);
|
|
88
|
+
const loaded = loadSigningKey(genDir);
|
|
89
|
+
expect(loaded).toBe(key);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('saveSigningKey creates .ai/ directory if it does not exist', () => {
|
|
93
|
+
const freshDir = join(process.cwd(), 'temp-signing-fresh');
|
|
94
|
+
try {
|
|
95
|
+
const key = generateSigningKey();
|
|
96
|
+
saveSigningKey(freshDir, key);
|
|
97
|
+
expect(existsSync(join(freshDir, '.ai', 'registry-signing-key'))).toBe(true);
|
|
98
|
+
const loaded = loadSigningKey(freshDir);
|
|
99
|
+
expect(loaded).toBe(key);
|
|
100
|
+
} finally {
|
|
101
|
+
if (existsSync(freshDir)) {
|
|
102
|
+
rmSync(freshDir, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Registry Signing — signPayload', () => {
|
|
109
|
+
const validKey = 'f'.repeat(64);
|
|
110
|
+
|
|
111
|
+
it('returns a 64-char lowercase hex HMAC-SHA256 digest', () => {
|
|
112
|
+
const sig = signPayload(validKey, 'test-payload');
|
|
113
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('produces the same output for the same key and payload (deterministic)', () => {
|
|
117
|
+
const sig1 = signPayload(validKey, 'same-payload');
|
|
118
|
+
const sig2 = signPayload(validKey, 'same-payload');
|
|
119
|
+
expect(sig1).toBe(sig2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('produces different output for different payloads', () => {
|
|
123
|
+
const sig1 = signPayload(validKey, 'payload-A');
|
|
124
|
+
const sig2 = signPayload(validKey, 'payload-B');
|
|
125
|
+
expect(sig1).not.toBe(sig2);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('produces different output for different keys', () => {
|
|
129
|
+
const key1 = 'a'.repeat(64);
|
|
130
|
+
const key2 = 'b'.repeat(64);
|
|
131
|
+
const sig1 = signPayload(key1, 'same-payload');
|
|
132
|
+
const sig2 = signPayload(key2, 'same-payload');
|
|
133
|
+
expect(sig1).not.toBe(sig2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('throws for an invalid key (non-hex)', () => {
|
|
137
|
+
expect(() => signPayload('not-a-valid-key'.padEnd(64, 'z'), 'payload')).toThrow('Invalid signing key');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('throws for an invalid key (wrong length)', () => {
|
|
141
|
+
expect(() => signPayload('abc', 'payload')).toThrow('Invalid signing key');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('throws if payload is not a string', () => {
|
|
145
|
+
expect(() => signPayload(validKey, 12345)).toThrow('Payload to sign must be a string');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('Registry Signing — verifySignature', () => {
|
|
150
|
+
const key = 'c'.repeat(64);
|
|
151
|
+
const payload = 'catalog-sha256-hash-string';
|
|
152
|
+
|
|
153
|
+
it('returns true for a valid signature', () => {
|
|
154
|
+
const sig = signPayload(key, payload);
|
|
155
|
+
expect(verifySignature(key, payload, sig)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns false for a tampered payload', () => {
|
|
159
|
+
const sig = signPayload(key, payload);
|
|
160
|
+
expect(verifySignature(key, 'tampered-payload', sig)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns false for a wrong key', () => {
|
|
164
|
+
const sig = signPayload(key, payload);
|
|
165
|
+
const wrongKey = 'd'.repeat(64);
|
|
166
|
+
expect(verifySignature(wrongKey, payload, sig)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns false for a corrupted signature', () => {
|
|
170
|
+
const sig = signPayload(key, payload);
|
|
171
|
+
// Flip first char
|
|
172
|
+
const corruptedSig = (sig[0] === 'a' ? 'b' : 'a') + sig.slice(1);
|
|
173
|
+
expect(verifySignature(key, payload, corruptedSig)).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('returns false for non-string arguments', () => {
|
|
177
|
+
expect(verifySignature(null, payload, 'sig')).toBe(false);
|
|
178
|
+
expect(verifySignature(key, null, 'sig')).toBe(false);
|
|
179
|
+
expect(verifySignature(key, payload, null)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns false for mismatched-length signature', () => {
|
|
183
|
+
expect(verifySignature(key, payload, 'abc')).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('uses timing-safe comparison — does not short-circuit on length match', () => {
|
|
187
|
+
// We can't test timing directly, but we verify the correctness of the logic:
|
|
188
|
+
// two sigs of equal length but different values must still return false
|
|
189
|
+
const sig = signPayload(key, payload);
|
|
190
|
+
const fakeMatchLength = 'e'.repeat(64); // Same 64-char hex length, wrong value
|
|
191
|
+
expect(verifySignature(key, payload, fakeMatchLength)).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|