provenance-protocol 0.3.0 → 0.5.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/SPEC.md CHANGED
@@ -251,8 +251,30 @@ provenance:npm:@scope/package-name
251
251
  provenance:pypi:package-name
252
252
  provenance:huggingface:owner/space-name
253
253
  provenance:clawmarket:listing-id
254
+ provenance:domain:agent.example.com
255
+ provenance:domain:example.com/agents/research
254
256
  ```
255
257
 
258
+ ### Domain identifiers
259
+
260
+ `provenance:domain:<hostname>` is for an agent that runs as a service and has no
261
+ public repository — which is most commercial agents. Control is proven exactly as
262
+ it is for a repository: the declaration is served from the location the
263
+ identifier names, and only someone with write access to that location could have
264
+ put it there.
265
+
266
+ The hostname must match exactly. A subdomain is a different party for this
267
+ purpose, and treating `agent.example.com` as covered by `example.com` would be
268
+ the whole attack.
269
+
270
+ Optional path segments allow several agents under one domain — those segments
271
+ must appear in the retrieval path.
272
+
273
+ Without this form, a hosted service serving its own declaration could never be
274
+ verified as its operator's: the signature would check out while the location
275
+ check reported `unchecked`, so no verifier could conclude the declaration was
276
+ genuinely theirs.
277
+
256
278
  Add `provenance_id` to your PROVENANCE.yml to link your file to your
257
279
  index entry and claim your agent profile.
258
280
 
@@ -408,27 +430,49 @@ This is the same division of labour as a machine-readable passport: the
408
430
  document proves its own integrity offline, while identity binding and
409
431
  current standing are looked up.
410
432
 
433
+ ### Every signed payload is domain-separated
434
+
435
+ An agent's key signs several different things. Each payload MUST carry the
436
+ prefix for its purpose, so that a signature obtained for one purpose can never
437
+ be presented as another:
438
+
439
+ | Purpose | Signed payload |
440
+ |---|---|
441
+ | Declaration (0.2) | `provenance-declaration-v1:<canonical JSON>` |
442
+ | Live proof of key control | `provenance-challenge-v1:<provenance_id>:<nonce>` |
443
+ | Revocation | `provenance-revocation-v1:<provenance_id>` |
444
+
445
+ This is not a precaution against something hypothetical. In 0.1 a challenge was
446
+ signed as `<provenance_id>:<nonce>` and a revocation as
447
+ `<provenance_id>:REVOKE` — **the same payload with a chosen nonce.** Any
448
+ publicly reachable endpoint that signs a caller-supplied nonce in the 0.1 form
449
+ therefore hands out valid revocation signatures for its own key, and a stranger
450
+ can revoke the agent. Supplying the public key as the nonce likewise reproduces
451
+ the 0.1 declaration signature.
452
+
453
+ An endpoint that signs a caller-supplied value MUST use the separated challenge
454
+ form, and MUST NOT sign the 0.1 payload. Verifiers SHOULD require the separated
455
+ form from any agent that exposes a public challenge endpoint.
456
+
457
+ The 0.1 payloads remain defined so existing deployments keep working, but they
458
+ are unsafe to expose and are superseded.
459
+
411
460
  ### Live proof of key control
412
461
 
413
462
  A signature on a file proves the file's origin. It does not prove that the
414
- agent running right now controls that key. For that, a receiving system
415
- issues a nonce and the agent returns a signature over:
416
-
417
- ```
418
- <provenance_id>:<nonce>
419
- ```
463
+ agent running right now controls that key. For that, a receiving system issues
464
+ a nonce and the agent returns a signature over
465
+ `provenance-challenge-v1:<provenance_id>:<nonce>`.
420
466
 
421
- Nonces must be single-use and unpredictable. The receiving system verifies
422
- the signature against the public key it already holds for that
423
- `provenance_id`.
467
+ Nonces must be single-use and unpredictable. The receiving system verifies the
468
+ signature against the public key it already holds for that `provenance_id`.
424
469
 
425
470
  ### Revocation
426
471
 
427
- A key holder revokes a `provenance_id` by signing:
428
-
429
- ```
430
- <provenance_id>:REVOKE
431
- ```
472
+ A key holder revokes a `provenance_id` by signing
473
+ `provenance-revocation-v1:<provenance_id>`. The payload contains no
474
+ caller-supplied input, so it cannot be produced by a challenge endpoint however
475
+ that endpoint is called.
432
476
 
433
477
  Revocation is the one operation that cannot be verified offline — a verifier
434
478
  has no way to know a revocation has been issued without asking. Implementations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "provenance-protocol",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Provenance Protocol \u2014 open standard for AI agent identity \u2014 and its reference SDK",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -58,6 +58,6 @@
58
58
  "node": ">=14.0.0"
59
59
  },
60
60
  "scripts": {
61
- "test": "node test/verify.test.mjs && node test/declaration-signature.test.mjs"
61
+ "test": "node test/verify.test.mjs && node test/declaration-signature.test.mjs && node test/payload-separation.test.mjs && node test/location.test.mjs"
62
62
  }
63
63
  }
package/src/canonical.js CHANGED
@@ -20,8 +20,6 @@
20
20
  * silently coerced into an ambiguous signature
21
21
  */
22
22
 
23
- const DOMAIN = 'provenance-declaration-v1';
24
-
25
23
  /** Thrown when a declaration contains something that cannot be canonicalised. */
26
24
  export class CanonicalError extends Error {
27
25
  constructor(message) {
@@ -30,6 +28,44 @@ export class CanonicalError extends Error {
30
28
  }
31
29
  }
32
30
 
31
+ const DOMAIN = 'provenance-declaration-v1';
32
+
33
+ /**
34
+ * Every distinct thing an agent key signs gets its own prefix, so a signature
35
+ * obtained for one purpose can never be presented as another.
36
+ *
37
+ * This is not theoretical. Spec 0.1 signed a challenge as "<id>:<nonce>" and a
38
+ * revocation as "<id>:REVOKE" — the same payload with a chosen nonce. Any
39
+ * publicly reachable endpoint that signs a caller-supplied nonce therefore
40
+ * hands out valid revocation signatures for its own key, letting a stranger
41
+ * revoke the agent. A key lifted into the nonce likewise reproduces the 0.1
42
+ * declaration payload.
43
+ *
44
+ * The separated forms below cannot be confused with each other whatever the
45
+ * caller supplies.
46
+ */
47
+ export const CHALLENGE_DOMAIN = 'provenance-challenge-v1';
48
+ export const REVOCATION_DOMAIN = 'provenance-revocation-v1';
49
+
50
+ /** Payload for proving live control of a key. Nonce must be single-use. */
51
+ export function challengePayload(provenanceId, nonce) {
52
+ if (typeof provenanceId !== 'string' || provenanceId.length === 0) {
53
+ throw new CanonicalError('provenanceId is required');
54
+ }
55
+ if (typeof nonce !== 'string' || nonce.length === 0) {
56
+ throw new CanonicalError('nonce is required');
57
+ }
58
+ return `${CHALLENGE_DOMAIN}:${provenanceId}:${nonce}`;
59
+ }
60
+
61
+ /** Payload for revoking a provenance id. Carries no caller-supplied input. */
62
+ export function revocationPayload(provenanceId) {
63
+ if (typeof provenanceId !== 'string' || provenanceId.length === 0) {
64
+ throw new CanonicalError('provenanceId is required');
65
+ }
66
+ return `${REVOCATION_DOMAIN}:${provenanceId}`;
67
+ }
68
+
33
69
  function canonicalValue(value, path = '$') {
34
70
  if (value === null) return 'null';
35
71
 
package/src/cli.js CHANGED
File without changes
package/src/keygen.d.ts CHANGED
@@ -61,3 +61,15 @@ export function signRevocation(privateKeyBase64: string, provenanceId: string):
61
61
  * @returns Base64 signature — put it in identity.signature
62
62
  */
63
63
  export function signDeclaration(privateKeyBase64: string, declaration: object): string;
64
+
65
+ /**
66
+ * Prove live control of a key against a nonce — domain-separated form.
67
+ *
68
+ * Use this for any endpoint a stranger can call. The legacy `signChallenge`
69
+ * signs the same payload shape as a revocation, so exposing that publicly lets
70
+ * a caller obtain a valid revocation signature for your own key.
71
+ */
72
+ export function signAgentChallenge(privateKeyBase64: string, provenanceId: string, nonce: string): string;
73
+
74
+ /** Revoke a provenance id — domain-separated form. Takes no caller-supplied input. */
75
+ export function signAgentRevocation(privateKeyBase64: string, provenanceId: string): string;
package/src/keygen.js CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { generateKeyPairSync, sign, createPrivateKey } from 'crypto';
28
- import { declarationSigningPayload } from './canonical.js';
28
+ import { declarationSigningPayload, challengePayload, revocationPayload } from './canonical.js';
29
29
 
30
30
  /**
31
31
  * Generate a new Ed25519 keypair for use with Provenance identity.
@@ -58,7 +58,12 @@ export function generateProvenanceKeyPair() {
58
58
  }
59
59
 
60
60
  /**
61
- * Sign a challenge from a receiving system.
61
+ * Sign a challenge from a receiving system — LEGACY, spec 0.1 form.
62
+ *
63
+ * Signs "<provenanceId>:<nonce>", which is indistinguishable from a revocation
64
+ * when the nonce is "REVOKE". NEVER expose an endpoint that calls this with a
65
+ * caller-supplied nonce: a stranger can use it to revoke your key. Use
66
+ * `signAgentChallenge` instead.
62
67
  *
63
68
  * Call this when a receiving system sends you a nonce to prove your identity.
64
69
  * The signed message is always `${provenanceId}:${nonce}` — this binds the
@@ -149,6 +154,47 @@ export function signRevocation(privateKeyBase64, provenanceId) {
149
154
  * @param {object} declaration Parsed declaration; identity.signature is ignored
150
155
  * @returns {string} Base64 signature — put it in identity.signature
151
156
  */
157
+ function _sign(privateKeyBase64, message) {
158
+ const privateKey = createPrivateKey({
159
+ key: Buffer.from(privateKeyBase64, 'base64'),
160
+ format: 'der',
161
+ type: 'pkcs8',
162
+ });
163
+ return sign(null, Buffer.from(message, 'utf8'), privateKey).toString('base64');
164
+ }
165
+
166
+ /**
167
+ * Prove live control of a key against a nonce — domain-separated form.
168
+ *
169
+ * Use this for any endpoint a stranger can call. The legacy `signChallenge`
170
+ * signs "<provenanceId>:<nonce>", which is the same shape as a revocation with
171
+ * nonce "REVOKE" — so exposing that publicly lets a caller obtain a valid
172
+ * revocation signature for your own key and revoke you. This form cannot be
173
+ * confused with a revocation or a declaration whatever nonce is supplied.
174
+ *
175
+ * @param {string} privateKeyBase64
176
+ * @param {string} provenanceId
177
+ * @param {string} nonce Single-use and unpredictable
178
+ * @returns {string} Base64 signature
179
+ */
180
+ export function signAgentChallenge(privateKeyBase64, provenanceId, nonce) {
181
+ return _sign(privateKeyBase64, challengePayload(provenanceId, nonce));
182
+ }
183
+
184
+ /**
185
+ * Revoke a provenance id — domain-separated form.
186
+ *
187
+ * Takes no caller-supplied input, so it cannot be produced by a challenge
188
+ * endpoint however it is called.
189
+ *
190
+ * @param {string} privateKeyBase64
191
+ * @param {string} provenanceId
192
+ * @returns {string} Base64 signature
193
+ */
194
+ export function signAgentRevocation(privateKeyBase64, provenanceId) {
195
+ return _sign(privateKeyBase64, revocationPayload(provenanceId));
196
+ }
197
+
152
198
  export function signDeclaration(privateKeyBase64, declaration) {
153
199
  const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
154
200
  const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
package/src/verify.d.ts CHANGED
@@ -93,3 +93,16 @@ export function verifyRevocation(
93
93
  provenanceId: string,
94
94
  signatureBase64: string
95
95
  ): Promise<boolean>;
96
+
97
+ /**
98
+ * Verify a live challenge response — domain-separated form. Prefer this over
99
+ * `verifyChallenge`, whose payload is indistinguishable from a revocation.
100
+ */
101
+ export function verifyAgentChallenge(
102
+ publicKeyBase64: string, provenanceId: string, nonce: string, signatureBase64: string
103
+ ): Promise<boolean>;
104
+
105
+ /** Verify a revocation — domain-separated form. */
106
+ export function verifyAgentRevocation(
107
+ publicKeyBase64: string, provenanceId: string, signatureBase64: string
108
+ ): Promise<boolean>;
package/src/verify.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * Uses the Web Crypto API (crypto.subtle): all modern browsers, Node 18+.
17
17
  */
18
18
 
19
- import { declarationSigningPayload } from './canonical.js';
19
+ import { declarationSigningPayload, challengePayload, revocationPayload } from './canonical.js';
20
20
 
21
21
  /** Signature algorithm. Ed25519 in every spec version so far. */
22
22
  const ALGORITHM = 'ed25519';
@@ -131,6 +131,26 @@ export function checkLocation(provenanceId, retrievedFrom) {
131
131
  return 'unchecked';
132
132
  }
133
133
 
134
+ // provenance:domain:<hostname>[/<path>] — for an agent that runs as a service
135
+ // and has no public repository. Control is proven the same way as with a
136
+ // repo: whoever put the file there had write access to the location. Most
137
+ // commercial agents are this shape, so without it they could never be
138
+ // verified as their operator's.
139
+ if (id.platform === 'domain') {
140
+ const [declaredHost, ...declaredPath] = id.path.split('/').filter(Boolean);
141
+ if (!declaredHost) return 'unchecked';
142
+ // Exact host match. A subdomain is a different party as far as this is
143
+ // concerned, and treating it as the same would be the whole attack.
144
+ if (url.hostname.toLowerCase() !== declaredHost.toLowerCase()) return 'mismatch';
145
+ if (declaredPath.length === 0) return 'match';
146
+ const segs = url.pathname.split('/').filter(Boolean).map((x) => x.toLowerCase());
147
+ const want = declaredPath.map((x) => x.toLowerCase());
148
+ for (let i = 0; i + want.length <= segs.length; i++) {
149
+ if (want.every((part, j) => segs[i + j] === part)) return 'match';
150
+ }
151
+ return 'mismatch';
152
+ }
153
+
134
154
  const platform = HOST_PLATFORMS.find(([host]) => host.test(url.hostname))?.[1];
135
155
  if (!platform) return 'unchecked';
136
156
  if (platform !== id.platform) return 'mismatch';
@@ -282,9 +302,53 @@ export async function verifyDeclaration(declaration, options = {}) {
282
302
  };
283
303
  }
284
304
 
305
+ /**
306
+ * Verify a live challenge response — domain-separated form.
307
+ *
308
+ * Pair with `signAgentChallenge`. Prefer this over `verifyChallenge`: the legacy
309
+ * payload is the same shape as a revocation, so any public endpoint signing it
310
+ * is a way to revoke the agent's own key.
311
+ *
312
+ * @param {string} publicKeyBase64
313
+ * @param {string} provenanceId
314
+ * @param {string} nonce Single-use, unpredictable
315
+ * @param {string} signatureBase64
316
+ * @returns {Promise<boolean>}
317
+ */
318
+ export async function verifyAgentChallenge(publicKeyBase64, provenanceId, nonce, signatureBase64) {
319
+ try {
320
+ return await verifyEd25519(publicKeyBase64, signatureBase64, challengePayload(provenanceId, nonce));
321
+ } catch {
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Verify a revocation — domain-separated form.
328
+ *
329
+ * Confirms it came from the key holder. It does not tell you whether a
330
+ * revocation exists; that requires asking an index.
331
+ *
332
+ * @param {string} publicKeyBase64
333
+ * @param {string} provenanceId
334
+ * @param {string} signatureBase64
335
+ * @returns {Promise<boolean>}
336
+ */
337
+ export async function verifyAgentRevocation(publicKeyBase64, provenanceId, signatureBase64) {
338
+ try {
339
+ return await verifyEd25519(publicKeyBase64, signatureBase64, revocationPayload(provenanceId));
340
+ } catch {
341
+ return false;
342
+ }
343
+ }
344
+
285
345
  /**
286
346
  * Verify a live challenge response offline, against a key you already hold.
287
347
  *
348
+ * LEGACY (spec 0.1 payload). Accepts "<provenanceId>:<nonce>", which is the same
349
+ * shape as a revocation with nonce "REVOKE" — so never verify against a peer
350
+ * that exposes this form publicly. Use `verifyAgentChallenge`.
351
+ *
288
352
  * The network equivalent in the main SDK looks the key up in the index; this
289
353
  * takes the key directly, so a system that already stores keys can verify
290
354
  * without contacting anyone.