diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 1aa080b..86a0069 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -3663,10 +3663,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", @@ -3705,6 +3708,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, @@ -3716,6 +3720,7 @@ fn cmd_diagnose() -> Result<()> { version, .. }) => { + daemon_responding = true; report.finding(DiagnoseLevel::Ok, " daemon responding", ""); if daemon_outdated(&version) { report.finding( @@ -3857,6 +3862,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."); @@ -3876,6 +3889,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 f7b9131..ed19c4f 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 27d6800..d55e9e4 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -12,6 +12,10 @@ 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). - **A COPR repository for Fedora**, so `dnf upgrade` brings each new release: `sudo dnf copr enable narl-rs/proton-drive-linux`. Every release also ships the vendored crates as `proton-drive-linux-vendor-.tar.xz`, which the COPR build uses offline (#42). diff --git a/docs/CLI.md b/docs/CLI.md index ac41777..003b5da 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -179,7 +179,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 7a62fbc..3b1804d 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 21b6eb7..93f9270 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 @@ -146,7 +151,8 @@ session before it shows your files. Your account keys cannot open your Drive, which happens after a password reset: Proton locks the files that were encrypted with the old keys. Since 3.4.1 the client then starts a new, empty My files. Your old files are still there, locked: open [drive.proton.me](https://drive.proton.me) and -restore them with your previous password. +restore them with your previous password. If your files are not locked and the error stays, run +`pdfs diagnose` and post its **Keys** section in an issue. ### Everything in the folder hangs