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.
Files changed (53) hide show
  1. {provared-0.3.0/src/provared.egg-info → provared-0.5.0}/PKG-INFO +116 -4
  2. {provared-0.3.0 → provared-0.5.0}/README.md +115 -3
  3. {provared-0.3.0 → provared-0.5.0}/pyproject.toml +1 -1
  4. {provared-0.3.0 → provared-0.5.0}/src/provared/__init__.py +12 -4
  5. {provared-0.3.0 → provared-0.5.0}/src/provared/dev.py +1 -1
  6. provared-0.5.0/src/provared/documents.py +159 -0
  7. {provared-0.3.0 → provared-0.5.0}/src/provared/encoding.py +1 -1
  8. {provared-0.3.0 → provared-0.5.0}/src/provared/guard.py +100 -6
  9. {provared-0.3.0 → provared-0.5.0}/src/provared/pass_.py +2 -2
  10. {provared-0.3.0 → provared-0.5.0}/src/provared/recorder.py +190 -13
  11. {provared-0.3.0 → provared-0.5.0}/src/provared/seal.py +5 -3
  12. {provared-0.3.0 → provared-0.5.0}/src/provared/service.py +6 -4
  13. provared-0.5.0/src/provared/signatures.py +425 -0
  14. {provared-0.3.0 → provared-0.5.0}/src/provared/standing.py +5 -4
  15. {provared-0.3.0 → provared-0.5.0}/src/provared/stub.py +16 -4
  16. {provared-0.3.0 → provared-0.5.0/src/provared.egg-info}/PKG-INFO +116 -4
  17. {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/SOURCES.txt +5 -0
  18. provared-0.5.0/tests/test_check_before.py +44 -0
  19. provared-0.5.0/tests/test_documents.py +272 -0
  20. {provared-0.3.0 → provared-0.5.0}/tests/test_recorder.py +45 -0
  21. provared-0.5.0/tests/test_signatures.py +47 -0
  22. provared-0.5.0/tests/test_signers.py +553 -0
  23. {provared-0.3.0 → provared-0.5.0}/tests/test_vectors.py +19 -0
  24. provared-0.3.0/src/provared/signatures.py +0 -149
  25. {provared-0.3.0 → provared-0.5.0}/LICENSE +0 -0
  26. {provared-0.3.0 → provared-0.5.0}/LICENSE-CC-BY-4.0.txt +0 -0
  27. {provared-0.3.0 → provared-0.5.0}/setup.cfg +0 -0
  28. {provared-0.3.0 → provared-0.5.0}/src/provared/_js.py +0 -0
  29. {provared-0.3.0 → provared-0.5.0}/src/provared/actions.py +0 -0
  30. {provared-0.3.0 → provared-0.5.0}/src/provared/approval.py +0 -0
  31. {provared-0.3.0 → provared-0.5.0}/src/provared/blockstamp.py +0 -0
  32. {provared-0.3.0 → provared-0.5.0}/src/provared/book.py +0 -0
  33. {provared-0.3.0 → provared-0.5.0}/src/provared/check.py +0 -0
  34. {provared-0.3.0 → provared-0.5.0}/src/provared/compare.py +0 -0
  35. {provared-0.3.0 → provared-0.5.0}/src/provared/cover.py +0 -0
  36. {provared-0.3.0 → provared-0.5.0}/src/provared/der.py +0 -0
  37. {provared-0.3.0 → provared-0.5.0}/src/provared/fields.py +0 -0
  38. {provared-0.3.0 → provared-0.5.0}/src/provared/headers.py +0 -0
  39. {provared-0.3.0 → provared-0.5.0}/src/provared/jws.py +0 -0
  40. {provared-0.3.0 → provared-0.5.0}/src/provared/keys.py +0 -0
  41. {provared-0.3.0 → provared-0.5.0}/src/provared/slip.py +0 -0
  42. {provared-0.3.0 → provared-0.5.0}/src/provared/timestamp.py +0 -0
  43. {provared-0.3.0 → provared-0.5.0}/src/provared/tools.py +0 -0
  44. {provared-0.3.0 → provared-0.5.0}/src/provared/tree.py +0 -0
  45. {provared-0.3.0 → provared-0.5.0}/src/provared/webauthn.py +0 -0
  46. {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/dependency_links.txt +0 -0
  47. {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/requires.txt +0 -0
  48. {provared-0.3.0 → provared-0.5.0}/src/provared.egg-info/top_level.txt +0 -0
  49. {provared-0.3.0 → provared-0.5.0}/tests/test_dev.py +0 -0
  50. {provared-0.3.0 → provared-0.5.0}/tests/test_record_function.py +0 -0
  51. {provared-0.3.0 → provared-0.5.0}/tests/test_records.py +0 -0
  52. {provared-0.3.0 → provared-0.5.0}/tests/test_review2.py +0 -0
  53. {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.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.3.0, a draft**, the same version as the JavaScript library.
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.3.0, a draft**, the same version as the JavaScript library.
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.3.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
- # Names: the shared list of actions, the kinds of action, the rules of conduct
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.tzinfo is not None:
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')