provared 0.3.0__tar.gz → 0.5.0__tar.gz
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.
- {provared-0.3.0/src/provared.egg-info → provared-0.5.0}/PKG-INFO +116 -4
- {provared-0.3.0 → provared-0.5.0}/README.md +115 -3
- {provared-0.3.0 → provared-0.5.0}/pyproject.toml +1 -1
- {provared-0.3.0 → provared-0.5.0}/src/provared/__init__.py +12 -4
- {provared-0.3.0 → provared-0.5.0}/src/provared/dev.py +1 -1
- provared-0.5.0/src/provared/documents.py +159 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/encoding.py +1 -1
- {provared-0.3.0 → provared-0.5.0}/src/provared/guard.py +100 -6
- {provared-0.3.0 → provared-0.5.0}/src/provared/pass_.py +2 -2
- {provared-0.3.0 → provared-0.5.0}/src/provared/recorder.py +190 -13
- {provared-0.3.0 → provared-0.5.0}/src/provared/seal.py +5 -3
- {provared-0.3.0 → provared-0.5.0}/src/provared/service.py +6 -4
- provared-0.5.0/src/provared/signatures.py +425 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/standing.py +5 -4
- {provared-0.3.0 → provared-0.5.0}/src/provared/stub.py +16 -4
- {provared-0.3.0 → provared-0.5.0/src/provared.egg-info}/PKG-INFO +116 -4
- {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/SOURCES.txt +5 -0
- provared-0.5.0/tests/test_check_before.py +44 -0
- provared-0.5.0/tests/test_documents.py +272 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_recorder.py +45 -0
- provared-0.5.0/tests/test_signatures.py +47 -0
- provared-0.5.0/tests/test_signers.py +553 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_vectors.py +19 -0
- provared-0.3.0/src/provared/signatures.py +0 -149
- {provared-0.3.0 → provared-0.5.0}/LICENSE +0 -0
- {provared-0.3.0 → provared-0.5.0}/LICENSE-CC-BY-4.0.txt +0 -0
- {provared-0.3.0 → provared-0.5.0}/setup.cfg +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/_js.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/actions.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/approval.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/blockstamp.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/book.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/check.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/compare.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/cover.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/der.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/fields.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/headers.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/jws.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/keys.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/slip.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/timestamp.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/tools.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/tree.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared/webauthn.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/dependency_links.txt +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/requires.txt +0 -0
- {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/top_level.txt +0 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_dev.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_record_function.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_records.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_review2.py +0 -0
- {provared-0.3.0 → provared-0.5.0}/tests/test_tree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: provared
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a checker. Evidence, not a verdict.
|
|
5
5
|
Author: Pavel Izmaylov
|
|
6
6
|
License-Expression: Apache-2.0 AND CC-BY-4.0
|
|
@@ -19,7 +19,7 @@ License-File: LICENSE-CC-BY-4.0.txt
|
|
|
19
19
|
Requires-Dist: cryptography>=48
|
|
20
20
|
Dynamic: license-file
|
|
21
21
|
|
|
22
|
-
# Provared for Python
|
|
22
|
+
# <img src="https://prova.red/favicon.svg" alt="" width="36"> Provared for Python
|
|
23
23
|
|
|
24
24
|
Proof of what an AI agent was allowed to do, and what it then did.
|
|
25
25
|
|
|
@@ -34,7 +34,7 @@ particular to Python.
|
|
|
34
34
|
|
|
35
35
|
## Status
|
|
36
36
|
|
|
37
|
-
**Version 0.
|
|
37
|
+
**Version 0.5.0, a draft**, the same version as the JavaScript library.
|
|
38
38
|
Published as it is, by one author in their own time; fixes as time
|
|
39
39
|
allows. It gives the same answer, case for case, as the shared test
|
|
40
40
|
files in [`test-vectors/`](https://github.com/provared/provared/tree/main/test-vectors) show: the same result, the
|
|
@@ -46,7 +46,10 @@ needs, `cryptography`, does not yet have SLH-DSA (FIPS 205), the method
|
|
|
46
46
|
of a seal's third signature. So a seal's third signature is reported as
|
|
47
47
|
"not checked on this device", and never as a pass, as the checking page
|
|
48
48
|
in a browser reports it. Seals are checked in full by the JavaScript
|
|
49
|
-
command-line checker.
|
|
49
|
+
command-line checker. The check before acting, and so the stub writer,
|
|
50
|
+
goes on after a seal whose Ed25519 and ML-DSA-87 signatures were checked
|
|
51
|
+
here, and its answer says what was not checked (`notChecked`); any
|
|
52
|
+
other signature left unchecked still allows nothing.
|
|
50
53
|
|
|
51
54
|
## Install
|
|
52
55
|
|
|
@@ -199,6 +202,115 @@ are dicts and lists, with the same member names as in the format.
|
|
|
199
202
|
`provared.check` holds the checker alone, for anyone who audits the
|
|
200
203
|
checking.
|
|
201
204
|
|
|
205
|
+
Where the other side will not countersign, a stub can name the
|
|
206
|
+
fingerprint of the other system's own identifier for the call, so that
|
|
207
|
+
someone who holds that system's log can match the two. Document names
|
|
208
|
+
that begin `provared.` are reserved for this (`DOCUMENT_NAMES`; format
|
|
209
|
+
description, section 27), and any other such name is refused.
|
|
210
|
+
`identifier_fingerprint`, `http_request_fingerprint` and
|
|
211
|
+
`http_response_fingerprint` make the fingerprints (a body is bytes, a
|
|
212
|
+
bytearray or a memoryview, after any content coding such as gzip; None
|
|
213
|
+
is no body; a status may be an int, or a float equal to a whole number;
|
|
214
|
+
no header is covered; a trace-id's form is not checked). Only
|
|
215
|
+
`provared.trace-id`, `provared.request-id` and `provared.event-id` may
|
|
216
|
+
be named more than once in a stub. An identifier that the platform gives only
|
|
217
|
+
in its answer goes in through `details_after` (the JavaScript name
|
|
218
|
+
`detailsAfter` is taken too):
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from provared import identifier_fingerprint
|
|
222
|
+
|
|
223
|
+
result = writer.act(
|
|
224
|
+
{'action': 'provared.data.change', 'with': 'tickets'},
|
|
225
|
+
lambda: update_the_ticket(),
|
|
226
|
+
{'details_after': lambda answer: [{'name': 'provared.event-id', 'sha256': identifier_fingerprint(answer['request_id'])}]},
|
|
227
|
+
)
|
|
228
|
+
result['detailsAfter'] # {'added': 1, 'problem': None}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
The stub is checked with the whole book before the action, and signed
|
|
232
|
+
again after it with up to four further documents, in the same call; it
|
|
233
|
+
keeps the time the action began. `details_after` is asked in a thread
|
|
234
|
+
of its own, must be an ordinary function, and is given as long as the
|
|
235
|
+
other side is given to countersign. If it fails, gives no answer in
|
|
236
|
+
time, or gives what cannot go in, the stub is written without the
|
|
237
|
+
further documents and `result['detailsAfter']['problem']` says why. It
|
|
238
|
+
cannot be used with an approval. A matching identifier shows that the
|
|
239
|
+
two records speak of one call, not that the call did what either says;
|
|
240
|
+
a fingerprint of a request, an answer, the arguments or an error shows
|
|
241
|
+
only that the content matches, not that it was the same call.
|
|
242
|
+
`record_tools` and `record_function` still name the arguments
|
|
243
|
+
`arguments` by default.
|
|
244
|
+
|
|
245
|
+
## Keys held elsewhere: signers
|
|
246
|
+
|
|
247
|
+
The keys of an agent, a service or an organisation need not sit in the
|
|
248
|
+
memory of the program that writes the records. Wherever a writer takes
|
|
249
|
+
`private_keys` (`open_recorder`, `write_stub`, `countersign`,
|
|
250
|
+
`write_refusal`, `write_terms`, `write_pass`, `write_acknowledgement`,
|
|
251
|
+
`write_vouching` and `write_withdrawal`), each item may be a key of the
|
|
252
|
+
package `cryptography`, as before, or a **signer**: any object with a
|
|
253
|
+
`key` attribute, the public key as a dict in one of the shapes the format
|
|
254
|
+
allows (format description, section 3.8), and a `sign(data)` method that
|
|
255
|
+
gives the signature over exactly those bytes (`bytes`, `bytearray` or
|
|
256
|
+
`memoryview`). `provared.Signer` describes it for type checkers. Keys and
|
|
257
|
+
signers may be mixed. `signer_from_key(alg, private_key, key)` wraps a
|
|
258
|
+
key held in memory as a signer.
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
from provared import open_recorder, signer_from_key
|
|
262
|
+
|
|
263
|
+
class HeldSigner:
|
|
264
|
+
"""The agent's ML-DSA-87 key, held in a key service."""
|
|
265
|
+
key = agent_keys[1] # the public key, as the slip names it
|
|
266
|
+
def sign(self, data):
|
|
267
|
+
return key_service.sign('agent-ml-dsa-87', data) # the bytes themselves, not a fingerprint of them
|
|
268
|
+
|
|
269
|
+
writer = open_recorder(book_text, slip_fingerprint,
|
|
270
|
+
[signer_from_key('Ed25519', ed25519_private_key, agent_keys[0]), HeldSigner()],
|
|
271
|
+
issuer_keys=[passkey_thumbprint], sign_within=10000)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
A record made through a signer is the same record as one made with the
|
|
275
|
+
key: the same content, labels and Ed25519 signature (an ML-DSA-87
|
|
276
|
+
signature is made afresh each time, either way). A signer is not taken
|
|
277
|
+
on trust. Its two members are read once, and its key must be exactly a
|
|
278
|
+
key of the method of its place, as plain data: a `dict` whose names and
|
|
279
|
+
values are plain `str`, not a kind derived from them. A signer for
|
|
280
|
+
SLH-DSA is refused before any signer is asked, as `cryptography` cannot
|
|
281
|
+
check SLH-DSA, so no seal is written in Python, with keys or with
|
|
282
|
+
signers. Where the writer knows the key the record must be signed with
|
|
283
|
+
(the agent's keys, or a helper's, in the stub writer; the keys `by`
|
|
284
|
+
names in `write_refusal`, `write_terms` and `write_vouching`), a signer
|
|
285
|
+
for another key is refused (`bad-key`) before any signer is asked. The
|
|
286
|
+
stub writer compares when it is opened, with the slip, or the pass it
|
|
287
|
+
acts under, as its book holds it; opened with a pass that its book does
|
|
288
|
+
not yet hold, it compares nothing then, and refuses each action
|
|
289
|
+
(`pass-missing`). `write_stub`, `countersign`, `write_pass`,
|
|
290
|
+
`write_acknowledgement` and `write_withdrawal`, used by themselves,
|
|
291
|
+
cannot know the key: there a signer for another key gives a record that
|
|
292
|
+
fails its own check, as a wrong key always did. Every signature is
|
|
293
|
+
checked against the signer's key before it is used: one of the wrong
|
|
294
|
+
length, one that does not check, something other than bytes, a
|
|
295
|
+
coroutine, or an error raised leaves no record (`signature-invalid`),
|
|
296
|
+
and the error names the place, the method and the key's thumbprint. In
|
|
297
|
+
the stub writer a signer is asked in a thread of its own and given
|
|
298
|
+
`sign_within` milliseconds (30,000 by default) for each signature; after
|
|
299
|
+
that the action is not taken, nothing is written, and a late answer is
|
|
300
|
+
not used. The other writers set no time of their own: give the signer's
|
|
301
|
+
own connection a time limit. Where no record is made, signers earlier in
|
|
302
|
+
the list may already have signed, and a signer that ran out of time may
|
|
303
|
+
still sign later: a key service's own log may hold signatures that match
|
|
304
|
+
no record. A private key of `cryptography` of another method than its
|
|
305
|
+
place's, or anything else that is neither a key nor a signer, is refused
|
|
306
|
+
(`bad-field`).
|
|
307
|
+
|
|
308
|
+
The library cannot know how a key held elsewhere is kept, or whether
|
|
309
|
+
the service keeps it safe; it checks only that each signature fits the
|
|
310
|
+
key the signer names. A record does not say where a key was kept. Each
|
|
311
|
+
signature costs one more check (under a thousandth of a second for
|
|
312
|
+
Ed25519 and ML-DSA-87 on one computer).
|
|
313
|
+
|
|
202
314
|
## Tests
|
|
203
315
|
|
|
204
316
|
From the `python` folder:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Provared for Python
|
|
1
|
+
# <img src="https://prova.red/favicon.svg" alt="" width="36"> Provared for Python
|
|
2
2
|
|
|
3
3
|
Proof of what an AI agent was allowed to do, and what it then did.
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ particular to Python.
|
|
|
13
13
|
|
|
14
14
|
## Status
|
|
15
15
|
|
|
16
|
-
**Version 0.
|
|
16
|
+
**Version 0.5.0, a draft**, the same version as the JavaScript library.
|
|
17
17
|
Published as it is, by one author in their own time; fixes as time
|
|
18
18
|
allows. It gives the same answer, case for case, as the shared test
|
|
19
19
|
files in [`test-vectors/`](https://github.com/provared/provared/tree/main/test-vectors) show: the same result, the
|
|
@@ -25,7 +25,10 @@ needs, `cryptography`, does not yet have SLH-DSA (FIPS 205), the method
|
|
|
25
25
|
of a seal's third signature. So a seal's third signature is reported as
|
|
26
26
|
"not checked on this device", and never as a pass, as the checking page
|
|
27
27
|
in a browser reports it. Seals are checked in full by the JavaScript
|
|
28
|
-
command-line checker.
|
|
28
|
+
command-line checker. The check before acting, and so the stub writer,
|
|
29
|
+
goes on after a seal whose Ed25519 and ML-DSA-87 signatures were checked
|
|
30
|
+
here, and its answer says what was not checked (`notChecked`); any
|
|
31
|
+
other signature left unchecked still allows nothing.
|
|
29
32
|
|
|
30
33
|
## Install
|
|
31
34
|
|
|
@@ -178,6 +181,115 @@ are dicts and lists, with the same member names as in the format.
|
|
|
178
181
|
`provared.check` holds the checker alone, for anyone who audits the
|
|
179
182
|
checking.
|
|
180
183
|
|
|
184
|
+
Where the other side will not countersign, a stub can name the
|
|
185
|
+
fingerprint of the other system's own identifier for the call, so that
|
|
186
|
+
someone who holds that system's log can match the two. Document names
|
|
187
|
+
that begin `provared.` are reserved for this (`DOCUMENT_NAMES`; format
|
|
188
|
+
description, section 27), and any other such name is refused.
|
|
189
|
+
`identifier_fingerprint`, `http_request_fingerprint` and
|
|
190
|
+
`http_response_fingerprint` make the fingerprints (a body is bytes, a
|
|
191
|
+
bytearray or a memoryview, after any content coding such as gzip; None
|
|
192
|
+
is no body; a status may be an int, or a float equal to a whole number;
|
|
193
|
+
no header is covered; a trace-id's form is not checked). Only
|
|
194
|
+
`provared.trace-id`, `provared.request-id` and `provared.event-id` may
|
|
195
|
+
be named more than once in a stub. An identifier that the platform gives only
|
|
196
|
+
in its answer goes in through `details_after` (the JavaScript name
|
|
197
|
+
`detailsAfter` is taken too):
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from provared import identifier_fingerprint
|
|
201
|
+
|
|
202
|
+
result = writer.act(
|
|
203
|
+
{'action': 'provared.data.change', 'with': 'tickets'},
|
|
204
|
+
lambda: update_the_ticket(),
|
|
205
|
+
{'details_after': lambda answer: [{'name': 'provared.event-id', 'sha256': identifier_fingerprint(answer['request_id'])}]},
|
|
206
|
+
)
|
|
207
|
+
result['detailsAfter'] # {'added': 1, 'problem': None}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The stub is checked with the whole book before the action, and signed
|
|
211
|
+
again after it with up to four further documents, in the same call; it
|
|
212
|
+
keeps the time the action began. `details_after` is asked in a thread
|
|
213
|
+
of its own, must be an ordinary function, and is given as long as the
|
|
214
|
+
other side is given to countersign. If it fails, gives no answer in
|
|
215
|
+
time, or gives what cannot go in, the stub is written without the
|
|
216
|
+
further documents and `result['detailsAfter']['problem']` says why. It
|
|
217
|
+
cannot be used with an approval. A matching identifier shows that the
|
|
218
|
+
two records speak of one call, not that the call did what either says;
|
|
219
|
+
a fingerprint of a request, an answer, the arguments or an error shows
|
|
220
|
+
only that the content matches, not that it was the same call.
|
|
221
|
+
`record_tools` and `record_function` still name the arguments
|
|
222
|
+
`arguments` by default.
|
|
223
|
+
|
|
224
|
+
## Keys held elsewhere: signers
|
|
225
|
+
|
|
226
|
+
The keys of an agent, a service or an organisation need not sit in the
|
|
227
|
+
memory of the program that writes the records. Wherever a writer takes
|
|
228
|
+
`private_keys` (`open_recorder`, `write_stub`, `countersign`,
|
|
229
|
+
`write_refusal`, `write_terms`, `write_pass`, `write_acknowledgement`,
|
|
230
|
+
`write_vouching` and `write_withdrawal`), each item may be a key of the
|
|
231
|
+
package `cryptography`, as before, or a **signer**: any object with a
|
|
232
|
+
`key` attribute, the public key as a dict in one of the shapes the format
|
|
233
|
+
allows (format description, section 3.8), and a `sign(data)` method that
|
|
234
|
+
gives the signature over exactly those bytes (`bytes`, `bytearray` or
|
|
235
|
+
`memoryview`). `provared.Signer` describes it for type checkers. Keys and
|
|
236
|
+
signers may be mixed. `signer_from_key(alg, private_key, key)` wraps a
|
|
237
|
+
key held in memory as a signer.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from provared import open_recorder, signer_from_key
|
|
241
|
+
|
|
242
|
+
class HeldSigner:
|
|
243
|
+
"""The agent's ML-DSA-87 key, held in a key service."""
|
|
244
|
+
key = agent_keys[1] # the public key, as the slip names it
|
|
245
|
+
def sign(self, data):
|
|
246
|
+
return key_service.sign('agent-ml-dsa-87', data) # the bytes themselves, not a fingerprint of them
|
|
247
|
+
|
|
248
|
+
writer = open_recorder(book_text, slip_fingerprint,
|
|
249
|
+
[signer_from_key('Ed25519', ed25519_private_key, agent_keys[0]), HeldSigner()],
|
|
250
|
+
issuer_keys=[passkey_thumbprint], sign_within=10000)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
A record made through a signer is the same record as one made with the
|
|
254
|
+
key: the same content, labels and Ed25519 signature (an ML-DSA-87
|
|
255
|
+
signature is made afresh each time, either way). A signer is not taken
|
|
256
|
+
on trust. Its two members are read once, and its key must be exactly a
|
|
257
|
+
key of the method of its place, as plain data: a `dict` whose names and
|
|
258
|
+
values are plain `str`, not a kind derived from them. A signer for
|
|
259
|
+
SLH-DSA is refused before any signer is asked, as `cryptography` cannot
|
|
260
|
+
check SLH-DSA, so no seal is written in Python, with keys or with
|
|
261
|
+
signers. Where the writer knows the key the record must be signed with
|
|
262
|
+
(the agent's keys, or a helper's, in the stub writer; the keys `by`
|
|
263
|
+
names in `write_refusal`, `write_terms` and `write_vouching`), a signer
|
|
264
|
+
for another key is refused (`bad-key`) before any signer is asked. The
|
|
265
|
+
stub writer compares when it is opened, with the slip, or the pass it
|
|
266
|
+
acts under, as its book holds it; opened with a pass that its book does
|
|
267
|
+
not yet hold, it compares nothing then, and refuses each action
|
|
268
|
+
(`pass-missing`). `write_stub`, `countersign`, `write_pass`,
|
|
269
|
+
`write_acknowledgement` and `write_withdrawal`, used by themselves,
|
|
270
|
+
cannot know the key: there a signer for another key gives a record that
|
|
271
|
+
fails its own check, as a wrong key always did. Every signature is
|
|
272
|
+
checked against the signer's key before it is used: one of the wrong
|
|
273
|
+
length, one that does not check, something other than bytes, a
|
|
274
|
+
coroutine, or an error raised leaves no record (`signature-invalid`),
|
|
275
|
+
and the error names the place, the method and the key's thumbprint. In
|
|
276
|
+
the stub writer a signer is asked in a thread of its own and given
|
|
277
|
+
`sign_within` milliseconds (30,000 by default) for each signature; after
|
|
278
|
+
that the action is not taken, nothing is written, and a late answer is
|
|
279
|
+
not used. The other writers set no time of their own: give the signer's
|
|
280
|
+
own connection a time limit. Where no record is made, signers earlier in
|
|
281
|
+
the list may already have signed, and a signer that ran out of time may
|
|
282
|
+
still sign later: a key service's own log may hold signatures that match
|
|
283
|
+
no record. A private key of `cryptography` of another method than its
|
|
284
|
+
place's, or anything else that is neither a key nor a signer, is refused
|
|
285
|
+
(`bad-field`).
|
|
286
|
+
|
|
287
|
+
The library cannot know how a key held elsewhere is kept, or whether
|
|
288
|
+
the service keeps it safe; it checks only that each signature fits the
|
|
289
|
+
key the signer names. A record does not say where a key was kept. Each
|
|
290
|
+
signature costs one more check (under a thousandth of a second for
|
|
291
|
+
Ed25519 and ML-DSA-87 on one computer).
|
|
292
|
+
|
|
181
293
|
## Tests
|
|
182
294
|
|
|
183
295
|
From the `python` folder:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "provared"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Proof of what an AI agent was allowed to do, and what it then did. A passkey-signed permission with limits a machine can check, a signed receipt for each action, and a checker. Evidence, not a verdict."
|
|
9
9
|
keywords = ["ai-agent", "agent", "audit-trail", "permission", "authorization", "receipt", "tamper-evident", "passkey", "webauthn", "jws", "ed25519", "ml-dsa", "post-quantum", "evidence", "langchain"]
|
|
10
10
|
readme = "README.md"
|
|
@@ -24,23 +24,29 @@
|
|
|
24
24
|
# the receipt), record_tools (an agent's tools behind the stub
|
|
25
25
|
# writer), arguments_fingerprint, NotTaken
|
|
26
26
|
# Keys: generate_key_set, key_set_from_seeds, key_set_seeds, thumbprint,
|
|
27
|
-
# key_set_fingerprint
|
|
28
|
-
#
|
|
27
|
+
# key_set_fingerprint, signer_from_key and Signer (wherever
|
|
28
|
+
# "private_keys" is taken, each item may be a key or a signer, an
|
|
29
|
+
# object with "key" and "sign", for a key held elsewhere)
|
|
30
|
+
# Names: the shared list of actions, the kinds of action, the rules of conduct;
|
|
31
|
+
# the reserved document names (DOCUMENT_NAMES), and the fingerprints
|
|
32
|
+
# they stand for (http_request_fingerprint, http_response_fingerprint,
|
|
33
|
+
# identifier_fingerprint)
|
|
29
34
|
|
|
30
35
|
from .actions import ACTION_KINDS, CONDUCT_RULES, RESERVED_PREFIX, SHARED_ACTIONS, action_kind
|
|
31
36
|
from .approval import assemble_approval, prepare_approval
|
|
32
37
|
from .blockstamp import block_fingerprint, block_stamp_item
|
|
33
38
|
from .book import LIMITS, check_book, check_show, entry_line, make_show, open_checker, write_book
|
|
34
39
|
from .compare import condition_words, limit_words, never_words
|
|
40
|
+
from .documents import DOCUMENT_NAMES, RESERVED_DOCUMENT_PREFIX, http_request_fingerprint, http_response_fingerprint, identifier_fingerprint
|
|
35
41
|
from .encoding import Refusal, fingerprint, format_time, from_base64url, random_id, sha256, to_base64url, utf8
|
|
36
42
|
from .guard import check_before
|
|
37
43
|
from .headers import MIN_BLOCKS_AFTER, blocks_in_chain, check_header_chain
|
|
38
44
|
from .keys import thumbprint
|
|
39
45
|
from .pass_ import write_pass
|
|
40
|
-
from .recorder import COUNTERSIGN_WITHIN_MS, Recorder, open_recorder
|
|
46
|
+
from .recorder import COUNTERSIGN_WITHIN_MS, SIGN_WITHIN_MS, Recorder, open_recorder
|
|
41
47
|
from .seal import key_set_fingerprint
|
|
42
48
|
from .service import REFUSAL_REASONS, write_refusal, write_terms
|
|
43
|
-
from .signatures import generate_key_set, key_set_from_seeds, key_set_seeds, method_available
|
|
49
|
+
from .signatures import Signer, generate_key_set, key_set_from_seeds, key_set_seeds, method_available, signer_from_key
|
|
44
50
|
from .slip import COVERABLE, assemble_slip, check_slip, prepare_slip
|
|
45
51
|
from .standing import VOUCHED_KINDS, assemble_cancellation, prepare_cancellation, write_acknowledgement, write_vouching, write_withdrawal
|
|
46
52
|
from .stub import countersign, write_stub
|
|
@@ -49,8 +55,10 @@ from .tools import NotTaken, arguments_fingerprint, record_function, record_tool
|
|
|
49
55
|
__all__ = [
|
|
50
56
|
'MIN_BLOCKS_AFTER', 'blocks_in_chain', 'check_header_chain',
|
|
51
57
|
'COUNTERSIGN_WITHIN_MS', 'NotTaken', 'Recorder', 'arguments_fingerprint', 'key_set_from_seeds', 'key_set_seeds', 'open_recorder',
|
|
58
|
+
'SIGN_WITHIN_MS', 'Signer', 'signer_from_key',
|
|
52
59
|
'record_function', 'record_tools',
|
|
53
60
|
'ACTION_KINDS', 'CONDUCT_RULES', 'COVERABLE', 'LIMITS', 'REFUSAL_REASONS', 'RESERVED_PREFIX', 'SHARED_ACTIONS', 'VOUCHED_KINDS',
|
|
61
|
+
'DOCUMENT_NAMES', 'RESERVED_DOCUMENT_PREFIX', 'http_request_fingerprint', 'http_response_fingerprint', 'identifier_fingerprint',
|
|
54
62
|
'Refusal', 'action_kind', 'assemble_approval', 'assemble_cancellation', 'assemble_slip', 'block_fingerprint', 'block_stamp_item',
|
|
55
63
|
'check_before', 'check_book', 'check_show', 'check_slip', 'condition_words', 'countersign', 'entry_line', 'fingerprint',
|
|
56
64
|
'format_time', 'from_base64url', 'generate_key_set', 'key_set_fingerprint', 'limit_words', 'make_show', 'method_available',
|
|
@@ -126,7 +126,7 @@ def development_recorder(fields=None, more=None, **named):
|
|
|
126
126
|
"""The stub writer, opened under a development slip, in one call: the
|
|
127
127
|
shortest way to a first record. The same as development_slip followed by
|
|
128
128
|
open_recorder; "more" (a dict) is passed to open_recorder: "options",
|
|
129
|
-
"now", "countersign_within". What it hands back has the writer as
|
|
129
|
+
"now", "countersign_within", "sign_within". What it hands back has the writer as
|
|
130
130
|
".writer", beside everything development_slip gives."""
|
|
131
131
|
made = development_slip(fields, **named)
|
|
132
132
|
made.writer = open_recorder(made.book, made.slip_fingerprint, made.agent_private_keys, made.issuer_keys, **(more or {}))
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# The reserved names of the documents a stub names (format description,
|
|
2
|
+
# section 27), and the fingerprints they stand for.
|
|
3
|
+
#
|
|
4
|
+
# A stub's "details" names documents by their fingerprints. Most names are
|
|
5
|
+
# labels that whoever wrote the stub chose. Names that begin "provared."
|
|
6
|
+
# are reserved: each one on the list below says exactly which text its
|
|
7
|
+
# fingerprint is made from, so that an investigator who holds that text
|
|
8
|
+
# (the call's arguments, the HTTP request, the identifier a platform's own
|
|
9
|
+
# log shows for the call) can work the fingerprint out again and match
|
|
10
|
+
# the stub to it, without either side's help.
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import re
|
|
14
|
+
import types
|
|
15
|
+
|
|
16
|
+
from .encoding import Refusal, _well_formed, fingerprint, sha256, utf8
|
|
17
|
+
|
|
18
|
+
RESERVED_DOCUMENT_PREFIX = 'provared.'
|
|
19
|
+
"""Every document name that begins with this is reserved for the list below."""
|
|
20
|
+
|
|
21
|
+
DOCUMENT_NAMES = types.MappingProxyType({
|
|
22
|
+
'provared.arguments': "The call's arguments, written in the one form of RFC 8785.",
|
|
23
|
+
'provared.http-request': 'The request, written as the signature base of RFC 9421, section 2.5, over "@method", "@target-uri" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
|
|
24
|
+
'provared.http-response': 'The answer, written as the signature base of RFC 9421, section 2.5, over "@status" and, where there is a body, "content-digest" (RFC 9530, with SHA-256). No header is covered.',
|
|
25
|
+
'provared.trace-id': 'The trace-id of the W3C Trace Context "traceparent" header that the agent\'s side sent, which that standard writes as 32 lower-case hexadecimal characters. Its form is not checked.',
|
|
26
|
+
'provared.request-id': "An identifier that the agent's side chose and sent with the call, exactly as sent.",
|
|
27
|
+
'provared.event-id': 'An identifier that the platform gave for the call, exactly as it gave it.',
|
|
28
|
+
'provared.error': "The error text that the agent's software was given, exactly as given.",
|
|
29
|
+
})
|
|
30
|
+
"""The reserved document names, and the exact text whose SHA-256 each one gives."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
REPEATABLE_DOCUMENT_NAMES = ('provared.trace-id', 'provared.request-id', 'provared.event-id')
|
|
34
|
+
"""The reserved names that may appear more than once in one record: an
|
|
35
|
+
identifier from each of several systems. Every other reserved name may
|
|
36
|
+
appear once at most."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_unknown_reserved_document(name):
|
|
40
|
+
"""Whether a document name is reserved and not on the list: such a name is refused."""
|
|
41
|
+
return isinstance(name, str) and name.startswith(RESERVED_DOCUMENT_PREFIX) and name not in DOCUMENT_NAMES
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# A method is a token of RFC 9110, section 5.6.2: one or more of these.
|
|
45
|
+
_TOKEN = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+")
|
|
46
|
+
# An address is taken exactly as it was sent, never rewritten: so it must
|
|
47
|
+
# already be in the form a request carries, an absolute http or https
|
|
48
|
+
# address of visible ASCII characters, with no fragment (which is never
|
|
49
|
+
# sent).
|
|
50
|
+
_ADDRESS = re.compile(r'https?://[\x21\x22\x24-\x7e]+')
|
|
51
|
+
|
|
52
|
+
_ABSENT = object()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _member(r, name):
|
|
56
|
+
if isinstance(r, dict):
|
|
57
|
+
return r.get(name, _ABSENT)
|
|
58
|
+
return getattr(r, name, _ABSENT)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _body(value, path):
|
|
62
|
+
if value is _ABSENT or value is None:
|
|
63
|
+
return _ABSENT
|
|
64
|
+
if not isinstance(value, (bytes, bytearray, memoryview)):
|
|
65
|
+
raise Refusal('bad-field', f'{path}: must be the bytes of the body, as sent.')
|
|
66
|
+
# The bytes themselves, read through the buffer, never a subclass's own __bytes__.
|
|
67
|
+
return bytes(memoryview(value))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _content_digest(data):
|
|
71
|
+
# The Content-Digest field value of RFC 9530, with SHA-256 only; base64
|
|
72
|
+
# with padding (RFC 4648, section 4), as a byte sequence of RFC 8941 is written.
|
|
73
|
+
return 'sha-256=:' + base64.b64encode(sha256(data)).decode('ascii') + ':'
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _signature_base(components):
|
|
77
|
+
# The signature base of RFC 9421, section 2.5: a line for each component,
|
|
78
|
+
# "<identifier>: <value>" and a line feed, then the line
|
|
79
|
+
# "@signature-params" with the list of the components and no parameters,
|
|
80
|
+
# and no line feed after it. Nothing is signed with it.
|
|
81
|
+
lines = ''.join(f'"{name}": {value}\n' for name, value in components)
|
|
82
|
+
return lines + '"@signature-params": (' + ' '.join(f'"{name}"' for name, _ in components) + ')'
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_object(r):
|
|
86
|
+
return r is not None and not isinstance(r, (str, bytes, bytearray, int, float, bool, list, tuple))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def http_request_base(r):
|
|
90
|
+
"""The text that the reserved name "provared.http-request" is made from.
|
|
91
|
+
r: "method" and "url" exactly as sent, and "body" (bytes) where there is a body."""
|
|
92
|
+
if not _is_object(r):
|
|
93
|
+
raise Refusal('bad-field', 'The request must be an object.')
|
|
94
|
+
method = _member(r, 'method')
|
|
95
|
+
url = _member(r, 'url')
|
|
96
|
+
given = _body(_member(r, 'body'), 'body')
|
|
97
|
+
# The text itself, never a subclass's own way of writing it.
|
|
98
|
+
method = str.__str__(method) if isinstance(method, str) else method
|
|
99
|
+
url = str.__str__(url) if isinstance(url, str) else url
|
|
100
|
+
if not isinstance(method, str) or not _TOKEN.fullmatch(method):
|
|
101
|
+
raise Refusal('bad-field', 'method: must be the method of the request as sent, a token such as GET or POST.')
|
|
102
|
+
if not isinstance(url, str) or not _ADDRESS.fullmatch(url):
|
|
103
|
+
raise Refusal('bad-field', 'url: must be the address of the request as sent: an absolute http or https address of visible ASCII characters, with no fragment.')
|
|
104
|
+
components = [('@method', method), ('@target-uri', url)]
|
|
105
|
+
if given is not _ABSENT:
|
|
106
|
+
components.append(('content-digest', _content_digest(given)))
|
|
107
|
+
return _signature_base(components)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def http_response_base(r):
|
|
111
|
+
"""The text that the reserved name "provared.http-response" is made from.
|
|
112
|
+
r: "status", and "body" (bytes) where there is a body."""
|
|
113
|
+
if not _is_object(r):
|
|
114
|
+
raise Refusal('bad-field', 'The answer must be an object.')
|
|
115
|
+
status = _member(r, 'status')
|
|
116
|
+
given = _body(_member(r, 'body'), 'body')
|
|
117
|
+
# A whole number, whatever its type, as JavaScript reads one: a float
|
|
118
|
+
# that equals a whole number is that whole number. True and False are
|
|
119
|
+
# not numbers here. The value is read as the plain number, never through
|
|
120
|
+
# a subclass's own methods.
|
|
121
|
+
whole = None
|
|
122
|
+
if isinstance(status, int) and not isinstance(status, bool):
|
|
123
|
+
whole = int.__index__(status)
|
|
124
|
+
elif isinstance(status, float) and float.is_integer(status):
|
|
125
|
+
whole = float.__int__(status)
|
|
126
|
+
if whole is None or not 100 <= whole <= 599:
|
|
127
|
+
raise Refusal('bad-field', 'status: must be the status code of the answer, a whole number from 100 to 599.')
|
|
128
|
+
components = [('@status', '%d' % whole)]
|
|
129
|
+
if given is not _ABSENT:
|
|
130
|
+
components.append(('content-digest', _content_digest(given)))
|
|
131
|
+
return _signature_base(components)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def http_request_fingerprint(r):
|
|
135
|
+
"""The fingerprint of an HTTP request, for the document
|
|
136
|
+
"provared.http-request": "method" and "url" exactly as sent, and "body"
|
|
137
|
+
(bytes) where there is a body. Raises a Refusal, "bad-field", if the
|
|
138
|
+
request is not as set out."""
|
|
139
|
+
return fingerprint(utf8(http_request_base(r)))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def http_response_fingerprint(r):
|
|
143
|
+
"""The fingerprint of an HTTP answer, for the document
|
|
144
|
+
"provared.http-response": "status", and "body" (bytes) where there is a
|
|
145
|
+
body. Raises a Refusal, "bad-field", if the answer is not as set out."""
|
|
146
|
+
return fingerprint(utf8(http_response_base(r)))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def identifier_fingerprint(identifier):
|
|
150
|
+
"""The fingerprint of an identifier or of an error text, exactly as given:
|
|
151
|
+
SHA-256 of its UTF-8 bytes. For "provared.trace-id", "provared.request-id",
|
|
152
|
+
"provared.event-id" and "provared.error". Raises a Refusal, "bad-field", if
|
|
153
|
+
it is not text, is empty, or is not well-formed Unicode."""
|
|
154
|
+
if not isinstance(identifier, str) or len(identifier) == 0:
|
|
155
|
+
raise Refusal('bad-field', 'The identifier must be text of one character or more.')
|
|
156
|
+
identifier = str.__str__(identifier) # the text itself, never a subclass's own methods
|
|
157
|
+
if not _well_formed(identifier):
|
|
158
|
+
raise Refusal('bad-field', 'The identifier holds text that is not well-formed Unicode.')
|
|
159
|
+
return fingerprint(utf8(identifier))
|
|
@@ -250,7 +250,7 @@ def format_time(when):
|
|
|
250
250
|
read: text, True or a datetime with no time zone would be read by rules
|
|
251
251
|
that differ from one device to another (as local time), so they are
|
|
252
252
|
refused."""
|
|
253
|
-
if isinstance(when, _datetime.datetime) and when.
|
|
253
|
+
if isinstance(when, _datetime.datetime) and when.utcoffset() is not None:
|
|
254
254
|
when = (when - _EPOCH) // _datetime.timedelta(milliseconds=1)
|
|
255
255
|
if not _js.finite(when) or abs(when) > MAX_TIME_MS:
|
|
256
256
|
raise ValueError('Invalid time value')
|