restoring from chat historyy
This commit is contained in:
parent
ee49ace397
commit
8c08c2e748
31
README.md
31
README.md
@ -22,7 +22,7 @@ Build from source:
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd secret
|
||||
go build -o secret ./cmd/secret
|
||||
make build
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@ -107,7 +107,6 @@ 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:**
|
||||
@ -145,7 +144,6 @@ 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
|
||||
@ -174,12 +172,7 @@ Unlock keys provide different authentication methods to access the long-term key
|
||||
- Stored as encrypted Age keys
|
||||
- Cross-platform compatible
|
||||
|
||||
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**:
|
||||
2. **PGP Keys**:
|
||||
- Uses existing GPG key infrastructure
|
||||
- Leverages existing key management workflows
|
||||
- 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
|
||||
- Long-term keys protected by multiple unlock key layers
|
||||
|
||||
### Platform Integration
|
||||
- macOS Keychain integration for seamless authentication
|
||||
- GPG integration for existing key management workflows
|
||||
### Hardware Integration
|
||||
- Hardware token support via PGP/GPG integration
|
||||
|
||||
## Examples
|
||||
|
||||
@ -260,7 +252,6 @@ 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
|
||||
@ -305,11 +296,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 platform-specific authentication where available
|
||||
- Supports hardware-backed authentication where available
|
||||
|
||||
### Best Practices
|
||||
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
|
||||
4. Keep mnemonic phrases securely backed up offline
|
||||
5. Use separate vaults for different security contexts
|
||||
@ -317,15 +308,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
|
||||
- Platform-specific features limited to supported platforms
|
||||
- Hardware features limited to supported platforms
|
||||
|
||||
## Development
|
||||
|
||||
### Building
|
||||
```bash
|
||||
go build -o secret ./cmd/secret # Build binary
|
||||
go test ./... # Run tests
|
||||
go vet ./... # Run static analysis
|
||||
make build # Build binary
|
||||
make test # Run tests
|
||||
make lint # Run linter
|
||||
```
|
||||
|
||||
### Testing
|
||||
@ -337,7 +328,7 @@ go test ./... # Unit tests
|
||||
|
||||
## 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
|
||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||
|
@ -93,20 +93,3 @@ 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
|
||||
}
|
||||
|
516
internal/secret/keychainunlock.go
Normal file
516
internal/secret/keychainunlock.go
Normal file
@ -0,0 +1,516 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"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
|
||||
}
|
||||
|
||||
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
|
||||
func (k *KeychainUnlockKey) DecryptLongTermKey() ([]byte, error) {
|
||||
DebugWith("Decrypting long-term key with keychain unlock key",
|
||||
slog.String("key_id", k.GetID()),
|
||||
slog.String("key_type", k.GetType()),
|
||||
)
|
||||
|
||||
// Get keychain item name and retrieve data
|
||||
keychainItemName, err := k.GetKeychainItemName()
|
||||
if err != nil {
|
||||
Debug("Failed to get keychain item name for long-term decryption", "error", err, "key_id", k.GetID())
|
||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||
}
|
||||
|
||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||
if err != nil {
|
||||
Debug("Failed to retrieve data from keychain for long-term decryption", "error", err, "keychain_item", keychainItemName)
|
||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||
}
|
||||
|
||||
var keychainData KeychainData
|
||||
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
|
||||
Debug("Failed to parse keychain data for long-term decryption", "error", err, "key_id", k.GetID())
|
||||
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 {
|
||||
Debug("Failed to decode encrypted long-term key from keychain", "error", err, "key_id", k.GetID())
|
||||
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 {
|
||||
Debug("Failed to get keychain unlock identity for long-term decryption", "error", err, "key_id", k.GetID())
|
||||
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 {
|
||||
Debug("Failed to decrypt long-term private key with keychain unlock key", "error", err, "key_id", k.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term private key with keychain unlock key",
|
||||
slog.String("key_id", k.GetID()),
|
||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
||||
)
|
||||
|
||||
return ltPrivKeyData, 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)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using our unlock key
|
||||
ltPrivKeyData, err := k.DecryptLongTermKey()
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key for secret decryption", "error", err, "key_id", k.GetID())
|
||||
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
|
||||
ltPrivKeyData, err := vault.GetLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 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")
|
||||
}
|
@ -194,7 +194,7 @@ func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
|
||||
slog.String("key_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Get encrypted secret data
|
||||
// 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)
|
||||
@ -214,11 +214,25 @@ func (p *PassphraseUnlockKey) DecryptSecret(secret *Secret) ([]byte, error) {
|
||||
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)
|
||||
// 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, err
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret with passphrase unlock key",
|
||||
|
@ -1 +1,405 @@
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"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
|
||||
}
|
||||
|
||||
// DecryptLongTermKey decrypts and returns the long-term private key for this vault
|
||||
func (p *PGPUnlockKey) DecryptLongTermKey() ([]byte, error) {
|
||||
DebugWith("Decrypting long-term key with PGP unlock key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.String("key_type", p.GetType()),
|
||||
)
|
||||
|
||||
// Get our age identity
|
||||
unlockIdentity, err := p.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get PGP unlock identity for long-term decryption", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to get unlock identity: %w", err)
|
||||
}
|
||||
|
||||
// Read encrypted long-term private key
|
||||
encryptedLtPrivKeyPath := filepath.Join(p.Directory, "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(p.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted long-term private key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using our unlock key
|
||||
Debug("Decrypting long-term private key with PGP unlock key", "key_id", p.GetID())
|
||||
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "key_id", p.GetID())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term private key",
|
||||
slog.String("key_id", p.GetID()),
|
||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
||||
)
|
||||
|
||||
return ltPrivKeyData, 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)),
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
ltPrivKeyData, err := vault.GetLongTermKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get 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
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user