mirror of
https://github.com/davidallendj/magellan.git
synced 2025-12-20 11:37:01 -07:00
feat(secrets): implement SecretStore interface and StaticStore/LocalStore for credential management
This commit is contained in:
parent
ccce61694b
commit
ee1fc327e2
13 changed files with 531 additions and 34 deletions
75
pkg/secrets/encryption.go
Normal file
75
pkg/secrets/encryption.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
)
|
||||
|
||||
// Derive a unique AES key per SecretID using HKDF
|
||||
func deriveAESKey(masterKey []byte, secretID string) []byte {
|
||||
salt := []byte(secretID)
|
||||
hkdf := hkdf.New(sha256.New, masterKey, salt, nil)
|
||||
derivedKey := make([]byte, 32) // AES-256 key
|
||||
io.ReadFull(hkdf, derivedKey)
|
||||
return derivedKey
|
||||
}
|
||||
|
||||
// Encrypt data using AES-GCM
|
||||
func encryptAESGCM(key, plaintext []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
_, err = io.ReadFull(rand.Reader, nonce)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil)
|
||||
return hex.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt data using AES-GCM
|
||||
func decryptAESGCM(key []byte, encryptedData string) (string, error) {
|
||||
data, err := hex.DecodeString(encryptedData)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
41
pkg/secrets/encryption_test.go
Normal file
41
pkg/secrets/encryption_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveAESKey(t *testing.T) {
|
||||
masterKey := []byte("testmasterkey")
|
||||
secretID := "mySecretID"
|
||||
key1 := deriveAESKey(masterKey, secretID)
|
||||
key2 := deriveAESKey(masterKey, secretID)
|
||||
|
||||
if len(key1) != 32 {
|
||||
t.Errorf("derived key should be 32 bytes, got %d", len(key1))
|
||||
}
|
||||
if string(key1) != string(key2) {
|
||||
t.Errorf("keys derived from same secretID should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptAESGCM(t *testing.T) {
|
||||
masterKey := []byte("anotherTestMasterKey")
|
||||
secretID := "testSecret"
|
||||
plaintext := "Hello, secrets!"
|
||||
|
||||
key := deriveAESKey(masterKey, secretID)
|
||||
|
||||
encrypted, err := encryptAESGCM(key, []byte(plaintext))
|
||||
if err != nil {
|
||||
t.Fatalf("encryption failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := decryptAESGCM(key, encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("decryption failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("expected %q, got %q", plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
129
pkg/secrets/localstore.go
Normal file
129
pkg/secrets/localstore.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Structure to store encrypted secrets in a JSON file
|
||||
type LocalSecretStore struct {
|
||||
mu sync.RWMutex
|
||||
masterKey []byte
|
||||
filename string
|
||||
Secrets map[string]string `json:"secrets"`
|
||||
}
|
||||
|
||||
func NewLocalSecretStore(masterKeyHex, filename string, create bool) (*LocalSecretStore, error) {
|
||||
var secrets map[string]string
|
||||
|
||||
masterKey, err := hex.DecodeString(masterKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to generate masterkey from hex representation: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||
if !create {
|
||||
return nil, fmt.Errorf("file %s does not exist", filename)
|
||||
}
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to create file %s: %v", filename, err)
|
||||
}
|
||||
file.Close()
|
||||
secrets = make(map[string]string)
|
||||
}
|
||||
|
||||
if secrets == nil {
|
||||
secrets, err = loadSecrets(filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to load secrets from file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &LocalSecretStore{
|
||||
masterKey: masterKey,
|
||||
filename: filename,
|
||||
Secrets: secrets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateMasterKey creates a 32-byte random key and returns it as a hex string.
|
||||
func GenerateMasterKey() (string, error) {
|
||||
key := make([]byte, 32) // 32 bytes for AES-256
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// GetSecretByID decrypts the secret using the master key and returns it
|
||||
func (l *LocalSecretStore) GetSecretByID(secretID string) (string, error) {
|
||||
l.mu.RLock()
|
||||
encrypted, exists := l.Secrets[secretID]
|
||||
l.mu.RUnlock()
|
||||
if !exists {
|
||||
return "", fmt.Errorf("no secret found for %s", secretID)
|
||||
}
|
||||
|
||||
derivedKey := deriveAESKey(l.masterKey, secretID)
|
||||
return decryptAESGCM(derivedKey, encrypted)
|
||||
}
|
||||
|
||||
// StoreSecretByID encrypts the secret using the master key and stores it in the JSON file
|
||||
func (l *LocalSecretStore) StoreSecretByID(secretID, secret string) error {
|
||||
derivedKey := deriveAESKey(l.masterKey, secretID)
|
||||
encryptedSecret, err := encryptAESGCM(derivedKey, []byte(secret))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
l.Secrets[secretID] = encryptedSecret
|
||||
err = saveSecrets(l.filename, l.Secrets)
|
||||
l.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListSecrets returns a copy of secret IDs to secrets stored in memory
|
||||
func (l *LocalSecretStore) ListSecrets() (map[string]string, error) {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
secretsCopy := make(map[string]string)
|
||||
for key, value := range l.Secrets {
|
||||
secretsCopy[key] = value
|
||||
}
|
||||
return secretsCopy, nil
|
||||
}
|
||||
|
||||
// Saves secrets back to the JSON file
|
||||
func saveSecrets(jsonFile string, store map[string]string) error {
|
||||
file, err := os.OpenFile(jsonFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(store)
|
||||
}
|
||||
|
||||
// Loads the secrets JSON file
|
||||
func loadSecrets(jsonFile string) (map[string]string, error) {
|
||||
file, err := os.Open(jsonFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to open secret file %s:%v", jsonFile, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
store := make(map[string]string)
|
||||
decoder := json.NewDecoder(file)
|
||||
err = decoder.Decode(&store)
|
||||
return store, err
|
||||
}
|
||||
151
pkg/secrets/localstore_test.go
Normal file
151
pkg/secrets/localstore_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewLocalSecretStore(t *testing.T) {
|
||||
masterKey, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate master key: %v", err)
|
||||
}
|
||||
|
||||
filename := "test_secrets.json"
|
||||
defer os.Remove(filename)
|
||||
|
||||
store, err := NewLocalSecretStore(masterKey, filename, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create LocalSecretStore: %v", err)
|
||||
}
|
||||
|
||||
if store.filename != filename {
|
||||
t.Errorf("Expected filename %s, got %s", filename, store.filename)
|
||||
}
|
||||
|
||||
if hex.EncodeToString(store.masterKey) != masterKey {
|
||||
t.Errorf("Expected master key %s, got %s", masterKey, hex.EncodeToString(store.masterKey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMasterKey(t *testing.T) {
|
||||
key, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate master key: %v", err)
|
||||
}
|
||||
|
||||
if len(key) != 64 { // 32 bytes in hex representation
|
||||
t.Errorf("Expected key length 64, got %d", len(key))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAndGetSecretByID(t *testing.T) {
|
||||
masterKey, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate master key: %v", err)
|
||||
}
|
||||
|
||||
filename := "test_secrets.json"
|
||||
defer os.Remove(filename)
|
||||
|
||||
store, err := NewLocalSecretStore(masterKey, filename, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create LocalSecretStore: %v", err)
|
||||
}
|
||||
|
||||
secretID := "test_secret"
|
||||
secretValue := "my_secret_value"
|
||||
|
||||
err = store.StoreSecretByID(secretID, secretValue)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to store secret: %v", err)
|
||||
}
|
||||
|
||||
retrievedSecret, err := store.GetSecretByID(secretID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get secret: %v", err)
|
||||
}
|
||||
|
||||
if retrievedSecret != secretValue {
|
||||
t.Errorf("Expected secret value %s, got %s", secretValue, retrievedSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAndGetSecretJSON(t *testing.T) {
|
||||
masterKey, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate master key: %v", err)
|
||||
}
|
||||
|
||||
filename := "test_secrets.json"
|
||||
defer os.Remove(filename)
|
||||
|
||||
store, err := NewLocalSecretStore(masterKey, filename, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create LocalSecretStore: %v", err)
|
||||
}
|
||||
|
||||
secretID := "json_creds"
|
||||
jsonSecret := `{"username":"testUser","password":"testPass"}`
|
||||
|
||||
if err := store.StoreSecretByID(secretID, jsonSecret); err != nil {
|
||||
t.Fatalf("Failed to store JSON secret: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := store.GetSecretByID(secretID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get JSON secret by ID: %v", err)
|
||||
}
|
||||
|
||||
if retrieved != jsonSecret {
|
||||
t.Errorf("Expected %s, got %s", jsonSecret, retrieved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSecrets(t *testing.T) {
|
||||
masterKey, err := GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate master key: %v", err)
|
||||
}
|
||||
|
||||
filename := "test_secrets.json"
|
||||
defer os.Remove(filename)
|
||||
|
||||
store, err := NewLocalSecretStore(masterKey, filename, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create LocalSecretStore: %v", err)
|
||||
}
|
||||
|
||||
secretID1 := "test_secret_1"
|
||||
secretValue1 := "my_secret_value_1"
|
||||
secretID2 := "test_secret_2"
|
||||
secretValue2 := "my_secret_value_2"
|
||||
|
||||
err = store.StoreSecretByID(secretID1, secretValue1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to store secret: %v", err)
|
||||
}
|
||||
|
||||
err = store.StoreSecretByID(secretID2, secretValue2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to store secret: %v", err)
|
||||
}
|
||||
|
||||
secrets, err := store.ListSecrets()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to list secrets: %v", err)
|
||||
}
|
||||
|
||||
if len(secrets) != 2 {
|
||||
t.Errorf("Expected 2 secrets, got %d", len(secrets))
|
||||
}
|
||||
|
||||
if secrets[secretID1] != store.Secrets[secretID1] {
|
||||
t.Errorf("Expected secret value %s, got %s", store.Secrets[secretID1], secrets[secretID1])
|
||||
}
|
||||
|
||||
if secrets[secretID2] != store.Secrets[secretID2] {
|
||||
t.Errorf("Expected secret value %s, got %s", store.Secrets[secretID2], secrets[secretID2])
|
||||
}
|
||||
}
|
||||
7
pkg/secrets/main.go
Normal file
7
pkg/secrets/main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package secrets
|
||||
|
||||
type SecretStore interface {
|
||||
GetSecretByID(secretID string) (string, error)
|
||||
StoreSecretByID(secretID, secret string) error
|
||||
ListSecrets() (map[string]string, error)
|
||||
}
|
||||
28
pkg/secrets/staticstore.go
Normal file
28
pkg/secrets/staticstore.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package secrets
|
||||
|
||||
import "fmt"
|
||||
|
||||
type StaticStore struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// NewStaticStore creates a new StaticStore with the given username and password.
|
||||
func NewStaticStore(username, password string) *StaticStore {
|
||||
return &StaticStore{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StaticStore) GetSecretByID(secretID string) (string, error) {
|
||||
return fmt.Sprintf(`{"username":"%s","password":"%s"}`, s.Username, s.Password), nil
|
||||
}
|
||||
func (s *StaticStore) StoreSecretByID(secretID, secret string) error {
|
||||
return nil
|
||||
}
|
||||
func (s *StaticStore) ListSecrets() (map[string]string, error) {
|
||||
return map[string]string{
|
||||
"static_creds": fmt.Sprintf(`{"username":"%s","password":"%s"}`, s.Username, s.Password),
|
||||
}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue