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) = 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
104pub 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#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct AttestingKey {
124 pub fingerprint: String,
126 pub openssh: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum Requirement {
133 NobodyToAsk,
136 CallerIsTarget,
139 Required(Vec<AttestingKey>),
141}
142
143impl Requirement {
144 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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
161pub enum AttestationProblem {
162 #[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 target: String,
172 holders: usize,
174 fingerprint: String,
176 },
177 #[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 target: String,
186 adding: String,
188 attested_for: String,
190 attested: String,
192 },
193 #[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 target: String,
201 signer: String,
203 },
204 #[error("the attestation does not verify: {0}")]
206 NotVerified(crate::sshsig::EventProblem),
207}
208
209pub 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 #[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 assert!(matches!(
308 Requirement::of("alice", "", vec![holder(&k)]),
309 Requirement::Required(_)
310 ));
311 }
312
313 #[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 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 assert_eq!(check(&Requirement::NobodyToAsk, "alice", &adding, None), Ok(()));
355 assert_eq!(check(&Requirement::CallerIsTarget, "alice", &adding, None), Ok(()));
356 }
357
358 #[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 #[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}