provenance-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 CHANGED
@@ -30,11 +30,23 @@ const result = await verifyDeclaration(YAML.parse(fileContents), {
30
30
  });
31
31
 
32
32
  result.valid // the signature verifies against the key in the file
33
+ result.coverage // 'declaration' (0.2) | 'identity' (0.1)
33
34
  result.location // 'match' | 'mismatch' | 'unchecked'
34
35
  result.trustworthy // valid AND served from the location it claims
35
36
  result.fingerprint // SHA-256 of the key — store it to detect rotation
36
37
  ```
37
38
 
39
+ `coverage` matters. Under spec **0.2** the signature covers the whole
40
+ declaration, so deleting a constraint breaks it. Under **0.1** it covered only
41
+ the identity and key — the declared capabilities and constraints were *not*
42
+ protected, and a valid 0.1 signature says nothing about whether they were
43
+ edited. Sign new declarations with 0.2:
44
+
45
+ ```js
46
+ import { signDeclaration } from 'provenance-protocol/keygen';
47
+ declaration.identity.signature = signDeclaration(privateKey, declaration);
48
+ ```
49
+
38
50
  A valid signature proves the declaration came from the holder of that private
39
51
  key and has not been altered. It does **not** prove who that holder is, that
40
52
  the declared capabilities are accurate, or that the declaration is current.
package/SPEC.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Provenance Protocol Specification
2
- **Version 0.1**
2
+ **Versions 0.1 and 0.2**
3
3
 
4
4
  ---
5
5
 
@@ -299,7 +299,7 @@ identity:
299
299
  ```
300
300
 
301
301
  `public_key` is required when `identity` is present. `algorithm` is optional
302
- and defaults to `ed25519`; no other value is valid in v0.1.
302
+ and defaults to `ed25519`; no other value is valid.
303
303
 
304
304
  `signature` is optional, and its presence changes what the block means:
305
305
 
@@ -315,7 +315,35 @@ Prefer the signed form wherever the agent has a public location. A verifier
315
315
  that finds a key with no signature has learned which key to challenge, and
316
316
  nothing about whether the file has been altered.
317
317
 
318
- ### What gets signed
318
+ ### What gets signed — and this differs by version
319
+
320
+ The `provenance` field selects the rule. A verifier MUST use the rule for the
321
+ version the declaration declares, and MUST refuse to guess for a version it does
322
+ not know.
323
+
324
+ #### 0.2 — the whole declaration (use this)
325
+
326
+ The signed message is the UTF-8 encoding of:
327
+
328
+ ```
329
+ provenance-declaration-v1:<canonical JSON of the declaration>
330
+ ```
331
+
332
+ The canonical JSON is produced from the **parsed** declaration, with
333
+ `identity.signature` removed — it cannot cover itself — and with object keys
334
+ sorted by Unicode code point recursively and no insignificant whitespace.
335
+
336
+ Because canonicalisation applies to the parsed value rather than the file's
337
+ bytes, comments, indentation, quoting style and key order do not affect the
338
+ signature. A declaration can be reformatted without re-signing. Only JSON
339
+ representable values may be signed; a parser that yields dates or other
340
+ non-plain objects must be made to yield strings instead.
341
+
342
+ Every field is covered, so deleting a constraint or adding a capability breaks
343
+ the signature. `provenance_id` is not required to verify a 0.2 signature, though
344
+ the location check still needs it.
345
+
346
+ #### 0.1 — the identity only (legacy)
319
347
 
320
348
  The signed message is the UTF-8 encoding of:
321
349
 
@@ -323,12 +351,18 @@ The signed message is the UTF-8 encoding of:
323
351
  <provenance_id>:<public_key>
324
352
  ```
325
353
 
326
- where `<public_key>` is the same base64 string that appears in
327
- `identity.public_key`. Concatenating the two binds the key to the identity,
328
- so a key lifted from one declaration cannot be replayed under a different
329
- `provenance_id`.
354
+ This binds the key to the identity, so a key lifted from one declaration cannot
355
+ be replayed under a different `provenance_id`, and it requires `provenance_id`
356
+ to be present.
330
357
 
331
- Signing a declaration therefore requires `provenance_id` to be present.
358
+ **It does not cover any other field.** A 0.1 declaration's capabilities and
359
+ constraints are not protected by its signature: someone with write access to the
360
+ location can delete a declared constraint and the signature still verifies.
361
+ Location and write access are the only controls there.
362
+
363
+ 0.1 declarations remain readable and verifiable — nothing published stops
364
+ working — but new declarations SHOULD use 0.2, and a verifier SHOULD report
365
+ which coverage it checked so a reader is not misled about what was proven.
332
366
 
333
367
  ### How to verify
334
368
 
@@ -344,10 +378,12 @@ anyone.
344
378
 
345
379
  ### What verification proves — and what it does not
346
380
 
347
- A valid signature proves:
381
+ A valid signature proves the declaration was produced by the holder of that
382
+ private key, and:
348
383
 
349
- - the declaration was produced by the holder of that private key, and
350
- - not one byte of `provenance_id` or `public_key` has changed since.
384
+ - under **0.2**, that no field of the declaration has changed since;
385
+ - under **0.1**, only that `provenance_id` and `public_key` have not changed
386
+ everything else is unprotected.
351
387
 
352
388
  A valid signature does **not** prove:
353
389
 
@@ -405,19 +441,25 @@ they would re-check any other freshness signal.
405
441
 
406
442
  An implementation of this specification is conformant if it:
407
443
 
408
- 1. Accepts every file that validates against `schema/provenance-0.1.json`.
444
+ 1. Accepts every file that validates against the schema for its declared
445
+ version — `schema/provenance-0.1.json` or `schema/provenance-0.2.json`.
409
446
  2. Treats `provenance`, `name` and `description` as required and everything
410
447
  else as optional.
411
448
  3. Ignores unrecognised top-level fields rather than rejecting the file.
412
449
  Future spec versions add fields; a `0.1` reader must not break on them.
413
450
  4. Treats `identity.signature` as optional, and an unsigned `identity`
414
451
  block as advertising a key rather than attesting the file.
415
- 5. Reproduces every signature in `test-vectors/signatures-0.1.json` marked
416
- `valid`, and refuses every one marked `invalid`.
417
- 6. Treats a declaration whose `provenance_id` does not match its retrieval
452
+ 5. Applies the signing rule for the version the declaration declares, refuses
453
+ to guess for an unknown version, and reports which coverage it checked —
454
+ a reader must not be left thinking a 0.1 signature protected the
455
+ declared constraints.
456
+ 6. Reproduces every signature in `test-vectors/signatures-0.1.json` and every
457
+ declaration in `test-vectors/declarations-0.2.json` marked `valid`, and
458
+ refuses every one marked `invalid`.
459
+ 7. Treats a declaration whose `provenance_id` does not match its retrieval
418
460
  location as unverified.
419
461
 
420
- Points 5 and 6 are what make independent implementations agree. An
462
+ Points 6 and 7 are what make independent implementations agree. An
421
463
  implementation that passes the test vectors interoperates with every other
422
464
  one that does, with no reference to any particular service.
423
465
 
@@ -442,10 +484,22 @@ monitors, attesters — are applications of the standard, not part of it.
442
484
 
443
485
  ## Versioning
444
486
 
445
- The `provenance` field records which spec version you are using.
446
- We commit to backwards compatibility a `0.1` file will always be
447
- readable regardless of future spec versions.
448
- New versions add fields. Existing fields are never removed.
487
+ The `provenance` field records which spec version you are using, and it is what
488
+ a reader uses to decide how to interpret the file.
489
+
490
+ We commit to backwards compatibility: a `0.1` file will always be readable and
491
+ verifiable, whatever later versions say. Fields are never removed.
492
+
493
+ Usually a new version only adds fields. Version 0.2 is the exception so far — it
494
+ changed what an existing field *covers*, because `identity.signature` in 0.1
495
+ protected only the identity and left the declared capabilities and constraints
496
+ unprotected, which was weaker than readers assumed. Rather than redefine 0.1
497
+ under declarations already published, 0.2 defines the stronger rule and the
498
+ `provenance` field keeps the two apart. A verifier implements both and applies
499
+ the one the declaration asks for.
500
+
501
+ Where a version changes the meaning of an existing field, it will always do so
502
+ by declaring a new version, never by reinterpreting an old one.
449
503
 
450
504
  ---
451
505
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "provenance-protocol",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
@@ -33,7 +33,9 @@
33
33
  "README.md",
34
34
  "SPEC.md",
35
35
  "schema/provenance-0.1.json",
36
- "test-vectors/"
36
+ "test-vectors/",
37
+ "src/canonical.js",
38
+ "schema/provenance-0.2.json"
37
39
  ],
38
40
  "keywords": [
39
41
  "ai-agent",
@@ -56,6 +58,6 @@
56
58
  "node": ">=14.0.0"
57
59
  },
58
60
  "scripts": {
59
- "test": "node test/verify.test.mjs"
61
+ "test": "node test/verify.test.mjs && node test/declaration-signature.test.mjs"
60
62
  }
61
63
  }
@@ -187,7 +187,7 @@
187
187
  },
188
188
  "signature": {
189
189
  "type": "string",
190
- "description": "Base64 Ed25519 signature of '<provenance_id>:<public_key>' \u2014 proves you control the private key and that the declaration is unaltered. Optional: omit it when the key is published only for live challenge-response. Requires provenance_id."
190
+ "description": "Base64 Ed25519 signature of '<provenance_id>:<public_key>' \u2014 proves you control the private key under that identity. NOTE: in 0.1 it does NOT cover the rest of the declaration, so capabilities and constraints are not protected by it; spec 0.2 signs the whole declaration. Optional: omit it when the key is published only for live challenge-response. Requires provenance_id."
191
191
  },
192
192
  "algorithm": {
193
193
  "type": "string",
@@ -0,0 +1,229 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://getprovenance.dev/schema/0.2.json",
4
+ "title": "PROVENANCE.yml",
5
+ "description": "Provenance Protocol v0.2 schema for AI agent identity files",
6
+ "type": "object",
7
+ "required": [
8
+ "provenance",
9
+ "name",
10
+ "description"
11
+ ],
12
+ "properties": {
13
+ "provenance": {
14
+ "type": "string",
15
+ "description": "Spec version. 0.2 signs the whole declaration.",
16
+ "enum": [
17
+ "0.2"
18
+ ]
19
+ },
20
+ "name": {
21
+ "type": "string",
22
+ "description": "Human-readable name for this agent",
23
+ "minLength": 1,
24
+ "maxLength": 100
25
+ },
26
+ "description": {
27
+ "type": "string",
28
+ "description": "What this agent does, in plain language",
29
+ "minLength": 1,
30
+ "maxLength": 1000
31
+ },
32
+ "version": {
33
+ "type": "string",
34
+ "description": "Semantic version of the agent",
35
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+.*$"
36
+ },
37
+ "model": {
38
+ "type": "object",
39
+ "description": "The LLM powering this agent",
40
+ "properties": {
41
+ "provider": {
42
+ "type": "string",
43
+ "enum": [
44
+ "anthropic",
45
+ "openai",
46
+ "google",
47
+ "mistral",
48
+ "meta",
49
+ "local",
50
+ "other"
51
+ ],
52
+ "description": "LLM provider"
53
+ },
54
+ "model_id": {
55
+ "type": "string",
56
+ "description": "Specific model ID e.g. claude-sonnet-4-5"
57
+ }
58
+ },
59
+ "additionalProperties": false
60
+ },
61
+ "capabilities": {
62
+ "type": "array",
63
+ "description": "What this agent can do \u2014 standard vocabulary or domain:custom",
64
+ "items": {
65
+ "type": "string",
66
+ "pattern": "^[a-z][a-z0-9_-]*(:[a-z0-9_:-]+)?$"
67
+ },
68
+ "uniqueItems": true
69
+ },
70
+ "constraints": {
71
+ "type": "array",
72
+ "description": "Public commitments \u2014 what this agent will NEVER do",
73
+ "items": {
74
+ "type": "string",
75
+ "pattern": "^no:[a-z0-9_:-]+$"
76
+ },
77
+ "uniqueItems": true
78
+ },
79
+ "delegates": {
80
+ "type": "array",
81
+ "description": "Sub-agents this orchestrator spawns \u2014 required if delegate:agents capability declared",
82
+ "items": {
83
+ "type": "object",
84
+ "required": [
85
+ "provenance_id"
86
+ ],
87
+ "properties": {
88
+ "provenance_id": {
89
+ "type": "string",
90
+ "pattern": "^provenance:(github|npm|pypi|huggingface|clawmarket):.+$"
91
+ },
92
+ "purpose": {
93
+ "type": "string",
94
+ "maxLength": 200
95
+ }
96
+ },
97
+ "additionalProperties": false
98
+ }
99
+ },
100
+ "skills": {
101
+ "type": "array",
102
+ "description": "External skills this agent depends on",
103
+ "items": {
104
+ "type": "object",
105
+ "required": [
106
+ "id",
107
+ "source"
108
+ ],
109
+ "properties": {
110
+ "id": {
111
+ "type": "string"
112
+ },
113
+ "source": {
114
+ "type": "string",
115
+ "enum": [
116
+ "skillsmp",
117
+ "github",
118
+ "npm",
119
+ "custom"
120
+ ]
121
+ },
122
+ "url": {
123
+ "type": "string",
124
+ "format": "uri"
125
+ }
126
+ },
127
+ "additionalProperties": false
128
+ }
129
+ },
130
+ "runtime": {
131
+ "type": "object",
132
+ "description": "How this agent runs",
133
+ "properties": {
134
+ "type": {
135
+ "type": "string",
136
+ "enum": [
137
+ "task",
138
+ "persistent",
139
+ "scheduled"
140
+ ],
141
+ "description": "task: runs to completion | persistent: always on | scheduled: cron-like"
142
+ },
143
+ "trigger": {
144
+ "type": "string",
145
+ "enum": [
146
+ "api",
147
+ "webhook",
148
+ "schedule",
149
+ "event"
150
+ ],
151
+ "description": "What starts this agent"
152
+ }
153
+ },
154
+ "additionalProperties": false
155
+ },
156
+ "contact": {
157
+ "type": "object",
158
+ "description": "Who is responsible for this agent",
159
+ "properties": {
160
+ "name": {
161
+ "type": "string",
162
+ "minLength": 1
163
+ },
164
+ "url": {
165
+ "type": "string",
166
+ "format": "uri"
167
+ },
168
+ "email": {
169
+ "type": "string",
170
+ "format": "email"
171
+ }
172
+ },
173
+ "additionalProperties": false
174
+ },
175
+ "provenance_id": {
176
+ "type": "string",
177
+ "description": "Your Provenance identifier \u2014 links this file to your index entry",
178
+ "pattern": "^provenance:(github|npm|pypi|huggingface|clawmarket):.+$"
179
+ },
180
+ "identity": {
181
+ "type": "object",
182
+ "description": "Cryptographic identity. public_key alone advertises the key an agent will use to prove control live. Adding signature makes the entire declaration tamper-evident.",
183
+ "properties": {
184
+ "public_key": {
185
+ "type": "string",
186
+ "description": "Base64-encoded Ed25519 SPKI DER public key. Generate with: generateProvenanceKeyPair() from provenance-protocol/keygen"
187
+ },
188
+ "signature": {
189
+ "type": "string",
190
+ "description": "Base64 Ed25519 signature over 'provenance-declaration-v1:' followed by the canonical JSON of this parsed declaration with identity.signature removed. Covers every field, so editing capabilities or deleting a constraint breaks it. Generate with signDeclaration() from provenance-protocol/keygen. Optional: omit when the key is published only for live challenge-response."
191
+ },
192
+ "algorithm": {
193
+ "type": "string",
194
+ "enum": [
195
+ "ed25519"
196
+ ],
197
+ "description": "Signing algorithm. Always ed25519."
198
+ }
199
+ },
200
+ "required": [
201
+ "public_key"
202
+ ],
203
+ "additionalProperties": false
204
+ },
205
+ "ajp": {
206
+ "type": "object",
207
+ "description": "Agent Job Protocol endpoint \u2014 enables agent-to-agent job delegation",
208
+ "properties": {
209
+ "endpoint": {
210
+ "type": "string",
211
+ "format": "uri",
212
+ "description": "Base URL for your AJP implementation. Must accept POST /jobs, GET /jobs/:id, POST /jobs/:id/ack"
213
+ },
214
+ "version": {
215
+ "type": "string",
216
+ "enum": [
217
+ "0.1"
218
+ ],
219
+ "description": "AJP spec version"
220
+ }
221
+ },
222
+ "required": [
223
+ "endpoint"
224
+ ],
225
+ "additionalProperties": false
226
+ }
227
+ },
228
+ "additionalProperties": false
229
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * provenance-protocol — canonical form for signing declarations
3
+ *
4
+ * Spec 0.1 signed only "<provenance_id>:<public_key>", so a signature proved
5
+ * key control but left the rest of the declaration unprotected: a constraint
6
+ * could be deleted and the signature would still verify. Spec 0.2 signs the
7
+ * whole declaration, which needs one canonical serialisation that every
8
+ * implementation agrees on byte for byte.
9
+ *
10
+ * Canonicalisation applies to the PARSED value, not the file's bytes. Comments,
11
+ * indentation, quoting style and key order therefore do not affect the
12
+ * signature — a declaration can be reformatted without re-signing, which is
13
+ * what anyone would expect.
14
+ *
15
+ * Rules:
16
+ * - object keys sorted by Unicode code point, recursively
17
+ * - no insignificant whitespace
18
+ * - `identity.signature` removed before signing (it cannot cover itself)
19
+ * - only JSON-representable values; anything else throws rather than being
20
+ * silently coerced into an ambiguous signature
21
+ */
22
+
23
+ const DOMAIN = 'provenance-declaration-v1';
24
+
25
+ /** Thrown when a declaration contains something that cannot be canonicalised. */
26
+ export class CanonicalError extends Error {
27
+ constructor(message) {
28
+ super(message);
29
+ this.name = 'CanonicalError';
30
+ }
31
+ }
32
+
33
+ function canonicalValue(value, path = '$') {
34
+ if (value === null) return 'null';
35
+
36
+ const type = typeof value;
37
+
38
+ if (type === 'string') return JSON.stringify(value);
39
+ if (type === 'boolean') return value ? 'true' : 'false';
40
+ if (type === 'number') {
41
+ if (!Number.isFinite(value)) {
42
+ throw new CanonicalError(`${path}: non-finite numbers cannot be signed`);
43
+ }
44
+ return JSON.stringify(value);
45
+ }
46
+
47
+ if (Array.isArray(value)) {
48
+ return `[${value.map((item, i) => canonicalValue(item, `${path}[${i}]`)).join(',')}]`;
49
+ }
50
+
51
+ if (type === 'object') {
52
+ // A YAML parser may produce Dates, Maps, Buffers and so on. Signing those
53
+ // would depend on whichever serialisation happened to be used, so refuse.
54
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
55
+ throw new CanonicalError(
56
+ `${path}: only plain objects can be signed (got ${value.constructor?.name ?? 'unknown type'}) — quote the value as a string`
57
+ );
58
+ }
59
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
60
+ const parts = keys.map(
61
+ (k) => `${JSON.stringify(k)}:${canonicalValue(value[k], `${path}.${k}`)}`
62
+ );
63
+ return `{${parts.join(',')}}`;
64
+ }
65
+
66
+ throw new CanonicalError(`${path}: values of type ${type} cannot be signed`);
67
+ }
68
+
69
+ /**
70
+ * The exact string a spec-0.2 declaration signature is computed over.
71
+ *
72
+ * Domain-separated so a declaration signature can never be replayed as a
73
+ * signature over some other kind of message.
74
+ *
75
+ * @param {object} declaration Parsed declaration (its identity.signature is ignored)
76
+ * @returns {string}
77
+ */
78
+ export function declarationSigningPayload(declaration) {
79
+ if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) {
80
+ throw new CanonicalError('Declaration must be a parsed object');
81
+ }
82
+
83
+ const { identity, ...rest } = declaration;
84
+ const covered = { ...rest };
85
+
86
+ if (identity !== undefined) {
87
+ if (identity === null || typeof identity !== 'object' || Array.isArray(identity)) {
88
+ throw new CanonicalError('$.identity must be an object when present');
89
+ }
90
+ // public_key and algorithm ARE covered; only the signature is excluded.
91
+ const { signature: _excluded, ...identityRest } = identity;
92
+ covered.identity = identityRest;
93
+ }
94
+
95
+ return `${DOMAIN}:${canonicalValue(covered)}`;
96
+ }
97
+
98
+ export { DOMAIN as DECLARATION_SIGNING_DOMAIN };
package/src/keygen.d.ts CHANGED
@@ -46,3 +46,18 @@ export function signForProvenance(privateKeyBase64: string, provenanceId: string
46
46
  * Send the result as signed_challenge to POST /api/agents/revoke.
47
47
  */
48
48
  export function signRevocation(privateKeyBase64: string, provenanceId: string): string;
49
+
50
+ /**
51
+ * Sign a whole declaration — spec 0.2.
52
+ *
53
+ * Covers every field, so deleting a constraint or adding a capability breaks the
54
+ * signature. `signForProvenance` (spec 0.1) covers only the identity and key.
55
+ *
56
+ * Signs the canonical form of the PARSED declaration, so reformatting the file
57
+ * does not invalidate the signature.
58
+ *
59
+ * @param privateKeyBase64 Your PROVENANCE_PRIVATE_KEY (base64 PKCS8 DER)
60
+ * @param declaration Parsed declaration; identity.signature is ignored
61
+ * @returns Base64 signature — put it in identity.signature
62
+ */
63
+ export function signDeclaration(privateKeyBase64: string, declaration: object): string;
package/src/keygen.js CHANGED
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import { generateKeyPairSync, sign, createPrivateKey } from 'crypto';
28
+ import { declarationSigningPayload } from './canonical.js';
28
29
 
29
30
  /**
30
31
  * Generate a new Ed25519 keypair for use with Provenance identity.
@@ -134,6 +135,27 @@ export function signRevocation(privateKeyBase64, provenanceId) {
134
135
  return signChallenge(privateKeyBase64, provenanceId, 'REVOKE');
135
136
  }
136
137
 
138
+ /**
139
+ * Sign a whole declaration — spec 0.2.
140
+ *
141
+ * Covers every field, so deleting a constraint or adding a capability breaks
142
+ * the signature. `signForProvenance` (spec 0.1) covers only the identity and
143
+ * key, which leaves the rest of the declaration unprotected; prefer this.
144
+ *
145
+ * Signs the canonical form of the PARSED declaration, so reformatting the file
146
+ * does not invalidate the signature.
147
+ *
148
+ * @param {string} privateKeyBase64 Base64 PKCS8 DER private key
149
+ * @param {object} declaration Parsed declaration; identity.signature is ignored
150
+ * @returns {string} Base64 signature — put it in identity.signature
151
+ */
152
+ export function signDeclaration(privateKeyBase64, declaration) {
153
+ const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
154
+ const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
155
+ const message = Buffer.from(declarationSigningPayload(declaration), 'utf8');
156
+ return sign(null, message, privateKey).toString('base64');
157
+ }
158
+
137
159
  export function signForProvenance(privateKeyBase64, provenanceId, publicKeyBase64) {
138
160
  const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
139
161
  const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
package/src/verify.d.ts CHANGED
@@ -8,6 +8,9 @@
8
8
  /** Whether a declaration was served from the location its provenance_id names. */
9
9
  export type LocationCheck = 'match' | 'mismatch' | 'unchecked';
10
10
 
11
+ /** What a signature was found to cover, per the declaration's spec version. */
12
+ export type SignatureCoverage = 'declaration' | 'identity';
13
+
11
14
  export interface VerificationResult {
12
15
  /** An identity.signature was present to check. */
13
16
  signed: boolean;
@@ -20,6 +23,13 @@ export interface VerificationResult {
20
23
  /** SHA-256 of the public key, hex. Store it to detect key rotation. */
21
24
  fingerprint: string | null;
22
25
  location: LocationCheck;
26
+ /**
27
+ * 'declaration' (spec 0.2) — every field is covered; any edit breaks it.
28
+ * 'identity' (spec 0.1) — only the identity and key are covered, so the
29
+ * declared capabilities and constraints are NOT protected by the signature.
30
+ * null when no signature was checked.
31
+ */
32
+ coverage: SignatureCoverage | null;
23
33
  /**
24
34
  * Signature valid AND retrieval location confirmed. Only both together
25
35
  * justify treating the declaration as the named project owner's.
package/src/verify.js CHANGED
@@ -16,9 +16,23 @@
16
16
  * Uses the Web Crypto API (crypto.subtle): all modern browsers, Node 18+.
17
17
  */
18
18
 
19
- /** Signature algorithm for spec v0.1. No other value is valid. */
19
+ import { declarationSigningPayload } from './canonical.js';
20
+
21
+ /** Signature algorithm. Ed25519 in every spec version so far. */
20
22
  const ALGORITHM = 'ed25519';
21
23
 
24
+ /**
25
+ * Which spec versions this module knows how to verify a signature for, and what
26
+ * the signature covers in each.
27
+ *
28
+ * 0.1 — signs "<provenance_id>:<public_key>". Proves key control under that
29
+ * identity. Does NOT cover the rest of the declaration: a constraint can
30
+ * be deleted and the signature still verifies.
31
+ * 0.2 — signs the canonical form of the whole declaration. Any change to any
32
+ * field breaks it.
33
+ */
34
+ const SIGNATURE_COVERAGE = { '0.1': 'identity', '0.2': 'declaration' };
35
+
22
36
  function subtle() {
23
37
  const s = globalThis.crypto?.subtle;
24
38
  if (!s) throw new Error('Web Crypto API (crypto.subtle) not available');
@@ -142,10 +156,14 @@ export function checkLocation(provenanceId, retrievedFrom) {
142
156
  * when `retrievedFrom` is given — whether the file was served from the
143
157
  * location it claims.
144
158
  *
145
- * A valid signature proves the declaration was produced by the holder of that
146
- * private key and has not been altered. It does NOT prove who that holder is,
147
- * that the declared capabilities are accurate, or that the declaration is
148
- * current. Revocation and standing cannot be checked offline.
159
+ * What a valid signature proves depends on the spec version, and `coverage`
160
+ * reports which: 'declaration' (0.2) means every field is covered, so any edit
161
+ * breaks it; 'identity' (0.1) means only the identity and key are covered, so
162
+ * the declared capabilities and constraints are NOT protected by it.
163
+ *
164
+ * In neither case does a signature prove who the key holder is, that the
165
+ * declared capabilities are accurate, or that the declaration is current.
166
+ * Revocation and standing cannot be checked offline.
149
167
  *
150
168
  * @param {object} declaration Parsed PROVENANCE.yml
151
169
  * @param {object} [options]
@@ -158,6 +176,7 @@ export function checkLocation(provenanceId, retrievedFrom) {
158
176
  * publicKey: string | null,
159
177
  * fingerprint: string | null,
160
178
  * location: 'match' | 'mismatch' | 'unchecked',
179
+ * coverage: 'declaration' | 'identity' | null,
161
180
  * trustworthy: boolean
162
181
  * }>}
163
182
  */
@@ -170,6 +189,7 @@ export async function verifyDeclaration(declaration, options = {}) {
170
189
  publicKey: null,
171
190
  fingerprint: null,
172
191
  location: 'unchecked',
192
+ coverage: null,
173
193
  trustworthy: false,
174
194
  };
175
195
 
@@ -213,13 +233,39 @@ export async function verifyDeclaration(declaration, options = {}) {
213
233
  }
214
234
  result.signed = true;
215
235
 
216
- if (!provenanceId) {
217
- return { ...result, reason: 'provenance_id is required to verify a signature' };
236
+ const declaredVersion = typeof declaration.provenance === 'string' ? declaration.provenance : '0.1';
237
+ if (!provenanceId && declaredVersion === '0.1') {
238
+ // The 0.1 payload is built from provenance_id, so without it there is
239
+ // nothing to verify. A 0.2 signature covers the whole declaration and does
240
+ // not need it (though the location check still does).
241
+ return { ...result, reason: 'provenance_id is required to verify a 0.1 signature' };
242
+ }
243
+
244
+ // What the signature covers depends on the spec version the declaration
245
+ // declares, so the payload is built differently for each.
246
+ const specVersion = typeof declaration.provenance === 'string' ? declaration.provenance : '0.1';
247
+ const coverage = SIGNATURE_COVERAGE[specVersion];
248
+ if (!coverage) {
249
+ return {
250
+ ...result,
251
+ reason: `Spec version ${specVersion} is not known to this verifier — cannot check its signature`,
252
+ };
253
+ }
254
+ result.coverage = coverage;
255
+
256
+ let payload;
257
+ try {
258
+ payload =
259
+ coverage === 'declaration'
260
+ ? declarationSigningPayload(declaration)
261
+ : `${provenanceId}:${publicKey}`;
262
+ } catch (e) {
263
+ return { ...result, reason: `Declaration cannot be canonicalised: ${e.message}` };
218
264
  }
219
265
 
220
266
  let valid;
221
267
  try {
222
- valid = await verifyEd25519(publicKey, signature, `${provenanceId}:${publicKey}`);
268
+ valid = await verifyEd25519(publicKey, signature, payload);
223
269
  } catch {
224
270
  return { ...result, reason: 'identity.signature is malformed' };
225
271
  }
@@ -0,0 +1,217 @@
1
+ {
2
+ "$comment": "Normative test vectors for Provenance Protocol v0.2 declaration signatures. The signature covers the canonical form of the entire parsed declaration, excluding identity.signature itself.",
3
+ "spec_version": "0.2",
4
+ "algorithm": "ed25519",
5
+ "signing": {
6
+ "payload": "the string \"provenance-declaration-v1:\" followed by the canonical JSON of the parsed declaration with identity.signature removed",
7
+ "canonicalisation": "object keys sorted by code point recursively, no insignificant whitespace, applied to the PARSED value so formatting and key order do not matter"
8
+ },
9
+ "keypair": {
10
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
11
+ "private_key": "MC4CAQAwBQYDK2VwBCIEICnsl5v8baf3DyJApejP5DG3cmx1xS4S5qewS1A28IMN",
12
+ "$comment": "Test key only. Never use in production."
13
+ },
14
+ "reference_payload": "provenance-declaration-v1:{\"capabilities\":[\"read:web\",\"write:summaries\"],\"constraints\":[\"no:pii\",\"no:financial:transact\"],\"description\":\"A bounded fixture declaration for the signing vectors.\",\"identity\":{\"algorithm\":\"ed25519\",\"public_key\":\"MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=\"},\"name\":\"Fixture Research Agent\",\"provenance\":\"0.2\",\"provenance_id\":\"provenance:github:example/research-agent\",\"version\":\"1.2.0\"}",
15
+ "vectors": [
16
+ {
17
+ "id": "declaration-signature-valid",
18
+ "expect": "valid",
19
+ "purpose": "a correctly signed 0.2 declaration",
20
+ "declaration": {
21
+ "provenance": "0.2",
22
+ "name": "Fixture Research Agent",
23
+ "description": "A bounded fixture declaration for the signing vectors.",
24
+ "version": "1.2.0",
25
+ "capabilities": [
26
+ "read:web",
27
+ "write:summaries"
28
+ ],
29
+ "constraints": [
30
+ "no:pii",
31
+ "no:financial:transact"
32
+ ],
33
+ "provenance_id": "provenance:github:example/research-agent",
34
+ "identity": {
35
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
36
+ "algorithm": "ed25519",
37
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg=="
38
+ }
39
+ }
40
+ },
41
+ {
42
+ "id": "declaration-key-order-irrelevant",
43
+ "expect": "valid",
44
+ "purpose": "the same declaration with keys in a different order must still verify",
45
+ "declaration": {
46
+ "identity": {
47
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg==",
48
+ "algorithm": "ed25519",
49
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98="
50
+ },
51
+ "constraints": [
52
+ "no:pii",
53
+ "no:financial:transact"
54
+ ],
55
+ "capabilities": [
56
+ "read:web",
57
+ "write:summaries"
58
+ ],
59
+ "provenance_id": "provenance:github:example/research-agent",
60
+ "version": "1.2.0",
61
+ "description": "A bounded fixture declaration for the signing vectors.",
62
+ "name": "Fixture Research Agent",
63
+ "provenance": "0.2"
64
+ }
65
+ },
66
+ {
67
+ "id": "declaration-constraint-removed",
68
+ "expect": "invalid",
69
+ "purpose": "THE CASE 0.1 MISSED — deleting a declared constraint must break the signature",
70
+ "declaration": {
71
+ "provenance": "0.2",
72
+ "name": "Fixture Research Agent",
73
+ "description": "A bounded fixture declaration for the signing vectors.",
74
+ "version": "1.2.0",
75
+ "capabilities": [
76
+ "read:web",
77
+ "write:summaries"
78
+ ],
79
+ "constraints": [
80
+ "no:pii"
81
+ ],
82
+ "provenance_id": "provenance:github:example/research-agent",
83
+ "identity": {
84
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
85
+ "algorithm": "ed25519",
86
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg=="
87
+ }
88
+ }
89
+ },
90
+ {
91
+ "id": "declaration-capability-added",
92
+ "expect": "invalid",
93
+ "purpose": "granting the agent a new declared power must break the signature",
94
+ "declaration": {
95
+ "provenance": "0.2",
96
+ "name": "Fixture Research Agent",
97
+ "description": "A bounded fixture declaration for the signing vectors.",
98
+ "version": "1.2.0",
99
+ "capabilities": [
100
+ "read:web",
101
+ "write:summaries",
102
+ "financial:transact"
103
+ ],
104
+ "constraints": [
105
+ "no:pii",
106
+ "no:financial:transact"
107
+ ],
108
+ "provenance_id": "provenance:github:example/research-agent",
109
+ "identity": {
110
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
111
+ "algorithm": "ed25519",
112
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg=="
113
+ }
114
+ }
115
+ },
116
+ {
117
+ "id": "declaration-key-swapped",
118
+ "expect": "invalid",
119
+ "purpose": "substituting another public key must break the signature",
120
+ "declaration": {
121
+ "provenance": "0.2",
122
+ "name": "Fixture Research Agent",
123
+ "description": "A bounded fixture declaration for the signing vectors.",
124
+ "version": "1.2.0",
125
+ "capabilities": [
126
+ "read:web",
127
+ "write:summaries"
128
+ ],
129
+ "constraints": [
130
+ "no:pii",
131
+ "no:financial:transact"
132
+ ],
133
+ "provenance_id": "provenance:github:example/research-agent",
134
+ "identity": {
135
+ "public_key": "MCowBQYDK2VwAyEAiuHMTpGGFwJ8XGYRofeYY0s6rV1ywTl8nmLgHj08lew=",
136
+ "algorithm": "ed25519",
137
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg=="
138
+ }
139
+ }
140
+ },
141
+ {
142
+ "id": "declaration-signed-with-wrong-key",
143
+ "expect": "invalid",
144
+ "purpose": "a signature made by a different private key must not verify",
145
+ "declaration": {
146
+ "provenance": "0.2",
147
+ "name": "Fixture Research Agent",
148
+ "description": "A bounded fixture declaration for the signing vectors.",
149
+ "version": "1.2.0",
150
+ "capabilities": [
151
+ "read:web",
152
+ "write:summaries"
153
+ ],
154
+ "constraints": [
155
+ "no:pii",
156
+ "no:financial:transact"
157
+ ],
158
+ "provenance_id": "provenance:github:example/research-agent",
159
+ "identity": {
160
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
161
+ "algorithm": "ed25519",
162
+ "signature": "SgK3ou1XNPOTQDP+gbwvbHPcs8+21SQaxChiHT14c5mHvZ3XzNH8secDlS/fDkog7iBN0oKQrw5O7RjLr61LCw=="
163
+ }
164
+ }
165
+ },
166
+ {
167
+ "id": "declaration-0.1-signature-in-0.2-file",
168
+ "expect": "invalid",
169
+ "purpose": "an old identity-only signature must not satisfy 0.2",
170
+ "declaration": {
171
+ "provenance": "0.2",
172
+ "name": "Fixture Research Agent",
173
+ "description": "A bounded fixture declaration for the signing vectors.",
174
+ "version": "1.2.0",
175
+ "capabilities": [
176
+ "read:web",
177
+ "write:summaries"
178
+ ],
179
+ "constraints": [
180
+ "no:pii",
181
+ "no:financial:transact"
182
+ ],
183
+ "provenance_id": "provenance:github:example/research-agent",
184
+ "identity": {
185
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
186
+ "algorithm": "ed25519",
187
+ "signature": "d4/wFYKJD8AQlKVDnC+JloeW7EnYUZ1pJoSG+HkM2wH/KMKYm8Dc6bdnCCNjtEIB/EiDei4+45DMdODPd9F0Cg=="
188
+ }
189
+ }
190
+ },
191
+ {
192
+ "id": "declaration-unknown-spec-version",
193
+ "expect": "invalid",
194
+ "purpose": "a verifier must refuse to guess the signing rule for a version it does not know",
195
+ "declaration": {
196
+ "provenance": "9.9",
197
+ "name": "Fixture Research Agent",
198
+ "description": "A bounded fixture declaration for the signing vectors.",
199
+ "version": "1.2.0",
200
+ "capabilities": [
201
+ "read:web",
202
+ "write:summaries"
203
+ ],
204
+ "constraints": [
205
+ "no:pii",
206
+ "no:financial:transact"
207
+ ],
208
+ "provenance_id": "provenance:github:example/research-agent",
209
+ "identity": {
210
+ "public_key": "MCowBQYDK2VwAyEAyuDQn/j1dbeAd288fU7lRV5cFcm7Zsm42jwCKy07N98=",
211
+ "algorithm": "ed25519",
212
+ "signature": "LqPe4bIXfa4qRhblNfp3VXElk2gG0x5UfTaHNKJGUDNbx4tRNXnJyXZZuu3BiC58Eur2JF719MieXU+EJe2pDg=="
213
+ }
214
+ }
215
+ }
216
+ ]
217
+ }