Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-core/src/membership.rs

//! **Group membership as signed, append-only evidence** (punkt 9, the signing
//! half).
//!
//! [`crate::auth`] owns *who is in a group right now*: the `memberships` map in
//! the identity table, under the control store's compare-and-swap, is the
//! authority the SSH listener resolves a grant through, and it must be — a
//! membership check on the connection path is one shard probe, not a walk of a
//! log.
//!
//! This module owns the other half: **who changed that membership, and when**,
//! as a signature. A row saying `alice is in admins` is the server's word; an
//! `sshsig` by the acting admin over `add alice to admins at T` is the admin's,
//! and a third party can check it later with `ssh-keygen -Y verify` and the
//! admin's registered public key — no shared secret, no trust in the server
//! that stored it. That is the whole of what gunnar promises about access: not
//! "trust our database", but "here is the evidence, check it yourself".
//!
//! It is the exact shape [`crate::forge`] gives a review approval, and it signs
//! through the same one signer ([`crate::sshsig`], one implementation). The difference is
//! only what is bound and under which namespace:
//!
//! # Events, not state
//!
//! What is signed is *what happened* — `add` or `remove`, naming the group, the
//! member, the actor and the time — and the membership at any instant is
//! [`fold`]ed from the events rather than read from a mutable row. Remove a
//! member and re-add them and the log holds **two** signed events, both
//! standing: "who was in this group at time T" is answered from the log, and
//! the answer to "was alice ever removed" is a fact nobody can quietly erase by
//! writing the row back. A mutable row cannot answer either.
//!
//! # Its own namespace
//!
//! [`MEMBERSHIP_NAMESPACE`] is distinct from every other, so a membership
//! signature can never be replayed as a forge approval, a
//! tree head or a `git` commit — [`crate::sshsig::verify`] binds the namespace
//! and refuses a blob made under any other. The red for that is
//! `a_membership_signature_does_not_verify_as_an_approval` below.

/// The `sshsig` namespace a membership event is signed under.
///
/// Its own, like every signed log in this crate. A signature made here does not
/// verify under [`crate::forge::FORGE_NAMESPACE`], or under `git` — which is what
/// stops "admin added alice to the release group" from being cut out and
/// pasted in as "admin approved commit abc".
pub const MEMBERSHIP_NAMESPACE: &str = "gunnar-membership";

/// What happened to a membership.
///
/// A closed set: an open `kind: String` would let a client write an event no
/// reader can fold, and a log whose state depends on who reads it is not
/// evidence.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MembershipAction {
    /// The member was added to the group.
    Add,
    /// The member was removed from the group.
    Remove,
}

impl MembershipAction {
    /// The word bound into the signature, and the word a reader sees.
    pub fn tag(&self) -> &'static str {
        match self {
            MembershipAction::Add => "add",
            MembershipAction::Remove => "remove",
        }
    }
}

/// One change to one group's membership, before it is signed.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MembershipEvent {
    /// The group whose membership changed — an ACL principal of kind
    /// [`Group`](crate::PrincipalKind::Group).
    pub group: String,
    /// The principal added to or removed from it (another group is allowed —
    /// that is nesting).
    pub member: String,
    /// `add` or `remove`.
    pub action: MembershipAction,
    /// **Who did it** — the ACL principal id of the acting admin. Bound into the
    /// signature, and checked at the seam against the principal the credential
    /// resolved to, so a membership change cannot be filed in another admin's
    /// name.
    pub actor: String,
    /// When.
    pub at_unix_ms: i64,
}

impl MembershipEvent {
    /// The exact bytes that are signed.
    ///
    /// Line-oriented and fixed-order, and every value length-prefixed, for the
    /// reason [`crate::forge::ForgeEvent::signing_bytes`] is: JSON has no
    /// canonical form, so signing a serialisation would make the signature
    /// depend on the serialiser, and a bare newline in a value could otherwise
    /// be made to look like a second field. A principal id is constrained
    /// elsewhere, but the log must not rest a signature's meaning on that
    /// holding.
    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()
    }
}

/// A [`MembershipEvent`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedMembershipEvent {
    /// The event the signature is over.
    #[serde(flatten)]
    pub event: MembershipEvent,
    /// `SHA256:…` of the actor's key.
    pub key_fingerprint: String,
    /// The `sshsig`, PEM-armoured.
    pub signature: String,
}

/// Sign a membership event with the actor's own key.
///
/// Through the one signer ([`crate::sshsig::sign`]) so the namespace is bound
/// into the signature exactly as it is for a forge approval (one implementation).
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,
    })
}

/// Verify a signed membership event against a public key.
///
/// The in-process twin of `ssh-keygen -Y verify -n gunnar-membership`: the same
/// namespace binding, the same refusal of a signature made under any other.
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,
    )
}

/// **Who is a member of one group right now**, folded from its events.
///
/// Direct members only — nesting and the transitive closure are
/// [`crate::auth::AuthTable`]'s to resolve, because that is the question the
/// connection path asks and it must not be a walk of this log. What this
/// answers is the audit question: *who is in `group`, and by whose hand*.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Membership {
    /// The group this is the membership of.
    pub group: String,
    /// Current direct members → the provenance of the add that put them there:
    /// `(actor, at_unix_ms)`. A later `remove` takes the entry out; a later
    /// `add` replaces the provenance, so the map always names the standing
    /// reason each member is in the group.
    pub members: std::collections::BTreeMap<String, (String, i64)>,
}

impl Membership {
    /// Whether `member` is a direct member of the group right now.
    pub fn contains(&self, member: &str) -> bool {
        self.members.contains_key(member)
    }
}

/// Fold one group's membership events, **up to and including** `as_of_unix_ms`.
///
/// Pass `i64::MAX` for "now". Events for other groups are ignored, so a caller
/// may hand in the whole server's log; events strictly after the cutoff are
/// ignored, which is what makes *"who was a member at time T"* answerable from
/// the signed log rather than from a mutable row that only remembers the
/// present.
///
/// Ordering is by the event's own `at_unix_ms`, then by position in the slice,
/// so a stable append order breaks ties the way the log was written.
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
}

/// The membership of `group` **right now** — [`fold_at`] with no cutoff.
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,
        }
    }

    /// **A membership change is evidence**: signed by the acting admin, over the
    /// group, the member, the action, the actor and the time — and no field can
    /// be moved under the signature.
    #[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");

        // Green: the actor's own key verifies it.
        assert_eq!(verify_event(&signed, admin.public_key()), Ok(()));

        // Red: a DIFFERENT key must be refused — a membership event signed by
        // the wrong key is not evidence about the actor it names.
        assert!(
            matches!(
                verify_event(&signed, mallory.public_key()),
                Err(EventProblem::WrongKey { .. })
            ),
            "a membership event verified against a key that did not sign it"
        );

        // Red: every field is bound. Move any one and the signature must fail.
        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
            );
        }
    }

    /// **Namespace isolation.** A membership signature must never verify as a
    /// forge approval, and a `git` commit signature must never verify as a
    /// membership event — the namespace is bound into what is signed.
    #[test]
    fn a_membership_signature_does_not_verify_as_an_approval() {
        let admin = key();
        let ev = event("admins", "alice", MembershipAction::Add, "root", 1);

        // The genuine membership signature, re-labelled as a forge event with
        // the same bytes: it is `Malformed` under the forge namespace, because
        // the blob carries `gunnar-membership` and the forge verifier binds
        // `gunnar-forge`.
        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"
        );

        // And the other direction: a signature made under `git` (a commit) is
        // Malformed as a membership event.
        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"
        );
    }

    /// **A value cannot smuggle a field past the signature.** Each is
    /// length-prefixed, so a member id that reads `alice\nactor mallory` is
    /// text, not two fields.
    #[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}"
        );
    }

    /// **The history is the evidence.** Remove then re-add a member and the log
    /// holds two standing signed events; the membership at any instant is
    /// answered by folding to that instant, not by reading a row that only
    /// knows the present.
    #[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),
        ];

        // At T=120, only alice is in.
        let at_120 = fold_at("admins", 120, &events);
        assert!(at_120.contains("alice"));
        assert!(!at_120.contains("bob"));

        // At T=250, alice has been removed; bob remains.
        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"));

        // Now: alice is back, and the provenance names the RE-ADD (carol at
        // 300), not the original add — the mutable row could not tell these
        // apart, the log does.
        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)));
    }

    /// Another group's events never leak into this fold.
    #[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"
        );
    }
}