feat: add derivation index to vault metadata for unique keys - Add VaultMetadata fields: DerivationIndex, LongTermKeyHash, MnemonicHash - Implement GetNextDerivationIndex() to track and increment indices for same mnemonics - Update init and import commands to use proper derivation indices - Add ComputeDoubleSHA256() for hash calculations - Save vault metadata on creation with all derivation information - Add comprehensive tests for metadata functionality. This ensures multiple vaults using the same mnemonic will derive different long-term keys by using incremented derivation indices. The mnemonic is double SHA256 hashed and stored to track which vaults share mnemonics. Fixes TODO item #5

This commit is contained in:
2025-05-29 16:23:29 -07:00
parent 1a1b11c5a3
commit 34d6870e6a
7 changed files with 378 additions and 18 deletions

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
@@ -74,14 +75,30 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
}
// Derive long-term keypair from mnemonic
secret.DebugWith("Deriving long-term key from mnemonic", slog.Int("index", 0))
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 0)
// Calculate mnemonic hash for index tracking
mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonicStr))
secret.DebugWith("Calculated mnemonic hash", slog.String("hash", mnemonicHash))
// Get the next available derivation index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
if err != nil {
secret.Debug("Failed to get next derivation index", "error", err)
return fmt.Errorf("failed to get next derivation index: %w", err)
}
secret.DebugWith("Using derivation index", slog.Uint64("index", uint64(derivationIndex)))
// Derive long-term keypair from mnemonic with the appropriate index
secret.DebugWith("Deriving long-term key from mnemonic", slog.Uint64("index", uint64(derivationIndex)))
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, derivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key", "error", err)
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
// Calculate the long-term key hash
ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
secret.DebugWith("Calculated long-term key hash", slog.String("hash", ltKeyHash))
// Create the default vault
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
@@ -106,6 +123,20 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
return fmt.Errorf("failed to write long-term public key: %w", err)
}
// Save vault metadata
metadata := &vault.VaultMetadata{
Name: "default",
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
LongTermKeyHash: ltKeyHash,
MnemonicHash: mnemonicHash,
}
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
secret.Debug("Failed to save vault metadata", "error", err)
return fmt.Errorf("failed to save vault metadata: %w", err)
}
secret.Debug("Saved vault metadata with derivation index and key hash")
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strings"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
@@ -164,7 +165,7 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
// Get the specific vault by name
vlt := vault.NewVault(cli.fs, vaultName, cli.stateDir)
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
// Check if vault exists
vaultDir, err := vlt.GetDirectory()
@@ -193,13 +194,29 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
return fmt.Errorf("invalid BIP39 mnemonic")
}
// Derive long-term key from mnemonic
secret.Debug("Deriving long-term key from mnemonic", "index", 0)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
// Calculate mnemonic hash for index tracking
mnemonicHash := vault.ComputeDoubleSHA256([]byte(mnemonic))
secret.Debug("Calculated mnemonic hash", "hash", mnemonicHash)
// Get the next available derivation index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonicHash)
if err != nil {
secret.Debug("Failed to get next derivation index", "error", err)
return fmt.Errorf("failed to get next derivation index: %w", err)
}
secret.Debug("Using derivation index", "index", derivationIndex)
// Derive long-term key from mnemonic with the appropriate index
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil {
return fmt.Errorf("failed to derive long-term key: %w", err)
}
// Calculate the long-term key hash
ltKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.String()))
secret.Debug("Calculated long-term key hash", "hash", ltKeyHash)
// Store long-term public key in vault
ltPublicKey := ltIdentity.Recipient().String()
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
@@ -209,6 +226,20 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
return fmt.Errorf("failed to store long-term public key: %w", err)
}
// Save vault metadata
metadata := &vault.VaultMetadata{
Name: vaultName,
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
LongTermKeyHash: ltKeyHash,
MnemonicHash: mnemonicHash,
}
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, metadata); err != nil {
secret.Debug("Failed to save vault metadata", "error", err)
return fmt.Errorf("failed to save vault metadata: %w", err)
}
secret.Debug("Saved vault metadata with derivation index and key hash")
// Get passphrase from environment variable
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
if passphraseStr == "" {

View File

@@ -6,9 +6,12 @@ import (
// VaultMetadata contains information about a vault
type VaultMetadata struct {
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
DerivationIndex uint32 `json:"derivation_index"`
LongTermKeyHash string `json:"long_term_key_hash"` // Double SHA256 hash of derived long-term private key
MnemonicHash string `json:"mnemonic_hash"` // Double SHA256 hash of mnemonic for index tracking
}
// UnlockKeyMetadata contains information about an unlock key

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"regexp"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
@@ -201,6 +202,18 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
return nil, fmt.Errorf("failed to create unlock keys directory: %w", err)
}
// Save initial vault metadata (without derivation info until a mnemonic is imported)
metadata := &VaultMetadata{
Name: name,
CreatedAt: time.Now(),
DerivationIndex: 0,
LongTermKeyHash: "", // Will be set when mnemonic is imported
MnemonicHash: "", // Will be set when mnemonic is imported
}
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
}
// Select the newly created vault as current
secret.Debug("Selecting newly created vault as current", "name", name)
if err := SelectVault(fs, stateDir, name); err != nil {

View File

@@ -1,7 +1,14 @@
package vault
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
// Alias the metadata types from secret package for convenience
@@ -9,3 +16,104 @@ type VaultMetadata = secret.VaultMetadata
type UnlockKeyMetadata = secret.UnlockKeyMetadata
type SecretMetadata = secret.SecretMetadata
type Configuration = secret.Configuration
// ComputeDoubleSHA256 computes the double SHA256 hash of data and returns it as hex
func ComputeDoubleSHA256(data []byte) string {
firstHash := sha256.Sum256(data)
secondHash := sha256.Sum256(firstHash[:])
return hex.EncodeToString(secondHash[:])
}
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic hash
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonicHash string) (uint32, error) {
vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir)
if err != nil {
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
}
if !exists {
// No vaults yet, start with index 0
return 0, nil
}
// Read all vault directories
entries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return 0, fmt.Errorf("failed to read vaults directory: %w", err)
}
// Track the highest index for this mnemonic
var highestIndex uint32 = 0
foundMatch := false
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// Try to read vault metadata
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
// Skip vaults without metadata
continue
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
// Skip vaults with invalid metadata
continue
}
// Check if this vault uses the same mnemonic
if metadata.MnemonicHash == mnemonicHash {
foundMatch = true
if metadata.DerivationIndex >= highestIndex {
highestIndex = metadata.DerivationIndex
}
}
}
// If we found a match, use the next index
if foundMatch {
return highestIndex + 1, nil
}
// No existing vault with this mnemonic, start at 0
return 0, nil
}
// SaveVaultMetadata saves vault metadata to the vault directory
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *VaultMetadata) error {
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vault metadata: %w", err)
}
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write vault metadata: %w", err)
}
return nil
}
// LoadVaultMetadata loads vault metadata from the vault directory
func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*VaultMetadata, error) {
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 unmarshal vault metadata: %w", err)
}
return &metadata, nil
}

View File

@@ -0,0 +1,175 @@
package vault
import (
"testing"
"path/filepath"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
func TestVaultMetadata(t *testing.T) {
fs := afero.NewMemMapFs()
stateDir := "/test/state"
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
// Test data
data := []byte("test data")
hash := ComputeDoubleSHA256(data)
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
if len(hash) != 64 {
t.Errorf("Expected hash length of 64, got %d", len(hash))
}
// Verify consistency
hash2 := ComputeDoubleSHA256(data)
if hash != hash2 {
t.Errorf("Hash should be consistent for same input")
}
// Verify different input produces different hash
hash3 := ComputeDoubleSHA256([]byte("different data"))
if hash == hash3 {
t.Errorf("Different input should produce different hash")
}
})
t.Run("GetNextDerivationIndex", func(t *testing.T) {
// Test with no existing vaults
index, err := GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 0 {
t.Errorf("Expected index 0 for first vault, got %d", index)
}
// Create a vault with metadata
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
metadata1 := &VaultMetadata{
Name: "vault1",
DerivationIndex: 0,
MnemonicHash: "mnemonic-hash-1",
LongTermKeyHash: "key-hash-1",
}
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
// Next index for same mnemonic should be 1
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 1 {
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
}
// Different mnemonic should start at 0
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-2")
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 0 {
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d", index)
}
// Add another vault with same mnemonic but higher index
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2")
if err := fs.MkdirAll(vaultDir2, 0700); err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
metadata2 := &VaultMetadata{
Name: "vault2",
DerivationIndex: 5,
MnemonicHash: "mnemonic-hash-1",
LongTermKeyHash: "key-hash-2",
}
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
// Next index should be 6
index, err = GetNextDerivationIndex(fs, stateDir, "mnemonic-hash-1")
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 6 {
t.Errorf("Expected index 6 after vault with index 5, got %d", index)
}
})
t.Run("MetadataPersistence", func(t *testing.T) {
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault")
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Create and save metadata
metadata := &VaultMetadata{
Name: "test-vault",
DerivationIndex: 3,
MnemonicHash: "test-mnemonic-hash",
LongTermKeyHash: "test-key-hash",
}
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
// Load and verify
loaded, err := LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load metadata: %v", err)
}
if loaded.Name != metadata.Name {
t.Errorf("Name mismatch: expected %s, got %s", metadata.Name, loaded.Name)
}
if loaded.DerivationIndex != metadata.DerivationIndex {
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
}
if loaded.MnemonicHash != metadata.MnemonicHash {
t.Errorf("MnemonicHash mismatch: expected %s, got %s", metadata.MnemonicHash, loaded.MnemonicHash)
}
if loaded.LongTermKeyHash != metadata.LongTermKeyHash {
t.Errorf("LongTermKeyHash mismatch: expected %s, got %s", metadata.LongTermKeyHash, loaded.LongTermKeyHash)
}
})
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive keys with different indices
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity with index 0: %v", err)
}
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
if err != nil {
t.Fatalf("Failed to derive identity with index 1: %v", err)
}
// Compute hashes
hash0 := ComputeDoubleSHA256([]byte(identity0.String()))
hash1 := ComputeDoubleSHA256([]byte(identity1.String()))
// Verify different indices produce different keys
if hash0 == hash1 {
t.Errorf("Different derivation indices should produce different keys")
}
// Verify public keys are also different
if identity0.Recipient().String() == identity1.Recipient().String() {
t.Errorf("Different derivation indices should produce different public keys")
}
})
}