check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault directory, its metadata, long-term public key and passphrase unlocker into a temporary directory, renames that into vaults.d once complete, and only then makes the vault current. secret init and secret vault create call it once instead of adding the unlocker afterwards, so a kill part-way leaves either no vault, whose temporary directory the next command that takes the lock deletes, or a complete one. A test records the state directory before every change the call makes and checks each state, and the command run again from it. Model: opus-5-5
367 lines
11 KiB
Go
367 lines
11 KiB
Go
// Package vault provides functionality for managing encrypted vaults.
|
|
package vault
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"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 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
|
|
}
|
|
|
|
// 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 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.
|
|
//
|
|
// 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 := 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)
|
|
}
|
|
|
|
if passphrase != nil && mnemonic == nil {
|
|
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
|
|
}
|
|
|
|
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
|
|
}
|