Add the Secrets Manager REST client with a fake-server test suite

Public surface Authenticate/GetSecret/ListSecrets/ListProjects: OAuth
client_credentials against /identity/connect/token (with the
encrypted_payload organization-key unwrap), refresh-before-expiry, and
bearer reads under /api with in-memory decryption. Errors are fixed
reason enums that can never embed material. Everything is tested against
an in-process fake Secrets Manager speaking the same protocol and crypto
(auth failure, expiry, refresh, missing secrets, malformed payloads,
tampered MACs, plaintext mode, and redaction sweeps over every error
path); the real vault is never contacted.
This commit is contained in:
2026-08-29 00:08:31 -05:00
parent fd0f22ca2e
commit c94c0b1de4
5 changed files with 1475 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
// Package bitwarden is a plain-REST client for Bitwarden Secrets Manager
// machine accounts, written with the Go standard library only. The
// official SDK is deliberately not imported: it is source-available under
// a license incompatible with this project (see DESIGN.md).
//
// Wire protocol implemented (verified against a fake Secrets Manager in
// tests; live servers attach with zero code change):
//
// - POST /identity/connect/token — grant_type=client_credentials,
// scope=api.secrets, machine client_id/client_secret. The response's
// access token is a JWT (claims exp/sub/organization) whose
// encrypted_payload carries the organization key, wrapped for the
// credential's embedded 16-byte key via HKDF-SHA256.
// - POST /identity/connect/token — grant_type=refresh_token for
// refresh-before-expiry.
// - GET /api/accounts/{account}/secrets, GET /api/secrets/{id},
// GET /api/projects/{project}/secrets, GET /api/accounts/{account}/projects
// — secrets and project names arrive as EncString cipher strings the
// client decrypts in memory with the organization key.
//
// Security rules enforced by construction and by tests: tokens, keys and
// secret values live in memory only, are never persisted, never logged,
// and never embedded in error strings; errors carry fixed reason enums
// plus non-secret identifiers (uuids, secret names) only.
package bitwarden
import (
"context"
"errors"
"fmt"
"net/http"
)
// Sentinel error classes. Error() text is fixed-shape and safe to log.
var (
// ErrInvalidCredentials: credential input malformed or incomplete.
ErrInvalidCredentials = errors.New("bitwarden: invalid credentials")
// ErrAuthFailed: token exchange or refresh rejected by the server.
ErrAuthFailed = errors.New("bitwarden: auth failed")
// ErrTokenExpired: access token expired and no refresh token held.
ErrTokenExpired = errors.New("bitwarden: token expired")
// ErrSecretNotFound: no secret matches the requested name/id.
ErrSecretNotFound = errors.New("bitwarden: secret not found")
// ErrMalformedResponse: server payload failed parsing or decryption.
ErrMalformedResponse = errors.New("bitwarden: malformed response")
// ErrDecrypt: cipher string failed integrity check or decryption.
ErrDecrypt = errors.New("bitwarden: decrypt failed")
// ErrServer: unexpected server status.
ErrServer = errors.New("bitwarden: server error")
// ErrUnreachable: transport-level failure.
ErrUnreachable = errors.New("bitwarden: server unreachable")
)
// Credentials describes one machine account. Either AccessToken (the full
// "0.<uuid>.<secret>:<key>" credential as printed by Secrets Manager) or
// the ClientID+ClientSecret pair must be set. An AccessToken unlocks
// end-to-end decryption; a bare pair can only read servers that return
// unencrypted payloads (test doubles, plaintext gateways).
//
// Credential fields are secrets: they are never logged and never included
// in error strings.
type Credentials struct {
// BaseURL is the server root, e.g. "https://vault.bitwarden.com".
// Endpoints are derived as BaseURL/identity/connect/token and
// BaseURL/api/... . No default is applied by the library; the CLI
// defaults it (see the config package).
BaseURL string
// AccessToken is the full machine credential string.
AccessToken string
// ClientID and ClientSecret are the split credential form.
ClientID string
ClientSecret string
// HTTPClient overrides the transport (tests, proxies). Optional.
HTTPClient *http.Client
}
// Project is one Secrets Manager project as returned by ListProjects.
type Project struct {
ID string
Name string // decrypted; empty if the server returned none
Revision string
ReadAccess bool
}
// SecretInfo is one secret's metadata as returned by ListSecrets. Values
// are intentionally NOT included: listing is for discovery; use GetSecret
// for material.
type SecretInfo struct {
ID string
Name string // decrypted
Revision string
ReadAccess bool
}
// Authenticate exchanges machine credentials for an access token. The
// returned Token lives in memory only: refresh it via GetSecret's
// automatic refresh-before-expiry or Token.Refresh, zero it with
// Token.Zero when done. The client_secret is used for this exchange and
// then dropped; refreshes use the refresh token.
func Authenticate(ctx context.Context, creds Credentials) (*Token, error) {
clientID, clientSecret, payloadKey := "", "", (*SymmetricKey)(nil)
switch {
case creds.AccessToken != "":
var err error
clientID, clientSecret, payloadKey, err = parseAccessToken(creds.AccessToken)
if err != nil {
return nil, err
}
case creds.ClientID != "" && creds.ClientSecret != "":
clientID, clientSecret = creds.ClientID, creds.ClientSecret
default:
return nil, fmt.Errorf("%w: no access token or client id/secret pair", ErrInvalidCredentials)
}
if creds.BaseURL == "" {
return nil, fmt.Errorf("%w: no server url", ErrInvalidCredentials)
}
t, err := exchangeToken(ctx, creds.BaseURL, clientID, clientSecret, payloadKey)
if err != nil {
return nil, err
}
t.clientID = clientID
t.payloadKey = payloadKey
return t, nil
}
// GetSecret resolves one secret to its decrypted value. key is either a
// secret NAME (looked up across the account's secrets; the first exact
// match in sorted-id order wins) or a secret UUID (fetched directly).
// The returned string exists in memory only; it is never logged.
func GetSecret(ctx context.Context, tok *Token, key string) (string, error) {
if tok == nil {
return "", fmt.Errorf("bitwarden: no token")
}
if err := tok.ensureFresh(ctx); err != nil {
return "", err
}
if isUUID(key) {
s, err := tok.getSecretByID(ctx, key)
if err != nil {
return "", err
}
return tok.decryptField("secret value", s.Value)
}
secrets, err := tok.listSecrets(ctx, tok.AccountID)
if err != nil {
return "", err
}
var match *wireSecret
for i := range secrets {
name, err := tok.decryptField("secret name", secrets[i].Key)
if err != nil {
return "", err
}
if name == key {
m := secrets[i]
match = &m
break
}
}
if match == nil {
return "", fmt.Errorf("%w: %s", ErrSecretNotFound, key)
}
if match.Value == "" {
s, err := tok.getSecretByID(ctx, match.ID)
if err != nil {
return "", err
}
match = s
}
return tok.decryptField("secret value", match.Value)
}
// ListSecrets returns the account's secret metadata (names decrypted,
// values never fetched into this listing).
func ListSecrets(ctx context.Context, tok *Token) ([]SecretInfo, error) {
if err := tok.require(ctx); err != nil {
return nil, err
}
wire, err := tok.listSecrets(ctx, tok.AccountID)
if err != nil {
return nil, err
}
out := make([]SecretInfo, 0, len(wire))
for _, s := range wire {
name, err := tok.decryptField("secret name", s.Key)
if err != nil {
return nil, err
}
out = append(out, SecretInfo{ID: s.ID, Name: name, Revision: s.RevisionDate, ReadAccess: s.Read})
}
return out, nil
}
// ListProjects returns the account's projects (names decrypted).
func ListProjects(ctx context.Context, tok *Token) ([]Project, error) {
if err := tok.require(ctx); err != nil {
return nil, err
}
wire, err := tok.listProjects(ctx, tok.AccountID)
if err != nil {
return nil, err
}
out := make([]Project, 0, len(wire))
for _, p := range wire {
name, err := tok.decryptField("project name", p.Name)
if err != nil {
return nil, err
}
out = append(out, Project{ID: p.ID, Name: name, Revision: p.RevisionDate, ReadAccess: p.Read})
}
return out, nil
}