| 1 | //! SSH public keys (spec §6). |
| 2 | //! |
| 3 | //! > **Git over SSH** authenticates by public key. `dogfood-ssh` looks the |
| 4 | //! > presented key's SHA256 fingerprint up in `ssh_keys`, resolves the user […] |
| 5 | //! |
| 6 | //! This module owns the *only* place a user-supplied key is parsed. `dogfood-ssh` |
| 7 | //! does the reverse lookup by fingerprint; what it looks up has to have been |
| 8 | //! normalised the same way on the way in, or a legitimate key silently fails to |
| 9 | //! authenticate. Keeping both sides on one fingerprint function is the point. |
| 10 | //! |
| 11 | //! Two things a naive implementation gets wrong: |
| 12 | //! |
| 13 | //! * **Storing what the user typed.** An `authorized_keys` line carries options, |
| 14 | //! a comment, and arbitrary whitespace. We store the re-serialised key, so what |
| 15 | //! is in the database is canonical regardless of what was pasted. |
| 16 | //! * **Accepting a private key.** People paste `id_ed25519` instead of |
| 17 | //! `id_ed25519.pub` more often than you would think, and a forge that stores it |
| 18 | //! without noticing has just been handed a credential it should never hold. |
| 19 | //! [`parse`] rejects that explicitly, with a message that says what happened. |
| 20 | |
| 21 | use ssh_key::{HashAlg, PublicKey}; |
| 22 | |
| 23 | /// A parsed, normalised public key ready to store. |
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 25 | pub struct ParsedKey { |
| 26 | /// `SHA256:…`, the lookup key `dogfood-ssh` authenticates against. |
| 27 | pub fingerprint: String, |
| 28 | /// The algorithm name, e.g. `ssh-ed25519`. |
| 29 | pub key_type: String, |
| 30 | /// Canonical OpenSSH serialisation, with the comment preserved. |
| 31 | pub openssh: String, |
| 32 | /// The comment from the key line, if it had one. Used to suggest a name. |
| 33 | pub comment: String, |
| 34 | } |
| 35 | |
| 36 | #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] |
| 37 | pub enum KeyError { |
| 38 | #[error("that does not look like an SSH public key")] |
| 39 | Malformed, |
| 40 | #[error( |
| 41 | "that is a *private* key — paste the contents of the matching .pub file instead, \ |
| 42 | and consider the key you just pasted compromised" |
| 43 | )] |
| 44 | PrivateKey, |
| 45 | #[error("that key type is not accepted; use ed25519, ecdsa, or an RSA key of 2048 bits or more")] |
| 46 | WeakAlgorithm, |
| 47 | #[error("that key is too long to be an SSH public key")] |
| 48 | TooLong, |
| 49 | } |
| 50 | |
| 51 | /// An `authorized_keys` line long enough to be a paste error rather than a key. |
| 52 | /// |
| 53 | /// A 16 KiB RSA key serialises to well under this; nothing legitimate is near it. |
| 54 | const MAX_LEN: usize = 16 * 1024; |
| 55 | |
| 56 | /// Minimum RSA modulus size. 1024-bit RSA is factorable by a well-resourced |
| 57 | /// attacker and 2048 is the floor every current guideline agrees on. |
| 58 | const MIN_RSA_BITS: usize = 2048; |
| 59 | |
| 60 | /// Parse and validate a pasted public key. |
| 61 | pub fn parse(input: &str) -> Result<ParsedKey, KeyError> { |
| 62 | let trimmed = input.trim(); |
| 63 | |
| 64 | if trimmed.len() > MAX_LEN { |
| 65 | return Err(KeyError::TooLong); |
| 66 | } |
| 67 | |
| 68 | // Checked before parsing so the error is the useful one. `ssh-key` would |
| 69 | // otherwise just report a malformed public key, and the user would go on |
| 70 | // pasting the same private key. |
| 71 | if trimmed.contains("PRIVATE KEY") { |
| 72 | return Err(KeyError::PrivateKey); |
| 73 | } |
| 74 | |
| 75 | let key = PublicKey::from_openssh(trimmed).map_err(|_| KeyError::Malformed)?; |
| 76 | |
| 77 | // DSA is disabled everywhere current, and undersized RSA is worse than no |
| 78 | // key because it looks like security. Rejected at the door rather than |
| 79 | // stored and quietly refused at authentication time. |
| 80 | let algorithm = key.algorithm(); |
| 81 | match key.key_data() { |
| 82 | d if d.dsa().is_some() => return Err(KeyError::WeakAlgorithm), |
| 83 | d => { |
| 84 | if let Some(rsa) = d.rsa() { |
| 85 | // `as_positive_bytes` strips the sign padding, so this is the |
| 86 | // true modulus size rather than the encoded length. |
| 87 | let bits = rsa.n.as_positive_bytes().map(|b| b.len() * 8).unwrap_or(0); |
| 88 | if bits < MIN_RSA_BITS { |
| 89 | return Err(KeyError::WeakAlgorithm); |
| 90 | } |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | Ok(ParsedKey { |
| 96 | fingerprint: key.fingerprint(HashAlg::Sha256).to_string(), |
| 97 | key_type: algorithm.as_str().to_owned(), |
| 98 | // Re-serialised, not echoed: whatever options or stray whitespace came |
| 99 | // in are gone, and what is stored is exactly what we would compare. |
| 100 | openssh: key.to_openssh().map_err(|_| KeyError::Malformed)?, |
| 101 | comment: key.comment().to_owned(), |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | /// A sensible default name for a key, derived from its comment. |
| 106 | /// |
| 107 | /// Comments are usually `user@host`, which is exactly the label a person wants |
| 108 | /// in the list. Falls back to the key type when there is no comment. |
| 109 | pub fn suggested_name(parsed: &ParsedKey) -> String { |
| 110 | let c = parsed.comment.trim(); |
| 111 | if c.is_empty() { |
| 112 | parsed.key_type.clone() |
| 113 | } else { |
| 114 | c.chars().take(100).collect() |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | #[cfg(test)] |
| 119 | mod tests { |
| 120 | use super::*; |
| 121 | |
| 122 | // Generated for this test with `ssh-keygen -t ed25519`. Never used anywhere. |
| 123 | const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb7ZqFmz8XN2ZsxKUJyF3vT1v1L0mHNqQaVwZ6Kx1pP alice@example.com"; |
| 124 | |
| 125 | #[test] |
| 126 | fn parses_an_ed25519_key_and_fingerprints_it() { |
| 127 | let k = parse(ED25519).unwrap(); |
| 128 | assert_eq!(k.key_type, "ssh-ed25519"); |
| 129 | assert_eq!(k.comment, "alice@example.com"); |
| 130 | assert!( |
| 131 | k.fingerprint.starts_with("SHA256:"), |
| 132 | "the fingerprint is the ssh-ssh lookup key and must be in the standard form: {}", |
| 133 | k.fingerprint |
| 134 | ); |
| 135 | } |
| 136 | |
| 137 | /// The fingerprint is what `dogfood-ssh` authenticates against, so the same |
| 138 | /// key pasted with different surrounding whitespace, options, or comment |
| 139 | /// must still resolve to one row. |
| 140 | #[test] |
| 141 | fn the_fingerprint_ignores_everything_but_the_key_material() { |
| 142 | let base = parse(ED25519).unwrap(); |
| 143 | |
| 144 | let padded = parse(&format!(" \n\t{ED25519}\n\n ")).unwrap(); |
| 145 | assert_eq!(padded.fingerprint, base.fingerprint); |
| 146 | |
| 147 | let recommented = ED25519.replace("alice@example.com", "bob@elsewhere"); |
| 148 | let other = parse(&recommented).unwrap(); |
| 149 | assert_eq!( |
| 150 | other.fingerprint, base.fingerprint, |
| 151 | "the comment is a label, not key material" |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | #[test] |
| 156 | fn a_pasted_private_key_is_rejected_with_a_useful_message() { |
| 157 | let private = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXk=\n-----END OPENSSH PRIVATE KEY-----"; |
| 158 | assert_eq!(parse(private).unwrap_err(), KeyError::PrivateKey); |
| 159 | assert!( |
| 160 | KeyError::PrivateKey.to_string().contains("compromised"), |
| 161 | "the user needs to be told to rotate it" |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | #[test] |
| 166 | fn junk_is_rejected() { |
| 167 | for junk in ["", "hello", "ssh-ed25519", "ssh-ed25519 not-base64!!", "{}"] { |
| 168 | assert_eq!(parse(junk).unwrap_err(), KeyError::Malformed, "{junk:?}"); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn absurdly_long_input_is_rejected_before_parsing() { |
| 174 | let huge = "a".repeat(MAX_LEN + 1); |
| 175 | assert_eq!(parse(&huge).unwrap_err(), KeyError::TooLong); |
| 176 | } |
| 177 | |
| 178 | #[test] |
| 179 | fn stored_form_is_canonical_not_whatever_was_pasted() { |
| 180 | let messy = format!(" {ED25519} "); |
| 181 | let k = parse(&messy).unwrap(); |
| 182 | assert_eq!(k.openssh, k.openssh.trim(), "no stray whitespace reaches the database"); |
| 183 | // Re-parsing what we store must yield the same key. |
| 184 | assert_eq!(parse(&k.openssh).unwrap().fingerprint, k.fingerprint); |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn names_default_to_the_comment() { |
| 189 | assert_eq!(suggested_name(&parse(ED25519).unwrap()), "alice@example.com"); |
| 190 | let no_comment = ED25519.rsplit_once(' ').unwrap().0; |
| 191 | assert_eq!(suggested_name(&parse(no_comment).unwrap()), "ssh-ed25519"); |
| 192 | } |
| 193 | } |
193 lines · Rust