Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! SSH public keys (spec §6).
Matt W2//!
Matt W3//! > **Git over SSH** authenticates by public key. `dogfood-ssh` looks the
Matt W4//! > presented key's SHA256 fingerprint up in `ssh_keys`, resolves the user […]
Matt W5//!
Matt W6//! This module owns the *only* place a user-supplied key is parsed. `dogfood-ssh`
Matt W7//! does the reverse lookup by fingerprint; what it looks up has to have been
Matt W8//! normalised the same way on the way in, or a legitimate key silently fails to
Matt W9//! authenticate. Keeping both sides on one fingerprint function is the point.
Matt W10//!
Matt W11//! Two things a naive implementation gets wrong:
Matt W12//!
Matt W13//! * **Storing what the user typed.** An `authorized_keys` line carries options,
Matt W14//! a comment, and arbitrary whitespace. We store the re-serialised key, so what
Matt W15//! is in the database is canonical regardless of what was pasted.
Matt W16//! * **Accepting a private key.** People paste `id_ed25519` instead of
Matt W17//! `id_ed25519.pub` more often than you would think, and a forge that stores it
Matt W18//! without noticing has just been handed a credential it should never hold.
Matt W19//! [`parse`] rejects that explicitly, with a message that says what happened.
Matt W20
Matt W21use ssh_key::{HashAlg, PublicKey};
Matt W22
Matt W23/// A parsed, normalised public key ready to store.
Matt W24#[derive(Debug, Clone, PartialEq, Eq)]
Matt W25pub struct ParsedKey {
Matt W26 /// `SHA256:…`, the lookup key `dogfood-ssh` authenticates against.
Matt W27 pub fingerprint: String,
Matt W28 /// The algorithm name, e.g. `ssh-ed25519`.
Matt W29 pub key_type: String,
Matt W30 /// Canonical OpenSSH serialisation, with the comment preserved.
Matt W31 pub openssh: String,
Matt W32 /// The comment from the key line, if it had one. Used to suggest a name.
Matt W33 pub comment: String,
Matt W34}
Matt W35
Matt W36#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
Matt W37pub enum KeyError {
Matt W38 #[error("that does not look like an SSH public key")]
Matt W39 Malformed,
Matt W40 #[error(
Matt W41 "that is a *private* key — paste the contents of the matching .pub file instead, \
Matt W42 and consider the key you just pasted compromised"
Matt W43 )]
Matt W44 PrivateKey,
Matt W45 #[error("that key type is not accepted; use ed25519, ecdsa, or an RSA key of 2048 bits or more")]
Matt W46 WeakAlgorithm,
Matt W47 #[error("that key is too long to be an SSH public key")]
Matt W48 TooLong,
Matt W49}
Matt W50
Matt W51/// An `authorized_keys` line long enough to be a paste error rather than a key.
Matt W52///
Matt W53/// A 16 KiB RSA key serialises to well under this; nothing legitimate is near it.
Matt W54const MAX_LEN: usize = 16 * 1024;
Matt W55
Matt W56/// Minimum RSA modulus size. 1024-bit RSA is factorable by a well-resourced
Matt W57/// attacker and 2048 is the floor every current guideline agrees on.
Matt W58const MIN_RSA_BITS: usize = 2048;
Matt W59
Matt W60/// Parse and validate a pasted public key.
Matt W61pub fn parse(input: &str) -> Result<ParsedKey, KeyError> {
Matt W62 let trimmed = input.trim();
Matt W63
Matt W64 if trimmed.len() > MAX_LEN {
Matt W65 return Err(KeyError::TooLong);
Matt W66 }
Matt W67
Matt W68 // Checked before parsing so the error is the useful one. `ssh-key` would
Matt W69 // otherwise just report a malformed public key, and the user would go on
Matt W70 // pasting the same private key.
Matt W71 if trimmed.contains("PRIVATE KEY") {
Matt W72 return Err(KeyError::PrivateKey);
Matt W73 }
Matt W74
Matt W75 let key = PublicKey::from_openssh(trimmed).map_err(|_| KeyError::Malformed)?;
Matt W76
Matt W77 // DSA is disabled everywhere current, and undersized RSA is worse than no
Matt W78 // key because it looks like security. Rejected at the door rather than
Matt W79 // stored and quietly refused at authentication time.
Matt W80 let algorithm = key.algorithm();
Matt W81 match key.key_data() {
Matt W82 d if d.dsa().is_some() => return Err(KeyError::WeakAlgorithm),
Matt W83 d => {
Matt W84 if let Some(rsa) = d.rsa() {
Matt W85 // `as_positive_bytes` strips the sign padding, so this is the
Matt W86 // true modulus size rather than the encoded length.
Matt W87 let bits = rsa.n.as_positive_bytes().map(|b| b.len() * 8).unwrap_or(0);
Matt W88 if bits < MIN_RSA_BITS {
Matt W89 return Err(KeyError::WeakAlgorithm);
Matt W90 }
Matt W91 }
Matt W92 }
Matt W93 }
Matt W94
Matt W95 Ok(ParsedKey {
Matt W96 fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
Matt W97 key_type: algorithm.as_str().to_owned(),
Matt W98 // Re-serialised, not echoed: whatever options or stray whitespace came
Matt W99 // in are gone, and what is stored is exactly what we would compare.
Matt W100 openssh: key.to_openssh().map_err(|_| KeyError::Malformed)?,
Matt W101 comment: key.comment().to_owned(),
Matt W102 })
Matt W103}
Matt W104
Matt W105/// A sensible default name for a key, derived from its comment.
Matt W106///
Matt W107/// Comments are usually `user@host`, which is exactly the label a person wants
Matt W108/// in the list. Falls back to the key type when there is no comment.
Matt W109pub fn suggested_name(parsed: &ParsedKey) -> String {
Matt W110 let c = parsed.comment.trim();
Matt W111 if c.is_empty() {
Matt W112 parsed.key_type.clone()
Matt W113 } else {
Matt W114 c.chars().take(100).collect()
Matt W115 }
Matt W116}
Matt W117
Matt W118#[cfg(test)]
Matt W119mod tests {
Matt W120 use super::*;
Matt W121
Matt W122 // Generated for this test with `ssh-keygen -t ed25519`. Never used anywhere.
Matt W123 const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb7ZqFmz8XN2ZsxKUJyF3vT1v1L0mHNqQaVwZ6Kx1pP alice@example.com";
Matt W124
Matt W125 #[test]
Matt W126 fn parses_an_ed25519_key_and_fingerprints_it() {
Matt W127 let k = parse(ED25519).unwrap();
Matt W128 assert_eq!(k.key_type, "ssh-ed25519");
Matt W129 assert_eq!(k.comment, "alice@example.com");
Matt W130 assert!(
Matt W131 k.fingerprint.starts_with("SHA256:"),
Matt W132 "the fingerprint is the ssh-ssh lookup key and must be in the standard form: {}",
Matt W133 k.fingerprint
Matt W134 );
Matt W135 }
Matt W136
Matt W137 /// The fingerprint is what `dogfood-ssh` authenticates against, so the same
Matt W138 /// key pasted with different surrounding whitespace, options, or comment
Matt W139 /// must still resolve to one row.
Matt W140 #[test]
Matt W141 fn the_fingerprint_ignores_everything_but_the_key_material() {
Matt W142 let base = parse(ED25519).unwrap();
Matt W143
Matt W144 let padded = parse(&format!(" \n\t{ED25519}\n\n ")).unwrap();
Matt W145 assert_eq!(padded.fingerprint, base.fingerprint);
Matt W146
Matt W147 let recommented = ED25519.replace("alice@example.com", "bob@elsewhere");
Matt W148 let other = parse(&recommented).unwrap();
Matt W149 assert_eq!(
Matt W150 other.fingerprint, base.fingerprint,
Matt W151 "the comment is a label, not key material"
Matt W152 );
Matt W153 }
Matt W154
Matt W155 #[test]
Matt W156 fn a_pasted_private_key_is_rejected_with_a_useful_message() {
Matt W157 let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\n-----END OPENSSH PRIVATE KEY-----";
Matt W158 assert_eq!(parse(private).unwrap_err(), KeyError::PrivateKey);
Matt W159 assert!(
Matt W160 KeyError::PrivateKey.to_string().contains("compromised"),
Matt W161 "the user needs to be told to rotate it"
Matt W162 );
Matt W163 }
Matt W164
Matt W165 #[test]
Matt W166 fn junk_is_rejected() {
Matt W167 for junk in ["", "hello", "ssh-ed25519", "ssh-ed25519 not-base64!!", "{}"] {
Matt W168 assert_eq!(parse(junk).unwrap_err(), KeyError::Malformed, "{junk:?}");
Matt W169 }
Matt W170 }
Matt W171
Matt W172 #[test]
Matt W173 fn absurdly_long_input_is_rejected_before_parsing() {
Matt W174 let huge = "a".repeat(MAX_LEN + 1);
Matt W175 assert_eq!(parse(&huge).unwrap_err(), KeyError::TooLong);
Matt W176 }
Matt W177
Matt W178 #[test]
Matt W179 fn stored_form_is_canonical_not_whatever_was_pasted() {
Matt W180 let messy = format!(" {ED25519} ");
Matt W181 let k = parse(&messy).unwrap();
Matt W182 assert_eq!(k.openssh, k.openssh.trim(), "no stray whitespace reaches the database");
Matt W183 // Re-parsing what we store must yield the same key.
Matt W184 assert_eq!(parse(&k.openssh).unwrap().fingerprint, k.fingerprint);
Matt W185 }
Matt W186
Matt W187 #[test]
Matt W188 fn names_default_to_the_comment() {
Matt W189 assert_eq!(suggested_name(&parse(ED25519).unwrap()), "alice@example.com");
Matt W190 let no_comment = ED25519.rsplit_once(' ').unwrap().0;
Matt W191 assert_eq!(suggested_name(&parse(no_comment).unwrap()), "ssh-ed25519");
Matt W192 }
Matt W193}

193 lines · Rust