Files
secret/internal/vault/metadata_test.go
T
sneak 41965aa810
check / check (push) Waiting to run
Stop vault creation when a vault's metadata cannot be read (closes #140)
GetNextDerivationIndex passed over a vault whose vault-metadata.json could
not be read or parsed, so the next vault made from the same mnemonic could
get that vault's derivation index and so the same long-term key. It now
returns an error naming the file, so vault create and init stop with that
error and leave the state directory as it was. A vault directory with no
metadata file is still skipped.

The regression test breaks one vault's metadata, once with content that
does not parse and once with a test filesystem that fails to open it, and
requires CreateVault with the same mnemonic to fail, naming the file, and
leave no new vault.

Model: opus-5-5
2026-10-15 03:32:28 +00:00

458 lines
13 KiB
Go

package vault_test
import (
"errors"
"path/filepath"
"strings"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
)
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
func TestVaultMetadata(t *testing.T) {
fs := afero.NewMemMapFs()
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
testComputeDoubleSHA256(t)
})
t.Run("GetNextDerivationIndex", func(t *testing.T) {
testGetNextDerivationIndex(t, fs)
})
t.Run("MetadataPersistence", func(t *testing.T) {
testMetadataPersistence(t, fs)
})
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
testDifferentKeysForDifferentIndices(t)
})
}
func testComputeDoubleSHA256(t *testing.T) {
t.Helper()
// Test data
data := []byte("test data")
hash := vault.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 := vault.ComputeDoubleSHA256(data)
if hash != hash2 {
t.Errorf("Hash should be consistent for same input")
}
// Verify different input produces different hash
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
if hash == hash3 {
t.Errorf("Different input should produce different hash")
}
}
// createVaultDirWithMetadata creates a vault directory containing a public
// key derived from testMnemonic at the given index plus saved metadata, and
// returns the derived public key hash. An empty familyHash defaults to the
// derived key's own hash.
func createVaultDirWithMetadata(
t *testing.T, fs afero.Fs, vaultName string,
derivationIndex uint32, familyHash string,
) string {
t.Helper()
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
err := fs.MkdirAll(vaultDir, 0o700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Derive identity for the requested index
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
if err != nil {
t.Fatalf("Failed to derive identity: %v", err)
}
pubKey := identity.Recipient().String()
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
// Write public key
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
[]byte(pubKey), 0o600)
if err != nil {
t.Fatalf("Failed to write public key: %v", err)
}
if familyHash == "" {
familyHash = pubKeyHash
}
metadata := &vault.Metadata{
DerivationIndex: derivationIndex,
PublicKeyHash: pubKeyHash,
MnemonicFamilyHash: familyHash,
}
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
return pubKeyHash
}
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
t.Helper()
// Test with no existing vaults
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
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 and matching public key (index 0; the
// family hash is the index 0 key hash)
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
// Next index for same mnemonic should be 1
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
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
//nolint:dupword // BIP39-style test mnemonic
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
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 (5), sharing
// the same family hash since it's from the same mnemonic
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
// Next index should be 1 (not 6): we look for the first available slot
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get derivation index: %v", err)
}
if index != 1 {
t.Errorf("Expected index 1 (first available), got %d", index)
}
}
// errMetadataUnreadable is returned by the test filesystem in place of a
// successful open of one vault's metadata file.
var errMetadataUnreadable = errors.New("input/output error")
// metadataReadFailFs fails every open of the file at unreadablePath. The
// file still exists, so checking for it succeeds and only reading it fails.
type metadataReadFailFs struct {
afero.Fs
unreadablePath string
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
if name == f.unreadablePath {
return nil, errMetadataUnreadable
}
return f.Fs.Open(name)
}
// TestCreateVaultWithCorruptMetadataFails checks that creating a vault from
// a mnemonic fails, naming the file and leaving no new vault, when another
// vault's metadata file exists but cannot be parsed or read. That vault's
// derivation index is then unknown, so the new vault could get the same one
// and the same key.
func TestCreateVaultWithCorruptMetadataFails(t *testing.T) {
t.Parallel()
vaultsDir := filepath.Join(testStateDir, "vaults.d")
metadataPath := filepath.Join(vaultsDir, "work", "vault-metadata.json")
tests := []struct {
name string
// breakMetadata makes the work vault's metadata file unusable on
// base and returns the filesystem to create the second vault on.
breakMetadata func(t *testing.T, base afero.Fs) afero.Fs
}{
{
name: "parsing the file fails",
breakMetadata: func(t *testing.T, base afero.Fs) afero.Fs {
t.Helper()
err := afero.WriteFile(base, metadataPath, []byte("{"),
secret.FilePerms)
require.NoError(t, err)
return base
},
},
{
name: "reading the file fails",
breakMetadata: func(_ *testing.T, base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: metadataPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testStateDir, "work",
testMnemonicBuffer(t), nil)
require.NoError(t, err)
fs := tt.breakMetadata(t, base)
_, err = vault.CreateVault(fs, testStateDir, "other",
testMnemonicBuffer(t), nil)
require.ErrorContains(t, err, metadataPath)
entries, err := afero.ReadDir(base, vaultsDir)
require.NoError(t, err)
require.Len(t, entries, 1)
assert.Equal(t, "work", entries[0].Name())
})
}
}
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
t.Helper()
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
err := fs.MkdirAll(vaultDir, 0o700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err)
}
// Create and save metadata
metadata := &vault.Metadata{
DerivationIndex: 3,
PublicKeyHash: "test-public-key-hash",
}
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
t.Fatalf("Failed to save metadata: %v", err)
}
// Load and verify
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil {
t.Fatalf("Failed to load metadata: %v", err)
}
if loaded.DerivationIndex != metadata.DerivationIndex {
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
metadata.DerivationIndex, loaded.DerivationIndex)
}
if loaded.PublicKeyHash != metadata.PublicKeyHash {
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
metadata.PublicKeyHash, loaded.PublicKeyHash)
}
}
func testDifferentKeysForDifferentIndices(t *testing.T) {
t.Helper()
// 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 public key hashes
pubKey0 := identity0.Recipient().String()
pubKey1 := identity1.Recipient().String()
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
// Verify different indices produce different public keys
if pubKey0 == pubKey1 {
t.Errorf("Different derivation indices should produce different public keys")
}
// But the hash of index 0's public key should be the same for the same
// mnemonic. This is what we use as the identifier
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
pubKey0Again := identity0Again.Recipient().String()
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
if hash0 != hash0Again {
t.Errorf("Same mnemonic should produce same public key hash for index 0")
}
}
func TestPublicKeyHashConsistency(t *testing.T) {
t.Parallel()
// Derive identity from index 0 multiple times
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive first identity: %v", err)
}
identity2, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive second identity: %v", err)
}
// Verify identities are the same
if identity1.Recipient().String() != identity2.Recipient().String() {
t.Errorf("Identity derivation is not deterministic")
t.Logf("First: %s", identity1.Recipient().String())
t.Logf("Second: %s", identity2.Recipient().String())
}
// Compute public key hashes
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
// Verify hashes are the same
if hash1 != hash2 {
t.Errorf("Public key hash computation is not deterministic")
t.Logf("First hash: %s", hash1)
t.Logf("Second hash: %s", hash2)
}
t.Logf("Test mnemonic public key hash (index 0): %s", hash1)
}
func TestSampleHashCalculation(t *testing.T) {
t.Parallel()
// Test with the exact mnemonic from integration test if available. We
// also test with a few different mnemonics to make sure they produce
// different hashes
mnemonics := []string{
testMnemonic,
"legal winner thank year wave sausage worth useful legal winner thank yellow",
//nolint:dupword // BIP39-style test mnemonic
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
}
for i, mnemonic := range mnemonics {
identity, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
t.Logf(" Recipient: %s", identity.Recipient().String())
}
}
func TestReverseEngineerHash(t *testing.T) {
t.Parallel()
// This is the hash that the work vault is getting in the failing test
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
// Calculate hash for test mnemonic
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive identity: %v", err)
}
calculatedHash := vault.ComputeDoubleSHA256(
[]byte(identity.Recipient().String()))
t.Logf("Test mnemonic hash: %s", calculatedHash)
if calculatedHash == correctHash {
t.Logf("Test mnemonic produces the correct hash")
} else {
t.Errorf("Test mnemonic does not produce the correct hash")
}
if calculatedHash == wrongHash {
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
}
// Try some other possibilities: maybe a string normalization issue?
variations := []string{
testMnemonic,
" " + testMnemonic + " ",
testMnemonic + "\n",
strings.TrimSpace(testMnemonic),
}
for i, variation := range variations {
identity, err := agehd.DeriveIdentity(variation, 0)
if err != nil {
t.Logf("Variation %d failed: %v", i, err)
continue
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Variation %d hash: %s", i, hash)
if hash == wrongHash {
t.Logf("Found variation that produces wrong hash: '%s'", variation)
}
}
// Maybe let's try an empty mnemonic or something else?
emptyMnemonics := []string{
"",
" ",
}
for i, emptyMnemonic := range emptyMnemonics {
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
if err != nil {
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
continue
}
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
t.Logf("Empty mnemonic %d hash: %s", i, hash)
if hash == wrongHash {
t.Logf("Empty mnemonic produces wrong hash!")
}
}
}