Skip to content

Commit ef69f9b

Browse files
author
Cooper Maruyama
committed
feat: doctor command, rich decrypt errors, recipient warnings, historical keychain keys
- cli/doctor.rs: new 'himitsu doctor' command auditing identities, secrets (stale/orphan), and key provider hygiene - cli/get.rs: decrypt failure now shows file recipients mapped to names, loaded identity pubkeys, and a rekey hint - cli/recipient.rs: 'recipient add' warns about secrets missing the new key; 'recipient rm' warns about git-history access - crypto/keystore.rs: 'load_identities' accepts Optional recipients_dir; when set, probes all *.pub files in the store so historical/rotated keychain entries can still decrypt old secrets
1 parent b9de3c5 commit ef69f9b

5 files changed

Lines changed: 575 additions & 10 deletions

File tree

rust/src/cli/doctor.rs

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use std::collections::HashMap;
2+
use std::io::IsTerminal;
3+
use std::path::Path;
4+
5+
use clap::Args;
6+
use owo_colors::OwoColorize;
7+
8+
use super::Context;
9+
use crate::config::KeyProvider;
10+
use crate::crypto::keystore;
11+
use crate::error::Result;
12+
use crate::remote::store as rstore;
13+
14+
#[derive(Debug, Args)]
15+
pub struct DoctorArgs {}
16+
17+
pub fn run(_args: DoctorArgs, ctx: &Context) -> Result<()> {
18+
let use_color = std::io::stdout().is_terminal();
19+
let mut warnings = 0usize;
20+
21+
println!("{}", "─".repeat(50));
22+
23+
// ── IDENTITIES ──────────────────────────────────────────────────────────
24+
println!("IDENTITIES");
25+
let rdir = rstore::recipients_dir_with_override(&ctx.store, ctx.recipients_path.as_deref());
26+
let recipient_map = collect_recipient_map(&rdir); // name → pubkey
27+
28+
let loaded_pubkeys: Vec<String> = ctx
29+
.load_identities()
30+
.unwrap_or_default()
31+
.iter()
32+
.map(|id| id.to_public().to_string())
33+
.collect();
34+
35+
if recipient_map.is_empty() {
36+
println!(" (no recipients found)");
37+
} else {
38+
for (name, pubkey) in &recipient_map {
39+
let short = short_key(pubkey);
40+
let can_decrypt = loaded_pubkeys.iter().any(|lp| lp == pubkey.trim());
41+
let source = if can_decrypt {
42+
match &ctx.key_provider {
43+
KeyProvider::MacosKeychain => "[keychain]",
44+
KeyProvider::Disk => "[disk]",
45+
}
46+
} else {
47+
""
48+
};
49+
if can_decrypt {
50+
let line = format!(" \u{2713} {name:<20} {short} {source}");
51+
println!("{}", if use_color { line.green().to_string() } else { line });
52+
} else {
53+
warnings += 1;
54+
let line = format!(" \u{2717} {name:<20} {short} [no key found]");
55+
println!("{}", if use_color { line.red().to_string() } else { line });
56+
}
57+
}
58+
}
59+
60+
// ── SECRETS ─────────────────────────────────────────────────────────────
61+
println!("\nSECRETS");
62+
let own_pubkey = std::fs::read_to_string(keystore::pubkey_path(&ctx.data_dir))
63+
.ok()
64+
.map(|s| s.trim().to_string());
65+
66+
// Build pubkey → name reverse map.
67+
let pubkey_to_name: HashMap<String, String> = recipient_map
68+
.iter()
69+
.map(|(n, k)| (k.trim().to_string(), n.clone()))
70+
.collect();
71+
72+
let secrets_dir = rstore::secrets_dir(&ctx.store);
73+
if !secrets_dir.exists() {
74+
println!(" (no secrets)");
75+
} else {
76+
let mut secret_paths = vec![];
77+
collect_secret_paths(&secrets_dir, &secrets_dir, &mut secret_paths);
78+
secret_paths.sort();
79+
80+
if secret_paths.is_empty() {
81+
println!(" (no secrets)");
82+
}
83+
84+
for (rel, abs) in &secret_paths {
85+
let Ok(contents) = std::fs::read_to_string(abs) else {
86+
continue;
87+
};
88+
let Ok(val) = serde_yaml::from_str::<serde_yaml::Value>(&contents) else {
89+
continue;
90+
};
91+
92+
let file_pubkeys: Vec<String> = val["himitsu"]["age"]
93+
.as_sequence()
94+
.map(|seq| {
95+
seq.iter()
96+
.filter_map(|e| e["recipient"].as_str().map(|s| s.to_string()))
97+
.collect()
98+
})
99+
.unwrap_or_default();
100+
101+
// Stale: own key not in file's recipient list.
102+
let self_included = own_pubkey
103+
.as_ref()
104+
.map(|own| file_pubkeys.iter().any(|fp| fp.trim() == own.trim()))
105+
.unwrap_or(true);
106+
107+
// Orphan: file has a pubkey that no longer has a .pub in recipients/.
108+
let orphan_keys: Vec<String> = file_pubkeys
109+
.iter()
110+
.filter(|fp| !pubkey_to_name.contains_key(fp.trim()))
111+
.map(|fp| short_key(fp))
112+
.collect();
113+
114+
let named: Vec<String> = file_pubkeys
115+
.iter()
116+
.map(|pk| {
117+
pubkey_to_name
118+
.get(pk.trim())
119+
.cloned()
120+
.unwrap_or_else(|| short_key(pk))
121+
})
122+
.collect();
123+
124+
if !self_included || !orphan_keys.is_empty() {
125+
warnings += 1;
126+
if !self_included {
127+
let line = format!(
128+
" \u{26a0} {rel:<30} stale — your identity is not a recipient (recipients: {})",
129+
named.join(", ")
130+
);
131+
println!("{}", if use_color { line.yellow().to_string() } else { line });
132+
}
133+
if !orphan_keys.is_empty() {
134+
let line = format!(
135+
" \u{26a0} {rel:<30} orphan recipients: {}",
136+
orphan_keys.join(", ")
137+
);
138+
println!("{}", if use_color { line.yellow().to_string() } else { line });
139+
}
140+
} else {
141+
let line = format!(
142+
" \u{2713} {rel:<30} encrypted for: {}",
143+
named.join(", ")
144+
);
145+
println!("{}", if use_color { line.green().to_string() } else { line });
146+
}
147+
}
148+
}
149+
150+
// ── KEY PROVIDER ─────────────────────────────────────────────────────────
151+
println!("\nKEY PROVIDER");
152+
let provider_name = match &ctx.key_provider {
153+
KeyProvider::MacosKeychain => "macos-keychain",
154+
KeyProvider::Disk => "disk",
155+
};
156+
println!(" \u{2713} provider: {provider_name}");
157+
158+
let disk_key_path = keystore::disk_secret_path(&ctx.data_dir);
159+
if matches!(ctx.key_provider, KeyProvider::MacosKeychain) && disk_key_path.exists() {
160+
warnings += 1;
161+
println!(
162+
" \u{26a0} disk key file still present at {} — consider removing after confirming keychain entry works",
163+
disk_key_path.display()
164+
);
165+
}
166+
167+
println!("{}", "─".repeat(50));
168+
if warnings == 0 {
169+
println!("No issues found.");
170+
} else {
171+
println!("{warnings} warning(s)");
172+
}
173+
Ok(())
174+
}
175+
176+
fn collect_recipient_map(rdir: &Path) -> HashMap<String, String> {
177+
let mut map = HashMap::new();
178+
if rdir.exists() {
179+
walk_recipients(rdir, rdir, &mut map);
180+
}
181+
map
182+
}
183+
184+
fn walk_recipients(base: &Path, dir: &Path, map: &mut HashMap<String, String>) {
185+
let Ok(rd) = std::fs::read_dir(dir) else {
186+
return;
187+
};
188+
for entry in rd.flatten() {
189+
let path = entry.path();
190+
if path.is_dir() {
191+
walk_recipients(base, &path, map);
192+
continue;
193+
}
194+
if path.extension().and_then(|e| e.to_str()) != Some("pub") {
195+
continue;
196+
}
197+
let Ok(key) = std::fs::read_to_string(&path) else {
198+
continue;
199+
};
200+
let rel = path
201+
.strip_prefix(base)
202+
.unwrap_or(&path)
203+
.with_extension("")
204+
.to_string_lossy()
205+
.to_string();
206+
map.insert(rel, key.trim().to_string());
207+
}
208+
}
209+
210+
fn collect_secret_paths(
211+
base: &Path,
212+
dir: &Path,
213+
out: &mut Vec<(String, std::path::PathBuf)>,
214+
) {
215+
let Ok(rd) = std::fs::read_dir(dir) else {
216+
return;
217+
};
218+
for entry in rd.flatten() {
219+
let path = entry.path();
220+
if path.is_dir() {
221+
collect_secret_paths(base, &path, out);
222+
continue;
223+
}
224+
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
225+
continue;
226+
}
227+
let rel = path
228+
.strip_prefix(base)
229+
.unwrap_or(&path)
230+
.with_extension("")
231+
.to_string_lossy()
232+
.to_string();
233+
out.push((rel, path));
234+
}
235+
}
236+
237+
fn short_key(pk: &str) -> String {
238+
let s = pk.trim();
239+
if s.len() <= 16 {
240+
s.to_string()
241+
} else {
242+
format!("{}\u{2026}", &s[..12])
243+
}
244+
}
245+
246+
#[cfg(test)]
247+
mod tests {
248+
use super::*;
249+
use tempfile::TempDir;
250+
251+
fn make_ctx(tmp: &TempDir) -> Context {
252+
let data_dir = tmp.path().join("data");
253+
let state_dir = tmp.path().join("state");
254+
let store = tmp.path().join("store");
255+
std::fs::create_dir_all(&data_dir).unwrap();
256+
std::fs::create_dir_all(&state_dir).unwrap();
257+
std::fs::create_dir_all(store.join(".himitsu/secrets")).unwrap();
258+
std::fs::create_dir_all(store.join(".himitsu/recipients")).unwrap();
259+
Context {
260+
data_dir,
261+
state_dir,
262+
store,
263+
recipients_path: None,
264+
key_provider: KeyProvider::Disk,
265+
}
266+
}
267+
268+
#[test]
269+
fn doctor_no_issues_empty_store() {
270+
let tmp = TempDir::new().unwrap();
271+
let ctx = make_ctx(&tmp);
272+
// Should return Ok even with no keys or secrets.
273+
let result = run(DoctorArgs {}, &ctx);
274+
assert!(result.is_ok(), "doctor should succeed on empty store");
275+
}
276+
277+
#[test]
278+
fn doctor_stale_secret_runs_ok() {
279+
let tmp = TempDir::new().unwrap();
280+
let ctx = make_ctx(&tmp);
281+
282+
// Write own pubkey (different from the one in the secret).
283+
let own_pub = "age1ownkey000000000000000000000000000000000000000000000000000";
284+
std::fs::write(keystore::pubkey_path(&ctx.data_dir), format!("{own_pub}\n")).unwrap();
285+
286+
// Write a recipient .pub for "alice" with a different key.
287+
let alice_pub = "age1alice0000000000000000000000000000000000000000000000000000";
288+
std::fs::write(
289+
ctx.store.join(".himitsu/recipients/alice.pub"),
290+
format!("{alice_pub}\n"),
291+
)
292+
.unwrap();
293+
294+
// Write a secret YAML envelope encrypted only for alice (not own key).
295+
let secret_yaml = format!(
296+
"value: 'ENC[age,AAAA]'\nhimitsu:\n created_at: '2026-01-01'\n lastmodified: '2026-01-01T00:00:00Z'\n age:\n - recipient: {alice_pub}\n"
297+
);
298+
std::fs::create_dir_all(ctx.store.join(".himitsu/secrets/prod")).unwrap();
299+
std::fs::write(
300+
ctx.store.join(".himitsu/secrets/prod/API_KEY.yaml"),
301+
secret_yaml,
302+
)
303+
.unwrap();
304+
305+
// Doctor should run without panicking and return Ok.
306+
// (The stale warning is printed but not asserted on here.)
307+
let result = run(DoctorArgs {}, &ctx);
308+
assert!(result.is_ok(), "doctor should succeed even with stale secret");
309+
}
310+
}

0 commit comments

Comments
 (0)