Documentation
¶
Overview ¶
Package gpgsmith is the gpgsmith kernel: orchestration, sessions, and the types frontends (CLI, web UI, TUI) build on. It sits above the primitive packages (pkg/gpg, pkg/vault, pkg/audit) and below the frontend packages (pkg/cli/gpgsmith, pkg/webui/gpgsmith, pkg/tui/gpgsmith).
Index ¶
- Constants
- func DeleteEphemeralFiles(statePath, infoPath string) error
- func HardenProcess() error
- func IsMasterKeyMismatch(err error) bool
- func ParseSessionFilename(name string) (canonicalBase, hostname string, ok bool)
- func SessionFilenamesFor(canonicalBase, hostname string) (statePath, infoPath string)
- func WriteEphemeralInfo(path string, info *EphemeralInfo) error
- type Ephemeral
- type EphemeralInfo
- type EphemeralStatus
- type LockSource
- type MasterKeyMismatchError
- type OpenSessionResult
- type Session
- func (s *Session) AutoSealAndDrop(ctx context.Context) error
- func (s *Session) Discard(ctx context.Context) error
- func (s *Session) Generation() uint64
- func (s *Session) IsClosed() bool
- func (s *Session) MarkChanged()
- func (s *Session) Seal(ctx context.Context, message string) (*vault.Snapshot, error)
- type SessionOpts
Constants ¶
const ( // HeartbeatInterval is how often a running daemon refreshes the .info // liveness timestamp on disk. HeartbeatInterval = 30 * time.Second // StaleHeartbeatThreshold is how long ago the last heartbeat must be // before another process treats the session as stale (presumed crashed // or otherwise dead). Generously larger than HeartbeatInterval to // tolerate sync delay and load spikes. StaleHeartbeatThreshold = 90 * time.Second // EphemeralStatusActive marks a session whose daemon is presumed to be // running and heartbeating. EphemeralStatusActive EphemeralStatus = "active" // EphemeralStatusIdleSealed marks a session that was auto-sealed by the // idle timer; the encrypted state file is on disk and the in-memory // daemon state has been dropped. EphemeralStatusIdleSealed EphemeralStatus = "idle-sealed" )
const ( // DefaultHeartbeatInterval is the production tick rate for the .info // liveness sidecar. Tests pass a smaller value via SessionOpts. DefaultHeartbeatInterval = HeartbeatInterval )
Variables ¶
This section is empty.
Functions ¶
func DeleteEphemeralFiles ¶ added in v0.4.0
DeleteEphemeralFiles removes the .session-<host> and .info file pair. Either or both may be absent; missing files are not errors.
func HardenProcess ¶ added in v0.4.0
func HardenProcess() error
HardenProcess applies process-level defenses against same-user attackers trying to read the daemon's heap or files via debugger or /proc.
On Linux:
- PR_SET_DUMPABLE = 0 blocks ptrace, process_vm_readv, /proc/<pid>/{mem, maps,root,cwd} from non-root processes (the kernel re-owns these to root once dumpable=0)
- RLIMIT_CORE = 0,0 prevents core dumps from leaking heap on crash
On macOS:
- PT_DENY_ATTACH blocks ptrace attach (the macOS analog of PR_SET_DUMPABLE for debugger denial)
- RLIMIT_CORE = 0,0 prevents core dumps
HardenProcess is intended to be called once at daemon startup, before any secret material is touched. It is safe to call multiple times. It returns an error only if the syscalls fail in a way that suggests the host kernel is broken; routine "operation not permitted" returns are downgraded to a silent no-op so test environments and unusual configurations don't break the daemon.
HardenProcess is NOT called automatically by Session or any other kernel API. The daemon binary's main() is responsible for calling it explicitly, because:
- Hardening is a process-wide flag — it would affect tests that import the kernel package if it ran in package init.
- Setting PR_SET_DUMPABLE=0 makes a process non-attachable by gdb, which is friction during development. The daemon binary opts in; tests and non-daemon callers do not.
- The daemon may want to set other rlimits or prctls beyond the defaults; surfacing this as an explicit call lets it compose with future tightening.
To opt out (for debugging) the daemon main() can simply not call this. We do not provide an environment-variable opt-out because the call site is in the daemon's own main() — adding env logic there is the user of this function's choice, not ours.
func IsMasterKeyMismatch ¶ added in v0.4.0
IsMasterKeyMismatch reports whether err is a MasterKeyMismatchError.
func ParseSessionFilename ¶ added in v0.4.0
ParseSessionFilename extracts the canonical base and hostname from a .session-<hostname> filename. Returns ok=false if the name does not match the expected pattern. Accepts both bare names and full paths; the directory portion is ignored.
func SessionFilenamesFor ¶ added in v0.4.0
SessionFilenamesFor returns the (state, info) filenames for a session held by the given hostname against the given canonical snapshot base filename. hostname must be the bare host name (no dots stripped, no munging beyond what os.Hostname returns).
func WriteEphemeralInfo ¶ added in v0.4.0
func WriteEphemeralInfo(path string, info *EphemeralInfo) error
WriteEphemeralInfo serializes info to YAML and writes it atomically to path. The write goes to a temp file followed by rename so observers never see a half-written file.
Types ¶
type Ephemeral ¶ added in v0.4.0
type Ephemeral struct {
// VaultDir is the absolute path of the vault directory holding the
// session files.
VaultDir string
// CanonicalBase is the filename of the canonical snapshot this
// session was opened from (e.g. "20260410T143012Z_setup.tar.age").
CanonicalBase string
// SessionPath is the full path to the encrypted state file
// (<vaultdir>/<canonical>.session-<host>). May be empty if the
// session was opened but never wrote any state to disk yet
// (the .info exists alone).
SessionPath string
// InfoPath is the full path to the .info sidecar.
InfoPath string
// Info is the parsed .info contents.
Info EphemeralInfo
}
Ephemeral describes an in-progress session file pair (.session-host and its .info sidecar) discovered in a vault directory. It does NOT include any decrypted state — only the on-disk filenames and the plaintext .info contents.
func FindEphemeralFor ¶ added in v0.4.0
FindEphemeralFor returns the Ephemeral matching the given hostname in vaultDir, or nil if none exists.
func ListEphemerals ¶ added in v0.4.0
ListEphemerals scans vaultDir for all .session-<hostname>.info files and returns the parsed Ephemeral records. Files whose .info cannot be parsed are silently skipped (they're treated as junk; the caller can decide what to do with them). The returned slice is sorted by hostname for stable output.
func (*Ephemeral) IsDivergent ¶ added in v0.4.0
IsDivergent reports whether the canonical the ephemeral was based on is older than the latest canonical present in the same vault directory. Returns true when newer canonicals exist (the user has changes from elsewhere that the ephemeral does not include).
canonicalNames is the list of canonical snapshot filenames currently in the vault dir, in arbitrary order. This is supplied by the caller (rather than read here) so the function is testable in isolation.
type EphemeralInfo ¶ added in v0.4.0
type EphemeralInfo struct {
Hostname string `yaml:"hostname"`
Source LockSource `yaml:"source"`
StartedAt time.Time `yaml:"started_at"`
LastHeartbeat time.Time `yaml:"last_heartbeat"`
Generation uint64 `yaml:"generation"`
Status EphemeralStatus `yaml:"status"`
}
EphemeralInfo is the plaintext content of a .session-<host>.info file. It is updated on every heartbeat tick and read by other processes (other gpgsmith invocations on the same or different hosts) without needing the vault passphrase.
func ReadEphemeralInfo ¶ added in v0.4.0
func ReadEphemeralInfo(path string) (*EphemeralInfo, error)
ReadEphemeralInfo reads and parses a .info sidecar file.
func (*EphemeralInfo) IsIdleSealed ¶ added in v0.4.0
func (e *EphemeralInfo) IsIdleSealed() bool
IsIdleSealed reports whether the ephemeral was put to rest by the idle timer (as opposed to actively crashed or actively running).
type EphemeralStatus ¶ added in v0.4.0
type EphemeralStatus string
EphemeralStatus is the lifecycle state recorded in a .info sidecar.
type LockSource ¶ added in v0.4.0
type LockSource string
LockSource identifies which gpgsmith frontend is driving an open session. Before the daemon refactor this identifier tagged a flock acquisition; in the daemon era it is recorded in the encrypted ephemeral .info sidecar for diagnostic purposes only.
const ( // LockSourceCLI marks a session opened by the CLI frontend. LockSourceCLI LockSource = "cli" // LockSourceUI marks a session opened by the local web UI. LockSourceUI LockSource = "ui" // LockSourceTUI marks a session opened by the terminal UI (future). LockSourceTUI LockSource = "tui" )
type MasterKeyMismatchError ¶ added in v0.4.0
type MasterKeyMismatchError struct {
VaultName string
Expected string // from vault.Entry.TrustedMasterFP
Found string // from gpgsmith.yaml inside the decrypted snapshot
Snapshot string // basename of the snapshot the embedded fp was read from
}
MasterKeyMismatchError is returned by OpenSession (and ResumeSession) when the master fingerprint embedded in the decrypted vault does not match the trusted fingerprint recorded in the vault registry. This is the loud security signal: either an attacker substituted the snapshot or the user rotated their master key without updating the trust anchor.
func (*MasterKeyMismatchError) Error ¶ added in v0.4.0
func (e *MasterKeyMismatchError) Error() string
Error implements the error interface.
type OpenSessionResult ¶ added in v0.4.0
type OpenSessionResult struct {
Session *Session
// TOFUFingerprint is non-empty when this is the first time we've
// seen a master fingerprint for this vault and the caller should
// persist it into the vault registry's TrustedMasterFP field. It
// is empty when:
// - The Entry already had a TrustedMasterFP and it matched.
// - The decrypted vault has no gpgsmith.yaml yet (no master key
// generated; nothing to TOFU on).
TOFUFingerprint string
}
OpenSessionResult is returned by OpenSession. It carries the Session and side-channel information about TOFU first-use, which the caller is responsible for persisting back to the vault registry config.
func OpenSession ¶ added in v0.4.0
func OpenSession( ctx context.Context, v *vault.Vault, entry *vault.Entry, opts SessionOpts, ) (*OpenSessionResult, error)
OpenSession decrypts the latest canonical snapshot of the resolved vault entry and prepares an in-memory Session ready for operations. It:
- Calls v.Open(ctx) to decrypt the latest snapshot into a tmpfs workdir
- Writes gpg.conf and gpg-agent.conf into the workdir for loopback pinentry mode
- Constructs a gpg.Client wired to the workdir and the vault passphrase
- Reads the embedded gpgsmith.yaml and performs TOFU on master_fp: - If entry.TrustedMasterFP is empty, populate it (returned in OpenSessionResult.TOFUFingerprint) - If non-empty and matches, OK - If non-empty and mismatches, refuse with MasterKeyMismatchError
- Computes the ephemeral file paths from the source canonical filename and the local hostname
- Writes the initial .info sidecar with status=active and the current heartbeat timestamp
- Starts the heartbeat goroutine
On any failure after the workdir is decrypted, the workdir is removed and any partially-written ephemeral files are cleaned up.
func ResumeSession ¶ added in v0.4.0
func ResumeSession( ctx context.Context, v *vault.Vault, entry *vault.Entry, eph *Ephemeral, opts SessionOpts, ) (*OpenSessionResult, error)
ResumeSession is the resume-from-ephemeral counterpart to OpenSession.
Where OpenSession decrypts the latest canonical snapshot, ResumeSession decrypts the previously-flushed .session-<host> ephemeral state file pointed to by eph and uses that as the workdir. This recovers an in-progress session that was put to rest by AutoSealAndDrop or by an earlier crash, restoring the exact mutation state that was on disk at the time of the last heartbeat-flush.
On success the on-disk ephemeral file pair is deleted (the new in-memory session takes over ownership of those bytes), the .info sidecar is rewritten in active state, and the heartbeat goroutine is started.
On failure no ephemeral files are touched, so a subsequent attempt can retry the resume.
type Session ¶ added in v0.4.0
type Session struct {
Vault *vault.Vault
Entry *vault.Entry
Source LockSource
Hostname string
Logger *slog.Logger
Workdir string
GPG *gpg.Client
SourceSnap *vault.Snapshot // the canonical the session was opened from
StartedAt time.Time
CanonicalBase string // base filename of SourceSnap; used to derive ephemeral paths
// TOFU result. ConfiguredMasterFP is the master_fp read from the
// decrypted gpgsmith.yaml inside the workdir, or "" if no key has
// been generated yet.
ConfiguredMasterFP string
// contains filtered or unexported fields
}
Session is the in-memory representation of an open vault. It owns the decrypted GNUPGHOME workdir, an authenticated GPG client, the heartbeat goroutine that keeps the on-disk liveness sidecar fresh, and a mutation generation counter that drives periodic re-flush of the encrypted .session-<host> ephemeral state file.
A Session is created via OpenSession (open the latest canonical) or ResumeSession (resume from an existing .session-<host> ephemeral). It is ended via Seal (write a new canonical) or Discard (throw away the work). Both end-paths stop the heartbeat goroutine, delete the on-disk ephemeral pair, and remove the workdir from /dev/shm.
AutoSealAndDrop is a third end-path triggered by an idle timeout. It flushes the workdir to the encrypted ephemeral file, drops the in-memory state, and stops heartbeating — but leaves the .session-<host> and .info files on disk so the next OpenSession on the same vault can offer to resume.
Session is safe for concurrent use by multiple goroutines: mutation counters are atomic, the heartbeat goroutine never reads or writes the workdir on its own, and Seal/Discard are protected by an internal mutex.
func (*Session) AutoSealAndDrop ¶ added in v0.4.0
AutoSealAndDrop is the idle-timeout end-path. It does NOT write a new canonical snapshot — the work-in-progress remains in the encrypted .session-<host> ephemeral file on disk, ready for the next OpenSession on this vault to offer as a resume option.
Specifically, AutoSealAndDrop:
- Forces a final flush of the workdir to the encrypted ephemeral file
- Marks the .info sidecar status as idle-sealed
- Stops the heartbeat goroutine
- Removes the workdir from tmpfs
- Marks the Session as closed (any further method call returns an error)
The .session-<host> and .info files remain on disk. The next attempt to open the vault will detect them and prompt to resume.
func (*Session) Discard ¶ added in v0.4.0
Discard explicitly throws away the workdir and the ephemeral file pair without writing a new canonical snapshot. The per-session gpg-agent and scdaemon are killed before the workdir is removed so they don't become orphans pointing at a non-existent directory. After Discard returns, the Session is unusable.
func (*Session) Generation ¶ added in v0.4.0
Generation returns the current mutation generation. Useful for tests.
func (*Session) IsClosed ¶ added in v0.4.0
IsClosed reports whether the Session has been ended via Seal, Discard, or AutoSealAndDrop.
func (*Session) MarkChanged ¶ added in v0.4.0
func (s *Session) MarkChanged()
MarkChanged increments the mutation generation counter. The next heartbeat tick will detect the change and re-flush the workdir to the encrypted .session-<host> ephemeral file. Mutation operations on the kernel API (key generation, identity add, etc.) call this after a successful change.
Safe to call from any goroutine.
type SessionOpts ¶ added in v0.4.0
type SessionOpts struct {
// Source identifies which frontend is opening this session.
Source LockSource
// Logger receives kernel-level structured logs. Defaults to
// slog.Default().
Logger *slog.Logger
// HeartbeatInterval overrides the default heartbeat tick. Mainly
// for tests; production callers should leave this zero (uses
// DefaultHeartbeatInterval).
HeartbeatInterval time.Duration
}
SessionOpts configures how a session is opened.