BREAKING: Removes Node.js/Bitwarden CLI dependency entirely. ## What changed - New native HTTP client talks directly to Vaultwarden API - Bitwarden-compatible crypto in pure Go: - AES-256-CBC + HMAC-SHA256 decryption - PBKDF2-SHA256 and Argon2id key derivation - HKDF key stretching (with legacy fallback) - Auto token refresh (never expires) - Background vault sync (configurable interval, default 5min) - Docker image: ~233MB → ~15MB (pure Alpine, no Node.js) ## New env vars - VAULTWARDEN_EMAIL (replaces CLIENT_ID/CLIENT_SECRET) - SYNC_INTERVAL (default: 5m) ## Removed - internal/vaultwarden/client_cli.go (CLI interaction) - Bitwarden CLI dependency (Node.js + npm package) - VAULTWARDEN_CLIENT_ID, VAULTWARDEN_CLIENT_SECRET env vars ## Added - internal/vaultwarden/crypto.go (encryption/decryption) - internal/vaultwarden/crypto_test.go (11 unit tests) - internal/vaultwarden/api_client.go (HTTP client) ## Unchanged - External API surface (GET /secret/:name, POST /refresh, GET /health) - All security middleware (auth, IP whitelist, rate limiting)
322 lines
7.4 KiB
Go
322 lines
7.4 KiB
Go
package vaultwarden
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/pbkdf2"
|
|
)
|
|
|
|
func TestParseCipherString_Type2(t *testing.T) {
|
|
// Build a known cipher string of type 2 (AES-CBC-256 + HMAC-SHA256).
|
|
iv := make([]byte, 16)
|
|
ct := make([]byte, 32) // 2 blocks
|
|
mac := make([]byte, 32)
|
|
for i := range iv {
|
|
iv[i] = byte(i)
|
|
}
|
|
for i := range ct {
|
|
ct[i] = byte(i + 16)
|
|
}
|
|
for i := range mac {
|
|
mac[i] = byte(i + 48)
|
|
}
|
|
|
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
|
base64.StdEncoding.EncodeToString(mac)
|
|
|
|
cs, err := ParseCipherString(s)
|
|
if err != nil {
|
|
t.Fatalf("ParseCipherString failed: %v", err)
|
|
}
|
|
if cs.Type != 2 {
|
|
t.Errorf("expected type 2, got %d", cs.Type)
|
|
}
|
|
if len(cs.IV) != 16 {
|
|
t.Errorf("expected IV length 16, got %d", len(cs.IV))
|
|
}
|
|
if len(cs.CT) != 32 {
|
|
t.Errorf("expected CT length 32, got %d", len(cs.CT))
|
|
}
|
|
if len(cs.MAC) != 32 {
|
|
t.Errorf("expected MAC length 32, got %d", len(cs.MAC))
|
|
}
|
|
}
|
|
|
|
func TestParseCipherString_Type0(t *testing.T) {
|
|
iv := make([]byte, 16)
|
|
ct := make([]byte, 32)
|
|
s := "0." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
|
base64.StdEncoding.EncodeToString(ct)
|
|
|
|
cs, err := ParseCipherString(s)
|
|
if err != nil {
|
|
t.Fatalf("ParseCipherString failed: %v", err)
|
|
}
|
|
if cs.Type != 0 {
|
|
t.Errorf("expected type 0, got %d", cs.Type)
|
|
}
|
|
if cs.MAC != nil {
|
|
t.Error("expected nil MAC for type 0")
|
|
}
|
|
}
|
|
|
|
func TestParseCipherString_Invalid(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"empty", ""},
|
|
{"no type", "abcdef"},
|
|
{"bad type", "x.abc|def|ghi"},
|
|
{"type 2 missing mac", "2.abc|def"},
|
|
{"type 0 extra parts", "0.abc|def|ghi"},
|
|
{"unsupported type", "5.abc|def"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := ParseCipherString(tt.input)
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPKCS7Unpad(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []byte
|
|
want []byte
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "1 byte padding",
|
|
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x01},
|
|
want: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F},
|
|
},
|
|
{
|
|
name: "full block padding",
|
|
input: append(make([]byte, 0), 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x10),
|
|
want: []byte{},
|
|
},
|
|
{
|
|
name: "empty input",
|
|
input: []byte{},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "zero padding byte",
|
|
input: []byte{0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x00},
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := pkcs7Unpad(tt.input, 16)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != len(tt.want) {
|
|
t.Errorf("length mismatch: got %d, want %d", len(got), len(tt.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecrypt_Type2_RoundTrip(t *testing.T) {
|
|
// Create a known key, encrypt some data, then decrypt it.
|
|
encKey := make([]byte, 32)
|
|
macKey := make([]byte, 32)
|
|
for i := range encKey {
|
|
encKey[i] = byte(i)
|
|
}
|
|
for i := range macKey {
|
|
macKey[i] = byte(i + 32)
|
|
}
|
|
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
|
|
|
plaintext := []byte("Hello, Vaultwarden!")
|
|
|
|
// Pad the plaintext with PKCS7.
|
|
padLen := aes.BlockSize - (len(plaintext) % aes.BlockSize)
|
|
padded := make([]byte, len(plaintext)+padLen)
|
|
copy(padded, plaintext)
|
|
for i := len(plaintext); i < len(padded); i++ {
|
|
padded[i] = byte(padLen)
|
|
}
|
|
|
|
// Encrypt with AES-CBC.
|
|
iv := make([]byte, 16)
|
|
for i := range iv {
|
|
iv[i] = byte(i + 100)
|
|
}
|
|
|
|
block, err := aes.NewCipher(encKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ct := make([]byte, len(padded))
|
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
|
|
|
// Compute MAC.
|
|
mac := hmac.New(sha256.New, macKey)
|
|
mac.Write(iv)
|
|
mac.Write(ct)
|
|
macBytes := mac.Sum(nil)
|
|
|
|
// Build cipher string.
|
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
|
base64.StdEncoding.EncodeToString(macBytes)
|
|
|
|
cs, err := ParseCipherString(s)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
|
|
result, err := cs.DecryptToString(key)
|
|
if err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
|
|
if result != "Hello, Vaultwarden!" {
|
|
t.Errorf("got %q, want %q", result, "Hello, Vaultwarden!")
|
|
}
|
|
}
|
|
|
|
func TestDecrypt_MACVerificationFails(t *testing.T) {
|
|
encKey := make([]byte, 32)
|
|
macKey := make([]byte, 32)
|
|
key := SymmetricKey{EncKey: encKey, MacKey: macKey}
|
|
|
|
// Create a valid ciphertext but with a wrong MAC.
|
|
iv := make([]byte, 16)
|
|
ct := make([]byte, 16)
|
|
wrongMAC := make([]byte, 32)
|
|
for i := range wrongMAC {
|
|
wrongMAC[i] = 0xFF
|
|
}
|
|
|
|
s := "2." + base64.StdEncoding.EncodeToString(iv) + "|" +
|
|
base64.StdEncoding.EncodeToString(ct) + "|" +
|
|
base64.StdEncoding.EncodeToString(wrongMAC)
|
|
|
|
cs, err := ParseCipherString(s)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = cs.Decrypt(key)
|
|
if err == nil {
|
|
t.Error("expected MAC verification error")
|
|
}
|
|
if !strings.Contains(err.Error(), "MAC verification failed") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMakeMasterKey_PBKDF2(t *testing.T) {
|
|
key, err := MakeMasterKey("password123", "user@example.com", KdfPBKDF2, 600000, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("MakeMasterKey failed: %v", err)
|
|
}
|
|
if len(key) != 32 {
|
|
t.Errorf("expected 32 bytes, got %d", len(key))
|
|
}
|
|
|
|
// Verify it matches raw PBKDF2.
|
|
expected := pbkdf2.Key([]byte("password123"), []byte("user@example.com"), 600000, 32, sha256.New)
|
|
for i := range key {
|
|
if key[i] != expected[i] {
|
|
t.Fatalf("key mismatch at byte %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMakeMasterKey_Argon2id(t *testing.T) {
|
|
mem := 64
|
|
par := 4
|
|
key, err := MakeMasterKey("password123", "user@example.com", KdfArgon2id, 3, &mem, &par)
|
|
if err != nil {
|
|
t.Fatalf("MakeMasterKey failed: %v", err)
|
|
}
|
|
if len(key) != 32 {
|
|
t.Errorf("expected 32 bytes, got %d", len(key))
|
|
}
|
|
}
|
|
|
|
func TestHashPassword(t *testing.T) {
|
|
masterKey := make([]byte, 32)
|
|
for i := range masterKey {
|
|
masterKey[i] = byte(i)
|
|
}
|
|
|
|
hash := HashPassword("password123", masterKey)
|
|
if hash == "" {
|
|
t.Error("empty hash")
|
|
}
|
|
|
|
// Should be valid base64.
|
|
_, err := base64.StdEncoding.DecodeString(hash)
|
|
if err != nil {
|
|
t.Errorf("hash is not valid base64: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestStretchKey(t *testing.T) {
|
|
masterKey := make([]byte, 32)
|
|
for i := range masterKey {
|
|
masterKey[i] = byte(i)
|
|
}
|
|
|
|
key, err := StretchKey(masterKey)
|
|
if err != nil {
|
|
t.Fatalf("StretchKey failed: %v", err)
|
|
}
|
|
|
|
if len(key.EncKey) != 32 {
|
|
t.Errorf("expected 32-byte enc key, got %d", len(key.EncKey))
|
|
}
|
|
if len(key.MacKey) != 32 {
|
|
t.Errorf("expected 32-byte mac key, got %d", len(key.MacKey))
|
|
}
|
|
|
|
// Enc and Mac keys should be different.
|
|
same := true
|
|
for i := range key.EncKey {
|
|
if key.EncKey[i] != key.MacKey[i] {
|
|
same = false
|
|
break
|
|
}
|
|
}
|
|
if same {
|
|
t.Error("enc and mac keys should differ")
|
|
}
|
|
}
|
|
|
|
func TestDecryptStr_Empty(t *testing.T) {
|
|
key := SymmetricKey{EncKey: make([]byte, 32), MacKey: make([]byte, 32)}
|
|
result, err := DecryptStr("", key)
|
|
if err != nil {
|
|
t.Fatalf("DecryptStr empty should not error: %v", err)
|
|
}
|
|
if result != "" {
|
|
t.Errorf("expected empty string, got %q", result)
|
|
}
|
|
}
|