ajp-protocol 0.1.0 → 0.2.2
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/.github/workflows/publish.yml +63 -4
- package/README.md +72 -22
- package/cli/index.js +231 -0
- package/cli/package.json +26 -0
- package/package.json +15 -5
- package/schema/job-offer.json +3 -3
- package/spec/SPEC.md +102 -23
- package/src/client.js +36 -16
- package/src/index.js +7 -0
- package/src/server.js +109 -29
- package/src/trust.js +205 -0
- package/src/utils.js +62 -10
- package/test/trust.test.mjs +88 -0
package/src/trust.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AJP — sender trust resolution
|
|
3
|
+
*
|
|
4
|
+
* Establishing who sent a job splits into two questions, and only one of them
|
|
5
|
+
* needs a network service:
|
|
6
|
+
*
|
|
7
|
+
* 1. IDENTITY — is this signature really from the party named in `from`?
|
|
8
|
+
* Answerable offline. The sender's declaration carries its public key and
|
|
9
|
+
* is itself signed; verifying it against the location it claims binds the
|
|
10
|
+
* key to the identity with no third party involved.
|
|
11
|
+
*
|
|
12
|
+
* 2. STANDING — is that party currently in good order? Revoked, open
|
|
13
|
+
* incidents, stale evidence. NOT answerable offline: absence of news
|
|
14
|
+
* cannot be carried in a document. This requires asking someone, and who
|
|
15
|
+
* to ask is the receiver's choice.
|
|
16
|
+
*
|
|
17
|
+
* Earlier versions collapsed both into one call to a single index, which made
|
|
18
|
+
* even the cryptography depend on one company's uptime. These resolvers keep
|
|
19
|
+
* them apart: identity never calls out to an index, and standing is opt-in.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { verifyDeclaration, keyFingerprint } from 'provenance-protocol/verify';
|
|
23
|
+
|
|
24
|
+
/** Thrown when a sender's identity cannot be established. */
|
|
25
|
+
export class SenderIdentityError extends Error {
|
|
26
|
+
constructor(message, code = 'SENDER_IDENTITY_FAILED') {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'SenderIdentityError';
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const MAX_DECLARATION_BYTES = 64 * 1024;
|
|
34
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a sender's public key from its own declaration, with no index.
|
|
38
|
+
*
|
|
39
|
+
* The sender points at where its declaration lives (`from.declaration_url`).
|
|
40
|
+
* Hosting a copy elsewhere does not help an impostor: the declaration names
|
|
41
|
+
* its own provenance id, and a declaration served from a location that does
|
|
42
|
+
* not match that id is rejected. Forging one is not possible without the
|
|
43
|
+
* genuine private key.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} [options]
|
|
46
|
+
* @param {(url: string) => Promise<string>} [options.fetchText] Override for tests.
|
|
47
|
+
* @param {Map<string,string>} [options.knownKeys] provenanceId -> key fingerprint
|
|
48
|
+
* seen before. A different key later is a rotation, and by default a
|
|
49
|
+
* rotation is refused rather than silently accepted.
|
|
50
|
+
* @param {boolean} [options.allowKeyRotation] Accept a changed key (default false).
|
|
51
|
+
* @param {(declaration: string) => unknown} [options.parseDeclaration] YAML parser.
|
|
52
|
+
* Declarations are YAML; AJP has no YAML dependency, so supply one to accept
|
|
53
|
+
* YAML declarations. Without it, only JSON declarations are read.
|
|
54
|
+
* @returns {(provenanceId: string, from: object) => Promise<{publicKey: string, fingerprint: string, source: string}>}
|
|
55
|
+
*/
|
|
56
|
+
export function declarationKeyResolver(options = {}) {
|
|
57
|
+
const {
|
|
58
|
+
fetchText = defaultFetchText,
|
|
59
|
+
knownKeys,
|
|
60
|
+
allowKeyRotation = false,
|
|
61
|
+
parseDeclaration,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
return async function resolve(provenanceId, from = {}) {
|
|
65
|
+
const url = from.declaration_url;
|
|
66
|
+
if (!url) {
|
|
67
|
+
throw new SenderIdentityError(
|
|
68
|
+
'Sender did not provide from.declaration_url, so its key cannot be established offline',
|
|
69
|
+
'NO_DECLARATION_URL'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let text;
|
|
74
|
+
try {
|
|
75
|
+
text = await fetchText(url);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
throw new SenderIdentityError(`Could not fetch sender declaration: ${e.message}`, 'DECLARATION_UNREACHABLE');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let declaration;
|
|
81
|
+
try {
|
|
82
|
+
declaration = parseDeclaration ? parseDeclaration(text) : JSON.parse(text);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new SenderIdentityError(
|
|
85
|
+
parseDeclaration
|
|
86
|
+
? 'Sender declaration could not be parsed'
|
|
87
|
+
: 'Sender declaration is not JSON; pass parseDeclaration to accept YAML',
|
|
88
|
+
'DECLARATION_UNPARSEABLE'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Verifies the signature against the key inside the file AND that the file
|
|
93
|
+
// was served from the location its own provenance id names.
|
|
94
|
+
const result = await verifyDeclaration(declaration, { retrievedFrom: url });
|
|
95
|
+
|
|
96
|
+
if (!result.valid) {
|
|
97
|
+
throw new SenderIdentityError(
|
|
98
|
+
`Sender declaration did not verify: ${result.reason ?? 'unknown reason'}`,
|
|
99
|
+
'DECLARATION_INVALID'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
if (result.location !== 'match') {
|
|
103
|
+
throw new SenderIdentityError(
|
|
104
|
+
'Sender declaration was not served from the location its provenance id names',
|
|
105
|
+
'DECLARATION_LOCATION_MISMATCH'
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
if (result.provenanceId !== provenanceId) {
|
|
109
|
+
throw new SenderIdentityError(
|
|
110
|
+
`Sender declaration is for ${result.provenanceId}, not ${provenanceId}`,
|
|
111
|
+
'DECLARATION_ID_MISMATCH'
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (knownKeys) {
|
|
116
|
+
const seen = knownKeys.get(provenanceId);
|
|
117
|
+
if (seen && seen !== result.fingerprint && !allowKeyRotation) {
|
|
118
|
+
throw new SenderIdentityError(
|
|
119
|
+
'Sender is signing with a different key than previously seen — treat as key rotation, not as a routine update',
|
|
120
|
+
'KEY_ROTATED'
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!seen) knownKeys.set(provenanceId, result.fingerprint);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { publicKey: result.publicKey, fingerprint: result.fingerprint, source: url };
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a sender's key from a Provenance index instead of its declaration.
|
|
132
|
+
*
|
|
133
|
+
* Kept for receivers that already trust an index and prefer one lookup to a
|
|
134
|
+
* fetch — but note this makes identity verification depend on that service
|
|
135
|
+
* being reachable, which `declarationKeyResolver` does not.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} provenanceClient An instance of Provenance from provenance-protocol
|
|
138
|
+
*/
|
|
139
|
+
export function indexKeyResolver(provenanceClient) {
|
|
140
|
+
return async function resolve(provenanceId) {
|
|
141
|
+
const profile = await provenanceClient.check(provenanceId).catch(() => null);
|
|
142
|
+
if (!profile?.found) {
|
|
143
|
+
throw new SenderIdentityError('Sender not found in the index', 'SENDER_NOT_INDEXED');
|
|
144
|
+
}
|
|
145
|
+
if (!profile.public_key) {
|
|
146
|
+
throw new SenderIdentityError('Sender has no public key in the index', 'NO_PUBLIC_KEY');
|
|
147
|
+
}
|
|
148
|
+
const fingerprint = await keyFingerprint(profile.public_key).catch(() => null);
|
|
149
|
+
return { publicKey: profile.public_key, fingerprint, source: 'index' };
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Try resolvers in order and use the first that succeeds.
|
|
155
|
+
*
|
|
156
|
+
* The useful arrangement is offline first, index second: identity is
|
|
157
|
+
* established without a network service whenever the sender supports it, and
|
|
158
|
+
* the index is a fallback rather than a requirement.
|
|
159
|
+
*/
|
|
160
|
+
export function firstResolver(...resolvers) {
|
|
161
|
+
const usable = resolvers.filter(Boolean);
|
|
162
|
+
return async function resolve(provenanceId, from) {
|
|
163
|
+
let last;
|
|
164
|
+
for (const resolver of usable) {
|
|
165
|
+
try {
|
|
166
|
+
return await resolver(provenanceId, from);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
last = e;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
throw last ?? new SenderIdentityError('No key resolver was configured', 'NO_RESOLVER');
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check a sender's current standing against a Provenance index.
|
|
177
|
+
*
|
|
178
|
+
* Opt-in on purpose. A receiver may use this, another attester, several, or
|
|
179
|
+
* none — standing is a policy question, not a protocol requirement.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} provenanceClient An instance of Provenance
|
|
182
|
+
* @param {object} [requirements] Passed through to gate()
|
|
183
|
+
*/
|
|
184
|
+
export function indexStandingCheck(provenanceClient, requirements = {}) {
|
|
185
|
+
return async function check(provenanceId) {
|
|
186
|
+
const result = await provenanceClient.gate(provenanceId, requirements);
|
|
187
|
+
return { allowed: result.allowed, reason: result.reason ?? null, fallback: result.fallback ?? false };
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function defaultFetchText(url) {
|
|
192
|
+
const parsed = new URL(url);
|
|
193
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
194
|
+
throw new Error(`Unsupported protocol: ${parsed.protocol}`);
|
|
195
|
+
}
|
|
196
|
+
const res = await fetch(url, {
|
|
197
|
+
redirect: 'error',
|
|
198
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
199
|
+
headers: { Accept: 'application/json, application/yaml, text/yaml, text/plain' },
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
202
|
+
const text = await res.text();
|
|
203
|
+
if (text.length > MAX_DECLARATION_BYTES) throw new Error('declaration too large');
|
|
204
|
+
return text;
|
|
205
|
+
}
|
package/src/utils.js
CHANGED
|
@@ -3,26 +3,33 @@
|
|
|
3
3
|
* Consistent with provenance-protocol SDK conventions.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import crypto from 'crypto';
|
|
6
|
+
import crypto, { createPrivateKey, createPublicKey, sign as nodeSign, verify as nodeVerify } from 'crypto';
|
|
7
7
|
|
|
8
8
|
// ── Signing ───────────────────────────────────────────────────────────────
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Canonical form for signing — sorts keys, excludes `signature` field.
|
|
12
|
+
* Used by both HMAC and Ed25519 paths for consistency.
|
|
13
|
+
*/
|
|
14
|
+
function _canonical(body) {
|
|
15
|
+
const { signature: _, ...rest } = body;
|
|
16
|
+
return JSON.stringify(rest, Object.keys(rest).sort());
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Sign a message body with HMAC-SHA256.
|
|
12
|
-
*
|
|
21
|
+
* Used for human callers (no Provenance identity).
|
|
13
22
|
*/
|
|
14
23
|
export function sign(body, secret) {
|
|
15
|
-
const
|
|
16
|
-
const canonical = JSON.stringify(rest, Object.keys(rest).sort());
|
|
17
|
-
const hash = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
|
|
24
|
+
const hash = crypto.createHmac('sha256', secret).update(_canonical(body)).digest('hex');
|
|
18
25
|
return `sha256:${hash}`;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
/**
|
|
22
|
-
* Verify
|
|
29
|
+
* Verify an HMAC-SHA256 signature. Returns true if valid.
|
|
23
30
|
*/
|
|
24
31
|
export function verify(body, secret) {
|
|
25
|
-
if (!body.signature) return false;
|
|
32
|
+
if (!body.signature?.startsWith('sha256:')) return false;
|
|
26
33
|
const expected = sign(body, secret);
|
|
27
34
|
return crypto.timingSafeEqual(
|
|
28
35
|
Buffer.from(body.signature),
|
|
@@ -30,16 +37,52 @@ export function verify(body, secret) {
|
|
|
30
37
|
);
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Sign a message body with Ed25519.
|
|
42
|
+
* Used by agents and orchestrators — consistent with provenance-protocol/keygen.js.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} body Message body (signature field excluded automatically)
|
|
45
|
+
* @param {string} privateKeyBase64 Base64 PKCS8 DER private key (PROVENANCE_PRIVATE_KEY)
|
|
46
|
+
* @returns {string} Signature string: "ed25519:<base64>"
|
|
47
|
+
*/
|
|
48
|
+
export function signWithKey(body, privateKeyBase64) {
|
|
49
|
+
const privateKey = createPrivateKey({
|
|
50
|
+
key: Buffer.from(privateKeyBase64, 'base64'),
|
|
51
|
+
format: 'der',
|
|
52
|
+
type: 'pkcs8',
|
|
53
|
+
});
|
|
54
|
+
const sig = nodeSign(null, Buffer.from(_canonical(body), 'utf8'), privateKey);
|
|
55
|
+
return `ed25519:${sig.toString('base64')}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Verify an Ed25519 signature using a public key from the Provenance index.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} body Message body including signature field
|
|
62
|
+
* @param {string} publicKeyBase64 Base64 SPKI DER public key (from Provenance profile)
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
export function verifyWithKey(body, publicKeyBase64) {
|
|
66
|
+
if (!body.signature?.startsWith('ed25519:')) return false;
|
|
67
|
+
const publicKey = createPublicKey({
|
|
68
|
+
key: Buffer.from(publicKeyBase64, 'base64'),
|
|
69
|
+
format: 'der',
|
|
70
|
+
type: 'spki',
|
|
71
|
+
});
|
|
72
|
+
const sigBuffer = Buffer.from(body.signature.slice('ed25519:'.length), 'base64');
|
|
73
|
+
return nodeVerify(null, Buffer.from(_canonical(body), 'utf8'), publicKey, sigBuffer);
|
|
74
|
+
}
|
|
75
|
+
|
|
33
76
|
// ── Job ID generation ─────────────────────────────────────────────────────
|
|
34
77
|
|
|
35
78
|
/**
|
|
36
79
|
* Generate a unique job ID.
|
|
37
|
-
* Format: job_ + timestamp_ms (base36) + random (base36)
|
|
80
|
+
* Format: job_ + timestamp_ms (base36) + cryptographically random suffix (base36)
|
|
38
81
|
* Sortable, URL-safe, no external deps.
|
|
39
82
|
*/
|
|
40
83
|
export function generateJobId() {
|
|
41
84
|
const ts = Date.now().toString(36);
|
|
42
|
-
const rand =
|
|
85
|
+
const rand = crypto.randomBytes(6).toString('hex');
|
|
43
86
|
return `job_${ts}${rand}`;
|
|
44
87
|
}
|
|
45
88
|
|
|
@@ -51,10 +94,12 @@ export function generateJobId() {
|
|
|
51
94
|
*/
|
|
52
95
|
export function validateOffer(offer) {
|
|
53
96
|
const errors = [];
|
|
97
|
+
const warnings = [];
|
|
54
98
|
|
|
55
99
|
if (!offer.ajp) errors.push('missing: ajp');
|
|
56
100
|
if (!offer.job_id) errors.push('missing: job_id');
|
|
57
101
|
if (!offer.from?.type) errors.push('missing: from.type');
|
|
102
|
+
else if (!['human', 'agent', 'orchestrator'].includes(offer.from.type)) errors.push('from.type must be human, agent, or orchestrator');
|
|
58
103
|
if (!offer.to?.provenance_id) errors.push('missing: to.provenance_id');
|
|
59
104
|
if (!offer.task?.type) errors.push('missing: task.type');
|
|
60
105
|
if (!offer.task?.instruction) errors.push('missing: task.instruction');
|
|
@@ -65,6 +110,13 @@ export function validateOffer(offer) {
|
|
|
65
110
|
|
|
66
111
|
if (offer.from?.type === 'agent' || offer.from?.type === 'orchestrator') {
|
|
67
112
|
if (!offer.from.provenance_id) errors.push('from.provenance_id required when type is agent/orchestrator');
|
|
113
|
+
// declaration_url is optional on purpose — a receiver configured with an
|
|
114
|
+
// index resolver does not need it. But without it, that receiver cannot
|
|
115
|
+
// establish this sender's key without consulting a third party, so the
|
|
116
|
+
// offer may be refused. Surfaced as a warning, never a validation failure.
|
|
117
|
+
if (!offer.from.declaration_url) {
|
|
118
|
+
warnings.push('from.declaration_url absent — receivers cannot verify this sender offline and may refuse the offer');
|
|
119
|
+
}
|
|
68
120
|
}
|
|
69
121
|
|
|
70
122
|
// Check expiry
|
|
@@ -72,7 +124,7 @@ export function validateOffer(offer) {
|
|
|
72
124
|
errors.push('offer has expired');
|
|
73
125
|
}
|
|
74
126
|
|
|
75
|
-
return { valid: errors.length === 0, errors };
|
|
127
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
76
128
|
}
|
|
77
129
|
|
|
78
130
|
// ── Status helpers ────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { generateProvenanceKeyPair, signForProvenance } from 'provenance-protocol/keygen';
|
|
2
|
+
import { declarationKeyResolver, SenderIdentityError } from '../src/trust.js';
|
|
3
|
+
|
|
4
|
+
let pass = 0, fail = 0;
|
|
5
|
+
const t = (name, ok, detail = '') => {
|
|
6
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ' ' + detail}`);
|
|
7
|
+
ok ? pass++ : fail++;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Alice publishes a signed declaration in her own repo.
|
|
11
|
+
const ALICE = 'provenance:github:alice/research-agent';
|
|
12
|
+
const alice = generateProvenanceKeyPair();
|
|
13
|
+
const aliceDecl = {
|
|
14
|
+
provenance: '0.1', name: 'Research Agent', description: 'Searches and summarises.',
|
|
15
|
+
provenance_id: ALICE,
|
|
16
|
+
identity: {
|
|
17
|
+
public_key: alice.publicKey,
|
|
18
|
+
signature: signForProvenance(alice.privateKey, ALICE, alice.publicKey),
|
|
19
|
+
algorithm: 'ed25519',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const ALICE_URL = 'https://raw.githubusercontent.com/alice/research-agent/main/PROVENANCE.json';
|
|
23
|
+
|
|
24
|
+
// A fake network: url -> body
|
|
25
|
+
const net = new Map([[ALICE_URL, JSON.stringify(aliceDecl)]]);
|
|
26
|
+
const fetchText = async (url) => {
|
|
27
|
+
if (!net.has(url)) throw new Error('404');
|
|
28
|
+
return net.get(url);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const resolve = declarationKeyResolver({ fetchText });
|
|
32
|
+
|
|
33
|
+
// 1. Honest sender resolves offline, no index anywhere.
|
|
34
|
+
const ok1 = await resolve(ALICE, { declaration_url: ALICE_URL });
|
|
35
|
+
t('honest sender resolves offline', ok1.publicKey === alice.publicKey && !!ok1.fingerprint);
|
|
36
|
+
|
|
37
|
+
// 2. No declaration_url -> refused with a specific code.
|
|
38
|
+
try { await resolve(ALICE, {}); t('missing declaration_url refused', false); }
|
|
39
|
+
catch (e) { t('missing declaration_url refused', e.code === 'NO_DECLARATION_URL', e.code); }
|
|
40
|
+
|
|
41
|
+
// 3. Impostor re-hosts Alice's genuine declaration on their own server.
|
|
42
|
+
const EVIL_URL = 'https://evil.example/copied/PROVENANCE.json';
|
|
43
|
+
net.set(EVIL_URL, JSON.stringify(aliceDecl));
|
|
44
|
+
try { await resolve(ALICE, { declaration_url: EVIL_URL }); t('re-hosted declaration refused', false); }
|
|
45
|
+
catch (e) { t('re-hosted declaration refused', e.code === 'DECLARATION_LOCATION_MISMATCH', e.code); }
|
|
46
|
+
|
|
47
|
+
// 4. Impostor forges their own key under Alice's id.
|
|
48
|
+
const evil = generateProvenanceKeyPair();
|
|
49
|
+
const forged = { ...aliceDecl, identity: {
|
|
50
|
+
public_key: evil.publicKey,
|
|
51
|
+
signature: signForProvenance(evil.privateKey, ALICE, evil.publicKey),
|
|
52
|
+
algorithm: 'ed25519' } };
|
|
53
|
+
const FORGED_URL = 'https://raw.githubusercontent.com/evil/fork/main/PROVENANCE.json';
|
|
54
|
+
net.set(FORGED_URL, JSON.stringify(forged));
|
|
55
|
+
try { await resolve(ALICE, { declaration_url: FORGED_URL }); t('forged key at wrong location refused', false); }
|
|
56
|
+
catch (e) { t('forged key at wrong location refused', e.code === 'DECLARATION_LOCATION_MISMATCH', e.code); }
|
|
57
|
+
|
|
58
|
+
// 5. Tampered declaration (constraints added after signing) fails verification.
|
|
59
|
+
const tampered = { ...aliceDecl, provenance_id: 'provenance:github:alice/research-agent' , identity: { ...aliceDecl.identity, public_key: evil.publicKey } };
|
|
60
|
+
net.set('https://raw.githubusercontent.com/alice/research-agent/main/TAMPERED.json', JSON.stringify(tampered));
|
|
61
|
+
try {
|
|
62
|
+
await resolve(ALICE, { declaration_url: 'https://raw.githubusercontent.com/alice/research-agent/main/TAMPERED.json' });
|
|
63
|
+
t('tampered declaration refused', false);
|
|
64
|
+
} catch (e) { t('tampered declaration refused', e.code === 'DECLARATION_INVALID', e.code); }
|
|
65
|
+
|
|
66
|
+
// 6. Key rotation is flagged, not silently accepted.
|
|
67
|
+
const known = new Map();
|
|
68
|
+
const pinning = declarationKeyResolver({ fetchText, knownKeys: known });
|
|
69
|
+
await pinning(ALICE, { declaration_url: ALICE_URL });
|
|
70
|
+
const rotated = generateProvenanceKeyPair();
|
|
71
|
+
net.set(ALICE_URL, JSON.stringify({ ...aliceDecl, identity: {
|
|
72
|
+
public_key: rotated.publicKey,
|
|
73
|
+
signature: signForProvenance(rotated.privateKey, ALICE, rotated.publicKey),
|
|
74
|
+
algorithm: 'ed25519' } }));
|
|
75
|
+
try { await pinning(ALICE, { declaration_url: ALICE_URL }); t('key rotation refused by default', false); }
|
|
76
|
+
catch (e) { t('key rotation refused by default', e.code === 'KEY_ROTATED', e.code); }
|
|
77
|
+
|
|
78
|
+
// 7. Rotation accepted when explicitly allowed.
|
|
79
|
+
const allowing = declarationKeyResolver({ fetchText, knownKeys: new Map([[ALICE, 'old']]), allowKeyRotation: true });
|
|
80
|
+
const ok7 = await allowing(ALICE, { declaration_url: ALICE_URL });
|
|
81
|
+
t('rotation accepted when allowed', ok7.publicKey === rotated.publicKey);
|
|
82
|
+
|
|
83
|
+
// 8. Unreachable declaration is its own error, distinct from invalid.
|
|
84
|
+
try { await resolve(ALICE, { declaration_url: 'https://nowhere.example/x.json' }); t('unreachable is distinct from invalid', false); }
|
|
85
|
+
catch (e) { t('unreachable is distinct from invalid', e.code === 'DECLARATION_UNREACHABLE', e.code); }
|
|
86
|
+
|
|
87
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
88
|
+
process.exit(fail ? 1 : 0);
|