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
}