1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
//! The platform secret store a vendor CLI keeps its live subscription in.
//!
//! [`crate::credential_store`] is built on the premise that the credential file
//! is the shared mutable state every holder reads and writes. On macOS that
//! premise does not hold: Claude Code keeps its live credential in the login
//! Keychain and leaves `~/.claude/.credentials.json` behind as a snapshot that
//! nothing rotates. The router read only the file, so it saw a credential that
//! had been dead for hours while the vendor client — on the same account —
//! kept working, and every rung of the recovery ladder from issue #239 was
//! reading a store the vendor client was not writing to (issue #249).
//!
//! The entry holds the same JSON shape the file does, so only *retrieval*
//! differs; parsing is shared with [`crate::subscription`]. Linux and Windows
//! keep their own conventions and have no lookup here yet, which is why this is
//! a per-platform probe with the file as the fallback rather than a macOS
//! special case: [`lookup`] simply reports "no such store" everywhere else, and
//! the file path is unchanged.
//!
//! Secrets are never logged. Only the *name* of the store is, which is the
//! thing an operator staring at a valid-looking file could not previously see.
use crate::subscription::SubscriptionProvider;
/// Which store a credential was read from, for operator-facing messages.
///
/// `doctor` prints this: a file that looks valid while the router reports
/// `rejected` is indistinguishable from a bug until the output names the place
/// the router actually read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Origin {
/// A credential file under the provider's home directory.
File,
/// A credential file copied from a vendor-owned rotating refresh chain.
ExternalFile,
/// A Router reference to the vendor client's writable credential file.
/// Both processes therefore advance the same rotating refresh chain.
AdoptedFile,
/// The platform secret store the vendor CLI uses.
Keychain,
}
impl Origin {
/// A short label naming the store.
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::File => "file",
Self::ExternalFile => "external file",
Self::AdoptedFile => "adopted file",
Self::Keychain => "keychain",
}
}
}
/// The keychain service name a provider's vendor CLI stores its credential
/// under, when this platform has one at all.
///
/// Only Claude Code is known to do this today. Returning `None` for the rest
/// keeps them on the file path they already use rather than guessing at a
/// service name and reporting a confusing miss.
#[must_use]
pub const fn service_name(provider: SubscriptionProvider) -> Option<&'static str> {
match provider {
SubscriptionProvider::Claude if cfg!(target_os = "macos") => {
Some("Claude Code-credentials")
}
_ => None,
}
}
/// The Keychain service Claude Code keeps the login of one config directory in.
///
/// Claude Code scopes its entry by `CLAUDE_CONFIG_DIR`: with the variable
/// unset it uses `Claude Code-credentials`; with it set, the name gains `-` and
/// the first eight hex digits of the SHA-256 of the variable's value. One fixed
/// name therefore answers for the wrong profile whenever a separate config
/// directory is in use (issue #653). `config_dir` is the raw variable value,
/// not a canonical path: the vendor hashes what it was given.
#[must_use]
pub fn claude_service_for(config_dir: Option<&std::ffi::OsStr>) -> String {
use sha2::Digest as _;
const BASE: &str = "Claude Code-credentials";
let Some(dir) = config_dir.filter(|dir| !dir.is_empty()) else {
return BASE.to_string();
};
let digest = sha2::Sha256::digest(dir.to_string_lossy().as_bytes());
format!("{BASE}-{}", hex::encode(&digest[..4]))
}
/// Whether the platform store holds an entry under `service`.
///
/// Only existence is asked: the secret is never requested, so no access-control
/// prompt can appear and no credential byte is read (issue #653). Returns
/// `false` off macOS and whenever the store cannot be asked.
#[must_use]
#[allow(clippy::missing_const_for_fn)] // const only off macOS
pub fn has_entry(service: &str) -> bool {
generic_password_exists(service)
}
#[cfg(target_os = "macos")]
fn generic_password_exists(service: &str) -> bool {
// No `-w`: attributes are readable without the item's ACL; the password
// is not.
std::process::Command::new("/usr/bin/security")
.args(["find-generic-password", "-s", service])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
#[cfg(not(target_os = "macos"))]
const fn generic_password_exists(_service: &str) -> bool {
false
}
/// Read the raw credential JSON a vendor CLI keeps in the platform store.
///
/// Returns `None` when this platform or provider has no such store, when the
/// entry is absent, or when the lookup fails for any reason — an unavailable
/// keychain must degrade to the file rather than fail the command.
#[must_use]
pub fn lookup(provider: SubscriptionProvider) -> Option<String> {
let service = service_name(provider)?;
read_generic_password(service)
}
/// macOS: ask the Security framework, via the `security` tool, for the entry.
///
/// The CLI rather than a linked framework binding keeps this dependency-free
/// and, more importantly, keeps the router subject to the same keychain access
/// control the vendor client is: if the user has not granted access, this fails
/// and the file remains the source, which is exactly the pre-existing behaviour.
#[cfg(target_os = "macos")]
fn read_generic_password(service: &str) -> Option<String> {
let output = std::process::Command::new("/usr/bin/security")
.args(["find-generic-password", "-s", service, "-w"])
.output()
.ok()?;
if !output.status.success() {
// An absent entry is the ordinary case on a machine that logged in with
// an older client; it is not worth an operator-facing warning.
tracing::debug!("no keychain entry for {service}");
return None;
}
let secret = String::from_utf8(output.stdout).ok()?;
let secret = secret.trim().to_string();
(!secret.is_empty()).then_some(secret)
}
/// Every other platform: no known vendor keychain, so the file stands alone.
#[cfg(not(target_os = "macos"))]
const fn read_generic_password(_service: &str) -> Option<String> {
None
}
#[cfg(test)]
#[path = "platform_keychain_tests.rs"]
mod tests;