package secret import ( "encoding/json" "errors" "fmt" "log/slog" "os" "path/filepath" "strings" "time" "filippo.io/age" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/awnumar/memguard" "github.com/spf13/afero" ) var ( // errSecretNotFound carries only the message tail; callers compose // "secret not found" around it so the emitted text is // unchanged. errSecretNotFound = errors.New("not found") errUnlockerRequired = errors.New("unlocker required to decrypt secret") errGetEncryptedDataDeprecated = errors.New( "GetEncryptedData is deprecated - use version-specific methods") errGetCurrentVaultNotRegistered = errors.New( "GetCurrentVault function not registered") ) // VaultInterface defines the interface that vault implementations must satisfy type VaultInterface interface { GetDirectory() (string, error) AddSecret(name string, value *memguard.LockedBuffer, force bool) error GetName() string GetFilesystem() afero.Fs GetCurrentUnlocker() (Unlocker, error) CreatePassphraseUnlocker( passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error) } // Secret represents a secret in a vault type Secret struct { Name string Directory string Metadata Metadata vault VaultInterface } // NewSecret creates a new Secret instance func NewSecret(vault VaultInterface, name string) *Secret { DebugWith("Creating new secret instance", slog.String("secret_name", name), slog.String("vault_name", vault.GetName()), ) // Convert slashes to percent signs for storage directory name storageName := strings.ReplaceAll(name, "/", "%") vaultDir, _ := vault.GetDirectory() secretDir := filepath.Join(vaultDir, "secrets.d", storageName) DebugWith("Secret storage details", slog.String("secret_name", name), slog.String("storage_name", storageName), slog.String("secret_dir", secretDir), ) return &Secret{ Name: name, Directory: secretDir, vault: vault, Metadata: Metadata{ CreatedAt: time.Now(), UpdatedAt: time.Now(), }, } } // GetValue retrieves and decrypts the current version's value using the // provided unlocker func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) { DebugWith("Getting secret value", slog.String("secret_name", s.Name), slog.String("vault_name", s.vault.GetName()), ) // Check if secret exists exists, err := s.Exists() if err != nil { Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to check if secret exists: %w", err) } if !exists { Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName()) return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound) } Debug("Secret exists, getting current version", "secret_name", s.Name) // Get current version currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory) if err != nil { Debug("Failed to get current version", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to get current version: %w", err) } // Create version object version := NewVersion(s.vault, s.Name, currentVersion) // Check for SB_SECRET_MNEMONIC environment variable for direct decryption if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { return s.getValueViaMnemonic(version, envMnemonic) } Debug("Using unlocker for vault access", "secret_name", s.Name) // Use the provided unlocker to get the vault's long-term private key if unlocker == nil { Debug("No unlocker provided for secret decryption", "secret_name", s.Name) return nil, errUnlockerRequired } ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker) if err != nil { return nil, err } DebugWith("Successfully obtained vault's long-term key", slog.String("secret_name", s.Name), slog.String("public_key", ltIdentity.Recipient().String()), ) // Use the long-term key to decrypt the version return version.GetValue(ltIdentity) } // LoadMetadata is deprecated - metadata is now per-version and encrypted func (s *Secret) LoadMetadata() error { Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name) // For backward compatibility, we'll populate with basic info now := time.Now() s.Metadata = Metadata{ CreatedAt: now, UpdatedAt: now, } return nil } // GetMetadata returns the secret metadata (deprecated) func (s *Secret) GetMetadata() Metadata { Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name) return s.Metadata } // GetEncryptedData is deprecated - data is now stored in versions func (s *Secret) GetEncryptedData() ([]byte, error) { Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name) return nil, errGetEncryptedDataDeprecated } // Exists checks if the secret exists on disk func (s *Secret) Exists() (bool, error) { DebugWith("Checking if secret exists", slog.String("secret_name", s.Name), slog.String("vault_name", s.vault.GetName()), ) // Check if the secret directory exists and has a current symlink exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory) if err != nil { Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory) return false, err } if !exists { Debug("Secret directory does not exist", "secret_dir", s.Directory) return false, nil } // Check if current symlink exists _, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory) if err != nil { Debug("No current version found", "error", err, "secret_name", s.Name) return false, nil } DebugWith("Secret existence check result", slog.String("secret_name", s.Name), slog.Bool("exists", true), ) return true, nil } // getValueViaMnemonic derives the vault's long-term key from the // mnemonic in the environment and decrypts the version value with it. func (s *Secret) getValueViaMnemonic( version *Version, envMnemonic string, ) (*memguard.LockedBuffer, error) { Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name) // Get vault directory to read metadata vaultDir, err := s.vault.GetDirectory() if err != nil { Debug("Failed to get vault directory", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to get vault directory: %w", err) } // Load vault metadata to get the correct derivation index metadataPath := filepath.Join(vaultDir, "vault-metadata.json") metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath) if err != nil { Debug("Failed to read vault metadata", "error", err, "path", metadataPath) return nil, fmt.Errorf("failed to read vault metadata: %w", err) } var metadata VaultMetadata err = json.Unmarshal(metadataBytes, &metadata) if err != nil { Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to parse vault metadata: %w", err) } DebugWith("Using vault derivation index from metadata", slog.String("secret_name", s.Name), slog.String("vault_name", s.vault.GetName()), slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)), ) // Use mnemonic with the vault's derivation index from metadata ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) if err != nil { Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name) return nil, fmt.Errorf( "failed to derive long-term key from mnemonic: %w", err) } Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name) // Use the long-term key to decrypt the version return version.GetValue(ltIdentity) } // getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse // the vault's long-term private key. func (s *Secret) getLongTermIdentityFromUnlocker( unlocker Unlocker, ) (*age.X25519Identity, error) { DebugWith("Getting vault's long-term key using unlocker", slog.String("secret_name", s.Name), slog.String("unlocker_type", unlocker.GetType()), slog.String("unlocker_id", unlocker.GetID()), ) // Step 1: Use the unlocker to get the vault's long-term private key unlockIdentity, err := unlocker.GetIdentity() if err != nil { Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType()) return nil, fmt.Errorf("failed to get unlocker identity: %w", err) } // Read the encrypted long-term private key from the unlocker directory encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age") Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath) encryptedLtPrivKey, err := afero.ReadFile( s.vault.GetFilesystem(), encryptedLtPrivKeyPath) if err != nil { Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath) return nil, fmt.Errorf( "failed to read encrypted long-term private key: %w", err) } // Decrypt the encrypted long-term private key using the unlocker Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name) ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity) if err != nil { Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) } defer ltPrivKeyBuffer.Destroy() // Parse the long-term private key Debug("Parsing long-term private key", "secret_name", s.Name) ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String()) if err != nil { Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name) return nil, fmt.Errorf("failed to parse long-term private key: %w", err) } return ltIdentity, nil } // GetCurrentVault gets the current vault from the file system // This function is a wrapper around the actual implementation in the vault package // and exists to break the import cycle. // //nolint:ireturn // must return the interface to break the import cycle func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) { // This is a forward declaration. The actual implementation is provided // by the vault package when it calls RegisterGetCurrentVaultFunc. if getCurrentVaultFunc == nil { return nil, errGetCurrentVaultNotRegistered } return getCurrentVaultFunc(fs, stateDir) } // getCurrentVaultFunc is a function variable that will be set by the vault package // to implement the actual GetCurrentVault functionality // //nolint:gochecknoglobals // Required to break import cycle var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error) // RegisterGetCurrentVaultFunc allows the vault package to register its // implementation of GetCurrentVault to break the import cycle func RegisterGetCurrentVaultFunc( fn func(fs afero.Fs, stateDir string) (VaultInterface, error), ) { getCurrentVaultFunc = fn }