Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).

---
8 changes: 6 additions & 2 deletions phase/misc/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`\$\{([^}]+)\}`)
)


Expand Down
15 changes: 11 additions & 4 deletions phase/misc/misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 101 additions & 2 deletions phase/phase.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"log"
"strings"

"github.com/phasehq/golang-sdk/phase/crypto"
"github.com/phasehq/golang-sdk/phase/misc"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down