provenance-protocol 0.3.0 → 0.4.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
@@ -408,27 +408,49 @@ This is the same division of labour as a machine-readable passport: the
408
408
  document proves its own integrity offline, while identity binding and
409
409
  current standing are looked up.
410
410
 
411
+ ### Every signed payload is domain-separated
412
+
413
+ An agent's key signs several different things. Each payload MUST carry the
414
+ prefix for its purpose, so that a signature obtained for one purpose can never
415
+ be presented as another:
416
+
417
+ | Purpose | Signed payload |
418
+ |---|---|
419
+ | Declaration (0.2) | `provenance-declaration-v1:<canonical JSON>` |
420
+ | Live proof of key control | `provenance-challenge-v1:<provenance_id>:<nonce>` |
421
+ | Revocation | `provenance-revocation-v1:<provenance_id>` |
422
+
423
+ This is not a precaution against something hypothetical. In 0.1 a challenge was
424
+ signed as `<provenance_id>:<nonce>` and a revocation as
425
+ `<provenance_id>:REVOKE` — **the same payload with a chosen nonce.** Any
426
+ publicly reachable endpoint that signs a caller-supplied nonce in the 0.1 form
427
+ therefore hands out valid revocation signatures for its own key, and a stranger
428
+ can revoke the agent. Supplying the public key as the nonce likewise reproduces
429
+ the 0.1 declaration signature.
430
+
431
+ An endpoint that signs a caller-supplied value MUST use the separated challenge
432
+ form, and MUST NOT sign the 0.1 payload. Verifiers SHOULD require the separated
433
+ form from any agent that exposes a public challenge endpoint.
434
+
435
+ The 0.1 payloads remain defined so existing deployments keep working, but they
436
+ are unsafe to expose and are superseded.
437
+
411
438
  ### Live proof of key control
412
439
 
413
440
  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:
441
+ agent running right now controls that key. For that, a receiving system issues
442
+ a nonce and the agent returns a signature over
443
+ `provenance-challenge-v1:<provenance_id>:<nonce>`.
416
444
 
417
- ```
418
- <provenance_id>:<nonce>
419
- ```
420
-
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`.
445
+ Nonces must be single-use and unpredictable. The receiving system verifies the
446
+ signature against the public key it already holds for that `provenance_id`.
424
447
 
425
448
  ### Revocation
426
449
 
427
- A key holder revokes a `provenance_id` by signing:
428
-
429
- ```
430
- <provenance_id>:REVOKE
431
- ```
450
+ A key holder revokes a `provenance_id` by signing
451
+ `provenance-revocation-v1:<provenance_id>`. The payload contains no
452
+ caller-supplied input, so it cannot be produced by a challenge endpoint however
453
+ that endpoint is called.
432
454
 
433
455
  Revocation is the one operation that cannot be verified offline — a verifier
434
456
  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.4.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"
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/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';
@@ -282,9 +282,53 @@ export async function verifyDeclaration(declaration, options = {}) {
282
282
  };
283
283
  }
284
284
 
285
+ /**
286
+ * Verify a live challenge response — domain-separated form.
287
+ *
288
+ * Pair with `signAgentChallenge`. Prefer this over `verifyChallenge`: the legacy
289
+ * payload is the same shape as a revocation, so any public endpoint signing it
290
+ * is a way to revoke the agent's own key.
291
+ *
292
+ * @param {string} publicKeyBase64
293
+ * @param {string} provenanceId
294
+ * @param {string} nonce Single-use, unpredictable
295
+ * @param {string} signatureBase64
296
+ * @returns {Promise<boolean>}
297
+ */
298
+ export async function verifyAgentChallenge(publicKeyBase64, provenanceId, nonce, signatureBase64) {
299
+ try {
300
+ return await verifyEd25519(publicKeyBase64, signatureBase64, challengePayload(provenanceId, nonce));
301
+ } catch {
302
+ return false;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Verify a revocation — domain-separated form.
308
+ *
309
+ * Confirms it came from the key holder. It does not tell you whether a
310
+ * revocation exists; that requires asking an index.
311
+ *
312
+ * @param {string} publicKeyBase64
313
+ * @param {string} provenanceId
314
+ * @param {string} signatureBase64
315
+ * @returns {Promise<boolean>}
316
+ */
317
+ export async function verifyAgentRevocation(publicKeyBase64, provenanceId, signatureBase64) {
318
+ try {
319
+ return await verifyEd25519(publicKeyBase64, signatureBase64, revocationPayload(provenanceId));
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
285
325
  /**
286
326
  * Verify a live challenge response offline, against a key you already hold.
287
327
  *
328
+ * LEGACY (spec 0.1 payload). Accepts "<provenanceId>:<nonce>", which is the same
329
+ * shape as a revocation with nonce "REVOKE" — so never verify against a peer
330
+ * that exposes this form publicly. Use `verifyAgentChallenge`.
331
+ *
288
332
  * The network equivalent in the main SDK looks the key up in the index; this
289
333
  * takes the key directly, so a system that already stores keys can verify
290
334
  * without contacting anyone.