* Filtering feature, minor logic changes +: Add IP validation (to properly log IP even when trusted proxy is missing) +: Added synchronization of decrypted organization, collection, and folder names (for filtering purposes) +: Changed sync behavior to prevent collapsing multiple logins with the same name +: Added possibility to filter logins based on organization, collection, and folder name/id in GetSecret +: Added support for space character in secret name validation (it is also properly URL-decoded) * Update README * Fixes related to issues reported by CodeRabbit: +: Changed british use of organisations in README +: Reduced line count in parseSecretFilters by including helper function for filter dimensions resolution +: Added comment clarifying absence of existence checking on dimension IDs as opposed to names +: Removed efensive comma split logic on IPs (redundant due to IPValidation setting on Fiber) +: Changed Query validator to align with name validator (mainly removing hard-coded values) +: Added comments to decryptVaultLabel for clarity +: Change empty SyncNameMaps instance with emptySyncNameMaps helper for consistency +: Add lexical sort for lookupIDByName for deterministic result in case of multiple matches * Bumped logging to warn and added entity metadata in log * Unit tests: +: Added unit tests for every newly introduced or changed function +: Rewrote NewClient constructor to accept optional preloaded data (so it can be used in test scenarios) * Changes requested by CodeRabbit
238 lines
7.0 KiB
Go
238 lines
7.0 KiB
Go
package vaultwarden
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"testing"
|
|
)
|
|
|
|
const (
|
|
testOrgID = "11111111-1111-4111-8111-111111111111"
|
|
testOrgID2 = "22222222-2222-4222-8222-222222222222"
|
|
testFolderID = "33333333-3333-4333-8333-333333333333"
|
|
testCollectionID = "44444444-4444-4444-8444-444444444444"
|
|
)
|
|
|
|
// testUserKey generates a test symmetric key for the user.
|
|
func testUserKey() SymmetricKey {
|
|
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)
|
|
}
|
|
return SymmetricKey{EncKey: encKey, MacKey: macKey}
|
|
}
|
|
|
|
// testOrgKey generates a test symmetric key for the organization.
|
|
func testOrgKey() SymmetricKey {
|
|
encKey := make([]byte, 32)
|
|
macKey := make([]byte, 32)
|
|
for i := range encKey {
|
|
encKey[i] = byte(i + 64)
|
|
}
|
|
for i := range macKey {
|
|
macKey[i] = byte(i + 96)
|
|
}
|
|
return SymmetricKey{EncKey: encKey, MacKey: macKey}
|
|
}
|
|
|
|
// encryptType2Cipher builds a Bitwarden type-2 cipher string for unit tests.
|
|
func encryptType2Cipher(plaintext string, key SymmetricKey) (string, error) {
|
|
data := []byte(plaintext)
|
|
padLen := aes.BlockSize - (len(data) % aes.BlockSize)
|
|
padded := make([]byte, len(data)+padLen)
|
|
copy(padded, data)
|
|
for i := len(data); i < len(padded); i++ {
|
|
padded[i] = byte(padLen)
|
|
}
|
|
|
|
iv := make([]byte, 16)
|
|
for i := range iv {
|
|
iv[i] = byte(i + 7)
|
|
}
|
|
|
|
block, err := aes.NewCipher(key.EncKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ct := make([]byte, len(padded))
|
|
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded)
|
|
|
|
mac := hmac.New(sha256.New, key.MacKey)
|
|
mac.Write(iv)
|
|
mac.Write(ct)
|
|
macBytes := mac.Sum(nil)
|
|
|
|
return fmt.Sprintf("2.%s|%s|%s",
|
|
base64.StdEncoding.EncodeToString(iv),
|
|
base64.StdEncoding.EncodeToString(ct),
|
|
base64.StdEncoding.EncodeToString(macBytes),
|
|
), nil
|
|
}
|
|
|
|
// mustEncryptType2Cipher encrypts a plaintext string using the given symmetric key and returns the encrypted string.
|
|
func mustEncryptType2Cipher(t *testing.T, plaintext string, key SymmetricKey) string {
|
|
t.Helper()
|
|
s, err := encryptType2Cipher(plaintext, key)
|
|
if err != nil {
|
|
t.Fatalf("encryptType2Cipher: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestEmptySyncNameMaps(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Test that the function generates empty maps
|
|
m := emptySyncNameMaps()
|
|
if m.Organizations == nil || m.Folders == nil || m.Collections == nil {
|
|
t.Fatal("expected non-nil maps")
|
|
}
|
|
if len(m.Organizations)+len(m.Folders)+len(m.Collections) != 0 {
|
|
t.Fatal("expected empty maps")
|
|
}
|
|
|
|
// Test that the function generates a new object each time it is called
|
|
m.Organizations["x"] = "y"
|
|
m2 := emptySyncNameMaps()
|
|
if _, ok := m2.Organizations["x"]; ok {
|
|
t.Fatal("maps should not be shared between calls")
|
|
}
|
|
}
|
|
|
|
func TestDecryptVaultLabel(t *testing.T) {
|
|
userKey := testUserKey()
|
|
wrongKey := testOrgKey()
|
|
encrypted := mustEncryptType2Cipher(t, "Secret Org", userKey)
|
|
encryptedNope := mustEncryptType2Cipher(t, "Nope", userKey)
|
|
|
|
tests := []struct {
|
|
name string
|
|
raw string
|
|
keys []SymmetricKey
|
|
want string
|
|
}{
|
|
// Check that empty input returns empty string
|
|
{"empty", "", nil, ""},
|
|
// Check that plaintext is passed through as-is
|
|
{"plaintext passthrough", "Plain Org", []SymmetricKey{userKey}, "Plain Org"},
|
|
// Check that encrypted text is decrypted with the given keys
|
|
{"encrypted with user key", encrypted, []SymmetricKey{userKey}, "Secret Org"},
|
|
{"ecnrypted try multiple", encrypted, []SymmetricKey{wrongKey, userKey}, "Secret Org"},
|
|
// Check that wrong key on ciphertext returns empty string
|
|
{"wrong key on ciphertext", encryptedNope, []SymmetricKey{wrongKey}, ""},
|
|
// Check that empty keys only returns empty string
|
|
{"empty keys only", encrypted, []SymmetricKey{{}, {}}, ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := decryptVaultLabel(tt.raw, "organization", testOrgID, tt.keys...)
|
|
if got != tt.want {
|
|
t.Errorf("decryptVaultLabel() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildSyncNameMaps(t *testing.T) {
|
|
userKey := testUserKey()
|
|
orgKey := testOrgKey()
|
|
orgKeys := map[string]SymmetricKey{testOrgID: orgKey}
|
|
|
|
// Create fake sync response containing test orgs, folders, and collections
|
|
syncResp := SyncResponse{
|
|
Profile: SyncProfile{
|
|
Organizations: []SyncOrganization{
|
|
{ID: testOrgID, Name: "Plain Org"},
|
|
{ID: testOrgID2, Name: mustEncryptType2Cipher(t, "Encrypted Org", userKey)},
|
|
},
|
|
},
|
|
Folders: []SyncFolder{
|
|
{ID: testFolderID, Name: mustEncryptType2Cipher(t, "Work", userKey)},
|
|
{ID: "00000000-0000-4000-8000-000000000099", Name: mustEncryptType2Cipher(t, "Bad", testOrgKey())},
|
|
},
|
|
Collections: []SyncCollection{
|
|
{ID: testCollectionID, OrganizationID: testOrgID, Name: mustEncryptType2Cipher(t, "Shared", orgKey)},
|
|
{ID: "55555555-5555-4555-8555-555555555555", OrganizationID: "99999999-9999-4999-8999-999999999999", Name: "orphan"},
|
|
{ID: "", OrganizationID: testOrgID, Name: "skip-empty-id"},
|
|
},
|
|
}
|
|
|
|
maps := buildSyncNameMaps(syncResp, userKey, orgKeys)
|
|
|
|
// Test that the function decrypts the organization names correctly
|
|
if got := maps.Organizations[testOrgID]; got != "Plain Org" {
|
|
t.Errorf("plain org name = %q, want Plain Org", got)
|
|
}
|
|
if got := maps.Organizations[testOrgID2]; got != "Encrypted Org" {
|
|
t.Errorf("encrypted org name = %q, want Encrypted Org", got)
|
|
}
|
|
// Test that the function decrypts the folder names correctly
|
|
if got := maps.Folders[testFolderID]; got != "Work" {
|
|
t.Errorf("folder name = %q, want Work", got)
|
|
}
|
|
// Test that the function does not include folders with wrong decrypt key
|
|
if _, ok := maps.Folders["00000000-0000-4000-8000-000000000099"]; ok {
|
|
t.Error("folder with wrong decrypt key should be omitted")
|
|
}
|
|
// Test that the function decrypts the collection names correctly
|
|
if got := maps.Collections[testCollectionID]; got != "Shared" {
|
|
t.Errorf("collection name = %q, want Shared", got)
|
|
}
|
|
// Test that the function does not include collections with empty organization ID
|
|
if len(maps.Collections) != 1 {
|
|
t.Errorf("collections map len = %d, want 1", len(maps.Collections))
|
|
}
|
|
}
|
|
|
|
func TestLookupIDByName(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// The map to run tests on
|
|
idToName := map[string]string{
|
|
"bbb-id": "Acme",
|
|
"aaa-id": "Acme",
|
|
"ccc-id": "Other",
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
target string
|
|
wantID string
|
|
wantOK bool
|
|
}{
|
|
{"exact match", "Acme", "aaa-id", true},
|
|
{"case insensitive and trimmed", " acme ", "aaa-id", true},
|
|
{"unknown", "Missing", "", false},
|
|
{"empty target", "", "", false},
|
|
{"other name", "other", "ccc-id", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
id, ok := LookupIDByName(idToName, tt.target)
|
|
if ok != tt.wantOK || id != tt.wantID {
|
|
t.Errorf("LookupIDByName(%q) = (%q, %v), want (%q, %v)", tt.target, id, ok, tt.wantID, tt.wantOK)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Test that empty map returns false
|
|
t.Run("empty map", func(t *testing.T) {
|
|
t.Parallel()
|
|
_, ok := LookupIDByName(nil, "Acme")
|
|
if ok {
|
|
t.Error("expected false for empty map")
|
|
}
|
|
})
|
|
}
|