secfile

package
v0.213.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2026 License: MIT Imports: 4 Imported by: 0

Documentation

Overview

Package secfile creates files and directories that only their owner can read.

Why this package exists

Every credential this binary keeps on disk was written with os.WriteFile or os.OpenFile at 0o600, and on Linux and darwin that is the whole control: the kernel applies the mode at create time and os.Stat reads it back.

On Windows the same line does nothing of the kind. Go's os package ignores the permission argument there except for the read-only bit, so a file created with 0o600 simply inherits the containing directory's DACL, and os.Stat reports 0666 however it was opened. Under a user profile the inherited ACL is tolerable (the user, SYSTEM, Administrators). Under %ProgramData%, where a machine-wide credential lives, the default ACL grants BUILTIN\Users read access: a machine-wide provisioned API key sat there readable by every interactive account on the box.

So the property "only the owner can read this" needs a different mechanism on each platform, and the whole point of this package is that its CALLERS never have to know which. They ask for a private file; the platform half underneath picks mode bits or an explicit DACL.

What "private" means here

The owner, LocalSystem and the local Administrators group, and nobody else. SYSTEM is on the list because a machine-wide service runs as it, and Administrators because an operator collecting a diagnostic bundle is not an attacker. On unix those same three collapse to "the owner, plus root, which can read anything anyway", which is exactly what 0o600 already says.

On Windows the DACL is set PROTECTED, which is the load-bearing detail: a protected DACL DROPS the ACEs the object would have inherited from its parent rather than merging them, and the inherited BUILTIN\Users read is precisely the exposure. Setting a tight-looking DACL without the protect flag would leave the inherited grant in place and still read as a fix.

Prove it, do not assume it

Harden sets the protection; Verify READS IT BACK and judges what is actually on the object. They are separate on purpose. A check that re-applies the fix before measuring cannot fail when the fix is missing, so Verify never writes, which is what lets the test suite call the same function production does without the assertion becoming circular.

What this does NOT close

On Windows the object has to exist before SetNamedSecurityInfo can name it, so between the create and the DACL there is an instant in which the file carries its inherited ACL. WriteFile and OpenFile shrink that window to an EMPTY file: the secret is written afterwards, through a handle on an object that is already private. What remains is a local attacker who is already racing this exact path and who opens the empty file in that instant, keeping a handle whose granted access survives the DACL change.

Closing it completely means creating the file with a SECURITY_ATTRIBUTES carrying the DACL, which is a per-platform open path rather than the os.OpenFile every caller here uses. That is worth doing and is not what this package set out to do; the thing to do meanwhile is to say what the window is rather than to imply there is none.

Index

Constants

View Source
const (
	FileMode os.FileMode = 0o600
	DirMode  os.FileMode = 0o700
)

FileMode and DirMode are the unix modes this package writes. They are also the permission argument handed to the os calls on Windows, where they decide only the read-only attribute and the DACL does the real work.

View Source
const ExposureMask uint32 = 0x80000000 |
	0x40000000 |
	0x20000000 |
	0x10000000 |
	0x00000001 |
	0x00000002 |
	0x00000004 |
	0x00000008 |
	0x00000010 |
	0x00000100 |
	0x00010000 |
	0x00040000 |

	0x00080000 //   WRITE_OWNER

ExposureMask is the set of access rights that count as "can read or alter" for a private file. An ALLOW ace granting any of these to a principal that is not the owner, SYSTEM or Administrators means the file is exposed.

It deliberately covers the WRITE rights as well as the read ones. A principal holding WRITE_DAC or WRITE_OWNER can grant itself the read at any moment, so on a file whose risk is disclosure those two ARE disclosure, one call later.

Variables

View Source
var ErrExposed = errors.New("a principal other than the owner can read or alter it")

ErrExposed is returned by Verify and VerifyDir when the object on disk is reachable by someone other than its owner. Callers match it with errors.Is to tell "this host cannot keep a credential private" apart from an ordinary filesystem failure.

Functions

func Harden

func Harden(path string) error

Harden makes an existing FILE owner-only.

A chmod, and the reason it is a separate call from the write is the same on both platforms: umask can only take bits away, but a file that already existed carries whatever mode it was left with, and an earlier version of this binary (or an operator's cp) may have left it 0644.

The two guards are not ceremony. Chmod follows the path to whatever is at the end of it, so a Harden aimed at a directory would strip its execute bit and make it untraversable, and a Harden aimed at a fifo or a device node would re-permission something that is not ours. Both are refused or skipped here rather than discovered afterwards by Verify, which can only report damage already done.

func HardenDir

func HardenDir(path string) error

HardenDir makes an existing DIRECTORY owner-only.

func MkdirAll

func MkdirAll(path string) error

MkdirAll creates path and any missing parents, and makes the leaf private when, and only when, this call is what created it.

The "only when we created it" rule is not timidity, it is the difference between a fix and an outage. A caller's watch dirs come from a config file, config is not trusted to name safe paths, and the existing code says as much: "MkdirAll on an existing / or /etc is a harmless no-op". It stops being harmless the moment the call also re-permissions what it found, and a whisper-cli that chmod 0700's /etc because someone put it in a dirs list is a far worse bug than the one this package fixes.

Nothing is lost by it. The FILE is what holds the credential, and WriteFile and OpenFile harden the file itself unconditionally with a DACL that is PROTECTED, so a file is private whatever its directory grants. The directory is defense in depth: it keeps a newly created credential directory unlistable and, on Windows, gives what lands inside an owner-only starting point.

Only the leaf is hardened, never a parent. A credential directory under %ProgramData% or under ~/.config has parents whose policy is not ours.

func MkdirAllFor

func MkdirAllFor(path string) error

MkdirAllFor is MkdirAll for the directory that will hold path, which is the shape almost every caller wants: make the parent private, then write the file into it.

func OpenFile

func OpenFile(path string, flag int) (*os.File, error)

OpenFile opens path with flag as a private file and returns the open handle.

The permission argument is not a parameter because there is only one answer for a file that holds a credential. Callers needing O_EXCL or O_APPEND pass them in flag, and O_CREATE is implied by neither, so pass it when the file may not exist yet.

The file is hardened before the caller can write a byte into it, which is the narrowest window this can be given without a Windows-specific create path: on Windows the object has to exist before SetNamedSecurityInfo can name it, so there is an instant between the create and the DACL landing. Nothing secret is in the file during that instant, because the write has not happened yet.

func Secure

func Secure(path string) error

Secure hardens an EXISTING file and then proves the hardening held.

Use it for a file some other code opened: an append-only file another component already holds a handle on, a service log written through its own path. An empty path is not a file and reports nil, because several callers use "" to mean stdout.

It fails closed. When the hardening itself errors the read-back still gets the last word, because the file may already carry a DACL or a mode that satisfies the guarantee: an operator-locked directory, or a re-open of a file an earlier run already secured. Only when BOTH refuse is this an error.

func SecureDir

func SecureDir(path string) error

SecureDir is Secure for a directory.

func Verify

func Verify(path string) error

Verify reports whether the file at path is owner-only, reading the inode rather than trusting that a write happened. It never modifies anything.

The property asserted is "no group or other bits", not "exactly 0600": 0400 and 0600 are both owner-only and both fine, while 0640 and 0644 are the regression this catches.

func VerifyDir

func VerifyDir(path string) error

VerifyDir is Verify for a directory.

func WriteFile

func WriteFile(path string, data []byte) error

WriteFile writes data to path as a private file, creating or truncating it.

It is the drop-in replacement for os.WriteFile(path, data, 0o600) at every site that stores a credential, and it goes through OpenFile rather than os.WriteFile for one reason: the hardening has to happen BEFORE the secret bytes land. Writing first and tightening afterwards leaves a real, if brief, window in which a live API key sits on disk under the directory's inherited ACL, which is the exact exposure this package exists to end. Truncating an existing file before hardening is safe in the same way: the old content is already gone when the DACL lands.

Types

type ACEJudgment

type ACEJudgment uint8

ACEJudgment is the verdict on one DACL ace, reached from the ace TYPE and MASK alone. Resolving the principal an ace names needs Windows; deciding whether the principal even matters does not, and that split is what keeps the table testable on every platform.

const (
	// ACEHarmless: this ace cannot widen access, whoever it names. A deny, or
	// an allow whose mask grants nothing this gate cares about.
	ACEHarmless ACEJudgment = iota
	// ACENeedsPrincipal: this ace grants exposing rights, so the verdict now
	// turns on WHO it names, which only the Windows binding can answer.
	ACENeedsPrincipal
	// ACEUnjudgeable: an ace shape this walk cannot parse. Terminal, and it
	// fails toward "exposed" on purpose.
	ACEUnjudgeable
)

func JudgeACE

func JudgeACE(aceType byte, mask uint32) ACEJudgment

JudgeACE is the decision table behind the DACL read-back. Its three fail directions each have a reason:

  • a DENY ace can only ever tighten access, so it is harmless to trust;
  • a plain ALLOW ace is judged by its mask and then by the principal it names, which only the Windows binding can resolve;
  • any OTHER ace type, a callback (conditional) allow, an object allow, or whatever winnt.h grows next, can still GRANT read while keeping its SID at a type-specific offset this walk does not know. It is unjudgeable and the gate fails toward "exposed". Without that last row a single conditional-allow ace would slip a grant-Everyone-read DACL past as private.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL