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