Compare commits
	
		
			No commits in common. "3d90388b5ba2145b7bbe94a07812b1d67a40d7f1" and "ee49ace397a33ca4cb7fb0b873bbc676a1c25a81" have entirely different histories.
		
	
	
		
			3d90388b5b
			...
			ee49ace397
		
	
		
							
								
								
									
										31
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										31
									
								
								README.md
									
									
									
									
									
								
							@ -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
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,5 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import "git.eeqj.de/sneak/secret/internal/secret"
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	secret.CLIEntry()
 | 
			
		||||
	CLIEntry()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
@ -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")
 | 
			
		||||
}
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
}
 | 
			
		||||
@ -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
											
										
									
								
							
		Loading…
	
		Reference in New Issue
	
	Block a user