ajp-protocol 0.2.2 → 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 +52 -24
- package/cli/index.js +43 -26
- package/cli/package.json +8 -3
- package/examples/agent-to-agent/example.js +14 -11
- package/examples/orchestrator/example.js +1 -1
- package/package.json +5 -5
- package/spec/SPEC.md +44 -18
- package/src/client.js +18 -30
- package/src/index.js +6 -2
- package/src/server.js +55 -8
- package/src/trust.js +132 -49
- package/src/utils.js +38 -5
- package/test/resolution.test.mjs +117 -0
- package/test/trust.test.mjs +12 -3
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# ajp-protocol
|
|
2
2
|
|
|
3
3
|
The Agent Job Protocol — standard interaction layer for the agent internet.
|
|
4
|
-
|
|
4
|
+
Built on the [Provenance Protocol](https://github.com/ilucky21c/provenance-protocol):
|
|
5
|
+
agents find and verify each other from their own signed declarations, with no
|
|
6
|
+
directory or index in between.
|
|
5
7
|
|
|
6
8
|
```bash
|
|
7
9
|
npm install ajp-protocol
|
|
@@ -15,7 +17,7 @@ AJP defines how any party — a human, an agent, or an orchestrator — hands a
|
|
|
15
17
|
job to another agent, tracks its progress, and receives the result.
|
|
16
18
|
|
|
17
19
|
Three endpoints. Three message types. Runs over standard HTTP.
|
|
18
|
-
|
|
20
|
+
Every message is signed in full; identity is checked offline.
|
|
19
21
|
|
|
20
22
|
---
|
|
21
23
|
|
|
@@ -26,7 +28,7 @@ and job lifecycle automatically.
|
|
|
26
28
|
|
|
27
29
|
```js
|
|
28
30
|
import { AJPServer, declarationKeyResolver, indexStandingCheck } from 'ajp-protocol';
|
|
29
|
-
import { Provenance } from 'provenance-protocol';
|
|
31
|
+
import { Provenance } from 'provenance-protocol/index-client';
|
|
30
32
|
import express from 'express';
|
|
31
33
|
|
|
32
34
|
const app = express();
|
|
@@ -47,15 +49,17 @@ const server = new AJPServer({
|
|
|
47
49
|
// This is the default — no index is consulted to check a signature.
|
|
48
50
|
resolveSenderKey: declarationKeyResolver(),
|
|
49
51
|
|
|
52
|
+
// Requirements on what the sender declares are checked offline, against
|
|
53
|
+
// its verified declaration.
|
|
54
|
+
trustRequirements: { requireCapabilities: ['delegate:agents'] },
|
|
55
|
+
|
|
50
56
|
// Standing (revocation, incidents, freshness) cannot be checked offline, so
|
|
51
57
|
// it is opt-in and you choose whom to ask. Omit it entirely to accept any
|
|
52
58
|
// sender whose identity verifies.
|
|
53
|
-
checkStanding: indexStandingCheck(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
requireMinAge: 7,
|
|
58
|
-
}),
|
|
59
|
+
checkStanding: indexStandingCheck(
|
|
60
|
+
new Provenance({ apiUrl: 'https://index.example.com' }), // an index you choose
|
|
61
|
+
{ requireClean: true, requireMinAge: 7 }
|
|
62
|
+
),
|
|
59
63
|
onStandingUnavailable: 'deny', // an unreachable attester is not an accusation
|
|
60
64
|
|
|
61
65
|
// Your agent logic — receives the job, returns the result
|
|
@@ -84,12 +88,14 @@ const client = new AJPClient({
|
|
|
84
88
|
type: 'agent', // 'human' | 'agent' | 'orchestrator'
|
|
85
89
|
provenance_id: 'provenance:github:alice/orchestrator',
|
|
86
90
|
},
|
|
91
|
+
// Where the recipient's endpoint comes from: by default its own signed
|
|
92
|
+
// declaration, found from its provenance id. No index involved.
|
|
87
93
|
// Agent/orchestrator callers sign with Ed25519 — no shared secret needed
|
|
88
94
|
privateKey: process.env.PROVENANCE_PRIVATE_KEY,
|
|
89
95
|
});
|
|
90
96
|
|
|
91
97
|
const result = await client.send(
|
|
92
|
-
'provenance:
|
|
98
|
+
'provenance:domain:research.bob.example', // who to hire
|
|
93
99
|
{
|
|
94
100
|
type: 'research',
|
|
95
101
|
instruction: 'Find the top 3 papers on transformer attention in 2024.',
|
|
@@ -122,7 +128,7 @@ const result = await client.send(agentId, task, budget);
|
|
|
122
128
|
### Agent hiring an agent
|
|
123
129
|
```js
|
|
124
130
|
// Agent callers sign with Ed25519 — no shared secret, no prior setup
|
|
125
|
-
// The receiving agent verifies
|
|
131
|
+
// The receiving agent verifies against the key in your published declaration
|
|
126
132
|
const client = new AJPClient({
|
|
127
133
|
from: { type: 'agent', provenance_id: 'provenance:github:alice/pipeline' },
|
|
128
134
|
privateKey: process.env.PROVENANCE_PRIVATE_KEY,
|
|
@@ -149,12 +155,14 @@ Two separate questions, and only one of them needs a network service.
|
|
|
149
155
|
AJPServer.receive()
|
|
150
156
|
→ validate the offer's shape
|
|
151
157
|
→ IDENTITY (offline, always)
|
|
152
|
-
fetch the sender's declaration from
|
|
158
|
+
fetch the sender's declaration (from.declaration_url, or the
|
|
159
|
+
standard location its provenance id names)
|
|
153
160
|
→ does it verify against the key inside it?
|
|
154
161
|
→ was it served from the location its provenance id names?
|
|
155
162
|
→ is that id the one the offer claims?
|
|
156
163
|
→ has this sender's key changed since last time?
|
|
157
164
|
then check the offer's signature with that key
|
|
165
|
+
→ does the declaration promise what trustRequirements ask?
|
|
158
166
|
→ STANDING (online, optional, you choose the attester)
|
|
159
167
|
→ revoked? open incidents? evidence stale? old enough?
|
|
160
168
|
→ unreachable attester → your policy, not a failed trust check
|
|
@@ -173,7 +181,7 @@ Platform-level auth is assumed for humans.
|
|
|
173
181
|
## Declare AJP in your PROVENANCE.yml
|
|
174
182
|
|
|
175
183
|
```yaml
|
|
176
|
-
provenance: "0.
|
|
184
|
+
provenance: "0.2"
|
|
177
185
|
name: "Research Assistant"
|
|
178
186
|
|
|
179
187
|
capabilities:
|
|
@@ -186,8 +194,9 @@ ajp:
|
|
|
186
194
|
version: "0.1"
|
|
187
195
|
```
|
|
188
196
|
|
|
189
|
-
|
|
190
|
-
|
|
197
|
+
Senders read `ajp.endpoint` from your signed declaration, so they can find
|
|
198
|
+
you without out-of-band configuration — and without trusting anyone's copy of
|
|
199
|
+
it. Sign the declaration with `npx provenance-protocol sign`.
|
|
191
200
|
|
|
192
201
|
---
|
|
193
202
|
|
|
@@ -200,6 +209,7 @@ import { NextResponse } from 'next/server';
|
|
|
200
209
|
|
|
201
210
|
const server = new AJPServer({
|
|
202
211
|
provenanceId: process.env.PROVENANCE_ID,
|
|
212
|
+
privateKey: process.env.PROVENANCE_PRIVATE_KEY, // signs results
|
|
203
213
|
secret: process.env.AJP_SECRET,
|
|
204
214
|
onJob: async (job) => {
|
|
205
215
|
// your agent logic
|
|
@@ -218,17 +228,17 @@ export async function POST(req) {
|
|
|
218
228
|
|
|
219
229
|
| Package | Purpose |
|
|
220
230
|
|---|---|
|
|
221
|
-
| `provenance-protocol` |
|
|
231
|
+
| [`provenance-protocol`](https://github.com/ilucky21c/provenance-protocol) | Declarations and attestations: sign, verify, locate — offline |
|
|
222
232
|
| `ajp-protocol` | Send and receive agent jobs (this package) |
|
|
223
|
-
| `
|
|
233
|
+
| [`provenance-middleware`](https://github.com/ilucky21c/provenance-middleware) | Serve and sign your declaration from your own service |
|
|
224
234
|
|
|
225
235
|
---
|
|
226
236
|
|
|
227
237
|
## CLI
|
|
228
238
|
|
|
229
239
|
```bash
|
|
230
|
-
# Send a job to any
|
|
231
|
-
npx @ilucky21c/ajp-cli hire provenance:
|
|
240
|
+
# Send a job to any agent that publishes a declaration with ajp.endpoint
|
|
241
|
+
npx @ilucky21c/ajp-cli hire provenance:domain:summarizer.example.com \
|
|
232
242
|
--instruction "Summarize this paper: https://arxiv.org/abs/..." \
|
|
233
243
|
--budget 0.50 --timeout 60
|
|
234
244
|
|
|
@@ -236,16 +246,34 @@ npx @ilucky21c/ajp-cli hire provenance:github:alice/summarizer \
|
|
|
236
246
|
npx @ilucky21c/ajp-cli jobs job_m0abc123 --endpoint https://alice-agent.example.com/api/agent
|
|
237
247
|
```
|
|
238
248
|
|
|
239
|
-
Requires Provenance identity —
|
|
249
|
+
Requires a Provenance identity — `npx provenance-protocol keygen`, then publish a
|
|
250
|
+
signed declaration. `--endpoint <url>` skips resolution; `--index <url>`
|
|
251
|
+
resolves through an index you choose instead.
|
|
252
|
+
|
|
253
|
+
---
|
|
240
254
|
|
|
241
|
-
|
|
255
|
+
## Upgrading from 0.2
|
|
256
|
+
|
|
257
|
+
- **Signatures now cover the whole message.** Before 0.3 only top-level keys were
|
|
258
|
+
signed and every nested field — the task, budget, sender, recipient, result —
|
|
259
|
+
was left out, so a signed job could be rewritten in transit. Old and new
|
|
260
|
+
versions do not interoperate; upgrade both sides. A 0.3 receiver answers an
|
|
261
|
+
old-style signature with `LEGACY_SIGNATURE`.
|
|
262
|
+
- `AJPClient` finds the recipient from its own declaration by default.
|
|
263
|
+
`provenanceApiUrl` is gone (passing it throws); use
|
|
264
|
+
`resolveEndpoint: indexEndpointResolver(new Provenance({ apiUrl }))` to keep
|
|
265
|
+
using an index, or `send(..., { endpoint })` to skip resolution.
|
|
266
|
+
- `trustRequirements.requireConstraints` / `requireCapabilities` are enforced
|
|
267
|
+
offline against the sender's declaration. Standing requirements
|
|
268
|
+
(`requireClean`, `requireMinAge`, …) without `checkStanding` now fail at
|
|
269
|
+
startup instead of being silently ignored.
|
|
242
270
|
|
|
243
271
|
---
|
|
244
272
|
|
|
245
|
-
## Full
|
|
273
|
+
## Full specification
|
|
246
274
|
|
|
247
|
-
[
|
|
275
|
+
[spec/SPEC.md](./spec/SPEC.md)
|
|
248
276
|
|
|
249
277
|
---
|
|
250
278
|
|
|
251
|
-
## MIT License
|
|
279
|
+
## MIT License
|
package/cli/index.js
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ajp-cli — Agent Job Protocol CLI
|
|
4
4
|
*
|
|
5
|
-
* Requires Provenance identity (PROVENANCE_ID + PROVENANCE_PRIVATE_KEY).
|
|
6
|
-
* For identity setup: npx provenance-protocol keygen
|
|
5
|
+
* Requires a Provenance identity (PROVENANCE_ID + PROVENANCE_PRIVATE_KEY).
|
|
6
|
+
* For identity setup: npx provenance-protocol keygen, then publish a signed
|
|
7
|
+
* declaration (npx provenance-protocol sign).
|
|
7
8
|
*
|
|
8
9
|
* Usage:
|
|
9
10
|
* ajp hire <provenance_id> --instruction <text> [--budget <usd>] [--timeout <s>]
|
|
10
11
|
* ajp jobs <job_id> --endpoint <url>
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import {
|
|
14
|
+
import { randomBytes } from 'crypto';
|
|
15
|
+
import { createRequire } from 'module';
|
|
16
|
+
import YAML from 'yaml';
|
|
17
|
+
import { signWithKey, declarationEndpointResolver, indexEndpointResolver } from 'ajp-protocol';
|
|
18
|
+
import { Provenance } from 'provenance-protocol/index-client';
|
|
14
19
|
|
|
15
|
-
const
|
|
16
|
-
const VERSION = '0.1.0';
|
|
20
|
+
const VERSION = createRequire(import.meta.url)('./package.json').version;
|
|
17
21
|
|
|
18
22
|
// ── Colours ───────────────────────────────────────────────────────────────────
|
|
19
23
|
|
|
@@ -50,11 +54,16 @@ function generateJobId() {
|
|
|
50
54
|
return `job_${Date.now().toString(36)}${randomBytes(6).toString('hex')}`;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// Where to send the job. By default, read from the recipient's own signed
|
|
58
|
+
// declaration — no index involved. --endpoint skips resolution; --index asks
|
|
59
|
+
// an index you name instead.
|
|
60
|
+
async function resolveEndpoint(targetId, args) {
|
|
61
|
+
if (typeof args.endpoint === 'string') return args.endpoint.replace(/\/$/, '');
|
|
62
|
+
const index = args.index || process.env.PROVENANCE_INDEX_URL;
|
|
63
|
+
const resolver = typeof index === 'string'
|
|
64
|
+
? indexEndpointResolver(new Provenance({ apiUrl: index }))
|
|
65
|
+
: declarationEndpointResolver({ parseDeclaration: (text) => YAML.parse(text) });
|
|
66
|
+
return resolver(targetId);
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
@@ -70,19 +79,19 @@ async function cmdHire(args) {
|
|
|
70
79
|
if (!targetId) { console.error(err('Usage: ajp hire <provenance_id> --instruction <text>')); process.exit(1); }
|
|
71
80
|
if (!instruction) { console.error(err('--instruction required')); process.exit(1); }
|
|
72
81
|
if (!privateKey) { console.error(err('PROVENANCE_PRIVATE_KEY not set. Run: npx provenance-protocol keygen')); process.exit(1); }
|
|
73
|
-
if (!provenanceId) { console.error(err('PROVENANCE_ID not set
|
|
82
|
+
if (!provenanceId) { console.error(err('PROVENANCE_ID not set — your agent\'s provenance id')); process.exit(1); }
|
|
74
83
|
|
|
75
84
|
console.log(`\n${amb('Hiring')} ${hi(targetId)}...\n`);
|
|
76
85
|
|
|
77
|
-
// Resolve endpoint
|
|
78
86
|
process.stdout.write(dim(' Resolving endpoint...'));
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
let endpoint;
|
|
88
|
+
try {
|
|
89
|
+
endpoint = await resolveEndpoint(targetId, args);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.log('\n' + err(e.message));
|
|
92
|
+
console.log(dim(' Nothing was sent. Pass --endpoint <url> if you know where the agent accepts jobs.'));
|
|
93
|
+
process.exit(2);
|
|
84
94
|
}
|
|
85
|
-
const endpoint = agentData.ajp.endpoint.replace(/\/$/, '');
|
|
86
95
|
console.log(` ${c.green}${endpoint}${c.reset}`);
|
|
87
96
|
|
|
88
97
|
// Build and sign offer
|
|
@@ -92,7 +101,10 @@ async function cmdHire(args) {
|
|
|
92
101
|
|
|
93
102
|
const offer = {
|
|
94
103
|
ajp: '0.1', job_id: jobId, parent_job_id: null,
|
|
95
|
-
from: {
|
|
104
|
+
from: {
|
|
105
|
+
type: 'orchestrator', id: null, provenance_id: provenanceId,
|
|
106
|
+
declaration_url: args['declaration-url'] || process.env.PROVENANCE_DECLARATION_URL || null,
|
|
107
|
+
},
|
|
96
108
|
to: { provenance_id: targetId },
|
|
97
109
|
task: { type: 'task', instruction, input: {}, output_format: 'json' },
|
|
98
110
|
context: { credentials: {}, memory: [], constraints: [] },
|
|
@@ -102,7 +114,7 @@ async function cmdHire(args) {
|
|
|
102
114
|
expires_at: expiresAt.toISOString(),
|
|
103
115
|
signature: '',
|
|
104
116
|
};
|
|
105
|
-
offer.signature =
|
|
117
|
+
offer.signature = signWithKey(offer, privateKey);
|
|
106
118
|
|
|
107
119
|
// Submit
|
|
108
120
|
process.stdout.write(dim(' Submitting job...'));
|
|
@@ -191,17 +203,21 @@ ${amb('Commands:')}
|
|
|
191
203
|
[--timeout <seconds>] Max wait time (default: 120)
|
|
192
204
|
[--from-id <id>] Your Provenance ID (default: $PROVENANCE_ID)
|
|
193
205
|
[--private-key <key>] Your private key (default: $PROVENANCE_PRIVATE_KEY)
|
|
206
|
+
[--declaration-url <url>] Where your signed declaration is published
|
|
207
|
+
[--endpoint <url>] Send here instead of reading the recipient's declaration
|
|
208
|
+
[--index <url>] Resolve the recipient through this index instead
|
|
194
209
|
|
|
195
210
|
${hi('jobs')} <job_id> Check status of a job
|
|
196
211
|
--endpoint <url> The agent's AJP endpoint URL
|
|
197
212
|
|
|
198
213
|
${amb('Environment variables:')}
|
|
199
|
-
PROVENANCE_ID
|
|
200
|
-
PROVENANCE_PRIVATE_KEY
|
|
201
|
-
|
|
214
|
+
PROVENANCE_ID Your provenance id
|
|
215
|
+
PROVENANCE_PRIVATE_KEY Your Ed25519 private key (npx provenance-protocol keygen)
|
|
216
|
+
PROVENANCE_DECLARATION_URL Where your signed declaration is published
|
|
217
|
+
PROVENANCE_INDEX_URL Resolve recipients through this index (optional)
|
|
202
218
|
|
|
203
219
|
${amb('Examples:')}
|
|
204
|
-
ajp hire provenance:
|
|
220
|
+
ajp hire provenance:domain:summarizer.example.com \\
|
|
205
221
|
--instruction "Summarize https://arxiv.org/abs/2501.00001" \\
|
|
206
222
|
--budget 0.50 --timeout 60
|
|
207
223
|
|
|
@@ -209,8 +225,9 @@ ${amb('Examples:')}
|
|
|
209
225
|
|
|
210
226
|
${amb('Identity setup (first time):')}
|
|
211
227
|
npx provenance-protocol keygen
|
|
212
|
-
npx provenance-protocol
|
|
213
|
-
${dim('
|
|
228
|
+
npx provenance-protocol sign PROVENANCE.yml
|
|
229
|
+
${dim('Publish the declaration where your provenance id says, then set')}
|
|
230
|
+
${dim('PROVENANCE_ID and PROVENANCE_PRIVATE_KEY in your environment.')}
|
|
214
231
|
`);
|
|
215
232
|
}
|
|
216
233
|
|
package/cli/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ilucky21c/ajp-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for the Agent Job Protocol
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI for the Agent Job Protocol — hire agents and check job status from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ajp": "./index.js"
|
|
@@ -22,5 +22,10 @@
|
|
|
22
22
|
"agents",
|
|
23
23
|
"cli"
|
|
24
24
|
],
|
|
25
|
-
"license": "MIT"
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ajp-protocol": "^0.3.0",
|
|
28
|
+
"provenance-protocol": "^0.6.0",
|
|
29
|
+
"yaml": "^2.9.1"
|
|
30
|
+
}
|
|
26
31
|
}
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// It hires a specialist PDF agent to handle that step.
|
|
4
4
|
//
|
|
5
5
|
// Key difference from human→agent: the receiving agent
|
|
6
|
-
// MUST verify the sender's Provenance ID before accepting
|
|
6
|
+
// MUST verify the sender's Provenance ID before accepting — offline, against
|
|
7
|
+
// the sender's own signed declaration.
|
|
7
8
|
|
|
8
9
|
import { AJPClient } from 'ajp-protocol';
|
|
9
|
-
import { provenance } from 'provenance-protocol';
|
|
10
10
|
|
|
11
11
|
// ── Sending side (data pipeline agent) ───────────────────────────────────
|
|
12
12
|
|
|
@@ -15,12 +15,15 @@ const client = new AJPClient({
|
|
|
15
15
|
type: 'agent',
|
|
16
16
|
provenance_id: 'provenance:github:alice/data-pipeline',
|
|
17
17
|
},
|
|
18
|
-
|
|
18
|
+
// Agents sign with their own key; the recipient checks it against the
|
|
19
|
+
// declaration Alice publishes in her repository.
|
|
20
|
+
privateKey: process.env.PROVENANCE_PRIVATE_KEY,
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
// Send job to a specialist PDF extractor
|
|
23
|
+
// Send job to a specialist PDF extractor. Its endpoint is read from its own
|
|
24
|
+
// signed declaration at https://pdf.bob.example/.well-known/provenance.json.
|
|
22
25
|
const result = await client.send(
|
|
23
|
-
'provenance:
|
|
26
|
+
'provenance:domain:pdf.bob.example',
|
|
24
27
|
{
|
|
25
28
|
type: 'extract',
|
|
26
29
|
instruction: 'Extract all tables from this PDF and return them as structured JSON.',
|
|
@@ -41,15 +44,15 @@ console.log(result.output.tables);
|
|
|
41
44
|
import { AJPServer } from 'ajp-protocol';
|
|
42
45
|
|
|
43
46
|
const server = new AJPServer({
|
|
44
|
-
provenanceId: 'provenance:
|
|
45
|
-
|
|
47
|
+
provenanceId: 'provenance:domain:pdf.bob.example',
|
|
48
|
+
privateKey: process.env.PROVENANCE_PRIVATE_KEY, // signs results
|
|
46
49
|
|
|
47
|
-
//
|
|
50
|
+
// Checked offline against the sender's verified declaration.
|
|
48
51
|
trustRequirements: {
|
|
49
|
-
|
|
50
|
-
requireClean: true, // no open incidents
|
|
51
|
-
requireMinAge: 7, // not a brand-new agent
|
|
52
|
+
requireConstraints: ['no:pii'],
|
|
52
53
|
},
|
|
54
|
+
// Standing (incidents, age) needs someone to ask — add checkStanding with
|
|
55
|
+
// an attester you choose if you want requireClean or requireMinAge.
|
|
53
56
|
|
|
54
57
|
onJob: async (job) => {
|
|
55
58
|
// job.from.provenance_id already verified by AJPServer
|
|
@@ -9,7 +9,7 @@ const client = new AJPClient({
|
|
|
9
9
|
type: 'orchestrator',
|
|
10
10
|
provenance_id: 'provenance:github:alice/research-orchestrator',
|
|
11
11
|
},
|
|
12
|
-
|
|
12
|
+
privateKey: process.env.PROVENANCE_PRIVATE_KEY, // orchestrators sign with their own key
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
const PARENT_JOB_ID = 'job_parent_01J8X2K9M3N4P5Q6';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ajp-protocol",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent Job Protocol
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Agent Job Protocol — standard interaction layer for the agent internet",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
],
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"provenance-protocol": "^0.
|
|
20
|
+
"provenance-protocol": "^0.6.0"
|
|
21
21
|
},
|
|
22
22
|
"repository": {
|
|
23
23
|
"type": "git",
|
|
24
24
|
"url": "https://github.com/ilucky21c/ajp-protocol"
|
|
25
25
|
},
|
|
26
|
-
"homepage": "https://
|
|
26
|
+
"homepage": "https://github.com/ilucky21c/ajp-protocol#readme",
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node test/trust.test.mjs"
|
|
28
|
+
"test": "node test/trust.test.mjs && node test/resolution.test.mjs"
|
|
29
29
|
}
|
|
30
30
|
}
|
package/spec/SPEC.md
CHANGED
|
@@ -27,8 +27,9 @@ inside is yours to define. An agent that searches the web and an agent that
|
|
|
27
27
|
processes invoices use the same protocol.
|
|
28
28
|
|
|
29
29
|
**Trust is built in, not bolted on.** Every JobOffer is signed by the sender.
|
|
30
|
-
Every receiver verifies the sender against the
|
|
31
|
-
|
|
30
|
+
Every receiver verifies the sender against the sender's own signed Provenance
|
|
31
|
+
declaration before accepting — offline, with no index involved. Trust
|
|
32
|
+
verification is part of the protocol, not optional middleware.
|
|
32
33
|
|
|
33
34
|
**Three parties, same protocol.** A human hiring an agent, an agent hiring an
|
|
34
35
|
agent, and an orchestrator delegating to sub-agents all use identical message
|
|
@@ -46,7 +47,7 @@ types. The `from` field distinguishes them.
|
|
|
46
47
|
|
|
47
48
|
A human (or a platform acting on their behalf) sends a JobOffer to an agent.
|
|
48
49
|
The `from.type` is `human`. No Provenance verification of the sender is required
|
|
49
|
-
— humans
|
|
50
|
+
— humans do not publish agent declarations. Platform-level auth handles human identity.
|
|
50
51
|
|
|
51
52
|
```
|
|
52
53
|
Human / Platform ──JobOffer──► Agent
|
|
@@ -222,7 +223,7 @@ Status values: `accepted` `running` `completed` `failed` `rejected` `expired`
|
|
|
222
223
|
}
|
|
223
224
|
```
|
|
224
225
|
|
|
225
|
-
`constraints_asserted` lists the constraints the agent declares it honored during this job. The field is included in the signed payload — the signature ties the assertion to the agent's
|
|
226
|
+
`constraints_asserted` lists the constraints the agent declares it honored during this job. The field is included in the signed payload — the signature ties the assertion to the key in the agent's declaration. Self-reported, but attributable: a false assertion is a cryptographic receipt of the lie, which the receiver can hand to any attester as evidence (a Provenance `report` attestation can reference it).
|
|
226
227
|
|
|
227
228
|
---
|
|
228
229
|
|
|
@@ -236,7 +237,9 @@ questions, and only one of them needs a network service.
|
|
|
236
237
|
*Is this signature really from the party named in `from`?*
|
|
237
238
|
|
|
238
239
|
The sender publishes its signed declaration and points at it with
|
|
239
|
-
`from.declaration_url
|
|
240
|
+
`from.declaration_url`; when absent, the receiver uses the standard location its
|
|
241
|
+
`provenance_id` names (see *Where a declaration lives* in the Provenance
|
|
242
|
+
specification). The receiver fetches that file and verifies it locally:
|
|
240
243
|
|
|
241
244
|
```js
|
|
242
245
|
import { verifyDeclaration } from 'provenance-protocol/verify';
|
|
@@ -275,17 +278,25 @@ NOT hardcode a single provider.
|
|
|
275
278
|
|
|
276
279
|
```js
|
|
277
280
|
import { AJPServer, declarationKeyResolver, indexStandingCheck } from 'ajp-protocol';
|
|
278
|
-
import { Provenance } from 'provenance-protocol';
|
|
281
|
+
import { Provenance } from 'provenance-protocol/index-client';
|
|
282
|
+
|
|
283
|
+
const index = new Provenance({ apiUrl: 'https://index.example.com' }); // an index you choose
|
|
279
284
|
|
|
280
285
|
const server = new AJPServer({
|
|
281
|
-
provenanceId, onJob,
|
|
282
|
-
resolveSenderKey: declarationKeyResolver(),
|
|
283
|
-
|
|
284
|
-
|
|
286
|
+
provenanceId, privateKey, onJob,
|
|
287
|
+
resolveSenderKey: declarationKeyResolver(), // offline, the default
|
|
288
|
+
trustRequirements: { requireConstraints: ['no:pii'], requireClean: true, requireMinAge: 7 },
|
|
289
|
+
checkStanding: indexStandingCheck(index, { requireClean: true, requireMinAge: 7 }), // opt-in, swappable
|
|
285
290
|
onStandingUnavailable: 'deny',
|
|
286
291
|
});
|
|
287
292
|
```
|
|
288
293
|
|
|
294
|
+
Requirements on **declared** fields (`requireConstraints`, `requireCapabilities`)
|
|
295
|
+
are checked against the sender's verified declaration, offline. Requirements on
|
|
296
|
+
**standing** (`requireClean`, `requireMinAge`, …) need a standing source; an
|
|
297
|
+
implementation MUST refuse to start with them configured and no source, rather
|
|
298
|
+
than accept jobs while ignoring them.
|
|
299
|
+
|
|
289
300
|
A receiver that performs a standing check MUST distinguish *checked and failed*
|
|
290
301
|
from *could not check*, and MUST state which way it fails when the check is
|
|
291
302
|
unavailable. Reporting an unreachable attester as a failed trust check turns
|
|
@@ -299,7 +310,16 @@ offer, and the shared-secret signature covers it.
|
|
|
299
310
|
## Signature
|
|
300
311
|
|
|
301
312
|
Every JobOffer and JobResult is signed by the sender. The signature covers the
|
|
302
|
-
full message body excluding the `signature` field itself
|
|
313
|
+
full message body excluding the `signature` field itself — every field at every
|
|
314
|
+
depth. `canonical(body)` is the canonical JSON defined by the Provenance
|
|
315
|
+
Protocol, which is the JSON Canonicalization Scheme (JCS, RFC 8785): object
|
|
316
|
+
keys sorted by UTF-16 code units at every depth, no insignificant whitespace.
|
|
317
|
+
|
|
318
|
+
Implementations before `ajp-protocol` 0.3 serialised only the top-level keys:
|
|
319
|
+
nested objects — `task`, `budget`, `from`, `to`, `context`, `output` — were
|
|
320
|
+
emptied before signing, so they were not covered and could be altered without
|
|
321
|
+
detection. A receiver MUST NOT accept such signatures. It MAY recognise one in
|
|
322
|
+
order to tell the sender to upgrade.
|
|
303
323
|
|
|
304
324
|
**Human senders** (no Provenance identity) — HMAC-SHA256 with a shared secret:
|
|
305
325
|
```
|
|
@@ -311,8 +331,12 @@ signature = "sha256:" + hex(HMAC-SHA256(canonical(body), sender_secret))
|
|
|
311
331
|
signature = "ed25519:" + base64(Ed25519Sign(canonical(body), provenance_private_key))
|
|
312
332
|
```
|
|
313
333
|
|
|
314
|
-
The receiving server verifies agent signatures
|
|
315
|
-
|
|
334
|
+
The receiving server verifies agent signatures against the public key in the
|
|
335
|
+
sender's own verified declaration (see Trust verification). This ties every
|
|
336
|
+
message to a published identity without a shared secret or an index.
|
|
337
|
+
|
|
338
|
+
A sender SHOULD verify a JobResult's signature the same way, against the key in
|
|
339
|
+
the recipient's declaration, before relying on it.
|
|
316
340
|
|
|
317
341
|
The `ajp-protocol` SDK handles signing and verification automatically based on `from.type`.
|
|
318
342
|
|
|
@@ -369,7 +393,7 @@ const result = await client.send(
|
|
|
369
393
|
Agents that implement AJP should declare it:
|
|
370
394
|
|
|
371
395
|
```yaml
|
|
372
|
-
provenance: "0.
|
|
396
|
+
provenance: "0.2"
|
|
373
397
|
name: "Research Assistant"
|
|
374
398
|
|
|
375
399
|
capabilities:
|
|
@@ -382,8 +406,11 @@ ajp:
|
|
|
382
406
|
version: "0.1"
|
|
383
407
|
```
|
|
384
408
|
|
|
385
|
-
|
|
386
|
-
|
|
409
|
+
A sender finds an agent's AJP endpoint by fetching the agent's declaration from
|
|
410
|
+
the location its `provenance_id` names, verifying it, and reading
|
|
411
|
+
`ajp.endpoint`. Because the declaration is signed and tied to its location, the
|
|
412
|
+
endpoint is the operator's own statement — no directory or index is needed.
|
|
413
|
+
Indexes may also record it, as a convenience.
|
|
387
414
|
|
|
388
415
|
---
|
|
389
416
|
|
|
@@ -407,6 +434,5 @@ version. Future versions add fields, never remove them.
|
|
|
407
434
|
|
|
408
435
|
---
|
|
409
436
|
|
|
410
|
-
*AJP v0.1 —
|
|
411
|
-
*https://getprovenance.dev/ajp*
|
|
437
|
+
*AJP v0.1 — MIT License*
|
|
412
438
|
*https://github.com/ilucky21c/ajp-protocol*
|
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 {
|
|
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 {
|
|
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,
|
|
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.
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
*
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
12
|
-
* Used by both HMAC and Ed25519 paths
|
|
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
|
|
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
|
|
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(
|
|
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);
|
package/test/trust.test.mjs
CHANGED
|
@@ -34,9 +34,18 @@ const resolve = declarationKeyResolver({ fetchText });
|
|
|
34
34
|
const ok1 = await resolve(ALICE, { declaration_url: ALICE_URL });
|
|
35
35
|
t('honest sender resolves offline', ok1.publicKey === alice.publicKey && !!ok1.fingerprint);
|
|
36
36
|
|
|
37
|
-
// 2. No declaration_url -> refused with a specific code.
|
|
38
|
-
try { await resolve(
|
|
39
|
-
catch (e) { t('
|
|
37
|
+
// 2. No declaration_url and no standard location -> refused with a specific code.
|
|
38
|
+
try { await resolve('provenance:npm:some-agent', {}); t('no declaration_url and no standard location refused', false); }
|
|
39
|
+
catch (e) { t('no declaration_url and no standard location refused', e.code === 'NO_DECLARATION_URL', e.code); }
|
|
40
|
+
|
|
41
|
+
// 2b. No declaration_url, but the id names a standard location -> that is fetched.
|
|
42
|
+
{
|
|
43
|
+
const std = 'https://raw.githubusercontent.com/alice/research-agent/HEAD/PROVENANCE.yml';
|
|
44
|
+
net.set(std, JSON.stringify(aliceDecl));
|
|
45
|
+
const r = await resolve(ALICE, {});
|
|
46
|
+
t('falls back to the standard location for its id', r.publicKey === alice.publicKey && r.source === std);
|
|
47
|
+
net.delete(std);
|
|
48
|
+
}
|
|
40
49
|
|
|
41
50
|
// 3. Impostor re-hosts Alice's genuine declaration on their own server.
|
|
42
51
|
const EVIL_URL = 'https://evil.example/copied/PROVENANCE.json';
|