pub const MEMBERSHIP_NAMESPACE: &str = "gunnar-membership";
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MembershipAction {
Add,
Remove,
}
impl MembershipAction {
pub fn tag(&self) -> &'static str {
match self {
MembershipAction::Add => "add",
MembershipAction::Remove => "remove",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MembershipEvent {
pub group: String,
pub member: String,
pub action: MembershipAction,
pub actor: String,
pub at_unix_ms: i64,
}
impl MembershipEvent {
pub fn signing_bytes(&self) -> Vec<u8> {
let mut out = String::new();
out.push_str("gunnar-membership-v1\n");
out.push_str(&format!("action {}\n", self.action.tag()));
out.push_str(&format!("at {}\n", self.at_unix_ms));
let mut field = |name: &str, value: &str| {
out.push_str(&format!("{name} {} {value}\n", value.len()));
};
field("group", &self.group);
field("member", &self.member);
field("actor", &self.actor);
out.into_bytes()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedMembershipEvent {
#[serde(flatten)]
pub event: MembershipEvent,
pub key_fingerprint: String,
pub signature: String,
}
pub fn sign_event(
key: &ssh_key::PrivateKey,
event: MembershipEvent,
) -> Result<SignedMembershipEvent, ssh_key::Error> {
let (key_fingerprint, signature) =
crate::sshsig::sign(key, MEMBERSHIP_NAMESPACE, &event.signing_bytes())?;
Ok(SignedMembershipEvent {
key_fingerprint,
signature,
event,
})
}
pub fn verify_event(
signed: &SignedMembershipEvent,
key: &ssh_key::PublicKey,
) -> Result<(), crate::sshsig::EventProblem> {
crate::sshsig::verify(
key,
MEMBERSHIP_NAMESPACE,
&signed.event.signing_bytes(),
&signed.key_fingerprint,
&signed.signature,
)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Membership {
pub group: String,
pub members: std::collections::BTreeMap<String, (String, i64)>,
}
impl Membership {
pub fn contains(&self, member: &str) -> bool {
self.members.contains_key(member)
}
}
pub fn fold_at(group: &str, as_of_unix_ms: i64, events: &[MembershipEvent]) -> Membership {
let mut ordered: Vec<(usize, &MembershipEvent)> = events
.iter()
.enumerate()
.filter(|(_, e)| e.group == group && e.at_unix_ms <= as_of_unix_ms)
.collect();
ordered.sort_by_key(|(i, e)| (e.at_unix_ms, *i));
let mut state = Membership {
group: group.to_owned(),
..Membership::default()
};
for (_, event) in ordered {
match event.action {
MembershipAction::Add => {
state.members.insert(
event.member.clone(),
(event.actor.clone(), event.at_unix_ms),
);
}
MembershipAction::Remove => {
state.members.remove(&event.member);
}
}
}
state
}
pub fn fold(group: &str, events: &[MembershipEvent]) -> Membership {
fold_at(group, i64::MAX, events)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sshsig::EventProblem;
fn key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
}
fn event(
group: &str,
member: &str,
action: MembershipAction,
actor: &str,
at: i64,
) -> MembershipEvent {
MembershipEvent {
group: group.into(),
member: member.into(),
action,
actor: actor.into(),
at_unix_ms: at,
}
}
#[test]
fn a_membership_change_verifies_and_no_field_can_be_moved_under_it() {
let admin = key();
let mallory = key();
let signed = sign_event(
&admin,
event(
"admins",
"alice",
MembershipAction::Add,
"root",
1_700_000_000_000,
),
)
.expect("sign");
assert_eq!(verify_event(&signed, admin.public_key()), Ok(()));
assert!(
matches!(
verify_event(&signed, mallory.public_key()),
Err(EventProblem::WrongKey { .. })
),
"a membership event verified against a key that did not sign it"
);
for mutate in [
|e: &mut MembershipEvent| e.action = MembershipAction::Remove,
|e: &mut MembershipEvent| e.member = "mallory".into(),
|e: &mut MembershipEvent| e.group = "everyone".into(),
|e: &mut MembershipEvent| e.actor = "mallory".into(),
|e: &mut MembershipEvent| e.at_unix_ms += 1,
] {
let mut tampered = signed.clone();
mutate(&mut tampered.event);
assert_eq!(
verify_event(&tampered, admin.public_key()),
Err(EventProblem::NotVerified),
"a field moved under the signature and still verified: {:?}",
tampered.event
);
}
}
#[test]
fn a_membership_signature_does_not_verify_as_an_approval() {
let admin = key();
let ev = event("admins", "alice", MembershipAction::Add, "root", 1);
let signed = sign_event(&admin, ev.clone()).expect("sign");
let smuggled = crate::forge::SignedEvent {
event: crate::forge::ForgeEvent {
repo: "team/repo".into(),
pull: 1,
actor: "root".into(),
at_unix_ms: 1,
event: crate::forge::Event::Approved {
commit: "a".repeat(40),
},
},
key_fingerprint: signed.key_fingerprint.clone(),
signature: signed.signature.clone(),
attested: None,
};
assert!(
matches!(
crate::forge::verify_event(&smuggled, admin.public_key()),
Err(EventProblem::Malformed(_))
),
"a membership signature was accepted as a forge approval — namespaces did not isolate"
);
let elsewhere = admin
.sign("git", ssh_key::HashAlg::Sha512, &ev.signing_bytes())
.expect("sign under git");
let as_membership = SignedMembershipEvent {
key_fingerprint: crate::sshsig::fingerprint(admin.public_key()),
signature: elsewhere.to_pem(ssh_key::LineEnding::LF).expect("pem"),
event: ev,
};
assert!(
matches!(
verify_event(&as_membership, admin.public_key()),
Err(EventProblem::Malformed(_))
),
"a git commit signature was accepted as a membership event"
);
}
#[test]
fn a_value_cannot_smuggle_a_field() {
let honest = event("admins", "alice", MembershipAction::Add, "root", 1);
let sneaky = event(
"admins",
"alice\nactor mallory",
MembershipAction::Add,
"root",
1,
);
assert_ne!(honest.signing_bytes(), sneaky.signing_bytes());
let text = String::from_utf8(sneaky.signing_bytes()).expect("utf-8");
assert!(
text.contains("member 19 alice\nactor mallory"),
"the member id must be length-prefixed: {text}"
);
}
#[test]
fn remove_then_readd_leaves_two_events_and_time_is_answerable() {
let events = vec![
event("admins", "alice", MembershipAction::Add, "root", 100),
event("admins", "bob", MembershipAction::Add, "root", 150),
event("admins", "alice", MembershipAction::Remove, "root", 200),
event("admins", "alice", MembershipAction::Add, "carol", 300),
];
let at_120 = fold_at("admins", 120, &events);
assert!(at_120.contains("alice"));
assert!(!at_120.contains("bob"));
let at_250 = fold_at("admins", 250, &events);
assert!(
!at_250.contains("alice"),
"the removal at T=200 was not honoured"
);
assert!(at_250.contains("bob"));
let now = fold("admins", &events);
assert!(now.contains("alice"));
assert_eq!(now.members.get("alice"), Some(&("carol".to_string(), 300)));
assert_eq!(now.members.get("bob"), Some(&("root".to_string(), 150)));
}
#[test]
fn a_fold_is_scoped_to_its_group() {
let events = vec![
event("admins", "alice", MembershipAction::Add, "root", 1),
event("release", "alice", MembershipAction::Add, "root", 2),
];
let release = fold("release", &events);
assert!(release.contains("alice"));
assert_eq!(
release.members.len(),
1,
"an event for another group leaked in"
);
}
}