Skip to main content

hoike_gossip/
crypto.rs

1//! Ed25519 message authentication for gossip broadcasts (design §6.3, FPT_ITT.1).
2//!
3//! SWIM membership traffic (foca's postcard-coded pings/acks) is left alone; this
4//! layer wraps only the `GossipMessage` *custom broadcasts* — the payloads that
5//! announce new signed artifacts and drive bundle propagation. Those are the
6//! messages that carry a trust decision ("epoch N exists for this scope"), so
7//! those are the ones that must be attributable to a known fleet member.
8//!
9//! ## Wire framing
10//!
11//! A signed broadcast is `[TAG][64-byte Ed25519 signature over payload][payload]`
12//! where `payload` is the exact JSON `GossipMessage` bytes an unsigned node would
13//! emit. Legacy (unsigned) payloads are raw JSON and therefore always begin with
14//! `{` (0x7B); the [`SIGNED_TAG`] byte (0x01) can never collide with that, so a
15//! receiver can tell signed from unsigned without a version handshake. The
16//! signature travels with the message, so a relaying node re-broadcasts the
17//! *original signer's* signature — every hop verifies the origin, not the last
18//! hop.
19//!
20//! ## Rollout policy
21//!
22//! `identity_key` signs outbound messages. `peer_identities` binds each trusted
23//! key to its node name; a nonempty mapping requires authenticated broadcasts.
24//! An empty mapping retains unsigned rollout compatibility. Legacy `peer_keys`
25//! is rejected at startup because a key list cannot authorize node identities.
26
27use std::fs;
28use std::path::Path;
29
30use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey};
31use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
32
33/// Wire tag for a signed broadcast frame. Chosen so it can never collide with a
34/// legacy raw-JSON payload, which always starts with `{` (0x7B).
35pub const SIGNED_TAG: u8 = 0x01;
36const SIG_LEN: usize = 64;
37
38/// Error loading an Ed25519 gossip key.
39#[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
62/// Load an Ed25519 PKCS#8 private key. Accepts PEM (`-----BEGIN PRIVATE KEY-----`)
63/// or raw DER, chosen by sniffing for the PEM armor.
64pub 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
76/// Load an Ed25519 SPKI public key. Accepts PEM (`-----BEGIN PUBLIC KEY-----`)
77/// or raw DER.
78pub 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
90/// Signs outbound gossip broadcasts with this node's identity key.
91pub struct GossipSigner {
92    key: SigningKey,
93}
94
95impl GossipSigner {
96    pub fn new(key: SigningKey) -> Self {
97        Self { key }
98    }
99
100    /// This signer's public half — folded into the local verifier's trusted set
101    /// so a node accepts its own broadcasts echoed back through the mesh.
102    pub fn verifying_key(&self) -> VerifyingKey {
103        self.key.verifying_key()
104    }
105
106    /// Wrap a raw JSON payload in a signed frame: `[TAG][sig][payload]`.
107    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/// Inbound verification stance — see the module-level rollout policy.
118#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119pub enum VerifyPolicy {
120    /// Accept unsigned (legacy) messages; a signed-but-invalid message is still
121    /// dropped. Used while the fleet still contains unsigned nodes.
122    Permissive,
123    /// Drop anything without a valid signature from a trusted key.
124    Required,
125}
126
127/// Result of checking an inbound broadcast frame.
128pub enum VerifyOutcome<'a> {
129    /// The inner JSON payload to decode (signature verified, or unsigned under a
130    /// permissive policy).
131    Accept(&'a [u8]),
132    /// Drop the message; the reason is a static string for logging.
133    Reject(&'static str),
134}
135
136/// Verifies inbound gossip broadcasts against a set of trusted Ed25519 keys.
137pub 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    /// Bind a verified key to the origin claimed in the signed payload.
154    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    /// Classify a raw broadcast frame, returning the payload to decode or a drop
167    /// reason. A signed frame (leading [`SIGNED_TAG`]) must verify against a
168    /// trusted key regardless of policy; an unsigned frame is admitted only when
169    /// the policy is [`VerifyPolicy::Permissive`].
170    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            // Legacy / unsigned raw JSON (or empty).
202            _ => 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
212/// Strip a signed frame's tag + signature *without verifying it*, returning the
213/// inner JSON payload; pass any unsigned/raw payload through unchanged.
214///
215/// This is the decode path for a node that has opted out of verification
216/// entirely (`verifier: None` — neither an `identity_key` nor `peer_keys`
217/// configured). Such a node must still understand the signed wire format enough
218/// to reach the inner payload, or it would fail to decode *every* framed
219/// broadcast a signing peer emits during a rolling upgrade — exactly the
220/// "mixed fleet stays interoperable" property the [`SIGNED_TAG`] framing is
221/// meant to provide. Accepting the unverified payload is no weaker than the
222/// unsigned messages this node already accepts (an attacker could send the same
223/// bytes unsigned), so it opens no new attack surface. A node that wants
224/// authentication configures `peer_keys` and gets a real [`GossipVerifier`]
225/// instead of this pass-through.
226pub 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        // Framed by an attacker key the verifier does not trust.
289        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        // Flip a byte in the payload region (after tag + signature).
305        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        // Empty trusted set + permissive == today's behavior during rollout.
324        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        // Even permissive nodes must not honor a signed message from an untrusted
335        // key — otherwise signing would be worse than useless during rollout.
336        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        // Tag present but no room for a full signature.
347        let truncated = [SIGNED_TAG, 0x00, 0x01, 0x02];
348        assert!(matches!(
349            verifier.check(&truncated),
350            VerifyOutcome::Reject("truncated signed frame")
351        ));
352    }
353}