feat(secrets): implement SecretStore interface and StaticStore/LocalStore for credential management

This commit is contained in:
Alex Lovell-Troy 2025-03-07 17:10:31 -05:00 committed by David J. Allen
parent 5bbd0b8998
commit 853e27c312
Signed by: towk
GPG key ID: 793B2924A49B3A3F
13 changed files with 525 additions and 28 deletions

View file

@ -8,6 +8,7 @@ import (
urlx "github.com/OpenCHAMI/magellan/internal/url" urlx "github.com/OpenCHAMI/magellan/internal/url"
magellan "github.com/OpenCHAMI/magellan/pkg" magellan "github.com/OpenCHAMI/magellan/pkg"
"github.com/OpenCHAMI/magellan/pkg/auth" "github.com/OpenCHAMI/magellan/pkg/auth"
"github.com/OpenCHAMI/magellan/pkg/secrets"
"github.com/cznic/mathutil" "github.com/cznic/mathutil"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -55,10 +56,10 @@ var CollectCmd = &cobra.Command{
if concurrency <= 0 { if concurrency <= 0 {
concurrency = mathutil.Clamp(len(scannedResults), 1, 10000) concurrency = mathutil.Clamp(len(scannedResults), 1, 10000)
} }
// Create a StaticSecretStore to hold the username and password
secrets := secrets.NewStaticStore(username, password)
_, err = magellan.CollectInventory(&scannedResults, &magellan.CollectParams{ _, err = magellan.CollectInventory(&scannedResults, &magellan.CollectParams{
URI: host, URI: host,
Username: username,
Password: password,
Timeout: timeout, Timeout: timeout,
Concurrency: concurrency, Concurrency: concurrency,
Verbose: verbose, Verbose: verbose,
@ -66,7 +67,7 @@ var CollectCmd = &cobra.Command{
OutputPath: outputPath, OutputPath: outputPath,
ForceUpdate: forceUpdate, ForceUpdate: forceUpdate,
AccessToken: accessToken, AccessToken: accessToken,
}) }, secrets)
if err != nil { if err != nil {
log.Error().Err(err).Msgf("failed to collect data") log.Error().Err(err).Msgf("failed to collect data")
} }

View file

@ -7,6 +7,7 @@ import (
urlx "github.com/OpenCHAMI/magellan/internal/url" urlx "github.com/OpenCHAMI/magellan/internal/url"
"github.com/OpenCHAMI/magellan/pkg/crawler" "github.com/OpenCHAMI/magellan/pkg/crawler"
"github.com/OpenCHAMI/magellan/pkg/secrets"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -35,10 +36,13 @@ var CrawlCmd = &cobra.Command{
return nil return nil
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
staticStore := &secrets.StaticStore{
Username: viper.GetString("crawl.username"),
Password: viper.GetString("crawl.password"),
}
systems, err := crawler.CrawlBMCForSystems(crawler.CrawlerConfig{ systems, err := crawler.CrawlBMCForSystems(crawler.CrawlerConfig{
URI: args[0], URI: args[0],
Username: cmd.Flag("username").Value.String(), CredentialStore: staticStore,
Password: cmd.Flag("password").Value.String(),
Insecure: cmd.Flag("insecure").Value.String() == "true", Insecure: cmd.Flag("insecure").Value.String() == "true",
}) })
if err != nil { if err != nil {

5
go.mod
View file

@ -16,7 +16,10 @@ require (
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8
) )
require github.com/rs/zerolog v1.33.0 require (
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.32.0
)
require ( require (
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect

View file

@ -17,6 +17,7 @@ import (
"github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/client"
"github.com/OpenCHAMI/magellan/pkg/crawler" "github.com/OpenCHAMI/magellan/pkg/crawler"
"github.com/OpenCHAMI/magellan/pkg/secrets"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -48,7 +49,7 @@ type CollectParams struct {
// //
// Requests can be made to several of the nodes using a goroutine by setting the q.Concurrency // Requests can be made to several of the nodes using a goroutine by setting the q.Concurrency
// property value between 1 and 10000. // property value between 1 and 10000.
func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[string]any, error) { func CollectInventory(assets *[]RemoteAsset, params *CollectParams, store secrets.SecretStore) ([]map[string]any, error) {
// check for available remote assets found from scan // check for available remote assets found from scan
if assets == nil { if assets == nil {
return nil, fmt.Errorf("no assets found") return nil, fmt.Errorf("no assets found")
@ -118,8 +119,7 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[strin
managers []crawler.Manager managers []crawler.Manager
config = crawler.CrawlerConfig{ config = crawler.CrawlerConfig{
URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port),
Username: params.Username, CredentialStore: store,
Password: params.Password,
Insecure: true, Insecure: true,
} }
) )
@ -260,10 +260,15 @@ func FindMACAddressWithIP(config crawler.CrawlerConfig, targetIP net.IP) (string
// gofish (at least for now). If there's a need for grabbing more // gofish (at least for now). If there's a need for grabbing more
// manager information in the future, we can move the logic into // manager information in the future, we can move the logic into
// the crawler. // the crawler.
bmc_creds, err := config.GetUserPass()
if err != nil {
return "", fmt.Errorf("failed to get credentials for URI: %s", config.URI)
}
client, err := gofish.Connect(gofish.ClientConfig{ client, err := gofish.Connect(gofish.ClientConfig{
Endpoint: config.URI, Endpoint: config.URI,
Username: config.Username, Username: bmc_creds.Username,
Password: config.Password, Password: bmc_creds.Password,
Insecure: config.Insecure, Insecure: config.Insecure,
BasicAuth: true, BasicAuth: true,
}) })

View file

@ -1,9 +1,11 @@
package crawler package crawler
import ( import (
"encoding/json"
"fmt" "fmt"
"strings" "strings"
"github.com/OpenCHAMI/magellan/pkg/secrets"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish"
"github.com/stmcginnis/gofish/redfish" "github.com/stmcginnis/gofish/redfish"
@ -11,9 +13,17 @@ import (
type CrawlerConfig struct { type CrawlerConfig struct {
URI string // URI of the BMC URI string // URI of the BMC
Username string // Username for the BMC
Password string // Password for the BMC
Insecure bool // Whether to ignore SSL errors Insecure bool // Whether to ignore SSL errors
CredentialStore secrets.SecretStore
}
func (cc *CrawlerConfig) GetUserPass() (BMCUsernamePassword, error) {
return loadBMCCreds(*cc)
}
type BMCUsernamePassword struct {
Username string `json:"username"`
Password string `json:"password"`
} }
type EthernetInterface struct { type EthernetInterface struct {
@ -82,11 +92,20 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) {
systems []InventoryDetail systems []InventoryDetail
rf_systems []*redfish.ComputerSystem rf_systems []*redfish.ComputerSystem
) )
// get username and password from secret store
bmc_creds, err := loadBMCCreds(config)
if err != nil {
event := log.Error()
event.Err(err)
event.Msg("failed to load BMC credentials")
return nil, err
}
// initialize gofish client // initialize gofish client
client, err := gofish.Connect(gofish.ClientConfig{ client, err := gofish.Connect(gofish.ClientConfig{
Endpoint: config.URI, Endpoint: config.URI,
Username: config.Username, Username: bmc_creds.Username,
Password: config.Password, Password: bmc_creds.Password,
Insecure: config.Insecure, Insecure: config.Insecure,
BasicAuth: true, BasicAuth: true,
}) })
@ -149,12 +168,21 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) {
// 6. Fetches the list of managers from the ServiceRoot. // 6. Fetches the list of managers from the ServiceRoot.
// 7. Returns the list of managers and any error encountered during the process. // 7. Returns the list of managers and any error encountered during the process.
func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) { func CrawlBMCForManagers(config CrawlerConfig) ([]Manager, error) {
// get username and password from secret store
bmc_creds, err := loadBMCCreds(config)
if err != nil {
event := log.Error()
event.Err(err)
event.Msg("failed to load BMC credentials")
return nil, err
}
// initialize gofish client // initialize gofish client
var managers []Manager var managers []Manager
client, err := gofish.Connect(gofish.ClientConfig{ client, err := gofish.Connect(gofish.ClientConfig{
Endpoint: config.URI, Endpoint: config.URI,
Username: config.Username, Username: bmc_creds.Username,
Password: config.Password, Password: bmc_creds.Password,
Insecure: config.Insecure, Insecure: config.Insecure,
BasicAuth: true, BasicAuth: true,
}) })
@ -344,3 +372,22 @@ func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, er
} }
return managers, nil return managers, nil
} }
func loadBMCCreds(config CrawlerConfig) (BMCUsernamePassword, error) {
creds, err := config.CredentialStore.GetSecretByID(config.URI)
if err != nil {
event := log.Error()
event.Err(err)
event.Msg("failed to get credentials from secret store")
return BMCUsernamePassword{}, err
}
var bmc_creds BMCUsernamePassword
err = json.Unmarshal([]byte(creds), &bmc_creds)
if err != nil {
event := log.Error()
event.Err(err)
event.Msg("failed to unmarshal credentials")
return BMCUsernamePassword{}, err
}
return bmc_creds, nil
}

View file

@ -203,7 +203,7 @@ func rawConnect(address string, protocol string, timeoutSeconds int, keepOpenOnl
) )
// try to conntect to host (expects host in format [10.0.0.0]:443) // try to conntect to host (expects host in format [10.0.0.0]:443)
target := fmt.Sprintf("%s:%s", uri.Hostname(), uri.Port()) target := net.JoinHostPort(uri.Hostname(), uri.Port())
conn, err := net.DialTimeout(protocol, target, timeoutDuration) conn, err := net.DialTimeout(protocol, target, timeoutDuration)
if err != nil { if err != nil {
asset.State = false asset.State = false

75
pkg/secrets/encryption.go Normal file
View 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
}

View 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
View 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
}

View 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
View 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)
}

View 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
}

View file

@ -16,6 +16,7 @@ import (
"github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/client"
"github.com/OpenCHAMI/magellan/pkg/crawler" "github.com/OpenCHAMI/magellan/pkg/crawler"
"github.com/OpenCHAMI/magellan/pkg/secrets"
) )
var ( var (
@ -126,11 +127,16 @@ func TestExpectedOutput(t *testing.T) {
t.Fatalf("failed while waiting for emulator: %v", err) t.Fatalf("failed while waiting for emulator: %v", err)
} }
// initialize a credential store
staticStore := &secrets.StaticStore{
Username: *username,
Password: *password,
}
systems, err := crawler.CrawlBMCForSystems( systems, err := crawler.CrawlBMCForSystems(
crawler.CrawlerConfig{ crawler.CrawlerConfig{
URI: *host, URI: *host,
Username: *username, CredentialStore: staticStore,
Password: *password,
Insecure: true, Insecure: true,
}, },
) )