gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
//! **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(_))
        ));
    }
}