Compare commits

..

2 Commits

Author SHA1 Message Date
clawbot
79ae572cc3 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.
2026-02-08 17:45:34 -08:00
clawbot
2a4ceb2045 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.
2026-02-08 12:03:06 -08:00
7 changed files with 163 additions and 63 deletions

View File

@ -7,10 +7,12 @@ import (
"path/filepath"
"strings"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39"
)
@ -152,8 +154,35 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
return fmt.Errorf("failed to create unlocker: %w", err)
}
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
// private key to longterm.age, so no need to do it again here.
// Encrypt long-term private key to the unlocker
unlockerDir := passphraseUnlocker.GetDirectory()
// Read unlocker public key
unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
if err != nil {
return fmt.Errorf("failed to read unlocker public key: %w", err)
}
unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
if err != nil {
return fmt.Errorf("failed to parse unlocker public key: %w", err)
}
// Encrypt long-term private key to unlocker
// Use memguard to protect the private key in memory
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerRecipient)
if err != nil {
return fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(cli.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n")

View File

@ -68,11 +68,6 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
// Create a secure buffer for the decrypted data
resultBuffer := memguard.NewBufferFromBytes(result)
// Zero out the original slice to prevent plaintext from lingering in unprotected memory
for i := range result {
result[i] = 0
}
return resultBuffer, nil
}

View File

@ -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")
}

View File

@ -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)
}

View File

@ -4,8 +4,6 @@
package secret
import (
"fmt"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@ -24,59 +22,52 @@ type KeychainUnlocker struct {
fs afero.Fs
}
var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS")
// GetIdentity returns an error on non-Darwin platforms
// GetIdentity panics on non-Darwin platforms
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
return nil, errKeychainNotSupported
panic("keychain unlockers are only supported on macOS")
}
// GetType returns the unlocker type
// GetType panics on non-Darwin platforms
func (k *KeychainUnlocker) GetType() string {
return "keychain"
panic("keychain unlockers are only supported on macOS")
}
// GetMetadata returns the unlocker metadata
// GetMetadata panics on non-Darwin platforms
func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
return k.Metadata
panic("keychain unlockers are only supported on macOS")
}
// GetDirectory returns the unlocker directory
// GetDirectory panics on non-Darwin platforms
func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory
panic("keychain unlockers are only supported on macOS")
}
// GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string {
return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04"))
panic("keychain unlockers are only supported on macOS")
}
// GetKeychainItemName returns an error on non-Darwin platforms
// GetKeychainItemName panics on non-Darwin platforms
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
return "", errKeychainNotSupported
panic("keychain unlockers are only supported on macOS")
}
// Remove returns an error on non-Darwin platforms
// Remove panics on non-Darwin platforms
func (k *KeychainUnlocker) Remove() error {
return errKeychainNotSupported
panic("keychain unlockers are only supported on macOS")
}
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin platforms.
// The returned instance's methods that require macOS functionality will return errors.
// NewKeychainUnlocker panics on non-Darwin platforms
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
panic("keychain unlockers are only supported on macOS")
}
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
// CreateKeychainUnlocker panics on non-Darwin platforms
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
panic("keychain unlockers are only supported on macOS")
}
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
return nil, errKeychainNotSupported
// getLongTermPrivateKey panics on non-Darwin platforms
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
panic("keychain unlockers are only supported on macOS")
}

View File

@ -227,23 +227,27 @@ func (v *Vault) NumSecrets() (int, error) {
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
}
// Count only directories that have a "current" version pointer file
// Count only directories that contain at least one version file
count := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// A valid secret has a "current" file pointing to the active version
// Check if this secret directory contains any version files
secretDir := filepath.Join(secretsDir, entry.Name())
currentFile := filepath.Join(secretDir, "current")
exists, err := afero.Exists(v.fs, currentFile)
versionFiles, err := afero.ReadDir(v.fs, secretDir)
if err != nil {
continue // Skip directories we can't read
}
if exists {
count++
// Look for at least one version file (excluding "current" symlink)
for _, vFile := range versionFiles {
if !vFile.IsDir() && vFile.Name() != "current" {
count++
break // Found at least one version, count this secret
}
}
}

View File

@ -162,24 +162,6 @@ func TestVaultOperations(t *testing.T) {
}
})
// Test NumSecrets
t.Run("NumSecrets", func(t *testing.T) {
vlt, err := GetCurrentVault(fs, stateDir)
if err != nil {
t.Fatalf("Failed to get current vault: %v", err)
}
numSecrets, err := vlt.NumSecrets()
if err != nil {
t.Fatalf("Failed to count secrets: %v", err)
}
// We added one secret in SecretOperations
if numSecrets != 1 {
t.Errorf("Expected 1 secret, got %d", numSecrets)
}
})
// Test unlocker operations
t.Run("UnlockerOperations", func(t *testing.T) {
vlt, err := GetCurrentVault(fs, stateDir)