pub const KEY_ATTESTATION_NAMESPACE: &str = "gunnar-key-attestation";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyAttestation {
pub principal_id: String,
pub fingerprint: String,
pub at_unix_ms: i64,
}
impl KeyAttestation {
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()
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SignedKeyAttestation {
#[serde(flatten)]
pub attestation: KeyAttestation,
pub key_fingerprint: String,
pub signature: String,
}
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,
})
}
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,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttestingKey {
pub fingerprint: String,
pub openssh: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Requirement {
NobodyToAsk,
CallerIsTarget,
Required(Vec<AttestingKey>),
}
impl Requirement {
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AttestationProblem {
#[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 {
target: String,
holders: usize,
fingerprint: String,
},
#[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 {
target: String,
adding: String,
attested_for: String,
attested: String,
},
#[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 {
target: String,
signer: String,
},
#[error("the attestation does not verify: {0}")]
NotVerified(crate::sshsig::EventProblem),
}
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")
}
#[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
));
assert!(matches!(
Requirement::of("alice", "", vec![holder(&k)]),
Requirement::Required(_)
));
}
#[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 { .. })
));
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"
);
assert_eq!(
check(&Requirement::NobodyToAsk, "alice", &adding, None),
Ok(())
);
assert_eq!(
check(&Requirement::CallerIsTarget, "alice", &adding, None),
Ok(())
);
}
#[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)
);
}
#[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(_))
));
}
}