use std::fmt;
use std::path::{Path, PathBuf};
use runner_manager_domain::model::StartMode;
use secrecy::{ExposeSecret, SecretString};
use crate::paths::{APPLICATION, ORGANIZATION, QUALIFIER};
#[cfg(target_os = "macos")]
static KEYCHAIN_SERVICE: std::sync::LazyLock<String> =
std::sync::LazyLock::new(|| format!("{QUALIFIER}.{ORGANIZATION}.{APPLICATION}"));
const ITEM: &str = "user-access-token";
const DIRECTORY: &str = "secrets";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SecretScope {
Machine,
User,
}
impl SecretScope {
#[must_use]
pub const fn for_start_mode(mode: StartMode) -> Self {
match mode {
StartMode::Boot => Self::Machine,
StartMode::Login => Self::User,
}
}
#[must_use]
pub const fn start_mode(self) -> StartMode {
match self {
Self::Machine => StartMode::Boot,
Self::User => StartMode::Login,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Machine => "machine",
Self::User => "user",
}
}
}
impl fmt::Display for SecretScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SecretStoreError {
#[error("cannot work out where the {scope}-scoped secret store lives: {reason}")]
Resolve {
scope: SecretScope,
reason: String,
},
#[error(
"cannot write the user access token to the {scope}-scoped store at {location}: {source}"
)]
Store {
scope: SecretScope,
location: String,
#[source]
source: std::io::Error,
},
#[error(
"cannot read the user access token from the {scope}-scoped store at {location}: {source}"
)]
Load {
scope: SecretScope,
location: String,
#[source]
source: std::io::Error,
},
#[error(
"cannot delete the user access token from the {scope}-scoped store at {location}: {source}"
)]
Delete {
scope: SecretScope,
location: String,
#[source]
source: std::io::Error,
},
#[error(
"the {scope}-scoped store at {location} does not hold a user access token this product \
wrote: {detail}. Run `auth logout` to purge it and `auth login` to obtain a fresh token."
)]
Corrupt {
scope: SecretScope,
location: String,
detail: String,
},
#[error("cannot inspect what protects the {scope}-scoped store at {}: {source}", guard.display())]
Inspect {
scope: SecretScope,
guard: PathBuf,
#[source]
source: crate::process::HandoffError,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Removal {
Removed,
AlreadyAbsent,
}
impl Removal {
#[must_use]
pub const fn removed_something(self) -> bool {
matches!(self, Self::Removed)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Protection {
guard: PathBuf,
description: String,
readable_by_other_local_users: bool,
}
impl Protection {
#[must_use]
pub fn guard(&self) -> &Path {
&self.guard
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub const fn readable_by_other_local_users(&self) -> bool {
self.readable_by_other_local_users
}
}
impl fmt::Display for Protection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} ({}){}",
self.guard.display(),
self.description,
if self.readable_by_other_local_users {
" -- READABLE BY OTHER LOCAL USERS"
} else {
""
}
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveStore {
scope: SecretScope,
start_mode: StartMode,
location: String,
}
impl ActiveStore {
#[must_use]
pub fn of(store: &dyn SecretStore, start_mode: StartMode) -> Self {
Self {
scope: store.scope(),
start_mode,
location: store.location(),
}
}
#[must_use]
pub const fn scope(&self) -> SecretScope {
self.scope
}
#[must_use]
pub const fn start_mode(&self) -> StartMode {
self.start_mode
}
#[must_use]
pub fn location(&self) -> &str {
&self.location
}
#[must_use]
pub fn agrees_with_start_mode(&self) -> bool {
SecretScope::for_start_mode(self.start_mode) == self.scope
}
}
impl fmt::Display for ActiveStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}-scoped secret store at {} (service starts at {})",
self.scope, self.location, self.start_mode
)?;
if !self.agrees_with_start_mode() {
write!(
f,
" -- MISMATCH: starting at {} needs the {}-scoped store",
self.start_mode,
SecretScope::for_start_mode(self.start_mode)
)?;
}
Ok(())
}
}
pub trait SecretStore: fmt::Debug + Send + Sync {
fn scope(&self) -> SecretScope;
fn location(&self) -> String;
fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError>;
fn load(&self) -> Result<Option<SecretString>, SecretStoreError>;
fn delete(&self) -> Result<Removal, SecretStoreError>;
fn protection(&self) -> Result<Protection, SecretStoreError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformSecretStore {
scope: SecretScope,
site: sys::Site,
}
impl PlatformSecretStore {
pub fn standard(scope: SecretScope) -> Result<Self, SecretStoreError> {
let site = sys::standard_site(scope).map_err(|reason| SecretStoreError::Resolve {
scope,
reason: reason.to_string(),
})?;
Ok(Self { scope, site })
}
pub fn for_start_mode(mode: StartMode) -> Result<Self, SecretStoreError> {
Self::standard(SecretScope::for_start_mode(mode))
}
pub fn rooted_at(scope: SecretScope, root: impl AsRef<Path>) -> Result<Self, SecretStoreError> {
let site =
sys::rooted_site(scope, root.as_ref()).map_err(|reason| SecretStoreError::Resolve {
scope,
reason: reason.to_string(),
})?;
Ok(Self { scope, site })
}
#[must_use]
pub fn guard(&self) -> PathBuf {
sys::guard(&self.site)
}
fn decode(&self, bytes: Vec<u8>) -> Result<SecretString, SecretStoreError> {
use secrecy::zeroize::Zeroize as _;
let length = bytes.len();
if length == 0 {
return Err(self.corrupt("it is empty"));
}
match String::from_utf8(bytes) {
Ok(text) => Ok(SecretString::from(text)),
Err(error) => {
let mut bytes = error.into_bytes();
bytes.zeroize();
Err(self.corrupt(&format!("the {length} bytes there are not valid UTF-8")))
}
}
}
fn corrupt(&self, detail: &str) -> SecretStoreError {
SecretStoreError::Corrupt {
scope: self.scope,
location: self.location(),
detail: detail.to_string(),
}
}
}
impl SecretStore for PlatformSecretStore {
fn scope(&self) -> SecretScope {
self.scope
}
fn location(&self) -> String {
sys::describe(&self.site)
}
fn store(&self, secret: &SecretString) -> Result<(), SecretStoreError> {
let failed = |source| SecretStoreError::Store {
scope: self.scope,
location: self.location(),
source,
};
let exposed = secret.expose_secret();
if exposed.is_empty() {
return Err(failed(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"an empty value is not a user access token",
)));
}
sys::store(&self.site, self.scope, exposed.as_bytes()).map_err(failed)?;
tracing::info!(
event = "secret_store_written",
scope = self.scope.as_str(),
"the user access token was written to the secret store"
);
Ok(())
}
fn load(&self) -> Result<Option<SecretString>, SecretStoreError> {
let bytes = sys::load(&self.site, self.scope).map_err(|source| {
if source.kind() == std::io::ErrorKind::InvalidData {
self.corrupt(&source.to_string())
} else {
SecretStoreError::Load {
scope: self.scope,
location: self.location(),
source,
}
}
})?;
match bytes {
Some(bytes) => self.decode(bytes).map(Some),
None => Ok(None),
}
}
fn delete(&self) -> Result<Removal, SecretStoreError> {
let removed = sys::delete(&self.site).map_err(|source| SecretStoreError::Delete {
scope: self.scope,
location: self.location(),
source,
})?;
let removal = if removed {
Removal::Removed
} else {
Removal::AlreadyAbsent
};
tracing::info!(
event = "secret_store_purged",
scope = self.scope.as_str(),
outcome = if removal.removed_something() {
"removed"
} else {
"already_absent"
},
"the user access token was purged from the secret store"
);
Ok(removal)
}
fn protection(&self) -> Result<Protection, SecretStoreError> {
let guard = sys::guard(&self.site);
let summary = crate::process::permissions_summary(&guard).map_err(|source| {
SecretStoreError::Inspect {
scope: self.scope,
guard: guard.clone(),
source,
}
})?;
Ok(Protection {
guard,
description: summary.description,
readable_by_other_local_users: summary.readable_by_other_local_users,
})
}
}
pub const ROOTED_KEYCHAIN_PASSWORD: &str = "runner-manager-rooted-keychain";
pub const SYSTEMD_CREDENTIAL: &str = "runner-manager.user-access-token";
pub const CREDENTIALS_DIRECTORY: &str = "CREDENTIALS_DIRECTORY";
#[cfg(not(target_os = "macos"))]
const TEMP_PREFIX: &str = "user-access-token.";
#[cfg(not(target_os = "macos"))]
fn sweep_temporaries(directory: &Path) {
let Ok(entries) = std::fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if name.starts_with(TEMP_PREFIX) && name.ends_with(".tmp") {
let _ = std::fs::remove_file(entry.path());
}
}
}
#[cfg(not(target_os = "macos"))]
fn overwrite(path: &Path) -> std::io::Result<bool> {
use std::io::Write as _;
let mut file = match std::fs::OpenOptions::new().write(true).open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error),
};
let length = usize::try_from(file.metadata()?.len()).unwrap_or(0);
file.write_all(&vec![0u8; length])?;
file.flush()?;
file.sync_all()?;
Ok(true)
}
#[cfg(not(target_os = "macos"))]
#[cfg_attr(
windows,
allow(
dead_code,
reason = "the systemd credential path is Linux-only; this is compiled on Windows so \
that its unit test runs on every leg of the matrix rather than on the one \
platform a developer usually cannot execute"
)
)]
fn trim_trailing_ascii_whitespace(mut bytes: Vec<u8>) -> Vec<u8> {
while bytes.last().is_some_and(u8::is_ascii_whitespace) {
bytes.pop();
}
bytes
}
#[cfg(windows)]
mod sys {
use std::fs::File;
use std::io::{self, Write as _};
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::FromRawHandle;
use std::path::{Path, PathBuf};
use windows::Win32::Foundation::{ERROR_SUCCESS, HLOCAL, LocalFree};
use windows::Win32::Security::Authorization::{
ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
GetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows::Win32::Security::Cryptography::{
CRYPT_INTEGER_BLOB, CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN,
CryptProtectData, CryptUnprotectData,
};
use windows::Win32::Security::{
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES,
};
use windows::Win32::Storage::FileSystem::{
CREATE_NEW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
FILE_SHARE_NONE,
};
use windows::core::{PCWSTR, PWSTR};
use super::{
APPLICATION, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER, SecretScope, TEMP_PREFIX, overwrite,
sweep_temporaries,
};
const ENTROPY: &[u8] = b"io.github.IvanMurzak.runner-manager/user-access-token/v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Site {
file: PathBuf,
}
pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
let root = match scope {
SecretScope::Machine => std::env::var_os("ProgramData")
.map(PathBuf::from)
.ok_or_else(|| {
"this Windows reports no %ProgramData%, so the machine-wide \
application-data directory cannot be resolved. Set ProgramData, or \
install the service with --start-at login to use the user-scoped store."
.to_string()
})?
.join(ORGANIZATION)
.join(APPLICATION),
SecretScope::User => {
directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.ok_or_else(|| {
"the operating system reports no home directory for this account, so \
the user-scoped store cannot be resolved. A service account \
configured with no profile normally hits this; give the account a \
home directory, or use the machine-scoped store."
.to_string()
})?
.data_local_dir()
.to_path_buf()
}
};
Ok(Site {
file: root.join(DIRECTORY).join(format!("{ITEM}.dpapi")),
})
}
pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
Ok(Site {
file: root
.join(DIRECTORY)
.join(scope.as_str())
.join(format!("{ITEM}.dpapi")),
})
}
pub(super) fn describe(site: &Site) -> String {
format!("DPAPI blob at {}", site.file.display())
}
pub(super) fn guard(site: &Site) -> PathBuf {
site.file.clone()
}
pub(super) const fn sddl(scope: SecretScope) -> &'static str {
match scope {
SecretScope::Machine => "D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)",
SecretScope::User => "D:P(A;;FA;;;BA)(A;;FA;;;OW)",
}
}
pub(super) fn replacement_sddl(scope: SecretScope, carried: &[String]) -> String {
let mut text = sddl(scope).to_owned();
for sid in carried {
text.push_str("(A;;FA;;;");
text.push_str(sid);
text.push(')');
}
text
}
const ALREADY_GRANTED: [&str; 5] = ["SY", "BA", "OW", "S-1-5-18", "S-1-5-32-544"];
fn carried_grants(path: &Path) -> Vec<String> {
let dacl = crate::process::permissions_summary(path)
.map(|summary| summary.description)
.unwrap_or_default();
let mut grants = merge_grants(previous_owner(path).as_deref(), &dacl);
if let Ok(writer) = crate::process::current_user_sid()
&& !ALREADY_GRANTED.contains(&&*writer)
&& !grants.contains(&writer)
{
grants.push(writer);
}
grants
}
pub(super) fn merge_grants(previous_owner: Option<&str>, previous_dacl: &str) -> Vec<String> {
let mut carried: Vec<String> = Vec::new();
let mut add = |sid: &str| {
if !ALREADY_GRANTED.contains(&sid) && !carried.iter().any(|seen| seen == sid) {
carried.push(sid.to_owned());
}
};
if let Some(owner) = previous_owner {
add(owner);
}
for trustee in trustees(previous_dacl) {
add(&trustee);
}
carried
}
pub(super) fn trustees(sddl: &str) -> Vec<String> {
let mut found = Vec::new();
for ace in sddl.split('(').skip(1) {
let Some(body) = ace.split(')').next() else {
continue;
};
let Some(trustee) = body.rsplit(';').next() else {
continue;
};
if !trustee.is_empty() && !found.iter().any(|seen| seen == trustee) {
found.push(trustee.to_owned());
}
}
found
}
fn previous_owner(path: &Path) -> Option<String> {
let wide = to_wide(path);
let mut owner = PSID::default();
let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
let status = unsafe {
GetNamedSecurityInfoW(
PCWSTR(wide.as_ptr()),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
&mut descriptor,
)
};
if status != ERROR_SUCCESS {
return None;
}
let mut sid_string = PWSTR::null();
let converted = unsafe { ConvertSidToStringSidW(owner, &mut sid_string) };
let text = match converted {
Ok(()) => {
let text = unsafe { sid_string.to_string() }.ok();
unsafe {
let _ = LocalFree(Some(HLOCAL(sid_string.0.cast())));
}
text
}
Err(_) => None,
};
unsafe {
let _ = LocalFree(Some(HLOCAL(descriptor.0)));
}
text
}
const DELETE: u32 = 0x0001_0000;
fn cannot_replace(site: &Site) -> Option<io::Error> {
use std::os::windows::fs::OpenOptionsExt as _;
match std::fs::OpenOptions::new()
.access_mode(DELETE)
.open(&site.file)
{
Ok(_) => None,
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => Some(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"the machine-scoped store at {} already holds a token that belongs to \
another account on this host, and this account may not replace it. This \
product keeps one machine-scoped credential per host -- it is the host's \
credential, not an operator's -- so a second operator does not get a \
second store. Either run `auth logout` as the account that stored it, or \
run `auth login` from an elevated prompt, since the local Administrators \
group is granted access, or install the service with `--start-at login`, \
which uses the per-user store instead. Nothing was written.",
site.file.display()
),
)),
Err(_) => None,
}
}
pub(super) fn store(site: &Site, scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
let directory = site
.file
.parent()
.ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
std::fs::create_dir_all(directory)?;
if let Some(refusal) = cannot_replace(site) {
return Err(refusal);
}
let blob = protect(plaintext, scope)?;
let descriptor = replacement_sddl(scope, &carried_grants(&site.file));
let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
let written = (|| -> io::Result<()> {
let mut file = create_protected_file(&temporary, &descriptor)?;
file.write_all(&blob)?;
file.flush()?;
file.sync_all()
})();
if let Err(error) = written {
return Err(discard(&temporary, error));
}
if let Err(error) = std::fs::rename(&temporary, &site.file) {
let error = if error.kind() == io::ErrorKind::PermissionDenied {
cannot_replace(site).unwrap_or(error)
} else {
error
};
return Err(discard(&temporary, error));
}
Ok(())
}
fn discard(temporary: &Path, error: io::Error) -> io::Error {
let _ = overwrite(temporary);
match std::fs::remove_file(temporary) {
Ok(()) => error,
Err(removal) if removal.kind() == io::ErrorKind::NotFound => error,
Err(removal) => io::Error::new(
error.kind(),
format!(
"{error}. A temporary file holding the encrypted token was also left at \
{} and could not be removed ({removal}); delete it by hand.",
temporary.display()
),
),
}
}
fn locked_out(site: &Site, source: &io::Error) -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"{source}. The file exists but this account may not read it, which on this \
store means its owner changed: the service renews the token under its own \
account. Grant this account access explicitly from an elevated prompt -- it \
returns immediately, and stays, because every later renewal carries the \
grant forward:\n icacls \"{}\" /grant \"%USERNAME%:(F)\"\nDo NOT use \
`takeown`: changing the owner makes Windows delete the OWNER RIGHTS ACE, \
which leaves this account owning a file it still cannot read. An `auth \
logout` followed by `auth login`, also elevated, is the heavier alternative \
and costs a fresh sign-in.",
site.file.display()
),
)
}
pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
let blob = match std::fs::read(&site.file) {
Ok(blob) => blob,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {
return Err(locked_out(site, &error));
}
Err(error) => return Err(error),
};
if blob.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"the stored file is empty",
));
}
unprotect(&blob).map(Some)
}
pub(super) fn delete(site: &Site) -> io::Result<bool> {
let _ = overwrite(&site.file);
let removed = match std::fs::remove_file(&site.file) {
Ok(()) => true,
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(error),
};
if let Some(directory) = site.file.parent() {
sweep_temporaries(directory);
}
Ok(removed)
}
pub(super) fn io_error(error: &windows::core::Error) -> io::Error {
let code = error.code().0;
if (code as u32) & 0xffff_0000 == 0x8007_0000 {
io::Error::from_raw_os_error(code & 0xffff)
} else {
io::Error::from_raw_os_error(code)
}
}
fn blob_of(bytes: &mut [u8]) -> CRYPT_INTEGER_BLOB {
CRYPT_INTEGER_BLOB {
cbData: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
pbData: bytes.as_mut_ptr(),
}
}
pub(super) unsafe fn copy_and_scrub(ptr: *mut u8, len: usize) -> Vec<u8> {
use secrecy::zeroize::Zeroize as _;
let source = unsafe { std::slice::from_raw_parts_mut(ptr, len) };
let copy = source.to_vec();
source.zeroize();
copy
}
unsafe fn take_blob(out: &mut CRYPT_INTEGER_BLOB) -> Vec<u8> {
if out.pbData.is_null() {
return Vec::new();
}
let bytes = unsafe { copy_and_scrub(out.pbData, out.cbData as usize) };
unsafe {
let _ = LocalFree(Some(HLOCAL(out.pbData.cast())));
}
out.pbData = std::ptr::null_mut();
out.cbData = 0;
bytes
}
fn protect(plaintext: &[u8], scope: SecretScope) -> io::Result<Vec<u8>> {
use secrecy::zeroize::Zeroize as _;
let mut input = plaintext.to_vec();
let mut entropy = ENTROPY.to_vec();
let input_blob = blob_of(&mut input);
let entropy_blob = blob_of(&mut entropy);
let mut out = CRYPT_INTEGER_BLOB::default();
let flags = CRYPTPROTECT_UI_FORBIDDEN
| match scope {
SecretScope::Machine => CRYPTPROTECT_LOCAL_MACHINE,
SecretScope::User => 0,
};
let result = unsafe {
CryptProtectData(
&raw const input_blob,
PCWSTR::null(),
Some(&raw const entropy_blob),
None,
None,
flags,
&raw mut out,
)
};
input.zeroize();
result.map_err(|error| io_error(&error))?;
Ok(unsafe { take_blob(&mut out) })
}
fn unprotect(blob: &[u8]) -> io::Result<Vec<u8>> {
let mut input = blob.to_vec();
let mut entropy = ENTROPY.to_vec();
let input_blob = blob_of(&mut input);
let entropy_blob = blob_of(&mut entropy);
let mut out = CRYPT_INTEGER_BLOB::default();
let result = unsafe {
CryptUnprotectData(
&raw const input_blob,
None,
Some(&raw const entropy_blob),
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&raw mut out,
)
};
match result {
Ok(()) => Ok(unsafe { take_blob(&mut out) }),
Err(error) => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"the stored bytes could not be unprotected with this machine's DPAPI key \
({error})"
),
)),
}
}
fn to_wide(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn create_protected_file(path: &Path, descriptor: &str) -> io::Result<File> {
let sddl_wide: Vec<u16> = descriptor
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut descriptor = PSECURITY_DESCRIPTOR(std::ptr::null_mut());
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
PCWSTR(sddl_wide.as_ptr()),
SDDL_REVISION_1,
&mut descriptor,
None,
)
}
.map_err(|error| io_error(&error))?;
let attributes = SECURITY_ATTRIBUTES {
nLength: u32::try_from(size_of::<SECURITY_ATTRIBUTES>()).unwrap_or(u32::MAX),
lpSecurityDescriptor: descriptor.0,
bInheritHandle: windows::core::BOOL(0),
};
let wide = to_wide(path);
let handle = unsafe {
CreateFileW(
PCWSTR(wide.as_ptr()),
FILE_GENERIC_READ.0 | FILE_GENERIC_WRITE.0,
FILE_SHARE_NONE,
Some(&raw const attributes),
CREATE_NEW,
FILE_ATTRIBUTE_NORMAL,
None,
)
};
unsafe {
let _ = LocalFree(Some(HLOCAL(descriptor.0)));
}
let handle = handle.map_err(|error| io_error(&error))?;
Ok(unsafe { File::from_raw_handle(handle.0) })
}
}
#[cfg(target_os = "macos")]
mod sys {
use std::io;
use std::os::unix::fs::{DirBuilderExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use security_framework::os::macos::keychain::{CreateOptions, KeychainSettings, SecKeychain};
use super::{DIRECTORY, ITEM, KEYCHAIN_SERVICE, ROOTED_KEYCHAIN_PASSWORD, SecretScope};
pub(super) fn service() -> &'static str {
&KEYCHAIN_SERVICE
}
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
const ERR_SEC_NO_SUCH_KEYCHAIN: i32 = -25294;
const SYSTEM_KEYCHAIN_MASTER_KEY: &str = "/var/db/SystemKey";
const SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Site {
path: PathBuf,
kind: Kind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
System,
Login,
Rooted,
}
pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
match scope {
SecretScope::Machine => Ok(Site {
path: PathBuf::from(SYSTEM_KEYCHAIN),
kind: Kind::System,
}),
SecretScope::User => {
let home = directories::BaseDirs::new()
.ok_or_else(|| {
"the operating system reports no home directory for this account, so \
the login keychain cannot be resolved. A service account configured \
with no profile normally hits this; use the machine-scoped store, \
which is what --start-at boot installs."
.to_string()
})?
.home_dir()
.join("Library")
.join("Keychains");
let modern = home.join("login.keychain-db");
let legacy = home.join("login.keychain");
let path = if modern.exists() || !legacy.exists() {
modern
} else {
legacy
};
Ok(Site {
path,
kind: Kind::Login,
})
}
}
}
pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
Ok(Site {
path: root
.join(DIRECTORY)
.join(scope.as_str())
.join("runner-manager.keychain-db"),
kind: Kind::Rooted,
})
}
pub(super) fn describe(site: &Site) -> String {
let kind = match site.kind {
Kind::System => "System",
Kind::Login => "login",
Kind::Rooted => "rooted",
};
format!(
"{kind} keychain {}, item {}/{ITEM}",
site.path.display(),
service()
)
}
pub(super) fn guard(site: &Site) -> PathBuf {
match site.kind {
Kind::System => PathBuf::from(SYSTEM_KEYCHAIN_MASTER_KEY),
Kind::Login | Kind::Rooted => site.path.clone(),
}
}
fn sec_error(error: &security_framework::base::Error) -> io::Error {
io::Error::other(format!(
"Security.framework returned {} ({error})",
error.code()
))
}
fn is_absence(error: &security_framework::base::Error) -> bool {
matches!(
error.code(),
ERR_SEC_ITEM_NOT_FOUND | ERR_SEC_NO_SUCH_KEYCHAIN
)
}
const ERR_SEC_DUPLICATE_ITEM: i32 = -25299;
const ERR_SEC_AUTH_FAILED: i32 = -25293;
fn is_duplicate(error: &security_framework::base::Error) -> bool {
error.code() == ERR_SEC_DUPLICATE_ITEM
}
fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
fn replace_unreadable(keychain: &SecKeychain) -> io::Result<()> {
delete_by_query(keychain).map_err(|error| sec_error(&error))
}
fn delete_by_query(keychain: &SecKeychain) -> Result<(), security_framework::base::Error> {
use security_framework::item::{ItemClass, ItemSearchOptions};
ItemSearchOptions::new()
.class(ItemClass::generic_password())
.keychains(std::slice::from_ref(keychain))
.service(service())
.account(ITEM)
.delete()
}
fn remove_any_existing(keychain: &SecKeychain) -> io::Result<()> {
match delete_by_query(keychain) {
Ok(()) => Ok(()),
Err(error) if is_absence(&error) => Ok(()),
Err(error) => Err(sec_error(&error)),
}
}
fn without_user_interaction()
-> Option<security_framework::os::macos::keychain::KeychainUserInteractionLock> {
SecKeychain::disable_user_interaction().ok()
}
fn open(site: &Site, create_if_missing: bool) -> io::Result<Option<SecKeychain>> {
match site.kind {
Kind::System | Kind::Login => {
if !site.path.exists() {
return Ok(None);
}
SecKeychain::open(&site.path)
.map(Some)
.map_err(|error| sec_error(&error))
}
Kind::Rooted if site.path.exists() => {
let mut keychain =
SecKeychain::open(&site.path).map_err(|error| sec_error(&error))?;
keychain
.unlock(Some(ROOTED_KEYCHAIN_PASSWORD))
.map_err(|error| sec_error(&error))?;
Ok(Some(keychain))
}
Kind::Rooted if create_if_missing => Ok(Some(create_rooted(site)?)),
Kind::Rooted => Ok(None),
}
}
fn create_rooted(site: &Site) -> io::Result<SecKeychain> {
let directory = site
.path
.parent()
.ok_or_else(|| io::Error::other("the rooted keychain path has no parent directory"))?;
std::fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(directory)?;
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
let mut keychain = CreateOptions::new()
.password(ROOTED_KEYCHAIN_PASSWORD)
.prompt_user(false)
.create(&site.path)
.map_err(|error| sec_error(&error))?;
let mut settings = KeychainSettings::new();
settings.set_lock_on_sleep(false);
settings.set_lock_interval(None);
keychain
.set_settings(&settings)
.map_err(|error| sec_error(&error))?;
if !site.path.exists() {
return Err(io::Error::other(format!(
"SecKeychainCreate reported success but there is no keychain at {}. {} holds {:?}",
site.path.display(),
directory.display(),
std::fs::read_dir(directory)
.map(|entries| entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>())
.unwrap_or_default()
)));
}
restrict(site)?;
Ok(keychain)
}
fn restrict(site: &Site) -> io::Result<()> {
if site.kind != Kind::Rooted {
return Ok(());
}
std::fs::set_permissions(&site.path, std::fs::Permissions::from_mode(0o600))
}
pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
let _no_ui = without_user_interaction();
let keychain = open(site, true)?.ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
format!(
"there is no keychain at {}. The machine-scoped store needs the System \
Keychain, which only root may write; the user-scoped store needs a login \
keychain, which exists once the account has logged in.",
site.path.display()
),
)
})?;
if grants_every_application(site) {
remove_any_existing(&keychain)?;
add_granted_to_every_application(&keychain, plaintext)?;
} else {
match keychain.set_generic_password(service(), ITEM, plaintext) {
Ok(()) => {}
Err(error) if is_duplicate(&error) => {
replace_unreadable(&keychain)?;
keychain
.set_generic_password(service(), ITEM, plaintext)
.map_err(|error| sec_error(&error))?;
}
Err(error) => return Err(sec_error(&error)),
}
}
restrict(site)
}
const ACCESS_DESCRIPTOR: &str = "runner-manager user access token";
fn grants_every_application(site: &Site) -> bool {
match site.kind {
Kind::System | Kind::Rooted => true,
Kind::Login => false,
}
}
fn add_granted_to_every_application(
keychain: &SecKeychain,
plaintext: &[u8],
) -> io::Result<()> {
use core_foundation::base::TCFType as _;
let access = AnyApplicationAccess::create()?;
let service_name = service();
let account = ITEM;
let mut attributes = [
ffi::SecKeychainAttribute {
tag: ffi::SEC_SERVICE_ITEM_ATTR,
length: u32::try_from(service_name.len()).map_err(|_| {
io::Error::other(
"the keychain service name is longer than an attribute can carry",
)
})?,
data: service_name.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
},
ffi::SecKeychainAttribute {
tag: ffi::SEC_ACCOUNT_ITEM_ATTR,
length: u32::try_from(account.len()).map_err(|_| {
io::Error::other(
"the keychain account name is longer than an attribute can carry",
)
})?,
data: account.as_ptr().cast::<std::ffi::c_void>().cast_mut(),
},
];
let mut list = ffi::SecKeychainAttributeList {
count: 2,
attr: attributes.as_mut_ptr(),
};
let length = u32::try_from(plaintext.len())
.map_err(|_| io::Error::other("the value is longer than a keychain item can hold"))?;
let status = unsafe {
ffi::SecKeychainItemCreateFromContent(
ffi::SEC_GENERIC_PASSWORD_ITEM_CLASS,
&raw mut list,
length,
plaintext.as_ptr().cast(),
keychain.as_concrete_TypeRef().cast(),
access.raw(),
std::ptr::null_mut(),
)
};
os_status("SecKeychainItemCreateFromContent", status)
}
struct AnyApplicationAccess(ffi::SecAccessRef);
impl AnyApplicationAccess {
fn create() -> io::Result<Self> {
use core_foundation::base::TCFType as _;
use core_foundation::string::CFString;
let descriptor = CFString::new(ACCESS_DESCRIPTOR);
let mut access: ffi::SecAccessRef = std::ptr::null_mut();
let status = unsafe {
ffi::SecAccessCreate(
descriptor.as_concrete_TypeRef().cast(),
std::ptr::null(),
&raw mut access,
)
};
os_status("SecAccessCreate", status)?;
if access.is_null() {
return Err(io::Error::other(
"SecAccessCreate reported success and produced no access",
));
}
let owned = Self(access);
owned.widen()?;
Ok(owned)
}
fn raw(&self) -> ffi::SecAccessRef {
self.0
}
fn widen(&self) -> io::Result<()> {
let mut list: ffi::CFArrayRef = std::ptr::null();
let status = unsafe { ffi::SecAccessCopyACLList(self.0, &raw mut list) };
os_status("SecAccessCopyACLList", status)?;
let entries = CfOwned(list.cast());
let count = unsafe { ffi::CFArrayGetCount(list) };
for index in 0..count {
let entry = unsafe { ffi::CFArrayGetValueAtIndex(list, index) };
if entry.is_null() {
continue;
}
widen_one(entry.cast_mut().cast())?;
}
drop(entries);
Ok(())
}
}
impl Drop for AnyApplicationAccess {
fn drop(&mut self) {
unsafe { ffi::CFRelease(self.0.cast_const().cast()) };
}
}
fn widen_one(entry: ffi::SecACLRef) -> io::Result<()> {
let mut applications: ffi::CFArrayRef = std::ptr::null();
let mut description: ffi::CFStringRef = std::ptr::null();
let mut prompt: u16 = 0;
let copied = unsafe {
ffi::SecACLCopyContents(
entry,
&raw mut applications,
&raw mut description,
&raw mut prompt,
)
};
if copied != 0 {
return Ok(());
}
let previous = CfOwned(applications.cast());
let text = CfOwned(description.cast());
let status =
unsafe { ffi::SecACLSetContents(entry, std::ptr::null(), description, prompt) };
drop(previous);
drop(text);
os_status("SecACLSetContents", status)
}
struct CfOwned(*const std::ffi::c_void);
impl Drop for CfOwned {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { ffi::CFRelease(self.0) };
}
}
}
fn os_status(call: &'static str, status: i32) -> io::Result<()> {
if status == 0 {
Ok(())
} else {
Err(io::Error::other(format!("{call} returned {status}")))
}
}
mod ffi {
use std::ffi::c_void;
pub type CFArrayRef = *const c_void;
pub type CFStringRef = *const c_void;
pub type CFIndex = isize;
pub type SecAccessRef = *mut c_void;
pub type SecACLRef = *mut c_void;
pub type SecKeychainRef = *mut c_void;
pub type SecKeychainItemRef = *mut c_void;
pub const SEC_GENERIC_PASSWORD_ITEM_CLASS: u32 = u32::from_be_bytes(*b"genp");
pub const SEC_SERVICE_ITEM_ATTR: u32 = u32::from_be_bytes(*b"svce");
pub const SEC_ACCOUNT_ITEM_ATTR: u32 = u32::from_be_bytes(*b"acct");
#[repr(C)]
pub struct SecKeychainAttribute {
pub tag: u32,
pub length: u32,
pub data: *mut c_void,
}
#[repr(C)]
pub struct SecKeychainAttributeList {
pub count: u32,
pub attr: *mut SecKeychainAttribute,
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
pub fn CFRelease(value: *const c_void);
pub fn CFArrayGetCount(array: CFArrayRef) -> CFIndex;
pub fn CFArrayGetValueAtIndex(array: CFArrayRef, index: CFIndex) -> *const c_void;
}
#[link(name = "Security", kind = "framework")]
unsafe extern "C" {
pub fn SecAccessCreate(
descriptor: CFStringRef,
trusted_list: CFArrayRef,
access: *mut SecAccessRef,
) -> i32;
pub fn SecAccessCopyACLList(access: SecAccessRef, list: *mut CFArrayRef) -> i32;
pub fn SecACLCopyContents(
entry: SecACLRef,
applications: *mut CFArrayRef,
description: *mut CFStringRef,
prompt_selector: *mut u16,
) -> i32;
pub fn SecACLSetContents(
entry: SecACLRef,
applications: CFArrayRef,
description: CFStringRef,
prompt_selector: u16,
) -> i32;
pub fn SecKeychainItemCreateFromContent(
item_class: u32,
attributes: *mut SecKeychainAttributeList,
length: u32,
data: *const c_void,
keychain: SecKeychainRef,
initial_access: SecAccessRef,
item: *mut SecKeychainItemRef,
) -> i32;
}
}
pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
let _no_ui = without_user_interaction();
let Some(keychain) = open(site, false)? else {
return Ok(None);
};
match keychain.find_generic_password(service(), ITEM) {
Ok((password, _item)) => Ok(Some(password.as_ref().to_vec())),
Err(error) if is_absence(&error) => Ok(None),
Err(error) if error.code() == ERR_SEC_AUTH_FAILED => Err(locked_out(site, &error)),
Err(error) => Err(sec_error(&error)),
}
}
fn locked_out(site: &Site, error: &security_framework::base::Error) -> io::Error {
if site.kind == Kind::System && !is_root() {
return io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"Security.framework returned {} ({error}). The machine-scoped store is the \
System Keychain, and what decrypts it is /var/db/SystemKey, which only \
root may read -- so this is what a healthy credential looks like to an \
account that is not the one holding it. The boot-mode daemon runs as root \
and reads it. Nothing here needs repairing: run this command with sudo if \
you need the value itself, or install with `--start-at login` to keep the \
token in your own login keychain instead.",
error.code()
),
);
}
io::Error::other(format!(
"Security.framework returned {} ({error}). The item is there and this keychain does \
not grant it to the program asking. An earlier version granted the stored token to \
the single binary that wrote it, and an upgrade replaces that binary -- so an item \
written by one of those versions locks out every later copy, the daemon's included. \
Signing in once more rewrites it with a grant that survives upgrades: run \
`runner-manager auth login`, with sudo if this is the machine-scoped store.",
error.code()
))
}
pub(super) fn delete(site: &Site) -> io::Result<bool> {
let _no_ui = without_user_interaction();
let Some(keychain) = open(site, false)? else {
return Ok(false);
};
match keychain.find_generic_password(service(), ITEM) {
Ok((password, item)) => {
drop(password);
item.delete();
match keychain.find_generic_password(service(), ITEM) {
Err(error) if is_absence(&error) => Ok(true),
Ok(_) => Err(io::Error::other(
"the keychain item is still present after being deleted",
)),
Err(error) => Err(sec_error(&error)),
}
}
Err(error) if error.code() == ERR_SEC_AUTH_FAILED => {
replace_unreadable(&keychain)?;
Ok(true)
}
Err(error) if is_absence(&error) => Ok(false),
Err(error) => Err(sec_error(&error)),
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod sys {
use std::io::{self, Write as _};
use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _};
use std::path::{Path, PathBuf};
use super::{
APPLICATION, CREDENTIALS_DIRECTORY, DIRECTORY, ITEM, ORGANIZATION, QUALIFIER,
SYSTEMD_CREDENTIAL, SecretScope, TEMP_PREFIX, overwrite, sweep_temporaries,
trim_trailing_ascii_whitespace,
};
const MACHINE_PREFIX: &str = "/var/lib";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Site {
file: PathBuf,
credential: Option<PathBuf>,
}
impl Site {
pub(super) fn with_credentials_directory(mut self, directory: &Path) -> Self {
self.credential = Some(directory.join(SYSTEMD_CREDENTIAL));
self
}
pub(super) fn credential(&self) -> Option<&Path> {
self.credential.as_deref()
}
}
fn credential_from_environment() -> Option<PathBuf> {
std::env::var_os(CREDENTIALS_DIRECTORY)
.filter(|value| !value.is_empty())
.map(|value| PathBuf::from(value).join(SYSTEMD_CREDENTIAL))
}
pub(super) fn standard_site(scope: SecretScope) -> Result<Site, String> {
match scope {
SecretScope::Machine => Ok(Site {
file: Path::new(MACHINE_PREFIX)
.join(APPLICATION)
.join(DIRECTORY)
.join(ITEM),
credential: credential_from_environment(),
}),
SecretScope::User => {
let root = directories::ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION)
.ok_or_else(|| {
"the operating system reports no home directory for this account, \
so the user-scoped store cannot be resolved. A service account \
configured with no profile normally hits this; use the \
machine-scoped store, which is what --start-at boot installs."
.to_string()
})?
.data_local_dir()
.to_path_buf();
Ok(Site {
file: root.join(DIRECTORY).join(ITEM),
credential: None,
})
}
}
}
pub(super) fn rooted_site(scope: SecretScope, root: &Path) -> Result<Site, String> {
Ok(Site {
file: root.join(DIRECTORY).join(scope.as_str()).join(ITEM),
credential: None,
})
}
pub(super) fn describe(site: &Site) -> String {
match &site.credential {
Some(credential) => format!(
"0600 file at {} (superseded by the systemd credential at {})",
site.file.display(),
credential.display()
),
None => format!("0600 file at {}", site.file.display()),
}
}
pub(super) fn guard(site: &Site) -> PathBuf {
match &site.credential {
Some(credential) if credential.exists() => credential.clone(),
_ => site.file.clone(),
}
}
fn shadowed_by_credential(site: &Site, verb: &str) -> Option<io::Error> {
let credential = site.credential.as_ref()?;
if !credential.exists() {
return None;
}
Some(io::Error::other(format!(
"this process was started with the systemd credential `{SYSTEMD_CREDENTIAL}`, which \
takes precedence over {}. {verb} Change the credential in the unit that supplies \
it -- `systemd-creds` and `LoadCredentialEncrypted=` -- and restart the service.",
site.file.display()
)))
}
pub(super) fn store(site: &Site, _scope: SecretScope, plaintext: &[u8]) -> io::Result<()> {
if let Some(error) = shadowed_by_credential(
site,
"A token written here would be shadowed by it on the very next load, so nothing \
was written.",
) {
return Err(error);
}
let directory = site
.file
.parent()
.ok_or_else(|| io::Error::other("the store path has no parent directory"))?;
std::fs::DirBuilder::new()
.mode(0o700)
.recursive(true)
.create(directory)?;
std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700))?;
let temporary = directory.join(format!("{TEMP_PREFIX}{}.tmp", uuid::Uuid::new_v4()));
let written = (|| -> io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)?;
file.write_all(plaintext)?;
file.flush()?;
file.sync_all()
})();
if let Err(error) = written {
let _ = std::fs::remove_file(&temporary);
return Err(error);
}
if let Err(error) = std::fs::rename(&temporary, &site.file) {
let _ = std::fs::remove_file(&temporary);
return Err(error);
}
if let Ok(handle) = std::fs::File::open(directory) {
let _ = handle.sync_all();
}
Ok(())
}
pub(super) fn load(site: &Site, _scope: SecretScope) -> io::Result<Option<Vec<u8>>> {
if let Some(credential) = &site.credential {
match std::fs::read(credential) {
Ok(bytes) => return Ok(Some(trim_trailing_ascii_whitespace(bytes))),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
match std::fs::read(&site.file) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub(super) fn delete(site: &Site) -> io::Result<bool> {
let shadowed = shadowed_by_credential(
site,
"The file below was removed, but the credential is still supplying a token and \
this host is not purged.",
);
let removed = overwrite_then_remove(&site.file)?;
if let Some(directory) = site.file.parent() {
sweep_temporaries(directory);
}
match shadowed {
Some(error) => Err(error),
None => Ok(removed),
}
}
fn overwrite_then_remove(path: &Path) -> io::Result<bool> {
let _ = overwrite(path);
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error),
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
impl PlatformSecretStore {
#[must_use]
pub fn with_credentials_directory(mut self, directory: impl AsRef<Path>) -> Self {
self.site = self.site.with_credentials_directory(directory.as_ref());
self
}
#[must_use]
pub fn credential_path(&self) -> Option<&Path> {
self.site.credential()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn fixture_token() -> SecretString {
SecretString::from(format!("{}{}", "ghu_", "d2FixtureNotARealCredential000000"))
}
fn other_token() -> SecretString {
SecretString::from(format!("{}{}", "ghu_", "d2SecondFixtureNotARealOne000000"))
}
fn exposed(secret: &SecretString) -> String {
secret.expose_secret().to_string()
}
fn rooted(scope: SecretScope, root: &TempDir) -> PlatformSecretStore {
PlatformSecretStore::rooted_at(scope, root.path()).expect("a rooted store resolves")
}
fn stored(store: &PlatformSecretStore) -> String {
exposed(
&store
.load()
.expect("the store is readable")
.expect("a value was stored"),
)
}
#[test]
fn the_scope_is_decided_by_the_start_mode() {
assert_eq!(
SecretScope::for_start_mode(StartMode::Boot),
SecretScope::Machine,
"a service that starts at boot has no login session to read a user-scoped store from"
);
assert_eq!(
SecretScope::for_start_mode(StartMode::Login),
SecretScope::User
);
}
#[test]
fn every_scope_names_the_start_mode_it_is_the_answer_to() {
for scope in [SecretScope::Machine, SecretScope::User] {
assert_eq!(SecretScope::for_start_mode(scope.start_mode()), scope);
}
for mode in [StartMode::Boot, StartMode::Login] {
assert_eq!(SecretScope::for_start_mode(mode).start_mode(), mode);
}
}
#[test]
fn for_start_mode_opens_the_store_that_start_mode_obliges() {
for mode in [StartMode::Boot, StartMode::Login] {
let Ok(store) = PlatformSecretStore::for_start_mode(mode) else {
panic!("the standard store for --start-at {mode} could not be resolved");
};
assert_eq!(store.scope(), SecretScope::for_start_mode(mode));
}
}
#[test]
fn a_machine_scoped_store_round_trips() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
let token = fixture_token();
assert!(
store
.load()
.expect("an empty store reads cleanly")
.is_none(),
"nothing is stored before the first `auth login`"
);
store.store(&token).expect("the token is stored");
assert_eq!(stored(&store), exposed(&token));
assert_eq!(
store.delete().expect("the token is purged"),
Removal::Removed
);
assert!(
store
.load()
.expect("a purged store reads cleanly")
.is_none()
);
}
#[test]
fn a_user_scoped_store_round_trips() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::User, &root);
let token = fixture_token();
store.store(&token).expect("the token is stored");
assert_eq!(stored(&store), exposed(&token));
assert_eq!(
store.delete().expect("the token is purged"),
Removal::Removed
);
assert!(
store
.load()
.expect("a purged store reads cleanly")
.is_none()
);
}
#[test]
fn the_two_variants_do_not_share_a_value() {
let root = TempDir::new().expect("a temporary directory");
let machine = rooted(SecretScope::Machine, &root);
let user = rooted(SecretScope::User, &root);
machine.store(&fixture_token()).expect("stored");
user.store(&other_token()).expect("stored");
assert_eq!(stored(&machine), exposed(&fixture_token()));
assert_eq!(stored(&user), exposed(&other_token()));
machine.delete().expect("purged");
assert!(
machine.load().expect("readable").is_none(),
"the machine store is empty"
);
assert_eq!(
stored(&user),
exposed(&other_token()),
"purging one variant must not purge the other; `auth logout` under \
--start-at boot has no business touching a user-scoped store"
);
}
#[test]
fn storing_again_replaces_the_value() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
store.store(&other_token()).expect("stored again");
assert_eq!(
stored(&store),
exposed(&other_token()),
"a re-issued token replaces the old one rather than being refused"
);
}
#[test]
fn a_load_after_delete_reports_absence_rather_than_a_failure() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
store.delete().expect("purged");
match store.load() {
Ok(None) => {}
Ok(Some(_)) => panic!("the value survived a delete"),
Err(error) => panic!("absence was reported as a failure a caller may retry: {error}"),
}
}
#[test]
fn deleting_what_is_not_there_is_success_and_says_so() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
assert_eq!(
store
.delete()
.expect("purging an empty store is not a failure"),
Removal::AlreadyAbsent,
"`auth logout` is run on every host during a credential-disclosure \
response, including the ones that were never logged in"
);
store.store(&fixture_token()).expect("stored");
assert_eq!(store.delete().expect("purged"), Removal::Removed);
assert_eq!(
store.delete().expect("purged again"),
Removal::AlreadyAbsent
);
}
#[test]
fn deleting_leaves_no_recoverable_remnant() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let guard = store.guard();
assert!(guard.exists(), "something was written");
store.delete().expect("purged");
assert!(
store
.load()
.expect("a purged store reads cleanly")
.is_none(),
"the value is still readable through the store's own API"
);
if let Ok(bytes) = std::fs::read(&guard) {
let token = exposed(&fixture_token());
assert!(
!bytes
.windows(token.len())
.any(|window| window == token.as_bytes()),
"the value is still lying in {} after a purge",
guard.display()
);
}
#[cfg(not(target_os = "macos"))]
{
assert!(
!guard.exists(),
"the stored value is still at {}",
guard.display()
);
if let Some(directory) = guard.parent()
&& let Ok(entries) = std::fs::read_dir(directory)
{
let remnants: Vec<_> = entries
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("user-access-token"))
.collect();
assert!(
remnants.is_empty(),
"a purge left {remnants:?} in {}",
directory.display()
);
}
}
}
#[cfg(not(target_os = "macos"))]
#[test]
fn a_purge_sweeps_a_temporary_left_by_an_interrupted_write() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let directory = store
.guard()
.parent()
.expect("the guard has a directory")
.to_path_buf();
let abandoned = directory.join(format!("{TEMP_PREFIX}00000000-dead-beef.tmp"));
std::fs::write(&abandoned, exposed(&fixture_token())).expect("planted");
store.delete().expect("purged");
assert!(
!abandoned.exists(),
"a purge left {} behind, which is the token on disk under a name nobody looks at",
abandoned.display()
);
}
#[test]
fn an_empty_value_is_refused_rather_than_stored() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
let error = store
.store(&SecretString::from(String::new()))
.expect_err("an empty value is not a token");
assert!(matches!(error, SecretStoreError::Store { .. }));
assert!(
store.load().expect("readable").is_none(),
"a refused store wrote nothing"
);
}
#[test]
fn bytes_that_are_not_a_token_are_reported_as_corrupt_and_not_as_absence() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
let error = store
.decode(vec![0xff, 0xfe, 0xfd])
.expect_err("invalid UTF-8 is not a token");
assert!(
matches!(error, SecretStoreError::Corrupt { .. }),
"got {error:?}"
);
assert!(
store.decode(Vec::new()).is_err(),
"an empty value read back is a truncated write, not an absence"
);
}
#[test]
fn no_error_this_module_produces_repeats_the_value() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
let token = exposed(&fixture_token());
let errors = vec![
SecretStoreError::Resolve {
scope: SecretScope::Machine,
reason: "no %ProgramData%".to_string(),
},
store
.store(&SecretString::from(String::new()))
.expect_err("empty is refused"),
store
.decode(token.clone().into_bytes())
.err()
.unwrap_or_else(|| store.corrupt("a placeholder")),
store.corrupt("the bytes there are not valid UTF-8"),
store
.protection()
.err()
.unwrap_or_else(|| SecretStoreError::Inspect {
scope: SecretScope::Machine,
guard: store.guard(),
source: crate::process::permissions_summary(std::path::Path::new(
"a-path-that-is-not-there",
))
.expect_err("a missing path cannot be inspected"),
}),
];
for error in errors {
let rendered = format!("{error} / {error:?}");
assert!(
!rendered.contains(&token),
"an error rendered the token: {rendered}"
);
}
}
#[test]
fn a_stored_value_is_not_readable_by_an_unprivileged_local_user() {
for scope in [SecretScope::Machine, SecretScope::User] {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(scope, &root);
store.store(&fixture_token()).expect("stored");
let protection = store.protection().expect("the guard is inspectable");
assert!(
!protection.readable_by_other_local_users(),
"the {scope}-scoped store is readable by other local users: {protection}"
);
}
}
#[test]
fn the_readability_check_reports_a_guard_that_was_loosened() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let guard = store.guard();
assert!(
!store
.protection()
.expect("inspectable")
.readable_by_other_local_users(),
"the control starts from a store that passes"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&guard, std::fs::Permissions::from_mode(0o644))
.expect("the guard can be loosened");
}
#[cfg(windows)]
{
std::fs::remove_file(&guard).expect("the guard can be replaced");
std::fs::write(&guard, b"not a protected file").expect("written");
}
let protection = store
.protection()
.expect("the loosened guard is still inspectable");
assert!(
protection.readable_by_other_local_users(),
"a loosened guard was reported as safe, so the assertion above proves nothing: \
{protection}"
);
}
#[test]
fn the_protection_names_the_object_it_inspected() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let protection = store.protection().expect("inspectable");
assert_eq!(protection.guard(), store.guard());
assert!(
!protection.description().is_empty(),
"a protection with no description is not a diagnosis"
);
}
#[test]
fn the_active_store_is_reported_and_agrees_with_the_start_mode() {
let root = TempDir::new().expect("a temporary directory");
for mode in [StartMode::Boot, StartMode::Login] {
let store = rooted(SecretScope::for_start_mode(mode), &root);
let active = ActiveStore::of(&store, mode);
assert_eq!(active.scope(), SecretScope::for_start_mode(mode));
assert_eq!(active.start_mode(), mode);
assert!(active.agrees_with_start_mode());
let rendered = active.to_string();
assert!(
rendered.contains(active.scope().as_str()),
"`host show` must name the variant in use: {rendered}"
);
assert!(
rendered.contains(&mode.to_string()),
"`service status` must name the start mode: {rendered}"
);
assert!(
!rendered.contains("MISMATCH"),
"a matching pair must not be reported as a mismatch: {rendered}"
);
}
}
#[test]
fn a_store_that_disagrees_with_the_start_mode_says_so() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::User, &root);
let active = ActiveStore::of(&store, StartMode::Boot);
assert!(!active.agrees_with_start_mode());
let rendered = active.to_string();
assert!(rendered.contains("MISMATCH"), "{rendered}");
assert!(rendered.contains("machine"), "{rendered}");
}
#[test]
fn the_reported_location_is_not_the_value() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let token = exposed(&fixture_token());
let active = ActiveStore::of(&store, StartMode::Boot);
for rendered in [
store.location(),
active.to_string(),
format!("{store:?}"),
format!("{active:?}"),
store.protection().expect("inspectable").to_string(),
] {
assert!(
!rendered.contains(&token),
"a report carried the value: {rendered}"
);
}
}
#[test]
fn the_machine_store_is_not_under_the_home_directory() {
let store = PlatformSecretStore::standard(SecretScope::Machine)
.expect("the machine store resolves");
let guard = store.guard();
let Some(base) = directories::BaseDirs::new() else {
panic!("this account has no home directory, so the assertion cannot be made");
};
assert!(
!guard.starts_with(base.home_dir()),
"the machine store at {} is under the home directory {}",
guard.display(),
base.home_dir().display()
);
}
#[test]
fn the_user_store_is_under_the_home_directory() {
let store =
PlatformSecretStore::standard(SecretScope::User).expect("the user store resolves");
let Some(base) = directories::BaseDirs::new() else {
panic!("this account has no home directory, so the assertion cannot be made");
};
assert!(
store.guard().starts_with(base.home_dir()),
"the user store at {} is not under the home directory {}",
store.guard().display(),
base.home_dir().display()
);
}
#[test]
fn the_standard_locations_are_the_documented_ones() {
let machine = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
let user = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
#[cfg(windows)]
{
let program_data = std::path::PathBuf::from(
std::env::var_os("ProgramData").expect("Windows sets ProgramData"),
);
assert_eq!(
machine.guard(),
program_data
.join("IvanMurzak")
.join("runner-manager")
.join("secrets")
.join("user-access-token.dpapi")
);
assert!(user.location().contains("DPAPI"), "{}", user.location());
}
#[cfg(target_os = "macos")]
{
assert_eq!(
machine.guard(),
std::path::Path::new("/var/db/SystemKey"),
"the System Keychain's protection is its root-only master key, \
not the world-readable database beside it"
);
assert!(
machine
.location()
.contains("/Library/Keychains/System.keychain"),
"{}",
machine.location()
);
assert!(
user.location().contains("login.keychain"),
"{}",
user.location()
);
}
#[cfg(all(unix, not(target_os = "macos")))]
{
assert_eq!(
machine.guard(),
std::path::Path::new("/var/lib/runner-manager/secrets/user-access-token")
);
assert!(user.location().contains("0600"), "{}", user.location());
}
}
#[test]
fn the_product_identity_is_the_one_paths_defines() {
assert_eq!(QUALIFIER, "io.github");
assert_eq!(ORGANIZATION, "IvanMurzak");
assert_eq!(APPLICATION, "runner-manager");
#[cfg(target_os = "macos")]
assert_eq!(
sys::service(),
"io.github.IvanMurzak.runner-manager",
"the keychain service names the product; a change here moves every \
stored item and the token reads as absent"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn overwrite_zeroes_every_byte_and_leaves_the_file_there() {
let root = TempDir::new().expect("a temporary directory");
let path = root.path().join("value");
let token = exposed(&fixture_token());
std::fs::write(&path, &token).expect("written");
assert!(overwrite(&path).expect("overwritten"), "the file was there");
let after = std::fs::read(&path).expect("still there, so the fill is observable");
assert_eq!(
after.len(),
token.len(),
"the overwrite must not truncate; a shorter file leaves the tail of the old \
value in the block"
);
assert!(
after.iter().all(|byte| *byte == 0),
"the file still holds non-zero bytes after an overwrite: {after:?}"
);
assert!(
!after
.windows(token.len())
.any(|window| window == token.as_bytes()),
"the value survived the overwrite"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn overwriting_what_is_not_there_is_not_a_failure() {
let root = TempDir::new().expect("a temporary directory");
assert!(
!overwrite(&root.path().join("absent")).expect("absence is not an error"),
"a store that was never written has nothing to scrub"
);
}
#[cfg(not(target_os = "macos"))]
#[test]
fn a_trailing_newline_is_not_part_of_the_token() {
let token = exposed(&fixture_token());
for suffix in ["\n", "\r\n", "\n\n", " ", "\t\n", ""] {
let raw = format!("{token}{suffix}").into_bytes();
assert_eq!(
trim_trailing_ascii_whitespace(raw),
token.clone().into_bytes(),
"a credential written with {suffix:?} on the end yielded a different token"
);
}
let interior = b"gh u_x\n".to_vec();
assert_eq!(trim_trailing_ascii_whitespace(interior), b"gh u_x".to_vec());
assert!(trim_trailing_ascii_whitespace(b"\n\n".to_vec()).is_empty());
}
#[cfg(windows)]
mod windows {
use super::*;
use crate::secrets::sys::{merge_grants, replacement_sddl, sddl, trustees};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AlsoDeny {
Nothing,
Creation,
}
struct DeniedReplace {
file: std::path::PathBuf,
directory: std::path::PathBuf,
restored: bool,
}
impl DeniedReplace {
fn new(file: &std::path::Path, directory: &std::path::Path, also: AlsoDeny) -> Self {
let denied = Self {
file: file.to_path_buf(),
directory: directory.to_path_buf(),
restored: false,
};
icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(D)"]);
icacls(&[
&denied.directory.display().to_string(),
"/deny",
"*S-1-1-0:(DC)",
]);
if also == AlsoDeny::Creation {
icacls(&[
&denied.directory.display().to_string(),
"/deny",
"*S-1-1-0:(WD)",
]);
}
denied
}
fn restore(&mut self) {
if self.restored {
return;
}
self.restored = true;
for path in [&self.file, &self.directory] {
let arguments = [
path.display().to_string(),
"/remove:d".into(),
"*S-1-1-0".into(),
];
match run_icacls(&arguments.each_ref().map(String::as_str)) {
Ok(output) if output.status.success() => {}
other => eprintln!(
"could not restore the ACL on {}: {other:?}; {} may survive in \
the temporary directory",
path.display(),
path.display()
),
}
}
}
}
impl Drop for DeniedReplace {
fn drop(&mut self) {
self.restore();
}
}
fn run_icacls(arguments: &[&str]) -> std::io::Result<std::process::Output> {
std::process::Command::new("icacls.exe")
.args(arguments)
.output()
}
fn icacls(arguments: &[&str]) {
let output = run_icacls(arguments).expect("icacls is present on every Windows");
assert!(
output.status.success(),
"icacls {arguments:?} failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn a_replacement_carries_the_previous_owner_and_a_first_write_does_not() {
for scope in [SecretScope::Machine, SecretScope::User] {
assert_eq!(
replacement_sddl(scope, &[]),
sddl(scope),
"a first write has nothing to carry, so it gets the constant DACL and \
nothing else"
);
assert_eq!(
replacement_sddl(scope, &["S-1-5-21-1-2-3-1001".to_owned()]),
format!("{}(A;;FA;;;S-1-5-21-1-2-3-1001)", sddl(scope)),
"one carried grant is one appended ACE"
);
}
}
#[test]
fn a_grant_survives_a_renewal_by_an_account_that_is_not_the_previous_owner() {
let operator = "S-1-5-21-9-8-7-1001";
let after_one_renewal = format!("{}(A;;FA;;;{operator})", sddl(SecretScope::Machine));
assert_eq!(
merge_grants(Some("S-1-5-18"), &after_one_renewal),
vec![operator.to_owned()],
"the owner is now LocalSystem, which `SY` already grants; what must survive is \
the operator named in the DACL the previous renewal wrote"
);
assert_eq!(
merge_grants(Some(operator), sddl(SecretScope::Machine)),
vec![operator.to_owned()],
"and the first renewal, where the operator is still the owner and the DACL \
names nobody, carries the same one account"
);
assert!(
merge_grants(None, "").is_empty(),
"a first write has no owner and no DACL to read, and carries nothing"
);
assert_eq!(
merge_grants(Some(operator), &after_one_renewal),
vec![operator.to_owned()],
"an account reachable both ways is named once, so the DACL cannot grow by an \
ACE per write"
);
let after_a_renewal_for_a_builtin =
format!("{}(A;;FA;;;LA)", sddl(SecretScope::Machine));
assert_eq!(
merge_grants(Some("S-1-5-18"), &after_a_renewal_for_a_builtin),
vec!["LA".to_owned()],
"a grant is a grant whichever spelling the DACL reads back in"
);
assert!(
merge_grants(None, sddl(SecretScope::Machine)).is_empty(),
"and the constant DACL's own aliases are not carried, or every write would \
double the base"
);
}
#[test]
fn the_trustee_scan_reads_both_spellings() {
assert_eq!(
trustees("D:P(A;;FA;;;SY)(A;;FA;;;BA)(A;;FA;;;OW)(A;;FA;;;S-1-5-21-9-8-7-1001)"),
vec![
"SY".to_owned(),
"BA".to_owned(),
"OW".to_owned(),
"S-1-5-21-9-8-7-1001".to_owned(),
],
"the scan reads trustees; deciding which of them are already granted is \
`merge_grants`'s job and not this one's"
);
assert_eq!(
trustees("D:P(A;;FA;;;LA)"),
vec!["LA".to_owned()],
"an alias is a trustee too, which is what CI's built-in Administrator account \
reads back as"
);
}
#[test]
fn replacing_a_stored_credential_keeps_the_previous_owners_grant() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
let base = sddl(SecretScope::Machine);
store.store(&fixture_token()).expect("the first write");
let first = store
.protection()
.expect("the first file's DACL is readable")
.description()
.to_string();
assert_ne!(
first, base,
"even a first write names the account that made it, because `OW` alone does \
not survive a change of owner"
);
let mut previous: Option<String> = Some(first);
for round in 1..=3 {
store.store(&other_token()).expect("the replacement");
let dacl = store
.protection()
.expect("the replacement's DACL is readable")
.description()
.to_string();
assert_ne!(
dacl, base,
"write {round}: the account that owned what was replaced must stay granted \
explicitly, because a writer under another account takes `OW` with it"
);
if let Some(previous) = &previous {
assert_eq!(
&dacl, previous,
"write {round}: and the set must settle -- a DACL that keeps growing is \
an ACE per renewal, and one that shrinks back to the constant is the \
lockout returning"
);
}
previous = Some(dacl);
}
assert_eq!(
store
.load()
.expect("the replacement is readable")
.map(|secret| secret.expose_secret().to_string()),
Some(other_token().expose_secret().to_string()),
"carrying an ACE forward must not disturb what the store holds"
);
}
struct DeniedRead {
file: std::path::PathBuf,
restored: bool,
}
impl DeniedRead {
fn new(file: &std::path::Path) -> Self {
let denied = Self {
file: file.to_path_buf(),
restored: false,
};
icacls(&[&denied.file.display().to_string(), "/deny", "*S-1-1-0:(R)"]);
denied
}
fn restore(&mut self) {
if self.restored {
return;
}
self.restored = true;
let arguments = [
self.file.display().to_string(),
"/remove:d".into(),
"*S-1-1-0".into(),
];
match run_icacls(&arguments.each_ref().map(String::as_str)) {
Ok(output) if output.status.success() => {}
other => eprintln!(
"could not restore the ACL on {}: {other:?}",
self.file.display()
),
}
}
}
impl Drop for DeniedRead {
fn drop(&mut self) {
self.restore();
}
}
#[test]
fn a_store_this_account_may_not_read_names_the_command_that_gives_it_back() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("the first write");
let guard = store.guard();
let _denied = DeniedRead::new(&guard);
let error = store
.load()
.expect_err("a store this account may not read is not a store it can load");
let rendered = error.to_string();
for expected in ["icacls", "/grant", &guard.display().to_string(), "elevated"] {
assert!(
rendered.contains(expected),
"the refusal must name the remedy and the file it applies to. Wanted \
{expected:?} in: {rendered}"
);
}
assert!(
!rendered.contains(fixture_token().expose_secret()),
"and it must not carry the value it could not read"
);
assert!(
rendered.contains("Do NOT use `takeown`"),
"and it must warn off the repair that looks right and is not. Changing the \
owner makes Windows delete the OWNER RIGHTS ACE, so the account ends up \
owning a file it still cannot read -- permanently. Offered on a real host on \
2026-08-30, where it appeared to work only because the check that followed \
ran in the same elevated prompt. Got: {rendered}"
);
}
#[test]
fn a_second_operator_is_refused_with_a_remedy_and_writes_nothing() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store
.store(&fixture_token())
.expect("the first operator stores");
let guard = store.guard();
let directory = guard
.parent()
.expect("the guard has a directory")
.to_path_buf();
let mut denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Nothing);
let error = store
.store(&other_token())
.expect_err("a value this account may not replace is not a value it may store");
let rendered = error.to_string();
for expected in [
"one machine-scoped credential per host",
"auth logout",
"elevated",
"--start-at login",
"Nothing was written",
] {
assert!(
rendered.contains(expected),
"the refusal does not name {expected:?}, so an operator cannot act on \
it: {rendered}"
);
}
assert!(
matches!(
&error,
SecretStoreError::Store { source, .. }
if source.kind() == std::io::ErrorKind::PermissionDenied
),
"a caller cannot classify this as a permission problem: {error:?}"
);
let strays: Vec<_> = std::fs::read_dir(&directory)
.expect("readable")
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(strays.is_empty(), "a refused store left {strays:?}");
denied.restore();
assert_eq!(stored(&store), exposed(&fixture_token()));
store
.store(&other_token())
.expect("stored once allowed again");
assert_eq!(stored(&store), exposed(&other_token()));
}
#[test]
fn the_refusal_is_made_before_anything_is_written() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store
.store(&fixture_token())
.expect("the first operator stores");
let guard = store.guard();
let directory = guard
.parent()
.expect("the guard has a directory")
.to_path_buf();
let _denied = DeniedReplace::new(&guard, &directory, AlsoDeny::Creation);
let error = store
.store(&other_token())
.expect_err("nothing can be created here, so nothing can be stored");
let rendered = error.to_string();
for expected in [
"one machine-scoped credential per host",
"auth logout",
"elevated",
"--start-at login",
"Nothing was written",
] {
assert!(
rendered.contains(expected),
"the refusal does not name {expected:?}. With creation denied, the only \
way to produce that text is a check that ran BEFORE the write -- so this \
message came from `create_protected_file` instead, and the store reached \
the write before it refused: {rendered}"
);
}
}
#[test]
fn the_dpapi_buffer_is_scrubbed_before_it_is_freed() {
let token = exposed(&fixture_token());
let mut buffer = token.clone().into_bytes();
let length = buffer.len();
let copy = unsafe { sys::copy_and_scrub(buffer.as_mut_ptr(), length) };
assert_eq!(
copy,
token.clone().into_bytes(),
"the caller must still receive the value"
);
assert!(
buffer.iter().all(|byte| *byte == 0),
"the source buffer still holds the token after the copy, and on the \
unprotect path that buffer is handed back to the heap by LocalFree: \
{buffer:?}"
);
}
#[test]
fn a_win32_error_keeps_its_kind_through_the_hresult_wrapper() {
let denied = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
0x8007_0005_u32 as i32,
));
assert_eq!(
sys::io_error(&denied).kind(),
std::io::ErrorKind::PermissionDenied
);
let missing = ::windows::core::Error::from_hresult(::windows::core::HRESULT(
0x8007_0002_u32 as i32,
));
assert_eq!(sys::io_error(&missing).kind(), std::io::ErrorKind::NotFound);
}
#[test]
fn both_dacls_are_protected_and_name_no_broad_trustee() {
for scope in [SecretScope::Machine, SecretScope::User] {
let sddl = sys::sddl(scope);
assert!(
sddl.starts_with("D:P"),
"an unprotected DACL inherits whatever %ProgramData% grants, which is \
Builtin Users read: {sddl}"
);
for broad in ["WD", "AU", "BU", "IU", "AN"] {
assert!(
!sddl.contains(&format!(";{broad})")),
"{scope}: {sddl} names the broad trustee {broad}"
);
}
}
}
#[test]
fn only_the_machine_dacl_carries_the_local_system_ace() {
assert!(sys::sddl(SecretScope::Machine).contains("(A;;FA;;;SY)"));
assert!(!sys::sddl(SecretScope::User).contains(";SY)"));
}
#[test]
fn the_dacl_on_disk_is_the_one_the_backend_asked_for() {
let root = TempDir::new().expect("a temporary directory");
for scope in [SecretScope::Machine, SecretScope::User] {
let store = rooted(scope, &root);
store.store(&fixture_token()).expect("stored");
let description = store.protection().expect("inspectable").description;
assert!(
description.contains("D:P"),
"{scope}: the stored file did not keep its protected DACL: {description}"
);
assert!(
description.contains("FA;;;BA"),
"{scope}: an administrator must still be able to clean up: {description}"
);
assert!(
description.contains(";OW)") || description.contains(";S-1-3-4)"),
"{scope}: the OWNER RIGHTS ACE did not survive to disk, so a \
non-administrative operator cannot read their own token: {description}"
);
if scope == SecretScope::Machine {
assert!(
description.contains("FA;;;SY"),
"a LocalSystem daemon must be able to read the machine store: \
{description}"
);
}
}
}
#[test]
fn the_bytes_on_disk_are_not_the_value() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let blob = std::fs::read(store.guard()).expect("the blob is readable");
let token = exposed(&fixture_token());
assert!(
!blob
.windows(token.len())
.any(|window| window == token.as_bytes()),
"the DPAPI blob contains the plaintext token"
);
}
#[test]
fn bytes_that_are_not_a_blob_are_reported_as_corrupt() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
std::fs::write(store.guard(), b"this is not a DPAPI blob").expect("planted");
let error = store.load().expect_err("a foreign blob is not a value");
assert!(
matches!(error, SecretStoreError::Corrupt { .. }),
"got {error:?}"
);
}
}
#[cfg(unix)]
mod unix {
use super::*;
use std::os::unix::fs::PermissionsExt as _;
fn mode_of(path: &std::path::Path) -> u32 {
std::fs::metadata(path)
.unwrap_or_else(|error| panic!("{} is not there: {error}", path.display()))
.permissions()
.mode()
& 0o777
}
#[test]
fn the_guard_is_0600_and_its_directory_is_0700() {
for scope in [SecretScope::Machine, SecretScope::User] {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(scope, &root);
store.store(&fixture_token()).expect("stored");
let guard = store.guard();
assert_eq!(
mode_of(&guard),
0o600,
"{scope}: {} is not 0600",
guard.display()
);
let directory = guard.parent().expect("the guard has a directory");
assert_eq!(
mode_of(directory),
0o700,
"{scope}: {} is not 0700",
directory.display()
);
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod linux {
use super::*;
use std::os::unix::fs::PermissionsExt as _;
fn plant_credential(directory: &std::path::Path, bytes: &[u8]) -> std::path::PathBuf {
std::fs::create_dir_all(directory).expect("the credentials directory");
let path = directory.join(SYSTEMD_CREDENTIAL);
std::fs::write(&path, bytes).expect("the credential is written");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400))
.expect("the credential is tightened");
path
}
#[test]
fn a_systemd_credential_is_read_in_preference_to_the_file() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
let plain = rooted(SecretScope::Machine, &root);
plain.store(&other_token()).expect("stored");
plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
assert_eq!(
stored(&store),
exposed(&fixture_token()),
"a unit given LoadCredentialEncrypted= must not be overridden by a stale file"
);
assert_eq!(
store.credential_path(),
Some(credentials.path().join(SYSTEMD_CREDENTIAL).as_path())
);
assert!(
store.location().contains("systemd credential"),
"`host show` must say where the value is actually coming from: {}",
store.location()
);
}
#[test]
fn a_credential_written_with_a_trailing_newline_yields_the_token_without_it() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
let token = exposed(&fixture_token());
plant_credential(credentials.path(), format!("{token}\n").as_bytes());
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
assert_eq!(stored(&store), token);
}
#[test]
fn the_stores_own_file_is_read_back_verbatim() {
let root = TempDir::new().expect("a temporary directory");
let store = rooted(SecretScope::Machine, &root);
store.store(&fixture_token()).expect("stored");
let token = exposed(&fixture_token());
std::fs::write(store.guard(), format!("{token}\n")).expect("planted");
assert_eq!(
stored(&store),
format!("{token}\n"),
"the store's own file is used verbatim; only a systemd credential is trimmed"
);
}
#[test]
fn a_credentials_directory_without_this_credential_falls_back_to_the_file() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
let plain = rooted(SecretScope::Machine, &root);
plain.store(&fixture_token()).expect("stored");
std::fs::write(credentials.path().join("something.else"), b"x").expect("written");
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
assert_eq!(stored(&store), exposed(&fixture_token()));
}
#[test]
fn the_credential_is_what_protection_inspects_when_there_is_one() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
let planted =
plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
let protection = store.protection().expect("inspectable");
assert_eq!(protection.guard(), planted);
assert!(!protection.readable_by_other_local_users(), "{protection}");
}
#[test]
fn storing_under_a_systemd_credential_is_refused_rather_than_shadowed() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
let file = rooted(SecretScope::Machine, &root).guard();
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
let error = store
.store(&other_token())
.expect_err("a write that the next read would ignore is not a write");
let rendered = error.to_string();
assert!(rendered.contains(SYSTEMD_CREDENTIAL), "{rendered}");
assert!(!file.exists(), "{} was written anyway", file.display());
}
#[test]
fn purging_under_a_systemd_credential_removes_the_file_and_reports_the_remainder() {
let root = TempDir::new().expect("a temporary directory");
let credentials = TempDir::new().expect("a credentials directory");
let plain = rooted(SecretScope::Machine, &root);
plain.store(&other_token()).expect("stored");
let file = plain.guard();
plant_credential(credentials.path(), exposed(&fixture_token()).as_bytes());
let store =
rooted(SecretScope::Machine, &root).with_credentials_directory(credentials.path());
let error = store
.delete()
.expect_err("this host is not purged and `auth logout` must not say it is");
assert!(!file.exists(), "the file it could remove was removed");
assert!(
error.to_string().contains(SYSTEMD_CREDENTIAL),
"the operator has to be told what is still supplying a token: {error}"
);
}
#[test]
#[serial_test::serial]
fn the_standard_machine_store_reads_the_credentials_directory_from_the_environment() {
let credentials = TempDir::new().expect("a credentials directory");
unsafe {
std::env::set_var(CREDENTIALS_DIRECTORY, credentials.path());
}
let store = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
let resolved = store.credential_path().map(std::path::Path::to_path_buf);
unsafe {
std::env::remove_var(CREDENTIALS_DIRECTORY);
}
assert_eq!(
resolved,
Some(credentials.path().join(SYSTEMD_CREDENTIAL)),
"a daemon started by systemd gets its credential without being told to"
);
let without = PlatformSecretStore::standard(SecretScope::Machine).expect("resolves");
assert_eq!(
without.credential_path(),
None,
"a daemon started by anything else must not invent one"
);
}
#[test]
fn a_user_scoped_store_never_consults_a_service_credential() {
let store = PlatformSecretStore::standard(SecretScope::User).expect("resolves");
assert_eq!(store.credential_path(), None);
}
}
}