Skip to main content

isb_server/auth/
ssh_keys.rs

1//! SSH public keys on isb accounts: what `isb ssh-proxy` lets into an
2//! instance (docs/guides/ssh.md).
3//!
4//! A key is stored as its algorithm and base64 blob only, re-validated on
5//! the way in: no `authorized_keys` options (`command=`, `from=`), no
6//! comment, nothing that could add a line or an option when it is written
7//! into an instance. Its comment becomes the key's name. The fingerprint is
8//! OpenSSH's (`SHA256:` and unpadded base64 of the blob's SHA-256), so it
9//! matches what `ssh-keygen -lf` and sshd's log print.
10
11use 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
18/// The key types accepted: OpenSSH's current ones. DSA is long gone.
19pub 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
29/// How many keys one account may hold.
30pub const MAX_KEYS: i64 = 50;
31
32/// A parsed public key: `algorithm blob`, and the comment it came with.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PublicKey {
35    pub algorithm: String,
36    /// Standard base64 of the wire-format key.
37    pub blob: String,
38    pub comment: String,
39}
40
41impl PublicKey {
42    /// One `authorized_keys`/`known_hosts`-style line, or what is wrong
43    /// with it. Options before the key are refused, not dropped: a key
44    /// pasted with `command="..."` is not the key its owner thinks it is.
45    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        // The blob starts with its own type as an SSH string.
69        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    /// `algorithm blob`, nothing else: what is written into an instance.
94    pub fn line(&self) -> String {
95        format!("{} {}", self.algorithm, self.blob)
96    }
97
98    /// OpenSSH's fingerprint: `SHA256:` and unpadded base64.
99    pub fn fingerprint(&self) -> String {
100        let blob = STANDARD.decode(&self.blob).unwrap_or_default();
101        fingerprint_of(&blob)
102    }
103}
104
105/// The OpenSSH SHA256 fingerprint of a wire-format key.
106pub 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
111/// The first SSH string (u32 length, bytes) of `b`.
112fn 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/// A key on an account, as listed.
118#[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    /// `algorithm blob`.
125    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    /// Add a public key to `user_id`'s account. `name` defaults to the key's
148    /// comment.
149    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    /// Remove one of `user_id`'s keys; `false` if they have no such key.
209    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    /// Is `fingerprint` still one of `user_id`'s keys? Asked during a live
217    /// session, so removing a key ends the sessions it opened.
218    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    /// Note that a key just opened a session.
231    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    /// `ssh-keygen -t ed25519` output (a throwaway key).
245    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        // As `ssh-keygen -lf` prints it.
257        assert_eq!(
258            k.fingerprint(),
259            "SHA256:OGav3hSvMQSOfHiDB0OdyYFOPHDbUJTzSsNvCdLadvQ"
260        );
261        // Surrounding whitespace and a missing comment are fine.
262        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            // An ed25519 blob labelled as ecdsa.
278            "ecdsa-sha2-nistp256 AAAAC3NzaC1lZDI1NTE5AAAAIK85M+Nlyes6IrHWrRVqw80hYRdvPHO+GwqREPk1qxkh",
279            // A truncated blob.
280            "ssh-ed25519 AAAAC3NzaC1lZDI1",
281            // RSA-1024.
282            "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        // Someone else's id is not yours to remove.
308        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}