CH-J Toolsδιαχείριση διακομιστή πάνω από SSH
Μενού
Δημοσίευση πηγής

CH-J Tools

Περιηγηθείτε σε καταλόγους και αρχεία για μια συγκεκριμένη έκδοση εφαρμογών.

Κατεβάστε την πηγή ZIP
CH-J / Server Manager 1704 αρχεία
0.1.2-beta Προστέθηκε: · Χτισμένο:
keyserver.rs Προστέθηκε: Ημερομηνία που δεν έχει καταγραφεί 19,3 KB · 480 γραμμές
Κωδικός Λήψη αρχείου
1//! Optional public OpenPGP distribution through the fixed CH-J Hagrid VKS endpoint.
2//! Requests contain a freshly serialized public certificate; private packets never leave Core.
3use crate::core::{
4 error::{CoreError, CoreResult},
5 keychain,
6 vault::VaultStore,
7};
8use pgp::{
9 composed::{Deserializable, SignedPublicKey, SignedSecretKey},
10 types::KeyDetails,
11};
12use serde_json::{json, Value};
13use std::{io::Cursor, time::Duration};
15pub const ORIGIN: &str = "https://keys.ch-j.de";
16const MAX_BODY: usize = 1024 * 1024;
17fn error(code: &str, message: &str) -> CoreError {
18 CoreError::new(code, message)
20fn request_error(_: reqwest::Error) -> CoreError {
21 error(
22 "KEYSERVER_UNAVAILABLE",
23 "Could not reach keys.ch-j.de over verified HTTPS. Check the network connection and try again.",
24 )
26// Resolve A and AAAA with the system's DNS servers (dual-stack since 2026-11-05, IPv6 first); using
27// explicit DNS also avoids macOS's negative getaddrinfo cache for newly added names.
28struct KeyserverResolver;
29impl reqwest::dns::Resolve for KeyserverResolver {
30 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
31 let host = name.as_str().to_string();
32 Box::pin(async move {
33 type DnsError = Box<dyn std::error::Error + Send + Sync>;
34 if host != "keys.ch-j.de" {
35 return Err(
36 Box::new(std::io::Error::other("Unexpected keyserver DNS name")) as DnsError,
37 );
38 }
39 let (config, mut options) = hickory_resolver::system_conf::read_system_conf()
40 .map_err(|e| Box::new(e) as DnsError)?;
41 options.ip_strategy = hickory_resolver::config::LookupIpStrategy::Ipv6thenIpv4;
42 options.timeout = Duration::from_secs(4);
43 options.attempts = 2;
44 let resolver = hickory_resolver::TokioResolver::builder_with_config(
45 config,
46 hickory_resolver::name_server::TokioConnectionProvider::default(),
47 )
48 .with_options(options)
49 .build();
50 let lookup = resolver
51 .lookup_ip("keys.ch-j.de.")
52 .await
53 .map_err(|e| Box::new(e) as DnsError)?;
54 let addresses: Vec<std::net::SocketAddr> = lookup
55 .iter()
56 .map(|ip| std::net::SocketAddr::new(ip, 0))
57 .collect();
58 if addresses.is_empty() {
59 return Err(
60 Box::new(std::io::Error::other("Keyserver has no address")) as DnsError,
61 );
62 }
63 Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs)
64 })
65 }
67pub(crate) fn client() -> CoreResult<reqwest::Client> {
68 reqwest::Client::builder()
69 .https_only(true)
70 .dns_resolver(std::sync::Arc::new(KeyserverResolver))
71 .use_rustls_tls()
72 .min_tls_version(reqwest::tls::Version::TLS_1_2)
73 .redirect(reqwest::redirect::Policy::none())
74 .no_proxy()
75 .connect_timeout(Duration::from_secs(10))
76 .timeout(Duration::from_secs(30))
77 .user_agent(concat!("CH-J-Tools/", env!("CARGO_PKG_VERSION")))
78 .build()
79 .map_err(request_error)
81fn certificate(text: &str) -> CoreResult<SignedPublicKey> {
82 if crate::core::openpgp_compat::handles(text.as_bytes())? {
83 return Err(error("KEYSERVER_UNSUPPORTED_VERSION", "The keyserver supports OpenPGP V4 and V6 certificates."));
84 }
85 if text.len() > MAX_BODY || SignedSecretKey::from_reader_single(Cursor::new(text)).is_ok() {
86 return Err(error(
87 "KEYSERVER_PUBLIC_ONLY",
88 "Only public OpenPGP certificates can be sent to the keyserver.",
89 ));
90 }
91 let (key, _) = SignedPublicKey::from_reader_single(Cursor::new(text)).map_err(|_| {
92 error(
93 "KEYSERVER_PUBLIC_ONLY",
94 "Invalid public OpenPGP certificate.",
95 )
96 })?;
97 key.verify_bindings().map_err(|_| {
98 error(
99 "KEYSERVER_INVALID_CERTIFICATE",
100 "Invalid OpenPGP certificate bindings.",
101 )
102 })?;
103 Ok(key)
105pub fn public_payload(entry: &Value) -> CoreResult<(String, Vec<u8>)> {
106 if entry["kind"] != "openpgp" {
107 return Err(error(
108 "KEYSERVER_OPENPGP_ONLY",
109 "Hagrid distributes OpenPGP certificates; SSH identities remain in the local Keychain.",
110 ));
111 }
112 let mut key = certificate(entry["public"].as_str().unwrap_or(""))?;
113 if !matches!(
114 key.primary_key.version(),
115 pgp::types::KeyVersion::V4 | pgp::types::KeyVersion::V6
116 ) {
117 return Err(error(
118 "KEYSERVER_UNSUPPORTED_VERSION",
119 "The keyserver supports OpenPGP V4 and V6 certificates.",
120 ));
121 }
122 let fingerprint = hex::encode_upper(key.fingerprint().as_ref());
123 if entry["fingerprint"] != fingerprint {
124 return Err(error(
125 "KEYSERVER_FINGERPRINT_MISMATCH",
126 "The certificate fingerprint does not match this identity.",
127 ));
128 }
129 // New PQ certificates are fingerprint-first uploads. Self-certification is not
130 // independent proof of email ownership; never disclose unverified email UIDs.
131 let pq=key.primary_key.algorithm().is_pqc() || key.public_subkeys.iter().any(|s|s.key.algorithm().is_pqc());
132 if pq && key.details.users.iter().any(|u|u.id.id().contains(&b'@')) {
133 if key.primary_key.version()!=pgp::types::KeyVersion::V6 {
134 return Err(error("KEYSERVER_UNVERIFIED_IDENTITY","This PQ V4 certificate contains an email identity. Use the keyserver's explicit identity-verification workflow; no upload was sent."));
135 }
136 key.details.users.retain(|u|!u.id.id().contains(&b'@'));
137 }
138 let text = key.to_armored_string(Default::default()).map_err(|_| {
139 error(
140 "KEYSERVER_INVALID_CERTIFICATE",
141 "Could not export the public certificate.",
142 )
143 })?;
144 let body = serde_json::to_vec(&json!({"keytext":text}))?;
145 if body.len() > MAX_BODY {
146 return Err(error(
147 "KEYSERVER_TOO_LARGE",
148 "The public certificate exceeds the keyserver upload limit.",
149 ));
150 }
151 Ok((fingerprint, body))
153async fn body(mut response: reqwest::Response) -> CoreResult<Vec<u8>> {
154 if response
155 .content_length()
156 .is_some_and(|size| size > MAX_BODY as u64)
157 {
158 return Err(error(
159 "KEYSERVER_RESPONSE_INVALID",
160 "Keyserver response exceeds the size limit.",
161 ));
162 }
163 let mut bytes = Vec::new();
164 while let Some(chunk) = response.chunk().await.map_err(request_error)? {
165 if bytes.len() + chunk.len() > MAX_BODY {
166 return Err(error(
167 "KEYSERVER_RESPONSE_INVALID",
168 "Keyserver response exceeds the size limit.",
169 ));
170 }
171 bytes.extend_from_slice(&chunk);
172 }
173 Ok(bytes)
175fn status(status: reqwest::StatusCode) -> CoreResult<()> {
176 if status.is_success() {
177 return Ok(());
178 }
179 Err(match status.as_u16() {
180 429 => error(
181 "KEYSERVER_RATE_LIMITED",
182 "The keyserver rate limit was reached. Try again later.",
183 ),
184 415 => error("KEYSERVER_UNSUPPORTED_FORMAT","The server does not support this certificate format. This is not a cryptographic verification failure."),
185 413 => error(
186 "KEYSERVER_TOO_LARGE",
187 "The public certificate exceeds the keyserver upload limit.",
188 ),
189 _ => error(
190 "KEYSERVER_REJECTED",
191 "The keyserver did not accept the request. No remote success has been recorded.",
192 ),
193 }
194 .with("status", status.as_u16()))
196fn upload_ack(bytes: &[u8], fingerprint: &str) -> CoreResult<()> {
197 let response: Value = serde_json::from_slice(bytes).map_err(|_| {
198 error(
199 "KEYSERVER_RESPONSE_INVALID",
200 "Invalid keyserver acknowledgement.",
201 )
202 })?;
203 if response["key_fpr"] != fingerprint {
204 return Err(error(
205 "KEYSERVER_FINGERPRINT_MISMATCH",
206 "The keyserver acknowledged a different fingerprint.",
207 ));
208 }
209 Ok(())
211pub(crate) async fn upload_with(
212 client: &reqwest::Client,
213 fingerprint: &str,
214 payload: Vec<u8>,
215) -> CoreResult<()> {
216 let response = client
217 .post(format!("{ORIGIN}/vks/v1/upload"))
218 .header(reqwest::header::CONTENT_TYPE, "application/json")
219 .header(reqwest::header::ACCEPT, "application/json")
220 .body(payload)
221 .send()
222 .await
223 .map_err(request_error)?;
224 status(response.status())?;
225 upload_ack(&body(response).await?, fingerprint)
227/// Downloads the public certificate for `fingerprint` (primary key or subkey, full hex) from keys.ch-j.de for a
228/// one-off verification. `Ok(None)` when the server does not know it. The returned certificate must contain the
229/// fingerprint, so the server cannot hand over an unrelated key.
230pub async fn fetch_certificate(fingerprint: &str) -> CoreResult<Option<String>> {
231 let fingerprint: String = fingerprint.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_uppercase();
232 if ![40, 64].contains(&fingerprint.len()) || !fingerprint.chars().all(|c| c.is_ascii_hexdigit()) {
233 return Err(error("KEYSERVER_FINGERPRINT_INVALID", "Use the full OpenPGP fingerprint (40 or 64 hexadecimal characters)."));
234 }
235 let response = client()?
236 .get(format!("{ORIGIN}/vks/v1/by-fingerprint/{fingerprint}"))
237 .header(reqwest::header::ACCEPT, "application/pgp-keys")
238 .send()
239 .await
240 .map_err(request_error)?;
241 if response.status() == reqwest::StatusCode::NOT_FOUND {
242 return Ok(None);
243 }
244 status(response.status())?;
245 let bytes = body(response).await?;
246 let text = String::from_utf8(bytes).map_err(|_| error("KEYSERVER_RESPONSE_INVALID", "Invalid keyserver certificate."))?;
247 let key = certificate(&text)?;
248 let primary = hex::encode_upper(key.fingerprint().as_ref());
249 let subkeys = key.public_subkeys.iter().map(|sub| hex::encode_upper(sub.key.fingerprint().as_ref()));
250 if primary != fingerprint && !subkeys.into_iter().any(|fp| fp == fingerprint) {
251 return Err(error("KEYSERVER_FINGERPRINT_MISMATCH", "The keyserver returned a different certificate."));
252 }
253 Ok(Some(text))
256pub(crate) async fn lookup_with(client: &reqwest::Client, fingerprint: &str) -> CoreResult<Value> {
257 if ![40, 64].contains(&fingerprint.len())
258 || !fingerprint
259 .chars()
260 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())
261 {
262 return Err(error(
263 "KEYSERVER_FINGERPRINT_INVALID",
264 "Use the full uppercase OpenPGP fingerprint.",
265 ));
266 }
267 let response = client
268 .get(format!("{ORIGIN}/vks/v1/by-fingerprint/{fingerprint}"))
269 .header(reqwest::header::ACCEPT, "application/pgp-keys")
270 .send()
271 .await
272 .map_err(request_error)?;
273 if response.status() == reqwest::StatusCode::NOT_FOUND {
274 return Ok(json!({"found":false,"revoked":false,"subkeys":0}));
275 }
276 status(response.status())?;
277 let bytes = body(response).await?;
278 let text = std::str::from_utf8(&bytes).map_err(|_| {
279 error(
280 "KEYSERVER_RESPONSE_INVALID",
281 "Invalid keyserver certificate.",
282 )
283 })?;
284 let key = certificate(text)?;
285 if hex::encode_upper(key.fingerprint().as_ref()) != fingerprint {
286 return Err(error(
287 "KEYSERVER_FINGERPRINT_MISMATCH",
288 "The keyserver returned a different certificate.",
289 ));
290 }
291 let revoked = key
292 .details
293 .revocation_signatures
294 .iter()
295 .any(|sig| sig.verify_key(&key.primary_key).is_ok());
296 Ok(json!({"found":true,"revoked":revoked,"subkeys":key.public_subkeys.len()}))
298fn record(
299 vault: &VaultStore,
300 id: &str,
301 generation: u64,
302 fingerprint: &str,
303 expected_public: &str,
304 remote: &Value,
305 published: bool,
306) -> CoreResult<Value> {
307 vault.update_at_generation(generation, |data| {
308 let entry = data
309 .pointer_mut("/secrets/keychain")
310 .and_then(Value::as_array_mut)
311 .and_then(|items| items.iter_mut().find(|e| e["id"] == id))
312 .ok_or_else(|| error("KEYCHAIN_NOT_FOUND", "Key was not found."))?;
313 if entry["fingerprint"] != fingerprint || entry["public"] != expected_public {
314 return Err(error(
315 "KEYSERVER_FINGERPRINT_MISMATCH",
316 "The identity changed while the request was running.",
317 ));
318 }
319 let now = chrono::Utc::now().to_rfc3339();
320 if !entry["keyserver"].is_object() {
321 entry["keyserver"] = json!({});
322 }
323 entry["keyserver"]["origin"] = json!(ORIGIN);
324 entry["keyserver"]["checkedAt"] = json!(now);
325 entry["keyserver"]["found"] = remote["found"].clone();
326 entry["keyserver"]["revoked"] = remote["revoked"].clone();
327 if published {
328 entry["keyserver"][if remote["revoked"] == true {
329 "revocationPublishedAt"
330 } else {
331 "publishedAt"
332 }] = json!(now);
333 }
334 Ok(json!({"keyserver":entry["keyserver"],"identity":keychain::describe(entry)}))
335 })
337pub async fn publish(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {
338 let entry = keychain::get_at_generation(vault, id, generation)?;
339 let (fingerprint, payload) = public_payload(&entry)?;
340 let http = client()?;
341 vault.assert_generation(generation)?;
342 upload_with(&http, &fingerprint, payload).await?;
343 let remote = lookup_with(&http, &fingerprint).await?;
344 if remote["found"] != true || (entry["revoked"] == true && remote["revoked"] != true) {
345 return Err(error(
346 "KEYSERVER_NOT_CONFIRMED",
347 "The keyserver has not confirmed the certificate or its revocation. Retry publishing.",
348 ));
349 }
350 let mut result = record(
351 vault,
352 id,
353 generation,
354 &fingerprint,
355 entry["public"].as_str().unwrap_or(""),
356 &remote,
357 true,
358 )?;
359 result["published"] = json!(true);
360 Ok(result)
362pub async fn check(vault: &VaultStore, id: &str, generation: u64) -> CoreResult<Value> {
363 let entry = keychain::get_at_generation(vault, id, generation)?;
364 let (fingerprint, _) = public_payload(&entry)?;
365 let remote = lookup_with(&client()?, &fingerprint).await?;
366 record(
367 vault,
368 id,
369 generation,
370 &fingerprint,
371 entry["public"].as_str().unwrap_or(""),
372 &remote,
373 false,
374 )
377#[cfg(test)]
378mod tests {
379 use super::*;
380 #[test]
381 fn request_contains_only_reserialized_public_material_and_correct_identity() {
382 let (_, entry) =
383 crate::core::openpgp::generate(&json!({"name":"Synthetic API fixture","subkeys":[]}))
384 .unwrap();
385 let (fingerprint, body) = public_payload(&entry).unwrap();
386 let request: Value = serde_json::from_slice(&body).unwrap();
387 assert_eq!(request.as_object().unwrap().len(), 1);
388 assert_eq!(fingerprint, entry["fingerprint"]);
389 assert!(request["keytext"]
390 .as_str()
391 .unwrap()
392 .starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----"));
393 assert!(!String::from_utf8(body).unwrap().contains("PRIVATE"));
394 let mut tampered = entry.clone();
395 tampered["public"] = entry["private"].clone();
396 assert!(public_payload(&tampered).is_err());
397 tampered = entry.clone();
398 tampered["fingerprint"] = json!("0000000000000000000000000000000000000000");
399 assert!(public_payload(&tampered).is_err());
400 tampered["kind"] = json!("ssh");
401 assert!(public_payload(&tampered).is_err());
402 assert!(upload_ack(
403 br#"{"key_fpr":"WRONG","token":"must-not-be-retained"}"#,
404 &fingerprint
405 )
406 .is_err());
407 assert!(upload_ack(
408 serde_json::to_vec(&json!({"key_fpr":fingerprint,"token":"must-not-be-retained"}))
409 .unwrap()
410 .as_slice(),
411 &fingerprint
412 )
413 .is_ok());
414 }
415 #[tokio::test]
416 async fn remote_results_cannot_cross_a_lock_or_overwrite_a_changed_certificate() {
417 let root = tempfile::tempdir().unwrap();
418 let vault = VaultStore::new(root.path());
419 vault.create("synthetic-password").await.unwrap();
420 let (_, entry) =
421 crate::core::openpgp::generate(&json!({"name":"Synthetic vault fixture","subkeys":[]}))
422 .unwrap();
423 let generation = vault.unlocked_generation().unwrap();
424 keychain::store(&vault, entry.clone()).unwrap();
425 let id = entry["id"].as_str().unwrap();
426 let fp = entry["fingerprint"].as_str().unwrap();
427 let public = entry["public"].as_str().unwrap();
428 let remote = json!({"found":true,"revoked":false});
429 record(&vault, id, generation, fp, public, &remote, true).unwrap();
430 let revoked = keychain::revoke(&entry, "").unwrap();
431 vault
432 .update_at_generation(generation, |data| {
433 data["secrets"]["keychain"][0] = revoked;
434 Ok(())
435 })
436 .unwrap();
437 assert!(record(&vault, id, generation, fp, public, &remote, true).is_err());
438 vault.lock();
439 vault.unlock("synthetic-password").await.unwrap();
440 assert!(record(&vault, id, generation, fp, public, &remote, true).is_err());
441 assert_eq!(keychain::get(&vault, id).unwrap()["revoked"], true);
442 }
443 #[tokio::test]
444 #[ignore = "explicit live IPv6/HTTPS test against the owner's keyserver; publishes only synthetic public certificates and their revocations"]
445 async fn live_hagrid_accepts_v4_v6_and_preserves_revocation() {
446 let http = if let Ok(address) = std::env::var("CHJ_KEYS_TEST_IPV6") {
447 let ip: std::net::Ipv6Addr = address.parse().unwrap();
448 reqwest::Client::builder()
449 .https_only(true)
450 .redirect(reqwest::redirect::Policy::none())
451 .no_proxy()
452 .resolve("keys.ch-j.de", std::net::SocketAddr::new(ip.into(), 443))
453 .timeout(Duration::from_secs(30))
454 .build()
455 .unwrap()
456 } else {
457 client().unwrap()
458 };
459 for version in [4, 6] {
460 let (_,entry)=crate::core::openpgp::generate(&json!({"name":"CH-J API integration fixture","comment":uuid::Uuid::new_v4().to_string(),"version":version,"subkeys":[]})).unwrap();
461 let (fp, payload) = public_payload(&entry).unwrap();
462 assert_eq!(lookup_with(&http, &fp).await.unwrap()["found"], false);
463 upload_with(&http, &fp, payload).await.unwrap();
464 let status = lookup_with(&http, &fp).await.unwrap();
465 assert_eq!(status["found"], true);
466 assert_eq!(status["revoked"], false);
467 let revoked = keychain::revoke(&entry, "").unwrap();
468 let (_, payload) = public_payload(&revoked).unwrap();
469 upload_with(&http, &fp, payload).await.unwrap();
470 assert_eq!(lookup_with(&http, &fp).await.unwrap()["revoked"], true);
471 let (_, old) = public_payload(&entry).unwrap();
472 upload_with(&http, &fp, old).await.unwrap();
473 assert_eq!(
474 lookup_with(&http, &fp).await.unwrap()["revoked"],
475 true,
476 "Uploading an older certificate must not undo revocation"
477 );
478 }
479 }
SHA-256f9f9feca4089b727dae2984a78730af4e6c5304526a16d0474b1be9149037be2

CH-J Proprietary Software License 1.14 · Η πηγή παρέχεται στο CH-J Proprietary Software License 1.14. Η διαθεσιμότητά του δεν αλλάζει τους όρους της άδειας χρήσης ούτε χορηγεί πρόσθετα δικαιώματα.

Archive SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Αρχεία