Skip to content

fix(signing): write the private key through a root, so 0600 means it - #422

Merged
David-Noble-at-work merged 2 commits into
developfrom
signing-key-through-root
Aug 15, 2026
Merged

David-Noble-at-work merged 2 commits into
developfrom
signing-key-through-root

Conversation

@David-Noble-at-work

Copy link
Copy Markdown
Contributor

Summary

The defect that opened #405.
generateLocalKey wrote an Ed25519 private key at 0600 into a 0700 directory through
os.MkdirAll and os.WriteFile. On Windows os.Chmod toggles only the read-only attribute, so neither
mode was enforced — the key landed readable by anyone with directory access, and the code carried a
TODO(#405) saying exactly that.

Every write now goes through a root the caller supplies. DefaultSigner takes an fsroot.Root;
internal/cli/store.go opens one, because the CLI is the session owner for CLI-side work and a leaf never
constructs its own filesystem access
— phase 2b's invariant, applied to its first real consumer.

pkg/signing now has zero os.* mutations and no TODO.

It needed almost none of the groundwork it appeared to

No Session type, no internal/cli xdg migration, no internal/ sweep, no curation verdicts. The existing
DevloreConfigHome() anchor was enough. Writable-unconfined rather than confined, because the config tree may
not exist on first run and a confined root requires its anchor to exist.

The suite name left pkg/signing as a side effect: the root arrives anchored at <config>/devlore, so signing
joins signing/ed25519 and no longer knows the string "devlore".

DefaultSigner's first branch still reads ~/.ssh/id_ed25519 directly, now with a // Confinement: comment —
the user's SSH directory is not ours to confine, and a root anchored at our config tree cannot address it.

Proof is a DACL read-back, not a mode assertion

Mode().Perm() reports 0666 on Windows however private an object actually is (ruling 5), so asserting
0600 there would prove nothing. signing_windows_test.go reads the security descriptor off the generated
key and asserts SE_DACL_PROTECTED plus exactly three trustees — owner, SYSTEM, Administrators.

The count is as load-bearing as the flag: a fourth allow ACE would leave the DACL "protected" while granting
someone else read access to a signing key. Trustees are matched in both SDDL renderings — alias and numeric
SID — which is the lesson from phase 2's first CI run failing against a DACL that was already correct.

A third test pins the deliberate asymmetry: the .pub half must not be protected. It is 0644 public
trust material whose purpose is to be copied into someone else's allowed_signers, and ruling 4's boundary
leaves anything granting other on its parent's inherited DACL.

Verification

make test zero failures; make lint-all zero issues on linux, darwin, windows.
test (windows-latest) holds at its known 28 — no existing test asserts the key's mode, so the new ones
are additive.

Assisted-by: Claude

The defect that opened #405. generateLocalKey wrote an Ed25519 private
key at 0600 into a 0700 directory using os.MkdirAll and os.WriteFile.
On Windows os.Chmod toggles only the read-only attribute, so neither
mode was enforced: the key landed readable by anyone with directory
access, and the code carried a TODO saying so.

Every write now goes through a root the caller supplies. DefaultSigner
takes an fsroot.Root; internal/cli/store.go opens one -- the CLI is the
session owner for CLI-side work, and a leaf never constructs its own
filesystem access (phase 2b's invariant). Writable-unconfined, because
the config tree may not exist on first run and a confined root requires
its anchor to exist.

pkg/signing now has ZERO os.* mutations and no TODO.

It needed almost none of the groundwork it appeared to need. No Session
type, no internal/cli xdg migration, no internal/ sweep, no curation --
the existing DevloreConfigHome() anchor was enough. The suite name left
pkg/signing as a side effect: the root arrives anchored at
<config>/devlore, so signing joins "signing/ed25519" and no longer
knows the string "devlore".

DefaultSigner's first branch still reads ~/.ssh/id_ed25519 directly,
now with a // Confinement: comment. The user's SSH directory is not ours
to confine, and a root anchored at our config tree cannot address it.

PROOF IS A DACL READ-BACK, NOT A MODE ASSERTION. Mode().Perm() reports
0666 on Windows however private an object actually is (ruling 5), so
asserting 0600 would prove nothing there. signing_windows_test.go reads
the security descriptor off the generated key and asserts
SE_DACL_PROTECTED plus exactly three trustees -- owner, SYSTEM,
Administrators. The count is as load-bearing as the flag: a fourth allow
ACE would leave the DACL "protected" while granting someone else read
access to a signing key. Trustees are matched in both the SDDL alias and
numeric-SID renderings, the lesson from phase 2's first CI run failing
against a DACL that was already correct.

A third test pins the deliberate asymmetry: the .pub half must NOT be
protected. It is 0644 public trust material whose purpose is to be
copied into someone else's allowed_signers, and ruling 4's boundary
leaves anything granting `other` on its parent's inherited DACL.

The windows baseline does not move -- no existing test asserts the key's
mode, so these are additive.

Verified: make test zero failures; make lint-all zero issues on linux,
darwin and windows.

Assisted-by: Claude
Signed-off-by: David Noble <[email protected]>
@David-Noble-at-work David-Noble-at-work self-assigned this Aug 15, 2026
The signing key's DACL read-back failed on windows-latest, and the
descriptor it printed was the whole story:

  D:AI(A;ID;FA;;;LA)(A;ID;FA;;;SY)(A;ID;FA;;;BA)

AI rather than P, every ACE tagged ID -- purely INHERITED. The key was
private only because its parent directory happened to be protected,
which any later change to that parent would quietly erode.

Phase 2 recorded applyMode as wired into WriteFile, OpenFile, Mkdir,
MkdirAll and Chmod "on both writable implementations". True of every
method except WriteFile on unconfinedRootReaderWriter, which returned
os.WriteFile directly and applied nothing. An audit of all eight
mode-bearing methods across both implementations found exactly this one
gap; Create is unenforced on both BY DESIGN, per phase 1's ruling that
it produces an unrestricted 0666 file that must never carry sensitive
content.

HAD THIS NOT BEEN CAUGHT, PHASE 3 WOULD HAVE COMPLETED AND PROTECTED
NOTHING. The campaign rules CLI-side roots are ModeWritableUnconfined,
so every remaining site -- state home, run index, user config,
self-install manifest, internal/document's writes -- routes through the
one unenforced path. The migration would have looked finished.

Why the tests missed it: every DACL test in applymode_windows_test.go
used testConfinedRoot. The confined implementation was the one already
enforcing, so the tests proved the safe path and never touched the one
production uses outside an execution.
TestApplyMode_BothWritableRootsProtect now runs the same assertions
across both, so the asymmetry cannot return.

The lesson for phases 4 and 5, recorded in the plan: a test that
exercises the safest implementation proves the least.

Verified: make test zero failures; make lint-all zero issues on linux,
darwin and windows.

Assisted-by: Claude
Signed-off-by: David Noble <[email protected]>
@David-Noble-at-work
David-Noble-at-work merged commit ed6f468 into develop Aug 15, 2026
7 of 8 checks passed
@David-Noble-at-work
David-Noble-at-work deleted the signing-key-through-root branch August 15, 2026 05:04
@David-Noble-at-work David-Noble-at-work mentioned this pull request Aug 17, 2026
11 tasks
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