src/pqc_mbom/signer.py
5.2 KB · 158 lines · python Raw
1 """MBOM signing and verification using ML-DSA."""
2
3 from __future__ import annotations
4
5 import hashlib
6 from dataclasses import dataclass
7 from datetime import datetime, timezone
8
9 from quantumshield.core.algorithms import SignatureAlgorithm
10 from quantumshield.core.signatures import sign, verify
11 from quantumshield.identity.agent import AgentIdentity
12
13 from pqc_mbom.errors import SignatureVerificationError
14 from pqc_mbom.mbom import MBOM
15
16
17 @dataclass(frozen=True)
18 class VerificationResult:
19 """Outcome of verifying a signed MBOM."""
20
21 signature_valid: bool
22 root_hash_valid: bool
23 mbom_id: str
24 signer_did: str | None
25 algorithm: str | None
26 error: str | None = None
27
28 @property
29 def valid(self) -> bool:
30 """True iff both the ML-DSA signature and the recomputed root match."""
31 return self.signature_valid and self.root_hash_valid
32
33
34 class MBOMSigner:
35 """Signs MBOM documents with a fixed AgentIdentity.
36
37 Usage:
38 identity = AgentIdentity.create("llama-release-pipeline")
39 signer = MBOMSigner(identity)
40 signer.sign(mbom) # mutates mbom in place
41 """
42
43 def __init__(self, identity: AgentIdentity) -> None:
44 self.identity = identity
45
46 def sign(self, mbom: MBOM) -> MBOM:
47 """Populate signature fields on `mbom` using ML-DSA.
48
49 Recomputes the components_root_hash before signing so the signature
50 always commits to the current component set.
51 """
52 mbom.recompute_root()
53 digest = hashlib.sha3_256(mbom.canonical_bytes()).digest()
54 sig = sign(digest, self.identity.signing_keypair)
55 mbom.signer_did = self.identity.did
56 mbom.algorithm = self.identity.signing_keypair.algorithm.value
57 mbom.signature = sig.hex()
58 mbom.public_key = self.identity.signing_keypair.public_key.hex()
59 mbom.signed_at = datetime.now(timezone.utc).isoformat()
60 return mbom
61
62
63 class MBOMVerifier:
64 """Verifies MBOM signatures and component-root integrity."""
65
66 @staticmethod
67 def verify(mbom: MBOM) -> VerificationResult:
68 """Check both the signature and the components_root_hash."""
69 expected_root = MBOMVerifier._expected_root(mbom)
70 root_ok = expected_root == mbom.components_root_hash
71
72 if not mbom.signature or not mbom.algorithm:
73 return VerificationResult(
74 signature_valid=False,
75 root_hash_valid=root_ok,
76 mbom_id=mbom.mbom_id,
77 signer_did=mbom.signer_did or None,
78 algorithm=mbom.algorithm or None,
79 error="mbom is unsigned",
80 )
81
82 try:
83 algorithm = SignatureAlgorithm(mbom.algorithm)
84 except ValueError:
85 return VerificationResult(
86 signature_valid=False,
87 root_hash_valid=root_ok,
88 mbom_id=mbom.mbom_id,
89 signer_did=mbom.signer_did,
90 algorithm=mbom.algorithm,
91 error=f"unknown algorithm {mbom.algorithm}",
92 )
93
94 digest = hashlib.sha3_256(mbom.canonical_bytes()).digest()
95 try:
96 sig_valid = verify(
97 digest,
98 bytes.fromhex(mbom.signature),
99 bytes.fromhex(mbom.public_key),
100 algorithm,
101 )
102 except Exception as exc:
103 return VerificationResult(
104 signature_valid=False,
105 root_hash_valid=root_ok,
106 mbom_id=mbom.mbom_id,
107 signer_did=mbom.signer_did,
108 algorithm=mbom.algorithm,
109 error=f"signature verify failed: {exc}",
110 )
111
112 if not sig_valid:
113 return VerificationResult(
114 signature_valid=False,
115 root_hash_valid=root_ok,
116 mbom_id=mbom.mbom_id,
117 signer_did=mbom.signer_did,
118 algorithm=mbom.algorithm,
119 error="invalid ML-DSA signature",
120 )
121
122 if not root_ok:
123 return VerificationResult(
124 signature_valid=True,
125 root_hash_valid=False,
126 mbom_id=mbom.mbom_id,
127 signer_did=mbom.signer_did,
128 algorithm=mbom.algorithm,
129 error=(
130 f"components_root_hash mismatch (expected {expected_root[:16]}, "
131 f"got {mbom.components_root_hash[:16]})"
132 ),
133 )
134
135 return VerificationResult(
136 signature_valid=True,
137 root_hash_valid=True,
138 mbom_id=mbom.mbom_id,
139 signer_did=mbom.signer_did,
140 algorithm=mbom.algorithm,
141 )
142
143 @staticmethod
144 def verify_or_raise(mbom: MBOM) -> VerificationResult:
145 """Verify and raise SignatureVerificationError on any failure."""
146 result = MBOMVerifier.verify(mbom)
147 if not result.valid:
148 raise SignatureVerificationError(
149 f"MBOM {mbom.mbom_id} failed verification: {result.error}"
150 )
151 return result
152
153 @staticmethod
154 def _expected_root(mbom: MBOM) -> str:
155 component_hashes = sorted(c.hash() for c in mbom.components)
156 concat = "|".join(component_hashes).encode("utf-8")
157 return hashlib.sha3_256(concat).hexdigest()
158