Files
secret/internal/vault/vault.go
sneak 397011a592
All checks were successful
check / check (push) Successful in 2m0s
Update golangci-lint to v2.12.2 with canonical config (closes #30)
- Replace .golangci.yml with the canonical strict config (all linters
  enabled except the standard disable list; lll 88, funlen 80/50,
  cyclop 15, dupl 100; test files now linted)
- Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by
  tag and digest (Debian-based)
- Fix all ~1550 findings surfaced by the new config: line wrapping,
  wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel
  errors, perfsprint/modernize rewrites, goconst constants, thelper,
  testifylint, noctx CommandContext, testpackage conversions,
  t.Parallel() where safe, and complexity/dupl helper extraction
- Record the change and follow-up items in TODO.md

User-visible strings
--------------------

No user-visible string changes remain. Every error message this branch
composes is byte-identical to the one main composes.

The err113 sentinels are shaped so that fmt.Errorf reassembles the
original text around them: a sentinel carries the fixed words of the
message and the caller supplies the interpolated value in the position
it has always occupied. Where the value sits in the middle of the
sentence the sentinel therefore holds only a fragment (for example
vault.ErrVaultNotFound is "does not exist", composed by its caller as
"vault <name> does not exist"); each such sentinel documents the
message it participates in.

Verified mechanically rather than by inspection: every fmt.Errorf and
errors.New call site in both trees was parsed, the Error() text of any
sentinel passed to %w substituted in, and the resulting sets of
composed message templates compared. All 350 templates main produces
are still produced, character for character; the set of messages lost
or altered is empty.

unlocker list
-------------

findUnlockerIDByMetadata now returns (string, error) instead of
signalling failure with an empty ID. An unreadable unlockers.d is no
longer indistinguishable from "no matching entry", so UnlockersList
skips the entry with a warning naming the directory, as it did before
the scan was extracted into a helper, rather than emitting a row under
a synthesized fallback ID that no unlocker remove or unlocker select
can match and that suppresses the current-unlocker marker. The
duplicate-check and shell-completion callers skip on the same
condition, matching their pre-extraction behavior. Covered by tests in
internal/cli/unlockers_list_test.go.
2026-08-09 02:00:27 +00:00

275 lines
7.9 KiB
Go

package vault
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// Vault represents a secrets vault
type Vault struct {
Name string
fs afero.Fs
stateDir string
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
}
// NewVault creates a new Vault instance
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
secret.Debug("Creating NewVault instance")
v := &Vault{
Name: name,
fs: fs,
stateDir: stateDir,
longTermKey: nil,
}
secret.Debug("Created NewVault instance successfully")
return v
}
// Locked returns true if the vault doesn't have a long-term key in memory
func (v *Vault) Locked() bool {
return v.longTermKey == nil
}
// Unlock sets the long-term key in memory, unlocking the vault
func (v *Vault) Unlock(key *age.X25519Identity) {
v.longTermKey = key
}
// GetLongTermKey returns the long-term key if available in memory
func (v *Vault) GetLongTermKey() *age.X25519Identity {
return v.longTermKey
}
// ClearLongTermKey removes the long-term key from memory (locks the vault)
func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil
}
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
// from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// If we have it in memory, return it
if !v.Locked() {
return v.longTermKey, nil
}
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
}
// No mnemonic available, try to use current unlocker
secret.Debug("No mnemonic available, using current unlocker to unlock vault",
"vault_name", v.Name)
// Get current unlocker
unlocker, err := v.GetCurrentUnlocker()
if err != nil {
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
secret.DebugWith("Retrieved current unlocker for vault unlock",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Get the long-term key via the unlocker.
// SE unlockers return the long-term key directly from GetIdentity().
// Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil {
return nil, err
}
secret.DebugWith("Successfully obtained long-term identity via unlocker",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Cache the derived key by unlocking the vault
v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via unlocker",
"vault_name", v.Name, "unlocker_type", unlocker.GetType())
return ltIdentity, nil
}
// GetDirectory returns the vault's directory path
func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
}
// GetName returns the vault's name (for VaultInterface compatibility)
func (v *Vault) GetName() string {
return v.Name
}
// GetFilesystem returns the vault's filesystem (for VaultInterface
// compatibility)
//
//nolint:ireturn // afero.Fs is the interface required by VaultInterface
func (v *Vault) GetFilesystem() afero.Fs {
return v.fs
}
// NumSecrets returns the number of secrets in the vault
func (v *Vault) NumSecrets() (int, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return 0, fmt.Errorf("failed to get vault directory: %w", err)
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(v.fs, secretsDir)
if !exists {
return 0, nil
}
entries, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
}
// Count only directories that have a "current" version pointer file
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// A valid secret has a "current" file pointing to the active version
secretDir := filepath.Join(secretsDir, entry.Name())
currentFile := filepath.Join(secretDir, "current")
exists, err := afero.Exists(v.fs, currentFile)
if err != nil {
continue // Skip directories we can't read
}
if exists {
count++
}
}
return count, nil
}
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic(
envMnemonic string,
) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic from environment for long-term key derivation",
"vault_name", v.Name)
// Load vault metadata to get the derivation index
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
if err != nil {
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key from mnemonic",
"error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Verify that the derived key matches the stored public key hash
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
if derivedPubKeyHash != metadata.PublicKeyHash {
secret.Debug("Derived public key hash does not match stored hash",
"vault_name", v.Name,
"derived_hash", derivedPubKeyHash,
"stored_hash", metadata.PublicKeyHash,
"derivation_index", metadata.DerivationIndex)
return nil, ErrMnemonicMismatch
}
secret.DebugWith("Successfully derived long-term key from mnemonic",
slog.String("vault_name", v.Name),
slog.String("public_key", ltIdentity.Recipient().String()),
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
)
// Cache the derived key by unlocking the vault
v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic",
"vault_name", v.Name)
return ltIdentity, nil
}
// unlockLongTermKey extracts the vault's long-term key using the given
// unlocker. SE unlockers decrypt the long-term key directly; other unlockers
// use an intermediate identity.
func (v *Vault) unlockLongTermKey(
unlocker secret.Unlocker,
) (*age.X25519Identity, error) {
if unlocker.GetType() == unlockerTypeSecureEnclave {
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
ltIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
}
return ltIdentity, nil
}
// Standard unlockers: get unlocker identity, then decrypt longterm.age
unlockerIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
encryptedLtPrivKey, unlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}