Skip to content

feat: deny read of SSH key material referenced by the user's ssh config - #474

Open
ig-ant wants to merge 3 commits into
mainfrom
ig/ssh-config-read-exclusions
Open

ig-ant wants to merge 3 commits into
mainfrom
ig/ssh-config-read-exclusions

Conversation

@ig-ant

@ig-ant ig-ant commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

denyRead on ~/.ssh doesn't cover keys the ssh config points at elsewhere. At initialize/updateConfig the runtime now parses ~/.ssh/config (Include chains, bounded: depth ≤ 8, ≤ 64 files, realpath cycle detection; only statically-knowable tokens expanded — ~, %d, %u, %%; connection-scoped tokens skip the entry) and appends read denies for IdentityFile, CertificateFile, ControlPath, and IdentityAgent targets, plus ssh's default key filenames. Append-only and fail-open on malformed configs (protection can only widen; init never fails).

Placement: the collected paths ride the credential deny-read channel (getCredentialDenyReadPaths — the file's documented choke point for "paths this config wants read-denied"), so all three enforcement surfaces get them: POSIX seatbelt/bwrap assembly, the Windows ACL stamp set, and the per-exec path. Targets are denied whether or not they exist yet — a ControlPath mux socket only appears when the first master connection opens, and every backend tolerates absent deny entries (seatbelt subpath denies, Linux log-and-skip, Windows placeholder materialization). Candidates containing glob metacharacters (legal filename bytes) are skipped with a warning rather than mis-compiled as globs. Agent-socket rationale: an IdentityAgent socket signs with every loaded key — strictly more credential-equivalent than any single key file. Provider libraries (PKCS11Provider etc.) are deliberately excluded (public code, not secrets).

Test plan

23 unit tests (test/sandbox/ssh-config-deny.test.ts): parsing, Include recursion/globs/cycles, token expansion and skips, deny-before-exists, IdentityAgent, glob-metachar skip, IdentityFile none. Neighboring suites (wrap-with-sandbox, allow-read) green: 79 pass / 0 fail; tsc + eslint clean. E2E on macOS: built CLI with a staged fake HOME — an ssh-config-referenced key outside ~/.ssh and ~/.ssh/id_ed25519 both read "Operation not permitted" inside the sandbox while a sibling file reads normally.

denyRead on ~/.ssh does not cover keys living elsewhere, and ssh
configs routinely point IdentityFile/CertificateFile at them. At
initialize (and updateConfig), parse ~/.ssh/config — Include chains
bounded by depth/file-count/cycle checks; only statically-knowable
tokens expanded — and append read denies for IdentityFile,
CertificateFile, ControlPath, and IdentityAgent targets, plus ssh's
default key filenames. Append-only: never narrows configured
protection, and a malformed config never fails initialization.

The paths ride the credential deny-read channel
(getCredentialDenyReadPaths) — the documented choke point — so every
backend enforces them: the POSIX seatbelt/bwrap assembly, the
Windows ACL stamp set, and the per-exec path. Targets are denied
whether or not they exist yet (a ControlPath mux socket appears only
when the first master connection opens; every backend tolerates
absent deny entries). Candidates containing glob metacharacters are
skipped with a warning rather than mis-compiled as globs. To make a
key readable, allowRead its exact path (directory allowRead does not
override a file-specific deny).
…t tests

Appending ssh's default key names when no .ssh directory exists at
all would placeholder-materialize a .ssh skeleton into every
Windows profile that never used ssh; gate the defaults on the
directory (names within an existing .ssh stay denied before the
keys exist, so mid-session keygen remains covered).

credential-deny's exact-set assertions picked up the runner's real
~/.ssh via the new protection: point the scan at an empty temp home
through a _test seam — bun's os.homedir() reads passwd, not $HOME,
so an env-var swap cannot make the scan hermetic.
The choke-point routing through getCredentialDenyReadPaths had a
platform hole: getCredentialRestrictions early-returns for configs
with no credentials block, so POSIX assembly silently dropped every
ssh deny in the common credentials-less config while Windows (which
calls the function directly) kept them. The ssh set is now its own
NAMED source: a third arm in unionDenyReadPaths (POSIX), an
explicit union in computeWindowsFsAccessSet (session stamp), and
its own sshFiles field in the updateConfig staleness inputs.
getCredentialDenyReadPaths is pure again, which also restores the
per-exec fast path and the filesystem.disabled invariant (per-exec
denies are credential-files-only; the ssh set is session-stamped).

updateConfig re-scans the ssh config BEFORE the staleness compare
(recomputing after made ssh drift invisible to the warning, then
desynchronized silently); reset() clears the set.

Platform fixes: glob-metachar checks use the platform-aware
predicate (brackets are legal literal bytes on Windows — a bracket
path is deniable there, and Include expansion now routes through
expandGlobPattern); tilde expansion reuses the shared expandTilde
(gaining the Windows `~\` form) with an injectable home; id_xmss
joins the default identity list; on Windows, absent targets are
skipped (srt-win placeholder-materializes absent deny targets, and
planting zero-byte files at default key names inside a real ~/.ssh
makes OpenSSH try to load them and fail) while POSIX keeps
deny-before-exists.

Hygiene: single test seam (_test.homedirOverride; the parameter is
gone), filesParsed derived from visitedFiles.size, mkdtempSync in
tests, contract docs rewritten to match behavior.

This branch has not been deployed

No deployments
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.

2 participants