diff --git a/README.md b/README.md index f4c221a..b69f300 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,73 @@ The Phase Secrets SDK provides a Go package for managing secrets in your application environments using the Phase service. This SDK let's you create, retrieve, update, and delete secrets, with end-to-end encryption with just a few lines of code. +## Features: + +- End-to-end encrypting secret CRUD +- Cross and local env secret referencing +- Built in handling of rate limiting + +### Secret referencing syntax: + +| Reference syntax | Environment | Path | Secret Key Being Referenced | Description | +| --------------------------------- | ---------------- | --------------------------------- | --------------------------- | ------------------------------------------------------------------ | +| `${KEY}` | same environment | `/ | KEY | Local reference in the same environment and path root (/). | +| `${staging.DEBUG}` | `dev` | `/` (root of staging environment) | DEBUG | Cross-environment reference to a secret at the root (/). | +| `${prod./frontend/SECRET_KEY}` | `prod` | `/frontend/` | SECRET_KEY | Cross-environment reference to a secret in a specific path. | +| `${/backend/payments/STRIPE_KEY}` | same environment | `/backend/payments/` | STRIPE_KEY | Local reference with a specified path within the same environment. | + ## Installation -To start using the Phase SDK in your Go project, install it using `go get`: +This SDK uses the `sodium` package to perform cryptographic operations, on most system you will need to install the `libsodium` library as a system dependency. Here's how you can install `libsodium` or its development packages on different platforms, including macOS, Ubuntu, Debian, Arch Linux, Alpine Linux, and Windows. + +### macOS + +```sh +brew install libsodium +``` + +## Fedora + +```sh +sudo dnf install libsodium-devel +``` + +### Ubuntu and Debian + +```sh +sudo apt-get update && sudo apt-get install libsodium-dev +``` + +### Arch Linux + +```sh +sudo pacman -Syu libsodium +``` + +### Alpine Linux + +```sh +sudo apk add libsodium-dev +``` + +### Windows + +On Windows, the process is a bit different due to the variety of development environments. However, you can download pre-built binaries from the official [libsodium GitHub releases page](https://github.com/jedisct1/libsodium/releases). Choose the appropriate version for your system architecture (e.g., Win32 or Win64), download it, and follow the instructions included to integrate `libsodium` with your development environment. For development with Visual Studio, you'll typically include the header files and link against the `libsodium.lib` or `libsodium.dll` file. + +If you're using a package manager like `vcpkg` or `chocolatey`, you can also find `libsodium` packages available for installation: + +- Using `vcpkg`: + ```sh + vcpkg install libsodium + ``` +- Using `chocolatey`: + ```sh + choco install libsodium + ``` + +Remember, after installing the library, you might need to configure your project or environment variables to locate the `libsodium` libraries correctly, especially on Windows. + +Next, start using the Phase SDK in your Go project, install it using `go get`: ```bash go get github.com/phasehq/golang-sdk/phase @@ -116,6 +180,6 @@ if err != nil { } ``` -For more information and advanced usage, refer to the official Phase documentation. +For more information and advanced usage, refer to the [Phase Docs](https://docs.phase.dev/sdks/go). --- diff --git a/phase/misc/const.go b/phase/misc/const.go index 9650eed..8e867a0 100644 --- a/phase/misc/const.go +++ b/phase/misc/const.go @@ -20,8 +20,12 @@ var ( // Compiled regex patterns PssUserPattern = regexp.MustCompile(`^pss_user:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64})$`) PssServicePattern = regexp.MustCompile(`^pss_service:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64})$`) - CrossEnvPattern = regexp.MustCompile(`\$\{(.+?)\.(.+?)\}`) - LocalRefPattern = regexp.MustCompile(`\$\{([^.]+?)\}`) + + //CrossEnvPattern = regexp.MustCompile(`\$\{(.+?)\.(.+?)\}`) + // LocalRefPattern = regexp.MustCompile(`\$\{([^.]+?)\}`) + + // Regex to identify secret references + SecretRefRegex = regexp.MustCompile(`\$\{([^}]+)\}`) ) diff --git a/phase/misc/misc.go b/phase/misc/misc.go index bdef2c4..95e0ced 100644 --- a/phase/misc/misc.go +++ b/phase/misc/misc.go @@ -19,17 +19,24 @@ func PhaseGetContext(userData AppKeyResponse, appName, envName string) (string, return "", "", "", fmt.Errorf("matching context not found") } +// FindEnvironmentKey searches for an environment key with case-insensitive and partial matching. func FindEnvironmentKey(userData AppKeyResponse, envName, appName string) (*EnvironmentKey, error) { + // Convert envName and appName to lowercase for case-insensitive comparison + lcEnvName := strings.ToLower(envName) + lcAppName := strings.ToLower(appName) + for _, app := range userData.Apps { - if appName == "" || app.Name == appName { + // Support partial and case-insensitive matching for appName + if appName == "" || strings.Contains(strings.ToLower(app.Name), lcAppName) { for _, envKey := range app.EnvironmentKeys { - if envKey.Environment.Name == envName { - return &envKey, nil // Note the address-of operator (&) before envKey + // Support partial and case-insensitive matching for envName + if strings.Contains(strings.ToLower(envKey.Environment.Name), lcEnvName) { + return &envKey, nil } } } } - return nil, fmt.Errorf("environment key not found") + return nil, fmt.Errorf("environment key not found for app '%s' and environment '%s'", appName, envName) } // normalizeTag replaces underscores with spaces and converts the string to lower case. diff --git a/phase/phase.go b/phase/phase.go index 9cd1af7..fcc16d1 100644 --- a/phase/phase.go +++ b/phase/phase.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "strings" "github.com/phasehq/golang-sdk/phase/crypto" "github.com/phasehq/golang-sdk/phase/misc" @@ -85,6 +86,86 @@ func Init(serviceToken, host string, debug bool) *Phase { } } +func (p *Phase) resolveSecretReference(ref, currentEnvName string) (string, error) { + var envName, path, keyName string + + // Check if the reference starts with an environment name followed by a dot + if strings.Contains(ref, ".") { + // Split on the first dot to differentiate environment from path/key + parts := strings.SplitN(ref, ".", 2) + envName = parts[0] + + // Further split the second part to separate the path and the key + // The last segment after the last "/" is the key, the rest is the path + lastSlashIndex := strings.LastIndex(parts[1], "/") + if lastSlashIndex != -1 { // Path is specified + path = parts[1][:lastSlashIndex] // Include the slash in the path + keyName = parts[1][lastSlashIndex+1:] + } else { // No path specified, use root + path = "/" + keyName = parts[1] + } + } else { // Local reference without an environment prefix + envName = currentEnvName + lastSlashIndex := strings.LastIndex(ref, "/") + if lastSlashIndex != -1 { // Path is specified + path = ref[:lastSlashIndex] // Include the slash in the path + keyName = ref[lastSlashIndex+1:] + } else { // No path specified, use root + path = "/" + keyName = ref + } + } + + // Validate the extracted parts + if keyName == "" { + return "", fmt.Errorf("invalid secret reference format: %s", ref) + } + + // Fetch and decrypt the referenced secret + opts := GetSecretOptions{ + EnvName: envName, + AppName: "", // AppName is available globally + KeyToFind: keyName, + SecretPath: path, + } + resolvedSecret, err := p.Get(opts) + if err != nil { + return "", fmt.Errorf("failed to resolve secret reference %s: %v", ref, err) + } + + // Return the decrypted value of the referenced secret + decryptedValue, ok := (*resolvedSecret)["value"].(string) + if !ok { + return "", fmt.Errorf("decrypted value of the secret reference %s is not a string", ref) + } + + return decryptedValue, nil +} + +// resolveSecretValue resolves all secret references in a given value string. +func (p *Phase) resolveSecretValue(value string, currentEnvName string) (string, error) { + refs := misc.SecretRefRegex.FindAllString(value, -1) + resolvedValue := value + + for _, ref := range refs { + // Extract just the reference part without the surrounding ${} + refMatch := misc.SecretRefRegex.FindStringSubmatch(ref) + if len(refMatch) > 1 { + // Pass the current environment name if needed for resolution + resolvedSecretValue, err := p.resolveSecretReference(refMatch[1], currentEnvName) + if err != nil { + return "", err + } + // Directly use the string value returned by resolveSecretReference + resolvedValue = strings.Replace(resolvedValue, ref, resolvedSecretValue, -1) + } + } + + return resolvedValue, nil +} + +// Get fetches and decrypts a secret, resolving any secret references within its value. func (p *Phase) Get(opts GetSecretOptions) (*map[string]interface{}, error) { // Fetch user data resp, err := network.FetchPhaseUser(p.AppToken, p.Host) @@ -160,6 +241,15 @@ func (p *Phase) Get(opts GetSecretOptions) (*map[string]interface{}, error) { return nil, err } + // Resolve any secret references within the decryptedValue before creating the result map + resolvedValue, err := p.resolveSecretValue(decryptedValue, opts.EnvName) + if err != nil { + if p.Debug { + log.Printf("Failed to resolve secret value: %v", err) + } + return nil, err + } + // Verify tag match if a tag is provided var stringTags []string if opts.Tag != "" { @@ -180,7 +270,7 @@ func (p *Phase) Get(opts GetSecretOptions) (*map[string]interface{}, error) { result := &map[string]interface{}{ "key": decryptedKey, - "value": decryptedValue, + "value": resolvedValue, // Use resolvedValue here "comment": decryptedComment, "tags": stringTags, "path": secretPath, @@ -259,6 +349,15 @@ func (p *Phase) GetAll(opts GetAllSecretsOptions) ([]map[string]interface{}, err continue } + // Resolve any secret references within the decryptedValue + resolvedValue, err := p.resolveSecretValue(decryptedValue, opts.EnvName) + if err != nil { + if p.Debug { + log.Printf("Failed to resolve secret value: %v\n", err) + } + continue + } + // Prepare tags for inclusion in result var stringTags []string if secretTags, ok := secret["tags"].([]interface{}); ok { @@ -280,7 +379,7 @@ func (p *Phase) GetAll(opts GetAllSecretsOptions) ([]map[string]interface{}, err // Append decrypted secret with path to result list result := map[string]interface{}{ "key": decryptedKey, - "value": decryptedValue, + "value": resolvedValue, // Use resolvedValue here "comment": decryptedComment, "tags": stringTags, "path": path,