Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
21use ssh_key::{HashAlg, PublicKey};
22
23/// A parsed, normalised public key ready to store.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub 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)]
37pub 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.
54const 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.
58const MIN_RSA_BITS: usize = 2048;
59
60/// Parse and validate a pasted public key.
61pub 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.
109pub 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)]
119mod 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