diff --git a/cmd/collect.go b/cmd/collect.go index 242fd0d..c304beb 100644 --- a/cmd/collect.go +++ b/cmd/collect.go @@ -8,6 +8,7 @@ import ( urlx "github.com/OpenCHAMI/magellan/internal/url" magellan "github.com/OpenCHAMI/magellan/pkg" "github.com/OpenCHAMI/magellan/pkg/auth" + "github.com/OpenCHAMI/magellan/pkg/secrets" "github.com/cznic/mathutil" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -55,10 +56,10 @@ var CollectCmd = &cobra.Command{ if concurrency <= 0 { 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{ URI: host, - Username: username, - Password: password, Timeout: timeout, Concurrency: concurrency, Verbose: verbose, @@ -66,7 +67,7 @@ var CollectCmd = &cobra.Command{ OutputPath: outputPath, ForceUpdate: forceUpdate, AccessToken: accessToken, - }) + }, secrets) if err != nil { log.Error().Err(err).Msgf("failed to collect data") } diff --git a/cmd/crawl.go b/cmd/crawl.go index ae61135..e9e91bd 100644 --- a/cmd/crawl.go +++ b/cmd/crawl.go @@ -7,6 +7,7 @@ import ( urlx "github.com/OpenCHAMI/magellan/internal/url" "github.com/OpenCHAMI/magellan/pkg/crawler" + "github.com/OpenCHAMI/magellan/pkg/secrets" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -35,11 +36,14 @@ var CrawlCmd = &cobra.Command{ return nil }, 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{ - URI: args[0], - Username: cmd.Flag("username").Value.String(), - Password: cmd.Flag("password").Value.String(), - Insecure: cmd.Flag("insecure").Value.String() == "true", + URI: args[0], + CredentialStore: staticStore, + Insecure: cmd.Flag("insecure").Value.String() == "true", }) if err != nil { log.Fatalf("Error crawling BMC: %v", err) diff --git a/go.mod b/go.mod index 21ee7de..eb835b0 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,10 @@ require ( 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 ( github.com/google/go-cmp v0.6.0 // indirect diff --git a/pkg/collect.go b/pkg/collect.go index 1e47ca7..ccb1a67 100644 --- a/pkg/collect.go +++ b/pkg/collect.go @@ -17,6 +17,7 @@ import ( "github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/crawler" + "github.com/OpenCHAMI/magellan/pkg/secrets" "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 // 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 if assets == nil { return nil, fmt.Errorf("no assets found") @@ -117,10 +118,9 @@ func CollectInventory(assets *[]RemoteAsset, params *CollectParams) ([]map[strin systems []crawler.InventoryDetail managers []crawler.Manager config = crawler.CrawlerConfig{ - URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), - Username: params.Username, - Password: params.Password, - Insecure: true, + URI: fmt.Sprintf("%s:%d", sr.Host, sr.Port), + CredentialStore: store, + Insecure: true, } ) systems, err := crawler.CrawlBMCForSystems(config) @@ -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 // manager information in the future, we can move the logic into // 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{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) diff --git a/pkg/crawler/main.go b/pkg/crawler/main.go index f9526cd..ddf6357 100644 --- a/pkg/crawler/main.go +++ b/pkg/crawler/main.go @@ -1,19 +1,29 @@ package crawler import ( + "encoding/json" "fmt" "strings" + "github.com/OpenCHAMI/magellan/pkg/secrets" "github.com/rs/zerolog/log" "github.com/stmcginnis/gofish" "github.com/stmcginnis/gofish/redfish" ) type CrawlerConfig struct { - 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 + URI string // URI of the BMC + 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 { @@ -82,11 +92,20 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { systems []InventoryDetail 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 client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) @@ -149,12 +168,21 @@ func CrawlBMCForSystems(config CrawlerConfig) ([]InventoryDetail, error) { // 6. Fetches the list of managers from the ServiceRoot. // 7. Returns the list of managers and any error encountered during the process. 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 var managers []Manager client, err := gofish.Connect(gofish.ClientConfig{ Endpoint: config.URI, - Username: config.Username, - Password: config.Password, + Username: bmc_creds.Username, + Password: bmc_creds.Password, Insecure: config.Insecure, BasicAuth: true, }) @@ -344,3 +372,22 @@ func walkManagers(rf_managers []*redfish.Manager, baseURI string) ([]Manager, er } 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 +} diff --git a/pkg/scan.go b/pkg/scan.go index a88116d..58785ca 100644 --- a/pkg/scan.go +++ b/pkg/scan.go @@ -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) - target := fmt.Sprintf("%s:%s", uri.Hostname(), uri.Port()) + target := net.JoinHostPort(uri.Hostname(), uri.Port()) conn, err := net.DialTimeout(protocol, target, timeoutDuration) if err != nil { asset.State = false diff --git a/pkg/secrets/encryption.go b/pkg/secrets/encryption.go new file mode 100644 index 0000000..6faa737 --- /dev/null +++ b/pkg/secrets/encryption.go @@ -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 +} diff --git a/pkg/secrets/encryption_test.go b/pkg/secrets/encryption_test.go new file mode 100644 index 0000000..bc1919b --- /dev/null +++ b/pkg/secrets/encryption_test.go @@ -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) + } +} diff --git a/pkg/secrets/localstore.go b/pkg/secrets/localstore.go new file mode 100644 index 0000000..76fd136 --- /dev/null +++ b/pkg/secrets/localstore.go @@ -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 +} diff --git a/pkg/secrets/localstore_test.go b/pkg/secrets/localstore_test.go new file mode 100644 index 0000000..4009946 --- /dev/null +++ b/pkg/secrets/localstore_test.go @@ -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]) + } +} diff --git a/pkg/secrets/main.go b/pkg/secrets/main.go new file mode 100644 index 0000000..5925d53 --- /dev/null +++ b/pkg/secrets/main.go @@ -0,0 +1,7 @@ +package secrets + +type SecretStore interface { + GetSecretByID(secretID string) (string, error) + StoreSecretByID(secretID, secret string) error + ListSecrets() (map[string]string, error) +} diff --git a/pkg/secrets/staticstore.go b/pkg/secrets/staticstore.go new file mode 100644 index 0000000..3e77870 --- /dev/null +++ b/pkg/secrets/staticstore.go @@ -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 +} diff --git a/tests/compatibility_test.go b/tests/compatibility_test.go index ce2e876..dfcc5e5 100644 --- a/tests/compatibility_test.go +++ b/tests/compatibility_test.go @@ -16,6 +16,7 @@ import ( "github.com/OpenCHAMI/magellan/pkg/client" "github.com/OpenCHAMI/magellan/pkg/crawler" + "github.com/OpenCHAMI/magellan/pkg/secrets" ) var ( @@ -126,12 +127,17 @@ func TestExpectedOutput(t *testing.T) { t.Fatalf("failed while waiting for emulator: %v", err) } + // initialize a credential store + staticStore := &secrets.StaticStore{ + Username: *username, + Password: *password, + } + systems, err := crawler.CrawlBMCForSystems( crawler.CrawlerConfig{ - URI: *host, - Username: *username, - Password: *password, - Insecure: true, + URI: *host, + CredentialStore: staticStore, + Insecure: true, }, )