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
+211
View File
@@ -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>.<b64 iv>.<b64 ciphertext>[.<b64 mac>]
//
// 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
}