check / check (push) Failing after 1s
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
345 lines
10 KiB
Go
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
|
|
}
|