Files
secret/internal/vault/metadata.go
T
sneak 3ba8857a03
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, and vault create, vault import and init
stop before writing anything. A vault directory with no metadata file is
still passed over.

The regression test corrupts one vault's metadata and requires CreateVault
with the same mnemonic to fail, naming the file, and leave no new vault.

Unverified by test: an unreadable metadata file; checked by hand only.

Model: opus-5-5
2026-10-14 21:03:59 +00:00

149 lines
4.0 KiB
Go

package vault
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
)
// Metadata is an alias for secret.VaultMetadata
type Metadata = secret.VaultMetadata
// UnlockerMetadata is an alias for secret.UnlockerMetadata
type UnlockerMetadata = secret.UnlockerMetadata
// SecretMetadata is an alias for secret.Metadata
type SecretMetadata = secret.Metadata
// Configuration is an alias for secret.Configuration
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
// by deriving the public key for index 0 and using its hash to identify related vaults.
// It fails when a vault's metadata file exists but cannot be read or parsed: the
// index that vault uses is then unknown, and handing it out again would give a
// second vault the same long-term key.
func GetNextDerivationIndex(
fs afero.Fs, stateDir string, mnemonic string,
) (uint32, error) {
// First, derive the public key for index 0 to get our identifier
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil {
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
}
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
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 which indices are in use for this mnemonic
usedIndices := make(map[uint32]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if errors.Is(err, os.ErrNotExist) {
// Skip directories without metadata
continue
}
if err != nil {
return 0, fmt.Errorf("failed to read vault metadata %s: %w", metadataPath, err)
}
var metadata Metadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return 0, fmt.Errorf("failed to parse vault metadata %s: %w", metadataPath, err)
}
// Check if this vault uses the same mnemonic by comparing family hashes
if metadata.MnemonicFamilyHash == pubKeyHash {
usedIndices[metadata.DerivationIndex] = true
}
}
// Find the first available index
var index uint32
for usedIndices[index] {
index++
}
return index, nil
}
// SaveVaultMetadata saves vault metadata to the vault directory
func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) 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)
}
err = secret.WriteFileAtomic(fs, metadataPath, metadataBytes)
if 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) (*Metadata, 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 Metadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
}
return &metadata, nil
}