Sors ippubblikat
Sors ta' tniżżil ZIP
CH-J Tools
direttorji browse u fajls għal rilaxx applikazzjoni speċifika.
CH-J / Server Manager
1704 fajls
0.1.2-beta
Miżjud: · Buillt:
1
//! KeySyncManager: persistent publication/revocation queue and verified server state for keys.ch-j.de.2
//!3
//! `keysync.json` in the data directory (0600, atomic writes) holds only public material:4
//! - `keys`: public V4/V6 certificates mirrored from the Keychain plus the state *verified* on the server;5
//! - `jobs`: what still has to be sent (publish, revoke, check), attempts, next run, last error.6
//! Queue state and server state are separate records: an HTTP 200 never counts as success, only a7
//! certificate fetched back from the server that shows the change. The Standby Helper reads the same file8
//! to finish pending revocations while the application is closed; private keys never enter it.9
//!10
//! Two processes share the file (owner 2026-11-05: Core syncs while the app runs, the helper only when it11
//! is closed). `keysync.owner.lock` is held by whoever may process jobs: Core for its whole lifetime, the12
//! helper only for one pass when it can take it. Every write re-reads the file under `keysync.json.lock`,13
//! so a change made by the other process is never overwritten.15
use crate::core::error::{CoreError, CoreResult};16
use parking_lot::Mutex;17
use serde_json::{json, Map, Value};18
use std::path::{Path, PathBuf};19
use std::sync::Arc;20
use std::time::Duration;22
pub const FILE: &str = "keysync.json";23
pub const WRITE_LOCK: &str = "keysync.json.lock";24
pub const OWNER_LOCK: &str = "keysync.owner.lock";25
pub const INTERVAL: Duration = Duration::from_secs(90);26
/// Freshness of the verified server state (owner, 2026-11-05): own key pairs daily, imported public keys every 90 min.27
pub const FRESH_FULL_SECS: i64 = 24 * 3600;28
pub const FRESH_PUBLIC_SECS: i64 = 90 * 60;29
const MAX_JOBS_PER_RUN: usize = 4;30
const MAX_REFRESH_PER_RUN: usize = 3;31
const MAX_UNCONFIRMED: u64 = 5;32
const MAX_BACKOFF_SECS: i64 = 3600;33
const KEEP_FINISHED_SECS: i64 = 24 * 3600;34
/// A failed refresh is tried again after this pause instead of waiting for the full freshness period.35
const REFRESH_ERROR_PAUSE_SECS: i64 = 600;36
const MAX_JOBS: usize = 500;38
/// Server access. Production uses the strict HTTPS client of `keyserver.rs`; tests use a scripted server.39
pub enum Transport {40
Https(reqwest::Client),41
#[cfg(test)]42
Fake(Arc<Mutex<tests::FakeServer>>),43
}45
impl Transport {46
pub fn https() -> CoreResult<Self> {47
Ok(Self::Https(crate::core::keyserver::client()?))48
}49
async fn upload(&self, fingerprint: &str, keytext: &str) -> CoreResult<()> {50
match self {51
Self::Https(client) => {52
let body = serde_json::to_vec(&json!({ "keytext": keytext }))?;53
crate::core::keyserver::upload_with(client, fingerprint, body).await54
}55
#[cfg(test)]56
Self::Fake(server) => server.lock().upload(fingerprint, keytext),57
}58
}59
async fn lookup(&self, fingerprint: &str) -> CoreResult<Value> {60
match self {61
Self::Https(client) => crate::core::keyserver::lookup_with(client, fingerprint).await,62
#[cfg(test)]63
Self::Fake(server) => server.lock().lookup(fingerprint),64
}65
}66
}68
pub struct KeySync {69
path: PathBuf,70
write_lock: PathBuf,71
owner_path: PathBuf,72
/// Held while this process may process jobs (see the module comment).73
owner: Mutex<Option<std::fs::File>>,74
state: Mutex<Value>,75
pub notify: tokio::sync::Notify,76
run_lock: tokio::sync::Mutex<()>,77
}79
fn now() -> i64 {80
chrono::Utc::now().timestamp()81
}83
fn open_lock(path: &Path) -> std::io::Result<std::fs::File> {84
let mut options = std::fs::OpenOptions::new();85
options.create(true).read(true).write(true).truncate(false);86
#[cfg(unix)]87
{88
use std::os::unix::fs::OpenOptionsExt;89
options.mode(0o600);90
}91
options.open(path)92
}94
fn valid(state: Value) -> Option<Value> {95
Some(state).filter(|v| v["version"] == 1 && v["keys"].is_object() && v["jobs"].is_array())96
}98
fn empty() -> Value {99
json!({ "version": 1, "lastRunAt": null, "keys": {}, "jobs": [] })100
}102
/// Number of subkeys in an armored certificate; the server must return at least as many.103
fn subkey_count(armored: &str) -> u64 {104
use pgp::composed::{Deserializable, SignedPublicKey};105
SignedPublicKey::from_armor_single(std::io::Cursor::new(armored.as_bytes()))106
.map(|(key, _)| key.public_subkeys.len() as u64)107
.unwrap_or(0)108
}110
fn retryable(error: &CoreError) -> bool {111
match error.code.as_str() {112
"KEYSERVER_UNAVAILABLE" | "KEYSERVER_RATE_LIMITED" => true,113
"KEYSERVER_REJECTED" => error.details.get("status").and_then(Value::as_u64).is_some_and(|s| s >= 500),114
_ => false,115
}116
}118
impl KeySync {119
pub fn new(data: &Path) -> Arc<Self> {120
let path = data.join(FILE);121
let state = crate::core::storage::read_json(&path).ok().and_then(valid).unwrap_or_else(empty);122
let sync = Arc::new(Self { path, write_lock: data.join(WRITE_LOCK), owner_path: data.join(OWNER_LOCK), owner: Mutex::new(None),123
state: Mutex::new(state), notify: tokio::sync::Notify::new(), run_lock: tokio::sync::Mutex::new(()) });124
sync.ensure_owner();125
sync126
}128
fn disk(&self) -> Option<Value> {129
crate::core::storage::read_json(&self.path).ok().and_then(valid)130
}132
/// Take the processing role if the helper does not hold it right now. On taking it, the helper's133
/// results on disk become the current state.134
pub fn ensure_owner(&self) -> bool {135
let mut owner = self.owner.lock();136
if owner.is_some() {137
return true;138
}139
// Without a lock file (read-only data folder) behave as the only process, as before.140
let Ok(file) = open_lock(&self.owner_path) else { return true };141
if file.try_lock().is_err() {142
return false;143
}144
*owner = Some(file);145
if let Some(current) = self.disk() {146
*self.state.lock() = current;147
}148
true149
}151
fn save(&self, state: &Value) -> CoreResult<()> {152
crate::core::storage::atomic_write_json(&self.path, state)153
}155
fn update<R>(&self, change: impl FnOnce(&mut Value) -> CoreResult<R>) -> CoreResult<R> {156
let mut state = self.state.lock();157
// Short exclusive section shared with the helper: read the newest file, change it, write it back.158
let _guard = open_lock(&self.write_lock).and_then(|file| file.lock().map(|_| file)).ok();159
let mut draft = self.disk().unwrap_or_else(|| state.clone());160
let result = change(&mut draft)?;161
self.save(&draft)?;162
*state = draft;163
Ok(result)164
}166
/// Mirror the public part of Keychain OpenPGP entries (raw vault entries). Keys removed from the Keychain167
/// disappear unless a job for them is still open.168
pub fn mirror(&self, entries: &[Value]) -> CoreResult<()> {169
self.mirror_with(entries, true)170
}172
/// `prune` only for a complete Keychain snapshot; adding one key (enqueue) must not drop the others' records.173
fn mirror_with(&self, entries: &[Value], prune: bool) -> CoreResult<()> {174
let mut seen = std::collections::HashSet::new();175
let mut mirrored = Map::new();176
for entry in entries.iter().filter(|e| e["kind"] == "openpgp") {177
let Ok((fingerprint, body)) = crate::core::keyserver::public_payload(entry) else { continue };178
let keytext = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v["keytext"].as_str().map(str::to_string)).unwrap_or_default();179
seen.insert(fingerprint.clone());180
mirrored.insert(fingerprint, json!({ "keychainId": entry["id"], "label": entry["label"], "hasPrivate": entry["private"].is_string(),181
"revoked": entry["revoked"] == true, "public": keytext, "subkeys": subkey_count(entry["public"].as_str().unwrap_or("")) }));182
}183
let apply = |state: &mut Value| -> bool {184
let mut changed = false;185
let open: std::collections::HashSet<String> = state["jobs"].as_array().into_iter().flatten()186
.filter(|j| !matches!(j["state"].as_str(), Some("success" | "failed"))).filter_map(|j| j["fingerprint"].as_str().map(str::to_string)).collect();187
let keys = state["keys"].as_object_mut().expect("keys");188
if prune {189
keys.retain(|fingerprint, _| { let keep = seen.contains(fingerprint) || open.contains(fingerprint); changed |= !keep; keep });190
}191
for (fingerprint, fresh) in &mirrored {192
let slot = keys.entry(fingerprint.clone()).or_insert_with(|| json!({}));193
for field in ["keychainId", "label", "hasPrivate", "revoked", "public", "subkeys"] {194
if slot[field] != fresh[field] { slot[field] = fresh[field].clone(); changed = true; }195
}196
}197
changed198
};199
// Write only when the mirror differs; the write applies the same change to the newest file.200
if apply(&mut self.state.lock().clone()) { self.update(|state| { apply(state); Ok(()) })?; }201
Ok(())202
}204
/// Queue a publication, revocation or check for a raw Keychain entry. A revocation supersedes an open205
/// publication of the same key; an identical open job is refreshed instead of duplicated.206
pub fn enqueue(&self, kind: &str, entry: &Value) -> CoreResult<Value> {207
if !["publish", "revoke", "check"].contains(&kind) {208
return Err(CoreError::plain("Unknown key sync job."));209
}210
let (fingerprint, body) = crate::core::keyserver::public_payload(entry)?;211
if kind == "revoke" && entry["revoked"] != true {212
return Err(CoreError::new("KEYSYNC_NOT_REVOKED", "Revoke the key locally before publishing its revocation."));213
}214
let keytext = serde_json::from_slice::<Value>(&body)?["keytext"].as_str().unwrap_or("").to_string();215
self.mirror_with(std::slice::from_ref(entry), false).ok();216
let job = self.update(|state| {217
let stamp = now();218
let jobs = state["jobs"].as_array_mut().expect("jobs");219
if kind == "revoke" {220
jobs.retain(|j| !(j["fingerprint"] == fingerprint && j["kind"] == "publish" && !matches!(j["state"].as_str(), Some("success" | "failed"))));221
}222
if let Some(open) = jobs.iter_mut().find(|j| j["fingerprint"] == fingerprint && j["kind"] == kind && !matches!(j["state"].as_str(), Some("success" | "failed"))) {223
// A running job keeps its payload; a waiting one takes the newest certificate and runs now.224
if !matches!(open["state"].as_str(), Some("uploading" | "verifying")) {225
open["keytext"] = json!(keytext); open["state"] = json!("pending"); open["nextAt"] = json!(stamp); open["updatedAt"] = json!(stamp);226
}227
return Ok(open.clone());228
}229
if jobs.len() >= MAX_JOBS { jobs.retain(|j| !matches!(j["state"].as_str(), Some("success"))); }230
if jobs.len() >= MAX_JOBS {231
return Err(CoreError::new("KEYSYNC_QUEUE_FULL", "The key server queue is full. Dismiss failed jobs first."));232
}233
let job = json!({ "id": uuid::Uuid::new_v4().to_string(), "fingerprint": fingerprint, "kind": kind, "state": "pending", "attempts": 0,234
"unconfirmed": 0, "nextAt": stamp, "lastError": null, "createdAt": stamp, "updatedAt": stamp, "keytext": if kind == "check" { String::new() } else { keytext } });235
jobs.push(job.clone());236
Ok(job)237
})?;238
self.notify.notify_one();239
Ok(public_job(&job))240
}242
pub fn retry(&self, job_id: &str) -> CoreResult<()> {243
self.update(|state| {244
let job = state["jobs"].as_array_mut().expect("jobs").iter_mut().find(|j| j["id"] == job_id && j["state"] == "failed")245
.ok_or_else(|| CoreError::plain("Only failed jobs can be retried."))?;246
job["state"] = json!("pending"); job["attempts"] = json!(0); job["unconfirmed"] = json!(0); job["nextAt"] = json!(now());247
Ok(())248
})?;249
self.notify.notify_one();250
Ok(())251
}253
pub fn dismiss(&self, job_id: &str) -> CoreResult<()> {254
self.update(|state| {255
state["jobs"].as_array_mut().expect("jobs").retain(|j| !(j["id"] == job_id && matches!(j["state"].as_str(), Some("success" | "failed"))));256
Ok(())257
})258
}260
/// Queue overview for the Keychain bar and per-key state (no certificate text).261
pub fn summary(&self) -> Value {262
let state = self.state.lock();263
let jobs: Vec<Value> = state["jobs"].as_array().into_iter().flatten().map(public_job).collect();264
let mut counts = json!({ "pending": 0, "uploading": 0, "verifying": 0, "success": 0, "retry": 0, "failed": 0 });265
for job in &jobs { if let Some(key) = job["state"].as_str() { counts[key] = json!(counts[key].as_u64().unwrap_or(0) + 1); } }266
let mut keys = Map::new();267
for (fingerprint, key) in state["keys"].as_object().into_iter().flatten() {268
let job = jobs.iter().rev().find(|j| j["fingerprint"] == fingerprint.as_str());269
keys.insert(fingerprint.clone(), json!({ "server": key["server"], "job": job.map(|j| json!({ "kind": j["kind"], "state": j["state"], "lastError": j["lastError"] })) }));270
}271
json!({ "intervalSecs": INTERVAL.as_secs(), "lastRunAt": state["lastRunAt"], "counts": counts, "jobs": jobs, "keys": keys })272
}274
/// One pass: due jobs (revocations first), then stale verified states. Returns whether anything changed.275
pub async fn run_once(&self, transport: &Transport) -> bool {276
let _turn = self.run_lock.lock().await;277
// The helper is finishing a pass right now; Core takes over at the next run.278
if !self.ensure_owner() {279
return false;280
}281
// Jobs left `uploading`/`verifying` by an interrupted pass (app or helper ended mid-job) would never be282
// picked again: run_lock and the owner lock mean no pass is active now, so they run again (idempotent upload).283
let interrupted = self.state.lock()["jobs"].as_array().into_iter().flatten().any(|j| matches!(j["state"].as_str(), Some("uploading" | "verifying")));284
if interrupted {285
let _ = self.update(|state| {286
let stamp = now();287
for job in state["jobs"].as_array_mut().expect("jobs").iter_mut().filter(|j| matches!(j["state"].as_str(), Some("uploading" | "verifying"))) {288
job["state"] = json!("retry"); job["nextAt"] = json!(stamp); job["updatedAt"] = json!(stamp);289
}290
Ok(())291
});292
}293
let stamp = now();294
let due: Vec<Value> = {295
let state = self.state.lock();296
let mut due: Vec<Value> = state["jobs"].as_array().into_iter().flatten()297
.filter(|j| matches!(j["state"].as_str(), Some("pending" | "retry")) && j["nextAt"].as_i64().unwrap_or(0) <= stamp).cloned().collect();298
due.sort_by_key(|j| (j["kind"] != "revoke", j["createdAt"].as_i64().unwrap_or(0)));299
due.truncate(MAX_JOBS_PER_RUN);300
due301
};302
let mut changed = false;303
for job in due {304
changed = true;305
self.process(transport, &job).await;306
}307
let stale: Vec<(String, bool)> = {308
let state = self.state.lock();309
let busy: std::collections::HashSet<&str> = state["jobs"].as_array().into_iter().flatten()310
.filter(|j| !matches!(j["state"].as_str(), Some("success" | "failed"))).filter_map(|j| j["fingerprint"].as_str()).collect();311
let mut stale: Vec<(String, bool, i64)> = state["keys"].as_object().into_iter().flatten().filter(|(fp, _)| !busy.contains(fp.as_str())).filter_map(|(fp, key)| {312
let checked = key["server"]["checkedAt"].as_i64().unwrap_or(0);313
let error_at = key["server"]["errorAt"].as_i64().unwrap_or(0);314
let fresh = if key["hasPrivate"] == true { FRESH_FULL_SECS } else { FRESH_PUBLIC_SECS };315
// The helper compares packets without rPGP; Core confirms its states with a full check.316
let by_helper = key["server"]["by"] == "helper";317
((by_helper || stamp - checked >= fresh) && stamp - error_at >= REFRESH_ERROR_PAUSE_SECS).then(|| (fp.clone(), key["revoked"] == true, checked))318
}).collect();319
stale.sort_by_key(|(_, _, checked)| *checked);320
stale.into_iter().take(MAX_REFRESH_PER_RUN).map(|(fp, revoked, _)| (fp, revoked)).collect()321
};322
for (fingerprint, _) in stale {323
changed = true;324
match transport.lookup(&fingerprint).await {325
Ok(remote) => { let _ = self.record_server(&fingerprint, &remote); }326
// The last verified state stays; only the error and its time are noted.327
Err(error) => { let _ = self.update(|state| { if let Some(key) = state["keys"].get_mut(&fingerprint) { if !key["server"].is_object() { key["server"] = json!({}); } key["server"]["errorAt"] = json!(now()); key["server"]["error"] = json!(error.code); } Ok(()) }); }328
}329
}330
let _ = self.update(|state| {331
state["lastRunAt"] = json!(now());332
let limit = now() - KEEP_FINISHED_SECS;333
state["jobs"].as_array_mut().expect("jobs").retain(|j| !(j["state"] == "success" && j["updatedAt"].as_i64().unwrap_or(0) < limit));334
Ok(())335
});336
changed337
}339
fn set_job(&self, id: &str, change: impl FnOnce(&mut Value)) {340
let _ = self.update(|state| {341
if let Some(job) = state["jobs"].as_array_mut().expect("jobs").iter_mut().find(|j| j["id"] == id) { change(job); job["updatedAt"] = json!(now()); }342
Ok(())343
});344
}346
fn record_server(&self, fingerprint: &str, remote: &Value) -> CoreResult<()> {347
self.update(|state| {348
if let Some(key) = state["keys"].get_mut(fingerprint) {349
let stamp = now();350
key["server"] = json!({ "checkedAt": stamp, "verifiedAt": stamp, "found": remote["found"], "revoked": remote["revoked"], "subkeys": remote["subkeys"] });351
}352
Ok(())353
})354
}356
async fn process(&self, transport: &Transport, job: &Value) {357
let id = job["id"].as_str().unwrap_or("").to_string();358
let fingerprint = job["fingerprint"].as_str().unwrap_or("").to_string();359
let kind = job["kind"].as_str().unwrap_or("").to_string();360
let expected_subkeys = self.state.lock()["keys"][&fingerprint]["subkeys"].as_u64().unwrap_or(0);361
let outcome: CoreResult<Value> = async {362
if kind != "check" {363
self.set_job(&id, |j| j["state"] = json!("uploading"));364
transport.upload(&fingerprint, job["keytext"].as_str().unwrap_or("")).await?;365
}366
self.set_job(&id, |j| j["state"] = json!("verifying"));367
transport.lookup(&fingerprint).await368
}.await;369
match outcome {370
Ok(remote) => {371
let _ = self.record_server(&fingerprint, &remote);372
let confirmed = match kind.as_str() {373
"check" => true,374
"revoke" => remote["found"] == true && remote["revoked"] == true,375
// A server that strips subkeys or signatures (seen with PQ certificates) is not a success.376
_ => remote["found"] == true && remote["subkeys"].as_u64().unwrap_or(0) >= expected_subkeys,377
};378
self.set_job(&id, |j| {379
if confirmed { j["state"] = json!("success"); j["lastError"] = Value::Null; return; }380
let unconfirmed = j["unconfirmed"].as_u64().unwrap_or(0) + 1;381
j["unconfirmed"] = json!(unconfirmed);382
j["lastError"] = json!("KEYSERVER_NOT_CONFIRMED");383
if unconfirmed >= MAX_UNCONFIRMED { j["state"] = json!("failed"); } else { j["state"] = json!("retry"); j["nextAt"] = json!(now() + INTERVAL.as_secs() as i64); }384
});385
}386
Err(error) => {387
let again = retryable(&error);388
self.set_job(&id, |j| {389
let attempts = j["attempts"].as_u64().unwrap_or(0) + 1;390
j["attempts"] = json!(attempts);391
j["lastError"] = json!(error.code);392
if again {393
let backoff = (INTERVAL.as_secs() as i64).saturating_mul(1 << attempts.min(6)).min(MAX_BACKOFF_SECS);394
j["state"] = json!("retry"); j["nextAt"] = json!(now() + backoff);395
} else { j["state"] = json!("failed"); }396
});397
}398
}399
}401
/// Server state for a quick verification (owner 2026-11-05): a stored state younger than the freshness402
/// period (24 h key pair, 90 min public key) is shown as is; an older one is checked on the server now.403
pub async fn status_for_verify(&self, transport: &Transport, fingerprint: &str) -> Value {404
let fingerprint = fingerprint.to_ascii_uppercase();405
let (server, period) = {406
let state = self.state.lock();407
let Some(key) = state["keys"].get(&fingerprint) else { return json!({ "known": false }) };408
(key["server"].clone(), if key["hasPrivate"] == true { FRESH_FULL_SECS } else { FRESH_PUBLIC_SECS })409
};410
let verified = server["verifiedAt"].as_i64();411
// A helper result is only a packet comparison; Core shows it as fresh after its own verification.412
if server["by"] != "helper" && verified.is_some_and(|at| now() - at < period) {413
return json!({ "known": true, "found": server["found"], "revoked": server["revoked"], "verifiedAt": verified, "fresh": true, "live": false });414
}415
match tokio::time::timeout(Duration::from_secs(12), transport.lookup(&fingerprint)).await {416
Ok(Ok(remote)) => {417
let _ = self.record_server(&fingerprint, &remote);418
json!({ "known": true, "found": remote["found"], "revoked": remote["revoked"], "verifiedAt": now(), "fresh": true, "live": true })419
}420
outcome => {421
let code = match outcome { Ok(Err(error)) => error.code, _ => "KEYSERVER_UNAVAILABLE".to_string() };422
json!({ "known": true, "found": server["found"], "revoked": server["revoked"], "verifiedAt": verified, "fresh": false, "live": false, "error": code })423
}424
}425
}427
/// Keyserver state for a verification with any key: Keychain identities use `status_for_verify`; other keys428
/// (saved in Hash, picked file, downloaded now) are looked up live, without recording anything.429
pub async fn server_status_any(&self, transport: &Transport, fingerprint: &str) -> Value {430
let tracked = self.status_for_verify(transport, fingerprint).await;431
if tracked["known"] == true {432
return tracked;433
}434
match tokio::time::timeout(Duration::from_secs(12), transport.lookup(&fingerprint.to_ascii_uppercase())).await {435
Ok(Ok(remote)) => json!({ "known": true, "tracked": false, "found": remote["found"], "revoked": remote["revoked"], "verifiedAt": now(), "fresh": true, "live": true }),436
outcome => {437
let code = match outcome { Ok(Err(error)) => error.code, _ => "KEYSERVER_UNAVAILABLE".to_string() };438
json!({ "known": true, "tracked": false, "found": null, "revoked": null, "fresh": false, "live": false, "error": code })439
}440
}441
}443
/// Copy verified server states into the vault's per-key `keyserver` record (only while unlocked and only444
/// when the stored certificate is still the one that was checked).445
pub fn apply_to_vault(&self, vault: &crate::core::vault::VaultStore) {446
let Ok(generation) = vault.unlocked_generation() else { return };447
let keys = self.state.lock()["keys"].clone();448
let _ = vault.update_at_generation(generation, |data| {449
let Some(items) = data.pointer_mut("/secrets/keychain").and_then(Value::as_array_mut) else { return Ok(()) };450
for entry in items.iter_mut().filter(|e| e["kind"] == "openpgp") {451
let Some(fingerprint) = entry["fingerprint"].as_str() else { continue };452
// Helper results wait for Core's own verification (see run_once) before they reach the vault.453
let Some(server) = keys.get(fingerprint).map(|k| &k["server"]).filter(|s| s["verifiedAt"].is_i64() && s["by"] != "helper") else { continue };454
let checked = chrono::DateTime::from_timestamp(server["verifiedAt"].as_i64().unwrap_or(0), 0).map(|d| d.to_rfc3339()).unwrap_or_default();455
if entry["keyserver"]["checkedAt"].as_str().is_some_and(|c| c >= checked.as_str()) { continue; }456
if !entry["keyserver"].is_object() { entry["keyserver"] = json!({}); }457
entry["keyserver"]["origin"] = json!(crate::core::keyserver::ORIGIN);458
entry["keyserver"]["checkedAt"] = json!(checked);459
entry["keyserver"]["found"] = server["found"].clone();460
entry["keyserver"]["revoked"] = server["revoked"].clone();461
if server["found"] == true { entry["keyserver"][if server["revoked"] == true { "revocationPublishedAt" } else { "publishedAt" }] = json!(checked); }462
}463
Ok(())464
});465
}466
}468
fn public_job(job: &Value) -> Value {469
json!({ "id": job["id"], "fingerprint": job["fingerprint"], "kind": job["kind"], "state": job["state"], "attempts": job["attempts"],470
"nextAt": job["nextAt"], "lastError": job["lastError"], "createdAt": job["createdAt"], "updatedAt": job["updatedAt"] })471
}473
#[cfg(test)]474
pub mod tests {475
use super::*;477
/// Scripted keyserver: stores uploads, can fail, can strip subkeys like the PQ incident.478
#[derive(Default)]479
pub struct FakeServer {480
pub stored: std::collections::HashMap<String, (bool, u64)>,481
pub fail_with: Option<(String, Option<u16>)>,482
pub strip_subkeys: bool,483
pub uploads: usize,484
}485
impl FakeServer {486
pub fn upload(&mut self, fingerprint: &str, keytext: &str) -> CoreResult<()> {487
if let Some((code, status)) = self.fail_with.clone() {488
let error = CoreError::new(&code, "scripted failure");489
return Err(match status { Some(s) => error.with("status", s), None => error });490
}491
self.uploads += 1;492
use pgp::composed::{Deserializable, SignedPublicKey};493
let (key, _) = SignedPublicKey::from_armor_single(std::io::Cursor::new(keytext.as_bytes())).map_err(|_| CoreError::plain("bad"))?;494
let revoked = !key.details.revocation_signatures.is_empty() || self.stored.get(fingerprint).is_some_and(|s| s.0);495
self.stored.insert(fingerprint.to_string(), (revoked, if self.strip_subkeys { 0 } else { key.public_subkeys.len() as u64 }));496
Ok(())497
}498
pub fn lookup(&mut self, fingerprint: &str) -> CoreResult<Value> {499
if let Some((code, _)) = self.fail_with.clone() { return Err(CoreError::new(&code, "scripted failure")); }500
Ok(match self.stored.get(fingerprint) { Some((revoked, subkeys)) => json!({"found":true,"revoked":revoked,"subkeys":subkeys}), None => json!({"found":false,"revoked":false,"subkeys":0}) })501
}502
}504
fn entry() -> Value {505
let (_, entry) = crate::core::openpgp::generate(&json!({"name":"KeySync fixture","subkeys":[{"role":"encrypt","algorithm":"curve25519"}]})).unwrap();506
entry507
}508
fn fake() -> (Arc<Mutex<FakeServer>>, Transport) { let server = Arc::new(Mutex::new(FakeServer::default())); (server.clone(), Transport::Fake(server)) }509
fn job_state(sync: &KeySync, kind: &str) -> String {510
sync.summary()["jobs"].as_array().unwrap().iter().rev().find(|j| j["kind"] == kind).map(|j| j["state"].as_str().unwrap().to_string()).unwrap_or_default()511
}512
fn rewind(sync: &KeySync) { let _ = sync.update(|s| { for j in s["jobs"].as_array_mut().unwrap() { j["nextAt"] = json!(0); } Ok(()) }); }514
#[tokio::test]515
async fn interrupted_jobs_run_again_and_enqueue_keeps_other_keys() {516
let dir = tempfile::tempdir().unwrap();517
let sync = KeySync::new(dir.path());518
let (a, b) = (entry(), entry());519
sync.mirror(&[a.clone(), b.clone()]).unwrap();520
let fp_b = b["fingerprint"].as_str().unwrap().to_string();521
let _ = sync.update(|s| { s["keys"][&fp_b]["server"] = json!({"verifiedAt": now(), "found": true, "revoked": false}); Ok(()) });522
sync.enqueue("publish", &a).unwrap();523
assert_eq!(sync.summary()["keys"][&fp_b]["server"]["found"], true, "queueing A must not drop B's record");524
// A pass ended mid-upload (app quit, helper stopped for an update).525
let _ = sync.update(|s| { s["jobs"][0]["state"] = json!("uploading"); s["jobs"][0]["nextAt"] = json!(now() + 3600); Ok(()) });526
let (server, transport) = fake();527
assert!(sync.run_once(&transport).await);528
assert_eq!(job_state(&sync, "publish"), "success");529
assert_eq!(server.lock().uploads, 1);530
}532
#[tokio::test]533
async fn helper_results_are_not_shown_as_verified_before_core_checks_them() {534
let dir = tempfile::tempdir().unwrap();535
let sync = KeySync::new(dir.path());536
let a = entry();537
let fp = a["fingerprint"].as_str().unwrap().to_string();538
sync.mirror(std::slice::from_ref(&a)).unwrap();539
let _ = sync.update(|s| { s["keys"][&fp]["server"] = json!({"verifiedAt": now(), "found": true, "revoked": true, "by": "helper"}); Ok(()) });540
let (_server, transport) = fake();541
let status = sync.status_for_verify(&transport, &fp).await;542
assert_eq!((status["live"].as_bool(), status["revoked"].as_bool()), (Some(true), Some(false)), "{status}");543
}545
#[tokio::test]546
async fn publish_succeeds_only_after_the_certificate_is_fetched_back_and_persists() {547
let dir = tempfile::tempdir().unwrap();548
let sync = KeySync::new(dir.path());549
let entry = entry();550
let (server, transport) = fake();551
sync.enqueue("publish", &entry).unwrap();552
assert_eq!(job_state(&sync, "publish"), "pending");553
assert!(sync.run_once(&transport).await);554
assert_eq!(job_state(&sync, "publish"), "success");555
assert_eq!(server.lock().uploads, 1);556
let fp = entry["fingerprint"].as_str().unwrap();557
assert_eq!(sync.summary()["keys"][fp]["server"]["found"], true);558
// Persisted public-only file, readable again (e.g. by the helper).559
let text = std::fs::read_to_string(dir.path().join(FILE)).unwrap();560
assert!(text.contains("PGP PUBLIC KEY BLOCK") && !text.contains("PRIVATE KEY"));561
assert_eq!(KeySync::new(dir.path()).summary()["counts"]["success"], 1);562
}564
#[tokio::test]565
async fn stripped_certificate_is_never_success_and_network_errors_back_off() {566
let dir = tempfile::tempdir().unwrap();567
let sync = KeySync::new(dir.path());568
let entry = entry();569
let (server, transport) = fake();570
server.lock().strip_subkeys = true;571
sync.enqueue("publish", &entry).unwrap();572
for _ in 0..MAX_UNCONFIRMED { rewind(&sync); sync.run_once(&transport).await; }573
assert_eq!(job_state(&sync, "publish"), "failed");574
server.lock().strip_subkeys = false;575
server.lock().fail_with = Some(("KEYSERVER_UNAVAILABLE".into(), None));576
sync.enqueue("check", &entry).unwrap();577
sync.run_once(&transport).await;578
assert_eq!(job_state(&sync, "check"), "retry");579
let next = sync.summary()["jobs"].as_array().unwrap().iter().find(|j| j["kind"] == "check").unwrap()["nextAt"].as_i64().unwrap();580
assert!(next >= now() + 90);581
server.lock().fail_with = Some(("KEYSERVER_UNSUPPORTED_FORMAT".into(), Some(415)));582
rewind(&sync); sync.run_once(&transport).await;583
assert_eq!(job_state(&sync, "check"), "failed");584
}586
#[tokio::test]587
async fn running_job_is_not_duplicated_and_failed_refresh_retries_after_a_pause() {588
let dir = tempfile::tempdir().unwrap();589
let sync = KeySync::new(dir.path());590
let entry = entry();591
sync.enqueue("check", &entry).unwrap();592
let _ = sync.update(|s| { s["jobs"][0]["state"] = json!("verifying"); Ok(()) });593
sync.enqueue("check", &entry).unwrap();594
assert_eq!(sync.summary()["jobs"].as_array().unwrap().len(), 1);595
let _ = sync.update(|s| { s["jobs"] = json!([]); Ok(()) });596
let (server, transport) = fake();597
server.lock().fail_with = Some(("KEYSERVER_UNAVAILABLE".into(), None));598
sync.run_once(&transport).await;599
let fp = entry["fingerprint"].as_str().unwrap();600
assert_eq!(sync.summary()["keys"][fp]["server"]["checkedAt"], Value::Null);601
server.lock().fail_with = None;602
sync.run_once(&transport).await;603
assert_eq!(sync.summary()["keys"][fp]["server"]["found"], Value::Null, "paused after an error");604
let _ = sync.update(|s| { s["keys"][fp]["server"]["errorAt"] = json!(now() - REFRESH_ERROR_PAUSE_SECS); Ok(()) });605
sync.run_once(&transport).await;606
assert_eq!(sync.summary()["keys"][fp]["server"]["found"], false);607
}609
#[tokio::test]610
async fn revocation_supersedes_publication_and_needs_a_revoked_server_copy() {611
let dir = tempfile::tempdir().unwrap();612
let sync = KeySync::new(dir.path());613
let entry = entry();614
let (server, transport) = fake();615
assert_eq!(sync.enqueue("revoke", &entry).unwrap_err().code, "KEYSYNC_NOT_REVOKED");616
sync.enqueue("publish", &entry).unwrap();617
let revoked = crate::core::keychain::revoke(&entry, "").unwrap();618
sync.enqueue("revoke", &revoked).unwrap();619
let jobs = sync.summary()["jobs"].as_array().unwrap().clone();620
assert_eq!(jobs.len(), 1);621
assert_eq!(jobs[0]["kind"], "revoke");622
sync.run_once(&transport).await;623
assert_eq!(job_state(&sync, "revoke"), "success");624
assert_eq!(server.lock().stored[revoked["fingerprint"].as_str().unwrap()].0, true);625
}627
#[tokio::test]628
async fn owner_lock_keeps_core_from_processing_while_the_helper_runs_and_writes_merge() {629
let dir = tempfile::tempdir().unwrap();630
// The helper holds the processing role before the app starts.631
let helper = open_lock(&dir.path().join(OWNER_LOCK)).unwrap();632
helper.try_lock().unwrap();633
let sync = KeySync::new(dir.path());634
let entry = entry();635
let (server, transport) = fake();636
sync.enqueue("publish", &entry).unwrap();637
assert!(!sync.run_once(&transport).await);638
assert_eq!(server.lock().uploads, 0);639
// The helper finishes the job in the file; Core's later write must keep that result.640
let mut disk: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(FILE)).unwrap()).unwrap();641
disk["jobs"][0]["state"] = json!("success");642
std::fs::write(dir.path().join(FILE), disk.to_string()).unwrap();643
let mut other = self::entry(); other["private"] = Value::Null;644
sync.mirror(&[entry.clone(), other]).unwrap();645
drop(helper);646
assert!(sync.ensure_owner());647
assert_eq!(job_state(&sync, "publish"), "success");648
assert_eq!(sync.summary()["keys"].as_object().unwrap().len(), 2);649
}651
#[tokio::test]652
async fn verification_uses_a_fresh_state_and_checks_a_stale_one_on_the_server() {653
let dir = tempfile::tempdir().unwrap();654
let sync = KeySync::new(dir.path());655
let mut public_only = entry(); public_only["private"] = Value::Null;656
let fp = public_only["fingerprint"].as_str().unwrap().to_string();657
sync.mirror(std::slice::from_ref(&public_only)).unwrap();658
let (server, transport) = fake();659
assert_eq!(sync.status_for_verify(&transport, "0".repeat(40).as_str()).await["known"], false);660
server.lock().stored.insert(fp.clone(), (true, 1));661
let first = sync.status_for_verify(&transport, &fp).await;662
assert_eq!((first["live"].clone(), first["revoked"].clone()), (json!(true), json!(true)));663
server.lock().stored.clear();664
let cached = sync.status_for_verify(&transport, &fp).await;665
assert_eq!((cached["live"].clone(), cached["revoked"].clone()), (json!(false), json!(true)));666
// Older than 90 min for a public-only key: checked again; a network error keeps the old state, marked stale.667
let _ = sync.update(|s| { s["keys"][&fp]["server"]["verifiedAt"] = json!(now() - FRESH_PUBLIC_SECS); Ok(()) });668
server.lock().fail_with = Some(("KEYSERVER_UNAVAILABLE".into(), None));669
let stale = sync.status_for_verify(&transport, &fp).await;670
assert_eq!((stale["fresh"].clone(), stale["error"].clone(), stale["revoked"].clone()), (json!(false), json!("KEYSERVER_UNAVAILABLE"), json!(true)));671
// Helper-recorded states are refreshed by Core at its next run.672
let _ = sync.update(|s| { s["keys"][&fp]["server"] = json!({"checkedAt": now(), "verifiedAt": now(), "found": true, "revoked": true, "by": "helper"}); Ok(()) });673
server.lock().fail_with = None;674
sync.run_once(&transport).await;675
assert_eq!(sync.summary()["keys"][&fp]["server"]["found"], false);676
}678
#[tokio::test]679
async fn stale_states_refresh_by_freshness_and_the_vault_gets_the_verified_state() {680
let dir = tempfile::tempdir().unwrap();681
let sync = KeySync::new(dir.path());682
let full = entry();683
let mut public_only = entry(); public_only["private"] = Value::Null;684
sync.mirror(&[full.clone(), public_only.clone()]).unwrap();685
let (server, transport) = fake();686
server.lock().stored.insert(public_only["fingerprint"].as_str().unwrap().into(), (false, 1));687
sync.run_once(&transport).await;688
let fp_full = full["fingerprint"].as_str().unwrap(); let fp_pub = public_only["fingerprint"].as_str().unwrap();689
assert_eq!(sync.summary()["keys"][fp_pub]["server"]["found"], true);690
// Checked 2 h ago: the public-only key (90 min) is stale, the key pair (24 h) is not.691
let _ = sync.update(|s| { for fp in [fp_full, fp_pub] { s["keys"][fp]["server"]["checkedAt"] = json!(now() - 7200); } Ok(()) });692
server.lock().stored.clear();693
sync.run_once(&transport).await;694
assert_eq!(sync.summary()["keys"][fp_pub]["server"]["found"], false);695
assert_eq!(sync.summary()["keys"][fp_full]["server"]["checkedAt"].as_i64().unwrap() <= now() - 7000, true);696
// Removed from the Keychain without an open job: dropped from the mirror.697
sync.mirror(&[full.clone()]).unwrap();698
assert!(sync.summary()["keys"].get(fp_pub).is_none());699
// Verified state reaches the vault record.700
let vault = crate::core::vault::VaultStore::new(dir.path().join("vault-root").as_path());701
vault.create("fixture-vault-password").await.unwrap();702
crate::core::keychain::store(&vault, full.clone()).unwrap();703
sync.apply_to_vault(&vault);704
assert_eq!(crate::core::keychain::list(&vault).unwrap()[0]["keyserver"]["found"], false);705
}706
}SHA-256
8ae86182763a6c577f076296e40692ad1c65a2820dd16bab58c8726f19e6ad47CH-J Proprietary Software License 1.14 · Sors huwa pprovdut taħt iż-CH-J Proprietary Software License 1.14. Id-disponibbiltà tagħha ma tibdilx it-termini tal-liċenzja jew tagħti drittijiet addizzjonali.
Archive SHA-256
ac8fe790f4ba60fc098f8c985fc2656cfcc6cdc21fb34b30e308a2b54e62258f