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 +58 -14
- package/package.json +2 -2
- package/src/canonical.js +38 -2
- package/src/cli.js +0 -0
- package/src/keygen.d.ts +12 -0
- package/src/keygen.js +48 -2
- package/src/verify.d.ts +13 -0
- package/src/verify.js +65 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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.
|