provared 0.3.0__tar.gz → 0.4.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 (49) hide show
  1. {provared-0.3.0/src/provared.egg-info → provared-0.4.0}/PKG-INFO +7 -4
  2. {provared-0.3.0 → provared-0.4.0}/README.md +6 -3
  3. {provared-0.3.0 → provared-0.4.0}/pyproject.toml +1 -1
  4. {provared-0.3.0 → provared-0.4.0}/src/provared/encoding.py +1 -1
  5. {provared-0.3.0 → provared-0.4.0}/src/provared/guard.py +100 -6
  6. {provared-0.3.0 → provared-0.4.0}/src/provared/recorder.py +4 -1
  7. {provared-0.3.0 → provared-0.4.0}/src/provared/signatures.py +8 -0
  8. {provared-0.3.0 → provared-0.4.0/src/provared.egg-info}/PKG-INFO +7 -4
  9. {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/SOURCES.txt +2 -0
  10. provared-0.4.0/tests/test_check_before.py +44 -0
  11. {provared-0.3.0 → provared-0.4.0}/tests/test_recorder.py +45 -0
  12. provared-0.4.0/tests/test_signatures.py +47 -0
  13. {provared-0.3.0 → provared-0.4.0}/LICENSE +0 -0
  14. {provared-0.3.0 → provared-0.4.0}/LICENSE-CC-BY-4.0.txt +0 -0
  15. {provared-0.3.0 → provared-0.4.0}/setup.cfg +0 -0
  16. {provared-0.3.0 → provared-0.4.0}/src/provared/__init__.py +0 -0
  17. {provared-0.3.0 → provared-0.4.0}/src/provared/_js.py +0 -0
  18. {provared-0.3.0 → provared-0.4.0}/src/provared/actions.py +0 -0
  19. {provared-0.3.0 → provared-0.4.0}/src/provared/approval.py +0 -0
  20. {provared-0.3.0 → provared-0.4.0}/src/provared/blockstamp.py +0 -0
  21. {provared-0.3.0 → provared-0.4.0}/src/provared/book.py +0 -0
  22. {provared-0.3.0 → provared-0.4.0}/src/provared/check.py +0 -0
  23. {provared-0.3.0 → provared-0.4.0}/src/provared/compare.py +0 -0
  24. {provared-0.3.0 → provared-0.4.0}/src/provared/cover.py +0 -0
  25. {provared-0.3.0 → provared-0.4.0}/src/provared/der.py +0 -0
  26. {provared-0.3.0 → provared-0.4.0}/src/provared/dev.py +0 -0
  27. {provared-0.3.0 → provared-0.4.0}/src/provared/fields.py +0 -0
  28. {provared-0.3.0 → provared-0.4.0}/src/provared/headers.py +0 -0
  29. {provared-0.3.0 → provared-0.4.0}/src/provared/jws.py +0 -0
  30. {provared-0.3.0 → provared-0.4.0}/src/provared/keys.py +0 -0
  31. {provared-0.3.0 → provared-0.4.0}/src/provared/pass_.py +0 -0
  32. {provared-0.3.0 → provared-0.4.0}/src/provared/seal.py +0 -0
  33. {provared-0.3.0 → provared-0.4.0}/src/provared/service.py +0 -0
  34. {provared-0.3.0 → provared-0.4.0}/src/provared/slip.py +0 -0
  35. {provared-0.3.0 → provared-0.4.0}/src/provared/standing.py +0 -0
  36. {provared-0.3.0 → provared-0.4.0}/src/provared/stub.py +0 -0
  37. {provared-0.3.0 → provared-0.4.0}/src/provared/timestamp.py +0 -0
  38. {provared-0.3.0 → provared-0.4.0}/src/provared/tools.py +0 -0
  39. {provared-0.3.0 → provared-0.4.0}/src/provared/tree.py +0 -0
  40. {provared-0.3.0 → provared-0.4.0}/src/provared/webauthn.py +0 -0
  41. {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/dependency_links.txt +0 -0
  42. {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/requires.txt +0 -0
  43. {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/top_level.txt +0 -0
  44. {provared-0.3.0 → provared-0.4.0}/tests/test_dev.py +0 -0
  45. {provared-0.3.0 → provared-0.4.0}/tests/test_record_function.py +0 -0
  46. {provared-0.3.0 → provared-0.4.0}/tests/test_records.py +0 -0
  47. {provared-0.3.0 → provared-0.4.0}/tests/test_review2.py +0 -0
  48. {provared-0.3.0 → provared-0.4.0}/tests/test_tree.py +0 -0
  49. {provared-0.3.0 → provared-0.4.0}/tests/test_vectors.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.4.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.4.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
 
@@ -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.4.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
 
@@ -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.4.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"
@@ -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')
@@ -7,8 +7,10 @@
7
7
  # a period which a stub dated after it already overfills. It writes
8
8
  # nothing and changes nothing.
9
9
 
10
+ import datetime
10
11
  from collections.abc import Mapping
11
12
 
13
+ from . import _js
12
14
  from . import fields as f
13
15
  from .book import MAX_ENTRIES, MAX_LINE_BYTES, _Ids, check_approval_record, check_book_keeping_state, fixed_options, keys_of, pass_as_slip
14
16
  from .compare import compare_with_slip
@@ -53,9 +55,11 @@ def check_before(book, proposal, options=None, **named):
53
55
  the passkeys this check trusts) must be given: a slip signed by any
54
56
  other key allows nothing.
55
57
 
56
- Returns {"allowed", "problems", "breaches", "needs"}. "allowed" is True
57
- only if the record so far is sound, this device checked every signature
58
- in it, and the action would be inside the slip. "needs" lists what the
58
+ Returns {"allowed", "problems", "breaches", "needs"}, and "notChecked"
59
+ where it applies. "allowed" is True only if the record so far is sound,
60
+ this device checked every signature in it (or every one but the third
61
+ signature of a seal whose first two it checked, which "notChecked" then
62
+ names), and the action would be inside the slip. "needs" lists what the
59
63
  slip asks to go with the action. A needed approval must be handed over,
60
64
  or the action is not allowed. A needed countersignature cannot be known
61
65
  beforehand: the action is allowed, and is inside the slip only if the
@@ -73,6 +77,78 @@ def check_before_read(read, proposal, options=None):
73
77
  return _decide(read, proposal, options)
74
78
 
75
79
 
80
+ SEAL_THIRD = 'SLH-DSA-SHA2-256s'
81
+
82
+
83
+ def _only_seal_third_unchecked(result):
84
+ """The methods not checked on this device, if the only thing not checked
85
+ is the third signature of one or more seals whose Ed25519 and ML-DSA-87
86
+ signatures were checked here; otherwise None. It counts as the checker
87
+ counts for "fully checked" (book.py): a signature whose method is not
88
+ built in, or an entry with nothing to check, is not checked; a time-stamp
89
+ from a service the reader did not name is not "unchecked", it is uncounted."""
90
+ if result['summary']['fullyChecked']:
91
+ return None
92
+ state = {'skipped': False, 'excused': 0}
93
+ missing = set()
94
+
95
+ def note(items):
96
+ if len(items) == 0:
97
+ state['skipped'] = True
98
+ for x in items:
99
+ if x.get('state') == 'unavailable':
100
+ missing.add(x.get('method'))
101
+
102
+ def stamps(items):
103
+ for x in items:
104
+ if x.get('state') == 'unavailable':
105
+ missing.add('stamp')
106
+
107
+ for e in result['entries']:
108
+ kind = e.get('kind')
109
+ if kind == 'seal':
110
+ sigs = e.get('signatures') or []
111
+ by = {x.get('method'): x.get('state') for x in sigs}
112
+ third = len(sigs) == 3 and by.get('Ed25519') == 'valid' and by.get('ML-DSA-87') == 'valid' and by.get(SEAL_THIRD) == 'unavailable'
113
+ if third:
114
+ state['excused'] += 1
115
+ else:
116
+ note(sigs)
117
+ stamps(e.get('stamps') or [])
118
+ elif kind == 'slip':
119
+ sig = e.get('signature')
120
+ if not sig or sig.get('state') == 'unavailable':
121
+ missing.add(sig.get('alg') if sig else 'passkey')
122
+ elif sig.get('state') == 'unchecked':
123
+ state['skipped'] = True
124
+ elif kind == 'stub':
125
+ note(e.get('signatures') or [])
126
+ counter = e.get('countersignature') or {'state': 'absent', 'signatures': []}
127
+ if counter.get('state') != 'absent':
128
+ note(counter.get('signatures') or [])
129
+ approval = e.get('approval') or {'state': 'absent'}
130
+ if approval.get('state') == 'unavailable':
131
+ missing.add(approval.get('alg'))
132
+ elif kind == 'cancellation':
133
+ sig = e.get('signature')
134
+ if not sig:
135
+ state['skipped'] = True
136
+ elif sig.get('state') == 'unavailable':
137
+ missing.add(sig.get('alg'))
138
+ stamps(e.get('stamps') or [])
139
+ elif kind in ('refusal', 'terms', 'vouching', 'withdrawal', 'pass', 'acknowledgement'):
140
+ note(e.get('signatures') or [])
141
+ else:
142
+ state['skipped'] = True
143
+ for h in result.get('held') or []:
144
+ stamps(h.get('stamps') or [])
145
+ for a in h.get('acknowledgements') or []:
146
+ note(a.get('signatures') or [])
147
+ if not state['skipped'] and len(missing) == 0 and state['excused'] > 0:
148
+ return [SEAL_THIRD]
149
+ return None
150
+
151
+
76
152
  def _decide(read, proposal, options):
77
153
  answer = {'allowed': False, 'problems': [], 'breaches': [], 'needs': []}
78
154
  try:
@@ -85,17 +161,35 @@ def _decide(read, proposal, options):
85
161
  result, state, beside = got['result'], got['state'], got['beside']
86
162
  if result['summary']['problemFound']:
87
163
  raise _not_sound('A problem was found in the record so far. It must be looked at before the agent acts again.')
88
- # A record this device could only partly check is not a pass, here either.
89
- if not result['summary']['fullyChecked']:
164
+ # A record this device could only partly check is not a pass, here
165
+ # either, with one stated exception: a seal's third signature
166
+ # (SLH-DSA), where its first two were checked here. A seal widens no
167
+ # permission, and this version cannot check that method (F-79), so it
168
+ # would otherwise be refused everything once the book holds a seal.
169
+ # The answer names what was not checked.
170
+ not_checked = _only_seal_third_unchecked(result)
171
+ if not result['summary']['fullyChecked'] and not_checked is None:
90
172
  raise _not_sound('This device could not check every signature in the record so far.')
173
+ if not_checked is not None:
174
+ answer['notChecked'] = not_checked
91
175
  if not all(e.get('compared') is True for e in result['entries'] if e['kind'] in ('stub', 'pass')):
92
176
  raise _not_sound('This device could not compare every stub and every pass in the record so far with its slip.')
93
177
  if result['size'] >= MAX_ENTRIES:
94
178
  raise Refusal('too-large', 'The book is full: it holds 100,000 entries.')
95
179
 
96
180
  f.members(proposal, ['action', 'slip'], ['amount', 'approval', 'details', 'pass', 'terms', 'when', 'with'], 'proposal')
181
+ # The time, as the stub writer takes it: a number of milliseconds or a
182
+ # datetime with a time zone. Text is read by rules that differ between
183
+ # devices.
97
184
  when_given = proposal.get('when')
98
- c = {'action': proposal['action'], 'when': format_time(now_ms() if when_given is None else when_given)}
185
+ given = now_ms() if when_given is None else when_given
186
+ if not _js.is_number(given) and not (isinstance(given, datetime.datetime) and given.utcoffset() is not None):
187
+ raise Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.')
188
+ try:
189
+ when_text = format_time(given)
190
+ except ValueError:
191
+ raise Refusal('bad-field', 'when: does not give a time.') from None
192
+ c = {'action': proposal['action'], 'when': when_text}
99
193
  for name in ('amount', 'details', 'with'):
100
194
  if name in proposal:
101
195
  c[name] = proposal[name]
@@ -322,7 +322,7 @@ def _time_value(value):
322
322
  library."""
323
323
  if _js.is_number(value):
324
324
  return _time_clip(value)
325
- if isinstance(value, datetime.datetime) and value.tzinfo is not None:
325
+ if isinstance(value, datetime.datetime) and value.utcoffset() is not None:
326
326
  epoch = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
327
327
  return _time_clip((value - epoch) // datetime.timedelta(milliseconds=1))
328
328
  raise Refusal('bad-field', 'when: must be a number of milliseconds, or a Date.')
@@ -1028,6 +1028,9 @@ class Recorder:
1028
1028
  def work(call):
1029
1029
  self._write_waiting(self._now())
1030
1030
  when = call['when'] if call['when'] is not _ABSENT else self._now()
1031
+ # In the words of the check before acting, which "act" and "before" give.
1032
+ if not _js.finite(when):
1033
+ raise Refusal('bad-field', 'when: does not give a time.')
1031
1034
  if self._ahead_of_clock(when):
1032
1035
  raise _not_written('The stub was not written: it is dated ahead of the clock.')
1033
1036
  made = self._compose_stub(call, when)
@@ -29,6 +29,10 @@ except ImportError: # "cryptography" before version 48
29
29
 
30
30
  SLH = 'SLH-DSA-SHA2-256s'
31
31
 
32
+ # The length of a signature, in bytes, by method (RFC 8032, FIPS 204,
33
+ # FIPS 205).
34
+ _SIGNATURE_BYTES = {'Ed25519': 64, 'ML-DSA-87': 4627, SLH: 29792}
35
+
32
36
 
33
37
  def method_available(alg):
34
38
  """Whether this device can check signatures made with this method."""
@@ -66,6 +70,10 @@ def verify_signature(jwk, signature, data, without=()):
66
70
  without them; it can only withhold a pass.
67
71
  """
68
72
  alg = jwk['alg']
73
+ # A signature of another length than its method's can never check, so it
74
+ # is refused on every device, whether or not this one has the method.
75
+ if alg in _SIGNATURE_BYTES and len(signature) != _SIGNATURE_BYTES[alg]:
76
+ return 'invalid'
69
77
  if alg == SLH:
70
78
  return 'unavailable'
71
79
  if alg not in ('Ed25519', 'ML-DSA-87', 'ES256', 'RS256'):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: provared
3
- Version: 0.3.0
3
+ Version: 0.4.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.4.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
 
@@ -36,10 +36,12 @@ src/provared.egg-info/SOURCES.txt
36
36
  src/provared.egg-info/dependency_links.txt
37
37
  src/provared.egg-info/requires.txt
38
38
  src/provared.egg-info/top_level.txt
39
+ tests/test_check_before.py
39
40
  tests/test_dev.py
40
41
  tests/test_record_function.py
41
42
  tests/test_recorder.py
42
43
  tests/test_records.py
43
44
  tests/test_review2.py
45
+ tests/test_signatures.py
44
46
  tests/test_tree.py
45
47
  tests/test_vectors.py
@@ -0,0 +1,44 @@
1
+ # The check before acting, with a seal in the book (format description,
2
+ # section 20). This version cannot check a seal's third signature
3
+ # (SLH-DSA-SHA2-256s), so the check goes on where the seal's first two
4
+ # signatures were checked here, and says what it did not check. A book of a
5
+ # permission and a seal alone shows the rule itself: the seal's first two
6
+ # signatures are the only ones of their methods. Python cannot make a seal,
7
+ # so the book is the one in the shared test file, made by the JavaScript
8
+ # library.
9
+
10
+ import json
11
+ import pathlib
12
+ import unittest
13
+
14
+ from provared import check_before
15
+
16
+ VECTORS = pathlib.Path(__file__).resolve().parents[2] / 'test-vectors'
17
+ SLH = 'SLH-DSA-SHA2-256s'
18
+ NOT_CHECKED = [{'code': 'record-not-sound', 'message': 'This device could not check every signature in the record so far.'}]
19
+
20
+
21
+ def shared_case(name):
22
+ cases = json.loads((VECTORS / 'check-before.json').read_text(encoding='utf-8'))['cases']
23
+ return next(c for c in cases if c['name'] == name)
24
+
25
+
26
+ class TheSealRule(unittest.TestCase):
27
+ def test_the_third_signature_is_let_through_only_where_the_first_two_were_both_checked_here(self):
28
+ c = shared_case('a permission and a seal, on a device without Ed25519 either')
29
+ trusted = {'issuerKeys': c['options']['issuerKeys'], 'sealKeys': c['options']['sealKeys']}
30
+ lone = c['text']
31
+ self.assertEqual([json.loads(line).keys() >= {'seal'} for line in lone.splitlines()], [False, True], 'a permission and a seal')
32
+ for without in ([], [SLH]):
33
+ r = check_before(lone, c['proposal'], {**trusted, 'withoutMethods': without})
34
+ self.assertTrue(r['allowed'], without)
35
+ self.assertEqual(r['notChecked'], [SLH])
36
+ for also in ('ML-DSA-87', 'Ed25519'):
37
+ r = check_before(lone, c['proposal'], {**trusted, 'withoutMethods': [SLH, also]})
38
+ self.assertFalse(r['allowed'], f'without {also} as well: refused')
39
+ self.assertEqual(r['problems'], NOT_CHECKED)
40
+ self.assertNotIn('notChecked', r)
41
+
42
+
43
+ if __name__ == '__main__':
44
+ unittest.main()
@@ -22,6 +22,7 @@ from provared import (
22
22
  Recorder,
23
23
  Refusal,
24
24
  arguments_fingerprint,
25
+ check_before,
25
26
  check_book,
26
27
  countersign,
27
28
  fingerprint,
@@ -285,6 +286,50 @@ class PythonValues(unittest.TestCase):
285
286
  self.assertTrue(r['done'])
286
287
  self.assertEqual(check_book(writer.book())['entries'][1]['content']['when'], '2026-10-05T10:00:00Z')
287
288
 
289
+ def test_a_datetime_whose_time_zone_gives_no_offset_is_refused_in_plain_words(self):
290
+ # Python's own rule: a datetime is "aware" only where its time zone
291
+ # gives an offset. One that gives none is read as local time, which
292
+ # differs from one device to another: the check before acting and
293
+ # the writer refuse it in the same words, and nothing is written.
294
+ class NoOffset(datetime.tzinfo):
295
+ def utcoffset(self, dt):
296
+ return None
297
+
298
+ def dst(self, dt):
299
+ return None
300
+
301
+ moment = datetime.datetime(2026, 10, 5, 10, 0, tzinfo=NoOffset())
302
+ refusal = {'code': 'bad-field', 'message': 'when: must be a number of milliseconds, or a Date.'}
303
+ w = World()
304
+ answer = check_before(w.book(), {'slip': w.slip_fingerprint, **order(1), 'when': moment}, issuer_keys=w.issuer_keys)
305
+ self.assertFalse(answer['allowed'])
306
+ self.assertEqual(answer['problems'], [refusal])
307
+ with self.assertRaises(Refusal) as refused:
308
+ _time_value(moment)
309
+ self.assertEqual((refused.exception.code, refused.exception.message), (refusal['code'], refusal['message']))
310
+ writer = w.open()
311
+ for call in (lambda more: writer.before(order(1), more), lambda more: writer.act(order(1), lambda: None, more), lambda more: writer.record(order(1), more)):
312
+ with self.assertRaises(Refusal) as refused:
313
+ call({'when': moment})
314
+ self.assertEqual((refused.exception.code, refused.exception.message), (refusal['code'], refusal['message']))
315
+ self.assertEqual(writer.book(), w.book())
316
+
317
+ def test_a_time_that_gives_no_time_is_refused_in_plain_words_by_record_too(self):
318
+ # "before" and "act" answer as the check before acting does;
319
+ # "record", which does not ask first, refuses in the same words.
320
+ plainly = {'code': 'bad-field', 'message': 'when: does not give a time.'}
321
+ w = World()
322
+ writer = w.open()
323
+ for when in (float('nan'), float('inf'), 9e15):
324
+ self.assertEqual(writer.before(order(1), {'when': when})['problems'], [plainly])
325
+ acted = writer.act(order(1), lambda: self.fail('the action was taken'), {'when': when})
326
+ self.assertFalse(acted['done'])
327
+ self.assertEqual(acted['answer']['problems'], [plainly])
328
+ with self.assertRaises(Refusal) as refused:
329
+ writer.record(order(1), {'when': when})
330
+ self.assertEqual((refused.exception.code, refused.exception.message), (plainly['code'], plainly['message']))
331
+ self.assertEqual(writer.book(), w.book())
332
+
288
333
  def test_what_is_not_plain_data_is_refused_where_javascript_could_not_copy_it(self):
289
334
  w = World()
290
335
  writer = w.open()
@@ -0,0 +1,47 @@
1
+ # The length of a signature. An Ed25519 signature is 64 bytes, an ML-DSA-87
2
+ # signature 4,627 and an SLH-DSA-SHA2-256s signature 29,792 (RFC 8032,
3
+ # FIPS 204, FIPS 205). One shorter or longer can never check, so no device
4
+ # calls it "not checked here", whether or not it has the method (format
5
+ # description, section 3.9). The shared test files hold a seal whose third
6
+ # signature is cut short; these tests hold the rule for every method, both
7
+ # ways, with and without the method.
8
+
9
+ import json
10
+ import unittest
11
+
12
+ from test_recorder import World, order
13
+
14
+ from provared import check_book, to_base64url, write_book
15
+ from provared.signatures import generate_key_set, verify_signature
16
+
17
+ SLH_KEY = {'alg': 'SLH-DSA-SHA2-256s', 'kty': 'AKP', 'pub': to_base64url(bytes(64))}
18
+
19
+
20
+ class SignatureLengths(unittest.TestCase):
21
+ def test_a_signature_of_another_length_than_its_methods_is_invalid_with_the_method_or_without_it(self):
22
+ keys, _ = generate_key_set()
23
+ for key, length in ((keys[0], 64), (keys[1], 4627), (SLH_KEY, 29792)):
24
+ for wrong in (0, length - 1, length + 1, 2 * length):
25
+ for without in ((), (key['alg'],)):
26
+ where = f"{key['alg']}, {wrong} bytes, {'without' if without else 'with'} the method"
27
+ self.assertEqual(verify_signature(key, bytes(wrong), b'x', without), 'invalid', where)
28
+ # Of the right length, on a device without the method: not checked here, never a pass.
29
+ self.assertEqual(verify_signature(key, bytes(length), b'x', (key['alg'],)), 'unavailable', key['alg'])
30
+
31
+ def test_a_book_whose_signatures_are_emptied_has_a_problem_on_a_device_with_neither_method(self):
32
+ w = World()
33
+ writer = w.open()
34
+ self.assertTrue(writer.act(order(1), lambda: None)['done'])
35
+ entries = [{'slip': w.slip}]
36
+ for line in writer.book().splitlines()[1:]:
37
+ entry = json.loads(line)
38
+ entry['stub'] = {**entry['stub'], 'signatures': [{**s, 'signature': ''} for s in entry['stub']['signatures']]}
39
+ entries.append(entry)
40
+ result = check_book(write_book(entries), {'issuerKeys': w.issuer_keys, 'withoutMethods': ['Ed25519', 'ML-DSA-87']})
41
+ self.assertTrue(result['summary']['problemFound'])
42
+ self.assertFalse(result['summary']['intact'])
43
+ self.assertEqual([s['state'] for s in result['entries'][1]['signatures']], ['invalid', 'invalid'])
44
+
45
+
46
+ if __name__ == '__main__':
47
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes