ajp-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/src/client.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { sign, signWithKey, generateJobId, validateOffer } from './utils.js';
9
- import { Provenance } from 'provenance-protocol';
9
+ import { declarationEndpointResolver } from './trust.js';
10
10
 
11
11
  export class AJPClient {
12
12
 
@@ -24,15 +24,25 @@ export class AJPClient {
24
24
  * Required for agent/orchestrator senders. Allows any
25
25
  * indexed agent to call any other without a shared secret.
26
26
  * @param {string} [opts.secret] — HMAC secret. Required for human senders only.
27
- * @param {string} [opts.provenanceApiUrl] — override Provenance API URL
27
+ * @param {function} [opts.resolveEndpoint] — async (provenanceId) => endpoint base URL.
28
+ * Defaults to reading `ajp.endpoint` from the recipient's own signed
29
+ * declaration, verified offline. No index is consulted unless you
30
+ * pass indexEndpointResolver.
28
31
  * @param {number} [opts.defaultTimeoutMs] — default job timeout in ms (30s)
29
32
  */
30
- constructor({ from, privateKey, secret, provenanceApiUrl, defaultTimeoutMs = 30000 }) {
33
+ constructor({ from, privateKey, secret, resolveEndpoint, defaultTimeoutMs = 30000, ...rest }) {
34
+ if ('provenanceApiUrl' in rest) {
35
+ // Ignoring it would silently change where jobs are routed.
36
+ throw new Error(
37
+ 'provenanceApiUrl was removed in ajp-protocol 0.3: recipients are resolved from their own declarations. ' +
38
+ 'To use an index, pass resolveEndpoint: indexEndpointResolver(new Provenance({ apiUrl })).'
39
+ );
40
+ }
31
41
  this.from = from;
32
42
  this.privateKey = privateKey || null;
33
43
  this.secret = secret || null;
34
44
  this.defaultTimeoutMs = defaultTimeoutMs;
35
- this.provenance = new Provenance({ apiUrl: provenanceApiUrl });
45
+ this.resolveEndpoint = resolveEndpoint ?? declarationEndpointResolver();
36
46
 
37
47
  if (from.type === 'agent' || from.type === 'orchestrator') {
38
48
  if (!from.provenance_id) throw new Error('from.provenance_id required when type is agent or orchestrator');
@@ -56,11 +66,13 @@ export class AJPClient {
56
66
  * @param {object} [opts.context] — { credentials?, memory?, constraints? }
57
67
  * @param {object} [opts.callback] — { url, headers? } for async delivery
58
68
  * @param {number} [opts.pollIntervalMs] — how often to poll for result (2000)
69
+ * @param {string} [opts.endpoint] — send here instead of resolving the recipient
59
70
  * @returns {Promise<JobResult>}
60
71
  */
61
72
  async send(toProvenanceId, task, budget = {}, opts = {}) {
62
- // Resolve the agent's AJP endpoint from Provenance
63
- const endpoint = await this._resolveEndpoint(toProvenanceId);
73
+ const endpoint = opts.endpoint
74
+ ? opts.endpoint.replace(/\/$/, '')
75
+ : await this.resolveEndpoint(toProvenanceId);
64
76
 
65
77
  // Build the job offer
66
78
  const jobId = generateJobId();
@@ -188,29 +200,5 @@ export class AJPClient {
188
200
  throw new Error(`Job timed out after ${timeoutMs}ms`);
189
201
  }
190
202
 
191
- // ── Endpoint resolution ───────────────────────────────────────────────
192
-
193
- async _resolveEndpoint(provenanceId) {
194
- try {
195
- const profile = await this.provenance.check(provenanceId);
196
- if (!profile.found) throw new Error(`Agent not found in Provenance index: ${provenanceId}`);
197
-
198
- // AJP endpoint is stored in the agent's PROVENANCE.yml
199
- const endpoint = profile.provenance_yml?.ajp?.endpoint;
200
- if (endpoint) return endpoint.replace(/\/$/, '');
201
-
202
- // Fallback: derive from agent URL — unreliable, agent should declare ajp.endpoint in PROVENANCE.yml
203
- if (profile.url) {
204
- console.warn(`[AJP] No ajp.endpoint declared for ${provenanceId} — falling back to ${profile.url}/api/agent. Add ajp.endpoint to PROVENANCE.yml for reliability.`);
205
- return `${profile.url.replace(/\/$/, '')}/api/agent`;
206
- }
207
-
208
- throw new Error(`No AJP endpoint found for ${provenanceId}`);
209
- } catch (e) {
210
- if (e.message.includes('No AJP endpoint')) throw e;
211
- throw new Error(`Could not resolve endpoint for ${provenanceId}: ${e.message}`);
212
- }
213
- }
214
-
215
203
  _sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
216
204
  }
package/src/index.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * ajp-protocol
3
3
  *
4
4
  * The Agent Job Protocol — standard interaction layer for the agent internet.
5
- * Part of the Provenance Protocol family.
5
+ * Builds on the Provenance Protocol: identities are provenance ids, and both
6
+ * sides find and verify each other from their own signed declarations.
6
7
  *
7
8
  * npm install ajp-protocol
8
9
  *
@@ -21,10 +22,13 @@
21
22
 
22
23
  export { AJPClient } from './client.js';
23
24
  export { AJPServer } from './server.js';
24
- export { sign, verify, generateJobId, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
25
+ export { sign, verify, signWithKey, verifyWithKey, generateJobId, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
25
26
  export {
26
27
  declarationKeyResolver,
28
+ declarationEndpointResolver,
27
29
  indexKeyResolver,
30
+ indexEndpointResolver,
31
+ RecipientResolutionError,
28
32
  firstResolver,
29
33
  indexStandingCheck,
30
34
  SenderIdentityError,
package/src/server.js CHANGED
@@ -6,8 +6,7 @@
6
6
  * Express, Next.js API routes, Fastify, or any Node HTTP framework.
7
7
  */
8
8
 
9
- import { verify, verifyWithKey, sign, signWithKey, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
10
- import { Provenance } from 'provenance-protocol';
9
+ import { verify, verifyWithKey, isLegacySignature, sign, signWithKey, validateOffer, JOB_STATUS, FROM_TYPE } from './utils.js';
11
10
  import { declarationKeyResolver, SenderIdentityError } from './trust.js';
12
11
 
13
12
  export class AJPServer {
@@ -37,7 +36,6 @@ export class AJPServer {
37
36
  * @param {string[]} [opts.constraints] — constraints this agent honors (from PROVENANCE.yml).
38
37
  * Included as `constraints_asserted` in every signed JobResult,
39
38
  * creating a cryptographic receipt tied to the registered identity.
40
- * @param {string} [opts.provenanceApiUrl] — override Provenance API URL
41
39
  */
42
40
  constructor({
43
41
  provenanceId,
@@ -46,11 +44,17 @@ export class AJPServer {
46
44
  onJob,
47
45
  constraints = [],
48
46
  trustRequirements = {},
49
- provenanceApiUrl,
50
47
  resolveSenderKey,
51
48
  checkStanding,
52
49
  onStandingUnavailable = 'deny',
50
+ ...rest
53
51
  }) {
52
+ if ('provenanceApiUrl' in rest) {
53
+ throw new Error(
54
+ 'provenanceApiUrl was removed in ajp-protocol 0.3: sender keys come from their own declarations. ' +
55
+ 'To use an index, pass resolveSenderKey / checkStanding built from new Provenance({ apiUrl }).'
56
+ );
57
+ }
54
58
  if (!privateKey) throw new Error('privateKey (PROVENANCE_PRIVATE_KEY) required — used to sign job results');
55
59
  this.provenanceId = provenanceId;
56
60
  this.privateKey = privateKey;
@@ -58,13 +62,24 @@ export class AJPServer {
58
62
  this.onJob = onJob;
59
63
  this.constraints = constraints;
60
64
  this.trustRequirements = trustRequirements;
61
- this.provenance = new Provenance({ apiUrl: provenanceApiUrl });
62
65
  // Identity resolution never touches an index by default: a signature check
63
66
  // that depends on someone's web service being up is not a signature check.
64
67
  this.resolveSenderKey = resolveSenderKey ?? declarationKeyResolver();
65
68
  this.checkStanding = checkStanding ?? null;
66
69
  this.onStandingUnavailable = onStandingUnavailable;
67
70
 
71
+ // Requirements about standing (incidents, age) can only be answered by
72
+ // someone you ask. Accepting them with nobody to ask would ignore them
73
+ // while looking configured, so refuse at startup instead.
74
+ const needsStanding = ['requireClean', 'requireMinAge', 'requireMinConfidence', 'requireVerified']
75
+ .filter((k) => trustRequirements[k]);
76
+ if (needsStanding.length && !this.checkStanding) {
77
+ throw new Error(
78
+ `trustRequirements.${needsStanding.join(', ')} need a standing source: pass checkStanding ` +
79
+ '(e.g. indexStandingCheck(new Provenance({ apiUrl }))), or remove them.'
80
+ );
81
+ }
82
+
68
83
  // In-memory job store — replace with DB for production
69
84
  this.jobs = new Map();
70
85
  }
@@ -89,7 +104,7 @@ export class AJPServer {
89
104
  return this._json(res, 403, { error: 'This agent does not accept human callers' });
90
105
  }
91
106
  if (!verify(offer, this.secret)) {
92
- return this._json(res, 401, { error: 'Invalid signature' });
107
+ return this._json(res, 401, this._badSignature(offer, { secret: this.secret }));
93
108
  }
94
109
  } else {
95
110
  // Agent/orchestrator callers: Ed25519. The key comes from whatever the
@@ -106,7 +121,28 @@ export class AJPServer {
106
121
  return this._json(res, 403, { error: 'Sender identity could not be established', reason: 'No public key resolved' });
107
122
  }
108
123
  if (!verifyWithKey(offer, sender.publicKey)) {
109
- return this._json(res, 401, { error: 'Invalid signature' });
124
+ return this._json(res, 401, this._badSignature(offer, { publicKey: sender.publicKey }));
125
+ }
126
+
127
+ // Declared commitments are checked against the sender's own signed
128
+ // declaration when the resolver supplied one — no index needed. With
129
+ // no declaration and no standing source they cannot be checked at
130
+ // all, and are refused rather than skipped.
131
+ const { requireConstraints = [], requireCapabilities = [] } = this.trustRequirements;
132
+ if (requireConstraints.length || requireCapabilities.length) {
133
+ if (sender.declaration) {
134
+ const has = (list, v) => Array.isArray(list) && list.includes(v);
135
+ const missingC = requireConstraints.find((c) => !has(sender.declaration.constraints, c));
136
+ if (missingC) return this._json(res, 403, { error: 'Trust check failed', reason: `Sender has not committed to constraint: ${missingC}` });
137
+ const missingK = requireCapabilities.find((c) => !has(sender.declaration.capabilities, c));
138
+ if (missingK) return this._json(res, 403, { error: 'Trust check failed', reason: `Sender does not declare capability: ${missingK}` });
139
+ } else if (!this.checkStanding) {
140
+ return this._json(res, 403, {
141
+ error: 'Trust requirements could not be checked',
142
+ reason: 'No sender declaration was resolved and no standing source is configured',
143
+ code: 'REQUIREMENTS_UNCHECKABLE',
144
+ });
145
+ }
110
146
  }
111
147
  }
112
148
 
@@ -259,7 +295,7 @@ export class AJPServer {
259
295
  job.updated_at = job.completed_at;
260
296
  job.usage.duration_seconds = durationSeconds;
261
297
 
262
- // Sign the result with Ed25519 — callers verify using this agent's public key from Provenance index.
298
+ // Sign the result with Ed25519 — callers verify it against the public key in this agent's declaration.
263
299
  // constraints_asserted is included in the signed payload — a cryptographic receipt of declared behavior.
264
300
  job.signature = signWithKey({
265
301
  job_id: job.job_id,
@@ -322,6 +358,17 @@ export class AJPServer {
322
358
  res.end(JSON.stringify(body));
323
359
  }
324
360
 
361
+ _badSignature(offer, keys) {
362
+ if (isLegacySignature(offer, keys)) {
363
+ return {
364
+ error: 'Invalid signature',
365
+ code: 'LEGACY_SIGNATURE',
366
+ reason: 'Signed by ajp-protocol < 0.3, whose signatures do not cover the job contents. The sender must upgrade.',
367
+ };
368
+ }
369
+ return { error: 'Invalid signature' };
370
+ }
371
+
325
372
  async _parseBody(req) {
326
373
  // Next.js App Router: req.json()
327
374
  if (typeof req.json === 'function') return req.json();
package/src/trust.js CHANGED
@@ -19,7 +19,7 @@
19
19
  * them apart: identity never calls out to an index, and standing is opt-in.
20
20
  */
21
21
 
22
- import { verifyDeclaration, keyFingerprint } from 'provenance-protocol/verify';
22
+ import { verifyDeclaration, keyFingerprint, locateDeclaration } from 'provenance-protocol/verify';
23
23
 
24
24
  /** Thrown when a sender's identity cannot be established. */
25
25
  export class SenderIdentityError extends Error {
@@ -36,7 +36,8 @@ const FETCH_TIMEOUT_MS = 8000;
36
36
  /**
37
37
  * Resolve a sender's public key from its own declaration, with no index.
38
38
  *
39
- * The sender points at where its declaration lives (`from.declaration_url`).
39
+ * The sender points at where its declaration lives (`from.declaration_url`),
40
+ * or — for domain and GitHub ids — the id itself names the standard location.
40
41
  * Hosting a copy elsewhere does not help an impostor: the declaration names
41
42
  * its own provenance id, and a declaration served from a location that does
42
43
  * not match that id is rejected. Forging one is not possible without the
@@ -51,7 +52,7 @@ const FETCH_TIMEOUT_MS = 8000;
51
52
  * @param {(declaration: string) => unknown} [options.parseDeclaration] YAML parser.
52
53
  * Declarations are YAML; AJP has no YAML dependency, so supply one to accept
53
54
  * YAML declarations. Without it, only JSON declarations are read.
54
- * @returns {(provenanceId: string, from: object) => Promise<{publicKey: string, fingerprint: string, source: string}>}
55
+ * @returns {(provenanceId: string, from: object) => Promise<{publicKey: string, fingerprint: string, source: string, declaration: object}>}
55
56
  */
56
57
  export function declarationKeyResolver(options = {}) {
57
58
  const {
@@ -62,55 +63,17 @@ export function declarationKeyResolver(options = {}) {
62
63
  } = options;
63
64
 
64
65
  return async function resolve(provenanceId, from = {}) {
65
- const url = from.declaration_url;
66
+ // The sender may say where its declaration is; otherwise its provenance id
67
+ // names the standard location.
68
+ const url = from.declaration_url || locateDeclaration(provenanceId);
66
69
  if (!url) {
67
70
  throw new SenderIdentityError(
68
- 'Sender did not provide from.declaration_url, so its key cannot be established offline',
71
+ 'Sender gave no from.declaration_url and its provenance id has no standard location, so its key cannot be established offline',
69
72
  'NO_DECLARATION_URL'
70
73
  );
71
74
  }
72
75
 
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
- }
76
+ const result = await fetchVerifiedDeclaration(url, provenanceId, { fetchText, parseDeclaration }, SenderIdentityError, 'Sender');
114
77
 
115
78
  if (knownKeys) {
116
79
  const seen = knownKeys.get(provenanceId);
@@ -123,7 +86,7 @@ export function declarationKeyResolver(options = {}) {
123
86
  if (!seen) knownKeys.set(provenanceId, result.fingerprint);
124
87
  }
125
88
 
126
- return { publicKey: result.publicKey, fingerprint: result.fingerprint, source: url };
89
+ return { publicKey: result.publicKey, fingerprint: result.fingerprint, source: url, declaration: result.declaration };
127
90
  };
128
91
  }
129
92
 
@@ -134,7 +97,7 @@ export function declarationKeyResolver(options = {}) {
134
97
  * fetch — but note this makes identity verification depend on that service
135
98
  * being reachable, which `declarationKeyResolver` does not.
136
99
  *
137
- * @param {object} provenanceClient An instance of Provenance from provenance-protocol
100
+ * @param {object} provenanceClient new Provenance({ apiUrl }) from provenance-protocol/index-client
138
101
  */
139
102
  export function indexKeyResolver(provenanceClient) {
140
103
  return async function resolve(provenanceId) {
@@ -178,7 +141,7 @@ export function firstResolver(...resolvers) {
178
141
  * Opt-in on purpose. A receiver may use this, another attester, several, or
179
142
  * none — standing is a policy question, not a protocol requirement.
180
143
  *
181
- * @param {object} provenanceClient An instance of Provenance
144
+ * @param {object} provenanceClient new Provenance({ apiUrl }) from provenance-protocol/index-client
182
145
  * @param {object} [requirements] Passed through to gate()
183
146
  */
184
147
  export function indexStandingCheck(provenanceClient, requirements = {}) {
@@ -188,6 +151,126 @@ export function indexStandingCheck(provenanceClient, requirements = {}) {
188
151
  };
189
152
  }
190
153
 
154
+ /**
155
+ * Fetch a declaration, verify its signature, and confirm it was served from the
156
+ * location its own provenance id names and is for the agent expected.
157
+ * Throws `ErrorType` with a specific code for each way it can fail.
158
+ */
159
+ async function fetchVerifiedDeclaration(url, provenanceId, { fetchText, parseDeclaration }, ErrorType, who) {
160
+ let text;
161
+ try {
162
+ text = await fetchText(url);
163
+ } catch (e) {
164
+ throw new ErrorType(`Could not fetch ${who.toLowerCase()} declaration: ${e.message}`, 'DECLARATION_UNREACHABLE');
165
+ }
166
+
167
+ let declaration;
168
+ try {
169
+ declaration = parseDeclaration ? parseDeclaration(text) : JSON.parse(text);
170
+ } catch {
171
+ throw new ErrorType(
172
+ parseDeclaration
173
+ ? `${who} declaration could not be parsed`
174
+ : `${who} declaration is not JSON; pass parseDeclaration to accept YAML`,
175
+ 'DECLARATION_UNPARSEABLE'
176
+ );
177
+ }
178
+
179
+ // Verifies the signature against the key inside the file AND that the file
180
+ // was served from the location its own provenance id names.
181
+ const result = await verifyDeclaration(declaration, { retrievedFrom: url });
182
+
183
+ if (!result.valid) {
184
+ throw new ErrorType(`${who} declaration did not verify: ${result.reason ?? 'unknown reason'}`, 'DECLARATION_INVALID');
185
+ }
186
+ if (result.location !== 'match') {
187
+ throw new ErrorType(`${who} declaration was not served from the location its provenance id names`, 'DECLARATION_LOCATION_MISMATCH');
188
+ }
189
+ if (result.provenanceId !== provenanceId) {
190
+ throw new ErrorType(`${who} declaration is for ${result.provenanceId}, not ${provenanceId}`, 'DECLARATION_ID_MISMATCH');
191
+ }
192
+ return { ...result, declaration };
193
+ }
194
+
195
+ /** Thrown when the agent a job is addressed to cannot be located. */
196
+ export class RecipientResolutionError extends Error {
197
+ constructor(message, code = 'RECIPIENT_UNRESOLVED') {
198
+ super(message);
199
+ this.name = 'RecipientResolutionError';
200
+ this.code = code;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Find where to send a job from the recipient's own declaration, with no index.
206
+ *
207
+ * The declaration is found from the provenance id alone (a domain id at
208
+ * /.well-known/provenance.json, a GitHub id at PROVENANCE.yml), verified, and
209
+ * its `ajp.endpoint` used. Because the declaration is signed and tied to its
210
+ * location, an endpoint read this way is the operator's, not a third party's
211
+ * record of it.
212
+ *
213
+ * @param {object} [options]
214
+ * @param {Record<string,string>} [options.declarationUrls] provenanceId -> URL, for
215
+ * platforms without a standard location (npm, pypi, …)
216
+ * @param {(url: string) => Promise<string>} [options.fetchText]
217
+ * @param {(text: string) => unknown} [options.parseDeclaration] YAML parser, needed
218
+ * for GitHub-hosted declarations
219
+ * @returns {(provenanceId: string) => Promise<string>} the endpoint base URL
220
+ */
221
+ export function declarationEndpointResolver(options = {}) {
222
+ const { declarationUrls = {}, fetchText = defaultFetchText, parseDeclaration } = options;
223
+
224
+ return async function resolveEndpoint(provenanceId) {
225
+ const url = declarationUrls[provenanceId] ?? locateDeclaration(provenanceId);
226
+ if (!url) {
227
+ throw new RecipientResolutionError(
228
+ `${provenanceId} has no standard declaration location; supply it in declarationUrls`,
229
+ 'NO_DECLARATION_LOCATION'
230
+ );
231
+ }
232
+ const { declaration } = await fetchVerifiedDeclaration(
233
+ url, provenanceId, { fetchText, parseDeclaration }, RecipientResolutionError, 'Recipient'
234
+ );
235
+
236
+ const endpoint = declaration.ajp?.endpoint;
237
+ if (typeof endpoint !== 'string' || !endpoint) {
238
+ throw new RecipientResolutionError(`${provenanceId} does not declare ajp.endpoint`, 'NO_AJP_ENDPOINT');
239
+ }
240
+ let parsed;
241
+ try { parsed = new URL(endpoint); } catch {
242
+ throw new RecipientResolutionError(`${provenanceId} declares an ajp.endpoint that is not a URL`, 'BAD_AJP_ENDPOINT');
243
+ }
244
+ if (parsed.protocol !== 'https:') {
245
+ throw new RecipientResolutionError(`${provenanceId} declares a non-HTTPS ajp.endpoint`, 'BAD_AJP_ENDPOINT');
246
+ }
247
+ return endpoint.replace(/\/$/, '');
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Find where to send a job by asking an index you choose.
253
+ *
254
+ * Makes sending depend on that index being up and correct. Prefer
255
+ * `declarationEndpointResolver`; use this as a fallback with `firstResolver`.
256
+ *
257
+ * @param {object} indexClient new Provenance({ apiUrl }) from provenance-protocol/index-client
258
+ */
259
+ export function indexEndpointResolver(indexClient) {
260
+ return async function resolveEndpoint(provenanceId) {
261
+ let profile;
262
+ try {
263
+ profile = await indexClient.check(provenanceId);
264
+ } catch (e) {
265
+ throw new RecipientResolutionError(`Index lookup failed: ${e.message}`, 'INDEX_UNAVAILABLE');
266
+ }
267
+ if (!profile?.found) throw new RecipientResolutionError(`${provenanceId} is not in the index`, 'NOT_INDEXED');
268
+ const endpoint = profile.provenance_yml?.ajp?.endpoint ?? profile.ajp_endpoint;
269
+ if (!endpoint) throw new RecipientResolutionError(`The index has no ajp.endpoint for ${provenanceId}`, 'NO_AJP_ENDPOINT');
270
+ return endpoint.replace(/\/$/, '');
271
+ };
272
+ }
273
+
191
274
  async function defaultFetchText(url) {
192
275
  const parsed = new URL(url);
193
276
  if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
package/src/utils.js CHANGED
@@ -4,14 +4,26 @@
4
4
  */
5
5
 
6
6
  import crypto, { createPrivateKey, createPublicKey, sign as nodeSign, verify as nodeVerify } from 'crypto';
7
+ import { canonicalJson } from 'provenance-protocol';
7
8
 
8
9
  // ── Signing ───────────────────────────────────────────────────────────────
9
10
 
10
11
  /**
11
- * Canonical form for signing — sorts keys, excludes `signature` field.
12
- * Used by both HMAC and Ed25519 paths for consistency.
12
+ * Canonical form for signing: the whole body except `signature`, with keys
13
+ * sorted at every depth. Used by both HMAC and Ed25519 paths.
14
+ *
15
+ * Before 0.3 this passed the sorted top-level keys to JSON.stringify as a
16
+ * replacer — which is an allow-list applied at every depth, so every nested
17
+ * field was dropped from the signed bytes. The task, budget, sender and
18
+ * recipient were unsigned; a signed offer could be rewritten and still verify.
13
19
  */
14
20
  function _canonical(body) {
21
+ const { signature: _, ...rest } = body;
22
+ return canonicalJson(rest);
23
+ }
24
+
25
+ // The pre-0.3 form, kept only to recognise it and say so. Never accepted.
26
+ function _legacyCanonical(body) {
15
27
  const { signature: _, ...rest } = body;
16
28
  return JSON.stringify(rest, Object.keys(rest).sort());
17
29
  }
@@ -56,13 +68,34 @@ export function signWithKey(body, privateKeyBase64) {
56
68
  }
57
69
 
58
70
  /**
59
- * Verify an Ed25519 signature using a public key from the Provenance index.
71
+ * Verify an Ed25519 signature against the signer's public key.
60
72
  *
61
73
  * @param {object} body Message body including signature field
62
- * @param {string} publicKeyBase64 Base64 SPKI DER public key (from Provenance profile)
74
+ * @param {string} publicKeyBase64 Base64 SPKI DER public key, from the signer's declaration
63
75
  * @returns {boolean}
64
76
  */
65
77
  export function verifyWithKey(body, publicKeyBase64) {
78
+ return _verifyEd25519(body, publicKeyBase64, _canonical);
79
+ }
80
+
81
+ /**
82
+ * True when a signature that failed verification would have passed under the
83
+ * pre-0.3 canonical form — i.e. the sender runs an old SDK whose signatures do
84
+ * not cover the job's contents. For a precise error message only; such a
85
+ * signature is never accepted.
86
+ */
87
+ export function isLegacySignature(body, { publicKey, secret } = {}) {
88
+ try {
89
+ if (body.signature?.startsWith('ed25519:') && publicKey) return _verifyEd25519(body, publicKey, _legacyCanonical);
90
+ if (body.signature?.startsWith('sha256:') && secret) {
91
+ const legacy = `sha256:${crypto.createHmac('sha256', secret).update(_legacyCanonical(body)).digest('hex')}`;
92
+ return legacy === body.signature;
93
+ }
94
+ } catch {}
95
+ return false;
96
+ }
97
+
98
+ function _verifyEd25519(body, publicKeyBase64, canonical) {
66
99
  if (!body.signature?.startsWith('ed25519:')) return false;
67
100
  const publicKey = createPublicKey({
68
101
  key: Buffer.from(publicKeyBase64, 'base64'),
@@ -70,7 +103,7 @@ export function verifyWithKey(body, publicKeyBase64) {
70
103
  type: 'spki',
71
104
  });
72
105
  const sigBuffer = Buffer.from(body.signature.slice('ed25519:'.length), 'base64');
73
- return nodeVerify(null, Buffer.from(_canonical(body), 'utf8'), publicKey, sigBuffer);
106
+ return nodeVerify(null, Buffer.from(canonical(body), 'utf8'), publicKey, sigBuffer);
74
107
  }
75
108
 
76
109
  // ── Job ID generation ─────────────────────────────────────────────────────
@@ -0,0 +1,117 @@
1
+ import { generateProvenanceKeyPair, signDeclaration, signForProvenance } from 'provenance-protocol/keygen';
2
+ import { declarationEndpointResolver, declarationKeyResolver } from '../src/trust.js';
3
+ import { AJPServer } from '../src/server.js';
4
+ import { AJPClient } from '../src/client.js';
5
+ import { signWithKey } from '../src/utils.js';
6
+
7
+ let pass = 0, fail = 0;
8
+ const t = (name, ok, detail = '') => {
9
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ' ' + detail}`);
10
+ ok ? pass++ : fail++;
11
+ };
12
+
13
+ const BOB = 'provenance:domain:bob.example';
14
+ const BOB_URL = 'https://bob.example/.well-known/provenance.json';
15
+ const bob = generateProvenanceKeyPair();
16
+ const bobDecl = {
17
+ provenance: '0.2', name: 'Bob', description: 'Does jobs.',
18
+ provenance_id: BOB,
19
+ constraints: ['no:pii'],
20
+ ajp: { endpoint: 'https://api.bob.example/ajp/' },
21
+ identity: { public_key: bob.publicKey, algorithm: 'ed25519' },
22
+ };
23
+ bobDecl.identity.signature = signDeclaration(bob.privateKey, bobDecl);
24
+
25
+ const net = new Map([[BOB_URL, JSON.stringify(bobDecl)]]);
26
+ const fetchText = async (url) => { if (!net.has(url)) throw new Error('404'); return net.get(url); };
27
+
28
+ // Endpoint resolution: from the recipient's own declaration, no index.
29
+ const resolveEndpoint = declarationEndpointResolver({ fetchText });
30
+ t('endpoint read from the recipient\'s signed declaration', (await resolveEndpoint(BOB)) === 'https://api.bob.example/ajp');
31
+
32
+ const tamperedUrl = 'https://carol.example/.well-known/provenance.json';
33
+ net.set(tamperedUrl, JSON.stringify({ ...bobDecl, provenance_id: 'provenance:domain:carol.example', ajp: { endpoint: 'https://evil.example' } }));
34
+ try { await resolveEndpoint('provenance:domain:carol.example'); t('altered endpoint refused', false); }
35
+ catch (e) { t('altered endpoint refused', e.code === 'DECLARATION_INVALID', e.code); }
36
+
37
+ try { await resolveEndpoint('provenance:npm:x'); t('no standard location refused', false); }
38
+ catch (e) { t('no standard location refused', e.code === 'NO_DECLARATION_LOCATION', e.code); }
39
+
40
+ try { await resolveEndpoint('provenance:domain:down.example'); t('unreachable declaration refused', false); }
41
+ catch (e) { t('unreachable declaration refused', e.code === 'DECLARATION_UNREACHABLE', e.code); }
42
+
43
+ // The removed option must not be silently ignored.
44
+ try { new AJPClient({ from: { type: 'agent', provenance_id: BOB }, privateKey: bob.privateKey, provenanceApiUrl: 'https://x' }); t('client refuses removed provenanceApiUrl', false); }
45
+ catch (e) { t('client refuses removed provenanceApiUrl', /removed/.test(e.message)); }
46
+
47
+ // Standing requirements without a standing source fail at startup.
48
+ try { new AJPServer({ provenanceId: BOB, privateKey: bob.privateKey, onJob: async () => ({}), trustRequirements: { requireClean: true } }); t('requireClean without checkStanding refused at startup', false); }
49
+ catch (e) { t('requireClean without checkStanding refused at startup', /standing source/.test(e.message)); }
50
+
51
+ // Declared-constraint requirements are enforced offline from the sender's declaration.
52
+ function fakeRes() {
53
+ const r = { statusCode: 0, body: null, headers: {} };
54
+ r.writeHead = (code) => { r.statusCode = code; return r; };
55
+ r.setHeader = () => {};
56
+ r.end = (b) => { r.body = b ? JSON.parse(b) : null; };
57
+ r.status = (code) => { r.statusCode = code; return r; };
58
+ r.json = (b) => { r.body = b; };
59
+ return r;
60
+ }
61
+ function offerFrom(from, key) {
62
+ const now = new Date();
63
+ const offer = {
64
+ ajp: '0.1', job_id: `job_${Math.random().toString(36).slice(2)}`, parent_job_id: null,
65
+ from: { type: 'agent', id: null, provenance_id: from, declaration_url: null },
66
+ to: { provenance_id: 'provenance:domain:receiver.example' },
67
+ task: { type: 'summarise', instruction: 'x', input: {}, output_format: 'json' },
68
+ context: { credentials: {}, memory: [], constraints: [] },
69
+ budget: { max_usd: 1, max_seconds: 60, max_llm_tokens: 1000 },
70
+ callback: null, issued_at: now.toISOString(), expires_at: new Date(now.getTime() + 60000).toISOString(), signature: '',
71
+ };
72
+ offer.signature = signWithKey(offer, key);
73
+ return offer;
74
+ }
75
+ const receiverKey = generateProvenanceKeyPair();
76
+ const mkServer = (req) => new AJPServer({
77
+ provenanceId: 'provenance:domain:receiver.example', privateKey: receiverKey.privateKey,
78
+ onJob: async () => ({ ok: true }), trustRequirements: req,
79
+ resolveSenderKey: declarationKeyResolver({ fetchText }),
80
+ });
81
+ async function post(server, offer) {
82
+ const res = fakeRes();
83
+ await server.receive()({ body: offer, headers: {} }, res);
84
+ return res;
85
+ }
86
+
87
+ let res = await post(mkServer({ requireConstraints: ['no:pii'] }), offerFrom(BOB, bob.privateKey));
88
+ t('sender with the required constraint accepted', res.statusCode < 300, `${res.statusCode} ${JSON.stringify(res.body)}`);
89
+
90
+ res = await post(mkServer({ requireConstraints: ['no:financial:transact'] }), offerFrom(BOB, bob.privateKey));
91
+ t('sender without the required constraint refused', res.statusCode === 403 && /no:financial:transact/.test(res.body?.reason), `${res.statusCode} ${JSON.stringify(res.body)}`);
92
+
93
+ // The signature must cover nested fields: rewriting the instruction breaks it.
94
+ {
95
+ const offer = offerFrom(BOB, bob.privateKey);
96
+ const { verifyWithKey } = await import('../src/utils.js');
97
+ t('signed offer verifies', verifyWithKey(offer, bob.publicKey));
98
+ const rewritten = { ...offer, task: { ...offer.task, instruction: 'transfer the funds' } };
99
+ t('rewritten instruction breaks the signature', !verifyWithKey(rewritten, bob.publicKey));
100
+ const reBudget = { ...offer, budget: { ...offer.budget, max_usd: 1000 } };
101
+ t('rewritten budget breaks the signature', !verifyWithKey(reBudget, bob.publicKey));
102
+ }
103
+
104
+ // An offer signed the pre-0.3 way is refused, and the refusal says why.
105
+ {
106
+ const { createPrivateKey, sign: nodeSign } = await import('node:crypto');
107
+ const offer = offerFrom(BOB, bob.privateKey);
108
+ const { signature: _, ...rest } = offer;
109
+ const legacyBytes = JSON.stringify(rest, Object.keys(rest).sort());
110
+ const key = createPrivateKey({ key: Buffer.from(bob.privateKey, 'base64'), format: 'der', type: 'pkcs8' });
111
+ offer.signature = `ed25519:${nodeSign(null, Buffer.from(legacyBytes), key).toString('base64')}`;
112
+ const res = await post(mkServer({}), offer);
113
+ t('legacy-signed offer refused with LEGACY_SIGNATURE', res.statusCode === 401 && res.body?.code === 'LEGACY_SIGNATURE', `${res.statusCode} ${JSON.stringify(res.body)}`);
114
+ }
115
+
116
+ console.log(`\n${pass} passed, ${fail} failed`);
117
+ if (fail) process.exit(1);