Files
secret/internal/vault/management.go
T
clawbot 13c7721bad
check / check (push) Waiting to run
Refuse an existing vault before asking for the mnemonic (closes #151)
`secret init` and `secret vault create` asked for the mnemonic and the
unlocker passphrase, and `secret init` printed "Initialized secrets
manager at", before refusing a vault that already exists. The check
`vault.CreateVault` made before writing is now `vault.CheckNewVaultName`,
which both commands call while holding the state directory lock, before
the first prompt; `CreateVault` still calls it. `secret init` prints the
"Initialized" line only once the vault is created, so
`TestMnemonicNotReadNamesOnlyMnemonic` no longer expects it.

Judgement call: the same check validates the name, so `secret vault
create` with an invalid name also fails before the prompts.

Model: opus-5-5
2026-10-15 01:32:23 +02:00

378 lines
12 KiB
Go

// Package vault provides functionality for managing encrypted vaults.
package vault
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// 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 long-term key, nil when there is no mnemonic, and the
// derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (*age.X25519Identity, 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 nil, 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 nil, 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 nil, 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 nil, 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 nil, 0, "", "",
fmt.Errorf("failed to derive identity for index 0: %w", err)
}
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
}
// CheckNewVaultName returns an error when name cannot be the name of a new
// vault: one wrapping ErrInvalidVaultName when it is not a valid vault name,
// and one wrapping ErrVaultExists when that vault already exists. Creating an
// existing vault again would replace its keys, and its secrets could no
// longer be decrypted. CreateVault checks this before writing anything; the
// commands that create a vault also check it before they ask for the
// mnemonic or passphrase, holding the state directory lock.
func CheckNewVaultName(fs afero.Fs, stateDir string, name string) error {
err := ValidateVaultName(name)
if err != nil {
return err
}
exists, err := afero.DirExists(fs, filepath.Join(stateDir, "vaults.d", name))
if err != nil {
return fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
return 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. When passphrase is not nil, the vault
// gets a passphrase unlocker protected by it, as its current unlocker; that
// needs a mnemonic. It refuses an invalid name or a vault that already exists,
// as CheckNewVaultName does, before writing anything. The commands that call
// it hold the state directory lock, so no other command can create the vault
// between the check and the writes.
//
// The vault is written whole into a temporary directory, which is renamed
// into vaults.d only once complete, and only then selected: a crash at any
// point leaves either no vault or a complete one. The next command that
// takes the lock deletes what the crash left under a temporary name.
func CreateVault(
fs afero.Fs, stateDir string, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := CheckNewVaultName(fs, stateDir, name)
if err != nil {
return nil, err
}
if passphrase != nil && mnemonic == nil {
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
}
vaultDir := filepath.Join(stateDir, "vaults.d", name)
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
})
if err != nil {
return nil, 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
}
// writeVaultFiles writes the files of the new vault name into vaultDir: its
// secrets and unlockers directories, its long-term public key and metadata,
// and, when passphrase is not nil, a passphrase unlocker as its current one.
func writeVaultFiles(
fs afero.Fs, stateDir, vaultDir, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) error {
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return err
}
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return fmt.Errorf("failed to save vault metadata: %w", err)
}
if passphrase == nil {
return nil
}
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
return err
}
// 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
}