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/__init__.py +60 -0
- provared/_js.py +450 -0
- provared/actions.py +84 -0
- provared/approval.py +108 -0
- provared/blockstamp.py +223 -0
- provared/book.py +2320 -0
- provared/check.py +21 -0
- provared/compare.py +266 -0
- provared/cover.py +187 -0
- provared/der.py +230 -0
- provared/encoding.py +278 -0
- provared/fields.py +95 -0
- provared/guard.py +177 -0
- provared/headers.py +507 -0
- provared/jws.py +164 -0
- provared/keys.py +134 -0
- provared/pass_.py +86 -0
- provared/recorder.py +1426 -0
- provared/seal.py +133 -0
- provared/service.py +138 -0
- provared/signatures.py +149 -0
- provared/slip.py +413 -0
- provared/standing.py +198 -0
- provared/stub.py +131 -0
- provared/timestamp.py +456 -0
- provared/tools.py +415 -0
- provared/tree.py +148 -0
- provared/webauthn.py +107 -0
- provared-0.2.0.dist-info/METADATA +176 -0
- provared-0.2.0.dist-info/RECORD +34 -0
- provared-0.2.0.dist-info/WHEEL +5 -0
- provared-0.2.0.dist-info/licenses/LICENSE +202 -0
- provared-0.2.0.dist-info/licenses/LICENSE-CC-BY-4.0.txt +396 -0
- provared-0.2.0.dist-info/top_level.txt +1 -0
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
|