From c94c0b1de495ee2bbb7e28d76df50ab30ebf42aa Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 00:08:31 -0500 Subject: [PATCH] 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. --- api.go | 263 ++++++++++++++++++++++ bitwarden.go | 215 ++++++++++++++++++ bitwarden_test.go | 448 ++++++++++++++++++++++++++++++++++++++ internal/fakesm/fakesm.go | 398 +++++++++++++++++++++++++++++++++ token.go | 151 +++++++++++++ 5 files changed, 1475 insertions(+) create mode 100644 api.go create mode 100644 bitwarden.go create mode 100644 bitwarden_test.go create mode 100644 internal/fakesm/fakesm.go create mode 100644 token.go diff --git a/api.go b/api.go new file mode 100644 index 0000000..11c465d --- /dev/null +++ b/api.go @@ -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 +} diff --git a/bitwarden.go b/bitwarden.go new file mode 100644 index 0000000..fd25171 --- /dev/null +++ b/bitwarden.go @@ -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..:" 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 +} diff --git a/bitwarden_test.go b/bitwarden_test.go new file mode 100644 index 0000000..8c301a4 --- /dev/null +++ b/bitwarden_test.go @@ -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..:"} + 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") +} diff --git a/internal/fakesm/fakesm.go b/internal/fakesm/fakesm.go new file mode 100644 index 0000000..dc50a4d --- /dev/null +++ b/internal/fakesm/fakesm.go @@ -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": ""}. +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) +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..e488031 --- /dev/null +++ b/token.go @@ -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 +}