From 2a4ceb2045a6ec43c40f42d7c4e9be474a8e761b Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 12:03:06 -0800 Subject: [PATCH 1/2] fix: use vault derivation index in getLongTermPrivateKey instead of hardcoded 0 Previously, getLongTermPrivateKey() always used derivation index 0 when deriving the long-term key from a mnemonic. This caused wrong key derivation for vaults with index > 0 (second+ vault from same mnemonic), leading to silent data corruption in keychain unlocker creation. Now reads the vault's actual DerivationIndex from vault-metadata.json. --- internal/secret/keychainunlocker.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/secret/keychainunlocker.go b/internal/secret/keychainunlocker.go index c19705a..c544214 100644 --- a/internal/secret/keychainunlocker.go +++ b/internal/secret/keychainunlocker.go @@ -251,8 +251,25 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB // Check if mnemonic is available in environment variable envMnemonic := os.Getenv(EnvMnemonic) if envMnemonic != "" { - // Use mnemonic directly to derive long-term key - ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) + // Read vault metadata to get the correct derivation index + vaultDir, err := vault.GetDirectory() + if err != nil { + return nil, fmt.Errorf("failed to get vault directory: %w", err) + } + + metadataPath := filepath.Join(vaultDir, "vault-metadata.json") + metadataBytes, err := afero.ReadFile(fs, metadataPath) + if err != nil { + return nil, fmt.Errorf("failed to read vault metadata: %w", err) + } + + var metadata VaultMetadata + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse vault metadata: %w", err) + } + + // Use mnemonic with the vault's actual derivation index + ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) if err != nil { return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) } -- 2.45.2 From 79ae572cc3fa4c136c18abffc3922d1701e191b9 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 8 Feb 2026 17:21:31 -0800 Subject: [PATCH 2/2] test: add test for getLongTermPrivateKey derivation index Verifies that getLongTermPrivateKey reads the derivation index from vault metadata instead of using hardcoded index 0. Test creates a mock vault with DerivationIndex=5 and confirms the derived key matches index 5. --- internal/secret/derivation_index_test.go | 82 ++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 internal/secret/derivation_index_test.go diff --git a/internal/secret/derivation_index_test.go b/internal/secret/derivation_index_test.go new file mode 100644 index 0000000..ad86553 --- /dev/null +++ b/internal/secret/derivation_index_test.go @@ -0,0 +1,82 @@ +package secret + +import ( + "encoding/json" + "path/filepath" + "testing" + "time" + + "git.eeqj.de/sneak/secret/pkg/agehd" + "github.com/awnumar/memguard" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// realVault is a minimal VaultInterface backed by a real afero filesystem, +// using the same directory layout as vault.Vault. +type realVault struct { + name string + stateDir string + fs afero.Fs +} + +func (v *realVault) GetDirectory() (string, error) { + return filepath.Join(v.stateDir, "vaults.d", v.name), nil +} +func (v *realVault) GetName() string { return v.name } +func (v *realVault) GetFilesystem() afero.Fs { return v.fs } + +// Unused by getLongTermPrivateKey — these satisfy VaultInterface. +func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") } +func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } +func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) { + panic("not used") +} + +// createRealVault sets up a complete vault directory structure on an in-memory +// filesystem, identical to what vault.CreateVault produces. +func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault { + t.Helper() + + vaultDir := filepath.Join(stateDir, "vaults.d", name) + require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)) + require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms)) + + metadata := VaultMetadata{ + CreatedAt: time.Now(), + DerivationIndex: derivationIndex, + } + metaBytes, err := json.Marshal(metadata) + require.NoError(t, err) + require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms)) + + return &realVault{name: name, stateDir: stateDir, fs: fs} +} + +func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) { + const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + // Derive expected keys at two different indices to prove they differ. + key0, err := agehd.DeriveIdentity(testMnemonic, 0) + require.NoError(t, err) + key5, err := agehd.DeriveIdentity(testMnemonic, 5) + require.NoError(t, err) + require.NotEqual(t, key0.String(), key5.String(), + "sanity check: different derivation indices must produce different keys") + + // Build a real vault with DerivationIndex=5 on an in-memory filesystem. + fs := afero.NewMemMapFs() + vault := createRealVault(t, fs, "/state", "test-vault", 5) + + t.Setenv(EnvMnemonic, testMnemonic) + + result, err := getLongTermPrivateKey(fs, vault) + require.NoError(t, err) + defer result.Destroy() + + assert.Equal(t, key5.String(), string(result.Bytes()), + "getLongTermPrivateKey should derive at vault's DerivationIndex (5)") + assert.NotEqual(t, key0.String(), string(result.Bytes()), + "getLongTermPrivateKey must not use hardcoded index 0") +} -- 2.45.2