package bitwarden import ( "crypto/hkdf" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "strings" ) // Machine-account credentials as printed by Bitwarden Secrets Manager are a // single string: // // 0..: // // The uuid becomes the OAuth client_id, the client-secret the // client_secret, and the 16-byte key seeds the HKDF derivation that // unwraps the encrypted payload delivered with the access token (which in // turn carries the organization key that decrypts secrets). Credential // material is never logged and never appears in error strings; a bad // credential yields only "malformed" without echoing the input. // parseAccessToken splits a machine credential into its parts and derives // the payload-unwrapping key (HKDF-SHA256, salt "bitwarden-accesstoken", // info "sm-access-token", 64 bytes = AES||HMAC). func parseAccessToken(s string) (clientID, clientSecret string, key *SymmetricKey, err error) { head, keyPart, ok := strings.Cut(s, ":") if !ok { return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials) } parts := strings.Split(head, ".") if len(parts) != 3 || parts[0] != "0" { return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials) } clientID, clientSecret = parts[1], parts[2] if clientID == "" || clientSecret == "" { return "", "", nil, fmt.Errorf("%w: credential is not a v0 access token", ErrInvalidCredentials) } raw, err := b64Decode(keyPart) if err != nil || len(raw) != 16 { return "", "", nil, fmt.Errorf("%w: credential key part is malformed", ErrInvalidCredentials) } key, err = deriveShareableKey(raw, "accesstoken", "sm-access-token") if err != nil { return "", "", nil, fmt.Errorf("%w: credential key part is malformed", ErrInvalidCredentials) } return clientID, clientSecret, key, nil } // deriveShareableKey mirrors Bitwarden's shareable-key derivation: // HKDF-SHA256 with salt "bitwarden-" and info (optional), producing // a 64-byte AES||HMAC key. func deriveShareableKey(secret []byte, name, info string) (*SymmetricKey, error) { okm, err := hkdf.Key(sha256.New, secret, []byte("bitwarden-"+name), info, 64) if err != nil { return nil, err } return NewSymmetricKey(okm) } // jwtClaims is the set of claims this client reads from access-token JWTs // (exp, subject, organization). Signatures are not verified locally, the // same trust posture as the official clients: the server endpoint is // reached over TLS and the claims are used only for expiry bookkeeping. type jwtClaims struct { Exp int64 `json:"exp"` Sub string `json:"sub"` Organization string `json:"organization"` } // parseJWTClaims base64-decodes the payload segment of a JWT. It returns // ok=false for non-JWT tokens (never an error with embedded content). func parseJWTClaims(token string) (jwtClaims, bool) { parts := strings.Split(token, ".") if len(parts) != 3 { return jwtClaims{}, false } raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) if err != nil { return jwtClaims{}, false } var c jwtClaims if err := json.Unmarshal(raw, &c); err != nil { return jwtClaims{}, false } return c, true }