Skip to content

fix(keymanager): refuse to write key material through a symlink - #356

Open
Jaro-c wants to merge 1 commit into
developfrom
fix/key-writes-refuse-symlinks
Open

Jaro-c wants to merge 1 commit into
developfrom
fix/key-writes-refuse-symlinks

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 7, 2026

Copy link
Copy Markdown
Member

A dangling symlink at a managed filename was classified as absent by os.Stat, so every presence check agreed the directory was empty and the freshly generated Ed25519 private key was written at the link target, outside KeysDir. Reproduced against all three managed files.

The attack needs something to populate KeysDir before authcore first runs, which for this library is ordinary rather than exotic: a mounted container volume, a provisioning script, a restored backup, a Compose file.

Every managed write now goes through createExclusive, opening with O_CREATE|O_EXCL. POSIX requires that to fail with EEXIST when the path is a symlink, dangling ones included. It also closes the narrower race the old code left open: a regular file appearing between the presence check and the write kept its own permissions, so a 0644 file planted at that moment received the private key.

O_NOFOLLOW would state the property more loudly, but it lives in syscall rather than os and is undefined on Windows, and this library ships to Windows consumers. The portable flag that already has the property is the one to rely on.

Reads still follow symlinks, on purpose

docs/key-management.md tells the operator to mount a Kubernetes Secret at KeysDir, and Kubernetes projects a Secret volume as a tree of symlinks. A loader that refused links would pass a security review and break every replica following the deployment guide. TestSymlinkedKeysStillLoad pins that case: an existing key set reached entirely through links loads, and loads the same key id.

Read-following is the weaker exposure anyway. The read path already caps at 4 KiB and validates the pair, and anyone who can redirect a read can equally replace the file.

Two things I had wrong until I sabotaged them

I wrote this with Lstat in the presence check, believing that was the fix, and the commit would have said so. Reverting it to Stat left the dangling-symlink test green, so Lstat was never load-bearing. Reverting O_EXCL turns all three subtests red. O_EXCL is the control.

Measuring the two then inverted which is better. With Lstat the consistency check fires first and the operator is told to "delete all key files" — advice that, since auth/field shipped, destroys every encrypted column. With Stat the failure reaches createExclusive, which names the symlink and says what to do. So inspect uses Stat, and the test now asserts the message: that it mentions the symlink, and that it does not give the destructive advice.

Closes #342

A dangling symlink at a managed filename was classified as absent by
os.Stat, so every presence check agreed the directory was empty and the
freshly generated Ed25519 private key was written at the link target,
outside KeysDir. Reproduced against all three managed files.

The attack needs something to populate KeysDir before authcore first
runs, which for this library is ordinary: a mounted container volume, a
provisioning script, a restored backup, a Compose file.

Every managed write now goes through createExclusive, which opens with
O_CREATE|O_EXCL. POSIX requires that to fail with EEXIST when the path
is a symlink, dangling ones included. It also closes the narrower race
the old code left: a regular file appearing between the presence check
and the write kept its own permissions, so a 0644 file planted at that
moment received the private key. O_NOFOLLOW would say this more loudly
but lives in syscall and is undefined on Windows, and this library ships
there.

Reads still follow symlinks, deliberately. docs/key-management.md tells
the operator to mount a Kubernetes Secret at KeysDir, and Kubernetes
projects a Secret volume as a tree of symlinks, so a loader that refused
them would pass a security review and break every replica following the
deployment guide. TestSymlinkedKeysStillLoad pins that case.

Two things I had wrong until I sabotaged them:

I wrote this with Lstat in the presence check, believing that was the
fix. Reverting it to Stat left the test green, so Lstat was not
load-bearing: O_EXCL is. Reverting O_EXCL turns all three subtests red.

Measuring the two also inverted which is better. With Lstat the
consistency check fires first and tells the operator to "delete all key
files", which since auth/field shipped is the advice that destroys every
encrypted column. With Stat the failure reaches createExclusive, which
names the symlink and what to do about it. The test asserts the message
now, both that it mentions the symlink and that it does not give the
destructive advice.

Closes #342

Signed-off-by: Jose <75870284+Jaro-c@users.noreply.github.com>
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