multimodel-dev-os 3.2.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 +1 -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 +3 -2
- package/docs/public/llms-full.txt +5 -1
- package/docs/public/llms.txt +6 -1
- package/docs/public/sitemap.xml +10 -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 +1 -1
- package/docs/security-threat-model.md +96 -0
- package/docs/testing.md +15 -2
- package/docs/trusted-registries.md +1 -1
- package/docs/v3-roadmap.md +11 -6
- package/docs/v3.5.0-readiness.md +46 -0
- package/package.json +1 -1
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/verify.js +206 -9
- package/src/cli/help.js +1 -1
- package/src/cli/main.js +626 -81
- package/src/core/policy.js +9 -1
- package/src/registry/provenance.js +114 -0
- package/src/registry/signing.js +392 -0
- package/src/registry/trust-store.js +41 -0
- package/src/registry/verdict.js +51 -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/unit/registry-e2e-signature-fixtures.test.js +288 -0
- package/tests/unit/registry-policy.test.js +6 -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/src/core/policy.js
CHANGED
|
@@ -10,13 +10,21 @@ export function loadRegistryPolicy(targetDir) {
|
|
|
10
10
|
require_approval_for_remote_sync: true,
|
|
11
11
|
require_checksum: true,
|
|
12
12
|
require_signature: false,
|
|
13
|
+
require_lockfile_on_verify: false,
|
|
13
14
|
allow_untrusted_install: false,
|
|
14
15
|
allowed_write_roots: ['.ai/', 'adapters/'],
|
|
15
16
|
blocked_paths: ['.env', '.npmrc', '.git/', 'node_modules/', 'package.json', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'],
|
|
16
17
|
max_plugin_files: 20,
|
|
17
18
|
max_plugin_size_kb: 100,
|
|
18
19
|
max_registry_cache_size_kb: 512,
|
|
19
|
-
allowed_file_extensions: ['.md', '.yaml', '.yml', '.json']
|
|
20
|
+
allowed_file_extensions: ['.md', '.yaml', '.yml', '.json'],
|
|
21
|
+
allow_unsigned_local: true,
|
|
22
|
+
allow_unsigned_bundled: true,
|
|
23
|
+
allow_unsigned_remote: false,
|
|
24
|
+
trusted_keys_file: '.ai/registries/trusted-keys.yaml',
|
|
25
|
+
allowed_signature_algorithms: ['ed25519', 'hmac-sha256'],
|
|
26
|
+
require_trusted_publisher: false,
|
|
27
|
+
provenance_required: true
|
|
20
28
|
};
|
|
21
29
|
const paths = [];
|
|
22
30
|
if (targetDir) {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Provenance — Lockfile I/O
|
|
3
|
+
*
|
|
4
|
+
* Manages `.ai/registry-lock.json`, the tamper-evident record that captures
|
|
5
|
+
* deterministic provenance for every synced registry: URL, catalog/manifest
|
|
6
|
+
* hashes, sync timestamp, and optional HMAC-SHA256 signature.
|
|
7
|
+
*
|
|
8
|
+
* This module is pure I/O + structure. No signing logic lives here.
|
|
9
|
+
* See: src/registry/signing.js for HMAC operations.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
13
|
+
import { join, dirname } from 'path';
|
|
14
|
+
|
|
15
|
+
const LOCKFILE_VERSION = '1';
|
|
16
|
+
const LOCKFILE_FILENAME = 'registry-lock.json';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return the absolute path to the registry lockfile for a given project dir.
|
|
20
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export function getLockfilePath(targetDir) {
|
|
24
|
+
return join(targetDir, '.ai', LOCKFILE_FILENAME);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load the registry lockfile from disk.
|
|
29
|
+
* Returns a well-formed empty structure if the file does not exist or is invalid.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
32
|
+
* @returns {{ lockfile_version: string, generated_at: string, entries: Object }}
|
|
33
|
+
*/
|
|
34
|
+
export function loadRegistryLockfile(targetDir) {
|
|
35
|
+
const lockfilePath = getLockfilePath(targetDir);
|
|
36
|
+
const empty = { lockfile_version: LOCKFILE_VERSION, generated_at: '', entries: {} };
|
|
37
|
+
|
|
38
|
+
if (!existsSync(lockfilePath)) {
|
|
39
|
+
return empty;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const raw = readFileSync(lockfilePath, 'utf8');
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entries) {
|
|
46
|
+
return empty;
|
|
47
|
+
}
|
|
48
|
+
// Ensure lockfile_version is present
|
|
49
|
+
parsed.lockfile_version = parsed.lockfile_version || LOCKFILE_VERSION;
|
|
50
|
+
return parsed;
|
|
51
|
+
} catch (_e) {
|
|
52
|
+
return empty;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Persist the lockfile to disk.
|
|
58
|
+
* Creates the `.ai/` directory if it does not exist.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
61
|
+
* @param {{ lockfile_version: string, generated_at: string, entries: Object }} lockfile
|
|
62
|
+
*/
|
|
63
|
+
export function saveRegistryLockfile(targetDir, lockfile) {
|
|
64
|
+
const lockfilePath = getLockfilePath(targetDir);
|
|
65
|
+
const lockfileDir = dirname(lockfilePath);
|
|
66
|
+
|
|
67
|
+
if (!existsSync(lockfileDir)) {
|
|
68
|
+
mkdirSync(lockfileDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lockfile.generated_at = new Date().toISOString();
|
|
72
|
+
lockfile.lockfile_version = LOCKFILE_VERSION;
|
|
73
|
+
|
|
74
|
+
writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + '\n', 'utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Upsert a provenance entry for a registry into the lockfile object.
|
|
79
|
+
* Does NOT write to disk — call saveRegistryLockfile() after this.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ entries: Object }} lockfile The lockfile object to mutate.
|
|
82
|
+
* @param {string} name Registry name key.
|
|
83
|
+
* @param {ProvenanceEntry} entry
|
|
84
|
+
*
|
|
85
|
+
* @typedef {Object} ProvenanceEntry
|
|
86
|
+
* @property {string} url Registry URL.
|
|
87
|
+
* @property {string} synced_at ISO 8601 timestamp of the sync.
|
|
88
|
+
* @property {string} catalog_sha256 SHA-256 hex of the downloaded catalog.yaml.
|
|
89
|
+
* @property {string|null} manifest_sha256 SHA-256 hex of manifest.json (null if not downloaded).
|
|
90
|
+
* @property {string|null} signature HMAC-SHA256 hex of catalog_sha256 (null if no key).
|
|
91
|
+
* @property {string} signature_alg Algorithm identifier (e.g. "hmac-sha256").
|
|
92
|
+
*/
|
|
93
|
+
export function updateLockfileEntry(lockfile, name, entry) {
|
|
94
|
+
if (!lockfile.entries || typeof lockfile.entries !== 'object') {
|
|
95
|
+
lockfile.entries = {};
|
|
96
|
+
}
|
|
97
|
+
lockfile.entries[name] = {
|
|
98
|
+
url: entry.url,
|
|
99
|
+
synced_at: entry.synced_at || new Date().toISOString(),
|
|
100
|
+
catalog_sha256: entry.catalog_sha256,
|
|
101
|
+
manifest_sha256: entry.manifest_sha256 ?? null,
|
|
102
|
+
signature: entry.signature ?? null,
|
|
103
|
+
signature_alg: entry.signature_alg || 'hmac-sha256',
|
|
104
|
+
public_signature_status: entry.public_signature_status ?? null,
|
|
105
|
+
public_signature_algorithm: entry.public_signature_algorithm ?? null,
|
|
106
|
+
public_signature_key_id: entry.public_signature_key_id ?? null,
|
|
107
|
+
trusted_publisher_status: entry.trusted_publisher_status ?? null,
|
|
108
|
+
trust_store_path: entry.trust_store_path ?? null,
|
|
109
|
+
trust_verdict: entry.trust_verdict ?? null,
|
|
110
|
+
lockfile_verdict: entry.lockfile_verdict ?? null,
|
|
111
|
+
verification_errors: entry.verification_errors ?? [],
|
|
112
|
+
verification_warnings: entry.verification_warnings ?? []
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Signing — HMAC-SHA256 and Ed25519 key management + verification
|
|
3
|
+
*
|
|
4
|
+
* Provides project-scoped HMAC signing and public-key Ed25519 signature verification
|
|
5
|
+
* for registry manifests.
|
|
6
|
+
*
|
|
7
|
+
* HMAC key is stored at `.ai/registry-signing-key` as a 64-char hex string
|
|
8
|
+
* (32 random bytes). The file should be gitignored.
|
|
9
|
+
*
|
|
10
|
+
* Uses only Node.js built-in `crypto` — zero runtime dependencies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { generateKeyPairSync, sign, verify, createHmac, timingSafeEqual, randomBytes } from 'crypto';
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
|
|
15
|
+
import { join, dirname } from 'path';
|
|
16
|
+
|
|
17
|
+
const SIGNING_KEY_FILENAME = 'registry-signing-key';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the absolute path to the signing key file for a given project dir.
|
|
21
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export function getSigningKeyPath(targetDir) {
|
|
25
|
+
return join(targetDir, '.ai', SIGNING_KEY_FILENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load the project-scoped signing key from `.ai/registry-signing-key`.
|
|
30
|
+
* Returns null (without throwing) if the file does not exist.
|
|
31
|
+
* Throws if the file exists but contains an invalid key format.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
34
|
+
* @returns {string|null} 64-char hex key string, or null.
|
|
35
|
+
*/
|
|
36
|
+
export function loadSigningKey(targetDir) {
|
|
37
|
+
const keyPath = getSigningKeyPath(targetDir);
|
|
38
|
+
if (!existsSync(keyPath)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = readFileSync(keyPath, 'utf8').trim();
|
|
43
|
+
|
|
44
|
+
if (!/^[0-9a-f]{64}$/.test(raw)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Signing key at '${keyPath}' is malformed. Expected a 64-character lowercase hex string (32 bytes). ` +
|
|
47
|
+
`Re-generate with: npx multimodel-dev-os registry keygen --approved`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return raw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Generate a new random 32-byte signing key (64-char hex).
|
|
56
|
+
* @returns {string} 64-char hex string.
|
|
57
|
+
*/
|
|
58
|
+
export function generateSigningKey() {
|
|
59
|
+
return randomBytes(32).toString('hex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write a signing key to disk at the project-scoped location.
|
|
64
|
+
* Creates the `.ai/` directory if it does not exist.
|
|
65
|
+
* Sets file permissions to 0o600 (owner read/write only) where supported.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} targetDir Absolute path to the project root.
|
|
68
|
+
* @param {string} key 64-char hex key string.
|
|
69
|
+
*/
|
|
70
|
+
export function saveSigningKey(targetDir, key) {
|
|
71
|
+
const keyPath = getSigningKeyPath(targetDir);
|
|
72
|
+
const keyDir = dirname(keyPath);
|
|
73
|
+
|
|
74
|
+
if (!existsSync(keyDir)) {
|
|
75
|
+
mkdirSync(keyDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
writeFileSync(keyPath, key + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
79
|
+
|
|
80
|
+
// Best-effort chmod on platforms that support it (no-op on Windows)
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(keyPath, 0o600);
|
|
83
|
+
} catch (_e) {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Compute HMAC-SHA256 of a payload using the provided hex key.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} hexKey 64-char hex signing key.
|
|
90
|
+
* @param {string} payload Plaintext string to sign (typically the catalog_sha256 hex).
|
|
91
|
+
* @returns {string} HMAC-SHA256 hex digest.
|
|
92
|
+
*/
|
|
93
|
+
export function signPayload(hexKey, payload) {
|
|
94
|
+
if (typeof hexKey !== 'string' || !/^[0-9a-f]{64}$/.test(hexKey)) {
|
|
95
|
+
throw new Error('Invalid signing key: must be a 64-character lowercase hex string.');
|
|
96
|
+
}
|
|
97
|
+
if (typeof payload !== 'string') {
|
|
98
|
+
throw new Error('Payload to sign must be a string.');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const keyBytes = Buffer.from(hexKey, 'hex');
|
|
102
|
+
return createHmac('sha256', keyBytes).update(payload, 'utf8').digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify that a previously computed HMAC-SHA256 signature matches the payload.
|
|
107
|
+
* Uses `timingSafeEqual` to prevent timing-based side-channel attacks.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} hexKey 64-char hex signing key.
|
|
110
|
+
* @param {string} payload Plaintext string that was signed.
|
|
111
|
+
* @param {string} expectedSig The stored HMAC-SHA256 hex digest to compare against.
|
|
112
|
+
* @returns {boolean} true if valid, false if tampered or mismatched.
|
|
113
|
+
*/
|
|
114
|
+
export function verifySignature(hexKey, payload, expectedSig) {
|
|
115
|
+
if (typeof hexKey !== 'string' || typeof payload !== 'string' || typeof expectedSig !== 'string') {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let actualSig;
|
|
120
|
+
try {
|
|
121
|
+
actualSig = signPayload(hexKey, payload);
|
|
122
|
+
} catch (_e) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (actualSig.length !== expectedSig.length) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
return timingSafeEqual(
|
|
132
|
+
Buffer.from(actualSig, 'hex'),
|
|
133
|
+
Buffer.from(expectedSig, 'hex')
|
|
134
|
+
);
|
|
135
|
+
} catch (_e) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Generate a deterministic canonical payload string from an object based on specified fields.
|
|
142
|
+
* Recursively sorts keys of nested objects to ensure key order stability.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} data The source object.
|
|
145
|
+
* @param {string[]} fields The fields to extract and canonicalize.
|
|
146
|
+
* @returns {string} JSON-serialized canonical string.
|
|
147
|
+
*/
|
|
148
|
+
export function createCanonicalPayload(data, fields) {
|
|
149
|
+
if (!data || typeof data !== 'object') {
|
|
150
|
+
throw new Error('Data must be an object.');
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(fields)) {
|
|
153
|
+
throw new Error('Fields must be an array of strings.');
|
|
154
|
+
}
|
|
155
|
+
const sortedFields = [...fields].sort();
|
|
156
|
+
const obj = {};
|
|
157
|
+
for (const field of sortedFields) {
|
|
158
|
+
if (data[field] !== undefined) {
|
|
159
|
+
obj[field] = data[field];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return JSON.stringify(obj, (key, value) => {
|
|
163
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
164
|
+
return Object.keys(value).sort().reduce((sorted, k) => {
|
|
165
|
+
sorted[k] = value[k];
|
|
166
|
+
return sorted;
|
|
167
|
+
}, {});
|
|
168
|
+
}
|
|
169
|
+
return value;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Generate an Ed25519 keypair in PEM SPKI/PKCS8 format.
|
|
175
|
+
*
|
|
176
|
+
* @returns {{ publicKey: string, privateKey: string }}
|
|
177
|
+
*/
|
|
178
|
+
export function generateEd25519KeyPair() {
|
|
179
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
180
|
+
publicKeyEncoding: {
|
|
181
|
+
type: 'spki',
|
|
182
|
+
format: 'pem'
|
|
183
|
+
},
|
|
184
|
+
privateKeyEncoding: {
|
|
185
|
+
type: 'pkcs8',
|
|
186
|
+
format: 'pem'
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return { publicKey, privateKey };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Sign a payload using an Ed25519 private key PEM.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} privateKey Ed25519 PEM private key string.
|
|
196
|
+
* @param {string} payload Canonical payload string.
|
|
197
|
+
* @returns {string} Base64-encoded signature.
|
|
198
|
+
*/
|
|
199
|
+
export function signEd25519Payload(privateKey, payload) {
|
|
200
|
+
if (typeof privateKey !== 'string') {
|
|
201
|
+
throw new Error('Private key must be a PEM string.');
|
|
202
|
+
}
|
|
203
|
+
if (typeof payload !== 'string') {
|
|
204
|
+
throw new Error('Payload to sign must be a string.');
|
|
205
|
+
}
|
|
206
|
+
const signatureBuffer = sign(null, Buffer.from(payload, 'utf8'), privateKey);
|
|
207
|
+
return signatureBuffer.toString('base64');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Normalize a public key to ensure it is in PEM SPKI format.
|
|
212
|
+
* Wraps bare base64 public keys in SPKI headers/footers if needed.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} input PEM public key or raw base64.
|
|
215
|
+
* @returns {string} Normalized PEM public key string.
|
|
216
|
+
*/
|
|
217
|
+
export function normalizePublicKey(input) {
|
|
218
|
+
if (typeof input !== 'string') {
|
|
219
|
+
throw new Error('Public key must be a string.');
|
|
220
|
+
}
|
|
221
|
+
let trimmed = input.trim();
|
|
222
|
+
if (trimmed.startsWith('-----BEGIN PUBLIC KEY-----')) {
|
|
223
|
+
return trimmed;
|
|
224
|
+
}
|
|
225
|
+
if (trimmed.startsWith('-----BEGIN')) {
|
|
226
|
+
return trimmed;
|
|
227
|
+
}
|
|
228
|
+
const clean = trimmed.replace(/\s+/g, '');
|
|
229
|
+
const lines = [];
|
|
230
|
+
for (let i = 0; i < clean.length; i += 64) {
|
|
231
|
+
lines.push(clean.slice(i, i + 64));
|
|
232
|
+
}
|
|
233
|
+
return `-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Verify an Ed25519 signature of a payload.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} publicKey Public key in PEM or raw base64.
|
|
240
|
+
* @param {string} payload Payload that was signed.
|
|
241
|
+
* @param {string} signature Base64 signature to verify.
|
|
242
|
+
* @returns {boolean} true if signature is valid, false otherwise.
|
|
243
|
+
*/
|
|
244
|
+
export function verifyEd25519Payload(publicKey, payload, signature) {
|
|
245
|
+
if (typeof publicKey !== 'string' || typeof payload !== 'string' || typeof signature !== 'string') {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const pubKey = normalizePublicKey(publicKey);
|
|
250
|
+
const sigBuffer = Buffer.from(signature, 'base64');
|
|
251
|
+
return verify(null, Buffer.from(payload, 'utf8'), pubKey, sigBuffer);
|
|
252
|
+
} catch (_e) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Detect the signature algorithm from a signature block object.
|
|
259
|
+
*
|
|
260
|
+
* @param {Object} signatureBlock The signature block.
|
|
261
|
+
* @returns {string|null} Algorithm name or null.
|
|
262
|
+
*/
|
|
263
|
+
export function detectSignatureAlgorithm(signatureBlock) {
|
|
264
|
+
if (!signatureBlock || typeof signatureBlock !== 'object') {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return signatureBlock.algorithm || null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Verify a manifest signature or signatures array block using trusted keys and policy.
|
|
272
|
+
*
|
|
273
|
+
* @param {Object} options
|
|
274
|
+
* @param {Object} options.manifest Parsed registry manifest object.
|
|
275
|
+
* @param {Object[]} options.trustedKeys Array of trusted publishers from trust store.
|
|
276
|
+
* @param {Object} [options.policy] Policy settings.
|
|
277
|
+
* @param {string|null} [options.hmacKey] Project HMAC key.
|
|
278
|
+
* @param {Object} [options.source] Source configuration for the registry.
|
|
279
|
+
* @returns {{ verified: boolean, status: string, error?: string, errors?: string[], warning?: string, message?: string, verified_signatures?: Object[] }}
|
|
280
|
+
*/
|
|
281
|
+
export function verifySignatureBlock({ manifest, trustedKeys, policy = {}, hmacKey = null, source = {} }) {
|
|
282
|
+
const isBundled = source.name === 'bundled';
|
|
283
|
+
const isLocal = source.type === 'local';
|
|
284
|
+
const isRemote = source.type === 'remote' || (!isBundled && !isLocal);
|
|
285
|
+
|
|
286
|
+
const signatureBlocks = [];
|
|
287
|
+
if (manifest.signature && typeof manifest.signature === 'object') {
|
|
288
|
+
signatureBlocks.push(manifest.signature);
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(manifest.signatures)) {
|
|
291
|
+
signatureBlocks.push(...manifest.signatures);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (signatureBlocks.length === 0) {
|
|
295
|
+
if (policy.require_signature) {
|
|
296
|
+
return { verified: false, status: 'failed', error: 'Signature is required by policy but missing from manifest.' };
|
|
297
|
+
}
|
|
298
|
+
if (isRemote && policy.allow_unsigned_remote === false) {
|
|
299
|
+
return { verified: false, status: 'failed', error: 'Unsigned remote registries are not allowed by policy.' };
|
|
300
|
+
}
|
|
301
|
+
if (isBundled && policy.allow_unsigned_bundled === false) {
|
|
302
|
+
return { verified: false, status: 'failed', error: 'Unsigned bundled registries are not allowed by policy.' };
|
|
303
|
+
}
|
|
304
|
+
if (isLocal && !isBundled && policy.allow_unsigned_local === false) {
|
|
305
|
+
return { verified: false, status: 'failed', error: 'Unsigned local registries are not allowed by policy.' };
|
|
306
|
+
}
|
|
307
|
+
return { verified: true, status: 'unsigned', message: 'Registry is unsigned (allowed by policy).' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let verifiedCount = 0;
|
|
311
|
+
const errors = [];
|
|
312
|
+
const allowedAlgs = policy.allowed_signature_algorithms || ['ed25519', 'hmac-sha256'];
|
|
313
|
+
|
|
314
|
+
for (const sigBlock of signatureBlocks) {
|
|
315
|
+
const alg = sigBlock.algorithm;
|
|
316
|
+
const keyId = sigBlock.key_id;
|
|
317
|
+
const signature = sigBlock.signature;
|
|
318
|
+
const signedFields = sigBlock.signed_fields;
|
|
319
|
+
|
|
320
|
+
if (!alg || !keyId || !signature || !Array.isArray(signedFields)) {
|
|
321
|
+
errors.push(`Malformed signature block for key_id '${keyId || 'unknown'}'.`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!allowedAlgs.includes(alg)) {
|
|
326
|
+
errors.push(`Signature algorithm '${alg}' is not allowed by policy (allowed: ${allowedAlgs.join(', ')}).`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (alg === 'hmac-sha256') {
|
|
331
|
+
if (!hmacKey) {
|
|
332
|
+
errors.push(`HMAC key not configured locally for key_id '${keyId}'.`);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
const payload = createCanonicalPayload(manifest, signedFields);
|
|
337
|
+
const expected = createHmac('sha256', Buffer.from(hmacKey, 'hex')).update(payload, 'utf8').digest('hex');
|
|
338
|
+
if (timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) {
|
|
339
|
+
verifiedCount++;
|
|
340
|
+
} else {
|
|
341
|
+
errors.push(`Invalid HMAC signature for key_id '${keyId}'.`);
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
errors.push(`HMAC signature verification failed: ${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
} else if (alg === 'ed25519') {
|
|
347
|
+
const trustedKey = trustedKeys ? trustedKeys.find(k => k.key_id === keyId) : null;
|
|
348
|
+
if (!trustedKey) {
|
|
349
|
+
errors.push(`Key ID '${keyId}' not found in trust store.`);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (trustedKey.status !== 'active') {
|
|
354
|
+
errors.push(`Key ID '${keyId}' is ${trustedKey.status} (must be active).`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const scopes = trustedKey.scopes || [];
|
|
359
|
+
if (!scopes.includes('registry') && !scopes.includes('catalog')) {
|
|
360
|
+
errors.push(`Key ID '${keyId}' does not have required scope 'registry' or 'catalog' (scopes: ${scopes.join(', ')}).`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
const payload = createCanonicalPayload(manifest, signedFields);
|
|
366
|
+
if (verifyEd25519Payload(trustedKey.public_key, payload, signature)) {
|
|
367
|
+
verifiedCount++;
|
|
368
|
+
} else {
|
|
369
|
+
errors.push(`Invalid Ed25519 signature for key_id '${keyId}'.`);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
errors.push(`Ed25519 signature verification failed: ${err.message}`);
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
errors.push(`Unsupported signature algorithm '${alg}' for key_id '${keyId}'.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (verifiedCount > 0) {
|
|
380
|
+
return {
|
|
381
|
+
verified: true,
|
|
382
|
+
status: 'verified',
|
|
383
|
+
verified_signatures: signatureBlocks.map(s => ({ key_id: s.key_id, algorithm: s.algorithm }))
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
verified: false,
|
|
389
|
+
status: 'failed',
|
|
390
|
+
errors
|
|
391
|
+
};
|
|
392
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Trust Store
|
|
3
|
+
*
|
|
4
|
+
* Manages trusted publisher public keys stored in `.ai/registries/trusted-keys.yaml`
|
|
5
|
+
* (or as configured in registry-policy.yaml).
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js built-in fs and yaml parser from the codebase.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
import { join, isAbsolute } from 'path';
|
|
12
|
+
import { parseYaml } from '../core/yaml.js';
|
|
13
|
+
import { loadRegistryPolicy } from '../core/policy.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load trusted publisher keys from disk.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} targetDir Absolute path to project root.
|
|
19
|
+
* @param {Object} [policy] Preloaded policy (optional).
|
|
20
|
+
* @returns {Object[]} Array of trusted publisher records.
|
|
21
|
+
*/
|
|
22
|
+
export function loadTrustedKeys(targetDir, policy) {
|
|
23
|
+
const pol = policy || loadRegistryPolicy(targetDir);
|
|
24
|
+
const keyFile = pol.trusted_keys_file || '.ai/registries/trusted-keys.yaml';
|
|
25
|
+
const filePath = isAbsolute(keyFile) ? keyFile : join(targetDir, keyFile);
|
|
26
|
+
|
|
27
|
+
if (!existsSync(filePath)) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
33
|
+
const parsed = parseYaml(raw);
|
|
34
|
+
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.trusted_publishers)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return parsed.trusted_publishers;
|
|
38
|
+
} catch (_e) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry Trust Verdict Helper
|
|
3
|
+
*
|
|
4
|
+
* Builds a deterministic, JSON-serializable structured trust verdict object.
|
|
5
|
+
* Used for both CLI display and provenance lockfile persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates a structured trust verdict object.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} options
|
|
12
|
+
* @param {string} options.source Registry name.
|
|
13
|
+
* @param {string} options.source_type "bundled" | "local" | "remote".
|
|
14
|
+
* @param {string} [options.manifest_hash_status] Manifest verification status.
|
|
15
|
+
* @param {string} [options.catalog_hash_status] Catalog verification status.
|
|
16
|
+
* @param {string} [options.lockfile_status] Lockfile existence status.
|
|
17
|
+
* @param {string} [options.provenance_status] Provenance lock match status.
|
|
18
|
+
* @param {string} [options.signature_status] Signature check status.
|
|
19
|
+
* @param {string} [options.trusted_publisher_status] Trust store lookup status.
|
|
20
|
+
* @param {string[]} [options.errors] List of verification errors.
|
|
21
|
+
* @param {string[]} [options.warnings] List of verification warnings.
|
|
22
|
+
* @param {"trusted"|"warning"|"untrusted"|"unknown"} [options.final_status] Overall trust state.
|
|
23
|
+
* @returns {Object}
|
|
24
|
+
*/
|
|
25
|
+
export function createTrustVerdict({
|
|
26
|
+
source,
|
|
27
|
+
source_type,
|
|
28
|
+
manifest_hash_status = 'N/A',
|
|
29
|
+
catalog_hash_status = 'N/A',
|
|
30
|
+
lockfile_status = 'N/A',
|
|
31
|
+
provenance_status = 'N/A',
|
|
32
|
+
signature_status = 'N/A',
|
|
33
|
+
trusted_publisher_status = 'N/A',
|
|
34
|
+
errors = [],
|
|
35
|
+
warnings = [],
|
|
36
|
+
final_status = 'unknown'
|
|
37
|
+
}) {
|
|
38
|
+
return {
|
|
39
|
+
source,
|
|
40
|
+
source_type,
|
|
41
|
+
manifest_hash_status,
|
|
42
|
+
catalog_hash_status,
|
|
43
|
+
lockfile_status,
|
|
44
|
+
provenance_status,
|
|
45
|
+
signature_status,
|
|
46
|
+
trusted_publisher_status,
|
|
47
|
+
errors: Array.isArray(errors) ? errors : [],
|
|
48
|
+
warnings: Array.isArray(warnings) ? warnings : [],
|
|
49
|
+
final_status
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
registry_name: "test-fixture-registry"
|
|
2
|
+
publisher: "Mock Valid Publisher"
|
|
3
|
+
version: "1.0.0"
|
|
4
|
+
generated_at: "2026-06-20T12:00:00Z"
|
|
5
|
+
minimum_mmdo_version: "3.2.0"
|
|
6
|
+
safety_policy_version: "1.0.0"
|
|
7
|
+
catalog_hash: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
|
|
8
|
+
files_hashes:
|
|
9
|
+
catalog.yaml: "sha256:289e7ff82e8037b27471990d4106f76e7f8e44e26aeb049787c185a30249420e"
|
|
10
|
+
signature:
|
|
11
|
+
algorithm: "ed25519"
|
|
12
|
+
key_id: "test-key-revoked"
|
|
13
|
+
signature: "LyuSIQ72kyOuNVqie1P/vZVi2PqFsCS//Q6/mAstTG1zDQvGPd5HwH8t1FPEOtP7Sbb31udJ7NJY3cdCWpACCg=="
|
|
14
|
+
signed_fields: ["registry_name", "publisher", "version", "generated_at", "catalog_hash", "minimum_mmdo_version", "safety_policy_version"]
|