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.
- {provared-0.3.0/src/provared.egg-info → provared-0.4.0}/PKG-INFO +7 -4
- {provared-0.3.0 → provared-0.4.0}/README.md +6 -3
- {provared-0.3.0 → provared-0.4.0}/pyproject.toml +1 -1
- {provared-0.3.0 → provared-0.4.0}/src/provared/encoding.py +1 -1
- {provared-0.3.0 → provared-0.4.0}/src/provared/guard.py +100 -6
- {provared-0.3.0 → provared-0.4.0}/src/provared/recorder.py +4 -1
- {provared-0.3.0 → provared-0.4.0}/src/provared/signatures.py +8 -0
- {provared-0.3.0 → provared-0.4.0/src/provared.egg-info}/PKG-INFO +7 -4
- {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/SOURCES.txt +2 -0
- provared-0.4.0/tests/test_check_before.py +44 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_recorder.py +45 -0
- provared-0.4.0/tests/test_signatures.py +47 -0
- {provared-0.3.0 → provared-0.4.0}/LICENSE +0 -0
- {provared-0.3.0 → provared-0.4.0}/LICENSE-CC-BY-4.0.txt +0 -0
- {provared-0.3.0 → provared-0.4.0}/setup.cfg +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/__init__.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/_js.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/actions.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/approval.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/blockstamp.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/book.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/check.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/compare.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/cover.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/der.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/dev.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/fields.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/headers.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/jws.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/keys.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/pass_.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/seal.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/service.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/slip.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/standing.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/stub.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/timestamp.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/tools.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/tree.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared/webauthn.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/dependency_links.txt +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/requires.txt +0 -0
- {provared-0.3.0 → provared-0.4.0}/src/provared.egg-info/top_level.txt +0 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_dev.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_record_function.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_records.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_review2.py +0 -0
- {provared-0.3.0 → provared-0.4.0}/tests/test_tree.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"}
|
|
57
|
-
only if the record so far is sound,
|
|
58
|
-
|
|
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
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
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.
|
|
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
|
|
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
|