Compare commits

...

2 Commits

Author SHA1 Message Date
3d90388b5b restored from backups 2025-05-29 08:30:16 -07:00
8c08c2e748 restoring from chat historyy 2025-05-29 08:22:43 -07:00
10 changed files with 2520 additions and 76 deletions

View File

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

View File

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

View File

@ -93,20 +93,3 @@ func readPassphrase(prompt string) (string, error) {
return string(passphrase), nil 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
}

133
internal/secret/debug.go Normal file
View File

@ -0,0 +1,133 @@
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

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

View File

@ -1 +1,467 @@
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
}

269
internal/secret/secret.go Normal file
View File

@ -0,0 +1,269 @@
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,9 +14,6 @@ type UnlockKey interface {
ID() string // Generate ID from the key's public key ID() string // Generate ID from the key's public key
Remove() error // Remove the unlock key and any associated resources 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 decrypts a secret using this unlock key's long-term key management
DecryptSecret(secret *Secret) ([]byte, error) DecryptSecret(secret *Secret) ([]byte, error)
} }

File diff suppressed because it is too large Load Diff