passes tests now!

This commit is contained in:
2025-06-20 07:24:48 -07:00
parent 0b31fba663
commit 004dce5472
19 changed files with 165 additions and 756 deletions

View File

@@ -9,6 +9,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/term"
)
@@ -19,6 +20,7 @@ var stdinScanner *bufio.Scanner
type CLIInstance struct {
fs afero.Fs
stateDir string
cmd *cobra.Command
}
// NewCLIInstance creates a new CLI instance with the real filesystem

View File

@@ -22,6 +22,7 @@ func newEncryptCmd() *cobra.Command {
outputFile, _ := cmd.Flags().GetString("output")
cli := NewCLIInstance()
cli.cmd = cmd
return cli.Encrypt(args[0], inputFile, outputFile)
},
}
@@ -42,6 +43,7 @@ func newDecryptCmd() *cobra.Command {
outputFile, _ := cmd.Flags().GetString("output")
cli := NewCLIInstance()
cli.cmd = cmd
return cli.Decrypt(args[0], inputFile, outputFile)
},
}
@@ -127,7 +129,7 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
}
// Set up output writer
var output io.Writer = os.Stdout
var output io.Writer = cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {
@@ -213,7 +215,7 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
}
// Set up output writer
var output io.Writer = os.Stdout
var output io.Writer = cli.cmd.OutOrStdout()
if outputFile != "" {
file, err := cli.fs.Create(outputFile)
if err != nil {

View File

@@ -11,6 +11,7 @@ import (
"time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -50,10 +51,13 @@ func TestMain(m *testing.M) {
// all functionality of the secret manager using a real filesystem in a temporary directory.
// This test serves as both validation and documentation of the program's behavior.
func TestSecretManagerIntegration(t *testing.T) {
// Enable debug logging to diagnose test failures
// Enable debug logging to diagnose issues
os.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
defer os.Unsetenv("GODEBUG")
// Reinitialize debug logging to pick up the environment variable change
secret.InitDebugLogging()
// Test configuration
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testPassphrase := "test-passphrase-123"
@@ -349,10 +353,18 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
// Read the current-unlocker file to see what it contains
currentUnlockerContent := readFile(t, currentUnlockerFile)
// The file likely contains the unlocker ID
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
// Read the current-unlocker symlink to see what it points to
symlinkTarget, err := os.Readlink(currentUnlockerFile)
if err != nil {
t.Logf("DEBUG: failed to read symlink %s: %v", currentUnlockerFile, err)
// Fallback to reading as file if it's not a symlink
currentUnlockerContent := readFile(t, currentUnlockerFile)
t.Logf("DEBUG: current-unlocker file content: %q", string(currentUnlockerContent))
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should be passphrase type")
} else {
t.Logf("DEBUG: current-unlocker symlink points to: %q", symlinkTarget)
assert.Contains(t, symlinkTarget, "passphrase", "current unlocker should be passphrase type")
}
// Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
@@ -1006,6 +1018,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
// List unlockers
output, err := runSecret("unlockers", "list")
require.NoError(t, err, "unlockers list should succeed")
t.Logf("DEBUG: unlockers list output: %q", output)
// Should have the passphrase unlocker created during init
assert.Contains(t, output, "passphrase", "should have passphrase unlocker")
@@ -1034,6 +1047,7 @@ func test13UnlockerManagement(t *testing.T, tempDir, testMnemonic string, runSec
}
// Note: This might still show 1 if the implementation doesn't support multiple passphrase unlockers
// Just verify we have at least 1
t.Logf("DEBUG: passphrase count: %d, output lines: %v", passphraseCount, lines)
assert.GreaterOrEqual(t, passphraseCount, 1, "should have at least 1 passphrase unlocker")
// Test JSON output
@@ -1309,6 +1323,7 @@ func test18AgeKeyOperations(t *testing.T, tempDir, secretPath, testMnemonic stri
"SB_SECRET_MNEMONIC": testMnemonic,
}, "encrypt", "encryption/key", "--input", testFile)
require.NoError(t, err, "encrypt to stdout should succeed")
t.Logf("DEBUG: encrypt output: %q", output)
assert.Contains(t, output, "age-encryption.org", "should output age format")
// Test that the age key was stored as a secret
@@ -1804,10 +1819,10 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
require.NoError(t, err, "default vault metadata should be valid JSON")
// Verify required fields
assert.Equal(t, "default", defaultMetadata["name"])
assert.Equal(t, float64(0), defaultMetadata["derivation_index"])
assert.Contains(t, defaultMetadata, "createdAt")
assert.Contains(t, defaultMetadata, "public_key_hash")
assert.Contains(t, defaultMetadata, "mnemonic_family_hash")
// Check work vault metadata
workMetadataPath := filepath.Join(tempDir, "vaults.d", "work", "vault-metadata.json")
@@ -1819,13 +1834,12 @@ func test28VaultMetadata(t *testing.T, tempDir string) {
require.NoError(t, err, "work vault metadata should be valid JSON")
// Work vault should have different derivation index
assert.Equal(t, "work", workMetadata["name"])
workIndex := workMetadata["derivation_index"].(float64)
assert.NotEqual(t, float64(0), workIndex, "work vault should have non-zero derivation index")
// Both vaults created with same mnemonic should have same public_key_hash
assert.Equal(t, defaultMetadata["public_key_hash"], workMetadata["public_key_hash"],
"vaults from same mnemonic should have same public_key_hash")
// Both vaults created with same mnemonic should have same mnemonic_family_hash
assert.Equal(t, defaultMetadata["mnemonic_family_hash"], workMetadata["mnemonic_family_hash"],
"vaults from same mnemonic should have same mnemonic_family_hash")
}
func test29SymlinkHandling(t *testing.T, tempDir, secretPath, testMnemonic string) {
@@ -2025,7 +2039,7 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
// This is the expected behavior with the current bug
assert.Error(t, err, "get should fail due to wrong derivation index")
assert.Contains(t, getOutput, "failed to decrypt", "should indicate decryption failure")
assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
// Document what should happen when the bug is fixed
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")

View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
"git.eeqj.de/sneak/secret/internal/secret"
@@ -25,6 +24,7 @@ func newAddCmd() *cobra.Command {
secret.Debug("Got force flag", "force", force)
cli := NewCLIInstance()
cli.cmd = cmd // Set the command for stdin access
secret.Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force)
},
@@ -110,7 +110,7 @@ func (cli *CLIInstance) AddSecret(secretName string, force bool) error {
// Read secret value from stdin
secret.Debug("Reading secret value from stdin")
value, err := io.ReadAll(os.Stdin)
value, err := io.ReadAll(cli.cmd.InOrStdin())
if err != nil {
return fmt.Errorf("failed to read secret value: %w", err)
}
@@ -167,6 +167,15 @@ func (cli *CLIInstance) GetSecretWithVersion(cmd *cobra.Command, secretName stri
// Print the secret value to stdout
cmd.Print(string(value))
secret.Debug("Printed value to cmd")
// Debug: Log what we're actually printing
secret.Debug("Secret retrieval debug info",
"secretName", secretName,
"version", version,
"valueLength", len(value),
"valueAsString", string(value),
"isEmpty", len(value) == 0)
return nil
}

View File

@@ -45,6 +45,11 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
output := buf.String()
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
// Add debug info for troubleshooting
if len(output) == 0 && err == nil {
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
}
// Restore environment
for k, v := range savedEnv {
if v == "" {

View File

@@ -40,6 +40,7 @@ func newUnlockersListCmd() *cobra.Command {
jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance()
cli.cmd = cmd
return cli.UnlockersList(jsonOutput)
},
}
@@ -201,31 +202,31 @@ func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
return fmt.Errorf("failed to marshal JSON: %w", err)
}
fmt.Println(string(jsonBytes))
cli.cmd.Println(string(jsonBytes))
} else {
// Pretty table output
if len(unlockers) == 0 {
fmt.Println("No unlockers found in current vault.")
fmt.Println("Run 'secret unlockers add passphrase' to create one.")
cli.cmd.Println("No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlockers add passphrase' to create one.")
return nil
}
fmt.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
fmt.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
cli.cmd.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
for _, unlocker := range unlockers {
flags := ""
if len(unlocker.Flags) > 0 {
flags = strings.Join(unlocker.Flags, ",")
}
fmt.Printf("%-18s %-12s %-20s %s\n",
cli.cmd.Printf("%-18s %-12s %-20s %s\n",
unlocker.ID,
unlocker.Type,
unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags)
}
fmt.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
}
return nil

View File

@@ -223,13 +223,17 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
return fmt.Errorf("failed to store long-term public key: %w", err)
}
// Calculate public key hash from index 0 (same for all vaults with this mnemonic)
// Calculate public key hash from the actual derivation index being used
// This is used to verify that the derived key matches what was stored
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
// Calculate 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 fmt.Errorf("failed to derive identity for index 0: %w", err)
}
publicKeyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
// Load existing metadata
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
@@ -243,6 +247,7 @@ func (cli *CLIInstance) VaultImport(cmd *cobra.Command, vaultName string) error
// Update metadata with new derivation info
existingMetadata.DerivationIndex = derivationIndex
existingMetadata.PublicKeyHash = publicKeyHash
existingMetadata.MnemonicFamilyHash = familyHash
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
secret.Debug("Failed to save vault metadata", "error", err)

View File

@@ -2,7 +2,6 @@ package cli
import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"
@@ -110,7 +109,7 @@ func (cli *CLIInstance) ListVersions(cmd *cobra.Command, secretName string) erro
}
// Create table writer
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
// Load and display each version's metadata
@@ -185,15 +184,8 @@ func (cli *CLIInstance) PromoteVersion(cmd *cobra.Command, secretName string, ve
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
}
// Update the current symlink
currentLink := filepath.Join(secretDir, "current")
// Remove existing symlink
_ = cli.fs.Remove(currentLink)
// Create new symlink to the selected version
relativePath := filepath.Join("versions", version)
if err := afero.WriteFile(cli.fs, currentLink, []byte(relativePath), 0644); err != nil {
// Update the current symlink using the proper function
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
return fmt.Errorf("failed to update current version: %w", err)
}

View File

@@ -18,11 +18,11 @@ var (
)
func init() {
initDebugLogging()
InitDebugLogging()
}
// initDebugLogging initializes the debug logging system based on GODEBUG environment variable
func initDebugLogging() {
// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
func InitDebugLogging() {
godebug := os.Getenv("GODEBUG")
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")

View File

@@ -21,7 +21,7 @@ func TestDebugLogging(t *testing.T) {
os.Setenv("GODEBUG", originalGodebug)
}
// Re-initialize debug system with original setting
initDebugLogging()
InitDebugLogging()
}()
tests := []struct {
@@ -61,7 +61,7 @@ func TestDebugLogging(t *testing.T) {
}
// Re-initialize debug system
initDebugLogging()
InitDebugLogging()
// Test if debug is enabled
enabled := IsDebugEnabled()
@@ -112,10 +112,10 @@ func TestDebugFunctions(t *testing.T) {
} else {
os.Setenv("GODEBUG", originalGodebug)
}
initDebugLogging()
InitDebugLogging()
}()
initDebugLogging()
InitDebugLogging()
if !IsDebugEnabled() {
t.Log("Debug not enabled, but continuing with debug function tests anyway")

View File

@@ -6,10 +6,11 @@ import (
// VaultMetadata contains information about a vault
type VaultMetadata struct {
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
DerivationIndex uint32 `json:"derivation_index"`
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the long-term public key
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
DerivationIndex uint32 `json:"derivation_index"`
PublicKeyHash string `json:"public_key_hash,omitempty"` // Double SHA256 hash of the actual long-term public key
MnemonicFamilyHash string `json:"mnemonic_family_hash,omitempty"` // Double SHA256 hash of index-0 key (for grouping vaults from same mnemonic)
}
// UnlockerMetadata contains information about an unlocker

View File

@@ -287,22 +287,33 @@ func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error
slog.String("version", sv.Version),
)
// Debug: Log the directory and long-term key info
Debug("SecretVersion GetValue debug info",
"secret_name", sv.SecretName,
"version", sv.Version,
"directory", sv.Directory,
"lt_identity_public_key", ltIdentity.Recipient().String())
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
}
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
// Step 2: Decrypt version private key using long-term key
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
@@ -313,20 +324,27 @@ func (sv *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, error
// Step 4: Read encrypted value
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
Debug("Reading encrypted value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
}
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
// Step 5: Decrypt value using version key
Debug("Decrypting value with version identity", "version", sv.Version)
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
}
Debug("Successfully retrieved version value", "version", sv.Version, "value_length", len(value))
Debug("Successfully retrieved version value",
"version", sv.Version,
"value_length", len(value),
"value_as_string", string(value),
"is_empty", len(value) == 0)
return value, nil
}

View File

@@ -207,6 +207,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
mnemonic := os.Getenv(secret.EnvMnemonic)
var derivationIndex uint32
var publicKeyHash string
var familyHash string
if mnemonic != "" {
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", name)
@@ -232,13 +233,16 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
}
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
// Compute public key hash from index 0 (same for all vaults with this mnemonic)
// 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, fmt.Errorf("failed to derive identity for index 0: %w", err)
}
publicKeyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
} else {
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", name)
// Use 0 for derivation index when no mnemonic is provided
@@ -247,9 +251,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Save vault metadata
metadata := &VaultMetadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
return nil, fmt.Errorf("failed to save vault metadata: %w", err)

View File

@@ -75,8 +75,8 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
continue
}
// Check if this vault uses the same mnemonic by comparing public key hashes
if metadata.PublicKeyHash == pubKeyHash {
// Check if this vault uses the same mnemonic by comparing family hashes
if metadata.MnemonicFamilyHash == pubKeyHash {
usedIndices[metadata.DerivationIndex] = true
}
}

View File

@@ -71,8 +71,9 @@ func TestVaultMetadata(t *testing.T) {
}
metadata1 := &VaultMetadata{
DerivationIndex: 0,
PublicKeyHash: pubKeyHash0,
DerivationIndex: 0,
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
}
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
t.Fatalf("Failed to save metadata: %v", err)
@@ -115,9 +116,13 @@ func TestVaultMetadata(t *testing.T) {
t.Fatalf("Failed to write public key: %v", err)
}
// Compute the hash for index 5 key
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
metadata2 := &VaultMetadata{
DerivationIndex: 5,
PublicKeyHash: pubKeyHash0, // Same hash since it's from the same mnemonic
DerivationIndex: 5,
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
}
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
t.Fatalf("Failed to save metadata: %v", err)

View File

@@ -351,6 +351,7 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
)
// Get the version's value
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
if err != nil {
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
@@ -364,6 +365,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.Int("decrypted_length", len(decryptedValue)),
)
// Debug: Log metadata about the decrypted value without exposing the actual secret
secret.Debug("Vault secret decryption debug info",
"secret_name", name,
"version", version,
"decrypted_value_length", len(decryptedValue),
"is_empty", len(decryptedValue) == 0)
return decryptedValue, nil
}

View File

@@ -34,17 +34,23 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// Resolve the symlink to get the target directory
var unlockerDir string
if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlocker symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath)
if linkReader, ok := v.fs.(afero.LinkReader); ok {
secret.Debug("Resolving unlocker symlink using afero")
// Try to read as symlink first
unlockerDir, err = linkReader.ReadlinkIfPossible(currentUnlockerPath)
if err != nil {
secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath)
return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err)
secret.Debug("Failed to read symlink, falling back to file contents", "error", err, "symlink_path", currentUnlockerPath)
// Fallback: read the path from file contents
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
}
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
}
} else {
secret.Debug("Reading unlocker path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
secret.Debug("Reading unlocker path (filesystem doesn't support symlinks)")
// Fallback for filesystems that don't support symlinks: read the path from file contents
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
@@ -319,8 +325,21 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
}
}
// Create new symlink
return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms)
// Create new symlink using afero's SymlinkIfPossible
if linker, ok := v.fs.(afero.Linker); ok {
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
return fmt.Errorf("failed to create unlocker symlink: %w", err)
}
} else {
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
}
}
return nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker

View File

@@ -84,6 +84,17 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
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, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
}
secret.DebugWith("Successfully derived long-term key from mnemonic",
slog.String("vault_name", v.Name),
slog.String("public_key", ltIdentity.Recipient().String()),