Files
mopac-bitwarden-go/encstring_test.go
T
mrcharles fd0f22ca2e 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.
2026-08-29 00:08:22 -05:00

215 lines
6.6 KiB
Go

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")
}
}