Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
33585ef
docs(private-fs): state the intent of private-fs
CBenoit Oct 26, 2026
13253dd
docs(agent): require the agent's account or root to own Unix pending …
CBenoit Oct 26, 2026
90146b9
refactor(private-fs): define rules and directory handles
CBenoit Oct 26, 2026
071e150
feat(private-fs): implement the Windows backend
CBenoit Oct 26, 2026
16372ca
feat(private-fs): implement the Linux and macOS backend
CBenoit Oct 26, 2026
81289c6
feat(private-fs): remove a created file through its own handle
CBenoit Oct 26, 2026
1f458cc
feat(agent): manage device key files with private-fs
CBenoit Oct 26, 2026
2622b4a
fix(private-fs): harden the Windows backend
CBenoit Oct 26, 2026
1234c96
fix(private-fs): keep Windows directory handles shareable and refuse …
CBenoit Oct 26, 2026
352b0c3
feat(agent): keep identity state with private-fs
CBenoit Oct 26, 2026
d54fcf8
fix(private-fs): tighten the Linux and macOS checks
CBenoit Oct 26, 2026
22d5926
fix(private-fs): read Linux ACLs at their size
CBenoit Oct 26, 2026
ac7da4c
fix(private-fs): report a link before an unreadable directory
CBenoit Oct 26, 2026
cf84b77
fix(agent): require the agent's account to own identity state
CBenoit Oct 26, 2026
159b134
fix(private-fs): require exact OWNER RIGHTS and repair with the owner…
CBenoit Oct 26, 2026
46e5dee
fix(private-fs): report a missing base directory without checking abo…
CBenoit Oct 26, 2026
e5d2864
fix(agent): skip anything but regular files when cleaning up device keys
CBenoit Oct 26, 2026
9630c2b
docs(private-fs): say how Windows reparse points are listed and refused
CBenoit Oct 26, 2026
2c2b129
test(private-fs): pin shareable and exclusive Windows directory handles
CBenoit Oct 26, 2026
20da4ea
fix(private-fs): discard a Windows file only while it keeps its name
CBenoit Oct 26, 2026
718087d
docs(private-fs): state that discard keeps a file renamed by persist
CBenoit Oct 26, 2026
50848b3
refactor(agent): tighten the identity drop box and its checks
CBenoit Oct 26, 2026
d998aff
docs(private-fs): say how discard compares names on Windows
CBenoit Oct 26, 2026
d92087c
fix(private-fs): compare Windows names as NTFS does when discarding
CBenoit Oct 26, 2026
bf200d2
test(agent): run the elevated drop-box test against the test's data d…
CBenoit Oct 26, 2026
5110f33
test(private-fs): cover the parent of the first existing directory, u…
CBenoit Oct 26, 2026
50da61a
fix(agent): repair an identity directory whose access denies the agent
CBenoit Oct 26, 2026
14f7cf2
style(private-fs): format
CBenoit Oct 26, 2026
d52105e
test(private-fs): name the Linux headers the ACL and volume tests cite
CBenoit Oct 26, 2026
c587375
fix(private-fs): refuse remote volumes and read directories above wit…
CBenoit Oct 26, 2026
5cf04e8
fix(agent): hold the state lock through a drop-box submission
CBenoit Oct 26, 2026
54e134f
feat(private-fs): repair an open file on Linux and macOS
CBenoit Oct 26, 2026
4ed9a6f
feat(private-fs): repair an open file
CBenoit Oct 26, 2026
661f848
fix(private-fs): repair a directory its owner cannot open
CBenoit Oct 26, 2026
b4976bb
fix(private-fs): refuse a Windows rule that can't create as an owner …
CBenoit Oct 26, 2026
a9c9db7
fix(agent): leave the parents of a new data directory their inherited…
CBenoit Oct 26, 2026
1310ce4
fix(private-fs): check the volume before a Unix repair
CBenoit Oct 26, 2026
2c29fa8
fix(private-fs): refuse a Windows mount folder above a base directory
CBenoit Oct 26, 2026
503d1f4
fix(agent): fail a device-key operation on a leftover it can't remove
CBenoit Oct 26, 2026
ea11048
test(private-fs): report skipped Windows tests through one helper
CBenoit Oct 26, 2026
2f4d527
fix(private-fs): restore a new directory's owner access on Unix
CBenoit Oct 26, 2026
0d6e715
fix(agent): create a missing data directory's parents with the identi…
CBenoit Oct 26, 2026
45cd649
test(agent): keep the conformance work directory under ProgramData on…
CBenoit Oct 26, 2026
1ec5da2
test(private-fs): cite each test's deciding source instead of the cra…
CBenoit Oct 26, 2026
1533153
test(private-fs): prune the Windows twins of removed and remarked tests
CBenoit Oct 26, 2026
9e7456d
fix(private-fs): require the rule's mode under an exact Unix rule
CBenoit Oct 26, 2026
1f14f29
docs(private-fs): say what an exact rule requires on Linux and macOS
CBenoit Oct 26, 2026
a5ccded
fix(agent): never list a hard-linked device key, and fail on a bad lo…
CBenoit Oct 26, 2026
81a2417
fix(private-fs): report a mount folder above a base as unreadable
CBenoit Oct 26, 2026
0623c07
docs(private-fs): describe an access refusal as access other than the…
CBenoit Oct 26, 2026
f8380d1
feat(private-fs): report each listed entry's hard-link count
CBenoit Oct 26, 2026
2c2fd27
fix(private-fs): reset a narrowed Unix mode in repair, not in checks
CBenoit Oct 26, 2026
4a49d16
fix(private-fs): leave a Windows item already in line with its rule u…
CBenoit Oct 26, 2026
7f9f83e
fix(private-fs): count the links of a Windows entry whose name isn't …
CBenoit Oct 26, 2026
2d7ef82
fix(agent): always repair the identity directories, and list keys by …
CBenoit Oct 26, 2026
5748201
test(agent): check that provisioning resets a narrowed identity direc…
CBenoit Oct 26, 2026
e3fe73e
fix(private-fs): leave out a Windows entry pending deletion when listing
CBenoit Oct 26, 2026
de1d633
fix(private-fs): list an entry whose link count can't be read
CBenoit Oct 26, 2026
adeaadb
fix(agent): list a device key only when its file has one known link
CBenoit Oct 26, 2026
9b8b603
docs(private-fs): say which listed entries go without a link count
CBenoit Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ jobs:
# A root-only test run without root skips and passes, so after changing this step, check that its log has no SKIP line.
run: |
cargo test --locked --target ${{ matrix.target }} --config "target.${{ matrix.target }}.runner='sudo'" -p device-key --lib root_only_
cargo test --locked --target ${{ matrix.target }} --config "target.${{ matrix.target }}.runner='sudo'" -p testsuite --test integration_tests private_fs::unix::root_only_
env:
AWS_LC_SYS_NO_ASM: true

- name: Show sccache stats
if: ${{ needs.preflight.outputs.sccache == 'true' && !cancelled() }}
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/agent-identity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ httpdate = "1"
http-client-proxy = { path = "../http-client-proxy" }
p256 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] }
p384 = { version = "=0.14.0-rc.14", features = ["ecdsa", "pkcs8"] }
private-fs = { path = "../private-fs" }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "socks"] }
rustls-pemfile = "2.2"
serde = { version = "1", features = ["derive"] }
Expand All @@ -40,7 +41,6 @@ zeroize = "1"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
private-fs = { path = "../private-fs" }

[target.'cfg(windows)'.dependencies]
win-api-wrappers = { path = "../win-api-wrappers" }
Expand Down
2 changes: 1 addition & 1 deletion crates/agent-identity/INTENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The location and format of the files below are stable, because the conformance t
- `<hex>` is the SHA-256 of the full token's UTF-8 bytes encoded as lowercase hexadecimal, and is the only encoding of a token hash the agent stores: file names and every `token_sha256` field use it.
- One file per token, named `<hex>`:
- Linux and macOS: `<data-dir>/identity/pending/<hex>.json`, mode `0600`, with content `{ "version": 1, "token": "<token>" }`.
The agent does not enforce the file's owner, and ignores and keeps a file with any group or other permission bit.
The file is owned by the agent's account or root; the agent ignores and keeps a file owned by any other account or with any group or other permission bit.
The file holds the token in plaintext, protected only by its permissions, until the authority answers; that is accepted.
- Windows: `<data-dir>\identity\pending\<hex>.dat`, whose content is DPAPI `CryptProtectData(json, entropy = "Devolutions.Agent.PendingEnrollment.v1", CRYPTPROTECT_LOCAL_MACHINE)` of the same JSON; `.dat` because the content is an opaque blob, not JSON.
The agent accepts exactly two forms and ignores and keeps any other file:
Expand Down
2 changes: 0 additions & 2 deletions crates/agent-identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ pub mod state;
pub mod token;

mod chain;
#[cfg(target_os = "macos")]
mod macos_acl;
mod private_file;
mod rejected;
mod state_lock;
Expand Down
51 changes: 0 additions & 51 deletions crates/agent-identity/src/macos_acl.rs

This file was deleted.

91 changes: 41 additions & 50 deletions crates/agent-identity/src/pending.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ pub fn write(data_dir: &Utf8Path, token: &Token, grant_current_user: bool) -> an
let contents = plaintext;
#[cfg(windows)]
if administrator_writer && !grant_current_user {
crate::windows::submit_pending_as_administrator(data_dir, &path, &contents)?;
private_file::submit_pending_as_administrator(data_dir, &path, &contents)?;
return Ok(path);
}
let _lock = state_lock::acquire(data_dir, grant_current_user)?;
Expand Down Expand Up @@ -110,7 +110,7 @@ pub fn list(data_dir: &Utf8Path) -> anyhow::Result<Vec<Utf8PathBuf>> {
Ok(paths)
}

fn pending_hash_from_name(name: &str) -> Option<&str> {
pub(crate) fn pending_hash_from_name(name: &str) -> Option<&str> {
name.strip_suffix(&format!(".{EXTENSION}"))
.filter(|hash| valid_hash_hex(hash))
}
Expand Down Expand Up @@ -352,22 +352,19 @@ mod tests {
URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#),
"A".repeat(43)
))?;
let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("pending-tests")
.join(uuid::Uuid::new_v4().to_string());
fs::create_dir_all(&dir)?;
let test_dir = private_file::TestDir::new()?;
let dir = test_dir.path();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;

if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 {
fs::remove_dir_all(&dir)?;
if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 {
fs::remove_dir_all(dir)?;
return Ok(());
}
}
private_file::prepare_directories(&dir, true)?;
let path = file_path(&dir, &token.sha256_hex())?;
private_file::prepare_directories(dir, true)?;
let path = file_path(dir, &token.sha256_hex())?;
let body = format!(r#"{{"version":1,"token":"{}"}}"#, token.as_str());
// JSON allows trailing white space, which pads the payload to an exact size.
for (size, readable) in [(16 * 1024, true), (16 * 1024 + 1, false)] {
Expand All @@ -376,14 +373,14 @@ mod tests {
let contents = crate::windows::protect(padded.as_bytes())?;
#[cfg(not(windows))]
let contents = padded.into_bytes();
private_file::write_atomic(&dir, &path, &contents, true)?;
private_file::write_atomic(dir, &path, &contents, true)?;
assert_eq!(
matches!(read(&path, true)?, PendingRead::Ready(found) if found.as_str() == token.as_str()),
readable,
"{size}"
);
}
fs::remove_dir_all(&dir)?;
fs::remove_dir_all(dir)?;
Ok(())
}

Expand All @@ -396,23 +393,20 @@ mod tests {
URL_SAFE_NO_PAD.encode(r#"{"u":"https://example.test"}"#),
"A".repeat(43)
);
let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("pending-tests")
.join(uuid::Uuid::new_v4().to_string());
fs::create_dir_all(&dir)?;
let test_dir = private_file::TestDir::new()?;
let dir = test_dir.path();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;

if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 {
fs::remove_dir_all(&dir)?;
if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 {
fs::remove_dir_all(dir)?;
return Ok(());
}
}
let hash = token::sha256_hex(&token);
let path = file_path(&dir, &hash)?;
private_file::prepare_directories(&dir, true)?;
let path = file_path(dir, &hash)?;
private_file::prepare_directories(dir, true)?;
for oversized in [
format!(
r#"{{"version":1,"token":"{token}","padding":"{}"}}"#,
Expand All @@ -429,7 +423,7 @@ mod tests {
let contents = crate::windows::protect(oversized.as_bytes())?;
#[cfg(not(windows))]
let contents = oversized.into_bytes();
private_file::write_atomic(&dir, &path, &contents, true)?;
private_file::write_atomic(dir, &path, &contents, true)?;
assert!(matches!(read(&path, true)?, PendingRead::Unusable));
}
#[cfg(windows)]
Expand All @@ -441,10 +435,10 @@ mod tests {
.into_bytes();
let ciphertext = crate::windows::protect(&oversized)?;
assert!(ciphertext.len() > MAX_PENDING_BYTES + 4096);
private_file::write_atomic(&dir, &path, &ciphertext, true)?;
private_file::write_atomic(dir, &path, &ciphertext, true)?;
assert!(matches!(read(&path, true)?, PendingRead::Unusable));
}
fs::remove_dir_all(&dir)?;
fs::remove_dir_all(dir)?;
Ok(())
}

Expand All @@ -460,47 +454,44 @@ mod tests {
"A".repeat(43)
);
let token = Token::parse(&raw)?;
let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
.join("target")
.join("pending-tests")
.join(uuid::Uuid::new_v4().to_string());
fs::create_dir_all(&dir)?;
let test_dir = private_file::TestDir::new()?;
let dir = test_dir.path();
let hash = hex::encode(Sha256::digest(raw.as_bytes()));
assert_eq!(
file_path(&dir, &hash)?,
directory(&dir).join(format!("{hash}.{EXTENSION}"))
file_path(dir, &hash)?,
directory(dir).join(format!("{hash}.{EXTENSION}"))
);
let result = write(&dir, &token, true);
let result = write(dir, &token, true);
#[cfg(unix)]
if fs::metadata(&dir)?.permissions().mode() & 0o022 != 0 {
if fs::metadata(dir)?.permissions().mode() & 0o022 != 0 {
assert!(
result.is_err(),
"pending file written to volume without owner-only permissions"
);
assert!(list(&dir)?.is_empty());
fs::remove_dir_all(&dir)?;
assert!(list(dir)?.is_empty());
fs::remove_dir_all(dir)?;
return Ok(());
}
let path = result?;
assert_eq!(path, file_path(&dir, &hash)?);
assert_eq!(path, file_path(dir, &hash)?);
assert!(matches!(read(&path, true)?, PendingRead::Ready(found) if found.as_str() == raw));
let overwritten = write(&dir, &token, true)?;
let overwritten = write(dir, &token, true)?;
assert_eq!(overwritten, path);
assert_eq!(list(&dir)?, vec![path.clone()]);
let token_named = directory(&dir).join(format!("{raw}.{EXTENSION}"));
assert_eq!(list(dir)?, vec![path.clone()]);
let token_named = directory(dir).join(format!("{raw}.{EXTENSION}"));
fs::write(&token_named, b"not encrypted")?;
#[cfg(unix)]
fs::set_permissions(&token_named, fs::Permissions::from_mode(0o644))?;
assert_eq!(list(&dir)?, vec![path.clone()]);
assert_eq!(list(dir)?, vec![path.clone()]);
let error = read(&token_named, true).expect_err("untrusted token-named file was read");
assert!(!format!("{error:#}").contains(&raw));
fs::remove_file(token_named)?;
private_file::write_atomic(&dir, &path, b"not a token", true)?;
private_file::write_atomic(dir, &path, b"not a token", true)?;
#[cfg(windows)]
{
assert!(matches!(read(&path, true)?, PendingRead::Undecryptable));
let encrypted = crate::windows::protect(b"not valid JSON")?;
private_file::write_atomic(&dir, &path, &encrypted, true)?;
private_file::write_atomic(dir, &path, &encrypted, true)?;
assert!(matches!(read(&path, true)?, PendingRead::Unusable));
}
#[cfg(not(windows))]
Expand All @@ -522,26 +513,26 @@ mod tests {
URL_SAFE_NO_PAD.encode(r#"{"u":"https://other.test"}"#),
"A".repeat(43)
))?;
let independent = file_path(&dir, &other.sha256_hex())?;
let independent = file_path(dir, &other.sha256_hex())?;
fs::write(&independent, b"independently supplied blob")?;
assert!(
read(&independent, true).is_err(),
"files without a protected DACL must not be processed"
);
fs::remove_file(&independent)?;
crate::windows::assert_pending_protection(&path, true)?;
crate::windows::test::assert_pending_protection(&path, true)?;
let protected_path = independent;
if crate::windows::is_system()? {
assert_eq!(write(&dir, &other, false)?, protected_path);
assert_eq!(write(&dir, &other, false)?, protected_path);
crate::windows::assert_pending_protection(&protected_path, false)?;
assert_eq!(write(dir, &other, false)?, protected_path);
assert_eq!(write(dir, &other, false)?, protected_path);
crate::windows::test::assert_pending_protection(&protected_path, false)?;
fs::remove_file(protected_path)?;
} else {
assert!(write(&dir, &other, false).is_err());
assert!(write(dir, &other, false).is_err());
assert!(!protected_path.exists());
}
}
fs::remove_dir_all(&dir)?;
fs::remove_dir_all(dir)?;
Ok(())
}
}
Loading
Loading