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:
Thijs Herman authored and GitHub committed 2026-02-21 12:08:03 +01:00
1 parent f194e85ec0
commit 2b4340d8b0
5 files changed
+586 -13

No files matched your search

+405
View File
@@ -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")
}
}