Repository navigation
6.2: The git key is never an SSH login key, and its file is root-only - #80
Merged
Merged
Conversation
…t-only Every session signs with the git key through the broker's relay socket. When the same key is also in SSH_AUTHORIZED_KEYS, any session can log in over SSH to every container that accepts it. And the brokered key file was readable by the agent off its bind mount whenever the host file's owner had the agent's UID (1000 on a single-user host), because /etc/claude was 755. - /etc/claude is root-owned, mode 700: baked in the image and set again by the entrypoint (section 4) before any agent process starts. A brokered key the agent user can still read stops the boot; only CLAUDE_BROKER_GIT_KEY=0 passes. - sshd refuses the git key whatever any authorized_keys says: the entrypoint writes its public half to /etc/ssh/revoked_keys (RevokedKeys, root-owned, baked empty, written on every boot) and names a git key that is also in authorized_keys in a boot-log banner. - claude-compose-gen and claude-launch refuse a GIT_SSH_KEY whose public half is in SSH_AUTHORIZED_KEYS (by fingerprint, as sshd reads the file), and name any other container on the host that accepts this git key or pushes with a key this file accepts. --mount refuses /etc/claude itself. - test/keys-unit.sh (55 checks, in CI and npm test); smoke section 15 now proves the refusal and pushes to a stand-in sshd, and 15e covers the boot stop. - Docs: SECURITY.md, security-notes, troubleshooting, README, .env.example, volume reference, the compose template. Per-container SSH host keys are not part of this change: the private host keys are root-only in every container, and new keys would change every client's known_hosts entry. Claude-Session: https://claude.ai/code/session_01U97ssbeo9C37pqPiWMQ1BU
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Every session signs with the git key through the broker's relay socket (entrypoint section 5). Two gaps let that key, or its file, reach further than git:
GIT_SSH_KEY's public half is also inSSH_AUTHORIZED_KEYS, any session can log in over SSH to every container that accepts it. On the owner's host, both stacks mount one keypair as the git key and asauthorized_keys, so the key in each session's agent opened every container./etc/claude/git-key(the directory was 755).SECURITY.mdsays the agent cannot read the key, so this was in scope by the repo's own rules. The smoke test had worked around it withchmod 000on its fixture.What changed
/etc/claudeis now root-owned, mode 700. The image bakes it that way and entrypoint section 4 sets it again before any agent process starts, so neither mounted file can be opened by the agent, whatever its own owner and mode. If a key that is to be brokered is still readable by the agent user (for example, a whole host directory mounted on/etc/claude), the boot stops before the agent starts and says why. Only the explicitCLAUDE_BROKER_GIT_KEY=0opt-out passes./etc/ssh/revoked_keys. That file issshd_config's newRevokedKeys: root-owned, written empty when there is no git key, and baked empty because sshd refuses all public keys when it cannot read the file. sshd then refuses the git key whatever anyauthorized_keyssays, including a line the agent adds to its own file. When the git key is also in the mountedauthorized_keys, aGIT KEY IN AUTHORIZED_KEYSbanner names it by fingerprint.claude-compose-genandclaude-launchrefuse aGIT_SSH_KEYwhose public half is inSSH_AUTHORIZED_KEYS; nothing is written or started. Keys are compared by fingerprint (ssh-keygen -lf), which reads the file the way sshd does: options,cert-authority,rsa-sha2-512for an RSA key, CRLF, comments. Both tools also warn when another claude-managed container on the host accepts this git key for SSH, or pushes with a key thisSSH_AUTHORIZED_KEYSaccepts, which covers keys that differ between stacks.--mountnow refuses/etc/claudeitself.SECURITY.mdgains a new in-scope claim and a clearer credential-isolation claim. Also updated:docs/security-notes.md(SSH keys),docs/troubleshooting.md, the README env table,.env.example,docs/volume-reference.md, the compose template and the generated header (lines 4 and 5 now name "SSH login keys" and "Git key").How it was checked
npm run lint: ok.npm test: every suite green, including the newtest/keys-unit.sh(55 checks; it is also a new CI step). Locally, two suites needed environment fixes unrelated to this change: PyYAML for compose-gen-unit, and unsetting theGIT_REPO_URLthis container inherits for workspace-unit.test/keys-unit.shcovers:ssh-keygen);RevokedKeyscontent, authorized_keys installed as mounted, the banner, the boot stop for every brokered spelling, and the opt-out passing;sshd_configlines.Against a real sshd (OpenSSH 10.0p2, unprivileged, 127.0.0.1, throwaway keys). Section 4 was extracted and run on an authorized_keys holding a login key and the git key, then sshd was pointed at the files it wrote. With the git key in a test ssh-agent:
Permission denied (publickey), and sshd loggedFailed publickey ... revoked by file. With the login key:Accepted publickey. Separately, with the git key in authorized_keys and an emptyRevokedKeys(the old behaviour) the login was accepted; with a missingRevokedKeysfile every key was refused, which is why the file is baked and always written.Adversarial review of the design. Findings and what we did:
/proc/<pid>/root,/proc/<pid>/fdof root processes,open_by_handle_at, hard links and user namespaces; all were denied.ssh-keygen -y -P ''never prompts.ssh-add/ssh-keygendo not apply the "unprotected key" check to a key file owned by another UID.cert-authoritycan sign certificates), and that a text match on the key field missesrsa-sha2-512names and other encodings. We switched toRevokedKeysand fingerprints for those reasons.Not run here (needs Docker):
make smoke. Section 15 was updated, and 15e is new:/etc/claudeisroot 700and cannot be listed by the agent; the banner is printed;RevokedKeyslists the git key;authorized_keys;/etc/claudestops the boot.The owner runs
IMAGE=claude-code-box:latest test/smoke.shbefore recreating containers.Done lines (plan task 6.2)
sshinto another container with the loaded key is refused, andgit pushstill works." The check needs the owner's host steps: separate login keys inSSH_AUTHORIZED_KEYS, a rebuild, a regenerate and a recreate. Until then, every regenerate is refused by this change. After the recreate, sshd in every container refuses the git key even if the old file is still mounted.git pushuses the unchanged broker.Scope notes
claude-sshkeyshost keys, asSECURITY.mdalready lists among known and accepted items. The private host keys are root-only in every container. New keys would change every client'sknown_hostsentry for every port.ssh-addwith a passphrase-protected key. On a container started with a TTY,ssh-addin section 5 can wait for a passphrase. The broker cannot load such a key anyway, and section 4 now warns about it.https://claude.ai/code/session_01U97ssbeo9C37pqPiWMQ1BU