aevum-verify 0.8.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.
@@ -0,0 +1,20 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from aevum.verify._core import (
3
+ VerifyResult,
4
+ dump_chain,
5
+ event_from_dict,
6
+ event_to_dict,
7
+ load_chain,
8
+ verify_chain,
9
+ verify_entry,
10
+ )
11
+
12
+ __all__ = [
13
+ "VerifyResult",
14
+ "verify_entry",
15
+ "verify_chain",
16
+ "load_chain",
17
+ "dump_chain",
18
+ "event_to_dict",
19
+ "event_from_dict",
20
+ ]
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ aevum-verify — standalone sigchain verifier CLI.
4
+
5
+ Usage:
6
+ aevum-verify CHAIN_FILE --ed25519-pub HEX [--mldsa65-pub HEX]
7
+
8
+ CHAIN_FILE Path to a JSON file containing a list of serialised chain entries.
9
+ --ed25519-pub Pinned Ed25519 public key as 64-char hex, or @/path/to/file for
10
+ raw 32-byte binary.
11
+ --mldsa65-pub Pinned ML-DSA-65 public key as hex or @filepath; required for
12
+ hybrid (ed25519+ml-dsa-65) chains.
13
+
14
+ Exit codes:
15
+ 0 VERIFIED — all entries intact.
16
+ 1 FAILED — chain tampered, signature invalid, or trust-anchor mismatch.
17
+ 2 Usage error (bad arguments or unreadable file).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from aevum.verify._core import load_chain, verify_chain
26
+
27
+
28
+ def _load_key(value: str) -> bytes:
29
+ """Load a key from a hex string or @filepath (raw binary)."""
30
+ if value.startswith("@"):
31
+ return Path(value[1:]).read_bytes()
32
+ return bytes.fromhex(value)
33
+
34
+
35
+ def main() -> None:
36
+ parser = argparse.ArgumentParser(
37
+ prog="aevum-verify",
38
+ description="Verify an Aevum sigchain export against pinned public keys.",
39
+ formatter_class=argparse.RawDescriptionHelpFormatter,
40
+ epilog=__doc__,
41
+ )
42
+ parser.add_argument("chain_file", metavar="CHAIN_FILE", help="path to JSON chain file")
43
+ parser.add_argument(
44
+ "--ed25519-pub",
45
+ required=True,
46
+ metavar="HEX",
47
+ help="pinned Ed25519 public key (64-char hex or @filepath)",
48
+ )
49
+ parser.add_argument(
50
+ "--mldsa65-pub",
51
+ default=None,
52
+ metavar="HEX",
53
+ help="pinned ML-DSA-65 public key (hex or @filepath); required for hybrid chains",
54
+ )
55
+ args = parser.parse_args()
56
+
57
+ try:
58
+ ed25519_pub = _load_key(args.ed25519_pub)
59
+ except Exception as exc:
60
+ print(f"ERROR: invalid --ed25519-pub: {exc}", file=sys.stderr)
61
+ sys.exit(2)
62
+
63
+ mldsa65_pub: bytes | None = None
64
+ if args.mldsa65_pub:
65
+ try:
66
+ mldsa65_pub = _load_key(args.mldsa65_pub)
67
+ except Exception as exc:
68
+ print(f"ERROR: invalid --mldsa65-pub: {exc}", file=sys.stderr)
69
+ sys.exit(2)
70
+
71
+ try:
72
+ chain_path = Path(args.chain_file)
73
+ entries = load_chain(chain_path)
74
+ except Exception as exc:
75
+ print(f"ERROR: could not load chain file: {exc}", file=sys.stderr)
76
+ sys.exit(2)
77
+
78
+ result = verify_chain(entries, ed25519_pub=ed25519_pub, mldsa65_pub=mldsa65_pub)
79
+
80
+ if result.ok:
81
+ print(f"VERIFIED — {len(entries)} entries intact")
82
+ sys.exit(0)
83
+ else:
84
+ idx = result.failing_index
85
+ reason = result.reason
86
+ if idx is not None:
87
+ print(f"FAILED — entry {idx}: {reason}", file=sys.stderr)
88
+ else:
89
+ print(f"FAILED — {reason}", file=sys.stderr)
90
+ sys.exit(1)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ main()
aevum/verify/_core.py ADDED
@@ -0,0 +1,617 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """
3
+ aevum.verify._core — standalone sigchain verifier.
4
+
5
+ Trust model
6
+ -----------
7
+ Classical anchor: The pinned Ed25519 public-key bytes supplied out-of-band
8
+ (``ed25519_pub`` kwarg). Every entry's Ed25519 signature is
9
+ verified against this key.
10
+
11
+ ``signer_key_id`` is an informational signed field whose
12
+ integrity is already guaranteed by the Ed25519 signature —
13
+ it lives inside the signing_fields set, so mutating it
14
+ invalidates the signature. No identity comparison between
15
+ ``signer_key_id`` and the Ed25519 public key is performed;
16
+ the trust anchor is the key bytes, not the identifier.
17
+
18
+ Hybrid anchor: The pinned Ed25519 key (above) PLUS the pinned ML-DSA-65
19
+ public-key bytes supplied out-of-band (``mldsa65_pub`` kwarg).
20
+ For hybrid entries (key_scheme ``ed25519+ml-dsa-65``):
21
+ - the embedded ``mldsa65_pub`` field must equal the pinned key;
22
+ - the ML-DSA-65 signature must verify against it.
23
+ Absence of either sig or pub → fail closed (tamper / downgrade).
24
+
25
+ Merkle + STH layer:
26
+ verify_inclusion / verify_consistency / leaf_hash / node_hash /
27
+ recompute_root are re-implemented from the RFC 6962 spec (SHA3-256).
28
+ They do NOT import from aevum.core.audit.merkle — independence is
29
+ enforced by the AST import test in test_merkle_sth.py.
30
+
31
+ verify_sth checks the hybrid STH signatures (Ed25519 + ML-DSA-65)
32
+ against PINNED keys using the same domain prefix and fail-closed
33
+ rules as entry verification. An optional expected_root check
34
+ confirms the STH root matches the locally recomputed Merkle root.
35
+
36
+ verify_sth_tsa_full extends verify_sth_tsa (imprint-only) with
37
+ full RFC 3161 token-signature + cert-chain validation against a
38
+ pinned TSA root certificate. Returns None / True / False
39
+ (no-token / valid / invalid) preserving the existing tri-state.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import base64
44
+ import dataclasses
45
+ import hashlib
46
+ import json
47
+ import logging
48
+ from pathlib import Path
49
+ from typing import Any, Protocol
50
+
51
+ import rfc8785
52
+ from aevum.core.audit.event import AuditEvent, _message_representative
53
+ from aevum.core.audit.sigchain import GENESIS_HASH
54
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
55
+ from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
56
+ from rfc3161_client import VerificationError, VerifierBuilder, decode_timestamp_response
57
+
58
+ logger = logging.getLogger(__name__)
59
+
60
+ # Maps the lower-case key_scheme suffix to the OQS algorithm name.
61
+ _MLDSA_LEVEL_MAP: dict[str, str] = {"ml-dsa-65": "ML-DSA-65"}
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Merkle constants (must match aevum.core.audit.merkle exactly)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ # RFC 6962 leaf / node domain bytes
68
+ _LEAF: bytes = b"\x00"
69
+ _NODE: bytes = b"\x01"
70
+
71
+ # Empty-tree root: sha3_256(b"") — MTH of 0 entries per RFC 6962
72
+ EMPTY_ROOT: bytes = hashlib.sha3_256(b"").digest()
73
+
74
+ # STH domain prefix — cross-type separation from entry prefix b"aevum-sigchain-v1\x00"
75
+ _STH_DOMAIN: bytes = b"aevum-sth-v1\x00"
76
+
77
+ # SHA OID → hashlib algorithm (covers OIDs rfc3161-client uses for TSA imprints)
78
+ _SHA_OID_TO_ALGO: dict[str, str] = {
79
+ "2.16.840.1.101.3.4.2.1": "sha256",
80
+ "2.16.840.1.101.3.4.2.2": "sha384",
81
+ "2.16.840.1.101.3.4.2.3": "sha512",
82
+ }
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # _STHLike — duck-typed protocol satisfied by aevum.core.audit.merkle.SignedTreeHead
87
+ # (aevum.core.audit.merkle is never imported at runtime; independence enforced by test)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ class _STHLike(Protocol):
91
+ """Protocol satisfied by SignedTreeHead — used for type annotations only."""
92
+
93
+ tree_size: int
94
+ root_hash: str # hex (32 bytes = 64 chars)
95
+ timestamp: int # Unix seconds
96
+ log_id: str # Ed25519 public key hex
97
+ hash_alg: str # "sha3-256"
98
+ key_scheme: str # "ed25519+ml-dsa-65"
99
+ ed25519_sig: str # url-safe base64
100
+ mldsa65_sig: str # hex
101
+ mldsa65_pub: str # hex
102
+ ed25519_pub: str # hex
103
+ tsa_token: str | None
104
+
105
+
106
+ @dataclasses.dataclass(frozen=True)
107
+ class VerifyResult:
108
+ """Result of a chain or entry verification.
109
+
110
+ Attributes:
111
+ ok: True iff every verified entry is cryptographically intact.
112
+ failing_index: 0-based index of the first failing entry (None when ok is True
113
+ or when the failure precedes entry iteration, e.g. homogeneity).
114
+ reason: Human-readable failure description (empty string when ok is True).
115
+ """
116
+
117
+ ok: bool
118
+ failing_index: int | None = None
119
+ reason: str = ""
120
+
121
+
122
+ def verify_entry(
123
+ entry: AuditEvent,
124
+ *,
125
+ ed25519_pub: bytes,
126
+ mldsa65_pub: bytes | None,
127
+ expected_prior: str,
128
+ ) -> VerifyResult:
129
+ """Verify a single chain entry against pinned public keys.
130
+
131
+ The check order matches aevum-core's verify_chain to guarantee that both
132
+ implementations detect the same failure in the same entry.
133
+
134
+ Args:
135
+ entry: The AuditEvent to verify.
136
+ ed25519_pub: Pinned Ed25519 public key bytes (32 bytes).
137
+ mldsa65_pub: Pinned ML-DSA-65 public key bytes; required for hybrid entries.
138
+ expected_prior: Expected value of entry.prior_hash.
139
+
140
+ Returns:
141
+ VerifyResult(ok=True) if the entry is intact, VerifyResult(ok=False, ...) otherwise.
142
+ """
143
+ if entry.sig_format_version != 1:
144
+ return VerifyResult(
145
+ ok=False,
146
+ reason=f"sig_format_version {entry.sig_format_version!r} != 1",
147
+ )
148
+
149
+ if entry.prior_hash != expected_prior:
150
+ return VerifyResult(ok=False, reason="prior_hash mismatch")
151
+
152
+ if AuditEvent.hash_payload(entry.payload) != entry.payload_hash:
153
+ return VerifyResult(ok=False, reason="payload_hash mismatch")
154
+
155
+ signing_fields: dict[str, Any] = {
156
+ "event_id": entry.event_id,
157
+ "episode_id": entry.episode_id,
158
+ "sequence": entry.sequence,
159
+ "event_type": entry.event_type,
160
+ "schema_version": entry.schema_version,
161
+ "valid_from": entry.valid_from,
162
+ "valid_to": entry.valid_to,
163
+ # HLC system_time may exceed 2^53; must be a string in the signed field set.
164
+ "system_time": str(entry.system_time),
165
+ "causation_id": entry.causation_id,
166
+ "correlation_id": entry.correlation_id,
167
+ "actor": entry.actor,
168
+ "trace_id": entry.trace_id,
169
+ "span_id": entry.span_id,
170
+ "payload_hash": entry.payload_hash,
171
+ "prior_hash": entry.prior_hash,
172
+ "signer_key_id": entry.signer_key_id,
173
+ "key_scheme": entry.key_scheme,
174
+ "sig_format_version": 1,
175
+ "hash_alg": entry.hash_alg,
176
+ }
177
+ representative = _message_representative(signing_fields)
178
+ digest = hashlib.sha3_256(representative).digest()
179
+
180
+ # Ed25519 verify against the PINNED key — the sole classical trust anchor.
181
+ # No comparison of signer_key_id to the key bytes is performed here.
182
+ try:
183
+ public_key = Ed25519PublicKey.from_public_bytes(ed25519_pub)
184
+ sig_bytes = base64.urlsafe_b64decode(entry.signature + "==")
185
+ public_key.verify(sig_bytes, digest)
186
+ except Exception:
187
+ return VerifyResult(ok=False, reason="Ed25519 signature invalid")
188
+
189
+ ks = entry.key_scheme
190
+ if ks == "ed25519":
191
+ pass # classical-only; primary Ed25519 already verified
192
+ elif ks.startswith("ed25519+"):
193
+ level_suffix = ks[len("ed25519+"):]
194
+ mldsa_alg = _MLDSA_LEVEL_MAP.get(level_suffix)
195
+ if mldsa_alg is None:
196
+ return VerifyResult(ok=False, reason=f"unknown ML-DSA level: {level_suffix!r}")
197
+
198
+ # Fail closed: both sig and pub fields must be present for hybrid entries.
199
+ if entry.mldsa65_sig is None or entry.mldsa65_pub is None:
200
+ return VerifyResult(ok=False, reason="ML-DSA sig/pub absent for hybrid entry")
201
+
202
+ # Caller must supply the pinned ML-DSA key for hybrid verification.
203
+ if mldsa65_pub is None:
204
+ return VerifyResult(
205
+ ok=False,
206
+ reason="hybrid entry requires --mldsa65-pub; no pinned ML-DSA-65 key supplied",
207
+ )
208
+
209
+ # Pinned-key match: the embedded key must equal the published anchor.
210
+ if bytes.fromhex(entry.mldsa65_pub) != mldsa65_pub:
211
+ return VerifyResult(
212
+ ok=False,
213
+ reason="embedded mldsa65_pub does not match pinned ML-DSA-65 key",
214
+ )
215
+
216
+ # ML-DSA verification over the representative bytes (not the hash of them).
217
+ try:
218
+ from aevum.core.signing import DualSigner
219
+
220
+ DualSigner.verify_mldsa(
221
+ representative,
222
+ bytes.fromhex(entry.mldsa65_sig),
223
+ mldsa65_pub,
224
+ alg=mldsa_alg,
225
+ )
226
+ except Exception as exc:
227
+ return VerifyResult(ok=False, reason=f"ML-DSA signature invalid: {exc}")
228
+ else:
229
+ return VerifyResult(ok=False, reason=f"unknown key_scheme: {ks!r}")
230
+
231
+ return VerifyResult(ok=True)
232
+
233
+
234
+ def verify_chain(
235
+ entries: list[AuditEvent],
236
+ *,
237
+ ed25519_pub: bytes,
238
+ mldsa65_pub: bytes | None = None,
239
+ ) -> VerifyResult:
240
+ """Verify an entire sigchain from genesis.
241
+
242
+ Applies the same pre-pass checks as aevum-core's Sigchain.verify_chain to
243
+ guarantee both implementations detect failures at the same entry:
244
+ 1. sig_format_version == 1 for every entry.
245
+ 2. key_scheme homogeneity — a mixed chain is a downgrade/splice fingerprint.
246
+ 3. Per-entry: prior_hash linkage, payload_hash, Ed25519 + ML-DSA (if hybrid).
247
+
248
+ Args:
249
+ entries: Ordered list of AuditEvent objects starting from genesis.
250
+ ed25519_pub: Pinned Ed25519 public key bytes (the sole classical trust anchor).
251
+ mldsa65_pub: Pinned ML-DSA-65 public key bytes; required for hybrid chains.
252
+
253
+ Returns:
254
+ VerifyResult(ok=True) if every entry is intact.
255
+ VerifyResult(ok=False, failing_index=N, reason=...) on the first failure.
256
+ """
257
+ if not entries:
258
+ return VerifyResult(ok=True)
259
+
260
+ # Pre-pass 1: sig_format_version must be 1 for every entry.
261
+ for i, e in enumerate(entries):
262
+ if getattr(e, "sig_format_version", None) != 1:
263
+ return VerifyResult(
264
+ ok=False,
265
+ failing_index=i,
266
+ reason=f"sig_format_version {e.sig_format_version!r} != 1",
267
+ )
268
+
269
+ # Pre-pass 2: homogeneity — all entries must share the same key_scheme.
270
+ # A mixed chain is the fingerprint of a downgrade or splice attack.
271
+ schemes = {e.key_scheme for e in entries}
272
+ if len(schemes) > 1:
273
+ return VerifyResult(
274
+ ok=False,
275
+ reason=f"mixed key_scheme detected {schemes!r} — downgrade or splice attack",
276
+ )
277
+
278
+ expected_prior = GENESIS_HASH
279
+ for i, entry in enumerate(entries):
280
+ result = verify_entry(
281
+ entry,
282
+ ed25519_pub=ed25519_pub,
283
+ mldsa65_pub=mldsa65_pub,
284
+ expected_prior=expected_prior,
285
+ )
286
+ if not result.ok:
287
+ return VerifyResult(ok=False, failing_index=i, reason=result.reason)
288
+ expected_prior = AuditEvent.hash_event_for_chain(entry)
289
+
290
+ return VerifyResult(ok=True)
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # JSON serialization helpers (for CLI and test fixtures)
295
+ # ---------------------------------------------------------------------------
296
+
297
+ def event_to_dict(event: AuditEvent) -> dict[str, Any]:
298
+ """Serialize an AuditEvent to a JSON-safe dict (receipt_cbor excluded)."""
299
+ return {
300
+ "event_id": event.event_id,
301
+ "episode_id": event.episode_id,
302
+ "sequence": event.sequence,
303
+ "event_type": event.event_type,
304
+ "schema_version": event.schema_version,
305
+ "valid_from": event.valid_from,
306
+ "valid_to": event.valid_to,
307
+ "system_time": event.system_time,
308
+ "causation_id": event.causation_id,
309
+ "correlation_id": event.correlation_id,
310
+ "actor": event.actor,
311
+ "trace_id": event.trace_id,
312
+ "span_id": event.span_id,
313
+ "payload": event.payload,
314
+ "payload_hash": event.payload_hash,
315
+ "prior_hash": event.prior_hash,
316
+ "signature": event.signature,
317
+ "signer_key_id": event.signer_key_id,
318
+ "mldsa65_sig": event.mldsa65_sig,
319
+ "mldsa65_pub": event.mldsa65_pub,
320
+ "tsa_url": event.tsa_url,
321
+ "tsa_token": event.tsa_token,
322
+ "key_scheme": event.key_scheme,
323
+ "sig_format_version": event.sig_format_version,
324
+ "hash_alg": event.hash_alg,
325
+ }
326
+
327
+
328
+ def event_from_dict(d: dict[str, Any]) -> AuditEvent:
329
+ """Deserialize an AuditEvent from a dict produced by event_to_dict."""
330
+ return AuditEvent(
331
+ event_id=d["event_id"],
332
+ episode_id=d["episode_id"],
333
+ sequence=int(d["sequence"]),
334
+ event_type=d["event_type"],
335
+ schema_version=d["schema_version"],
336
+ valid_from=d["valid_from"],
337
+ valid_to=d.get("valid_to"),
338
+ system_time=int(d["system_time"]),
339
+ causation_id=d.get("causation_id"),
340
+ correlation_id=d.get("correlation_id"),
341
+ actor=d["actor"],
342
+ trace_id=d.get("trace_id"),
343
+ span_id=d.get("span_id"),
344
+ payload=d["payload"],
345
+ payload_hash=d["payload_hash"],
346
+ prior_hash=d["prior_hash"],
347
+ signature=d["signature"],
348
+ signer_key_id=d["signer_key_id"],
349
+ mldsa65_sig=d.get("mldsa65_sig"),
350
+ mldsa65_pub=d.get("mldsa65_pub"),
351
+ tsa_url=d.get("tsa_url"),
352
+ tsa_token=d.get("tsa_token"),
353
+ key_scheme=d.get("key_scheme", "ed25519"),
354
+ sig_format_version=d.get("sig_format_version"),
355
+ hash_alg=d.get("hash_alg", "sha3-256"),
356
+ )
357
+
358
+
359
+ def load_chain(path: Path) -> list[AuditEvent]:
360
+ """Load a chain from a JSON file (array of event dicts)."""
361
+ data = json.loads(path.read_text())
362
+ if not isinstance(data, list):
363
+ raise ValueError(f"chain file must contain a JSON array, got {type(data).__name__}")
364
+ return [event_from_dict(entry) for entry in data]
365
+
366
+
367
+ def dump_chain(events: list[AuditEvent], path: Path) -> None:
368
+ """Write a chain to a JSON file."""
369
+ path.write_text(json.dumps([event_to_dict(e) for e in events], indent=2))
370
+
371
+
372
+ # ---------------------------------------------------------------------------
373
+ # Merkle primitives — re-implemented from RFC 6962 spec (SHA3-256)
374
+ # Never imports from aevum.core.audit.merkle (independence enforced by AST test)
375
+ # ---------------------------------------------------------------------------
376
+
377
+ def leaf_hash(entry_digest: bytes) -> bytes:
378
+ """sha3_256(0x00 || entry_digest) — RFC 6962 leaf hash with SHA3-256."""
379
+ return hashlib.sha3_256(_LEAF + entry_digest).digest()
380
+
381
+
382
+ def node_hash(left: bytes, right: bytes) -> bytes:
383
+ """sha3_256(0x01 || left || right) — RFC 6962 internal node hash with SHA3-256."""
384
+ return hashlib.sha3_256(_NODE + left + right).digest()
385
+
386
+
387
+ def _mth_impl(nodes: list[bytes]) -> bytes:
388
+ n = len(nodes)
389
+ if n == 0:
390
+ return EMPTY_ROOT
391
+ if n == 1:
392
+ return nodes[0]
393
+ k = 1 << ((n - 1).bit_length() - 1) # largest power of two < n
394
+ return node_hash(_mth_impl(nodes[:k]), _mth_impl(nodes[k:]))
395
+
396
+
397
+ def recompute_root(entries: list[AuditEvent]) -> bytes:
398
+ """Recompute the Merkle root from AuditEvent entries.
399
+
400
+ Leaf input per entry: bytes.fromhex(AuditEvent.hash_event_for_chain(entry)).
401
+ This matches aevum-core's MerkleLog.signed_tree_head() leaf-digest definition.
402
+ Empty list → EMPTY_ROOT.
403
+ """
404
+ leaves = [leaf_hash(bytes.fromhex(AuditEvent.hash_event_for_chain(e))) for e in entries]
405
+ return _mth_impl(leaves)
406
+
407
+
408
+ def verify_inclusion(
409
+ leaf_hash_value: bytes,
410
+ index: int,
411
+ tree_size: int,
412
+ proof: list[bytes],
413
+ root: bytes,
414
+ ) -> bool:
415
+ """RFC 6962 §2.1.1 inclusion verifier (re-implemented, independent of aevum-core).
416
+
417
+ Returns True iff the inclusion proof for leaf_hash_value at index in a tree of
418
+ tree_size entries recomputes to root. Never calls aevum.core.audit.merkle.
419
+ """
420
+ if index >= tree_size:
421
+ return False
422
+ fn = index
423
+ sn = tree_size - 1
424
+ r = leaf_hash_value
425
+ for step in proof:
426
+ if fn & 1 or fn == sn:
427
+ r = node_hash(step, r)
428
+ while fn != 0 and not (fn & 1):
429
+ fn >>= 1
430
+ sn >>= 1
431
+ else:
432
+ r = node_hash(r, step)
433
+ fn >>= 1
434
+ sn >>= 1
435
+ return r == root and sn == 0
436
+
437
+
438
+ def verify_consistency(
439
+ old_size: int,
440
+ new_size: int,
441
+ old_root: bytes,
442
+ new_root: bytes,
443
+ proof: list[bytes],
444
+ ) -> bool:
445
+ """RFC 6962 §2.1.2 consistency verifier (re-implemented, independent of aevum-core).
446
+
447
+ Returns True iff the log only grew (no history rewritten) between old and new.
448
+ m==0 → True; m==n → True iff roots equal and proof empty; m>n → False.
449
+ Never calls aevum.core.audit.merkle.
450
+ """
451
+ if old_size > new_size:
452
+ return False
453
+ if old_size == 0:
454
+ return True
455
+ if old_size == new_size:
456
+ return old_root == new_root and len(proof) == 0
457
+
458
+ fn = old_size - 1
459
+ sn = new_size - 1
460
+
461
+ while fn & 1:
462
+ fn >>= 1
463
+ sn >>= 1
464
+
465
+ proof_iter = iter(proof)
466
+
467
+ if fn == 0:
468
+ fr = old_root
469
+ sr = old_root
470
+ else:
471
+ first = next(proof_iter, None)
472
+ if first is None:
473
+ return False
474
+ fr = first
475
+ sr = first
476
+
477
+ for c in proof_iter:
478
+ if sn == 0:
479
+ return False
480
+ if (fn & 1) or (fn == sn):
481
+ fr = node_hash(c, fr)
482
+ sr = node_hash(c, sr)
483
+ while fn != 0 and not (fn & 1):
484
+ fn >>= 1
485
+ sn >>= 1
486
+ else:
487
+ sr = node_hash(sr, c)
488
+ fn >>= 1
489
+ sn >>= 1
490
+
491
+ if sn != 0:
492
+ return False
493
+ return fr == old_root and sr == new_root
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # STH field canonicalization (mirrors _sth_fields in aevum-core merkle.py)
498
+ # ---------------------------------------------------------------------------
499
+
500
+ def _sth_canonical_fields(sth: _STHLike) -> dict[str, Any]:
501
+ return {
502
+ "hash_alg": sth.hash_alg,
503
+ "key_scheme": sth.key_scheme,
504
+ "log_id": sth.log_id,
505
+ "root_hash": sth.root_hash,
506
+ # tree_size and timestamp encoded as strings (may exceed 2^53 in long-lived logs)
507
+ "timestamp": str(sth.timestamp),
508
+ "tree_size": str(sth.tree_size),
509
+ }
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # STH signature verifier
514
+ # ---------------------------------------------------------------------------
515
+
516
+ def verify_sth(
517
+ sth: _STHLike,
518
+ *,
519
+ ed25519_pub: bytes,
520
+ mldsa65_pub: bytes | None = None,
521
+ expected_root: bytes | None = None,
522
+ ) -> bool:
523
+ """Verify hybrid STH signatures against PINNED keys. Fail-closed: both must pass.
524
+
525
+ Reconstructs STH_DOMAIN + rfc8785(fields) and verifies:
526
+ - Ed25519 over sha3_256(representative) using the PINNED ed25519_pub
527
+ - ML-DSA-65 over representative directly using the PINNED mldsa65_pub
528
+
529
+ If expected_root is provided (typically from recompute_root(entries)) the
530
+ check also fails if bytes.fromhex(sth.root_hash) != expected_root.
531
+
532
+ Returns False if either signature is invalid, if mldsa65_pub is absent, or
533
+ if the expected_root check fails. Never raises — always returns bool.
534
+ """
535
+ if expected_root is not None and bytes.fromhex(sth.root_hash) != expected_root:
536
+ return False
537
+
538
+ fields = _sth_canonical_fields(sth)
539
+ representative: bytes = _STH_DOMAIN + rfc8785.dumps(fields)
540
+ digest = hashlib.sha3_256(representative).digest()
541
+
542
+ # Ed25519 over sha3_256(representative)
543
+ try:
544
+ sig_bytes = base64.urlsafe_b64decode(sth.ed25519_sig + "==")
545
+ pub = Ed25519PublicKey.from_public_bytes(ed25519_pub)
546
+ pub.verify(sig_bytes, digest)
547
+ except Exception:
548
+ return False
549
+
550
+ # ML-DSA-65 over representative directly (fail-closed: required for hybrid STH)
551
+ if mldsa65_pub is None:
552
+ return False
553
+ try:
554
+ from aevum.core.signing import DualSigner
555
+ DualSigner.verify_mldsa(
556
+ representative,
557
+ bytes.fromhex(sth.mldsa65_sig),
558
+ mldsa65_pub,
559
+ )
560
+ except Exception:
561
+ return False
562
+
563
+ return True
564
+
565
+
566
+ # ---------------------------------------------------------------------------
567
+ # TSA full chain verifier
568
+ # ---------------------------------------------------------------------------
569
+
570
+ def verify_sth_tsa_full(
571
+ sth: _STHLike,
572
+ *,
573
+ tsa_root_cert: bytes,
574
+ ) -> bool | None:
575
+ """Full RFC 3161 TSA validation: imprint + token signature + chain to pinned root.
576
+
577
+ Extends the imprint-only check in aevum-core's verify_sth_tsa with:
578
+ (ii) token signature verified via PKCS#7 chain
579
+ (iii) signing cert chains to the PINNED tsa_root_cert (PEM or DER bytes)
580
+ — the token's own embedded chain is verified *against* this anchor,
581
+ never trusted in isolation.
582
+
583
+ Returns:
584
+ None — sth.tsa_token is absent (no attestation; not invalid).
585
+ True — imprint == root hash AND token signature valid AND chain to root.
586
+ False — token present but any check fails.
587
+ """
588
+ if sth.tsa_token is None:
589
+ return None
590
+ try:
591
+ token_bytes = bytes.fromhex(sth.tsa_token)
592
+ response = decode_timestamp_response(token_bytes)
593
+ root_bytes = bytes.fromhex(sth.root_hash)
594
+
595
+ # Load the pinned root cert (accept PEM or DER)
596
+ try:
597
+ root_cert = load_pem_x509_certificate(tsa_root_cert)
598
+ except Exception:
599
+ root_cert = load_der_x509_certificate(tsa_root_cert)
600
+
601
+ # Build the verifier anchored to the pinned root.
602
+ # If the token has no embedded signing cert, also supply the root cert as the
603
+ # explicit leaf (self-signed root-is-signer case). For tokens with embedded
604
+ # certs the VerifierBuilder uses them for chain building.
605
+ has_embedded_certs = len(response.signed_data.certificates) > 0
606
+ builder = VerifierBuilder().add_root_certificate(root_cert)
607
+ if not has_embedded_certs:
608
+ builder = builder.tsa_certificate(root_cert)
609
+ verifier = builder.build()
610
+
611
+ # verify_message: hashes root_bytes with the token's own algorithm,
612
+ # then verifies imprint match + PKCS#7 chain + EKU (id-kp-timeStamping)
613
+ verifier.verify_message(response, root_bytes)
614
+ return True
615
+ except (VerificationError, Exception) as exc:
616
+ logger.debug("TSA full validation failed: %s", exc)
617
+ return False
aevum/verify/py.typed ADDED
File without changes
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: aevum-verify
3
+ Version: 0.8.0
4
+ Summary: Aevum — standalone sigchain verifier.
5
+ Project-URL: Homepage, https://aevum.build
6
+ Project-URL: Repository, https://github.com/aevum-labs/aevum
7
+ License: Apache-2.0
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: aevum-core
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy>=1.10; extra == 'dev'
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
20
+ Requires-Dist: pytest>=8.0; extra == 'dev'
21
+ Requires-Dist: ruff>=0.9; extra == 'dev'
22
+ Provides-Extra: pqc
23
+ Requires-Dist: liboqs-python>=0.14.0; extra == 'pqc'
@@ -0,0 +1,8 @@
1
+ aevum/verify/__init__.py,sha256=YS65h2r3XIQ-ZXmJyj7D6YwyyfEhvnBSwG3wiakhKRI,354
2
+ aevum/verify/__main__.py,sha256=e4rNkw56obSP0LGtjvKRlNSr0iesqIX5MQPuuaz9ZJw,2946
3
+ aevum/verify/_core.py,sha256=hZhnPJn4fJp62xVOQV0VJ2dJ3Ju_eaYvHMzfHbAmU70,23216
4
+ aevum/verify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ aevum_verify-0.8.0.dist-info/METADATA,sha256=8tJn7ROEYRppQfRt8XUgZV4Bh2C0BDQg7dh2v4PZoSE,881
6
+ aevum_verify-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ aevum_verify-0.8.0.dist-info/entry_points.txt,sha256=cExKU-iYo4hyFWPjUjw33olsH1b736zhmMISkqO6R_M,60
8
+ aevum_verify-0.8.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aevum-verify = aevum.verify.__main__:main