provenance-protocol 0.2.1 → 0.3.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/README.md +17 -5
- package/SPEC.md +74 -20
- package/package.json +5 -3
- package/schema/provenance-0.1.json +1 -1
- package/schema/provenance-0.2.json +229 -0
- package/src/canonical.js +98 -0
- package/src/cli.js +249 -151
- package/src/keygen.d.ts +15 -0
- package/src/keygen.js +22 -0
- package/src/verify.d.ts +10 -0
- package/src/verify.js +54 -8
- package/test-vectors/declarations-0.2.json +217 -0
package/src/cli.js
CHANGED
|
@@ -1,196 +1,294 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* provenance CLI
|
|
3
|
+
* provenance — Provenance Protocol identity CLI (the `provenance` bin of provenance-protocol)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* provenance keygen
|
|
7
|
+
* provenance register --id <id> --url <url> [options]
|
|
8
|
+
* provenance status <id>
|
|
9
|
+
* provenance validate [file]
|
|
10
|
+
* provenance revoke --id <id> [--private-key <key>]
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign as nodeSign } from 'crypto';
|
|
14
|
+
import { readFileSync, existsSync } from 'fs';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { createRequire } from 'module';
|
|
14
17
|
|
|
15
|
-
const API
|
|
16
|
-
const
|
|
18
|
+
const API = process.env.PROVENANCE_API_URL || 'https://getprovenance.dev';
|
|
19
|
+
const VERSION = createRequire(import.meta.url)('../package.json').version;
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
21
|
+
// ── Colours ───────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const c = {
|
|
24
|
+
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
|
|
25
|
+
green: '\x1b[32m', amber: '\x1b[33m', red: '\x1b[31m', white: '\x1b[97m',
|
|
26
|
+
};
|
|
27
|
+
const ok = s => `${c.green}✓${c.reset} ${s}`;
|
|
28
|
+
const err = s => `${c.red}✗${c.reset} ${s}`;
|
|
29
|
+
const dim = s => `${c.dim}${s}${c.reset}`;
|
|
30
|
+
const hi = s => `${c.white}${c.bold}${s}${c.reset}`;
|
|
31
|
+
const amb = s => `${c.amber}${s}${c.reset}`;
|
|
32
|
+
|
|
33
|
+
// ── Arg parsing ───────────────────────────────────────────────────────────────
|
|
24
34
|
|
|
25
35
|
function parseArgs(argv) {
|
|
26
|
-
const args = {};
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
const args = { _: [] };
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < argv.length) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (a.startsWith('--')) {
|
|
41
|
+
const key = a.slice(2);
|
|
42
|
+
const next = argv[i + 1];
|
|
43
|
+
if (next && !next.startsWith('--')) { args[key] = next; i += 2; }
|
|
44
|
+
else { args[key] = true; i++; }
|
|
45
|
+
} else { args._.push(a); i++; }
|
|
34
46
|
}
|
|
35
47
|
return args;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
39
|
-
const args = parseArgs(rest);
|
|
50
|
+
// ── Crypto helpers ────────────────────────────────────────────────────────────
|
|
40
51
|
|
|
41
|
-
|
|
52
|
+
function generateKeyPair() {
|
|
53
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
|
|
54
|
+
publicKeyEncoding: { type: 'spki', format: 'der' },
|
|
55
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
publicKey: Buffer.from(publicKey).toString('base64'),
|
|
59
|
+
privateKey: Buffer.from(privateKey).toString('base64'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
const {
|
|
63
|
+
function signMessage(privateKeyBase64, message) {
|
|
64
|
+
const key = createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
65
|
+
return nodeSign(null, Buffer.from(message, 'utf8'), key).toString('base64');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function derivePublicKey(privateKeyBase64) {
|
|
69
|
+
const priv = createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), format: 'der', type: 'pkcs8' });
|
|
70
|
+
return Buffer.from(createPublicKey(priv).export({ type: 'spki', format: 'der' })).toString('base64');
|
|
71
|
+
}
|
|
45
72
|
|
|
46
|
-
|
|
47
|
-
try { execSync(`grep -qxF "${KEY_FILE}" .gitignore 2>/dev/null || echo "${KEY_FILE}" >> .gitignore`); } catch {}
|
|
73
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
48
74
|
|
|
49
|
-
|
|
50
|
-
console.log(
|
|
51
|
-
|
|
75
|
+
async function cmdKeygen() {
|
|
76
|
+
console.log(`\n${amb('Generating Ed25519 keypair...')}\n`);
|
|
77
|
+
const { publicKey, privateKey } = generateKeyPair();
|
|
78
|
+
|
|
79
|
+
console.log(`${hi('Public key')} ${dim('(add to PROVENANCE.yml identity.public_key)')}`);
|
|
80
|
+
console.log(`${c.green}${publicKey}${c.reset}\n`);
|
|
81
|
+
console.log(`${hi('Private key')} ${dim('(store as PROVENANCE_PRIVATE_KEY — never commit)')}`);
|
|
82
|
+
console.log(`${c.amber}${privateKey}${c.reset}\n`);
|
|
83
|
+
console.log(dim('─'.repeat(60)));
|
|
84
|
+
console.log(dim('Add to your environment:'));
|
|
85
|
+
console.log(` PROVENANCE_PRIVATE_KEY=${privateKey}\n`);
|
|
86
|
+
console.log(dim('Add to PROVENANCE.yml:'));
|
|
52
87
|
console.log(` identity:`);
|
|
53
88
|
console.log(` public_key: "${publicKey}"`);
|
|
54
|
-
console.log(` algorithm: ed25519`);
|
|
55
|
-
console.log(`\nNext: npx provenance register --id provenance:<platform>:<org>/<name>\n`);
|
|
56
|
-
process.exit(0);
|
|
89
|
+
console.log(` algorithm: ed25519\n`);
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
// Best path: require keygen was run, read public key from PROVENANCE.yml if present
|
|
74
|
-
let pubKey = args['public-key'];
|
|
75
|
-
if (!pubKey) {
|
|
76
|
-
if (existsSync('PROVENANCE.yml')) {
|
|
77
|
-
const yml = readFileSync('PROVENANCE.yml', 'utf8');
|
|
78
|
-
const match = yml.match(/public_key:\s*["']?([A-Za-z0-9+/=]+)["']?/);
|
|
79
|
-
if (match) pubKey = match[1];
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (!pubKey) {
|
|
83
|
-
console.error('Public key required. Pass --public-key or add identity.public_key to PROVENANCE.yml first.');
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
92
|
+
async function cmdRegister(args) {
|
|
93
|
+
const id = args.id;
|
|
94
|
+
const url = args.url;
|
|
95
|
+
const name = args.name;
|
|
96
|
+
const description = args.description || args.desc;
|
|
97
|
+
const caps = args.capabilities ? args.capabilities.split(',').map(s => s.trim()) : [];
|
|
98
|
+
const cons = args.constraints ? args.constraints.split(',').map(s => s.trim()) : [];
|
|
99
|
+
const model = args.model;
|
|
100
|
+
const modelId = args['model-id'];
|
|
101
|
+
const ajpEndpoint = args['ajp-endpoint'];
|
|
102
|
+
const privateKey = args['private-key'] || process.env.PROVENANCE_PRIVATE_KEY;
|
|
103
|
+
|
|
104
|
+
if (!id) { console.error(err('--id required')); process.exit(1); }
|
|
105
|
+
if (!url) { console.error(err('--url required')); process.exit(1); }
|
|
86
106
|
|
|
87
|
-
|
|
107
|
+
console.log(`\n${amb('Registering')} ${hi(id)}...\n`);
|
|
108
|
+
|
|
109
|
+
let pubKey, signedChallenge;
|
|
110
|
+
if (privateKey) {
|
|
111
|
+
pubKey = args['public-key'] || process.env.PROVENANCE_PUBLIC_KEY || derivePublicKey(privateKey);
|
|
112
|
+
signedChallenge = signMessage(privateKey, `${id}:REGISTER`);
|
|
113
|
+
console.log(ok('Signing with private key'));
|
|
114
|
+
}
|
|
88
115
|
|
|
89
116
|
const body = {
|
|
90
|
-
provenance_id: id,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
...(
|
|
94
|
-
...(
|
|
95
|
-
...(
|
|
96
|
-
...(
|
|
97
|
-
...(
|
|
98
|
-
...(
|
|
99
|
-
...(
|
|
117
|
+
provenance_id: id, url,
|
|
118
|
+
...(name && { name }),
|
|
119
|
+
...(description && { description }),
|
|
120
|
+
...(caps.length && { capabilities: caps }),
|
|
121
|
+
...(cons.length && { constraints: cons }),
|
|
122
|
+
...(model && { model_provider: model }),
|
|
123
|
+
...(modelId && { model_id: modelId }),
|
|
124
|
+
...(ajpEndpoint && { ajp_endpoint: ajpEndpoint }),
|
|
125
|
+
...(pubKey && { public_key: pubKey }),
|
|
126
|
+
...(signedChallenge && { signed_challenge: signedChallenge }),
|
|
100
127
|
};
|
|
101
128
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
console.log(
|
|
116
|
-
|
|
117
|
-
} catch (e) {
|
|
118
|
-
console.error('Network error:', e.message);
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
121
|
-
process.exit(0);
|
|
129
|
+
const res = await fetch(`${API}/api/agents/register`, {
|
|
130
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
131
|
+
});
|
|
132
|
+
const data = await res.json();
|
|
133
|
+
|
|
134
|
+
if (!res.ok) { console.error(err(data.error || `HTTP ${res.status}`)); process.exit(1); }
|
|
135
|
+
|
|
136
|
+
const agent = data.agent || data;
|
|
137
|
+
console.log(ok(data.created ? 'Agent registered' : 'Agent updated'));
|
|
138
|
+
console.log(` ${dim('confidence:')} ${c.green}${agent.confidence}${c.reset}`);
|
|
139
|
+
console.log(` ${dim('identity_verified:')} ${agent.identity_verified ? c.green + 'true' : c.amber + 'false'}${c.reset}`);
|
|
140
|
+
if (ajpEndpoint) console.log(` ${dim('ajp_endpoint:')} ${ajpEndpoint}`);
|
|
141
|
+
if (!agent.identity_verified)
|
|
142
|
+
console.log(`\n${c.amber}Tip:${c.reset} Run with ${hi('--private-key')} to get identity_verified status`);
|
|
143
|
+
console.log();
|
|
122
144
|
}
|
|
123
145
|
|
|
124
|
-
|
|
146
|
+
async function cmdStatus(args) {
|
|
147
|
+
const id = args._[1];
|
|
148
|
+
if (!id) { console.error(err('Usage: provenance status <provenance_id>')); process.exit(1); }
|
|
125
149
|
|
|
126
|
-
|
|
127
|
-
const id = args._?.[0] || args.id;
|
|
128
|
-
if (!id) { console.error('Usage: npx provenance status <provenance_id>'); process.exit(1); }
|
|
150
|
+
console.log(`\n${amb('Checking')} ${hi(id)}...\n`);
|
|
129
151
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const a = data.agent;
|
|
135
|
-
console.log(`\n${id}`);
|
|
136
|
-
console.log(`name: ${a.name || '—'}`);
|
|
137
|
-
console.log(`identity: ${a.identity}`);
|
|
138
|
-
console.log(`status: ${a.status}`);
|
|
139
|
-
console.log(`profile: https://getprovenance.dev/agent/${id.replace('provenance:', '').replace(':', '/')}\n`);
|
|
140
|
-
} catch (e) {
|
|
141
|
-
console.error('Network error:', e.message);
|
|
142
|
-
process.exit(1);
|
|
143
|
-
}
|
|
144
|
-
process.exit(0);
|
|
145
|
-
}
|
|
152
|
+
const res = await fetch(`${API}/api/agent/${id.replace('provenance:', '').replace(':', '/')}`);
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
|
|
155
|
+
if (!res.ok || data.error) { console.error(err(data.error || 'Not found')); process.exit(1); }
|
|
146
156
|
|
|
147
|
-
|
|
157
|
+
const trust = Math.round((data.confidence || 0) * 100);
|
|
158
|
+
const trustColor = trust >= 80 ? c.green : trust >= 50 ? c.amber : c.red;
|
|
148
159
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
160
|
+
console.log(`${hi(data.name || id)}`);
|
|
161
|
+
console.log(`${dim(data.provenance_id)}\n`);
|
|
162
|
+
console.log(`${dim('Trust score:')} ${trustColor}${trust}/100${c.reset}`);
|
|
163
|
+
console.log(`${dim('Declared:')} ${data.declared ? c.green + 'yes' : c.amber + 'no'}${c.reset}`);
|
|
164
|
+
console.log(`${dim('Identity verified:')} ${data.identity_verified ? c.green + 'yes' : c.amber + 'no'}${c.reset}`);
|
|
165
|
+
console.log(`${dim('AJP endpoint:')} ${data.ajp?.endpoint ? c.green + data.ajp.endpoint : c.dim + 'not set'}${c.reset}`);
|
|
166
|
+
console.log(`${dim('Incidents:')} ${(data.incident_count || 0) === 0 ? c.green + '0' : c.red + data.incident_count}${c.reset}`);
|
|
167
|
+
if (data.capabilities?.length) console.log(`${dim('Capabilities:')} ${data.capabilities.join(', ')}`);
|
|
168
|
+
if (data.constraints?.length) console.log(`${dim('Constraints:')} ${data.constraints.join(', ')}`);
|
|
152
169
|
|
|
153
|
-
|
|
154
|
-
const
|
|
170
|
+
console.log();
|
|
171
|
+
for (const [pass, label] of [
|
|
172
|
+
[data.declared, 'PROVENANCE.yml declared'],
|
|
173
|
+
[data.identity_verified, 'Identity verified (Ed25519)'],
|
|
174
|
+
[!!data.ajp?.endpoint, 'AJP endpoint configured'],
|
|
175
|
+
[(data.incident_count||0)===0, 'No open incidents'],
|
|
176
|
+
]) console.log(` ${pass ? ok(label) : dim('○ ' + label)}`);
|
|
177
|
+
console.log();
|
|
178
|
+
}
|
|
155
179
|
|
|
180
|
+
async function cmdValidate(args) {
|
|
181
|
+
const file = args._[1] || 'PROVENANCE.yml';
|
|
182
|
+
const path = resolve(process.cwd(), file);
|
|
183
|
+
if (!existsSync(path)) { console.error(err(`File not found: ${path}`)); process.exit(1); }
|
|
184
|
+
|
|
185
|
+
console.log(`\n${amb('Validating')} ${hi(file)}...\n`);
|
|
186
|
+
const content = readFileSync(path, 'utf8');
|
|
187
|
+
|
|
188
|
+
// "Could not check" and "checked, and it is invalid" must not look alike:
|
|
189
|
+
// this runs in CI, where a validator that cannot reach the service and
|
|
190
|
+
// reports failure would be indistinguishable from a broken declaration.
|
|
191
|
+
// Transport trouble exits 2; a genuinely invalid file exits 1.
|
|
192
|
+
let data;
|
|
156
193
|
try {
|
|
157
|
-
const res = await fetch(`${API}/
|
|
158
|
-
method: 'POST',
|
|
159
|
-
|
|
160
|
-
|
|
194
|
+
const res = await fetch(`${API}/api/mcp`, {
|
|
195
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
196
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
197
|
+
params: { name: 'validate_provenance_yml', arguments: { content } } }),
|
|
161
198
|
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
if (!res.ok) throw new Error(`validation service returned HTTP ${res.status}`);
|
|
200
|
+
const rpc = await res.json();
|
|
201
|
+
const text = rpc.result?.content?.[0]?.text;
|
|
202
|
+
if (!text) throw new Error('validation service returned no result');
|
|
203
|
+
data = JSON.parse(text);
|
|
204
|
+
if (typeof data.valid !== 'boolean') throw new Error('validation service returned no verdict');
|
|
166
205
|
} catch (e) {
|
|
167
|
-
console.error(
|
|
168
|
-
|
|
206
|
+
console.error(err(`Could not validate: ${e.message}`));
|
|
207
|
+
console.error(dim(`The file was not checked. This is not a validation failure.`));
|
|
208
|
+
console.log();
|
|
209
|
+
process.exit(2);
|
|
169
210
|
}
|
|
170
|
-
|
|
211
|
+
|
|
212
|
+
if (data.valid) console.log(ok('Valid PROVENANCE.yml'));
|
|
213
|
+
else { console.log(err('Validation failed')); for (const e of data.errors || []) console.log(` ${c.red}✗${c.reset} ${e}`); }
|
|
214
|
+
for (const w of data.warnings || []) console.log(` ${c.amber}⚠${c.reset} ${w}`);
|
|
215
|
+
console.log();
|
|
216
|
+
|
|
217
|
+
// Exit non-zero so `provenance validate` can gate a pipeline. It previously
|
|
218
|
+
// exited 0 on an invalid file, which made every CI check that used it pass.
|
|
219
|
+
if (!data.valid) process.exit(1);
|
|
171
220
|
}
|
|
172
221
|
|
|
173
|
-
|
|
222
|
+
async function cmdRevoke(args) {
|
|
223
|
+
const id = args.id;
|
|
224
|
+
const privateKey = args['private-key'] || process.env.PROVENANCE_PRIVATE_KEY;
|
|
225
|
+
if (!id) { console.error(err('--id required')); process.exit(1); }
|
|
226
|
+
if (!privateKey) { console.error(err('--private-key or PROVENANCE_PRIVATE_KEY required')); process.exit(1); }
|
|
174
227
|
|
|
175
|
-
console.log(`
|
|
176
|
-
provenance <command>
|
|
228
|
+
console.log(`\n${c.red}Revoking identity for${c.reset} ${hi(id)}...\n`);
|
|
177
229
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
const res = await fetch(`${API}/api/agents/revoke`, {
|
|
231
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
232
|
+
body: JSON.stringify({ provenance_id: id, signed_challenge: signMessage(privateKey, `${id}:REVOKE`) }),
|
|
233
|
+
});
|
|
234
|
+
const data = await res.json();
|
|
235
|
+
if (!res.ok || !data.success) { console.error(err(data.error || `HTTP ${res.status}`)); process.exit(1); }
|
|
182
236
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
--name Agent display name
|
|
187
|
-
--description One-sentence description
|
|
188
|
-
--capabilities read:web,write:summaries
|
|
189
|
-
--constraints no:pii,no:financial:transact
|
|
190
|
-
--model anthropic / openai / etc.
|
|
191
|
-
--model-id claude-sonnet-4-6 / gpt-4o / etc.
|
|
192
|
-
--public-key Base64 public key (auto-read from PROVENANCE.yml if present)
|
|
237
|
+
console.log(ok('Identity revoked'));
|
|
238
|
+
console.log(dim('Run `provenance register` with a new keypair to re-establish.\n'));
|
|
239
|
+
}
|
|
193
240
|
|
|
194
|
-
|
|
241
|
+
function cmdHelp() {
|
|
242
|
+
console.log(`
|
|
243
|
+
${hi('provenance')} ${dim(`v${VERSION}`)} — Provenance Protocol identity CLI
|
|
244
|
+
|
|
245
|
+
${amb('Commands:')}
|
|
246
|
+
${hi('keygen')} Generate an Ed25519 keypair
|
|
247
|
+
${hi('register')} --id <id> --url <url> Register or update your agent
|
|
248
|
+
[--name <name>]
|
|
249
|
+
[--description <text>]
|
|
250
|
+
[--capabilities read:web,write:code]
|
|
251
|
+
[--constraints no:pii,no:financial:transact]
|
|
252
|
+
[--model anthropic] [--model-id claude-sonnet-4-6]
|
|
253
|
+
[--ajp-endpoint <url>]
|
|
254
|
+
[--private-key <key>]
|
|
255
|
+
${hi('status')} <provenance_id> Check trust score and checklist
|
|
256
|
+
${hi('validate')} [file] Validate PROVENANCE.yml (default: ./PROVENANCE.yml)
|
|
257
|
+
${hi('revoke')} --id <id> Revoke cryptographic identity
|
|
258
|
+
[--private-key <key>]
|
|
259
|
+
|
|
260
|
+
${amb('Environment variables:')}
|
|
261
|
+
PROVENANCE_ID Your agent's Provenance ID
|
|
262
|
+
PROVENANCE_PRIVATE_KEY Your Ed25519 private key (base64 PKCS8 DER)
|
|
263
|
+
PROVENANCE_API_URL Override API base (default: https://getprovenance.dev)
|
|
264
|
+
|
|
265
|
+
${amb('For AJP job delegation:')}
|
|
266
|
+
${dim('npm install -g ajp-cli')}
|
|
267
|
+
${dim('npx ajp hire <id> --instruction "..."')}
|
|
268
|
+
|
|
269
|
+
${amb('Examples:')}
|
|
270
|
+
provenance keygen
|
|
271
|
+
provenance register --id provenance:github:alice/my-agent --url https://github.com/alice/my-agent
|
|
272
|
+
provenance status provenance:github:alice/my-agent
|
|
273
|
+
provenance validate
|
|
195
274
|
`);
|
|
196
|
-
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
const argv = process.argv.slice(2);
|
|
280
|
+
const args = parseArgs(argv);
|
|
281
|
+
const cmd = args._[0];
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
if (!cmd || cmd === 'help' || args.help) cmdHelp();
|
|
285
|
+
else if (cmd === 'keygen') await cmdKeygen();
|
|
286
|
+
else if (cmd === 'register') await cmdRegister(args);
|
|
287
|
+
else if (cmd === 'status') await cmdStatus(args);
|
|
288
|
+
else if (cmd === 'validate') await cmdValidate(args);
|
|
289
|
+
else if (cmd === 'revoke') await cmdRevoke(args);
|
|
290
|
+
else { console.error(err(`Unknown command: ${cmd}\nRun \`provenance help\` for usage.`)); process.exit(1); }
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error(err(e.message));
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
package/src/keygen.d.ts
CHANGED
|
@@ -46,3 +46,18 @@ export function signForProvenance(privateKeyBase64: string, provenanceId: string
|
|
|
46
46
|
* Send the result as signed_challenge to POST /api/agents/revoke.
|
|
47
47
|
*/
|
|
48
48
|
export function signRevocation(privateKeyBase64: string, provenanceId: string): string;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sign a whole declaration — spec 0.2.
|
|
52
|
+
*
|
|
53
|
+
* Covers every field, so deleting a constraint or adding a capability breaks the
|
|
54
|
+
* signature. `signForProvenance` (spec 0.1) covers only the identity and key.
|
|
55
|
+
*
|
|
56
|
+
* Signs the canonical form of the PARSED declaration, so reformatting the file
|
|
57
|
+
* does not invalidate the signature.
|
|
58
|
+
*
|
|
59
|
+
* @param privateKeyBase64 Your PROVENANCE_PRIVATE_KEY (base64 PKCS8 DER)
|
|
60
|
+
* @param declaration Parsed declaration; identity.signature is ignored
|
|
61
|
+
* @returns Base64 signature — put it in identity.signature
|
|
62
|
+
*/
|
|
63
|
+
export function signDeclaration(privateKeyBase64: string, declaration: object): string;
|
package/src/keygen.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { generateKeyPairSync, sign, createPrivateKey } from 'crypto';
|
|
28
|
+
import { declarationSigningPayload } from './canonical.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Generate a new Ed25519 keypair for use with Provenance identity.
|
|
@@ -134,6 +135,27 @@ export function signRevocation(privateKeyBase64, provenanceId) {
|
|
|
134
135
|
return signChallenge(privateKeyBase64, provenanceId, 'REVOKE');
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Sign a whole declaration — spec 0.2.
|
|
140
|
+
*
|
|
141
|
+
* Covers every field, so deleting a constraint or adding a capability breaks
|
|
142
|
+
* the signature. `signForProvenance` (spec 0.1) covers only the identity and
|
|
143
|
+
* key, which leaves the rest of the declaration unprotected; prefer this.
|
|
144
|
+
*
|
|
145
|
+
* Signs the canonical form of the PARSED declaration, so reformatting the file
|
|
146
|
+
* does not invalidate the signature.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} privateKeyBase64 Base64 PKCS8 DER private key
|
|
149
|
+
* @param {object} declaration Parsed declaration; identity.signature is ignored
|
|
150
|
+
* @returns {string} Base64 signature — put it in identity.signature
|
|
151
|
+
*/
|
|
152
|
+
export function signDeclaration(privateKeyBase64, declaration) {
|
|
153
|
+
const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
|
|
154
|
+
const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
|
|
155
|
+
const message = Buffer.from(declarationSigningPayload(declaration), 'utf8');
|
|
156
|
+
return sign(null, message, privateKey).toString('base64');
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
export function signForProvenance(privateKeyBase64, provenanceId, publicKeyBase64) {
|
|
138
160
|
const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
|
|
139
161
|
const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
|
package/src/verify.d.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
/** Whether a declaration was served from the location its provenance_id names. */
|
|
9
9
|
export type LocationCheck = 'match' | 'mismatch' | 'unchecked';
|
|
10
10
|
|
|
11
|
+
/** What a signature was found to cover, per the declaration's spec version. */
|
|
12
|
+
export type SignatureCoverage = 'declaration' | 'identity';
|
|
13
|
+
|
|
11
14
|
export interface VerificationResult {
|
|
12
15
|
/** An identity.signature was present to check. */
|
|
13
16
|
signed: boolean;
|
|
@@ -20,6 +23,13 @@ export interface VerificationResult {
|
|
|
20
23
|
/** SHA-256 of the public key, hex. Store it to detect key rotation. */
|
|
21
24
|
fingerprint: string | null;
|
|
22
25
|
location: LocationCheck;
|
|
26
|
+
/**
|
|
27
|
+
* 'declaration' (spec 0.2) — every field is covered; any edit breaks it.
|
|
28
|
+
* 'identity' (spec 0.1) — only the identity and key are covered, so the
|
|
29
|
+
* declared capabilities and constraints are NOT protected by the signature.
|
|
30
|
+
* null when no signature was checked.
|
|
31
|
+
*/
|
|
32
|
+
coverage: SignatureCoverage | null;
|
|
23
33
|
/**
|
|
24
34
|
* Signature valid AND retrieval location confirmed. Only both together
|
|
25
35
|
* justify treating the declaration as the named project owner's.
|
package/src/verify.js
CHANGED
|
@@ -16,9 +16,23 @@
|
|
|
16
16
|
* Uses the Web Crypto API (crypto.subtle): all modern browsers, Node 18+.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
import { declarationSigningPayload } from './canonical.js';
|
|
20
|
+
|
|
21
|
+
/** Signature algorithm. Ed25519 in every spec version so far. */
|
|
20
22
|
const ALGORITHM = 'ed25519';
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Which spec versions this module knows how to verify a signature for, and what
|
|
26
|
+
* the signature covers in each.
|
|
27
|
+
*
|
|
28
|
+
* 0.1 — signs "<provenance_id>:<public_key>". Proves key control under that
|
|
29
|
+
* identity. Does NOT cover the rest of the declaration: a constraint can
|
|
30
|
+
* be deleted and the signature still verifies.
|
|
31
|
+
* 0.2 — signs the canonical form of the whole declaration. Any change to any
|
|
32
|
+
* field breaks it.
|
|
33
|
+
*/
|
|
34
|
+
const SIGNATURE_COVERAGE = { '0.1': 'identity', '0.2': 'declaration' };
|
|
35
|
+
|
|
22
36
|
function subtle() {
|
|
23
37
|
const s = globalThis.crypto?.subtle;
|
|
24
38
|
if (!s) throw new Error('Web Crypto API (crypto.subtle) not available');
|
|
@@ -142,10 +156,14 @@ export function checkLocation(provenanceId, retrievedFrom) {
|
|
|
142
156
|
* when `retrievedFrom` is given — whether the file was served from the
|
|
143
157
|
* location it claims.
|
|
144
158
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
159
|
+
* What a valid signature proves depends on the spec version, and `coverage`
|
|
160
|
+
* reports which: 'declaration' (0.2) means every field is covered, so any edit
|
|
161
|
+
* breaks it; 'identity' (0.1) means only the identity and key are covered, so
|
|
162
|
+
* the declared capabilities and constraints are NOT protected by it.
|
|
163
|
+
*
|
|
164
|
+
* In neither case does a signature prove who the key holder is, that the
|
|
165
|
+
* declared capabilities are accurate, or that the declaration is current.
|
|
166
|
+
* Revocation and standing cannot be checked offline.
|
|
149
167
|
*
|
|
150
168
|
* @param {object} declaration Parsed PROVENANCE.yml
|
|
151
169
|
* @param {object} [options]
|
|
@@ -158,6 +176,7 @@ export function checkLocation(provenanceId, retrievedFrom) {
|
|
|
158
176
|
* publicKey: string | null,
|
|
159
177
|
* fingerprint: string | null,
|
|
160
178
|
* location: 'match' | 'mismatch' | 'unchecked',
|
|
179
|
+
* coverage: 'declaration' | 'identity' | null,
|
|
161
180
|
* trustworthy: boolean
|
|
162
181
|
* }>}
|
|
163
182
|
*/
|
|
@@ -170,6 +189,7 @@ export async function verifyDeclaration(declaration, options = {}) {
|
|
|
170
189
|
publicKey: null,
|
|
171
190
|
fingerprint: null,
|
|
172
191
|
location: 'unchecked',
|
|
192
|
+
coverage: null,
|
|
173
193
|
trustworthy: false,
|
|
174
194
|
};
|
|
175
195
|
|
|
@@ -213,13 +233,39 @@ export async function verifyDeclaration(declaration, options = {}) {
|
|
|
213
233
|
}
|
|
214
234
|
result.signed = true;
|
|
215
235
|
|
|
216
|
-
|
|
217
|
-
|
|
236
|
+
const declaredVersion = typeof declaration.provenance === 'string' ? declaration.provenance : '0.1';
|
|
237
|
+
if (!provenanceId && declaredVersion === '0.1') {
|
|
238
|
+
// The 0.1 payload is built from provenance_id, so without it there is
|
|
239
|
+
// nothing to verify. A 0.2 signature covers the whole declaration and does
|
|
240
|
+
// not need it (though the location check still does).
|
|
241
|
+
return { ...result, reason: 'provenance_id is required to verify a 0.1 signature' };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// What the signature covers depends on the spec version the declaration
|
|
245
|
+
// declares, so the payload is built differently for each.
|
|
246
|
+
const specVersion = typeof declaration.provenance === 'string' ? declaration.provenance : '0.1';
|
|
247
|
+
const coverage = SIGNATURE_COVERAGE[specVersion];
|
|
248
|
+
if (!coverage) {
|
|
249
|
+
return {
|
|
250
|
+
...result,
|
|
251
|
+
reason: `Spec version ${specVersion} is not known to this verifier — cannot check its signature`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
result.coverage = coverage;
|
|
255
|
+
|
|
256
|
+
let payload;
|
|
257
|
+
try {
|
|
258
|
+
payload =
|
|
259
|
+
coverage === 'declaration'
|
|
260
|
+
? declarationSigningPayload(declaration)
|
|
261
|
+
: `${provenanceId}:${publicKey}`;
|
|
262
|
+
} catch (e) {
|
|
263
|
+
return { ...result, reason: `Declaration cannot be canonicalised: ${e.message}` };
|
|
218
264
|
}
|
|
219
265
|
|
|
220
266
|
let valid;
|
|
221
267
|
try {
|
|
222
|
-
valid = await verifyEd25519(publicKey, signature,
|
|
268
|
+
valid = await verifyEd25519(publicKey, signature, payload);
|
|
223
269
|
} catch {
|
|
224
270
|
return { ...result, reason: 'identity.signature is malformed' };
|
|
225
271
|
}
|