Files
secret/internal/vault/vault.go
T
clawbot 4d030f1109
check / check (push) Waiting to run
Make another unlocker current when the current one is removed (closes #137)
Removing the vault's current unlocker left current-unlocker naming it,
so every command that opened the vault failed although another
unlocker remained, and the error pointed at the mnemonic.
RemoveUnlocker now first makes current the first other unlocker that
`secret unlocker select` accepts, in the order `secret unlocker list`
shows, and returns its ID, which `secret unlocker remove` prints; with
none, current-unlocker is removed too. When current-unlocker names an
unlocker that does not exist and the vault has another that select
accepts, the error wraps ErrUnlockerNotFound and says to run
`secret unlocker select`, without the mnemonic advice.

Unverified: no test covers that advice for a vault other than the
current one, which gets `secret vault select` first.

Model: opus-5-5
2026-10-14 19:04:26 +00:00

327 lines
9.8 KiB
Go

package vault
import (
"errors"
"fmt"
"log/slog"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// Vault represents a secrets vault
type Vault struct {
Name string
fs afero.Fs
stateDir string
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
// Mnemonic, when not nil, is what the long-term key is derived from
// instead of the current unlocker. The caller destroys it.
Mnemonic *memguard.LockedBuffer
// UnlockPassphrase, when not nil, is given to the current unlocker
// when that is a passphrase unlocker, which otherwise prompts for it.
// The caller destroys it.
UnlockPassphrase *memguard.LockedBuffer
}
// NewVault creates a new Vault instance
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
secret.Debug("Creating NewVault instance")
v := &Vault{
Name: name,
fs: fs,
stateDir: stateDir,
longTermKey: nil,
}
secret.Debug("Created NewVault instance successfully")
return v
}
// Locked returns true if the vault doesn't have a long-term key in memory
func (v *Vault) Locked() bool {
return v.longTermKey == nil
}
// Unlock sets the long-term key in memory, unlocking the vault
func (v *Vault) Unlock(key *age.X25519Identity) {
v.longTermKey = key
}
// GetLongTermKey returns the long-term key if available in memory
func (v *Vault) GetLongTermKey() *age.X25519Identity {
return v.longTermKey
}
// ClearLongTermKey removes the long-term key from memory (locks the vault)
func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil
}
// SetMnemonic sets v.Mnemonic, for code that has v only as a
// secret.VaultInterface.
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
v.Mnemonic = mnemonic
}
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
// a secret.VaultInterface.
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
v.UnlockPassphrase = passphrase
}
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
// from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// If we have it in memory, return it
if !v.Locked() {
return v.longTermKey, nil
}
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
if v.Mnemonic != nil {
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
}
// No mnemonic available, try to use current unlocker
secret.Debug("No mnemonic available, using current unlocker to unlock vault",
"vault_name", v.Name)
// Get current unlocker
unlocker, err := v.GetCurrentUnlocker()
if err != nil {
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, v.withMnemonicAdvice(
fmt.Errorf("failed to get current unlocker: %w", err))
}
secret.DebugWith("Retrieved current unlocker for vault unlock",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Get the long-term key via the unlocker: its identity decrypts
// longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil {
return nil, v.withMnemonicAdvice(err)
}
secret.DebugWith("Successfully obtained long-term identity via unlocker",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Cache the derived key by unlocking the vault
v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via unlocker",
"vault_name", v.Name, "unlocker_type", unlocker.GetType())
return ltIdentity, nil
}
// GetDirectory returns the vault's directory path
func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
}
// GetName returns the vault's name (for VaultInterface compatibility)
func (v *Vault) GetName() string {
return v.Name
}
// GetFilesystem returns the vault's filesystem (for VaultInterface
// compatibility)
//
//nolint:ireturn // afero.Fs is the interface required by VaultInterface
func (v *Vault) GetFilesystem() afero.Fs {
return v.fs
}
// NumSecrets returns the number of secrets in the vault
func (v *Vault) NumSecrets() (int, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return 0, fmt.Errorf("failed to get vault directory: %w", err)
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(v.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err)
}
if !exists {
return 0, nil
}
entries, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
}
// Count only directories that have a "current" version pointer file
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// A valid secret has a "current" file pointing to the active version
secretDir := filepath.Join(secretsDir, entry.Name())
currentFile := filepath.Join(secretDir, "current")
exists, err := afero.Exists(v.fs, currentFile)
if err != nil {
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
}
if exists {
count++
}
}
return count, nil
}
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic(
mnemonic string,
) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic for long-term key derivation",
"vault_name", v.Name)
// Load vault metadata to get the derivation index
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
if err != nil {
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key from mnemonic",
"error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Verify that the derived key matches the stored public key hash
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
if derivedPubKeyHash != metadata.PublicKeyHash {
secret.Debug("Derived public key hash does not match stored hash",
"vault_name", v.Name,
"derived_hash", derivedPubKeyHash,
"stored_hash", metadata.PublicKeyHash,
"derivation_index", metadata.DerivationIndex)
return nil, ErrMnemonicMismatch
}
secret.DebugWith("Successfully derived long-term key from mnemonic",
slog.String("vault_name", v.Name),
slog.String("public_key", ltIdentity.Recipient().String()),
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
)
// Cache the derived key by unlocking the vault
v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic",
"vault_name", v.Name)
return ltIdentity, nil
}
// unlockLongTermKey extracts the vault's long-term key using the given
// unlocker's identity.
func (v *Vault) unlockLongTermKey(
unlocker secret.Unlocker,
) (*age.X25519Identity, error) {
unlockerIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
encryptedLtPrivKey, unlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}
// withMnemonicAdvice returns err, a failure to get the long-term key through
// the current unlocker, with advice added: that the mnemonic still opens the
// vault, and how to give it a new unlocker. The advice is added only when the
// vault metadata records the key that the mnemonic derives; a vault created
// without a mnemonic records none, and without its metadata the key cannot
// be derived. It is not added when the passphrase could not be read: the
// unlocker was not tried, and adding one would need a passphrase read the
// same way. Nor is it added when the current unlocker does not exist and the
// vault has others: the error already says to select one of them.
func (v *Vault) withMnemonicAdvice(err error) error {
if errors.Is(err, secret.ErrPassphraseNotRead) ||
errors.Is(err, ErrUnlockerNotFound) {
return err
}
vaultDir, _ := v.GetDirectory()
metadata, metadataErr := LoadVaultMetadata(v.fs, vaultDir)
if metadataErr != nil || metadata.PublicKeyHash == "" {
return err
}
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
"%s with %s set to the mnemonic to give it a new unlocker",
err, v.Name, v.withVaultSelect("'secret unlocker add passphrase'"),
secret.EnvMnemonic)
}
// withVaultSelect returns command, a quoted 'secret unlocker' command, for
// advice about this vault. Such a command acts on the current vault only,
// so when this vault is not the current one, the command that selects it
// comes first.
func (v *Vault) withVaultSelect(command string) string {
current, err := GetCurrentVault(v.fs, v.stateDir)
if err != nil || current.Name != v.Name {
return fmt.Sprintf("'secret vault select %s', then %s", v.Name, command)
}
return command
}