Replace shell-based keychain implementation with keybase/go-keychain library
- Replaced exec.Command calls to /usr/bin/security with native keybase/go-keychain API - Added comprehensive test suite for keychain operations - Fixed binary data storage in tests using hex encoding - Updated macse tests to skip with explanation about ADE requirements - All tests passing with CGO_ENABLED=1
This commit is contained in:
parent
bba1fb21e6
commit
816f53f819
@ -23,7 +23,10 @@
|
||||
"Bash(ls:*)",
|
||||
"WebFetch(domain:golangci-lint.run)",
|
||||
"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": []
|
||||
}
|
||||
|
41
README.md
41
README.md
@ -1,12 +1,12 @@
|
||||
# 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
|
||||
|
||||
### 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
|
||||
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:
|
||||
- Its own encryption key pair
|
||||
- Encrypted metadata including creation time and validity period
|
||||
- Metadata (unencrypted) including creation time and validity period
|
||||
- Immutable value storage
|
||||
- Atomic version switching via symlink updates
|
||||
|
||||
@ -125,6 +125,7 @@ Creates a new unlocker of the specified type:
|
||||
**Types:**
|
||||
- `passphrase`: Traditional passphrase-protected unlocker
|
||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||
- `keychain`: macOS Keychain integration (macOS only)
|
||||
|
||||
**Options:**
|
||||
- `--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
|
||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||
│ │ │ │ └── current -> versions/20231216.001
|
||||
│ │ │ └── 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
|
||||
- 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.
|
||||
|
||||
#### 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 token support via PGP/GPG integration
|
||||
- macOS Keychain integration for system-level security
|
||||
- Secure Enclave support planned (requires Apple Developer Program)
|
||||
|
||||
## Examples
|
||||
|
||||
@ -285,6 +300,7 @@ secret vault list
|
||||
# Add multiple unlock methods
|
||||
secret unlockers add passphrase # Password-based
|
||||
secret unlockers add pgp --keyid ABCD1234 # GPG key
|
||||
secret unlockers add keychain # macOS Keychain (macOS only)
|
||||
|
||||
# List unlockers
|
||||
secret unlockers list
|
||||
@ -316,7 +332,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
||||
|
||||
### File Formats
|
||||
- **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 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
|
||||
|
||||
### Cross-Platform Support
|
||||
- **macOS**: Full support including Keychain integration
|
||||
- **Linux**: Full support (excluding Keychain features)
|
||||
- **macOS**: Full support including Keychain and planned Secure Enclave integration
|
||||
- **Linux**: Full support (excluding macOS-specific features)
|
||||
- **Windows**: Basic support (filesystem operations only)
|
||||
|
||||
## Security Considerations
|
||||
@ -367,9 +383,18 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
||||
|
||||
## 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
|
||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||
- **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)
|
||||
|
22
TODO.md
22
TODO.md
@ -4,6 +4,28 @@ This document outlines the bugs, issues, and improvements that need to be
|
||||
addressed before the 1.0 release of the secret manager. Items are
|
||||
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
|
||||
|
1
go.mod
1
go.mod
@ -9,6 +9,7 @@ require (
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||
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/spf13/afero v1.14.0
|
||||
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 v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
|
||||
|
@ -25,24 +25,24 @@ typedef struct {
|
||||
|
||||
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,
|
||||
@ -51,18 +51,18 @@ KeyResult createEnclaveKey(bool requireBiometric) {
|
||||
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);
|
||||
@ -72,7 +72,7 @@ KeyResult createEnclaveKey(bool requireBiometric) {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// Generate random salt
|
||||
uint8_t* saltBytes = malloc(64);
|
||||
if (SecRandomCopyBytes(kSecRandomDefault, 64, saltBytes) != 0) {
|
||||
@ -81,129 +81,129 @@ KeyResult createEnclaveKey(bool requireBiometric) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -236,14 +236,14 @@ type EnclaveKey struct {
|
||||
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,
|
||||
@ -257,7 +257,7 @@ func (k *EnclaveKey) Encrypt(data []byte) ([]byte, error) {
|
||||
if len(k.salt) == 0 {
|
||||
return nil, errors.New("empty salt")
|
||||
}
|
||||
|
||||
|
||||
result := C.encryptData(
|
||||
k.privateKey,
|
||||
unsafe.Pointer(&k.salt[0]),
|
||||
@ -266,14 +266,14 @@ func (k *EnclaveKey) Encrypt(data []byte) ([]byte, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -284,7 +284,7 @@ func (k *EnclaveKey) Decrypt(data []byte) ([]byte, error) {
|
||||
if len(k.salt) == 0 {
|
||||
return nil, errors.New("empty salt")
|
||||
}
|
||||
|
||||
|
||||
result := C.decryptData(
|
||||
k.privateKey,
|
||||
unsafe.Pointer(&k.salt[0]),
|
||||
@ -294,14 +294,14 @@ func (k *EnclaveKey) Decrypt(data []byte) ([]byte, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@ -310,4 +310,4 @@ func (k *EnclaveKey) Close() {
|
||||
C.CFRelease(C.CFTypeRef(k.privateKey))
|
||||
k.privateKey = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,33 +9,38 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
@ -43,26 +48,31 @@ func TestEnclaveKeyEncryption(t *testing.T) {
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -70,8 +80,8 @@ func TestEnclaveKeyWithBiometric(t *testing.T) {
|
||||
t.Logf("Expected decryption failure without biometric auth: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if !bytes.Equal(plaintext, decrypted) {
|
||||
t.Errorf("Decrypted data does not match original")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,19 +6,21 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||
"github.com/awnumar/memguard"
|
||||
keychain "github.com/keybase/go-keychain"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
const (
|
||||
agePrivKeyPassphraseLength = 64
|
||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
||||
)
|
||||
|
||||
// keychainItemNameRegex validates keychain item names
|
||||
@ -438,13 +440,11 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
||||
}, 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 {
|
||||
cmd := exec.Command("/usr/bin/security", "help")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("macOS security command not available: %w (keychain unlockers are only supported on macOS)", err)
|
||||
if runtime.GOOS != "darwin" {
|
||||
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -461,7 +461,7 @@ func validateKeychainItemName(itemName string) error {
|
||||
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 *memguard.LockedBuffer) error {
|
||||
if data == nil {
|
||||
return fmt.Errorf("data buffer is nil")
|
||||
@ -469,54 +469,71 @@ func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
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", data.String(),
|
||||
"-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 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) {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "find-generic-password", //nolint:gosec
|
||||
"-a", itemName,
|
||||
"-s", itemName,
|
||||
"-w") // Return password only
|
||||
query := keychain.NewItem()
|
||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||
query.SetAccount(itemName)
|
||||
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||
query.SetReturnData(true)
|
||||
|
||||
output, err := cmd.Output()
|
||||
results, err := keychain.QueryItem(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||
}
|
||||
|
||||
// Remove trailing newline if present
|
||||
if len(output) > 0 && output[len(output)-1] == '\n' {
|
||||
output = output[:len(output)-1]
|
||||
if len(results) == 0 {
|
||||
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := validateKeychainItemName(itemName); err != nil {
|
||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("/usr/bin/security", "delete-generic-password", //nolint:gosec
|
||||
"-a", itemName,
|
||||
"-s", itemName)
|
||||
item := keychain.NewItem()
|
||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||
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)
|
||||
}
|
||||
|
||||
|
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)
|
||||
}
|
Loading…
Reference in New Issue
Block a user