Fuente publicada
Descargar fuente ZIP
CH-J Tools
Busque directorios y archivos para obtener una versión de aplicación específica.
CH-J / Server Manager
1704 archivos
0.1.2-beta
Añadido: · Construido:
1
//! authorized_keys handling (port of `users/authorizedKeys.js`).2
//!3
//! Works on bytes interpreted as Latin-1 so every byte of unrelated lines, including options,4
//! comments and line endings, is written back unchanged.6
use crate::core::error::CoreError;7
use base64::engine::general_purpose::{STANDARD as B64, STANDARD_NO_PAD};8
use base64::Engine;9
use serde_json::{json, Value};10
use sha2::{Digest, Sha256};12
pub const MAX_KEYS_FILE_BYTES: usize = 256 * 1024;13
const MAX_COMMENT_LENGTH: usize = 200;14
const ADDABLE_TYPES: [&str; 2] = ["ssh-ed25519", "sk-ssh-ed25519@openssh.com"];16
fn key_error(code: &str, message: &str) -> CoreError {17
CoreError::new(code, message)18
}20
/// `^(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]{1,80}$`21
fn is_key_type(value: &str) -> bool {22
let rest = value.strip_prefix("ssh-").or_else(|| value.strip_prefix("ecdsa-")).or_else(|| value.strip_prefix("sk-"));23
match rest {24
Some(rest) => (1..=80).contains(&rest.chars().count()) && rest.chars().all(|c| c.is_ascii_alphanumeric() || "@._-".contains(c)),25
None => false,26
}27
}29
pub fn fingerprint(blob: &[u8]) -> String {30
format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(blob)))31
}33
pub fn sha256_hex(content: &[u8]) -> String {34
hex::encode(Sha256::digest(content))35
}37
fn read_ssh_string(buffer: &[u8], offset: usize) -> Option<(&[u8], usize)> {38
if offset + 4 > buffer.len() {39
return None;40
}41
let length = u32::from_be_bytes(buffer[offset..offset + 4].try_into().ok()?) as usize;42
if length > buffer.len() - offset - 4 {43
return None;44
}45
Some((&buffer[offset + 4..offset + 4 + length], offset + 4 + length))46
}48
fn is_base64(text: &str) -> bool {49
let body = text.trim_end_matches('=');50
!body.is_empty() && text.len() - body.len() <= 2 && body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')51
}53
fn decode_blob(kind: &str, data: &str) -> Option<Vec<u8>> {54
if !is_base64(data) || data.len() % 4 != 0 {55
return None;56
}57
let blob = B64.decode(data).ok()?;58
let (embedded, next) = read_ssh_string(&blob, 0)?;59
if latin1(embedded) != kind {60
return None;61
}62
if kind == "ssh-ed25519" {63
let (key, end) = read_ssh_string(&blob, next)?;64
if key.len() != 32 || end != blob.len() {65
return None;66
}67
}68
Some(blob)69
}71
pub fn latin1(bytes: &[u8]) -> String {72
bytes.iter().map(|b| *b as char).collect()73
}75
fn from_latin1(text: &str) -> Vec<u8> {76
text.chars().map(|c| c as u32 as u8).collect()77
}79
fn skip_options(text: &[char]) -> Option<usize> {80
let mut quoted = false;81
let mut index = 0;82
while index < text.len() {83
let c = text[index];84
if c == '\\' && quoted {85
index += 2;86
continue;87
}88
if c == '"' {89
quoted = !quoted;90
} else if !quoted && (c == ' ' || c == '\t') {91
return Some(index);92
}93
index += 1;94
}95
None96
}98
/// Latin-1 text → UTF-8 text without control characters (0.1.0 `utf8()`).99
fn utf8(text: &str) -> String {100
let decoded = String::from_utf8_lossy(&from_latin1(text)).to_string();101
decoded.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect::<String>().trim().to_string()102
}104
fn split_ws(text: &str) -> Vec<&str> {105
text.split([' ', '\t']).filter(|s| !s.is_empty()).collect()106
}108
#[derive(Debug, Clone, PartialEq)]109
pub struct KeyEntry {110
pub line: usize,111
pub valid: bool,112
pub reason: Option<&'static str>,113
pub kind: Option<String>,114
pub fingerprint: Option<String>,115
pub comment: String,116
pub options: String,117
pub blob: Option<String>,118
}120
impl KeyEntry {121
pub fn public(&self) -> Value {122
if !self.valid {123
let mut value = json!({ "line": self.line, "valid": false, "reason": self.reason });124
if let Some(kind) = &self.kind {125
value["type"] = json!(kind);126
}127
return value;128
}129
let kind = self.kind.clone().unwrap_or_default();130
json!({131
"line": self.line,132
"valid": true,133
"type": kind,134
"ed25519": kind == "ssh-ed25519" || kind == "sk-ssh-ed25519@openssh.com",135
"fingerprint": self.fingerprint,136
"comment": self.comment,137
"options": self.options,138
"blob": self.blob139
})140
}141
}143
fn parse_line(raw: &str, line: usize) -> Option<KeyEntry> {144
let text = raw.strip_suffix('\r').unwrap_or(raw).trim();145
if text.is_empty() || text.starts_with('#') {146
return None;147
}148
let invalid = |reason: &'static str, kind: Option<String>| Some(KeyEntry { line, valid: false, reason: Some(reason), kind, fingerprint: None, comment: String::new(), options: String::new(), blob: None });149
let first = text.split([' ', '\t']).next().unwrap_or("");150
let (options, rest) = if is_key_type(first) {151
(String::new(), text.to_string())152
} else {153
let chars: Vec<char> = text.chars().collect();154
let Some(end) = skip_options(&chars) else { return invalid("unparsed", None) };155
(chars[..end].iter().collect::<String>(), chars[end..].iter().collect::<String>().trim().to_string())156
};157
let parts = split_ws(&rest);158
let kind = parts.first().copied().unwrap_or("");159
let data = parts.get(1).copied().unwrap_or("");160
if !is_key_type(kind) {161
return invalid("type", None);162
}163
let Some(blob) = decode_blob(kind, data) else { return invalid("data", Some(kind.to_string())) };164
Some(KeyEntry {165
line,166
valid: true,167
reason: None,168
kind: Some(kind.to_string()),169
fingerprint: Some(fingerprint(&blob)),170
comment: utf8(&parts[2.min(parts.len())..].join(" ")).chars().take(MAX_COMMENT_LENGTH).collect(),171
options: utf8(&options).chars().take(500).collect(),172
blob: Some(B64.encode(&blob)),173
})174
}176
pub fn parse_authorized_keys(content: &[u8]) -> Vec<KeyEntry> {177
let text = latin1(content);178
text.split('\n').enumerate().filter_map(|(i, raw)| parse_line(raw, i + 1)).collect()179
}181
#[derive(Debug, Clone)]182
pub struct NewKey {183
pub kind: String,184
pub data: String,185
pub comment: String,186
pub fingerprint: String,187
pub blob: String,188
}190
/// Accepts exactly one OpenSSH public key line without options. Only Ed25519 keys may be added.191
pub fn normalize_public_key(input: &Value, comment: &Value) -> Result<NewKey, CoreError> {192
let raw = input.as_str().unwrap_or("");193
let text = raw.strip_prefix('\u{feff}').unwrap_or(raw).trim();194
if text.is_empty() || text.encode_utf16().count() > 16 * 1024 {195
return Err(key_error("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key."));196
}197
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();198
if lines.len() != 1 {199
return Err(key_error("USERS_KEY_INVALID", "Paste exactly one OpenSSH public key."));200
}201
if text.contains("PRIVATE KEY") {202
return Err(key_error("USERS_KEY_PRIVATE", "This is a private key. Only public keys are uploaded to the server."));203
}204
let parts: Vec<&str> = lines[0].split_whitespace().collect();205
let kind = parts.first().copied().unwrap_or("");206
let data = parts.get(1).copied().unwrap_or("");207
if !is_key_type(kind) {208
return Err(key_error("USERS_KEY_INVALID", "Key options are not accepted; paste the plain public key."));209
}210
if !ADDABLE_TYPES.contains(&kind) {211
return Err(key_error("USERS_KEY_TYPE_UNSUPPORTED", "Only Ed25519 public keys can be added."));212
}213
let blob = decode_blob(kind, data).ok_or_else(|| key_error("USERS_KEY_INVALID", "The public key data is invalid."))?;214
let source = match comment {215
Value::Null => parts[2.min(parts.len())..].join(" "),216
Value::String(s) => s.clone(),217
other => other.to_string(),218
};219
let cleaned: String = source.chars().map(|c| if (c as u32) < 0x20 || c as u32 == 0x7f { ' ' } else { c }).collect();220
let final_comment = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");221
if final_comment.encode_utf16().count() > MAX_COMMENT_LENGTH {222
return Err(key_error("USERS_KEY_INVALID", "The key comment is too long."));223
}224
Ok(NewKey { kind: kind.into(), data: data.into(), comment: final_comment, fingerprint: fingerprint(&blob), blob: B64.encode(&blob) })225
}227
pub fn append_key(content: &[u8], key: &NewKey) -> Result<Vec<u8>, CoreError> {228
if parse_authorized_keys(content).iter().any(|e| e.valid && e.blob.as_deref() == Some(key.blob.as_str())) {229
return Err(key_error("USERS_KEY_DUPLICATE", "This public key is already authorized for the account."));230
}231
let mut out = content.to_vec();232
if !out.is_empty() && !out.ends_with(b"\n") {233
out.push(b'\n');234
}235
out.extend_from_slice(key.kind.as_bytes());236
out.push(b' ');237
out.extend_from_slice(key.data.as_bytes());238
if !key.comment.is_empty() {239
out.push(b' ');240
out.extend_from_slice(key.comment.as_bytes());241
}242
out.push(b'\n');243
if out.len() > MAX_KEYS_FILE_BYTES {244
return Err(key_error("USERS_KEYS_FILE_TOO_LARGE", "The authorized_keys file would exceed 256 KiB."));245
}246
Ok(out)247
}249
/// Removes exactly one line, identified by line number and fingerprint, leaving all other bytes untouched.250
pub fn remove_key(content: &[u8], line_number: &Value, expected_fingerprint: &str) -> Result<Vec<u8>, CoreError> {251
let not_found = || key_error("USERS_KEY_NOT_FOUND", "The selected key changed or no longer exists. Reload the key list.");252
let number = match line_number {253
Value::Number(n) => n.as_f64(),254
Value::String(s) => s.trim().parse::<f64>().ok(),255
_ => None,256
}257
.filter(|n| n.fract() == 0.0 && *n >= 1.0)258
.ok_or_else(not_found)? as usize;259
let mut lines: Vec<&[u8]> = content.split(|b| *b == b'\n').collect();260
let index = number - 1;261
if index >= lines.len() {262
return Err(not_found());263
}264
let entry = parse_line(&latin1(lines[index]), number).ok_or_else(not_found)?;265
if !entry.valid || entry.fingerprint.as_deref() != Some(expected_fingerprint) {266
return Err(not_found());267
}268
lines.remove(index);269
Ok(lines.join(&b'\n'))270
}272
#[cfg(test)]273
mod tests {274
use super::*;276
const KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";278
#[test]279
fn parse_append_remove_roundtrip() {280
let original = b"# comment\r\ncommand=\"echo hi\",no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl laptop\r\nbroken line\n".to_vec();281
let entries = parse_authorized_keys(&original);282
assert_eq!(entries.len(), 2);283
assert!(entries[0].valid);284
assert_eq!(entries[0].options, "command=\"echo hi\",no-pty");285
assert_eq!(entries[0].comment, "laptop");286
assert_eq!(entries[1].reason, Some("type"));287
let other = normalize_public_key(&json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBk1o2ZQ9bp1YtB4y3U8eXhP4h3JxS9s2VxkCwJmQnZx něco"), &Value::Null);288
// Random (not a valid point) but structurally valid 32-byte key.289
let other = other.unwrap();290
assert_eq!(other.comment, "něco");291
let appended = append_key(&original, &other).unwrap();292
assert!(appended.starts_with(&original));293
assert_eq!(append_key(&appended, &other).unwrap_err().code, "USERS_KEY_DUPLICATE");294
let fp = parse_authorized_keys(&appended).last().unwrap().fingerprint.clone().unwrap();295
let removed = remove_key(&appended, &json!(4), &fp).unwrap();296
assert_eq!(removed, [original.as_slice(), b""].concat());297
assert_eq!(remove_key(&appended, &json!(2), "SHA256:wrong").unwrap_err().code, "USERS_KEY_NOT_FOUND");298
assert_eq!(normalize_public_key(&json!("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQ=="), &Value::Null).unwrap_err().code, "USERS_KEY_TYPE_UNSUPPORTED");299
assert_eq!(normalize_public_key(&json!("-----BEGIN OPENSSH PRIVATE KEY-----"), &Value::Null).unwrap_err().code, "USERS_KEY_PRIVATE");300
assert!(normalize_public_key(&json!(KEY), &json!("x")).is_ok());301
}302
}SHA-256
1401e092d3644e95067f1fc50efd137c0c16f549ac30c16233312dbf4f3a203fCH-J Proprietary Software License 1.14 · La fuente se proporciona en el CH-J Proprietary Software License 1.14. Su disponibilidad no cambia los términos de la licencia ni otorga derechos adicionales.
Archivo SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f