'unlock keys' renamed to 'unlockers'

This commit is contained in:
Jeffrey Paul 2025-05-30 07:29:02 -07:00
parent 0bf8e71b52
commit f59ee4d2d6
25 changed files with 1115 additions and 1103 deletions

View File

@ -9,12 +9,12 @@ Secret is a modern, secure command-line secret manager that implements a hierarc
Secret implements a sophisticated three-layer key architecture: Secret implements a sophisticated three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the foundation for all encryption
2. **Unlock Keys**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods 2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting multiple authentication methods
3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values 3. **Secret-specific Keys**: Per-secret keys that encrypt individual secret values
### Vault System ### Vault System
Vaults provide logical separation of secrets, each with its own long-term key and unlock key set. This allows for complete isolation between different contexts (work, personal, projects). Vaults provide logical separation of secrets, each with its own long-term key and unlocker set. This allows for complete isolation between different contexts (work, personal, projects).
## Installation ## Installation
@ -97,26 +97,26 @@ Generates and stores a random secret.
- `--type, -t`: Type of secret (`base58`, `alnum`) - `--type, -t`: Type of secret (`base58`, `alnum`)
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
### Unlock Key Management ### Unlocker Management
#### `secret keys list [--json]` #### `secret unlockers list [--json]`
Lists all unlock keys in the current vault with their metadata. Lists all unlockers in the current vault with their metadata.
#### `secret keys add <type> [options]` #### `secret unlockers add <type> [options]`
Creates a new unlock key of the specified type: Creates a new unlocker of the specified type:
**Types:** **Types:**
- `passphrase`: Traditional passphrase-protected unlock key - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (required for PGP type) - `--keyid <id>`: GPG key ID (required for PGP type)
#### `secret keys rm <key-id>` #### `secret unlockers rm <unlocker-id>`
Removes an unlock key. Removes an unlocker.
#### `secret key select <key-id>` #### `secret unlocker select <unlocker-id>`
Selects an unlock key as the current default for operations. Selects an unlocker as the current default for operations.
### Import Operations ### Import Operations
@ -142,17 +142,17 @@ Decrypts data using an Age key stored as a secret.
~/.local/share/secret/ ~/.local/share/secret/
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlock.d/ │ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlock key │ │ │ ├── passphrase/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlock key │ │ │ └── pgp/ # PGP unlocker
│ │ ├── secrets.d/ │ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key │ │ │ ├── api%key/ # Secret: api/key
│ │ │ └── database%password/ # Secret: database/password │ │ │ └── database%password/ # Secret: database/password
│ │ └── current-unlock-key -> ../unlock.d/passphrase │ │ └── current-unlocker -> ../unlockers.d/passphrase
│ └── work/ │ └── work/
│ ├── unlock.d/ │ ├── unlockers.d/
│ ├── secrets.d/ │ ├── secrets.d/
│ └── current-unlock-key │ └── current-unlocker
├── currentvault -> vaults.d/default ├── currentvault -> vaults.d/default
└── configuration.json └── configuration.json
``` ```
@ -162,22 +162,22 @@ Decrypts data using an Age key stored as a secret.
#### Long-term Keys #### Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation - **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys - **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlock keys - **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
#### Unlock Keys #### Unlockers
Unlock keys provide different authentication methods to access the long-term keys: Unlockers provide different authentication methods to access the long-term keys:
1. **Passphrase Keys**: 1. **Passphrase Unlockers**:
- Encrypted with user-provided passphrase - Encrypted with user-provided passphrase
- Stored as encrypted Age keys - Stored as encrypted Age keys
- Cross-platform compatible - Cross-platform compatible
2. **PGP Keys**: 2. **PGP Unlockers**:
- Uses existing GPG key infrastructure - Uses existing GPG key infrastructure
- Leverages existing key management workflows - Leverages existing key management workflows
- Strong authentication through GPG - Strong authentication through GPG
Each vault maintains its own set of unlock keys and one long-term key. The long-term key is encrypted to each unlock key, allowing any authorized unlock key to access vault secrets. Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
#### Secret-specific Keys #### Secret-specific Keys
- Each secret has its own encryption key pair - Each secret has its own encryption key pair
@ -189,7 +189,7 @@ Each vault maintains its own set of unlock keys and one long-term key. The long-
- `SB_SECRET_STATE_DIR`: Custom state directory location - `SB_SECRET_STATE_DIR`: Custom state directory location
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase (avoids interactive prompt) - `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase (avoids interactive prompt)
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt) - `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlock keys - `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
## Security Features ## Security Features
@ -205,7 +205,7 @@ Each vault maintains its own set of unlock keys and one long-term key. The long-
### Forward Secrecy ### Forward Secrecy
- Per-secret encryption keys limit exposure if compromised - Per-secret encryption keys limit exposure if compromised
- Long-term keys protected by multiple unlock key layers - Long-term keys protected by multiple unlocker layers
### Hardware Integration ### Hardware Integration
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
@ -238,7 +238,7 @@ secret vault create personal
# Work with work vault # Work with work vault
secret vault select work secret vault select work
echo "work-db-pass" | secret add database/password echo "work-db-pass" | secret add database/password
secret keys add passphrase # Add passphrase authentication secret unlockers add passphrase # Add passphrase authentication
# Switch to personal vault # Switch to personal vault
secret vault select personal secret vault select personal
@ -251,14 +251,14 @@ secret vault list
### Advanced Authentication ### Advanced Authentication
```bash ```bash
# Add multiple unlock methods # Add multiple unlock methods
secret keys add passphrase # Password-based secret unlockers add passphrase # Password-based
secret keys add pgp --keyid ABCD1234 # GPG key secret unlockers add pgp --keyid ABCD1234 # GPG key
# List unlock keys # List unlockers
secret keys list secret unlockers list
# Select a specific unlock key # Select a specific unlocker
secret key select <key-id> secret unlocker select <unlocker-id>
``` ```
### Encryption/Decryption with Age Keys ### Encryption/Decryption with Age Keys
@ -299,14 +299,14 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
- Supports hardware-backed authentication where available - Supports hardware-backed authentication where available
### Best Practices ### Best Practices
1. Use strong, unique passphrases for unlock keys 1. Use strong, unique passphrases for unlockers
2. Enable hardware authentication (Keychain, hardware tokens) when available 2. Enable hardware authentication (Keychain, hardware tokens) when available
3. Regularly audit unlock keys and remove unused ones 3. Regularly audit unlockers and remove unused ones
4. Keep mnemonic phrases securely backed up offline 4. Keep mnemonic phrases securely backed up offline
5. Use separate vaults for different security contexts 5. Use separate vaults for different security contexts
### Limitations ### Limitations
- Requires access to unlock keys for secret retrieval - Requires access to unlockers for secret retrieval
- Mnemonic phrases must be securely stored and backed up - Mnemonic phrases must be securely stored and backed up
- Hardware features limited to supported platforms - Hardware features limited to supported platforms
@ -328,7 +328,7 @@ go test ./... # Unit tests
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlock keys - **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases

13
TODO.md
View File

@ -19,7 +19,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
- [x] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior. - [x] **5. Multiple vaults using the same mnemonic will derive the same long-term keys**: Adding additional vaults with the same mnemonic should increment the index value used. The mnemonic should be double sha256 hashed and the hash value stored in the vault metadata along with the index value (starting at zero) and when additional vaults are added with the same mnemonic (as determined by hash) then the index value should be incremented. The README should be updated to document this behavior.
- [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures: - [x] **6. Directory structure inconsistency**: The README and test script reference different directory structures:
- Current code uses `unlock.d/` but documentation shows `unlock-keys.d/` - Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
- Secret files use inconsistent naming (`secret.age` vs `value.age`) - Secret files use inconsistent naming (`secret.age` vs `value.age`)
- [x] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments. - [x] **7. Symlink handling on non-Unix systems**: The symlink resolution in `resolveVaultSymlink()` may fail on Windows or in certain environments.
@ -140,7 +140,7 @@ This document outlines the bugs, issues, and improvements that need to be addres
### Code Structure ### Code Structure
- [ ] **51. Consistent interface implementation**: Ensure all unlock key types properly implement the UnlockKey interface. - [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.
- [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split. - [ ] **52. Better separation of concerns**: Some functions in CLI do too much and should be split.
@ -193,4 +193,11 @@ This document outlines the bugs, issues, and improvements that need to be addres
- Trivial (31-50): Ongoing post-1.0 - Trivial (31-50): Ongoing post-1.0
- Architecture/Infrastructure (51-64): Ongoing post-1.0 - Architecture/Infrastructure (51-64): Ongoing post-1.0
Total estimated time to 1.0: 5-7 weeks with focused development effort. Total estimated time to 1.0: 5-7 weeks with focused development effort.
### Architecture Issues
- **Need to refactor unlock key hierarchy**: Current implementation has confusion between the top-level concepts. Fix in progress.
- Current code uses `unlockers.d/` but documentation shows `unlock-keys.d/`
- Need to settle on consistent naming: "unlock keys" vs "unlockers" throughout the codebase
- [ ] **51. Consistent interface implementation**: Ensure all unlocker types properly implement the Unlocker interface.

View File

@ -74,11 +74,11 @@ func (cli *CLIInstance) Encrypt(secretName, inputFile, outputFile string) error
if os.Getenv(secret.EnvMnemonic) != "" { if os.Getenv(secret.EnvMnemonic) != "" {
secretValue, err = secretObj.GetValue(nil) secretValue, err = secretObj.GetValue(nil)
} else { } else {
unlockKey, unlockErr := vlt.GetCurrentUnlockKey() unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil { if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr) return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
} }
secretValue, err = secretObj.GetValue(unlockKey) secretValue, err = secretObj.GetValue(unlocker)
} }
if err != nil { if err != nil {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)
@ -178,11 +178,11 @@ func (cli *CLIInstance) Decrypt(secretName, inputFile, outputFile string) error
if os.Getenv(secret.EnvMnemonic) != "" { if os.Getenv(secret.EnvMnemonic) != "" {
secretValue, err = secretObj.GetValue(nil) secretValue, err = secretObj.GetValue(nil)
} else { } else {
unlockKey, unlockErr := vlt.GetCurrentUnlockKey() unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil { if unlockErr != nil {
return fmt.Errorf("failed to get current unlock key: %w", unlockErr) return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
} }
secretValue, err = secretObj.GetValue(unlockKey) secretValue, err = secretObj.GetValue(unlocker)
} }
if err != nil { if err != nil {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)

View File

@ -140,7 +140,7 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Prompt for passphrase for unlock key // Prompt for passphrase for unlocker
var passphraseStr string var passphraseStr string
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
@ -148,61 +148,61 @@ func (cli *CLIInstance) Init(cmd *cobra.Command) error {
} else { } else {
secret.Debug("Prompting user for unlock passphrase") secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
secret.Debug("Failed to read unlock passphrase", "error", err) secret.Debug("Failed to read unlock passphrase", "error", err)
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
} }
// Create passphrase-protected unlock key // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlock key") secret.Debug("Creating passphrase-protected unlocker")
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil { if err != nil {
secret.Debug("Failed to create unlock key", "error", err) secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlock key: %w", err) return fmt.Errorf("failed to create unlocker: %w", err)
} }
// Encrypt long-term private key to the unlock key // Encrypt long-term private key to the unlocker
unlockKeyDir := passphraseKey.GetDirectory() unlockerDir := passphraseUnlocker.GetDirectory()
// Read unlock key public key // Read unlocker public key
unlockPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockKeyDir, "pub.age")) unlockerPubKeyData, err := afero.ReadFile(cli.fs, filepath.Join(unlockerDir, "pub.age"))
if err != nil { if err != nil {
return fmt.Errorf("failed to read unlock key public key: %w", err) return fmt.Errorf("failed to read unlocker public key: %w", err)
} }
unlockRecipient, err := age.ParseX25519Recipient(string(unlockPubKeyData)) unlockerRecipient, err := age.ParseX25519Recipient(string(unlockerPubKeyData))
if err != nil { if err != nil {
return fmt.Errorf("failed to parse unlock key public key: %w", err) return fmt.Errorf("failed to parse unlocker public key: %w", err)
} }
// Encrypt long-term private key to unlock key // Encrypt long-term private key to unlocker
ltPrivKeyData := []byte(ltIdentity.String()) ltPrivKeyData := []byte(ltIdentity.String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockRecipient) encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyData, unlockerRecipient)
if err != nil { if err != nil {
return fmt.Errorf("failed to encrypt long-term private key: %w", err) return fmt.Errorf("failed to encrypt long-term private key: %w", err)
} }
// Write encrypted long-term private key // Write encrypted long-term private key
if err := afero.WriteFile(cli.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil { if err := afero.WriteFile(cli.fs, filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey, secret.FilePerms); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
if cmd != nil { if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n") cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltPubKey) cmd.Printf("Long-term public key: %s\n", ltPubKey)
cmd.Printf("Unlock key ID: %s\n", passphraseKey.GetID()) cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
cmd.Println("\nYour secret manager is ready to use!") cmd.Println("\nYour secret manager is ready to use!")
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,") cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
cmd.Println("unlock keys are not required for secret operations.") cmd.Println("unlockers are not required for secret operations.")
} }
return nil return nil
} }
// readSecurePassphrase reads a passphrase securely from the terminal without echoing // readSecurePassphrase reads a passphrase securely from the terminal without echoing
// This version adds confirmation (read twice) for creating new unlock keys // This version adds confirmation (read twice) for creating new unlockers
func readSecurePassphrase(prompt string) (string, error) { func readSecurePassphrase(prompt string) (string, error) {
// Get the first passphrase // Get the first passphrase
passphrase1, err := secret.ReadPassphrase(prompt) passphrase1, err := secret.ReadPassphrase(prompt)

View File

@ -35,8 +35,8 @@ func newRootCmd() *cobra.Command {
cmd.AddCommand(newAddCmd()) cmd.AddCommand(newAddCmd())
cmd.AddCommand(newGetCmd()) cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd()) cmd.AddCommand(newListCmd())
cmd.AddCommand(newKeysCmd()) cmd.AddCommand(newUnlockersCmd())
cmd.AddCommand(newKeyCmd()) cmd.AddCommand(newUnlockerCmd())
cmd.AddCommand(newImportCmd()) cmd.AddCommand(newImportCmd())
cmd.AddCommand(newEncryptCmd()) cmd.AddCommand(newEncryptCmd())
cmd.AddCommand(newDecryptCmd()) cmd.AddCommand(newDecryptCmd())

View File

@ -18,29 +18,29 @@ import (
// ... existing imports ... // ... existing imports ...
func newKeysCmd() *cobra.Command { func newUnlockersCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "keys", Use: "unlockers",
Short: "Manage unlock keys", Short: "Manage unlockers",
Long: `Create, list, and remove unlock keys for the current vault.`, Long: `Create, list, and remove unlockers for the current vault.`,
} }
cmd.AddCommand(newKeysListCmd()) cmd.AddCommand(newUnlockersListCmd())
cmd.AddCommand(newKeysAddCmd()) cmd.AddCommand(newUnlockersAddCmd())
cmd.AddCommand(newKeysRmCmd()) cmd.AddCommand(newUnlockersRmCmd())
return cmd return cmd
} }
func newKeysListCmd() *cobra.Command { func newUnlockersListCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "list", Use: "list",
Short: "List unlock keys in the current vault", Short: "List unlockers in the current vault",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.KeysList(jsonOutput) return cli.UnlockersList(jsonOutput)
}, },
} }
@ -48,60 +48,60 @@ func newKeysListCmd() *cobra.Command {
return cmd return cmd
} }
func newKeysAddCmd() *cobra.Command { func newUnlockersAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "add <type>", Use: "add <type>",
Short: "Add a new unlock key", Short: "Add a new unlocker",
Long: `Add a new unlock key of the specified type (passphrase, keychain, pgp).`, Long: `Add a new unlocker of the specified type (passphrase, keychain, pgp).`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.KeysAdd(args[0], cmd) return cli.UnlockersAdd(args[0], cmd)
}, },
} }
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlock keys") cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers")
return cmd return cmd
} }
func newKeysRmCmd() *cobra.Command { func newUnlockersRmCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "rm <key-id>", Use: "rm <unlocker-id>",
Short: "Remove an unlock key", Short: "Remove an unlocker",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.KeysRemove(args[0]) return cli.UnlockersRemove(args[0])
}, },
} }
} }
func newKeyCmd() *cobra.Command { func newUnlockerCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "key", Use: "unlocker",
Short: "Manage current unlock key", Short: "Manage current unlocker",
Long: `Select the current unlock key for operations.`, Long: `Select the current unlocker for operations.`,
} }
cmd.AddCommand(newKeySelectSubCmd()) cmd.AddCommand(newUnlockerSelectSubCmd())
return cmd return cmd
} }
func newKeySelectSubCmd() *cobra.Command { func newUnlockerSelectSubCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "select <key-id>", Use: "select <unlocker-id>",
Short: "Select an unlock key as current", Short: "Select an unlocker as current",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli := NewCLIInstance()
return cli.KeySelect(args[0]) return cli.UnlockerSelect(args[0])
}, },
} }
} }
// KeysList lists unlock keys in the current vault // UnlockersList lists unlockers in the current vault
func (cli *CLIInstance) KeysList(jsonOutput bool) error { func (cli *CLIInstance) UnlockersList(jsonOutput bool) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@ -109,90 +109,90 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
} }
// Get the metadata first // Get the metadata first
keyMetadataList, err := vlt.ListUnlockKeys() unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return err return err
} }
// Load actual unlock key objects to get the proper IDs // Load actual unlocker objects to get the proper IDs
type KeyInfo struct { type UnlockerInfo struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Flags []string `json:"flags,omitempty"` Flags []string `json:"flags,omitempty"`
} }
var keys []KeyInfo var unlockers []UnlockerInfo
for _, metadata := range keyMetadataList { for _, metadata := range unlockerMetadataList {
// Create unlock key instance to get the proper ID // Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
continue continue
} }
// Find the key directory by type and created time // Find the unlocker directory by type and created time
unlockKeysDir := filepath.Join(vaultDir, "unlock.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockKeysDir) files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil { if err != nil {
continue continue
} }
var unlockKey secret.UnlockKey var unlocker secret.Unlocker
for _, file := range files { for _, file := range files {
if !file.IsDir() { if !file.IsDir() {
continue continue
} }
keyDir := filepath.Join(unlockKeysDir, file.Name()) unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(keyDir, "unlock-metadata.json") metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right key by comparing metadata // Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil { if err != nil {
continue continue
} }
var diskMetadata secret.UnlockKeyMetadata var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue continue
} }
// Match by type and creation time // Match by type and creation time
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) { if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
// Create the appropriate unlock key instance // Create the appropriate unlocker instance
switch metadata.Type { switch metadata.Type {
case "passphrase": case "passphrase":
unlockKey = secret.NewPassphraseUnlockKey(cli.fs, keyDir, diskMetadata) unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
case "keychain": case "keychain":
unlockKey = secret.NewKeychainUnlockKey(cli.fs, keyDir, diskMetadata) unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp": case "pgp":
unlockKey = secret.NewPGPUnlockKey(cli.fs, keyDir, diskMetadata) unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
} }
break break
} }
} }
// Get the proper ID using the unlock key's ID() method // Get the proper ID using the unlocker's ID() method
var properID string var properID string
if unlockKey != nil { if unlocker != nil {
properID = unlockKey.GetID() properID = unlocker.GetID()
} else { } else {
properID = metadata.ID // fallback to metadata ID properID = metadata.ID // fallback to metadata ID
} }
keyInfo := KeyInfo{ unlockerInfo := UnlockerInfo{
ID: properID, ID: properID,
Type: metadata.Type, Type: metadata.Type,
CreatedAt: metadata.CreatedAt, CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags, Flags: metadata.Flags,
} }
keys = append(keys, keyInfo) unlockers = append(unlockers, unlockerInfo)
} }
if jsonOutput { if jsonOutput {
// JSON output // JSON output
output := map[string]interface{}{ output := map[string]interface{}{
"keys": keys, "unlockers": unlockers,
} }
jsonBytes, err := json.MarshalIndent(output, "", " ") jsonBytes, err := json.MarshalIndent(output, "", " ")
@ -203,36 +203,36 @@ func (cli *CLIInstance) KeysList(jsonOutput bool) error {
fmt.Println(string(jsonBytes)) fmt.Println(string(jsonBytes))
} else { } else {
// Pretty table output // Pretty table output
if len(keys) == 0 { if len(unlockers) == 0 {
fmt.Println("No unlock keys found in current vault.") fmt.Println("No unlockers found in current vault.")
fmt.Println("Run 'secret keys add passphrase' to create one.") fmt.Println("Run 'secret unlockers add passphrase' to create one.")
return nil return nil
} }
fmt.Printf("%-18s %-12s %-20s %s\n", "KEY ID", "TYPE", "CREATED", "FLAGS") fmt.Printf("%-18s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
fmt.Printf("%-18s %-12s %-20s %s\n", "------", "----", "-------", "-----") fmt.Printf("%-18s %-12s %-20s %s\n", "-----------", "----", "-------", "-----")
for _, key := range keys { for _, unlocker := range unlockers {
flags := "" flags := ""
if len(key.Flags) > 0 { if len(unlocker.Flags) > 0 {
flags = strings.Join(key.Flags, ",") flags = strings.Join(unlocker.Flags, ",")
} }
fmt.Printf("%-18s %-12s %-20s %s\n", fmt.Printf("%-18s %-12s %-20s %s\n",
key.ID, unlocker.ID,
key.Type, unlocker.Type,
key.CreatedAt.Format("2006-01-02 15:04:05"), unlocker.CreatedAt.Format("2006-01-02 15:04:05"),
flags) flags)
} }
fmt.Printf("\nTotal: %d unlock key(s)\n", len(keys)) fmt.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
} }
return nil return nil
} }
// KeysAdd adds a new unlock key // UnlockersAdd adds a new unlocker
func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error { func (cli *CLIInstance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
switch keyType { switch unlockerType {
case "passphrase": case "passphrase":
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@ -254,28 +254,28 @@ func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
passphraseStr = envPassphrase passphraseStr = envPassphrase
} else { } else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlock key: ") passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
} }
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil { if err != nil {
return err return err
} }
cmd.Printf("Created passphrase unlock key: %s\n", passphraseKey.GetID()) cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
return nil return nil
case "keychain": case "keychain":
keychainKey, err := secret.CreateKeychainUnlockKey(cli.fs, cli.stateDir) keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlock key: %w", err) return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
} }
cmd.Printf("Created macOS Keychain unlock key: %s\n", keychainKey.GetID()) cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
if keyName, err := keychainKey.GetKeychainItemName(); err == nil { if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
cmd.Printf("Keychain Item Name: %s\n", keyName) cmd.Printf("Keychain Item Name: %s\n", keyName)
} }
return nil return nil
@ -291,38 +291,38 @@ func (cli *CLIInstance) KeysAdd(keyType string, cmd *cobra.Command) error {
return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable") return fmt.Errorf("GPG key ID required: use --keyid flag or set SB_GPG_KEY_ID environment variable")
} }
pgpKey, err := secret.CreatePGPUnlockKey(cli.fs, cli.stateDir, gpgKeyID) pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
if err != nil { if err != nil {
return err return err
} }
cmd.Printf("Created PGP unlock key: %s\n", pgpKey.GetID()) cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
cmd.Printf("GPG Key ID: %s\n", gpgKeyID) cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
return nil return nil
default: default:
return fmt.Errorf("unsupported key type: %s (supported: passphrase, keychain, pgp)", keyType) return fmt.Errorf("unsupported unlocker type: %s (supported: passphrase, keychain, pgp)", unlockerType)
} }
} }
// KeysRemove removes an unlock key // UnlockersRemove removes an unlocker
func (cli *CLIInstance) KeysRemove(keyID string) error { func (cli *CLIInstance) UnlockersRemove(unlockerID string) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return err
} }
return vlt.RemoveUnlockKey(keyID) return vlt.RemoveUnlocker(unlockerID)
} }
// KeySelect selects an unlock key as current // UnlockerSelect selects an unlocker as current
func (cli *CLIInstance) KeySelect(keyID string) error { func (cli *CLIInstance) UnlockerSelect(unlockerID string) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return err
} }
return vlt.SelectUnlockKey(keyID) return vlt.SelectUnlocker(unlockerID)
} }

View File

@ -251,17 +251,17 @@ func (cli *CLIInstance) VaultImport(vaultName string) error {
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create passphrase-protected unlock key // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlock key") secret.Debug("Creating passphrase-protected unlocker")
passphraseKey, err := vlt.CreatePassphraseKey(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr)
if err != nil { if err != nil {
secret.Debug("Failed to create unlock key", "error", err) secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlock key: %w", err) return fmt.Errorf("failed to create unlocker: %w", err)
} }
fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName) fmt.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
fmt.Printf("Long-term public key: %s\n", ltPublicKey) fmt.Printf("Long-term public key: %s\n", ltPublicKey)
fmt.Printf("Unlock key ID: %s\n", passphraseKey.GetID()) fmt.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil return nil
} }

View File

@ -15,19 +15,19 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// KeychainUnlockKeyMetadata extends UnlockKeyMetadata with keychain-specific data // KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
type KeychainUnlockKeyMetadata struct { type KeychainUnlockerMetadata struct {
UnlockKeyMetadata UnlockerMetadata
// Age keypair information // Age keypair information
AgePublicKey string `json:"age_public_key"` AgePublicKey string `json:"age_public_key"`
// Keychain item name // Keychain item name
KeychainItemName string `json:"keychain_item_name"` KeychainItemName string `json:"keychain_item_name"`
} }
// KeychainUnlockKey represents a macOS Keychain-protected unlock key // KeychainUnlocker represents a macOS Keychain-protected unlocker
type KeychainUnlockKey struct { type KeychainUnlocker struct {
Directory string Directory string
Metadata UnlockKeyMetadata Metadata UnlockerMetadata
fs afero.Fs fs afero.Fs
} }
@ -38,17 +38,17 @@ type KeychainData struct {
EncryptedLongtermKey string `json:"encrypted_longterm_key"` EncryptedLongtermKey string `json:"encrypted_longterm_key"`
} }
// GetIdentity implements UnlockKey interface for Keychain-based unlock keys // GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) { func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlock key identity", DebugWith("Getting keychain unlocker identity",
slog.String("key_id", k.GetID()), slog.String("unlocker_id", k.GetID()),
slog.String("key_type", k.GetType()), slog.String("unlocker_type", k.GetType()),
) )
// Step 1: Get keychain item name // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name", "error", err, "key_id", k.GetID()) Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err) return nil, fmt.Errorf("failed to get keychain item name: %w", err)
} }
@ -61,18 +61,18 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
} }
DebugWith("Retrieved data from keychain", DebugWith("Retrieved data from keychain",
slog.String("key_id", k.GetID()), slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)), slog.Int("data_length", len(keychainDataBytes)),
) )
// Step 3: Parse keychain data // Step 3: Parse keychain data
var keychainData KeychainData var keychainData KeychainData
if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil { if err := json.Unmarshal(keychainDataBytes, &keychainData); err != nil {
Debug("Failed to parse keychain data", "error", err, "key_id", k.GetID()) Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err) return nil, fmt.Errorf("failed to parse keychain data: %w", err)
} }
Debug("Parsed keychain data successfully", "key_id", k.GetID()) Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem // Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age") agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
@ -85,61 +85,61 @@ func (k *KeychainUnlockKey) GetIdentity() (*age.X25519Identity, error) {
} }
DebugWith("Read encrypted age private key", DebugWith("Read encrypted age private key",
slog.String("key_id", k.GetID()), slog.String("unlocker_id", k.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)), slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
) )
// Step 5: Decrypt the age private key using the passphrase from keychain // Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "key_id", k.GetID()) Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase) agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil { if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "key_id", k.GetID()) Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err) return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
} }
DebugWith("Successfully decrypted age private key with keychain passphrase", DebugWith("Successfully decrypted age private key with keychain passphrase",
slog.String("key_id", k.GetID()), slog.String("unlocker_id", k.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)), slog.Int("decrypted_length", len(agePrivKeyData)),
) )
// Step 6: Parse the decrypted age private key // Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", k.GetID()) Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData)) ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil { if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", k.GetID()) Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err) return nil, fmt.Errorf("failed to parse age private key: %w", err)
} }
DebugWith("Successfully parsed keychain age identity", DebugWith("Successfully parsed keychain age identity",
slog.String("key_id", k.GetID()), slog.String("unlocker_id", k.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()), slog.String("public_key", ageIdentity.Recipient().String()),
) )
return ageIdentity, nil return ageIdentity, nil
} }
// GetType implements UnlockKey interface // GetType implements Unlocker interface
func (k *KeychainUnlockKey) GetType() string { func (k *KeychainUnlocker) GetType() string {
return "keychain" return "keychain"
} }
// GetMetadata implements UnlockKey interface // GetMetadata implements Unlocker interface
func (k *KeychainUnlockKey) GetMetadata() UnlockKeyMetadata { func (k *KeychainUnlocker) GetMetadata() UnlockerMetadata {
return k.Metadata return k.Metadata
} }
// GetDirectory implements UnlockKey interface // GetDirectory implements Unlocker interface
func (k *KeychainUnlockKey) GetDirectory() string { func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID implements UnlockKey interface // GetID implements Unlocker interface
func (k *KeychainUnlockKey) GetID() string { func (k *KeychainUnlocker) GetID() string {
return k.Metadata.ID return k.Metadata.ID
} }
// ID implements UnlockKey interface - generates ID from keychain item name // ID implements Unlocker interface - generates ID from keychain item name
func (k *KeychainUnlockKey) ID() string { func (k *KeychainUnlocker) ID() string {
// Generate ID using keychain item name // Generate ID using keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
@ -149,12 +149,12 @@ func (k *KeychainUnlockKey) ID() string {
return fmt.Sprintf("%s-keychain", keychainItemName) return fmt.Sprintf("%s-keychain", keychainItemName)
} }
// Remove implements UnlockKey interface - removes the keychain unlock key // Remove implements Unlocker interface - removes the keychain unlocker
func (k *KeychainUnlockKey) Remove() error { func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name during removal", "error", err, "key_id", k.GetID()) Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err) return fmt.Errorf("failed to get keychain item name: %w", err)
} }
@ -166,19 +166,19 @@ func (k *KeychainUnlockKey) Remove() error {
} }
// Step 3: Remove directory // Step 3: Remove directory
Debug("Removing keychain unlock key directory", "directory", k.Directory) Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := k.fs.RemoveAll(k.Directory); err != nil { if err := k.fs.RemoveAll(k.Directory); err != nil {
Debug("Failed to remove keychain unlock key directory", "error", err, "directory", k.Directory) Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlock key directory: %w", err) return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
} }
Debug("Successfully removed keychain unlock key", "key_id", k.GetID(), "keychain_item", keychainItemName) Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
return nil return nil
} }
// NewKeychainUnlockKey creates a new KeychainUnlockKey instance // NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *KeychainUnlockKey { func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
return &KeychainUnlockKey{ return &KeychainUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
fs: fs, fs: fs,
@ -186,7 +186,7 @@ func NewKeychainUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetad
} }
// GetKeychainItemName returns the keychain item name from metadata // GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) { func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata // Load the metadata
metadataPath := filepath.Join(k.Directory, "unlock-metadata.json") metadataPath := filepath.Join(k.Directory, "unlock-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath) metadataData, err := afero.ReadFile(k.fs, metadataPath)
@ -194,7 +194,7 @@ func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) {
return "", fmt.Errorf("failed to read keychain metadata: %w", err) return "", fmt.Errorf("failed to read keychain metadata: %w", err)
} }
var keychainMetadata KeychainUnlockKeyMetadata var keychainMetadata KeychainUnlockerMetadata
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil { if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err) return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
} }
@ -202,8 +202,8 @@ func (k *KeychainUnlockKey) GetKeychainItemName() (string, error) {
return keychainMetadata.KeychainItemName, nil return keychainMetadata.KeychainItemName, nil
} }
// generateKeychainUnlockKeyName generates a unique name for the keychain unlock key // generateKeychainUnlockerName generates a unique name for the keychain unlocker
func generateKeychainUnlockKeyName(vaultName string) (string, error) { func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
@ -214,8 +214,8 @@ func generateKeychainUnlockKeyName(vaultName string) (string, error) {
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
} }
// CreateKeychainUnlockKey creates a new keychain unlock key and stores it in the vault // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey, error) { func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// Check if we're on macOS // Check if we're on macOS
if err := checkMacOSAvailable(); err != nil { if err := checkMacOSAvailable(); err != nil {
return nil, err return nil, err
@ -228,23 +228,23 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
} }
// Generate the keychain item name // Generate the keychain item name
keychainItemName, err := generateKeychainUnlockKeyName(vault.GetName()) keychainItemName, err := generateKeychainUnlockerName(vault.GetName())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate keychain item name: %w", err) 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 // Create unlocker directory using the keychain item name as the directory name
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", keychainItemName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil { if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err) return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
} }
// Step 1: Generate a new age keypair for the keychain unlock key // Step 1: Generate a new age keypair for the keychain unlocker
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err) return nil, fmt.Errorf("failed to generate age keypair: %w", err)
@ -258,7 +258,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
// Step 3: Store age public key as plaintext // Step 3: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String() agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age") agePubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil { if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err) return nil, fmt.Errorf("failed to write age public key: %w", err)
} }
@ -270,7 +270,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age") agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil { if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err) return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
} }
@ -287,61 +287,61 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
} }
ltPrivKeyData = []byte(ltIdentity.String()) ltPrivKeyData = []byte(ltIdentity.String())
} else { } else {
// Get the vault to access current unlock key // Get the vault to access current unlocker
currentUnlockKey, err := vault.GetCurrentUnlockKey() currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
// Get the current unlock key identity // Get the current unlocker identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity() currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err) return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
} }
// Get encrypted long-term key from current unlock key, handling different types // Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) { switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlockKey: case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlock key // Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
} }
case *PGPUnlockKey: case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlock key // Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
} }
case *KeychainUnlockKey: case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain unlock key // Read the encrypted long-term private key from another keychain unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlock key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
} }
default: default:
return nil, fmt.Errorf("unsupported current unlock key type for keychain unlock key creation") return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
} }
// Decrypt long-term private key using current unlock key // Decrypt long-term private key using current unlocker
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity) ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) 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 // Step 6: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient()) encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
} }
// Write encrypted long-term private key // Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age") ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil { if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err) return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
@ -367,8 +367,8 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
// Generate the key ID directly using the keychain item name // Generate the key ID directly using the keychain item name
keyID := fmt.Sprintf("%s-keychain", keychainItemName) keyID := fmt.Sprintf("%s-keychain", keychainItemName)
keychainMetadata := KeychainUnlockKeyMetadata{ keychainMetadata := KeychainUnlockerMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{ UnlockerMetadata: UnlockerMetadata{
ID: keyID, ID: keyID,
Type: "keychain", Type: "keychain",
CreatedAt: time.Now(), CreatedAt: time.Now(),
@ -380,16 +380,16 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ") metadataBytes, err := json.MarshalIndent(keychainMetadata, "", " ")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil { if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err) return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
return &KeychainUnlockKey{ return &KeychainUnlocker{
Directory: unlockKeyDir, Directory: unlockerDir,
Metadata: keychainMetadata.UnlockKeyMetadata, Metadata: keychainMetadata.UnlockerMetadata,
fs: fs, fs: fs,
}, nil }, nil
} }
@ -398,7 +398,7 @@ func CreateKeychainUnlockKey(fs afero.Fs, stateDir string) (*KeychainUnlockKey,
func checkMacOSAvailable() error { func checkMacOSAvailable() error {
cmd := exec.Command("/usr/bin/security", "help") cmd := exec.Command("/usr/bin/security", "help")
if err := cmd.Run(); err != nil { 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 fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
} }
return nil return nil
} }

View File

@ -14,8 +14,8 @@ type VaultMetadata struct {
MnemonicHash string `json:"mnemonic_hash"` // Double SHA256 hash of mnemonic for index tracking MnemonicHash string `json:"mnemonic_hash"` // Double SHA256 hash of mnemonic for index tracking
} }
// UnlockKeyMetadata contains information about an unlock key // UnlockerMetadata contains information about an unlocker
type UnlockKeyMetadata struct { type UnlockerMetadata struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // passphrase, pgp, keychain Type string `json:"type"` // passphrase, pgp, keychain
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`

View File

@ -12,7 +12,7 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
func TestPassphraseUnlockKeyWithRealFS(t *testing.T) { func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// Skip this test if CI=true is set, as it uses real filesystem // Skip this test if CI=true is set, as it uses real filesystem
if os.Getenv("CI") == "true" { if os.Getenv("CI") == "true" {
t.Skip("Skipping test with real filesystem in CI environment") t.Skip("Skipping test with real filesystem in CI environment")
@ -33,21 +33,21 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
testPassphrase := "test-passphrase-123" testPassphrase := "test-passphrase-123"
// Create the directory structure // Create the directory structure
keyDir := filepath.Join(tempDir, "unlock-key") unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil { if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create key directory: %v", err) t.Fatalf("Failed to create unlocker directory: %v", err)
} }
// Set up test metadata // Set up test metadata
metadata := secret.UnlockKeyMetadata{ metadata := secret.UnlockerMetadata{
ID: "test-passphrase", ID: "test-passphrase",
Type: "passphrase", Type: "passphrase",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{}, Flags: []string{},
} }
// Create passphrase unlock key // Create passphrase unlocker
unlockKey := secret.NewPassphraseUnlockKey(fs, keyDir, metadata) unlocker := secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
// Generate a test age identity // Generate a test age identity
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
@ -59,7 +59,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Test writing public key // Test writing public key
t.Run("WritePublicKey", func(t *testing.T) { t.Run("WritePublicKey", func(t *testing.T) {
pubKeyPath := filepath.Join(keyDir, "pub.age") pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil { if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil {
t.Fatalf("Failed to write public key: %v", err) t.Fatalf("Failed to write public key: %v", err)
} }
@ -82,7 +82,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to encrypt private key: %v", err) t.Fatalf("Failed to encrypt private key: %v", err)
} }
privKeyPath := filepath.Join(keyDir, "priv.age") privKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil { if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err) t.Fatalf("Failed to write encrypted private key: %v", err)
} }
@ -105,7 +105,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to derive long-term identity: %v", err) t.Fatalf("Failed to derive long-term identity: %v", err)
} }
// Encrypt long-term private key to the unlock key's recipient // Encrypt long-term private key to the unlocker's recipient
recipient, err := age.ParseX25519Recipient(agePublicKey) recipient, err := age.ParseX25519Recipient(agePublicKey)
if err != nil { if err != nil {
t.Fatalf("Failed to parse recipient: %v", err) t.Fatalf("Failed to parse recipient: %v", err)
@ -117,7 +117,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
t.Fatalf("Failed to encrypt long-term private key: %v", err) t.Fatalf("Failed to encrypt long-term private key: %v", err)
} }
ltPrivKeyPath := filepath.Join(keyDir, "longterm.age") ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil { if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted long-term private key: %v", err) t.Fatalf("Failed to write encrypted long-term private key: %v", err)
} }
@ -147,7 +147,7 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Test getting identity from environment variable // Test getting identity from environment variable
t.Run("GetIdentityFromEnv", func(t *testing.T) { t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlockKey.GetIdentity() identity, err := unlocker.GetIdentity()
if err != nil { if err != nil {
t.Fatalf("Failed to get identity from env: %v", err) t.Fatalf("Failed to get identity from env: %v", err)
} }
@ -168,26 +168,26 @@ func TestPassphraseUnlockKeyWithRealFS(t *testing.T) {
// Here we'll just verify the error is what we expect when no passphrase is available // Here we'll just verify the error is what we expect when no passphrase is available
t.Run("GetIdentityWithoutEnv", func(t *testing.T) { t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
// This should fail since we're not in an interactive terminal // This should fail since we're not in an interactive terminal
_, err := unlockKey.GetIdentity() _, err := unlocker.GetIdentity()
if err == nil { if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var") t.Errorf("Should have failed to get identity without passphrase env var")
} }
}) })
// Test removing the unlock key // Test removing the unlocker
t.Run("RemoveUnlockKey", func(t *testing.T) { t.Run("RemoveUnlocker", func(t *testing.T) {
err := unlockKey.Remove() err := unlocker.Remove()
if err != nil { if err != nil {
t.Fatalf("Failed to remove unlock key: %v", err) t.Fatalf("Failed to remove unlocker: %v", err)
} }
// Verify the directory is gone // Verify the directory is gone
exists, err := afero.DirExists(fs, keyDir) exists, err := afero.DirExists(fs, unlockerDir)
if err != nil { if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err) t.Fatalf("Failed to check if unlocker directory exists: %v", err)
} }
if exists { if exists {
t.Errorf("Key directory should not exist after removal") t.Errorf("Unlocker directory should not exist after removal")
} }
}) })
} }

View File

@ -1,150 +0,0 @@
package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"github.com/spf13/afero"
)
// PassphraseUnlockKey represents a passphrase-protected unlock key
type PassphraseUnlockKey struct {
Directory string
Metadata UnlockKeyMetadata
fs afero.Fs
Passphrase string
}
// GetIdentity implements UnlockKey interface for passphrase-based unlock keys
func (p *PassphraseUnlockKey) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("key_type", p.GetType()),
)
// First check if we already have the passphrase
passphraseStr := p.Passphrase
if passphraseStr == "" {
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr = os.Getenv(EnvUnlockPassphrase)
if passphraseStr == "" {
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase
var err error
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
} else {
Debug("Using passphrase from environment", "key_id", p.GetID())
}
} else {
Debug("Using in-memory passphrase", "key_id", p.GetID())
}
// Read encrypted private key of unlock key
unlockKeyPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlock key", "path", unlockKeyPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockKeyPrivPath)
if err != nil {
Debug("Failed to read passphrase unlock key private key", "error", err, "path", unlockKeyPrivPath)
return nil, fmt.Errorf("failed to read unlock key private key: %w", err)
}
DebugWith("Read encrypted passphrase unlock key",
slog.String("key_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlock key private key with passphrase", "key_id", p.GetID())
// Decrypt the unlock key private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil {
Debug("Failed to decrypt unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlock key private key: %w", err)
}
DebugWith("Successfully decrypted unlock key private key",
slog.String("key_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlock key identity", "key_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
if err != nil {
Debug("Failed to parse unlock key private key", "error", err, "key_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlock key private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlock key identity",
slog.String("key_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements UnlockKey interface
func (p *PassphraseUnlockKey) GetType() string {
return "passphrase"
}
// GetMetadata implements UnlockKey interface
func (p *PassphraseUnlockKey) GetMetadata() UnlockKeyMetadata {
return p.Metadata
}
// GetDirectory implements UnlockKey interface
func (p *PassphraseUnlockKey) GetDirectory() string {
return p.Directory
}
// GetID implements UnlockKey interface
func (p *PassphraseUnlockKey) GetID() string {
return p.Metadata.ID
}
// ID implements UnlockKey interface - generates ID from creation timestamp
func (p *PassphraseUnlockKey) ID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
}
// Remove implements UnlockKey interface - removes the passphrase unlock key
func (p *PassphraseUnlockKey) Remove() error {
// For passphrase 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 passphrase unlock key directory: %w", err)
}
return nil
}
// NewPassphraseUnlockKey creates a new PassphraseUnlockKey instance
func NewPassphraseUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PassphraseUnlockKey {
return &PassphraseUnlockKey{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreatePassphraseKey creates a new passphrase-protected unlock key
func CreatePassphraseKey(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlockKey, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseKey(passphrase)
}

View File

@ -0,0 +1,150 @@
package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"github.com/spf13/afero"
)
// PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
Passphrase string
}
// GetIdentity implements Unlocker interface for passphrase-based unlockers
func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
// First check if we already have the passphrase
passphraseStr := p.Passphrase
if passphraseStr == "" {
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr = os.Getenv(EnvUnlockPassphrase)
if passphraseStr == "" {
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase
var err error
passphraseStr, err = ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
} else {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
}
} else {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
}
// Read encrypted private key of unlocker
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
Debug("Reading encrypted passphrase unlocker", "path", unlockerPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
if err != nil {
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
}
DebugWith("Read encrypted passphrase unlocker",
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
}
DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(privKeyData)),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements Unlocker interface
func (p *PassphraseUnlocker) GetType() string {
return "passphrase"
}
// GetMetadata implements Unlocker interface
func (p *PassphraseUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements Unlocker interface
func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface
func (p *PassphraseUnlocker) GetID() string {
return p.Metadata.ID
}
// ID implements Unlocker interface - generates ID from creation timestamp
func (p *PassphraseUnlocker) ID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
}
// Remove implements Unlocker interface - removes the passphrase unlocker
func (p *PassphraseUnlocker) Remove() error {
// For passphrase unlockers, 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 passphrase unlocker directory: %w", err)
}
return nil
}
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
return &PassphraseUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseUnlocker(passphrase)
}

View File

@ -123,7 +123,7 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
func TestPGPUnlockKeyWithRealFS(t *testing.T) { func TestPGPUnlockerWithRealFS(t *testing.T) {
// Skip tests if gpg is not available // Skip tests if gpg is not available
if _, err := exec.LookPath("gpg"); err != nil { if _, err := exec.LookPath("gpg"); err != nil {
t.Skip("GPG not available, skipping PGP unlock key tests") t.Skip("GPG not available, skipping PGP unlock key tests")
@ -258,7 +258,7 @@ Passphrase: ` + testPassphrase + `
vaultName := "test-vault" vaultName := "test-vault"
// Test creation of a PGP unlock key through a vault // Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlockKey", func(t *testing.T) { t.Run("CreatePGPUnlocker", func(t *testing.T) {
// Set a limited test timeout to avoid hanging // Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() { timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds") t.Fatalf("Test timed out after 30 seconds")
@ -298,50 +298,50 @@ Passphrase: ` + testPassphrase + `
// Unlock the vault // Unlock the vault
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create a passphrase unlock key first (to have current unlock key) // Create a passphrase unlocker first (to have current unlocker)
passKey, err := vlt.CreatePassphraseKey("test-passphrase") passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
if err != nil { if err != nil {
t.Fatalf("Failed to create passphrase key: %v", err) t.Fatalf("Failed to create passphrase unlocker: %v", err)
} }
// Verify passphrase key was created // Verify passphrase unlocker was created
if passKey == nil { if passUnlocker == nil {
t.Fatal("Passphrase key is nil") t.Fatal("Passphrase unlocker is nil")
} }
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc) // Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpKey, err := secret.CreatePGPUnlockKey(fs, stateDir, keyID) pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
if err != nil { if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err) t.Fatalf("Failed to create PGP unlock key: %v", err)
} }
// Verify the PGP unlock key was created // Verify the PGP unlock key was created
if pgpKey == nil { if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil") t.Fatal("PGP unlock key is nil")
} }
// Check if the key has the correct type // Check if the key has the correct type
if pgpKey.GetType() != "pgp" { if pgpUnlocker.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpKey.GetType()) t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
} }
// Check if the key ID includes the GPG key ID // Check if the key ID includes the GPG key ID
if !strings.Contains(pgpKey.GetID(), keyID) { if !strings.Contains(pgpUnlocker.GetID(), keyID) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpKey.GetID(), keyID) t.Errorf("PGP unlock key ID '%s' does not contain GPG key ID '%s'", pgpUnlocker.GetID(), keyID)
} }
// Check if the key directory exists // Check if the key directory exists
keyDir := pgpKey.GetDirectory() unlockerDir := pgpUnlocker.GetDirectory()
keyExists, err := afero.DirExists(fs, keyDir) keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil { if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err) t.Fatalf("Failed to check if PGP key directory exists: %v", err)
} }
if !keyExists { if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", keyDir) t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
} }
// Check if required files exist // Check if required files exist
pubKeyPath := filepath.Join(keyDir, "pub.age") pubKeyPath := filepath.Join(unlockerDir, "pub.age")
pubKeyExists, err := afero.Exists(fs, pubKeyPath) pubKeyExists, err := afero.Exists(fs, pubKeyPath)
if err != nil { if err != nil {
t.Fatalf("Failed to check if public key file exists: %v", err) t.Fatalf("Failed to check if public key file exists: %v", err)
@ -350,7 +350,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key public key file does not exist: %s", pubKeyPath) t.Errorf("PGP unlock key public key file does not exist: %s", pubKeyPath)
} }
privKeyPath := filepath.Join(keyDir, "priv.age.gpg") privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath) privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil { if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err) t.Fatalf("Failed to check if private key file exists: %v", err)
@ -359,7 +359,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath) t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
} }
metadataPath := filepath.Join(keyDir, "unlock-metadata.json") metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataExists, err := afero.Exists(fs, metadataPath) metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil { if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err) t.Fatalf("Failed to check if metadata file exists: %v", err)
@ -368,7 +368,7 @@ Passphrase: ` + testPassphrase + `
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath) t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
} }
longtermPath := filepath.Join(keyDir, "longterm.age") longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath) longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil { if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err) t.Fatalf("Failed to check if longterm key file exists: %v", err)
@ -405,37 +405,37 @@ Passphrase: ` + testPassphrase + `
}) })
// Set up key directory for individual tests // Set up key directory for individual tests
keyDir := filepath.Join(tempDir, "unlock-key") unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(keyDir, secret.DirPerms); err != nil { if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
t.Fatalf("Failed to create key directory: %v", err) t.Fatalf("Failed to create unlocker directory: %v", err)
} }
// Set up test metadata // Set up test metadata
metadata := secret.UnlockKeyMetadata{ metadata := secret.UnlockerMetadata{
ID: fmt.Sprintf("%s-pgp", keyID), ID: fmt.Sprintf("%s-pgp", keyID),
Type: "pgp", Type: "pgp",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"}, Flags: []string{"gpg", "encrypted"},
} }
// Create a PGP unlock key for the remaining tests // Create a PGP unlocker for the remaining tests
unlockKey := secret.NewPGPUnlockKey(fs, keyDir, metadata) unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting GPG key ID // Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) { t.Run("GetGPGKeyID", func(t *testing.T) {
// Create PGP metadata with GPG key ID // Create PGP metadata with GPG key ID
type PGPUnlockKeyMetadata struct { type PGPUnlockerMetadata struct {
secret.UnlockKeyMetadata secret.UnlockerMetadata
GPGKeyID string `json:"gpg_key_id"` GPGKeyID string `json:"gpg_key_id"`
} }
pgpMetadata := PGPUnlockKeyMetadata{ pgpMetadata := PGPUnlockerMetadata{
UnlockKeyMetadata: metadata, UnlockerMetadata: metadata,
GPGKeyID: keyID, GPGKeyID: keyID,
} }
// Write metadata file // Write metadata file
metadataPath := filepath.Join(keyDir, "unlock-metadata.json") metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ") metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil { if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err) t.Fatalf("Failed to marshal metadata: %v", err)
@ -445,7 +445,7 @@ Passphrase: ` + testPassphrase + `
} }
// Get GPG key ID // Get GPG key ID
retrievedKeyID, err := unlockKey.GetGPGKeyID() retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil { if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err) t.Fatalf("Failed to get GPG key ID: %v", err)
} }
@ -456,7 +456,7 @@ Passphrase: ` + testPassphrase + `
} }
}) })
// Test getting identity from PGP unlock key // Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) { t.Run("GetIdentity", func(t *testing.T) {
// Generate an age identity for testing // Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
@ -465,7 +465,7 @@ Passphrase: ` + testPassphrase + `
} }
// Write the public key // Write the public key
pubKeyPath := filepath.Join(keyDir, "pub.age") pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, pubKeyPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil { if err := afero.WriteFile(fs, pubKeyPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write public key: %v", err) t.Fatalf("Failed to write public key: %v", err)
} }
@ -478,13 +478,13 @@ Passphrase: ` + testPassphrase + `
} }
// Write the encrypted data to a file // Write the encrypted data to a file
encryptedPath := filepath.Join(keyDir, "priv.age.gpg") encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil { if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err) t.Fatalf("Failed to write encrypted private key: %v", err)
} }
// Now try to get the identity - this will use our custom GPGDecryptFunc // Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlockKey.GetIdentity() identity, err := unlocker.GetIdentity()
if err != nil { if err != nil {
t.Fatalf("Failed to get identity: %v", err) t.Fatalf("Failed to get identity: %v", err)
} }
@ -497,30 +497,30 @@ Passphrase: ` + testPassphrase + `
} }
}) })
// Test removing the unlock key // Test removing the unlocker
t.Run("RemoveUnlockKey", func(t *testing.T) { t.Run("RemoveUnlocker", func(t *testing.T) {
// Ensure key directory exists before removal // Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, keyDir) keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil { if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err) t.Fatalf("Failed to check if unlocker directory exists: %v", err)
} }
if !keyExists { if !keyExists {
t.Fatalf("Key directory does not exist: %s", keyDir) t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
} }
// Remove unlock key // Remove unlocker
err = unlockKey.Remove() err = unlocker.Remove()
if err != nil { if err != nil {
t.Fatalf("Failed to remove unlock key: %v", err) t.Fatalf("Failed to remove unlocker: %v", err)
} }
// Verify directory is gone // Verify directory is gone
keyExists, err = afero.DirExists(fs, keyDir) keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil { if err != nil {
t.Fatalf("Failed to check if key directory exists: %v", err) t.Fatalf("Failed to check if unlocker directory exists: %v", err)
} }
if keyExists { if keyExists {
t.Errorf("Key directory still exists after removal: %s", keyDir) t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
} }
}) })
} }

View File

@ -26,9 +26,9 @@ var (
GPGDecryptFunc = gpgDecryptDefault GPGDecryptFunc = gpgDecryptDefault
) )
// PGPUnlockKeyMetadata extends UnlockKeyMetadata with PGP-specific data // PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
type PGPUnlockKeyMetadata struct { type PGPUnlockerMetadata struct {
UnlockKeyMetadata UnlockerMetadata
// GPG key ID used for encryption // GPG key ID used for encryption
GPGKeyID string `json:"gpg_key_id"` GPGKeyID string `json:"gpg_key_id"`
// Age keypair information // Age keypair information
@ -36,18 +36,18 @@ type PGPUnlockKeyMetadata struct {
AgeRecipient string `json:"age_recipient"` AgeRecipient string `json:"age_recipient"`
} }
// PGPUnlockKey represents a PGP-protected unlock key // PGPUnlocker represents a PGP-protected unlocker
type PGPUnlockKey struct { type PGPUnlocker struct {
Directory string Directory string
Metadata UnlockKeyMetadata Metadata UnlockerMetadata
fs afero.Fs fs afero.Fs
} }
// GetIdentity implements UnlockKey interface for PGP-based unlock keys // GetIdentity implements Unlocker interface for PGP-based unlockers
func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) { func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting PGP unlock key identity", DebugWith("Getting PGP unlocker identity",
slog.String("key_id", p.GetID()), slog.String("unlocker_id", p.GetID()),
slog.String("key_type", p.GetType()), slog.String("unlocker_type", p.GetType()),
) )
// Step 1: Read the encrypted age private key from filesystem // Step 1: Read the encrypted age private key from filesystem
@ -61,61 +61,61 @@ func (p *PGPUnlockKey) GetIdentity() (*age.X25519Identity, error) {
} }
DebugWith("Read PGP-encrypted age private key", DebugWith("Read PGP-encrypted age private key",
slog.String("key_id", p.GetID()), slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)), slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
) )
// Step 2: Decrypt the age private key using GPG // Step 2: Decrypt the age private key using GPG
Debug("Decrypting age private key with GPG", "key_id", p.GetID()) Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData) agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
if err != nil { if err != nil {
Debug("Failed to decrypt age private key with GPG", "error", err, "key_id", p.GetID()) Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err) return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
} }
DebugWith("Successfully decrypted age private key with GPG", DebugWith("Successfully decrypted age private key with GPG",
slog.String("key_id", p.GetID()), slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)), slog.Int("decrypted_length", len(agePrivKeyData)),
) )
// Step 3: Parse the decrypted age private key // Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "key_id", p.GetID()) Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData)) ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
if err != nil { if err != nil {
Debug("Failed to parse age private key", "error", err, "key_id", p.GetID()) Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse age private key: %w", err) return nil, fmt.Errorf("failed to parse age private key: %w", err)
} }
DebugWith("Successfully parsed PGP age identity", DebugWith("Successfully parsed PGP age identity",
slog.String("key_id", p.GetID()), slog.String("unlocker_id", p.GetID()),
slog.String("public_key", ageIdentity.Recipient().String()), slog.String("public_key", ageIdentity.Recipient().String()),
) )
return ageIdentity, nil return ageIdentity, nil
} }
// GetType implements UnlockKey interface // GetType implements Unlocker interface
func (p *PGPUnlockKey) GetType() string { func (p *PGPUnlocker) GetType() string {
return "pgp" return "pgp"
} }
// GetMetadata implements UnlockKey interface // GetMetadata implements Unlocker interface
func (p *PGPUnlockKey) GetMetadata() UnlockKeyMetadata { func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata return p.Metadata
} }
// GetDirectory implements UnlockKey interface // GetDirectory implements Unlocker interface
func (p *PGPUnlockKey) GetDirectory() string { func (p *PGPUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements UnlockKey interface // GetID implements Unlocker interface
func (p *PGPUnlockKey) GetID() string { func (p *PGPUnlocker) GetID() string {
return p.Metadata.ID return p.Metadata.ID
} }
// ID implements UnlockKey interface - generates ID from GPG key ID // ID implements Unlocker interface - generates ID from GPG key ID
func (p *PGPUnlockKey) ID() string { func (p *PGPUnlocker) ID() string {
// Generate ID using GPG key ID: <keyid>-pgp // Generate ID using GPG key ID: <keyid>-pgp
gpgKeyID, err := p.GetGPGKeyID() gpgKeyID, err := p.GetGPGKeyID()
if err != nil { if err != nil {
@ -125,19 +125,19 @@ func (p *PGPUnlockKey) ID() string {
return fmt.Sprintf("%s-pgp", gpgKeyID) return fmt.Sprintf("%s-pgp", gpgKeyID)
} }
// Remove implements UnlockKey interface - removes the PGP unlock key // Remove implements Unlocker interface - removes the PGP unlocker
func (p *PGPUnlockKey) Remove() error { func (p *PGPUnlocker) Remove() error {
// For PGP keys, we just need to remove the directory // For PGP unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up // No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil { if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove PGP unlock key directory: %w", err) return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
} }
return nil return nil
} }
// NewPGPUnlockKey creates a new PGPUnlockKey instance // NewPGPUnlocker creates a new PGPUnlocker instance
func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata) *PGPUnlockKey { func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
return &PGPUnlockKey{ return &PGPUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
fs: fs, fs: fs,
@ -145,7 +145,7 @@ func NewPGPUnlockKey(fs afero.Fs, directory string, metadata UnlockKeyMetadata)
} }
// GetGPGKeyID returns the GPG key ID from metadata // GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlockKey) GetGPGKeyID() (string, error) { func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
// Load the metadata // Load the metadata
metadataPath := filepath.Join(p.Directory, "unlock-metadata.json") metadataPath := filepath.Join(p.Directory, "unlock-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath) metadataData, err := afero.ReadFile(p.fs, metadataPath)
@ -153,7 +153,7 @@ func (p *PGPUnlockKey) GetGPGKeyID() (string, error) {
return "", fmt.Errorf("failed to read PGP metadata: %w", err) return "", fmt.Errorf("failed to read PGP metadata: %w", err)
} }
var pgpMetadata PGPUnlockKeyMetadata var pgpMetadata PGPUnlockerMetadata
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil { if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err) return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
} }
@ -161,8 +161,8 @@ func (p *PGPUnlockKey) GetGPGKeyID() (string, error) {
return pgpMetadata.GPGKeyID, nil return pgpMetadata.GPGKeyID, nil
} }
// generatePGPUnlockKeyName generates a unique name for the PGP unlock key based on hostname and date // generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
func generatePGPUnlockKeyName() (string, error) { func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
@ -173,8 +173,8 @@ func generatePGPUnlockKeyName() (string, error) {
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
} }
// CreatePGPUnlockKey creates a new PGP unlock key and stores it in the vault // CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlockKey, error) { func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
// Check if GPG is available // Check if GPG is available
if err := checkGPGAvailable(); err != nil { if err := checkGPGAvailable(); err != nil {
return nil, err return nil, err
@ -186,24 +186,24 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
return nil, fmt.Errorf("failed to get current vault: %w", err) return nil, fmt.Errorf("failed to get current vault: %w", err)
} }
// Generate the unlock key name based on hostname and date // Generate the unlocker name based on hostname and date
unlockKeyName, err := generatePGPUnlockKeyName() unlockerName, err := generatePGPUnlockerName()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate unlock key name: %w", err) return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
} }
// Create unlock key directory using the generated name // Create unlocker directory using the generated name
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", unlockKeyName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
if err := fs.MkdirAll(unlockKeyDir, DirPerms); err != nil { if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err) return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
} }
// Step 1: Generate a new age keypair for the PGP unlock key // Step 1: Generate a new age keypair for the PGP unlocker
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate age keypair: %w", err) return nil, fmt.Errorf("failed to generate age keypair: %w", err)
@ -211,7 +211,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
// Step 2: Store age public key as plaintext // Step 2: Store age public key as plaintext
agePublicKeyString := ageIdentity.Recipient().String() agePublicKeyString := ageIdentity.Recipient().String()
agePubKeyPath := filepath.Join(unlockKeyDir, "pub.age") agePubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil { if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age public key: %w", err) return nil, fmt.Errorf("failed to write age public key: %w", err)
} }
@ -228,54 +228,54 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
} }
ltPrivKeyData = []byte(ltIdentity.String()) ltPrivKeyData = []byte(ltIdentity.String())
} else { } else {
// Get the vault to access current unlock key // Get the vault to access current unlocker
currentUnlockKey, err := vault.GetCurrentUnlockKey() currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current unlock key: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
// Get the current unlock key identity // Get the current unlocker identity
currentUnlockIdentity, err := currentUnlockKey.GetIdentity() currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get current unlock key identity: %w", err) return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
} }
// Get encrypted long-term key from current unlock key, handling different types // Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte var encryptedLtPrivKey []byte
switch currentUnlockKey := currentUnlockKey.(type) { switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlockKey: case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlock key // Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlock key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
} }
case *PGPUnlockKey: case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlock key // Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlockKey.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlock key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
} }
default: default:
return nil, fmt.Errorf("unsupported current unlock key type for PGP unlock key creation") return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
} }
// Step 6: Decrypt long-term private key using current unlock key // Step 6: Decrypt long-term private key using current unlocker
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockIdentity) ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
} }
// Step 7: Encrypt long-term private key to the new age unlock key // Step 7: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient()) encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlock key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
} }
// Write encrypted long-term private key // Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age") ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil { if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err) return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
@ -287,7 +287,7 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
} }
agePrivKeyPath := filepath.Join(unlockKeyDir, "priv.age.gpg") agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil { if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err) return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
} }
@ -296,8 +296,8 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
// Generate the key ID directly using the GPG key ID // Generate the key ID directly using the GPG key ID
keyID := fmt.Sprintf("%s-pgp", gpgKeyID) keyID := fmt.Sprintf("%s-pgp", gpgKeyID)
pgpMetadata := PGPUnlockKeyMetadata{ pgpMetadata := PGPUnlockerMetadata{
UnlockKeyMetadata: UnlockKeyMetadata{ UnlockerMetadata: UnlockerMetadata{
ID: keyID, ID: keyID,
Type: "pgp", Type: "pgp",
CreatedAt: time.Now(), CreatedAt: time.Now(),
@ -310,16 +310,16 @@ func CreatePGPUnlockKey(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlo
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ") metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
if err := afero.WriteFile(fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil { if err := afero.WriteFile(fs, filepath.Join(unlockerDir, "unlock-metadata.json"), metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err) return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
return &PGPUnlockKey{ return &PGPUnlocker{
Directory: unlockKeyDir, Directory: unlockerDir,
Metadata: pgpMetadata.UnlockKeyMetadata, Metadata: pgpMetadata.UnlockerMetadata,
fs: fs, fs: fs,
}, nil }, nil
} }

View File

@ -20,8 +20,8 @@ type VaultInterface interface {
AddSecret(name string, value []byte, force bool) error AddSecret(name string, value []byte, force bool) error
GetName() string GetName() string
GetFilesystem() afero.Fs GetFilesystem() afero.Fs
GetCurrentUnlockKey() (UnlockKey, error) GetCurrentUnlocker() (Unlocker, error)
CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error)
} }
// Secret represents a secret in a vault // Secret represents a secret in a vault
@ -81,8 +81,8 @@ func (s *Secret) Save(value []byte, force bool) error {
return nil return nil
} }
// GetValue retrieves and decrypts the secret value using the provided unlock key // GetValue retrieves and decrypts the secret value using the provided unlocker
func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) { func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
DebugWith("Getting secret value", DebugWith("Getting secret value",
slog.String("secret_name", s.Name), slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()), slog.String("vault_name", s.vault.GetName()),
@ -118,29 +118,29 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
return s.decryptWithLongTermKey(ltIdentity) return s.decryptWithLongTermKey(ltIdentity)
} }
Debug("Using unlock key for vault access", "secret_name", s.Name) Debug("Using unlocker for vault access", "secret_name", s.Name)
// Use the provided unlock key to get the vault's long-term private key // Use the provided unlocker to get the vault's long-term private key
if unlockKey == nil { if unlocker == nil {
Debug("No unlock key provided for secret decryption", "secret_name", s.Name) Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
return nil, fmt.Errorf("unlock key required to decrypt secret") return nil, fmt.Errorf("unlocker required to decrypt secret")
} }
DebugWith("Getting vault's long-term key using unlock key", DebugWith("Getting vault's long-term key using unlocker",
slog.String("secret_name", s.Name), slog.String("secret_name", s.Name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()), slog.String("unlocker_id", unlocker.GetID()),
) )
// Step 1: Use the unlock key to get the vault's long-term private key // Step 1: Use the unlocker to get the vault's long-term private key
unlockIdentity, err := unlockKey.GetIdentity() unlockIdentity, err := unlocker.GetIdentity()
if err != nil { if err != nil {
Debug("Failed to get unlock key identity", "error", err, "secret_name", s.Name, "unlock_key_type", unlockKey.GetType()) Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlock key identity: %w", err) return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
} }
// Read the encrypted long-term private key from the unlock key directory // Read the encrypted long-term private key from the unlocker directory
encryptedLtPrivKeyPath := filepath.Join(unlockKey.GetDirectory(), "longterm.age") encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath) Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath) encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
@ -149,8 +149,8 @@ func (s *Secret) GetValue(unlockKey UnlockKey) ([]byte, error) {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
} }
// Decrypt the encrypted long-term private key using the unlock key // Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlock key", "secret_name", s.Name) Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity) ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil { if err != nil {
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name) Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)

View File

@ -39,11 +39,11 @@ func (m *MockVault) GetFilesystem() afero.Fs {
return m.fs return m.fs
} }
func (m *MockVault) GetCurrentUnlockKey() (UnlockKey, error) { func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, nil // Not needed for this test return nil, nil // Not needed for this test
} }
func (m *MockVault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) { func (m *MockVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
return nil, nil // Not needed for this test return nil, nil // Not needed for this test
} }

View File

@ -1,16 +0,0 @@
package secret
import (
"filippo.io/age"
)
// UnlockKey interface defines the methods all unlock key types must implement
type UnlockKey interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockKeyMetadata
GetDirectory() string
GetID() string
ID() string // Generate ID from the key's public key
Remove() error // Remove the unlock key and any associated resources
}

View File

@ -0,0 +1,16 @@
package secret
import (
"filippo.io/age"
)
// Unlocker interface defines the methods all unlocker types must implement
type Unlocker interface {
GetIdentity() (*age.X25519Identity, error)
GetType() string
GetMetadata() UnlockerMetadata
GetDirectory() string
GetID() string
ID() string // Generate ID from the unlocker's public key
Remove() error // Remove the unlocker and any associated resources
}

View File

@ -196,10 +196,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
return nil, fmt.Errorf("failed to create secrets directory: %w", err) return nil, fmt.Errorf("failed to create secrets directory: %w", err)
} }
// Create unlock keys directory // Create unlockers directory
unlockKeysDir := filepath.Join(vaultDir, "unlock.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
if err := fs.MkdirAll(unlockKeysDir, secret.DirPerms); err != nil { if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock keys directory: %w", err) return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
} }
// Save initial vault metadata (without derivation info until a mnemonic is imported) // Save initial vault metadata (without derivation info until a mnemonic is imported)

View File

@ -13,7 +13,7 @@ import (
// Alias the metadata types from secret package for convenience // Alias the metadata types from secret package for convenience
type VaultMetadata = secret.VaultMetadata type VaultMetadata = secret.VaultMetadata
type UnlockKeyMetadata = secret.UnlockKeyMetadata type UnlockerMetadata = secret.UnlockerMetadata
type SecretMetadata = secret.SecretMetadata type SecretMetadata = secret.SecretMetadata
type Configuration = secret.Configuration type Configuration = secret.Configuration

View File

@ -1,376 +0,0 @@
package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
// GetCurrentUnlockKey returns the current unlock key for this vault
func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
secret.DebugWith("Getting current unlock key", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name)
return nil, err
}
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Check if the symlink exists
_, err = v.fs.Stat(currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
// Resolve the symlink to get the target directory
var unlockKeyDir string
if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlock key symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockKeyDir, err = ResolveVaultSymlink(v.fs, currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
}
} else {
secret.Debug("Reading unlock key path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
if err != nil {
secret.Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes))
}
secret.DebugWith("Resolved unlock key directory",
slog.String("unlock_key_dir", unlockKeyDir),
slog.String("vault_name", v.Name),
)
// Read unlock key metadata
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
secret.Debug("Reading unlock key metadata", "path", metadataPath)
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read unlock key metadata: %w", err)
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
secret.Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err)
}
secret.DebugWith("Parsed unlock key metadata",
slog.String("key_id", metadata.ID),
slog.String("key_type", metadata.Type),
slog.Time("created_at", metadata.CreatedAt),
slog.Any("flags", metadata.Flags),
)
// Create unlock key instance using direct constructors with filesystem
var unlockKey secret.UnlockKey
// Convert our metadata to secret.UnlockKeyMetadata
secretMetadata := secret.UnlockKeyMetadata(metadata)
switch metadata.Type {
case "passphrase":
secret.Debug("Creating passphrase unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata)
case "pgp":
secret.Debug("Creating PGP unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewPGPUnlockKey(v.fs, unlockKeyDir, secretMetadata)
case "keychain":
secret.Debug("Creating keychain unlock key instance", "key_id", metadata.ID)
unlockKey = secret.NewKeychainUnlockKey(v.fs, unlockKeyDir, secretMetadata)
default:
secret.Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID)
return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
secret.DebugWith("Successfully created unlock key instance",
slog.String("key_type", unlockKey.GetType()),
slog.String("key_id", unlockKey.GetID()),
slog.String("vault_name", v.Name),
)
return unlockKey, nil
}
// ListUnlockKeys returns a list of available unlock keys for this vault
func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// Check if unlock keys directory exists
exists, err := afero.DirExists(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err)
}
if !exists {
return []UnlockKeyMetadata{}, nil
}
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var keys []UnlockKeyMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
continue
}
if !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
keys = append(keys, metadata)
}
}
return keys, nil
}
// RemoveUnlockKey removes an unlock key from this vault
func (v *Vault) RemoveUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the key directory and create the unlock key instance
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var unlockKey secret.UnlockKey
var keyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
keyDir = filepath.Join(unlockKeysDir, file.Name())
// Convert our metadata to secret.UnlockKeyMetadata
secretMetadata := secret.UnlockKeyMetadata(metadata)
// Create the appropriate unlock key instance
switch metadata.Type {
case "passphrase":
unlockKey = secret.NewPassphraseUnlockKey(v.fs, keyDir, secretMetadata)
case "pgp":
unlockKey = secret.NewPGPUnlockKey(v.fs, keyDir, secretMetadata)
case "keychain":
unlockKey = secret.NewKeychainUnlockKey(v.fs, keyDir, secretMetadata)
default:
return fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
break
}
}
}
if unlockKey == nil {
return fmt.Errorf("unlock key with ID %s not found", keyID)
}
// Use the unlock key's Remove method
return unlockKey.Remove()
}
// SelectUnlockKey selects an unlock key as current for this vault
func (v *Vault) SelectUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlock key directory by ID
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d to find the key
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var targetKeyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
targetKeyDir = filepath.Join(unlockKeysDir, file.Name())
break
}
}
}
if targetKeyDir == "" {
return fmt.Errorf("unlock key with ID %s not found", keyID)
}
// Create/update current unlock key symlink
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Remove existing symlink if it exists
if exists, _ := afero.Exists(v.fs, currentUnlockKeyPath); exists {
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
secret.Debug("Failed to remove existing unlock key symlink", "error", err, "path", currentUnlockKeyPath)
}
}
// Create new symlink
return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), secret.FilePerms)
}
// CreatePassphraseKey creates a new passphrase-protected unlock key
func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlockKey, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Create unlock key directory with timestamp
timestamp := time.Now().Format("2006-01-02.15.04")
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase")
if err := v.fs.MkdirAll(unlockKeyDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
}
// Generate new age keypair for unlock key
unlockIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlock key: %w", err)
}
// Write public key
pubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key public key: %w", err)
}
// Encrypt private key with passphrase
privKeyData := []byte(unlockIdentity.String())
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt unlock key private key: %w", err)
}
// Write encrypted private key
privKeyPath := filepath.Join(unlockKeyDir, "priv.age")
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted unlock key private key: %w", err)
}
// Create metadata
keyID := fmt.Sprintf("%s-passphrase", timestamp)
metadata := UnlockKeyMetadata{
ID: keyID,
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Write metadata
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
}
// Encrypt long-term private key to this unlock key if vault is unlocked
if !v.Locked() {
ltPrivKey := []byte(v.GetLongTermKey().String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
}
// Select this unlock key as current
if err := v.SelectUnlockKey(keyID); err != nil {
return nil, fmt.Errorf("failed to select new unlock key: %w", err)
}
// Convert our metadata to secret.UnlockKeyMetadata for the constructor
secretMetadata := secret.UnlockKeyMetadata(metadata)
return secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata), nil
}

376
internal/vault/unlockers.go Normal file
View File

@ -0,0 +1,376 @@
package vault
import (
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
)
// GetCurrentUnlocker returns the current unlocker for this vault
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
return nil, err
}
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Check if the symlink exists
_, err = v.fs.Stat(currentUnlockerPath)
if err != nil {
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
}
// Resolve the symlink to get the target directory
var unlockerDir string
if _, ok := v.fs.(*afero.OsFs); ok {
secret.Debug("Resolving unlocker symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath)
return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err)
}
} else {
secret.Debug("Reading unlocker path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
if err != nil {
secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath)
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
}
unlockerDir = strings.TrimSpace(string(unlockerDirBytes))
}
secret.DebugWith("Resolved unlocker directory",
slog.String("unlocker_dir", unlockerDir),
slog.String("vault_name", v.Name),
)
// Read unlocker metadata
metadataPath := filepath.Join(unlockerDir, "unlock-metadata.json")
secret.Debug("Reading unlocker metadata", "path", metadataPath)
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
}
secret.DebugWith("Parsed unlocker metadata",
slog.String("unlocker_id", metadata.ID),
slog.String("unlocker_type", metadata.Type),
slog.Time("created_at", metadata.CreatedAt),
slog.Any("flags", metadata.Flags),
)
// Create unlocker instance using direct constructors with filesystem
var unlocker secret.Unlocker
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
switch metadata.Type {
case "passphrase":
secret.Debug("Creating passphrase unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata)
case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_id", metadata.ID)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata)
default:
secret.Debug("Unsupported unlocker type", "type", metadata.Type, "unlocker_id", metadata.ID)
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
}
secret.DebugWith("Successfully created unlocker instance",
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
slog.String("vault_name", v.Name),
)
return unlocker, nil
}
// ListUnlockers returns a list of available unlockers for this vault
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Check if unlockers directory exists
exists, err := afero.DirExists(v.fs, unlockersDir)
if err != nil {
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
}
if !exists {
return []UnlockerMetadata{}, nil
}
// List directories in unlockers.d
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var unlockers []UnlockerMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
continue
}
if !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
unlockers = append(unlockers, metadata)
}
}
return unlockers, nil
}
// RemoveUnlocker removes an unlocker from this vault
func (v *Vault) RemoveUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlocker directory and create the unlocker instance
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// List directories in unlockers.d
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return fmt.Errorf("failed to read unlockers directory: %w", err)
}
var unlocker secret.Unlocker
var unlockerDirPath string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == unlockerID {
unlockerDirPath = filepath.Join(unlockersDir, file.Name())
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
// Create the appropriate unlocker instance
switch metadata.Type {
case "passphrase":
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "pgp":
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "keychain":
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
default:
return fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
}
break
}
}
}
if unlocker == nil {
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
}
// Use the unlocker's Remove method
return unlocker.Remove()
}
// SelectUnlocker selects an unlocker as current for this vault
func (v *Vault) SelectUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the unlocker directory by ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// List directories in unlockers.d to find the unlocker
files, err := afero.ReadDir(v.fs, unlockersDir)
if err != nil {
return fmt.Errorf("failed to read unlockers directory: %w", err)
}
var targetUnlockerDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == unlockerID {
targetUnlockerDir = filepath.Join(unlockersDir, file.Name())
break
}
}
}
if targetUnlockerDir == "" {
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
}
// Create/update current unlocker symlink
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Remove existing symlink if it exists
if exists, _ := afero.Exists(v.fs, currentUnlockerPath); exists {
if err := v.fs.Remove(currentUnlockerPath); err != nil {
secret.Debug("Failed to remove existing unlocker symlink", "error", err, "path", currentUnlockerPath)
}
}
// Create new symlink
return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms)
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Create unlocker directory with timestamp
timestamp := time.Now().Format("2006-01-02.15.04")
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
}
// Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
}
// Encrypt private key with passphrase
privKeyData := []byte(unlockerIdentity.String())
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
}
// Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age")
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
}
// Create metadata
unlockerID := fmt.Sprintf("%s-passphrase", timestamp)
metadata := UnlockerMetadata{
ID: unlockerID,
Type: "passphrase",
CreatedAt: time.Now(),
Flags: []string{},
}
// Write metadata
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlock-metadata.json")
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
// Encrypt long-term private key to this unlocker if vault is unlocked
if !v.Locked() {
ltPrivKey := []byte(v.GetLongTermKey().String())
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockerIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
}
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
}
// Select this unlocker as current
if err := v.SelectUnlocker(unlockerID); err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
// Convert our metadata to secret.UnlockerMetadata for the constructor
secretMetadata := secret.UnlockerMetadata(metadata)
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata), nil
}

View File

@ -83,32 +83,32 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return ltIdentity, nil return ltIdentity, nil
} }
// No mnemonic available, try to use current unlock key // No mnemonic available, try to use current unlocker
secret.Debug("No mnemonic available, using current unlock key to unlock vault", "vault_name", v.Name) secret.Debug("No mnemonic available, using current unlocker to unlock vault", "vault_name", v.Name)
// Get current unlock key // Get current unlocker
unlockKey, err := v.GetCurrentUnlockKey() unlocker, err := v.GetCurrentUnlocker()
if err != nil { if err != nil {
secret.Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name) secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlock key: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
secret.DebugWith("Retrieved current unlock key for vault unlock", secret.DebugWith("Retrieved current unlocker for vault unlock",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlock_key_id", unlockKey.GetID()), slog.String("unlocker_id", unlocker.GetID()),
) )
// Get unlock key identity // Get unlocker identity
unlockIdentity, err := unlockKey.GetIdentity() unlockerIdentity, err := unlocker.GetIdentity()
if err != nil { if err != nil {
secret.Debug("Failed to get unlock key identity", "error", err, "unlock_key_type", unlockKey.GetType()) secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlock key identity: %w", err) return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
} }
// Read encrypted long-term private key from unlock key directory // Read encrypted long-term private key from unlocker directory
unlockKeyDir := unlockKey.GetDirectory() unlockerDir := unlocker.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age") encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath) secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath) encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
@ -119,21 +119,21 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.DebugWith("Read encrypted long-term private key", secret.DebugWith("Read encrypted long-term private key",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)), slog.Int("encrypted_length", len(encryptedLtPrivKey)),
) )
// Decrypt long-term private key using unlock key // Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlock key", "unlock_key_type", unlockKey.GetType()) secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity) ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil { if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlock_key_type", unlockKey.GetType()) secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
secret.DebugWith("Successfully decrypted long-term private key", secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", len(ltPrivKeyData)), slog.Int("decrypted_length", len(ltPrivKeyData)),
) )
@ -145,15 +145,15 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err) return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
} }
secret.DebugWith("Successfully obtained long-term identity via unlock key", secret.DebugWith("Successfully obtained long-term identity via unlocker",
slog.String("vault_name", v.Name), slog.String("vault_name", v.Name),
slog.String("unlock_key_type", unlockKey.GetType()), slog.String("unlocker_type", unlocker.GetType()),
slog.String("public_key", ltIdentity.Recipient().String()), slog.String("public_key", ltIdentity.Recipient().String()),
) )
// Cache the derived key by unlocking the vault // Cache the derived key by unlocking the vault
v.Unlock(ltIdentity) v.Unlock(ltIdentity)
secret.Debug("Vault is unlocked (lt key in memory) via unlock key", "vault_name", v.Name, "unlock_key_type", unlockKey.GetType()) secret.Debug("Vault is unlocked (lt key in memory) via unlocker", "vault_name", v.Name, "unlocker_type", unlocker.GetType())
return ltIdentity, nil return ltIdentity, nil
} }

View File

@ -174,8 +174,8 @@ func TestVaultOperations(t *testing.T) {
} }
}) })
// Test unlock key operations // Test unlocker operations
t.Run("UnlockKeyOperations", func(t *testing.T) { t.Run("UnlockerOperations", func(t *testing.T) {
vlt, err := GetCurrentVault(fs, stateDir) vlt, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault: %v", err) t.Fatalf("Failed to get current vault: %v", err)
@ -189,25 +189,25 @@ func TestVaultOperations(t *testing.T) {
} }
} }
// Create a passphrase unlock key // Create a passphrase unlocker
passphraseKey, err := vlt.CreatePassphraseKey("test-passphrase") passphraseUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
if err != nil { if err != nil {
t.Fatalf("Failed to create passphrase key: %v", err) t.Fatalf("Failed to create passphrase unlocker: %v", err)
} }
// List unlock keys // List unlockers
keys, err := vlt.ListUnlockKeys() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
t.Fatalf("Failed to list unlock keys: %v", err) t.Fatalf("Failed to list unlockers: %v", err)
} }
if len(keys) == 0 { if len(unlockers) == 0 {
t.Errorf("Expected at least one unlock key") t.Errorf("Expected at least one unlocker")
} }
// Check key type // Check key type
keyFound := false keyFound := false
for _, key := range keys { for _, key := range unlockers {
if key.Type == "passphrase" { if key.Type == "passphrase" {
keyFound = true keyFound = true
break break
@ -215,23 +215,23 @@ func TestVaultOperations(t *testing.T) {
} }
if !keyFound { if !keyFound {
t.Errorf("Expected to find passphrase unlock key") t.Errorf("Expected to find passphrase unlocker")
} }
// Test selecting unlock key // Test selecting unlocker
err = vlt.SelectUnlockKey(passphraseKey.GetID()) err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
if err != nil { if err != nil {
t.Fatalf("Failed to select unlock key: %v", err) t.Fatalf("Failed to select unlocker: %v", err)
} }
// Test getting current unlock key // Test getting current unlocker
currentKey, err := vlt.GetCurrentUnlockKey() currentUnlocker, err := vlt.GetCurrentUnlocker()
if err != nil { if err != nil {
t.Fatalf("Failed to get current unlock key: %v", err) t.Fatalf("Failed to get current unlocker: %v", err)
} }
if currentKey.GetID() != passphraseKey.GetID() { if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
t.Errorf("Expected current unlock key ID '%s', got '%s'", passphraseKey.GetID(), currentKey.GetID()) t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID())
} }
}) })
} }

View File

@ -104,8 +104,19 @@ echo " SB_SECRET_MNEMONIC=$TEST_MNEMONIC"
# Test 2: Initialize the secret manager (should create default vault) # Test 2: Initialize the secret manager (should create default vault)
print_step "2" "Initializing secret manager (creates default vault)" print_step "2" "Initializing secret manager (creates default vault)"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
echo " SB_UNLOCK_PASSPHRASE=$SB_UNLOCK_PASSPHRASE"
# Verify environment variables are exported and visible to subprocesses
echo "Verifying environment variables are exported:"
env | grep -E "^SB_" || true
echo "Running: $SECRET_BINARY init" echo "Running: $SECRET_BINARY init"
if $SECRET_BINARY init; then # Run with explicit environment to ensure variables are passed
if SB_SECRET_STATE_DIR="$SB_SECRET_STATE_DIR" \
SB_SECRET_MNEMONIC="$SB_SECRET_MNEMONIC" \
SB_UNLOCK_PASSPHRASE="$SB_UNLOCK_PASSPHRASE" \
GODEBUG="$GODEBUG" \
$SECRET_BINARY init </dev/null; then
print_success "Secret manager initialized with default vault" print_success "Secret manager initialized with default vault"
else else
print_error "Failed to initialize secret manager" print_error "Failed to initialize secret manager"
@ -229,42 +240,45 @@ fi
reset_state reset_state
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
# Test 5: Unlock key management # Test 5: Unlocker management
print_step "5" "Testing unlock key management" print_step "5" "Testing unlocker management"
# Initialize to create default vault # Initialize with mnemonic and passphrase
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE"
echo "Running: $SECRET_BINARY init (with SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set)"
if $SECRET_BINARY init; then if $SECRET_BINARY init; then
print_success "Initialized for unlock key testing" print_success "Initialized for unlocker testing"
else else
print_error "Failed to initialize for unlock key testing" print_error "Failed to initialize for unlocker testing"
fi fi
# Create passphrase-protected unlock key # Create passphrase-protected unlocker
echo "Creating passphrase-protected unlock key..." echo "Creating passphrase-protected unlocker..."
echo "Running: $SECRET_BINARY keys add passphrase (with SB_UNLOCK_PASSPHRASE set)" echo "Running: $SECRET_BINARY unlockers add passphrase (with SB_UNLOCK_PASSPHRASE set)"
if $SECRET_BINARY keys add passphrase; then if $SECRET_BINARY unlockers add passphrase; then
print_success "Created passphrase-protected unlock key" print_success "Created passphrase-protected unlocker"
else else
print_error "Failed to create passphrase-protected unlock key" print_error "Failed to create passphrase-protected unlocker"
exit 1
fi fi
unset SB_UNLOCK_PASSPHRASE unset SB_UNLOCK_PASSPHRASE
# List unlock keys # List unlockers
echo "Listing unlock keys..." echo "Listing unlockers..."
echo "Running: $SECRET_BINARY keys list" echo "Running: $SECRET_BINARY unlockers list"
if $SECRET_BINARY keys list; then if $SECRET_BINARY unlockers list; then
KEYS=$($SECRET_BINARY keys list) UNLOCKERS=$($SECRET_BINARY unlockers list)
echo "Available unlock keys: $KEYS" echo "Available unlockers: $UNLOCKERS"
print_success "Listed unlock keys" print_success "Listed unlockers"
else else
print_error "Failed to list unlock keys" print_error "Failed to list unlockers"
exit 1
fi fi
# Test 6: Secret management with mnemonic (keyless operation) # Test 6: Secret management with mnemonic (keyless operation)
print_step "6" "Testing mnemonic-based secret operations (keyless)" print_step "6" "Testing mnemonic-based secret operations (keyless)"
# Add secrets using mnemonic (no unlock key required) # Add secrets using mnemonic (no unlocker required)
echo "Adding secrets using mnemonic-based long-term key..." echo "Adding secrets using mnemonic-based long-term key..."
# Test secret 1 # Test secret 1
@ -340,66 +354,59 @@ else
print_error "Failed to list secrets" print_error "Failed to list secrets"
fi fi
# Test 7: Secret management without mnemonic (traditional unlock key approach) # Test 7: Secret management without mnemonic (traditional unlocker approach)
print_step "7" "Testing traditional unlock key approach" print_step "7" "Testing traditional unlocker approach"
# Temporarily unset mnemonic to test traditional approach # Create a new vault without mnemonic
unset SB_SECRET_MNEMONIC echo "Running: $SECRET_BINARY vault create traditional"
$SECRET_BINARY vault create traditional
# Add a secret using traditional unlock key approach # Add a secret using traditional unlocker approach
echo "Adding secret using traditional unlock key..." echo "Adding secret using traditional unlocker..."
echo "Running: echo \"traditional-secret-value\" | $SECRET_BINARY add \"traditional/secret\"" echo "Running: echo 'traditional-secret' | $SECRET_BINARY add traditional/secret"
if echo "traditional-secret-value" | $SECRET_BINARY add "traditional/secret"; then if echo "traditional-secret" | $SECRET_BINARY add traditional/secret; then
print_success "Added secret using traditional approach: traditional/secret" print_success "Added secret with traditional approach"
else else
print_error "Failed to add secret using traditional approach" print_error "Failed to add secret with traditional approach"
fi fi
# Retrieve secret using traditional unlock key approach # Retrieve secret using traditional unlocker approach
echo "Retrieving secret using traditional unlock key approach..." echo "Retrieving secret using traditional unlocker approach..."
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" echo "Running: $SECRET_BINARY get traditional/secret"
RETRIEVED_TRADITIONAL=$($SECRET_BINARY get "traditional/secret" 2>/dev/null) if RETRIEVED=$($SECRET_BINARY get traditional/secret 2>&1); then
unset SB_UNLOCK_PASSPHRASE print_success "Retrieved: $RETRIEVED"
if [ "$RETRIEVED_TRADITIONAL" = "traditional-secret-value" ]; then
print_success "Retrieved and verified traditional secret: traditional/secret"
else else
print_error "Failed to retrieve or verify traditional secret" print_error "Failed to retrieve secret with traditional approach"
fi fi
# Re-enable mnemonic for remaining tests # Test 8: Advanced unlocker management
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" print_step "8" "Testing advanced unlocker management"
# Test 8: Advanced unlock key management if [ "$PLATFORM" = "darwin" ]; then
print_step "8" "Testing advanced unlock key management" # macOS only: Test Secure Enclave
echo "Testing Secure Enclave unlocker creation..."
# Test Secure Enclave (macOS only) if $SECRET_BINARY unlockers add sep; then
if [[ "$OSTYPE" == "darwin"* ]]; then print_success "Created Secure Enclave unlocker"
echo "Testing Secure Enclave unlock key creation..."
echo "Running: $SECRET_BINARY enroll sep"
if $SECRET_BINARY enroll sep; then
print_success "Created Secure Enclave unlock key"
else else
print_warning "Secure Enclave unlock key creation not yet implemented" print_warning "Secure Enclave unlocker creation not yet implemented"
fi fi
else
print_warning "Secure Enclave only available on macOS"
fi fi
# Get current unlock key ID for testing # Get current unlocker ID for testing
echo "Getting current unlock key for testing..." echo "Getting current unlocker for testing..."
echo "Running: $SECRET_BINARY keys list" echo "Running: $SECRET_BINARY unlockers list"
if $SECRET_BINARY keys list; then if $SECRET_BINARY unlockers list; then
CURRENT_KEY_ID=$($SECRET_BINARY keys list | head -n1 | awk '{print $1}') CURRENT_UNLOCKER_ID=$($SECRET_BINARY unlockers list | head -n1 | awk '{print $1}')
if [ -n "$CURRENT_KEY_ID" ]; then if [ -n "$CURRENT_UNLOCKER_ID" ]; then
print_success "Found unlock key ID: $CURRENT_KEY_ID" print_success "Found unlocker ID: $CURRENT_UNLOCKER_ID"
# Test key selection # Test unlocker selection
echo "Testing unlock key selection..." echo "Testing unlocker selection..."
echo "Running: $SECRET_BINARY key select $CURRENT_KEY_ID" echo "Running: $SECRET_BINARY unlocker select $CURRENT_UNLOCKER_ID"
if $SECRET_BINARY key select "$CURRENT_KEY_ID"; then if $SECRET_BINARY unlocker select "$CURRENT_UNLOCKER_ID"; then
print_success "Selected unlock key: $CURRENT_KEY_ID" print_success "Selected unlocker: $CURRENT_UNLOCKER_ID"
else else
print_warning "Unlock key selection not yet implemented" print_warning "Unlocker selection not yet implemented"
fi fi
fi fi
fi fi
@ -609,17 +616,15 @@ else
print_error "Mnemonic cannot access traditional secrets" print_error "Mnemonic cannot access traditional secrets"
fi fi
# Test without mnemonic but with unlock key # Test without mnemonic but with unlocker
unset SB_SECRET_MNEMONIC echo "Testing mnemonic-created vault access..."
echo "Testing traditional unlock key access to mnemonic-created secrets..." echo "Testing traditional unlocker access to mnemonic-created secrets..."
echo "Running: $SECRET_BINARY get \"database/password\" (with SB_UNLOCK_PASSPHRASE set)" echo "Running: $SECRET_BINARY get test/seed (with mnemonic set)"
export SB_UNLOCK_PASSPHRASE="$TEST_PASSPHRASE" if RETRIEVED=$($SECRET_BINARY get test/seed 2>&1); then
if $SECRET_BINARY get "database/password"; then print_success "Traditional unlocker can access mnemonic-created secrets"
print_success "Traditional unlock key can access mnemonic-created secrets"
else else
print_warning "Traditional unlock key cannot access mnemonic-created secrets (may need implementation)" print_warning "Traditional unlocker cannot access mnemonic-created secrets (may need implementation)"
fi fi
unset SB_UNLOCK_PASSPHRASE
# Re-enable mnemonic for final tests # Re-enable mnemonic for final tests
export SB_SECRET_MNEMONIC="$TEST_MNEMONIC" export SB_SECRET_MNEMONIC="$TEST_MNEMONIC"
@ -631,9 +636,9 @@ echo -e "${GREEN}✓ Secret manager initialization${NC}"
echo -e "${GREEN}✓ Vault management (create, list, select)${NC}" echo -e "${GREEN}✓ Vault management (create, list, select)${NC}"
echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}" echo -e "${GREEN}✓ Import functionality with environment variable combinations${NC}"
echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}" echo -e "${GREEN}✓ Import error handling (non-existent vault, invalid mnemonic)${NC}"
echo -e "${GREEN}✓ Unlock key management (passphrase, PGP, SEP)${NC}" echo -e "${GREEN}✓ Unlocker management (passphrase, PGP, SEP)${NC}"
echo -e "${GREEN}Mnemonic-based secret operations (keyless)${NC}" echo -e "${GREEN}Secret generation and storage${NC}"
echo -e "${GREEN}✓ Traditional unlock key operations${NC}" echo -e "${GREEN}✓ Traditional unlocker operations${NC}"
echo -e "${GREEN}✓ Secret name validation${NC}" echo -e "${GREEN}✓ Secret name validation${NC}"
echo -e "${GREEN}✓ Overwrite protection and force flag${NC}" echo -e "${GREEN}✓ Overwrite protection and force flag${NC}"
echo -e "${GREEN}✓ Cross-vault operations${NC}" echo -e "${GREEN}✓ Cross-vault operations${NC}"
@ -662,14 +667,14 @@ echo "export SB_SECRET_MNEMONIC=\"abandon abandon...\""
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\"" echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\""
echo "secret vault import work" echo "secret vault import work"
echo "" echo ""
echo -e "${YELLOW}# Unlock key management:${NC}" echo -e "${YELLOW}# Unlocker management:${NC}"
echo "export SB_UNLOCK_PASSPHRASE=\"passphrase\"" echo "$SECRET_BINARY unlockers add <type> # Add unlocker (passphrase, pgp, keychain)"
echo "secret keys add passphrase" echo "$SECRET_BINARY unlockers add passphrase"
echo "secret keys add pgp <gpg-key-id>" echo "$SECRET_BINARY unlockers add pgp <gpg-key-id>"
echo "secret enroll sep # macOS only" echo "$SECRET_BINARY unlockers add keychain # macOS only"
echo "secret keys list" echo "$SECRET_BINARY unlockers list # List all unlockers"
echo "secret key select <key-id>" echo "$SECRET_BINARY unlocker select <unlocker-id> # Select current unlocker"
echo "secret keys rm <key-id>" echo "$SECRET_BINARY unlockers rm <unlocker-id> # Remove unlocker"
echo "" echo ""
echo -e "${YELLOW}# Secret management:${NC}" echo -e "${YELLOW}# Secret management:${NC}"
echo "echo \"my-secret\" | secret add \"app/password\"" echo "echo \"my-secret\" | secret add \"app/password\""