Răsfoiți Sursa

Preserve unchanged encryption to eliminate false history diffs

When a document is saved without changing sensitive fields (passwords,
API keys), re-encryption with a new random IV produced different
ciphertext, creating false diffs in version history. Now the Upsert
handler compares plaintext values with decrypted existing values and
preserves the original $ENC$ ciphertext when unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Void User 5 luni în urmă
părinte
comite
cd3af3807a

+ 9 - 0
service/src/database_grpc_impl.cpp

@@ -172,6 +172,15 @@ grpc::Status DatabaseGrpcImpl::Upsert(
             doc.updatedBy = request->actor();
         }
 
+        // Preserve existing encrypted values for unchanged sensitive fields
+        // to avoid false diffs in version history (different IVs produce different ciphertext)
+        if (existed) {
+            auto existingDoc = store_.get(request->collection(), request->id());
+            if (existingDoc) {
+                encryption_.preserveUnchangedEncryption(doc, *existingDoc);
+            }
+        }
+
         encryption_.encryptSensitiveFields(doc);
 
         std::string id = store_.upsert(request->collection(), doc);

+ 54 - 0
service/src/encryption/encryption_manager.cpp

@@ -211,6 +211,60 @@ void EncryptionManager::decryptSensitiveFields(Document& doc) {
     decryptJsonField(doc.data, "", doc.collection, doc.id);
 }
 
+void EncryptionManager::preserveUnchangedEncryption(Document& newDoc, const Document& existingDoc) {
+    if (!isEnabled() || !existingDoc.encrypted) {
+        return;
+    }
+
+    std::lock_guard lock(mutex_);
+    preserveUnchangedJsonField(newDoc.data, existingDoc.data, "");
+}
+
+void EncryptionManager::preserveUnchangedJsonField(nlohmann::json& newObj,
+                                                    const nlohmann::json& existingObj,
+                                                    const std::string& path) {
+    if (!newObj.is_object() || !existingObj.is_object()) {
+        return;
+    }
+
+    for (auto& [key, newValue] : newObj.items()) {
+        if (!existingObj.contains(key)) {
+            continue;
+        }
+
+        std::string fieldPath = path.empty() ? key : path + "." + key;
+        const auto& existingValue = existingObj[key];
+
+        if (newValue.is_string() && existingValue.is_string() && isSensitiveField(fieldPath)) {
+            const std::string& newPlaintext = newValue.get_ref<const std::string&>();
+            const std::string& existingEncrypted = existingValue.get_ref<const std::string&>();
+
+            // Only compare if existing is encrypted and new is plaintext
+            if (existingEncrypted.find(ENCRYPTED_PREFIX) == 0 &&
+                newPlaintext.find(ENCRYPTED_PREFIX) != 0) {
+                try {
+                    std::string existingDecrypted = decryptValue(existingEncrypted);
+                    if (existingDecrypted == newPlaintext) {
+                        // Same value — preserve existing encrypted form to avoid false history diff
+                        newValue = existingEncrypted;
+                    }
+                } catch (...) {
+                    // Decryption failed — let encryptSensitiveFields handle it normally
+                }
+            }
+        } else if (newValue.is_object() && existingValue.is_object()) {
+            preserveUnchangedJsonField(newValue, existingValue, fieldPath);
+        } else if (newValue.is_array() && existingValue.is_array()) {
+            // For arrays, recurse into matching indices
+            size_t minSize = std::min(newValue.size(), existingValue.size());
+            for (size_t i = 0; i < minSize; ++i) {
+                preserveUnchangedJsonField(newValue[i], existingValue[i],
+                                           fieldPath + "[" + std::to_string(i) + "]");
+            }
+        }
+    }
+}
+
 std::string EncryptionManager::encryptValue(const std::string& plaintext) {
     if (!isEnabled()) {
         return plaintext;

+ 11 - 0
service/src/encryption/encryption_manager.hpp

@@ -57,6 +57,15 @@ public:
      */
     void decryptSensitiveFields(Document& doc);
 
+    /**
+     * Preserve existing encrypted values for unchanged sensitive fields.
+     * Compares plaintext values in newDoc with decrypted values from existingDoc.
+     * If a sensitive field hasn't changed, copies the existing $ENC$ value to
+     * newDoc so that encryptSensitiveFields() will skip re-encryption.
+     * This prevents false diffs in version history caused by different IVs.
+     */
+    void preserveUnchangedEncryption(Document& newDoc, const Document& existingDoc);
+
     /**
      * Encrypt a single string value.
      */
@@ -91,6 +100,8 @@ private:
     void encryptJsonField(nlohmann::json& obj, const std::string& path);
     void decryptJsonField(nlohmann::json& obj, const std::string& path,
                          const std::string& collection = "", const std::string& docId = "");
+    void preserveUnchangedJsonField(nlohmann::json& newObj, const nlohmann::json& existingObj,
+                                    const std::string& path);
 
     Config config_;
     bool initialized_ = false;