1pub const KEY_ATTESTATION_NAMESPACE: &str = "gunnar-key-attestation";
40
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct KeyAttestation {
45 pub principal_id: String,
47 pub fingerprint: String,
50 pub at_unix_ms: i64,
52}
53
54impl KeyAttestation {
55 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct SignedKeyAttestation {
78 #[serde(flatten)]
80 pub attestation: KeyAttestation,
81 pub key_fingerprint: String,
83 pub signature: String,
85}
86
87pub 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
101pub 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct AttestingKey {
121 pub fingerprint: String,
123 pub openssh: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum Requirement {
130 NobodyToAsk,
133 CallerIsTarget,
136 Required(Vec<AttestingKey>),
138}
139
140impl Requirement {
141 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
158pub enum AttestationProblem {
159 #[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 target: String,
169 holders: usize,
171 fingerprint: String,
173 },
174 #[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 target: String,
183 adding: String,
185 attested_for: String,
187 attested: String,
189 },
190 #[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 target: String,
198 signer: String,
200 },
201 #[error("the attestation does not verify: {0}")]
203 NotVerified(crate::sshsig::EventProblem),
204}
205
206pub 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 #[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 assert!(matches!(
305 Requirement::of("alice", "", vec![holder(&k)]),
306 Requirement::Required(_)
307 ));
308 }
309
310 #[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 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 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 #[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 #[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}