Offentliggjort kilde
Download kilde ZIP
CH-J Tools
Gennemse mapper og filer til en specifik applikationsudgivelse.
CH-J / Server Manager
1704 filer
0.1.2-beta
Tilføjet: · Bygget:
1
//! KeySync while the application is closed (owner 2026-11-05). Core syncs with keys.ch-j.de while the app2
//! 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 queued6
//! 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 during9
//! a pass, no async runtime and no OpenPGP library. Confirmation compares packets byte for byte: the10
//! server's copy must contain our primary key packet, and for a revocation our own revocation signature11
//! (created and checked by Core), for a publication at least as many subkeys as ours. Recorded server12
//! states carry `"by": "helper"`, and Core checks them again with a full rPGP verification at its next run.14
use base64::Engine as _;15
use serde_json::{json, Value};16
use std::io::Read;17
use std::path::Path;18
use std::time::Duration;20
const FILE: &str = "keysync.json";21
const WRITE_LOCK: &str = "keysync.json.lock";22
const OWNER_LOCK: &str = "keysync.owner.lock";23
const ORIGIN: &str = "https://keys.ch-j.de";24
const MAX_BODY: u64 = 1024 * 1024;25
const MAX_STATE: u64 = 8 * 1024 * 1024;26
const MAX_JOBS_PER_PASS: usize = 4;27
const MAX_UNCONFIRMED: u64 = 5;28
const INTERVAL_SECS: i64 = 90;29
const MAX_BACKOFF_SECS: i64 = 3600;30
/// How often the background process looks at the queue (same period as Core).31
pub const PASS_INTERVAL: Duration = Duration::from_secs(90);33
fn now() -> i64 {34
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)35
}37
fn 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)46
}48
fn 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
}64
}66
/// Atomic 0600 write next to the file, like Core's `atomic_write_json`.67
fn 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
})87
}89
/// Read the newest file under the shared write lock, change it and write it back.90
fn 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()98
}100
fn 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
});107
}109
// ---------------------------------------------------------------- OpenPGP packets (no crypto, compare only)111
/// Binary packets of the first ASCII-armored block: `(tag, body)`.112
fn 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 line132
}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 + 192153
}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 usize158
}159
_ => return None, // partial lengths never appear in certificates160
};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)178
}180
const TAG_SIGNATURE: u8 = 2;181
const TAG_PUBLIC_KEY: u8 = 6;182
const TAG_PUBLIC_SUBKEY: u8 = 14;183
const KEY_REVOCATION: u8 = 0x20;185
/// Direct-key revocation signatures: signature packets after the primary key, before any user ID or subkey.186
fn 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()189
}191
/// Server state from the certificate the server returned, compared with ours.192
fn 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 }))201
}203
// ---------------------------------------------------------------- HTTPS205
struct Failure {206
code: &'static str,207
status: Option<u16>,208
}210
impl 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
}214
}216
fn failure(code: &'static str) -> Failure {217
Failure { code, status: None }218
}220
fn 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) }228
}230
/// Same policy as Core's client: HTTPS only, no redirects, no proxy, TLS verified against webpki roots.231
fn 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()239
}241
fn 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)248
}250
fn 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
}257
}259
trait 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>;263
}265
struct Https(ureq::Agent);267
impl 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
}284
}286
// ---------------------------------------------------------------- pass288
fn valid_fingerprint(fp: &str) -> bool {289
[40, 64].contains(&fp.len()) && fp.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_lowercase())290
}292
fn interrupted(job: &Value) -> bool {293
matches!(job["state"].as_str(), Some("uploading" | "verifying"))294
}296
fn 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(""))299
}301
/// Indices of at most four due jobs, revocations first. Never clone queued payloads here.302
fn 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
due317
}319
fn 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
}376
}378
fn 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
state415
};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.436
}438
/// One pass over the queue. Returns a JSON summary without fingerprints or certificates.439
pub fn pass(data: &Path) -> Value {440
pass_with(data, || Box::new(Https(agent())))441
}443
#[cfg(test)]444
mod tests {445
use super::*;447
// Synthetic V4 certificate structure: primary key, revocation signature, user ID, subkey. Packet bodies448
// 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 IDs492
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
}683
}SHA-256
417a2dd43a76af7d815ba8d3d52d72333c8bfd3d1a5e3603875924c635271ae3CH-J Proprietary Software License 1.14 · Kilden leveres under CH-J Proprietary Software License 1.14. Dens tilgængelighed ændrer ikke licensbetingelserne eller giver yderligere rettigheder.
Arkiv SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f