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.
- aevum/verify/__init__.py +20 -0
- aevum/verify/__main__.py +94 -0
- aevum/verify/_core.py +617 -0
- aevum/verify/py.typed +0 -0
- aevum_verify-0.8.0.dist-info/METADATA +23 -0
- aevum_verify-0.8.0.dist-info/RECORD +8 -0
- aevum_verify-0.8.0.dist-info/WHEEL +4 -0
- aevum_verify-0.8.0.dist-info/entry_points.txt +2 -0
aevum/verify/__init__.py
ADDED
|
@@ -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
|
+
]
|
aevum/verify/__main__.py
ADDED
|
@@ -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,,
|