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.
215 lines
6.6 KiB
Go
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")
|
|
}
|
|
}
|