Skip to content

v0.5 production hardening: aarch64, attestation signing, supply-chain CI, threat model#2

Merged
aimable100 merged 11 commits into
mainfrom
feat/v0.5-hardening
May 13, 2026
Merged

v0.5 production hardening: aarch64, attestation signing, supply-chain CI, threat model#2
aimable100 merged 11 commits into
mainfrom
feat/v0.5-hardening

Conversation

@aimable100

@aimable100 aimable100 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Commit Scope
1d88b33 aarch64 Linux asm shim for the openat2 syscall + opt-in RESOLVE_NO_XDEV (mount-point containment). Refactors away the raw fstat (which had arch-specific struct-stat layouts) in favor of File::metadata().
7d6241f Signer / Verifier traits + JailFile::sign_attestation + Attestation::verify. Implements spec AC6 (signed-attestation verification) without vendoring crypto — callers wire up ed25519-dalek, ring, KMS, etc.
7fe93c6 Supply-chain CI: cargo-deny config + job (advisories, licenses, dep bans, sources), cargo-semver-checks PR job, --all-features everywhere (so guard/secure-open code is actually linted), release.yml tag/version match + --dry-run gate.
8f4d488 SECURITY.md threat model — attacker model, per-API defense table, out-of-scope list, API-selection flowchart, MSRV/versioning policy, vulnerability reporting channel.

Test plan

  • cargo test --all-features passes locally (48 security + 13 secure_open + 13 guard + 14 doctests)
  • cargo clippy --all-features --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • CI green across full matrix
  • cargo-deny job picks up no advisories or license issues
  • cross-compile to aarch64-unknown-linux-gnu succeeds (replaces previous compile_error!)
  • Manual: confirm the example signer test paths exercise sign → verify → tamper-detect

aimable100 added 11 commits May 13, 2026 11:17
Adds a hand-written aarch64 syscall shim alongside the existing x86_64
one (uses x8 for syscall number, x0..x3 for args, svc #0 to trap).
Removes the compile_error! that previously blocked all non-x86_64
builds. riscv64 still produces a compile error; that's a follow-up.

Eliminates the raw fstat() helper by reading attestation fields
(device/inode/nlink) through File::metadata(). std's stat wrapper
already handles arch-specific struct stat layouts portably, so this
side-steps the x86_64-vs-aarch64 layout divergence and removes ~50
lines of inline-asm fstat code we no longer need.

Adds OpenOptions::no_xdev() exposing RESOLVE_NO_XDEV for callers that
need mount-point containment (defends against bind-mount escapes).
Off by default to preserve the existing directory-tree-containment
semantics; opt-in matches the no_symlinks pattern.
Implements spec AC6 ("Signed attestation verifies under configured key")
without vendoring a crypto crate. Callers implement Signer / Verifier
with their crypto backend of choice (ed25519-dalek, ring, HSM client,
KMS, etc.) and path_jail stays zero-dependency.

Public surface:
- guard::Signer / guard::Verifier traits with associated Error types
- guard::VerifyError<E> enum (NotSigned vs Invalid(E))
- JailFile::sign_attestation(&S) -> Result<Attestation, S::Error>
- Attestation::verify(&V) -> Result<(), VerifyError<V::Error>>
- Attestation::signing_bytes() now pub so external enforcement points
  can replay the canonical wire format without going through verify()

Tests use a deterministic non-crypto stand-in (XOR rolling checksum) to
exercise the full happy/sad paths: signed verifies, wrong key fails,
unsigned returns NotSigned, tampering invalidates the signature.
Adds cargo-deny config and a deny job (advisories, licenses, dep bans,
sources) — main signal for a security crate that a transitive dep
brought in an advisory or a non-permissive license.

Adds a semver-checks job that runs on PRs (`|| true` for now since
this is the first release with the guard surface — drop the suffix
after a baseline ships).

Extends test/clippy/docs/MSRV jobs to exercise --all-features so the
guard and secure-open feature code is actually linted and doc-built,
not just compiled with default features.

Hardens release.yml:
- Verifies the git tag matches Cargo.toml version before doing anything
  else (catches "tagged but forgot to bump Cargo.toml" before publish)
- Runs `cargo publish --dry-run` before the real publish so packaging
  failures land in CI logs, not on crates.io
Documents the attacker model, per-API guarantees (Jail vs secure-open
vs guard) in a single comparison table, out-of-scope threats, an
API-selection flowchart, the versioning/MSRV policy, and a vulnerability
reporting channel.

Calls out explicitly that the default path-based Jail API is not
TOCTOU-safe — this is documented today but scattered across README
subsections, and it's the thing most likely to bite a downstream user
who follows the quick-start verbatim.
Three bugs surfaced by --all-features CI on the v0.5 branch:

1. openat2 returned EINVAL on every call. The kernel requires
   `how.mode == 0` unless `O_CREAT` or `O_TMPFILE` is in `how.flags`,
   but we hardcoded `mode: 0o666`. Tests never caught this because
   previous CI didn't run --all-features on Linux, so the guard
   integration tests never executed. Now mode is conditional on
   O_CREAT being set.

2. `--features guard` on Windows failed to compile: the fallback
   path uses Unix-only items (MetadataExt, custom_flags, OwnedFd).
   Windows is out of scope per SECURITY.md, so gate `pub mod guard`
   on cfg(unix) — Windows users with the feature flag get an absent
   module rather than a build error.

3. Clippy `needless_return` in FdJail::new. The two cfg-gated arms
   can both be tail expressions; only one compiles per target.
cargo-deny: dep tree is currently clean (cargo tree --duplicates --all-features
--target all returns nothing), so flip multiple-versions from "warn" to
"deny". For a security crate, duplicates are where advisories hide — one
copy gets patched, another doesn't. A future upstream split that
forces a duplicate will now fail the build and surface deliberately,
either via a cargo update / [patch] resolution or a documented `skip`
entry, rather than drifting silently.

no_xdev test: replace the comment on the builder test with a concrete
TODO describing the privileged-test setup needed for real EXDEV
assertion (CAP_SYS_ADMIN + bind mount). The builder test on its own is
weak signal and would rot once people stop remembering why it's
"just a type-check".
openat2(2) returns ELOOP for both RESOLVE_NO_MAGICLINKS and
RESOLVE_NO_SYMLINKS rejections; userspace cannot tell them apart from
the errno. The previous code checked `errno == 105` as "ENOLINK on
some kernels" but 105 is actually ENOBUFS, and the real ENOLINK (67)
is never produced by openat2. That code path was unreachable.

- Drop the bogus ENOLINK branch in map_errno_to_jail_error
- Document in JailError::MagicLink that the variant is currently
  unreachable on Linux; magic-link rejections surface as
  SymlinkRejected until a future kernel ABI separates the errnos
- Relax AC3 to accept SymlinkRejected (the actual production value)
  in addition to MagicLink and Escape

The MagicLink variant is preserved (not deprecated) so callers can
already match it for forward-compat with a future kernel change.
Last caller was the dead ENOLINK branch removed in 9099e8d. Nothing
else uses it; rustc/clippy with -D warnings catches it as dead code.
Same pattern as the guard fix in 09a6b65. src/open.rs has an inner
#![cfg(all(feature = "secure-open", unix))] attribute that empties
the file on Windows, but lib.rs declared mod open and re-exported
JailedFile gated only on the feature, not on unix. Result: Windows
--all-features build saw 'mod open' with no contents and the
re-export failed. Add the unix gate to both.
tests/guard.rs now gates on cfg(unix) in addition to feature="guard",
matching the module gate in lib.rs. Without this, Windows builds with
--all-features fail because the file imports path_jail::guard but the
module is cfg-gated away.

Also fixes a warning in tests/security.rs:handles_control_characters
where `let jail` is unused on Windows (the only usage is inside a
cfg(unix) block). Move the binding inside the cfg gate.
The doctest in lib.rs was gated on feature = "guard" only. On Windows
with --all-features the feature is set but the guard module is
cfg(unix)-gated away (see 09a6b65 / 301db16), so the doctest body
failed to resolve path_jail::guard.

Other guard-related doctests are inside src/guard/, which is already
cfg(unix)-gated at the module level, so they don't compile on Windows
and don't need this fix.
@aimable100 aimable100 merged commit 312f165 into main May 13, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant