From fd80c34152597d0cd8ceb49c8d77d6c6406867bf Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 17 Oct 2026 12:13:27 +0200 Subject: [PATCH] feat(cli,core): trace the keys that open My Files in pdfs diagnose When the session is stored and unlocked but the daemon does not answer, pdfs diagnose asks Proton for a key report and prints a Keys section: each account key, each address key and the account key its token is for, and the address key the My Files share is encrypted to. It holds key IDs only, so it can be posted in an issue. Refs #40 --- crates/pdfs-cli/src/main.rs | 52 +++++- crates/pdfs-core/src/keycheck.rs | 294 +++++++++++++++++++++++++++++++ crates/pdfs-core/src/lib.rs | 1 + docs/CHANGELOG.md | 6 + docs/CLI.md | 2 +- docs/DEVELOPMENT.md | 2 +- docs/TROUBLESHOOTING.md | 8 +- 7 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 crates/pdfs-core/src/keycheck.rs diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index d358c42..1f2eac5 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -3519,10 +3519,13 @@ fn cmd_diagnose() -> Result<()> { ); println!("\nAccount"); + let mut keys_unlocked = false; match pdfs_core::auth::load() { Ok(session) => { report.finding(DiagnoseLevel::Ok, " keyring session", session.username); - match key_state_name() { + let key_state = key_state_name(); + keys_unlocked = key_state != "locked"; + match key_state { "locked" => report.finding( DiagnoseLevel::Fail, " account keys", @@ -3561,6 +3564,7 @@ fn cmd_diagnose() -> Result<()> { ); // A stale socket file with no listener is the common failure, so the reply // is what decides this rather than the file's existence. + let mut daemon_responding = false; match control_request(CtlRequest::Status) { Ok(CtlResponse::Status { mountpoint, @@ -3572,6 +3576,7 @@ fn cmd_diagnose() -> Result<()> { version, .. }) => { + daemon_responding = true; report.finding(DiagnoseLevel::Ok, " daemon responding", ""); if daemon_outdated(&version) { report.finding( @@ -3713,6 +3718,14 @@ fn cmd_diagnose() -> Result<()> { mountpoint.display().to_string(), ); + // The daemon opens its control socket only once My Files is mounted, so a + // daemon that answers has the keys it needs. One that does not may be stuck + // on a key that does not open (#40); only then is Proton asked. + if keys_unlocked && !daemon_responding { + println!("\nKeys"); + diagnose_keys(&mut report); + } + if report.failures == 0 { if report.warnings == 0 { println!("\nNo problems found."); @@ -3732,6 +3745,43 @@ fn cmd_diagnose() -> Result<()> { } } +/// Ask Proton which account and address keys open, and which of them the My +/// Files share needs. The findings hold key IDs and no secrets. +fn diagnose_keys(report: &mut DiagnoseReport) { + use pdfs_core::keycheck::{self, Level}; + + let checked = tokio::runtime::Runtime::new() + .map_err(anyhow::Error::from) + .and_then(|rt| { + rt.block_on(async { + tokio::time::timeout(std::time::Duration::from_secs(30), keycheck::run()) + .await + .map_err(|_| anyhow!("Proton did not answer within 30 seconds"))? + .map_err(anyhow::Error::from) + }) + }); + let (keys, share) = match checked { + Ok(checked) => checked, + Err(e) => { + report.finding(DiagnoseLevel::Warn, " key check", format!("{e:#}")); + return; + } + }; + let findings = keycheck::findings(&keys, &share); + let any_failed = findings.iter().any(|f| f.level != Level::Ok); + for finding in findings { + let level = match finding.level { + Level::Ok => DiagnoseLevel::Ok, + Level::Warn => DiagnoseLevel::Warn, + Level::Fail => DiagnoseLevel::Fail, + }; + report.finding(level, &format!(" {}", finding.label), finding.detail); + } + if any_failed { + println!(" (key IDs are not secret; include this section when you report it)"); + } +} + /// Send one request to the running mount daemon's control socket and read its /// reply. Errors if no daemon is listening. fn control_request(req: CtlRequest) -> Result { diff --git a/crates/pdfs-core/src/keycheck.rs b/crates/pdfs-core/src/keycheck.rs new file mode 100644 index 0000000..6ad2e5e --- /dev/null +++ b/crates/pdfs-core/src/keycheck.rs @@ -0,0 +1,294 @@ +//! Trace the key chain that opens My Files, for `pdfs diagnose`. +//! +//! The daemon only says "missing key" when the My Files share does not open +//! (#40). This walks the chain the other way and names the link that breaks: +//! the share's passphrase is encrypted to an address key, whose token is +//! encrypted to an account key, which opens with the stored passphrase. Every +//! finding holds key IDs and flags only, never key material, so the output is +//! safe to post in an issue. + +use proton_sdk::account::{AddressKeyReport, KeyReport, UserKeyReport}; +use proton_sdk::ids::AddressId; + +use crate::auth; +use crate::error::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Ok, + Warn, + Fail, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Finding { + pub level: Level, + pub label: String, + pub detail: String, +} + +/// The address and recipients of the My Files share passphrase, or why they +/// could not be read. +pub type ShareRecipients = std::result::Result<(AddressId, Vec>), String>; + +/// Resume the stored session and ask Proton for the account's keys and the +/// My Files share. Fails when there is no session, the keys are locked, or +/// Proton cannot be reached; reading the share is allowed to fail on its own. +/// +/// The session's tokens are the daemon's too. A refresh here spends the +/// refresh token the daemon holds, which it recovers from by resuming the +/// stored session (see [`auth::stored_session_is_newer`]). +pub async fn run() -> Result<(KeyReport, ShareRecipients)> { + let (client, _session) = auth::resume_client().await?; + let report = client.account().key_report().await?; + let share = client + .my_files_share_recipients() + .await + .map_err(|e| e.to_string()); + Ok((report, share)) +} + +/// One finding per key, then one for the My Files share. +pub fn findings(report: &KeyReport, share: &ShareRecipients) -> Vec { + let mut findings = Vec::new(); + for key in &report.user_keys { + findings.push(user_key_finding(key)); + } + for address in &report.addresses { + for key in &address.keys { + findings.push(address_key_finding(report, &address.email, key)); + } + } + findings.push(share_finding(report, share)); + findings +} + +fn user_key_finding(key: &UserKeyReport) -> Finding { + let label = format!("account key {}", short(key.id.as_str())); + let (level, detail) = match (key.active, key.unlocks) { + (true, true) => (Level::Ok, "opens".to_string()), + (true, false) => ( + Level::Fail, + format!( + "does not open: {}", + key.error.as_deref().unwrap_or("unknown") + ), + ), + (false, true) => (Level::Ok, "inactive, opens with your password".to_string()), + (false, false) => (Level::Ok, "inactive, does not open".to_string()), + }; + Finding { + level, + label, + detail: with_primary(key.primary, detail), + } +} + +fn address_key_finding(report: &KeyReport, email: &str, key: &AddressKeyReport) -> Finding { + let label = format!("{email} key {}", short(key.id.as_str())); + let (level, detail) = if !key.active { + (Level::Ok, "inactive, not used".to_string()) + } else if key.unlocks { + (Level::Ok, "opens".to_string()) + } else { + // A non-primary key matters only for what was encrypted to it; the + // share finding says whether My Files is. + let level = if key.primary { + Level::Fail + } else { + Level::Warn + }; + (level, format!("does not open: {}", why_closed(report, key))) + }; + Finding { + level, + label, + detail: with_primary(key.primary, detail), + } +} + +/// Why an active address key does not open, named by the account key its +/// token was meant for when the token has one. +fn why_closed(report: &KeyReport, key: &AddressKeyReport) -> String { + let error = key.error.as_deref().unwrap_or("unknown"); + let Some(recipients) = &key.token_recipients else { + return error.to_string(); + }; + let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect(); + if let Some(user_key) = named.iter().find_map(|r| report.user_key_named(r)) { + let id = short(user_key.id.as_str()); + return match (user_key.active, user_key.unlocks) { + (false, true) => { + format!( + "its token is for inactive account key {id}, which opens with your password" + ) + } + (false, false) => { + format!("its token is for inactive account key {id}, which does not open either") + } + (true, false) => format!("its token is for account key {id}, which does not open"), + // The token opened; the key itself refused its passphrase. + (true, true) => error.to_string(), + }; + } + match named.first() { + Some(recipient) => { + format!("its token is for key {recipient}, which is none of your account keys") + } + None if recipients.is_empty() => error.to_string(), + None => "its token does not name the key it is for".to_string(), + } +} + +fn share_finding(report: &KeyReport, share: &ShareRecipients) -> Finding { + let label = "My Files share".to_string(); + let (level, detail) = match share { + Err(e) => (Level::Warn, format!("could not be read: {e}")), + Ok((address_id, recipients)) => { + let named: Vec<&str> = recipients.iter().flatten().map(String::as_str).collect(); + match named.iter().find_map(|r| report.address_key_named(r)) { + Some((address, key)) => { + let name = format!("{} key {}", address.email, short(key.id.as_str())); + if key.unlocks { + (Level::Ok, format!("encrypted to {name}, which opens")) + } else { + ( + Level::Fail, + format!("encrypted to {name}, which does not open"), + ) + } + } + None => ( + Level::Fail, + match named.first() { + Some(recipient) => format!( + "encrypted to key {recipient}, which is none of the keys of address {address_id}" + ), + None => "its passphrase does not name the key it is for".to_string(), + }, + ), + } + } + }; + Finding { + level, + label, + detail, + } +} + +fn with_primary(primary: bool, detail: String) -> String { + if primary { + format!("primary, {detail}") + } else { + detail + } +} + +/// The start of a Proton key ID: enough to tell keys apart and to find them +/// in the daemon log, which prints the whole ID. +fn short(id: &str) -> String { + match id.char_indices().nth(8) { + Some((end, _)) => format!("{}…", &id[..end]), + None => id.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proton_sdk::account::AddressReport; + use proton_sdk::ids::{AddressKeyId, UserKeyId}; + + fn user_key(id: &str, active: bool, unlocks: bool, pgp: &str) -> UserKeyReport { + UserKeyReport { + id: UserKeyId::new(id), + primary: active, + active, + unlocks, + error: (!unlocks).then(|| "unlock: wrong passphrase".to_string()), + key_ids: vec![pgp.to_string()], + } + } + + fn address_key(id: &str, primary: bool, token_for: &str, pgp: &str) -> AddressKeyReport { + AddressKeyReport { + id: AddressKeyId::new(id), + primary, + active: true, + token_recipients: Some(vec![Some(token_for.to_string())]), + unlocks: token_for == "u1", + error: (token_for != "u1").then(|| "token: failed to decrypt: missing key".to_string()), + key_ids: vec![pgp.to_string()], + } + } + + /// The account in #40, as far as its log shows: the primary address key + /// opens, an older one does not, and My Files is encrypted to the older one. + fn report(stranded_token_for: &str) -> KeyReport { + KeyReport { + user_keys: vec![ + user_key("UserKeyOne-123", true, true, "u1"), + user_key("UserKeyTwo-456", false, true, "u2"), + ], + addresses: vec![AddressReport { + id: AddressId::new("address-1"), + email: "user@proton.me".to_string(), + keys: vec![ + address_key("AddrKeyNew-789", true, "u1", "a1"), + address_key("AddrKeyOld-000", false, stranded_token_for, "a2"), + ], + }], + } + } + + #[test] + fn a_stranded_address_key_is_traced_to_the_inactive_account_key() { + let report = report("u2"); + let share = Ok((AddressId::new("address-1"), vec![Some("a2".to_string())])); + let findings = findings(&report, &share); + + assert_eq!(findings[0].detail, "primary, opens"); + assert_eq!(findings[1].detail, "inactive, opens with your password"); + assert_eq!(findings[2].level, Level::Ok); + assert_eq!(findings[3].level, Level::Warn); + assert_eq!(findings[3].label, "user@proton.me key AddrKeyO…"); + assert_eq!( + findings[3].detail, + "does not open: its token is for inactive account key UserKeyT…, which opens with your password" + ); + assert_eq!(findings[4].level, Level::Fail); + assert_eq!( + findings[4].detail, + "encrypted to user@proton.me key AddrKeyO…, which does not open" + ); + } + + #[test] + fn a_token_for_a_key_the_account_does_not_list_says_so() { + let report = report("ffff"); + let detail = &findings(&report, &Err("offline".to_string()))[3].detail; + assert_eq!( + detail, + "does not open: its token is for key ffff, which is none of your account keys" + ); + } + + #[test] + fn a_share_that_opens_and_one_that_cannot_be_read() { + let report = report("u2"); + let open = Ok((AddressId::new("address-1"), vec![Some("a1".to_string())])); + let last = findings(&report, &open).pop().unwrap(); + assert_eq!(last.level, Level::Ok); + assert_eq!( + last.detail, + "encrypted to user@proton.me key AddrKeyN…, which opens" + ); + + let last = findings(&report, &Err("offline".to_string())) + .pop() + .unwrap(); + assert_eq!(last.level, Level::Warn); + assert_eq!(last.detail, "could not be read: offline"); + } +} diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 73a84a7..7857db4 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod db; pub mod error; pub mod exiftime; pub mod kernelkey; +pub mod keycheck; pub mod localindex; pub mod menu; pub mod mounts; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ddcb5c..7666c06 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,12 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +### Added +- **`pdfs diagnose` names the key that keeps My Files closed.** When you are signed in and the + service does not answer, a new **Keys** section asks Proton which account and address keys + open, which account key each address key depends on, and which address key the My Files share + is encrypted to. It shows key IDs only, no secrets (#40). + ## [3.5.0] — 2026-10-16 Schema: **44**. Queued changes get an index by kind and name, and each synced file remembers the diff --git a/docs/CLI.md b/docs/CLI.md index a0e2045..2f49571 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -171,7 +171,7 @@ commands are for scripts and for cases where the mount is not available. | Command | Description | |---|---| -| `pdfs diagnose` | Check the installation and print a report. Works without the service. Exits non-zero if a check fails. | +| `pdfs diagnose` | Check the installation and print a report. Works without the service. When the service does not answer, also asks Proton which of your keys open. Exits non-zero if a check fails. | | `pdfs diagnostics` | What a running service is doing now: worker threads, queues, requests in flight, memory. For a service that has stopped answering. | | `pdfs cache inspect [--deep]` | Database size, reclaimable space, row counts, cache use against the limit. `--deep` also runs SQLite's integrity check (slow). | | `pdfs cache vacuum` | Compact the database. Needs free space for a second copy of it while it runs. | diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 61a1244..d91bf05 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -102,7 +102,7 @@ for l in $(cat po/LINGUAS); do msgfmt --check --statistics -o /dev/null po/$l.po | Tool | Use | |---|---| | `RUST_LOG` | Log filter for every binary, for example `RUST_LOG=pdfs_fuse::sync=trace,info` | -| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon | +| `pdfs diagnose` | Static checks: directories, FUSE, keyring, socket, database integrity. Works without the daemon. When the daemon does not answer, also traces which account and address keys open (`pdfs_core::keycheck`) | | `pdfs diagnostics` | Live state of a running daemon: each worker's current job and its age, queue depth, control requests in flight, memory | | `pdfs cache inspect --deep` | Database size, row counts and SQLite `integrity_check` | | `pdfs --json …` | Raw protocol payloads for query commands | diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index b0211aa..53b9b92 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -20,6 +20,11 @@ information listed under [Reporting a problem](#reporting-a-problem). Checks the installation and prints a report. It does not need the service, because the state worth diagnosing is often the one where the service will not start. +When you are signed in but the service does not answer, it also asks Proton which of your keys +open, under **Keys**: each account key, each address key, and the address key your My Files +share is encrypted to. A key that does not open is named with the key it depends on. The section +shows key IDs, which are not secret, so you can post it in an issue. + ```console $ pdfs diagnose Paths @@ -125,7 +130,8 @@ systemctl --user restart proton-drive.service keys cannot open your Drive volume, which happens after a password reset. Proton locks the volume that was encrypted to the old keys. From 3.4.1 the client starts a new, empty My Files volume in that case. Your old files stay in the locked volume: open [drive.proton.me](https://drive.proton.me) -and restore them there with your previous password. +and restore them there with your previous password. If the volume is not locked and the error +stays, run `pdfs diagnose` and post its **Keys** section in an issue. **The folder is empty or missing** — check that the service runs and is signed in (`pdfs status`). The service waits for a stored session before it mounts.