Compare commits

..

No commits in common. "3d90388b5ba2145b7bbe94a07812b1d67a40d7f1" and "ee49ace397a33ca4cb7fb0b873bbc676a1c25a81" have entirely different histories.

10 changed files with 78 additions and 2522 deletions

View File

@ -22,7 +22,7 @@ Build from source:
```bash
git clone <repository>
cd secret
make build
go build -o secret ./cmd/secret
```
## Quick Start
@ -107,6 +107,7 @@ Creates a new unlock key of the specified type:
**Types:**
- `passphrase`: Traditional passphrase-protected unlock key
- `keychain`: macOS Keychain-protected unlock key (macOS only)
- `pgp`: Uses an existing GPG key for encryption/decryption
**Options:**
@ -144,6 +145,7 @@ Decrypts data using an Age key stored as a secret.
│ ├── default/
│ │ ├── unlock-keys.d/
│ │ │ ├── passphrase/ # Passphrase unlock key
│ │ │ ├── keychain/ # Keychain unlock key (macOS)
│ │ │ └── pgp/ # PGP unlock key
│ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key
@ -172,7 +174,12 @@ Unlock keys provide different authentication methods to access the long-term key
- Stored as encrypted Age keys
- Cross-platform compatible
2. **PGP Keys**:
2. **Keychain Keys** (macOS only):
- Uses macOS Keychain for secure storage
- Provides seamless authentication on macOS systems
- Age private key encrypted with random passphrase stored in Keychain
3. **PGP Keys**:
- Uses existing GPG key infrastructure
- Leverages existing key management workflows
- Strong authentication through GPG
@ -207,8 +214,9 @@ Each vault maintains its own set of unlock keys and one long-term key. The long-
- Per-secret encryption keys limit exposure if compromised
- Long-term keys protected by multiple unlock key layers
### Hardware Integration
- Hardware token support via PGP/GPG integration
### Platform Integration
- macOS Keychain integration for seamless authentication
- GPG integration for existing key management workflows
## Examples
@ -252,6 +260,7 @@ secret vault list
```bash
# Add multiple unlock methods
secret keys add passphrase # Password-based
secret keys add keychain # macOS Keychain (macOS only)
secret keys add pgp --keyid ABCD1234 # GPG key
# List unlock keys
@ -296,11 +305,11 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Threat Model
- Protects against unauthorized access to secret values
- Provides defense against compromise of individual components
- Supports hardware-backed authentication where available
- Supports platform-specific authentication where available
### Best Practices
1. Use strong, unique passphrases for unlock keys
2. Enable hardware authentication (Keychain, hardware tokens) when available
2. Enable platform-specific authentication (Keychain) when available
3. Regularly audit unlock keys and remove unused ones
4. Keep mnemonic phrases securely backed up offline
5. Use separate vaults for different security contexts
@ -308,15 +317,15 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Limitations
- Requires access to unlock keys for secret retrieval
- Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms
- Platform-specific features limited to supported platforms
## Development
### Building
```bash
make build # Build binary
make test # Run tests
make lint # Run linter
go build -o secret ./cmd/secret # Build binary
go test ./... # Run tests
go vet ./... # Run static analysis
```
### Testing
@ -328,7 +337,7 @@ go test ./... # Unit tests
## Features
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlock keys
- **Multiple Authentication Methods**: Supports passphrase-based, keychain-based (macOS), and PGP-based unlock keys
- **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases

View File

@ -1,7 +1,5 @@
package main
import "git.eeqj.de/sneak/secret/internal/secret"
func main() {
secret.CLIEntry()
CLIEntry()
}

View File

@ -93,3 +93,20 @@ func readPassphrase(prompt string) (string, error) {
return string(passphrase), nil
}
// decryptSecretWithLongTermKey is a helper that parses a long-term private key and uses it to decrypt secret data
func decryptSecretWithLongTermKey(ltPrivKeyData []byte, encryptedData []byte) ([]byte, error) {
// Parse long-term private key
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
// Decrypt secret data using long-term key
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}
return decryptedData, nil
}

View File

@ -1,133 +0,0 @@
package secret
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"strings"
"syscall"
"golang.org/x/term"
)
var (
debugEnabled bool
debugLogger *slog.Logger
)
func init() {
initDebugLogging()
}
// initDebugLogging initializes the debug logging system based on GODEBUG environment variable
func initDebugLogging() {
godebug := os.Getenv("GODEBUG")
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
if !debugEnabled {
// Create a no-op logger that discards all output
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
return
}
// Check if STDERR is a TTY
isTTY := term.IsTerminal(int(syscall.Stderr))
var handler slog.Handler
if isTTY {
// TTY output: colorized structured format
handler = newColorizedHandler(os.Stderr)
} else {
// Non-TTY output: JSON Lines format
handler = slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
}
debugLogger = slog.New(handler)
}
// IsDebugEnabled returns true if debug logging is enabled
func IsDebugEnabled() bool {
return debugEnabled
}
// Debug logs a debug message with optional attributes
func Debug(msg string, args ...any) {
if !debugEnabled {
return
}
debugLogger.Debug(msg, args...)
}
// DebugF logs a formatted debug message with optional attributes
func DebugF(format string, args ...any) {
if !debugEnabled {
return
}
debugLogger.Debug(fmt.Sprintf(format, args...))
}
// DebugWith logs a debug message with structured attributes
func DebugWith(msg string, attrs ...slog.Attr) {
if !debugEnabled {
return
}
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
}
// colorizedHandler implements a TTY-friendly structured log handler
type colorizedHandler struct {
output io.Writer
}
func newColorizedHandler(output io.Writer) slog.Handler {
return &colorizedHandler{output: output}
}
func (h *colorizedHandler) Enabled(_ context.Context, level slog.Level) bool {
// Explicitly check that debug is enabled AND the level is DEBUG or higher
// This ensures we don't default to INFO level when debug is enabled
return debugEnabled && level >= slog.LevelDebug
}
func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
if !debugEnabled {
return nil
}
// Format: [DEBUG] message {key=value, key2=value2}
output := fmt.Sprintf("\033[36m[DEBUG]\033[0m \033[1m%s\033[0m", record.Message)
if record.NumAttrs() > 0 {
output += " \033[33m{"
first := true
record.Attrs(func(attr slog.Attr) bool {
if !first {
output += ", "
}
first = false
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
return true
})
output += "}\033[0m"
}
output += "\n"
_, err := h.output.Write([]byte(output))
return err
}
func (h *colorizedHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
// For simplicity, return the same handler
// In a more complex implementation, we'd create a new handler with the attrs
return h
}
func (h *colorizedHandler) WithGroup(name string) slog.Handler {
// For simplicity, return the same handler
// In a more complex implementation, we'd create a new handler with the group
return h
}

View File

@ -1,555 +0,0 @@
package secret
import (
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// KeychainUnlockKeyMetadata extends UnlockKeyMetadata with keychain-specific data
type KeychainUnlockKeyMetadata struct {
UnlockKeyMetadata
// Age keypair information
AgePublicKey string `json:"age_public_key"`
// Keychain item name
KeychainItemName string `json:"keychain_item_name"`
}
// KeychainUnlockKey represents a macOS Keychain-protected unlock key
type KeychainUnlockKey struct {
Directory string
Metadata UnlockKeyMetadata
fs afero.Fs
}
// KeychainData represents the data stored in the macOS keychain
type KeychainData struct {
AgePublicKey string `json:"age_public_key"`
AgePrivKeyPassphrase string `json:"age_priv_key_passphrase"`
EncryptedLongtermKey string `json:"encrypted_longterm_key"`
}
// GetIdentity implements UnlockKey interface for Keychain-based unlock keys
func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlock key identity",
slog.String("key_id", k.GetID()),
slog.String("key_type", k.GetType()),
)
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name", "error", err, "key_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
}
DebugWith("Retrieved data from keychain",
slog.String("key_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Step 3: Parse keychain data
var keychainData KeychainData
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
Debug("Failed to parse keychain data", "error", err, "key_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
Debug("Parsed keychain data successfully", "key_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
if err != nil {
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
}
DebugWith("Read encrypted age private key",
slog.String("key_id", k.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "key_id", k.GetID())
agePrivKeyData, err := decryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "key_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
}
DebugWith("Successfully decrypted age private key with keychain passphrase",
slog.String("key_id", k.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", k.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", k.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed keychain age identity",
slog.String("key_id", k.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements UnlockKey interface
func (k *KeychainUnlockKey) GetType() string {
return "keychain"
}
// GetMetadata implements UnlockKey interface
func (k *KeychainUnlockKey) GetMetadata() UnlockKeyMetadata {
return k.Metadata
}
// GetDirectory implements UnlockKey interface
func (k *KeychainUnlockKey) GetDirectory() string {
return k.Directory
}
// GetID implements UnlockKey interface
func (k *KeychainUnlockKey) GetID() string {
return k.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from keychain item name
func (k *KeychainUnlockKey) ID() string {
// Generate ID using keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
// Fallback to metadata ID if we can't read the keychain item name
return k.Metadata.ID
}
return fmt.Sprintf("%s-keychain", keychainItemName)
}
// Remove implements UnlockKey interface - removes the keychain unlock key
func (k *KeychainUnlockKey) Remove() error {
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name during removal", "error", err, "key_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Remove from keychain
Debug("Removing keychain item", "keychain_item", keychainItemName)
if err := deleteFromKeychain(keychainItemName); err != nil {
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
return fmt.Errorf("failed to remove keychain item: %w", err)
}
// Step 3: Remove directory
Debug("Removing keychain unlock key directory", "directory", k.Directory)
if err := k.fs.RemoveAll(k.Directory); err != nil {
Debug("Failed to remove keychain unlock key directory", "error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlock key directory: %w", err)
}
Debug("Successfully removed keychain unlock key", "key_id", k.GetID(), "keychain_item", keychainItemName)
return nil
}
// DecryptSecret decrypts a secret using this keychain unlock key's long-term key management
func (k *KeychainUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
DebugWith("Decrypting secret with keychain unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", k.GetID()),
slog.String("key_type", k.GetType()),
)
// Let the secret read its own encrypted data
encryptedData, err := secret.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted secret data for keychain decryption", "error", err, "secret_name", secret.Name)
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
}
DebugWith("Retrieved encrypted secret data for keychain decryption",
slog.String("secret_name", secret.Name),
slog.String("key_id", k.GetID()),
slog.Int("encrypted_length", len(encryptedData)),
)
// Get or derive the long-term private key
var ltPrivKeyData []byte
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get keychain item name and retrieve data
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil {
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
}
var keychainData KeychainData
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
// Decrypt the long-term private key using the encrypted data from keychain
encryptedLtPrivKey, err := hex.DecodeString(keychainData.EncryptedLongtermKey)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted long-term key: %w", err)
}
// Get our unlock key identity to decrypt the long-term key
unlockIdentity, err := k.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
}
// Decrypt long-term private key using our unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Parse long-term private key
Debug("Parsing long-term private key", "key_id", k.GetID())
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
Debug("Failed to parse long-term private key", "error", err, "key_id", k.GetID())
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
DebugWith("Successfully parsed long-term identity",
slog.String("key_id", k.GetID()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Decrypt secret data using long-term key
Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", k.GetID())
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
if err != nil {
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}
DebugWith("Successfully decrypted secret with keychain unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", k.GetID()),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
// NewKeychainUnlockKey creates a new KeychainUnlockKey instance
func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *KeychainUnlockKey {
return &KeychainUnlockKey{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) {
// Load the metadata
metadataPath := filepath.Join(k.Directory, "unlock-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
}
var keychainMetadata KeychainUnlockKeyMetadata
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
}
return keychainMetadata.KeychainItemName, nil
}
// generateKeychainUnlockKeyName generates a unique name for the keychain unlock key
func generateKeychainUnlockKeyName(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: secret-<vault>-<hostname>-<date>
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
}
// CreateKeychainUnlockKey creates a new keychain unlock key and stores it in the vault
func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey, error) {
// Check if we're on macOS
if err := checkMacOSAvailable(); err != nil {
return nil, err
}
// Get current vault
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the keychain item name
keychainItemName, err := generateKeychainUnlockKeyName(vault.Name)
if err != nil {
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
}
// Create unlock key directory using the keychain item name as the directory name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", keychainItemName)
if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
}
// Step 1: Generate a new age keypair for the keychain unlock key
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
}
// Step 2: Generate a random passphrase for encrypting the age private key
agePrivKeyPassphrase, err := generateRandomPassphrase(64)
if err != nil {
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
}
// Step 3: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), 0600); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err)
}
// Step 4: Encrypt age private key with the generated passphrase and store on disk
agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := encryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Step 5: Get or derive the long-term private key
var ltPrivKeyData []byte
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlock key
currentUnlockKey, err := vault.GetCurrentUnlockKey()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
}
// Get the current unlock key identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
}
// Get encrypted long-term key from current unlock key, handling different types
var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) {
case *PassphraseUnlockKey:
// Read the encrypted long-term private key from passphrase unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
}
case *PGPUnlockKey:
// Read the encrypted long-term private key from PGP unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
}
case *KeychainUnlockKey:
// Read the encrypted long-term private key from another keychain unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlock key: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlock key type for keychain unlock key creation")
}
// Decrypt long-term private key using current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Step 6: Encrypt long-term private key to the new age unlock key
encryptedLtPrivKeyToAge, err := encryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Step 7: Prepare keychain data
keychainData := KeychainData{
AgePublicKey: agePublicKeyString,
AgePrivKeyPassphrase: agePrivKeyPassphrase,
EncryptedLongtermKey: hex.EncodeToString(encryptedLtPrivKeyToAge),
}
keychainDataBytes, err := json.Marshal(keychainData)
if err != nil {
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
}
// Step 8: Store data in keychain
if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil {
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
}
// Step 9: Create and write enhanced metadata
// Generate the key ID directly using the keychain item name
keyID := fmt.Sprintf("%s-keychain", keychainItemName)
keychainMetadata := KeychainUnlockKeyMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{
ID: keyID,
Type: "keychain",
CreatedAt: time.Now(),
Flags: []string{"keychain", "macos"},
},
AgePublicKey: agePublicKeyString,
KeychainItemName: keychainItemName,
}
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
}
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
}
return &KeychainUnlockKey{
Directory: unlockKeyDir,
Metadata: keychainMetadata.UnlockKeyMetadata,
fs: fs,
}, nil
}
// checkMacOSAvailable verifies that we're running on macOS and security command is available
func checkMacOSAvailable() error {
cmd := exec.Command("security", "help")
if err := cmd.Run(); err != nil {
return fmt.Errorf("macOS security command not available: %w (keychain unlock keys are only supported on macOS)", err)
}
return nil
}
// storeInKeychain stores data in the macOS keychain using the security command
func storeInKeychain(itemName string, data []byte) error {
cmd := exec.Command("security", "add-generic-password",
"-a", itemName,
"-s", itemName,
"-w", string(data),
"-U") // Update if exists
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
func retrieveFromKeychain(itemName string) ([]byte, error) {
cmd := exec.Command("security", "find-generic-password",
"-a", itemName,
"-s", itemName,
"-w") // Return password only
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
// Remove trailing newline if present
if len(output) > 0 && output[len(output)-1] == '\n' {
output = output[:len(output)-1]
}
return output, nil
}
// deleteFromKeychain removes an item from the macOS keychain using the security command
func deleteFromKeychain(itemName string) error {
cmd := exec.Command("security", "delete-generic-password",
"-a", itemName,
"-s", itemName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to delete item from keychain: %w", err)
}
return nil
}
// generateRandomPassphrase generates a random passphrase for encrypting the age private key
func generateRandomPassphrase(length int) (string, error) {
return generateRandomString(length, "0123456789abcdef")
}

View File

@ -141,32 +141,17 @@ func CreatePassphraseKey(fs afero.Fs, stateDir string, passphrase string) (*Pass
return currentVault.CreatePassphraseKey(passphrase)
}
// DecryptSecret decrypts a secret using this passphrase unlock key's long-term key management
func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
DebugWith("Decrypting secret with passphrase unlock key",
slog.String("secret_name", secret.Name),
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
func (p *PassphraseUnlockKey) DecryptLongTermKey() ([]byte, error) {
DebugWith("Decrypting long-term key with passphrase unlock key",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Get our unlock key encrypted data
encryptedData, err := secret.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted secret data for passphrase decryption", "error", err, "secret_name", secret.Name)
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
}
DebugWith("Retrieved encrypted secret data for passphrase decryption",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedData)),
)
// Get our age identity
Debug("Getting passphrase unlock key identity for secret decryption", "key_id", p.GetID())
// Get our unlock key identity
unlockIdentity, err := p.GetIdentity()
if err != nil {
Debug("Failed to get passphrase unlock identity", "error", err, "key_id", p.GetID())
Debug("Failed to get passphrase unlock identity for long-term decryption", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
}
@ -198,25 +183,42 @@ func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
slog.Int("decrypted_length", len(ltPrivKeyData)),
)
// Parse long-term private key
Debug("Parsing long-term private key", "key_id", p.GetID())
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
Debug("Failed to parse long-term private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltPrivKeyData, nil
}
DebugWith("Successfully parsed long-term identity",
// DecryptSecret decrypts a secret using this passphrase unlock key's long-term key management
func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
DebugWith("Decrypting secret with passphrase unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.String("public_key", ltIdentity.Recipient().String()),
slog.String("key_type", p.GetType()),
)
// Decrypt secret data using long-term key
Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", p.GetID())
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
// Get encrypted secret data
encryptedData, err := secret.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted secret data for passphrase decryption", "error", err, "secret_name", secret.Name)
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
}
DebugWith("Retrieved encrypted secret data for passphrase decryption",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt long-term private key using our unlock key
ltPrivKeyData, err := p.DecryptLongTermKey()
if err != nil {
Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
// Use helper to parse long-term key and decrypt secret
decryptedData, err := decryptSecretWithLongTermKey(ltPrivKeyData, encryptedData)
if err != nil {
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
return nil, err
}
DebugWith("Successfully decrypted secret with passphrase unlock key",

View File

@ -1,467 +1 @@
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// PGPUnlockKeyMetadata extends UnlockKeyMetadata with PGP-specific data
type PGPUnlockKeyMetadata struct {
UnlockKeyMetadata
// GPG key ID used for encryption
GPGKeyID string `json:"gpg_key_id"`
// Age keypair information
AgePublicKey string `json:"age_public_key"`
AgeRecipient string `json:"age_recipient"`
}
// PGPUnlockKey represents a PGP-protected unlock key
type PGPUnlockKey struct {
Directory string
Metadata UnlockKeyMetadata
fs afero.Fs
}
// GetIdentity implements UnlockKey interface for PGP-based unlock keys
func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting PGP unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Step 1: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg")
Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath)
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
if err != nil {
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
}
DebugWith("Read PGP-encrypted age private key",
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
)
// Step 2: Decrypt the age private key using GPG
Debug("Decrypting age private key with GPG", "key_id", p.GetID())
agePrivKeyData, err := gpgDecrypt(encryptedAgePrivKeyData)
if err != nil {
Debug("Failed to decrypt age private key with GPG", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
}
DebugWith("Successfully decrypted age private key with GPG",
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err)
}
DebugWith("Successfully parsed PGP age identity",
slog.String("key_id", p.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()),
)
return ageIdentity, nil
}
// GetType implements UnlockKey interface
func (p *PGPUnlockKey) GetType() string {
return "pgp"
}
// GetMetadata implements UnlockKey interface
func (p *PGPUnlockKey) GetMetadata() UnlockKeyMetadata {
return p.Metadata
}
// GetDirectory implements UnlockKey interface
func (p *PGPUnlockKey) GetDirectory() string {
return p.Directory
}
// GetID implements UnlockKey interface
func (p *PGPUnlockKey) GetID() string {
return p.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from GPG key ID
func (p *PGPUnlockKey) ID() string {
// Generate ID using GPG key ID: <keyid>-pgp
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
// Fallback to metadata ID if we can't read the GPG key ID
return p.Metadata.ID
}
return fmt.Sprintf("%s-pgp", gpgKeyID)
}
// Remove implements UnlockKey interface - removes the PGP unlock key
func (p *PGPUnlockKey) Remove() error {
// For PGP keys, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove PGP unlock key directory: %w", err)
}
return nil
}
// DecryptSecret decrypts a secret using this PGP unlock key's long-term key management
func (p *PGPUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
DebugWith("Decrypting secret with PGP unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// Let the secret read its own encrypted data
encryptedData, err := secret.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted secret data for PGP decryption", "error", err, "secret_name", secret.Name)
return nil, fmt.Errorf("failed to get encrypted secret data: %w", err)
}
DebugWith("Retrieved encrypted secret data for PGP decryption",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedData)),
)
// Get our age identity
Debug("Getting PGP unlock key identity for secret decryption", "key_id", p.GetID())
_, err = p.GetIdentity()
if err != nil {
Debug("Failed to get PGP unlock identity", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
}
// Get or derive the long-term private key
var ltPrivKeyData []byte
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlock key
stateDir := filepath.Dir(filepath.Dir(filepath.Dir(p.Directory)))
vault, err := GetCurrentVault(p.fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get vault: %w", err)
}
// Get current unlock key
currentUnlockKey, err := vault.GetCurrentUnlockKey()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
}
// Get the current unlock key identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
}
// Get encrypted long-term key from current unlock key, handling different types
var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) {
case *PassphraseUnlockKey:
// Read the encrypted long-term private key from passphrase unlock key
encryptedLtPrivKey, err = afero.ReadFile(p.fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
}
case *PGPUnlockKey:
// Read the encrypted long-term private key from PGP unlock key
encryptedLtPrivKey, err = afero.ReadFile(p.fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation")
}
// Decrypt long-term private key using current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Parse long-term private key
Debug("Parsing long-term private key", "key_id", p.GetID())
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
if err != nil {
Debug("Failed to parse long-term private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
DebugWith("Successfully parsed long-term identity",
slog.String("key_id", p.GetID()),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Decrypt secret data using long-term key
Debug("Decrypting secret data with long-term key", "secret_name", secret.Name, "key_id", p.GetID())
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
if err != nil {
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", secret.Name, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}
DebugWith("Successfully decrypted secret with PGP unlock key",
slog.String("secret_name", secret.Name),
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
// NewPGPUnlockKey creates a new PGPUnlockKey instance
func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PGPUnlockKey {
return &PGPUnlockKey{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlockKey) GetGPGKeyID() (string, error) {
// Load the metadata
metadataPath := filepath.Join(p.Directory, "unlock-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
}
var pgpMetadata PGPUnlockKeyMetadata
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
return pgpMetadata.GPGKeyID, nil
}
// generatePGPUnlockKeyName generates a unique name for the PGP unlock key based on hostname and date
func generatePGPUnlockKeyName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: hostname-pgp-YYYY-MM-DD
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
}
// CreatePGPUnlockKey creates a new PGP unlock key and stores it in the vault
func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlockKey, error) {
// Check if GPG is available
if err := checkGPGAvailable(); err != nil {
return nil, err
}
// Get current vault
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the unlock key name based on hostname and date
unlockKeyName, err := generatePGPUnlockKeyName()
if err != nil {
return nil, fmt.Errorf("failed to generate unlock key name: %w", err)
}
// Create unlock key directory using the generated name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName)
if err := fs.MkdirAll(unlockKeyDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
}
// Step 1: Generate a new age keypair for the PGP unlock key
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
}
// Step 2: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), 0600); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err)
}
// Step 3: Get or derive the long-term private key
var ltPrivKeyData []byte
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlock key
currentUnlockKey, err := vault.GetCurrentUnlockKey()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
}
// Get the current unlock key identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err)
}
// Get encrypted long-term key from current unlock key, handling different types
var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) {
case *PassphraseUnlockKey:
// Read the encrypted long-term private key from passphrase unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err)
}
case *PGPUnlockKey:
// Read the encrypted long-term private key from PGP unlock key
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation")
}
// Decrypt long-term private key using current unlock key
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Step 4: Encrypt long-term private key to the new age unlock key
encryptedLtPrivKeyToAge, err := encryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Step 5: Encrypt age private key to the GPG key ID
agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := gpgEncrypt(agePrivateKeyBytes, gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
}
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Step 6: Create and write enhanced metadata
// Generate the key ID directly using the GPG key ID
keyID := fmt.Sprintf("%s-pgp", gpgKeyID)
pgpMetadata := PGPUnlockKeyMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{
ID: keyID,
Type: "pgp",
CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"},
},
GPGKeyID: gpgKeyID,
AgePublicKey: agePublicKeyString,
AgeRecipient: ageIdentity.Recipient().String(),
}
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
}
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
}
return &PGPUnlockKey{
Directory: unlockKeyDir,
Metadata: pgpMetadata.UnlockKeyMetadata,
fs: fs,
}, nil
}
// checkGPGAvailable verifies that GPG is available
func checkGPGAvailable() error {
cmd := exec.Command("gpg", "--version")
if err := cmd.Run(); err != nil {
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
}
return nil
}
// gpgEncrypt encrypts data to the specified GPG key ID
func gpgEncrypt(data []byte, keyID string) ([]byte, error) {
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
cmd.Stdin = strings.NewReader(string(data))
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w", err)
}
return output, nil
}
// gpgDecrypt decrypts GPG-encrypted data
func gpgDecrypt(encryptedData []byte) ([]byte, error) {
cmd := exec.Command("gpg", "--quiet", "--decrypt")
cmd.Stdin = strings.NewReader(string(encryptedData))
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w", err)
}
return output, nil
}

View File

@ -1,269 +0,0 @@
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// Secret represents a secret in a vault
type Secret struct {
Name string
Directory string
Metadata SecretMetadata
vault *Vault
}
// NewSecret creates a new Secret instance
func NewSecret(vault *Vault, name string) *Secret {
DebugWith("Creating new secret instance",
slog.String("secret_name", name),
slog.String("vault_name", vault.Name),
)
// 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: SecretMetadata{
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
}
// Save saves a secret value to the vault
func (s *Secret) Save(value []byte, force bool) error {
DebugWith("Saving secret",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.Name),
slog.Int("value_length", len(value)),
slog.Bool("force", force),
)
err := s.vault.AddSecret(s.Name, value, force)
if err != nil {
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
return err
}
Debug("Successfully saved secret", "secret_name", s.Name)
return nil
}
// GetValue retrieves and decrypts the secret value using the provided unlock key
func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.Name),
)
// 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.Name)
return nil, fmt.Errorf("secret %s not found", s.Name)
}
Debug("Secret exists, proceeding with decryption", "secret_name", s.Name)
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
Debug("Using mnemonic from environment for secret decryption", "secret_name", s.Name)
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
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)
// Read our own encrypted data
encryptedData, err := s.GetEncryptedData()
if err != nil {
Debug("Failed to get encrypted data for mnemonic decryption", "error", err, "secret_name", s.Name)
return nil, err
}
DebugWith("Retrieved encrypted data for mnemonic decryption",
slog.String("secret_name", s.Name),
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt secret data
Debug("Decrypting secret with long-term key from mnemonic", "secret_name", s.Name)
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
if err != nil {
Debug("Failed to decrypt secret with mnemonic", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
}
DebugWith("Successfully decrypted secret with mnemonic",
slog.String("secret_name", s.Name),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
Debug("Using unlock key for secret decryption", "secret_name", s.Name)
// Use the provided unlock key to decrypt the secret
if unlockKey == nil {
Debug("No unlock key provided for secret decryption", "secret_name", s.Name)
return nil, fmt.Errorf("unlock key required to decrypt secret")
}
DebugWith("Delegating secret decryption to unlock key",
slog.String("secret_name", s.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()),
)
// Delegate decryption to the unlock key implementation
decryptedData, err := unlockKey.DecryptSecret(s)
if err != nil {
Debug("Unlock key failed to decrypt secret", "error", err, "secret_name", s.Name, "unlock_key_type", unlockKey.GetType())
return nil, err
}
DebugWith("Successfully decrypted secret via unlock key",
slog.String("secret_name", s.Name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
// LoadMetadata loads the secret metadata from disk
func (s *Secret) LoadMetadata() error {
DebugWith("Loading secret metadata",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.Name),
)
vaultDir, err := s.vault.GetDirectory()
if err != nil {
Debug("Failed to get vault directory for metadata loading", "error", err, "secret_name", s.Name)
return err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(s.Name, "/", "%")
metadataPath := filepath.Join(vaultDir, "secrets.d", storageName, "secret-metadata.json")
DebugWith("Reading secret metadata",
slog.String("secret_name", s.Name),
slog.String("metadata_path", metadataPath),
)
// Read metadata file
metadataBytes, err := afero.ReadFile(s.vault.fs, metadataPath)
if err != nil {
Debug("Failed to read secret metadata file", "error", err, "metadata_path", metadataPath)
return fmt.Errorf("failed to read metadata: %w", err)
}
DebugWith("Read secret metadata file",
slog.String("secret_name", s.Name),
slog.Int("metadata_size", len(metadataBytes)),
)
var metadata SecretMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
Debug("Failed to parse secret metadata JSON", "error", err, "secret_name", s.Name)
return fmt.Errorf("failed to parse metadata: %w", err)
}
DebugWith("Parsed secret metadata",
slog.String("secret_name", metadata.Name),
slog.Time("created_at", metadata.CreatedAt),
slog.Time("updated_at", metadata.UpdatedAt),
)
s.Metadata = metadata
Debug("Successfully loaded secret metadata", "secret_name", s.Name)
return nil
}
// GetMetadata returns the secret metadata
func (s *Secret) GetMetadata() SecretMetadata {
Debug("Returning secret metadata", "secret_name", s.Name)
return s.Metadata
}
// GetEncryptedData reads and returns the encrypted secret data
func (s *Secret) GetEncryptedData() ([]byte, error) {
DebugWith("Getting encrypted secret data",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.Name),
)
secretPath := filepath.Join(s.Directory, "secret.age")
Debug("Reading encrypted secret file", "secret_path", secretPath)
encryptedData, err := afero.ReadFile(s.vault.fs, secretPath)
if err != nil {
Debug("Failed to read encrypted secret file", "error", err, "secret_path", secretPath)
return nil, fmt.Errorf("failed to read encrypted secret: %w", err)
}
DebugWith("Successfully read encrypted secret data",
slog.String("secret_name", s.Name),
slog.Int("encrypted_length", len(encryptedData)),
)
return encryptedData, nil
}
// 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.Name),
)
secretPath := filepath.Join(s.Directory, "secret.age")
Debug("Checking secret file existence", "secret_path", secretPath)
exists, err := afero.Exists(s.vault.fs, secretPath)
if err != nil {
Debug("Failed to check secret file existence", "error", err, "secret_path", secretPath)
return false, err
}
DebugWith("Secret existence check result",
slog.String("secret_name", s.Name),
slog.Bool("exists", exists),
)
return exists, nil
}

View File

@ -14,6 +14,9 @@ type UnlockKey interface {
ID() string // Generate ID from the key's public key
Remove() error // Remove the unlock key and any associated resources
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
DecryptLongTermKey() ([]byte, error)
// DecryptSecret decrypts a secret using this unlock key's long-term key management
DecryptSecret(secret *Secret) ([]byte, error)
}

File diff suppressed because it is too large Load Diff