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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user