Files
mopac-bitwarden-go/cred.go
T
mrcharles fd0f22ca2e Add the Bitwarden crypto core pinned to published SDK test vectors
EncString parse/decrypt (type 0 and type 2: AES-256-CBC + HMAC-SHA256
over iv||ciphertext, PKCS#7), the machine-credential format
(0.<uuid>.<secret>:<key>), and the HKDF shareable-key derivation that
unwraps the organization key. Stdlib only. Test vectors come from the
public Bitwarden SDK test suite, so the construction matches the official
clients exactly.
2026-08-29 00:08:22 -05:00

89 lines
3.2 KiB
Go

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.<access-token-uuid>.<client-secret>:<base64 16-byte key>
//
// 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-<name>" 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
}