All checks were successful
check / check (push) Successful in 2m0s
- Replace .golangci.yml with the canonical strict config (all linters enabled except the standard disable list; lll 88, funlen 80/50, cyclop 15, dupl 100; test files now linted) - Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by tag and digest (Debian-based) - Fix all ~1550 findings surfaced by the new config: line wrapping, wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel errors, perfsprint/modernize rewrites, goconst constants, thelper, testifylint, noctx CommandContext, testpackage conversions, t.Parallel() where safe, and complexity/dupl helper extraction - Record the change and follow-up items in TODO.md User-visible strings -------------------- No user-visible string changes remain. Every error message this branch composes is byte-identical to the one main composes. The err113 sentinels are shaped so that fmt.Errorf reassembles the original text around them: a sentinel carries the fixed words of the message and the caller supplies the interpolated value in the position it has always occupied. Where the value sits in the middle of the sentence the sentinel therefore holds only a fragment (for example vault.ErrVaultNotFound is "does not exist", composed by its caller as "vault <name> does not exist"); each such sentinel documents the message it participates in. Verified mechanically rather than by inspection: every fmt.Errorf and errors.New call site in both trees was parsed, the Error() text of any sentinel passed to %w substituted in, and the resulting sets of composed message templates compared. All 350 templates main produces are still produced, character for character; the set of messages lost or altered is empty. unlocker list ------------- findUnlockerIDByMetadata now returns (string, error) instead of signalling failure with an empty ID. An unreadable unlockers.d is no longer indistinguishable from "no matching entry", so UnlockersList skips the entry with a warning naming the directory, as it did before the scan was extracted into a helper, rather than emitting a row under a synthesized fallback ID that no unlocker remove or unlocker select can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by tests in internal/cli/unlockers_list_test.go.
321 lines
9.4 KiB
Go
321 lines
9.4 KiB
Go
// Package vault provides functionality for managing encrypted vaults.
|
|
package vault
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// Register the GetCurrentVault function with the secret package
|
|
//
|
|
//nolint:gochecknoinits // registers the vault accessor with the secret package
|
|
func init() {
|
|
secret.RegisterGetCurrentVaultFunc(
|
|
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
|
return GetCurrentVault(fs, stateDir)
|
|
})
|
|
}
|
|
|
|
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
|
// Note: We don't allow slashes in vault names unlike secret names
|
|
func isValidVaultName(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
|
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
|
|
|
return matched
|
|
}
|
|
|
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
|
// The file contains just the vault name (e.g., "default")
|
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
|
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
|
|
|
fileData, err := afero.ReadFile(fs, currentVaultPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to read currentvault file", "error", err)
|
|
|
|
return "", fmt.Errorf("failed to read currentvault file: %w", err)
|
|
}
|
|
|
|
// The file contains just the vault name like "default"
|
|
vaultName := strings.TrimSpace(string(fileData))
|
|
secret.Debug("Read vault name from file", "vault_name", vaultName)
|
|
|
|
// Resolve to absolute path: stateDir/vaults.d/vaultName
|
|
stateDir := filepath.Dir(currentVaultPath)
|
|
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
|
|
|
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
|
|
|
return absolutePath, nil
|
|
}
|
|
|
|
// GetCurrentVault gets the current vault from the file system
|
|
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|
secret.Debug("Getting current vault", "state_dir", stateDir)
|
|
|
|
// Check if the current vault symlink exists
|
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
|
|
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
|
|
|
_, err := fs.Stat(currentVaultPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to stat current vault symlink",
|
|
"error", err, "path", currentVaultPath)
|
|
|
|
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
|
}
|
|
|
|
secret.Debug("Current vault symlink exists")
|
|
|
|
// Resolve the symlink to get the actual vault directory
|
|
secret.Debug("Resolving vault symlink")
|
|
|
|
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secret.Debug("Resolved vault symlink", "target_path", targetPath)
|
|
|
|
// Extract the vault name from the path
|
|
// The path will be something like "/path/to/vaults.d/default"
|
|
vaultName := filepath.Base(targetPath)
|
|
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
|
|
|
secret.Debug("Current vault resolved",
|
|
"vault_name", vaultName, "target_path", targetPath)
|
|
|
|
// Create and return the vault
|
|
return NewVault(fs, stateDir, vaultName), nil
|
|
}
|
|
|
|
// ListVaults lists all vaults in the state directory
|
|
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
|
|
|
// Check if vaults directory exists
|
|
exists, err := afero.DirExists(fs, vaultsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return []string{}, nil
|
|
}
|
|
|
|
// Read the vaults directory
|
|
entries, err := afero.ReadDir(fs, vaultsDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
|
|
}
|
|
|
|
// Extract vault names
|
|
var vaults []string
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
vaults = append(vaults, entry.Name())
|
|
}
|
|
}
|
|
|
|
return vaults, nil
|
|
}
|
|
|
|
// processMnemonicForVault handles mnemonic processing for vault creation.
|
|
// It returns the derivation index, public key hash, and family hash.
|
|
func processMnemonicForVault(
|
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
|
) (uint32, string, string, error) {
|
|
// Check if mnemonic is available in environment
|
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
|
|
|
if mnemonic == "" {
|
|
secret.Debug("No mnemonic in environment, vault created without long-term key",
|
|
"vault", vaultName)
|
|
// Use 0 for derivation index when no mnemonic is provided
|
|
return 0, "", "", nil
|
|
}
|
|
|
|
secret.Debug("Mnemonic found in environment, deriving long-term key",
|
|
"vault", vaultName)
|
|
|
|
// Get the next available derivation index for this mnemonic
|
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
|
if err != nil {
|
|
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
|
}
|
|
|
|
// Derive the long-term key using the actual derivation index
|
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
|
if err != nil {
|
|
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
|
}
|
|
|
|
// Write the public key
|
|
ltPubKey := ltIdentity.Recipient().String()
|
|
|
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
|
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms)
|
|
if err != nil {
|
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
|
}
|
|
|
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
|
|
|
// Compute verification hash from actual derivation index
|
|
publicKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
|
|
|
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
|
// This is used to identify which vaults belong to the same mnemonic family
|
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
if err != nil {
|
|
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
|
}
|
|
|
|
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
|
|
|
return derivationIndex, publicKeyHash, familyHash, nil
|
|
}
|
|
|
|
// CreateVault creates a new vault
|
|
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
|
|
|
// Validate vault name
|
|
if !isValidVaultName(name) {
|
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
|
|
|
return nil, fmt.Errorf(
|
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
ErrInvalidVaultName, name,
|
|
)
|
|
}
|
|
|
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
|
|
|
// Create vault directory structure
|
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
|
|
|
// Create main vault directory
|
|
err := fs.MkdirAll(vaultDir, secret.DirPerms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
|
}
|
|
|
|
// Create secrets directory
|
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
|
|
|
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
|
}
|
|
|
|
// Create unlockers directory
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
|
}
|
|
|
|
// Process mnemonic if available
|
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
|
fs, stateDir, vaultDir, name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Save vault metadata
|
|
metadata := &Metadata{
|
|
CreatedAt: time.Now(),
|
|
DerivationIndex: derivationIndex,
|
|
PublicKeyHash: publicKeyHash,
|
|
MnemonicFamilyHash: familyHash,
|
|
}
|
|
|
|
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
|
}
|
|
|
|
// Select the newly created vault as current
|
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
|
|
|
err = SelectVault(fs, stateDir, name)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to select vault: %w", err)
|
|
}
|
|
|
|
// Create and return the vault
|
|
secret.Debug("Successfully created vault", "name", name)
|
|
|
|
return NewVault(fs, stateDir, name), nil
|
|
}
|
|
|
|
// SelectVault selects the given vault as the current vault
|
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
|
|
|
// Validate vault name
|
|
if !isValidVaultName(name) {
|
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
|
|
|
return fmt.Errorf(
|
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
ErrInvalidVaultName, name,
|
|
)
|
|
}
|
|
|
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
|
|
|
// Check if vault exists
|
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
|
|
|
exists, err := afero.DirExists(fs, vaultDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if vault exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
|
}
|
|
|
|
// Create or update the currentvault file with just the vault name
|
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
|
|
// Remove existing file if it exists
|
|
_, err = fs.Stat(currentVaultPath)
|
|
if err == nil {
|
|
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
|
|
|
_ = fs.Remove(currentVaultPath)
|
|
}
|
|
|
|
// Write just the vault name to the file
|
|
secret.Debug("Writing currentvault file", "vault_name", name)
|
|
|
|
err = afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to select vault: %w", err)
|
|
}
|
|
|
|
secret.Debug("Successfully selected vault", "vault_name", name)
|
|
|
|
return nil
|
|
}
|