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/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
- * Excludes the `signature` field from the hash input.
21
+ * Used for human callers (no Provenance identity).
13
22
  */
14
23
  export function sign(body, secret) {
15
- const { signature: _, ...rest } = body;
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 a signed message. Returns true if valid.
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 = Math.random().toString(36).slice(2, 10);
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);