// 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 }