provenance-protocol 0.2.2 → 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/README.md +12 -0
- package/SPEC.md +110 -34
- package/package.json +5 -3
- package/schema/provenance-0.1.json +1 -1
- package/schema/provenance-0.2.json +229 -0
- package/src/canonical.js +134 -0
- package/src/keygen.d.ts +27 -0
- package/src/keygen.js +69 -1
- package/src/verify.d.ts +23 -0
- package/src/verify.js +98 -8
- package/test-vectors/declarations-0.2.json +217 -0
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
|
-
**
|
|
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
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
350
|
-
-
|
|
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
|
|
|
@@ -372,27 +408,49 @@ This is the same division of labour as a machine-readable passport: the
|
|
|
372
408
|
document proves its own integrity offline, while identity binding and
|
|
373
409
|
current standing are looked up.
|
|
374
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
|
+
|
|
375
438
|
### Live proof of key control
|
|
376
439
|
|
|
377
440
|
A signature on a file proves the file's origin. It does not prove that the
|
|
378
|
-
agent running right now controls that key. For that, a receiving system
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
```
|
|
382
|
-
<provenance_id>:<nonce>
|
|
383
|
-
```
|
|
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>`.
|
|
384
444
|
|
|
385
|
-
Nonces must be single-use and unpredictable. The receiving system verifies
|
|
386
|
-
|
|
387
|
-
`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`.
|
|
388
447
|
|
|
389
448
|
### Revocation
|
|
390
449
|
|
|
391
|
-
A key holder revokes a `provenance_id` by signing
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
```
|
|
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.
|
|
396
454
|
|
|
397
455
|
Revocation is the one operation that cannot be verified offline — a verifier
|
|
398
456
|
has no way to know a revocation has been issued without asking. Implementations
|
|
@@ -405,19 +463,25 @@ they would re-check any other freshness signal.
|
|
|
405
463
|
|
|
406
464
|
An implementation of this specification is conformant if it:
|
|
407
465
|
|
|
408
|
-
1. Accepts every file that validates against
|
|
466
|
+
1. Accepts every file that validates against the schema for its declared
|
|
467
|
+
version — `schema/provenance-0.1.json` or `schema/provenance-0.2.json`.
|
|
409
468
|
2. Treats `provenance`, `name` and `description` as required and everything
|
|
410
469
|
else as optional.
|
|
411
470
|
3. Ignores unrecognised top-level fields rather than rejecting the file.
|
|
412
471
|
Future spec versions add fields; a `0.1` reader must not break on them.
|
|
413
472
|
4. Treats `identity.signature` as optional, and an unsigned `identity`
|
|
414
473
|
block as advertising a key rather than attesting the file.
|
|
415
|
-
5.
|
|
416
|
-
|
|
417
|
-
|
|
474
|
+
5. Applies the signing rule for the version the declaration declares, refuses
|
|
475
|
+
to guess for an unknown version, and reports which coverage it checked —
|
|
476
|
+
a reader must not be left thinking a 0.1 signature protected the
|
|
477
|
+
declared constraints.
|
|
478
|
+
6. Reproduces every signature in `test-vectors/signatures-0.1.json` and every
|
|
479
|
+
declaration in `test-vectors/declarations-0.2.json` marked `valid`, and
|
|
480
|
+
refuses every one marked `invalid`.
|
|
481
|
+
7. Treats a declaration whose `provenance_id` does not match its retrieval
|
|
418
482
|
location as unverified.
|
|
419
483
|
|
|
420
|
-
Points
|
|
484
|
+
Points 6 and 7 are what make independent implementations agree. An
|
|
421
485
|
implementation that passes the test vectors interoperates with every other
|
|
422
486
|
one that does, with no reference to any particular service.
|
|
423
487
|
|
|
@@ -442,10 +506,22 @@ monitors, attesters — are applications of the standard, not part of it.
|
|
|
442
506
|
|
|
443
507
|
## Versioning
|
|
444
508
|
|
|
445
|
-
The `provenance` field records which spec version you are using
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
509
|
+
The `provenance` field records which spec version you are using, and it is what
|
|
510
|
+
a reader uses to decide how to interpret the file.
|
|
511
|
+
|
|
512
|
+
We commit to backwards compatibility: a `0.1` file will always be readable and
|
|
513
|
+
verifiable, whatever later versions say. Fields are never removed.
|
|
514
|
+
|
|
515
|
+
Usually a new version only adds fields. Version 0.2 is the exception so far — it
|
|
516
|
+
changed what an existing field *covers*, because `identity.signature` in 0.1
|
|
517
|
+
protected only the identity and left the declared capabilities and constraints
|
|
518
|
+
unprotected, which was weaker than readers assumed. Rather than redefine 0.1
|
|
519
|
+
under declarations already published, 0.2 defines the stronger rule and the
|
|
520
|
+
`provenance` field keeps the two apart. A verifier implements both and applies
|
|
521
|
+
the one the declaration asks for.
|
|
522
|
+
|
|
523
|
+
Where a version changes the meaning of an existing field, it will always do so
|
|
524
|
+
by declaring a new version, never by reinterpreting an old one.
|
|
449
525
|
|
|
450
526
|
---
|
|
451
527
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "provenance-protocol",
|
|
3
|
-
"version": "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",
|
|
@@ -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 && node test/payload-separation.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
|
|
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
|
+
}
|
package/src/canonical.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
/** Thrown when a declaration contains something that cannot be canonicalised. */
|
|
24
|
+
export class CanonicalError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = 'CanonicalError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
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
|
+
|
|
69
|
+
function canonicalValue(value, path = '$') {
|
|
70
|
+
if (value === null) return 'null';
|
|
71
|
+
|
|
72
|
+
const type = typeof value;
|
|
73
|
+
|
|
74
|
+
if (type === 'string') return JSON.stringify(value);
|
|
75
|
+
if (type === 'boolean') return value ? 'true' : 'false';
|
|
76
|
+
if (type === 'number') {
|
|
77
|
+
if (!Number.isFinite(value)) {
|
|
78
|
+
throw new CanonicalError(`${path}: non-finite numbers cannot be signed`);
|
|
79
|
+
}
|
|
80
|
+
return JSON.stringify(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
return `[${value.map((item, i) => canonicalValue(item, `${path}[${i}]`)).join(',')}]`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (type === 'object') {
|
|
88
|
+
// A YAML parser may produce Dates, Maps, Buffers and so on. Signing those
|
|
89
|
+
// would depend on whichever serialisation happened to be used, so refuse.
|
|
90
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
|
|
91
|
+
throw new CanonicalError(
|
|
92
|
+
`${path}: only plain objects can be signed (got ${value.constructor?.name ?? 'unknown type'}) — quote the value as a string`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort();
|
|
96
|
+
const parts = keys.map(
|
|
97
|
+
(k) => `${JSON.stringify(k)}:${canonicalValue(value[k], `${path}.${k}`)}`
|
|
98
|
+
);
|
|
99
|
+
return `{${parts.join(',')}}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new CanonicalError(`${path}: values of type ${type} cannot be signed`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The exact string a spec-0.2 declaration signature is computed over.
|
|
107
|
+
*
|
|
108
|
+
* Domain-separated so a declaration signature can never be replayed as a
|
|
109
|
+
* signature over some other kind of message.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} declaration Parsed declaration (its identity.signature is ignored)
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function declarationSigningPayload(declaration) {
|
|
115
|
+
if (declaration === null || typeof declaration !== 'object' || Array.isArray(declaration)) {
|
|
116
|
+
throw new CanonicalError('Declaration must be a parsed object');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const { identity, ...rest } = declaration;
|
|
120
|
+
const covered = { ...rest };
|
|
121
|
+
|
|
122
|
+
if (identity !== undefined) {
|
|
123
|
+
if (identity === null || typeof identity !== 'object' || Array.isArray(identity)) {
|
|
124
|
+
throw new CanonicalError('$.identity must be an object when present');
|
|
125
|
+
}
|
|
126
|
+
// public_key and algorithm ARE covered; only the signature is excluded.
|
|
127
|
+
const { signature: _excluded, ...identityRest } = identity;
|
|
128
|
+
covered.identity = identityRest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return `${DOMAIN}:${canonicalValue(covered)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { DOMAIN as DECLARATION_SIGNING_DOMAIN };
|
package/src/keygen.d.ts
CHANGED
|
@@ -46,3 +46,30 @@ 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;
|
|
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,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { generateKeyPairSync, sign, createPrivateKey } from 'crypto';
|
|
28
|
+
import { declarationSigningPayload, challengePayload, revocationPayload } from './canonical.js';
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Generate a new Ed25519 keypair for use with Provenance identity.
|
|
@@ -57,7 +58,12 @@ export function generateProvenanceKeyPair() {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
|
-
* 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.
|
|
61
67
|
*
|
|
62
68
|
* Call this when a receiving system sends you a nonce to prove your identity.
|
|
63
69
|
* The signed message is always `${provenanceId}:${nonce}` — this binds the
|
|
@@ -134,6 +140,68 @@ export function signRevocation(privateKeyBase64, provenanceId) {
|
|
|
134
140
|
return signChallenge(privateKeyBase64, provenanceId, 'REVOKE');
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Sign a whole declaration — spec 0.2.
|
|
145
|
+
*
|
|
146
|
+
* Covers every field, so deleting a constraint or adding a capability breaks
|
|
147
|
+
* the signature. `signForProvenance` (spec 0.1) covers only the identity and
|
|
148
|
+
* key, which leaves the rest of the declaration unprotected; prefer this.
|
|
149
|
+
*
|
|
150
|
+
* Signs the canonical form of the PARSED declaration, so reformatting the file
|
|
151
|
+
* does not invalidate the signature.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} privateKeyBase64 Base64 PKCS8 DER private key
|
|
154
|
+
* @param {object} declaration Parsed declaration; identity.signature is ignored
|
|
155
|
+
* @returns {string} Base64 signature — put it in identity.signature
|
|
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
|
+
|
|
198
|
+
export function signDeclaration(privateKeyBase64, declaration) {
|
|
199
|
+
const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
|
|
200
|
+
const privateKey = createPrivateKey({ key: keyBuffer, format: 'der', type: 'pkcs8' });
|
|
201
|
+
const message = Buffer.from(declarationSigningPayload(declaration), 'utf8');
|
|
202
|
+
return sign(null, message, privateKey).toString('base64');
|
|
203
|
+
}
|
|
204
|
+
|
|
137
205
|
export function signForProvenance(privateKeyBase64, provenanceId, publicKeyBase64) {
|
|
138
206
|
const keyBuffer = Buffer.from(privateKeyBase64, 'base64');
|
|
139
207
|
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.
|
|
@@ -83,3 +93,16 @@ export function verifyRevocation(
|
|
|
83
93
|
provenanceId: string,
|
|
84
94
|
signatureBase64: string
|
|
85
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,9 +16,23 @@
|
|
|
16
16
|
* Uses the Web Crypto API (crypto.subtle): all modern browsers, Node 18+.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
import { declarationSigningPayload, challengePayload, revocationPayload } 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
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
217
|
-
|
|
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,
|
|
268
|
+
valid = await verifyEd25519(publicKey, signature, payload);
|
|
223
269
|
} catch {
|
|
224
270
|
return { ...result, reason: 'identity.signature is malformed' };
|
|
225
271
|
}
|
|
@@ -236,9 +282,53 @@ export async function verifyDeclaration(declaration, options = {}) {
|
|
|
236
282
|
};
|
|
237
283
|
}
|
|
238
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
|
+
|
|
239
325
|
/**
|
|
240
326
|
* Verify a live challenge response offline, against a key you already hold.
|
|
241
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
|
+
*
|
|
242
332
|
* The network equivalent in the main SDK looks the key up in the index; this
|
|
243
333
|
* takes the key directly, so a system that already stores keys can verify
|
|
244
334
|
* without contacting anyone.
|
|
@@ -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
|
+
}
|