#[cfg(feature = "config")]
use serde::{Deserialize, Serialize};
use crate::policy::RosterEntry;
const CANONICAL_VERSION: &str = "styrene-roster-v1";
#[derive(Debug, Clone)]
#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
pub struct SignedRosterEntry {
pub entry: RosterEntry,
pub hub_hash: String,
pub hub_pubkey: String,
pub signature: String,
pub issued_at: i64,
#[cfg_attr(feature = "config", serde(default))]
pub expires_at: i64,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "config", derive(Serialize, Deserialize))]
pub struct TrustedHub {
pub hub_hash: String,
pub hub_pubkey: String,
#[cfg_attr(feature = "config", serde(default))]
pub label: String,
}
impl SignedRosterEntry {
pub fn canonical_bytes(&self) -> Vec<u8> {
let entry_json = format!(
r#"{{"identity_hash":"{}","role":"{}","label":"{}","grants":[{}]}}"#,
self.entry.identity_hash.to_ascii_lowercase(),
self.entry.role.as_str(),
self.entry.label,
self.entry.grants().iter().map(|g| format!(r#""{g}""#)).collect::<Vec<_>>().join(","),
);
format!("{CANONICAL_VERSION}\n{entry_json}\nissued_at:{}", self.issued_at).into_bytes()
}
pub fn is_expired(&self, now_unix: i64) -> bool {
self.expires_at > 0 && now_unix > self.expires_at
}
#[cfg(feature = "signing")]
pub fn verify(&self) -> bool {
let Some(pubkey_bytes) = hex_to_32_bytes(&self.hub_pubkey) else {
return false;
};
let Some(sig_bytes) = hex_to_64_bytes(&self.signature) else {
return false;
};
let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pubkey_bytes) else {
return false;
};
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
let canonical = self.canonical_bytes();
use ed25519_dalek::Verifier;
verifying_key.verify(&canonical, &sig).is_ok()
}
#[cfg(feature = "signing")]
pub fn sign(
entry: RosterEntry,
signing_key: &ed25519_dalek::SigningKey,
issued_at: i64,
expires_at: i64,
) -> Self {
use ed25519_dalek::Signer;
use sha2::{Digest, Sha256};
let hub_pubkey = hex::encode(signing_key.verifying_key().as_bytes());
let hub_hash = {
let digest = Sha256::digest(signing_key.verifying_key().as_bytes());
hex::encode(&digest[..16])
};
let mut signed =
Self { entry, hub_hash, hub_pubkey, signature: String::new(), issued_at, expires_at };
let canonical = signed.canonical_bytes();
let sig = signing_key.sign(&canonical);
signed.signature = hex::encode(sig.to_bytes());
signed
}
}
#[cfg(feature = "signing")]
fn hex_to_32_bytes(hex_str: &str) -> Option<[u8; 32]> {
let bytes = hex::decode(hex_str).ok()?;
bytes.try_into().ok()
}
#[cfg(feature = "signing")]
fn hex_to_64_bytes(hex_str: &str) -> Option<[u8; 64]> {
let bytes = hex::decode(hex_str).ok()?;
bytes.try_into().ok()
}
impl TrustedHub {
pub fn matches(&self, entry: &SignedRosterEntry) -> bool {
self.hub_hash == entry.hub_hash && self.hub_pubkey == entry.hub_pubkey
}
}
#[cfg(all(test, feature = "signing"))]
mod tests {
use super::*;
use crate::{Capability, Role};
fn test_signing_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&[0x42; 32])
}
#[test]
fn sign_and_verify_roundtrip() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator)
.with_label("alice");
let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
assert!(signed.verify());
assert!(!signed.is_expired(2000));
}
#[test]
fn verify_rejects_tampered_role() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
signed.entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Admin);
assert!(!signed.verify());
}
#[test]
fn verify_rejects_tampered_identity() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
signed.entry = RosterEntry::new("bbbb2222cccc3333dddd4444eeee5555", Role::Operator);
assert!(!signed.verify());
}
#[test]
fn verify_rejects_wrong_key() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator);
let mut signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
let other_key = ed25519_dalek::SigningKey::from_bytes(&[0x99; 32]);
signed.hub_pubkey = hex::encode(other_key.verifying_key().as_bytes());
assert!(!signed.verify());
}
#[test]
fn expiry_check() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
let signed = SignedRosterEntry::sign(entry, &key, 1000, 2000);
assert!(!signed.is_expired(1500));
assert!(signed.is_expired(2001));
}
#[test]
fn no_expiry_when_zero() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
assert!(!signed.is_expired(999_999_999));
}
#[test]
fn grants_included_in_canonical() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Operator)
.with_grants(vec![Capability::VPN_HANDSHAKE.to_string()]);
let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
assert!(signed.verify());
let canonical = String::from_utf8(signed.canonical_bytes()).unwrap();
assert!(canonical.contains("vpn.handshake"));
}
#[test]
fn trusted_hub_matches() {
let key = test_signing_key();
let entry = RosterEntry::new("aaaa1111bbbb2222cccc3333dddd4444", Role::Peer);
let signed = SignedRosterEntry::sign(entry, &key, 1000, 0);
let hub = TrustedHub {
hub_hash: signed.hub_hash.clone(),
hub_pubkey: signed.hub_pubkey.clone(),
label: "test-hub".into(),
};
assert!(hub.matches(&signed));
let wrong_hub = TrustedHub {
hub_hash: "wrong".into(),
hub_pubkey: signed.hub_pubkey.clone(),
label: "wrong".into(),
};
assert!(!wrong_hub.matches(&signed));
}
}