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. A forced add of a secret that another command created meanwhile lets go of the lock, gets the key, and takes the lock again. secret.CreatePGPUnlocker now takes the vault, already unlocked; SetMnemonic and SetUnlockPassphrase, used only by it, are gone. Not done: secret move between vaults still unlocks both under the lock; the issue does not name it. Model: opus-5-5
318 lines
9.6 KiB
Go
318 lines
9.6 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
|
|
}
|
|
|
|
// 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 passphrase is wrong: the unlocker still
|
|
// opens the vault with the right one. 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, secret.ErrWrongPassphrase) ||
|
|
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
|
|
}
|