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.
264 lines
8.0 KiB
Go
264 lines
8.0 KiB
Go
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
|
|
}
|