-
Notifications
You must be signed in to change notification settings - Fork 15
Add Infisical KMS provider #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
78f1cb4
d8bff7e
07018e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| package infisical | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io/ioutil" | ||
| "net/http" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
| ) | ||
|
|
||
| const tokenExpiryBuffer = 5 * time.Second | ||
|
|
||
| type kmsEncryptDecrypter interface { | ||
| encrypt(plaintext string) (string, error) | ||
| decrypt(ciphertext string) (string, error) | ||
| } | ||
|
|
||
| type kmsClient struct { | ||
| httpClient *http.Client | ||
| baseURL string | ||
| kmsKeyID string | ||
| clientID string | ||
| clientSecret string | ||
|
|
||
| mu sync.RWMutex | ||
| token string | ||
| expiresAt time.Time | ||
| } | ||
|
|
||
| func newKmsClient(siteURL, kmsKeyID, clientID, clientSecret string) *kmsClient { | ||
| base := strings.TrimRight(siteURL, "/") | ||
| if !strings.HasSuffix(base, "/api") { | ||
| base += "/api" | ||
| } | ||
| return &kmsClient{ | ||
| httpClient: &http.Client{Timeout: 30 * time.Second}, | ||
| baseURL: base, | ||
| kmsKeyID: kmsKeyID, | ||
| clientID: clientID, | ||
| clientSecret: clientSecret, | ||
| } | ||
| } | ||
|
|
||
| type loginRequest struct { | ||
| ClientID string `json:"clientId"` | ||
| ClientSecret string `json:"clientSecret"` | ||
| } | ||
|
|
||
| type loginResponse struct { | ||
| AccessToken string `json:"accessToken"` | ||
| ExpiresIn int64 `json:"expiresIn"` | ||
| } | ||
|
|
||
| func (c *kmsClient) login() error { | ||
| body, err := json.Marshal(loginRequest{ | ||
| ClientID: c.clientID, | ||
| ClientSecret: c.clientSecret, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("infisical-kms: failed to marshal login request: %w", err) | ||
| } | ||
|
|
||
| resp, err := c.httpClient.Post( | ||
| c.baseURL+"/v1/auth/universal-auth/login", | ||
| "application/json", | ||
| bytes.NewReader(body), | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("infisical-kms: login request failed: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| msg, _ := ioutil.ReadAll(resp.Body) | ||
| return fmt.Errorf("infisical-kms: login returned %d: %s", resp.StatusCode, msg) | ||
| } | ||
|
|
||
| var result loginResponse | ||
| if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | ||
| return fmt.Errorf("infisical-kms: failed to decode login response: %w", err) | ||
| } | ||
|
|
||
| c.mu.Lock() | ||
| c.token = result.AccessToken | ||
| c.expiresAt = time.Now().Add(time.Duration(result.ExpiresIn)*time.Second - tokenExpiryBuffer) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We wont renew the token if it is revoked before this expiry time. Can we check for 401 Unauthorized errors in doKmsRequest and renew the token?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we get a 401 or 403 error (also used for expired tokens in Infisical API), we'll log in again and retry once. |
||
| c.mu.Unlock() | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| func (c *kmsClient) ensureToken() error { | ||
| c.mu.RLock() | ||
| valid := c.token != "" && time.Now().Before(c.expiresAt) | ||
| c.mu.RUnlock() | ||
| if valid { | ||
| return nil | ||
| } | ||
| return c.login() | ||
| } | ||
|
|
||
| func (c *kmsClient) doKmsRequest(path string, reqBody, respBody interface{}) error { | ||
| if err := c.ensureToken(); err != nil { | ||
| return err | ||
| } | ||
|
|
||
| body, err := json.Marshal(reqBody) | ||
| if err != nil { | ||
| return fmt.Errorf("infisical-kms: failed to marshal request: %w", err) | ||
| } | ||
|
|
||
| req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body)) | ||
| if err != nil { | ||
| return fmt.Errorf("infisical-kms: failed to create request: %w", err) | ||
| } | ||
| req.Header.Set("Content-Type", "application/json") | ||
|
|
||
| c.mu.RLock() | ||
| req.Header.Set("Authorization", "Bearer "+c.token) | ||
| c.mu.RUnlock() | ||
|
|
||
| resp, err := c.httpClient.Do(req) | ||
| if err != nil { | ||
| return fmt.Errorf("infisical-kms: request failed: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
|
|
||
| if resp.StatusCode != http.StatusOK { | ||
| msg, _ := ioutil.ReadAll(resp.Body) | ||
| return fmt.Errorf("infisical-kms: request returned %d: %s", resp.StatusCode, msg) | ||
| } | ||
|
|
||
| if err := json.NewDecoder(resp.Body).Decode(respBody); err != nil { | ||
| return fmt.Errorf("infisical-kms: failed to decode response: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| type encryptRequest struct { | ||
| Plaintext string `json:"plaintext"` | ||
| } | ||
|
|
||
| type encryptResponse struct { | ||
| Ciphertext string `json:"ciphertext"` | ||
| } | ||
|
|
||
| func (c *kmsClient) encrypt(plaintext string) (string, error) { | ||
| var resp encryptResponse | ||
| path := fmt.Sprintf("/v1/kms/keys/%s/encrypt", c.kmsKeyID) | ||
| encoded := base64.StdEncoding.EncodeToString([]byte(plaintext)) | ||
| if err := c.doKmsRequest(path, encryptRequest{Plaintext: encoded}, &resp); err != nil { | ||
| return "", err | ||
| } | ||
| return resp.Ciphertext, nil | ||
| } | ||
|
|
||
| type decryptRequest struct { | ||
| Ciphertext string `json:"ciphertext"` | ||
| } | ||
|
|
||
| type decryptResponse struct { | ||
| Plaintext string `json:"plaintext"` | ||
| } | ||
|
|
||
| func (c *kmsClient) decrypt(ciphertext string) (string, error) { | ||
| var resp decryptResponse | ||
| path := fmt.Sprintf("/v1/kms/keys/%s/decrypt", c.kmsKeyID) | ||
| if err := c.doKmsRequest(path, decryptRequest{Ciphertext: ciphertext}, &resp); err != nil { | ||
| return "", err | ||
| } | ||
| decoded, err := base64.StdEncoding.DecodeString(resp.Plaintext) | ||
| if err != nil { | ||
| return "", fmt.Errorf("infisical-kms: failed to base64-decode plaintext: %w", err) | ||
| } | ||
| return string(decoded), nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| package infisical | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "os" | ||
|
|
||
| "github.com/libopenstorage/secrets" | ||
| "github.com/libopenstorage/secrets/pkg/store" | ||
| "github.com/portworx/kvdb" | ||
| "github.com/sirupsen/logrus" | ||
| ) | ||
|
|
||
| const ( | ||
| // Name of the secret store | ||
| Name = secrets.TypeInfisical | ||
| // SiteURLKey is the base URL of the Infisical instance | ||
| SiteURLKey = "INFISICAL_SITE_URL" | ||
| // ClientIDKey is the Infisical Universal Auth machine identity client ID | ||
| ClientIDKey = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" | ||
| // ClientSecretKey is the Infisical Universal Auth machine identity client secret | ||
| ClientSecretKey = "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET" | ||
| // KMSKeyIDKey is the ID of the Infisical KMS key used for encrypt/decrypt | ||
| KMSKeyIDKey = "INFISICAL_KMS_KEY_ID" | ||
| // KvdbKey is used to setup Infisical KMS with kvdb for persistence | ||
| KvdbKey = "KMS_KVDB" | ||
| defaultSiteURL = "https://app.infisical.com" | ||
| kvdbPublicBasePath = "infisical/secrets/public/" | ||
| kvdbDataBasePath = "infisical/secrets/data/" | ||
| ) | ||
|
|
||
| var ( | ||
| // ErrKvdbNotProvided is returned when a valid kvdb instance is not provided | ||
| ErrKvdbNotProvided = errors.New("a valid kvdb.Kvdb instance must be provided via the KMS_KVDB config key") | ||
| // ErrClientIDRequired is returned when INFISICAL_UNIVERSAL_AUTH_CLIENT_ID is not set | ||
| ErrClientIDRequired = errors.New("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID is required (config key or env var)") | ||
| // ErrClientSecretRequired is returned when INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET is not set | ||
| ErrClientSecretRequired = errors.New("INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET is required (config key or env var)") | ||
| // ErrKMSKeyIDRequired is returned when INFISICAL_KMS_KEY_ID is not set | ||
| ErrKMSKeyIDRequired = errors.New("INFISICAL_KMS_KEY_ID is required (config key or env var)") | ||
| ) | ||
|
|
||
| type infisicalKms struct { | ||
| client kmsEncryptDecrypter | ||
| ps store.PersistenceStore | ||
| } | ||
|
|
||
| func New( | ||
| secretConfig map[string]interface{}, | ||
| ) (secrets.Secrets, error) { | ||
| v, ok := secretConfig[KvdbKey] | ||
| if !ok { | ||
| return nil, ErrKvdbNotProvided | ||
| } | ||
| kv, ok := v.(kvdb.Kvdb) | ||
| if !ok { | ||
| return nil, ErrKvdbNotProvided | ||
| } | ||
| ps := store.NewKvdbPersistenceStore(kv, kvdbPublicBasePath, kvdbDataBasePath) | ||
|
|
||
| siteURL := configString(secretConfig, SiteURLKey, defaultSiteURL) | ||
| clientID := configString(secretConfig, ClientIDKey, "") | ||
| clientSecret := configString(secretConfig, ClientSecretKey, "") | ||
| kmsKeyID := configString(secretConfig, KMSKeyIDKey, "") | ||
|
|
||
| if clientID == "" { | ||
| return nil, ErrClientIDRequired | ||
| } | ||
| if clientSecret == "" { | ||
| return nil, ErrClientSecretRequired | ||
| } | ||
| if kmsKeyID == "" { | ||
| return nil, ErrKMSKeyIDRequired | ||
| } | ||
|
|
||
| client := newKmsClient(siteURL, kmsKeyID, clientID, clientSecret) | ||
| if err := client.login(); err != nil { | ||
| return nil, fmt.Errorf("infisical-kms: authentication failed: %w", err) | ||
| } | ||
|
|
||
| logrus.WithFields(logrus.Fields{ | ||
| "site": siteURL, | ||
| "kmsKeyID": kmsKeyID, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is a KMS Key ID and is it ok to log it? Is it some sort of a Master Key ?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This KMS Key ID is not a master key, it's the ID handle that identifies the key on the Infisical KMS server. I'm dropping this to keep the same pattern used in other providers. |
||
| }).Info("infisical-kms: authenticated successfully") | ||
|
|
||
| return &infisicalKms{ | ||
| client: client, | ||
| ps: ps, | ||
| }, nil | ||
| } | ||
|
|
||
| func (k *infisicalKms) String() string { | ||
| return Name | ||
| } | ||
|
|
||
| func (k *infisicalKms) GetSecret( | ||
| secretId string, | ||
| keyContext map[string]string, | ||
| ) (map[string]interface{}, secrets.Version, error) { | ||
| if secretId == "" { | ||
| return nil, secrets.NoVersion, secrets.ErrEmptySecretId | ||
| } | ||
|
|
||
| exists, err := k.ps.Exists(secretId) | ||
| if err != nil { | ||
| return nil, secrets.NoVersion, err | ||
| } | ||
| if !exists { | ||
| return nil, secrets.NoVersion, secrets.ErrInvalidSecretId | ||
| } | ||
|
|
||
| ciphertextBytes, err := k.ps.GetPublic(secretId) | ||
| if err != nil { | ||
| return nil, secrets.NoVersion, err | ||
| } | ||
|
|
||
| plaintext, err := k.client.decrypt(string(ciphertextBytes)) | ||
| if err != nil { | ||
| return nil, secrets.NoVersion, fmt.Errorf("infisical-kms: decryption failed: %w", err) | ||
| } | ||
|
|
||
| result := make(map[string]interface{}) | ||
| if err := json.Unmarshal([]byte(plaintext), &result); err != nil { | ||
| return nil, secrets.NoVersion, fmt.Errorf("infisical-kms: failed to unmarshal decrypted data: %w", err) | ||
| } | ||
|
|
||
| return result, secrets.NoVersion, nil | ||
| } | ||
|
|
||
| func (k *infisicalKms) PutSecret( | ||
| secretId string, | ||
| plainText map[string]interface{}, | ||
| keyContext map[string]string, | ||
| ) (secrets.Version, error) { | ||
| if secretId == "" { | ||
| return secrets.NoVersion, secrets.ErrEmptySecretId | ||
| } | ||
| if len(plainText) == 0 { | ||
| return secrets.NoVersion, secrets.ErrEmptySecretData | ||
| } | ||
|
|
||
| _, override := keyContext[secrets.OverwriteSecretDataInStore] | ||
|
|
||
| jsonBytes, err := json.Marshal(plainText) | ||
| if err != nil { | ||
| return secrets.NoVersion, fmt.Errorf("infisical-kms: failed to marshal secret data: %w", err) | ||
| } | ||
|
|
||
| ciphertext, err := k.client.encrypt(string(jsonBytes)) | ||
| if err != nil { | ||
| return secrets.NoVersion, fmt.Errorf("infisical-kms: encryption failed: %w", err) | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do you envision the use of Infisical with this library? The callers of this library in certain cases also expect a KMS provider to store "secret data". I see that Infisical supports that, so in such a case you wont encrypt and return back the ciphertext but instead simply store this "secret data" in the KMS provider.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Infisical indeed also has a secrets management product. However, for the purposes of Portworx, we are currently only looking to integrate our KMS product. If there's demand for integrating secrets management, we can absolutely look into that in the future. |
||
|
|
||
| return secrets.NoVersion, k.ps.Set(secretId, []byte(ciphertext), nil, nil, override) | ||
| } | ||
|
|
||
| func (k *infisicalKms) DeleteSecret( | ||
| secretId string, | ||
| keyContext map[string]string, | ||
| ) error { | ||
| if secretId == "" { | ||
| return secrets.ErrEmptySecretId | ||
| } | ||
| return k.ps.Delete(secretId) | ||
| } | ||
|
|
||
| func (k *infisicalKms) ListSecrets() ([]string, error) { | ||
| return k.ps.List() | ||
| } | ||
|
|
||
| func (k *infisicalKms) Encrypt( | ||
| secretId string, | ||
| plaintTextData string, | ||
| keyContext map[string]string, | ||
| ) (string, error) { | ||
| return "", secrets.ErrNotSupported | ||
| } | ||
|
|
||
| func (k *infisicalKms) Decrypt( | ||
| secretId string, | ||
| encryptedData string, | ||
| keyContext map[string]string, | ||
| ) (string, error) { | ||
| return "", secrets.ErrNotSupported | ||
| } | ||
|
|
||
| func (k *infisicalKms) Rencrypt( | ||
| originalSecretId string, | ||
| newSecretId string, | ||
| originalKeyContext map[string]string, | ||
| newKeyContext map[string]string, | ||
| encryptedData string, | ||
| ) (string, error) { | ||
| return "", secrets.ErrNotSupported | ||
| } | ||
|
|
||
| func configString(config map[string]interface{}, key, fallback string) string { | ||
| if config != nil { | ||
| if v, ok := config[key]; ok { | ||
| if s, ok := v.(string); ok && s != "" { | ||
| return s | ||
| } | ||
| } | ||
| } | ||
| if env := os.Getenv(key); env != "" { | ||
| return env | ||
| } | ||
| return fallback | ||
| } | ||
|
|
||
| func init() { | ||
| if err := secrets.Register(Name, New); err != nil { | ||
| panic(err.Error()) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does Infisical support TLSConfig? Or can we enforce an "https" URL check to ensure that clientSecret is not passed as plaintext over the wire
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll update the code to add HTTPS enforcement.