Files
secret/internal/vault/management.go
T
clawbot db7d2c952e
check / check (push) Failing after 1s
Read secret environment variables once per command, then unset them (closes #60)
init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both;
CreatePGPUnlocker sets them on the vault it loads through SetMnemonic
and SetUnlockPassphrase, new in VaultInterface. README warns against
both variables.

Model: opus-5-5
2026-10-04 16:07:57 +02:00

345 lines
10 KiB
Go

// Package vault provides functionality for managing encrypted vaults.
package vault
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// Register the GetCurrentVault function with the secret package
//
//nolint:gochecknoinits // registers the vault accessor with the secret package
func init() {
secret.RegisterGetCurrentVaultFunc(
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
return GetCurrentVault(fs, stateDir)
})
}
// isValidVaultName reports whether name is a valid vault name: only
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
// "..". With no path separator allowed, a vault is always one directory
// directly under vaults.d.
func isValidVaultName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
return matched
}
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
// is not a valid vault name. Call it on the name exactly as the user gave it,
// before building any path from it.
func ValidateVaultName(name string) error {
if !isValidVaultName(name) {
return fmt.Errorf(
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
"are allowed, and a name must not be empty, '.' or '..'",
ErrInvalidVaultName, name,
)
}
return nil
}
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
// The file contains just the vault name (e.g., "default")
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
fileData, err := afero.ReadFile(fs, currentVaultPath)
if err != nil {
secret.Debug("Failed to read currentvault file", "error", err)
return "", fmt.Errorf("failed to read currentvault file: %w", err)
}
// The file contains just the vault name like "default"
vaultName := strings.TrimSpace(string(fileData))
secret.Debug("Read vault name from file", "vault_name", vaultName)
// Resolve to absolute path: stateDir/vaults.d/vaultName
stateDir := filepath.Dir(currentVaultPath)
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
return absolutePath, nil
}
// GetCurrentVault gets the current vault from the file system
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
secret.Debug("Getting current vault", "state_dir", stateDir)
// Check if the current vault symlink exists
currentVaultPath := filepath.Join(stateDir, "currentvault")
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
_, err := fs.Stat(currentVaultPath)
if err != nil {
secret.Debug("Failed to stat current vault symlink",
"error", err, "path", currentVaultPath)
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
}
secret.Debug("Current vault symlink exists")
// Resolve the symlink to get the actual vault directory
secret.Debug("Resolving vault symlink")
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
if err != nil {
return nil, err
}
secret.Debug("Resolved vault symlink", "target_path", targetPath)
// Extract the vault name from the path
// The path will be something like "/path/to/vaults.d/default"
vaultName := filepath.Base(targetPath)
secret.Debug("Extracted vault name", "vault_name", vaultName)
secret.Debug("Current vault resolved",
"vault_name", vaultName, "target_path", targetPath)
// Create and return the vault
return NewVault(fs, stateDir, vaultName), nil
}
// ListVaults lists all vaults in the state directory
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
}
if !exists {
return []string{}, nil
}
// Read the vaults directory
entries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
}
// Extract vault names
var vaults []string
for _, entry := range entries {
if entry.IsDir() {
vaults = append(vaults, entry.Name())
}
}
return vaults, nil
}
// processMnemonicForVault handles mnemonic processing for vault creation.
// It returns the derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (uint32, string, string, error) {
if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
"vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil
}
mnemonic := mnemonicBuffer.String()
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
if err != nil {
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
}
// Derive the long-term key using the actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil {
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
}
// Write the public key
ltPubKey := ltIdentity.Recipient().String()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil {
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
}
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
// Compute verification hash from actual derivation index
publicKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
// Compute family hash from index 0 (same for all vaults with this mnemonic)
// This is used to identify which vaults belong to the same mnemonic family
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
}
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. It refuses a vault that already
// exists before writing anything: creating it again would replace its keys,
// and its secrets could no longer be decrypted. The commands that call it
// hold the state directory lock, so no other command can create the vault
// between the check and the writes.
func CreateVault(
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, err
}
secret.Debug("Vault name validation passed", "vault_name", name)
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
// Create vault directory structure
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Create main vault directory
err = fs.MkdirAll(vaultDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create vault directory: %w", err)
}
// Create secrets directory
secretsDir := filepath.Join(vaultDir, "secrets.d")
err = fs.MkdirAll(secretsDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
}
// Create unlockers directory
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
}
// Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return nil, err
}
// Save vault metadata
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
}
// Select the newly created vault as current
secret.Debug("Selecting newly created vault as current", "name", name)
err = SelectVault(fs, stateDir, name)
if err != nil {
return nil, fmt.Errorf("failed to select vault: %w", err)
}
// Create and return the vault
secret.Debug("Successfully created vault", "name", name)
vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
}
// SelectVault selects the given vault as the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error {
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name)
return err
}
secret.Debug("Vault name validation passed", "vault_name", name)
// Check if vault exists
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return fmt.Errorf("failed to check if vault exists: %w", err)
}
if !exists {
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
}
// Create or replace the currentvault file with just the vault name. It
// is replaced in one rename, so it never goes missing.
currentVaultPath := filepath.Join(stateDir, "currentvault")
secret.Debug("Writing currentvault file", "vault_name", name)
err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name))
if err != nil {
return fmt.Errorf("failed to select vault: %w", err)
}
secret.Debug("Successfully selected vault", "vault_name", name)
return nil
}