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