From 2b4340d8b06f1bcb73bb95352b0f9fb07aedd8ef Mon Sep 17 00:00:00 2001 From: Thijs Herman <64640608+Turbootzz@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:08:03 +0100 Subject: [PATCH] feat: add organization vault support (#3) * feat: add organization vault support - Add RSA-OAEP decryption (type 3: SHA-256, type 4: SHA-1) - Decrypt user's RSA private key from sync response (PKCS8 DER) - Decrypt organization keys using user's RSA private key - Route cipher decryption to correct key based on organizationId - Personal vault items continue to work unchanged Adds 11 new unit tests covering: - RSA cipher string parsing (type 3/4) - RSA-OAEP round-trip (SHA-1 and SHA-256) - Private key decrypt/parse round-trip - Org key decrypt + encKey/macKey split - Cipher decryption with org vs personal key - Backward compatibility for personal items Also adds CI/CD pipeline (.github/workflows/ci.yml): - Tests + build on PRs and main pushes - Docker build + push to GHCR on main * fix: split concatenated .gitignore entries * chore: clean up gitignore --- .github/workflows/ci.yml | 45 ++++ .gitignore | 2 +- internal/vaultwarden/api_client.go | 63 ++++- internal/vaultwarden/crypto.go | 84 +++++- internal/vaultwarden/crypto_test.go | 405 ++++++++++++++++++++++++++++ 5 files changed, 586 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2cc073d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... -v -count=1 + + - name: Build + run: go build ./... + + docker: + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ghcr.io/turbootzz/vaultwarden-api:latest diff --git a/.gitignore b/.gitignore index d069697..d5c9503 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,4 @@ docker-compose.override.yml # Documentation artifacts CLAUDE.md docs/ -settings.local.json \ No newline at end of file +settings.local.json diff --git a/internal/vaultwarden/api_client.go b/internal/vaultwarden/api_client.go index eb0ff82..db70286 100644 --- a/internal/vaultwarden/api_client.go +++ b/internal/vaultwarden/api_client.go @@ -40,20 +40,30 @@ type SyncResponse struct { // SyncProfile contains user profile info. type SyncProfile struct { - ID string `json:"id"` - Email string `json:"email"` - Key string `json:"key"` + ID string `json:"id"` + Email string `json:"email"` + Key string `json:"key"` + PrivateKey string `json:"privateKey"` + Organizations []SyncOrganization `json:"organizations"` +} + +// SyncOrganization represents an organization the user belongs to. +type SyncOrganization struct { + ID string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` } // SyncCipher represents an encrypted vault item from the sync response. type SyncCipher struct { - ID string `json:"id"` - Type int `json:"type"` - Name string `json:"name"` - Notes *string `json:"notes"` - Login *SyncLogin `json:"login"` - Card *SyncCard `json:"card"` - Fields []SyncField `json:"fields"` + ID string `json:"id"` + Type int `json:"type"` + OrganizationID *string `json:"organizationId"` + Name string `json:"name"` + Notes *string `json:"notes"` + Login *SyncLogin `json:"login"` + Card *SyncCard `json:"card"` + Fields []SyncField `json:"fields"` } // SyncLogin contains encrypted login data. @@ -304,10 +314,41 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) { return nil, fmt.Errorf("decode sync response: %w", err) } + // Decrypt org keys if organizations are present. + orgKeys := make(map[string]SymmetricKey) + if len(syncResp.Profile.Organizations) > 0 && syncResp.Profile.PrivateKey != "" { + privateKey, err := DecryptPrivateKey(syncResp.Profile.PrivateKey, key) + if err != nil { + logger.Warn.Printf("Failed to decrypt RSA private key, org items will be skipped: %v", err) + } else { + for _, org := range syncResp.Profile.Organizations { + orgKey, err := DecryptOrgKey(org.Key, privateKey) + if err != nil { + logger.Warn.Printf("Failed to decrypt org key for %s: %v", org.ID, err) + continue + } + orgKeys[org.ID] = orgKey + logger.Debug.Printf("Decrypted org key for organization %s", org.ID) + } + logger.Info.Printf("Decrypted %d organization key(s)", len(orgKeys)) + } + } + // Decrypt all ciphers. items := make([]DecryptedItem, 0, len(syncResp.Ciphers)) for _, c := range syncResp.Ciphers { - item, err := decryptCipher(c, key) + // Select the correct decryption key. + decryptKey := key + if c.OrganizationID != nil && *c.OrganizationID != "" { + if orgKey, ok := orgKeys[*c.OrganizationID]; ok { + decryptKey = orgKey + } else { + logger.Debug.Printf("No org key for cipher %s (org %s), skipping", c.ID, *c.OrganizationID) + continue + } + } + + item, err := decryptCipher(c, decryptKey) if err != nil { logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err) continue diff --git a/internal/vaultwarden/crypto.go b/internal/vaultwarden/crypto.go index d9ecff9..b4cc04e 100644 --- a/internal/vaultwarden/crypto.go +++ b/internal/vaultwarden/crypto.go @@ -6,7 +6,10 @@ import ( "crypto/aes" "crypto/cipher" "crypto/hmac" + "crypto/rsa" + "crypto/sha1" "crypto/sha256" + "crypto/x509" "encoding/base64" "errors" "fmt" @@ -41,7 +44,8 @@ type SymmetricKey struct { } // CipherString represents an encrypted Bitwarden value. -// Format: ".||" +// Format for AES types: ".||" +// Format for RSA types: "." type CipherString struct { Type int IV []byte @@ -94,6 +98,13 @@ func ParseCipherString(s string) (*CipherString, error) { return nil, fmt.Errorf("invalid MAC: %w", err) } + case EncTypeRsa2048_OaepSha256_B64, EncTypeRsa2048_OaepSha1_B64: + // RSA types: single base64-encoded ciphertext, no IV or MAC. + raw := strings.Join(pieces, "|") // rejoin in case base64 contained no pipes + if cs.CT, err = base64.StdEncoding.DecodeString(raw); err != nil { + return nil, fmt.Errorf("invalid RSA ciphertext: %w", err) + } + default: return nil, fmt.Errorf("unsupported encryption type: %d", encType) } @@ -251,6 +262,77 @@ func DecryptSymmetricKey(encryptedKey string, masterKey []byte) (SymmetricKey, e }, nil } +// DecryptRSA decrypts a CipherString using an RSA private key (OAEP). +// Supports type 3 (SHA-256) and type 4 (SHA-1). +func (cs *CipherString) DecryptRSA(privateKey *rsa.PrivateKey) ([]byte, error) { + switch cs.Type { + case EncTypeRsa2048_OaepSha256_B64: + return rsa.DecryptOAEP(sha256.New(), nil, privateKey, cs.CT, nil) + case EncTypeRsa2048_OaepSha1_B64: + return rsa.DecryptOAEP(sha1.New(), nil, privateKey, cs.CT, nil) + default: + return nil, fmt.Errorf("not an RSA cipher type: %d", cs.Type) + } +} + +// DecryptPrivateKey decrypts the user's encrypted RSA private key from the sync response. +// The private key is AES-CBC encrypted with the user's symmetric key. +// When decrypted, it's a PKCS8 DER-encoded RSA private key. +func DecryptPrivateKey(encryptedPrivateKey string, symKey SymmetricKey) (*rsa.PrivateKey, error) { + if encryptedPrivateKey == "" { + return nil, errors.New("encrypted private key is empty") + } + + cs, err := ParseCipherString(encryptedPrivateKey) + if err != nil { + return nil, fmt.Errorf("parse private key cipher string: %w", err) + } + + derBytes, err := cs.Decrypt(symKey) + if err != nil { + return nil, fmt.Errorf("decrypt private key: %w", err) + } + + parsed, err := x509.ParsePKCS8PrivateKey(derBytes) + if err != nil { + return nil, fmt.Errorf("parse PKCS8 private key: %w", err) + } + + rsaKey, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + + return rsaKey, nil +} + +// DecryptOrgKey decrypts an organization's symmetric key using the user's RSA private key. +// The org key is RSA-OAEP encrypted. When decrypted, it's 64 bytes: encKey(32) + macKey(32). +func DecryptOrgKey(encryptedOrgKey string, privateKey *rsa.PrivateKey) (SymmetricKey, error) { + if encryptedOrgKey == "" { + return SymmetricKey{}, errors.New("encrypted org key is empty") + } + + cs, err := ParseCipherString(encryptedOrgKey) + if err != nil { + return SymmetricKey{}, fmt.Errorf("parse org key cipher string: %w", err) + } + + decrypted, err := cs.DecryptRSA(privateKey) + if err != nil { + return SymmetricKey{}, fmt.Errorf("RSA decrypt org key: %w", err) + } + + if len(decrypted) != 64 { + return SymmetricKey{}, fmt.Errorf("unexpected org key length: %d (expected 64)", len(decrypted)) + } + + return SymmetricKey{ + EncKey: decrypted[:32], + MacKey: decrypted[32:], + }, nil +} + // pkcs7Unpad removes PKCS#7 padding. func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { if len(data) == 0 { diff --git a/internal/vaultwarden/crypto_test.go b/internal/vaultwarden/crypto_test.go index 13e9797..8cef80e 100644 --- a/internal/vaultwarden/crypto_test.go +++ b/internal/vaultwarden/crypto_test.go @@ -4,8 +4,13 @@ import ( "crypto/aes" "crypto/cipher" "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" "crypto/sha256" + "crypto/x509" "encoding/base64" + "fmt" "strings" "testing" @@ -319,3 +324,403 @@ func TestDecryptStr_Empty(t *testing.T) { t.Errorf("expected empty string, got %q", result) } } + +// --- RSA / Organization key tests --- + +func TestParseCipherString_Type3_RSA_SHA256(t *testing.T) { + // Type 3: RSA-2048 OAEP SHA-256. Single base64 ciphertext, no IV or MAC. + ct := make([]byte, 256) // RSA-2048 ciphertext is 256 bytes + for i := range ct { + ct[i] = byte(i) + } + s := "3." + base64.StdEncoding.EncodeToString(ct) + + cs, err := ParseCipherString(s) + if err != nil { + t.Fatalf("ParseCipherString type 3 failed: %v", err) + } + if cs.Type != EncTypeRsa2048_OaepSha256_B64 { + t.Errorf("expected type %d, got %d", EncTypeRsa2048_OaepSha256_B64, cs.Type) + } + if len(cs.CT) != 256 { + t.Errorf("expected CT length 256, got %d", len(cs.CT)) + } + if cs.IV != nil { + t.Error("expected nil IV for RSA type") + } + if cs.MAC != nil { + t.Error("expected nil MAC for RSA type") + } +} + +func TestParseCipherString_Type4_RSA_SHA1(t *testing.T) { + ct := make([]byte, 256) + for i := range ct { + ct[i] = byte(i) + } + s := "4." + base64.StdEncoding.EncodeToString(ct) + + cs, err := ParseCipherString(s) + if err != nil { + t.Fatalf("ParseCipherString type 4 failed: %v", err) + } + if cs.Type != EncTypeRsa2048_OaepSha1_B64 { + t.Errorf("expected type %d, got %d", EncTypeRsa2048_OaepSha1_B64, cs.Type) + } + if len(cs.CT) != 256 { + t.Errorf("expected CT length 256, got %d", len(cs.CT)) + } +} + +func TestDecryptRSA_OAEP_SHA1_RoundTrip(t *testing.T) { + // Generate RSA key pair. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + // Encrypt a 64-byte org key with RSA-OAEP SHA-1. + orgKeyPlain := make([]byte, 64) + for i := range orgKeyPlain { + orgKeyPlain[i] = byte(i) + } + + ciphertext, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &privateKey.PublicKey, orgKeyPlain, nil) + if err != nil { + t.Fatalf("RSA encrypt: %v", err) + } + + // Build cipher string type 4. + csStr := "4." + base64.StdEncoding.EncodeToString(ciphertext) + cs, err := ParseCipherString(csStr) + if err != nil { + t.Fatalf("parse: %v", err) + } + + decrypted, err := cs.DecryptRSA(privateKey) + if err != nil { + t.Fatalf("DecryptRSA: %v", err) + } + + if len(decrypted) != 64 { + t.Fatalf("expected 64 bytes, got %d", len(decrypted)) + } + for i := range orgKeyPlain { + if decrypted[i] != orgKeyPlain[i] { + t.Fatalf("mismatch at byte %d", i) + } + } +} + +func TestDecryptRSA_OAEP_SHA256_RoundTrip(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + plaintext := []byte("Hello RSA-OAEP-SHA256!") + ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &privateKey.PublicKey, plaintext, nil) + if err != nil { + t.Fatalf("RSA encrypt: %v", err) + } + + csStr := "3." + base64.StdEncoding.EncodeToString(ciphertext) + cs, err := ParseCipherString(csStr) + if err != nil { + t.Fatalf("parse: %v", err) + } + + decrypted, err := cs.DecryptRSA(privateKey) + if err != nil { + t.Fatalf("DecryptRSA: %v", err) + } + + if string(decrypted) != string(plaintext) { + t.Errorf("got %q, want %q", decrypted, plaintext) + } +} + +func TestDecryptRSA_WrongType(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + + // Type 2 is AES, not RSA. + cs := &CipherString{Type: EncTypeAesCbc256_HmacSha256_B64, CT: []byte("test")} + _, err = cs.DecryptRSA(privateKey) + if err == nil { + t.Error("expected error for non-RSA type") + } +} + +func TestDecryptPrivateKey_RoundTrip(t *testing.T) { + // Generate RSA key pair. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + // Marshal to PKCS8 DER. + derBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatalf("marshal PKCS8: %v", err) + } + + // Encrypt with AES-CBC + HMAC (type 2) using a known symmetric key. + 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) + } + symKey := SymmetricKey{EncKey: encKey, MacKey: macKey} + + // PKCS7 pad the DER bytes. + padLen := aes.BlockSize - (len(derBytes) % aes.BlockSize) + padded := make([]byte, len(derBytes)+padLen) + copy(padded, derBytes) + for i := len(derBytes); i < len(padded); i++ { + padded[i] = byte(padLen) + } + + // Encrypt. + 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. + csStr := fmt.Sprintf("2.%s|%s|%s", + base64.StdEncoding.EncodeToString(iv), + base64.StdEncoding.EncodeToString(ct), + base64.StdEncoding.EncodeToString(macBytes), + ) + + // Decrypt and verify. + decryptedKey, err := DecryptPrivateKey(csStr, symKey) + if err != nil { + t.Fatalf("DecryptPrivateKey: %v", err) + } + + // Verify the decrypted key matches the original. + if decryptedKey.N.Cmp(privateKey.N) != 0 { + t.Error("decrypted private key N mismatch") + } + if decryptedKey.D.Cmp(privateKey.D) != 0 { + t.Error("decrypted private key D mismatch") + } +} + +func TestDecryptPrivateKey_Empty(t *testing.T) { + symKey := SymmetricKey{EncKey: make([]byte, 32), MacKey: make([]byte, 32)} + _, err := DecryptPrivateKey("", symKey) + if err == nil { + t.Error("expected error for empty private key") + } +} + +func TestDecryptOrgKey_RoundTrip(t *testing.T) { + // Generate RSA key pair. + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate RSA key: %v", err) + } + + // Create a 64-byte org key (32 enc + 32 mac). + orgKeyPlain := make([]byte, 64) + for i := 0; i < 32; i++ { + orgKeyPlain[i] = byte(i) // encKey + orgKeyPlain[32+i] = byte(i + 64) // macKey + } + + // Encrypt with RSA-OAEP SHA-1 (type 4). + ciphertext, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, &privateKey.PublicKey, orgKeyPlain, nil) + if err != nil { + t.Fatalf("RSA encrypt: %v", err) + } + + csStr := "4." + base64.StdEncoding.EncodeToString(ciphertext) + + // Decrypt. + orgKey, err := DecryptOrgKey(csStr, privateKey) + if err != nil { + t.Fatalf("DecryptOrgKey: %v", err) + } + + // Verify encKey. + for i := 0; i < 32; i++ { + if orgKey.EncKey[i] != byte(i) { + t.Fatalf("encKey mismatch at byte %d", i) + } + } + + // Verify macKey. + for i := 0; i < 32; i++ { + if orgKey.MacKey[i] != byte(i+64) { + t.Fatalf("macKey mismatch at byte %d", i) + } + } +} + +func TestDecryptOrgKey_Empty(t *testing.T) { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + _, err := DecryptOrgKey("", privateKey) + if err == nil { + t.Error("expected error for empty org key") + } +} + +func TestDecryptOrgKey_WrongLength(t *testing.T) { + privateKey, _ := rsa.GenerateKey(rand.Reader, 2048) + + // Encrypt a 32-byte value (should be 64). + plaintext := make([]byte, 32) + ct, _ := rsa.EncryptOAEP(sha1.New(), rand.Reader, &privateKey.PublicKey, plaintext, nil) + csStr := "4." + base64.StdEncoding.EncodeToString(ct) + + _, err := DecryptOrgKey(csStr, privateKey) + if err == nil { + t.Error("expected error for wrong org key length") + } + if !strings.Contains(err.Error(), "unexpected org key length") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestDecryptCipher_WithOrgKey(t *testing.T) { + // Create two different symmetric keys: personal and org. + personalKey := SymmetricKey{ + EncKey: make([]byte, 32), + MacKey: make([]byte, 32), + } + orgKey := SymmetricKey{ + EncKey: make([]byte, 32), + MacKey: make([]byte, 32), + } + for i := range orgKey.EncKey { + orgKey.EncKey[i] = byte(i + 100) + } + for i := range orgKey.MacKey { + orgKey.MacKey[i] = byte(i + 132) + } + + // Encrypt a cipher name with the org key. + plainName := []byte("ORG_SECRET") + padLen := aes.BlockSize - (len(plainName) % aes.BlockSize) + padded := make([]byte, len(plainName)+padLen) + copy(padded, plainName) + for i := len(plainName); i < len(padded); i++ { + padded[i] = byte(padLen) + } + + iv := make([]byte, 16) + for i := range iv { + iv[i] = byte(i + 50) + } + + block, _ := aes.NewCipher(orgKey.EncKey) + ct := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded) + + mac := hmac.New(sha256.New, orgKey.MacKey) + mac.Write(iv) + mac.Write(ct) + macBytes := mac.Sum(nil) + + encryptedName := fmt.Sprintf("2.%s|%s|%s", + base64.StdEncoding.EncodeToString(iv), + base64.StdEncoding.EncodeToString(ct), + base64.StdEncoding.EncodeToString(macBytes), + ) + + orgID := "org-123" + cipher := SyncCipher{ + ID: "cipher-1", + Type: CipherTypeLogin, + OrganizationID: &orgID, + Name: encryptedName, + } + + // Should fail with personal key (MAC mismatch). + _, err := decryptCipher(cipher, personalKey) + if err == nil { + t.Error("expected error when decrypting org cipher with personal key") + } + + // Should succeed with org key. + item, err := decryptCipher(cipher, orgKey) + if err != nil { + t.Fatalf("decryptCipher with org key failed: %v", err) + } + if item.Name != "ORG_SECRET" { + t.Errorf("got name %q, want %q", item.Name, "ORG_SECRET") + } +} + +func TestDecryptCipher_PersonalItemStillWorks(t *testing.T) { + // Verify backward compatibility: personal items with no orgID decrypt with personal key. + key := SymmetricKey{ + EncKey: make([]byte, 32), + MacKey: make([]byte, 32), + } + for i := range key.EncKey { + key.EncKey[i] = byte(i) + } + for i := range key.MacKey { + key.MacKey[i] = byte(i + 32) + } + + plainName := []byte("PERSONAL_SECRET") + padLen := aes.BlockSize - (len(plainName) % aes.BlockSize) + padded := make([]byte, len(plainName)+padLen) + copy(padded, plainName) + for i := len(plainName); i < len(padded); i++ { + padded[i] = byte(padLen) + } + + iv := make([]byte, 16) + block, _ := aes.NewCipher(key.EncKey) + 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) + + encryptedName := fmt.Sprintf("2.%s|%s|%s", + base64.StdEncoding.EncodeToString(iv), + base64.StdEncoding.EncodeToString(ct), + base64.StdEncoding.EncodeToString(macBytes), + ) + + // No organizationId — personal item. + c := SyncCipher{ + ID: "personal-1", + Type: CipherTypeSecureNote, + Name: encryptedName, + } + + item, err := decryptCipher(c, key) + if err != nil { + t.Fatalf("decryptCipher personal item failed: %v", err) + } + if item.Name != "PERSONAL_SECRET" { + t.Errorf("got name %q, want %q", item.Name, "PERSONAL_SECRET") + } +}