Skip to main content

styrene_rbac/
signed.rs

1//! Hub-signed roster entries — portable, cryptographically-verified role bindings.
2//!
3//! A `SignedRosterEntry` is a `RosterEntry` signed by a trusted hub's Ed25519 key.
4//! Nodes verify the signature against a list of trusted hub public keys before
5//! accepting the entry into their local RBAC policy.
6//!
7//! # Signing format
8//!
9//! Canonical bytes: `"styrene-roster-v1\n" + JSON(entry_fields) + "\nissued_at:" + issued_at`
10//!
11//! The signature covers the identity_hash, role, label, grants, and issued_at —
12//! binding the role assignment to a specific identity and point in time.
13
14#[cfg(feature = "config")]
15use serde::{Deserialize, Serialize};
16
17use crate::policy::RosterEntry;
18
19/// Version prefix for canonical signing format.
20const CANONICAL_VERSION: &str = "styrene-roster-v1";
21
22/// A roster entry signed by a trusted hub's Ed25519 key.
23#[derive(Debug, Clone)]
24#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
25pub struct SignedRosterEntry {
26    /// The roster entry being attested.
27    pub entry: RosterEntry,
28    /// Identity hash of the signing hub.
29    pub hub_hash: String,
30    /// Hub's Ed25519 public key (64 hex chars = 32 bytes).
31    pub hub_pubkey: String,
32    /// Ed25519 signature over canonical entry bytes (128 hex chars = 64 bytes).
33    pub signature: String,
34    /// When this entry was issued (Unix timestamp).
35    pub issued_at: i64,
36    /// When this entry expires (Unix timestamp, 0 = no expiry).
37    #[cfg_attr(feature = "config", serde(default))]
38    pub expires_at: i64,
39}
40
41/// A hub whose Ed25519 public key is trusted to sign roster entries.
42#[derive(Debug, Clone)]
43#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
44pub struct TrustedHub {
45    /// Identity hash of the hub (32 hex chars).
46    pub hub_hash: String,
47    /// Ed25519 public key (64 hex chars = 32 bytes).
48    pub hub_pubkey: String,
49    /// Human-readable label (e.g., "signum.styrene.io").
50    #[cfg_attr(feature = "config", serde(default))]
51    pub label: String,
52}
53
54impl SignedRosterEntry {
55    /// Build the canonical byte representation for signing/verification.
56    pub fn canonical_bytes(&self) -> Vec<u8> {
57        let entry_json = format!(
58            r#"{{"identity_hash":"{}","role":"{}","label":"{}","grants":[{}]}}"#,
59            self.entry.identity_hash.to_ascii_lowercase(),
60            self.entry.role.as_str(),
61            self.entry.label,
62            self.entry.grants().iter().map(|g| format!(r#""{g}""#)).collect::<Vec<_>>().join(","),
63        );
64        format!("{CANONICAL_VERSION}\n{entry_json}\nissued_at:{}", self.issued_at).into_bytes()
65    }
66
67    /// Check whether the entry has expired.
68    pub fn is_expired(&self, now_unix: i64) -> bool {
69        self.expires_at > 0 && now_unix > self.expires_at
70    }
71
72    /// Verify the Ed25519 signature against the embedded hub public key.
73    #[cfg(feature = "signing")]
74    pub fn verify(&self) -> bool {
75        let Some(pubkey_bytes) = hex_to_32_bytes(&self.hub_pubkey) else {
76            return false;
77        };
78        let Some(sig_bytes) = hex_to_64_bytes(&self.signature) else {
79            return false;
80        };
81        let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) else {
82            return false;
83        };
84        let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
85        let canonical = self.canonical_bytes();
86        use ed25519_dalek::Verifier;
87        verifying_key.verify(&canonical, &sig).is_ok()
88    }
89
90    /// Sign a roster entry with a hub's Ed25519 signing key.
91    #[cfg(feature = "signing")]
92    pub fn sign(
93        entry: RosterEntry,
94        signing_key: &ed25519_dalek::SigningKey,
95        issued_at: i64,
96        expires_at: i64,
97    ) -> Self {
98        use ed25519_dalek::Signer;
99        use sha2::{Digest, Sha256};
100
101        let hub_pubkey = hex::encode(signing_key.verifying_key().as_bytes());
102        let hub_hash = {
103            let digest = Sha256::digest(signing_key.verifying_key().as_bytes());
104            hex::encode(&digest[..16])
105        };
106        let mut signed =
107            Self { entry, hub_hash, hub_pubkey, signature: String::new(), issued_at, expires_at };
108        let canonical = signed.canonical_bytes();
109        let sig = signing_key.sign(&canonical);
110        signed.signature = hex::encode(sig.to_bytes());
111        signed
112    }
113}
114
115#[cfg(feature = "signing")]
116fn hex_to_32_bytes(hex_str: &str) -> Option<[u8; 32]> {
117    let bytes = hex::decode(hex_str).ok()?;
118    bytes.try_into().ok()
119}
120
121#[cfg(feature = "signing")]
122fn hex_to_64_bytes(hex_str: &str) -> Option<[u8; 64]> {
123    let bytes = hex::decode(hex_str).ok()?;
124    bytes.try_into().ok()
125}
126
127impl TrustedHub {
128    /// Check whether a SignedRosterEntry was signed by this hub.
129    pub fn matches(&self, entry: &SignedRosterEntry) -> bool {
130        self.hub_hash == entry.hub_hash && self.hub_pubkey == entry.hub_pubkey
131    }
132}
133
134#[cfg(all(test, feature = "signing"))]
135mod tests {
136    use super::*;
137    use crate::{Capability, Role};
138
139    fn test_signing_key() -> ed25519_dalek::SigningKey {
140        ed25519_dalek::SigningKey::from_bytes(&[0x42; 32])
141    }
142
143    #[test]
144    fn sign_and_verify_roundtrip() {
145        let key = test_signing_key();
146        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator)
147            .with_label("alice");
148        let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
149        assert!(signed.verify());
150        assert!(!signed.is_expired(2000));
151    }
152
153    #[test]
154    fn verify_rejects_tampered_role() {
155        let key = test_signing_key();
156        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
157        let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
158        signed.entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Admin);
159        assert!(!signed.verify());
160    }
161
162    #[test]
163    fn verify_rejects_tampered_identity() {
164        let key = test_signing_key();
165        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
166        let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
167        signed.entry = RosterEntry::new("bbbb2222cccc3333dddd4444eeee5555", Role::Operator);
168        assert!(!signed.verify());
169    }
170
171    #[test]
172    fn verify_rejects_wrong_key() {
173        let key = test_signing_key();
174        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
175        let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
176        let other_key = ed25519_dalek::SigningKey::from_bytes(&[0x99; 32]);
177        signed.hub_pubkey = hex::encode(other_key.verifying_key().as_bytes());
178        assert!(!signed.verify());
179    }
180
181    #[test]
182    fn expiry_check() {
183        let key = test_signing_key();
184        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
185        let signed = SignedRosterEntry::sign(entry, &key, 1000, 2000);
186        assert!(!signed.is_expired(1500));
187        assert!(signed.is_expired(2001));
188    }
189
190    #[test]
191    fn no_expiry_when_zero() {
192        let key = test_signing_key();
193        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
194        let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
195        assert!(!signed.is_expired(999_999_999));
196    }
197
198    #[test]
199    fn grants_included_in_canonical() {
200        let key = test_signing_key();
201        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator)
202            .with_grants(vec![Capability::VPN_HANDSHAKE.to_string()]);
203        let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
204        assert!(signed.verify());
205        let canonical = String::from_utf8(signed.canonical_bytes()).unwrap();
206        assert!(canonical.contains("vpn.handshake"));
207    }
208
209    #[test]
210    fn trusted_hub_matches() {
211        let key = test_signing_key();
212        let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
213        let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
214        let hub = TrustedHub {
215            hub_hash: signed.hub_hash.clone(),
216            hub_pubkey: signed.hub_pubkey.clone(),
217            label: "test-hub".into(),
218        };
219        assert!(hub.matches(&signed));
220        let wrong_hub = TrustedHub {
221            hub_hash: "wrong".into(),
222            hub_pubkey: signed.hub_pubkey.clone(),
223            label: "wrong".into(),
224        };
225        assert!(!wrong_hub.matches(&signed));
226    }
227}