provared 0.2.0__py3-none-any.whl

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/check.py ADDED
@@ -0,0 +1,21 @@
1
+ # The checker: everything needed to check a record, and nothing that can
2
+ # make one. This is the smallest part to read for anyone who audits the
3
+ # checking.
4
+ #
5
+ # It makes no network request.
6
+
7
+ from .blockstamp import block_fingerprint
8
+ from .book import LIMITS, check_book, check_show, open_checker
9
+ from .compare import condition_words, limit_words, never_words
10
+ from .headers import MIN_BLOCKS_AFTER, blocks_in_chain, check_header_chain
11
+ from .keys import thumbprint
12
+ from .seal import key_set_fingerprint
13
+ from .service import REFUSAL_REASONS
14
+ from .signatures import method_available
15
+ from .slip import check_slip
16
+
17
+ __all__ = [
18
+ 'LIMITS', 'MIN_BLOCKS_AFTER', 'REFUSAL_REASONS', 'block_fingerprint', 'blocks_in_chain', 'check_book', 'check_header_chain',
19
+ 'check_show', 'check_slip', 'condition_words', 'key_set_fingerprint', 'limit_words', 'method_available', 'never_words',
20
+ 'open_checker', 'thumbprint',
21
+ ]
provared/compare.py ADDED
@@ -0,0 +1,266 @@
1
+ # What a stub shows about the agent, compared with its slip.
2
+ # Format description, section 8.
3
+ #
4
+ # The checker and the "check before acting" function both use this one
5
+ # comparison, so that nothing is allowed beforehand that would be reported
6
+ # afterwards. In one case the check before acting refuses more: see
7
+ # _period_with.
8
+
9
+ from .actions import ACTION_KINDS, CONDUCT_RULES, action_kind
10
+ from .encoding import nan_time
11
+
12
+
13
+ def new_tally():
14
+ """The running state kept for one slip while its stubs are read in order."""
15
+ return {'sums': {}, 'counts': {}, 'periods': {}}
16
+
17
+
18
+ def _period_totals(items, period_ms):
19
+ """The total inside the period that ends at each stub. A period is counted
20
+ back from a stub by the times the stubs state, whichever chain a stub is
21
+ in and wherever it stands in the book: an agent and its helpers each have
22
+ a chain, and their stubs need not stand in the book in order of time. So
23
+ the stubs are first put in order of time (those with the same time stay
24
+ in the order of the book), and the period of each takes in the stubs up
25
+ to it that are later than its time minus the period."""
26
+ ordered = sorted(enumerate(items), key=lambda pair: (pair[1]['when'], pair[0]))
27
+ totals = []
28
+ head = 0
29
+ total = 0
30
+ for _, item in ordered:
31
+ total += item['value']
32
+ while ordered[head][1]['when'] <= item['when'] - period_ms:
33
+ total -= ordered[head][1]['value']
34
+ head += 1
35
+ totals.append((item, total))
36
+ return totals
37
+
38
+
39
+ def _period_with(items, when, period_ms, value):
40
+ """For a check before acting: the largest total of any period that the
41
+ proposed action would fall in. That is its own period, and the period of
42
+ every stub dated after it and less than one period later.
43
+
44
+ Where a stub dated after the proposed action is already over the limit,
45
+ the action is refused: it would add to a period that holds too much. A
46
+ checker, with the stub written, reports nothing new, because the finding
47
+ stands with the later stub, which it already marks. This is the one case
48
+ where the check before acting refuses more than a checker would report."""
49
+ proposed = {'when': when, 'value': value}
50
+ worst = 0
51
+ for item, total in _period_totals([*items, proposed], period_ms):
52
+ if (item is proposed or (item['when'] > when and item['when'] < when + period_ms)) and total > worst:
53
+ worst = total
54
+ return worst
55
+
56
+
57
+ def period_words(seconds):
58
+ if seconds % 86400 == 0:
59
+ return 'any 24 hours' if seconds == 86400 else f'any {seconds // 86400} days'
60
+ if seconds % 3600 == 0:
61
+ return 'any hour' if seconds == 3600 else f'any {seconds // 3600} hours'
62
+ return f'any {seconds} seconds'
63
+
64
+
65
+ def _allowed_in(limit):
66
+ return int(limit['count'] if 'count' in limit else limit['max'])
67
+
68
+
69
+ def _over_period(limit, total, W, proposed=False):
70
+ """proposed: an action that is only proposed (a check before acting), of
71
+ which no stub exists yet."""
72
+ With, are, is_ = ('With this action', 'there would be', 'would be') if proposed else ('With this stub', 'there are', 'is')
73
+ if 'count' in limit:
74
+ message = f"{With} {are} {total} such actions in {period_words(limit['per'])}. {W} allows {limit['count']}."
75
+ else:
76
+ message = f"{With} the total in {period_words(limit['per'])} {is_} {total} {limit['unit']}. {W}'s limit is {limit['max']} {limit['unit']}."
77
+ return {'code': 'over-period-limit', 'message': message}
78
+
79
+
80
+ def _words_for(what):
81
+ """The words for what a stub is compared with: a slip, the pass a helper
82
+ acts under, or a pass further up, from which that pass was handed on."""
83
+ if what == 'pass':
84
+ return 'The pass', 'the pass'
85
+ if what == 'earlier-pass':
86
+ return 'The earlier pass', 'the earlier pass'
87
+ return 'The slip', 'the slip'
88
+
89
+
90
+ def settle_periods(s, tally, late):
91
+ """Once every stub under a slip, or under a pass, has been read: settle its
92
+ limits "in any period". Each stub whose period holds more than the limit
93
+ allows is given the finding. It can be done only at the end, because a
94
+ stub further on in the book may be dated earlier (see _period_totals).
95
+
96
+ The findings are not put into the stubs' own lists, which stay as they
97
+ are: they are gathered in "late" (an IdentityMap), each list under the
98
+ stub's own list. So the same stubs can be settled again after more have
99
+ been read."""
100
+
101
+ def over(b):
102
+ return b['code'] == 'over-period-limit'
103
+
104
+ for i, items in tally['periods'].items():
105
+ limit = s['limits'][i]
106
+ allowed = _allowed_in(limit)
107
+ for item, total in _period_totals(items, limit['per'] * 1000):
108
+ if not (total > allowed):
109
+ continue
110
+ found = late.get(item['late'])
111
+ if found is None:
112
+ found = []
113
+ late.set(item['late'], found)
114
+ # A pass does not repeat a finding that the slip, or a pass before it, gave for the stub.
115
+ if item['what'] != 'slip' and (any(over(b) for b in item['late']) or any(over(b) for b in found)):
116
+ continue
117
+ found.append(_over_period(limit, total, item['W']))
118
+
119
+
120
+ def limit_words(limit):
121
+ """Describe a limit in words, for people."""
122
+ period = f" in {period_words(limit['per'])}" if 'per' in limit else ' in total'
123
+ if 'each' in limit:
124
+ return f"{limit['action']}: no single action above {limit['each']} {limit['unit']}"
125
+ if 'count' in limit:
126
+ return f"{limit['action']}: no more than {limit['count']} {'action' if limit['count'] == 1 else 'actions'}{period}"
127
+ return f"{limit['action']}: no more than {limit['max']} {limit['unit']}{period}"
128
+
129
+
130
+ def condition_words(r):
131
+ """Describe a condition ("requires") in words, for people."""
132
+ what = "needs the person's own approval" if r['need'] == 'approval' else "needs the other side's countersignature"
133
+ above = f" above {r['above']} {r['unit']}" if 'above' in r else ''
134
+ return f"{r['action'] if 'action' in r else 'every action'}: {what}{above}"
135
+
136
+
137
+ def never_words(name):
138
+ """Describe one name of a "never" list in words, for people."""
139
+ if name in CONDUCT_RULES:
140
+ return CONDUCT_RULES[name]
141
+ text = ACTION_KINDS[name]
142
+ return f'Never do an action of the kind "{name}": {text[:1].lower()}{text[1:]}'
143
+
144
+
145
+ def compare_with_slip(s, tally, c, facts, commit=True):
146
+ """Compare one stub, or one action that is only proposed, with its slip.
147
+
148
+ s: the slip's content. tally: the slip's running state, or None when only
149
+ single pages are at hand. c: the stub's content: "action", "when", and
150
+ where present "amount" and "with". facts: "service" (the service in the
151
+ slip that the stub names, if it names one the slip knows), "terms" (the
152
+ content of the service's terms the stub relies on, if any), "cancelled"
153
+ (whether the slip was cancelled earlier in the book), "what" ('slip',
154
+ 'pass' or 'earlier-pass'), "late" (where a finding that can be made only
155
+ once every stub has been read is to be put; see settle_periods). commit:
156
+ False to leave the running state as it was (a check before acting).
157
+
158
+ Returns {"breaches", "needs", "running"}; "needs" lists what the slip asks
159
+ to go with this action: "countersignature", "approval".
160
+ """
161
+ # The same comparison is made with a pass: the messages then say so.
162
+ W, w = _words_for(facts.get('what'))
163
+ breaches = []
164
+ when = nan_time(c['when'])
165
+
166
+ # A limit "in any period". While a book is read, the stub is only noted,
167
+ # and the limit is settled once every stub has been read (settle_periods).
168
+ # A check before acting works it out at once, for the proposed action.
169
+ def in_period(i, limit, value):
170
+ items = tally['periods'].get(i, [])
171
+ if commit:
172
+ tally['periods'][i] = items
173
+ items.append({'when': when, 'value': value, 'W': W, 'what': facts.get('what') or 'slip', 'late': facts.get('late') if facts.get('late') is not None else breaches})
174
+ return
175
+ worst = _period_with(items, when, limit['per'] * 1000, value)
176
+ if worst > _allowed_in(limit):
177
+ breaches.append(_over_period(limit, worst, W, True))
178
+
179
+ if c['action'] not in s['actions']:
180
+ breaches.append({'code': 'action-not-allowed', 'message': f'{W} does not allow the action "{c["action"]}".'})
181
+ kind = action_kind(c['action'])
182
+ if facts.get('cancelled'):
183
+ breaches.append({'code': 'after-cancellation', 'message': 'The person cancelled the slip before this action.'})
184
+ if kind and kind in s['never']:
185
+ breaches.append({'code': 'prohibited', 'message': f'{W} says the agent must never do an action of the kind "{kind}", and "{c["action"]}" is of that kind.'})
186
+ if 'with' in c and not facts.get('service'):
187
+ breaches.append({'code': 'party-not-allowed', 'message': f'{W} does not name "{c["with"]}" as someone the agent may deal with.'})
188
+ if when < nan_time(s['validFrom']) or when >= nan_time(s['validUntil']):
189
+ breaches.append({'code': 'outside-valid-time', 'message': f'The action is dated outside the time {w} allows.'})
190
+ if facts.get('terms'):
191
+ t = facts['terms']
192
+ if c['action'] not in t['accepts']:
193
+ breaches.append({'code': 'outside-terms', 'message': f'The service\'s terms for agents do not accept the action "{c["action"]}".'})
194
+ elif when < nan_time(t['validFrom']) or when >= nan_time(t['validUntil']):
195
+ breaches.append({'code': 'outside-terms', 'message': "The action is dated outside the time the service's terms for agents were in force."})
196
+
197
+ # Amounts. Every amount limit for one action is in one unit (the slip's
198
+ # check makes sure of it), so a stub is asked for its amount once.
199
+ missing = False
200
+
201
+ def amount_in(unit):
202
+ nonlocal missing
203
+ if c.get('amount') and c['amount']['unit'] == unit:
204
+ return True
205
+ if not missing:
206
+ breaches.append({'code': 'amount-missing', 'message': f'{W} sets a limit or a condition in "{unit}" for this action, and the {"stub" if commit else "action"} gives no amount in that unit.'})
207
+ missing = True
208
+ return False
209
+
210
+ running = None
211
+ for i, limit in enumerate(s['limits']):
212
+ if limit['action'] != c['action']:
213
+ continue
214
+ period_ms = limit['per'] * 1000 if 'per' in limit else None
215
+ if 'count' in limit:
216
+ if tally is None:
217
+ continue # a single page cannot show a count
218
+ if period_ms is None:
219
+ n = tally['counts'].get(i, 0) + 1
220
+ if commit:
221
+ tally['counts'][i] = n
222
+ if n > limit['count']:
223
+ breaches.append({'code': 'over-count-limit', 'message': f"{'This is' if commit else 'This would be'} action number {n} of this kind under {w}. {W} allows {limit['count']}."})
224
+ else:
225
+ in_period(i, limit, 1)
226
+ continue
227
+ if not amount_in(limit['unit']):
228
+ continue
229
+ value = int(c['amount']['value'])
230
+ if 'each' in limit:
231
+ if value > int(limit['each']):
232
+ breaches.append({'code': 'over-each-limit', 'message': f"This action is {value} {limit['unit']}. {W} allows no single action above {limit['each']} {limit['unit']}."})
233
+ elif period_ms is None:
234
+ if tally is not None:
235
+ total = tally['sums'].get(i, 0) + value
236
+ if commit:
237
+ tally['sums'][i] = total
238
+ running = {'action': c['action'], 'unit': limit['unit'], 'total': str(total), 'max': limit['max']}
239
+ if total > int(limit['max']):
240
+ breaches.append({
241
+ 'code': 'over-limit',
242
+ 'message': f"{'With this stub the total is' if commit else 'With this action the total would be'} {total} {limit['unit']}. {W}'s limit is {limit['max']} {limit['unit']}: over by {total - int(limit['max'])}.",
243
+ })
244
+ elif value > int(limit['max']):
245
+ # A single page cannot show a running total, but it can show a stub
246
+ # that passes the limit all by itself.
247
+ breaches.append({'code': 'over-limit', 'message': f"This stub alone is {value} {limit['unit']}. {W}'s limit is {limit['max']} {limit['unit']} in total."})
248
+ elif tally is not None:
249
+ in_period(i, limit, value)
250
+ elif value > int(limit['max']):
251
+ breaches.append({'code': 'over-period-limit', 'message': f"This stub alone is {value} {limit['unit']}. {W}'s limit is {limit['max']} {limit['unit']} in {period_words(limit['per'])}."})
252
+
253
+ # What the slip asks to go with this action.
254
+ needs = []
255
+ for r in s['requires']:
256
+ if 'action' in r and r['action'] != c['action']:
257
+ continue
258
+ if 'above' in r:
259
+ if not amount_in(r['unit']):
260
+ continue
261
+ if not (c['amount']['value'] > r['above']):
262
+ continue
263
+ if r['need'] not in needs:
264
+ needs.append(r['need'])
265
+
266
+ return {'breaches': breaches, 'needs': needs, 'running': running}
provared/cover.py ADDED
@@ -0,0 +1,187 @@
1
+ # Covered fields: a record can be shown with some fields hidden, while its
2
+ # signatures still check. The method is the standard "Selective Disclosure
3
+ # for JSON Web Tokens" (RFC 9901), as it stands:
4
+ #
5
+ # - a field to be covered is taken out of the signed content, and the
6
+ # fingerprint of a "disclosure" is put in its place, in a list named
7
+ # "_sd" (section 4.2.4.1);
8
+ # - a disclosure is a salt (a random value), the field's name and its
9
+ # value, written as a JSON list and then in base64url (section 4.2.1);
10
+ # - the fingerprint is taken over the base64url text itself (section 4.2.3);
11
+ # - to reveal the field, hand over the disclosure; to hide it, do not.
12
+ #
13
+ # Format description, section 24.
14
+
15
+ import re
16
+
17
+ from . import _js
18
+ from .encoding import Refusal, canonical_json, from_base64url, from_utf8, parse_canonical, random_id, sha256, to_base64url, utf8
19
+
20
+ MAX_DISCLOSURES = 64
21
+ """The most disclosures one record may come with."""
22
+
23
+ _MAX_DEPTH = 12
24
+
25
+
26
+ def _bad(why):
27
+ return Refusal('cover-invalid', f'A covered field is not as its standard sets out: {why}')
28
+
29
+
30
+ def disclosure_digest(disclosure):
31
+ """The fingerprint of a disclosure: SHA-256 over the characters of its
32
+ base64url text, written in base64url (RFC 9901, section 4.2.3)."""
33
+ return to_base64url(sha256(utf8(disclosure)))
34
+
35
+
36
+ def make_disclosure(name, value, salt=None):
37
+ """Make the disclosure for one member of an object (RFC 9901, section 4.2.1)."""
38
+ if salt is None:
39
+ salt = random_id()
40
+ disclosure = to_base64url(utf8(canonical_json([salt, name, value])))
41
+ return disclosure, disclosure_digest(disclosure)
42
+
43
+
44
+ def cover_members(obj, names):
45
+ """Take the named members out of an object and leave the fingerprints of
46
+ their disclosures in its "_sd" list, sorted, so that their order says
47
+ nothing (RFC 9901, section 4.2.4.1). Returns the new object and the
48
+ disclosures; the object handed in is not changed."""
49
+ out = dict(obj)
50
+ digests = list(out['_sd']) if isinstance(out.get('_sd'), list) else []
51
+ disclosures = []
52
+ for name in names:
53
+ if name not in out:
54
+ continue
55
+ disclosure, digest = make_disclosure(name, out[name])
56
+ del out[name]
57
+ digests.append(digest)
58
+ disclosures.append(disclosure)
59
+ if digests:
60
+ out['_sd'] = _js.sort_strings(digests)
61
+ return out, disclosures
62
+
63
+
64
+ class _Given:
65
+ __slots__ = ('parts', 'used')
66
+
67
+ def __init__(self, parts):
68
+ self.parts = parts
69
+ self.used = False
70
+
71
+
72
+ def uncover(payload, disclosures=(), list_items=True, canonical=False):
73
+ """Put back what the disclosures reveal, by the steps of RFC 9901, section
74
+ 7.1, step 3 to step 5.
75
+
76
+ Returns (content, places, named): the content with every revealed field
77
+ back in place and every "_sd" list, covered list item and "_sd_alg"
78
+ removed; for each object that held an "_sd" list, by its path ("" for
79
+ the top, "issuer", "with[0]"), how many fields are still covered there
80
+ and which were revealed; and whether the content named its fingerprint
81
+ method ("_sd_alg"). Raises a Refusal "cover-invalid".
82
+ """
83
+ if not isinstance(disclosures, (list, tuple)) or len(disclosures) > MAX_DISCLOSURES:
84
+ raise _bad('the disclosures must be a list of at most 64.')
85
+ # Each disclosure, by its fingerprint.
86
+ given = {}
87
+ for d in disclosures:
88
+ if not isinstance(d, str) or len(d) == 0 or _js.utf16_length(d) > 87384:
89
+ raise _bad('a disclosure must be text in base64url.')
90
+ try:
91
+ text = from_utf8(from_base64url(d), 'cover-invalid')
92
+ parts = parse_canonical(text, 'cover-invalid') if canonical else _js.parse(text)
93
+ except (Refusal, ValueError):
94
+ raise _bad(
95
+ 'a disclosure is not JSON in the canonical form, written in base64url.' if canonical else 'a disclosure is not JSON written in base64url.'
96
+ ) from None
97
+ if not isinstance(parts, list) or len(parts) not in (2, 3) or not isinstance(parts[0], str):
98
+ raise _bad('a disclosure must be a list of a salt and a value, or of a salt, a name and a value.')
99
+ digest = disclosure_digest(d)
100
+ if digest in given:
101
+ raise _bad('a disclosure is handed over twice.')
102
+ given[digest] = _Given(parts)
103
+
104
+ seen = set()
105
+ places = {}
106
+
107
+ def once(digest):
108
+ # A fingerprint is SHA-256 in base64url: 43 characters, and nothing else.
109
+ data = None
110
+ if isinstance(digest, str) and _js.utf16_length(digest) == 43:
111
+ try:
112
+ data = from_base64url(digest)
113
+ except Refusal:
114
+ data = None
115
+ if data is None or len(data) != 32:
116
+ raise _bad('a fingerprint in the content is not a SHA-256 fingerprint in base64url.')
117
+ # Step 4: a fingerprint may appear only once in the whole content.
118
+ if digest in seen:
119
+ raise _bad('the same fingerprint appears twice in the content.')
120
+ seen.add(digest)
121
+ return given.get(digest)
122
+
123
+ named = False
124
+
125
+ def walk(value, path, depth):
126
+ nonlocal named
127
+ if depth > _MAX_DEPTH:
128
+ raise _bad('the content is nested too deeply.')
129
+ if isinstance(value, list):
130
+ out = []
131
+ for i, item in enumerate(value):
132
+ if isinstance(item, dict) and len(item) == 1 and '...' in item:
133
+ if not list_items:
134
+ raise _bad('an item of a list may not be covered in this kind of record.')
135
+ found = once(item['...'])
136
+ # A covered list item with no disclosure is left out (step 3d).
137
+ if found is None:
138
+ continue
139
+ if len(found.parts) != 2:
140
+ raise _bad('the disclosure of a list item must hold a salt and a value.')
141
+ found.used = True
142
+ out.append(walk(found.parts[1], f'{path}[{i}]', depth + 1))
143
+ else:
144
+ out.append(walk(item, f'{path}[{i}]', depth + 1))
145
+ return out
146
+ if not isinstance(value, dict):
147
+ return value
148
+ out = {}
149
+ for name in _js.keys(value):
150
+ member = value[name]
151
+ if name == '_sd':
152
+ continue
153
+ if name == '_sd_alg':
154
+ # The fingerprint method is named at the top only, and is SHA-256.
155
+ if path != '' or member != 'sha-256' or not isinstance(member, str):
156
+ raise _bad('"_sd_alg" may stand only at the top, and must be "sha-256".')
157
+ named = True
158
+ continue
159
+ out[name] = walk(member, name if path == '' else f'{path}.{name}', depth + 1)
160
+ if '_sd' in value:
161
+ if not isinstance(value['_sd'], list):
162
+ raise _bad('"_sd" must be a list of fingerprints.')
163
+ place = {'covered': 0, 'revealed': []}
164
+ for digest in value['_sd']:
165
+ found = once(digest)
166
+ if found is None:
167
+ place['covered'] += 1
168
+ continue
169
+ if len(found.parts) != 3:
170
+ raise _bad('the disclosure of a named field must hold a salt, a name and a value.')
171
+ name = found.parts[1]
172
+ if not isinstance(name, str) or name in ('_sd', '...', '_sd_alg'):
173
+ raise _bad('a disclosure names a field that may not be covered.')
174
+ if name in out:
175
+ raise _bad('a disclosure names a field that is already there.')
176
+ found.used = True
177
+ out[name] = walk(found.parts[2], name if path == '' else f'{path}.{name}', depth + 1)
178
+ place['revealed'].append(name)
179
+ places[path] = place
180
+ return out
181
+
182
+ content = walk(payload, '', 0)
183
+ # Step 5: every disclosure handed over must belong to this content.
184
+ for d in given.values():
185
+ if not d.used:
186
+ raise _bad('a disclosure does not belong to this record.')
187
+ return content, places, named