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, 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
149 lines
4.0 KiB
Go
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
|
|
}
|