forked from sneak/secret
Add blank lines before return statements in all files to satisfy the nlreturn linter. This improves code readability by providing visual separation before return statements. Changes made across 24 files: - internal/cli/*.go - internal/secret/*.go - internal/vault/*.go - pkg/agehd/agehd.go - pkg/bip85/bip85.go All 143 nlreturn issues have been resolved.
404 lines
13 KiB
Go
404 lines
13 KiB
Go
package vault
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// GetCurrentUnlocker returns the current unlocker for this vault
|
|
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
|
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
|
|
|
return nil, err
|
|
}
|
|
|
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
|
|
// Check if the symlink exists
|
|
_, err = v.fs.Stat(currentUnlockerPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
|
|
|
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
|
}
|
|
|
|
// Resolve the symlink to get the target directory
|
|
unlockerDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secret.DebugWith("Resolved unlocker directory",
|
|
slog.String("unlocker_dir", unlockerDir),
|
|
slog.String("vault_name", v.Name),
|
|
)
|
|
|
|
// Read unlocker metadata
|
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
secret.Debug("Reading unlocker metadata", "path", metadataPath)
|
|
|
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
|
|
|
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
|
}
|
|
|
|
var metadata UnlockerMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
|
|
|
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
|
}
|
|
|
|
secret.DebugWith("Parsed unlocker metadata",
|
|
slog.String("unlocker_type", metadata.Type),
|
|
slog.Time("created_at", metadata.CreatedAt),
|
|
slog.Any("flags", metadata.Flags),
|
|
)
|
|
|
|
// Create unlocker instance using direct constructors with filesystem
|
|
var unlocker secret.Unlocker
|
|
// Use metadata directly as it's already the correct type
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
|
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
|
case "pgp":
|
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
|
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
|
case "keychain":
|
|
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
|
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
|
default:
|
|
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
|
|
|
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
|
}
|
|
|
|
secret.DebugWith("Successfully created unlocker instance",
|
|
slog.String("unlocker_type", unlocker.GetType()),
|
|
slog.String("unlocker_id", unlocker.GetID()),
|
|
slog.String("vault_name", v.Name),
|
|
)
|
|
|
|
return unlocker, nil
|
|
}
|
|
|
|
// resolveUnlockerDirectory resolves the unlocker directory from a symlink or file
|
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
|
linkReader, ok := v.fs.(afero.LinkReader)
|
|
if !ok {
|
|
// Fallback for filesystems that don't support symlinks
|
|
return v.readUnlockerPathFromFile(currentUnlockerPath)
|
|
}
|
|
|
|
secret.Debug("Resolving unlocker symlink using afero")
|
|
// Try to read as symlink first
|
|
unlockerDir, err := linkReader.ReadlinkIfPossible(currentUnlockerPath)
|
|
if err == nil {
|
|
return unlockerDir, nil
|
|
}
|
|
|
|
secret.Debug("Failed to read symlink, falling back to file contents",
|
|
"error", err, "symlink_path", currentUnlockerPath)
|
|
// Fallback: read the path from file contents
|
|
return v.readUnlockerPathFromFile(currentUnlockerPath)
|
|
}
|
|
|
|
// readUnlockerPathFromFile reads the unlocker directory path from a file
|
|
func (v *Vault) readUnlockerPathFromFile(path string) (string, error) {
|
|
secret.Debug("Reading unlocker path from file", "path", path)
|
|
unlockerDirBytes, err := afero.ReadFile(v.fs, path)
|
|
if err != nil {
|
|
secret.Debug("Failed to read unlocker path file", "error", err, "path", path)
|
|
|
|
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
|
}
|
|
|
|
return strings.TrimSpace(string(unlockerDirBytes)), nil
|
|
}
|
|
|
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
|
|
func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) {
|
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
|
}
|
|
|
|
for _, file := range files {
|
|
if !file.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// Read metadata file
|
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
|
exists, err := afero.Exists(v.fs, metadataPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
if !exists {
|
|
// Skip directories without metadata - they might not be unlockers
|
|
continue
|
|
}
|
|
|
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
|
|
var metadata UnlockerMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
|
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
|
|
|
// Create the appropriate unlocker instance
|
|
var tempUnlocker secret.Unlocker
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
|
case "pgp":
|
|
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
|
case "keychain":
|
|
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
|
default:
|
|
continue
|
|
}
|
|
|
|
// Check if this unlocker's ID matches
|
|
if tempUnlocker.GetID() == unlockerID {
|
|
return tempUnlocker, unlockerDirPath, nil
|
|
}
|
|
}
|
|
|
|
return nil, "", nil
|
|
}
|
|
|
|
// ListUnlockers returns a list of available unlockers for this vault
|
|
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
// Check if unlockers directory exists
|
|
exists, err := afero.DirExists(v.fs, unlockersDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
|
}
|
|
if !exists {
|
|
return []UnlockerMetadata{}, nil
|
|
}
|
|
|
|
// List directories in unlockers.d
|
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
|
}
|
|
|
|
var unlockers []UnlockerMetadata
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
// Read metadata file
|
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
|
exists, err := afero.Exists(v.fs, metadataPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
if !exists {
|
|
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name())
|
|
}
|
|
|
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
|
|
var metadata UnlockerMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
|
}
|
|
|
|
unlockers = append(unlockers, metadata)
|
|
}
|
|
}
|
|
|
|
return unlockers, nil
|
|
}
|
|
|
|
// RemoveUnlocker removes an unlocker from this vault
|
|
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the unlocker directory and create the unlocker instance
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
// Find the unlocker by ID
|
|
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if unlocker == nil {
|
|
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
|
}
|
|
|
|
// Use the unlocker's Remove method
|
|
return unlocker.Remove()
|
|
}
|
|
|
|
// SelectUnlocker selects an unlocker as current for this vault
|
|
func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the unlocker directory by ID
|
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
|
|
// Find the unlocker by ID
|
|
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if targetUnlockerDir == "" {
|
|
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
|
}
|
|
|
|
// Create/update current unlocker symlink
|
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
|
|
|
// Remove existing symlink if it exists
|
|
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
|
return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err)
|
|
} else if exists {
|
|
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
|
return fmt.Errorf("failed to remove existing unlocker symlink: %w", err)
|
|
}
|
|
}
|
|
|
|
// Create new symlink using afero's SymlinkIfPossible
|
|
if linker, ok := v.fs.(afero.Linker); ok {
|
|
secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath)
|
|
if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil {
|
|
return fmt.Errorf("failed to create unlocker symlink: %w", err)
|
|
}
|
|
} else {
|
|
// Fallback: create a regular file with the target path for filesystems that don't support symlinks
|
|
secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir)
|
|
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil {
|
|
return fmt.Errorf("failed to create unlocker symlink file: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
|
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
// Create unlocker directory
|
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
|
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
|
}
|
|
|
|
// Generate new age keypair for unlocker
|
|
unlockerIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
|
}
|
|
|
|
// Write public key
|
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
|
if err := afero.WriteFile(v.fs, pubKeyPath,
|
|
[]byte(unlockerIdentity.Recipient().String()),
|
|
secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
|
}
|
|
|
|
// Encrypt private key with passphrase
|
|
privKeyData := []byte(unlockerIdentity.String())
|
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
|
}
|
|
|
|
// Write encrypted private key
|
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
|
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
|
}
|
|
|
|
// Create metadata
|
|
metadata := UnlockerMetadata{
|
|
Type: "passphrase",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{},
|
|
}
|
|
|
|
// Write metadata
|
|
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
|
}
|
|
|
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
}
|
|
|
|
// Encrypt long-term private key to this unlocker
|
|
// We need to get the long-term key (either from memory if unlocked, or derive it)
|
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
}
|
|
|
|
ltPrivKey := []byte(ltIdentity.String())
|
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
|
}
|
|
|
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
}
|
|
|
|
// Create the unlocker instance
|
|
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
|
|
|
// Select this unlocker as current
|
|
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
|
}
|
|
|
|
return unlocker, nil
|
|
}
|