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:
+215
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user