tomb

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: MIT Imports: 21 Imported by: 0

README

pkg/tomb — hide the entire password store at rest

Overview

pkg/tomb implements the "tomb" feature from ARCHITECTURE §7: the entire password store is hidden when not in use — no file names, no directory structure, no metadata visible at rest. Three backends provide different trade-offs:

Backend OS Root Protection level
Coffin All No Age-encrypted tar archive. Plaintext in tmpfs during session.
LUKS Linux Yes dm-crypt container. Plaintext only in kernel mapping.
Sparsebundle macOS No Encrypted APFS sparse bundle.

Coffin is the default everywhere; LUKS is implemented and opt-in. Sparsebundle is a stub returning ErrNotImplemented.

State machine

Uninitialised ──Init──▶ Closed ──Open──▶ Open ──Close──▶ Closed
                                  ▲         │
                                  └─────────┘
                               (auto-close: timer, screen lock, suspend)
  • Init: Creates store.coffin.age from the current store content. Plaintext is not removed — Init is additive. The store continues to work normally until the user closes it.
  • Open: Decrypts the coffin into the store directory. Writes a state file to the sidecar directory ($XDG_DATA_HOME/binpass/). Starts the auto-close watcher. Blocks until SIGINT/SIGTERM or auto-close fires.
  • Close: Re-encrypts the current plaintext into the coffin (atomic tmp+rename), shreds the plaintext directory, removes the state file. Preserves dotfiles (.age-recipients, .gpg-id, .git).
  • Status: Reads the state file and reports whether the tomb is open, closed, or stale (crash without clean shutdown).

Files

File Purpose
tomb.go Package doc, Tomb interface, State struct, Backend enum, SelectBackend, DefaultBackend, sentinel errors
doc.go Extended package-level documentation
coffin.go Coffin backend: Init, Open, Close, Status, packEncrypt, unpackDecrypt, writeTar, readTar, shredDir, overwriteRandom, isWithin
state.go State persistence: loadState, saveState, RemoveState, statePath, stateDir
watcher_linux.go Auto-close watcher: timer + D-Bus (GNOME/KDE screensaver, logind suspend)
watcher_other.go Auto-close watcher: timer only (macOS, Windows)
mlock_linux.go mlockDir: mmap + Mlock for files < 64KB
mlock_other.go mlockDir: no-op
pid_unix.go pidIsDead via syscall.Kill(pid, 0)
pid_windows.go pidIsDead: always returns false (conservative)
luks_linux.go LUKS backend: drives cryptsetup, mkfs and mount; key file encrypted to the store recipients
luks_other.go LUKS stub: returns ErrUnsupported
sparsebundle_darwin.go Sparsebundle stub: checks hdiutil, returns ErrNotImplemented
sparsebundle_other.go Sparsebundle stub: returns ErrUnsupported

Coffin format

store.coffin.age = age.Encrypt(tar(store_dir/))

The tar archive contains all non-dotfile, non-coffin entries from the store directory. Directory permissions are preserved. Symlinks are stored as tar symlink headers. Dotfiles (.age-recipients, .gpg-id, .git/) are not archived — they persist on disk between close/open cycles.

On Open:

  1. age.Decrypt with the user's age identities → tar stream
  2. readTar extracts entries into the store directory
  3. isWithin guard rejects entries that escape the store root
  4. O_EXCL prevents overwriting existing files

On Close:

  1. writeTar packs non-dotfile, non-coffin entries from the store directory
  2. age.Encrypt with recipients from .age-recipients or .gpg-id
  3. Atomic write: tmp file → fsync → rename → replaces old coffin
  4. shredDir overwrites plaintext files with random data, then removes them
  5. State file removed

State file

Location: $XDG_DATA_HOME/binpass/<basename>-tomb.state (or $BINPASS_DATA_DIR).

The state file is stored outside the store directory so that git and cloud sync never pick it up. It is keyed by filepath.Base(abs) of the store directory's absolute path.

{
  "backend": "coffin",
  "store_dir": "/home/user/.password-store",
  "coffin_path": "/home/user/.password-store/store.coffin.age",
  "opened_at": "2026-08-09T14:30:00Z",
  "timer": 3600000000000,
  "pid": 12345
}
Stale state detection

If the process that opened the tomb crashes without closing, the state file remains. The next Open call detects this:

  1. loadState reads the state file
  2. IsStale() checks whether the PID is still alive via syscall.Kill(pid, 0)
  3. ESRCH → process dead → stale → remove state file, proceed with open
  4. EPERM → process alive (different UID) → not stale → ErrAlreadyOpen
  5. nil → process alive (same UID) → not stale → ErrAlreadyOpen

On Windows, pidIsDead always returns false (conservative: assumes alive). Crash recovery on Windows requires binpass doctor.

Auto-close watcher

Linux
┌─────────────┐
│   Watcher   │
├─────────────┤
│ timerLoop   │─── time.After(timer) ───▶ triggerClose()
│             │
│ sessionBus  │─── ActiveChanged(true) ─▶ triggerClose()
│  (D-Bus)    │    GNOME ScreenSaver
│             │    freedesktop ScreenSaver
│             │
│ systemBus   │─── PrepareForSleep(true) ▶ triggerClose()
│  (D-Bus)    │    logind
└─────────────┘

D-Bus connections are established in background goroutines. If the session bus is unavailable (headless, container), the watcher degrades to timer-only with no error. DoctorCheck() reports which D-Bus services are available.

triggerClose() is guarded by a mutex and fires exactly once. After the close callback returns, the done channel is closed, which unblocks Stop().

macOS / Windows

Timer only. Screen lock detection (IOKit, WinAPI) is planned.

CLI commands

binpass tomb init   [--type=coffin|luks|sparsebundle] [--size=1G] [--recipient=age1...]
binpass tomb open   [--timer=1h]
binpass tomb close  [--force]
binpass tomb status
Flag Command Description
--type init Backend type. Default: coffin
--size init Container size for LUKS/sparsebundle (e.g. 1G, 512M)
--recipient, -r init Age recipient string. Falls back to store's .age-recipients
--timer open Auto-close duration (e.g. 30m, 1h). Empty = disabled
--force close Close even if the store appears unchanged (reserved, not yet wired)

The tomb open command blocks the terminal until SIGINT/SIGTERM or the auto-close fires. This keeps the process alive so the watcher goroutines can run. On signal, watcher.Stop() is called to clean up D-Bus connections.

Security properties

At rest
  • Only store.coffin.age and dotfiles (.age-recipients, .gpg-id, .git/) are visible in the store directory.
  • No file names, no directory structure, no metadata leaks from the archive.
  • Age provides authenticated encryption; tampered ciphertexts fail decryption.
During session (open)
  • Plaintext files exist on disk (tmpfs on Linux, 0700 directory elsewhere).
  • mlockDir (Linux) prevents small files (< 64KB) from being swapped.
  • Auto-close (timer + screen lock + suspend) limits the exposure window.
  • State file (sidecar) records PID and timestamp for crash detection.
On close
  • Plaintext files are overwritten with crypto/rand data before removal.
  • Coffin is written atomically (tmp → fsync → rename) — crash during close leaves the old coffin intact.
  • Dotfiles survive close by design: .age-recipients and .gpg-id are needed for the next open, .git/ for sync.
Known limitations
  • SSD shred: overwriteRandom writes random bytes over each file, but SSD wear-leveling may remap the physical block. No guarantee of physical destruction. The CLI prints a warning on close.
  • Plaintext exposure window: the coffin backend cannot avoid having plaintext on disk during the session. Use LUKS for stronger guarantees: dm-crypt keeps the plaintext in the kernel mapping only.
  • No filesystem lock: concurrent Open calls from two processes can race. O_EXCL provides partial protection (second unpack fails on existing files), but the state file may end up inconsistent. Single-user local tool assumption makes this unlikely.
  • State file collision: filepath.Base(abs) means two stores sharing a directory name (e.g. ~/a/store and ~/b/store) collide on the state file. Future: hash the full absolute path.
  • Recipient trust: readRecipients reads .age-recipients from the store directory on every Close. If the file is modified between Open and Close, the coffin is re-encrypted for the new recipients. In the single-user threat model, anyone who can modify .age-recipients already has access to the plaintext.

Dependencies

  • filippo.io/age — age encryption/decryption
  • github.com/godbus/dbus/v5 — D-Bus auto-close (Linux only, pure Go, no CGO)
  • archive/tar — tar archive format (stdlib)
  • crypto/rand — random data for shred (stdlib)

Tests

File Scope Coverage
tomb_test.go Backend selection, lifecycle, state, shredDir, hasPlaintext Unit
coffin_test.go Tar round-trip, path traversal, encrypt/decrypt, overwrite, watcher, crash recovery Integration
e2e_test.go Full lifecycle, content survival, crash recovery, timer, shred, nested dirs, permissions E2E

Key test scenarios:

  • Full lifecycle: init → open → modify → close → open → verify
  • Content modifications survive close/open cycles
  • Deleted entries disappear after close/open
  • Double open rejected with ErrAlreadyOpen
  • Close without open rejected with ErrNotOpen
  • Crash recovery: state file left behind → next Open detects stale PID → cleanup → proceed
  • No plaintext remains after close (directory listing verified)
  • Timer auto-close fires and closes the tomb
  • Shred overwrites file content (read back after overwrite, verify not original)
  • Empty store works
  • Nested directory structure preserved
  • File permissions preserved across close/open
  • .age-recipients must exist for Close to find recipients
  • SelectBackend rejects unknown backend names
  • DefaultBackend returns coffin

Platform build matrix

File linux darwin windows
tomb.go
coffin.go
state.go
watcher_linux.go
watcher_other.go
mlock_linux.go
mlock_other.go
pid_unix.go
pid_windows.go
luks_linux.go
luks_other.go
sparsebundle_darwin.go
sparsebundle_other.go

All builds use CGO_ENABLED=0. The godbus/dbus/v5 dependency is pure Go and compiles on all platforms; the watcher_linux.go file uses build tags to restrict D-Bus code to Linux.

Documentation

Overview

Package tomb hides the entire password store when it is not in use: no file names, no directory structure, no metadata visible at rest. Three backends implement different trade-offs between security and portability.

Backends

Coffin (the default) packs the store into a single age-encrypted tar archive (store.coffin.age). Open decrypts to a private directory (tmpfs on Linux, 0700 + shred elsewhere). Close re-encrypts and shreds the plaintext. Works everywhere, no root required.

LUKS (Linux only) uses a dm-crypt container managed via the external cryptsetup binary. Plaintext exists only in the kernel dm-crypt mapping, never on disk. Requires root or polkit. Not yet implemented.

Sparsebundle (macOS only) uses an encrypted APFS sparse bundle via hdiutil. Not yet implemented.

Lifecycle

A tomb follows a strict state machine:

Uninitialised ──Init──▶ Closed ──Open──▶ Open ──Close──▶ Closed
                                      ▲         │
                                      └─────────┘
                                   (auto-close: timer, screen lock, suspend)

Init creates the encrypted container from the existing store directory. The plaintext is left in place; the user should close or remove it manually.

Open decrypts the container and writes state to a sidecar directory ($XDG_DATA_HOME/binpass/<name>-tomb.state). The state file records the PID, backend, timer, and timestamp so that crash recovery can detect stale state.

Close re-encrypts the current plaintext into the container (atomic tmp+rename), shreds the plaintext directory, and removes the state file.

Auto-close

The Watcher monitors OS events and fires a callback when the tomb should auto-close:

  • Timer: close after a configurable duration.
  • Screen lock (Linux): D-Bus signals from GNOME/KDE screensaver.
  • Suspend (Linux): D-Bus PrepareForSleep from logind.

On non-Linux, only the timer is available. The watcher is started by the CLI's "tomb open" command and runs in background goroutines.

State file

The state file is stored outside the store directory so that sync (git, cloud) never picks it up. It lives in $XDG_DATA_HOME/binpass/ (or $BINPASS_DATA_DIR) as <basename>-tomb.state, keyed by the store directory's base name. The state includes:

  • Backend type (coffin, luks, sparsebundle)
  • Store directory absolute path
  • Process PID (for stale-state detection)
  • OpenedAt timestamp
  • Timer duration

If the opening process has crashed, IsStale() returns true (by checking whether the PID still exists via syscall.Kill). The next Open call detects this and cleans up the stale state.

Security considerations

Plaintext exposure: the coffin backend writes decrypted files to a directory on disk (tmpfs on Linux, 0700 directory elsewhere). The plaintext is exposed for the duration of the session. Auto-close mitigates but does not eliminate this. The LUKS backend (when implemented) will provide stronger guarantees.

Shred limitations: overwriteRandom writes crypto/rand bytes over each file before removal. On SSDs with wear-leveling, this does not guarantee that the original data is physically overwritten. On tmpfs (Linux), the data lives only in RAM and is destroyed on unmount or reboot.

mlock: on Linux, mlockDir attempts to mmap and mlock files under 64KB in the store directory. This prevents them from being swapped to disk. Failures are silently ignored (mlock is advisory hardening).

Archive integrity: the coffin archive is age-encrypted. Age provides authenticated encryption; tampering with the ciphertext causes decryption to fail. However, a replacement coffin encrypted under the same recipient will decrypt successfully. Trust in the coffin's contents depends on the integrity of the storage medium (local disk, git, cloud sync).

Dotfile preservation: Close preserves dotfiles (.age-recipients, .gpg-id, .git) so that the next Open can find recipients and git can work. This means an attacker with write access to the store directory could plant a dotfile that survives close. In the single-user local threat model, anyone with write access to the directory already has access to the plaintext during the open session.

Package tomb hides the entire password store when it is not in use: no file names, no directory structure, no metadata visible at rest. Three backends implement different trade-offs between security and portability.

Coffin (the default) packs the store into a single age-encrypted archive that works everywhere. LUKS (Linux) uses a dm-crypt container for stronger guarantees. Sparsebundle (macOS) uses an encrypted APFS image.

The auto-close watcher (timer, screen lock, suspend) is critical: an open tomb exposes plaintext, and the value of the feature depends on that window being as short as practical.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNotInitialised reports that no tomb exists for this store.
	ErrNotInitialised = errors.New("tomb: not initialised")
	// ErrAlreadyOpen reports that the tomb is already open.
	ErrAlreadyOpen = errors.New("tomb: already open")
	// ErrNotOpen reports that the tomb is not open.
	ErrNotOpen = errors.New("tomb: not open")
	// ErrUnsupported reports that the backend is not available on this OS.
	ErrUnsupported = errors.New("tomb: backend not supported on this OS")
	// ErrNotImplemented reports that the backend is defined but not yet built.
	ErrNotImplemented = errors.New("tomb: backend not yet implemented")
	// ErrNeedRoot reports that the backend requires elevated privileges.
	ErrNeedRoot = errors.New("tomb: this backend requires root or polkit")
	// ErrShredWarning is appended to close output to warn about SSD limits.
	ErrShredWarning = errors.New("shred on SSD with wear-leveling does not guarantee data destruction")
)

Sentinel errors.

Functions

func DoctorCheck

func DoctorCheck() string

DoctorCheck inspects the D-Bus session for screen lock capability. Returns a human-readable status for binpass doctor.

func FormatWatcherStatus

func FormatWatcherStatus(timer time.Duration) string

FormatWatcherStatus returns a human-readable description of the watcher state for binpass tomb status.

func HasContainer

func HasContainer(dir string) bool

HasContainer reports whether a store has a tomb container at all.

This is what distinguishes "the tomb is open" from "there is no tomb": the state file is absent in both cases, and a close that cannot tell them apart reports success without having protected anything.

func RemoveState

func RemoveState(storeDir string) error

RemoveState deletes the state file for a store. Called on successful close.

Types

type Backend

type Backend string

Backend names the container type.

const (
	// BackendCoffin is the cross-platform default: a single age-encrypted tar
	// archive. Works without root, works on every OS.
	BackendCoffin Backend = "coffin"
	// BackendLUKS is a dm-crypt container managed via cryptsetup. Linux only,
	// requires root or polkit. Maximum protection: plaintext exists only in
	// the kernel dm-crypt mapping.
	BackendLUKS Backend = "luks"
	// BackendSparseBundle is an encrypted APFS sparse bundle via hdiutil.
	// macOS only.
	BackendSparseBundle Backend = "sparsebundle"
)

Supported backends.

func DefaultBackend

func DefaultBackend() Backend

DefaultBackend returns the best backend for the current OS.

func DetectBackend

func DetectBackend(dir string) (Backend, bool)

DetectBackend reports which backend a store's container belongs to.

The container on disk is the authority, not the state file: `tomb init` leaves no state behind, and a clean close removes it, so after either one the file is the only evidence of which backend was chosen.

type Coffin

type Coffin struct {
	// contains filtered or unexported fields
}

Coffin is the cross-platform tomb backend: the entire store is packed into a single age-encrypted tar archive. Open decrypts to a private directory (tmpfs on Linux, 0700 + shred elsewhere). Close re-encrypts and shreds the plaintext.

Security properties:

  • At rest: only the encrypted archive is visible. No file names, no directory structure, no metadata leaks.
  • Open: plaintext exists on disk (tmpfs on Linux, RAM-backed), protected by directory permissions and auto-close.
  • Close: plaintext is shredded (best-effort on SSD), then removed.

Limitations:

  • Plaintext is exposed for the duration of the session. Auto-close mitigates but does not eliminate this.
  • shred on SSD with wear-leveling does not guarantee data destruction.

func (*Coffin) Close

func (c *Coffin) Close(dir string, force bool) error

Close re-encrypts the plaintext store into the coffin, then shreds the plaintext directory. If force is true, skip the overwrite-random pass and just remove files (faster, but no shred guarantee — appropriate during shutdown or when the operator accepts the SSD caveat explicitly).

func (*Coffin) Init

func (c *Coffin) Init(dir string, rcp []string, _ int64) error

Init creates a coffin archive from the store directory. If the store has content, it is packed and encrypted. The recipients must be valid age recipient strings (age1..., ssh-..., or plugin recipients).

func (*Coffin) Name

func (c *Coffin) Name() Backend

Name returns BackendCoffin.

func (*Coffin) Open

func (c *Coffin) Open(dir string, timer time.Duration) error

Open decrypts the coffin archive into a private directory. On Linux, the target is a tmpfs mount (/dev/shm); elsewhere, it is a 0700 directory that will be shredded on close.

func (*Coffin) SetIdentities

func (c *Coffin) SetIdentities(fn crypto.IdentityFunc)

SetIdentities configures the identity resolver for decryption. Call this before Open if the default resolver is not wired in.

func (*Coffin) Status

func (c *Coffin) Status(dir string) (State, bool, error)

Status reports whether the coffin is currently open.

type IdentityAware

type IdentityAware interface {
	// SetIdentities configures the resolver used for decryption.
	SetIdentities(fn crypto.IdentityFunc)
}

IdentityAware is implemented by backends that decrypt something with the store's age identities: the coffin archive, or the LUKS container key.

Callers wire identities through this rather than by type-asserting each backend, so that a backend added later cannot quietly end up without them.

type LUKS

type LUKS struct {
	// contains filtered or unexported fields
}

LUKS manages a dm-crypt container via the external cryptsetup binary. The plaintext exists only in the kernel's dm-crypt mapping and never touches disk, which is the one thing the coffin backend cannot offer.

The container's key file is itself encrypted to the store's age recipients. That is what makes hardware tokens work here without any LUKS-specific support: unlocking the tomb is an ordinary age decryption, and whether the identity lives in a file or on a YubiKey is not this code's concern.

Requires root, or a polkit policy granting the user access to cryptsetup, losetup and mount.

func (*LUKS) Close

func (l *LUKS) Close(dir string, _ bool) error

Close unmounts the container and locks it again.

func (*LUKS) Init

func (l *LUKS) Init(dir string, rcp []string, size int64) error

Init creates a LUKS container and an age-encrypted key file.

The store's existing entries are moved into the container, so that after Init the plaintext lives inside it rather than beside it.

func (*LUKS) Name

func (l *LUKS) Name() Backend

Name returns BackendLUKS.

func (*LUKS) Open

func (l *LUKS) Open(dir string, timer time.Duration) error

Open decrypts the key file, unlocks the container, and mounts it at dir.

func (*LUKS) SetIdentities

func (l *LUKS) SetIdentities(fn crypto.IdentityFunc)

SetIdentities configures the identity resolver used to decrypt the key file. Call this before Open.

func (*LUKS) Status

func (l *LUKS) Status(dir string) (State, bool, error)

Status reports the current state.

type SparseBundle

type SparseBundle struct{}

SparseBundle is not available on non-macOS platforms.

func (SparseBundle) Close

func (SparseBundle) Close(string, bool) error

Close is not available.

func (SparseBundle) Init

func (SparseBundle) Init(string, []string, int64) error

Init is not available.

func (SparseBundle) Name

func (SparseBundle) Name() Backend

Name returns BackendSparseBundle.

func (SparseBundle) Open

Open is not available.

func (SparseBundle) Status

func (SparseBundle) Status(string) (State, bool, error)

Status is not available.

type State

type State struct {
	// Backend is the container type in use.
	Backend Backend `json:"backend"`
	// StoreDir is the absolute path where the plaintext store is mounted or
	// extracted.
	StoreDir string `json:"store_dir"`
	// CoffinPath is the path to the encrypted archive (coffin backend only).
	CoffinPath string `json:"coffin_path,omitempty"`
	// MountPoint is the dm-crypt or sparse bundle mount (LUKS/sparsebundle).
	MountPoint string `json:"mount_point,omitempty"`
	// MapperName is the dm-crypt mapper name (LUKS only).
	MapperName string `json:"mapper_name,omitempty"`
	// OpenedAt is when the tomb was opened.
	OpenedAt time.Time `json:"opened_at"`
	// Timer is the auto-close duration; zero means no timer.
	Timer time.Duration `json:"timer,omitempty"`
	// PID is the process that opened the tomb, for stale-state detection.
	PID int `json:"pid"`
}

State records the runtime state of an open tomb. It is persisted to the sidecar directory so that binpass doctor can detect an open container after a crash.

func LoadState

func LoadState(storeDir string) (*State, error)

LoadState reads the recorded state for a store, if any.

The CLI needs this to choose a backend before it has one: which backend to ask depends on what was opened, and that is only written down here.

func (*State) IsStale

func (s *State) IsStale() bool

IsStale reports whether the opening process is no longer running, indicating a crash without clean shutdown.

type Tomb

type Tomb interface {
	// Init creates an encrypted container for the store at dir. If the store
	// already has content, it is packed into the container.
	Init(dir string, rcp []string, size int64) error
	// Open decrypts the container and makes the plaintext available at dir.
	// If timer > 0, the tomb auto-closes after that duration.
	Open(dir string, timer time.Duration) error
	// Close re-encrypts the plaintext back into the container and removes the
	// plaintext. If force is true, skip the dirty check.
	Close(dir string, force bool) error
	// Status reports the current state of the tomb for this store.
	Status(dir string) (State, bool, error)
	// Name returns the backend name for diagnostics.
	Name() Backend
}

Tomb is the interface each backend implements. Callers should not construct a backend directly; use Open to select the right one.

func SelectBackend

func SelectBackend(want Backend) (Tomb, error)

SelectBackend picks the best available backend for the current OS. The caller can override with an explicit choice.

type Watcher

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher monitors OS events (screen lock, suspend) and fires a callback when the tomb should be auto-closed. It also manages a timer-based auto-close.

On Linux, it listens to:

  • org.freedesktop.login1: PrepareForSleep (suspend/resume)
  • org.freedesktop.ScreenSaver: ActiveChanged (screen lock/unlock, KDE)
  • org.gnome.ScreenSaver: ActiveChanged (GNOME)

The D-Bus connection is session-bus for screensaver signals and system-bus for login1. If D-Bus is unavailable (headless, container), only the timer is active.

func NewWatcher

func NewWatcher(dir string, timer time.Duration, onClose func(string) error) *Watcher

NewWatcher starts watching for auto-close triggers. It returns immediately; the actual monitoring runs in background goroutines.

func (*Watcher) Stop

func (w *Watcher) Stop()

Stop terminates the watcher. It is safe to call after the tomb has been closed (the callback will not fire after Stop returns).

Jump to

Keyboard shortcuts

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