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/key_attestation.rs

//! **A key added to a principal is attested by that principal** (2026-09-12).
//!
//! `Access.AddKey` takes the acting admin's method-bound credential, which
//! proves *who asked*. It proved nothing about whether the person whose access
//! grows agreed: an admin could enrol a key of their own on somebody else's
//! principal and act as that person, and the only trace was the admin's
//! credential on the call. This module is the other half — the target
//! principal's own `sshsig`, made with a key it already holds, over exactly the
//! key being added.
//!
//! It is the shape [`crate::membership`] gives a group change and
//! [`crate::forge`] gives a review approval: an event, its signer's
//! fingerprint, and a PEM `sshsig` under a namespace of its own, through the
//! one signer ([`crate::sshsig`]). Nothing new to verify with:
//! `ssh-keygen -Y verify -n gunnar-key-attestation` checks it.
//!
//! # When it is required, stated exactly
//!
//! [`Requirement::of`] is the one writer of the rule, and both control planes
//! (the shipped one and the in-memory double) ask it rather than spelling their
//! own:
//!
//! * the target holds **no** key able to sign → nobody to ask. The admin's word
//!   stands, as it does for `AddPrincipal`: this is creation, and it is the
//!   path `user add`, bootstrap, replication and a console's first enrolment
//!   take.
//! * the caller **is** the target → the call is already signed by one of the
//!   target's keys, and that is the attestation. No second signature.
//! * otherwise → required, and it must verify against a key the target holds.
//!
//! # Its own namespace
//!
//! [`KEY_ATTESTATION_NAMESPACE`] is distinct from every other, so an
//! attestation can never be replayed as a membership change, a forge
//! approval, a build provenance or a `git` commit, and none of those can be
//! presented here. The red for that is in the tests below.

/// The `sshsig` namespace a key attestation is signed under.
pub const KEY_ATTESTATION_NAMESPACE: &str = "gunnar-key-attestation";

/// What the target principal attests: *this key may act as me*.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyAttestation {
    /// The principal the key is being added to — the attester.
    pub principal_id: String,
    /// `SHA256:…` of the key being ADDED. The server derives the fingerprint
    /// from the key line it is handed and compares; a client never chooses it.
    pub fingerprint: String,
    /// When the attestation was made.
    pub at_unix_ms: i64,
}

impl KeyAttestation {
    /// The exact bytes that are signed.
    ///
    /// Line-oriented, fixed-order, every value length-prefixed — the form
    /// [`crate::membership::MembershipEvent::signing_bytes`] uses and for the
    /// same reason: JSON has no canonical form, and a newline inside a value
    /// must not be able to look like a second field.
    pub fn signing_bytes(&self) -> Vec<u8> {
        let mut out = String::new();
        out.push_str("gunnar-key-attestation-v1\n");
        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("principal", &self.principal_id);
        field("key", &self.fingerprint);
        out.into_bytes()
    }
}

/// A [`KeyAttestation`] with the signature that makes it evidence.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedKeyAttestation {
    /// What was attested.
    #[serde(flatten)]
    pub attestation: KeyAttestation,
    /// `SHA256:…` of the ATTESTING key — one the principal already holds.
    pub key_fingerprint: String,
    /// The `sshsig`, PEM-armoured.
    pub signature: String,
}

/// Sign an attestation with a key the principal already holds.
pub fn sign_attestation(
    key: &ssh_key::PrivateKey,
    attestation: KeyAttestation,
) -> Result<SignedKeyAttestation, ssh_key::Error> {
    let (key_fingerprint, signature) =
        crate::sshsig::sign(key, KEY_ATTESTATION_NAMESPACE, &attestation.signing_bytes())?;
    Ok(SignedKeyAttestation {
        attestation,
        key_fingerprint,
        signature,
    })
}

/// Verify a signed attestation against a public key, under exactly
/// [`KEY_ATTESTATION_NAMESPACE`].
pub fn verify_attestation(
    signed: &SignedKeyAttestation,
    key: &ssh_key::PublicKey,
) -> Result<(), crate::sshsig::EventProblem> {
    crate::sshsig::verify(
        key,
        KEY_ATTESTATION_NAMESPACE,
        &signed.attestation.signing_bytes(),
        &signed.key_fingerprint,
        &signed.signature,
    )
}

/// A key the target principal could attest with: its fingerprint and its
/// OpenSSH public line. What [`Requirement::of`] is handed, so it never
/// touches a table itself and both control planes can call it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttestingKey {
    /// `SHA256:…`.
    pub fingerprint: String,
    /// The `authorized_keys`-style line.
    pub openssh: String,
}

/// Whether an attestation is needed for this addition, and why not when not.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Requirement {
    /// The target holds no key able to sign, so there is nobody to ask. This is
    /// creation; the admin's word stands.
    NobodyToAsk,
    /// The caller is the target, and the call is already the target's own
    /// signed word.
    CallerIsTarget,
    /// The target must attest with one of these keys.
    Required(Vec<AttestingKey>),
}

impl Requirement {
    /// **The one writer of the rule.** `holders` are the target's keys that can
    /// sign right now (valid, and OpenSSH key material rather than a
    /// certificate); `caller` is the principal on the connection.
    pub fn of(target: &str, caller: &str, holders: Vec<AttestingKey>) -> Requirement {
        if holders.is_empty() {
            return Requirement::NobodyToAsk;
        }
        if !caller.is_empty() && caller == target {
            return Requirement::CallerIsTarget;
        }
        Requirement::Required(holders)
    }
}

/// Why an attestation was refused. Every arm names a different cause, so an
/// admin reading the refusal knows which of the four things to fix.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AttestationProblem {
    /// Required and not supplied.
    #[error(
        "principal {target} already holds {holders} key(s), so adding another needs {target}'s \
         own attestation: an sshsig by one of those keys over the new key's fingerprint \
         ({fingerprint}). Ask them for `gunnar control user attest-key {target} --key <the new \
         .pub>` and pass its output as --attestation"
    )]
    Missing {
        /// The target principal.
        target: String,
        /// How many keys it could attest with.
        holders: usize,
        /// The fingerprint the attestation must name.
        fingerprint: String,
    },
    /// The attestation is about a different principal or a different key than
    /// the request adds.
    #[error(
        "the attestation is for key {attested} on {attested_for:?} but the request adds key \
         {adding} to {target:?}; an attestation for one addition may not be spent on another"
    )]
    WrongSubject {
        /// The principal the request adds to.
        target: String,
        /// The fingerprint the request adds, as the server derived it.
        adding: String,
        /// The principal the attestation names.
        attested_for: String,
        /// The fingerprint the attestation names.
        attested: String,
    },
    /// The attesting key is not one the target holds.
    #[error(
        "the attestation is signed by {signer}, which is not a key {target:?} holds; only the \
         principal gaining a key can attest to it"
    )]
    NotTheTargetsKey {
        /// The principal the request adds to.
        target: String,
        /// The fingerprint that signed the attestation.
        signer: String,
    },
    /// The signature does not verify, or was made under another namespace.
    #[error("the attestation does not verify: {0}")]
    NotVerified(crate::sshsig::EventProblem),
}

/// **Check an addition against the rule**, before anything is written.
///
/// `adding` is the fingerprint the server derived from the key line being
/// added — never the client's claim. Returns `Ok(())` when nothing was
/// required or the attestation stands; the plane then writes the key.
pub fn check(
    requirement: &Requirement,
    target: &str,
    adding: &str,
    attestation: Option<&SignedKeyAttestation>,
) -> Result<(), AttestationProblem> {
    let holders = match requirement {
        Requirement::NobodyToAsk | Requirement::CallerIsTarget => return Ok(()),
        Requirement::Required(holders) => holders,
    };
    let Some(signed) = attestation else {
        return Err(AttestationProblem::Missing {
            target: target.to_owned(),
            holders: holders.len(),
            fingerprint: adding.to_owned(),
        });
    };
    if signed.attestation.principal_id != target || signed.attestation.fingerprint != adding {
        return Err(AttestationProblem::WrongSubject {
            target: target.to_owned(),
            adding: adding.to_owned(),
            attested_for: signed.attestation.principal_id.clone(),
            attested: signed.attestation.fingerprint.clone(),
        });
    }
    let Some(holder) = holders
        .iter()
        .find(|key| key.fingerprint == signed.key_fingerprint)
    else {
        return Err(AttestationProblem::NotTheTargetsKey {
            target: target.to_owned(),
            signer: signed.key_fingerprint.clone(),
        });
    };
    let public = ssh_key::PublicKey::from_openssh(&holder.openssh).map_err(|err| {
        AttestationProblem::NotVerified(crate::sshsig::EventProblem::Malformed(format!(
            "the stored key is unreadable: {err}"
        )))
    })?;
    verify_attestation(signed, &public).map_err(AttestationProblem::NotVerified)
}

#[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 fp(key: &ssh_key::PrivateKey) -> String {
        crate::sshsig::fingerprint(key.public_key())
    }

    fn holder(key: &ssh_key::PrivateKey) -> AttestingKey {
        AttestingKey {
            fingerprint: fp(key),
            openssh: key.public_key().to_openssh().expect("openssh"),
        }
    }

    fn attest(by: &ssh_key::PrivateKey, principal: &str, new: &str) -> SignedKeyAttestation {
        sign_attestation(
            by,
            KeyAttestation {
                principal_id: principal.into(),
                fingerprint: new.into(),
                at_unix_ms: 1_700_000_000_000,
            },
        )
        .expect("sign")
    }

    /// The rule, all three arms, from one writer.
    #[test]
    fn the_requirement_is_nobody_to_ask_then_the_caller_then_required() {
        assert_eq!(
            Requirement::of("alice", "root", vec![]),
            Requirement::NobodyToAsk,
            "a keyless principal has nobody to ask"
        );
        let k = key();
        assert_eq!(
            Requirement::of("alice", "alice", vec![holder(&k)]),
            Requirement::CallerIsTarget
        );
        assert!(matches!(
            Requirement::of("alice", "root", vec![holder(&k)]),
            Requirement::Required(ref h) if h.len() == 1
        ));
        // An EMPTY caller is not the target: the allow-all development mode
        // hands an empty id, and that must not read as "alice is calling".
        assert!(matches!(
            Requirement::of("alice", "", vec![holder(&k)]),
            Requirement::Required(_)
        ));
    }

    /// **Red first, four ways, then green.** Missing, the wrong subject, a key
    /// the target does not hold, and a forged signature — each refused with its
    /// own cause on the same addition that the honest attestation then passes.
    #[test]
    fn an_attestation_is_refused_for_each_cause_and_accepted_when_honest() {
        let alices = key();
        let roots = key();
        let new = key();
        let adding = fp(&new);
        let required = Requirement::of("alice", "root", vec![holder(&alices)]);

        assert!(matches!(
            check(&required, "alice", &adding, None),
            Err(AttestationProblem::Missing { holders: 1, .. })
        ));
        assert!(matches!(
            check(
                &required,
                "alice",
                &adding,
                Some(&attest(&alices, "alice", "SHA256:other"))
            ),
            Err(AttestationProblem::WrongSubject { .. })
        ));
        assert!(matches!(
            check(
                &required,
                "alice",
                &adding,
                Some(&attest(&alices, "bob", &adding))
            ),
            Err(AttestationProblem::WrongSubject { .. })
        ));
        assert!(matches!(
            check(
                &required,
                "alice",
                &adding,
                Some(&attest(&roots, "alice", &adding))
            ),
            Err(AttestationProblem::NotTheTargetsKey { .. })
        ));
        // A forged one: alice's fingerprint on root's signature.
        let mut forged = attest(&roots, "alice", &adding);
        forged.key_fingerprint = fp(&alices);
        assert!(matches!(
            check(&required, "alice", &adding, Some(&forged)),
            Err(AttestationProblem::NotVerified(EventProblem::NotVerified))
        ));

        assert_eq!(
            check(
                &required,
                "alice",
                &adding,
                Some(&attest(&alices, "alice", &adding))
            ),
            Ok(()),
            "the honest attestation must pass"
        );
        // And when nothing is required, nothing is checked.
        assert_eq!(
            check(&Requirement::NobodyToAsk, "alice", &adding, None),
            Ok(())
        );
        assert_eq!(
            check(&Requirement::CallerIsTarget, "alice", &adding, None),
            Ok(())
        );
    }

    /// No field can move under the signature.
    #[test]
    fn no_field_can_be_moved_under_the_signature() {
        let alices = key();
        let signed = attest(&alices, "alice", "SHA256:new");
        assert_eq!(verify_attestation(&signed, alices.public_key()), Ok(()));
        let mut moved = signed.clone();
        moved.attestation.fingerprint = "SHA256:neW".into();
        assert_eq!(
            verify_attestation(&moved, alices.public_key()),
            Err(EventProblem::NotVerified)
        );
        let mut moved = signed.clone();
        moved.attestation.principal_id = "alicf".into();
        assert_eq!(
            verify_attestation(&moved, alices.public_key()),
            Err(EventProblem::NotVerified)
        );
        let mut moved = signed;
        moved.attestation.at_unix_ms += 1;
        assert_eq!(
            verify_attestation(&moved, alices.public_key()),
            Err(EventProblem::NotVerified)
        );
    }

    /// An attestation does not verify as a membership event, and a membership
    /// event does not verify as an attestation: the namespaces isolate.
    #[test]
    fn the_namespace_isolates_it_from_the_membership_log() {
        let alices = key();
        let signed = attest(&alices, "alice", "SHA256:new");
        let smuggled = crate::membership::SignedMembershipEvent {
            event: crate::membership::MembershipEvent {
                group: "admins".into(),
                member: "alice".into(),
                action: crate::membership::MembershipAction::Add,
                actor: "alice".into(),
                at_unix_ms: 1_700_000_000_000,
            },
            key_fingerprint: signed.key_fingerprint.clone(),
            signature: signed.signature.clone(),
        };
        assert!(matches!(
            crate::membership::verify_event(&smuggled, alices.public_key()),
            Err(EventProblem::Malformed(_))
        ));
        let membership = crate::membership::sign_event(
            &alices,
            crate::membership::MembershipEvent {
                group: "admins".into(),
                member: "alice".into(),
                action: crate::membership::MembershipAction::Add,
                actor: "alice".into(),
                at_unix_ms: 1_700_000_000_000,
            },
        )
        .expect("sign");
        let smuggled_back = SignedKeyAttestation {
            attestation: KeyAttestation {
                principal_id: "alice".into(),
                fingerprint: "SHA256:new".into(),
                at_unix_ms: 1_700_000_000_000,
            },
            key_fingerprint: membership.key_fingerprint,
            signature: membership.signature,
        };
        assert!(matches!(
            verify_attestation(&smuggled_back, alices.public_key()),
            Err(EventProblem::Malformed(_))
        ));
    }
}