From fd0f22ca2e6869e6811e9b23fb6c662d93e6fa42 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Sat, 29 Aug 2026 00:08:22 -0500 Subject: [PATCH] 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..:), 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. --- cred.go | 88 +++++++++++++++++++ encstring.go | 211 +++++++++++++++++++++++++++++++++++++++++++++ encstring_test.go | 214 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 513 insertions(+) create mode 100644 cred.go create mode 100644 encstring.go create mode 100644 encstring_test.go diff --git a/cred.go b/cred.go new file mode 100644 index 0000000..7444137 --- /dev/null +++ b/cred.go @@ -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..: +// +// 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 +} diff --git a/encstring.go b/encstring.go new file mode 100644 index 0000000..9c22df4 --- /dev/null +++ b/encstring.go @@ -0,0 +1,211 @@ +package bitwarden + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "strings" +) + +// EncString is the Bitwarden cipher-string wire format: +// +// ..[.] +// +// Type 2 (AesCbc256_HmacSha256_B64) authenticates with HMAC-SHA256 over +// iv||ciphertext under the MAC half of a 64-byte key; type 0 +// (AesCbc256_B64) is unauthenticated AES-256-CBC under a 32-byte key. +// Only the read paths Secrets Manager needs are implemented; anything +// else fails closed with a typed error. Cipher text is never included in +// error messages. +type EncString struct { + Type byte + IV []byte + CT []byte + MAC []byte +} + +// ParseEncString decodes the wire format. ok=false means the value is not +// EncString-shaped at all (callers treat such values as plaintext). +func ParseEncString(s string) (EncString, bool) { + parts := strings.Split(s, ".") + if len(parts) < 3 || len(parts[0]) != 1 { + return EncString{}, false + } + t := parts[0][0] + switch t { + case '0', '2': + default: + return EncString{}, false + } + iv, err := b64Decode(parts[1]) + if err != nil || len(iv) != 16 { + return EncString{}, false + } + ct, err := b64Decode(parts[2]) + if err != nil || len(ct) == 0 || len(ct)%aes.BlockSize != 0 { + return EncString{}, false + } + e := EncString{Type: t, IV: iv, CT: ct} + if t == '2' { + if len(parts) != 4 { + return EncString{}, false + } + mac, err := b64Decode(parts[3]) + if err != nil || len(mac) != sha256.Size { + return EncString{}, false + } + e.MAC = mac + } else if len(parts) != 3 { + return EncString{}, false + } + return e, true +} + +// String re-encodes as the wire format (used by the fake server in tests). +func (e EncString) String() string { + var b strings.Builder + fmt.Fprintf(&b, "%c.%s.%s", e.Type, base64.StdEncoding.EncodeToString(e.IV), base64.StdEncoding.EncodeToString(e.CT)) + if len(e.MAC) > 0 { + b.WriteString("." + base64.StdEncoding.EncodeToString(e.MAC)) + } + return b.String() +} + +// Decrypt unwraps e under key. It verifies the MAC for type-2 strings +// BEFORE decrypting and fails closed on any integrity error. +func (e EncString) Decrypt(key *SymmetricKey) ([]byte, error) { + if key == nil { + return nil, fmt.Errorf("%w: no key available", ErrDecrypt) + } + switch e.Type { + case '2': + if key.MACKey == nil { + return nil, fmt.Errorf("%w: mac key required", ErrDecrypt) + } + mac := hmac.New(sha256.New, key.MACKey) + mac.Write(e.IV) + mac.Write(e.CT) + if !hmac.Equal(mac.Sum(nil), e.MAC) { + return nil, fmt.Errorf("%w: integrity check failed", ErrDecrypt) + } + case '0': + default: + return nil, fmt.Errorf("%w: unsupported type", ErrDecrypt) + } + block, err := aes.NewCipher(key.EncKey) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey) + } + pt := make([]byte, len(e.CT)) + cipher.NewCBCDecrypter(block, e.IV).CryptBlocks(pt, e.CT) + out, err := pkcs7Unpad(pt) + if err != nil { + return nil, fmt.Errorf("%w: bad padding", ErrDecrypt) + } + return out, nil +} + +// Encrypt seals pt under key with a fresh random IV (used by the fake +// server in tests to produce real Bitwarden-shaped payloads). +func Encrypt(key *SymmetricKey, pt []byte) (EncString, error) { + if key == nil || len(key.EncKey) != 32 { + return EncString{}, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey) + } + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return EncString{}, fmt.Errorf("%w: entropy source", ErrDecrypt) + } + padded := pkcs7Pad(pt) + ct := make([]byte, len(padded)) + block, err := aes.NewCipher(key.EncKey) + if err != nil { + return EncString{}, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey) + } + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded) + e := EncString{Type: '0', IV: iv, CT: ct} + if key.MACKey != nil { + mac := hmac.New(sha256.New, key.MACKey) + mac.Write(iv) + mac.Write(ct) + e.Type = '2' + e.MAC = mac.Sum(nil) + } + return e, nil +} + +// SymmetricKey is a Bitwarden symmetric key: a 32-byte AES key, or a +// 64-byte AES||HMAC key. Keys are held as bytes (not strings) so callers +// can zero them; they are never stringified or logged. +type SymmetricKey struct { + EncKey []byte + MACKey []byte +} + +// NewSymmetricKey builds a key from raw bytes: 64 bytes -> AES+HMAC, +// 32 bytes -> AES only (Bitwarden legacy org-key lengths). +func NewSymmetricKey(raw []byte) (*SymmetricKey, error) { + switch len(raw) { + case 64: + return &SymmetricKey{EncKey: raw[:32], MACKey: raw[32:]}, nil + case 32: + return &SymmetricKey{EncKey: raw}, nil + default: + return nil, fmt.Errorf("%w: %s", ErrDecrypt, errBadKey) + } +} + +// Zero wipes the key material in place. +func (k *SymmetricKey) Zero() { + if k == nil { + return + } + for i := range k.EncKey { + k.EncKey[i] = 0 + } + for i := range k.MACKey { + k.MACKey[i] = 0 + } +} + +// b64Decode accepts standard base64 with or without padding (the Bitwarden +// servers emit padded values; their own parsers accept both). +func b64Decode(s string) ([]byte, error) { + if b, err := base64.StdEncoding.DecodeString(s); err == nil { + return b, nil + } + return base64.RawStdEncoding.DecodeString(s) +} + +const errBadKey = "invalid key length" + +func pkcs7Pad(pt []byte) []byte { + n := aes.BlockSize - len(pt)%aes.BlockSize + out := make([]byte, len(pt)+n) + copy(out, pt) + for i := len(pt); i < len(out); i++ { + out[i] = byte(n) + } + return out +} + +func pkcs7Unpad(pt []byte) ([]byte, error) { + if len(pt) == 0 || len(pt)%aes.BlockSize != 0 { + return nil, errors.New("not block aligned") + } + n := int(pt[len(pt)-1]) + if n == 0 || n > aes.BlockSize || n > len(pt) { + return nil, errors.New("invalid padding") + } + for _, c := range pt[len(pt)-n:] { + if int(c) != n { + return nil, errors.New("invalid padding") + } + } + return pt[:len(pt)-n], nil +} diff --git a/encstring_test.go b/encstring_test.go new file mode 100644 index 0000000..a1aace4 --- /dev/null +++ b/encstring_test.go @@ -0,0 +1,214 @@ +package bitwarden + +import ( + "bytes" + "encoding/base64" + "strings" + "testing" +) + +// Vectors below are the published test vectors from the Bitwarden SDK +// (bitwarden/sdk-internal, crates bitwarden-crypto): they pin this +// stdlib-only implementation to the exact wire construction the official +// clients use. + +func TestDeriveShareableKeyVectors(t *testing.T) { + cases := []struct { + name string + secret string // raw bytes + salt string // name argument (salt becomes "bitwarden-"+name) + info string + want string // base64 of the 64-byte key + }{ + { + name: "no info", + secret: "&/$%F1a895g67HlX", + salt: "test_key", + info: "", + want: "4PV6+PcmF2w7YHRatvyMcVQtI7zvCyssv/wFWmzjiH6Iv9altjmDkuBD1aagLVaLezbthbSe+ktR+U6qswxNnQ==", + }, + { + name: "with info", + secret: "67t9b5g67$%Dh89n", + salt: "test_key", + info: "test", + want: "F9jVQmrACGx9VUPjuzfMYDjr726JtL300Y3Yg+VYUnVQtQ1s8oImJ5xtp1KALC9h2nav04++1LDW4iFD+infng==", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + key, err := deriveShareableKey([]byte(tc.secret), tc.salt, tc.info) + if err != nil { + t.Fatalf("derive: %v", err) + } + got := base64.StdEncoding.EncodeToString(append(key.EncKey, key.MACKey...)) + if got != tc.want { + t.Fatalf("derived key mismatch:\n got %s\nwant %s", got, tc.want) + } + }) + } +} + +func TestParseAccessTokenVector(t *testing.T) { + // Published vector: the credential format and the exact derived + // payload-unwrapping key. + const cred = "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==" + id, secret, key, err := parseAccessToken(cred) + if err != nil { + t.Fatalf("parse: %v", err) + } + if id != "ec2c1d46-6a4b-4751-a310-af9601317f2d" { + t.Fatalf("client id: %s", id) + } + if secret != "C2IgxjjLF7qSshsbwe8JGcbM075YXw" { + t.Fatalf("client secret: %s", secret) + } + got := base64.StdEncoding.EncodeToString(append(key.EncKey, key.MACKey...)) + const want = "H9/oIRLtL9nGCQOVDjSMoEbJsjWXSOCb3qeyDt6ckzS3FhyboEDWyTP/CQfbIszNmAVg2ExFganG1FVFGXO/Jg==" + if got != want { + t.Fatalf("derived key mismatch:\n got %s\nwant %s", got, want) + } +} + +func TestParseAccessTokenMalformed(t *testing.T) { + bad := []string{ + "", + "nonsense", + "1.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // wrong version + "ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // no version part + "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw", // no key part + "0..C2IgxjjLF7qSshsbwe8JGcbM075YXw:X8vbvA0bduihIDe/qrzIQQ==", // empty id + "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:c2hvcnQ=", // key not 16 bytes + "0.ec2c1d46-6a4b-4751-a310-af9601317f2d.C2IgxjjLF7qSshsbwe8JGcbM075YXw:!!!not-base64!!!", // key not base64 + } + for _, s := range bad { + _, _, _, err := parseAccessToken(s) + if err == nil { + t.Fatalf("expected error for %q", s) + } + if s != "" && strings.Contains(err.Error(), s) { + t.Fatalf("error echoes credential: %v", err) + } + } +} + +func TestLegacyAesCbcHmacVector(t *testing.T) { + // Published SDK vector for the type-2 EncString construction + // (AES-256-CBC + HMAC-SHA256 over iv||ciphertext). + key := make([]byte, 64) + for i := range key { + key[i] = byte(i) + } + sk, err := NewSymmetricKey(key) + if err != nil { + t.Fatalf("key: %v", err) + } + iv := []byte{216, 218, 36, 0, 196, 186, 150, 85, 49, 147, 110, 168, 185, 227, 42, 172} + ct := []byte{ + 234, 77, 16, 15, 189, 82, 36, 188, 182, 88, 64, 67, 145, 94, 30, 178, 36, 235, 130, 67, + 255, 207, 183, 168, 73, 231, 82, 122, 193, 139, 25, 129, + } + mac := []byte{ + 60, 78, 44, 111, 72, 233, 3, 6, 86, 250, 217, 242, 62, 229, 184, 221, 231, 150, 189, 44, + 99, 189, 220, 55, 196, 194, 101, 60, 102, 195, 149, 130, + } + const plaintext = "Bitwarden SDK test vector" + + e := EncString{Type: '2', IV: iv, CT: ct, MAC: mac} + pt, err := e.Decrypt(sk) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + if string(pt) != plaintext { + t.Fatalf("plaintext mismatch: %q", pt) + } +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + raw := make([]byte, 64) + for i := range raw { + raw[i] = byte(i * 7) + } + sk, err := NewSymmetricKey(raw) + if err != nil { + t.Fatalf("key: %v", err) + } + for _, pt := range []string{"", "a", "exactly-16-chars", "the quick brown fox jumps over the lazy dog 1234567890"} { + e, err := Encrypt(sk, []byte(pt)) + if err != nil { + t.Fatalf("encrypt %q: %v", pt, err) + } + got, err := e.Decrypt(sk) + if err != nil { + t.Fatalf("decrypt %q: %v", pt, err) + } + if !bytes.Equal(got, []byte(pt)) { + t.Fatalf("round trip mismatch: %q != %q", got, pt) + } + } +} + +func TestTamperDetection(t *testing.T) { + raw := make([]byte, 64) + for i := range raw { + raw[i] = byte(i) + } + sk, _ := NewSymmetricKey(raw) + e, err := Encrypt(sk, []byte("sensitive value")) + if err != nil { + t.Fatalf("encrypt: %v", err) + } + tampered := e + tampered.CT[0] ^= 0xFF + if _, err := tampered.Decrypt(sk); err == nil { + t.Fatal("tampered ciphertext decrypted: MAC not enforced") + } + badMAC := e + badMAC.MAC[0] ^= 0xFF + if _, err := badMAC.Decrypt(sk); err == nil { + t.Fatal("tampered MAC accepted") + } +} + +func TestParseEncStringShapes(t *testing.T) { + raw := make([]byte, 64) + sk, _ := NewSymmetricKey(raw) + e, _ := Encrypt(sk, []byte("x")) + if _, ok := ParseEncString(e.String()); !ok { + t.Fatal("valid encstring not recognized") + } + notEnc := []string{"", "plaintext", "2.short", "2.aGVsbG8.aGVsbG8", "9.aGVsbG8.x.y.z", "2!!!"} + for _, s := range notEnc { + if _, ok := ParseEncString(s); ok { + t.Fatalf("non-encstring accepted: %q", s) + } + } +} + +func TestNewSymmetricKeyLengths(t *testing.T) { + if _, err := NewSymmetricKey(make([]byte, 32)); err != nil { + t.Fatalf("32-byte key rejected: %v", err) + } + if _, err := NewSymmetricKey(make([]byte, 64)); err != nil { + t.Fatalf("64-byte key rejected: %v", err) + } + if _, err := NewSymmetricKey(make([]byte, 16)); err == nil { + t.Fatal("16-byte key accepted") + } +} + +func TestParseJWTClaims(t *testing.T) { + // header.payload.sig with payload {"sub":"acc","organization":"org-id","exp":1893456000} + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"acc","organization":"3fb1c0de-0000-4000-8000-000000000000","exp":1893456000}`)) + tok := "eyJhbGciOiJIUzI1NiJ9." + payload + ".c2ln" + c, ok := parseJWTClaims(tok) + if !ok { + t.Fatal("claims not parsed") + } + if c.Organization != "3fb1c0de-0000-4000-8000-000000000000" || c.Exp != 1893456000 { + t.Fatalf("claims wrong: %+v", c) + } + if _, ok := parseJWTClaims("not-a-jwt"); ok { + t.Fatal("non-jwt accepted") + } +}