Skip to content

6.2: The git key is never an SSH login key, and its file is root-only - #80

Merged
NSchatz merged 3 commits into
mainfrom
6.2-git-key-not-a-login-key
Oct 8, 2026
Merged

NSchatz merged 3 commits into
mainfrom
6.2-git-key-not-a-login-key

Conversation

@NSchatz

@NSchatz NSchatz commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

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:

  1. The git key opened other containers. When GIT_SSH_KEY's public half is also in SSH_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 as authorized_keys, so the key in each session's agent opened every container.
  2. The brokered key file was readable. A bind mount keeps the host file's owner. On a single-user host, that owner's UID is the agent's (1000), so a key the owner keeps at 0600 could be read straight off /etc/claude/git-key (the directory was 755). SECURITY.md says the agent cannot read the key, so this was in scope by the repo's own rules. The smoke test had worked around it with chmod 000 on its fixture.

What changed

  • Root-only key directory. /etc/claude is 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 explicit CLAUDE_BROKER_GIT_KEY=0 opt-out passes.
  • sshd refuses the git key. On every boot, section 4 writes the git key's public half to /etc/ssh/revoked_keys. That file is sshd_config's new RevokedKeys: 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 any authorized_keys says, including a line the agent adds to its own file. When the git key is also in the mounted authorized_keys, a GIT KEY IN AUTHORIZED_KEYS banner names it by fingerprint.
  • The launch tools refuse the overlap. claude-compose-gen and claude-launch refuse a GIT_SSH_KEY whose public half is in SSH_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-512 for 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 this SSH_AUTHORIZED_KEYS accepts, which covers keys that differ between stacks. --mount now refuses /etc/claude itself.
  • Docs. SECURITY.md gains 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 new test/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 the GIT_REPO_URL this container inherits for workspace-unit.

  • test/keys-unit.sh covers:

    • the helpers on real throwaway keys (ssh-keygen);
    • compose-gen and claude-launch refusing the overlap, with a stubbed docker;
    • the fleet warnings;
    • entrypoint section 4, run unprivileged in a sandbox: mode 700, the RevokedKeys content, authorized_keys installed as mounted, the banner, the boot stop for every brokered spelling, and the opt-out passing;
    • the Dockerfile and sshd_config lines.
  • 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 logged Failed publickey ... revoked by file. With the login key: Accepted publickey. Separately, with the git key in authorized_keys and an empty RevokedKeys (the old behaviour) the login was accepted; with a missing RevokedKeys file every key was refused, which is why the file is baked and always written.

  • Adversarial review of the design. Findings and what we did:

    • Path lookup through a mode-700 root directory blocks a UID-1000 process without capabilities. We tried /proc/<pid>/root, /proc/<pid>/fd of root processes, open_by_handle_at, hard links and user namespaces; all were denied.
    • runc keeps an existing image directory's mode when it mounts into it.
    • ssh-keygen -y -P '' never prompts.
    • Root's ssh-add / ssh-keygen do not apply the "unprotected key" check to a key file owned by another UID.
    • The reviewer found that removing a line from authorized_keys alone was not enough (the agent owns that file, and cert-authority can sign certificates), and that a text match on the key field misses rsa-sha2-512 names and other encodings. We switched to RevokedKeys and fingerprints for those reasons.
  • Not run here (needs Docker): make smoke. Section 15 was updated, and 15e is new:

    • the fixture key is now 0644 (0600 for a root runner) instead of 000, so only the directory protects it;
    • new checks: /etc/claude is root 700 and cannot be listed by the agent; the banner is printed; RevokedKeys lists the git key;
    • SSH with the key in the session's agent is refused, and still refused after the agent appends it to its own authorized_keys;
    • git push goes to a second, stand-in sshd that accepts the git key (as GitHub does) and must still succeed through the relay;
    • 15e: a host directory mounted on /etc/claude stops the boot.

    The owner runs IMAGE=claude-code-box:latest test/smoke.sh before recreating containers.

Done lines (plan task 6.2)

  • "From one session container, ssh into another container with the loaded key is refused, and git push still works." The check needs the owner's host steps: separate login keys in SSH_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 push uses the unchanged broker.
  • "The brokered key file must not be readable by the session user." Done by the root-only directory, checked by the boot itself (it stops otherwise) and by smoke section 15.

Scope notes

  • Per-container SSH host keys: not done. All containers still share the claude-sshkeys host keys, as SECURITY.md already lists among known and accepted items. The private host keys are root-only in every container. New keys would change every client's known_hosts entry for every port.
  • Not changed: ssh-add with a passphrase-protected key. On a container started with a TTY, ssh-add in 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

…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
@NSchatz
NSchatz merged commit 1293cfd into main Oct 8, 2026
4 checks passed
@NSchatz
NSchatz deleted the 6.2-git-key-not-a-login-key branch October 8, 2026 20:50
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