Say the mnemonic still opens a vault its unlocker cannot (closes #47)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
When a vault cannot be opened through its current unlocker, the error now ends by naming the vault, saying that it still opens with its mnemonic, and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set gives it a new unlocker, after 'secret vault select' when it is not the current vault. Only when the vault metadata records the key the mnemonic derives, and not when the passphrase could not be read. 'secret encrypt' and 'secret decrypt' read the key secret through vault.GetSecret, and Secret.GetValue with its helpers is removed. An unreadable 'current' file's error names 'secret version list' and 'secret version promote'. Causes stay wrapped. Model: opus-5-5
This commit was merged in pull request #111.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -10,6 +11,11 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
// ErrPassphraseNotRead is wrapped in the error of a passphrase unlocker
|
||||
// that could not read its passphrase from the terminal, for example because
|
||||
// there is none. The unlocker itself was not tried.
|
||||
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
||||
|
||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||
type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
@@ -149,7 +155,7 @@ func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
// errSecretNotFound carries only the message tail; callers compose
|
||||
// "secret <name> not found" around it so the emitted text is
|
||||
// unchanged.
|
||||
errSecretNotFound = errors.New("not found")
|
||||
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
||||
errGetEncryptedDataDeprecated = errors.New(
|
||||
"GetEncryptedData is deprecated - use version-specific methods")
|
||||
errGetCurrentVaultNotRegistered = errors.New(
|
||||
@@ -81,73 +73,6 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
}
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the current version's value, with the
|
||||
// vault's long-term key derived from mnemonic when it is not nil, else
|
||||
// obtained through unlocker
|
||||
func (s *Secret) GetValue(
|
||||
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := s.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists during GetValue",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
Debug("Secret not found during GetValue",
|
||||
"secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||
|
||||
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
|
||||
}
|
||||
|
||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||
|
||||
// Get current version
|
||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
||||
}
|
||||
|
||||
// Create version object
|
||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
if mnemonic != nil {
|
||||
return s.getValueViaMnemonic(version, mnemonic.String())
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
|
||||
// Use the provided unlocker to get the vault's long-term private key
|
||||
if unlocker == nil {
|
||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||
|
||||
return nil, errUnlockerRequired
|
||||
}
|
||||
|
||||
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DebugWith("Successfully obtained vault's long-term key",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
@@ -215,124 +140,6 @@ func (s *Secret) Exists() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getValueViaMnemonic derives the vault's long-term key from the
|
||||
// mnemonic and decrypts the version value with it.
|
||||
func (s *Secret) getValueViaMnemonic(
|
||||
version *Version, mnemonic string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
Debug("Using mnemonic for direct long-term key derivation",
|
||||
"secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic for secret",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
||||
// the vault's long-term private key.
|
||||
func (s *Secret) getLongTermIdentityFromUnlocker(
|
||||
unlocker Unlocker,
|
||||
) (*age.X25519Identity, error) {
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get unlocker identity",
|
||||
"error", err, "secret_name", s.Name,
|
||||
"unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(
|
||||
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key",
|
||||
"error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
// This function is a wrapper around the actual implementation in the vault package
|
||||
// and exists to break the import cycle.
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||
@@ -321,46 +319,3 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
t.Logf("Secret.Exists() works correctly")
|
||||
})
|
||||
}
|
||||
|
||||
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
|
||||
// GetValue, given the mnemonic, derives the long-term key at the derivation
|
||||
// index in the vault's metadata. At index 0 it could not decrypt the secret,
|
||||
// which was encrypted to the key at index 1.
|
||||
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vaultDir := "/test-config/vaults.d/test-vault"
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
vlt := &MockVault{
|
||||
name: "test-vault",
|
||||
fs: fs,
|
||||
directory: vaultDir,
|
||||
derivationIndex: 1,
|
||||
mnemonic: mnemonic,
|
||||
}
|
||||
|
||||
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
|
||||
|
||||
err = afero.WriteFile(
|
||||
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
|
||||
require.NoError(t, err)
|
||||
|
||||
secretName, secretValue := "x", "value"
|
||||
|
||||
err = vlt.AddSecret(secretName,
|
||||
memguard.NewBufferFromBytes([]byte(secretValue)), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer value.Destroy()
|
||||
|
||||
require.Equal(t, secretValue, value.String())
|
||||
}
|
||||
|
||||
@@ -557,13 +557,18 @@ func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error)
|
||||
}
|
||||
|
||||
// GetCurrentVersion returns the version that the "current" file points to
|
||||
// The file contains just the version name (e.g., "20231215.001")
|
||||
// The file contains just the version name (e.g., "20231215.001"). If it
|
||||
// cannot be read, the error says how to make a version current again: the
|
||||
// versions themselves are not in the file.
|
||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
|
||||
fileData, err := afero.ReadFile(fs, currentPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read current version file: %w", err)
|
||||
return "", fmt.Errorf("failed to read current version file: %w; "+
|
||||
"this file only names the current version: 'secret version list' "+
|
||||
"lists the secret's versions, and 'secret version promote' makes "+
|
||||
"one of them current", err)
|
||||
}
|
||||
|
||||
version := strings.TrimSpace(string(fileData))
|
||||
|
||||
Reference in New Issue
Block a user