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
+214
View File
@@ -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")
}
}