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
This commit is contained in:
1 parent
f194e85ec0
commit
2b4340d8b0
5 files changed
+586
-13
No files matched your search
@@ -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
|
||||||
+1
-1
@@ -60,4 +60,4 @@ docker-compose.override.yml
|
|||||||
# Documentation artifacts
|
# Documentation artifacts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs/
|
docs/
|
||||||
settings.local.json
|
settings.local.json
|
||||||
@@ -40,20 +40,30 @@ type SyncResponse struct {
|
|||||||
|
|
||||||
// SyncProfile contains user profile info.
|
// SyncProfile contains user profile info.
|
||||||
type SyncProfile struct {
|
type SyncProfile struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Key string `json:"key"`
|
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.
|
// SyncCipher represents an encrypted vault item from the sync response.
|
||||||
type SyncCipher struct {
|
type SyncCipher struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
Name string `json:"name"`
|
OrganizationID *string `json:"organizationId"`
|
||||||
Notes *string `json:"notes"`
|
Name string `json:"name"`
|
||||||
Login *SyncLogin `json:"login"`
|
Notes *string `json:"notes"`
|
||||||
Card *SyncCard `json:"card"`
|
Login *SyncLogin `json:"login"`
|
||||||
Fields []SyncField `json:"fields"`
|
Card *SyncCard `json:"card"`
|
||||||
|
Fields []SyncField `json:"fields"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncLogin contains encrypted login data.
|
// SyncLogin contains encrypted login data.
|
||||||
@@ -304,10 +314,41 @@ func (ac *APIClient) Sync() ([]DecryptedItem, error) {
|
|||||||
return nil, fmt.Errorf("decode sync response: %w", err)
|
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.
|
// Decrypt all ciphers.
|
||||||
items := make([]DecryptedItem, 0, len(syncResp.Ciphers))
|
items := make([]DecryptedItem, 0, len(syncResp.Ciphers))
|
||||||
for _, c := range 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 {
|
if err != nil {
|
||||||
logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err)
|
logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import (
|
|||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -41,7 +44,8 @@ type SymmetricKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CipherString represents an encrypted Bitwarden value.
|
// CipherString represents an encrypted Bitwarden value.
|
||||||
// Format: "<encType>.<iv>|<ciphertext>|<mac>"
|
// Format for AES types: "<encType>.<iv>|<ciphertext>|<mac>"
|
||||||
|
// Format for RSA types: "<encType>.<ciphertext>"
|
||||||
type CipherString struct {
|
type CipherString struct {
|
||||||
Type int
|
Type int
|
||||||
IV []byte
|
IV []byte
|
||||||
@@ -94,6 +98,13 @@ func ParseCipherString(s string) (*CipherString, error) {
|
|||||||
return nil, fmt.Errorf("invalid MAC: %w", err)
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported encryption type: %d", encType)
|
return nil, fmt.Errorf("unsupported encryption type: %d", encType)
|
||||||
}
|
}
|
||||||
@@ -251,6 +262,77 @@ func DecryptSymmetricKey(encryptedKey string, masterKey []byte) (SymmetricKey, e
|
|||||||
}, nil
|
}, 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.
|
// pkcs7Unpad removes PKCS#7 padding.
|
||||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import (
|
|||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -319,3 +324,403 @@ func TestDecryptStr_Empty(t *testing.T) {
|
|||||||
t.Errorf("expected empty string, got %q", result)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user