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