sci-protocol 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.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
pysci/png.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""PNG display files in baseline publications (protocol §2.1) and the §6.2 decode check.
|
|
2
|
+
|
|
3
|
+
:class:`PngStructure` is the required baseline check. It streams, keeps a few
|
|
4
|
+
bytes of state, and does not verify CRCs or decode. :func:`png_decode_problem`
|
|
5
|
+
is the additional check that §6.2 recommends for publishing tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
import zlib
|
|
12
|
+
from typing import Final
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DISPLAY_MAX_BYTES",
|
|
16
|
+
"DISPLAY_MAX_DIMENSION",
|
|
17
|
+
"DISPLAY_MAX_PIXELS",
|
|
18
|
+
"PngStructure",
|
|
19
|
+
"display_problem",
|
|
20
|
+
"is_display_candidate",
|
|
21
|
+
"png_decode_problem",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
DISPLAY_MAX_BYTES: Final = 2 * 1024 * 1024
|
|
25
|
+
DISPLAY_MAX_PIXELS: Final = 8_000_000
|
|
26
|
+
DISPLAY_MAX_DIMENSION: Final = 8192
|
|
27
|
+
|
|
28
|
+
_SIGNATURE: Final = b"\x89PNG\r\n\x1a\n"
|
|
29
|
+
_ANIMATION: Final = frozenset({b"acTL", b"fcTL", b"fdAT"})
|
|
30
|
+
_CRITICAL: Final = frozenset({b"IHDR", b"PLTE", b"IDAT", b"IEND"})
|
|
31
|
+
_DEPTHS: Final = {0: (1, 2, 4, 8, 16), 2: (8, 16), 3: (1, 2, 4, 8), 4: (8, 16), 6: (8, 16)}
|
|
32
|
+
_CHANNELS: Final = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
|
|
33
|
+
_ADAM7: Final = (
|
|
34
|
+
(0, 0, 8, 8),
|
|
35
|
+
(4, 0, 8, 8),
|
|
36
|
+
(0, 4, 4, 8),
|
|
37
|
+
(2, 0, 4, 4),
|
|
38
|
+
(0, 2, 2, 4),
|
|
39
|
+
(1, 0, 2, 2),
|
|
40
|
+
(0, 1, 1, 2),
|
|
41
|
+
)
|
|
42
|
+
_STAGE_SIGNATURE, _STAGE_HEADER, _STAGE_CHUNKS, _STAGE_ENDED = range(4)
|
|
43
|
+
_NEEDED: Final = {_STAGE_SIGNATURE: 8, _STAGE_HEADER: 16, _STAGE_CHUNKS: 8}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _has_png_extension(path: str) -> bool:
|
|
47
|
+
return path[-4:].lower() == ".png"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_display_candidate(path: str, size: int) -> bool:
|
|
51
|
+
"""True if the path and size allow a baseline display file."""
|
|
52
|
+
return _has_png_extension(path) and size <= DISPLAY_MAX_BYTES
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PngStructure:
|
|
56
|
+
"""Streaming §2.1 checks: signature, chunk layout, ``IHDR`` dimensions, no animation."""
|
|
57
|
+
|
|
58
|
+
__slots__ = ("_failure", "_pending", "_skip", "_stage")
|
|
59
|
+
|
|
60
|
+
def __init__(self) -> None:
|
|
61
|
+
self._stage = _STAGE_SIGNATURE
|
|
62
|
+
self._pending = bytearray()
|
|
63
|
+
self._skip = 0
|
|
64
|
+
self._failure: str | None = None
|
|
65
|
+
|
|
66
|
+
def feed(self, data: bytes) -> None:
|
|
67
|
+
offset = 0
|
|
68
|
+
while offset < len(data) and self._failure is None:
|
|
69
|
+
if self._skip:
|
|
70
|
+
step = min(self._skip, len(data) - offset)
|
|
71
|
+
self._skip -= step
|
|
72
|
+
offset += step
|
|
73
|
+
elif self._stage == _STAGE_ENDED:
|
|
74
|
+
self._failure = "bytes follow the IEND chunk"
|
|
75
|
+
else:
|
|
76
|
+
needed = _NEEDED[self._stage]
|
|
77
|
+
step = min(needed - len(self._pending), len(data) - offset)
|
|
78
|
+
self._pending += data[offset : offset + step]
|
|
79
|
+
offset += step
|
|
80
|
+
if len(self._pending) == needed:
|
|
81
|
+
self._consume(bytes(self._pending))
|
|
82
|
+
self._pending.clear()
|
|
83
|
+
|
|
84
|
+
def _consume(self, header: bytes) -> None:
|
|
85
|
+
if self._stage == _STAGE_SIGNATURE:
|
|
86
|
+
if header != _SIGNATURE:
|
|
87
|
+
self._failure = "the file does not start with the PNG signature"
|
|
88
|
+
self._stage = _STAGE_HEADER
|
|
89
|
+
return
|
|
90
|
+
size, kind = struct.unpack_from(">I4s", header)
|
|
91
|
+
if kind in _ANIMATION:
|
|
92
|
+
self._failure = "animated PNG chunks are not allowed"
|
|
93
|
+
elif self._stage == _STAGE_HEADER:
|
|
94
|
+
if kind != b"IHDR" or size != 13:
|
|
95
|
+
self._failure = "the first chunk must be IHDR with 13 data bytes"
|
|
96
|
+
return
|
|
97
|
+
width, height = struct.unpack_from(">II", header, 8)
|
|
98
|
+
if not (
|
|
99
|
+
1 <= width <= DISPLAY_MAX_DIMENSION
|
|
100
|
+
and 1 <= height <= DISPLAY_MAX_DIMENSION
|
|
101
|
+
and width * height <= DISPLAY_MAX_PIXELS
|
|
102
|
+
):
|
|
103
|
+
self._failure = f"{width} x {height} pixels is outside the display limits"
|
|
104
|
+
return
|
|
105
|
+
self._stage = _STAGE_CHUNKS
|
|
106
|
+
self._skip = size - 8 + 4
|
|
107
|
+
elif kind == b"IHDR":
|
|
108
|
+
self._failure = "only one IHDR chunk is allowed"
|
|
109
|
+
elif kind == b"IEND":
|
|
110
|
+
if size:
|
|
111
|
+
self._failure = "the IEND chunk must be empty"
|
|
112
|
+
self._stage = _STAGE_ENDED
|
|
113
|
+
self._skip = 4
|
|
114
|
+
else:
|
|
115
|
+
self._skip = size + 4
|
|
116
|
+
|
|
117
|
+
def problem(self) -> str | None:
|
|
118
|
+
"""Why the bytes fed so far fail the checks, or None if they pass."""
|
|
119
|
+
if self._failure is not None:
|
|
120
|
+
return self._failure
|
|
121
|
+
if self._stage != _STAGE_ENDED or self._skip or self._pending:
|
|
122
|
+
return "the file ends before a complete IEND chunk"
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def display_problem(path: str, size: int, structure: PngStructure | None) -> str | None:
|
|
127
|
+
"""Why a display file is outside the baseline, or None if it meets §2.1."""
|
|
128
|
+
if not _has_png_extension(path):
|
|
129
|
+
return "the extension is not .png"
|
|
130
|
+
if size > DISPLAY_MAX_BYTES:
|
|
131
|
+
return f"{size:,} bytes exceeds {DISPLAY_MAX_BYTES:,}"
|
|
132
|
+
if structure is None:
|
|
133
|
+
return "the file was not checked"
|
|
134
|
+
return structure.problem()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _expected_scanlines(width: int, height: int, depth: int, color: int, interlaced: bool) -> int:
|
|
138
|
+
channels = _CHANNELS[color]
|
|
139
|
+
total = 0
|
|
140
|
+
for x, y, dx, dy in _ADAM7 if interlaced else ((0, 0, 1, 1),):
|
|
141
|
+
columns = (width - x + dx - 1) // dx
|
|
142
|
+
rows = (height - y + dy - 1) // dy
|
|
143
|
+
if columns > 0 and rows > 0:
|
|
144
|
+
total += ((columns * depth * channels + 7) // 8 + 1) * rows
|
|
145
|
+
return total
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def png_decode_problem(data: bytes) -> str | None:
|
|
149
|
+
"""Why a PNG that passed :class:`PngStructure` cannot be decoded, or None (§6.2).
|
|
150
|
+
|
|
151
|
+
Checks CRCs, ``IHDR`` fields, required chunks, and the exact inflated size.
|
|
152
|
+
Inflated data is bounded by the §2.1 pixel limit.
|
|
153
|
+
"""
|
|
154
|
+
offset = 8
|
|
155
|
+
idat = bytearray()
|
|
156
|
+
idat_closed = False
|
|
157
|
+
palette = False
|
|
158
|
+
width = height = depth = color = interlace = 0
|
|
159
|
+
while offset < len(data):
|
|
160
|
+
size, kind = struct.unpack_from(">I4s", data, offset)
|
|
161
|
+
body = data[offset + 8 : offset + 8 + size]
|
|
162
|
+
stored = struct.unpack_from(">I", data, offset + 8 + size)[0]
|
|
163
|
+
if zlib.crc32(kind + body) != stored:
|
|
164
|
+
return f"chunk {kind.decode('latin-1')} has a bad CRC"
|
|
165
|
+
if not kind[0] & 0x20 and kind not in _CRITICAL:
|
|
166
|
+
return f"unknown critical chunk {kind.decode('latin-1')}"
|
|
167
|
+
if kind == b"IHDR":
|
|
168
|
+
width, height, depth, color, compression, filtering, interlace = struct.unpack(
|
|
169
|
+
">IIBBBBB", body
|
|
170
|
+
)
|
|
171
|
+
if depth not in _DEPTHS.get(color, ()):
|
|
172
|
+
return f"invalid color type {color} with bit depth {depth}"
|
|
173
|
+
if compression or filtering or interlace > 1:
|
|
174
|
+
return "unsupported IHDR compression, filter, or interlace method"
|
|
175
|
+
elif kind == b"PLTE":
|
|
176
|
+
palette = True
|
|
177
|
+
elif kind == b"IDAT":
|
|
178
|
+
if idat_closed:
|
|
179
|
+
return "IDAT chunks must be consecutive"
|
|
180
|
+
idat += body
|
|
181
|
+
elif idat:
|
|
182
|
+
idat_closed = True
|
|
183
|
+
offset += 12 + size
|
|
184
|
+
if color == 3 and not palette:
|
|
185
|
+
return "palette image without a PLTE chunk"
|
|
186
|
+
if not idat:
|
|
187
|
+
return "no IDAT chunk"
|
|
188
|
+
expected = _expected_scanlines(width, height, depth, color, interlace == 1)
|
|
189
|
+
inflater = zlib.decompressobj()
|
|
190
|
+
try:
|
|
191
|
+
pixels = inflater.decompress(bytes(idat), expected + 1)
|
|
192
|
+
extra = inflater.decompress(inflater.unconsumed_tail, 1) if len(pixels) == expected else b""
|
|
193
|
+
except zlib.error:
|
|
194
|
+
return "image data is not a valid zlib stream"
|
|
195
|
+
if len(pixels) != expected or extra:
|
|
196
|
+
return "image data does not match the image dimensions"
|
|
197
|
+
if not inflater.eof or inflater.unused_data:
|
|
198
|
+
return "image data continues after the end of the zlib stream"
|
|
199
|
+
return _filter_problem(pixels, width, height, depth, color, interlace == 1)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _filter_problem(
|
|
203
|
+
pixels: bytes, width: int, height: int, depth: int, color: int, interlaced: bool
|
|
204
|
+
) -> str | None:
|
|
205
|
+
channels = _CHANNELS[color]
|
|
206
|
+
position = 0
|
|
207
|
+
for x, y, dx, dy in _ADAM7 if interlaced else ((0, 0, 1, 1),):
|
|
208
|
+
columns = (width - x + dx - 1) // dx
|
|
209
|
+
rows = (height - y + dy - 1) // dy
|
|
210
|
+
if columns <= 0 or rows <= 0:
|
|
211
|
+
continue
|
|
212
|
+
stride = (columns * depth * channels + 7) // 8 + 1
|
|
213
|
+
for _ in range(rows):
|
|
214
|
+
if pixels[position] > 4:
|
|
215
|
+
return "a scanline uses an unknown filter"
|
|
216
|
+
position += stride
|
|
217
|
+
return None
|
pysci/py.typed
ADDED
|
File without changes
|
pysci/records.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Signed key records: claims (§5.2.1, §6.6) and revocations (§5.3, §6.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
10
|
+
|
|
11
|
+
from . import ed25519
|
|
12
|
+
from .accounts import Account, AccountType, account_problem
|
|
13
|
+
from .errors import Invalid, Issue, IssueCollector, Outcome, SciError
|
|
14
|
+
from .jsonstrict import JsonObject, json_type_name, parse_json_object
|
|
15
|
+
from .keys import PublicKey, parse_public_jwk, public_key_of
|
|
16
|
+
from .rfc3339 import format_rfc3339, parse_rfc3339
|
|
17
|
+
from .schemas import CLAIM_SCHEMA, REVOCATION_SCHEMA, check_schema
|
|
18
|
+
from .text import check_text
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_CLAIM_VALIDITY",
|
|
22
|
+
"REVOCATION_FILES",
|
|
23
|
+
"Claim",
|
|
24
|
+
"RecordVerification",
|
|
25
|
+
"SignedRecord",
|
|
26
|
+
"create_claim",
|
|
27
|
+
"create_revocation",
|
|
28
|
+
"parse_claim",
|
|
29
|
+
"parse_revocation",
|
|
30
|
+
"verify_claim",
|
|
31
|
+
"verify_revocation",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
DEFAULT_CLAIM_VALIDITY = timedelta(days=90)
|
|
35
|
+
REVOCATION_FILES = ("revocation.json", "revocation.json.sig", "revocation.json.sig.tsr")
|
|
36
|
+
MAX_NOTE = 2500
|
|
37
|
+
MAX_NAME = 80
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class SignedRecord:
|
|
42
|
+
"""Exact record bytes and the raw 64-byte signature over them."""
|
|
43
|
+
|
|
44
|
+
record: bytes
|
|
45
|
+
signature: bytes
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Claim:
|
|
50
|
+
"""A parsed ``sci-key-claim/0.1`` record (§5.2.1)."""
|
|
51
|
+
|
|
52
|
+
public_key: PublicKey
|
|
53
|
+
account: Account
|
|
54
|
+
expires: datetime
|
|
55
|
+
note: str | None = None
|
|
56
|
+
name: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class RecordVerification:
|
|
61
|
+
"""Outcome of :func:`verify_claim` or :func:`verify_revocation`."""
|
|
62
|
+
|
|
63
|
+
outcome: Outcome
|
|
64
|
+
issues: tuple[Issue, ...] = ()
|
|
65
|
+
public_key: PublicKey | None = None
|
|
66
|
+
claim: Claim | None = None
|
|
67
|
+
raw: JsonObject | None = field(default=None, repr=False)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ok(self) -> bool:
|
|
71
|
+
return self.outcome is Outcome.VALID
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fields(
|
|
75
|
+
record: JsonObject,
|
|
76
|
+
required: tuple[str, ...],
|
|
77
|
+
optional: tuple[str, ...],
|
|
78
|
+
file: str,
|
|
79
|
+
section: str,
|
|
80
|
+
issues: IssueCollector,
|
|
81
|
+
) -> None:
|
|
82
|
+
missing = [name for name in required if name not in record]
|
|
83
|
+
extra = sorted(set(record) - set(required) - set(optional))
|
|
84
|
+
if missing:
|
|
85
|
+
issues.add(
|
|
86
|
+
"record_fields",
|
|
87
|
+
"record is missing required fields",
|
|
88
|
+
section=section,
|
|
89
|
+
file=file,
|
|
90
|
+
observed=missing,
|
|
91
|
+
)
|
|
92
|
+
if extra:
|
|
93
|
+
issues.add(
|
|
94
|
+
"record_fields", "record fields are closed", section=section, file=file, observed=extra
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_claim(data: bytes, *, file: str = "claim.json") -> Claim:
|
|
99
|
+
"""Check a claim's encoding, schema, and fields (§5.2.1, §7). Signature not checked.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
Invalid, Unsupported: with all independent field issues attached.
|
|
103
|
+
"""
|
|
104
|
+
record = parse_json_object(data, file=file)
|
|
105
|
+
check_schema(record, CLAIM_SCHEMA, file=file)
|
|
106
|
+
issues = IssueCollector()
|
|
107
|
+
_fields(
|
|
108
|
+
record,
|
|
109
|
+
("schema", "public_key", "account", "expires"),
|
|
110
|
+
("note", "name"),
|
|
111
|
+
file,
|
|
112
|
+
"5.2.1",
|
|
113
|
+
issues,
|
|
114
|
+
)
|
|
115
|
+
public_key = None
|
|
116
|
+
if "public_key" in record:
|
|
117
|
+
public_key = parse_public_jwk(record["public_key"], file=file, collector=issues)
|
|
118
|
+
account = None
|
|
119
|
+
if "account" in record:
|
|
120
|
+
value = record["account"]
|
|
121
|
+
if (
|
|
122
|
+
not isinstance(value, dict)
|
|
123
|
+
or set(value) != {"type", "id"}
|
|
124
|
+
or not all(isinstance(v, str) for v in value.values())
|
|
125
|
+
):
|
|
126
|
+
issues.add(
|
|
127
|
+
"claim_account",
|
|
128
|
+
"account must have exactly two string fields, type and id",
|
|
129
|
+
section="5.2.1",
|
|
130
|
+
file=file,
|
|
131
|
+
field="/account",
|
|
132
|
+
observed=json_type_name(value) if not isinstance(value, dict) else sorted(value),
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
account_type, account_id = str(value["type"]), str(value["id"])
|
|
136
|
+
problem = account_problem(account_type, account_id)
|
|
137
|
+
if problem is not None and account_type not in set(AccountType):
|
|
138
|
+
issues.add(
|
|
139
|
+
"unsupported_account_type",
|
|
140
|
+
"account type has no supported method",
|
|
141
|
+
section="5.2",
|
|
142
|
+
file=file,
|
|
143
|
+
field="/account/type",
|
|
144
|
+
observed=account_type,
|
|
145
|
+
outcome=Outcome.UNSUPPORTED,
|
|
146
|
+
)
|
|
147
|
+
elif problem is not None:
|
|
148
|
+
issues.add(
|
|
149
|
+
"claim_account_id",
|
|
150
|
+
problem,
|
|
151
|
+
section="5.2",
|
|
152
|
+
file=file,
|
|
153
|
+
field="/account/id",
|
|
154
|
+
observed=account_id,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
account = Account(account_type, account_id)
|
|
158
|
+
expires = None
|
|
159
|
+
if "expires" in record:
|
|
160
|
+
value = record["expires"]
|
|
161
|
+
try:
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
raise ValueError
|
|
164
|
+
expires = parse_rfc3339(value)
|
|
165
|
+
except ValueError:
|
|
166
|
+
issues.add(
|
|
167
|
+
"claim_expires",
|
|
168
|
+
"expires must be an RFC 3339 date-time",
|
|
169
|
+
section="5.2.1",
|
|
170
|
+
file=file,
|
|
171
|
+
field="/expires",
|
|
172
|
+
observed=value,
|
|
173
|
+
)
|
|
174
|
+
note = None
|
|
175
|
+
if "note" in record:
|
|
176
|
+
value = record["note"]
|
|
177
|
+
if not isinstance(value, str):
|
|
178
|
+
issues.add(
|
|
179
|
+
"claim_note",
|
|
180
|
+
"note must be a string",
|
|
181
|
+
section="5.2.1",
|
|
182
|
+
file=file,
|
|
183
|
+
field="/note",
|
|
184
|
+
observed=json_type_name(value),
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
before = len(issues)
|
|
188
|
+
check_text(
|
|
189
|
+
value,
|
|
190
|
+
multiline=True,
|
|
191
|
+
nonblank=True,
|
|
192
|
+
max_code_points=MAX_NOTE,
|
|
193
|
+
file=file,
|
|
194
|
+
field="/note",
|
|
195
|
+
collector=issues,
|
|
196
|
+
)
|
|
197
|
+
note = value if len(issues) == before else None
|
|
198
|
+
name = None
|
|
199
|
+
if "name" in record:
|
|
200
|
+
value = record["name"]
|
|
201
|
+
if not isinstance(value, str):
|
|
202
|
+
issues.add(
|
|
203
|
+
"claim_name", "name must be a string", section="5.2.1", file=file, field="/name"
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
check_text(
|
|
207
|
+
value,
|
|
208
|
+
nonblank=True,
|
|
209
|
+
max_code_points=MAX_NAME,
|
|
210
|
+
file=file,
|
|
211
|
+
field="/name",
|
|
212
|
+
collector=issues,
|
|
213
|
+
)
|
|
214
|
+
name = value
|
|
215
|
+
issues.raise_if_any()
|
|
216
|
+
assert public_key is not None
|
|
217
|
+
assert account is not None
|
|
218
|
+
assert expires is not None
|
|
219
|
+
return Claim(public_key, account, expires, note, name)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def verify_claim(
|
|
223
|
+
record: bytes,
|
|
224
|
+
signature: bytes,
|
|
225
|
+
*,
|
|
226
|
+
expected_public_key: PublicKey | str,
|
|
227
|
+
requested_account: Account,
|
|
228
|
+
now: datetime | None = None,
|
|
229
|
+
retrieved_from_account_location: bool = True,
|
|
230
|
+
known_revocation: bool = False,
|
|
231
|
+
file: str = "claim.json",
|
|
232
|
+
) -> RecordVerification:
|
|
233
|
+
"""Key association checks (§6.6) on retrieved claim bytes.
|
|
234
|
+
|
|
235
|
+
``retrieved_from_account_location`` states that the bytes were retrieved
|
|
236
|
+
from the account method's hosting location (§5.2.2-5.2.4); a local
|
|
237
|
+
signature alone does not establish a hosted association.
|
|
238
|
+
``known_revocation`` is True if a verified revocation for the key is known.
|
|
239
|
+
"""
|
|
240
|
+
try:
|
|
241
|
+
claim = parse_claim(record, file=file)
|
|
242
|
+
ed25519.verify(claim.public_key.raw, signature, record, file=file + ".sig")
|
|
243
|
+
expected = (
|
|
244
|
+
expected_public_key
|
|
245
|
+
if isinstance(expected_public_key, PublicKey)
|
|
246
|
+
else PublicKey.from_x(expected_public_key)
|
|
247
|
+
)
|
|
248
|
+
issues = IssueCollector()
|
|
249
|
+
if claim.public_key.raw != expected.raw:
|
|
250
|
+
issues.add(
|
|
251
|
+
"claim_key_mismatch",
|
|
252
|
+
"claim public key must equal the expected key",
|
|
253
|
+
section="6.6",
|
|
254
|
+
file=file,
|
|
255
|
+
field="/public_key",
|
|
256
|
+
observed=claim.public_key.x,
|
|
257
|
+
)
|
|
258
|
+
if claim.account != requested_account:
|
|
259
|
+
issues.add(
|
|
260
|
+
"claim_account_mismatch",
|
|
261
|
+
"account type and id must equal the requested account",
|
|
262
|
+
section="6.6",
|
|
263
|
+
file=file,
|
|
264
|
+
field="/account",
|
|
265
|
+
observed=claim.account.to_json(),
|
|
266
|
+
)
|
|
267
|
+
current = now or datetime.now(UTC)
|
|
268
|
+
if not current < claim.expires:
|
|
269
|
+
issues.add(
|
|
270
|
+
"claim_expired",
|
|
271
|
+
"the current time must be before expires",
|
|
272
|
+
section="6.6",
|
|
273
|
+
file=file,
|
|
274
|
+
field="/expires",
|
|
275
|
+
observed=format_rfc3339(claim.expires),
|
|
276
|
+
)
|
|
277
|
+
if known_revocation:
|
|
278
|
+
issues.add(
|
|
279
|
+
"key_revoked",
|
|
280
|
+
"the key must have no known verified revocation",
|
|
281
|
+
section="6.6",
|
|
282
|
+
file=file,
|
|
283
|
+
)
|
|
284
|
+
if not retrieved_from_account_location:
|
|
285
|
+
issues.add(
|
|
286
|
+
"claim_not_hosted",
|
|
287
|
+
"the claim must be retrieved from the account's hosting location",
|
|
288
|
+
section="6.6",
|
|
289
|
+
file=file,
|
|
290
|
+
)
|
|
291
|
+
issues.raise_if_any()
|
|
292
|
+
except SciError as exc:
|
|
293
|
+
return RecordVerification(exc.outcome, exc.issues)
|
|
294
|
+
return RecordVerification(Outcome.VALID, (), claim.public_key, claim)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def create_claim(
|
|
298
|
+
private_key: Ed25519PrivateKey,
|
|
299
|
+
account: Account,
|
|
300
|
+
*,
|
|
301
|
+
expires: datetime | None = None,
|
|
302
|
+
note: str | None = None,
|
|
303
|
+
name: str | None = None,
|
|
304
|
+
now: datetime | None = None,
|
|
305
|
+
) -> SignedRecord:
|
|
306
|
+
"""Create and sign a claim (default expiry 90 days from ``now``).
|
|
307
|
+
|
|
308
|
+
The record is serialized compactly with members ``schema``,
|
|
309
|
+
``public_key``, ``account``, ``expires``, [``name``], [``note``]; it is re-parsed to
|
|
310
|
+
guarantee validity.
|
|
311
|
+
"""
|
|
312
|
+
problem = account_problem(account.type, account.id)
|
|
313
|
+
if problem is not None:
|
|
314
|
+
raise ValueError(problem)
|
|
315
|
+
expiry = expires or (now or datetime.now(UTC)) + DEFAULT_CLAIM_VALIDITY
|
|
316
|
+
document: dict[str, object] = {
|
|
317
|
+
"schema": CLAIM_SCHEMA,
|
|
318
|
+
"public_key": public_key_of(private_key).to_jwk(),
|
|
319
|
+
"account": account.to_json(),
|
|
320
|
+
"expires": format_rfc3339(expiry),
|
|
321
|
+
}
|
|
322
|
+
if name is not None:
|
|
323
|
+
document["name"] = name
|
|
324
|
+
if note is not None:
|
|
325
|
+
document["note"] = note
|
|
326
|
+
raw = json.dumps(document, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
327
|
+
parse_claim(raw)
|
|
328
|
+
return SignedRecord(raw, ed25519.sign(private_key, raw))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def parse_revocation(data: bytes, *, file: str = "revocation.json") -> PublicKey:
|
|
332
|
+
"""Check a revocation's encoding, schema, and fields (§5.3); return its key."""
|
|
333
|
+
record = parse_json_object(data, file=file)
|
|
334
|
+
check_schema(record, REVOCATION_SCHEMA, file=file)
|
|
335
|
+
issues = IssueCollector()
|
|
336
|
+
_fields(record, ("schema", "public_key"), (), file, "5.3", issues)
|
|
337
|
+
key = None
|
|
338
|
+
if "public_key" in record:
|
|
339
|
+
key = parse_public_jwk(record["public_key"], file=file, collector=issues)
|
|
340
|
+
issues.raise_if_any()
|
|
341
|
+
assert key is not None
|
|
342
|
+
return key
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def verify_revocation(
|
|
346
|
+
record: bytes,
|
|
347
|
+
signature: bytes,
|
|
348
|
+
*,
|
|
349
|
+
expected_public_key: PublicKey | str | None = None,
|
|
350
|
+
file: str = "revocation.json",
|
|
351
|
+
) -> RecordVerification:
|
|
352
|
+
"""Revocation checks (§6.7); optionally require the revoked key (§8.1.2)."""
|
|
353
|
+
try:
|
|
354
|
+
key = parse_revocation(record, file=file)
|
|
355
|
+
ed25519.verify(key.raw, signature, record, file=file + ".sig")
|
|
356
|
+
if expected_public_key is not None:
|
|
357
|
+
expected = (
|
|
358
|
+
expected_public_key
|
|
359
|
+
if isinstance(expected_public_key, PublicKey)
|
|
360
|
+
else PublicKey.from_x(expected_public_key)
|
|
361
|
+
)
|
|
362
|
+
if expected.raw != key.raw:
|
|
363
|
+
raise Invalid(
|
|
364
|
+
Issue(
|
|
365
|
+
"revocation_key_mismatch",
|
|
366
|
+
"record public_key.x must equal the requested key",
|
|
367
|
+
section="8.1.2",
|
|
368
|
+
file=file,
|
|
369
|
+
field="/public_key/x",
|
|
370
|
+
observed=key.x,
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
except SciError as exc:
|
|
374
|
+
return RecordVerification(exc.outcome, exc.issues)
|
|
375
|
+
return RecordVerification(Outcome.VALID, (), key)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def create_revocation(private_key: Ed25519PrivateKey) -> SignedRecord:
|
|
379
|
+
"""Create and sign a revocation record for ``private_key``'s public key (§5.3)."""
|
|
380
|
+
raw = json.dumps(
|
|
381
|
+
{"schema": REVOCATION_SCHEMA, "public_key": public_key_of(private_key).to_jwk()},
|
|
382
|
+
separators=(",", ":"),
|
|
383
|
+
).encode("ascii")
|
|
384
|
+
return SignedRecord(raw, ed25519.sign(private_key, raw))
|
pysci/rfc3339.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""RFC 3339 §5.6 ``date-time`` parsing and formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import calendar
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
__all__ = ["format_rfc3339", "parse_rfc3339"]
|
|
11
|
+
|
|
12
|
+
_DATE_TIME: Final = re.compile(
|
|
13
|
+
r"(?P<year>[0-9]{4})-(?P<month>[0-9]{2})-(?P<day>[0-9]{2})[Tt]"
|
|
14
|
+
r"(?P<hour>[0-9]{2}):(?P<minute>[0-9]{2}):(?P<second>[0-9]{2})(?P<fraction>\.[0-9]+)?"
|
|
15
|
+
r"(?P<offset>[Zz]|[+-][0-9]{2}:[0-9]{2})"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_rfc3339(value: str) -> datetime:
|
|
20
|
+
"""Parse an RFC 3339 ``date-time`` into an aware datetime.
|
|
21
|
+
|
|
22
|
+
Field ranges are checked (including days per month). A leap second
|
|
23
|
+
(``:60``) is accepted only at 23:59 UTC-equivalent minute boundaries and
|
|
24
|
+
is represented as the following second.
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: not an RFC 3339 date-time.
|
|
28
|
+
"""
|
|
29
|
+
match = _DATE_TIME.fullmatch(value)
|
|
30
|
+
if match is None:
|
|
31
|
+
raise ValueError("not an RFC 3339 date-time")
|
|
32
|
+
year, month, day = int(match["year"]), int(match["month"]), int(match["day"])
|
|
33
|
+
hour, minute, second = int(match["hour"]), int(match["minute"]), int(match["second"])
|
|
34
|
+
if not 1 <= month <= 12 or not 1 <= day <= calendar.monthrange(year, month)[1]:
|
|
35
|
+
raise ValueError("date out of range")
|
|
36
|
+
if hour > 23 or minute > 59 or second > 60:
|
|
37
|
+
raise ValueError("time out of range")
|
|
38
|
+
offset_text = match["offset"]
|
|
39
|
+
if offset_text in ("Z", "z"):
|
|
40
|
+
tz: timezone = UTC
|
|
41
|
+
else:
|
|
42
|
+
hours, minutes = int(offset_text[1:3]), int(offset_text[4:6])
|
|
43
|
+
if hours > 23 or minutes > 59:
|
|
44
|
+
raise ValueError("offset out of range")
|
|
45
|
+
delta = timedelta(hours=hours, minutes=minutes)
|
|
46
|
+
tz = timezone(-delta if offset_text[0] == "-" else delta)
|
|
47
|
+
microsecond = 0
|
|
48
|
+
if match["fraction"]:
|
|
49
|
+
microsecond = int((match["fraction"][1:] + "000000")[:6])
|
|
50
|
+
leap = second == 60
|
|
51
|
+
result = datetime(
|
|
52
|
+
year, month, day, hour, minute, 59 if leap else second, microsecond, tzinfo=tz
|
|
53
|
+
)
|
|
54
|
+
if leap:
|
|
55
|
+
utc = result.astimezone(UTC)
|
|
56
|
+
if (utc.hour, utc.minute) != (23, 59):
|
|
57
|
+
raise ValueError("leap seconds occur only at the end of a UTC day")
|
|
58
|
+
result += timedelta(seconds=1)
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def format_rfc3339(value: datetime) -> str:
|
|
63
|
+
"""Format an aware datetime as ``YYYY-MM-DDTHH:MM:SSZ`` in UTC (fractions dropped)."""
|
|
64
|
+
if value.tzinfo is None:
|
|
65
|
+
raise ValueError("datetime must be timezone-aware")
|
|
66
|
+
return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|