CH-J Toolsspráva serverov nad SSH
Menu
Uverejnený zdroj

CH-J Tools

Prechádzať adresáre a súbory pre konkrétne vydanie aplikácie.

Stiahnuť zdroj ZIP
CH-J / Server Manager 1704 súbory
0.1.2-beta Pridané: · Postavený:
keysync.rs Pridané: Dátum nezaznamenaný 29,8 KB · 683 riadky
Kód Stiahnuť súbor
1//! KeySync while the application is closed (owner 2026-11-05). Core syncs with keys.ch-j.de while the app
2//! runs; the helper only finishes jobs Core already queued in `<data>/keysync.json` (revocations first,
3//! then publications), and only when it can take `keysync.owner.lock`, which Core holds for its lifetime.
4//!
5//! The file holds public certificates only, so the helper never touches private keys or the vault. A queued
6//! revocation is the already signed public certificate with its revocation signature.
7//!
8//! Lean by design (owner: whole helper ideally under 20 MB RAM): blocking HTTPS (ureq + rustls) only during
9//! a pass, no async runtime and no OpenPGP library. Confirmation compares packets byte for byte: the
10//! server's copy must contain our primary key packet, and for a revocation our own revocation signature
11//! (created and checked by Core), for a publication at least as many subkeys as ours. Recorded server
12//! states carry `"by": "helper"`, and Core checks them again with a full rPGP verification at its next run.
14use base64::Engine as _;
15use serde_json::{json, Value};
16use std::io::Read;
17use std::path::Path;
18use std::time::Duration;
20const FILE: &str = "keysync.json";
21const WRITE_LOCK: &str = "keysync.json.lock";
22const OWNER_LOCK: &str = "keysync.owner.lock";
23const ORIGIN: &str = "https://keys.ch-j.de";
24const MAX_BODY: u64 = 1024 * 1024;
25const MAX_STATE: u64 = 8 * 1024 * 1024;
26const MAX_JOBS_PER_PASS: usize = 4;
27const MAX_UNCONFIRMED: u64 = 5;
28const INTERVAL_SECS: i64 = 90;
29const MAX_BACKOFF_SECS: i64 = 3600;
30/// How often the background process looks at the queue (same period as Core).
31pub const PASS_INTERVAL: Duration = Duration::from_secs(90);
33fn now() -> i64 {
34 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
37fn open_lock(path: &Path) -> std::io::Result<std::fs::File> {
38 let mut options = std::fs::OpenOptions::new();
39 options.create(true).read(true).write(true).truncate(false);
40 #[cfg(unix)]
41 {
42 use std::os::unix::fs::OpenOptionsExt;
43 options.mode(0o600);
44 }
45 options.open(path)
48fn read_state(data: &Path) -> Result<Value, &'static str> {
49 let file = std::fs::File::open(data.join(FILE)).map_err(|_| "noQueue")?;
50 if file.metadata().map_err(|_| "noQueue")?.len() > MAX_STATE {
51 return Err("stateTooLarge");
52 }
53 let mut bytes = Vec::new();
54 file.take(MAX_STATE + 1).read_to_end(&mut bytes).map_err(|_| "noQueue")?;
55 if bytes.len() as u64 > MAX_STATE {
56 return Err("stateTooLarge");
57 }
58 let state: Value = serde_json::from_slice(&bytes).map_err(|_| "noQueue")?;
59 if state["version"] == 1 && state["keys"].is_object() && state["jobs"].is_array() {
60 Ok(state)
61 } else {
62 Err("noQueue")
63 }
66/// Atomic 0600 write next to the file, like Core's `atomic_write_json`.
67fn write_state(data: &Path, state: &Value) -> std::io::Result<()> {
68 let target = data.join(FILE);
69 let temp = data.join(format!(".{FILE}.helper-{}.tmp", std::process::id()));
70 let mut text = serde_json::to_string_pretty(state).map_err(std::io::Error::other)?;
71 text.push('\n');
72 {
73 let mut options = std::fs::OpenOptions::new();
74 options.create(true).write(true).truncate(true);
75 #[cfg(unix)]
76 {
77 use std::os::unix::fs::OpenOptionsExt;
78 options.mode(0o600);
79 }
80 let mut file = options.open(&temp)?;
81 std::io::Write::write_all(&mut file, text.as_bytes())?;
82 file.sync_all()?;
83 }
84 std::fs::rename(&temp, &target).inspect_err(|_| {
85 let _ = std::fs::remove_file(&temp);
86 })
89/// Read the newest file under the shared write lock, change it and write it back.
90fn update(data: &Path, change: impl FnOnce(&mut Value)) -> bool {
91 let Ok(lock) = open_lock(&data.join(WRITE_LOCK)) else { return false };
92 if lock.lock().is_err() {
93 return false;
94 }
95 let Ok(mut state) = read_state(data) else { return false };
96 change(&mut state);
97 write_state(data, &state).is_ok()
100fn set_job(data: &Path, id: &str, change: impl FnOnce(&mut Value)) {
101 update(data, |state| {
102 if let Some(job) = state["jobs"].as_array_mut().and_then(|jobs| jobs.iter_mut().find(|j| j["id"] == id)) {
103 change(job);
104 job["updatedAt"] = json!(now());
105 }
106 });
109// ---------------------------------------------------------------- OpenPGP packets (no crypto, compare only)
111/// Binary packets of the first ASCII-armored block: `(tag, body)`.
112fn packets(armored: &str) -> Option<Vec<(u8, Vec<u8>)>> {
113 let mut lines = armored.lines().map(str::trim).skip_while(|l| !l.starts_with("-----BEGIN PGP "));
114 lines.next()?;
115 let mut base64 = String::new();
116 let mut in_headers = true;
117 for line in lines {
118 if line.starts_with("-----END PGP ") {
119 break;
120 }
121 if in_headers {
122 if line.is_empty() {
123 in_headers = false;
124 } else if !line.contains(": ") {
125 in_headers = false;
126 base64.push_str(line);
127 }
128 continue;
129 }
130 if line.starts_with('=') {
131 break; // CRC24 checksum line
132 }
133 base64.push_str(line);
134 }
135 let bytes = base64::engine::general_purpose::STANDARD.decode(base64.as_bytes()).ok()?;
136 let mut out = Vec::new();
137 let mut i = 0;
138 while i < bytes.len() {
139 let header = bytes[i];
140 if header & 0x80 == 0 {
141 return None;
142 }
143 i += 1;
144 let (tag, length) = if header & 0x40 != 0 {
145 let first = *bytes.get(i)? as usize;
146 i += 1;
147 let length = match first {
148 0..=191 => first,
149 192..=223 => {
150 let second = *bytes.get(i)? as usize;
151 i += 1;
152 ((first - 192) << 8) + second + 192
153 }
154 255 => {
155 let raw = bytes.get(i..i + 4)?;
156 i += 4;
157 u32::from_be_bytes([raw[0], raw[1], raw[2], raw[3]]) as usize
158 }
159 _ => return None, // partial lengths never appear in certificates
160 };
161 (header & 0x3f, length)
162 } else {
163 let size = match header & 0x03 {
164 0 => 1,
165 1 => 2,
166 2 => 4,
167 _ => return None,
168 };
169 let raw = bytes.get(i..i + size)?;
170 i += size;
171 ((header >> 2) & 0x0f, raw.iter().fold(0usize, |n, b| (n << 8) | *b as usize))
172 };
173 let body = bytes.get(i..i.checked_add(length)?)?.to_vec();
174 i += length;
175 out.push((tag, body));
176 }
177 Some(out)
180const TAG_SIGNATURE: u8 = 2;
181const TAG_PUBLIC_KEY: u8 = 6;
182const TAG_PUBLIC_SUBKEY: u8 = 14;
183const KEY_REVOCATION: u8 = 0x20;
185/// Direct-key revocation signatures: signature packets after the primary key, before any user ID or subkey.
186fn key_revocations(packets: &[(u8, Vec<u8>)]) -> Vec<&[u8]> {
187 packets.iter().skip(1).take_while(|(tag, _)| *tag == TAG_SIGNATURE)
188 .filter(|(_, body)| body.len() > 2 && body[1] == KEY_REVOCATION).map(|(_, body)| body.as_slice()).collect()
191/// Server state from the certificate the server returned, compared with ours.
192fn remote_state(ours: &[(u8, Vec<u8>)], theirs: &[(u8, Vec<u8>)]) -> Result<Value, &'static str> {
193 match (ours.first(), theirs.first()) {
194 (Some((TAG_PUBLIC_KEY, a)), Some((TAG_PUBLIC_KEY, b))) if a == b => {}
195 _ => return Err("KEYSERVER_FINGERPRINT_MISMATCH"),
196 }
197 let own = key_revocations(ours);
198 let revoked = key_revocations(theirs).iter().any(|sig| own.contains(sig));
199 let subkeys = theirs.iter().filter(|(tag, _)| *tag == TAG_PUBLIC_SUBKEY).count();
200 Ok(json!({ "found": true, "revoked": revoked, "subkeys": subkeys }))
203// ---------------------------------------------------------------- HTTPS
205struct Failure {
206 code: &'static str,
207 status: Option<u16>,
210impl Failure {
211 fn retryable(&self) -> bool {
212 matches!(self.code, "KEYSERVER_UNAVAILABLE" | "KEYSERVER_RATE_LIMITED") || (self.code == "KEYSERVER_REJECTED" && self.status.is_some_and(|s| s >= 500))
213 }
216fn failure(code: &'static str) -> Failure {
217 Failure { code, status: None }
220fn status_failure(status: u16) -> Failure {
221 let code = match status {
222 429 => "KEYSERVER_RATE_LIMITED",
223 415 => "KEYSERVER_UNSUPPORTED_FORMAT",
224 413 => "KEYSERVER_TOO_LARGE",
225 _ => "KEYSERVER_REJECTED",
226 };
227 Failure { code, status: Some(status) }
230/// Same policy as Core's client: HTTPS only, no redirects, no proxy, TLS verified against webpki roots.
231fn agent() -> ureq::Agent {
232 ureq::AgentBuilder::new()
233 .https_only(true)
234 .redirects(0)
235 .timeout_connect(Duration::from_secs(10))
236 .timeout(Duration::from_secs(30))
237 .user_agent(concat!("CH-J-Standby-Helper/", env!("CARGO_PKG_VERSION")))
238 .build()
241fn body(response: ureq::Response) -> Result<String, Failure> {
242 let mut text = String::new();
243 response.into_reader().take(MAX_BODY + 1).read_to_string(&mut text).map_err(|_| failure("KEYSERVER_RESPONSE_INVALID"))?;
244 if text.len() as u64 > MAX_BODY {
245 return Err(failure("KEYSERVER_RESPONSE_INVALID"));
246 }
247 Ok(text)
250fn call(result: Result<ureq::Response, ureq::Error>) -> Result<Option<ureq::Response>, Failure> {
251 match result {
252 Ok(response) => Ok(Some(response)),
253 Err(ureq::Error::Status(404, _)) => Ok(None),
254 Err(ureq::Error::Status(code, _)) => Err(status_failure(code)),
255 Err(ureq::Error::Transport(_)) => Err(failure("KEYSERVER_UNAVAILABLE")),
256 }
259trait Server {
260 fn upload(&mut self, fingerprint: &str, keytext: &str) -> Result<(), Failure>;
261 /// Armored certificate, or `None` when the server does not have it.
262 fn lookup(&mut self, fingerprint: &str) -> Result<Option<String>, Failure>;
265struct Https(ureq::Agent);
267impl Server for Https {
268 fn upload(&mut self, fingerprint: &str, keytext: &str) -> Result<(), Failure> {
269 let payload = serde_json::to_vec(&json!({ "keytext": keytext })).map_err(|_| failure("KEYSERVER_RESPONSE_INVALID"))?;
270 let response = call(self.0.post(&format!("{ORIGIN}/vks/v1/upload")).set("Content-Type", "application/json").set("Accept", "application/json").send_bytes(&payload))?
271 .ok_or_else(|| status_failure(404))?;
272 let ack: Value = serde_json::from_str(&body(response)?).map_err(|_| failure("KEYSERVER_RESPONSE_INVALID"))?;
273 if ack["key_fpr"] != fingerprint {
274 return Err(failure("KEYSERVER_FINGERPRINT_MISMATCH"));
275 }
276 Ok(())
277 }
278 fn lookup(&mut self, fingerprint: &str) -> Result<Option<String>, Failure> {
279 match call(self.0.get(&format!("{ORIGIN}/vks/v1/by-fingerprint/{fingerprint}")).set("Accept", "application/pgp-keys").call())? {
280 Some(response) => body(response).map(Some),
281 None => Ok(None),
282 }
283 }
286// ---------------------------------------------------------------- pass
288fn valid_fingerprint(fp: &str) -> bool {
289 [40, 64].contains(&fp.len()) && fp.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())
292fn interrupted(job: &Value) -> bool {
293 matches!(job["state"].as_str(), Some("uploading" | "verifying"))
296fn job_due(job: &Value, stamp: i64) -> bool {
297 matches!(job["kind"].as_str(), Some("revoke" | "publish")) && matches!(job["state"].as_str(), Some("pending" | "retry"))
298 && job["nextAt"].as_i64().unwrap_or(0) <= stamp && valid_fingerprint(job["fingerprint"].as_str().unwrap_or(""))
301/// Indices of at most four due jobs, revocations first. Never clone queued payloads here.
302fn due_jobs(state: &Value) -> Vec<usize> {
303 let stamp = now();
304 let mut due = Vec::with_capacity(MAX_JOBS_PER_PASS);
305 let Some(jobs) = state["jobs"].as_array() else { return due };
306 let order = |i: usize| (jobs[i]["kind"] != "revoke", jobs[i]["createdAt"].as_i64().unwrap_or(0));
307 for (i, _) in jobs.iter().enumerate().filter(|(_, j)| job_due(j, stamp)) {
308 let position = due.iter().position(|&other| order(i) < order(other)).unwrap_or(due.len());
309 if position < MAX_JOBS_PER_PASS {
310 if due.len() == MAX_JOBS_PER_PASS {
311 due.pop();
312 }
313 due.insert(position, i);
314 }
315 }
316 due
319fn process(data: &Path, server: &mut dyn Server, job: &Value, expected_subkeys: u64) {
320 let id = job["id"].as_str().unwrap_or("").to_string();
321 let fingerprint = job["fingerprint"].as_str().unwrap_or("").to_string();
322 let keytext = job["keytext"].as_str().unwrap_or("");
323 let revoke = job["kind"] == "revoke";
324 let outcome: Result<Value, Failure> = (|| {
325 let ours = packets(keytext).ok_or_else(|| failure("KEYSYNC_JOB_INVALID"))?;
326 if revoke && key_revocations(&ours).is_empty() {
327 return Err(failure("KEYSYNC_JOB_INVALID"));
328 }
329 set_job(data, &id, |j| j["state"] = json!("uploading"));
330 server.upload(&fingerprint, keytext)?;
331 set_job(data, &id, |j| j["state"] = json!("verifying"));
332 match server.lookup(&fingerprint)? {
333 None => Ok(json!({ "found": false, "revoked": false, "subkeys": 0 })),
334 Some(text) => remote_state(&ours, &packets(&text).ok_or_else(|| failure("KEYSERVER_RESPONSE_INVALID"))?).map_err(failure),
335 }
336 })();
337 match outcome {
338 Ok(remote) => {
339 update(data, |state| {
340 if let Some(key) = state["keys"].get_mut(&fingerprint) {
341 let stamp = now();
342 key["server"] = json!({ "checkedAt": stamp, "verifiedAt": stamp, "found": remote["found"], "revoked": remote["revoked"], "subkeys": remote["subkeys"], "by": "helper" });
343 }
344 });
345 let confirmed = remote["found"] == true && if revoke { remote["revoked"] == true } else { remote["subkeys"].as_u64().unwrap_or(0) >= expected_subkeys };
346 set_job(data, &id, |j| {
347 if confirmed {
348 j["state"] = json!("success");
349 j["lastError"] = Value::Null;
350 j["by"] = json!("helper");
351 return;
352 }
353 let unconfirmed = j["unconfirmed"].as_u64().unwrap_or(0) + 1;
354 j["unconfirmed"] = json!(unconfirmed);
355 j["lastError"] = json!("KEYSERVER_NOT_CONFIRMED");
356 if unconfirmed >= MAX_UNCONFIRMED {
357 j["state"] = json!("failed");
358 } else {
359 j["state"] = json!("retry");
360 j["nextAt"] = json!(now() + INTERVAL_SECS);
361 }
362 });
363 }
364 Err(error) => set_job(data, &id, |j| {
365 let attempts = j["attempts"].as_u64().unwrap_or(0) + 1;
366 j["attempts"] = json!(attempts);
367 j["lastError"] = json!(error.code);
368 if error.retryable() {
369 j["state"] = json!("retry");
370 j["nextAt"] = json!(now() + INTERVAL_SECS.saturating_mul(1 << attempts.min(6)).min(MAX_BACKOFF_SECS));
371 } else {
372 j["state"] = json!("failed");
373 }
374 }),
375 }
378fn pass_with(data: &Path, connect: impl FnOnce() -> Box<dyn Server>) -> Value {
379 // Cheap check first: no file or nothing due means no lock, no network.
380 let state = match read_state(data) {
381 Ok(state) => state,
382 Err(reason) => return json!({ "ran": false, "reason": reason }),
383 };
384 let stamp = now();
385 if !state["jobs"].as_array().into_iter().flatten().any(|j| interrupted(j) || job_due(j, stamp)) {
386 return json!({ "ran": false, "reason": "nothingDue" });
387 }
388 drop(state);
389 let Ok(owner) = open_lock(&data.join(OWNER_LOCK)) else { return json!({ "ran": false, "reason": "lockUnavailable" }) };
390 if owner.try_lock().is_err() {
391 return json!({ "ran": false, "reason": "appRunning" });
392 }
393 // Re-read and recover interrupted jobs only after owning the processing role and write lock.
394 let state = {
395 let Ok(lock) = open_lock(&data.join(WRITE_LOCK)) else { return json!({ "ran": false, "reason": "lockUnavailable" }) };
396 if lock.lock().is_err() {
397 return json!({ "ran": false, "reason": "lockUnavailable" });
398 }
399 let mut state = match read_state(data) {
400 Ok(state) => state,
401 Err(reason) => return json!({ "ran": false, "reason": reason }),
402 };
403 let stamp = now();
404 let mut recovered = false;
405 for job in state["jobs"].as_array_mut().into_iter().flatten().filter(|j| interrupted(j)) {
406 job["state"] = json!("retry");
407 job["nextAt"] = json!(stamp);
408 job["updatedAt"] = json!(stamp);
409 recovered = true;
410 }
411 if recovered && write_state(data, &state).is_err() {
412 return json!({ "ran": false, "reason": "stateWriteFailed" });
413 }
414 state
415 };
416 let jobs: Vec<Value> = due_jobs(&state).into_iter().map(|i| state["jobs"][i].clone()).collect();
417 drop(state);
418 if jobs.is_empty() {
419 return json!({ "ran": false, "reason": "nothingDue" });
420 }
421 let mut server = connect();
422 for job in &jobs {
423 let state = match read_state(data) {
424 Ok(state) => state,
425 Err(reason) => return json!({ "ran": false, "reason": reason }),
426 };
427 let expected = state["keys"][job["fingerprint"].as_str().unwrap_or("")]["subkeys"].as_u64().unwrap_or(0);
428 drop(state);
429 process(data, server.as_mut(), job, expected);
430 }
431 let states: Vec<Value> = read_state(data).map(|s| {
432 jobs.iter().filter_map(|job| s["jobs"].as_array()?.iter().find(|j| j["id"] == job["id"]).map(|j| json!({ "kind": j["kind"], "state": j["state"], "lastError": j["lastError"] }))).collect()
433 }).unwrap_or_default();
434 json!({ "ran": true, "jobs": states })
435 // `owner` is dropped here and Core can take the processing role again.
438/// One pass over the queue. Returns a JSON summary without fingerprints or certificates.
439pub fn pass(data: &Path) -> Value {
440 pass_with(data, || Box::new(Https(agent())))
443#[cfg(test)]
444mod tests {
445 use super::*;
447 // Synthetic V4 certificate structure: primary key, revocation signature, user ID, subkey. Packet bodies
448 // are fixed bytes; the helper only compares them, it never parses key material.
449 fn armor(packets: &[(u8, Vec<u8>)]) -> String {
450 let mut bytes = Vec::new();
451 for (tag, body) in packets {
452 bytes.push(0xC0 | tag);
453 assert!(body.len() < 192);
454 bytes.push(body.len() as u8);
455 bytes.extend_from_slice(body);
456 }
457 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
458 format!("-----BEGIN PGP PUBLIC KEY BLOCK-----\nComment: synthetic\n\n{b64}\n=AAAA\n-----END PGP PUBLIC KEY BLOCK-----\n")
459 }
460 fn primary() -> (u8, Vec<u8>) {
461 (TAG_PUBLIC_KEY, vec![4, 1, 2, 3, 4, 22, 9, 9, 9])
462 }
463 fn revocation() -> (u8, Vec<u8>) {
464 (TAG_SIGNATURE, vec![4, KEY_REVOCATION, 22, 10, 7, 7])
465 }
466 fn uid() -> (u8, Vec<u8>) {
467 (13, b"Fixture <fixture@example.invalid>".to_vec())
468 }
469 fn subkey() -> (u8, Vec<u8>) {
470 (TAG_PUBLIC_SUBKEY, vec![4, 5, 6, 7, 8, 18, 1])
471 }
473 #[derive(Default)]
474 struct Fake {
475 stored: Option<String>,
476 uploads: usize,
477 fail: Option<u16>,
478 strip_revocation: bool,
479 }
480 impl Server for std::rc::Rc<std::cell::RefCell<Fake>> {
481 fn upload(&mut self, _: &str, keytext: &str) -> Result<(), Failure> {
482 let mut fake = self.borrow_mut();
483 if let Some(status) = fake.fail {
484 return Err(if status == 0 { failure("KEYSERVER_UNAVAILABLE") } else { status_failure(status) });
485 }
486 fake.uploads += 1;
487 let mut parsed = packets(keytext).unwrap();
488 if fake.strip_revocation {
489 parsed.retain(|p| *p != revocation());
490 }
491 parsed.retain(|p| p.0 != 13); // Hagrid drops unverified user IDs
492 fake.stored = Some(armor(&parsed));
493 Ok(())
494 }
495 fn lookup(&mut self, _: &str) -> Result<Option<String>, Failure> {
496 Ok(self.borrow().stored.clone())
497 }
498 }
500 const FP: &str = "67AB1AE4D43201E97FB45CDB93232B77DFC45CEA";
502 fn queue(dir: &Path, kind: &str, keytext: &str, subkeys: u64) {
503 let state = json!({ "version": 1, "lastRunAt": null, "keys": { FP: { "hasPrivate": true, "revoked": kind == "revoke", "subkeys": subkeys, "server": {} } },
504 "jobs": [{ "id": "j1", "fingerprint": FP, "kind": kind, "state": "pending", "attempts": 0, "unconfirmed": 0, "nextAt": 0, "createdAt": 1, "updatedAt": 1, "keytext": keytext }] });
505 write_state(dir, &state).unwrap();
506 }
507 fn job(dir: &Path) -> Value {
508 read_state(dir).unwrap()["jobs"][0].clone()
509 }
510 fn run(dir: &Path, fake: &std::rc::Rc<std::cell::RefCell<Fake>>) -> Value {
511 let fake = fake.clone();
512 pass_with(dir, move || Box::new(fake))
513 }
515 #[test]
516 fn interrupted_jobs_are_recovered_and_processed_in_the_same_pass() {
517 for (kind, interrupted_state, keytext) in [
518 ("revoke", "uploading", armor(&[primary(), revocation(), subkey()])),
519 ("publish", "verifying", armor(&[primary(), subkey()])),
520 ] {
521 let dir = tempfile::tempdir().unwrap();
522 let fake = std::rc::Rc::new(std::cell::RefCell::new(Fake::default()));
523 queue(dir.path(), kind, &keytext, 1);
524 set_job(dir.path(), "j1", |j| {
525 j["state"] = json!(interrupted_state);
526 j["nextAt"] = json!(now() + MAX_BACKOFF_SECS);
527 j["attempts"] = json!(3);
528 j["unconfirmed"] = json!(2);
529 });
530 let before = now();
531 let result = pass_with(dir.path(), || {
532 let recovered = job(dir.path());
533 assert_eq!(recovered["state"], "retry");
534 assert!(recovered["nextAt"].as_i64().unwrap() >= before);
535 assert!(recovered["nextAt"].as_i64().unwrap() <= now());
536 assert_eq!(recovered["attempts"], 3);
537 assert_eq!(recovered["unconfirmed"], 2);
538 // Recovery has released the write lock, but still owns processing.
539 let writer = open_lock(&dir.path().join(WRITE_LOCK)).unwrap();
540 writer.try_lock().unwrap();
541 let owner = open_lock(&dir.path().join(OWNER_LOCK)).unwrap();
542 assert!(owner.try_lock().is_err());
543 Box::new(fake.clone())
544 });
545 assert_eq!(result["ran"], true);
546 assert_eq!(job(dir.path())["state"], "success");
547 assert_eq!(job(dir.path())["attempts"], 3);
548 assert_eq!(job(dir.path())["unconfirmed"], 2);
549 assert_eq!(fake.borrow().uploads, 1);
550 }
551 }
553 #[test]
554 fn oversized_state_is_refused_without_changing_the_file() {
555 let dir = tempfile::tempdir().unwrap();
556 queue(dir.path(), "publish", &armor(&[primary(), subkey()]), 1);
557 let path = dir.path().join(FILE);
558 let mut bytes = std::fs::read(&path).unwrap();
559 bytes.resize(MAX_STATE as usize, b' ');
560 std::fs::write(&path, &bytes).unwrap();
561 assert!(read_state(dir.path()).is_ok(), "exactly 8 MiB is allowed");
562 bytes.push(b' ');
563 std::fs::write(&path, &bytes).unwrap();
564 assert_eq!(pass_with(dir.path(), || panic!("oversized queue must not connect")), json!({ "ran": false, "reason": "stateTooLarge" }));
565 assert!(!dir.path().join(OWNER_LOCK).exists());
566 assert!(!update(dir.path(), |_| panic!("oversized queue must not be updated")));
567 assert_eq!(std::fs::read(&path).unwrap(), bytes);
568 }
570 #[test]
571 fn due_selection_is_bounded_and_orders_revocations_before_publications() {
572 let mut state = json!({ "jobs": [] });
573 for (kind, created) in [("publish", 0), ("revoke", 5), ("publish", 1), ("revoke", 3), ("revoke", 3), ("revoke", 4), ("revoke", 2)] {
574 state["jobs"].as_array_mut().unwrap().push(json!({ "kind": kind, "createdAt": created, "state": "pending", "fingerprint": FP, "nextAt": 0 }));
575 }
576 assert_eq!(due_jobs(&state), vec![6, 3, 4, 5]);
577 state["jobs"][6]["state"] = json!("success");
578 state["jobs"][1]["nextAt"] = json!(now() + MAX_BACKOFF_SECS);
579 assert_eq!(due_jobs(&state), vec![3, 4, 5, 0]);
580 }
582 #[test]
583 fn parses_armor_with_headers_and_old_format_packets() {
584 let text = armor(&[primary(), revocation(), uid(), subkey()]);
585 let parsed = packets(&text).unwrap();
586 assert_eq!(parsed.iter().map(|p| p.0).collect::<Vec<_>>(), vec![6, 2, 13, 14]);
587 assert_eq!(key_revocations(&parsed).len(), 1);
588 // Old-format header (tag 6, one length byte) gives the same packet.
589 let old = [vec![0x98, 9], primary().1].concat();
590 let b64 = base64::engine::general_purpose::STANDARD.encode(&old);
591 assert_eq!(packets(&format!("-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n{b64}\n-----END PGP PUBLIC KEY BLOCK-----")).unwrap()[0..1], [primary()]);
592 assert!(packets("not armor").is_none());
593 // A revocation after a user ID is a certification revocation, not a key revocation.
594 assert!(key_revocations(&[primary(), uid(), revocation()]).is_empty());
595 }
597 #[test]
598 fn revocation_is_confirmed_only_with_our_signature_back_from_the_server() {
599 let dir = tempfile::tempdir().unwrap();
600 let fake = std::rc::Rc::new(std::cell::RefCell::new(Fake::default()));
601 queue(dir.path(), "revoke", &armor(&[primary(), revocation(), uid(), subkey()]), 1);
602 assert_eq!(run(dir.path(), &fake)["ran"], true);
603 assert_eq!(job(dir.path())["state"], "success");
604 let server = &read_state(dir.path()).unwrap()["keys"][FP]["server"];
605 assert_eq!((server["revoked"].clone(), server["by"].clone()), (json!(true), json!("helper")));
606 // Nothing due any more: no lock, no network.
607 assert_eq!(run(dir.path(), &fake)["reason"], "nothingDue");
608 assert_eq!(fake.borrow().uploads, 1);
610 // A server that drops the revocation is never a success.
611 fake.borrow_mut().strip_revocation = true;
612 queue(dir.path(), "revoke", &armor(&[primary(), revocation(), subkey()]), 1);
613 run(dir.path(), &fake);
614 assert_eq!((job(dir.path())["state"].clone(), job(dir.path())["lastError"].clone()), (json!("retry"), json!("KEYSERVER_NOT_CONFIRMED")));
615 // A different key on the server is refused.
616 fake.borrow_mut().strip_revocation = false;
617 queue(dir.path(), "revoke", &armor(&[primary(), revocation()]), 0);
618 fake.borrow_mut().stored = Some(armor(&[(TAG_PUBLIC_KEY, vec![4, 9, 9]), revocation()]));
619 struct Wrong(std::rc::Rc<std::cell::RefCell<Fake>>);
620 impl Server for Wrong {
621 fn upload(&mut self, _: &str, _: &str) -> Result<(), Failure> { Ok(()) }
622 fn lookup(&mut self, _: &str) -> Result<Option<String>, Failure> { Ok(self.0.borrow().stored.clone()) }
623 }
624 let wrong = Wrong(fake.clone());
625 pass_with(dir.path(), move || Box::new(wrong));
626 assert_eq!((job(dir.path())["state"].clone(), job(dir.path())["lastError"].clone()), (json!("failed"), json!("KEYSERVER_FINGERPRINT_MISMATCH")));
627 // A revoke job without a revocation signature is refused before any upload.
628 queue(dir.path(), "revoke", &armor(&[primary(), uid()]), 0);
629 let before = fake.borrow().uploads;
630 run(dir.path(), &fake);
631 assert_eq!(job(dir.path())["lastError"], "KEYSYNC_JOB_INVALID");
632 assert_eq!(fake.borrow().uploads, before);
633 }
635 #[test]
636 fn publication_needs_every_subkey_and_network_errors_back_off() {
637 let dir = tempfile::tempdir().unwrap();
638 let fake = std::rc::Rc::new(std::cell::RefCell::new(Fake::default()));
639 queue(dir.path(), "publish", &armor(&[primary(), uid(), subkey()]), 2);
640 run(dir.path(), &fake);
641 assert_eq!(job(dir.path())["state"], "retry", "server copy has 1 of 2 subkeys");
642 queue(dir.path(), "publish", &armor(&[primary(), uid(), subkey()]), 1);
643 run(dir.path(), &fake);
644 assert_eq!(job(dir.path())["state"], "success");
645 fake.borrow_mut().fail = Some(0);
646 queue(dir.path(), "publish", &armor(&[primary(), subkey()]), 1);
647 run(dir.path(), &fake);
648 let after = job(dir.path());
649 assert_eq!((after["state"].clone(), after["lastError"].clone()), (json!("retry"), json!("KEYSERVER_UNAVAILABLE")));
650 assert!(after["nextAt"].as_i64().unwrap() >= now() + 2 * INTERVAL_SECS - 1);
651 fake.borrow_mut().fail = Some(415);
652 queue(dir.path(), "publish", &armor(&[primary(), subkey()]), 1);
653 run(dir.path(), &fake);
654 assert_eq!(job(dir.path())["state"], "failed");
655 }
657 #[test]
658 fn running_application_keeps_the_queue_and_check_jobs_stay_with_core() {
659 let dir = tempfile::tempdir().unwrap();
660 let fake = std::rc::Rc::new(std::cell::RefCell::new(Fake::default()));
661 assert_eq!(run(dir.path(), &fake)["reason"], "noQueue");
662 queue(dir.path(), "revoke", &armor(&[primary(), revocation()]), 0);
663 set_job(dir.path(), "j1", |j| j["state"] = json!("uploading"));
664 let before = std::fs::read(dir.path().join(FILE)).unwrap();
665 let core = open_lock(&dir.path().join(OWNER_LOCK)).unwrap();
666 core.try_lock().unwrap();
667 assert_eq!(run(dir.path(), &fake)["reason"], "appRunning");
668 assert_eq!(job(dir.path())["state"], "uploading");
669 assert_eq!(std::fs::read(dir.path().join(FILE)).unwrap(), before);
670 drop(core);
671 let mut state = read_state(dir.path()).unwrap();
672 state["jobs"][0]["kind"] = json!("check");
673 state["jobs"][0]["state"] = json!("pending");
674 write_state(dir.path(), &state).unwrap();
675 assert_eq!(run(dir.path(), &fake)["reason"], "nothingDue");
676 assert_eq!(fake.borrow().uploads, 0);
677 #[cfg(unix)]
678 {
679 use std::os::unix::fs::PermissionsExt;
680 assert_eq!(std::fs::metadata(dir.path().join(FILE)).unwrap().permissions().mode() & 0o777, 0o600);
681 }
682 }
SHA-256417a2dd43a76af7d815ba8d3d52d72333c8bfd3d1a5e3603875924c635271ae3

CH-J Proprietary Software License 1.14 · Zdroj sa poskytuje v rámci CH-J Proprietary Software License 1.14. Jeho dostupnosť nemení licenčné podmienky ani neposkytuje dodatočné práva.

Archív SHA-256ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f
Súbory