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 }