check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. What they checked before asking is checked again under the lock: unlocker add fails if the vault was removed meanwhile and refuses a GPG key added meanwhile, and a forced add fails if another command created the secret meanwhile, rather than ask under the lock. secret.CreatePGPUnlocker now takes the vault, already unlocked. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
397 lines
12 KiB
Go
397 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"). An empty or
|
|
// invalid name, or the name of a vault that does not exist, is an error
|
|
// wrapping ErrVaultNotFound that names the file and says to run
|
|
// 'secret vault select <name>'.
|
|
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)
|
|
|
|
// An empty name would make vaults.d itself the vault's directory
|
|
exists := false
|
|
if isValidVaultName(vaultName) {
|
|
exists, err = afero.DirExists(fs, absolutePath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to check if vault exists: %w", err)
|
|
}
|
|
}
|
|
|
|
if !exists {
|
|
return "", fmt.Errorf("vault '%s' named in %s %w; run "+
|
|
"'secret vault select <name>' to select a vault",
|
|
vaultName, currentVaultPath, ErrVaultNotFound)
|
|
}
|
|
|
|
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. Not nil, so that no vaults prints [] in JSON,
|
|
// not null.
|
|
vaults := make([]string, 0, len(entries))
|
|
|
|
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. The commands that create a vault check it before they
|
|
// ask for the mnemonic or passphrase, and CreateVault checks it again, under
|
|
// the state directory lock, before writing anything.
|
|
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. 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
|
|
}
|