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
+263
View File
@@ -0,0 +1,263 @@
package bitwarden
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// httpClient is the narrow transport the client needs. The fake server in
// tests plugs in here; the production implementation is a plain
// *http.Client with a 30-second per-request budget.
type httpClient interface {
postForm(ctx context.Context, endpoint, form string, out any) error
getJSON(ctx context.Context, endpoint, bearer string, out any) error
}
type stdHTTPClient struct{ c *http.Client }
func (s stdHTTPClient) postForm(ctx context.Context, endpoint, form string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form))
if err != nil {
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
return s.do(req, out)
}
func (s stdHTTPClient) getJSON(ctx context.Context, endpoint, bearer string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return fmt.Errorf("%w: bad endpoint", ErrUnreachable)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+bearer)
return s.do(req, out)
}
func (s stdHTTPClient) do(req *http.Request, out any) error {
resp, err := s.c.Do(req)
if err != nil {
// Transport errors can embed URLs and peer text; drop them.
return ErrUnreachable
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return ErrUnreachable
}
return handleResponse(resp.StatusCode, body, out)
}
// handleResponse maps one HTTP exchange to typed errors. Response BODIES
// are never surfaced: a server echo is assumed to be able to contain
// material.
func handleResponse(status int, body []byte, out any) error {
switch {
case status == http.StatusOK:
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("%w: body is not valid json", ErrMalformedResponse)
}
return nil
case status == http.StatusBadRequest || status == http.StatusUnauthorized:
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(body, &e) // best effort; codes are sanitized below
code := ""
if isOAuthCode(e.Error) {
code = " (" + e.Error + ")"
}
return fmt.Errorf("%w%s", ErrAuthFailed, code)
case status == http.StatusNotFound:
return ErrSecretNotFound
case status >= 500:
return fmt.Errorf("%w: http %d", ErrServer, status)
default:
return fmt.Errorf("%w: http %d", ErrServer, status)
}
}
// isOAuthCode allows only the fixed lowercase OAuth error codes into error
// strings; any other server text is dropped.
func isOAuthCode(s string) bool {
if len(s) == 0 || len(s) > 64 {
return false
}
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r == '_') {
return false
}
}
return true
}
func urlEscape(s string) string { return url.QueryEscape(s) }
// tokenResponseBody is the union of the machine-account success shapes:
// the OAuth fields plus Secrets Manager's encrypted_payload (present on
// client_credentials logins; absent on refresh).
type tokenResponseBody struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
EncryptedPayload *string `json:"encrypted_payload"`
}
// orgKeyPayload is the JSON inside encrypted_payload once decrypted.
type orgKeyPayload struct {
EncryptionKey string `json:"encryptionKey"`
}
// exchangeToken performs the client_credentials login and builds a Token
// with the organization key unwrapped.
func exchangeToken(ctx context.Context, baseURL, clientID, clientSecret string, payloadKey *SymmetricKey) (*Token, error) {
form := "grant_type=client_credentials" +
"&client_id=" + urlEscape(clientID) +
"&client_secret=" + urlEscape(clientSecret) +
"&scope=" + urlEscape("api.secrets")
httpc := stdHTTPClient{c: &http.Client{Timeout: 30 * time.Second}}
var body tokenResponseBody
if err := httpc.postForm(ctx, baseURL+"/identity/connect/token", form, &body); err != nil {
return nil, err
}
if body.AccessToken == "" || body.ExpiresIn == 0 {
return nil, fmt.Errorf("%w: token response missing fields", ErrMalformedResponse)
}
t := &Token{
baseURL: strings.TrimRight(baseURL, "/"),
clientID: clientID,
httpc: httpc,
}
t.applyTokenBody(&body)
t.AccountID = clientID
if body.EncryptedPayload != nil {
if payloadKey == nil {
return nil, fmt.Errorf("%w: server sent encrypted payload but credential carries no key", ErrDecrypt)
}
enc, ok := ParseEncString(*body.EncryptedPayload)
if !ok {
return nil, fmt.Errorf("%w: encrypted payload not an encstring", ErrMalformedResponse)
}
pt, err := enc.Decrypt(payloadKey)
if err != nil {
return nil, err
}
var kp orgKeyPayload
if err := json.Unmarshal(pt, &kp); err != nil || kp.EncryptionKey == "" {
return nil, fmt.Errorf("%w: encrypted payload not an org key", ErrMalformedResponse)
}
raw, err := b64Decode(kp.EncryptionKey)
if err != nil {
return nil, fmt.Errorf("%w: org key not base64", ErrMalformedResponse)
}
orgKey, err := NewSymmetricKey(raw)
if err != nil {
return nil, fmt.Errorf("%w: org key length", ErrMalformedResponse)
}
t.orgKey = orgKey
}
return t, nil
}
// applyTokenBody installs a token response, keeping key material and
// transport (refresh rotates tokens, not keys).
func (t *Token) applyTokenBody(body *tokenResponseBody) {
t.AccessToken = body.AccessToken
t.TokenType = body.TokenType
t.Scope = body.Scope
if body.RefreshToken != "" {
t.RefreshToken = body.RefreshToken
}
t.ExpiresAt = t.nowT().Add(time.Duration(body.ExpiresIn) * time.Second)
if claims, ok := parseJWTClaims(body.AccessToken); ok {
t.Organization = claims.Organization
}
}
// wireSecret mirrors the server's secret object (camelCase JSON).
type wireSecret struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Key string `json:"key"`
Value string `json:"value"`
Note string `json:"note"`
CreationDate string `json:"creationDate"`
RevisionDate string `json:"revisionDate"`
Read bool `json:"read"`
Write bool `json:"write"`
}
type wireSecretList struct {
Data []wireSecret `json:"data"`
}
type wireProject struct {
ID string `json:"id"`
OrganizationID string `json:"organizationId"`
Name string `json:"name"`
CreationDate string `json:"creationDate"`
RevisionDate string `json:"revisionDate"`
Read bool `json:"read"`
Write bool `json:"write"`
}
type wireProjectList struct {
Data []wireProject `json:"data"`
}
func (t *Token) listSecrets(ctx context.Context, accountID string) ([]wireSecret, error) {
var list wireSecretList
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/accounts/"+urlEscape(accountID)+"/secrets", t.AccessToken, &list); err != nil {
return nil, err
}
return list.Data, nil
}
func (t *Token) getSecretByID(ctx context.Context, id string) (*wireSecret, error) {
var s wireSecret
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/secrets/"+urlEscape(id), t.AccessToken, &s); err != nil {
return nil, err
}
return &s, nil
}
func (t *Token) listProjects(ctx context.Context, accountID string) ([]wireProject, error) {
var list wireProjectList
if err := t.httpc.getJSON(ctx, t.baseURL+"/api/accounts/"+urlEscape(accountID)+"/projects", t.AccessToken, &list); err != nil {
return nil, err
}
return list.Data, nil
}
// isUUID reports whether s looks like a Bitwarden guid (secret names and
// ids are distinguishable, so GetSecret can route uuids to the direct
// endpoint).
func isUUID(s string) bool {
if len(s) != 36 {
return false
}
for i, c := range s {
switch i {
case 8, 13, 18, 23:
if c != '-' {
return false
}
default:
isHex := c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
if !isHex {
return false
}
}
}
return true
}
+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
}
+448
View File
@@ -0,0 +1,448 @@
package bitwarden_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
"git.knownelement.com/ukrrs/mopac-bitwarden-go/internal/fakesm"
)
// The entire suite runs against the in-process fake Secrets Manager
// (internal/fakesm). No test in this repo ever contacts a real vault.
// fixture is one configured fake plus the redaction set: strings that must
// NEVER appear in any error message produced by the library.
type fixture struct {
srv *fakesm.Server
cred string
reds []string // forbidden substrings for errors
}
func newFixture(t *testing.T) *fixture {
t.Helper()
srv, cred := fakesm.NewServer()
srv.Start()
t.Cleanup(srv.Close)
s := srv.Secrets[0]
return &fixture{
srv: srv,
cred: cred,
reds: []string{
srv.ClientSecret,
srv.LastAccessToken(), // empty until minted; live values added per-assert
s.Value,
srv.Secrets[1].Value,
},
}
}
// forbid asserts err (when non-nil) leaks nothing from the redaction set.
func (f *fixture) forbid(t *testing.T, err error) {
t.Helper()
if err == nil {
return
}
msg := err.Error()
reds := append([]string{}, f.reds...)
if tok := f.srv.LastAccessToken(); tok != "" {
reds = append(reds, tok)
}
if rt := f.srv.LastRefreshToken(); rt != "" {
reds = append(reds, rt)
}
for _, r := range reds {
if r != "" && strings.Contains(msg, r) {
t.Fatalf("error leaks secret material (%q): %v", mask(r), err)
}
}
}
// mask keeps even the assertion output free of material.
func mask(s string) string {
if len(s) > 6 {
return s[:6] + "..."
}
return "***"
}
func (f *fixture) creds() bw.Credentials {
return bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: f.cred}
}
func TestAuthenticateAndGetSecretHappyPath(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
if tok.Organization != f.srv.OrgID {
t.Fatalf("organization claim: %s", tok.Organization)
}
if f.srv.TokenCalls != 1 {
t.Fatalf("token calls: %d", f.srv.TokenCalls)
}
for _, sec := range f.srv.Secrets {
got, err := bw.GetSecret(ctx, tok, sec.Name)
if err != nil {
t.Fatalf("get %s: %v", sec.Name, err)
}
if got != sec.Value {
t.Fatalf("get %s: wrong value", sec.Name)
}
}
if f.srv.RefreshCalls != 0 {
t.Fatalf("refresh on a fresh token: %d", f.srv.RefreshCalls)
}
}
func TestGetSecretByID(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, _ := bw.Authenticate(ctx, f.creds())
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("get by id: wrong value")
}
}
func TestListSecretsAndProjects(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, _ := bw.Authenticate(ctx, f.creds())
defer tok.Zero()
secrets, err := bw.ListSecrets(ctx, tok)
if err != nil {
t.Fatalf("list secrets: %v", err)
}
if len(secrets) != 2 || secrets[0].Name != "redmine-api-key" || secrets[1].Name != "litellm-key" {
t.Fatalf("list secrets: %+v", secrets)
}
for _, s := range secrets {
if strings.Contains(s.Name, "2.") && strings.Count(s.Name, ".") >= 3 {
t.Fatalf("list secrets returned ciphertext: %q", s.Name)
}
}
projects, err := bw.ListProjects(ctx, tok)
if err != nil {
t.Fatalf("list projects: %v", err)
}
if len(projects) != 1 || projects[0].Name != "harness" {
t.Fatalf("list projects: %+v", projects)
}
}
func TestFailureTable(t *testing.T) {
cases := []struct {
name string
mutate func(*fakesm.Server, *fixture)
call func(context.Context, *fixture) error
wantErr error
}{
{
name: "wrong client secret",
mutate: func(s *fakesm.Server, f *fixture) {
f.cred = strings.Replace(f.cred, s.ClientSecret, "wrong-secret-entirely", 1)
},
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrAuthFailed,
},
{
name: "server 500 on token endpoint",
mutate: func(s *fakesm.Server, f *fixture) { s.AuthStatusOverride = 500 },
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrServer,
},
{
name: "malformed token body",
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedTokenBody = true },
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrMalformedResponse,
},
{
name: "unreachable server",
mutate: func(s *fakesm.Server, f *fixture) {
s.Close()
},
call: func(ctx context.Context, f *fixture) error { _, err := bw.Authenticate(ctx, f.creds()); return err },
wantErr: bw.ErrUnreachable,
},
{
name: "missing secret name",
call: func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, "no-such-secret")
return err
},
wantErr: bw.ErrSecretNotFound,
},
{
name: "missing secret by id",
call: func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, "00000000-0000-4000-8000-00000000dead")
return err
},
wantErr: bw.ErrSecretNotFound,
},
{
name: "malformed secrets list",
mutate: func(s *fakesm.Server, f *fixture) { s.MalformedListBody = true },
wantErr: bw.ErrMalformedResponse,
},
{
name: "tampered value mac",
mutate: func(s *fakesm.Server, f *fixture) { s.TamperSecretMAC = true },
wantErr: bw.ErrDecrypt,
},
{
name: "api bearer rejected",
mutate: func(s *fakesm.Server, f *fixture) { s.RejectBearer = true },
wantErr: bw.ErrAuthFailed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newFixture(t)
if tc.mutate != nil {
tc.mutate(f.srv, f)
}
call := tc.call
if call == nil {
call = func(ctx context.Context, f *fixture) error {
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
return err
}
defer tok.Zero()
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
return err
}
}
err := call(context.Background(), f)
f.forbid(t, err)
if err == nil {
t.Fatal("expected error, got none")
}
if !errors.Is(err, tc.wantErr) {
t.Fatalf("error class: got %v, want %v", err, tc.wantErr)
}
})
}
}
func TestDropEncryptedPayloadFailsClosed(t *testing.T) {
f := newFixture(t)
f.srv.DropEncryptedPayload = true
tok, err := bw.Authenticate(context.Background(), f.creds())
f.forbid(t, err)
if err != nil {
t.Fatalf("authenticate should succeed without payload: %v", err)
}
defer tok.Zero()
_, err = bw.GetSecret(context.Background(), tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrDecrypt) {
t.Fatalf("expected decrypt failure without org key, got %v", err)
}
}
func TestSplitCredentialsWithoutKeyCannotDecrypt(t *testing.T) {
// Split-form credentials on an encrypting server: login works (the
// token endpoint itself needs no local key) but the encrypted payload
// cannot be unwrapped, so login must fail loudly rather than leak
// ciphertext downstream.
f := newFixture(t)
_, err := bw.Authenticate(context.Background(), bw.Credentials{
BaseURL: f.srv.BaseURL(),
ClientID: f.srv.ClientID,
ClientSecret: f.srv.ClientSecret,
})
f.forbid(t, err)
if !errors.Is(err, bw.ErrDecrypt) {
t.Fatalf("expected decrypt error, got %v", err)
}
}
func TestPlaintextServerMode(t *testing.T) {
// A server that returns unencrypted payloads (plaintext gateway or
// minimal test double) works with split credentials and with access
// tokens alike; nothing encrypted ever reaches the caller.
f := newFixture(t)
f.srv.OrgKey = nil
ctx := context.Background()
tok, err := bw.Authenticate(ctx, bw.Credentials{
BaseURL: f.srv.BaseURL(),
ClientID: f.srv.ClientID,
ClientSecret: f.srv.ClientSecret,
})
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("plaintext get: wrong value")
}
}
func TestRefreshBeforeExpiry(t *testing.T) {
f := newFixture(t)
f.srv.TokenTTL = 2 * time.Second // inside the 30s refresh skew
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
t.Fatalf("get after near-expiry refresh: %v", err)
}
if f.srv.RefreshCalls == 0 {
t.Fatal("no refresh happened before expiry")
}
if _, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name); err != nil {
t.Fatalf("second get: %v", err)
}
if tok.AccessToken == "" {
t.Fatal("access token lost after refresh")
}
f.forbid(t, nil)
}
func TestExpiredWithoutRefreshToken(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
tok.RefreshToken = "" // simulate a server that issued none
tok.ExpiresAt = time.Now().Add(-time.Minute)
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrTokenExpired) {
t.Fatalf("expected token expired, got %v", err)
}
}
func TestRefreshFailureSurfaces(t *testing.T) {
f := newFixture(t)
f.srv.TokenTTL = 2 * time.Second
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
f.srv.RejectRefresh = true
_, err = bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
f.forbid(t, err)
if !errors.Is(err, bw.ErrAuthFailed) {
t.Fatalf("expected auth failure from rejected refresh, got %v", err)
}
}
func TestOmittedValueInListFallsBackToByID(t *testing.T) {
f := newFixture(t)
f.srv.OmitValueInList = true
ctx := context.Background()
tok, err := bw.Authenticate(ctx, f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
got, err := bw.GetSecret(ctx, tok, f.srv.Secrets[0].Name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got != f.srv.Secrets[0].Value {
t.Fatal("fallback get: wrong value")
}
}
func TestTokenStringIsLogSafe(t *testing.T) {
f := newFixture(t)
tok, err := bw.Authenticate(context.Background(), f.creds())
if err != nil {
t.Fatalf("authenticate: %v", err)
}
defer tok.Zero()
s := tok.String()
for _, forbidden := range []string{tok.AccessToken, tok.RefreshToken, f.srv.ClientSecret} {
if forbidden != "" && strings.Contains(s, forbidden) {
t.Fatalf("Token.String leaks material: %s", s)
}
}
if !strings.Contains(s, "expires") {
t.Fatalf("Token.String lacks expiry: %s", s)
}
var nilTok *bw.Token
if _ = nilTok; nilTok.String() == "" {
t.Fatal("nil token String")
}
}
func TestInvalidCredentialsInput(t *testing.T) {
f := newFixture(t)
ctx := context.Background()
cases := []struct {
name string
creds bw.Credentials
}{
{"empty", bw.Credentials{}},
{"no base url", bw.Credentials{AccessToken: f.cred}},
{"garbage token", bw.Credentials{BaseURL: f.srv.BaseURL(), AccessToken: "not-a-token"}},
{"half split", bw.Credentials{BaseURL: f.srv.BaseURL(), ClientID: "x"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := bw.Authenticate(ctx, tc.creds)
if !errors.Is(err, bw.ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials, got %v", err)
}
f.forbid(t, err)
})
}
}
func ExampleGetSecret() {
// Wired exactly as keyproxy's bitwarden backend will call it; the
// server URL here is a stand-in (tests bind the fake server).
creds := bw.Credentials{BaseURL: "https://vault.example.com", AccessToken: "0.<uuid>.<secret>:<key>"}
tok, err := bw.Authenticate(context.Background(), creds)
if err != nil {
fmt.Println("auth:", err)
return
}
defer tok.Zero()
value, err := bw.GetSecret(context.Background(), tok, "redmine-api-key")
if err != nil {
fmt.Println("get:", err)
return
}
fmt.Println(len(value), "chars")
}
+398
View File
@@ -0,0 +1,398 @@
// Package fakesm is an in-process fake Bitwarden Secrets Manager for
// tests. It speaks the real wire protocol — OAuth client_credentials at
// /identity/connect/token (including the encrypted_payload organization
// key handoff), bearer-authenticated reads under /api — and encrypts
// secret names and values as type-2 EncStrings under its own organization
// key, so the client's full decrypt chain is exercised. It is imported
// ONLY by tests and the smoke script; nothing in the library depends on
// it, and it never touches a real vault.
package fakesm
import (
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
)
// Secret is one fake secret: plaintext on the server side; the wire form
// is encrypted with the server's organization key.
type Secret struct {
ID string
Name string
Value string
Note string
}
// Project is one fake project.
type Project struct {
ID string
Name string
}
// Server is the fake Secrets Manager. Behavior knobs are failure
// injectors for table-driven tests; zero values mean "behave correctly".
type Server struct {
// Credential the server accepts (the parts of the machine access
// token). NewMinted generates a consistent set and the matching
// credential string.
ClientID string
ClientSecret string
TokenKey []byte // 16 bytes, the ":key" part of the credential
OrgKey []byte // 64 bytes; nil = plaintext mode
OrgID string
TokenTTL time.Duration
Secrets []Secret
Projects []Project
// Failure injectors.
RejectAuth bool // token endpoint: 400 invalid_grant
AuthStatusOverride int // token endpoint: raw status (e.g. 500)
MalformedTokenBody bool // token endpoint: 200 with non-JSON body
MalformedListBody bool // secret list: 200 with non-JSON body
DropEncryptedPayload bool // successful login without encrypted_payload
TamperSecretMAC bool // corrupt the value MAC in responses
OmitValueInList bool // list omits value (forces by-id fetch)
RejectRefresh bool // refresh grant: 400 invalid_grant
RejectBearer bool // API endpoints: 401 for every token
// Counters for assertions.
TokenCalls int
RefreshCalls int
APICalls int
mu sync.Mutex
issued map[string]time.Time // access token -> expiry
lastAccess string
lastRefresh string
srv *httptest.Server
}
// NewServer builds a server with a fixed, deterministic credential set
// (matching the published SDK sample format) and a random organization
// key, one project and two secrets. The matching machine credential
// string is returned for client configuration.
func NewServer() (*Server, string) {
orgKey := make([]byte, 64)
if _, err := rand.Read(orgKey); err != nil {
panic("fakesm: entropy: " + err.Error())
}
tokenKey := make([]byte, 16)
if _, err := rand.Read(tokenKey); err != nil {
panic("fakesm: entropy: " + err.Error())
}
secretTail := make([]byte, 12)
_, _ = rand.Read(secretTail)
clientSecret := "C2IgxjjLF7qSshsbwe8JGcbM075YXw"
s := &Server{
ClientID: "ec2c1d46-6a4b-4751-a310-af9601317f2d",
ClientSecret: clientSecret,
TokenKey: tokenKey,
OrgKey: orgKey,
OrgID: "3fb1c0de-0000-4000-8000-000000000000",
TokenTTL: time.Hour,
Projects: []Project{
{ID: "ac1d0000-0000-4000-8000-000000000001", Name: "harness"},
},
Secrets: []Secret{
{ID: "5ec1e700-0000-4000-8000-00000000000a", Name: "redmine-api-key", Value: "rm-live-" + hex.EncodeToString(secretTail)},
{ID: "5ec1e700-0000-4000-8000-00000000000b", Name: "litellm-key", Value: "lm-live-" + hex.EncodeToString(secretTail)},
},
issued: map[string]time.Time{},
}
cred := fmt.Sprintf("0.%s.%s:%s", s.ClientID, clientSecret, base64.StdEncoding.EncodeToString(tokenKey))
return s, cred
}
// Start boots the HTTP server; BaseURL is the server root for clients.
func (s *Server) Start() *Server {
s.srv = httptest.NewServer(http.HandlerFunc(s.handle))
return s
}
// ListenAndServe runs the fake on a plain listener (used by the smoke
// script, which boots it inside a container). It blocks until the process
// is killed.
func (s *Server) ListenAndServe(addr string) error {
if s.issued == nil {
s.issued = map[string]time.Time{}
}
return http.ListenAndServe(addr, http.HandlerFunc(s.handle))
}
// Close shuts the server down.
func (s *Server) Close() {
if s.srv != nil {
s.srv.Close()
}
}
// BaseURL returns the server root ("" before Start).
func (s *Server) BaseURL() string {
if s.srv == nil {
return ""
}
return s.srv.URL
}
// LastAccessToken returns the most recently minted access token (for
// redaction assertions: this exact string must never appear in output).
func (s *Server) LastAccessToken() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastAccess
}
// LastRefreshToken returns the most recently issued refresh token.
func (s *Server) LastRefreshToken() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastRefresh
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/identity/connect/token":
s.handleToken(w, r)
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/"):
s.handleAPI(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
}
}
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
grant := r.PostFormValue("grant_type")
if grant == "refresh_token" {
s.RefreshCalls++
} else {
s.TokenCalls++
}
if s.AuthStatusOverride != 0 {
writeJSON(w, s.AuthStatusOverride, map[string]string{"message": "boom"})
return
}
if s.MalformedTokenBody {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{this-is-not-json`)
return
}
if s.RejectAuth {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
return
}
switch grant {
case "client_credentials":
if r.PostFormValue("client_id") != s.ClientID || r.PostFormValue("client_secret") != s.ClientSecret || r.PostFormValue("scope") != "api.secrets" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
return
}
body := s.mintAccessToken()
if !s.DropEncryptedPayload && s.OrgKey != nil {
body["encrypted_payload"] = s.encryptPayload()
}
writeJSON(w, http.StatusOK, body)
case "refresh_token":
if s.RejectRefresh || r.PostFormValue("refresh_token") == "" || r.PostFormValue("client_id") != s.ClientID {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_refresh_token"})
return
}
writeJSON(w, http.StatusOK, s.mintAccessToken())
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported_grant_type"})
}
}
// mintAccessToken issues and records a fresh access/refresh pair and
// returns the response body (without encrypted_payload; the caller adds
// it for client_credentials logins only, mirroring the real server).
func (s *Server) mintAccessToken() map[string]any {
claims := map[string]any{
"sub": s.ClientID,
"organization": s.OrgID,
"scope": []string{"api.secrets"},
"exp": time.Now().Add(s.TokenTTL).Unix(),
"nbf": time.Now().Add(-time.Minute).Unix(),
}
payload, _ := json.Marshal(claims)
access := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) +
"." + base64.RawURLEncoding.EncodeToString(payload) +
"." + base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
refreshBytes := make([]byte, 16)
_, _ = rand.Read(refreshBytes)
refresh := "rt-" + hex.EncodeToString(refreshBytes)
s.issued[access] = time.Now().Add(s.TokenTTL)
s.lastAccess = access
s.lastRefresh = refresh
return map[string]any{
"access_token": access,
"expires_in": int64(s.TokenTTL.Seconds()),
"refresh_token": refresh,
"token_type": "Bearer",
"scope": "api.secrets",
}
}
// encryptPayload seals the organization key for this credential exactly
// as the real server does: HKDF-SHA256 over the credential's 16-byte key
// (salt "bitwarden-accesstoken", info "sm-access-token"), then a type-2
// EncString of {"encryptionKey": "<b64 org key>"}.
func (s *Server) encryptPayload() string {
okm, err := hkdf.Key(sha256.New, s.TokenKey, []byte("bitwarden-accesstoken"), "sm-access-token", 64)
if err != nil {
panic("fakesm: hkdf: " + err.Error())
}
key, err := bw.NewSymmetricKey(okm)
if err != nil {
panic("fakesm: key: " + err.Error())
}
payload, _ := json.Marshal(map[string]string{"encryptionKey": base64.StdEncoding.EncodeToString(s.OrgKey)})
enc, err := bw.Encrypt(key, payload)
if err != nil {
panic("fakesm: encrypt: " + err.Error())
}
return enc.String()
}
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
s.APICalls++
if s.RejectBearer || !s.bearerValidLocked(r) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_token"})
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/")
switch {
case path == "accounts/"+s.ClientID+"/secrets":
if s.MalformedListBody {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"data": [broken`)
return
}
list := make([]map[string]any, 0, len(s.Secrets))
for _, sec := range s.Secrets {
item := map[string]any{
"object": "secret",
"id": sec.ID,
"organizationId": s.OrgID,
"key": s.seal(sec.Name),
"note": s.seal(sec.Note),
"creationDate": "2026-08-28T00:00:00Z",
"revisionDate": "2026-08-28T00:00:00Z",
"read": true,
"write": false,
}
if !s.OmitValueInList {
item["value"] = s.sealValue(sec.Value)
}
list = append(list, item)
}
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
case strings.HasPrefix(path, "secrets/"):
id := strings.TrimPrefix(path, "secrets/")
for _, sec := range s.Secrets {
if sec.ID == id {
writeJSON(w, http.StatusOK, map[string]any{
"object": "secretDetails",
"id": sec.ID,
"organizationId": s.OrgID,
"key": s.seal(sec.Name),
"value": s.sealValue(sec.Value),
"note": s.seal(sec.Note),
"creationDate": "2026-08-28T00:00:00Z",
"revisionDate": "2026-08-28T00:00:00Z",
"read": true,
"write": false,
})
return
}
}
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
case path == "accounts/"+s.ClientID+"/projects":
list := make([]map[string]any, 0, len(s.Projects))
for _, p := range s.Projects {
list = append(list, map[string]any{
"object": "project",
"id": p.ID,
"organizationId": s.OrgID,
"name": s.seal(p.Name),
"creationDate": "2026-08-28T00:00:00Z",
"revisionDate": "2026-08-28T00:00:00Z",
"read": true,
"write": false,
})
}
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
default:
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
}
}
func (s *Server) bearerValidLocked(r *http.Request) bool {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
return false
}
exp, ok := s.issued[strings.TrimPrefix(auth, prefix)]
return ok && exp.After(time.Now())
}
// seal encrypts v with the organization key (nil org key = plaintext
// server mode).
func (s *Server) seal(v string) string {
if s.OrgKey == nil {
return v
}
key, err := bw.NewSymmetricKey(s.OrgKey)
if err != nil {
panic("fakesm: org key: " + err.Error())
}
enc, err := bw.Encrypt(key, []byte(v))
if err != nil {
panic("fakesm: encrypt: " + err.Error())
}
return enc.String()
}
// sealValue additionally supports MAC tampering for integrity tests.
func (s *Server) sealValue(v string) string {
out := s.seal(v)
if !s.TamperSecretMAC || s.OrgKey == nil {
return out
}
parts := strings.Split(out, ".")
ct, err := base64.StdEncoding.DecodeString(parts[2])
if err != nil {
return out
}
ct[0] ^= 0xFF
parts[2] = base64.StdEncoding.EncodeToString(ct)
return strings.Join(parts, ".")
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+151
View File
@@ -0,0 +1,151 @@
package bitwarden
import (
"context"
"fmt"
"sync"
"time"
)
// refreshSkew is how long before expiry a token refreshes itself: read
// calls made inside the skew window refresh first so a token never dies
// mid-use. Tokens issued with a TTL shorter than the skew refresh on
// every call (fake-server tests rely on this).
const refreshSkew = 30 * time.Second
// Token is an authenticated Secrets Manager session. It lives in memory
// only, is never persisted by this package, and its String() form is safe
// for logs (it renders expiry and ids, never the token value).
type Token struct {
AccessToken string
RefreshToken string
TokenType string
Scope string
ExpiresAt time.Time
// AccountID is the machine account's uuid (the credential's client
// id); Organization is the JWT's organization claim when present.
AccountID string
Organization string
baseURL string
clientID string
payloadKey *SymmetricKey // credential-derived; unwraps encrypted_payload
orgKey *SymmetricKey // decrypted organization key; decrypts secrets
httpc httpClient
now func() time.Time
mu sync.Mutex
}
// Expired reports whether the access token is past (or within the refresh
// skew of) expiry.
func (t *Token) Expired() bool {
if t == nil {
return true
}
return t.nowT().Add(refreshSkew).After(t.ExpiresAt)
}
// String renders a log-safe summary. It deliberately has no way to
// produce the access token, refresh token, or any key material.
func (t *Token) String() string {
if t == nil {
return "bitwarden token (none)"
}
org := t.Organization
if len(org) >= 8 {
org = org[:8] + "..."
}
return fmt.Sprintf("bitwarden token (type %s, expires %s, org %s)", t.TokenType, t.ExpiresAt.UTC().Format(time.RFC3339), org)
}
// Zero wipes key material and clears token strings. Call when the session
// is no longer needed.
func (t *Token) Zero() {
if t == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.AccessToken = ""
t.RefreshToken = ""
t.payloadKey.Zero()
t.payloadKey = nil
t.orgKey.Zero()
t.orgKey = nil
}
// Refresh exchanges the refresh token for a fresh access token in place.
// Key material is retained (the organization key does not rotate on
// refresh).
func (t *Token) Refresh(ctx context.Context) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.RefreshToken == "" {
return fmt.Errorf("%w: no refresh token held", ErrTokenExpired)
}
return t.refreshLocked(ctx)
}
// require is the guard for read calls: token present, not expired.
func (t *Token) require(ctx context.Context) error {
return t.ensureFresh(ctx)
}
// ensureFresh refreshes before expiry when a refresh token is held.
// Without one, an expired token is a hard error telling the caller to
// re-authenticate.
func (t *Token) ensureFresh(ctx context.Context) error {
if t == nil {
return fmt.Errorf("bitwarden: no token")
}
t.mu.Lock()
defer t.mu.Unlock()
if t.nowT().Add(refreshSkew).After(t.ExpiresAt) {
if t.RefreshToken == "" {
return fmt.Errorf("%w: re-authenticate", ErrTokenExpired)
}
return t.refreshLocked(ctx)
}
return nil
}
func (t *Token) refreshLocked(ctx context.Context) error {
form := "grant_type=refresh_token&refresh_token=" + urlEscape(t.RefreshToken) + "&client_id=" + urlEscape(t.clientID)
var body tokenResponseBody
if err := t.httpc.postForm(ctx, t.baseURL+"/identity/connect/token", form, &body); err != nil {
return err
}
if body.AccessToken == "" || body.ExpiresIn == 0 {
return fmt.Errorf("%w: refresh response missing token", ErrMalformedResponse)
}
t.applyTokenBody(&body)
return nil
}
func (t *Token) nowT() time.Time {
if t.now != nil {
return t.now()
}
return time.Now()
}
// decryptField resolves one wire field: EncString-shaped values are
// decrypted with the organization key (failing closed), anything else is
// passed through as plaintext so plaintext-mode test servers and
// plaintext gateways work without weakening the encrypted path. label
// names the field in errors (names only, never contents).
func (t *Token) decryptField(label, field string) (string, error) {
if field == "" {
return "", nil
}
if enc, ok := ParseEncString(field); ok {
pt, err := enc.Decrypt(t.orgKey)
if err != nil {
return "", fmt.Errorf("%w: %s", err, label)
}
return string(pt), nil
}
return field, nil
}