use zeroize::Zeroizing;
use crate::crypto::{
from_hex, parse_x25519_pub, to_hex, unwrap_org_key, wrap_org_key, CryptoError, WrappedOrgKey,
};
use crate::oplog::Hlc;
use crate::org_key_directory::{MemberPublicKey, OrgKeyDirectory, OrgKeyDirectoryError};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use x25519_dalek::{PublicKey, StaticSecret};
const ALG_ROTATION_FLOOR: &str = "org-rotation-floor/v1";
#[derive(Debug)]
pub enum RotationError {
Crypto(CryptoError),
Directory(OrgKeyDirectoryError),
Untrusted,
NonMonotonic,
WrongAlgorithm(String),
BadSignature(String),
}
impl std::fmt::Display for RotationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RotationError::Crypto(e) => write!(f, "rotation crypto error: {e}"),
RotationError::Directory(e) => write!(f, "rotation directory error: {e}"),
RotationError::Untrusted => {
write!(f, "rotation floor not signed by a trusted granter")
}
RotationError::NonMonotonic => {
write!(f, "rotation floor epoch not strictly greater than previous")
}
RotationError::WrongAlgorithm(t) => {
write!(f, "rotation floor has unexpected algorithm tag: {t:?}")
}
RotationError::BadSignature(m) => write!(f, "rotation floor signature malformed: {m}"),
}
}
}
impl std::error::Error for RotationError {}
impl From<CryptoError> for RotationError {
fn from(e: CryptoError) -> Self {
RotationError::Crypto(e)
}
}
impl From<OrgKeyDirectoryError> for RotationError {
fn from(e: OrgKeyDirectoryError) -> Self {
RotationError::Directory(e)
}
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RotationFloor {
pub car_floor: String,
pub org: String,
pub floor_epoch: u64,
pub rotation_hlc: Hlc,
pub prev_floor_epoch: u64,
pub signature: String,
}
fn rotation_floor_transcript(
org: &str,
floor_epoch: u64,
rotation_hlc: &Hlc,
prev_floor_epoch: u64,
) -> Vec<u8> {
fn lp(buf: &mut Vec<u8>, field: &[u8]) {
buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
buf.extend_from_slice(field);
}
let mut t = Vec::new();
lp(&mut t, ALG_ROTATION_FLOOR.as_bytes());
lp(&mut t, org.as_bytes());
t.extend_from_slice(&floor_epoch.to_le_bytes());
t.extend_from_slice(&rotation_hlc.wall_ms.to_le_bytes());
t.extend_from_slice(&rotation_hlc.counter.to_le_bytes());
lp(&mut t, rotation_hlc.device_id.as_bytes());
t.extend_from_slice(&prev_floor_epoch.to_le_bytes());
t
}
pub fn sign_rotation_floor(
org: &str,
floor_epoch: u64,
rotation_hlc: Hlc,
prev_floor_epoch: u64,
signer: &SigningKey,
) -> RotationFloor {
let sig = signer.sign(&rotation_floor_transcript(
org,
floor_epoch,
&rotation_hlc,
prev_floor_epoch,
));
RotationFloor {
car_floor: ALG_ROTATION_FLOOR.to_string(),
org: org.to_string(),
floor_epoch,
rotation_hlc,
prev_floor_epoch,
signature: to_hex(&sig.to_bytes()),
}
}
pub fn verify_rotation_floor(
floor: &RotationFloor,
trusted: &[VerifyingKey],
) -> Result<u64, RotationError> {
if floor.car_floor != ALG_ROTATION_FLOOR {
return Err(RotationError::WrongAlgorithm(floor.car_floor.clone()));
}
if floor.floor_epoch <= floor.prev_floor_epoch {
return Err(RotationError::NonMonotonic);
}
let sig_bytes: [u8; 64] = from_hex(&floor.signature)
.map_err(RotationError::BadSignature)?
.try_into()
.map_err(|_| RotationError::BadSignature("signature must be 64 bytes".into()))?;
let signature = Signature::from_bytes(&sig_bytes);
let transcript = rotation_floor_transcript(
&floor.org,
floor.floor_epoch,
&floor.rotation_hlc,
floor.prev_floor_epoch,
);
let ok = trusted
.iter()
.any(|vk| vk.verify_strict(&transcript, &signature).is_ok());
if ok {
Ok(floor.floor_epoch)
} else {
Err(RotationError::Untrusted)
}
}
pub fn is_authoritative(kid: Option<u64>, hlc: &Hlc, floor: &RotationFloor) -> bool {
if let Some(k) = kid {
if k >= floor.floor_epoch {
return true;
}
}
*hlc <= floor.rotation_hlc
}
pub fn rotate_org_key(
directory: &mut dyn OrgKeyDirectory,
org: &str,
new_epoch: u64,
remaining: &[(&str, &PublicKey)],
k_org_new: &[u8; 32],
granter_user_id: &str,
granter_signer: &SigningKey,
) -> Result<(), RotationError> {
for (recipient_id, recipient_pub) in remaining {
let wrapped = wrap_org_key(
k_org_new,
org,
new_epoch,
recipient_id,
recipient_pub,
granter_user_id,
granter_signer,
)?;
directory.publish_wrapped(&wrapped)?;
}
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct ProvisionReport {
pub granted: Vec<String>,
pub skipped: Vec<(String, String)>,
}
pub fn provision_org_members(
directory: &mut dyn OrgKeyDirectory,
org: &str,
epoch: u64,
k_org: &[u8; 32],
members: &[MemberPublicKey],
granter_user_id: &str,
granter_signer: &SigningKey,
) -> ProvisionReport {
let mut report = ProvisionReport::default();
for m in members {
let recipient_pub = match parse_x25519_pub(&m.public_hex) {
Ok(p) => p,
Err(e) => {
report
.skipped
.push((m.account_id.clone(), format!("malformed pubkey: {e}")));
continue;
}
};
let wrapped = match wrap_org_key(
k_org,
org,
epoch,
&m.account_id,
&recipient_pub,
granter_user_id,
granter_signer,
) {
Ok(w) => w,
Err(e) => {
report
.skipped
.push((m.account_id.clone(), format!("wrap failed: {e}")));
continue;
}
};
match directory.publish_wrapped(&wrapped) {
Ok(()) => report.granted.push(m.account_id.clone()),
Err(e) => report
.skipped
.push((m.account_id.clone(), format!("publish failed: {e}"))),
}
}
report
}
pub fn resolve_all_org_roots(
directory: &dyn OrgKeyDirectory,
org: &str,
my_secret: &StaticSecret,
my_user_id: &str,
trusted: &[VerifyingKey],
) -> Result<std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>, OrgKeyDirectoryError> {
let mut roots = std::collections::BTreeMap::new();
let candidates: Vec<WrappedOrgKey> = directory.fetch_wrapped_for(my_user_id)?;
for w in candidates {
if w.org != org {
continue;
}
if roots.contains_key(&w.epoch) {
continue;
}
if let Ok(k) = unwrap_org_key(&w, my_secret, my_user_id, trusted) {
roots.insert(w.epoch, Zeroizing::new(k));
}
}
Ok(roots)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::{ed25519_verifying, generate_org_key, x25519_public, StretchedMaster};
use crate::org_key_directory::InMemoryOrgKeyDirectory;
fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
crate::crypto::derive_x25519_identity(
&StretchedMaster::from_issued_high_entropy(secret, user),
user,
)
}
fn ed25519_id(secret: &[u8], user: &str) -> SigningKey {
crate::crypto::derive_ed25519_identity(
&StretchedMaster::from_issued_high_entropy(secret, user),
user,
)
}
fn granter() -> SigningKey {
ed25519_id(b"granter-login", "acc_granter")
}
fn hlc(wall_ms: u64, counter: u32) -> Hlc {
Hlc {
wall_ms,
counter,
device_id: "dev-a".into(),
}
}
fn hex_of(pk: &x25519_dalek::PublicKey) -> String {
pk.as_bytes().iter().map(|b| format!("{b:02x}")).collect()
}
#[test]
fn rotation_grants_remaining_members_and_omits_the_removed() {
let mut dir = InMemoryOrgKeyDirectory::new();
let g = granter();
let alice = x25519_id(b"alice", "acc_alice");
let bob = x25519_id(b"bob", "acc_bob");
let carol = x25519_id(b"carol", "acc_carol");
let trusted = [ed25519_verifying(&g)];
let k1 = [1u8; 32];
for (id, sk) in [
("acc_alice", &alice),
("acc_bob", &bob),
("acc_carol", &carol),
] {
let w =
wrap_org_key(&k1, "acme", 1, id, &x25519_public(sk), "acc_granter", &g).unwrap();
dir.publish_wrapped(&w).unwrap();
}
let k2 = generate_org_key();
assert_ne!(*k2, k1, "new epoch key is independent of the old");
rotate_org_key(
&mut dir,
"acme",
2,
&[
("acc_alice", &x25519_public(&alice)),
("acc_bob", &x25519_public(&bob)),
],
&k2,
"acc_granter",
&g,
)
.unwrap();
let a_roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
assert_eq!(a_roots.keys().copied().collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(*a_roots[&1], k1);
assert_eq!(*a_roots[&2], *k2);
let c_roots = resolve_all_org_roots(&dir, "acme", &carol, "acc_carol", &trusted).unwrap();
assert_eq!(c_roots.keys().copied().collect::<Vec<_>>(), vec![1]);
assert!(
!c_roots.contains_key(&2),
"removed member gets no new epoch"
);
}
#[test]
fn provision_over_member_pubkeys_grants_and_skips_malformed() {
let mut dir = InMemoryOrgKeyDirectory::new();
let g = granter();
let trusted = [ed25519_verifying(&g)];
let alice = x25519_id(b"alice", "acc_alice");
let bob = x25519_id(b"bob", "acc_bob");
let members = vec![
MemberPublicKey {
account_id: "acc_alice".into(),
public_hex: hex_of(&x25519_public(&alice)),
},
MemberPublicKey {
account_id: "acc_bob".into(),
public_hex: hex_of(&x25519_public(&bob)),
},
MemberPublicKey {
account_id: "acc_broken".into(),
public_hex: "not-hex".into(),
},
MemberPublicKey {
account_id: "acc_loworder".into(),
public_hex: "0".repeat(64),
},
];
let k = [3u8; 32];
let report = provision_org_members(&mut dir, "acme", 1, &k, &members, "acc_granter", &g);
assert_eq!(
report.granted,
vec!["acc_alice", "acc_bob"],
"well-formed granted"
);
let skipped_ids: Vec<&str> = report.skipped.iter().map(|(id, _)| id.as_str()).collect();
assert_eq!(skipped_ids, vec!["acc_broken", "acc_loworder"]);
assert!(report.skipped.iter().all(|(_, why)| !why.is_empty()));
let a = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
assert_eq!(*a[&1], k);
let broken = x25519_id(b"broken", "acc_broken");
assert!(
resolve_all_org_roots(&dir, "acme", &broken, "acc_broken", &trusted)
.unwrap()
.is_empty()
);
}
#[test]
fn generate_org_key_is_independent_across_draws() {
assert_ne!(*generate_org_key(), *generate_org_key());
}
#[test]
fn resolve_skips_untrusted_higher_epoch_but_keeps_real_one() {
let mut dir = InMemoryOrgKeyDirectory::new();
let g = granter();
let mallory = ed25519_id(b"mallory", "acc_mallory");
let alice = x25519_id(b"alice", "acc_alice");
let trusted = [ed25519_verifying(&g)];
let real = wrap_org_key(
&[7u8; 32],
"acme",
1,
"acc_alice",
&x25519_public(&alice),
"acc_granter",
&g,
)
.unwrap();
dir.publish_wrapped(&real).unwrap();
let bogus = wrap_org_key(
&[9u8; 32],
"acme",
9,
"acc_alice",
&x25519_public(&alice),
"acc_mallory",
&mallory,
)
.unwrap();
dir.publish_wrapped(&bogus).unwrap();
let roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
assert_eq!(roots.keys().copied().collect::<Vec<_>>(), vec![1]);
assert_eq!(*roots[&1], [7u8; 32]);
assert!(
!roots.contains_key(&9),
"untrusted higher epoch is skipped, not adopted"
);
}
#[test]
fn resolve_all_wrong_org_wrap_is_skipped() {
let mut dir = InMemoryOrgKeyDirectory::new();
let g = granter();
let alice = x25519_id(b"alice", "acc_alice");
let trusted = [ed25519_verifying(&g)];
let w = wrap_org_key(
&[5u8; 32],
"other",
1,
"acc_alice",
&x25519_public(&alice),
"acc_granter",
&g,
)
.unwrap();
dir.publish_wrapped(&w).unwrap();
let roots = resolve_all_org_roots(&dir, "acme", &alice, "acc_alice", &trusted).unwrap();
assert!(roots.is_empty(), "wrong-org wrap skipped");
}
#[test]
fn floor_signed_by_trusted_granter_verifies() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
assert_eq!(
verify_rotation_floor(&floor, &[ed25519_verifying(&g)]).unwrap(),
2
);
}
#[test]
fn floor_from_untrusted_signer_is_rejected() {
let mallory = ed25519_id(b"mallory", "acc_mallory");
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &mallory);
assert!(matches!(
verify_rotation_floor(&floor, &[ed25519_verifying(&granter())]),
Err(RotationError::Untrusted)
));
}
#[test]
fn non_monotonic_floor_is_rejected_even_if_signed() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 2, &g);
assert!(matches!(
verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
Err(RotationError::NonMonotonic)
));
}
#[test]
fn tampered_floor_field_fails_verification() {
let g = granter();
let mut floor = sign_rotation_floor("acme", 5, hlc(100, 0), 1, &g);
floor.floor_epoch = 6;
assert!(matches!(
verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
Err(RotationError::Untrusted)
));
}
#[test]
fn floor_with_wrong_algorithm_tag_is_rejected() {
let g = granter();
let mut floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
floor.car_floor = "org-key-wrap/v2".into();
assert!(matches!(
verify_rotation_floor(&floor, &[ed25519_verifying(&g)]),
Err(RotationError::WrongAlgorithm(_))
));
}
#[test]
fn older_valid_floor_still_verifies_anti_rollback_is_the_callers_job() {
let g = granter();
let trusted = [ed25519_verifying(&g)];
let advanced = sign_rotation_floor("acme", 5, hlc(500, 0), 4, &g);
let stale = sign_rotation_floor("acme", 2, hlc(200, 0), 1, &g);
assert_eq!(verify_rotation_floor(&advanced, &trusted).unwrap(), 5);
assert_eq!(
verify_rotation_floor(&stale, &trusted).unwrap(),
2,
"stale-but-valid floor verifies; rollback defense is external"
);
}
#[test]
fn authoritative_accepts_new_epoch_regardless_of_time() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
assert!(is_authoritative(Some(2), &hlc(9_999, 0), &floor));
assert!(is_authoritative(Some(3), &hlc(9_999, 0), &floor));
}
#[test]
fn authoritative_keeps_precut_history_readable() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
assert!(is_authoritative(Some(1), &hlc(50, 0), &floor));
assert!(is_authoritative(Some(1), &hlc(100, 0), &floor));
}
#[test]
fn authoritative_rejects_postcut_stale_epoch_write() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
assert!(!is_authoritative(Some(1), &hlc(101, 0), &floor));
assert!(!is_authoritative(None, &hlc(101, 0), &floor));
}
#[test]
fn malicious_backdate_is_a_named_limitation_not_a_fix() {
let g = granter();
let floor = sign_rotation_floor("acme", 2, hlc(100, 0), 1, &g);
let backdated = hlc(50, 0); assert!(
is_authoritative(Some(1), &backdated, &floor),
"backdated forgery slips the HLC half — known, audit-deferred limitation"
);
}
}