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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. 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")