datumesh 0.1.1__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.
- datumesh/__init__.py +101 -0
- datumesh/_errors.py +72 -0
- datumesh/_json.py +221 -0
- datumesh/_keys.py +262 -0
- datumesh/_lexical.py +204 -0
- datumesh/_model.py +306 -0
- datumesh/_ucd15.py +1692 -0
- datumesh/_unicode.py +158 -0
- datumesh/_uri.py +173 -0
- datumesh/_verify.py +176 -0
- datumesh/py.typed +0 -0
- datumesh-0.1.1.dist-info/METADATA +61 -0
- datumesh-0.1.1.dist-info/RECORD +16 -0
- datumesh-0.1.1.dist-info/WHEEL +5 -0
- datumesh-0.1.1.dist-info/licenses/LICENSE +202 -0
- datumesh-0.1.1.dist-info/top_level.txt +1 -0
datumesh/__init__.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""datumesh: verify Datumesh receipts (signed answers for addressable data cells).
|
|
2
|
+
|
|
3
|
+
Implements the Datumesh protocol specification, revision ``spec-0.1.1``
|
|
4
|
+
(https://github.com/datumesh-com/datumesh-spec): RFC 8785 canonical JSON,
|
|
5
|
+
SHA-256 hashes, Ed25519 receipt signatures, cell URIs and key documents.
|
|
6
|
+
Only :func:`fetch_keys` touches the network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import Any, Dict, Optional
|
|
13
|
+
|
|
14
|
+
from . import _model
|
|
15
|
+
from ._errors import ERROR_CODES, DatumeshError
|
|
16
|
+
from ._json import jcs_bytes
|
|
17
|
+
from ._keys import Key, KeysResult, fetch_keys, key_id, keys_from_response
|
|
18
|
+
from ._uri import format_uri, parse_uri, uri_to_https
|
|
19
|
+
from ._verify import VerifyResult, load, payload_bytes, verify
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.1"
|
|
22
|
+
#: The specification revision this package conforms to.
|
|
23
|
+
SPEC_VERSION = "spec-0.1.1"
|
|
24
|
+
|
|
25
|
+
KINDS = ("scalar", "cell", "ref", "function", "receipt")
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"SPEC_VERSION",
|
|
29
|
+
"ERROR_CODES",
|
|
30
|
+
"KINDS",
|
|
31
|
+
"DatumeshError",
|
|
32
|
+
"Key",
|
|
33
|
+
"KeysResult",
|
|
34
|
+
"VerifyResult",
|
|
35
|
+
"canonicalize",
|
|
36
|
+
"cell_hash",
|
|
37
|
+
"fetch_keys",
|
|
38
|
+
"format_uri",
|
|
39
|
+
"function_hash",
|
|
40
|
+
"jcs",
|
|
41
|
+
"key_id",
|
|
42
|
+
"keys_from_response",
|
|
43
|
+
"parse_uri",
|
|
44
|
+
"receipt_hash",
|
|
45
|
+
"receipt_payload",
|
|
46
|
+
"uri_to_https",
|
|
47
|
+
"verify",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validated(obj: Any, kind: Optional[str]) -> Dict[str, Any]:
|
|
52
|
+
value = load(obj)
|
|
53
|
+
if kind is None:
|
|
54
|
+
kind = _model.infer_kind(value)
|
|
55
|
+
if kind is None:
|
|
56
|
+
raise DatumeshError("E_SCHEMA", "cannot tell which kind of Datumesh object this is", "$")
|
|
57
|
+
elif kind not in KINDS:
|
|
58
|
+
raise ValueError(f"kind must be one of {KINDS}, not {kind!r}")
|
|
59
|
+
_model.VALIDATORS[kind](value)
|
|
60
|
+
out: Dict[str, Any] = value
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def canonicalize(obj: Any, kind: Optional[str] = None) -> bytes:
|
|
65
|
+
"""Validate ``obj`` as a Datumesh ``kind`` and return its RFC 8785 (JCS) bytes.
|
|
66
|
+
|
|
67
|
+
``kind`` is ``"scalar"``, ``"cell"``, ``"ref"`` (cell reference),
|
|
68
|
+
``"function"`` (descriptor) or ``"receipt"`` (the full receipt, ``sig``
|
|
69
|
+
included; steps 2-4 of SPEC 10.1 are checked). When omitted it is inferred
|
|
70
|
+
from the members present. ``obj`` is a parsed object or JSON text/bytes,
|
|
71
|
+
which is parsed strictly (SPEC 6.1). Raises :class:`DatumeshError`.
|
|
72
|
+
"""
|
|
73
|
+
return jcs_bytes(_validated(obj, kind))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cell_hash(cell: Any) -> str:
|
|
77
|
+
"""The cell id: SHA-256 of the cell's canonical bytes (SPEC 4.4), in lowercase hex. Slices are allowed."""
|
|
78
|
+
return hashlib.sha256(jcs_bytes(_validated(cell, "cell"))).hexdigest()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def function_hash(descriptor: Any) -> str:
|
|
82
|
+
"""The function hash: SHA-256 of the canonical descriptor (SPEC 7), in lowercase hex."""
|
|
83
|
+
return hashlib.sha256(jcs_bytes(_validated(descriptor, "function"))).hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def receipt_payload(receipt: Any) -> bytes:
|
|
87
|
+
"""The bytes a receipt's signature covers: JCS of the receipt without ``sig`` (SPEC 8.3)."""
|
|
88
|
+
return payload_bytes(_validated(receipt, "receipt"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def receipt_hash(receipt: Any) -> str:
|
|
92
|
+
"""The receipt hash: SHA-256 of the receipt without ``sig`` (SPEC 8.3), in lowercase hex.
|
|
93
|
+
|
|
94
|
+
The receipt is validated (steps 1-4) first; the signature is not checked.
|
|
95
|
+
"""
|
|
96
|
+
return hashlib.sha256(receipt_payload(receipt)).hexdigest()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def jcs(value: Any) -> bytes:
|
|
100
|
+
"""RFC 8785 canonical bytes of any JSON value (parsed, or text parsed strictly). No Datumesh rules."""
|
|
101
|
+
return jcs_bytes(load(value))
|
datumesh/_errors.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Error type and the error codes of SPEC.md 10.2."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import NoReturn, Optional
|
|
6
|
+
|
|
7
|
+
#: Every error code SPEC.md 10.2 defines, in the order of its table.
|
|
8
|
+
ERROR_CODES: tuple[str, ...] = (
|
|
9
|
+
"E_PARSE_JSON",
|
|
10
|
+
"E_PARSE_DUPLICATE_KEY",
|
|
11
|
+
"E_PARSE_URI",
|
|
12
|
+
"E_SCHEMA",
|
|
13
|
+
"E_CANON_NAME",
|
|
14
|
+
"E_CANON_ARG_NAME",
|
|
15
|
+
"E_CANON_ARG_DUPLICATE",
|
|
16
|
+
"E_CANON_ARG_ORDER",
|
|
17
|
+
"E_CANON_PERCENT",
|
|
18
|
+
"E_CANON_TYPE",
|
|
19
|
+
"E_CANON_NUMBER",
|
|
20
|
+
"E_CANON_DATE",
|
|
21
|
+
"E_CANON_INSTANT",
|
|
22
|
+
"E_CANON_STRING",
|
|
23
|
+
"E_CANON_HEX",
|
|
24
|
+
"E_CANON_LICENCE",
|
|
25
|
+
"E_CANON_SET",
|
|
26
|
+
"E_CANON_LIMIT",
|
|
27
|
+
"E_SLICE",
|
|
28
|
+
"E_PUBLISHER_MISMATCH",
|
|
29
|
+
"E_VALID_MISMATCH",
|
|
30
|
+
"E_TIME_ORDER",
|
|
31
|
+
"E_ARG_UNKNOWN",
|
|
32
|
+
"E_ARG_MISSING",
|
|
33
|
+
"E_KEY_UNAVAILABLE",
|
|
34
|
+
"E_KEY_DOC",
|
|
35
|
+
"E_KEY_UNKNOWN",
|
|
36
|
+
"E_KEY_NOT_VALID",
|
|
37
|
+
"E_SIGNATURE",
|
|
38
|
+
"E_FN_UNAVAILABLE",
|
|
39
|
+
"E_FN_HASH_MISMATCH",
|
|
40
|
+
"E_ARG_TYPE",
|
|
41
|
+
"E_RETURN_TYPE",
|
|
42
|
+
"E_INPUT_UNAVAILABLE",
|
|
43
|
+
"E_INPUT_INVALID",
|
|
44
|
+
"E_INPUT_UNDECLARED",
|
|
45
|
+
"E_VALUE_MISMATCH",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DatumeshError(ValueError):
|
|
50
|
+
"""An input broke a rule of the Datumesh specification.
|
|
51
|
+
|
|
52
|
+
``code`` is one of :data:`ERROR_CODES`; ``path`` locates the offending
|
|
53
|
+
member (a JSON path such as ``$.cell.args.base.value``, or ``uri.path`` /
|
|
54
|
+
``uri.query.<name>`` for cell URIs), or is ``None``.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
code: str
|
|
58
|
+
path: Optional[str]
|
|
59
|
+
detail: str
|
|
60
|
+
|
|
61
|
+
def __init__(self, code: str, detail: str = "", path: Optional[str] = None) -> None:
|
|
62
|
+
if code not in ERROR_CODES:
|
|
63
|
+
raise AssertionError(f"unknown error code {code!r}")
|
|
64
|
+
self.code = code
|
|
65
|
+
self.detail = detail
|
|
66
|
+
self.path = path
|
|
67
|
+
where = f" at {path}" if path else ""
|
|
68
|
+
super().__init__(f"{code}{where}: {detail}" if detail else f"{code}{where}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fail(code: str, detail: str, path: Optional[str] = None) -> NoReturn:
|
|
72
|
+
raise DatumeshError(code, detail, path)
|
datumesh/_json.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Strict JSON parsing (SPEC.md 6.1, I-JSON) and RFC 8785 canonical serialisation (SPEC.md 6.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
8
|
+
|
|
9
|
+
from ._errors import DatumeshError, fail
|
|
10
|
+
|
|
11
|
+
JSONText = Union[str, bytes, bytearray, memoryview]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _Duplicate(Exception):
|
|
15
|
+
def __init__(self, key: str) -> None:
|
|
16
|
+
self.key = key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _pairs(pairs: List[Tuple[str, Any]]) -> Dict[str, Any]:
|
|
20
|
+
out: Dict[str, Any] = {}
|
|
21
|
+
for k, v in pairs:
|
|
22
|
+
if k in out:
|
|
23
|
+
raise _Duplicate(k)
|
|
24
|
+
out[k] = v
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _number(literal: str) -> float:
|
|
29
|
+
value = float(literal)
|
|
30
|
+
if math.isinf(value):
|
|
31
|
+
raise ValueError(f"number out of IEEE 754 double range: {literal}")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _constant(name: str) -> Any:
|
|
36
|
+
raise ValueError(f"{name} is not JSON")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _has_surrogate(s: str) -> bool:
|
|
40
|
+
for ch in s:
|
|
41
|
+
if "\ud800" <= ch <= "\udfff":
|
|
42
|
+
return True
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_json(data: JSONText, path: str = "$") -> Any:
|
|
47
|
+
"""Parse JSON text strictly per SPEC.md 6.1.
|
|
48
|
+
|
|
49
|
+
``data`` is bytes (must be UTF-8 without a byte order mark) or str (must not
|
|
50
|
+
start with U+FEFF). Duplicate member names raise E_PARSE_DUPLICATE_KEY; every
|
|
51
|
+
other failure (syntax, lone surrogates, NaN/Infinity, numbers outside the
|
|
52
|
+
double range, invalid UTF-8) raises E_PARSE_JSON. Numbers become floats.
|
|
53
|
+
"""
|
|
54
|
+
if isinstance(data, (bytes, bytearray, memoryview)):
|
|
55
|
+
raw = bytes(data)
|
|
56
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
57
|
+
fail("E_PARSE_JSON", "byte order mark", path)
|
|
58
|
+
try:
|
|
59
|
+
text = raw.decode("utf-8", errors="strict")
|
|
60
|
+
except UnicodeDecodeError as e:
|
|
61
|
+
fail("E_PARSE_JSON", f"invalid UTF-8: {e.reason}", path)
|
|
62
|
+
elif isinstance(data, str):
|
|
63
|
+
text = data
|
|
64
|
+
if text.startswith(""):
|
|
65
|
+
fail("E_PARSE_JSON", "byte order mark", path)
|
|
66
|
+
else:
|
|
67
|
+
raise TypeError(f"JSON text must be str or bytes, not {type(data).__name__}")
|
|
68
|
+
try:
|
|
69
|
+
value = json.loads(
|
|
70
|
+
text,
|
|
71
|
+
object_pairs_hook=_pairs,
|
|
72
|
+
parse_float=_number,
|
|
73
|
+
parse_int=_number,
|
|
74
|
+
parse_constant=_constant,
|
|
75
|
+
)
|
|
76
|
+
except _Duplicate as e:
|
|
77
|
+
fail("E_PARSE_DUPLICATE_KEY", f"duplicate member {e.key!r}", path)
|
|
78
|
+
except RecursionError:
|
|
79
|
+
fail("E_PARSE_JSON", "nesting too deep", path)
|
|
80
|
+
except ValueError as e: # json.JSONDecodeError is a ValueError
|
|
81
|
+
fail("E_PARSE_JSON", str(e), path)
|
|
82
|
+
check_json_value(value, path)
|
|
83
|
+
return value
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_json_value(value: Any, path: str = "$") -> None:
|
|
87
|
+
"""Check that an already-parsed value is I-JSON: dict/list/str/float/int/bool/None only,
|
|
88
|
+
string keys, no lone surrogates, finite numbers within the double range."""
|
|
89
|
+
stack: List[Tuple[Any, str]] = [(value, path)]
|
|
90
|
+
while stack:
|
|
91
|
+
v, p = stack.pop()
|
|
92
|
+
if v is None or isinstance(v, bool):
|
|
93
|
+
continue
|
|
94
|
+
if isinstance(v, str):
|
|
95
|
+
if _has_surrogate(v):
|
|
96
|
+
fail("E_PARSE_JSON", "lone surrogate", p)
|
|
97
|
+
elif isinstance(v, float):
|
|
98
|
+
if not math.isfinite(v):
|
|
99
|
+
fail("E_PARSE_JSON", "NaN and infinities are not JSON", p)
|
|
100
|
+
elif isinstance(v, int):
|
|
101
|
+
try:
|
|
102
|
+
float(v)
|
|
103
|
+
except OverflowError:
|
|
104
|
+
fail("E_PARSE_JSON", "number out of IEEE 754 double range", p)
|
|
105
|
+
elif isinstance(v, dict):
|
|
106
|
+
for k, item in v.items():
|
|
107
|
+
if not isinstance(k, str):
|
|
108
|
+
fail("E_PARSE_JSON", f"member name {k!r} is not a string", p)
|
|
109
|
+
if _has_surrogate(k):
|
|
110
|
+
fail("E_PARSE_JSON", "lone surrogate in member name", p)
|
|
111
|
+
stack.append((item, f"{p}.{k}"))
|
|
112
|
+
elif isinstance(v, list):
|
|
113
|
+
for i, item in enumerate(v):
|
|
114
|
+
stack.append((item, f"{p}[{i}]"))
|
|
115
|
+
else:
|
|
116
|
+
fail("E_PARSE_JSON", f"{type(v).__name__} is not a JSON value", p)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------- RFC 8785
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _escape(s: str) -> str:
|
|
123
|
+
out = ['"']
|
|
124
|
+
for ch in s:
|
|
125
|
+
o = ord(ch)
|
|
126
|
+
if ch == '"':
|
|
127
|
+
out.append('\\"')
|
|
128
|
+
elif ch == "\\":
|
|
129
|
+
out.append("\\\\")
|
|
130
|
+
elif o < 0x20:
|
|
131
|
+
out.append({"\b": "\\b", "\t": "\\t", "\n": "\\n", "\f": "\\f", "\r": "\\r"}.get(ch, "\\u%04x" % o))
|
|
132
|
+
else:
|
|
133
|
+
out.append(ch)
|
|
134
|
+
out.append('"')
|
|
135
|
+
return "".join(out)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_number(value: float) -> str:
|
|
139
|
+
"""ECMAScript Number.prototype.toString for a finite double (RFC 8785 3.2.2.3)."""
|
|
140
|
+
if not math.isfinite(value):
|
|
141
|
+
raise DatumeshError("E_PARSE_JSON", "NaN and infinities have no JSON form")
|
|
142
|
+
if value == 0:
|
|
143
|
+
return "0"
|
|
144
|
+
sign = "-" if value < 0 else ""
|
|
145
|
+
# repr() gives the shortest digit string that round-trips, choosing the closest
|
|
146
|
+
# when several exist: the same digits ECMAScript's algorithm selects.
|
|
147
|
+
mantissa, _, exp = repr(abs(value)).partition("e")
|
|
148
|
+
int_part, _, frac_part = mantissa.partition(".")
|
|
149
|
+
all_digits = int_part + frac_part
|
|
150
|
+
# value = 0.<all_digits> * 10**point
|
|
151
|
+
point = len(int_part) + (int(exp) if exp else 0)
|
|
152
|
+
leading = len(all_digits) - len(all_digits.lstrip("0"))
|
|
153
|
+
digits = all_digits[leading:].rstrip("0")
|
|
154
|
+
n = point - leading # ECMAScript's n: value = 0.<digits> * 10**n
|
|
155
|
+
k = len(digits)
|
|
156
|
+
if k <= n <= 21:
|
|
157
|
+
s = digits + "0" * (n - k)
|
|
158
|
+
elif 0 < n <= 21:
|
|
159
|
+
s = digits[:n] + "." + digits[n:]
|
|
160
|
+
elif -6 < n <= 0:
|
|
161
|
+
s = "0." + "0" * (-n) + digits
|
|
162
|
+
else:
|
|
163
|
+
e = n - 1
|
|
164
|
+
es = ("+" if e >= 0 else "-") + str(abs(e))
|
|
165
|
+
s = digits[0] + ("." + digits[1:] if k > 1 else "") + "e" + es
|
|
166
|
+
return sign + s
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _utf16_key(s: str) -> bytes:
|
|
170
|
+
return s.encode("utf-16-be", errors="surrogatepass")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _serialise(value: Any, out: List[str]) -> None:
|
|
174
|
+
if value is None:
|
|
175
|
+
out.append("null")
|
|
176
|
+
elif value is True:
|
|
177
|
+
out.append("true")
|
|
178
|
+
elif value is False:
|
|
179
|
+
out.append("false")
|
|
180
|
+
elif isinstance(value, str):
|
|
181
|
+
out.append(_escape(value))
|
|
182
|
+
elif isinstance(value, float):
|
|
183
|
+
out.append(format_number(value))
|
|
184
|
+
elif isinstance(value, int):
|
|
185
|
+
out.append(format_number(float(value)))
|
|
186
|
+
elif isinstance(value, dict):
|
|
187
|
+
out.append("{")
|
|
188
|
+
first = True
|
|
189
|
+
for k in sorted(value, key=_utf16_key):
|
|
190
|
+
if not first:
|
|
191
|
+
out.append(",")
|
|
192
|
+
first = False
|
|
193
|
+
out.append(_escape(k))
|
|
194
|
+
out.append(":")
|
|
195
|
+
_serialise(value[k], out)
|
|
196
|
+
out.append("}")
|
|
197
|
+
elif isinstance(value, list):
|
|
198
|
+
out.append("[")
|
|
199
|
+
for i, item in enumerate(value):
|
|
200
|
+
if i:
|
|
201
|
+
out.append(",")
|
|
202
|
+
_serialise(item, out)
|
|
203
|
+
out.append("]")
|
|
204
|
+
else:
|
|
205
|
+
raise DatumeshError("E_PARSE_JSON", f"{type(value).__name__} is not a JSON value")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def jcs_bytes(value: Any) -> bytes:
|
|
209
|
+
"""RFC 8785 serialisation of an I-JSON value (checked first), as UTF-8 bytes."""
|
|
210
|
+
check_json_value(value)
|
|
211
|
+
out: List[str] = []
|
|
212
|
+
try:
|
|
213
|
+
_serialise(value, out)
|
|
214
|
+
except RecursionError:
|
|
215
|
+
fail("E_PARSE_JSON", "nesting too deep")
|
|
216
|
+
return "".join(out).encode("utf-8")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def sort_key(name: str) -> bytes:
|
|
220
|
+
"""The JCS member order (UTF-16 code units)."""
|
|
221
|
+
return _utf16_key(name)
|
datumesh/_keys.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Keys (SPEC.md section 9): key ids, key documents, and their retrieval over HTTPS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import socket
|
|
7
|
+
import ssl
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, Optional, Tuple, Union
|
|
12
|
+
|
|
13
|
+
from . import _lexical as lx
|
|
14
|
+
from ._errors import DatumeshError, fail
|
|
15
|
+
from ._json import parse_json
|
|
16
|
+
from ._model import PROTO, require_object
|
|
17
|
+
|
|
18
|
+
WELL_KNOWN_PATH = "/.well-known/datumesh.json"
|
|
19
|
+
MAX_DOCUMENT_BYTES = 1 << 20
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def key_id(public: str) -> str:
|
|
23
|
+
"""The key id of an Ed25519 public key given as 64 lowercase hex digits (SPEC 9.1)."""
|
|
24
|
+
if not isinstance(public, str):
|
|
25
|
+
raise TypeError("public must be a str of 64 lowercase hex digits")
|
|
26
|
+
lx.check_hex(public, 64, "public")
|
|
27
|
+
return hashlib.sha256(bytes.fromhex(public)).hexdigest()[:16]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Key:
|
|
32
|
+
"""One published key. ``valid_from``/``valid_to`` are instants, or None for keys
|
|
33
|
+
supplied as a bare ``{key id: public key}`` mapping (no validity window)."""
|
|
34
|
+
|
|
35
|
+
id: str
|
|
36
|
+
public: str
|
|
37
|
+
valid_from: Optional[str] = None
|
|
38
|
+
valid_to: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
def usable_at(self, issued: str) -> bool:
|
|
41
|
+
"""SPEC 9.5: ``valid_from <= issued`` and, if present, ``issued < valid_to``."""
|
|
42
|
+
if self.valid_from is not None and not self.valid_from <= issued:
|
|
43
|
+
return False
|
|
44
|
+
return self.valid_to is None or issued < self.valid_to
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class KeysResult:
|
|
49
|
+
"""The outcome of resolving a host's keys (SPEC 9.3).
|
|
50
|
+
|
|
51
|
+
``ok`` is True and ``code`` is ``"OK"`` when ``keys`` holds the host's
|
|
52
|
+
validated keys; otherwise ``code`` is ``E_KEY_UNAVAILABLE`` or ``E_KEY_DOC``
|
|
53
|
+
and ``keys`` is empty. ``host`` is the host the keys belong to.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
ok: bool
|
|
57
|
+
code: str
|
|
58
|
+
host: Optional[str]
|
|
59
|
+
detail: str = ""
|
|
60
|
+
keys: Tuple[Key, ...] = field(default_factory=tuple)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def key_ids(self) -> Tuple[str, ...]:
|
|
64
|
+
return tuple(k.id for k in self.keys)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _doc_error(host: Optional[str], detail: str) -> KeysResult:
|
|
68
|
+
return KeysResult(False, "E_KEY_DOC", host, detail)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def validate_key_document(doc: Any, host: Optional[str]) -> KeysResult:
|
|
72
|
+
"""Check a parsed key document against SPEC 9.2. ``host`` is the host it was
|
|
73
|
+
fetched from (None: take the document's own ``host`` member)."""
|
|
74
|
+
try:
|
|
75
|
+
require_object(doc, "$", ("host", "keys", "proto"))
|
|
76
|
+
if doc["proto"] != PROTO:
|
|
77
|
+
fail("E_SCHEMA", f"proto must be {PROTO!r}", "$.proto")
|
|
78
|
+
doc_host = doc["host"]
|
|
79
|
+
if not isinstance(doc_host, str):
|
|
80
|
+
fail("E_SCHEMA", "host must be a string", "$.host")
|
|
81
|
+
lx.check_dns_name(doc_host, "$.host")
|
|
82
|
+
if host is not None and doc_host != host:
|
|
83
|
+
fail("E_SCHEMA", f"document is for host {doc_host!r}, fetched from {host!r}", "$.host")
|
|
84
|
+
keys = doc["keys"]
|
|
85
|
+
if not isinstance(keys, list) or not 1 <= len(keys) <= lx.LIMIT_KEYS:
|
|
86
|
+
fail("E_SCHEMA", f"keys must be an array of 1 to {lx.LIMIT_KEYS} keys", "$.keys")
|
|
87
|
+
out = []
|
|
88
|
+
seen_ids: set[str] = set()
|
|
89
|
+
seen_public: set[str] = set()
|
|
90
|
+
for i, entry in enumerate(keys):
|
|
91
|
+
p = f"$.keys[{i}]"
|
|
92
|
+
require_object(entry, p, ("alg", "id", "public", "valid_from"), ("valid_to",))
|
|
93
|
+
for name in ("alg", "id", "public", "valid_from", "valid_to"):
|
|
94
|
+
if name in entry and not isinstance(entry[name], str):
|
|
95
|
+
fail("E_SCHEMA", f"{name} must be a string", f"{p}.{name}")
|
|
96
|
+
if entry["alg"] != "ed25519":
|
|
97
|
+
fail("E_SCHEMA", "alg must be 'ed25519'", f"{p}.alg")
|
|
98
|
+
lx.check_hex(entry["id"], 16, f"{p}.id")
|
|
99
|
+
lx.check_hex(entry["public"], 64, f"{p}.public")
|
|
100
|
+
if key_id(entry["public"]) != entry["id"]:
|
|
101
|
+
fail("E_SCHEMA", "id is not the key id of public", f"{p}.id")
|
|
102
|
+
lx.check_instant(entry["valid_from"], f"{p}.valid_from")
|
|
103
|
+
valid_to = entry.get("valid_to")
|
|
104
|
+
if valid_to is not None:
|
|
105
|
+
lx.check_instant(valid_to, f"{p}.valid_to")
|
|
106
|
+
if not valid_to > entry["valid_from"]:
|
|
107
|
+
fail("E_SCHEMA", "valid_to must be later than valid_from", f"{p}.valid_to")
|
|
108
|
+
if entry["id"] in seen_ids or entry["public"] in seen_public:
|
|
109
|
+
fail("E_SCHEMA", "the same key appears twice", p)
|
|
110
|
+
seen_ids.add(entry["id"])
|
|
111
|
+
seen_public.add(entry["public"])
|
|
112
|
+
out.append(Key(entry["id"], entry["public"], entry["valid_from"], valid_to))
|
|
113
|
+
return KeysResult(True, "OK", doc_host, "", tuple(out))
|
|
114
|
+
except DatumeshError as e:
|
|
115
|
+
where = f" at {e.path}" if e.path else ""
|
|
116
|
+
return _doc_error(host if host is not None else (doc.get("host") if isinstance(doc, dict) and isinstance(doc.get("host"), str) else None),
|
|
117
|
+
f"{e.code}{where}: {e.detail}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def keys_from_response(host: str, status: Optional[int], body: Union[str, bytes, None]) -> KeysResult:
|
|
121
|
+
"""Interpret the response to ``GET https://<host>/.well-known/datumesh.json`` (SPEC 9.3).
|
|
122
|
+
|
|
123
|
+
``status`` is the HTTP status, or None for a network, DNS, TLS or timeout
|
|
124
|
+
failure. Any status other than 200 (redirects and 404 included) is
|
|
125
|
+
E_KEY_UNAVAILABLE; a 200 whose body is not a valid key document for
|
|
126
|
+
``host`` is E_KEY_DOC. Never raises.
|
|
127
|
+
"""
|
|
128
|
+
if not isinstance(host, str):
|
|
129
|
+
return KeysResult(False, "E_KEY_UNAVAILABLE", None, f"host must be a str, not {type(host).__name__}")
|
|
130
|
+
try:
|
|
131
|
+
lx.check_dns_name(host, "host")
|
|
132
|
+
except DatumeshError as e:
|
|
133
|
+
return KeysResult(False, "E_KEY_UNAVAILABLE", host, f"cannot retrieve keys for an invalid host: {e.detail}")
|
|
134
|
+
if status is None:
|
|
135
|
+
return KeysResult(False, "E_KEY_UNAVAILABLE", host, "network, DNS, TLS or timeout failure")
|
|
136
|
+
if isinstance(status, bool) or not isinstance(status, int) or status != 200:
|
|
137
|
+
return KeysResult(False, "E_KEY_UNAVAILABLE", host, f"HTTP status {status!r}")
|
|
138
|
+
if body is None:
|
|
139
|
+
return _doc_error(host, "200 response without a body")
|
|
140
|
+
if not isinstance(body, (str, bytes, bytearray, memoryview)):
|
|
141
|
+
return _doc_error(host, f"body must be str or bytes, not {type(body).__name__}")
|
|
142
|
+
try:
|
|
143
|
+
doc = parse_json(body)
|
|
144
|
+
except DatumeshError as e:
|
|
145
|
+
return _doc_error(host, f"{e.code}: {e.detail}")
|
|
146
|
+
return validate_key_document(doc, host)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
150
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
|
151
|
+
return None # urllib then raises HTTPError with the 3xx status
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _fetch_url(url: str, timeout: float, context: Optional[ssl.SSLContext] = None) -> Tuple[Optional[int], Optional[bytes]]:
|
|
155
|
+
"""One GET without following redirects. Returns (status, body); status None on any failure."""
|
|
156
|
+
ctx = context if context is not None else ssl.create_default_context()
|
|
157
|
+
opener = urllib.request.build_opener(_NoRedirect(), urllib.request.HTTPSHandler(context=ctx))
|
|
158
|
+
req = urllib.request.Request(url, method="GET", headers={
|
|
159
|
+
"Accept": "application/json",
|
|
160
|
+
"User-Agent": "datumesh-py",
|
|
161
|
+
})
|
|
162
|
+
try:
|
|
163
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
164
|
+
body = resp.read(MAX_DOCUMENT_BYTES + 1)
|
|
165
|
+
return resp.status, body
|
|
166
|
+
except urllib.error.HTTPError as e:
|
|
167
|
+
try:
|
|
168
|
+
e.close()
|
|
169
|
+
except Exception: # pragma: no cover
|
|
170
|
+
pass
|
|
171
|
+
return e.code, None
|
|
172
|
+
except (urllib.error.URLError, ssl.SSLError, socket.timeout, TimeoutError, ConnectionError, OSError, ValueError):
|
|
173
|
+
return None, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def fetch_keys(host: str, timeout: float = 10.0) -> KeysResult:
|
|
177
|
+
"""Fetch and validate ``https://<host>/.well-known/datumesh.json`` (SPEC 9.3, 9.4).
|
|
178
|
+
|
|
179
|
+
One GET; the TLS certificate is verified against the system trust store;
|
|
180
|
+
redirects are not followed (a 3xx is E_KEY_UNAVAILABLE); ``timeout`` is in
|
|
181
|
+
seconds. This is the only function in the package that uses the network,
|
|
182
|
+
and nothing else calls it.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(host, str):
|
|
185
|
+
raise TypeError(f"host must be a str, not {type(host).__name__}")
|
|
186
|
+
try:
|
|
187
|
+
lx.check_dns_name(host, "host")
|
|
188
|
+
except DatumeshError as e:
|
|
189
|
+
return KeysResult(False, "E_KEY_UNAVAILABLE", host, f"cannot retrieve keys for an invalid host: {e.detail}")
|
|
190
|
+
status, body = _fetch_url(f"https://{host}{WELL_KNOWN_PATH}", timeout)
|
|
191
|
+
if status == 200 and body is not None and len(body) > MAX_DOCUMENT_BYTES:
|
|
192
|
+
return _doc_error(host, f"key document larger than {MAX_DOCUMENT_BYTES} bytes")
|
|
193
|
+
return keys_from_response(host, status, body)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------- key sources for verify
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class _Source:
|
|
201
|
+
host: Optional[str] # None: covers every host
|
|
202
|
+
result: KeysResult
|
|
203
|
+
by_id: Dict[str, Key]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _source_from_result(r: KeysResult) -> _Source:
|
|
207
|
+
return _Source(r.host, r, {k.id: k for k in r.keys})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _source_from_mapping(m: Dict[Any, Any]) -> _Source:
|
|
211
|
+
keys = []
|
|
212
|
+
for kid in sorted(m, key=str):
|
|
213
|
+
pub = m[kid]
|
|
214
|
+
if not isinstance(kid, str) or not isinstance(pub, str):
|
|
215
|
+
return _source_from_result(_doc_error(None, "a key mapping maps key id strings to public key strings"))
|
|
216
|
+
try:
|
|
217
|
+
lx.check_hex(kid, 16, kid)
|
|
218
|
+
if key_id(pub) != kid:
|
|
219
|
+
fail("E_SCHEMA", f"{kid} is not the key id of {pub}", kid)
|
|
220
|
+
except DatumeshError as e:
|
|
221
|
+
return _source_from_result(_doc_error(None, f"bad key mapping entry {kid!r}: {e.detail}"))
|
|
222
|
+
keys.append(Key(kid, pub))
|
|
223
|
+
r = KeysResult(True, "OK", None, "key id mapping (no validity windows, any host)", tuple(keys))
|
|
224
|
+
return _source_from_result(r)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def key_sources(keys: Any) -> Tuple[_Source, ...]:
|
|
228
|
+
"""Normalise the ``keys`` argument of verify. Raises TypeError only for argument types it cannot accept."""
|
|
229
|
+
out = []
|
|
230
|
+
|
|
231
|
+
def add(item: Any, depth: int) -> None:
|
|
232
|
+
if isinstance(item, KeysResult):
|
|
233
|
+
out.append(_source_from_result(item))
|
|
234
|
+
elif isinstance(item, dict):
|
|
235
|
+
if any(k in item for k in ("host", "keys", "proto")):
|
|
236
|
+
out.append(_source_from_result(validate_key_document(item, None)))
|
|
237
|
+
else:
|
|
238
|
+
out.append(_source_from_mapping(item))
|
|
239
|
+
elif isinstance(item, (list, tuple)) and depth == 0:
|
|
240
|
+
for x in item:
|
|
241
|
+
add(x, 1)
|
|
242
|
+
else:
|
|
243
|
+
raise TypeError("keys must be a key document (dict), a KeysResult, a {key id: public key} dict, "
|
|
244
|
+
f"or a list of those; got {type(item).__name__}")
|
|
245
|
+
|
|
246
|
+
add(keys, 0)
|
|
247
|
+
return tuple(out)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def find_key(sources: Tuple[_Source, ...], host: str, kid: str, issued: str) -> Key:
|
|
251
|
+
"""SPEC 10.1 step 5. Raises DatumeshError with a key code."""
|
|
252
|
+
for s in sources:
|
|
253
|
+
if s.host is None or s.host == host:
|
|
254
|
+
if not s.result.ok:
|
|
255
|
+
fail(s.result.code, f"keys for {host}: {s.result.detail}", "$.computed_by")
|
|
256
|
+
key = s.by_id.get(kid)
|
|
257
|
+
if key is None:
|
|
258
|
+
fail("E_KEY_UNKNOWN", f"{host} publishes no key {kid}", "$.computed_by")
|
|
259
|
+
if not key.usable_at(issued):
|
|
260
|
+
fail("E_KEY_NOT_VALID", "issued is outside the key's validity window", "$.issued")
|
|
261
|
+
return key
|
|
262
|
+
fail("E_KEY_UNAVAILABLE", f"no keys supplied for host {host}", "$.computed_by")
|