check / check (push) Waiting to run
vault.CreateVault now checks for the vault before writing anything and fails with "vault NAME already exists" (vault.ErrVaultExists). secret init and secret vault create call it while holding the state directory lock, so two creates at once cannot both pass the check. Before, either command over an existing vault replaced its metadata, passphrase unlocker and longterm.age, so none of its secrets could be decrypted. The lock tests set up the vault "work" instead of "default", which init now refuses to create again. Model: opus-5-5
327 lines
9.7 KiB
Go
327 lines
9.7 KiB
Go
// Package vault provides functionality for managing encrypted vaults.
|
|
package vault
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"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 validates vault names according to the format [a-z0-9\.\-\_]+
|
|
// Note: We don't allow slashes in vault names unlike secret names
|
|
func isValidVaultName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
|
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
|
|
|
return matched
|
|
}
|
|
|
|
// 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,
|
|
) (uint32, string, string, error) {
|
|
// Check if mnemonic is available in environment
|
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
|
|
|
if mnemonic == "" {
|
|
secret.Debug("No mnemonic in environment, vault created without long-term key",
|
|
"vault", vaultName)
|
|
// Use 0 for derivation index when no mnemonic is provided
|
|
return 0, "", "", nil
|
|
}
|
|
|
|
secret.Debug("Mnemonic found in environment, 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. 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) (*Vault, error) {
|
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
|
|
|
// Validate vault name
|
|
if !isValidVaultName(name) {
|
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
|
|
|
return nil, fmt.Errorf(
|
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
ErrInvalidVaultName, name,
|
|
)
|
|
}
|
|
|
|
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)
|
|
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)
|
|
|
|
return NewVault(fs, stateDir, name), 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)
|
|
|
|
// Validate vault name
|
|
if !isValidVaultName(name) {
|
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
|
|
|
return fmt.Errorf(
|
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
ErrInvalidVaultName, name,
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|