1use std::fs;
28use std::path::Path;
29
30use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey};
31use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
32
33pub const SIGNED_TAG: u8 = 0x01;
36const SIG_LEN: usize = 64;
37
38#[derive(Debug)]
40pub enum GossipCryptoError {
41 Io(std::io::Error),
42 Decode(String),
43}
44
45impl std::fmt::Display for GossipCryptoError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 GossipCryptoError::Io(e) => write!(f, "gossip key I/O: {e}"),
49 GossipCryptoError::Decode(m) => write!(f, "gossip key decode: {m}"),
50 }
51 }
52}
53
54impl std::error::Error for GossipCryptoError {}
55
56impl From<std::io::Error> for GossipCryptoError {
57 fn from(e: std::io::Error) -> Self {
58 GossipCryptoError::Io(e)
59 }
60}
61
62pub fn load_signing_key(path: &Path) -> Result<SigningKey, GossipCryptoError> {
65 let bytes = fs::read(path)?;
66 if let Ok(text) = std::str::from_utf8(&bytes) {
67 if text.contains("-----BEGIN") {
68 return SigningKey::from_pkcs8_pem(text)
69 .map_err(|e| GossipCryptoError::Decode(format!("PKCS#8 PEM: {e}")));
70 }
71 }
72 SigningKey::from_pkcs8_der(&bytes)
73 .map_err(|e| GossipCryptoError::Decode(format!("PKCS#8 DER: {e}")))
74}
75
76pub fn load_verifying_key(path: &Path) -> Result<VerifyingKey, GossipCryptoError> {
79 let bytes = fs::read(path)?;
80 if let Ok(text) = std::str::from_utf8(&bytes) {
81 if text.contains("-----BEGIN") {
82 return VerifyingKey::from_public_key_pem(text)
83 .map_err(|e| GossipCryptoError::Decode(format!("SPKI PEM: {e}")));
84 }
85 }
86 VerifyingKey::from_public_key_der(&bytes)
87 .map_err(|e| GossipCryptoError::Decode(format!("SPKI DER: {e}")))
88}
89
90pub struct GossipSigner {
92 key: SigningKey,
93}
94
95impl GossipSigner {
96 pub fn new(key: SigningKey) -> Self {
97 Self { key }
98 }
99
100 pub fn verifying_key(&self) -> VerifyingKey {
103 self.key.verifying_key()
104 }
105
106 pub fn frame(&self, payload: &[u8]) -> Vec<u8> {
108 let sig = self.key.sign(payload);
109 let mut out = Vec::with_capacity(1 + SIG_LEN + payload.len());
110 out.push(SIGNED_TAG);
111 out.extend_from_slice(&sig.to_bytes());
112 out.extend_from_slice(payload);
113 out
114 }
115}
116
117#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119pub enum VerifyPolicy {
120 Permissive,
123 Required,
125}
126
127pub enum VerifyOutcome<'a> {
129 Accept(&'a [u8]),
132 Reject(&'static str),
134}
135
136pub struct GossipVerifier {
138 trusted: Vec<VerifyingKey>,
139 policy: VerifyPolicy,
140 identities: Vec<(String, VerifyingKey)>,
141}
142
143impl GossipVerifier {
144 #[cfg(test)]
145 pub(crate) fn new(trusted: Vec<VerifyingKey>, policy: VerifyPolicy) -> Self {
146 Self {
147 trusted,
148 policy,
149 identities: Vec::new(),
150 }
151 }
152
153 pub fn for_identities(identities: Vec<(String, VerifyingKey)>, policy: VerifyPolicy) -> Self {
155 Self {
156 trusted: identities.iter().map(|(_, key)| *key).collect(),
157 policy,
158 identities,
159 }
160 }
161
162 pub fn policy(&self) -> VerifyPolicy {
163 self.policy
164 }
165
166 pub fn check<'a>(&self, data: &'a [u8]) -> VerifyOutcome<'a> {
171 match data.first() {
172 Some(&SIGNED_TAG) => {
173 if data.len() < 1 + SIG_LEN {
174 return VerifyOutcome::Reject("truncated signed frame");
175 }
176 let sig_arr: [u8; SIG_LEN] = match data[1..1 + SIG_LEN].try_into() {
177 Ok(a) => a,
178 Err(_) => return VerifyOutcome::Reject("bad signature length"),
179 };
180 let sig = Signature::from_bytes(&sig_arr);
181 let payload = &data[1 + SIG_LEN..];
182 let ok = self.trusted.iter().any(|k| k.verify(payload, &sig).is_ok());
183 if ok {
184 if !self.identities.is_empty() {
185 let Ok(msg) =
186 serde_json::from_slice::<crate::broadcast::GossipMessage>(payload)
187 else {
188 return VerifyOutcome::Reject("invalid signed message");
189 };
190 if !self.identities.iter().any(|(name, key)| {
191 name == msg.origin_node() && key.verify_strict(payload, &sig).is_ok()
192 }) {
193 return VerifyOutcome::Reject("signing key not authorized for origin");
194 }
195 }
196 VerifyOutcome::Accept(payload)
197 } else {
198 VerifyOutcome::Reject("no trusted key verified signature")
199 }
200 }
201 _ => match self.policy {
203 VerifyPolicy::Permissive => VerifyOutcome::Accept(data),
204 VerifyPolicy::Required => {
205 VerifyOutcome::Reject("unsigned message under required-auth policy")
206 }
207 },
208 }
209 }
210}
211
212pub fn unwrap_frame(data: &[u8]) -> &[u8] {
227 if data.first() == Some(&SIGNED_TAG) && data.len() > SIG_LEN {
228 &data[1 + SIG_LEN..]
229 } else {
230 data
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use ed25519_dalek::SigningKey;
238
239 fn test_key(seed: u8) -> SigningKey {
240 SigningKey::from_bytes(&[seed; 32])
241 }
242
243 #[test]
244 fn authenticated_peer_cannot_claim_another_origin() {
245 let signer = GossipSigner::new(test_key(1));
246 let verifier = GossipVerifier::for_identities(
247 vec![("node-a".into(), signer.verifying_key())],
248 VerifyPolicy::Required,
249 );
250 let message = |origin: &str| {
251 serde_json::to_vec(&crate::broadcast::GossipMessage::UrgentRevocation {
252 producer_id: "p".into(),
253 issuer_key_hash: vec![1; 32],
254 epoch: 1,
255 origin_node: origin.into(),
256 })
257 .unwrap()
258 };
259 assert!(matches!(
260 verifier.check(&signer.frame(&message("node-a"))),
261 VerifyOutcome::Accept(_)
262 ));
263 for origin in ["node-b", ""] {
264 assert!(matches!(
265 verifier.check(&signer.frame(&message(origin))),
266 VerifyOutcome::Reject(_)
267 ));
268 }
269 }
270
271 #[test]
272 fn signed_frame_round_trips_and_verifies() {
273 let signer = GossipSigner::new(test_key(1));
274 let verifier = GossipVerifier::new(vec![signer.verifying_key()], VerifyPolicy::Required);
275
276 let payload = br#"{"GenerationAnnouncement":{"epoch":7}}"#;
277 let framed = signer.frame(payload);
278 assert_eq!(framed[0], SIGNED_TAG);
279
280 match verifier.check(&framed) {
281 VerifyOutcome::Accept(p) => assert_eq!(p, payload),
282 VerifyOutcome::Reject(r) => panic!("expected accept, got reject: {r}"),
283 }
284 }
285
286 #[test]
287 fn required_policy_drops_forged_signature() {
288 let attacker = GossipSigner::new(test_key(9));
290 let trusted =
291 GossipVerifier::new(vec![test_key(1).verifying_key()], VerifyPolicy::Required);
292 let framed = attacker.frame(br#"{"UrgentRevocation":{"epoch":1}}"#);
293 assert!(matches!(
294 trusted.check(&framed),
295 VerifyOutcome::Reject("no trusted key verified signature")
296 ));
297 }
298
299 #[test]
300 fn required_policy_drops_tampered_payload() {
301 let signer = GossipSigner::new(test_key(1));
302 let verifier = GossipVerifier::new(vec![signer.verifying_key()], VerifyPolicy::Required);
303 let mut framed = signer.frame(br#"{"epoch":1}"#);
304 let last = framed.len() - 1;
306 framed[last] ^= 0xFF;
307 assert!(matches!(verifier.check(&framed), VerifyOutcome::Reject(_)));
308 }
309
310 #[test]
311 fn required_policy_drops_unsigned_legacy() {
312 let verifier =
313 GossipVerifier::new(vec![test_key(1).verifying_key()], VerifyPolicy::Required);
314 let legacy = br#"{"GenerationAnnouncement":{"epoch":7}}"#;
315 assert!(matches!(
316 verifier.check(legacy),
317 VerifyOutcome::Reject("unsigned message under required-auth policy")
318 ));
319 }
320
321 #[test]
322 fn permissive_policy_accepts_unsigned_legacy() {
323 let verifier = GossipVerifier::new(vec![], VerifyPolicy::Permissive);
325 let legacy = br#"{"GenerationAnnouncement":{"epoch":7}}"#;
326 match verifier.check(legacy) {
327 VerifyOutcome::Accept(p) => assert_eq!(p, legacy),
328 VerifyOutcome::Reject(r) => panic!("permissive must accept legacy: {r}"),
329 }
330 }
331
332 #[test]
333 fn permissive_policy_still_drops_forged_signed() {
334 let attacker = GossipSigner::new(test_key(9));
337 let verifier =
338 GossipVerifier::new(vec![test_key(1).verifying_key()], VerifyPolicy::Permissive);
339 let framed = attacker.frame(br#"{"epoch":1}"#);
340 assert!(matches!(verifier.check(&framed), VerifyOutcome::Reject(_)));
341 }
342
343 #[test]
344 fn truncated_signed_frame_rejected() {
345 let verifier = GossipVerifier::new(vec![], VerifyPolicy::Permissive);
346 let truncated = [SIGNED_TAG, 0x00, 0x01, 0x02];
348 assert!(matches!(
349 verifier.check(&truncated),
350 VerifyOutcome::Reject("truncated signed frame")
351 ));
352 }
353}