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) =
93        crate::sshsig::sign(key, KEY_ATTESTATION_NAMESPACE, &attestation.signing_bytes())?;
94    Ok(SignedKeyAttestation {
95        attestation,
96        key_fingerprint,
97        signature,
98    })
99}
100
101/// Verify a signed attestation against a public key, under exactly
102/// [`KEY_ATTESTATION_NAMESPACE`].
103pub fn verify_attestation(
104    signed: &SignedKeyAttestation,
105    key: &ssh_key::PublicKey,
106) -> Result<(), crate::sshsig::EventProblem> {
107    crate::sshsig::verify(
108        key,
109        KEY_ATTESTATION_NAMESPACE,
110        &signed.attestation.signing_bytes(),
111        &signed.key_fingerprint,
112        &signed.signature,
113    )
114}
115
116/// A key the target principal could attest with: its fingerprint and its
117/// OpenSSH public line. What [`Requirement::of`] is handed, so it never
118/// touches a table itself and both control planes can call it.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct AttestingKey {
121    /// `SHA256:…`.
122    pub fingerprint: String,
123    /// The `authorized_keys`-style line.
124    pub openssh: String,
125}
126
127/// Whether an attestation is needed for this addition, and why not when not.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum Requirement {
130    /// The target holds no key able to sign, so there is nobody to ask. This is
131    /// creation; the admin's word stands.
132    NobodyToAsk,
133    /// The caller is the target, and the call is already the target's own
134    /// signed word.
135    CallerIsTarget,
136    /// The target must attest with one of these keys.
137    Required(Vec<AttestingKey>),
138}
139
140impl Requirement {
141    /// **The one writer of the rule.** `holders` are the target's keys that can
142    /// sign right now (valid, and OpenSSH key material rather than a
143    /// certificate); `caller` is the principal on the connection.
144    pub fn of(target: &str, caller: &str, holders: Vec<AttestingKey>) -> Requirement {
145        if holders.is_empty() {
146            return Requirement::NobodyToAsk;
147        }
148        if !caller.is_empty() && caller == target {
149            return Requirement::CallerIsTarget;
150        }
151        Requirement::Required(holders)
152    }
153}
154
155/// Why an attestation was refused. Every arm names a different cause, so an
156/// admin reading the refusal knows which of the four things to fix.
157#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
158pub enum AttestationProblem {
159    /// Required and not supplied.
160    #[error(
161        "principal {target} already holds {holders} key(s), so adding another needs {target}'s \
162         own attestation: an sshsig by one of those keys over the new key's fingerprint \
163         ({fingerprint}). Ask them for `gunnar control user attest-key {target} --key <the new \
164         .pub>` and pass its output as --attestation"
165    )]
166    Missing {
167        /// The target principal.
168        target: String,
169        /// How many keys it could attest with.
170        holders: usize,
171        /// The fingerprint the attestation must name.
172        fingerprint: String,
173    },
174    /// The attestation is about a different principal or a different key than
175    /// the request adds.
176    #[error(
177        "the attestation is for key {attested} on {attested_for:?} but the request adds key \
178         {adding} to {target:?}; an attestation for one addition may not be spent on another"
179    )]
180    WrongSubject {
181        /// The principal the request adds to.
182        target: String,
183        /// The fingerprint the request adds, as the server derived it.
184        adding: String,
185        /// The principal the attestation names.
186        attested_for: String,
187        /// The fingerprint the attestation names.
188        attested: String,
189    },
190    /// The attesting key is not one the target holds.
191    #[error(
192        "the attestation is signed by {signer}, which is not a key {target:?} holds; only the \
193         principal gaining a key can attest to it"
194    )]
195    NotTheTargetsKey {
196        /// The principal the request adds to.
197        target: String,
198        /// The fingerprint that signed the attestation.
199        signer: String,
200    },
201    /// The signature does not verify, or was made under another namespace.
202    #[error("the attestation does not verify: {0}")]
203    NotVerified(crate::sshsig::EventProblem),
204}
205
206/// **Check an addition against the rule**, before anything is written.
207///
208/// `adding` is the fingerprint the server derived from the key line being
209/// added — never the client's claim. Returns `Ok(())` when nothing was
210/// required or the attestation stands; the plane then writes the key.
211pub fn check(
212    requirement: &Requirement,
213    target: &str,
214    adding: &str,
215    attestation: Option<&SignedKeyAttestation>,
216) -> Result<(), AttestationProblem> {
217    let holders = match requirement {
218        Requirement::NobodyToAsk | Requirement::CallerIsTarget => return Ok(()),
219        Requirement::Required(holders) => holders,
220    };
221    let Some(signed) = attestation else {
222        return Err(AttestationProblem::Missing {
223            target: target.to_owned(),
224            holders: holders.len(),
225            fingerprint: adding.to_owned(),
226        });
227    };
228    if signed.attestation.principal_id != target || signed.attestation.fingerprint != adding {
229        return Err(AttestationProblem::WrongSubject {
230            target: target.to_owned(),
231            adding: adding.to_owned(),
232            attested_for: signed.attestation.principal_id.clone(),
233            attested: signed.attestation.fingerprint.clone(),
234        });
235    }
236    let Some(holder) = holders
237        .iter()
238        .find(|key| key.fingerprint == signed.key_fingerprint)
239    else {
240        return Err(AttestationProblem::NotTheTargetsKey {
241            target: target.to_owned(),
242            signer: signed.key_fingerprint.clone(),
243        });
244    };
245    let public = ssh_key::PublicKey::from_openssh(&holder.openssh).map_err(|err| {
246        AttestationProblem::NotVerified(crate::sshsig::EventProblem::Malformed(format!(
247            "the stored key is unreadable: {err}"
248        )))
249    })?;
250    verify_attestation(signed, &public).map_err(AttestationProblem::NotVerified)
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use crate::sshsig::EventProblem;
257
258    fn key() -> ssh_key::PrivateKey {
259        ssh_key::PrivateKey::from_openssh(crate::mint_ed25519().expect("mint")).expect("parse")
260    }
261
262    fn fp(key: &ssh_key::PrivateKey) -> String {
263        crate::sshsig::fingerprint(key.public_key())
264    }
265
266    fn holder(key: &ssh_key::PrivateKey) -> AttestingKey {
267        AttestingKey {
268            fingerprint: fp(key),
269            openssh: key.public_key().to_openssh().expect("openssh"),
270        }
271    }
272
273    fn attest(by: &ssh_key::PrivateKey, principal: &str, new: &str) -> SignedKeyAttestation {
274        sign_attestation(
275            by,
276            KeyAttestation {
277                principal_id: principal.into(),
278                fingerprint: new.into(),
279                at_unix_ms: 1_700_000_000_000,
280            },
281        )
282        .expect("sign")
283    }
284
285    /// The rule, all three arms, from one writer.
286    #[test]
287    fn the_requirement_is_nobody_to_ask_then_the_caller_then_required() {
288        assert_eq!(
289            Requirement::of("alice", "root", vec![]),
290            Requirement::NobodyToAsk,
291            "a keyless principal has nobody to ask"
292        );
293        let k = key();
294        assert_eq!(
295            Requirement::of("alice", "alice", vec![holder(&k)]),
296            Requirement::CallerIsTarget
297        );
298        assert!(matches!(
299            Requirement::of("alice", "root", vec![holder(&k)]),
300            Requirement::Required(ref h) if h.len() == 1
301        ));
302        // An EMPTY caller is not the target: the allow-all development mode
303        // hands an empty id, and that must not read as "alice is calling".
304        assert!(matches!(
305            Requirement::of("alice", "", vec![holder(&k)]),
306            Requirement::Required(_)
307        ));
308    }
309
310    /// **Red first, four ways, then green.** Missing, the wrong subject, a key
311    /// the target does not hold, and a forged signature — each refused with its
312    /// own cause on the same addition that the honest attestation then passes.
313    #[test]
314    fn an_attestation_is_refused_for_each_cause_and_accepted_when_honest() {
315        let alices = key();
316        let roots = key();
317        let new = key();
318        let adding = fp(&new);
319        let required = Requirement::of("alice", "root", vec![holder(&alices)]);
320
321        assert!(matches!(
322            check(&required, "alice", &adding, None),
323            Err(AttestationProblem::Missing { holders: 1, .. })
324        ));
325        assert!(matches!(
326            check(
327                &required,
328                "alice",
329                &adding,
330                Some(&attest(&alices, "alice", "SHA256:other"))
331            ),
332            Err(AttestationProblem::WrongSubject { .. })
333        ));
334        assert!(matches!(
335            check(
336                &required,
337                "alice",
338                &adding,
339                Some(&attest(&alices, "bob", &adding))
340            ),
341            Err(AttestationProblem::WrongSubject { .. })
342        ));
343        assert!(matches!(
344            check(
345                &required,
346                "alice",
347                &adding,
348                Some(&attest(&roots, "alice", &adding))
349            ),
350            Err(AttestationProblem::NotTheTargetsKey { .. })
351        ));
352        // A forged one: alice's fingerprint on root's signature.
353        let mut forged = attest(&roots, "alice", &adding);
354        forged.key_fingerprint = fp(&alices);
355        assert!(matches!(
356            check(&required, "alice", &adding, Some(&forged)),
357            Err(AttestationProblem::NotVerified(EventProblem::NotVerified))
358        ));
359
360        assert_eq!(
361            check(
362                &required,
363                "alice",
364                &adding,
365                Some(&attest(&alices, "alice", &adding))
366            ),
367            Ok(()),
368            "the honest attestation must pass"
369        );
370        // And when nothing is required, nothing is checked.
371        assert_eq!(
372            check(&Requirement::NobodyToAsk, "alice", &adding, None),
373            Ok(())
374        );
375        assert_eq!(
376            check(&Requirement::CallerIsTarget, "alice", &adding, None),
377            Ok(())
378        );
379    }
380
381    /// No field can move under the signature.
382    #[test]
383    fn no_field_can_be_moved_under_the_signature() {
384        let alices = key();
385        let signed = attest(&alices, "alice", "SHA256:new");
386        assert_eq!(verify_attestation(&signed, alices.public_key()), Ok(()));
387        let mut moved = signed.clone();
388        moved.attestation.fingerprint = "SHA256:neW".into();
389        assert_eq!(
390            verify_attestation(&moved, alices.public_key()),
391            Err(EventProblem::NotVerified)
392        );
393        let mut moved = signed.clone();
394        moved.attestation.principal_id = "alicf".into();
395        assert_eq!(
396            verify_attestation(&moved, alices.public_key()),
397            Err(EventProblem::NotVerified)
398        );
399        let mut moved = signed;
400        moved.attestation.at_unix_ms += 1;
401        assert_eq!(
402            verify_attestation(&moved, alices.public_key()),
403            Err(EventProblem::NotVerified)
404        );
405    }
406
407    /// An attestation does not verify as a membership event, and a membership
408    /// event does not verify as an attestation: the namespaces isolate.
409    #[test]
410    fn the_namespace_isolates_it_from_the_membership_log() {
411        let alices = key();
412        let signed = attest(&alices, "alice", "SHA256:new");
413        let smuggled = crate::membership::SignedMembershipEvent {
414            event: crate::membership::MembershipEvent {
415                group: "admins".into(),
416                member: "alice".into(),
417                action: crate::membership::MembershipAction::Add,
418                actor: "alice".into(),
419                at_unix_ms: 1_700_000_000_000,
420            },
421            key_fingerprint: signed.key_fingerprint.clone(),
422            signature: signed.signature.clone(),
423        };
424        assert!(matches!(
425            crate::membership::verify_event(&smuggled, alices.public_key()),
426            Err(EventProblem::Malformed(_))
427        ));
428        let membership = crate::membership::sign_event(
429            &alices,
430            crate::membership::MembershipEvent {
431                group: "admins".into(),
432                member: "alice".into(),
433                action: crate::membership::MembershipAction::Add,
434                actor: "alice".into(),
435                at_unix_ms: 1_700_000_000_000,
436            },
437        )
438        .expect("sign");
439        let smuggled_back = SignedKeyAttestation {
440            attestation: KeyAttestation {
441                principal_id: "alice".into(),
442                fingerprint: "SHA256:new".into(),
443                at_unix_ms: 1_700_000_000_000,
444            },
445            key_fingerprint: membership.key_fingerprint,
446            signature: membership.signature,
447        };
448        assert!(matches!(
449            verify_attestation(&smuggled_back, alices.public_key()),
450            Err(EventProblem::Malformed(_))
451        ));
452    }
453}