//! authorized_keys handling (port of `users/authorizedKeys.js`). //! //! Works on bytes interpreted as Latin-1 so every byte of unrelated lines, including options, //! comments and line endings, is written back unchanged. use crate::core::error::CoreError; use base64::engine::general_purpose::{STANDARD as B64, STANDARD_NO_PAD}; use base64::Engine; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; pub const MAX_KEYS_FILE_BYTES: usize = 256 * 1024; const MAX_COMMENT_LENGTH: usize = 200; const ADDABLE_TYPES: [&str; 2] = ["ssh-ed25519", "sk-ssh-ed25519@openssh.com"]; fn key_error(code: &str, message: &str) -> CoreError { CoreError::new(code, message) } /// `^(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]{1,80}$` fn is_key_type(value: &str) -> bool { let rest = value.strip_prefix("ssh-").or_else(|| value.strip_prefix("ecdsa-")).or_else(|| value.strip_prefix("sk-")); match rest { Some(rest) => (1..=80).contains(&rest.chars().count()) && rest.chars().all(|c| c.is_ascii_alphanumeric() || "@._-".contains(c)), None => false, } } pub fn fingerprint(blob: &[u8]) -> String { format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(blob))) } pub fn sha256_hex(content: &[u8]) -> String { hex::encode(Sha256::digest(content)) } fn read_ssh_string(buffer: &[u8], offset: usize) -> Option<(&[u8], usize)> { if offset + 4 > buffer.len() { return None; } let length = u32::from_be_bytes(buffer[offset..offset + 4].try_into().ok()?) as usize; if length > buffer.len() - offset - 4 { return None; } Some((&buffer[offset + 4..offset + 4 + length], offset + 4 + length)) } fn is_base64(text: &str) -> bool { let body = text.trim_end_matches('='); !body.is_empty() && text.len() - body.len() <= 2 && body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/') } fn decode_blob(kind: &str, data: &str) -> Option> { if !is_base64(data) || data.len() % 4 != 0 { return None; } let blob = B64.decode(data).ok()?; let (embedded, next) = read_ssh_string(&blob, 0)?; if latin1(embedded) != kind { return None; } if kind == "ssh-ed25519" { let (key, end) = read_ssh_string(&blob, next)?; if key.len() != 32 || end != blob.len() { return None; } } Some(blob) } pub fn latin1(bytes: &[u8]) -> String { bytes.iter().map(|b| *b as char).collect() } fn from_latin1(text: &str) -> Vec { text.chars().map(|c| c as u32 as u8).collect() } fn skip_options(text: &[char]) -> Option { let mut quoted = false; let mut index = 0; while index < text.len() { let c = text[index]; if c == '\\' && quoted { index += 2; continue; } if c == '"' { quoted = !quoted; } else if !quoted && (c == ' ' || c == '\t') { return Some(index); } index += 1; } None } /// Latin-1 text → UTF-8 text without control characters (0.1.0 `utf8()`). fn utf8(text: &str) -> String { let decoded = String::from_utf8_lossy(&from_latin1(text)).to_string(); decoded.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect::().trim().to_string() } fn split_ws(text: &str) -> Vec<&str> { text.split([' ', '\t']).filter(|s| !s.is_empty()).collect() } #[derive(Debug, Clone, PartialEq)] pub struct KeyEntry { pub line: usize, pub valid: bool, pub reason: Option<&'static str>, pub kind: Option, pub fingerprint: Option, pub comment: String, pub options: String, pub blob: Option, } impl KeyEntry { pub fn public(&self) -> Value { if !self.valid { let mut value = json!({ "line": self.line, "valid": false, "reason": self.reason }); if let Some(kind) = &self.kind { value["type"] = json!(kind); } return value; } let kind = self.kind.clone().unwrap_or_default(); json!({ "line": self.line, "valid": true, "type": kind, "ed25519": kind == "ssh-ed25519" || kind == "sk-ssh-ed25519@openssh.com", "fingerprint": self.fingerprint, "comment": self.comment, "options": self.options, "blob": self.blob }) } } fn parse_line(raw: &str, line: usize) -> Option { let text = raw.strip_suffix('\r').unwrap_or(raw).trim(); if text.is_empty() || text.starts_with('#') { return None; } let invalid = |reason: &'static str, kind: Option| Some(KeyEntry { line, valid: false, reason: Some(reason), kind, fingerprint: None, comment: String::new(), options: String::new(), blob: None }); let first = text.split([' ', '\t']).next().unwrap_or(""); let (options, rest) = if is_key_type(first) { (String::new(), text.to_string()) } else { let chars: Vec = text.chars().collect(); let Some(end) = skip_options(&chars) else { return invalid("unparsed", None) }; (chars[..end].iter().collect::(), chars[end..].iter().collect::().trim().to_string()) }; let parts = split_ws(&rest); let kind = parts.first().copied().unwrap_or(""); let data = parts.get(1).copied().unwrap_or(""); if !is_key_type(kind) { return invalid("type", None); } let Some(blob) = decode_blob(kind, data) else { return invalid("data", Some(kind.to_string())) }; Some(KeyEntry { line, valid: true, reason: None, kind: Some(kind.to_string()), fingerprint: Some(fingerprint(&blob)), comment: utf8(&parts[2.min(parts.len())..].join(" ")).chars().take(MAX_COMMENT_LENGTH).collect(), options: utf8(&options).chars().take(500).collect(), blob: Some(B64.encode(&blob)), }) } pub fn parse_authorized_keys(content: &[u8]) -> Vec { let text = latin1(content); text.split('\n').enumerate().filter_map(|(i, raw)| parse_line(raw, i + 1)).collect() } #[derive(Debug, Clone)] pub struct NewKey { pub kind: String, pub data: String, pub comment: String, pub fingerprint: String, pub blob: String, } /// Accepts exactly one OpenSSH public key line without options. Only Ed25519 keys may be added. pub fn normalize_public_key(input: &Value, comment: &Value) -> Result { let raw = input.as_str().unwrap_or(""); let text = raw.strip_prefix('\u{feff}').unwrap_or(raw).trim(); if text.is_empty() || text.encode_utf16().count() > 16 * 1024 { return Err(key_error("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key.")); } let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l).trim()).filter(|l| !l.is_empty() && !l.starts_with('#')).collect(); if lines.len() != 1 { return Err(key_error("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key.")); } if text.contains("PRIVATE KEY") { return Err(key_error("USERS_KEY_PRIVATE", "This is a private key. Only public keys are uploaded to the server.")); } let parts: Vec<&str> = lines[0].split_whitespace().collect(); let kind = parts.first().copied().unwrap_or(""); let data = parts.get(1).copied().unwrap_or(""); if !is_key_type(kind) { return Err(key_error("USERS_KEY_INVALID", "Key options are not accepted; paste the plain public key.")); } if !ADDABLE_TYPES.contains(&kind) { return Err(key_error("USERS_KEY_TYPE_UNSUPPORTED", "Only Ed25519 public keys can be added.")); } let blob = decode_blob(kind, data).ok_or_else(|| key_error("USERS_KEY_INVALID", "The public key data is invalid."))?; let source = match comment { Value::Null => parts[2.min(parts.len())..].join(" "), Value::String(s) => s.clone(), other => other.to_string(), }; let cleaned: String = source.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect(); let final_comment = cleaned.split_whitespace().collect::>().join(" "); if final_comment.encode_utf16().count() > MAX_COMMENT_LENGTH { return Err(key_error("USERS_KEY_INVALID", "The key comment is too long.")); } Ok(NewKey { kind: kind.into(), data: data.into(), comment: final_comment, fingerprint: fingerprint(&blob), blob: B64.encode(&blob) }) } pub fn append_key(content: &[u8], key: &NewKey) -> Result, CoreError> { if parse_authorized_keys(content).iter().any(|e| e.valid && e.blob.as_deref() == Some(key.blob.as_str())) { return Err(key_error("USERS_KEY_DUPLICATE", "This public key is already authorized for the account.")); } let mut out = content.to_vec(); if !out.is_empty() && !out.ends_with(b"\n") { out.push(b'\n'); } out.extend_from_slice(key.kind.as_bytes()); out.push(b' '); out.extend_from_slice(key.data.as_bytes()); if !key.comment.is_empty() { out.push(b' '); out.extend_from_slice(key.comment.as_bytes()); } out.push(b'\n'); if out.len() > MAX_KEYS_FILE_BYTES { return Err(key_error("USERS_KEYS_FILE_TOO_LARGE", "The authorized_keys file would exceed 256 KiB.")); } Ok(out) } /// Removes exactly one line, identified by line number and fingerprint, leaving all other bytes untouched. pub fn remove_key(content: &[u8], line_number: &Value, expected_fingerprint: &str) -> Result, CoreError> { let not_found = || key_error("USERS_KEY_NOT_FOUND", "The selected key changed or no longer exists. Reload the key list."); let number = match line_number { Value::Number(n) => n.as_f64(), Value::String(s) => s.trim().parse::().ok(), _ => None, } .filter(|n| n.fract() == 0.0 && *n >= 1.0) .ok_or_else(not_found)? as usize; let mut lines: Vec<&[u8]> = content.split(|b| *b == b'\n').collect(); let index = number - 1; if index >= lines.len() { return Err(not_found()); } let entry = parse_line(&latin1(lines[index]), number).ok_or_else(not_found)?; if !entry.valid || entry.fingerprint.as_deref() != Some(expected_fingerprint) { return Err(not_found()); } lines.remove(index); Ok(lines.join(&b'\n')) } #[cfg(test)] mod tests { use super::*; const KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"; #[test] fn parse_append_remove_roundtrip() { let original = b"# comment\r\ncommand=\"echo hi\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl laptop\r\nbroken line\n".to_vec(); let entries = parse_authorized_keys(&original); assert_eq!(entries.len(), 2); assert!(entries[0].valid); assert_eq!(entries[0].options, "command=\"echo hi\",no-pty"); assert_eq!(entries[0].comment, "laptop"); assert_eq!(entries[1].reason, Some("type")); let other = normalize_public_key(&json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBk1o2ZQ9bp1YtB4y3U8eXhP4h3JxS9s2VxkCwJmQnZx něco"), &Value::Null); // Random (not a valid point) but structurally valid 32-byte key. let other = other.unwrap(); assert_eq!(other.comment, "něco"); let appended = append_key(&original, &other).unwrap(); assert!(appended.starts_with(&original)); assert_eq!(append_key(&appended, &other).unwrap_err().code, "USERS_KEY_DUPLICATE"); let fp = parse_authorized_keys(&appended).last().unwrap().fingerprint.clone().unwrap(); let removed = remove_key(&appended, &json!(4), &fp).unwrap(); assert_eq!(removed, [original.as_slice(), b""].concat()); assert_eq!(remove_key(&appended, &json!(2), "SHA256:wrong").unwrap_err().code, "USERS_KEY_NOT_FOUND"); assert_eq!(normalize_public_key(&json!("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQ=="), &Value::Null).unwrap_err().code, "USERS_KEY_TYPE_UNSUPPORTED"); assert_eq!(normalize_public_key(&json!("-----BEGIN OPENSSH PRIVATE KEY-----"), &Value::Null).unwrap_err().code, "USERS_KEY_PRIVATE"); assert!(normalize_public_key(&json!(KEY), &json!("x")).is_ok()); } }