Skip to main content

gunnar_core/
key_attestation.rs

1//! **A key added to a principal is attested by that principal** (2026-09-12).
2//!
3//! `Access.AddKey` takes the acting admin's method-bound credential, which
4//! proves *who asked*. It proved nothing about whether the person whose access
5//! grows agreed: an admin could enrol a key of their own on somebody else's
6//! principal and act as that person, and the only trace was the admin's
7//! credential on the call. This module is the other half — the target
8//! principal's own `sshsig`, made with a key it already holds, over exactly the
9//! key being added.
10//!
11//! It is the shape [`crate::membership`] gives a group change and
12//! [`crate::forge`] gives a review approval: an event, its signer's
13//! fingerprint, and a PEM `sshsig` under a namespace of its own, through the
14//! one signer ([`crate::sshsig`]). Nothing new to verify with:
15//! `ssh-keygen -Y verify -n gunnar-key-attestation` checks it.
16//!
17//! # When it is required, stated exactly
18//!
19//! [`Requirement::of`] is the one writer of the rule, and both control planes
20//! (the shipped one and the in-memory double) ask it rather than spelling their
21//! own:
22//!
23//! * the target holds **no** key able to sign → nobody to ask. The admin's word
24//!   stands, as it does for `AddPrincipal`: this is creation, and it is the
25//!   path `user add`, bootstrap, replication and a console's first enrolment
26//!   take.
27//! * the caller **is** the target → the call is already signed by one of the
28//!   target's keys, and that is the attestation. No second signature.
29//! * otherwise → required, and it must verify against a key the target holds.
30//!
31//! # Its own namespace
32//!
33//! [`KEY_ATTESTATION_NAMESPACE`] is distinct from every other, so an
34//! attestation can never be replayed as a membership change, a forge
35//! approval, a build provenance or a `git` commit, and none of those can be
36//! presented here. The red for that is in the tests below.
37
38/// The `sshsig` namespace a key attestation is signed under.
39pub const KEY_ATTESTATION_NAMESPACE: &str = "gunnar-key-attestation";
40
41/// What the target principal attests: *this key may act as me*.
42#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct KeyAttestation {
45    /// The principal the key is being added to — the attester.
46    pub principal_id: String,
47    /// `SHA256:…` of the key being ADDED. The server derives the fingerprint
48    /// from the key line it is handed and compares; a client never chooses it.
49    pub fingerprint: String,
50    /// When the attestation was made.
51    pub at_unix_ms: i64,
52}
53
54impl KeyAttestation {
55    /// The exact bytes that are signed.
56    ///
57    /// Line-oriented, fixed-order, every value length-prefixed — the form
58    /// [`crate::membership::MembershipEvent::signing_bytes`] uses and for the
59    /// same reason: JSON has no canonical form, and a newline inside a value
60    /// must not be able to look like a second field.
61    pub fn signing_bytes(&self) -> Vec<u8> {
62        let mut out = String::new();
63        out.push_str("gunnar-key-attestation-v1\n");
64        out.push_str(&format!("at {}\n", self.at_unix_ms));
65        let mut field = |name: &str, value: &str| {
66            out.push_str(&format!("{name} {} {value}\n", value.len()));
67        };
68        field("principal", &self.principal_id);
69        field("key", &self.fingerprint);
70        out.into_bytes()
71    }
72}
73
74/// A [`KeyAttestation`] with the signature that makes it evidence.
75#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct SignedKeyAttestation {
78    /// What was attested.
79    #[serde(flatten)]
80    pub attestation: KeyAttestation,
81    /// `SHA256:…` of the ATTESTING key — one the principal already holds.
82    pub key_fingerprint: String,
83    /// The `sshsig`, PEM-armoured.
84    pub signature: String,
85}
86
87/// Sign an attestation with a key the principal already holds.
88pub fn sign_attestation(
89    key: &ssh_key::PrivateKey,
90    attestation: KeyAttestation,
91) -> Result<SignedKeyAttestation, ssh_key::Error> {
92    let (key_fingerprint, signature) = crate::sshsig::sign(
93        key,
94        KEY_ATTESTATION_NAMESPACE,
95        &attestation.signing_bytes(),
96    )?;
97    Ok(SignedKeyAttestation {
98        attestation,
99        key_fingerprint,
100        signature,
101    })
102}
103
104/// Verify a signed attestation against a public key, under exactly
105/// [`KEY_ATTESTATION_NAMESPACE`].
106pub fn verify_attestation(
107    signed: &SignedKeyAttestation,
108    key: &ssh_key::PublicKey,
109) -> Result<(), crate::sshsig::EventProblem> {
110    crate::sshsig::verify(
111        key,
112        KEY_ATTESTATION_NAMESPACE,
113        &signed.attestation.signing_bytes(),
114        &signed.key_fingerprint,
115        &signed.signature,
116    )
117}
118
119/// A key the target principal could attest with: its fingerprint and its
120/// OpenSSH public line. What [`Requirement::of`] is handed, so it never
121/// touches a table itself and both control planes can call it.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct AttestingKey {
124    /// `SHA256:…`.
125    pub fingerprint: String,
126    /// The `authorized_keys`-style line.
127    pub openssh: String,
128}
129
130/// Whether an attestation is needed for this addition, and why not when not.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum Requirement {
133    /// The target holds no key able to sign, so there is nobody to ask. This is
134    /// creation; the admin's word stands.
135    NobodyToAsk,
136    /// The caller is the target, and the call is already the target's own
137    /// signed word.
138    CallerIsTarget,
139    /// The target must attest with one of these keys.
140    Required(Vec<AttestingKey>),
141}
142
143impl Requirement {
144    /// **The one writer of the rule.** `holders` are the target's keys that can
145    /// sign right now (valid, and OpenSSH key material rather than a
146    /// certificate); `caller` is the principal on the connection.
147    pub fn of(target: &str, caller: &str, holders: Vec<AttestingKey>) -> Requirement {
148        if holders.is_empty() {
149            return Requirement::NobodyToAsk;
150        }
151        if !caller.is_empty() && caller == target {
152            return Requirement::CallerIsTarget;
153        }
154        Requirement::Required(holders)
155    }
156}
157
158/// Why an attestation was refused. Every arm names a different cause, so an
159/// admin reading the refusal knows which of the four things to fix.
160#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
161pub enum AttestationProblem {
162    /// Required and not supplied.
163    #[error(
164        "principal {target} already holds {holders} key(s), so adding another needs {target}'s \
165         own attestation: an sshsig by one of those keys over the new key's fingerprint \
166         ({fingerprint}). Ask them for `gunnar control user attest-key {target} --key <the new \
167         .pub>` and pass its output as --attestation"
168    )]
169    Missing {
170        /// The target principal.
171        target: String,
172        /// How many keys it could attest with.
173        holders: usize,
174        /// The fingerprint the attestation must name.
175        fingerprint: String,
176    },
177    /// The attestation is about a different principal or a different key than
178    /// the request adds.
179    #[error(
180        "the attestation is for key {attested} on {attested_for:?} but the request adds key \
181         {adding} to {target:?}; an attestation for one addition may not be spent on another"
182    )]
183    WrongSubject {
184        /// The principal the request adds to.
185        target: String,
186        /// The fingerprint the request adds, as the server derived it.
187        adding: String,
188        /// The principal the attestation names.
189        attested_for: String,
190        /// The fingerprint the attestation names.
191        attested: String,
192    },
193    /// The attesting key is not one the target holds.
194    #[error(
195        "the attestation is signed by {signer}, which is not a key {target:?} holds; only the \
196         principal gaining a key can attest to it"
197    )]
198    NotTheTargetsKey {
199        /// The principal the request adds to.
200        target: String,
201        /// The fingerprint that signed the attestation.
202        signer: String,
203    },
204    /// The signature does not verify, or was made under another namespace.
205    #[error("the attestation does not verify: {0}")]
206    NotVerified(crate::sshsig::EventProblem),
207}
208
209/// **Check an addition against the rule**, before anything is written.
210///
211/// `adding` is the fingerprint the server derived from the key line being
212/// added — never the client's claim. Returns `Ok(())` when nothing was
213/// required or the attestation stands; the plane then writes the key.
214pub fn check(
215    requirement: &Requirement,
216    target: &str,
217    adding: &str,
218    attestation: Option<&SignedKeyAttestation>,
219) -> Result<(), AttestationProblem> {
220    let holders = match requirement {
221        Requirement::NobodyToAsk | Requirement::CallerIsTarget => return Ok(()),
222        Requirement::Required(holders) => holders,
223    };
224    let Some(signed) = attestation else {
225        return Err(AttestationProblem::Missing {
226            target: target.to_owned(),
227            holders: holders.len(),
228            fingerprint: adding.to_owned(),
229        });
230    };
231    if signed.attestation.principal_id != target || signed.attestation.fingerprint != adding {
232        return Err(AttestationProblem::WrongSubject {
233            target: target.to_owned(),
234            adding: adding.to_owned(),
235            attested_for: signed.attestation.principal_id.clone(),
236            attested: signed.attestation.fingerprint.clone(),
237        });
238    }
239    let Some(holder) = holders
240        .iter()
241        .find(|key| key.fingerprint == signed.key_fingerprint)
242    else {
243        return Err(AttestationProblem::NotTheTargetsKey {
244            target: target.to_owned(),
245            signer: signed.key_fingerprint.clone(),
246        });
247    };
248    let public = ssh_key::PublicKey::from_openssh(&holder.openssh).map_err(|err| {
249        AttestationProblem::NotVerified(crate::sshsig::EventProblem::Malformed(format!(
250            "the stored key is unreadable: {err}"
251        )))
252    })?;
253    verify_attestation(signed, &public).map_err(AttestationProblem::NotVerified)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::sshsig::EventProblem;
260
261    fn key() -> ssh_key::PrivateKey {
262        ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
263    }
264
265    fn fp(key: &ssh_key::PrivateKey) -> String {
266        crate::sshsig::fingerprint(key.public_key())
267    }
268
269    fn holder(key: &ssh_key::PrivateKey) -> AttestingKey {
270        AttestingKey {
271            fingerprint: fp(key),
272            openssh: key.public_key().to_openssh().expect("openssh"),
273        }
274    }
275
276    fn attest(by: &ssh_key::PrivateKey, principal: &str, new: &str) -> SignedKeyAttestation {
277        sign_attestation(
278            by,
279            KeyAttestation {
280                principal_id: principal.into(),
281                fingerprint: new.into(),
282                at_unix_ms: 1_700_000_000_000,
283            },
284        )
285        .expect("sign")
286    }
287
288    /// The rule, all three arms, from one writer.
289    #[test]
290    fn the_requirement_is_nobody_to_ask_then_the_caller_then_required() {
291        assert_eq!(
292            Requirement::of("alice", "root", vec![]),
293            Requirement::NobodyToAsk,
294            "a keyless principal has nobody to ask"
295        );
296        let k = key();
297        assert_eq!(
298            Requirement::of("alice", "alice", vec![holder(&k)]),
299            Requirement::CallerIsTarget
300        );
301        assert!(matches!(
302            Requirement::of("alice", "root", vec![holder(&k)]),
303            Requirement::Required(ref h) if h.len() == 1
304        ));
305        // An EMPTY caller is not the target: the allow-all development mode
306        // hands an empty id, and that must not read as "alice is calling".
307        assert!(matches!(
308            Requirement::of("alice", "", vec![holder(&k)]),
309            Requirement::Required(_)
310        ));
311    }
312
313    /// **Red first, four ways, then green.** Missing, the wrong subject, a key
314    /// the target does not hold, and a forged signature — each refused with its
315    /// own cause on the same addition that the honest attestation then passes.
316    #[test]
317    fn an_attestation_is_refused_for_each_cause_and_accepted_when_honest() {
318        let alices = key();
319        let roots = key();
320        let new = key();
321        let adding = fp(&new);
322        let required = Requirement::of("alice", "root", vec![holder(&alices)]);
323
324        assert!(matches!(
325            check(&required, "alice", &adding, None),
326            Err(AttestationProblem::Missing { holders: 1, .. })
327        ));
328        assert!(matches!(
329            check(&required, "alice", &adding, Some(&attest(&alices, "alice", "SHA256:other"))),
330            Err(AttestationProblem::WrongSubject { .. })
331        ));
332        assert!(matches!(
333            check(&required, "alice", &adding, Some(&attest(&alices, "bob", &adding))),
334            Err(AttestationProblem::WrongSubject { .. })
335        ));
336        assert!(matches!(
337            check(&required, "alice", &adding, Some(&attest(&roots, "alice", &adding))),
338            Err(AttestationProblem::NotTheTargetsKey { .. })
339        ));
340        // A forged one: alice's fingerprint on root's signature.
341        let mut forged = attest(&roots, "alice", &adding);
342        forged.key_fingerprint = fp(&alices);
343        assert!(matches!(
344            check(&required, "alice", &adding, Some(&forged)),
345            Err(AttestationProblem::NotVerified(EventProblem::NotVerified))
346        ));
347
348        assert_eq!(
349            check(&required, "alice", &adding, Some(&attest(&alices, "alice", &adding))),
350            Ok(()),
351            "the honest attestation must pass"
352        );
353        // And when nothing is required, nothing is checked.
354        assert_eq!(check(&Requirement::NobodyToAsk, "alice", &adding, None), Ok(()));
355        assert_eq!(check(&Requirement::CallerIsTarget, "alice", &adding, None), Ok(()));
356    }
357
358    /// No field can move under the signature.
359    #[test]
360    fn no_field_can_be_moved_under_the_signature() {
361        let alices = key();
362        let signed = attest(&alices, "alice", "SHA256:new");
363        assert_eq!(verify_attestation(&signed, alices.public_key()), Ok(()));
364        let mut moved = signed.clone();
365        moved.attestation.fingerprint = "SHA256:neW".into();
366        assert_eq!(
367            verify_attestation(&moved, alices.public_key()),
368            Err(EventProblem::NotVerified)
369        );
370        let mut moved = signed.clone();
371        moved.attestation.principal_id = "alicf".into();
372        assert_eq!(
373            verify_attestation(&moved, alices.public_key()),
374            Err(EventProblem::NotVerified)
375        );
376        let mut moved = signed;
377        moved.attestation.at_unix_ms += 1;
378        assert_eq!(
379            verify_attestation(&moved, alices.public_key()),
380            Err(EventProblem::NotVerified)
381        );
382    }
383
384    /// An attestation does not verify as a membership event, and a membership
385    /// event does not verify as an attestation: the namespaces isolate.
386    #[test]
387    fn the_namespace_isolates_it_from_the_membership_log() {
388        let alices = key();
389        let signed = attest(&alices, "alice", "SHA256:new");
390        let smuggled = crate::membership::SignedMembershipEvent {
391            event: crate::membership::MembershipEvent {
392                group: "admins".into(),
393                member: "alice".into(),
394                action: crate::membership::MembershipAction::Add,
395                actor: "alice".into(),
396                at_unix_ms: 1_700_000_000_000,
397            },
398            key_fingerprint: signed.key_fingerprint.clone(),
399            signature: signed.signature.clone(),
400        };
401        assert!(matches!(
402            crate::membership::verify_event(&smuggled, alices.public_key()),
403            Err(EventProblem::Malformed(_))
404        ));
405        let membership = crate::membership::sign_event(
406            &alices,
407            crate::membership::MembershipEvent {
408                group: "admins".into(),
409                member: "alice".into(),
410                action: crate::membership::MembershipAction::Add,
411                actor: "alice".into(),
412                at_unix_ms: 1_700_000_000_000,
413            },
414        )
415        .expect("sign");
416        let smuggled_back = SignedKeyAttestation {
417            attestation: KeyAttestation {
418                principal_id: "alice".into(),
419                fingerprint: "SHA256:new".into(),
420                at_unix_ms: 1_700_000_000_000,
421            },
422            key_fingerprint: membership.key_fingerprint,
423            signature: membership.signature,
424        };
425        assert!(matches!(
426            verify_attestation(&smuggled_back, alices.public_key()),
427            Err(EventProblem::Malformed(_))
428        ));
429    }
430}