runner_manager_platform/secrets.rs
1// owner: d2-machine-secret-store
2
3//! The one persisted GitHub credential, in a place a boot-time service can
4//! read.
5//!
6//! `07-security.md` counts the persisted credential surface and gets to one:
7//! *"The product now holds exactly one persisted GitHub credential and one
8//! short-lived sensitive value."* This module is where that one credential
9//! lives. The short-lived one — the encoded JIT configuration — belongs to
10//! [`crate::process::RestrictiveHandoff`] and is not stored here or anywhere
11//! else.
12//!
13//! # The constraint that decides the whole design
14//!
15//! D13 requires the service to start at **machine boot**.
16//! `05-infrastructure.md`: *"A boot-time service runs outside any user's login
17//! session and cannot read a per-user keychain on any supported OS: macOS
18//! LaunchAgents start only at login, and Windows Credential Manager vaults are
19//! per-user."* So the default store is machine-scoped, and every per-user
20//! secret facility on all three operating systems is unavailable to it by
21//! construction rather than by preference.
22//!
23//! | OS | [`SecretScope::Machine`] | [`SecretScope::User`] |
24//! |---|---|---|
25//! | Windows | DPAPI **machine** scope, in a file under `%ProgramData%` with its own protected DACL | DPAPI **user** scope, in a file under `%LOCALAPPDATA%` with its own protected DACL |
26//! | macOS | System Keychain (`/Library/Keychains/System.keychain`) | the account's login keychain |
27//! | Linux | `0600` file under `/var/lib/runner-manager`, plus the systemd credential the service is started with | `0600` file under `$XDG_DATA_HOME/runner-manager` |
28//!
29//! `service install --start-at login` is the escape hatch
30//! `07-security.md` promises operators who reject machine-scoped storage:
31//! *"Operators who reject this can use `service install --start-at login` and
32//! keep a user-scoped store, accepting no unattended restart."* Both columns
33//! implement [`SecretStore`], the active one is chosen by
34//! [`SecretScope::for_start_mode`], and [`ActiveStore`] is what `host show`
35//! and `service status` print so the choice is inspectable rather than
36//! implied.
37//!
38//! # The accepted trade-off, implemented honestly
39//!
40//! A local administrator or `root` on this machine can read a machine-scoped
41//! secret. `07-security.md` records that as an accepted consequence, on the
42//! grounds that such an account *"can already read the runner's own
43//! credentials and job workspaces"*. Nothing here tries to defeat it, and
44//! nothing here pretends to. What is defended is the case that is actually in
45//! the threat model: **an ordinary local user who is not an administrator must
46//! not be able to read the stored value**, and [`SecretStore::protection`]
47//! reports whether that holds, per OS, through the one cross-platform name
48//! [`crate::process::permissions_summary`] already defines.
49//!
50//! Delete is delete, not erasure. [`crate::process::RestrictiveHandoff`] sets
51//! out why no userspace program can promise that the bytes are unrecoverable —
52//! a journal, a snapshot, or an SSD's wear levelling each keep copies an
53//! overwrite never reaches — and the same disclaimer applies here. The Linux
54//! backend overwrites before unlinking because the value is at rest in
55//! plaintext there and the overwrite is free; that is a best effort and is not
56//! a claim.
57//!
58//! # What never holds this value
59//!
60//! SQLite, TOML configuration, logs, diagnostics, UI state, and command-line
61//! arguments. The type system carries as much of that as it can: the value
62//! crosses this module's surface only as a [`SecretString`], which has no
63//! `Display` and a redacting `Debug`, and no error in [`SecretStoreError`]
64//! carries the value or any part of it. `client_id` is public by design
65//! (`07-security.md`: *"Public by design; may appear in logs and
66//! documentation"*) and is not a secret this store handles.
67//!
68//! # Blocking, not async
69//!
70//! DPAPI, Security.framework and `open(2)` are blocking calls that take
71//! microseconds. This runs twice in the life of a process — once at `auth
72//! login`, once at startup — so an async surface would buy nothing and would
73//! oblige every caller to be in a runtime to read a file.
74//!
75//! # Where a test may point it, and where it may not
76//!
77//! [`PlatformSecretStore::standard`] resolves the production location in the
78//! table above. [`PlatformSecretStore::rooted_at`] puts the same backend under
79//! a directory the caller names, exactly as
80//! [`crate::paths::AppPaths::rooted_at`] does for the four application-data
81//! directories, and for the same two reasons: a test needs a disposable store,
82//! and a service installed against an explicitly configured root has to
83//! reproduce one.
84//!
85//! **No test in this crate writes to a standard location**, and that is a
86//! deliberate constraint rather than an oversight. Two of the six standard
87//! locations need `root` to create (`/var/lib`, the System Keychain), and the
88//! other four are the operator's real store — a suite that wrote there would
89//! destroy a developer's `auth login` every time it ran. So the standard
90//! locations are asserted by *resolution*, and every round trip runs against a
91//! rooted store. What that does and does not cover is written out at
92//! [`PlatformSecretStore::rooted_at`].
93
94use std::fmt;
95use std::path::{Path, PathBuf};
96
97use runner_manager_domain::model::StartMode;
98use secrecy::{ExposeSecret, SecretString};
99
100/// The product identity, resolved from the one place that defines it.
101///
102/// `crate::paths` owns these three segments and builds the four
103/// application-data directories out of them. This module builds *different*
104/// locations — `%ProgramData%\<org>\<app>`, `$XDG_DATA_HOME/<app>`, the macOS
105/// keychain service — but out of the same identity, and it must stay the same
106/// identity. Spelled a second time here, a drift would move the secret store
107/// out from under an upgraded binary and the token would read as simply
108/// absent, which is the one failure mode this store must never produce
109/// silently.
110use crate::paths::{APPLICATION, ORGANIZATION, QUALIFIER};
111
112/// Reverse-domain service name for the macOS keychain item.
113///
114/// Composed rather than written out, for the reason above. It is a `LazyLock`
115/// because the three segments are `const`s and neither `concat!` nor a `const
116/// fn` can join them — `concat!` takes literals, not constant items. The
117/// composition is asserted against the documented literal by
118/// `the_standard_locations_are_the_documented_ones`, which keeps its own
119/// hard-coded strings precisely so that it is an *independent* oracle rather
120/// than a second copy of this expression.
121#[cfg(target_os = "macos")]
122static KEYCHAIN_SERVICE: std::sync::LazyLock<String> =
123 std::sync::LazyLock::new(|| format!("{QUALIFIER}.{ORGANIZATION}.{APPLICATION}"));
124
125/// The keychain account, and the stem of the file name on the two platforms
126/// that use a file. One value, one name, everywhere.
127const ITEM: &str = "user-access-token";
128
129/// Which value a [`PlatformSecretStore`] holds.
130///
131/// Every item lives in the same protected place, with the same per-OS
132/// protection; only its name differs. The GitHub credential is the default.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum SecretItem {
135 /// The GitHub user access token (`auth login`).
136 GithubCredential,
137 /// The Ed25519 private key this host proves itself to a coordinator with
138 /// (`coordinator join`).
139 CoordinatorKey,
140 /// A person's session at a coordinator, for `--host remote:NAME`
141 /// (`coordinator login`). Kept in the user-scoped store of whoever signed
142 /// in, never the service's.
143 CoordinatorCliSession,
144}
145
146impl SecretItem {
147 /// The item's name: the keychain account, or the file stem.
148 #[must_use]
149 pub const fn as_str(self) -> &'static str {
150 match self {
151 Self::GithubCredential => ITEM,
152 Self::CoordinatorKey => "coordinator-host-key",
153 Self::CoordinatorCliSession => "coordinator-cli-session",
154 }
155 }
156}
157
158/// The directory a file-backed store keeps its value in, under whichever root
159/// the scope resolved to. Never `config/` — `05-infrastructure.md` reserves
160/// that for *"non-secret TOML and SQLite"*.
161const DIRECTORY: &str = "secrets";
162
163// ---------------------------------------------------------------------------
164// Scope
165// ---------------------------------------------------------------------------
166
167/// Which of the two stores a value lives in.
168///
169/// Not a preference and not a tuning knob: it is a direct function of the
170/// service's start mode, because a service that starts at boot has no login
171/// session to read a user-scoped store from. [`SecretScope::for_start_mode`]
172/// is that function, and it is total in both directions —
173/// [`SecretScope::start_mode`] is its inverse, which is what lets
174/// [`ActiveStore`] state whether the store in use agrees with the start mode
175/// actually configured.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
177pub enum SecretScope {
178 /// Readable by a service running at machine boot, outside any login
179 /// session. The default, and what `--start-at boot` requires.
180 Machine,
181 /// Readable only inside the operator's login session. What
182 /// `--start-at login` gets, at the cost of no unattended restart.
183 User,
184}
185
186impl SecretScope {
187 /// The store a given start mode obliges.
188 #[must_use]
189 pub const fn for_start_mode(mode: StartMode) -> Self {
190 match mode {
191 StartMode::Boot => Self::Machine,
192 StartMode::Login => Self::User,
193 }
194 }
195
196 /// The start mode this store is the one correct choice for.
197 #[must_use]
198 pub const fn start_mode(self) -> StartMode {
199 match self {
200 Self::Machine => StartMode::Boot,
201 Self::User => StartMode::Login,
202 }
203 }
204
205 /// The name `host show`, `service status`, and the `scope` log field use.
206 #[must_use]
207 pub const fn as_str(self) -> &'static str {
208 match self {
209 Self::Machine => "machine",
210 Self::User => "user",
211 }
212 }
213}
214
215impl fmt::Display for SecretScope {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.write_str(self.as_str())
218 }
219}
220
221// ---------------------------------------------------------------------------
222// Errors
223// ---------------------------------------------------------------------------
224
225/// Something went wrong reaching the secret store.
226///
227/// **No variant carries the stored value, and none ever may.** Every one of
228/// them is formatted into an operator-facing message and into a `tracing`
229/// event, and `07-security.md`'s release gate is that the user access token is
230/// *"absent from logs, databases, snapshots, crash reports, and CLI output"*.
231/// A variant that carried the value would put it in all four at once.
232///
233/// Note what is *not* here: an "absent" variant. A load that finds nothing is
234/// [`Ok(None)`], because absence is the ordinary state of this store before
235/// `auth login` and after `auth logout`, and a caller that has to distinguish
236/// "not logged in" from "the keychain is unreachable" cannot be asked to do it
237/// by matching on an error kind.
238#[derive(Debug, thiserror::Error)]
239pub enum SecretStoreError {
240 /// The store's location could not be worked out at all.
241 #[error("cannot work out where the {scope}-scoped secret store lives: {reason}")]
242 Resolve {
243 /// Which store was being resolved.
244 scope: SecretScope,
245 /// What was missing, in terms an operator can act on.
246 reason: String,
247 },
248
249 /// The value could not be written.
250 #[error(
251 "cannot write the user access token to the {scope}-scoped store at {location}: {source}"
252 )]
253 Store {
254 /// Which store.
255 scope: SecretScope,
256 /// Where it lives, as [`SecretStore::location`] reports it.
257 location: String,
258 /// The underlying platform error.
259 #[source]
260 source: std::io::Error,
261 },
262
263 /// The value could not be read back.
264 #[error(
265 "cannot read the user access token from the {scope}-scoped store at {location}: {source}"
266 )]
267 Load {
268 /// Which store.
269 scope: SecretScope,
270 /// Where it lives.
271 location: String,
272 /// The underlying platform error.
273 #[source]
274 source: std::io::Error,
275 },
276
277 /// The value could not be removed.
278 ///
279 /// Worth an error rather than a shrug, for the reason
280 /// `05-infrastructure.md` gives the credential-disclosure response: step 2
281 /// is *"Run `auth logout` on every host to purge the machine-scoped secret
282 /// store"*, and an operator following that procedure has to be told when a
283 /// host did not comply.
284 #[error(
285 "cannot delete the user access token from the {scope}-scoped store at {location}: {source}"
286 )]
287 Delete {
288 /// Which store.
289 scope: SecretScope,
290 /// Where it lives.
291 location: String,
292 /// The underlying platform error.
293 #[source]
294 source: std::io::Error,
295 },
296
297 /// Something is stored, and it is not a value this store wrote.
298 ///
299 /// Deliberately not folded into [`SecretStoreError::Load`] and deliberately
300 /// not reported as absence. A caller that saw absence would silently start
301 /// a device-flow login and overwrite whatever is there; a caller that saw a
302 /// transient read failure would retry forever. This is neither: it is an
303 /// operator-actionable condition whose remedy is `auth logout` followed by
304 /// `auth login`.
305 #[error(
306 "the {scope}-scoped store at {location} does not hold a user access token this product \
307 wrote: {detail}. Run `auth logout` to purge it and `auth login` to obtain a fresh token."
308 )]
309 Corrupt {
310 /// Which store.
311 scope: SecretScope,
312 /// Where it lives.
313 location: String,
314 /// What is wrong with what is there, in terms that name no part of it.
315 /// A byte count is not a disclosure; the bytes would be, so they are
316 /// never carried.
317 detail: String,
318 },
319
320 /// The access control protecting the value could not be read back.
321 #[error("cannot inspect what protects the {scope}-scoped store at {}: {source}", guard.display())]
322 Inspect {
323 /// Which store.
324 scope: SecretScope,
325 /// The filesystem object whose access control was being read.
326 guard: PathBuf,
327 /// The underlying error.
328 #[source]
329 source: crate::process::HandoffError,
330 },
331}
332
333// ---------------------------------------------------------------------------
334// What the operations report
335// ---------------------------------------------------------------------------
336
337/// What [`SecretStore::delete`] found.
338///
339/// Both variants are success. `auth logout` on a host that was never logged in
340/// is not a failure, and the credential-disclosure procedure in
341/// `05-infrastructure.md` is run across *every* host precisely because the
342/// operator does not know which ones hold a value.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub enum Removal {
345 /// A value was there and is not any more.
346 Removed,
347 /// There was nothing to remove.
348 AlreadyAbsent,
349}
350
351impl Removal {
352 /// Whether this call is the one that removed something.
353 #[must_use]
354 pub const fn removed_something(self) -> bool {
355 matches!(self, Self::Removed)
356 }
357}
358
359/// What actually stands between the stored value and an ordinary local user.
360///
361/// The three operating systems protect it with three different mechanisms, so
362/// the useful cross-platform question is not "what is the mode" but "**which
363/// object's access control decides who can read this, and does it exclude an
364/// unprivileged local user**". [`Protection::guard`] answers the first half and
365/// [`Protection::readable_by_other_local_users`] the second.
366///
367/// | store | guard |
368/// |---|---|
369/// | Windows, either scope | the file holding the DPAPI blob, whose DACL is protected and names no broad trustee |
370/// | Linux, either scope | the `0600` file |
371/// | macOS, login or rooted keychain | the keychain database file |
372/// | macOS, System Keychain | `/var/db/SystemKey`, the root-only master key that unlocks it |
373///
374/// That last row is the one worth reading twice.
375/// `/Library/Keychains/System.keychain` is itself world-readable, and saying so
376/// and stopping would be both true and useless: its contents are encrypted, and
377/// what decides who can decrypt them is the mode of the master key beside it.
378/// Reporting the keychain database there would answer a question nobody asked
379/// and would answer it wrongly.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct Protection {
382 guard: PathBuf,
383 description: String,
384 readable_by_other_local_users: bool,
385}
386
387impl Protection {
388 /// The filesystem object whose access control was inspected.
389 #[must_use]
390 pub fn guard(&self) -> &Path {
391 &self.guard
392 }
393
394 /// The platform's own description of it — a Unix mode, or a DACL in SDDL
395 /// form. For diagnostics and for test failure messages.
396 #[must_use]
397 pub fn description(&self) -> &str {
398 &self.description
399 }
400
401 /// Whether an ordinary local user other than the owner could read the
402 /// stored value.
403 ///
404 /// A local administrator or `root` is deliberately outside this question;
405 /// see this module's documentation and `07-security.md`.
406 #[must_use]
407 pub const fn readable_by_other_local_users(&self) -> bool {
408 self.readable_by_other_local_users
409 }
410}
411
412impl fmt::Display for Protection {
413 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
414 write!(
415 f,
416 "{} ({}){}",
417 self.guard.display(),
418 self.description,
419 if self.readable_by_other_local_users {
420 " -- READABLE BY OTHER LOCAL USERS"
421 } else {
422 ""
423 }
424 )
425 }
426}
427
428/// Which store is in use, and whether that is the one the configured start
429/// mode obliges.
430///
431/// `05-infrastructure.md` requires the start mode to be visible in `host show`
432/// and in TUI host settings, and requires `service status` to say when
433/// `--start-at login` means the agent does not run until the operator logs in.
434/// This is the value both of those print. It exists as a type rather than as a
435/// formatted string in two places so that the two cannot drift.
436///
437/// [`ActiveStore::agrees_with_start_mode`] is the check worth having: the store
438/// a process opened and the start mode recorded for the installed service are
439/// two independently persisted facts, and a host whose service was switched
440/// from `boot` to `login` without the token being moved is a host whose daemon
441/// will start and then fail to find a credential. Saying so in `service status`
442/// is cheaper than finding out at three in the morning.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct ActiveStore {
445 scope: SecretScope,
446 start_mode: StartMode,
447 location: String,
448}
449
450impl ActiveStore {
451 /// Pairs a store with the start mode currently configured for the service.
452 #[must_use]
453 pub fn of(store: &dyn SecretStore, start_mode: StartMode) -> Self {
454 Self {
455 scope: store.scope(),
456 start_mode,
457 location: store.location(),
458 }
459 }
460
461 /// The store in use.
462 #[must_use]
463 pub const fn scope(&self) -> SecretScope {
464 self.scope
465 }
466
467 /// The start mode configured for the service.
468 #[must_use]
469 pub const fn start_mode(&self) -> StartMode {
470 self.start_mode
471 }
472
473 /// Where the store keeps its value, in the platform's own terms.
474 #[must_use]
475 pub fn location(&self) -> &str {
476 &self.location
477 }
478
479 /// Whether the store in use is the one this start mode obliges.
480 #[must_use]
481 pub fn agrees_with_start_mode(&self) -> bool {
482 SecretScope::for_start_mode(self.start_mode) == self.scope
483 }
484}
485
486impl fmt::Display for ActiveStore {
487 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488 write!(
489 f,
490 "{}-scoped secret store at {} (service starts at {})",
491 self.scope, self.location, self.start_mode
492 )?;
493 if !self.agrees_with_start_mode() {
494 write!(
495 f,
496 " -- MISMATCH: starting at {} needs the {}-scoped store",
497 self.start_mode,
498 SecretScope::for_start_mode(self.start_mode)
499 )?;
500 }
501 Ok(())
502 }
503}
504
505// ---------------------------------------------------------------------------
506// The port
507// ---------------------------------------------------------------------------
508
509/// Store, load, delete — and say where the value lives and what protects it.
510///
511/// `Send + Sync` because the agent holds one across `tokio` tasks; `Debug`
512/// because every other port in this workspace is, and because a store that
513/// could not be printed in an error context would be replaced by a path that
514/// could.
515pub trait SecretStore: fmt::Debug + Send + Sync {
516 /// Which of the two stores this is.
517 fn scope(&self) -> SecretScope;
518
519 /// Where the value lives, in the platform's own terms. What `host show`
520 /// and `service status` print, and never the value itself.
521 fn location(&self) -> String;
522
523 /// Writes the value, replacing whatever was there.
524 ///
525 /// # Errors
526 ///
527 /// [`SecretStoreError::Store`].
528 fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError>;
529
530 /// Refuses when [`SecretStore::store`] would, without writing anything.
531 ///
532 /// For a caller about to do something that only a successful write makes
533 /// safe. Renewal is that caller: GitHub retires a refresh token the moment
534 /// it is exchanged, so a process that exchanges it and then cannot store
535 /// the replacement has destroyed the credential for every process sharing
536 /// this store. On a boot-mode host that is any TUI or CLI command started
537 /// without the privilege the machine-scoped store needs to be written,
538 /// even though it can read it.
539 ///
540 /// A permission check, not a promise: a write can still fail for reasons
541 /// no check can foresee. It answers the question that is stable across a
542 /// process's lifetime -- whether *this* account may write here at all.
543 ///
544 /// # Errors
545 ///
546 /// [`SecretStoreError::Store`], carrying why a write would be refused.
547 fn ensure_writable(&self) -> Result<(), SecretStoreError>;
548
549 /// Reads the value back, or reports that there is none.
550 ///
551 /// `Ok(None)` is absence and is not an error; see [`SecretStoreError`].
552 ///
553 /// # Errors
554 ///
555 /// [`SecretStoreError::Load`] when the store cannot be reached, and
556 /// [`SecretStoreError::Corrupt`] when what is there is not a value this
557 /// store wrote.
558 fn load(&self) -> Result<Option<SecretString>, SecretStoreError>;
559
560 /// Removes the value.
561 ///
562 /// # Errors
563 ///
564 /// [`SecretStoreError::Delete`].
565 fn delete(&self) -> Result<Removal, SecretStoreError>;
566
567 /// What stands between the stored value and an ordinary local user.
568 ///
569 /// # Errors
570 ///
571 /// [`SecretStoreError::Inspect`].
572 fn protection(&self) -> Result<Protection, SecretStoreError>;
573}
574
575// ---------------------------------------------------------------------------
576// The platform store
577// ---------------------------------------------------------------------------
578
579/// The real store: DPAPI on Windows, a keychain on macOS, a `0600` file plus
580/// systemd credentials on Linux.
581///
582/// One type with three bodies rather than three types, because the choice is
583/// made by `cfg` at compile time and a caller never has one without the other
584/// two being impossible. The scope, by contrast, is a runtime choice and is a
585/// field.
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub struct PlatformSecretStore {
588 scope: SecretScope,
589 site: sys::Site,
590}
591
592impl PlatformSecretStore {
593 /// Resolves the platform-standard location for `scope`.
594 ///
595 /// This is what production uses and what `host show` reports. Nothing is
596 /// touched on disk here; the directory, the file, and the keychain are
597 /// created by the first [`SecretStore::store`].
598 ///
599 /// # Errors
600 ///
601 /// [`SecretStoreError::Resolve`] when the platform cannot say where the
602 /// location is — no `%ProgramData%` on Windows, no home directory for this
603 /// account on the two Unixes. A service account configured with no profile
604 /// is the way that actually happens, and the message says so.
605 pub fn standard(scope: SecretScope) -> Result<Self, SecretStoreError> {
606 let site = sys::standard_site(scope).map_err(|reason| SecretStoreError::Resolve {
607 scope,
608 reason: reason.to_string(),
609 })?;
610 Ok(Self { scope, site })
611 }
612
613 /// Resolves the store the configured start mode obliges.
614 ///
615 /// The one constructor a daemon should use: it makes the store a function
616 /// of the recorded start mode rather than of whichever constructor the
617 /// call site happened to reach for.
618 ///
619 /// # Errors
620 ///
621 /// As [`PlatformSecretStore::standard`].
622 pub fn for_start_mode(mode: StartMode) -> Result<Self, SecretStoreError> {
623 Self::standard(SecretScope::for_start_mode(mode))
624 }
625
626 /// Places the same backend under a root the caller names.
627 ///
628 /// [`crate::paths::AppPaths::rooted_at`] is the precedent and the reasoning
629 /// is the same: a test needs a disposable store, and a service installed
630 /// against an explicitly configured root has to reproduce one. A relative
631 /// root stays relative, which is the caller's decision to make.
632 ///
633 /// # What a rooted store does and does not exercise
634 ///
635 /// Everything that makes a scope a scope, on two of the three platforms.
636 /// The Windows backend picks its DPAPI flag and its DACL from the scope,
637 /// not from the site, so a rooted machine-scoped store is protected and
638 /// encrypted exactly as the standard one is. The Linux backend's `0600`
639 /// file, its atomic replace, and its systemd-credential read path are the
640 /// same code under either root.
641 ///
642 /// macOS is where a rooted store is genuinely weaker, and it is worth being
643 /// precise about how. A rooted store is a keychain this program creates in
644 /// the root directory, and both scopes get one; what the standard sites
645 /// have and it does not is the *choice of keychain* — the System Keychain
646 /// against the login keychain — and the System Keychain's root-only master
647 /// key. The keychain calls themselves, the item, the not-found handling and
648 /// the delete are identical. The password protecting a rooted keychain is
649 /// [`ROOTED_KEYCHAIN_PASSWORD`], which is a constant in a public binary and
650 /// therefore protects nothing on its own: what protects a rooted keychain
651 /// is the mode of the directory it is in, which is the same protection the
652 /// Linux backend relies on for a value it stores in plaintext.
653 ///
654 /// # Errors
655 ///
656 /// [`SecretStoreError::Resolve`]; in practice it cannot fail, because the
657 /// caller supplied the root.
658 pub fn rooted_at(scope: SecretScope, root: impl AsRef<Path>) -> Result<Self, SecretStoreError> {
659 let site =
660 sys::rooted_site(scope, root.as_ref()).map_err(|reason| SecretStoreError::Resolve {
661 scope,
662 reason: reason.to_string(),
663 })?;
664 Ok(Self { scope, site })
665 }
666
667 /// The same store, holding `item` instead of the GitHub credential.
668 #[must_use]
669 pub fn for_item(self, item: SecretItem) -> Self {
670 Self {
671 scope: self.scope,
672 site: sys::with_item(self.site, item.as_str()),
673 }
674 }
675
676 /// The filesystem object whose access control decides who can read this
677 /// store, whether or not anything is stored yet.
678 ///
679 /// Separate from [`SecretStore::protection`] because the guard can be named
680 /// before the store exists, which is what lets `host show` report the
681 /// machine store's protection on a host that has never logged in.
682 #[must_use]
683 pub fn guard(&self) -> PathBuf {
684 sys::guard(&self.site)
685 }
686
687 /// Turns platform bytes into the value, or says why they are not one.
688 fn decode(&self, bytes: Vec<u8>) -> Result<SecretString, SecretStoreError> {
689 use secrecy::zeroize::Zeroize as _;
690
691 let length = bytes.len();
692 // A zero-length value is not a credential and cannot have been written
693 // by `store`, which refuses one. Treating it as absence would hide a
694 // truncated write behind a device-flow login.
695 if length == 0 {
696 return Err(self.corrupt("it is empty"));
697 }
698
699 match String::from_utf8(bytes) {
700 Ok(text) => Ok(SecretString::from(text)),
701 Err(error) => {
702 // The bytes are not the value, but they may still be *a* value
703 // — something else's, or a partially overwritten one. Scrub the
704 // buffer rather than dropping it, and report only its length.
705 let mut bytes = error.into_bytes();
706 bytes.zeroize();
707 Err(self.corrupt(&format!("the {length} bytes there are not valid UTF-8")))
708 }
709 }
710 }
711
712 fn corrupt(&self, detail: &str) -> SecretStoreError {
713 SecretStoreError::Corrupt {
714 scope: self.scope,
715 location: self.location(),
716 detail: detail.to_string(),
717 }
718 }
719}
720
721impl SecretStore for PlatformSecretStore {
722 fn scope(&self) -> SecretScope {
723 self.scope
724 }
725
726 fn location(&self) -> String {
727 sys::describe(&self.site)
728 }
729
730 fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError> {
731 let failed = |source| SecretStoreError::Store {
732 scope: self.scope,
733 location: self.location(),
734 source,
735 };
736
737 let exposed = secret.expose_secret();
738 if exposed.is_empty() {
739 // Refused here rather than accepted and stored, because an empty
740 // value is indistinguishable from a truncated one on the way back
741 // out and `decode` has to reject it either way.
742 return Err(failed(std::io::Error::new(
743 std::io::ErrorKind::InvalidInput,
744 "an empty value is not a user access token",
745 )));
746 }
747
748 sys::store(&self.site, self.scope, exposed.as_bytes()).map_err(failed)?;
749
750 tracing::info!(
751 event = "secret_store_written",
752 scope = self.scope.as_str(),
753 "the user access token was written to the secret store"
754 );
755 Ok(())
756 }
757
758 fn ensure_writable(&self) -> Result<(), SecretStoreError> {
759 sys::ensure_writable(&self.site).map_err(|source| SecretStoreError::Store {
760 scope: self.scope,
761 location: self.location(),
762 source,
763 })
764 }
765
766 fn load(&self) -> Result<Option<SecretString>, SecretStoreError> {
767 let bytes = sys::load(&self.site, self.scope).map_err(|source| {
768 // A backend reports "there is something here and it is not ours"
769 // as `InvalidData` -- a DPAPI blob this machine's key cannot
770 // unprotect, a keychain item of the wrong shape. That is the
771 // `Corrupt` condition, not a transient read failure, and the
772 // difference decides whether a caller retries or tells the
773 // operator to run `auth logout`.
774 if source.kind() == std::io::ErrorKind::InvalidData {
775 self.corrupt(&source.to_string())
776 } else {
777 SecretStoreError::Load {
778 scope: self.scope,
779 location: self.location(),
780 source,
781 }
782 }
783 })?;
784
785 match bytes {
786 Some(bytes) => self.decode(bytes).map(Some),
787 None => Ok(None),
788 }
789 }
790
791 fn delete(&self) -> Result<Removal, SecretStoreError> {
792 let removed = sys::delete(&self.site).map_err(|source| SecretStoreError::Delete {
793 scope: self.scope,
794 location: self.location(),
795 source,
796 })?;
797
798 let removal = if removed {
799 Removal::Removed
800 } else {
801 Removal::AlreadyAbsent
802 };
803
804 tracing::info!(
805 event = "secret_store_purged",
806 scope = self.scope.as_str(),
807 outcome = if removal.removed_something() {
808 "removed"
809 } else {
810 "already_absent"
811 },
812 "the user access token was purged from the secret store"
813 );
814 Ok(removal)
815 }
816
817 fn protection(&self) -> Result<Protection, SecretStoreError> {
818 let guard = sys::guard(&self.site);
819 let summary = crate::process::permissions_summary(&guard).map_err(|source| {
820 SecretStoreError::Inspect {
821 scope: self.scope,
822 guard: guard.clone(),
823 source,
824 }
825 })?;
826
827 Ok(Protection {
828 guard,
829 description: summary.description,
830 readable_by_other_local_users: summary.readable_by_other_local_users,
831 })
832 }
833}
834
835/// The password protecting a keychain created by
836/// [`PlatformSecretStore::rooted_at`] on macOS.
837///
838/// A constant in a public binary, and therefore not a secret. It is here
839/// because `SecKeychainCreate` requires *a* password and the alternative —
840/// passing `NULL` — prompts the operator for one, which a daemon must never
841/// do. What protects a rooted keychain is the mode of the directory it is
842/// created in; see [`PlatformSecretStore::rooted_at`].
843///
844/// Public so that a test can assert this is what it is, rather than discovering
845/// it by reading the backend.
846pub const ROOTED_KEYCHAIN_PASSWORD: &str = "runner-manager-rooted-keychain";
847
848/// The name of the systemd credential the Linux machine-scoped store reads
849/// before it reads its own file.
850///
851/// `05-infrastructure.md` puts the Linux machine store at *"`0600` file plus
852/// systemd credentials"*, and the second half is a read path rather than a
853/// write path: `systemd` decrypts a credential into a private `ramfs` at
854/// `$CREDENTIALS_DIRECTORY` and mounts it read-only, so a service given
855/// `LoadCredentialEncrypted=` gets the value without the file ever being
856/// readable by anything but that unit. A store that ignored it would oblige an
857/// operator who had set one up to keep a second plaintext copy on disk.
858pub const SYSTEMD_CREDENTIAL: &str = "runner-manager.user-access-token";
859
860/// The environment variable `systemd` sets for a unit that was given
861/// credentials. Read once, at [`PlatformSecretStore::standard`] time.
862pub const CREDENTIALS_DIRECTORY: &str = "CREDENTIALS_DIRECTORY";
863
864// ---------------------------------------------------------------------------
865// Platform implementations
866// ---------------------------------------------------------------------------
867//
868// Each `sys` module offers the same nine items, and the shared code above is
869// the only caller:
870//
871// Site -- where one store keeps its value
872// standard_site(scope) -> the production location for that scope
873// rooted_site(scope, root) -> the same backend under a caller-named root
874// describe(&Site) -> what `host show` prints
875// guard(&Site) -> the object whose access control decides
876// who can read the value
877// store(&Site, scope, bytes) -> write, replacing whatever was there
878// ensure_writable(&Site) -> refuse when `store` would be refused by
879// permissions, without writing anything
880// load(&Site, scope) -> Ok(None) when nothing is stored;
881// ErrorKind::InvalidData when something is
882// stored and it is not ours
883// delete(&Site) -> Ok(false) when there was nothing to remove
884//
885// `standard_site` and `rooted_site` fail with a `String` rather than an
886// `io::Error`, because a resolution failure is never an `errno`: it is "this
887// account has no home directory" or "this Windows reports no %ProgramData%",
888// and both want a sentence an operator can act on.
889
890/// The stem every temporary file this module writes shares, so that a crash
891/// leaves something [`sweep_temporaries`] can recognise rather than an
892/// anonymous file beside the store.
893#[cfg(not(target_os = "macos"))]
894const TEMP_PREFIX: &str = "user-access-token.";
895
896/// Removes temporary files left in `directory` by an interrupted write.
897///
898/// Called from `delete` and not from `store`, and that order is deliberate:
899/// two `store` calls can be in flight at once and a sweep there could remove a
900/// live temporary out from under the other one. `delete` is `auth logout`,
901/// which is where "leave no remnant" is the actual requirement.
902#[cfg(not(target_os = "macos"))]
903fn sweep_temporaries(directory: &Path) {
904 let Ok(entries) = std::fs::read_dir(directory) else {
905 return;
906 };
907 for entry in entries.flatten() {
908 let name = entry.file_name();
909 let Some(name) = name.to_str() else { continue };
910 if name.starts_with(TEMP_PREFIX) && name.ends_with(".tmp") {
911 let _ = std::fs::remove_file(entry.path());
912 }
913 }
914}
915
916/// Overwrites every byte of a file with zeroes, in place.
917///
918/// Returns `Ok(false)` when there is nothing there, which is the ordinary state
919/// of a store that was never written.
920///
921/// # What this is and is not
922///
923/// It is **not** secure erasure and nothing here claims it is. The reasoning is
924/// [`crate::process::RestrictiveHandoff`]'s, unchanged: a journal, a
925/// copy-on-write snapshot, or an SSD's wear levelling can each keep a copy that
926/// an overwrite never reaches. It is here because it costs one `write` and
927/// because on Linux the stored value is at rest in **plaintext** — the mode is
928/// the whole access control there — so leaving the bytes in the block after the
929/// inode is unlinked is worse than not.
930///
931/// # Why it is a separate function, and not `cfg`-gated to Linux
932///
933/// Both are the same reason, and it is a testing reason rather than a
934/// portability one. Folded into the delete path, the zero fill was asserted by
935/// nothing: a test could only observe the file's *absence* afterwards, which is
936/// what removing it proves too, so the fill could have been deleted outright
937/// and the suite would have stayed green. Split out, the fill is observable —
938/// [`tests::overwrite_zeroes_every_byte_and_leaves_the_file_there`] reads the
939/// bytes back before anything unlinks them. Compiled on Windows as well as
940/// Linux, that test runs on every leg of the matrix rather than on one, and the
941/// Windows delete path calls it too: the value there is ciphertext, so the fill
942/// buys less, but it costs the same and it keeps the mechanism on a path a
943/// developer can execute.
944#[cfg(not(target_os = "macos"))]
945fn overwrite(path: &Path) -> std::io::Result<bool> {
946 use std::io::Write as _;
947
948 let mut file = match std::fs::OpenOptions::new().write(true).open(path) {
949 Ok(file) => file,
950 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
951 Err(error) => return Err(error),
952 };
953
954 let length = usize::try_from(file.metadata()?.len()).unwrap_or(0);
955 file.write_all(&vec![0u8; length])?;
956 file.flush()?;
957 file.sync_all()?;
958 Ok(true)
959}
960
961/// Drops trailing ASCII whitespace from a value that came from outside this
962/// module.
963///
964/// There is exactly one such value: the systemd credential. `store` writes no
965/// newline and its own file is read back verbatim, deliberately — trimming
966/// there would quietly repair a corruption this store would rather report. A
967/// credential, though, is produced by the operator through `systemd-creds
968/// encrypt`, and `echo`, `printf '%s\n'`, and every text editor put a newline
969/// on the end. Used byte for byte, that newline becomes part of the token and
970/// surfaces much later inside an `Authorization` header, where it looks like a
971/// bad credential rather than a bad read.
972///
973/// Trailing only. A token has no interior whitespace, but if one ever did,
974/// silently rewriting its middle would be a worse bug than the one this fixes.
975#[cfg(not(target_os = "macos"))]
976// `allow` rather than `expect`: on Windows this is dead in a normal build and
977// live in a test build, because the test below is the whole reason it is
978// compiled there, and an `expect` that is fulfilled in only one of the two
979// configurations warns in the other.
980#[cfg_attr(
981 windows,
982 allow(
983 dead_code,
984 reason = "the systemd credential path is Linux-only; this is compiled on Windows so \
985 that its unit test runs on every leg of the matrix rather than on the one \
986 platform a developer usually cannot execute"
987 )
988)]
989fn trim_trailing_ascii_whitespace(mut bytes: Vec<u8>) -> Vec<u8> {
990 while bytes.last().is_some_and(u8::is_ascii_whitespace) {
991 bytes.pop();
992 }
993 bytes
994}
995
996/// Refuses when this process may not create entries in the directory a write
997/// to `target` replaces it from.
998///
999/// Both Unix backends write by creating a new file beside the old one and
1000/// renaming it over. The Linux store does that itself; a keychain does it
1001/// inside Security.framework, in this process -- `create
1002/// /Library/Keychains/System.keychain.sb-...: Permission denied` is what an
1003/// unprivileged TUI logged there. So the right to create entries in that
1004/// directory is exactly the permission a write turns on.
1005///
1006/// The nearest directory that exists is the one asked, because `store` creates
1007/// any that are missing. `AT_EACCESS` asks with the effective IDs, which are
1008/// the ones the write itself would use, so a command run under `sudo` passes.
1009#[cfg(unix)]
1010fn ensure_directory_writable(target: &Path) -> std::io::Result<()> {
1011 use std::os::unix::ffi::OsStrExt as _;
1012
1013 let Some(directory) = target.ancestors().skip(1).find(|path| path.is_dir()) else {
1014 return Ok(());
1015 };
1016 let c_path =
1017 std::ffi::CString::new(directory.as_os_str().as_bytes()).map_err(std::io::Error::other)?;
1018 // SAFETY: `c_path` is a NUL-terminated string that outlives the call, and
1019 // `faccessat` only reads it.
1020 let status = unsafe {
1021 libc::faccessat(
1022 libc::AT_FDCWD,
1023 c_path.as_ptr(),
1024 libc::W_OK | libc::X_OK,
1025 libc::AT_EACCESS,
1026 )
1027 };
1028 if status == 0 {
1029 return Ok(());
1030 }
1031 let error = std::io::Error::last_os_error();
1032 Err(std::io::Error::new(
1033 error.kind(),
1034 format!(
1035 "this account may not create files in {} ({error}), so the store would refuse the \
1036 write",
1037 directory.display()
1038 ),
1039 ))
1040}
1041
1042// ---------------------------------------------------------------------------
1043
1044#[cfg(windows)]
1045mod sys {
1046 //! DPAPI, in a file whose DACL is this store's real access control.
1047 //!
1048 //! # Two mechanisms, and which one is doing the work
1049 //!
1050 //! `CryptProtectData` with `CRYPTPROTECT_LOCAL_MACHINE` produces a blob
1051 //! that **any process on this machine** can unprotect. That is not a
1052 //! weakness in the flag, it is the definition of machine scope, and it is
1053 //! what D13 needs: a service starting at boot, under an account that has
1054 //! never logged in, has no user master key to decrypt with. The
1055 //! consequence is that the encryption is not the access control — the
1056 //! file's DACL is. `07-security.md` says the same thing from the other
1057 //! end: *"Stored only in the machine-scoped secret store, ACL'd to the
1058 //! service account."*
1059 //!
1060 //! The user-scoped store passes no scope flag, so the blob is bound to the
1061 //! calling account's master key. There the encryption *is* an access
1062 //! control, and the DACL is a second one.
1063 //!
1064 //! # The DACL, and why it is not `process.rs`'s
1065 //!
1066 //! [`crate::process::RestrictiveHandoff`] writes
1067 //! `D:P(A;;FA;;;BA)(A;;FA;;;<this account>)` and documents at length why it
1068 //! carries no `SY` ACE: that file's writer and reader are the same
1069 //! process, so if the process is `LocalSystem` its own SID *is* S-1-5-18
1070 //! and a separate ACE adds nothing.
1071 //!
1072 //! This store's writer and reader are **not** the same process. `auth
1073 //! login` runs as the interactive operator; the daemon runs as whatever
1074 //! `service install` registered, which defaults to `LocalSystem`. So `SY`
1075 //! is load-bearing here in exactly the case where it was redundant there,
1076 //! and the machine-scoped DACL carries it. The user-scoped DACL does not,
1077 //! because a user-scoped store is deliberately not for a service.
1078 //!
1079 //! The third trustee is `OW` — OWNER RIGHTS, S-1-3-4 — rather than the
1080 //! creating account's SID read back out of the process token. When a DACL
1081 //! contains an ACE for OWNER RIGHTS, that ACE is what the object's owner
1082 //! gets, and the owner of a file is the account that created it. So `OW`
1083 //! says "the account that ran `auth login` keeps access to what it stored"
1084 //! without opening a process token, without a `TOKEN_USER` buffer, and
1085 //! without a second copy of the SID lookup `process.rs` already carries.
1086 //!
1087 //! **That sentence held only while `auth login` was the sole writer.** Token
1088 //! renewal made the daemon a second writer under a different account, and
1089 //! because every write creates a new file, the owner moved with it and `OW`
1090 //! stopped meaning the operator. [`replacement_sddl`] is what keeps the
1091 //! grant `OW` describes from evaporating; it carries the previous owner's
1092 //! SID onto the replacement explicitly.
1093 //!
1094 //! # One store per host, and what a second operator actually gets
1095 //!
1096 //! An earlier version of this paragraph said that ownership moves with the
1097 //! write, so a second non-administrative operator's `auth login` "takes"
1098 //! the store. **That was wrong, and it was wrong in the optimistic
1099 //! direction.** Ownership does not move, because the write never lands:
1100 //! `store` finishes with a replacing rename, a replacing rename deletes the
1101 //! target, and delete is granted by `DELETE` on the file or
1102 //! `FILE_DELETE_CHILD` on its parent. `sddl(Machine)` names `SY`, `BA` and
1103 //! `OW`, none of which is operator B; and a stock `%ProgramData%` grants
1104 //! `BUILTIN\Users` only `(OI)(CI)(RX)` plus `(CI)(WD,AD,WEA,WA)` — `WD`
1105 //! lets B create the temporary, and the absence of `DC` and `DE` is what
1106 //! denies the rename. So B's `auth login` **fails**.
1107 //!
1108 //! That is the correct behaviour, and it is the behaviour this store now
1109 //! states rather than stumbles into. **The machine-scoped value is the
1110 //! host's one credential, not an operator's.** `07-security.md` counts the
1111 //! persisted credential surface and gets to one; `service install`
1112 //! registers one service reading one store; the domain has one `Host` and
1113 //! `d1`'s lock permits one agent. A second operator does not get a second
1114 //! store, and whether B may overwrite A's token is a policy question whose
1115 //! answer is "only if B is trusted with this host" — which on Windows is
1116 //! spelled *administrator*, and `BA` already grants it.
1117 //!
1118 //! What changed is the *failure*, not the policy. [`cannot_replace`] asks
1119 //! before anything is encrypted or written, so B gets a message naming the
1120 //! three ways forward instead of a bare "Access is denied" raised at the
1121 //! last step of the write, after a valid machine-decryptable blob of a live
1122 //! token has already been placed on disk.
1123 //!
1124 //! Widening the DACL was considered and rejected. The DACL is the **entire**
1125 //! access control here — a machine-scope DPAPI blob is unprotectable by any
1126 //! process on the host, by definition — so an ACE that let B replace the
1127 //! file would also let every interactive account on the machine read the
1128 //! one credential the product holds. The reader that actually matters is
1129 //! the service account, which `SY` covers by default and which `d3` grants
1130 //! explicitly when `service install` registers a least-privilege account
1131 //! instead (`05-infrastructure.md`, service behaviour, item 2).
1132 //!
1133 //! [`crate::process::permissions_summary`] reads the result back, and it
1134 //! treats an *unprotected* DACL as broadly readable because an inherited
1135 //! one is not this program's to vouch for. That is why every DACL below
1136 //! begins `D:P`, and why the file carries its own rather than inheriting
1137 //! `%ProgramData%`'s, which grants `BU` read.
1138 //!
1139 //! # Write, then replace
1140 //!
1141 //! The blob goes to a temporary file *carrying its final DACL from the
1142 //! moment it exists*, is `sync_all`ed, and is then renamed over the target.
1143 //! `process.rs` argues the first half — a file created and then tightened
1144 //! is readable for however long the gap lasts. The second half is this
1145 //! store's own: the value is long-lived, so a write interrupted half way
1146 //! must not leave a truncated blob where a whole one was.
1147
1148 use std::fs::File;
1149 use std::io::{self, Write as _};
1150 use std::os::windows::ffi::OsStrExt;
1151 use std::os::windows::io::FromRawHandle;
1152 use std::path::{Path, PathBuf};
1153
1154 use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
1155 use windows::Win32::Security::Authorization::{
1156 ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
1157 GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
1158 };
1159 use windows::Win32::Security::Cryptography::{
1160 CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN,
1161 CryptProtectData, CryptUnprotectData,
1162 };
1163 use windows::Win32::Security::{
1164 OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES,
1165 };
1166 use windows::Win32::Storage::FileSystem::{
1167 CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
1168 FILE_SHARE_NONE,
1169 };
1170 use windows::core::{PCWSTR, PWSTR};
1171
1172 use super::{
1173 APPLICATION, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER, SecretScope, TEMP_PREFIX, overwrite,
1174 sweep_temporaries,
1175 };
1176
1177 /// Optional entropy mixed into every blob.
1178 ///
1179 /// **Not a secret**, and nothing here pretends otherwise: it is a constant
1180 /// in a published binary. It binds a blob to this product, so that a
1181 /// machine-scoped blob found on disk is not readable by a passing program
1182 /// that merely calls `CryptUnprotectData` with no arguments, and so that a
1183 /// value written by some future second store cannot be silently read back
1184 /// by this one. Against an attacker holding the binary it buys nothing.
1185 const ENTROPY: &[u8] = b"io.github.IvanMurzak.runner-manager/user-access-token/v1";
1186
1187 /// The file the blob lives in.
1188 #[derive(Debug, Clone, PartialEq, Eq)]
1189 pub(super) struct Site {
1190 file: PathBuf,
1191 }
1192
1193 pub(super) fn with_item(mut site: Site, item: &'static str) -> Site {
1194 site.file.set_file_name(format!("{item}.dpapi"));
1195 site
1196 }
1197
1198 pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
1199 let root = match scope {
1200 // `%ProgramData%` rather than `%LOCALAPPDATA%`, because the whole
1201 // requirement is that an account which has never logged in can
1202 // read it — and `%LOCALAPPDATA%` does not exist for such an
1203 // account until its profile is loaded.
1204 SecretScope::Machine => std::env::var_os("ProgramData")
1205 .map(PathBuf::from)
1206 .ok_or_else(|| {
1207 "this Windows reports no %ProgramData%, so the machine-wide \
1208 application-data directory cannot be resolved. Set ProgramData, or \
1209 install the service with --start-at login to use the user-scoped store."
1210 .to_string()
1211 })?
1212 .join(ORGANIZATION)
1213 .join(APPLICATION),
1214 SecretScope::User => {
1215 directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
1216 .ok_or_else(|| {
1217 "the operating system reports no home directory for this account, so \
1218 the user-scoped store cannot be resolved. A service account \
1219 configured with no profile normally hits this; give the account a \
1220 home directory, or use the machine-scoped store."
1221 .to_string()
1222 })?
1223 .data_local_dir()
1224 .to_path_buf()
1225 }
1226 };
1227 Ok(Site {
1228 file: root.join(DIRECTORY).join(format!("{ITEM}.dpapi")),
1229 })
1230 }
1231
1232 pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
1233 // The scope is a path segment here and is not one under a standard
1234 // site, because a standard site gets its separation from the root:
1235 // `%ProgramData%` against `%LOCALAPPDATA%`. Under one caller-named root
1236 // there is no such separation, and two stores sharing a file is the
1237 // failure `the_two_variants_do_not_share_a_value` exists to catch --
1238 // `auth logout` under one scope would silently purge the other.
1239 Ok(Site {
1240 file: root
1241 .join(DIRECTORY)
1242 .join(scope.as_str())
1243 .join(format!("{ITEM}.dpapi")),
1244 })
1245 }
1246
1247 pub(super) fn describe(site: &Site) -> String {
1248 format!("DPAPI blob at {}", site.file.display())
1249 }
1250
1251 pub(super) fn guard(site: &Site) -> PathBuf {
1252 site.file.clone()
1253 }
1254
1255 /// The protected DACL a store of this scope gets, in SDDL.
1256 ///
1257 /// Split out and `pub(super)` so a test can assert the exact string rather
1258 /// than infer it from a file, and so a reviewer can read the two DACLs
1259 /// side by side.
1260 pub(super) const fn sddl(scope: SecretScope) -> &'static str {
1261 match scope {
1262 SecretScope::Machine => "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)",
1263 SecretScope::User => "D:P(A;;FA;;;BA)(A;;FA;;;OW)",
1264 }
1265 }
1266
1267 /// The DACL a *replacement* gets: [`sddl`], plus the account that owned
1268 /// what is being replaced.
1269 ///
1270 /// # Why `OW` alone stopped being enough
1271 ///
1272 /// `OW` is OWNER RIGHTS, and the owner of a file is whoever created it.
1273 /// That made `OW` an exact statement of *"the account that ran `auth login`
1274 /// keeps access to what it stored"* for as long as `auth login` was the
1275 /// only writer.
1276 ///
1277 /// Token renewal made the daemon a second writer with a different
1278 /// identity. [`store`] finishes by renaming a temporary over the target, so
1279 /// every write creates a new file, and the new file's owner is whoever
1280 /// created it — `LocalSystem` for a daemon installed at boot. `OW` then
1281 /// resolves to `LocalSystem`, the operator matches none of the three ACEs,
1282 /// and an unelevated `auth status` cannot read the store or even its ACL.
1283 ///
1284 /// Observed on 2026-08-29: a store readable at `07:50Z` and unreadable at
1285 /// `07:58Z`, with nothing between the two but the daemon renewing its
1286 /// eight-hour token. See `docs/spikes/token-expiry-and-renewal.md`.
1287 ///
1288 /// # Why the previous owner, rather than preserving ownership
1289 ///
1290 /// Setting a new file's owner to an account that is not the caller's needs
1291 /// `SE_RESTORE_NAME`, which `LocalSystem` holds but a least-privilege
1292 /// service account — which `service install` can register — does not. An
1293 /// ACE needs nothing: the writer created the file and so may say who else
1294 /// reaches it. This works the same for both, which is why it is the
1295 /// mechanism rather than the fallback.
1296 ///
1297 /// This widens nothing. The previous owner already had full control
1298 /// through `OW`; carrying the SID forward is what keeps a grant that
1299 /// already existed from evaporating when somebody else writes.
1300 ///
1301 /// # Why the previous *grants* and not just the previous owner
1302 ///
1303 /// Carrying the owner alone survives exactly one write and then undoes
1304 /// itself. After the first renewal the owner is the daemon and the operator
1305 /// is named by an ACE; at the second renewal the owner read back is the
1306 /// *daemon's*, so a DACL rebuilt from the owner drops the operator and
1307 /// locks them out again — the same failure, deferred by eight hours. What
1308 /// is carried is therefore every SID the previous DACL granted, which
1309 /// includes the ACE the previous write added.
1310 ///
1311 /// [`carried_grants`] reads that set and drops the SIDs the constant
1312 /// already covers, so the DACL cannot grow by one ACE per write.
1313 pub(super) fn replacement_sddl(scope: SecretScope, carried: &[String]) -> String {
1314 let mut text = sddl(scope).to_owned();
1315 for sid in carried {
1316 // The SID goes in where a two-letter alias usually does; SDDL takes
1317 // either.
1318 text.push_str("(A;;FA;;;");
1319 text.push_str(sid);
1320 text.push(')');
1321 }
1322 text
1323 }
1324
1325 /// Trustees the constant DACL already grants, in both spellings.
1326 ///
1327 /// The aliases are what [`sddl`] itself writes, so re-emitting them would
1328 /// double every ACE in the base. The two SIDs are the same accounts as
1329 /// `SY` and `BA`, which is the spelling [`previous_owner`] hands back:
1330 /// `ConvertSidToStringSidW` always answers `S-1-…`, never an alias.
1331 const ALREADY_GRANTED: [&str; 5] = ["SY", "BA", "OW", "S-1-5-18", "S-1-5-32-544"];
1332
1333 /// Every account the value being replaced was readable by, so that
1334 /// replacing it does not quietly take the store away from one of them.
1335 ///
1336 /// The owner, because `OW` granted it; plus the SIDs already named
1337 /// explicitly, because those are the owners of earlier writes that this
1338 /// mechanism already rescued. Bounded by [`ALREADY_GRANTED`] and by
1339 /// deduplication: in practice the set is the one operator who signed in.
1340 ///
1341 /// Deliberately infallible, for the reason [`previous_owner`] gives.
1342 fn carried_grants(path: &Path) -> Vec<String> {
1343 // The reader `protection` already goes through, rather than a second
1344 // descriptor round trip of this module's own.
1345 let dacl = crate::process::permissions_summary(path)
1346 .map(|summary| summary.description)
1347 .unwrap_or_default();
1348 let mut grants = merge_grants(previous_owner(path).as_deref(), &dacl);
1349
1350 // ------------------------------------------------------------------
1351 // AND THIS WRITER, BY NAME, BECAUSE `OW` DOES NOT SURVIVE.
1352 // ------------------------------------------------------------------
1353 // `OW` was chosen to mean "the account that wrote this keeps access",
1354 // and it does -- until anybody changes the owner, at which point
1355 // Windows **deletes the OWNER RIGHTS ACE**. Taking ownership of the
1356 // file therefore removes the only thing granting the new owner access:
1357 // they end up owning a file they cannot read, holding `READ_CONTROL`
1358 // and `WRITE_DAC` and nothing else. Read back from a real host on
1359 // 2026-08-30, after `takeown` had been offered as the repair:
1360 //
1361 // O:S-1-5-21-...-1001 G:SY D:P(A;;FA;;;SY)(A;;FA;;;BA)
1362 //
1363 // -- the owner is the operator, and `OW` is simply gone.
1364 //
1365 // So the writer is named by SID as well. `OW` stays because it costs
1366 // nothing and still covers the ordinary case, but nothing depends on
1367 // it any more. `ALREADY_GRANTED` drops the service accounts, so a
1368 // daemon writing under `LocalSystem` adds no ACE at all and the set
1369 // stays at the one operator who signed in.
1370 if let Ok(writer) = crate::process::current_user_sid()
1371 && !ALREADY_GRANTED.contains(&&*writer)
1372 && !grants.contains(&writer)
1373 {
1374 grants.push(writer);
1375 }
1376 grants
1377 }
1378
1379 /// [`carried_grants`] without the two reads, which is where its one rule
1380 /// lives and the only part a test can drive.
1381 ///
1382 /// A test process is one account: it cannot become `LocalSystem`, so it
1383 /// cannot make the owner move between writes, so driving [`store`] can
1384 /// never reach the case this rule exists for. Passing the owner and the
1385 /// DACL in is what makes "the writer is not the previous owner" reachable
1386 /// at all.
1387 pub(super) fn merge_grants(previous_owner: Option<&str>, previous_dacl: &str) -> Vec<String> {
1388 let mut carried: Vec<String> = Vec::new();
1389 let mut add = |sid: &str| {
1390 if !ALREADY_GRANTED.contains(&sid) && !carried.iter().any(|seen| seen == sid) {
1391 carried.push(sid.to_owned());
1392 }
1393 };
1394 if let Some(owner) = previous_owner {
1395 add(owner);
1396 }
1397 for trustee in trustees(previous_dacl) {
1398 add(&trustee);
1399 }
1400 carried
1401 }
1402
1403 /// Every trustee an SDDL DACL names, in whatever spelling it names them.
1404 ///
1405 /// An ACE is `(type;flags;rights;object;inherit;trustee)`, so the trustee is
1406 /// what follows the last `;` before the closing parenthesis.
1407 ///
1408 /// # Why not "the ones written as `S-1-…`"
1409 ///
1410 /// Because Windows does not read back what was written. A DACL built with
1411 /// `S-1-5-21-…-500` comes out of
1412 /// `ConvertSecurityDescriptorToStringSecurityDescriptorW` as `(A;;FA;;;LA)`
1413 /// — the alias for the built-in Administrator — and any other well-known
1414 /// account behaves the same way. Filtering on the `S-1-` prefix therefore
1415 /// dropped exactly the accounts whose grant had already been rescued once,
1416 /// so the carry survived one write and undid itself on the next, which is
1417 /// the bug it exists to prevent.
1418 ///
1419 /// Found by CI, whose Windows runner signs in as that account. It passes on
1420 /// an ordinary developer machine, where the operator has a plain
1421 /// `S-1-5-21-…-1001` that survives the round trip unchanged.
1422 ///
1423 /// [`ALREADY_GRANTED`] is what keeps the base DACL's own aliases out.
1424 pub(super) fn trustees(sddl: &str) -> Vec<String> {
1425 let mut found = Vec::new();
1426 for ace in sddl.split('(').skip(1) {
1427 let Some(body) = ace.split(')').next() else {
1428 continue;
1429 };
1430 let Some(trustee) = body.rsplit(';').next() else {
1431 continue;
1432 };
1433 if !trustee.is_empty() && !found.iter().any(|seen| seen == trustee) {
1434 found.push(trustee.to_owned());
1435 }
1436 }
1437 found
1438 }
1439
1440 /// The SID of the account that owns the file already there, as a string.
1441 ///
1442 /// `None` when there is no file, when its owner cannot be read, or when the
1443 /// SID will not convert — all of which mean the same thing to the caller:
1444 /// there is no previous grant to carry forward, so write [`sddl`] as it
1445 /// stands. A first write reaches this with nothing on disk and takes that
1446 /// path.
1447 ///
1448 /// Deliberately infallible. A store that refused to write because it could
1449 /// not read a security descriptor would trade a working credential for a
1450 /// tidier ACL.
1451 fn previous_owner(path: &Path) -> Option<String> {
1452 let wide = to_wide(path);
1453 let mut owner = PSID::default();
1454 let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1455 // SAFETY: `wide` is NUL-terminated and outlives the call. `descriptor`
1456 // receives a LocalAlloc'd descriptor freed below on every path, and
1457 // `owner` points into it rather than owning anything itself.
1458 let status = unsafe {
1459 GetNamedSecurityInfoW(
1460 PCWSTR(wide.as_ptr()),
1461 SE_FILE_OBJECT,
1462 OWNER_SECURITY_INFORMATION,
1463 Some(&mut owner),
1464 None,
1465 None,
1466 None,
1467 &mut descriptor,
1468 )
1469 };
1470 if status != ERROR_SUCCESS {
1471 return None;
1472 }
1473
1474 let mut sid_string = PWSTR::null();
1475 // SAFETY: `owner` points into the descriptor, which is still live.
1476 let converted = unsafe { ConvertSidToStringSidW(owner, &mut sid_string) };
1477 let text = match converted {
1478 // SAFETY: the conversion succeeded, so `sid_string` is a
1479 // LocalAlloc'd NUL-terminated string, freed immediately after.
1480 Ok(()) => {
1481 let text = unsafe { sid_string.to_string() }.ok();
1482 unsafe {
1483 let _ = LocalFree(Some(HLOCAL(sid_string.0.cast())));
1484 }
1485 text
1486 }
1487 Err(_) => None,
1488 };
1489 // SAFETY: LocalAlloc'd by `GetNamedSecurityInfoW`, freed exactly once.
1490 unsafe {
1491 let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1492 }
1493 text
1494 }
1495
1496 /// The access right a replacing rename needs on the file it replaces.
1497 ///
1498 /// `MOVEFILE_REPLACE_EXISTING` deletes the target, and delete is granted by
1499 /// `DELETE` on the object or by `FILE_DELETE_CHILD` on its parent. Probing
1500 /// for it is `CreateFileW` with `dwDesiredAccess = DELETE`, which
1501 /// [`std::os::windows::fs::OpenOptionsExt::access_mode`] reaches without
1502 /// another FFI declaration.
1503 const DELETE: u32 = 0x0001_0000;
1504
1505 /// Whether this account may replace the value already in the store, and if
1506 /// not, why an operator is seeing it.
1507 ///
1508 /// Returns `None` when there is nothing there, or when the existing value
1509 /// can be replaced — the ordinary cases.
1510 ///
1511 /// # What this catches
1512 ///
1513 /// A second non-administrative operator on a shared host. Their `auth
1514 /// login` inherits `WD` (create file) from `%ProgramData%` so the temporary
1515 /// file is written happily, and then the rename over the first operator's
1516 /// file is denied, because `sddl(Machine)` names `SY`, `BA` and `OW` and
1517 /// this account is none of the three. Left to the rename, that surfaces as
1518 /// a bare "Access is denied" at the last step of the write, after a valid
1519 /// machine-decryptable blob of a real token has already been put on disk.
1520 ///
1521 /// # Why it refuses rather than widening the DACL
1522 ///
1523 /// **This product keeps one machine-scoped credential per host, and that is
1524 /// the intended model rather than an accident of the ACL.**
1525 /// `07-security.md` counts the persisted credential surface and gets to
1526 /// one; `05-infrastructure.md` has `service install` register one service
1527 /// reading one store; the domain has one `Host`, and `d1`'s single-instance
1528 /// lock allows one agent. The value is *the host's* credential, not an
1529 /// operator's — so a second operator does not get a second store, and the
1530 /// question "may B overwrite A's token" is a policy question with a policy
1531 /// answer: only if B is trusted with the host, which on Windows means
1532 /// administrator.
1533 ///
1534 /// Widening the DACL to make the rename succeed would give every
1535 /// interactive account on the machine write access to the one credential
1536 /// the product holds, and read access with it, since the DACL is the
1537 /// **entire** access control here — a machine-scope DPAPI blob is
1538 /// unprotectable by any process on the host by definition. That trade is
1539 /// not worth a smoother second login.
1540 fn cannot_replace(site: &Site) -> Option<io::Error> {
1541 use std::os::windows::fs::OpenOptionsExt as _;
1542
1543 match std::fs::OpenOptions::new()
1544 .access_mode(DELETE)
1545 .open(&site.file)
1546 {
1547 // Replaceable, or nothing there to replace.
1548 Ok(_) => None,
1549 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
1550 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => Some(io::Error::new(
1551 io::ErrorKind::PermissionDenied,
1552 format!(
1553 "the machine-scoped store at {} already holds a token that belongs to \
1554 another account on this host, and this account may not replace it. This \
1555 product keeps one machine-scoped credential per host -- it is the host's \
1556 credential, not an operator's -- so a second operator does not get a \
1557 second store. Either run `auth logout` as the account that stored it, or \
1558 run `auth login` from an elevated prompt, since the local Administrators \
1559 group is granted access, or install the service with `--start-at login`, \
1560 which uses the per-user store instead. Nothing was written.",
1561 site.file.display()
1562 ),
1563 )),
1564 // Anything else is not this condition. Say nothing and let the
1565 // write report whatever it actually runs into, rather than
1566 // inventing a diagnosis from an unrelated errno.
1567 Err(_) => None,
1568 }
1569 }
1570
1571 pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
1572 if let Some(refusal) = cannot_replace(site) {
1573 return Err(refusal);
1574 }
1575 Ok(())
1576 }
1577
1578 pub(super) fn store(site: &Site, scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
1579 let directory = site
1580 .file
1581 .parent()
1582 .ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
1583 std::fs::create_dir_all(directory)?;
1584
1585 // Before anything is encrypted or written. The point of asking here is
1586 // that the alternative -- finding out at the rename -- means a valid,
1587 // machine-decryptable blob of a live token has already been placed on
1588 // disk before the refusal.
1589 if let Some(refusal) = cannot_replace(site) {
1590 return Err(refusal);
1591 }
1592
1593 let blob = protect(plaintext, scope)?;
1594
1595 // Read before the temporary exists, from the file about to be replaced.
1596 // A renewal writes as the daemon's account and would otherwise take
1597 // `OW` away from the operator who signed in; see `replacement_sddl`.
1598 let descriptor = replacement_sddl(scope, &carried_grants(&site.file));
1599
1600 let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
1601 let written = (|| -> io::Result<()> {
1602 let mut file = create_protected_file(&temporary, &descriptor)?;
1603 file.write_all(&blob)?;
1604 file.flush()?;
1605 file.sync_all()
1606 })();
1607 if let Err(error) = written {
1608 return Err(discard(&temporary, error));
1609 }
1610
1611 // `std::fs::rename` is `MoveFileExW(.., MOVEFILE_REPLACE_EXISTING)` on
1612 // Windows, so this replaces a previous token rather than failing. The
1613 // file keeps its own security descriptor across the move, so the DACL
1614 // applied at creation is the one the store ends up with.
1615 if let Err(error) = std::fs::rename(&temporary, &site.file) {
1616 // The probe above should have caught the second-operator case, but
1617 // it is a probe and this is a race: another account can take the
1618 // file between the two calls. Report the same diagnosis rather than
1619 // the bare denial, then discard the blob either way.
1620 let error = if error.kind() == io::ErrorKind::PermissionDenied {
1621 cannot_replace(site).unwrap_or(error)
1622 } else {
1623 error
1624 };
1625 return Err(discard(&temporary, error));
1626 }
1627 Ok(())
1628 }
1629
1630 /// Scrubs and removes a temporary that will not become the store, and folds
1631 /// a failure to do so into the error the caller is already returning.
1632 ///
1633 /// A temporary that survives a failed `store` is a valid machine-scope
1634 /// DPAPI blob of a real token, sitting under a name nobody looks at.
1635 /// Removing it was already the behaviour; what was missing is that the
1636 /// removal was `let _ =`, so a temporary that could *not* be removed left
1637 /// the token on disk with nothing said about it.
1638 ///
1639 /// A blanket [`sweep_temporaries`] is deliberately not used here. Its own
1640 /// documentation says why: two `store` calls can be in flight at once, and
1641 /// sweeping the directory would remove the other one's live temporary. This
1642 /// removes exactly the file this call created.
1643 fn discard(temporary: &Path, error: io::Error) -> io::Error {
1644 let _ = overwrite(temporary);
1645 match std::fs::remove_file(temporary) {
1646 Ok(()) => error,
1647 Err(removal) if removal.kind() == io::ErrorKind::NotFound => error,
1648 Err(removal) => io::Error::new(
1649 error.kind(),
1650 format!(
1651 "{error}. A temporary file holding the encrypted token was also left at \
1652 {} and could not be removed ({removal}); delete it by hand.",
1653 temporary.display()
1654 ),
1655 ),
1656 }
1657 }
1658
1659 /// Why an operator cannot read a store that is theirs, and the one command
1660 /// that gives it back.
1661 ///
1662 /// # The state this diagnoses
1663 ///
1664 /// A file that exists and that this account may not read. On this store
1665 /// that has one cause: the DACL grants `SY`, `BA` and `OW`, and the owner
1666 /// is whoever wrote last. A daemon under `LocalSystem` renewing the token
1667 /// became that owner, so `OW` stopped meaning the operator.
1668 ///
1669 /// # Why it names `icacls` and not `takeown`
1670 ///
1671 /// **Because `takeown` makes it permanent.** Changing an object's owner
1672 /// makes Windows delete its OWNER RIGHTS ACE, so taking ownership removes
1673 /// the one thing that would have granted the new owner access: they end up
1674 /// owning a file they cannot read, holding `READ_CONTROL` and `WRITE_DAC`
1675 /// and nothing else.
1676 ///
1677 /// This was offered as the repair on 2026-08-30 and appeared to work,
1678 /// because the `auth status` that followed ran in the same elevated prompt
1679 /// and succeeded through `BA`. The store read back:
1680 ///
1681 /// ```text
1682 /// O:S-1-5-21-...-1001 G:SY D:P(A;;FA;;;SY)(A;;FA;;;BA)
1683 /// ```
1684 ///
1685 /// The operator owns it. `OW` is gone. An unelevated read is still denied,
1686 /// and no renewal will ever bring it back.
1687 ///
1688 /// An explicit grant has none of that: it adds an ACE, changes no owner,
1689 /// deletes nothing, and is exactly the shape [`carried_grants`] then
1690 /// preserves on every later write.
1691 ///
1692 /// # Why a message and not a repair
1693 ///
1694 /// Naming an account in the DACL of the file holding this host's
1695 /// credential is not something a status command should do under an
1696 /// operator, and the account that can is an administrator, which this
1697 /// process may not be. So it says exactly what to run.
1698 fn locked_out(site: &Site, source: &io::Error) -> io::Error {
1699 io::Error::new(
1700 io::ErrorKind::PermissionDenied,
1701 format!(
1702 "{source}. The file exists but this account may not read it, which on this \
1703 store means its owner changed: the service renews the token under its own \
1704 account. Grant this account access explicitly from an elevated prompt -- it \
1705 returns immediately, and stays, because every later renewal carries the \
1706 grant forward:\n icacls \"{}\" /grant \"%USERNAME%:(F)\"\nDo NOT use \
1707 `takeown`: changing the owner makes Windows delete the OWNER RIGHTS ACE, \
1708 which leaves this account owning a file it still cannot read. An `auth \
1709 logout` followed by `auth login`, also elevated, is the heavier alternative \
1710 and costs a fresh sign-in.",
1711 site.file.display()
1712 ),
1713 )
1714 }
1715
1716 pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
1717 let blob = match std::fs::read(&site.file) {
1718 Ok(blob) => blob,
1719 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
1720 // Asked before the bare denial is returned, for the same reason
1721 // `cannot_replace` exists on the write path: `Access is denied` on
1722 // a file the operator owns the machine of is a true statement that
1723 // helps nobody.
1724 Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
1725 return Err(locked_out(site, &error));
1726 }
1727 Err(error) => return Err(error),
1728 };
1729 if blob.is_empty() {
1730 // An empty file is the remnant of an interrupted write, not a
1731 // value. Reported as `InvalidData` so the caller says `Corrupt`
1732 // rather than starting a fresh login over the top of it.
1733 return Err(io::Error::new(
1734 io::ErrorKind::InvalidData,
1735 "the stored file is empty",
1736 ));
1737 }
1738 unprotect(&blob).map(Some)
1739 }
1740
1741 pub(super) fn delete(site: &Site) -> io::Result<bool> {
1742 // Zeroed before it is unlinked, the same as the Linux backend. The
1743 // value here is a DPAPI blob rather than plaintext, so the fill buys
1744 // less; it costs one `write`, and it keeps one mechanism instead of two
1745 // on the path the Definition of Done calls "leaves no recoverable
1746 // remnant". `overwrite`'s documentation carries the disclaimer.
1747 let _ = overwrite(&site.file);
1748
1749 let removed = match std::fs::remove_file(&site.file) {
1750 Ok(()) => true,
1751 Err(error) if error.kind() == io::ErrorKind::NotFound => false,
1752 Err(error) => return Err(error),
1753 };
1754 if let Some(directory) = site.file.parent() {
1755 sweep_temporaries(directory);
1756 }
1757 Ok(removed)
1758 }
1759
1760 // -- DPAPI ---------------------------------------------------------------
1761
1762 /// Turns a windows-rs error into one whose [`io::Error::kind`] means
1763 /// something.
1764 ///
1765 /// `Error::code()` is an **HRESULT**, and handing that straight to
1766 /// `from_raw_os_error` is what an earlier version did. It produces an
1767 /// `io::Error` whose `raw_os_error` is `0x8007_0005` rather than `5`, so
1768 /// `kind()` is `Uncategorized` and a caller cannot tell "access denied"
1769 /// from anything else — which matters here, because the second operator
1770 /// case below is precisely an access denial a caller has to recognise.
1771 ///
1772 /// The `0x8007_xxxx` range is `HRESULT_FROM_WIN32`, so its low sixteen bits
1773 /// are the Win32 code and unwrapping them restores the classification.
1774 /// Anything outside that range is not a Win32 error and is passed through
1775 /// as it is.
1776 pub(super) fn io_error(error: &windows::core::Error) -> io::Error {
1777 let code = error.code().0;
1778 if (code as u32) & 0xffff_0000 == 0x8007_0000 {
1779 io::Error::from_raw_os_error(code & 0xffff)
1780 } else {
1781 io::Error::from_raw_os_error(code)
1782 }
1783 }
1784
1785 /// A blob descriptor over a buffer the caller keeps alive.
1786 fn blob_of(bytes: &mut [u8]) -> CRYPT_INTEGER_BLOB {
1787 CRYPT_INTEGER_BLOB {
1788 cbData: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
1789 pbData: bytes.as_mut_ptr(),
1790 }
1791 }
1792
1793 /// Copies `len` bytes out of `ptr` and zeroes the source.
1794 ///
1795 /// Split out from [`take_blob`] for one reason: a scrub that happens inside
1796 /// an `unsafe` block around a DPAPI buffer can be asserted by nothing —
1797 /// once `LocalFree` has run there is nothing left to look at. Given a
1798 /// pointer, the scrub is a property a test can check against a buffer Rust
1799 /// owns, which is what
1800 /// [`tests::windows::the_dpapi_buffer_is_scrubbed_before_it_is_freed`]
1801 /// does.
1802 ///
1803 /// # Safety
1804 ///
1805 /// `ptr` must be valid for reads *and writes* of `len` bytes, and must not
1806 /// be aliased for the duration of the call.
1807 pub(super) unsafe fn copy_and_scrub(ptr: *mut u8, len: usize) -> Vec<u8> {
1808 use secrecy::zeroize::Zeroize as _;
1809
1810 // SAFETY: the caller guarantees `ptr` is valid for `len` bytes and
1811 // unaliased, so one exclusive slice over it is sound.
1812 let source = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
1813 let copy = source.to_vec();
1814 source.zeroize();
1815 copy
1816 }
1817
1818 /// Copies a DPAPI-allocated output blob into a `Vec`, scrubs the original,
1819 /// and frees it.
1820 ///
1821 /// The scrub is not optional and is not only for tidiness. On the
1822 /// [`unprotect`] path this buffer holds **the user access token in
1823 /// plaintext**, and `LocalFree` returns it to the process heap exactly as
1824 /// it is — where a later allocation, a crash dump, or a core file can pick
1825 /// it up. Every other copy this module makes is scrubbed (`protect`'s
1826 /// input, `decode`'s rejected bytes); this one was the exception.
1827 ///
1828 /// It runs on the [`protect`] path too, where the buffer is ciphertext and
1829 /// the scrub buys nothing. Making it conditional would buy a branch and an
1830 /// opportunity to get the condition wrong.
1831 ///
1832 /// # Safety
1833 ///
1834 /// `out` must be an output blob DPAPI filled in and that has not yet been
1835 /// freed.
1836 unsafe fn take_blob(out: &mut CRYPT_INTEGER_BLOB) -> Vec<u8> {
1837 if out.pbData.is_null() {
1838 return Vec::new();
1839 }
1840 // SAFETY: DPAPI filled this blob in, so the pointer is valid for
1841 // `cbData` bytes and nothing else holds a reference to it.
1842 let bytes = unsafe { copy_and_scrub(out.pbData, out.cbData as usize) };
1843 // SAFETY: the buffer was `LocalAlloc`ed by DPAPI and is freed once.
1844 unsafe {
1845 let _ = LocalFree(Some(HLOCAL(out.pbData.cast())));
1846 }
1847 out.pbData = std::ptr::null_mut();
1848 out.cbData = 0;
1849 bytes
1850 }
1851
1852 fn protect(plaintext: &[u8], scope: SecretScope) -> io::Result<Vec<u8>> {
1853 use secrecy::zeroize::Zeroize as _;
1854
1855 let mut input = plaintext.to_vec();
1856 let mut entropy = ENTROPY.to_vec();
1857 let input_blob = blob_of(&mut input);
1858 let entropy_blob = blob_of(&mut entropy);
1859 let mut out = CRYPT_INTEGER_BLOB::default();
1860
1861 // `CRYPTPROTECT_UI_FORBIDDEN` on both scopes, without exception. A
1862 // daemon that starts at boot has no desktop to draw a prompt on, and a
1863 // call blocked waiting for one is indistinguishable from a hang.
1864 let flags = CRYPTPROTECT_UI_FORBIDDEN
1865 | match scope {
1866 SecretScope::Machine => CRYPTPROTECT_LOCAL_MACHINE,
1867 SecretScope::User => 0,
1868 };
1869
1870 // SAFETY: both input descriptors point into `input` and `entropy`,
1871 // which outlive the call; `out` is a zeroed descriptor DPAPI fills in
1872 // and `take_blob` frees exactly once.
1873 let result = unsafe {
1874 CryptProtectData(
1875 &raw const input_blob,
1876 PCWSTR::null(),
1877 Some(&raw const entropy_blob),
1878 None,
1879 None,
1880 flags,
1881 &raw mut out,
1882 )
1883 };
1884
1885 // The plaintext copy this function made is scrubbed on every path out,
1886 // the failure path included.
1887 input.zeroize();
1888
1889 result.map_err(|error| io_error(&error))?;
1890 // SAFETY: `CryptProtectData` returned success, so `out` is a blob it
1891 // allocated and has not freed.
1892 Ok(unsafe { take_blob(&mut out) })
1893 }
1894
1895 fn unprotect(blob: &[u8]) -> io::Result<Vec<u8>> {
1896 let mut input = blob.to_vec();
1897 let mut entropy = ENTROPY.to_vec();
1898 let input_blob = blob_of(&mut input);
1899 let entropy_blob = blob_of(&mut entropy);
1900 let mut out = CRYPT_INTEGER_BLOB::default();
1901
1902 // SAFETY: as `protect`.
1903 let result = unsafe {
1904 CryptUnprotectData(
1905 &raw const input_blob,
1906 None,
1907 Some(&raw const entropy_blob),
1908 None,
1909 None,
1910 CRYPTPROTECT_UI_FORBIDDEN,
1911 &raw mut out,
1912 )
1913 };
1914
1915 match result {
1916 // SAFETY: success, so `out` is DPAPI's and unfreed.
1917 Ok(()) => Ok(unsafe { take_blob(&mut out) }),
1918 // Every way this fails means one thing to a caller: there are bytes
1919 // here and they are not a value this store can read back. Reported
1920 // as `InvalidData` so it surfaces as `Corrupt` and sends the
1921 // operator to `auth logout`, rather than as a transient read
1922 // failure that something would retry forever.
1923 Err(error) => Err(io::Error::new(
1924 io::ErrorKind::InvalidData,
1925 format!(
1926 "the stored bytes could not be unprotected with this machine's DPAPI key \
1927 ({error})"
1928 ),
1929 )),
1930 }
1931 }
1932
1933 // -- The protected file --------------------------------------------------
1934
1935 fn to_wide(path: &Path) -> Vec<u16> {
1936 path.as_os_str()
1937 .encode_wide()
1938 .chain(std::iter::once(0))
1939 .collect()
1940 }
1941
1942 /// Creates a new file carrying `descriptor` from the moment it exists.
1943 ///
1944 /// Takes the SDDL rather than the scope because a replacement's DACL is
1945 /// [`sddl`] plus the previous owner — see [`replacement_sddl`] — and a file
1946 /// that is created and then widened is unreadable to the account it is
1947 /// being widened for for however long the gap lasts.
1948 fn create_protected_file(path: &Path, descriptor: &str) -> io::Result<File> {
1949 let sddl_wide: Vec<u16> = descriptor
1950 .encode_utf16()
1951 .chain(std::iter::once(0))
1952 .collect();
1953
1954 let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
1955 // SAFETY: `sddl_wide` is a NUL-terminated UTF-16 buffer that outlives
1956 // the call, and `descriptor` receives a LocalAlloc'd descriptor freed
1957 // below on both paths.
1958 unsafe {
1959 ConvertStringSecurityDescriptorToSecurityDescriptorW(
1960 PCWSTR(sddl_wide.as_ptr()),
1961 SDDL_REVISION_1,
1962 &mut descriptor,
1963 None,
1964 )
1965 }
1966 .map_err(|error| io_error(&error))?;
1967
1968 let attributes = SECURITY_ATTRIBUTES {
1969 nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
1970 lpSecurityDescriptor: descriptor.0,
1971 // False: no child process this agent spawns has any business
1972 // inheriting a handle to the token.
1973 bInheritHandle: windows::core::BOOL(0),
1974 };
1975
1976 let wide = to_wide(path);
1977 // SAFETY: `wide` is NUL-terminated and outlives the call; `attributes`
1978 // points at a descriptor that is still live here.
1979 let handle = unsafe {
1980 CreateFileW(
1981 PCWSTR(wide.as_ptr()),
1982 FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
1983 FILE_SHARE_NONE,
1984 Some(&raw const attributes),
1985 CREATE_NEW,
1986 FILE_ATTRIBUTE_NORMAL,
1987 None,
1988 )
1989 };
1990
1991 // SAFETY: the descriptor was LocalAlloc'd by the conversion above and
1992 // is freed exactly once, after the last use of `attributes`.
1993 unsafe {
1994 let _ = LocalFree(Some(HLOCAL(descriptor.0)));
1995 }
1996
1997 let handle = handle.map_err(|error| io_error(&error))?;
1998 // SAFETY: `CreateFileW` returned success, so the handle is a valid
1999 // owned file handle this `File` takes over.
2000 Ok(unsafe { File::from_raw_handle(handle.0) })
2001 }
2002}
2003
2004// ---------------------------------------------------------------------------
2005
2006/// The System keychain's refusal of an ordinary account, explained.
2007///
2008/// `auth login` without `--start-at` on a Mac with no service used to reach
2009/// for this store and print only `SecKeychainItemCreateFromContent returned
2010/// -61`, after the operator had already approved the device code.
2011#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
2012fn system_keychain_write_refused(source: &str) -> String {
2013 format!(
2014 "{source}. The machine-scoped store is the System keychain, and only root may write \
2015 it. It holds the credential for a service that starts at boot; sign in for that one \
2016 with `{}`. For a service that runs as you \
2017 (`runner-manager service install --start-at login`), sign in to your own login \
2018 keychain instead: `runner-manager auth login --start-at login`",
2019 crate::service::sudo_command("auth login --start-at boot")
2020 )
2021}
2022
2023#[cfg(target_os = "macos")]
2024mod sys {
2025 //! A generic-password item in a keychain, and which keychain is the whole
2026 //! of the scope.
2027 //!
2028 //! | scope | keychain | why |
2029 //! |---|---|---|
2030 //! | [`SecretScope::Machine`] | `/Library/Keychains/System.keychain` | the only keychain a process with no login session can open. A LaunchAgent starts at login; a LaunchDaemon starts at boot and has no user keychain to reach for. |
2031 //! | [`SecretScope::User`] | `~/Library/Keychains/login.keychain-db` | the operator's own keychain, unlocked by their login, gone when they log out. |
2032 //!
2033 //! # Why the login keychain is opened by path
2034 //!
2035 //! `SecKeychainCopyDefault` would give the account's *current* default
2036 //! keychain, which is more nearly what an operator expects. It hands back a
2037 //! handle and no path — and this store has to be able to name the file
2038 //! whose mode protects the value, both for `host show` and for
2039 //! [`super::SecretStore::protection`]. A store that could not say what
2040 //! protects it would be asserting the security property by assumption.
2041 //! So the login keychain is resolved by path, `login.keychain-db` first and
2042 //! the pre-Sierra `login.keychain` second, and an operator who has moved
2043 //! their default elsewhere gets the store in their login keychain rather
2044 //! than wherever the default now points.
2045 //!
2046 //! # User interaction is disabled around every call
2047 //!
2048 //! `SecKeychainSetUserInteractionAllowed(false)` for the duration of each
2049 //! operation, unconditionally. A daemon started at boot has no desktop, and
2050 //! a keychain call that decides to draw an unlock panel there does not fail
2051 //! — it waits. `errSecInteractionNotAllowed` returned in a second is a
2052 //! diagnosable condition; a process wedged behind an invisible dialog is
2053 //! not.
2054 //!
2055 //! # What a rooted keychain is for
2056 //!
2057 //! [`super::PlatformSecretStore::rooted_at`] creates a keychain of its own
2058 //! under the caller's root. That is the only way the suite can exercise
2059 //! this backend at all: writing to `/Library/Keychains/System.keychain`
2060 //! needs `root`, and writing to the login keychain would destroy a
2061 //! developer's real `auth login` every time the tests ran. What it covers
2062 //! and what it does not is set out on `rooted_at` itself.
2063
2064 use std::io;
2065 use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
2066 use std::path::{Path, PathBuf};
2067
2068 use security_framework::os::macos::keychain::{CreateOptions, KeychainSettings, SecKeychain};
2069
2070 use super::{
2071 DIRECTORY, ITEM, KEYCHAIN_SERVICE, ROOTED_KEYCHAIN_PASSWORD, SecretScope, StartMode,
2072 };
2073
2074 /// The composed product identity, as a plain `&str`.
2075 ///
2076 /// [`KEYCHAIN_SERVICE`] is a `LazyLock<String>` so that it is built from
2077 /// `crate::paths`'s three segments rather than written out a second time.
2078 /// This is the one place that unwraps it, so the call sites below read as
2079 /// they did when it was a constant.
2080 pub(super) fn service() -> &'static str {
2081 &KEYCHAIN_SERVICE
2082 }
2083
2084 /// `errSecItemNotFound`. Hard-coded rather than imported because
2085 /// `security-framework-sys` is not a dependency of this workspace and
2086 /// adding one would be an A-group change for a single integer.
2087 const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
2088 /// `errSecNoSuchKeychain`. What a keychain file that is not there answers
2089 /// with, which is absence rather than failure.
2090 const ERR_SEC_NO_SUCH_KEYCHAIN: i32 = -25294;
2091
2092 /// The System Keychain's master key. Root-only, and the reason an
2093 /// unprivileged local user cannot read a machine-scoped item even though
2094 /// the keychain database beside it is world-readable.
2095 const SYSTEM_KEYCHAIN_MASTER_KEY: &str = "/var/db/SystemKey";
2096 /// The machine-scoped keychain itself.
2097 const SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
2098
2099 /// Which keychain, and where it is.
2100 #[derive(Debug, Clone, PartialEq, Eq)]
2101 pub(super) struct Site {
2102 path: PathBuf,
2103 kind: Kind,
2104 /// The keychain account the value is stored under.
2105 item: &'static str,
2106 }
2107
2108 pub(super) fn with_item(site: Site, item: &'static str) -> Site {
2109 Site { item, ..site }
2110 }
2111
2112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
2113 enum Kind {
2114 /// The System Keychain. Written by `root`, readable by a LaunchDaemon.
2115 System,
2116 /// The operator's login keychain.
2117 Login,
2118 /// A keychain this program created under a caller-named root.
2119 Rooted,
2120 }
2121
2122 pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
2123 match scope {
2124 SecretScope::Machine => Ok(Site {
2125 path: PathBuf::from(SYSTEM_KEYCHAIN),
2126 kind: Kind::System,
2127 item: ITEM,
2128 }),
2129 SecretScope::User => {
2130 let home = directories::BaseDirs::new()
2131 .ok_or_else(|| {
2132 "the operating system reports no home directory for this account, so \
2133 the login keychain cannot be resolved. A service account configured \
2134 with no profile normally hits this; use the machine-scoped store, \
2135 which is what --start-at boot installs."
2136 .to_string()
2137 })?
2138 .home_dir()
2139 .join("Library")
2140 .join("Keychains");
2141
2142 // Sierra renamed the login keychain and kept the old one
2143 // working, so a machine upgraded across that boundary can have
2144 // either. Prefer the modern name, fall back to the legacy one
2145 // only when it is the one that actually exists, and resolve to
2146 // the modern name when neither does so that a first `auth
2147 // login` creates the right thing.
2148 let modern = home.join("login.keychain-db");
2149 let legacy = home.join("login.keychain");
2150 let path = if modern.exists() || !legacy.exists() {
2151 modern
2152 } else {
2153 legacy
2154 };
2155 Ok(Site {
2156 path,
2157 kind: Kind::Login,
2158 item: ITEM,
2159 })
2160 }
2161 }
2162 }
2163
2164 pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
2165 // A keychain per scope, for the reason the Windows and Linux backends
2166 // give a directory per scope: under one caller-named root the two
2167 // stores have nothing else keeping them apart, and two stores sharing
2168 // one item means `auth logout` under either scope purges both.
2169 Ok(Site {
2170 path: root
2171 .join(DIRECTORY)
2172 .join(scope.as_str())
2173 .join("runner-manager.keychain-db"),
2174 kind: Kind::Rooted,
2175 item: ITEM,
2176 })
2177 }
2178
2179 pub(super) fn describe(site: &Site) -> String {
2180 let kind = match site.kind {
2181 Kind::System => "System",
2182 Kind::Login => "login",
2183 Kind::Rooted => "rooted",
2184 };
2185 format!(
2186 "{kind} keychain {}, item {}/{}",
2187 site.path.display(),
2188 service(),
2189 site.item
2190 )
2191 }
2192
2193 pub(super) fn guard(site: &Site) -> PathBuf {
2194 match site.kind {
2195 // Not the keychain database. `/Library/Keychains/System.keychain`
2196 // is world-readable and its contents are encrypted; the mode that
2197 // decides who can decrypt them belongs to the master key beside it.
2198 // Reporting the database here would answer a question nobody asked,
2199 // and answer it wrongly.
2200 Kind::System => PathBuf::from(SYSTEM_KEYCHAIN_MASTER_KEY),
2201 Kind::Login | Kind::Rooted => site.path.clone(),
2202 }
2203 }
2204
2205 /// `errSecWrPerm`: the keychain refused to be written.
2206 const ERR_SEC_WR_PERM: i32 = -61;
2207
2208 /// A Security.framework status, carried inside the `io::Error` so that a
2209 /// caller acts on the code rather than on the words.
2210 #[derive(Debug)]
2211 struct Status {
2212 message: String,
2213 code: i32,
2214 }
2215
2216 impl std::fmt::Display for Status {
2217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2218 f.write_str(&self.message)
2219 }
2220 }
2221
2222 impl std::error::Error for Status {}
2223
2224 /// The status an error from this module carries, if it carries one.
2225 fn status_of(error: &io::Error) -> Option<i32> {
2226 error
2227 .get_ref()?
2228 .downcast_ref::<Status>()
2229 .map(|status| status.code)
2230 }
2231
2232 fn sec_error(error: &security_framework::base::Error) -> io::Error {
2233 io::Error::other(Status {
2234 message: format!("Security.framework returned {} ({error})", error.code()),
2235 code: error.code(),
2236 })
2237 }
2238
2239 fn is_absence(error: &security_framework::base::Error) -> bool {
2240 matches!(
2241 error.code(),
2242 ERR_SEC_ITEM_NOT_FOUND | ERR_SEC_NO_SUCH_KEYCHAIN
2243 )
2244 }
2245
2246 /// `errSecDuplicateItem`, which this store only ever meets in one
2247 /// situation. See [`replace_unreadable`].
2248 const ERR_SEC_DUPLICATE_ITEM: i32 = -25299;
2249
2250 /// `errSecAuthFailed`, which a keychain answers when the item is there and
2251 /// the caller may not have it — because the ACL does not name the program
2252 /// asking, or because the keychain's master key is out of this account's
2253 /// reach. [`locked_out`] tells those two apart.
2254 const ERR_SEC_AUTH_FAILED: i32 = -25293;
2255
2256 fn is_duplicate(error: &security_framework::base::Error) -> bool {
2257 error.code() == ERR_SEC_DUPLICATE_ITEM
2258 }
2259
2260 /// Whether this process is `root`.
2261 ///
2262 /// Only ever used to *explain* a refusal, never to decide whether to
2263 /// attempt one: the keychain is the authority on what this process may
2264 /// read, and a privilege check of our own that disagreed with it would be a
2265 /// second, wrong answer.
2266 fn is_root() -> bool {
2267 // SAFETY: `geteuid` takes no argument, reads only this process's own
2268 // credentials, and is documented never to fail.
2269 unsafe { libc::geteuid() == 0 }
2270 }
2271
2272 /// Removes the item without reading it, so that a value this program may
2273 /// not read can still be replaced.
2274 ///
2275 /// # Why this is needed at all
2276 ///
2277 /// A keychain grants access per *application*, and the daemon runs a copy
2278 /// of the binary that `service install` makes. Replacing that copy — which
2279 /// is what an upgrade is — produces a program the item's ACL does not name,
2280 /// and it reads `-25293` from the credential it is supposed to own.
2281 ///
2282 /// `auth login` is the remedy, and before this it could not be: the
2283 /// crate's `set_generic_password` is
2284 ///
2285 /// ```text
2286 /// match self.find_generic_password(service, account) {
2287 /// Ok((_, mut item)) => item.set_password(password),
2288 /// _ => self.add_generic_password(service, account, password),
2289 /// }
2290 /// ```
2291 ///
2292 /// so a `find` refused by the ACL falls through to `add`, and `add` meets
2293 /// the item that is already there: **`-25299`**. Update refused because the
2294 /// item cannot be read, add refused because it exists. A device flow that
2295 /// had already completed then had nowhere to put the token it obtained,
2296 /// and the refresh half went with it.
2297 ///
2298 /// Watched on 2026-08-30, upgrading a real host from 0.1.12 to 0.1.15.
2299 ///
2300 /// # Why deleting works where updating does not
2301 ///
2302 /// `SecItemDelete` matches on the query — class, keychain, service,
2303 /// account — and never asks for the data, so the ACL that guards *reading*
2304 /// does not apply. The value is being replaced either way, which is the
2305 /// only reason destroying it is the right move here.
2306 fn replace_unreadable(keychain: &SecKeychain, account: &str) -> io::Result<()> {
2307 delete_by_query(keychain, account).map_err(|error| sec_error(&error))
2308 }
2309
2310 /// The delete itself, with the platform error kept intact.
2311 ///
2312 /// Separate from [`replace_unreadable`] only so that
2313 /// [`remove_any_existing`] can read the status code rather than the
2314 /// rendered message.
2315 fn delete_by_query(
2316 keychain: &SecKeychain,
2317 account: &str,
2318 ) -> Result<(), security_framework::base::Error> {
2319 use security_framework::item::{ItemClass, ItemSearchOptions};
2320
2321 ItemSearchOptions::new()
2322 .class(ItemClass::generic_password())
2323 .keychains(std::slice::from_ref(keychain))
2324 .service(service())
2325 .account(account)
2326 .delete()
2327 }
2328
2329 /// [`replace_unreadable`], for a caller that does not know whether there is
2330 /// anything there.
2331 ///
2332 /// Nothing to delete is the ordinary state of a first `auth login`, and is
2333 /// success.
2334 fn remove_any_existing(keychain: &SecKeychain, account: &str) -> io::Result<()> {
2335 match delete_by_query(keychain, account) {
2336 Ok(()) => Ok(()),
2337 Err(error) if is_absence(&error) => Ok(()),
2338 Err(error) => Err(sec_error(&error)),
2339 }
2340 }
2341
2342 /// Suppresses keychain UI for as long as the returned guards live, and keeps
2343 /// this process's other keychain operations out meanwhile.
2344 ///
2345 /// The setting is process-wide, so without that one thread's guard would
2346 /// turn the interaction back on under another thread's operation.
2347 ///
2348 /// Best effort: a platform that refuses the call is not a reason to fail
2349 /// the operation, only a reason not to have the guarantee. The guard
2350 /// re-enables interaction on drop, so this never leaks process-wide state
2351 /// past the call that took it.
2352 fn without_user_interaction() -> (
2353 std::sync::MutexGuard<'static, ()>,
2354 Option<security_framework::os::macos::keychain::KeychainUserInteractionLock>,
2355 ) {
2356 static ONE_AT_A_TIME: std::sync::Mutex<()> = std::sync::Mutex::new(());
2357 let turn = ONE_AT_A_TIME
2358 .lock()
2359 .unwrap_or_else(std::sync::PoisonError::into_inner);
2360 (turn, SecKeychain::disable_user_interaction().ok())
2361 }
2362
2363 /// Opens the keychain, creating a rooted one if asked and if it is missing.
2364 ///
2365 /// `Ok(None)` means "there is no such keychain", which for a read is
2366 /// absence rather than failure.
2367 fn open(site: &Site, create_if_missing: bool) -> io::Result<Option<SecKeychain>> {
2368 match site.kind {
2369 Kind::System | Kind::Login => {
2370 if !site.path.exists() {
2371 return Ok(None);
2372 }
2373 SecKeychain::open(&site.path)
2374 .map(Some)
2375 .map_err(|error| sec_error(&error))
2376 }
2377 Kind::Rooted if site.path.exists() => {
2378 let mut keychain =
2379 SecKeychain::open(&site.path).map_err(|error| sec_error(&error))?;
2380 keychain
2381 .unlock(Some(ROOTED_KEYCHAIN_PASSWORD))
2382 .map_err(|error| sec_error(&error))?;
2383 Ok(Some(keychain))
2384 }
2385 Kind::Rooted if create_if_missing => Ok(Some(create_rooted(site)?)),
2386 Kind::Rooted => Ok(None),
2387 }
2388 }
2389
2390 fn create_rooted(site: &Site) -> io::Result<SecKeychain> {
2391 let directory = site
2392 .path
2393 .parent()
2394 .ok_or_else(|| io::Error::other("the rooted keychain path has no parent directory"))?;
2395 // `0700` at `mkdir(2)` time, for the reason `crate::paths` gives: a
2396 // two-step create-then-chmod leaves a window, and here the window is
2397 // over the directory whose mode is the only thing protecting a rooted
2398 // keychain.
2399 std::fs::DirBuilder::new()
2400 .mode(0o700)
2401 .recursive(true)
2402 .create(directory)?;
2403 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
2404
2405 let mut keychain = CreateOptions::new()
2406 .password(ROOTED_KEYCHAIN_PASSWORD)
2407 // Never. A daemon has no operator to ask.
2408 .prompt_user(false)
2409 .create(&site.path)
2410 .map_err(|error| sec_error(&error))?;
2411
2412 // No auto-lock and no lock on sleep: an agent that has been running for
2413 // a week must not find its own store locked. The keychain is inside a
2414 // `0700` directory, so locking would add nothing an unprivileged local
2415 // user could defeat anyway.
2416 let mut settings = KeychainSettings::new();
2417 settings.set_lock_on_sleep(false);
2418 settings.set_lock_interval(None);
2419 keychain
2420 .set_settings(&settings)
2421 .map_err(|error| sec_error(&error))?;
2422
2423 // `SecKeychainCreate` is documented to take a POSIX path and is
2424 // expected to use it verbatim; the `-db` suffix `security
2425 // create-keychain` is known for is appended to a *bare name*, and the
2426 // name above already carries it. If that expectation is ever wrong the
2427 // next line fails with a bare `NotFound` on a path nobody would think
2428 // to look at, so say what actually appeared instead.
2429 if !site.path.exists() {
2430 return Err(io::Error::other(format!(
2431 "SecKeychainCreate reported success but there is no keychain at {}. {} holds {:?}",
2432 site.path.display(),
2433 directory.display(),
2434 std::fs::read_dir(directory)
2435 .map(|entries| entries
2436 .flatten()
2437 .map(|entry| entry.file_name().to_string_lossy().into_owned())
2438 .collect::<Vec<_>>())
2439 .unwrap_or_default()
2440 )));
2441 }
2442
2443 restrict(site)?;
2444 Ok(keychain)
2445 }
2446
2447 /// Sets a keychain this program created to `0600`.
2448 ///
2449 /// Called after creation *and* after every write. A keychain database is
2450 /// rewritten by `securityd` rather than by this process, and a rewrite that
2451 /// went through a fresh file would take the umask's mode rather than the
2452 /// one set at creation — which is the mode this store's entire access
2453 /// control rests on for a rooted keychain, and which
2454 /// `the_guard_is_0600_and_its_directory_is_0700` asserts.
2455 ///
2456 /// Never applied to the System or login keychains: their modes are the
2457 /// operating system's business, not this program's.
2458 fn restrict(site: &Site) -> io::Result<()> {
2459 if site.kind != Kind::Rooted {
2460 return Ok(());
2461 }
2462 std::fs::set_permissions(&site.path, std::fs::Permissions::from_mode(0o600))
2463 }
2464
2465 /// The keychain database is rewritten beside itself, in this process, so
2466 /// its directory is what a write needs. Root's alone for the System
2467 /// keychain -- which every local account can nevertheless *read* an item
2468 /// from, once it is granted to every application.
2469 pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
2470 super::ensure_directory_writable(&site.path)
2471 }
2472
2473 pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
2474 write(site, plaintext).map_err(|error| {
2475 if site.kind == Kind::System && !is_root() && status_of(&error) == Some(ERR_SEC_WR_PERM)
2476 {
2477 io::Error::new(
2478 io::ErrorKind::PermissionDenied,
2479 super::system_keychain_write_refused(&error.to_string()),
2480 )
2481 } else {
2482 error
2483 }
2484 })
2485 }
2486
2487 fn write(site: &Site, plaintext: &[u8]) -> io::Result<()> {
2488 let _no_ui = without_user_interaction();
2489 let keychain = open(site, true)?.ok_or_else(|| {
2490 io::Error::new(
2491 io::ErrorKind::NotFound,
2492 format!(
2493 "there is no keychain at {}. The machine-scoped store needs the System \
2494 Keychain, which only root may write; the user-scoped store needs a login \
2495 keychain, which exists once the account has logged in.",
2496 site.path.display()
2497 ),
2498 )
2499 })?;
2500
2501 if grants_every_application(site) {
2502 // Removed and written afresh, never updated. An item keeps the
2503 // access it was created with, so `set_generic_password` on an
2504 // existing item would leave the per-binary grant in place — which
2505 // is the whole condition this branch exists to end. Deleting by
2506 // query needs no permission to *read* the value, so it works even
2507 // against an item this program is already locked out of.
2508 remove_any_existing(&keychain, site.item)?;
2509 add_granted_to_every_application(&keychain, site.item, plaintext)?;
2510 } else {
2511 match keychain.set_generic_password(service(), site.item, plaintext) {
2512 Ok(()) => {}
2513 // The item is there and this program may not read it, so the
2514 // crate's update-or-add could do neither. Take it away and
2515 // write a fresh one, which is what replacing a credential means
2516 // anyway.
2517 Err(error) if is_duplicate(&error) => {
2518 replace_unreadable(&keychain, site.item)?;
2519 keychain
2520 .set_generic_password(service(), site.item, plaintext)
2521 .map_err(|error| sec_error(&error))?;
2522 }
2523 Err(error) => return Err(sec_error(&error)),
2524 }
2525 }
2526
2527 restrict(site)
2528 }
2529
2530 // -----------------------------------------------------------------------
2531 // Who the stored item is granted to
2532 // -----------------------------------------------------------------------
2533
2534 /// The name the keychain shows for this grant, if it ever shows one.
2535 const ACCESS_DESCRIPTOR: &str = "runner-manager user access token";
2536
2537 /// Whether items in this keychain are granted to **every** application
2538 /// rather than to the one binary that wrote them.
2539 ///
2540 /// # The failure this ends
2541 ///
2542 /// A keychain ACL names *applications*, and identifies an unsigned one by
2543 /// its code directory hash — so replacing the binary produces a program the
2544 /// ACL does not name. Replacing the binary is what an upgrade is. The
2545 /// daemon then reads `errSecAuthFailed` from the credential it is supposed
2546 /// to own, exits `13`, and launchd restarts it every fifteen seconds
2547 /// forever; watched on a real host upgrading 0.1.12 to 0.1.15, and again on
2548 /// 0.1.16 to 0.1.17.
2549 ///
2550 /// The remedy the store used to print — sign in again, from a graphical
2551 /// terminal, with the exact copy of the binary that will do the reading —
2552 /// works exactly once, until the next upgrade. It is not a remedy, it is a
2553 /// recurring outage with instructions.
2554 ///
2555 /// # Why widening the grant costs nothing on these two keychains
2556 ///
2557 /// The per-application ACL is only a boundary where something else is
2558 /// **not** already the boundary, and on both of these it is:
2559 ///
2560 /// * The **System Keychain** is decrypted with `/var/db/SystemKey`, which
2561 /// is `root`-only. Every process that can read an item there is already
2562 /// `root`, and `07-security.md` records that *"a local administrator or
2563 /// `root` can read the token"* as an accepted trade-off of machine-scoped
2564 /// storage. Granting every application does not admit one caller that the
2565 /// master key was keeping out.
2566 /// * A **rooted** keychain is protected by the `0700` directory it sits in
2567 /// and is unlocked with [`ROOTED_KEYCHAIN_PASSWORD`], a constant in a
2568 /// public binary. Its ACL was never the thing holding anybody out either.
2569 ///
2570 /// The **login** keychain is the one place where the ACL is a real boundary
2571 /// — every process running as the operator can reach that keychain, and the
2572 /// per-application grant is what stops one of them reading this token
2573 /// silently. So it keeps the default.
2574 ///
2575 /// Widening would not help there anyway. The login keychain also records
2576 /// the *partition* of the code that created an item -- `cdhash:<hash>` for
2577 /// an ad-hoc signed binary -- and refuses every other partition without a
2578 /// prompt, whatever the application list says. Measured on macOS 27 with
2579 /// two ad-hoc builds of one program: an item granted to every application
2580 /// in the login keychain was refused to the second build, and the same item
2581 /// in a keychain of its own was not. What survives an upgrade there is the
2582 /// new build writing the item itself, which the daemon arranges while it
2583 /// upgrades (`hand_over_credential_to_new_binary` in the application).
2584 fn grants_every_application(site: &Site) -> bool {
2585 match site.kind {
2586 Kind::System | Kind::Rooted => true,
2587 Kind::Login => false,
2588 }
2589 }
2590
2591 /// Adds the item with its access already decided.
2592 ///
2593 /// # Why not add it and then widen it
2594 ///
2595 /// That was the first shape of this, and it deadlocks a headless process.
2596 /// `SecKeychainItemSetAccess` *changes* an existing item's access control,
2597 /// which is itself an ACL-guarded operation, and macOS asks the person at
2598 /// the desk to confirm it — through Authorization Services, which
2599 /// `SecKeychainSetUserInteractionAllowed(false)` does not suppress. The
2600 /// call does not fail; `SecurityAgent` starts and the process waits behind
2601 /// a panel that a daemon has no desktop to draw on. Watched here: the store
2602 /// hung indefinitely with `SecurityAgent` running.
2603 ///
2604 /// Supplying the access at creation asks nobody anything, because there is
2605 /// no existing access to be authorized against. It is also what
2606 /// `security add-generic-password -A` does, and for the same reason.
2607 fn add_granted_to_every_application(
2608 keychain: &SecKeychain,
2609 account: &str,
2610 plaintext: &[u8],
2611 ) -> io::Result<()> {
2612 use core_foundation::base::TCFType as _;
2613
2614 let access = AnyApplicationAccess::create()?;
2615
2616 // The two attributes that identify a generic password. Both buffers
2617 // outlive the call below, which is the whole of the safety argument for
2618 // the raw pointers in the attribute list.
2619 let service_name = service();
2620
2621 let mut attributes = [
2622 ffi::SecKeychainAttribute {
2623 tag: ffi::SEC_SERVICE_ITEM_ATTR,
2624 length: u32::try_from(service_name.len()).map_err(|_| {
2625 io::Error::other(
2626 "the keychain service name is longer than an attribute can carry",
2627 )
2628 })?,
2629 data: service_name.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
2630 },
2631 ffi::SecKeychainAttribute {
2632 tag: ffi::SEC_ACCOUNT_ITEM_ATTR,
2633 length: u32::try_from(account.len()).map_err(|_| {
2634 io::Error::other(
2635 "the keychain account name is longer than an attribute can carry",
2636 )
2637 })?,
2638 data: account.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
2639 },
2640 ];
2641 let mut list = ffi::SecKeychainAttributeList {
2642 count: 2,
2643 attr: attributes.as_mut_ptr(),
2644 };
2645 let length = u32::try_from(plaintext.len())
2646 .map_err(|_| io::Error::other("the value is longer than a keychain item can hold"))?;
2647
2648 // SAFETY: the attribute list, its two buffers and `plaintext` all
2649 // outlive the call; `keychain` and `access` are live references; the
2650 // item is not asked for, so nothing is returned that must be released.
2651 let status = unsafe {
2652 ffi::SecKeychainItemCreateFromContent(
2653 ffi::SEC_GENERIC_PASSWORD_ITEM_CLASS,
2654 &raw mut list,
2655 length,
2656 plaintext.as_ptr().cast(),
2657 keychain.as_concrete_TypeRef().cast(),
2658 access.raw(),
2659 std::ptr::null_mut(),
2660 )
2661 };
2662 os_status("SecKeychainItemCreateFromContent", status)
2663 }
2664
2665 /// A `SecAccess` whose every access-control entry names no application,
2666 /// which is how Security.framework spells *"any application, without
2667 /// prompting"*.
2668 ///
2669 /// `SecAccessCreate` with a null trusted list produces the system default:
2670 /// entries naming just the calling application. Each is then rewritten with
2671 /// a null application list, keeping the prompt text and selector the system
2672 /// chose. This is what `security add-generic-password -A` does, in the same
2673 /// order.
2674 struct AnyApplicationAccess(ffi::SecAccessRef);
2675
2676 impl AnyApplicationAccess {
2677 fn create() -> io::Result<Self> {
2678 use core_foundation::base::TCFType as _;
2679 use core_foundation::string::CFString;
2680
2681 let descriptor = CFString::new(ACCESS_DESCRIPTOR);
2682 let mut access: ffi::SecAccessRef = std::ptr::null_mut();
2683 // SAFETY: `descriptor` outlives the call; a null trusted list is
2684 // the documented way to ask for the default entries; `access` is a
2685 // valid out-pointer and is only read when the call reports success.
2686 let status = unsafe {
2687 ffi::SecAccessCreate(
2688 descriptor.as_concrete_TypeRef().cast(),
2689 std::ptr::null(),
2690 &raw mut access,
2691 )
2692 };
2693 os_status("SecAccessCreate", status)?;
2694 if access.is_null() {
2695 return Err(io::Error::other(
2696 "SecAccessCreate reported success and produced no access",
2697 ));
2698 }
2699 let owned = Self(access);
2700 owned.widen()?;
2701 Ok(owned)
2702 }
2703
2704 fn raw(&self) -> ffi::SecAccessRef {
2705 self.0
2706 }
2707
2708 /// Rewrites every entry to name no application.
2709 fn widen(&self) -> io::Result<()> {
2710 let mut list: ffi::CFArrayRef = std::ptr::null();
2711 // SAFETY: `self.0` is a live `SecAccessRef`; `list` is a valid
2712 // out-pointer, and the array it returns is owned by this call.
2713 let status = unsafe { ffi::SecAccessCopyACLList(self.0, &raw mut list) };
2714 os_status("SecAccessCopyACLList", status)?;
2715 let entries = CfOwned(list.cast());
2716
2717 // SAFETY: `list` came back retained from the call above.
2718 let count = unsafe { ffi::CFArrayGetCount(list) };
2719 for index in 0..count {
2720 // SAFETY: `index` is within the count the array just reported.
2721 let entry = unsafe { ffi::CFArrayGetValueAtIndex(list, index) };
2722 if entry.is_null() {
2723 continue;
2724 }
2725 widen_one(entry.cast_mut().cast())?;
2726 }
2727 drop(entries);
2728 Ok(())
2729 }
2730 }
2731
2732 impl Drop for AnyApplicationAccess {
2733 fn drop(&mut self) {
2734 // SAFETY: created retained by `SecAccessCreate` and released once.
2735 unsafe { ffi::CFRelease(self.0.cast_const().cast()) };
2736 }
2737 }
2738
2739 /// One access-control entry, rewritten to name no application.
2740 ///
2741 /// An entry whose contents cannot be read in this simple form is left
2742 /// alone rather than failing the store: `SecAccessCreate`'s default
2743 /// includes entries that are not application lists at all, and the one that
2744 /// decides whether the value can be decrypted is not among them.
2745 fn widen_one(entry: ffi::SecACLRef) -> io::Result<()> {
2746 let mut applications: ffi::CFArrayRef = std::ptr::null();
2747 let mut description: ffi::CFStringRef = std::ptr::null();
2748 let mut prompt: u16 = 0;
2749 // SAFETY: `entry` is an element of a live ACL array, and all three
2750 // out-pointers are valid. Anything returned is owned by this call.
2751 let copied = unsafe {
2752 ffi::SecACLCopyContents(
2753 entry,
2754 &raw mut applications,
2755 &raw mut description,
2756 &raw mut prompt,
2757 )
2758 };
2759 if copied != 0 {
2760 return Ok(());
2761 }
2762 let previous = CfOwned(applications.cast());
2763 let text = CfOwned(description.cast());
2764
2765 // SAFETY: `entry` is live and `description` is either null or a live
2766 // string; a null application list is the documented "any application".
2767 let status =
2768 unsafe { ffi::SecACLSetContents(entry, std::ptr::null(), description, prompt) };
2769 drop(previous);
2770 drop(text);
2771 os_status("SecACLSetContents", status)
2772 }
2773
2774 /// A Core Foundation value this code owns a reference to.
2775 struct CfOwned(*const std::ffi::c_void);
2776
2777 impl Drop for CfOwned {
2778 fn drop(&mut self) {
2779 if !self.0.is_null() {
2780 // SAFETY: every construction site holds a reference returned by
2781 // a `Copy`/`Create` call, and releases it exactly once.
2782 unsafe { ffi::CFRelease(self.0) };
2783 }
2784 }
2785 }
2786
2787 /// `noErr` is success; anything else is named with the call that returned
2788 /// it, because an operator reading `-25244` needs to know which of five
2789 /// calls produced it.
2790 fn os_status(call: &'static str, status: i32) -> io::Result<()> {
2791 if status == 0 {
2792 Ok(())
2793 } else {
2794 Err(io::Error::other(Status {
2795 message: format!("{call} returned {status}"),
2796 code: status,
2797 }))
2798 }
2799 }
2800
2801 /// The Security and Core Foundation entry points that `security-framework`
2802 /// does not wrap.
2803 ///
2804 /// Declared here rather than pulled in as another dependency:
2805 /// `security-framework-sys` binds neither `SecAccess*` nor `SecACL*`, so a
2806 /// dependency would have to be a new one, for five functions whose
2807 /// signatures are fixed platform ABI.
2808 mod ffi {
2809 use std::ffi::c_void;
2810
2811 pub type CFArrayRef = *const c_void;
2812 pub type CFStringRef = *const c_void;
2813 pub type CFIndex = isize;
2814 pub type SecAccessRef = *mut c_void;
2815 pub type SecACLRef = *mut c_void;
2816 pub type SecKeychainRef = *mut c_void;
2817 pub type SecKeychainItemRef = *mut c_void;
2818
2819 /// `kSecGenericPasswordItemClass`, the four-character code `'genp'`.
2820 pub const SEC_GENERIC_PASSWORD_ITEM_CLASS: u32 = u32::from_be_bytes(*b"genp");
2821 /// `kSecServiceItemAttr`, `'svce'`.
2822 pub const SEC_SERVICE_ITEM_ATTR: u32 = u32::from_be_bytes(*b"svce");
2823 /// `kSecAccountItemAttr`, `'acct'`.
2824 pub const SEC_ACCOUNT_ITEM_ATTR: u32 = u32::from_be_bytes(*b"acct");
2825 /// `kSecUnlockStateStatus`, the bit `SecKeychainGetStatus` sets for an
2826 /// unlocked keychain.
2827 pub const SEC_UNLOCK_STATE_STATUS: u32 = 1;
2828
2829 /// `SecKeychainAttribute` from `SecBase.h`.
2830 #[repr(C)]
2831 pub struct SecKeychainAttribute {
2832 pub tag: u32,
2833 pub length: u32,
2834 pub data: *mut c_void,
2835 }
2836
2837 /// `SecKeychainAttributeList` from `SecBase.h`.
2838 #[repr(C)]
2839 pub struct SecKeychainAttributeList {
2840 pub count: u32,
2841 pub attr: *mut SecKeychainAttribute,
2842 }
2843
2844 #[link(name = "CoreFoundation", kind = "framework")]
2845 unsafe extern "C" {
2846 pub fn CFRelease(value: *const c_void);
2847 pub fn CFArrayGetCount(array: CFArrayRef) -> CFIndex;
2848 pub fn CFArrayGetValueAtIndex(array: CFArrayRef, index: CFIndex) -> *const c_void;
2849 }
2850
2851 #[link(name = "Security", kind = "framework")]
2852 unsafe extern "C" {
2853 pub fn SecAccessCreate(
2854 descriptor: CFStringRef,
2855 trusted_list: CFArrayRef,
2856 access: *mut SecAccessRef,
2857 ) -> i32;
2858 pub fn SecAccessCopyACLList(access: SecAccessRef, list: *mut CFArrayRef) -> i32;
2859 pub fn SecACLCopyContents(
2860 entry: SecACLRef,
2861 applications: *mut CFArrayRef,
2862 description: *mut CFStringRef,
2863 prompt_selector: *mut u16,
2864 ) -> i32;
2865 pub fn SecACLSetContents(
2866 entry: SecACLRef,
2867 applications: CFArrayRef,
2868 description: CFStringRef,
2869 prompt_selector: u16,
2870 ) -> i32;
2871 pub fn SecKeychainGetStatus(keychain: SecKeychainRef, status: *mut u32) -> i32;
2872 pub fn SecKeychainItemCreateFromContent(
2873 item_class: u32,
2874 attributes: *mut SecKeychainAttributeList,
2875 length: u32,
2876 data: *const c_void,
2877 keychain: SecKeychainRef,
2878 initial_access: SecAccessRef,
2879 item: *mut SecKeychainItemRef,
2880 ) -> i32;
2881 }
2882 }
2883
2884 pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
2885 let _no_ui = without_user_interaction();
2886 let Some(keychain) = open(site, false)? else {
2887 return Ok(None);
2888 };
2889
2890 match keychain.find_generic_password(service(), site.item) {
2891 Ok((password, _item)) => Ok(Some(password.as_ref().to_vec())),
2892 Err(error) if is_absence(&error) => Ok(None),
2893 // The one refusal an operator can act on, and the one they cannot
2894 // guess. See `locked_out`.
2895 Err(error) if error.code() == ERR_SEC_AUTH_FAILED => {
2896 Err(locked_out(site, &keychain, &error))
2897 }
2898 Err(error) => Err(sec_error(&error)),
2899 }
2900 }
2901
2902 /// Why a keychain refuses a program its own credential, and what ends it.
2903 ///
2904 /// # Three states wear the same number
2905 ///
2906 /// `errSecAuthFailed` is what a keychain says when the item is there and
2907 /// the caller may not have it, and there are three quite different reasons
2908 /// for that. Telling an operator the wrong one sends them to a remedy that
2909 /// cannot work, so this reads which one it is instead of guessing:
2910 ///
2911 /// 1. **The account may not open the keychain at all.** The System Keychain
2912 /// is decrypted with `/var/db/SystemKey`, which is `root`-only, so an
2913 /// ordinary `runner-manager status` gets `-25293` on a perfectly healthy
2914 /// credential. Nothing is broken and nothing needs repairing — the value
2915 /// belongs to the account the boot-mode daemon runs as.
2916 /// 2. **The keychain is locked for this session.** A login keychain is
2917 /// unlocked for the desktop session that logged in, and an SSH session
2918 /// is not that session: measured on macOS 27, even the binary that wrote
2919 /// an item reads `-25293` over SSH while the LaunchAgent beside it reads
2920 /// the same item fine. Nothing about the item is wrong, and signing in
2921 /// from there cannot work either.
2922 /// 3. **The item was written by a different build.** The login keychain
2923 /// partitions every item to the code that created it, and an ad-hoc
2924 /// signed binary's partition is its code hash, so every release is a
2925 /// stranger to the item the previous one wrote. The daemon hands its
2926 /// credential to the new build while it upgrades (see
2927 /// `daemon adopt-credential`); this is what is left when that did not
2928 /// happen, and one sign-in by the build that will read it ends it.
2929 fn locked_out(
2930 site: &Site,
2931 keychain: &SecKeychain,
2932 error: &security_framework::base::Error,
2933 ) -> io::Error {
2934 let code = error.code();
2935 match site.kind {
2936 Kind::System if !is_root() => io::Error::new(
2937 io::ErrorKind::PermissionDenied,
2938 format!(
2939 "Security.framework returned {code} ({error}). The machine-scoped store is the \
2940 System Keychain, and what decrypts it is /var/db/SystemKey, which only \
2941 root may read -- so this is what a healthy credential looks like to an \
2942 account that is not the one holding it. The boot-mode daemon runs as root \
2943 and reads it. Nothing here needs repairing: run this command with sudo if \
2944 you need the value itself, or install with `--start-at login` to keep the \
2945 token in your own login keychain instead."
2946 ),
2947 ),
2948 Kind::Login if is_locked(keychain) => io::Error::new(
2949 io::ErrorKind::PermissionDenied,
2950 format!(
2951 "Security.framework returned {code} ({error}). {} is locked for this \
2952 session, so nothing in it can be read from here -- the service, which runs \
2953 in the desktop session, may be reading it fine. An SSH session does not \
2954 share the desktop session's unlocked login keychain. Run this from a \
2955 Terminal in the Mac's desktop session, or unlock the keychain for this \
2956 session first with `security unlock-keychain`.",
2957 site.path.display()
2958 ),
2959 ),
2960 Kind::Login => io::Error::other(format!(
2961 "Security.framework returned {code} ({error}). The item is there and this keychain \
2962 does not grant it to the program asking. macOS ties a login-keychain item to the \
2963 exact build that wrote it -- an ad-hoc signed binary is known by the hash of its \
2964 code -- so every release, and every rebuild, is a different program to it. The \
2965 service hands its credential to the new build while it upgrades; an item written \
2966 by a version that could not, or by a binary the service does not run, needs one \
2967 more sign-in by the build that will read it: run {}.",
2968 crate::service::sign_in_instruction(StartMode::Login)
2969 )),
2970 // The System Keychain as root, and a rooted keychain: both grant
2971 // every application, so only an item from before that can refuse.
2972 Kind::System | Kind::Rooted => io::Error::other(format!(
2973 "Security.framework returned {code} ({error}). The item is there and this keychain \
2974 does not grant it to the program asking. An earlier version granted the stored \
2975 token to the single binary that wrote it, and an upgrade replaces that binary -- \
2976 so an item written by one of those versions locks out every later copy, the \
2977 daemon's included. Signing in once more rewrites it so that every application \
2978 may read it: run `runner-manager auth login`, with sudo if this is the \
2979 machine-scoped store."
2980 )),
2981 }
2982 }
2983
2984 /// Whether `keychain` is locked as far as this process can tell.
2985 ///
2986 /// Unlocking is per security session, so the answer is this session's: a
2987 /// login keychain reads as unlocked to a LaunchAgent and as locked to an SSH
2988 /// login of the same account at the same moment. A status this process
2989 /// cannot read is treated as unlocked, so that the explanation falls back to
2990 /// the one it gave before this was asked.
2991 fn is_locked(keychain: &SecKeychain) -> bool {
2992 use core_foundation::base::TCFType as _;
2993
2994 let mut status = 0_u32;
2995 // SAFETY: `keychain` is a live reference and `status` a valid
2996 // out-pointer that is only read when the call reports success.
2997 let result = unsafe {
2998 ffi::SecKeychainGetStatus(keychain.as_concrete_TypeRef().cast(), &raw mut status)
2999 };
3000 result == 0 && status & ffi::SEC_UNLOCK_STATE_STATUS == 0
3001 }
3002
3003 pub(super) fn delete(site: &Site) -> io::Result<bool> {
3004 let _no_ui = without_user_interaction();
3005 let Some(keychain) = open(site, false)? else {
3006 return Ok(false);
3007 };
3008
3009 match keychain.find_generic_password(service(), site.item) {
3010 Ok((password, item)) => {
3011 // The password buffer holds the value. Drop it before anything
3012 // else happens, so it exists for as few instructions as it can.
3013 drop(password);
3014 // `SecKeychainItem::delete` consumes the item and discards the
3015 // OSStatus, so the only way to know it worked is to look. An
3016 // `auth logout` that reported success without removing anything
3017 // would be a lie told during a credential-disclosure response.
3018 item.delete();
3019 match keychain.find_generic_password(service(), site.item) {
3020 Err(error) if is_absence(&error) => Ok(true),
3021 Ok(_) => Err(io::Error::other(
3022 "the keychain item is still present after being deleted",
3023 )),
3024 Err(error) => Err(sec_error(&error)),
3025 }
3026 }
3027 // `auth logout` is one of the remedies this store hands out, and
3028 // before this it could not run against the state that most needs
3029 // it: `find` is refused by the ACL, so the item this program may
3030 // not read was also an item it could not remove. The query-based
3031 // delete never asks for the data. See `replace_unreadable`.
3032 Err(error) if error.code() == ERR_SEC_AUTH_FAILED => {
3033 replace_unreadable(&keychain, site.item)?;
3034 Ok(true)
3035 }
3036 Err(error) if is_absence(&error) => Ok(false),
3037 Err(error) => Err(sec_error(&error)),
3038 }
3039 }
3040}
3041
3042// ---------------------------------------------------------------------------
3043
3044#[cfg(all(unix, not(target_os = "macos")))]
3045mod sys {
3046 //! A `0600` file, and the systemd credential that takes precedence over it.
3047 //!
3048 //! `05-infrastructure.md` gives the Linux machine store as *"`0600` file
3049 //! plus systemd credentials"*, and the two halves are not alternatives.
3050 //!
3051 //! **The file** is what `auth login` writes and what `auth logout` removes.
3052 //! It holds the token at rest with no encryption, because there is no key
3053 //! to encrypt it with that a boot-time service could also reach — a key in
3054 //! a second file is not a key, it is an indirection. Its mode is therefore
3055 //! the entire access control, which is why it is `0600` inside a `0700`
3056 //! directory and why [`super::SecretStore::protection`] reports the mode
3057 //! rather than something more reassuring.
3058 //!
3059 //! **The systemd credential** is a *read* path, not a write path. A unit
3060 //! given `LoadCredentialEncrypted=` has the value decrypted into a private
3061 //! `ramfs` at `$CREDENTIALS_DIRECTORY`, mounted read-only and visible to
3062 //! no other unit, and nothing is ever written to the agent's own disk. An
3063 //! operator who has gone to that trouble should not also have to keep a
3064 //! plaintext copy beside it, so a credential — when one is present — wins.
3065 //!
3066 //! That precedence is the reason `store` and `delete` refuse rather than
3067 //! pretend. A `store` whose value would be shadowed by the credential on
3068 //! the very next `load` has not stored anything useful, and an `auth
3069 //! logout` that removed the file while the credential kept the daemon
3070 //! authenticated would be a false negative in the one procedure —
3071 //! `05-infrastructure.md`'s credential-disclosure response — where a false
3072 //! negative is worst. Both say what the operator has to change instead.
3073 //!
3074 //! # Machine scope is `/var/lib`, and that is the point
3075 //!
3076 //! `$XDG_DATA_HOME` resolves under `$HOME`, and an account that has never
3077 //! logged in has no `$HOME` to speak of; `$XDG_RUNTIME_DIR` is cleared when
3078 //! the session ends, which is exactly the event a boot-time service starts
3079 //! before. `/var/lib/runner-manager` belongs to no session and survives a
3080 //! reboot, which is the whole requirement.
3081
3082 use std::io::{self, Write as _};
3083 use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
3084 use std::path::{Path, PathBuf};
3085
3086 use super::{
3087 APPLICATION, CREDENTIALS_DIRECTORY, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER,
3088 SYSTEMD_CREDENTIAL, SecretScope, TEMP_PREFIX, overwrite, sweep_temporaries,
3089 trim_trailing_ascii_whitespace,
3090 };
3091
3092 /// Where a machine-scoped store lives. Not under any home directory and
3093 /// not under any runtime directory; see the module documentation.
3094 /// The FHS directory for state a program owns; the product's own segment
3095 /// is [`APPLICATION`], not a second spelling of it.
3096 const MACHINE_PREFIX: &str = "/var/lib";
3097
3098 /// The file, and the systemd credential that outranks it.
3099 #[derive(Debug, Clone, PartialEq, Eq)]
3100 pub(super) struct Site {
3101 file: PathBuf,
3102 credential: Option<PathBuf>,
3103 }
3104
3105 /// A sibling file in the same protected directory. The systemd
3106 /// credential carries the GitHub credential only, so any other item is
3107 /// read from its file alone.
3108 pub(super) fn with_item(mut site: Site, item: &'static str) -> Site {
3109 site.file.set_file_name(item);
3110 if item != ITEM {
3111 site.credential = None;
3112 }
3113 site
3114 }
3115
3116 impl Site {
3117 /// Points this site at a credentials directory the caller names.
3118 pub(super) fn with_credentials_directory(mut self, directory: &Path) -> Self {
3119 self.credential = Some(directory.join(SYSTEMD_CREDENTIAL));
3120 self
3121 }
3122
3123 /// The systemd credential this site would read, if it has one.
3124 pub(super) fn credential(&self) -> Option<&Path> {
3125 self.credential.as_deref()
3126 }
3127 }
3128
3129 /// `$CREDENTIALS_DIRECTORY/<name>`, when systemd set one.
3130 fn credential_from_environment() -> Option<PathBuf> {
3131 std::env::var_os(CREDENTIALS_DIRECTORY)
3132 .filter(|value| !value.is_empty())
3133 .map(|value| PathBuf::from(value).join(SYSTEMD_CREDENTIAL))
3134 }
3135
3136 pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
3137 match scope {
3138 SecretScope::Machine => Ok(Site {
3139 file: Path::new(MACHINE_PREFIX)
3140 .join(APPLICATION)
3141 .join(DIRECTORY)
3142 .join(ITEM),
3143 credential: credential_from_environment(),
3144 }),
3145 SecretScope::User => {
3146 let root = directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
3147 .ok_or_else(|| {
3148 "the operating system reports no home directory for this account, \
3149 so the user-scoped store cannot be resolved. A service account \
3150 configured with no profile normally hits this; use the \
3151 machine-scoped store, which is what --start-at boot installs."
3152 .to_string()
3153 })?
3154 .data_local_dir()
3155 .to_path_buf();
3156 Ok(Site {
3157 file: root.join(DIRECTORY).join(ITEM),
3158 // A user-scoped store is deliberately not for a service, so
3159 // it never consults a service's credentials.
3160 credential: None,
3161 })
3162 }
3163 }
3164 }
3165
3166 pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
3167 // A directory per scope. A standard site gets its separation from the
3168 // root -- `/var/lib` against `$XDG_DATA_HOME` -- and under one
3169 // caller-named root there is none, so two stores would share a file
3170 // and `auth logout` under either scope would purge both.
3171 Ok(Site {
3172 file: root.join(DIRECTORY).join(scope.as_str()).join(ITEM),
3173 credential: None,
3174 })
3175 }
3176
3177 pub(super) fn describe(site: &Site) -> String {
3178 match &site.credential {
3179 Some(credential) => format!(
3180 "0600 file at {} (superseded by the systemd credential at {})",
3181 site.file.display(),
3182 credential.display()
3183 ),
3184 None => format!("0600 file at {}", site.file.display()),
3185 }
3186 }
3187
3188 pub(super) fn guard(site: &Site) -> PathBuf {
3189 // The object that actually holds the value being read. When systemd
3190 // supplied one, that is its credential file in the unit's private
3191 // ramfs, and reporting the agent's own file instead would describe the
3192 // protection of something nothing reads.
3193 match &site.credential {
3194 Some(credential) if credential.exists() => credential.clone(),
3195 _ => site.file.clone(),
3196 }
3197 }
3198
3199 /// The refusal both `store` and `delete` owe an operator whose unit
3200 /// supplies a credential.
3201 fn shadowed_by_credential(site: &Site, verb: &str) -> Option<io::Error> {
3202 let credential = site.credential.as_ref()?;
3203 if !credential.exists() {
3204 return None;
3205 }
3206 Some(io::Error::other(format!(
3207 "this process was started with the systemd credential `{SYSTEMD_CREDENTIAL}`, which \
3208 takes precedence over {}. {verb} Change the credential in the unit that supplies \
3209 it -- `systemd-creds` and `LoadCredentialEncrypted=` -- and restart the service.",
3210 site.file.display()
3211 )))
3212 }
3213
3214 pub(super) fn ensure_writable(site: &Site) -> io::Result<()> {
3215 if let Some(error) = shadowed_by_credential(
3216 site,
3217 "A token written here would be shadowed by it on the very next load.",
3218 ) {
3219 return Err(error);
3220 }
3221 super::ensure_directory_writable(&site.file)
3222 }
3223
3224 pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
3225 if let Some(error) = shadowed_by_credential(
3226 site,
3227 "A token written here would be shadowed by it on the very next load, so nothing \
3228 was written.",
3229 ) {
3230 return Err(error);
3231 }
3232
3233 let directory = site
3234 .file
3235 .parent()
3236 .ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
3237 // `0700` through `mkdir(2)` rather than a following `chmod`, exactly as
3238 // `crate::paths::AppPaths::create_all` argues; and then set explicitly,
3239 // because `mkdir` applies the umask and a directory that was already
3240 // there may predate this rule.
3241 std::fs::DirBuilder::new()
3242 .mode(0o700)
3243 .recursive(true)
3244 .create(directory)?;
3245 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
3246
3247 let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
3248 let written = (|| -> io::Result<()> {
3249 // `mode` is applied by `open(2)`, so the file never exists at any
3250 // other permissions; `create_new` makes it exclusive, so a path
3251 // pre-created by another account is an error rather than a file
3252 // this process writes a token into.
3253 let mut file = std::fs::OpenOptions::new()
3254 .write(true)
3255 .create_new(true)
3256 .mode(0o600)
3257 .open(&temporary)?;
3258 file.write_all(plaintext)?;
3259 file.flush()?;
3260 file.sync_all()
3261 })();
3262 if let Err(error) = written {
3263 let _ = std::fs::remove_file(&temporary);
3264 return Err(error);
3265 }
3266
3267 if let Err(error) = std::fs::rename(&temporary, &site.file) {
3268 let _ = std::fs::remove_file(&temporary);
3269 return Err(error);
3270 }
3271
3272 // Best effort: without it the rename can still be lost to a power cut
3273 // that the file's own `sync_all` survived. Not worth failing a store
3274 // that has already succeeded.
3275 if let Ok(handle) = std::fs::File::open(directory) {
3276 let _ = handle.sync_all();
3277 }
3278 Ok(())
3279 }
3280
3281 pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
3282 if let Some(credential) = &site.credential {
3283 match std::fs::read(credential) {
3284 // Trimmed, and *only* here. The operator produced this file
3285 // with `systemd-creds encrypt`, and `echo`, `printf '%s\n'` and
3286 // every text editor leave a newline on the end. Byte for byte
3287 // that newline becomes part of the token and fails much later
3288 // inside an `Authorization` header, where it reads as a bad
3289 // credential rather than as a bad read.
3290 Ok(bytes) => return Ok(Some(trim_trailing_ascii_whitespace(bytes))),
3291 // No credential of that name: fall through to the file. A unit
3292 // can be given credentials without being given this one.
3293 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
3294 Err(error) => return Err(error),
3295 }
3296 }
3297
3298 // Not trimmed. `store` writes the value verbatim and with no newline,
3299 // so anything trailing here is corruption rather than formatting, and
3300 // silently repairing it would hide the one thing `Corrupt` exists to
3301 // report.
3302 match std::fs::read(&site.file) {
3303 Ok(bytes) => Ok(Some(bytes)),
3304 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
3305 Err(error) => Err(error),
3306 }
3307 }
3308
3309 pub(super) fn delete(site: &Site) -> io::Result<bool> {
3310 let shadowed = shadowed_by_credential(
3311 site,
3312 "The file below was removed, but the credential is still supplying a token and \
3313 this host is not purged.",
3314 );
3315
3316 let removed = overwrite_then_remove(&site.file)?;
3317 if let Some(directory) = site.file.parent() {
3318 sweep_temporaries(directory);
3319 }
3320
3321 match shadowed {
3322 Some(error) => Err(error),
3323 None => Ok(removed),
3324 }
3325 }
3326
3327 /// Zeroes the file's bytes, then unlinks it.
3328 ///
3329 /// The zero fill is [`overwrite`], which lives in the parent module so that
3330 /// it is a function a test can watch rather than a side effect inside a
3331 /// delete; its documentation carries the "best effort, not a claim"
3332 /// reasoning in full.
3333 ///
3334 /// Not being able to overwrite is not a reason to skip the unlink. The
3335 /// unlink is the part that matters, it may still succeed, and a store that
3336 /// refused to purge because it could not scrub first would fail `auth
3337 /// logout` in exactly the situation where finishing it matters most.
3338 fn overwrite_then_remove(path: &Path) -> io::Result<bool> {
3339 let _ = overwrite(path);
3340
3341 match std::fs::remove_file(path) {
3342 Ok(()) => Ok(true),
3343 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
3344 Err(error) => Err(error),
3345 }
3346 }
3347}
3348
3349// ---------------------------------------------------------------------------
3350// The Linux-only half of the surface
3351// ---------------------------------------------------------------------------
3352
3353/// Systemd credentials, which exist on no other platform.
3354///
3355/// A separate `impl` block rather than a cross-platform method that does
3356/// nothing on two of three operating systems: a caller that names
3357/// [`PlatformSecretStore::with_credentials_directory`] is writing code that
3358/// only means something on Linux, and it should not compile anywhere else.
3359#[cfg(all(unix, not(target_os = "macos")))]
3360impl PlatformSecretStore {
3361 /// Points this store at a systemd credentials directory the caller names,
3362 /// instead of the one `$CREDENTIALS_DIRECTORY` named.
3363 ///
3364 /// [`PlatformSecretStore::standard`] already reads the environment
3365 /// variable, so a daemon started by systemd needs none of this. It is here
3366 /// for the two callers that cannot use the environment: a test, which must
3367 /// not mutate a process-wide variable that every other test is reading at
3368 /// the same time, and `service install`, which resolves what the unit it is
3369 /// about to write will set.
3370 #[must_use]
3371 pub fn with_credentials_directory(mut self, directory: impl AsRef<Path>) -> Self {
3372 self.site = self.site.with_credentials_directory(directory.as_ref());
3373 self
3374 }
3375
3376 /// The systemd credential this store reads before it reads its own file,
3377 /// when it has one.
3378 #[must_use]
3379 pub fn credential_path(&self) -> Option<&Path> {
3380 self.site.credential()
3381 }
3382}
3383
3384// ---------------------------------------------------------------------------
3385// Tests
3386// ---------------------------------------------------------------------------
3387
3388#[cfg(test)]
3389mod tests {
3390 use super::*;
3391
3392 use tempfile::TempDir;
3393
3394 #[test]
3395 fn a_system_keychain_write_refusal_says_which_sign_in_to_run() {
3396 let explained =
3397 system_keychain_write_refused("SecKeychainItemCreateFromContent returned -61");
3398 assert!(explained.starts_with("SecKeychainItemCreateFromContent returned -61. "));
3399 assert!(explained.contains("only root may write it"), "{explained}");
3400 assert!(
3401 explained.contains("`runner-manager auth login --start-at login`"),
3402 "{explained}"
3403 );
3404 assert!(
3405 explained.contains("`sudo runner-manager auth login --start-at boot`"),
3406 "{explained}"
3407 );
3408 }
3409
3410 /// Shaped like a real `ghu_` user access token and unmistakably not one.
3411 ///
3412 /// **Assembled at run time from fragments on purpose.** The whole literal
3413 /// therefore appears in no source file and in no compiled artifact, which
3414 /// is what lets `tests/no_token_outside_the_store.rs` scan this repository
3415 /// — `target/` included — and treat any hit as a real leak rather than as
3416 /// its own test data. A `const` here, or a `concat!`, would be folded into
3417 /// the binary and would make that scan meaningless.
3418 fn fixture_token() -> SecretString {
3419 SecretString::from(format!("{}{}", "ghu_", "d2FixtureNotARealCredential000000"))
3420 }
3421
3422 /// A second, equally fake token, for the tests that need two.
3423 fn other_token() -> SecretString {
3424 SecretString::from(format!("{}{}", "ghu_", "d2SecondFixtureNotARealOne000000"))
3425 }
3426
3427 fn exposed(secret: &SecretString) -> String {
3428 secret.expose_secret().to_string()
3429 }
3430
3431 fn rooted(scope: SecretScope, root: &TempDir) -> PlatformSecretStore {
3432 PlatformSecretStore::rooted_at(scope, root.path()).expect("a rooted store resolves")
3433 }
3434
3435 fn stored(store: &PlatformSecretStore) -> String {
3436 exposed(
3437 &store
3438 .load()
3439 .expect("the store is readable")
3440 .expect("a value was stored"),
3441 )
3442 }
3443
3444 // -----------------------------------------------------------------------
3445 // The scope is a function of the start mode, in both directions
3446 // -----------------------------------------------------------------------
3447
3448 #[test]
3449 fn the_scope_is_decided_by_the_start_mode() {
3450 assert_eq!(
3451 SecretScope::for_start_mode(StartMode::Boot),
3452 SecretScope::Machine,
3453 "a service that starts at boot has no login session to read a user-scoped store from"
3454 );
3455 assert_eq!(
3456 SecretScope::for_start_mode(StartMode::Login),
3457 SecretScope::User
3458 );
3459 }
3460
3461 #[test]
3462 fn every_scope_names_the_start_mode_it_is_the_answer_to() {
3463 for scope in [SecretScope::Machine, SecretScope::User] {
3464 assert_eq!(SecretScope::for_start_mode(scope.start_mode()), scope);
3465 }
3466 for mode in [StartMode::Boot, StartMode::Login] {
3467 assert_eq!(SecretScope::for_start_mode(mode).start_mode(), mode);
3468 }
3469 }
3470
3471 #[test]
3472 fn for_start_mode_opens_the_store_that_start_mode_obliges() {
3473 for mode in [StartMode::Boot, StartMode::Login] {
3474 let Ok(store) = PlatformSecretStore::for_start_mode(mode) else {
3475 // Resolution needs a home directory or a %ProgramData%. A host
3476 // with neither cannot run the product at all, and saying so is
3477 // more useful than a panic that names neither.
3478 panic!("the standard store for --start-at {mode} could not be resolved");
3479 };
3480 assert_eq!(store.scope(), SecretScope::for_start_mode(mode));
3481 }
3482 }
3483
3484 // -----------------------------------------------------------------------
3485 // Store, load, delete -- both variants
3486 // -----------------------------------------------------------------------
3487
3488 #[test]
3489 fn a_machine_scoped_store_round_trips() {
3490 let root = TempDir::new().expect("a temporary directory");
3491 let store = rooted(SecretScope::Machine, &root);
3492 let token = fixture_token();
3493
3494 assert!(
3495 store
3496 .load()
3497 .expect("an empty store reads cleanly")
3498 .is_none(),
3499 "nothing is stored before the first `auth login`"
3500 );
3501
3502 store.store(&token).expect("the token is stored");
3503 assert_eq!(stored(&store), exposed(&token));
3504
3505 assert_eq!(
3506 store.delete().expect("the token is purged"),
3507 Removal::Removed
3508 );
3509 assert!(
3510 store
3511 .load()
3512 .expect("a purged store reads cleanly")
3513 .is_none()
3514 );
3515 }
3516
3517 #[test]
3518 fn a_user_scoped_store_round_trips() {
3519 let root = TempDir::new().expect("a temporary directory");
3520 let store = rooted(SecretScope::User, &root);
3521 let token = fixture_token();
3522
3523 store.store(&token).expect("the token is stored");
3524 assert_eq!(stored(&store), exposed(&token));
3525 assert_eq!(
3526 store.delete().expect("the token is purged"),
3527 Removal::Removed
3528 );
3529 assert!(
3530 store
3531 .load()
3532 .expect("a purged store reads cleanly")
3533 .is_none()
3534 );
3535 }
3536
3537 #[test]
3538 fn the_two_variants_do_not_share_a_value() {
3539 // Both under one root, because that is the arrangement most likely to
3540 // let them collide by accident.
3541 let root = TempDir::new().expect("a temporary directory");
3542 let machine = rooted(SecretScope::Machine, &root);
3543 let user = rooted(SecretScope::User, &root);
3544
3545 machine.store(&fixture_token()).expect("stored");
3546 user.store(&other_token()).expect("stored");
3547
3548 assert_eq!(stored(&machine), exposed(&fixture_token()));
3549 assert_eq!(stored(&user), exposed(&other_token()));
3550
3551 machine.delete().expect("purged");
3552 assert!(
3553 machine.load().expect("readable").is_none(),
3554 "the machine store is empty"
3555 );
3556 assert_eq!(
3557 stored(&user),
3558 exposed(&other_token()),
3559 "purging one variant must not purge the other; `auth logout` under \
3560 --start-at boot has no business touching a user-scoped store"
3561 );
3562 }
3563
3564 #[test]
3565 fn the_coordinator_key_and_the_github_credential_do_not_share_a_value() {
3566 let root = TempDir::new().expect("a temporary directory");
3567 let github = rooted(SecretScope::Machine, &root);
3568 let key = rooted(SecretScope::Machine, &root).for_item(SecretItem::CoordinatorKey);
3569
3570 github.store(&fixture_token()).expect("stored");
3571 key.store(&other_token()).expect("stored");
3572 assert_eq!(stored(&github), exposed(&fixture_token()));
3573 assert_eq!(stored(&key), exposed(&other_token()));
3574 assert_ne!(github.location(), key.location());
3575
3576 key.delete().expect("purged");
3577 assert!(key.load().expect("readable").is_none());
3578 assert_eq!(
3579 stored(&github),
3580 exposed(&fixture_token()),
3581 "`coordinator leave` must not sign the host out of GitHub"
3582 );
3583 }
3584
3585 #[test]
3586 fn storing_again_replaces_the_value() {
3587 let root = TempDir::new().expect("a temporary directory");
3588 let store = rooted(SecretScope::Machine, &root);
3589
3590 store.store(&fixture_token()).expect("stored");
3591 store.store(&other_token()).expect("stored again");
3592
3593 assert_eq!(
3594 stored(&store),
3595 exposed(&other_token()),
3596 "a re-issued token replaces the old one rather than being refused"
3597 );
3598 }
3599
3600 // -----------------------------------------------------------------------
3601 // Absence is not an error, and delete is idempotent
3602 // -----------------------------------------------------------------------
3603
3604 #[test]
3605 fn a_load_after_delete_reports_absence_rather_than_a_failure() {
3606 let root = TempDir::new().expect("a temporary directory");
3607 let store = rooted(SecretScope::Machine, &root);
3608 store.store(&fixture_token()).expect("stored");
3609 store.delete().expect("purged");
3610
3611 // Phrased as a match rather than as `is_none`, because the property
3612 // under test is which *arm* the caller lands in. An `Err` here is the
3613 // failure the Definition of Done names: "a load after delete reports
3614 // absence rather than an error that a caller might mistake for a
3615 // transient failure".
3616 match store.load() {
3617 Ok(None) => {}
3618 Ok(Some(_)) => panic!("the value survived a delete"),
3619 Err(error) => panic!("absence was reported as a failure a caller may retry: {error}"),
3620 }
3621 }
3622
3623 #[test]
3624 fn deleting_what_is_not_there_is_success_and_says_so() {
3625 let root = TempDir::new().expect("a temporary directory");
3626 let store = rooted(SecretScope::Machine, &root);
3627
3628 assert_eq!(
3629 store
3630 .delete()
3631 .expect("purging an empty store is not a failure"),
3632 Removal::AlreadyAbsent,
3633 "`auth logout` is run on every host during a credential-disclosure \
3634 response, including the ones that were never logged in"
3635 );
3636
3637 store.store(&fixture_token()).expect("stored");
3638 assert_eq!(store.delete().expect("purged"), Removal::Removed);
3639 assert_eq!(
3640 store.delete().expect("purged again"),
3641 Removal::AlreadyAbsent
3642 );
3643 }
3644
3645 /// "Delete leaves no recoverable remnant" — stated as the same property on
3646 /// all three platforms, and checked through whatever *carries* the value on
3647 /// each.
3648 ///
3649 /// The distinction is one CI caught rather than one this test was written
3650 /// with. A file-backed store keeps the value *in* the guard, so the remnant
3651 /// question is "is the file gone". A keychain-backed store keeps it in an
3652 /// item *inside* the guard, and the guard is a database that legitimately
3653 /// outlives every item it ever held — asserting the keychain disappears
3654 /// would be asserting that `auth logout` deletes the operator's login
3655 /// keychain, which would be a bug rather than a purge.
3656 ///
3657 /// So the two things asserted everywhere are the two that mean the same
3658 /// thing everywhere: nothing readable is left, and no byte of the value is
3659 /// lying in whatever does remain.
3660 #[test]
3661 fn deleting_leaves_no_recoverable_remnant() {
3662 let root = TempDir::new().expect("a temporary directory");
3663 let store = rooted(SecretScope::Machine, &root);
3664 store.store(&fixture_token()).expect("stored");
3665
3666 let guard = store.guard();
3667 assert!(guard.exists(), "something was written");
3668 store.delete().expect("purged");
3669
3670 assert!(
3671 store
3672 .load()
3673 .expect("a purged store reads cleanly")
3674 .is_none(),
3675 "the value is still readable through the store's own API"
3676 );
3677 if let Ok(bytes) = std::fs::read(&guard) {
3678 let token = exposed(&fixture_token());
3679 assert!(
3680 !bytes
3681 .windows(token.len())
3682 .any(|window| window == token.as_bytes()),
3683 "the value is still lying in {} after a purge",
3684 guard.display()
3685 );
3686 }
3687
3688 // A file-backed store additionally leaves nothing at all: neither the
3689 // named file, nor a `user-access-token.<uuid>.tmp` from a write that was
3690 // interrupted, which is the token on disk under a name nobody looks at.
3691 #[cfg(not(target_os = "macos"))]
3692 {
3693 assert!(
3694 !guard.exists(),
3695 "the stored value is still at {}",
3696 guard.display()
3697 );
3698 if let Some(directory) = guard.parent()
3699 && let Ok(entries) = std::fs::read_dir(directory)
3700 {
3701 let remnants: Vec<_> = entries
3702 .flatten()
3703 .map(|entry| entry.file_name().to_string_lossy().into_owned())
3704 .filter(|name| name.starts_with("user-access-token"))
3705 .collect();
3706 assert!(
3707 remnants.is_empty(),
3708 "a purge left {remnants:?} in {}",
3709 directory.display()
3710 );
3711 }
3712 }
3713 }
3714
3715 /// The negative control for the sweep above: a temporary file from an
3716 /// interrupted write is a token on disk, and a purge that only removed the
3717 /// file it was told about would leave it there.
3718 #[cfg(not(target_os = "macos"))]
3719 #[test]
3720 fn a_purge_sweeps_a_temporary_left_by_an_interrupted_write() {
3721 let root = TempDir::new().expect("a temporary directory");
3722 let store = rooted(SecretScope::Machine, &root);
3723 store.store(&fixture_token()).expect("stored");
3724
3725 let directory = store
3726 .guard()
3727 .parent()
3728 .expect("the guard has a directory")
3729 .to_path_buf();
3730 let abandoned = directory.join(format!("{TEMP_PREFIX}00000000-dead-beef.tmp"));
3731 std::fs::write(&abandoned, exposed(&fixture_token())).expect("planted");
3732
3733 store.delete().expect("purged");
3734 assert!(
3735 !abandoned.exists(),
3736 "a purge left {} behind, which is the token on disk under a name nobody looks at",
3737 abandoned.display()
3738 );
3739 }
3740
3741 // -----------------------------------------------------------------------
3742 // What is not a value
3743 // -----------------------------------------------------------------------
3744
3745 #[test]
3746 fn an_empty_value_is_refused_rather_than_stored() {
3747 let root = TempDir::new().expect("a temporary directory");
3748 let store = rooted(SecretScope::Machine, &root);
3749
3750 let error = store
3751 .store(&SecretString::from(String::new()))
3752 .expect_err("an empty value is not a token");
3753 assert!(matches!(error, SecretStoreError::Store { .. }));
3754 assert!(
3755 store.load().expect("readable").is_none(),
3756 "a refused store wrote nothing"
3757 );
3758 }
3759
3760 #[test]
3761 fn bytes_that_are_not_a_token_are_reported_as_corrupt_and_not_as_absence() {
3762 let root = TempDir::new().expect("a temporary directory");
3763 let store = rooted(SecretScope::Machine, &root);
3764
3765 // `decode` is the half of the read path that is the same on all three
3766 // operating systems, and it is reachable here because these tests are a
3767 // child of the module. The platform half -- a DPAPI blob this key
3768 // cannot unprotect, a file of raw bytes -- is exercised per OS below.
3769 let error = store
3770 .decode(vec![0xff, 0xfe, 0xfd])
3771 .expect_err("invalid UTF-8 is not a token");
3772 assert!(
3773 matches!(error, SecretStoreError::Corrupt { .. }),
3774 "got {error:?}"
3775 );
3776 assert!(
3777 store.decode(Vec::new()).is_err(),
3778 "an empty value read back is a truncated write, not an absence"
3779 );
3780 }
3781
3782 #[test]
3783 fn no_error_this_module_produces_repeats_the_value() {
3784 let root = TempDir::new().expect("a temporary directory");
3785 let store = rooted(SecretScope::Machine, &root);
3786 let token = exposed(&fixture_token());
3787
3788 // Every variant that can be constructed without a real platform
3789 // failure, rendered both ways a caller can render one.
3790 let errors = vec![
3791 SecretStoreError::Resolve {
3792 scope: SecretScope::Machine,
3793 reason: "no %ProgramData%".to_string(),
3794 },
3795 store
3796 .store(&SecretString::from(String::new()))
3797 .expect_err("empty is refused"),
3798 store
3799 .decode(token.clone().into_bytes())
3800 .err()
3801 .unwrap_or_else(|| store.corrupt("a placeholder")),
3802 store.corrupt("the bytes there are not valid UTF-8"),
3803 store
3804 .protection()
3805 .err()
3806 .unwrap_or_else(|| SecretStoreError::Inspect {
3807 scope: SecretScope::Machine,
3808 guard: store.guard(),
3809 source: crate::process::permissions_summary(std::path::Path::new(
3810 "a-path-that-is-not-there",
3811 ))
3812 .expect_err("a missing path cannot be inspected"),
3813 }),
3814 ];
3815
3816 for error in errors {
3817 let rendered = format!("{error} / {error:?}");
3818 assert!(
3819 !rendered.contains(&token),
3820 "an error rendered the token: {rendered}"
3821 );
3822 }
3823 }
3824
3825 // -----------------------------------------------------------------------
3826 // Who can read it
3827 // -----------------------------------------------------------------------
3828
3829 #[test]
3830 fn a_stored_value_is_not_readable_by_an_unprivileged_local_user() {
3831 for scope in [SecretScope::Machine, SecretScope::User] {
3832 let root = TempDir::new().expect("a temporary directory");
3833 let store = rooted(scope, &root);
3834 store.store(&fixture_token()).expect("stored");
3835
3836 let protection = store.protection().expect("the guard is inspectable");
3837 assert!(
3838 !protection.readable_by_other_local_users(),
3839 "the {scope}-scoped store is readable by other local users: {protection}"
3840 );
3841 }
3842 }
3843
3844 /// The negative control for the assertion above.
3845 ///
3846 /// A check that only ever returns "not readable" proves nothing, and this
3847 /// one is the whole of the evidence for a `security_critical` Definition of
3848 /// Done item. So: take a store that has just passed, loosen the one thing
3849 /// that was protecting it, and require the same call to report the leak.
3850 #[test]
3851 fn the_readability_check_reports_a_guard_that_was_loosened() {
3852 let root = TempDir::new().expect("a temporary directory");
3853 let store = rooted(SecretScope::Machine, &root);
3854 store.store(&fixture_token()).expect("stored");
3855 let guard = store.guard();
3856
3857 assert!(
3858 !store
3859 .protection()
3860 .expect("inspectable")
3861 .readable_by_other_local_users(),
3862 "the control starts from a store that passes"
3863 );
3864
3865 #[cfg(unix)]
3866 {
3867 use std::os::unix::fs::PermissionsExt as _;
3868 std::fs::set_permissions(&guard, std::fs::Permissions::from_mode(0o644))
3869 .expect("the guard can be loosened");
3870 }
3871 #[cfg(windows)]
3872 {
3873 // Replaced with a file created the ordinary way, which inherits the
3874 // temporary directory's DACL instead of carrying a protected one of
3875 // its own. That is exactly the mistake the backend exists to avoid:
3876 // an inherited DACL is whatever the parent grants, and under
3877 // `%ProgramData%` the parent grants Builtin Users read.
3878 std::fs::remove_file(&guard).expect("the guard can be replaced");
3879 std::fs::write(&guard, b"not a protected file").expect("written");
3880 }
3881
3882 let protection = store
3883 .protection()
3884 .expect("the loosened guard is still inspectable");
3885 assert!(
3886 protection.readable_by_other_local_users(),
3887 "a loosened guard was reported as safe, so the assertion above proves nothing: \
3888 {protection}"
3889 );
3890 }
3891
3892 #[test]
3893 fn the_protection_names_the_object_it_inspected() {
3894 let root = TempDir::new().expect("a temporary directory");
3895 let store = rooted(SecretScope::Machine, &root);
3896 store.store(&fixture_token()).expect("stored");
3897
3898 let protection = store.protection().expect("inspectable");
3899 assert_eq!(protection.guard(), store.guard());
3900 assert!(
3901 !protection.description().is_empty(),
3902 "a protection with no description is not a diagnosis"
3903 );
3904 }
3905
3906 // -----------------------------------------------------------------------
3907 // What `host show` and `service status` print
3908 // -----------------------------------------------------------------------
3909
3910 #[test]
3911 fn the_active_store_is_reported_and_agrees_with_the_start_mode() {
3912 let root = TempDir::new().expect("a temporary directory");
3913
3914 for mode in [StartMode::Boot, StartMode::Login] {
3915 let store = rooted(SecretScope::for_start_mode(mode), &root);
3916 let active = ActiveStore::of(&store, mode);
3917
3918 assert_eq!(active.scope(), SecretScope::for_start_mode(mode));
3919 assert_eq!(active.start_mode(), mode);
3920 assert!(active.agrees_with_start_mode());
3921
3922 let rendered = active.to_string();
3923 assert!(
3924 rendered.contains(active.scope().as_str()),
3925 "`host show` must name the variant in use: {rendered}"
3926 );
3927 assert!(
3928 rendered.contains(&mode.to_string()),
3929 "`service status` must name the start mode: {rendered}"
3930 );
3931 assert!(
3932 !rendered.contains("MISMATCH"),
3933 "a matching pair must not be reported as a mismatch: {rendered}"
3934 );
3935 }
3936 }
3937
3938 #[test]
3939 fn a_store_that_disagrees_with_the_start_mode_says_so() {
3940 let root = TempDir::new().expect("a temporary directory");
3941 // The failure this exists to catch: a service switched to `--start-at
3942 // boot` while the token is still in the operator's user-scoped store.
3943 // The daemon starts, finds nothing, and the only clue is here.
3944 let store = rooted(SecretScope::User, &root);
3945 let active = ActiveStore::of(&store, StartMode::Boot);
3946
3947 assert!(!active.agrees_with_start_mode());
3948 let rendered = active.to_string();
3949 assert!(rendered.contains("MISMATCH"), "{rendered}");
3950 assert!(rendered.contains("machine"), "{rendered}");
3951 }
3952
3953 #[test]
3954 fn the_reported_location_is_not_the_value() {
3955 let root = TempDir::new().expect("a temporary directory");
3956 let store = rooted(SecretScope::Machine, &root);
3957 store.store(&fixture_token()).expect("stored");
3958
3959 let token = exposed(&fixture_token());
3960 let active = ActiveStore::of(&store, StartMode::Boot);
3961 for rendered in [
3962 store.location(),
3963 active.to_string(),
3964 format!("{store:?}"),
3965 format!("{active:?}"),
3966 store.protection().expect("inspectable").to_string(),
3967 ] {
3968 assert!(
3969 !rendered.contains(&token),
3970 "a report carried the value: {rendered}"
3971 );
3972 }
3973 }
3974
3975 // -----------------------------------------------------------------------
3976 // Where the standard stores live
3977 // -----------------------------------------------------------------------
3978
3979 /// The property that makes a machine-scoped store readable after a reboot,
3980 /// stated as a location rather than as a hope.
3981 ///
3982 /// Every per-user directory on all three operating systems hangs off the
3983 /// account's home directory, and an account that has never logged in does
3984 /// not have one mounted. A machine store under `$HOME` would work on the
3985 /// developer's laptop and fail on the first boot of the machine it was
3986 /// installed on, which is the failure this assertion exists to make
3987 /// impossible to introduce.
3988 #[test]
3989 fn the_machine_store_is_not_under_the_home_directory() {
3990 let store = PlatformSecretStore::standard(SecretScope::Machine)
3991 .expect("the machine store resolves");
3992 let guard = store.guard();
3993
3994 let Some(base) = directories::BaseDirs::new() else {
3995 panic!("this account has no home directory, so the assertion cannot be made");
3996 };
3997 assert!(
3998 !guard.starts_with(base.home_dir()),
3999 "the machine store at {} is under the home directory {}",
4000 guard.display(),
4001 base.home_dir().display()
4002 );
4003 }
4004
4005 #[test]
4006 fn the_user_store_is_under_the_home_directory() {
4007 // The mirror of the assertion above, and the reason `--start-at login`
4008 // means what it says: this store is gone when the operator logs out.
4009 let store =
4010 PlatformSecretStore::standard(SecretScope::User).expect("the user store resolves");
4011 let Some(base) = directories::BaseDirs::new() else {
4012 panic!("this account has no home directory, so the assertion cannot be made");
4013 };
4014 assert!(
4015 store.guard().starts_with(base.home_dir()),
4016 "the user store at {} is not under the home directory {}",
4017 store.guard().display(),
4018 base.home_dir().display()
4019 );
4020 }
4021
4022 #[test]
4023 fn the_standard_locations_are_the_documented_ones() {
4024 let machine = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
4025 let user = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
4026
4027 #[cfg(windows)]
4028 {
4029 let program_data = std::path::PathBuf::from(
4030 std::env::var_os("ProgramData").expect("Windows sets ProgramData"),
4031 );
4032 assert_eq!(
4033 machine.guard(),
4034 program_data
4035 .join("IvanMurzak")
4036 .join("runner-manager")
4037 .join("secrets")
4038 .join("user-access-token.dpapi")
4039 );
4040 assert!(user.location().contains("DPAPI"), "{}", user.location());
4041 }
4042 #[cfg(target_os = "macos")]
4043 {
4044 assert_eq!(
4045 machine.guard(),
4046 std::path::Path::new("/var/db/SystemKey"),
4047 "the System Keychain's protection is its root-only master key, \
4048 not the world-readable database beside it"
4049 );
4050 assert!(
4051 machine
4052 .location()
4053 .contains("/Library/Keychains/System.keychain"),
4054 "{}",
4055 machine.location()
4056 );
4057 assert!(
4058 user.location().contains("login.keychain"),
4059 "{}",
4060 user.location()
4061 );
4062 }
4063 #[cfg(all(unix, not(target_os = "macos")))]
4064 {
4065 assert_eq!(
4066 machine.guard(),
4067 std::path::Path::new("/var/lib/runner-manager/secrets/user-access-token")
4068 );
4069 assert!(user.location().contains("0600"), "{}", user.location());
4070 }
4071 }
4072
4073 /// The independent oracle for the identity `secrets.rs` now *borrows* from
4074 /// [`crate::paths`] rather than spelling out.
4075 ///
4076 /// Borrowing removes the drift the reviewer named — two files can no longer
4077 /// disagree — but it moves the risk rather than removing it: a change in
4078 /// `paths.rs` now silently moves the secret store as well as the four
4079 /// application-data directories, and a token that has moved reads as simply
4080 /// absent. The literals below are the only place in this module that is
4081 /// *not* derived from those constants, which is exactly what makes them
4082 /// able to catch such a change.
4083 #[test]
4084 fn the_product_identity_is_the_one_paths_defines() {
4085 assert_eq!(QUALIFIER, "io.github");
4086 assert_eq!(ORGANIZATION, "IvanMurzak");
4087 assert_eq!(APPLICATION, "runner-manager");
4088
4089 #[cfg(target_os = "macos")]
4090 assert_eq!(
4091 sys::service(),
4092 "io.github.IvanMurzak.runner-manager",
4093 "the keychain service names the product; a change here moves every \
4094 stored item and the token reads as absent"
4095 );
4096 }
4097
4098 /// Finding 2's replacement, and the whole of what the zero fill claims.
4099 ///
4100 /// The test it replaces stored, deleted, and asserted the file was gone —
4101 /// which is what *removing* it proves too, so
4102 /// `overwrite`'s `write_all` could have been deleted outright and the suite
4103 /// would have stayed green. This one observes the fill itself: it reads the
4104 /// bytes back **before** anything unlinks them, so the subject of the test
4105 /// cannot be removed without it failing.
4106 ///
4107 /// Not `cfg`-gated to Linux, although Linux is the platform whose stored
4108 /// value is plaintext, because the mechanism is shared and a test that only
4109 /// a CI leg can run is a test its author never watched fail.
4110 #[cfg(not(target_os = "macos"))]
4111 #[test]
4112 fn overwrite_zeroes_every_byte_and_leaves_the_file_there() {
4113 let root = TempDir::new().expect("a temporary directory");
4114 let path = root.path().join("value");
4115 let token = exposed(&fixture_token());
4116 std::fs::write(&path, &token).expect("written");
4117
4118 assert!(overwrite(&path).expect("overwritten"), "the file was there");
4119
4120 let after = std::fs::read(&path).expect("still there, so the fill is observable");
4121 assert_eq!(
4122 after.len(),
4123 token.len(),
4124 "the overwrite must not truncate; a shorter file leaves the tail of the old \
4125 value in the block"
4126 );
4127 assert!(
4128 after.iter().all(|byte| *byte == 0),
4129 "the file still holds non-zero bytes after an overwrite: {after:?}"
4130 );
4131 assert!(
4132 !after
4133 .windows(token.len())
4134 .any(|window| window == token.as_bytes()),
4135 "the value survived the overwrite"
4136 );
4137 }
4138
4139 #[cfg(not(target_os = "macos"))]
4140 #[test]
4141 fn overwriting_what_is_not_there_is_not_a_failure() {
4142 let root = TempDir::new().expect("a temporary directory");
4143 assert!(
4144 !overwrite(&root.path().join("absent")).expect("absence is not an error"),
4145 "a store that was never written has nothing to scrub"
4146 );
4147 }
4148
4149 /// Finding 6's pure half, run on every leg.
4150 ///
4151 /// The systemd credential path that uses this is Linux-only, so its
4152 /// end-to-end test is too; the decision the trim makes is not
4153 /// platform-specific, and testing it here is what let this be watched
4154 /// failing on the machine it was written on.
4155 #[cfg(not(target_os = "macos"))]
4156 #[test]
4157 fn a_trailing_newline_is_not_part_of_the_token() {
4158 let token = exposed(&fixture_token());
4159
4160 for suffix in ["\n", "\r\n", "\n\n", " ", "\t\n", ""] {
4161 let raw = format!("{token}{suffix}").into_bytes();
4162 assert_eq!(
4163 trim_trailing_ascii_whitespace(raw),
4164 token.clone().into_bytes(),
4165 "a credential written with {suffix:?} on the end yielded a different token"
4166 );
4167 }
4168
4169 // Interior whitespace is left alone. A token has none, and silently
4170 // rewriting the middle of a value would be a worse bug than the one
4171 // this fixes.
4172 let interior = b"gh u_x\n".to_vec();
4173 assert_eq!(trim_trailing_ascii_whitespace(interior), b"gh u_x".to_vec());
4174
4175 // A value that is nothing but whitespace becomes empty, which `decode`
4176 // reports as `Corrupt` rather than as absence.
4177 assert!(trim_trailing_ascii_whitespace(b"\n\n".to_vec()).is_empty());
4178 }
4179
4180 // -----------------------------------------------------------------------
4181 // Windows
4182 // -----------------------------------------------------------------------
4183
4184 #[cfg(windows)]
4185 mod windows {
4186 use super::*;
4187 use crate::secrets::sys::{merge_grants, replacement_sddl, sddl, trustees};
4188
4189 /// How much a [`DeniedReplace`] takes away.
4190 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4191 enum AlsoDeny {
4192 /// Only what denies a *replacing rename*. Creating a file in the
4193 /// directory still works, exactly as it does for operator B, so the
4194 /// temporary is still written if the store gets that far.
4195 Nothing,
4196 /// Additionally `FILE_ADD_FILE`, so nothing can be created in the
4197 /// directory at all. What makes
4198 /// [`the_refusal_is_made_before_anything_is_written`] able to tell
4199 /// a pre-write refusal from a post-write one.
4200 Creation,
4201 }
4202
4203 /// Reproduces what a second non-administrative operator meets, without
4204 /// needing a second account, and puts the rights back on **every** path
4205 /// out — including an unwind.
4206 ///
4207 /// **Both denies are required, and finding that out was the point of
4208 /// running this against the un-fixed code.** Windows grants delete two
4209 /// ways — `DELETE` on the object, or `FILE_DELETE_CHILD` on its parent
4210 /// — and `MOVEFILE_REPLACE_EXISTING`, which is what `std::fs::rename`
4211 /// is on Windows, takes either. Operator B has neither: the file's DACL
4212 /// names `SY`, `BA` and `OW`, and a stock `%ProgramData%` grants
4213 /// `BUILTIN\Users` no `DC`. The test process, by contrast, *owns* its
4214 /// temporary directory and so does hold `FILE_DELETE_CHILD` — with only
4215 /// the file denied, the rename went through and the simulation
4216 /// reproduced nothing while looking exactly right.
4217 ///
4218 /// # Why the restore is a `Drop` and not a line at the end of the body
4219 ///
4220 /// Because a failing assertion unwinds past that line, and what it
4221 /// leaves behind is not tidy: `Everyone:(D)` denies deleting the guard
4222 /// and `Everyone:(DC)` denies deleting the directory's children, so
4223 /// `TempDir::drop` — which ignores removal errors — cannot clean up. A
4224 /// DPAPI blob of the fixture token would sit in `%TEMP%` under an ACL
4225 /// ordinary cleanup cannot remove, on exactly the run where somebody is
4226 /// already debugging a failure.
4227 struct DeniedReplace {
4228 file: std::path::PathBuf,
4229 directory: std::path::PathBuf,
4230 restored: bool,
4231 }
4232
4233 impl DeniedReplace {
4234 fn new(file: &std::path::Path, directory: &std::path::Path, also: AlsoDeny) -> Self {
4235 let denied = Self {
4236 file: file.to_path_buf(),
4237 directory: directory.to_path_buf(),
4238 restored: false,
4239 };
4240 // Constructed first, so that a failure in any `icacls` below
4241 // unwinds through this guard's `Drop` and undoes the ones that
4242 // did take effect.
4243 icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(D)"]);
4244 icacls(&[
4245 &denied.directory.display().to_string(),
4246 "/deny",
4247 "*S-1-1-0:(DC)",
4248 ]);
4249 if also == AlsoDeny::Creation {
4250 icacls(&[
4251 &denied.directory.display().to_string(),
4252 "/deny",
4253 "*S-1-1-0:(WD)",
4254 ]);
4255 }
4256 denied
4257 }
4258
4259 /// Puts the rights back now, for a test that needs to go on and
4260 /// observe the store working again. Idempotent, so [`Drop`] running
4261 /// afterwards is a no-op.
4262 fn restore(&mut self) {
4263 if self.restored {
4264 return;
4265 }
4266 self.restored = true;
4267 // `/remove:d` drops every deny ACE this SID has on the object,
4268 // so one call per object undoes all of the above.
4269 //
4270 // Deliberately not asserting: this runs on the unwind path,
4271 // where a panic would abort the process and replace a readable
4272 // test failure with one nobody can diagnose.
4273 for path in [&self.file, &self.directory] {
4274 let arguments = [
4275 path.display().to_string(),
4276 "/remove:d".into(),
4277 "*S-1-1-0".into(),
4278 ];
4279 match run_icacls(&arguments.each_ref().map(String::as_str)) {
4280 Ok(output) if output.status.success() => {}
4281 other => eprintln!(
4282 "could not restore the ACL on {}: {other:?}; {} may survive in \
4283 the temporary directory",
4284 path.display(),
4285 path.display()
4286 ),
4287 }
4288 }
4289 }
4290 }
4291
4292 impl Drop for DeniedReplace {
4293 fn drop(&mut self) {
4294 self.restore();
4295 }
4296 }
4297
4298 fn run_icacls(arguments: &[&str]) -> std::io::Result<std::process::Output> {
4299 std::process::Command::new("icacls.exe")
4300 .args(arguments)
4301 .output()
4302 }
4303
4304 fn icacls(arguments: &[&str]) {
4305 let output = run_icacls(arguments).expect("icacls is present on every Windows");
4306 assert!(
4307 output.status.success(),
4308 "icacls {arguments:?} failed: {}{}",
4309 String::from_utf8_lossy(&output.stdout),
4310 String::from_utf8_lossy(&output.stderr)
4311 );
4312 }
4313
4314 /// The DACL string alone, with no OS in the way.
4315 #[test]
4316 fn a_replacement_carries_the_previous_owner_and_a_first_write_does_not() {
4317 for scope in [SecretScope::Machine, SecretScope::User] {
4318 assert_eq!(
4319 replacement_sddl(scope, &[]),
4320 sddl(scope),
4321 "a first write has nothing to carry, so it gets the constant DACL and \
4322 nothing else"
4323 );
4324 assert_eq!(
4325 replacement_sddl(scope, &["S-1-5-21-1-2-3-1001".to_owned()]),
4326 format!("{}(A;;FA;;;S-1-5-21-1-2-3-1001)", sddl(scope)),
4327 "one carried grant is one appended ACE"
4328 );
4329 }
4330 }
4331
4332 /// The second renewal, which is where carrying only the owner undoes
4333 /// itself.
4334 ///
4335 /// After one renewal the file is owned by `LocalSystem` and the
4336 /// operator is named by the ACE that renewal carried. A mechanism that
4337 /// rebuilds the DACL from the owner alone reads `S-1-5-18` here, drops
4338 /// the operator, and locks them out again eight hours after the fix
4339 /// appeared to work.
4340 ///
4341 /// Driving `store` cannot reach this: a test process is one account and
4342 /// cannot make the owner move. So the rule is tested where it lives.
4343 #[test]
4344 fn a_grant_survives_a_renewal_by_an_account_that_is_not_the_previous_owner() {
4345 let operator = "S-1-5-21-9-8-7-1001";
4346 let after_one_renewal = format!("{}(A;;FA;;;{operator})", sddl(SecretScope::Machine));
4347
4348 assert_eq!(
4349 merge_grants(Some("S-1-5-18"), &after_one_renewal),
4350 vec![operator.to_owned()],
4351 "the owner is now LocalSystem, which `SY` already grants; what must survive is \
4352 the operator named in the DACL the previous renewal wrote"
4353 );
4354
4355 assert_eq!(
4356 merge_grants(Some(operator), sddl(SecretScope::Machine)),
4357 vec![operator.to_owned()],
4358 "and the first renewal, where the operator is still the owner and the DACL \
4359 names nobody, carries the same one account"
4360 );
4361
4362 assert!(
4363 merge_grants(None, "").is_empty(),
4364 "a first write has no owner and no DACL to read, and carries nothing"
4365 );
4366
4367 assert_eq!(
4368 merge_grants(Some(operator), &after_one_renewal),
4369 vec![operator.to_owned()],
4370 "an account reachable both ways is named once, so the DACL cannot grow by an \
4371 ACE per write"
4372 );
4373
4374 // The CI runner's own case, which an ordinary developer machine
4375 // does not reach: Windows renders a well-known account's ACE by
4376 // alias, so what the previous renewal wrote as `S-1-5-21-...-500`
4377 // reads back as `LA`. Carrying only trustees spelled `S-1-` drops
4378 // it and the operator is locked out one renewal later.
4379 let after_a_renewal_for_a_builtin =
4380 format!("{}(A;;FA;;;LA)", sddl(SecretScope::Machine));
4381 assert_eq!(
4382 merge_grants(Some("S-1-5-18"), &after_a_renewal_for_a_builtin),
4383 vec!["LA".to_owned()],
4384 "a grant is a grant whichever spelling the DACL reads back in"
4385 );
4386
4387 assert!(
4388 merge_grants(None, sddl(SecretScope::Machine)).is_empty(),
4389 "and the constant DACL's own aliases are not carried, or every write would \
4390 double the base"
4391 );
4392 }
4393
4394 /// Every trustee, because after the first replacement one of them is
4395 /// where the operator's access lives — and Windows chooses the
4396 /// spelling, not this code.
4397 #[test]
4398 fn the_trustee_scan_reads_both_spellings() {
4399 assert_eq!(
4400 trustees("D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)(A;;FA;;;S-1-5-21-9-8-7-1001)"),
4401 vec![
4402 "SY".to_owned(),
4403 "BA".to_owned(),
4404 "OW".to_owned(),
4405 "S-1-5-21-9-8-7-1001".to_owned(),
4406 ],
4407 "the scan reads trustees; deciding which of them are already granted is \
4408 `merge_grants`'s job and not this one's"
4409 );
4410 assert_eq!(
4411 trustees("D:P(A;;FA;;;LA)"),
4412 vec!["LA".to_owned()],
4413 "an alias is a trustee too, which is what CI's built-in Administrator account \
4414 reads back as"
4415 );
4416 }
4417
4418 /// The lockout, as a test: renewal writes as a different account, and
4419 /// the operator who signed in must still be able to read the store.
4420 ///
4421 /// # What this can and cannot reproduce
4422 ///
4423 /// A test process is one account, so it cannot *become* `LocalSystem`
4424 /// and take ownership the way a daemon does. What it pins is the
4425 /// mechanism that makes that survivable: after a replacement, the
4426 /// previous owner is named in the DACL by SID rather than left to
4427 /// `OW` — so when the owner does move, the grant does not move with it.
4428 ///
4429 /// Un-fixed, the second description equals the first: `OW` and nothing
4430 /// else, which is exactly the state that locked an operator out of
4431 /// their own credential on 2026-08-29.
4432 ///
4433 /// # What it asserts, and why not the SID
4434 ///
4435 /// That the very first write already names somebody beyond the
4436 /// constant, and that the DACL then stops changing. Growth would be an
4437 /// ACE per write; a return to the constant would be the account
4438 /// dropped, which is the lockout.
4439 ///
4440 /// The first write counts because `OW` is not enough on its own: the
4441 /// system deletes the OWNER RIGHTS ACE whenever an owner changes, so a
4442 /// store that leans on it loses the grant to a single `takeown`. The
4443 /// writer is named by SID from the start.
4444 ///
4445 /// It deliberately does not name the account. Windows chooses the
4446 /// spelling: this machine's operator reads back as
4447 /// `S-1-5-21-…-1001`, and CI's, being the built-in Administrator,
4448 /// reads back as the alias `LA`. An earlier version of this test
4449 /// asserted the SID and failed on CI for that reason alone —
4450 /// and the same assumption was in the code, where it was a real
4451 /// defect. The spelling-sensitive rule is pinned in
4452 /// [`the_trustee_scan_reads_both_spellings`] instead.
4453 #[test]
4454 fn replacing_a_stored_credential_keeps_the_previous_owners_grant() {
4455 let root = TempDir::new().expect("a temporary directory");
4456 let store = rooted(SecretScope::Machine, &root);
4457 let base = sddl(SecretScope::Machine);
4458
4459 store.store(&fixture_token()).expect("the first write");
4460 let first = store
4461 .protection()
4462 .expect("the first file's DACL is readable")
4463 .description()
4464 .to_string();
4465 assert_ne!(
4466 first, base,
4467 "even a first write names the account that made it, because `OW` alone does \
4468 not survive a change of owner"
4469 );
4470
4471 let mut previous: Option<String> = Some(first);
4472 for round in 1..=3 {
4473 store.store(&other_token()).expect("the replacement");
4474 let dacl = store
4475 .protection()
4476 .expect("the replacement's DACL is readable")
4477 .description()
4478 .to_string();
4479 assert_ne!(
4480 dacl, base,
4481 "write {round}: the account that owned what was replaced must stay granted \
4482 explicitly, because a writer under another account takes `OW` with it"
4483 );
4484 if let Some(previous) = &previous {
4485 assert_eq!(
4486 &dacl, previous,
4487 "write {round}: and the set must settle -- a DACL that keeps growing is \
4488 an ACE per renewal, and one that shrinks back to the constant is the \
4489 lockout returning"
4490 );
4491 }
4492 previous = Some(dacl);
4493 }
4494
4495 assert_eq!(
4496 store
4497 .load()
4498 .expect("the replacement is readable")
4499 .map(|secret| secret.expose_secret().to_string()),
4500 Some(other_token().expose_secret().to_string()),
4501 "carrying an ACE forward must not disturb what the store holds"
4502 );
4503 }
4504
4505 /// Denies *reading* the store, which is the state a renewal under a
4506 /// service account leaves an operator in, and puts the right back on
4507 /// every path out including an unwind.
4508 ///
4509 /// A deny ACE for `Everyone` binds administrators too, which is what
4510 /// makes this reproducible on CI's elevated runner as well as on an
4511 /// ordinary developer machine.
4512 struct DeniedRead {
4513 file: std::path::PathBuf,
4514 restored: bool,
4515 }
4516
4517 impl DeniedRead {
4518 fn new(file: &std::path::Path) -> Self {
4519 let denied = Self {
4520 file: file.to_path_buf(),
4521 restored: false,
4522 };
4523 icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(R)"]);
4524 denied
4525 }
4526
4527 fn restore(&mut self) {
4528 if self.restored {
4529 return;
4530 }
4531 self.restored = true;
4532 let arguments = [
4533 self.file.display().to_string(),
4534 "/remove:d".into(),
4535 "*S-1-1-0".into(),
4536 ];
4537 // Not asserted: this runs on the unwind path, where a panic
4538 // would replace a readable failure with an aborted process.
4539 match run_icacls(&arguments.each_ref().map(String::as_str)) {
4540 Ok(output) if output.status.success() => {}
4541 other => eprintln!(
4542 "could not restore the ACL on {}: {other:?}",
4543 self.file.display()
4544 ),
4545 }
4546 }
4547 }
4548
4549 impl Drop for DeniedRead {
4550 fn drop(&mut self) {
4551 self.restore();
4552 }
4553 }
4554
4555 /// A store the operator may not read says how to get it back.
4556 ///
4557 /// Carrying the previous owner keeps this from *starting*, but it
4558 /// cannot end it on a host where it already happened: the file grants
4559 /// nobody but the service and has nothing left in its DACL to carry, so
4560 /// every renewal rebuilds the same DACL. Without a message the operator
4561 /// is told `Access is denied` by every command, forever, with no way to
4562 /// find out that one elevated `takeown` ends it.
4563 ///
4564 /// Watched on a real host on 2026-08-30, on 0.1.13, which had the carry
4565 /// and still could not read its own credential.
4566 #[test]
4567 fn a_store_this_account_may_not_read_names_the_command_that_gives_it_back() {
4568 let root = TempDir::new().expect("a temporary directory");
4569 let store = rooted(SecretScope::Machine, &root);
4570 store.store(&fixture_token()).expect("the first write");
4571
4572 let guard = store.guard();
4573 let _denied = DeniedRead::new(&guard);
4574
4575 let error = store
4576 .load()
4577 .expect_err("a store this account may not read is not a store it can load");
4578 let rendered = error.to_string();
4579
4580 for expected in ["icacls", "/grant", &guard.display().to_string(), "elevated"] {
4581 assert!(
4582 rendered.contains(expected),
4583 "the refusal must name the remedy and the file it applies to. Wanted \
4584 {expected:?} in: {rendered}"
4585 );
4586 }
4587 assert!(
4588 !rendered.contains(fixture_token().expose_secret()),
4589 "and it must not carry the value it could not read"
4590 );
4591 assert!(
4592 rendered.contains("Do NOT use `takeown`"),
4593 "and it must warn off the repair that looks right and is not. Changing the \
4594 owner makes Windows delete the OWNER RIGHTS ACE, so the account ends up \
4595 owning a file it still cannot read -- permanently. Offered on a real host on \
4596 2026-08-30, where it appeared to work only because the check that followed \
4597 ran in the same elevated prompt. Got: {rendered}"
4598 );
4599 }
4600
4601 /// Finding 1: a second operator is refused with a remedy, before
4602 /// anything is written.
4603 ///
4604 /// The behaviour is right — one machine-scoped credential per host, and
4605 /// replacing it is an administrator's call; see the backend's module
4606 /// documentation for why widening the DACL is the wrong answer. What
4607 /// this pins is that the refusal *says so*, that it is classifiable as
4608 /// a permission problem, and that it leaves nothing on disk.
4609 #[test]
4610 fn a_second_operator_is_refused_with_a_remedy_and_writes_nothing() {
4611 let root = TempDir::new().expect("a temporary directory");
4612 let store = rooted(SecretScope::Machine, &root);
4613 store
4614 .store(&fixture_token())
4615 .expect("the first operator stores");
4616
4617 let guard = store.guard();
4618 let directory = guard
4619 .parent()
4620 .expect("the guard has a directory")
4621 .to_path_buf();
4622 let mut denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Nothing);
4623
4624 let error = store
4625 .store(&other_token())
4626 .expect_err("a value this account may not replace is not a value it may store");
4627
4628 let rendered = error.to_string();
4629 for expected in [
4630 "one machine-scoped credential per host",
4631 "auth logout",
4632 "elevated",
4633 "--start-at login",
4634 "Nothing was written",
4635 ] {
4636 assert!(
4637 rendered.contains(expected),
4638 "the refusal does not name {expected:?}, so an operator cannot act on \
4639 it: {rendered}"
4640 );
4641 }
4642 assert!(
4643 matches!(
4644 &error,
4645 SecretStoreError::Store { source, .. }
4646 if source.kind() == std::io::ErrorKind::PermissionDenied
4647 ),
4648 "a caller cannot classify this as a permission problem: {error:?}"
4649 );
4650
4651 // Nothing was written, so the refusal did not also leave a valid
4652 // machine-decryptable blob of a live token under a name nobody
4653 // looks at. This is the half the old code got wrong: it found out
4654 // at the rename, after the blob was already on disk.
4655 let strays: Vec<_> = std::fs::read_dir(&directory)
4656 .expect("readable")
4657 .flatten()
4658 .map(|entry| entry.file_name().to_string_lossy().into_owned())
4659 .filter(|name| name.ends_with(".tmp"))
4660 .collect();
4661 assert!(strays.is_empty(), "a refused store left {strays:?}");
4662
4663 // And the first operator's value is untouched.
4664 denied.restore();
4665 assert_eq!(stored(&store), exposed(&fixture_token()));
4666
4667 // With the denial lifted, the same call succeeds -- which is what
4668 // makes the assertions above a statement about the denial rather
4669 // than about the store being broken.
4670 store
4671 .store(&other_token())
4672 .expect("stored once allowed again");
4673 assert_eq!(stored(&store), exposed(&other_token()));
4674 }
4675
4676 /// The ordering the fix is built around: the refusal is made **before**
4677 /// anything is encrypted or written.
4678 ///
4679 /// The test above cannot tell. The rename path re-derives the identical
4680 /// diagnosis — on `PermissionDenied` it calls `cannot_replace` itself —
4681 /// so moving the probe back behind the write leaves all five message
4682 /// assertions and the `kind()` assertion passing, and `discard` removes
4683 /// the temporary either way, so the `strays` assertion does not catch it
4684 /// either. The property stated at `store`'s call site and twice in the
4685 /// module documentation was asserted by nothing: the same shape as the
4686 /// zero fill that could be deleted with the suite staying green.
4687 ///
4688 /// [`AlsoDeny::Creation`] closes that. With `FILE_ADD_FILE` denied on
4689 /// the directory:
4690 ///
4691 /// - a probe that runs **first** still produces the remedy, because it
4692 /// only reads the target's ACL, and nothing has been written;
4693 /// - a probe that runs **after** the write never runs at all, because
4694 /// `create_protected_file` dies first with a bare denial that carries
4695 /// no remedy in it.
4696 ///
4697 /// So this pins the ordering rather than the wording.
4698 #[test]
4699 fn the_refusal_is_made_before_anything_is_written() {
4700 let root = TempDir::new().expect("a temporary directory");
4701 let store = rooted(SecretScope::Machine, &root);
4702 store
4703 .store(&fixture_token())
4704 .expect("the first operator stores");
4705
4706 let guard = store.guard();
4707 let directory = guard
4708 .parent()
4709 .expect("the guard has a directory")
4710 .to_path_buf();
4711 let _denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Creation);
4712
4713 let error = store
4714 .store(&other_token())
4715 .expect_err("nothing can be created here, so nothing can be stored");
4716
4717 let rendered = error.to_string();
4718 for expected in [
4719 "one machine-scoped credential per host",
4720 "auth logout",
4721 "elevated",
4722 "--start-at login",
4723 "Nothing was written",
4724 ] {
4725 assert!(
4726 rendered.contains(expected),
4727 "the refusal does not name {expected:?}. With creation denied, the only \
4728 way to produce that text is a check that ran BEFORE the write -- so this \
4729 message came from `create_protected_file` instead, and the store reached \
4730 the write before it refused: {rendered}"
4731 );
4732 }
4733 }
4734
4735 /// Finding 3: the DPAPI output buffer is zeroed before it is freed.
4736 ///
4737 /// Asserted against a buffer Rust owns, because once `LocalFree` has
4738 /// run there is nothing left to look at. `copy_and_scrub` is the whole
4739 /// of what `take_blob` does to that buffer before freeing it, so this
4740 /// is the property and not a rehearsal of it.
4741 #[test]
4742 fn the_dpapi_buffer_is_scrubbed_before_it_is_freed() {
4743 let token = exposed(&fixture_token());
4744 let mut buffer = token.clone().into_bytes();
4745 let length = buffer.len();
4746
4747 // SAFETY: `buffer` is a live allocation of exactly `length` bytes
4748 // and nothing else refers to it for the duration of the call.
4749 let copy = unsafe { sys::copy_and_scrub(buffer.as_mut_ptr(), length) };
4750
4751 assert_eq!(
4752 copy,
4753 token.clone().into_bytes(),
4754 "the caller must still receive the value"
4755 );
4756 assert!(
4757 buffer.iter().all(|byte| *byte == 0),
4758 "the source buffer still holds the token after the copy, and on the \
4759 unprotect path that buffer is handed back to the heap by LocalFree: \
4760 {buffer:?}"
4761 );
4762 }
4763
4764 #[test]
4765 fn a_win32_error_keeps_its_kind_through_the_hresult_wrapper() {
4766 // ERROR_ACCESS_DENIED as windows-rs reports it: HRESULT 0x80070005.
4767 // Handed straight to `from_raw_os_error` this is `Uncategorized`,
4768 // and finding 1's refusal could not be classified by a caller.
4769 let denied = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
4770 0x8007_0005_u32 as i32,
4771 ));
4772 assert_eq!(
4773 sys::io_error(&denied).kind(),
4774 std::io::ErrorKind::PermissionDenied
4775 );
4776
4777 // ERROR_FILE_NOT_FOUND, 0x80070002.
4778 let missing = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
4779 0x8007_0002_u32 as i32,
4780 ));
4781 assert_eq!(sys::io_error(&missing).kind(), std::io::ErrorKind::NotFound);
4782 }
4783
4784 #[test]
4785 fn both_dacls_are_protected_and_name_no_broad_trustee() {
4786 for scope in [SecretScope::Machine, SecretScope::User] {
4787 let sddl = sys::sddl(scope);
4788 assert!(
4789 sddl.starts_with("D:P"),
4790 "an unprotected DACL inherits whatever %ProgramData% grants, which is \
4791 Builtin Users read: {sddl}"
4792 );
4793 for broad in ["WD", "AU", "BU", "IU", "AN"] {
4794 assert!(
4795 !sddl.contains(&format!(";{broad})")),
4796 "{scope}: {sddl} names the broad trustee {broad}"
4797 );
4798 }
4799 }
4800 }
4801
4802 #[test]
4803 fn only_the_machine_dacl_carries_the_local_system_ace() {
4804 // `process.rs` argues at length that a `SY` ACE adds nothing to the
4805 // JIT handoff, and it is right there: that file's writer and reader
4806 // are one process. Here they are not -- `auth login` writes as the
4807 // operator and the daemon reads as the service account -- so `SY`
4808 // is load-bearing on the machine store and out of place on the
4809 // user store, which is deliberately not for a service.
4810 assert!(sys::sddl(SecretScope::Machine).contains("(A;;FA;;;SY)"));
4811 assert!(!sys::sddl(SecretScope::User).contains(";SY)"));
4812 }
4813
4814 #[test]
4815 fn the_dacl_on_disk_is_the_one_the_backend_asked_for() {
4816 let root = TempDir::new().expect("a temporary directory");
4817 for scope in [SecretScope::Machine, SecretScope::User] {
4818 let store = rooted(scope, &root);
4819 store.store(&fixture_token()).expect("stored");
4820
4821 let description = store.protection().expect("inspectable").description;
4822 assert!(
4823 description.contains("D:P"),
4824 "{scope}: the stored file did not keep its protected DACL: {description}"
4825 );
4826 assert!(
4827 description.contains("FA;;;BA"),
4828 "{scope}: an administrator must still be able to clean up: {description}"
4829 );
4830 // The one ACE that keeps a *non-administrative* operator able
4831 // to read back what they just stored. Nothing else in the DACL
4832 // names them, so if `CreateFileW` had quietly dropped it the
4833 // round trip would still pass on an administrator's machine and
4834 // fail on everybody else's.
4835 assert!(
4836 description.contains(";OW)") || description.contains(";S-1-3-4)"),
4837 "{scope}: the OWNER RIGHTS ACE did not survive to disk, so a \
4838 non-administrative operator cannot read their own token: {description}"
4839 );
4840 if scope == SecretScope::Machine {
4841 assert!(
4842 description.contains("FA;;;SY"),
4843 "a LocalSystem daemon must be able to read the machine store: \
4844 {description}"
4845 );
4846 }
4847 }
4848 }
4849
4850 #[test]
4851 fn the_bytes_on_disk_are_not_the_value() {
4852 let root = TempDir::new().expect("a temporary directory");
4853 let store = rooted(SecretScope::Machine, &root);
4854 store.store(&fixture_token()).expect("stored");
4855
4856 let blob = std::fs::read(store.guard()).expect("the blob is readable");
4857 let token = exposed(&fixture_token());
4858 assert!(
4859 !blob
4860 .windows(token.len())
4861 .any(|window| window == token.as_bytes()),
4862 "the DPAPI blob contains the plaintext token"
4863 );
4864 }
4865
4866 #[test]
4867 fn bytes_that_are_not_a_blob_are_reported_as_corrupt() {
4868 let root = TempDir::new().expect("a temporary directory");
4869 let store = rooted(SecretScope::Machine, &root);
4870 store.store(&fixture_token()).expect("stored");
4871
4872 // A blob truncated by an interrupted write, or one written by
4873 // another machine and copied here. Either way DPAPI refuses it, and
4874 // the caller must be told to purge rather than to retry.
4875 std::fs::write(store.guard(), b"this is not a DPAPI blob").expect("planted");
4876 let error = store.load().expect_err("a foreign blob is not a value");
4877 assert!(
4878 matches!(error, SecretStoreError::Corrupt { .. }),
4879 "got {error:?}"
4880 );
4881 }
4882 }
4883
4884 // -----------------------------------------------------------------------
4885 // Unix -- the mode bits, on both Unixes
4886 // -----------------------------------------------------------------------
4887
4888 #[cfg(unix)]
4889 mod unix {
4890 use super::*;
4891
4892 use std::os::unix::fs::PermissionsExt as _;
4893
4894 fn mode_of(path: &std::path::Path) -> u32 {
4895 std::fs::metadata(path)
4896 .unwrap_or_else(|error| panic!("{} is not there: {error}", path.display()))
4897 .permissions()
4898 .mode()
4899 & 0o777
4900 }
4901
4902 /// Asked before anything irreversible, so it has to agree with what a
4903 /// write would meet -- in both directions.
4904 #[test]
4905 fn ensure_writable_refuses_exactly_when_the_store_directory_is_not_writable() {
4906 // SAFETY: `geteuid` reads this process's own credentials, takes no
4907 // pointer, and cannot fail.
4908 if unsafe { libc::geteuid() } == 0 {
4909 // Modes refuse nobody running as root, so the refusal half
4910 // cannot be staged here.
4911 return;
4912 }
4913 for scope in [SecretScope::Machine, SecretScope::User] {
4914 let root = TempDir::new().expect("a temporary directory");
4915 let store = rooted(scope, &root);
4916 store
4917 .ensure_writable()
4918 .unwrap_or_else(|error| panic!("{scope}: nothing written yet: {error}"));
4919 store.store(&fixture_token()).expect("stored");
4920 store
4921 .ensure_writable()
4922 .unwrap_or_else(|error| panic!("{scope}: the writer's own store: {error}"));
4923
4924 let guard = store.guard();
4925 let directory = guard.parent().expect("the guard has a directory");
4926 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o500))
4927 .expect("the directory is made read-only");
4928 let refusal = store.ensure_writable();
4929 // A new entry beside the store, which is what both backends'
4930 // replacing write creates first. Not `store` itself: the Linux
4931 // backend re-applies `0700` to a directory this test's account
4932 // owns, so it would undo the staging rather than meet it. The
4933 // production case -- a directory another account owns -- is
4934 // one no chmod of ours can repair.
4935 let write = std::fs::File::create(directory.join("probe.tmp"));
4936 std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))
4937 .expect("the directory is restored for cleanup");
4938
4939 let refusal = refusal.expect_err("a read-only directory refuses a write");
4940 assert!(
4941 matches!(
4942 &refusal,
4943 SecretStoreError::Store { source, .. }
4944 if source.kind() == std::io::ErrorKind::PermissionDenied
4945 ),
4946 "{scope}: got {refusal:?}"
4947 );
4948 assert!(
4949 write.is_err(),
4950 "{scope}: the check refused an entry the directory then accepted"
4951 );
4952 }
4953 }
4954
4955 #[test]
4956 fn the_guard_is_0600_and_its_directory_is_0700() {
4957 for scope in [SecretScope::Machine, SecretScope::User] {
4958 let root = TempDir::new().expect("a temporary directory");
4959 let store = rooted(scope, &root);
4960 store.store(&fixture_token()).expect("stored");
4961
4962 let guard = store.guard();
4963 assert_eq!(
4964 mode_of(&guard),
4965 0o600,
4966 "{scope}: {} is not 0600",
4967 guard.display()
4968 );
4969 let directory = guard.parent().expect("the guard has a directory");
4970 assert_eq!(
4971 mode_of(directory),
4972 0o700,
4973 "{scope}: {} is not 0700",
4974 directory.display()
4975 );
4976 }
4977 }
4978 }
4979
4980 // -----------------------------------------------------------------------
4981 // Linux -- systemd credentials
4982 // -----------------------------------------------------------------------
4983
4984 #[cfg(all(unix, not(target_os = "macos")))]
4985 mod linux {
4986 use super::*;
4987
4988 use std::os::unix::fs::PermissionsExt as _;
4989
4990 /// Writes `bytes` where systemd would have put a credential.
4991 fn plant_credential(directory: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
4992 std::fs::create_dir_all(directory).expect("the credentials directory");
4993 let path = directory.join(SYSTEMD_CREDENTIAL);
4994 std::fs::write(&path, bytes).expect("the credential is written");
4995 // systemd mounts the credentials directory read-only and gives each
4996 // credential 0400. Reproduced so that the protection assertion sees
4997 // what production would.
4998 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))
4999 .expect("the credential is tightened");
5000 path
5001 }
5002
5003 #[test]
5004 fn a_systemd_credential_is_read_in_preference_to_the_file() {
5005 let root = TempDir::new().expect("a temporary directory");
5006 let credentials = TempDir::new().expect("a credentials directory");
5007
5008 // The file first, so that reading the credential is a preference
5009 // rather than the only thing there is to read.
5010 let plain = rooted(SecretScope::Machine, &root);
5011 plain.store(&other_token()).expect("stored");
5012
5013 plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5014 let store =
5015 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5016
5017 assert_eq!(
5018 stored(&store),
5019 exposed(&fixture_token()),
5020 "a unit given LoadCredentialEncrypted= must not be overridden by a stale file"
5021 );
5022 assert_eq!(
5023 store.credential_path(),
5024 Some(credentials.path().join(SYSTEMD_CREDENTIAL).as_path())
5025 );
5026 assert!(
5027 store.location().contains("systemd credential"),
5028 "`host show` must say where the value is actually coming from: {}",
5029 store.location()
5030 );
5031 }
5032
5033 /// Finding 6, end to end: what an operator's `systemd-creds encrypt`
5034 /// actually produces.
5035 ///
5036 /// `echo`, `printf '%s\n'` and every text editor leave a newline. Used
5037 /// byte for byte it becomes part of the token and fails much later
5038 /// inside an `Authorization` header, where it reads as a bad credential
5039 /// rather than as a bad read — which is the failure this pins.
5040 #[test]
5041 fn a_credential_written_with_a_trailing_newline_yields_the_token_without_it() {
5042 let root = TempDir::new().expect("a temporary directory");
5043 let credentials = TempDir::new().expect("a credentials directory");
5044 let token = exposed(&fixture_token());
5045
5046 plant_credential(credentials.path(), format!("{token}\n").as_bytes());
5047 let store =
5048 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5049
5050 assert_eq!(stored(&store), token);
5051 }
5052
5053 /// The file half of the same decision, which must *not* be trimmed.
5054 ///
5055 /// `store` writes no newline, so anything trailing in its own file is
5056 /// corruption rather than formatting, and repairing it silently would
5057 /// hide the one thing `Corrupt` exists to report.
5058 #[test]
5059 fn the_stores_own_file_is_read_back_verbatim() {
5060 let root = TempDir::new().expect("a temporary directory");
5061 let store = rooted(SecretScope::Machine, &root);
5062 store.store(&fixture_token()).expect("stored");
5063
5064 let token = exposed(&fixture_token());
5065 std::fs::write(store.guard(), format!("{token}\n")).expect("planted");
5066
5067 assert_eq!(
5068 stored(&store),
5069 format!("{token}\n"),
5070 "the store's own file is used verbatim; only a systemd credential is trimmed"
5071 );
5072 }
5073
5074 #[test]
5075 fn a_credentials_directory_without_this_credential_falls_back_to_the_file() {
5076 let root = TempDir::new().expect("a temporary directory");
5077 let credentials = TempDir::new().expect("a credentials directory");
5078
5079 let plain = rooted(SecretScope::Machine, &root);
5080 plain.store(&fixture_token()).expect("stored");
5081
5082 // A unit can be given credentials without being given this one.
5083 std::fs::write(credentials.path().join("something.else"), b"x").expect("written");
5084 let store =
5085 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5086
5087 assert_eq!(stored(&store), exposed(&fixture_token()));
5088 }
5089
5090 #[test]
5091 fn the_credential_is_what_protection_inspects_when_there_is_one() {
5092 let root = TempDir::new().expect("a temporary directory");
5093 let credentials = TempDir::new().expect("a credentials directory");
5094 let planted =
5095 plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5096
5097 let store =
5098 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5099 let protection = store.protection().expect("inspectable");
5100
5101 assert_eq!(protection.guard(), planted);
5102 assert!(!protection.readable_by_other_local_users(), "{protection}");
5103 }
5104
5105 #[test]
5106 fn storing_under_a_systemd_credential_is_refused_rather_than_shadowed() {
5107 let root = TempDir::new().expect("a temporary directory");
5108 let credentials = TempDir::new().expect("a credentials directory");
5109 plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5110
5111 // The same site without the credential, so the test can name the
5112 // file the refusal must not have written.
5113 let file = rooted(SecretScope::Machine, &root).guard();
5114
5115 let store =
5116 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5117 let error = store
5118 .store(&other_token())
5119 .expect_err("a write that the next read would ignore is not a write");
5120 let rendered = error.to_string();
5121 assert!(rendered.contains(SYSTEMD_CREDENTIAL), "{rendered}");
5122
5123 // And nothing was written, so the refusal did not also leave a
5124 // second copy of a token on disk.
5125 assert!(!file.exists(), "{} was written anyway", file.display());
5126 }
5127
5128 #[test]
5129 fn purging_under_a_systemd_credential_removes_the_file_and_reports_the_remainder() {
5130 let root = TempDir::new().expect("a temporary directory");
5131 let credentials = TempDir::new().expect("a credentials directory");
5132
5133 let plain = rooted(SecretScope::Machine, &root);
5134 plain.store(&other_token()).expect("stored");
5135 let file = plain.guard();
5136 plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
5137
5138 let store =
5139 rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
5140 let error = store
5141 .delete()
5142 .expect_err("this host is not purged and `auth logout` must not say it is");
5143
5144 assert!(!file.exists(), "the file it could remove was removed");
5145 assert!(
5146 error.to_string().contains(SYSTEMD_CREDENTIAL),
5147 "the operator has to be told what is still supplying a token: {error}"
5148 );
5149 }
5150
5151 #[test]
5152 #[serial_test::serial]
5153 fn the_standard_machine_store_reads_the_credentials_directory_from_the_environment() {
5154 let credentials = TempDir::new().expect("a credentials directory");
5155
5156 // SAFETY: `serial_test` guarantees no other test in this binary is
5157 // running, and this is the only test that touches this variable.
5158 unsafe {
5159 std::env::set_var(CREDENTIALS_DIRECTORY, credentials.path());
5160 }
5161 let store = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
5162 let resolved = store.credential_path().map(std::path::Path::to_path_buf);
5163
5164 // SAFETY: as above.
5165 unsafe {
5166 std::env::remove_var(CREDENTIALS_DIRECTORY);
5167 }
5168
5169 assert_eq!(
5170 resolved,
5171 Some(credentials.path().join(SYSTEMD_CREDENTIAL)),
5172 "a daemon started by systemd gets its credential without being told to"
5173 );
5174
5175 let without = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
5176 assert_eq!(
5177 without.credential_path(),
5178 None,
5179 "a daemon started by anything else must not invent one"
5180 );
5181 }
5182
5183 #[test]
5184 fn a_user_scoped_store_never_consults_a_service_credential() {
5185 let store = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
5186 assert_eq!(store.credential_path(), None);
5187 }
5188 }
5189}