Compare commits
12 Commits
fix-memory
...
816f53f819
| Author | SHA1 | Date | |
|---|---|---|---|
| 816f53f819 | |||
| bba1fb21e6 | |||
| d4f557631b | |||
| e53161188c | |||
| ff17b9b107 | |||
| 63cc06b93c | |||
| 8ec3fc877d | |||
| 819902f385 | |||
| 292564c6e7 | |||
| eef2332823 | |||
| e82d428b05 | |||
| 9cbe055791 |
@@ -23,7 +23,10 @@
|
|||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"WebFetch(domain:golangci-lint.run)",
|
"WebFetch(domain:golangci-lint.run)",
|
||||||
"Bash(go:*)",
|
"Bash(go:*)",
|
||||||
"WebFetch(domain:pkg.go.dev)"
|
"WebFetch(domain:pkg.go.dev)",
|
||||||
|
"Bash(CGO_ENABLED=1 make fmt)",
|
||||||
|
"Bash(CGO_ENABLED=1 make test)",
|
||||||
|
"Bash(git merge:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -1,12 +1,12 @@
|
|||||||
# Secret - Hierarchical Secret Manager
|
# Secret - Hierarchical Secret Manager
|
||||||
|
|
||||||
Secret is a modern, secure command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
|
Secret is a command-line secret manager that implements a hierarchical key architecture for storing and managing sensitive data. It supports multiple vaults, various unlock mechanisms, and provides secure storage using the Age encryption library.
|
||||||
|
|
||||||
## Core Architecture
|
## Core Architecture
|
||||||
|
|
||||||
### Three-Layer Key Hierarchy
|
### Three-Layer Key Hierarchy
|
||||||
|
|
||||||
Secret implements a sophisticated three-layer key architecture:
|
Secret implements a 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. **Unlockers**: 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
|
||||||
@@ -16,7 +16,7 @@ Secret implements a sophisticated three-layer key architecture:
|
|||||||
|
|
||||||
Each secret maintains a history of versions, with each version having:
|
Each secret maintains a history of versions, with each version having:
|
||||||
- Its own encryption key pair
|
- Its own encryption key pair
|
||||||
- Encrypted metadata including creation time and validity period
|
- Metadata (unencrypted) including creation time and validity period
|
||||||
- Immutable value storage
|
- Immutable value storage
|
||||||
- Atomic version switching via symlink updates
|
- Atomic version switching via symlink updates
|
||||||
|
|
||||||
@@ -125,6 +125,7 @@ Creates a new unlocker of the specified type:
|
|||||||
**Types:**
|
**Types:**
|
||||||
- `passphrase`: Traditional passphrase-protected unlocker
|
- `passphrase`: Traditional passphrase-protected unlocker
|
||||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||||
|
- `keychain`: macOS Keychain integration (macOS only)
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (required for PGP type)
|
- `--keyid <id>`: GPG key ID (required for PGP type)
|
||||||
@@ -169,7 +170,7 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
||||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||||
│ │ │ │ └── current -> versions/20231216.001
|
│ │ │ │ └── current -> versions/20231216.001
|
||||||
│ │ │ └── database%password/ # Secret: database/password
|
│ │ │ └── database%password/ # Secret: database/password
|
||||||
@@ -207,6 +208,18 @@ Unlockers provide different authentication methods to access the long-term keys:
|
|||||||
- Leverages existing key management workflows
|
- Leverages existing key management workflows
|
||||||
- Strong authentication through GPG
|
- Strong authentication through GPG
|
||||||
|
|
||||||
|
3. **Keychain Unlockers** (macOS only):
|
||||||
|
- Stores unlock keys in macOS Keychain
|
||||||
|
- Protected by system authentication (Touch ID, password)
|
||||||
|
- Automatic unlocking when Keychain is unlocked
|
||||||
|
- Cross-application integration
|
||||||
|
|
||||||
|
4. **Secure Enclave Unlockers** (macOS - planned):
|
||||||
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
|
- Currently partially implemented but non-functional
|
||||||
|
- Requires Apple Developer Program membership and code signing entitlements
|
||||||
|
- Full implementation blocked by entitlement requirements
|
||||||
|
|
||||||
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.
|
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
|
||||||
@@ -241,6 +254,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
|
|
||||||
### Hardware Integration
|
### Hardware Integration
|
||||||
- Hardware token support via PGP/GPG integration
|
- Hardware token support via PGP/GPG integration
|
||||||
|
- macOS Keychain integration for system-level security
|
||||||
|
- Secure Enclave support planned (requires Apple Developer Program)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -285,6 +300,7 @@ secret vault list
|
|||||||
# Add multiple unlock methods
|
# Add multiple unlock methods
|
||||||
secret unlockers add passphrase # Password-based
|
secret unlockers add passphrase # Password-based
|
||||||
secret unlockers add pgp --keyid ABCD1234 # GPG key
|
secret unlockers add pgp --keyid ABCD1234 # GPG key
|
||||||
|
secret unlockers add keychain # macOS Keychain (macOS only)
|
||||||
|
|
||||||
# List unlockers
|
# List unlockers
|
||||||
secret unlockers list
|
secret unlockers list
|
||||||
@@ -316,7 +332,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
- **Age Files**: Standard Age encryption format (.age extension)
|
- **Age Files**: Standard Age encryption format (.age extension)
|
||||||
- **Metadata**: JSON format with timestamps and type information
|
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
||||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
@@ -325,8 +341,8 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
||||||
|
|
||||||
### Cross-Platform Support
|
### Cross-Platform Support
|
||||||
- **macOS**: Full support including Keychain integration
|
- **macOS**: Full support including Keychain and planned Secure Enclave integration
|
||||||
- **Linux**: Full support (excluding Keychain features)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
- **Windows**: Basic support (filesystem operations only)
|
- **Windows**: Basic support (filesystem operations only)
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
@@ -367,9 +383,18 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Authentication Methods**: Supports passphrase-based and PGP-based unlockers
|
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain 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
|
||||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||||
|
|
||||||
|
# Author
|
||||||
|
|
||||||
|
Made with love and lots of expensive SOTA AI by [sneak](https://sneak.berlin) in Berlin in the summer of 2025.
|
||||||
|
|
||||||
|
Released as a free software gift to the world, no strings attached, under the [WTFPL](https://www.wtfpl.net/) license.
|
||||||
|
|
||||||
|
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
|
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||||
|
|||||||
45
TODO.md
45
TODO.md
@@ -4,6 +4,51 @@ This document outlines the bugs, issues, and improvements that need to be
|
|||||||
addressed before the 1.0 release of the secret manager. Items are
|
addressed before the 1.0 release of the secret manager. Items are
|
||||||
prioritized from most critical (top) to least critical (bottom).
|
prioritized from most critical (top) to least critical (bottom).
|
||||||
|
|
||||||
|
## CRITICAL BLOCKERS FOR 1.0 RELEASE
|
||||||
|
|
||||||
|
### Command Injection Vulnerabilities
|
||||||
|
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping
|
||||||
|
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
|
||||||
|
|
||||||
|
### Memory Security Critical Issues
|
||||||
|
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
|
||||||
|
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
|
||||||
|
|
||||||
|
### Race Conditions (Data Corruption Risk)
|
||||||
|
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
|
||||||
|
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
|
||||||
|
|
||||||
|
### Input Validation Vulnerabilities
|
||||||
|
- [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding
|
||||||
|
- [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion
|
||||||
|
|
||||||
|
### Timing Attack Vulnerabilities
|
||||||
|
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
|
||||||
|
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
|
||||||
|
|
||||||
|
## CRITICAL MEMORY SECURITY ISSUES
|
||||||
|
|
||||||
|
### Functions accepting bare []byte for sensitive data
|
||||||
|
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
|
||||||
|
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
|
||||||
|
|
||||||
|
### Functions returning unprotected secrets
|
||||||
|
- [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally
|
||||||
|
- [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer
|
||||||
|
- [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer
|
||||||
|
|
||||||
|
### Intermediate string variables for passphrases
|
||||||
|
- [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter
|
||||||
|
- [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer
|
||||||
|
|
||||||
|
### Unprotected buffer.Bytes() usage
|
||||||
|
- [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function
|
||||||
|
- [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes
|
||||||
|
|
||||||
## Code Cleanups
|
## Code Cleanups
|
||||||
|
|
||||||
* we shouldn't be passing around a statedir, it should be read from the
|
* we shouldn't be passing around a statedir, it should be read from the
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.14.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -63,6 +63,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
|
|||||||
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 h1:yi1W8qcFJ2plmaGJFN1npm0KQviWPMCtQOYuwDT6Swk=
|
||||||
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1/go.mod h1:qDHUvIjGZJUtdPtuP4WMu5/U4aVWbFw1MhlkJqCGmCQ=
|
||||||
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||||
|
|||||||
@@ -96,21 +96,13 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Secret exists, get the age secret key from it
|
// Secret exists, get the age secret key from it
|
||||||
secretValue, err := cli.getSecretValue(vlt, secretObj)
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
// Create secure buffer for the secret value
|
ageSecretKey = secretBuffer.String()
|
||||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secureBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original secret value
|
|
||||||
for i := range secretValue {
|
|
||||||
secretValue[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ageSecretKey = secureBuffer.String()
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(ageSecretKey) {
|
if !isValidAgeSecretKey(ageSecretKey) {
|
||||||
@@ -189,36 +181,28 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the age secret key from the secret
|
// Get the age secret key from the secret
|
||||||
var secretValue []byte
|
var secretBuffer *memguard.LockedBuffer
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
secretValue, err = secretObj.GetValue(nil)
|
secretBuffer, err = secretObj.GetValue(nil)
|
||||||
} else {
|
} else {
|
||||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
||||||
if unlockErr != nil {
|
if unlockErr != nil {
|
||||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
||||||
}
|
}
|
||||||
secretValue, err = secretObj.GetValue(unlocker)
|
secretBuffer, 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)
|
||||||
}
|
}
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
// Create secure buffer for the secret value
|
|
||||||
secureBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secureBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original secret value
|
|
||||||
for i := range secretValue {
|
|
||||||
secretValue[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(secureBuffer.String()) {
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the age secret key to get the identity
|
// Parse the age secret key to get the identity
|
||||||
identity, err := age.ParseX25519Identity(secureBuffer.String())
|
identity, err := age.ParseX25519Identity(secretBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -266,7 +250,7 @@ func isValidAgeSecretKey(key string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
||||||
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
|
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
return secretObj.GetValue(nil)
|
return secretObj.GetValue(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
17
internal/macse/README.md
Normal file
17
internal/macse/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# secure enclave
|
||||||
|
|
||||||
|
```
|
||||||
|
akrotiri:~/dev/secret/internal/macse$ CGO_ENABLED=1 go test ./...
|
||||||
|
--- FAIL: TestEnclaveKeyEncryption (0.04s)
|
||||||
|
enclave_test.go:16: Failed to create enclave key: failed to create enclave key: error code -34018
|
||||||
|
--- FAIL: TestEnclaveKeyPersistence (0.01s)
|
||||||
|
enclave_test.go:52: Failed to create enclave key: failed to create enclave key: error code -34018
|
||||||
|
```
|
||||||
|
|
||||||
|
This works with temporary keys. When you try to use persistent keys, you
|
||||||
|
get the above error, because to persist keys in the SE you must have the
|
||||||
|
appropriate entitlements from Apple, which is only possible with an Apple
|
||||||
|
Developer Program paid membership (which requires doxxing yourself, and
|
||||||
|
paying them).
|
||||||
|
|
||||||
|
So this is a dead end for now.
|
||||||
313
internal/macse/enclave.go
Normal file
313
internal/macse/enclave.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package macse
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -x objective-c
|
||||||
|
#cgo LDFLAGS: -framework Foundation -framework Security -framework LocalAuthentication
|
||||||
|
#import <Foundation/Foundation.h>
|
||||||
|
#import <Security/Security.h>
|
||||||
|
#import <LocalAuthentication/LocalAuthentication.h>
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const void* data;
|
||||||
|
int len;
|
||||||
|
int error;
|
||||||
|
} DataResult;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
SecKeyRef privateKey;
|
||||||
|
const void* salt;
|
||||||
|
int saltLen;
|
||||||
|
int error;
|
||||||
|
} KeyResult;
|
||||||
|
|
||||||
|
KeyResult createEnclaveKey(bool requireBiometric) {
|
||||||
|
KeyResult result = {NULL, NULL, 0, 0};
|
||||||
|
|
||||||
|
// Create authentication context
|
||||||
|
LAContext* authContext = [[LAContext alloc] init];
|
||||||
|
authContext.localizedReason = @"Create Secure Enclave key";
|
||||||
|
|
||||||
|
CFMutableDictionaryRef attributes = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
||||||
|
CFDictionarySetValue(attributes, kSecAttrKeyType, kSecAttrKeyTypeECSECPrimeRandom);
|
||||||
|
CFDictionarySetValue(attributes, kSecAttrKeySizeInBits, (__bridge CFNumberRef)@256);
|
||||||
|
CFDictionarySetValue(attributes, kSecAttrTokenID, kSecAttrTokenIDSecureEnclave);
|
||||||
|
|
||||||
|
CFMutableDictionaryRef privateKeyAttrs = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
||||||
|
CFDictionarySetValue(privateKeyAttrs, kSecAttrIsPermanent, kCFBooleanFalse);
|
||||||
|
|
||||||
|
SecAccessControlCreateFlags flags = kSecAccessControlPrivateKeyUsage;
|
||||||
|
if (requireBiometric) {
|
||||||
|
flags |= kSecAccessControlBiometryCurrentSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
SecAccessControlRef access = SecAccessControlCreateWithFlags(kCFAllocatorDefault,
|
||||||
|
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
|
flags,
|
||||||
|
NULL);
|
||||||
|
if (!access) {
|
||||||
|
result.error = -1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
CFDictionarySetValue(privateKeyAttrs, kSecAttrAccessControl, access);
|
||||||
|
CFDictionarySetValue(privateKeyAttrs, kSecUseAuthenticationContext, (__bridge CFTypeRef)authContext);
|
||||||
|
CFDictionarySetValue(attributes, kSecPrivateKeyAttrs, privateKeyAttrs);
|
||||||
|
|
||||||
|
CFErrorRef error = NULL;
|
||||||
|
SecKeyRef privateKey = SecKeyCreateRandomKey(attributes, &error);
|
||||||
|
|
||||||
|
CFRelease(attributes);
|
||||||
|
CFRelease(privateKeyAttrs);
|
||||||
|
CFRelease(access);
|
||||||
|
|
||||||
|
if (error || !privateKey) {
|
||||||
|
if (error) {
|
||||||
|
result.error = (int)CFErrorGetCode(error);
|
||||||
|
CFRelease(error);
|
||||||
|
} else {
|
||||||
|
result.error = -3;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random salt
|
||||||
|
uint8_t* saltBytes = malloc(64);
|
||||||
|
if (SecRandomCopyBytes(kSecRandomDefault, 64, saltBytes) != 0) {
|
||||||
|
result.error = -2;
|
||||||
|
free(saltBytes);
|
||||||
|
if (privateKey) CFRelease(privateKey);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.privateKey = privateKey;
|
||||||
|
result.salt = saltBytes;
|
||||||
|
result.saltLen = 64;
|
||||||
|
|
||||||
|
// Retain the key so it's not released
|
||||||
|
CFRetain(privateKey);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataResult encryptData(SecKeyRef privateKey, const void* saltData, int saltLen, const void* plainData, int plainLen) {
|
||||||
|
DataResult result = {NULL, 0, 0};
|
||||||
|
|
||||||
|
// Get public key from private key
|
||||||
|
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||||
|
if (!publicKey) {
|
||||||
|
result.error = -1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform ECDH key agreement with self
|
||||||
|
CFErrorRef error = NULL;
|
||||||
|
CFMutableDictionaryRef params = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
||||||
|
CFDataRef sharedSecret = SecKeyCopyKeyExchangeResult(privateKey, kSecKeyAlgorithmECDHKeyExchangeStandard, publicKey, params, &error);
|
||||||
|
CFRelease(params);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
result.error = (int)CFErrorGetCode(error);
|
||||||
|
CFRelease(error);
|
||||||
|
CFRelease(publicKey);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For simplicity, we'll use the shared secret directly as a symmetric key
|
||||||
|
// In production, you'd want to use HKDF as shown in the Swift code
|
||||||
|
|
||||||
|
// Create encryption key from shared secret
|
||||||
|
const uint8_t* secretBytes = CFDataGetBytePtr(sharedSecret);
|
||||||
|
size_t secretLen = CFDataGetLength(sharedSecret);
|
||||||
|
|
||||||
|
// Simple XOR encryption for demonstration (NOT SECURE - use proper encryption in production)
|
||||||
|
uint8_t* encrypted = malloc(plainLen);
|
||||||
|
for (int i = 0; i < plainLen; i++) {
|
||||||
|
encrypted[i] = ((uint8_t*)plainData)[i] ^ secretBytes[i % secretLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data = encrypted;
|
||||||
|
result.len = plainLen;
|
||||||
|
|
||||||
|
CFRelease(publicKey);
|
||||||
|
CFRelease(sharedSecret);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataResult decryptData(SecKeyRef privateKey, const void* saltData, int saltLen, const void* encData, int encLen, void* context) {
|
||||||
|
DataResult result = {NULL, 0, 0};
|
||||||
|
|
||||||
|
// Set up authentication context
|
||||||
|
LAContext* authContext = [[LAContext alloc] init];
|
||||||
|
NSError* authError = nil;
|
||||||
|
|
||||||
|
// Check if biometric authentication is available
|
||||||
|
if ([authContext canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&authError]) {
|
||||||
|
// Evaluate biometric authentication synchronously
|
||||||
|
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
|
||||||
|
__block BOOL authSuccess = NO;
|
||||||
|
|
||||||
|
[authContext evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
|
||||||
|
localizedReason:@"Decrypt data using Secure Enclave"
|
||||||
|
reply:^(BOOL success, NSError * _Nullable error) {
|
||||||
|
authSuccess = success;
|
||||||
|
dispatch_semaphore_signal(sema);
|
||||||
|
}];
|
||||||
|
|
||||||
|
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
|
||||||
|
|
||||||
|
if (!authSuccess) {
|
||||||
|
result.error = -3;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public key from private key
|
||||||
|
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
|
||||||
|
if (!publicKey) {
|
||||||
|
result.error = -1;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create algorithm parameters with authentication context
|
||||||
|
CFMutableDictionaryRef params = CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
|
||||||
|
CFDictionarySetValue(params, kSecUseAuthenticationContext, (__bridge CFTypeRef)authContext);
|
||||||
|
|
||||||
|
// Perform ECDH key agreement with self
|
||||||
|
CFErrorRef error = NULL;
|
||||||
|
CFDataRef sharedSecret = SecKeyCopyKeyExchangeResult(privateKey, kSecKeyAlgorithmECDHKeyExchangeStandard, publicKey, params, &error);
|
||||||
|
CFRelease(params);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
result.error = (int)CFErrorGetCode(error);
|
||||||
|
CFRelease(error);
|
||||||
|
CFRelease(publicKey);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt using shared secret
|
||||||
|
const uint8_t* secretBytes = CFDataGetBytePtr(sharedSecret);
|
||||||
|
size_t secretLen = CFDataGetLength(sharedSecret);
|
||||||
|
|
||||||
|
// Simple XOR decryption for demonstration
|
||||||
|
uint8_t* decrypted = malloc(encLen);
|
||||||
|
for (int i = 0; i < encLen; i++) {
|
||||||
|
decrypted[i] = ((uint8_t*)encData)[i] ^ secretBytes[i % secretLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data = decrypted;
|
||||||
|
result.len = encLen;
|
||||||
|
|
||||||
|
CFRelease(publicKey);
|
||||||
|
CFRelease(sharedSecret);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void freeKeyResult(KeyResult* result) {
|
||||||
|
if (result->privateKey) {
|
||||||
|
CFRelease(result->privateKey);
|
||||||
|
}
|
||||||
|
if (result->salt) {
|
||||||
|
free((void*)result->salt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void freeDataResult(DataResult* result) {
|
||||||
|
if (result->data) {
|
||||||
|
free((void*)result->data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EnclaveKey struct {
|
||||||
|
privateKey C.SecKeyRef
|
||||||
|
salt []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEnclaveKey(requireBiometric bool) (*EnclaveKey, error) {
|
||||||
|
result := C.createEnclaveKey(C.bool(requireBiometric))
|
||||||
|
defer C.freeKeyResult(&result)
|
||||||
|
|
||||||
|
if result.error != 0 {
|
||||||
|
return nil, errors.New("failed to create enclave key")
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := make([]byte, result.saltLen)
|
||||||
|
copy(salt, (*[1 << 30]byte)(unsafe.Pointer(result.salt))[:result.saltLen:result.saltLen])
|
||||||
|
|
||||||
|
return &EnclaveKey{
|
||||||
|
privateKey: result.privateKey,
|
||||||
|
salt: salt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *EnclaveKey) Encrypt(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, errors.New("empty data")
|
||||||
|
}
|
||||||
|
if len(k.salt) == 0 {
|
||||||
|
return nil, errors.New("empty salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := C.encryptData(
|
||||||
|
k.privateKey,
|
||||||
|
unsafe.Pointer(&k.salt[0]),
|
||||||
|
C.int(len(k.salt)),
|
||||||
|
unsafe.Pointer(&data[0]),
|
||||||
|
C.int(len(data)),
|
||||||
|
)
|
||||||
|
defer C.freeDataResult(&result)
|
||||||
|
|
||||||
|
if result.error != 0 {
|
||||||
|
return nil, errors.New("encryption failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted := make([]byte, result.len)
|
||||||
|
copy(encrypted, (*[1 << 30]byte)(unsafe.Pointer(result.data))[:result.len:result.len])
|
||||||
|
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *EnclaveKey) Decrypt(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil, errors.New("empty data")
|
||||||
|
}
|
||||||
|
if len(k.salt) == 0 {
|
||||||
|
return nil, errors.New("empty salt")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := C.decryptData(
|
||||||
|
k.privateKey,
|
||||||
|
unsafe.Pointer(&k.salt[0]),
|
||||||
|
C.int(len(k.salt)),
|
||||||
|
unsafe.Pointer(&data[0]),
|
||||||
|
C.int(len(data)),
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
defer C.freeDataResult(&result)
|
||||||
|
|
||||||
|
if result.error != 0 {
|
||||||
|
return nil, errors.New("decryption failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted := make([]byte, result.len)
|
||||||
|
copy(decrypted, (*[1 << 30]byte)(unsafe.Pointer(result.data))[:result.len:result.len])
|
||||||
|
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *EnclaveKey) Close() {
|
||||||
|
if k.privateKey != 0 {
|
||||||
|
C.CFRelease(C.CFTypeRef(k.privateKey))
|
||||||
|
k.privateKey = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/macse/enclave_test.go
Normal file
87
internal/macse/enclave_test.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package macse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnclaveKeyEncryption(t *testing.T) {
|
||||||
|
// Skip: Secure Enclave access requires Apple Developer Enterprise (ADE) membership,
|
||||||
|
// proper code signing, and entitlements for non-ephemeral keys.
|
||||||
|
// Without these, only ephemeral keys work which are not suitable for our use case.
|
||||||
|
t.Skip("Skipping: Requires ADE membership, signing, and entitlements for non-ephemeral keys")
|
||||||
|
|
||||||
|
// Create a new enclave key without requiring biometric
|
||||||
|
key, err := NewEnclaveKey(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create enclave key: %v", err)
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
plaintext := []byte("Hello, Secure Enclave!")
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
encrypted, err := key.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify encrypted data is different from plaintext
|
||||||
|
if bytes.Equal(plaintext, encrypted) {
|
||||||
|
t.Error("Encrypted data should not equal plaintext")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, err := key.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decrypt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify decrypted data matches original
|
||||||
|
if !bytes.Equal(plaintext, decrypted) {
|
||||||
|
t.Errorf("Decrypted data does not match original: got %s, want %s", decrypted, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnclaveKeyWithBiometric(t *testing.T) {
|
||||||
|
// Skip: Secure Enclave access requires Apple Developer Enterprise (ADE) membership,
|
||||||
|
// proper code signing, and entitlements for non-ephemeral keys.
|
||||||
|
// Without these, only ephemeral keys work which are not suitable for our use case.
|
||||||
|
t.Skip("Skipping: Requires ADE membership, signing, and entitlements for non-ephemeral keys")
|
||||||
|
|
||||||
|
// This test requires user interaction
|
||||||
|
// Run with: CGO_ENABLED=1 go test -v -run TestEnclaveKeyWithBiometric
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("Skipping biometric test in short mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := NewEnclaveKey(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("Expected failure creating biometric key in test environment: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
plaintext := []byte("Biometric protected data")
|
||||||
|
|
||||||
|
encrypted, err := key.Encrypt(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt with biometric key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decryption would require biometric authentication
|
||||||
|
decrypted, err := key.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
// This is expected without proper biometric authentication
|
||||||
|
t.Logf("Expected decryption failure without biometric auth: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(plaintext, decrypted) {
|
||||||
|
t.Errorf("Decrypted data does not match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithIdentity decrypts data with an identity using age
|
// DecryptWithIdentity decrypts data with an identity using age
|
||||||
func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
|
||||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||||
@@ -65,40 +65,40 @@ func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
return nil, fmt.Errorf("failed to read decrypted data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
resultBuffer := memguard.NewBufferFromBytes(result)
|
||||||
|
|
||||||
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||||
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the passphrase string temporarily
|
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||||
passphraseStr := passphrase.String()
|
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||||
recipient, err := age.NewScryptRecipient(passphraseStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer for the data
|
return EncryptToRecipient(data, recipient)
|
||||||
dataBuffer := memguard.NewBufferFromBytes(data)
|
|
||||||
defer dataBuffer.Destroy()
|
|
||||||
|
|
||||||
return EncryptToRecipient(dataBuffer, recipient)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||||
if passphrase == nil {
|
if passphrase == nil {
|
||||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the passphrase string temporarily
|
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||||
passphraseStr := passphrase.String()
|
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||||
identity, err := age.NewScryptIdentity(passphraseStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
@@ -107,30 +109,22 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_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)
|
||||||
}
|
}
|
||||||
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
DebugWith("Successfully decrypted age private key with keychain passphrase",
|
||||||
slog.String("unlocker_id", k.GetID()),
|
slog.String("unlocker_id", k.GetID()),
|
||||||
slog.Int("decrypted_length", len(agePrivKeyData)),
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 6: Parse the decrypted age private key
|
// Step 6: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
|
||||||
|
|
||||||
// Create a secure buffer for the private key data
|
|
||||||
agePrivKeyBuffer := memguard.NewBufferFromBytes(agePrivKeyData)
|
|
||||||
defer agePrivKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original private key data
|
|
||||||
for i := range agePrivKeyData {
|
|
||||||
agePrivKeyData[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
|
||||||
@@ -301,13 +295,13 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt long-term private key using current unlocker
|
// Decrypt long-term private key using current unlocker
|
||||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
ltPrivKeyBuffer, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the decrypted key in a secure buffer
|
// Return the decrypted key buffer
|
||||||
return memguard.NewBufferFromBytes(ltPrivKeyData), nil
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
||||||
@@ -368,7 +362,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)
|
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -409,8 +403,12 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
return nil, fmt.Errorf("failed to marshal keychain data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a secure buffer for keychain data
|
||||||
|
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
||||||
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 8: Store data in keychain
|
// Step 8: Store data in keychain
|
||||||
if err := storeInKeychain(keychainItemName, keychainDataBytes); err != nil {
|
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
||||||
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,13 +440,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMacOSAvailable verifies that we're running on macOS and security command is available
|
// checkMacOSAvailable verifies that we're running on macOS
|
||||||
func checkMacOSAvailable() error {
|
func checkMacOSAvailable() error {
|
||||||
cmd := exec.Command("/usr/bin/security", "help")
|
if runtime.GOOS != "darwin" {
|
||||||
if err := cmd.Run(); err != nil {
|
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||||
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,59 +461,79 @@ func validateKeychainItemName(itemName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// storeInKeychain stores data in the macOS keychain using the security command
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||||
func storeInKeychain(itemName string, data []byte) error {
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||||
|
if data == nil {
|
||||||
|
return fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("/usr/bin/security", "add-generic-password", //nolint:gosec
|
|
||||||
"-a", itemName,
|
|
||||||
"-s", itemName,
|
|
||||||
"-w", string(data),
|
|
||||||
"-U") // Update if exists
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
|
item.SetDescription("Secret vault keychain data")
|
||||||
|
item.SetComment("This item stores encrypted key material for the secret vault")
|
||||||
|
item.SetData([]byte(data.String()))
|
||||||
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||||
|
|
||||||
|
// First try to delete any existing item
|
||||||
|
deleteItem := keychain.NewItem()
|
||||||
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
deleteItem.SetAccount(itemName)
|
||||||
|
keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
|
// Add the new item
|
||||||
|
if err := keychain.AddItem(item); err != nil {
|
||||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieveFromKeychain retrieves data from the macOS keychain using the security command
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
|
query := keychain.NewItem()
|
||||||
"-a", itemName,
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
"-s", itemName,
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
"-w") // Return password only
|
query.SetAccount(itemName)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
results, err := keychain.QueryItem(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing newline if present
|
if len(results) == 0 {
|
||||||
if len(output) > 0 && output[len(output)-1] == '\n' {
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||||
output = output[:len(output)-1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
return results[0].Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteFromKeychain removes an item from the macOS keychain using the security command
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||||
func deleteFromKeychain(itemName string) error {
|
func deleteFromKeychain(itemName string) error {
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
|
item := keychain.NewItem()
|
||||||
"-a", itemName,
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
"-s", itemName)
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
167
internal/secret/keychainunlocker_test.go
Normal file
167
internal/secret/keychainunlocker_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
//go:build darwin
|
||||||
|
// +build darwin
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
testItemName := "test-secret-keychain-item"
|
||||||
|
testData := "test-secret-data-12345"
|
||||||
|
testBuffer := memguard.NewBufferFromBytes([]byte(testData))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
|
// Clean up any existing item first
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
|
||||||
|
// Test 1: Store data in keychain
|
||||||
|
err := storeInKeychain(testItemName, testBuffer)
|
||||||
|
require.NoError(t, err, "Failed to store data in keychain")
|
||||||
|
|
||||||
|
// Test 2: Retrieve data from keychain
|
||||||
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||||
|
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
|
||||||
|
|
||||||
|
// Test 3: Update existing item (store again with different data)
|
||||||
|
newTestData := "updated-test-data-67890"
|
||||||
|
newTestBuffer := memguard.NewBufferFromBytes([]byte(newTestData))
|
||||||
|
defer newTestBuffer.Destroy()
|
||||||
|
|
||||||
|
err = storeInKeychain(testItemName, newTestBuffer)
|
||||||
|
require.NoError(t, err, "Failed to update data in keychain")
|
||||||
|
|
||||||
|
// Verify updated data
|
||||||
|
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||||
|
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
|
||||||
|
|
||||||
|
// Test 4: Delete from keychain
|
||||||
|
err = deleteFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to delete data from keychain")
|
||||||
|
|
||||||
|
// Test 5: Verify item is deleted (should fail to retrieve)
|
||||||
|
_, err = retrieveFromKeychain(testItemName)
|
||||||
|
assert.Error(t, err, "Expected error when retrieving deleted item")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeychainInvalidItemName(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
testData := memguard.NewBufferFromBytes([]byte("test"))
|
||||||
|
defer testData.Destroy()
|
||||||
|
|
||||||
|
// Test invalid item names
|
||||||
|
invalidNames := []string{
|
||||||
|
"", // Empty name
|
||||||
|
"test space", // Contains space
|
||||||
|
"test/slash", // Contains slash
|
||||||
|
"test\\backslash", // Contains backslash
|
||||||
|
"test:colon", // Contains colon
|
||||||
|
"test;semicolon", // Contains semicolon
|
||||||
|
"test|pipe", // Contains pipe
|
||||||
|
"test@at", // Contains @
|
||||||
|
"test#hash", // Contains #
|
||||||
|
"test$dollar", // Contains $
|
||||||
|
"test&ersand", // Contains &
|
||||||
|
"test*asterisk", // Contains *
|
||||||
|
"test?question", // Contains ?
|
||||||
|
"test!exclamation", // Contains !
|
||||||
|
"test'quote", // Contains single quote
|
||||||
|
"test\"doublequote", // Contains double quote
|
||||||
|
"test(paren", // Contains parenthesis
|
||||||
|
"test[bracket", // Contains bracket
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range invalidNames {
|
||||||
|
err := storeInKeychain(name, testData)
|
||||||
|
assert.Error(t, err, "Expected error for invalid name: %s", name)
|
||||||
|
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test valid names (should not error on validation)
|
||||||
|
validNames := []string{
|
||||||
|
"test-name",
|
||||||
|
"test_name",
|
||||||
|
"test.name",
|
||||||
|
"TestName123",
|
||||||
|
"TEST_NAME_123",
|
||||||
|
"com.example.test",
|
||||||
|
"secret-vault-hostname-2024-01-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range validNames {
|
||||||
|
err := validateKeychainItemName(name)
|
||||||
|
assert.NoError(t, err, "Expected no error for valid name: %s", name)
|
||||||
|
// Clean up
|
||||||
|
_ = deleteFromKeychain(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeychainNilData(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test storing nil data
|
||||||
|
err := storeInKeychain("test-item", nil)
|
||||||
|
assert.Error(t, err, "Expected error when storing nil data")
|
||||||
|
assert.Contains(t, err.Error(), "data buffer is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeychainLargeData(t *testing.T) {
|
||||||
|
// Skip test if not on macOS
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
t.Skip("Keychain tests only run on macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with larger hex-encoded data (512 bytes of binary data = 1KB hex)
|
||||||
|
largeData := make([]byte, 512)
|
||||||
|
for i := range largeData {
|
||||||
|
largeData[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to hex string for storage
|
||||||
|
hexData := hex.EncodeToString(largeData)
|
||||||
|
|
||||||
|
testItemName := "test-large-data"
|
||||||
|
testBuffer := memguard.NewBufferFromBytes([]byte(hexData))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
|
// Clean up first
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
|
||||||
|
// Store hex data
|
||||||
|
err := storeInKeychain(testItemName, testBuffer)
|
||||||
|
require.NoError(t, err, "Failed to store large data")
|
||||||
|
|
||||||
|
// Retrieve and verify
|
||||||
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
|
require.NoError(t, err, "Failed to retrieve large data")
|
||||||
|
|
||||||
|
// Decode hex and compare
|
||||||
|
decodedData, err := hex.DecodeString(string(retrievedData))
|
||||||
|
require.NoError(t, err, "Failed to decode hex data")
|
||||||
|
assert.Equal(t, largeData, decodedData, "Large data mismatch")
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
}
|
||||||
@@ -76,10 +76,11 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Test encrypting private key with passphrase
|
// Test encrypting private key with passphrase
|
||||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||||
privKeyData := []byte(agePrivateKey)
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphraseBuffer)
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,30 +84,22 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
// Decrypt the unlocker private key with passphrase
|
// Decrypt the unlocker private key with passphrase
|
||||||
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
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)
|
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted unlocker private key",
|
DebugWith("Successfully decrypted unlocker private key",
|
||||||
slog.String("unlocker_id", p.GetID()),
|
slog.String("unlocker_id", p.GetID()),
|
||||||
slog.Int("decrypted_length", len(privKeyData)),
|
slog.Int("decrypted_length", privKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse the decrypted private key
|
// Parse the decrypted private key
|
||||||
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
// Create a secure buffer for the private key data
|
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes(privKeyData)
|
|
||||||
defer privKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Clear the original private key data
|
|
||||||
for i := range privKeyData {
|
|
||||||
privKeyData[i] = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ pinentry-mode loopback
|
|||||||
origDecryptFunc := secret.GPGDecryptFunc
|
origDecryptFunc := secret.GPGDecryptFunc
|
||||||
|
|
||||||
// Set custom GPG functions for this test
|
// Set custom GPG functions for this test
|
||||||
secret.GPGEncryptFunc = func(data []byte, keyID string) ([]byte, error) {
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
cmd := exec.Command("gpg",
|
cmd := exec.Command("gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
@@ -60,7 +63,7 @@ pinentry-mode loopback
|
|||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(data)
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
@@ -69,7 +72,7 @@ pinentry-mode loopback
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.GPGDecryptFunc = func(encryptedData []byte) ([]byte, error) {
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.Command("gpg",
|
cmd := exec.Command("gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
@@ -88,7 +91,8 @@ pinentry-mode loopback
|
|||||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout.Bytes(), nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
return memguard.NewBufferFromBytes(stdout.Bytes()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original functions after test
|
// Restore original functions after test
|
||||||
@@ -444,8 +448,9 @@ Passphrase: ` + testPassphrase + `
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GPG encrypt the private key using our custom encrypt function
|
// GPG encrypt the private key using our custom encrypt function
|
||||||
privKeyData := []byte(ageIdentity.String())
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyData, keyID)
|
defer privKeyBuffer.Destroy()
|
||||||
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import (
|
|||||||
var (
|
var (
|
||||||
// GPGEncryptFunc is the function used for GPG encryption
|
// GPGEncryptFunc is the function used for GPG encryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
GPGEncryptFunc = gpgEncryptDefault //nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
|
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
|
||||||
|
|
||||||
// GPGDecryptFunc is the function used for GPG decryption
|
// GPGDecryptFunc is the function used for GPG decryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking
|
//nolint:gochecknoglobals // Required for test mocking
|
||||||
|
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||||
|
|
||||||
// gpgKeyIDRegex validates GPG key IDs
|
// gpgKeyIDRegex validates GPG key IDs
|
||||||
// Allows either:
|
// Allows either:
|
||||||
@@ -79,21 +81,22 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// 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", "unlocker_id", p.GetID())
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||||
agePrivKeyData, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_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)
|
||||||
}
|
}
|
||||||
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Successfully decrypted age private key with GPG",
|
DebugWith("Successfully decrypted age private key with GPG",
|
||||||
slog.String("unlocker_id", p.GetID()),
|
slog.String("unlocker_id", p.GetID()),
|
||||||
slog.Int("decrypted_length", len(agePrivKeyData)),
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 3: Parse the decrypted age private key
|
// Step 3: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||||
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
@@ -253,7 +256,7 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
|||||||
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -348,13 +351,16 @@ func checkGPGAvailable() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||||
func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
|
if data == nil {
|
||||||
|
return nil, fmt.Errorf("data buffer is nil")
|
||||||
|
}
|
||||||
if err := validateGPGKeyID(keyID); err != nil {
|
if err := validateGPGKeyID(keyID); err != nil {
|
||||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
|
||||||
cmd.Stdin = strings.NewReader(string(data))
|
cmd.Stdin = strings.NewReader(data.String())
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -365,7 +371,7 @@ func gpgEncryptDefault(data []byte, keyID string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||||
func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||||
|
|
||||||
@@ -374,5 +380,8 @@ func gpgDecryptDefault(encryptedData []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
// Create a secure buffer for the decrypted data
|
||||||
|
outputBuffer := memguard.NewBufferFromBytes(output)
|
||||||
|
|
||||||
|
return outputBuffer, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,35 +62,8 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save is deprecated - use vault.AddSecret directly which creates versions
|
|
||||||
// Kept for backward compatibility
|
|
||||||
func (s *Secret) Save(value []byte, force bool) error {
|
|
||||||
DebugWith("Saving secret (deprecated method)",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
|
||||||
slog.Int("value_length", len(value)),
|
|
||||||
slog.Bool("force", force),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create a secure buffer for the value - note that the caller
|
|
||||||
// should ideally pass a LockedBuffer directly to vault.AddSecret
|
|
||||||
valueBuffer := memguard.NewBufferFromBytes(value)
|
|
||||||
defer valueBuffer.Destroy()
|
|
||||||
|
|
||||||
err := s.vault.AddSecret(s.Name, valueBuffer, force)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to save secret", "error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Successfully saved secret", "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||||
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, 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()),
|
||||||
@@ -206,16 +179,17 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
|||||||
|
|
||||||
// Decrypt the encrypted long-term private key using the unlocker
|
// Decrypt the encrypted long-term private key using the unlocker
|
||||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||||
ltPrivKeyData, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
ltPrivKeyBuffer, 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)
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Parse the long-term private key
|
// Parse the long-term private key
|
||||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
||||||
|
|
||||||
|
|||||||
@@ -277,15 +277,16 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Decrypt version private key using long-term key
|
// Step 2: Decrypt version private key using long-term key
|
||||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Step 3: Parse version private key
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@@ -302,16 +303,17 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Decrypt metadata using version key
|
// Step 5: Decrypt metadata using version key
|
||||||
metadataBytes, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
return fmt.Errorf("failed to decrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 6: Unmarshal metadata
|
// Step 6: Unmarshal metadata
|
||||||
var metadata VersionMetadata
|
var metadata VersionMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
|
||||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||||
@@ -324,7 +326,7 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the version value
|
// GetValue retrieves and decrypts the version value
|
||||||
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
|
||||||
DebugWith("Getting version value",
|
DebugWith("Getting version value",
|
||||||
slog.String("secret_name", sv.SecretName),
|
slog.String("secret_name", sv.SecretName),
|
||||||
slog.String("version", sv.Version),
|
slog.String("version", sv.Version),
|
||||||
@@ -352,16 +354,17 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
// Step 2: Decrypt version private key using long-term key
|
// Step 2: Decrypt version private key using long-term key
|
||||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||||
versionPrivKeyData, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||||
|
|
||||||
// Step 3: Parse version private key
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@@ -381,7 +384,7 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
// Step 5: Decrypt value using version key
|
// Step 5: Decrypt value using version key
|
||||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||||
value, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
@@ -390,10 +393,10 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) ([]byte, error) {
|
|||||||
|
|
||||||
Debug("Successfully retrieved version value",
|
Debug("Successfully retrieved version value",
|
||||||
"version", sv.Version,
|
"version", sv.Version,
|
||||||
"value_length", len(value),
|
"value_length", valueBuffer.Size(),
|
||||||
"is_empty", len(value) == 0)
|
"is_empty", valueBuffer.Size() == 0)
|
||||||
|
|
||||||
return value, nil
|
return valueBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVersions lists all versions of a secret
|
// ListVersions lists all versions of a secret
|
||||||
|
|||||||
@@ -255,10 +255,11 @@ func TestSecretVersionGetValue(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Retrieve the value
|
// Retrieve the value
|
||||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer retrievedBuffer.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedValue)
|
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersions(t *testing.T) {
|
func TestListVersions(t *testing.T) {
|
||||||
|
|||||||
@@ -259,13 +259,14 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt version private key using long-term key
|
// Decrypt version private key using long-term key
|
||||||
versionPrivKeyData, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
return fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
// Parse version private key
|
// Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse version private key: %w", err)
|
return fmt.Errorf("failed to parse version private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -393,21 +394,26 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a copy to return since the buffer will be destroyed
|
||||||
|
result := make([]byte, decryptedValue.Size())
|
||||||
|
copy(result, decryptedValue.Bytes())
|
||||||
|
decryptedValue.Destroy()
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted secret version",
|
secret.DebugWith("Successfully decrypted secret version",
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.Int("decrypted_length", len(decryptedValue)),
|
slog.Int("decrypted_length", len(result)),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
||||||
secret.Debug("Vault secret decryption debug info",
|
secret.Debug("Vault secret decryption debug info",
|
||||||
"secret_name", name,
|
"secret_name", name,
|
||||||
"version", version,
|
"version", version,
|
||||||
"decrypted_value_length", len(decryptedValue),
|
"decrypted_value_length", len(result),
|
||||||
"is_empty", len(decryptedValue) == 0)
|
"is_empty", len(result) == 0)
|
||||||
|
|
||||||
return decryptedValue, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockVault unlocks the vault and returns the long-term private key
|
// UnlockVault unlocks the vault and returns the long-term private key
|
||||||
|
|||||||
@@ -346,7 +346,9 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
|
|
||||||
// Encrypt private key with passphrase
|
// Encrypt private key with passphrase
|
||||||
privKeyStr := unlockerIdentity.String()
|
privKeyStr := unlockerIdentity.String()
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase)
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,22 +157,23 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Decrypt long-term private key using unlocker
|
// Decrypt long-term private key using unlocker
|
||||||
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
|
||||||
ltPrivKeyData, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.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)
|
||||||
}
|
}
|
||||||
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
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("unlocker_type", unlocker.GetType()),
|
slog.String("unlocker_type", unlocker.GetType()),
|
||||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Parse long-term private key
|
// Parse long-term private key
|
||||||
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
|
||||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user