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.
This commit is contained in:
2026-08-29 00:08:22 -05:00
parent c8bae4961a
commit fd0f22ca2e
3 changed files with 513 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
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
}