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