1use base64::Engine;
12use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
13use rusqlite::{OptionalExtension, Row, params};
14use serde::Serialize;
15
16use super::{AuthError, AuthResult, AuthStore};
17
18pub const ALGORITHMS: &[&str] = &[
20 "ssh-ed25519",
21 "ecdsa-sha2-nistp256",
22 "ecdsa-sha2-nistp384",
23 "ecdsa-sha2-nistp521",
24 "sk-ssh-ed25519@openssh.com",
25 "sk-ecdsa-sha2-nistp256@openssh.com",
26 "ssh-rsa",
27];
28
29pub const MAX_KEYS: i64 = 50;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PublicKey {
35 pub algorithm: String,
36 pub blob: String,
38 pub comment: String,
39}
40
41impl PublicKey {
42 pub fn parse(line: &str) -> std::result::Result<PublicKey, String> {
46 let line = line.trim();
47 if line.contains(['\n', '\r', '\0']) {
48 return Err("one key per line".into());
49 }
50 let mut parts = line.split_ascii_whitespace();
51 let algorithm = parts.next().ok_or("empty key")?;
52 if !ALGORITHMS.contains(&algorithm) {
53 return Err(if algorithm.contains('=') || algorithm.contains('"') {
54 "authorized_keys options (command=, from=, ...) are not accepted; paste the key alone"
55 .into()
56 } else {
57 format!(
58 "unsupported key type {:?}; use one of {}",
59 algorithm.chars().take(40).collect::<String>(),
60 ALGORITHMS.join(", ")
61 )
62 });
63 }
64 let b64 = parts.next().ok_or("the key's base64 part is missing")?;
65 let blob = STANDARD
66 .decode(b64)
67 .map_err(|_| "the key's base64 part does not decode".to_string())?;
68 let named = wire_string(&blob).ok_or("the key's data is malformed")?;
70 if named != algorithm.as_bytes() {
71 return Err("the key's type and its data disagree".into());
72 }
73 if blob.len() > 16 * 1024 {
74 return Err("the key is implausibly large".into());
75 }
76 if algorithm == "ssh-rsa" && blob.len() < 270 {
77 return Err("RSA keys under 2048 bits are not accepted".into());
78 }
79 let comment: String = parts
80 .collect::<Vec<_>>()
81 .join(" ")
82 .chars()
83 .filter(|c| !c.is_control())
84 .take(100)
85 .collect();
86 Ok(PublicKey {
87 algorithm: algorithm.to_string(),
88 blob: STANDARD.encode(&blob),
89 comment,
90 })
91 }
92
93 pub fn line(&self) -> String {
95 format!("{} {}", self.algorithm, self.blob)
96 }
97
98 pub fn fingerprint(&self) -> String {
100 let blob = STANDARD.decode(&self.blob).unwrap_or_default();
101 fingerprint_of(&blob)
102 }
103}
104
105pub fn fingerprint_of(blob: &[u8]) -> String {
107 let d = ring::digest::digest(&ring::digest::SHA256, blob);
108 format!("SHA256:{}", STANDARD_NO_PAD.encode(d.as_ref()))
109}
110
111fn wire_string(b: &[u8]) -> Option<&[u8]> {
113 let n = u32::from_be_bytes(b.get(..4)?.try_into().ok()?) as usize;
114 b.get(4..4usize.checked_add(n)?)
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119pub struct SshKey {
120 pub id: i64,
121 pub user_id: i64,
122 pub name: String,
123 pub algorithm: String,
124 pub public_key: String,
126 pub fingerprint: String,
127 pub created_at: i64,
128 pub last_used: Option<i64>,
129}
130
131const COLS: &str = "id, user_id, name, algorithm, public_key, fingerprint, created_at, last_used";
132
133fn row(r: &Row) -> rusqlite::Result<SshKey> {
134 Ok(SshKey {
135 id: r.get(0)?,
136 user_id: r.get(1)?,
137 name: r.get(2)?,
138 algorithm: r.get(3)?,
139 public_key: r.get(4)?,
140 fingerprint: r.get(5)?,
141 created_at: r.get(6)?,
142 last_used: r.get(7)?,
143 })
144}
145
146impl AuthStore {
147 pub fn add_ssh_key(&self, user_id: i64, key: &str, name: Option<&str>) -> AuthResult<SshKey> {
150 let k = PublicKey::parse(key).map_err(AuthError::Invalid)?;
151 let name = super::clean_name(name.unwrap_or(&k.comment))?;
152 let fp = k.fingerprint();
153 let db = self.db();
154 let n: i64 = db.query_row(
155 "SELECT COUNT(*) FROM ssh_keys WHERE user_id = ?1",
156 [user_id],
157 |r| r.get(0),
158 )?;
159 if n >= MAX_KEYS {
160 return Err(AuthError::Invalid(format!(
161 "an account holds at most {MAX_KEYS} SSH keys; remove one first"
162 )));
163 }
164 let r = db.execute(
165 "INSERT INTO ssh_keys (user_id, name, algorithm, public_key, fingerprint, created_at)
166 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
167 params![user_id, name, k.algorithm, k.line(), fp, self.now()],
168 );
169 match r {
170 Ok(_) => {}
171 Err(rusqlite::Error::SqliteFailure(e, _))
172 if e.code == rusqlite::ErrorCode::ConstraintViolation =>
173 {
174 return Err(AuthError::Conflict(format!(
175 "that key ({fp}) is already on this account"
176 )));
177 }
178 Err(e) => return Err(e.into()),
179 }
180 let id = db.last_insert_rowid();
181 Ok(db.query_row(
182 &format!("SELECT {COLS} FROM ssh_keys WHERE id = ?1"),
183 [id],
184 row,
185 )?)
186 }
187
188 pub fn list_ssh_keys(&self, user_id: i64) -> AuthResult<Vec<SshKey>> {
189 let db = self.db();
190 let mut st = db.prepare(&format!(
191 "SELECT {COLS} FROM ssh_keys WHERE user_id = ?1 ORDER BY id"
192 ))?;
193 let rows = st.query_map([user_id], row)?;
194 Ok(rows.collect::<rusqlite::Result<_>>()?)
195 }
196
197 pub fn ssh_key(&self, user_id: i64, id: i64) -> AuthResult<Option<SshKey>> {
198 Ok(self
199 .db()
200 .query_row(
201 &format!("SELECT {COLS} FROM ssh_keys WHERE id = ?1 AND user_id = ?2"),
202 params![id, user_id],
203 row,
204 )
205 .optional()?)
206 }
207
208 pub fn delete_ssh_key(&self, user_id: i64, id: i64) -> AuthResult<bool> {
210 Ok(self.db().execute(
211 "DELETE FROM ssh_keys WHERE id = ?1 AND user_id = ?2",
212 params![id, user_id],
213 )? > 0)
214 }
215
216 pub fn has_ssh_key(&self, user_id: i64, fingerprint: &str) -> AuthResult<bool> {
219 Ok(self
220 .db()
221 .query_row(
222 "SELECT 1 FROM ssh_keys WHERE user_id = ?1 AND fingerprint = ?2",
223 params![user_id, fingerprint],
224 |_| Ok(()),
225 )
226 .optional()?
227 .is_some())
228 }
229
230 pub fn touch_ssh_key(&self, user_id: i64, fingerprint: &str) -> AuthResult<()> {
232 self.db().execute(
233 "UPDATE ssh_keys SET last_used = ?3 WHERE user_id = ?1 AND fingerprint = ?2",
234 params![user_id, fingerprint, self.now()],
235 )?;
236 Ok(())
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 const ED: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK85M+Nlyes6IrHWrRVqw80hYRdvPHO+GwqREPk1qxkh alice@laptop";
246
247 #[test]
248 fn parses_and_fingerprints_like_openssh() {
249 let k = PublicKey::parse(ED).unwrap();
250 assert_eq!(k.algorithm, "ssh-ed25519");
251 assert_eq!(k.comment, "alice@laptop");
252 assert_eq!(
253 k.line(),
254 "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK85M+Nlyes6IrHWrRVqw80hYRdvPHO+GwqREPk1qxkh"
255 );
256 assert_eq!(
258 k.fingerprint(),
259 "SHA256:OGav3hSvMQSOfHiDB0OdyYFOPHDbUJTzSsNvCdLadvQ"
260 );
261 let bare = PublicKey::parse(&format!(" {} ", k.line())).unwrap();
263 assert_eq!(bare.comment, "");
264 assert_eq!(bare.fingerprint(), k.fingerprint());
265 }
266
267 #[test]
268 fn refuses_options_garbage_and_mismatches() {
269 for bad in [
270 "",
271 "ssh-ed25519",
272 "ssh-ed25519 !!!notbase64",
273 "ssh-dss AAAAB3NzaC1kc3MAAACBAP",
274 &format!("command=\"rm -rf /\" {ED}"),
275 &format!("no-pty {ED}"),
276 &format!("{ED}\nssh-ed25519 AAAA"),
277 "ecdsa-sha2-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIK85M+Nlyes6IrHWrRVqw80hYRdvPHO+GwqREPk1qxkh",
279 "ssh-ed25519 AAAAC3NzaC1lZDI1",
281 "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCh+Ulvy6UEPNQQAlurADNGZXtfgFgks7nw6P1Esxf7DoMJyFmmmPnc3sVISBq2LusPE56vSNx5iRXIVe3YtacPtgmzPeNqM5cDILvQutXw3uwsGv6EnnALiDP6pluMnPJ1uKo318it/Dzgatzzmsl2qc4ZiC2e5IRTkto1nPjxfw==",
283 ] {
284 assert!(PublicKey::parse(bad).is_err(), "{bad:?}");
285 }
286 let e = PublicKey::parse(&format!("command=\"x\" {ED}")).unwrap_err();
287 assert!(e.contains("options"), "{e}");
288 }
289
290 #[test]
291 fn stores_lists_and_removes() {
292 let s = AuthStore::in_memory(Default::default()).unwrap();
293 let u = s
294 .create_first_admin("a@example.com", "A", "pw-long-enough-1")
295 .unwrap();
296 let k = s.add_ssh_key(u.id, ED, None).unwrap();
297 assert_eq!(k.name, "alice@laptop");
298 assert!(s.has_ssh_key(u.id, &k.fingerprint).unwrap());
299 assert!(matches!(
300 s.add_ssh_key(u.id, ED, Some("again")),
301 Err(AuthError::Conflict(_))
302 ));
303 s.touch_ssh_key(u.id, &k.fingerprint).unwrap();
304 let l = s.list_ssh_keys(u.id).unwrap();
305 assert_eq!(l.len(), 1);
306 assert!(l[0].last_used.is_some());
307 assert!(!s.delete_ssh_key(u.id + 1, k.id).unwrap());
309 assert!(s.delete_ssh_key(u.id, k.id).unwrap());
310 assert!(!s.has_ssh_key(u.id, &k.fingerprint).unwrap());
311 assert!(s.list_ssh_keys(u.id).unwrap().is_empty());
312 }
313}