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:
Jeffrey Paul 2025-05-29 16:23:29 -07:00
parent 1a1b11c5a3
commit 34d6870e6a
7 changed files with 378 additions and 18 deletions

15
TODO.md
View File

@ -6,7 +6,6 @@ This document outlines the bugs, issues, and improvements that need to be addres
### Error Handling and User Experience
- [ ] **1. Inappropriate Cobra usage printing**: Commands currently print usage information for all errors, including internal program failures. Usage should only be printed when the user provides incorrect arguments or invalid commands, not when the program encounters internal errors (like file system issues, crypto failures, etc.).
- [ ] **2. Inconsistent error messages**: Error messages need standardization and should be user-friendly. Many errors currently expose internal implementation details.
@ -17,7 +16,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
### Core Functionality Bugs
- [ ] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
- [x] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
- [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/`
@ -45,15 +44,15 @@ This document outlines the bugs, issues, and improvements that need to be addres
- [ ] **14. Improve progress indicators**: Long operations (key generation, encryption) should show progress.
- [ ] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
- [x] **15. Better secret name validation**: Currently allows some characters that may cause issues, needs comprehensive validation.
- [ ] **16. Add `--help` examples**: Command help should include practical examples for each operation.
### Command Implementation Gaps
- [ ] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
- [x] **17. `secret keys rm` not fully implemented**: Based on test output, this command may not be working correctly.
- [ ] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
- [x] **18. `secret key select` not fully implemented**: Key selection functionality appears incomplete.
- [ ] **19. Missing vault deletion command**: No way to delete vaults that are no longer needed.
@ -71,7 +70,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
### PGP Integration Issues
- [ ] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
- [x] **25. Incomplete PGP unlock key implementation**: The `--keyid` parameter processing may not be fully working.
- [ ] **26. Missing GPG agent integration**: Should detect and use existing GPG agent when available.
@ -149,9 +148,9 @@ This document outlines the bugs, issues, and improvements that need to be addres
### Testing Infrastructure
- [ ] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
- [x] **54. Mock filesystem consistency**: Ensure mock filesystem behavior matches real filesystem in all cases.
- [ ] **55. Integration test isolation**: Tests should not affect each other or the host system.
- [x] **55. Integration test isolation**: Tests should not affect each other or the host system.
- [ ] **56. Performance benchmarks**: Add benchmarks for crypto operations and file I/O.

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