MongoDB offers a feature called Client-Side Field Level Encryption (CSFLE) which allows applications to encrypt data before storing it in the database. As the name suggests you can control the encryption for each field of a document individually using a different Data Encryption Key (DEK) for every field if needed. These DEKs are stored in a collection called Key Vault where the actual key material is encrypted with a key named Customer Master Key (CMK). The CMK has a fixed size of 96 bytes. To read an encrypted field from a document, an application has to
- grab the document from the database
- fetch the corresponding DEK from the Key Vault
- decrypt the DEK using the CMK
- decrypt the encrypted field from the document using the decrypted DEK
Storing an encrypted field uses the same mechanism.
Key element is the CMK, especially regarding security as MongoDB points out.
My understanding of encryption so far is that larger keys usually provide more security and that you have to use the same key to decrypt something you've encrypted.
However, while working on a Java-project using CSFLE I noticed MongoDB behaving differently from what I'd expect: I can use different CMKs and still get a valid decryption.
To be more precise, any change in the last 32 bytes of the original CMK does not throw an exception.
How is this possible? Is this intended?
I don't understand this so I'm looking forward to any kind of explanation.
How to replicate
You can copy the example code from MongoDB to replicate this. I've made some adjustments to the code (e.g. authentication)
MongoClientSettings clientSettings = MongoClientSettings.builder()
.uuidRepresentation(UuidRepresentation.STANDARD)
.applyToClusterSettings(builder -> builder.hosts(Arrays.asList(new ServerAddress("localhost", 27017))))
.credential(...).build();
ClientEncryptionSettings clientEncryptionSettings = ClientEncryptionSettings.builder()
.keyVaultMongoClientSettings(MongoClientSettings.builder()
.applyToClusterSettings(builder -> builder.hosts(Arrays.asList(new ServerAddress("localhost", 27017))))
.credential(...).build())
.keyVaultNamespace(keyVaultNamespace.getFullName()).kmsProviders(kmsProviders).build();
to keep the DEK over subsequent runs
BsonBinary dataKeyId = null;
List<String> keyAltNames = List.of("testCMK");
MongoCursor<Document> cursor = keyVaultCollection.find().iterator();
try {
while (cursor.hasNext() && dataKeyId == null) {
Document key = cursor.next();
List<String> altNames = key.getList("keyAltNames", String.class);
if (altNames != null) {
for (String name : altNames) {
if (name.contentEquals("testCMK")) {
dataKeyId = new BsonBinary(key.get("_id", UUID.class));
break;
}
}
}
}
if (dataKeyId == null)
dataKeyId = clientEncryption.createDataKey("local", new DataKeyOptions().keyAltNames(keyAltNames));
} finally {
cursor.close();
}
and only insert the document once
if (collection.find().first() == null)
collection.insertOne(new Document("encryptedField", encryptedFieldValue));
Document doc = collection.find().first();
// Explicitly decrypt the field
BsonValue decrypted = clientEncryption.decrypt(
new BsonBinary(BsonBinarySubType.ENCRYPTED, doc.get("encryptedField", Binary.class).getData()));
System.out.println("decrypted: " + decrypted);
The original example code doesn't set the BsonBinarySubType
which causes clientEncryption.decrypt()
to do nothing because the passed BsonBinary
is of type 0 (generic binary) instead of 6 (encrypted).
Generating the master key
The CMK is always generated as follows
final byte[] localMasterKey = new byte[96];
for (int i = 0; i < localMasterKey.length; i++)
localMasterKey[i] = (byte) (i + 1);
Running the program multiple times will always produce the same output:
raw value: BsonString{value='123456789'}
decrypted: BsonString{value='123456789'}
Now change the CMK by adding localMasterKey[0] = 0;
after the for
-loop which generates the key.
Running the program will result in an java.lang.reflect.InvocationTargetException
caused by com.mongodb.crypt.capi.MongoCryptException: HMAC validation failure
which is expected because the DEK cannot be decrypted with a different CMK.
However, if we instead add localMasterKey[localMasterKey.length - 1] = 0;
, the program will again produce
raw value: BsonString{value='123456789'}
decrypted: BsonString{value='123456789'}
I would've expected an exception to be thrown but it decrypts the field with the correct value.
To summarize, the encryption/decryption fails if the generated master key differs from the original (first) master key in any of the first 64 bytes but succeeds when the difference is in the last 32 bytes.
This is not limited to a one byte difference - decryption works even when all 32 bytes are different.
Why is that?