CH-J ToolsSSH کے ذریعے سرور کا انتظام
مینو
شائع شدہ سورس

CH-J Tools

ایپلیکیشن کی کسی مخصوص ریلیز کی ڈائریکٹریز اور فائلیں دیکھیں۔

سورس ZIP ڈاؤن لوڈ کریں
CH-J / Server Manager 1704 فائلیں
0.1.2-beta شامل کیا گیا: · بلڈ کیا گیا:
keys.rs شامل کیا گیا: تاریخ درج نہیں 12.0 KB · 302 لائنیں
کوڈ فائل ڈاؤن لوڈ کریں
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.
6use crate::core::error::CoreError;
7use base64::engine::general_purpose::{STANDARD as B64, STANDARD_NO_PAD};
8use base64::Engine;
9use serde_json::{json, Value};
10use sha2::{Digest, Sha256};
12pub const MAX_KEYS_FILE_BYTES: usize = 256 * 1024;
13const MAX_COMMENT_LENGTH: usize = 200;
14const ADDABLE_TYPES: [&str; 2] = ["ssh-ed25519", "sk-ssh-ed25519@openssh.com"];
16fn key_error(code: &str, message: &str) -> CoreError {
17 CoreError::new(code, message)
20/// `^(?:ssh-|ecdsa-|sk-)[A-Za-z0-9@._-]{1,80}$`
21fn 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 }
29pub fn fingerprint(blob: &[u8]) -> String {
30 format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(blob)))
33pub fn sha256_hex(content: &[u8]) -> String {
34 hex::encode(Sha256::digest(content))
37fn 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))
48fn 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 == '/')
53fn 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)
71pub fn latin1(bytes: &[u8]) -> String {
72 bytes.iter().map(|b| *b as char).collect()
75fn from_latin1(text: &str) -> Vec<u8> {
76 text.chars().map(|c| c as u32 as u8).collect()
79fn 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 None
98/// Latin-1 text ← UTF-8 text without control characters (0.1.0 `utf8()`).
99fn 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()
104fn split_ws(text: &str) -> Vec<&str> {
105 text.split([' ', '\t']).filter(|s| !s.is_empty()).collect()
108#[derive(Debug, Clone, PartialEq)]
109pub 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>,
120impl 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.blob
139 })
140 }
143fn 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 })
176pub 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()
181#[derive(Debug, Clone)]
182pub struct NewKey {
183 pub kind: String,
184 pub data: String,
185 pub comment: String,
186 pub fingerprint: String,
187 pub blob: String,
190/// Accepts exactly one OpenSSH public key line without options. Only Ed25519 keys may be added.
191pub 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) })
227pub 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)
249/// Removes exactly one line, identified by line number and fingerprint, leaving all other bytes untouched.
250pub 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'))
272#[cfg(test)]
273mod 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 }
SHA-2561401e092d3644e95067f1fc50efd137c0c16f549ac30c16233312dbf4f3a203f

CH-J Proprietary Software License 1.14 · سورس CH-J Proprietary Software License 1.14 کے تحت فراہم کیا جاتا ہے۔ اس کی دستیابی لائسنس کی شرائط نہیں بدلتی اور نہ ہی اضافی حقوق دیتی ہے۔

آرکائیو کا SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
فائلیں