forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.6 KiB
1//! Page: SSH keys (`/settings/keys`). Only ed25519 and ECDSA keys are accepted.
2
3use base64::prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD, Engine as _};
4use forge_core::{AuditAction, SshKey};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::content::Form;
10use topcoat::router::error::{SeeOther, see_other};
11use topcoat::router::{page, route};
12use topcoat::view::{View, component, view};
13
14use crate::audit::{self, Actor, Target};
15use crate::auth::{require_csrf, require_user};
16use crate::i18n::{self, Key, Lang, added_line, t, unsupported_key_type};
17use crate::state::AppState;
18use crate::views::{alert, shell, user_facing_message};
19
20/// Algorithms accepted for web-registered keys. `ssh-rsa` is rejected with its own message.
21const ACCEPTED: &[&str] = &[
22 "ssh-ed25519",
23 "ecdsa-sha2-nistp256",
24 "ecdsa-sha2-nistp384",
25 "ecdsa-sha2-nistp521",
26 "sk-ssh-ed25519@openssh.com",
27 "sk-ecdsa-sha2-nistp256@openssh.com",
28];
29const MAX_KEY_CHARS: usize = 16 * 1024;
30
31/// Why a pasted key line is refused. `message` says it in the viewer's language.
32#[derive(Debug, PartialEq, Eq)]
33pub enum KeyProblem {
34 Empty,
35 TooLong,
36 MissingBody,
37 Rsa,
38 UnsupportedType(String),
39 BadBase64,
40 Truncated,
41 Mismatch,
42}
43
44impl KeyProblem {
45 pub fn message(&self, lang: Lang) -> String {
46 match self {
47 KeyProblem::Empty => t(lang, Key::ErrKeyEmpty).to_owned(),
48 KeyProblem::TooLong => t(lang, Key::ErrKeyTooLong).to_owned(),
49 KeyProblem::MissingBody => t(lang, Key::ErrKeyMissingBody).to_owned(),
50 KeyProblem::Rsa => t(lang, Key::ErrKeyRsa).to_owned(),
51 KeyProblem::UnsupportedType(algorithm) => unsupported_key_type(lang, algorithm),
52 KeyProblem::BadBase64 => t(lang, Key::ErrKeyBadBase64).to_owned(),
53 KeyProblem::Truncated => t(lang, Key::ErrKeyTruncated).to_owned(),
54 KeyProblem::Mismatch => t(lang, Key::ErrKeyMismatch).to_owned(),
55 }
56 }
57}
58
59/// A parsed OpenSSH public key line.
60#[derive(Debug, PartialEq, Eq)]
61pub struct ParsedKey {
62 pub algorithm: String,
63 /// Raw wire-format blob (the base64 body, decoded).
64 pub blob: Vec<u8>,
65 pub fingerprint: String,
66 pub comment: String,
67}
68
69/// Parses `<algorithm> <base64> [comment]`. Checks that the algorithm is accepted and that
70/// the blob's embedded algorithm string matches it.
71pub fn parse_public_key(input: &str) -> std::result::Result<ParsedKey, KeyProblem> {
72 let line = input.trim();
73 if line.is_empty() {
74 return Err(KeyProblem::Empty);
75 }
76 if line.len() > MAX_KEY_CHARS {
77 return Err(KeyProblem::TooLong);
78 }
79 let mut parts = line.split_whitespace();
80 let algorithm = parts.next().unwrap_or_default();
81 let encoded = parts.next().ok_or(KeyProblem::MissingBody)?;
82 let comment = parts.collect::<Vec<_>>().join(" ");
83
84 if algorithm == "ssh-rsa" {
85 return Err(KeyProblem::Rsa);
86 }
87 if !ACCEPTED.contains(&algorithm) {
88 return Err(KeyProblem::UnsupportedType(algorithm.to_owned()));
89 }
90 let blob = BASE64_STANDARD
91 .decode(encoded)
92 .map_err(|_| KeyProblem::BadBase64)?;
93 let embedded = read_ssh_string(&blob).ok_or(KeyProblem::Truncated)?;
94 if embedded != algorithm.as_bytes() {
95 return Err(KeyProblem::Mismatch);
96 }
97 Ok(ParsedKey {
98 algorithm: algorithm.to_owned(),
99 fingerprint: fingerprint(&blob),
100 blob,
101 comment,
102 })
103}
104
105/// Reads one SSH wire-format string (u32 big-endian length, then bytes) from the start of `buf`.
106fn read_ssh_string(buf: &[u8]) -> Option<&[u8]> {
107 let len = u32::from_be_bytes(buf.get(..4)?.try_into().ok()?);
108 let end = 4usize.checked_add(usize::try_from(len).ok()?)?;
109 buf.get(4..end)
110}
111
112/// `SHA256:` followed by unpadded base64 of the SHA-256 of the key blob, as `ssh-keygen -l` prints.
113pub fn fingerprint(blob: &[u8]) -> String {
114 format!(
115 "SHA256:{}",
116 BASE64_STANDARD_NO_PAD.encode(Sha256::digest(blob))
117 )
118}
119
120#[derive(Deserialize)]
121struct AddKeyForm {
122 #[serde(default)]
123 csrf: String,
124 #[serde(default)]
125 title: String,
126 #[serde(default)]
127 public_key: String,
128}
129
130#[derive(Deserialize)]
131struct DeleteKeyForm {
132 #[serde(default)]
133 csrf: String,
134 #[serde(default)]
135 id: String,
136}
137
138#[component]
139async fn keys_view(
140 lang: Lang,
141 csrf: &str,
142 keys: Vec<SshKey>,
143 public_key: &str,
144 error: Option<String>,
145) -> Result<impl View> {
146 Ok(view! {
147 <section class="page-head">
148 <h1>(t(lang, Key::SshKeys))</h1>
149 </section>
150 <section class="card">
151 if keys.is_empty() {
152 <p class="muted">(t(lang, Key::NoSshKeys))</p>
153 } else {
154 <ul class="item-list">
155 for key in keys.into_iter() {
156 <li class="item">
157 <div class="item-main">
158 <strong>(key.title.clone())</strong>
159 <span class="muted small">(key.algorithm.clone())</span>
160 <code class="small">(key.fingerprint.clone())</code>
161 <span class="muted small">(added_line(lang, key.created_at.as_str(), key.last_used_at.as_deref()))</span>
162 </div>
163 <form method="post" action="/settings/keys/delete">
164 <input type="hidden" name="csrf" value=(csrf)>
165 <input type="hidden" name="id" value=(key.id.clone())>
166 <button type="submit" class="danger">(t(lang, Key::Delete))</button>
167 </form>
168 </li>
169 }
170 </ul>
171 }
172 </section>
173 <section class="card">
174 <h2>(t(lang, Key::AddSshKeyTitle))</h2>
175 if let Some(message) = error {
176 alert(message: message.as_str())
177 }
178 <form method="post" action="/settings/keys" class="stack">
179 <input type="hidden" name="csrf" value=(csrf)>
180 <label>
181 <span>(t(lang, Key::TitleOptional))</span>
182 <input type="text" name="title" maxlength="100" autocomplete="off">
183 </label>
184 <label>
185 <span>(t(lang, Key::PublicKeyLabel))</span>
186 <textarea name="public_key" rows="4" spellcheck="false" required=(true)>(public_key)</textarea>
187 <small class="muted">(t(lang, Key::PublicKeyHint))</small>
188 </label>
189 <button type="submit" class="primary">(t(lang, Key::AddKeyButton))</button>
190 </form>
191 </section>
192 })
193}
194
195#[page("/settings/keys")]
196async fn keys_page(cx: &Cx) -> Result<impl View> {
197 let viewer = require_user(cx).await?;
198 let lang = i18n::lang(cx, Some(&viewer.0));
199 let keys = app_context::<AppState>(cx)
200 .store
201 .list_ssh_keys(&viewer.0.id)
202 .await?;
203 let csrf = viewer.1.csrf_token.clone();
204 Ok(view! {
205 shell(title: t(lang, Key::SshKeys), viewer: Some(&viewer), keys_view(lang: lang, csrf: csrf.as_str(), keys: keys, public_key: "", error: None))
206 })
207}
208
209#[page(POST "/settings/keys")]
210async fn keys_add(cx: &Cx, Form(form): Form<AddKeyForm>) -> Result<impl View> {
211 let viewer = require_csrf(cx, &form.csrf).await?;
212 let lang = i18n::lang(cx, Some(&viewer.0));
213 let state = app_context::<AppState>(cx);
214 let store = &state.store;
215
216 let outcome: std::result::Result<(), String> = match parse_public_key(&form.public_key) {
217 Err(problem) => Err(problem.message(lang)),
218 Ok(parsed) => {
219 let title = match form.title.trim() {
220 "" if !parsed.comment.is_empty() => parsed.comment.clone(),
221 "" => parsed.algorithm.clone(),
222 t => t.to_owned(),
223 };
224 match store
225 .add_ssh_key(
226 &viewer.0.id,
227 &title,
228 &parsed.algorithm,
229 &parsed.blob,
230 &parsed.fingerprint,
231 )
232 .await
233 {
234 Ok(key) => {
235 audit::record(
236 store,
237 Some(cx),
238 Actor::User(&viewer.0),
239 AuditAction::SshKeyAdded,
240 Target {
241 kind: "ssh_key",
242 id: &key.id,
243 name: &key.title,
244 },
245 serde_json::Value::Null,
246 )
247 .await;
248 crate::signatures::on_keys_changed(state, &viewer.0.id).await;
249 return Err(see_other("/settings/keys").into());
250 }
251 Err(e) => match user_facing_message(&e) {
252 Some(message) => Err(message),
253 None => return Err(e.into()),
254 },
255 }
256 }
257 };
258 let message = outcome.err().unwrap_or_default();
259
260 let keys = store.list_ssh_keys(&viewer.0.id).await?;
261 let csrf = viewer.1.csrf_token.clone();
262 Ok(view! {
263 shell(title: t(lang, Key::SshKeys), viewer: Some(&viewer), keys_view(lang: lang, csrf: csrf.as_str(), keys: keys, public_key: form.public_key.as_str(), error: Some(message)))
264 })
265}
266
267#[route(POST "/settings/keys/delete")]
268async fn keys_delete(cx: &Cx, Form(form): Form<DeleteKeyForm>) -> Result<SeeOther> {
269 let (user, _) = require_csrf(cx, &form.csrf).await?;
270 let state = app_context::<AppState>(cx);
271 if state.store.delete_ssh_key(&user.id, &form.id).await? {
272 audit::record(
273 &state.store,
274 Some(cx),
275 Actor::User(&user),
276 AuditAction::SshKeyRemoved,
277 Target {
278 kind: "ssh_key",
279 id: &form.id,
280 name: "",
281 },
282 serde_json::Value::Null,
283 )
284 .await;
285 crate::signatures::on_keys_changed(state, &user.id).await;
286 }
287 Ok(see_other("/settings/keys"))
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 /// Generated with `ssh-keygen -t ed25519`; fingerprint checked with `ssh-keygen -lf`.
295 const ED25519: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPTx1OLAmIEVXGSZou6JBQpDthDq02k21xOH+ZjDgGCT test@forge";
296 const ED25519_FP: &str = "SHA256:9zi5S6OMkaY6rUdUTVRIyH6rwSW8nKKOhu4IInOmbhk";
297 /// Generated with `ssh-keygen -t ecdsa -b 256`.
298 const ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBJKn08r7Jqy5wnRekXOHynUfXBt7rcyVJHzTKk+5oGZoBekmIENVjEfl2TlkYwU+9jCSMCQ8nsU2a0bzuWshqk0= tahinli@tFedora";
299 const ECDSA_FP: &str = "SHA256:0U8otw1ElxrQ6xL2P3888Sd6V2khCb8gXQZa0A2zRx4";
300
301 #[test]
302 fn parses_ed25519_and_matches_ssh_keygen_fingerprint() {
303 let parsed = parse_public_key(ED25519).unwrap();
304 assert_eq!(parsed.algorithm, "ssh-ed25519");
305 assert_eq!(parsed.comment, "test@forge");
306 assert_eq!(parsed.fingerprint, ED25519_FP);
307 }
308
309 #[test]
310 fn parses_ecdsa_and_matches_ssh_keygen_fingerprint() {
311 let parsed = parse_public_key(ECDSA).unwrap();
312 assert_eq!(parsed.algorithm, "ecdsa-sha2-nistp256");
313 assert_eq!(parsed.fingerprint, ECDSA_FP);
314 }
315
316 #[test]
317 fn fingerprint_is_sha256_unpadded_base64() {
318 let fp = fingerprint(b"blob");
319 assert!(fp.starts_with("SHA256:"));
320 assert!(!fp.ends_with('='));
321 assert_eq!(fp, fingerprint(b"blob"));
322 assert_ne!(fp, fingerprint(b"other"));
323 }
324
325 #[test]
326 fn accepts_the_surrounding_whitespace_and_no_comment() {
327 let bare = ED25519
328 .split_whitespace()
329 .take(2)
330 .collect::<Vec<_>>()
331 .join(" ");
332 let parsed = parse_public_key(&format!(" {bare}\n")).unwrap();
333 assert_eq!(parsed.comment, "");
334 assert_eq!(parsed.fingerprint, ED25519_FP);
335 }
336
337 #[test]
338 fn rejects_rsa_with_the_documented_message() {
339 let rsa = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCn test@host";
340 assert_eq!(parse_public_key(rsa).unwrap_err(), KeyProblem::Rsa);
341 }
342
343 #[test]
344 fn rejects_unsupported_types() {
345 let dss = "ssh-dss AAAAB3NzaC1kc3MAAACBAP test@host";
346 assert_eq!(
347 parse_public_key(dss).unwrap_err(),
348 KeyProblem::UnsupportedType("ssh-dss".into())
349 );
350 }
351
352 #[test]
353 fn rejects_algorithm_that_does_not_match_the_blob() {
354 // Real ed25519 body, relabelled as ECDSA.
355 let relabelled = ED25519.replacen("ssh-ed25519", "ecdsa-sha2-nistp256", 1);
356 assert_eq!(
357 parse_public_key(&relabelled).unwrap_err(),
358 KeyProblem::Mismatch
359 );
360 }
361
362 #[test]
363 fn rejects_bad_base64_truncated_and_empty_input() {
364 assert!(parse_public_key("ssh-ed25519 !!!notbase64").is_err());
365 assert_eq!(
366 parse_public_key("ssh-ed25519 AAAA").unwrap_err(),
367 KeyProblem::Truncated
368 );
369 assert!(parse_public_key(" ").is_err());
370 assert!(parse_public_key("ssh-ed25519").is_err());
371 let huge = format!("ssh-ed25519 {}", "A".repeat(MAX_KEY_CHARS));
372 assert_eq!(parse_public_key(&huge).unwrap_err(), KeyProblem::TooLong);
373 }
374
375 #[test]
376 fn rejects_a_length_prefix_past_the_end_of_the_blob() {
377 // Length prefix claims 255 bytes but only 4 follow.
378 let body = BASE64_STANDARD.encode([0u8, 0, 0, 255, b's', b's', b'h', b'!']);
379 let line = format!("ssh-ed25519 {body}");
380 assert!(parse_public_key(&line).is_err());
381 }
382}