Documentation menu

Clients & Libraries

Go

A context-aware client with typed services for secrets, certificates, SSH, GPG and OpenSSL keys, tokens, JKS keystores and operation-only crypto, plus Call() for any REST route. Requires Go 1.22 or newer.

Install

The module path is github.com/afterdarksys/secretserver-clients/go. Its source lives in the go/ directory of the clients repository. Add it to your module with:

go get github.com/afterdarksys/secretserver-clients/go@latest

Import the package as:

import ss "github.com/afterdarksys/secretserver-clients/go/secretserver"

Authenticate

The Go client does not read environment variables. Pass the key (and optionally the URL) in Config. APIURL defaults to https://api.secretserver.io. The default HTTP client has a 30 second timeout, TLS 1.2 minimum and never follows redirects.

client, err := ss.NewClient(&ss.Config{
    APIKey: os.Getenv("SS_API_KEY"),
    APIURL: "https://api.secretserver.io", // optional
})
if err != nil {
    log.Fatal(err) // missing key or non-https URL
}

Read a secret

By name

secret, err := client.Secrets.Get(ctx, "db-password", nil)
if err != nil {
    return err
}
value := secret.Data["value"]
etag := secret.ETag // use as IfMatch on the next update

Secrets.Get always returns the current version. For a container path or an older version, call the path route directly:

By container path and version

var out struct {
    Meta map[string]any    `json:"meta"`
    Data map[string]string `json:"data"`
}
path := "/s/" + url.PathEscape("production") + "/" + url.PathEscape("db-password") + "/2"
if _, err := client.Call(ctx, "GET", path, nil, &out); err != nil {
    return err
}
previous := out.Data["value"]

Write a secret

Create

container := "3f0c...uuid"
_, err := client.Secrets.Create(ctx, &ss.SecretCreateRequest{
    Name:        "db-password",
    Data:        map[string]string{"value": "s3cr3t"},
    Description: "Primary database",
    ContainerID: &container, // optional
    Tags:        []string{"prod"},
})

Update

A nil field is left out and keeps the stored value. To clear description, tags or container_id, name it in Clear. Update returns ss.ErrPartialUpdatesUnconfirmed without sending anything unless Config.PartialUpdates is true or IfMatch holds an ETag from Get or Update.

cur, err := client.Secrets.Get(ctx, "db-password", nil)
if err != nil {
    return err
}
_, err = client.Secrets.Update(ctx, "db-password", &ss.SecretUpdateRequest{
    Data:    map[string]string{"value": "new-value"},
    IfMatch: cur.ETag,
})

// metadata only
_, err = client.Secrets.Update(ctx, "db-password", &ss.SecretUpdateRequest{
    Clear:   []string{"description"},
    IfMatch: cur.ETag,
})

Delete

err := client.Secrets.Delete(ctx, "db-password")

List secrets

secrets, err := client.Secrets.List(ctx, &ss.SecretListOptions{
    Limit: 100,            // 1..1000; 0 uses the server default (100)
    Tags:  []string{"prod"}, // filtered client-side
})
for _, s := range secrets {
    fmt.Println(s.Name, s.Version)
}

Error handling

ErrorMeaning
*ss.ErrorResponseAny non-2xx response. Response.StatusCode holds the status; the message never includes the response body.
*ss.ConflictErrorHTTP 409. ETag is the current value; errors.As also matches *ss.ErrorResponse.
ss.ErrPartialUpdatesUnconfirmedUpdate refused locally: no opt-in and no ETag.
ss.ErrResponseTooLargeResponse exceeded 4 MiB (JSON) or 16 MiB (downloads).
_, err := client.Secrets.Get(ctx, "db-password", nil)
var conflict *ss.ConflictError
var apiErr *ss.ErrorResponse
switch {
case err == nil:
case errors.As(err, &conflict):
    log.Printf("stale update, current ETag %s", conflict.ETag)
case errors.As(err, &apiErr) && apiErr.Response.StatusCode == http.StatusNotFound:
    log.Print("not found")
case errors.As(err, &apiErr) && apiErr.Response.StatusCode == http.StatusForbidden:
    log.Print("API key lacks the required scope")
default:
    log.Printf("request failed: %v", err) // transport error or context cancellation
}

Complete example

main.go

package main

import (
    "context"
    "errors"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    ss "github.com/afterdarksys/secretserver-clients/go/secretserver"
)

func main() {
    client, err := ss.NewClient(&ss.Config{APIKey: os.Getenv("SS_API_KEY")})
    if err != nil {
        log.Fatal(err)
    }
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    const name = "example-api-token"
    cur, err := client.Secrets.Get(ctx, name, nil)
    var apiErr *ss.ErrorResponse
    if errors.As(err, &apiErr) && apiErr.Response.StatusCode == http.StatusNotFound {
        if _, err = client.Secrets.Create(ctx, &ss.SecretCreateRequest{
            Name: name,
            Data: map[string]string{"value": "first-value"},
        }); err != nil {
            log.Fatal(err)
        }
        cur, err = client.Secrets.Get(ctx, name, nil)
    }
    if err != nil {
        log.Fatal(err)
    }

    if _, err := client.Secrets.Update(ctx, name, &ss.SecretUpdateRequest{
        Data:    map[string]string{"value": "second-value"},
        IfMatch: cur.ETag,
    }); err != nil {
        log.Fatal(err)
    }

    all, err := client.Secrets.List(ctx, nil)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d secrets visible to this key\n", len(all))
}
Keep secret values out of logs and error messages.

More operations

  • Any REST route: client.Call(ctx, method, path, body, &out), with path relative to /api/v1 or including it.
  • Named variables: AssignVariable, Render(ctx, text), ResolveDocument(ctx, json.RawMessage).
  • Services on the client: Certificates, SSHKeys, GPGKeys, OpenSSLKeys, Passwords, Tokens, NTLMHashes, JKS, Crypto, Integrations, Transform, Intelligence, Extraction, LDAP.
  • Private CA: pass Config.HTTPClient with an *http.Transport whose TLSClientConfig.RootCAs includes it. Transports that skip verification are refused.