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(_))
));
}
}