1#[cfg(feature = "config")]
15use serde::{Deserialize, Serialize};
16
17use crate::policy::RosterEntry;
18
19const CANONICAL_VERSION: &str = "styrene-roster-v1";
21
22#[derive(Debug, Clone)]
24#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
25pub struct SignedRosterEntry {
26 pub entry: RosterEntry,
28 pub hub_hash: String,
30 pub hub_pubkey: String,
32 pub signature: String,
34 pub issued_at: i64,
36 #[cfg_attr(feature = "config", serde(default))]
38 pub expires_at: i64,
39}
40
41#[derive(Debug, Clone)]
43#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
44pub struct TrustedHub {
45 pub hub_hash: String,
47 pub hub_pubkey: String,
49 #[cfg_attr(feature = "config", serde(default))]
51 pub label: String,
52}
53
54impl SignedRosterEntry {
55 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 pub fn is_expired(&self, now_unix: i64) -> bool {
69 self.expires_at > 0 && now_unix > self.expires_at
70 }
71
72 #[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 #[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 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}