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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user