check / check (push) Waiting to run
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, vault import 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
458 lines
13 KiB
Go
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!")
|
|
}
|
|
}
|
|
}
|