Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature-1659: Initial draft #1680

Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions engine/src/main/java/com/arcadedb/database/DataEncryption.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* Copyright © 2024-present Arcade Data Ltd (info@arcadedata.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-FileCopyrightText: 2024-present Arcade Data Ltd (info@arcadedata.com)
* SPDX-License-Identifier: Apache-2.0
*/
package com.arcadedb.database;

/**
* Provides methods to intercept serialization of data allowing encryption and decryption.
*
* @author Pawel Maslej
* @since 27 Jun 2024
*/
public interface DataEncryption {
public byte[] encrypt(byte[] data);
public byte[] decrypt(byte[] data);
}
10 changes: 10 additions & 0 deletions engine/src/main/java/com/arcadedb/database/Database.java
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,14 @@ Edge newEdgeByKeys(Vertex sourceVertex, String destinationVertexType, String[] d
* @see #isAsyncFlush()
*/
Database setAsyncFlush(boolean value);

/**
* Sets data encryption to be used by the database.<br>
* THIS MUST BE DONE BEFORE WRITING ANY DATA TO THE DATABASE.
*
* @param encryption implementation of DataEncryption
*
* @see DefaultDataEncryption
*/
void setDataEncryption(DataEncryption encryption);
}
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,9 @@ default TransactionContext getTransaction() {
* Executes an operation after having locked files.
*/
<RET> RET executeLockingFiles(Collection<Integer> fileIds, Callable<RET> callable);

@Override
default void setDataEncryption(DataEncryption encryption) {
getSerializer().setDataEncryption(encryption);
}
}
120 changes: 120 additions & 0 deletions engine/src/main/java/com/arcadedb/database/DefaultDataEncryption.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright © 2024-present Arcade Data Ltd (info@arcadedata.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-FileCopyrightText: 2024-present Arcade Data Ltd (info@arcadedata.com)
* SPDX-License-Identifier: Apache-2.0
*/
package com.arcadedb.database;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.KeySpec;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;

/**
* Provides configurable default with implementation for data encryption and decryption.
*
* @author Pawel Maslej
* @since 27 Jun 2024
*/
public class DefaultDataEncryption implements DataEncryption {
public static final int DEFAULT_SALT_ITERATIONS = 65536;
public static final int DEFAULT_KEY_LENGTH = 256;
public static final String DEFAULT_PASSWORD_ALGORITHM = "PBKDF2WithHmacSHA256";
public static final String DEFAULT_SECRET_KEY_ALGORITHM = "AES";

public static final String DEFAULT_ALGORITHM = "AES/GCM/NoPadding";
public static final int DEFAULT_IV_SIZE = 12;
public static final int DEFAULT_TAG_SIZE = 128;

private SecretKey secretKey;
private String algorithm;
private int ivSize;
private int tagSize;

public DefaultDataEncryption(SecretKey secretKey, String algorithm, int ivSize, int tagSize) throws NoSuchAlgorithmException, NoSuchPaddingException {
this.secretKey = secretKey;
this.algorithm = algorithm;
this.ivSize = ivSize;
this.tagSize = tagSize;
Cipher.getInstance(algorithm); // validates before execution
}

@Override
public byte[] encrypt(byte[] data) {
try {
var ivBytes = generateIv(ivSize);
var cipher = Cipher.getInstance(algorithm);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(tagSize, ivBytes));
byte[] encryptedData = cipher.doFinal(data);
byte[] ivAndEncryptedData = new byte[ivBytes.length + encryptedData.length];
System.arraycopy(ivBytes, 0, ivAndEncryptedData, 0, ivBytes.length);
System.arraycopy(encryptedData, 0, ivAndEncryptedData, ivBytes.length, encryptedData.length);
return ivAndEncryptedData;
} catch (Exception e) {
throw new RuntimeException("Error while encrypting data", e);

Check warning on line 75 in engine/src/main/java/com/arcadedb/database/DefaultDataEncryption.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

engine/src/main/java/com/arcadedb/database/DefaultDataEncryption.java#L75

Avoid throwing raw exception types.
}
}

@Override
public byte[] decrypt(byte[] data) {
try {
byte[] ivBytes = new byte[ivSize];
byte[] encryptedData = new byte[data.length - ivSize];
System.arraycopy(data, 0, ivBytes, 0, ivSize);
System.arraycopy(data, ivSize, encryptedData, 0, encryptedData.length);
var decipher = Cipher.getInstance(algorithm);
decipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(tagSize, ivBytes));
return decipher.doFinal(encryptedData);
} catch (Exception e) {
throw new RuntimeException("Error while decrypting data", e);

Check warning on line 90 in engine/src/main/java/com/arcadedb/database/DefaultDataEncryption.java

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

engine/src/main/java/com/arcadedb/database/DefaultDataEncryption.java#L90

Avoid throwing raw exception types.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about creating a new exception class for encryption, like EncryptionException that extends ArcadeDBException (so it's a RuntimeException)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, will do like that

}
}

public static DefaultDataEncryption useDefaults(SecretKey secretKey) throws NoSuchAlgorithmException, NoSuchPaddingException {
return new DefaultDataEncryption(secretKey, DEFAULT_ALGORITHM, DEFAULT_IV_SIZE, DEFAULT_TAG_SIZE);
}

public static SecretKey generateRandomSecretKeyUsingDefaults() throws NoSuchAlgorithmException {
KeyGenerator keyGen = KeyGenerator.getInstance(DEFAULT_SECRET_KEY_ALGORITHM);
keyGen.init(DEFAULT_KEY_LENGTH);
return keyGen.generateKey();
}

public static SecretKey getSecretKeyFromPasswordUsingDefaults(String password, String salt) throws NoSuchAlgorithmException, InvalidKeySpecException {
return getKeyFromPassword(password, salt, DEFAULT_PASSWORD_ALGORITHM, DEFAULT_SECRET_KEY_ALGORITHM, DEFAULT_SALT_ITERATIONS, DEFAULT_KEY_LENGTH);
}

private static byte[] generateIv(int ivSize) {
final byte[] iv = new byte[ivSize];
new SecureRandom().nextBytes(iv);
return iv;
}

public static SecretKey getKeyFromPassword(String password, String salt, String passwordAlgorithm, String secretKeyAlgorithm, int saltIterations, int keyLength)
throws NoSuchAlgorithmException, InvalidKeySpecException {
final SecretKeyFactory factory = SecretKeyFactory.getInstance(passwordAlgorithm);
final KeySpec spec = new PBEKeySpec(password.toCharArray(), salt.getBytes(), saltIterations, keyLength);
return new SecretKeySpec(factory.generateSecret(spec).getEncoded(), secretKeyAlgorithm);
}
}
39 changes: 31 additions & 8 deletions engine/src/main/java/com/arcadedb/serializer/BinarySerializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.arcadedb.GlobalConfiguration;
import com.arcadedb.database.BaseRecord;
import com.arcadedb.database.Binary;
import com.arcadedb.database.DataEncryption;
import com.arcadedb.database.Database;
import com.arcadedb.database.DatabaseContext;
import com.arcadedb.database.DatabaseInternal;
Expand Down Expand Up @@ -65,6 +66,7 @@ public class BinarySerializer {
private final BinaryComparator comparator = new BinaryComparator();
private Class<?> dateImplementation;
private Class<?> dateTimeImplementation;
private DataEncryption dataEncryption;

public BinarySerializer(final ContextConfiguration configuration) throws ClassNotFoundException {
setDateImplementation(configuration.getValue(GlobalConfiguration.DATE_IMPLEMENTATION));
Expand Down Expand Up @@ -332,9 +334,10 @@ else if (properties == 0)
return null;
}

public void serializeValue(final Database database, final Binary content, final byte type, Object value) {
public void serializeValue(final Database database, final Binary serialized, final byte type, Object value) {
if (value == null)
return;
Binary content = dataEncryption != null ? new Binary() : serialized;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you need to create a new Binary() object in the case of encryption content?


switch (type) {
case BinaryTypes.TYPE_NULL:
Expand Down Expand Up @@ -393,8 +396,8 @@ else if (value instanceof LocalDate)
break;
case BinaryTypes.TYPE_COMPRESSED_RID: {
final RID rid = ((Identifiable) value).getIdentity();
content.putNumber(rid.getBucketId());
content.putNumber(rid.getPosition());
serialized.putNumber(rid.getBucketId());
serialized.putNumber(rid.getPosition());
break;
}
case BinaryTypes.TYPE_RID: {
Expand All @@ -403,8 +406,8 @@ else if (value instanceof LocalDate)
value = ((Result) value).getElement().get();

final RID rid = ((Identifiable) value).getIdentity();
content.putInt(rid.getBucketId());
content.putLong(rid.getPosition());
serialized.putInt(rid.getBucketId());
serialized.putLong(rid.getPosition());
break;
}
case BinaryTypes.TYPE_UUID: {
Expand Down Expand Up @@ -563,10 +566,26 @@ else if (value instanceof LocalDate)
default:
LogManager.instance().log(this, Level.INFO, "Error on serializing value '" + value + "', type not supported");
}

if (dataEncryption != null) {
switch (type) {
case BinaryTypes.TYPE_NULL:
case BinaryTypes.TYPE_COMPRESSED_RID:
case BinaryTypes.TYPE_RID:
break;
default:
serialized.putBytes(dataEncryption.encrypt(content.toByteArray()));
Copy link
Contributor

@lvca lvca Aug 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, you're encrypting every single value. Why don't encrypting the whole Binary at the end, once all the values are serialized? This would be much faster and you don't need to differentiate special cases like RIDs. WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll have a look

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was looking inside the code to find best place to do it, as Binary is referred in plenty of places, and it seemed that interactions with BaseRecord#buffer is what I am looking for. That however also is fairly nested inside many classes meaning it is difficult to intercept right moment to perform write/read as it gets passed around and often buffer is accessed directly to perform partial read.

In the end, I chosen implementing encryption at BinarySerializer in serializeProperties(), deserializeProperties(), deserializeProperty and hasProperty(). Idea is to write just all properties as encrypted at once and vice-versa. The issue I ran into is that serializeProperties() writes to two types: header and content. Header stores information about propertiesCount and about each propertyKey with its bufferPosition for the value. This allows reading just the value from whole buffer, and not all properties to find one. However this isn't possible with the goal above. I have to either re-factor the code to read all properties from buffer to decode, regardless of looking for specific one and then filter by key, or leave existing behaviour.

I could also re-factor structure of BaseRecord#binary to distinct between meta content (like ID) and data (encrypted) but that could be even more re-factoring, and it wouldn't resolve cherry picking properties anyway.

I understand that current implementation with encryption of each value adds performance cost, I didn't measure how this would affect our product yet, but it is mandatory requirement for us anyway.

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay on this review. It makes sense, it's better to avoid encrypting the metadata for fast accessing to the properties you're requesting. Also, this makes easier to encrypt single property by adding some configuration (in the Schema -> Type -> Property)

}
}
}

public Object deserializeValue(final Database database, final Binary content, final byte type,
public Object deserializeValue(final Database database, final Binary deserialized, final byte type,
final EmbeddedModifier embeddedModifier) {
Binary content = dataEncryption != null &&
type != BinaryTypes.TYPE_NULL &&
type != BinaryTypes.TYPE_COMPRESSED_RID &&
type != BinaryTypes.TYPE_RID ? new Binary(dataEncryption.decrypt(deserialized.getBytes())) : deserialized;

final Object value;
switch (type) {
case BinaryTypes.TYPE_NULL:
Expand Down Expand Up @@ -626,10 +645,10 @@ public Object deserializeValue(final Database database, final Binary content, fi
value = new BigDecimal(new BigInteger(unscaledValue), scale);
break;
case BinaryTypes.TYPE_COMPRESSED_RID:
value = new RID(database, (int) content.getNumber(), content.getNumber());
value = new RID(database, (int) deserialized.getNumber(), deserialized.getNumber());
break;
case BinaryTypes.TYPE_RID:
value = new RID(database, content.getInt(), content.getLong());
value = new RID(database, deserialized.getInt(), deserialized.getLong());
break;
case BinaryTypes.TYPE_UUID:
value = new UUID(content.getNumber(), content.getNumber());
Expand Down Expand Up @@ -799,4 +818,8 @@ private void serializeDateTime(final Binary content, final Object value, final b
content.putUnsignedNumber(DateUtils.dateTimeToTimestamp(value, DateUtils.getPrecisionFromBinaryType(type)));
}

public void setDataEncryption(final DataEncryption dataEncryption) {
this.dataEncryption = dataEncryption;
}

}
96 changes: 96 additions & 0 deletions engine/src/test/java/com/arcadedb/database/DataEncryptionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright © 2024-present Arcade Data Ltd (info@arcadedata.com)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-FileCopyrightText: 2024-present Arcade Data Ltd (info@arcadedata.com)
* SPDX-License-Identifier: Apache-2.0
*/
package com.arcadedb.database;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.util.concurrent.atomic.AtomicReference;

import javax.crypto.NoSuchPaddingException;

import org.junit.jupiter.api.Test;

import com.arcadedb.TestHelper;
import com.arcadedb.graph.Vertex;
import com.arcadedb.graph.Vertex.DIRECTION;

/**
* @author Pawel Maslej
* @since 1 Jul 2024
*/
class DataEncryptionTest extends TestHelper {

String password = "password";
String salt = "salt";

@Test
void dataIsEncrypted() throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException {
database.setDataEncryption(DefaultDataEncryption.useDefaults(DefaultDataEncryption.getSecretKeyFromPasswordUsingDefaults(password, salt)));

database.command("sql", "create vertex type Person");
database.command("sql", "create property Person.id string");
database.command("sql", "create index on Person (id) unique");
database.command("sql", "create property Person.name string");
database.command("sql", "create edge type Knows");

var v1Id = new AtomicReference<RID>(null);
var v2Id = new AtomicReference<RID>(null);

database.transaction(() -> {
var v1 = database.newVertex("Person").set("name", "John").save();
var v2 = database.newVertex("Person").set("name", "Doe").save();
v1.newEdge("Knows", v2, false, "since", 2024);
verify(v1, v2, true);
v1Id.set(v1.getIdentity());
v2Id.set(v2.getIdentity());
});
verify(v1Id.get(), v2Id.get(), true);

database.setDataEncryption(null);
verify(v1Id.get(), v2Id.get(), false);

reopenDatabase();
verify(v1Id.get(), v2Id.get(), false);

database.setDataEncryption(DefaultDataEncryption.useDefaults(DefaultDataEncryption.getSecretKeyFromPasswordUsingDefaults(password, salt)));
verify(v1Id.get(), v2Id.get(), true);
}

private void verify(RID rid1, RID rid2, boolean isEquals) {
database.transaction(() -> {
var p1 = database.lookupByRID(rid1, true).asVertex();
var p2 = database.lookupByRID(rid2, true).asVertex();
verify(p1, p2, isEquals);
});
}

private void verify(Vertex p1, Vertex p2, boolean isEquals) {
if (isEquals) {
assertEquals("John", p1.get("name"));
assertEquals("Doe", p2.get("name"));
assertEquals(2024, p1.getEdges(DIRECTION.OUT, "Knows").iterator().next().get("since"));
} else {
assertFalse(((String) p1.get("name")).contains("John"));
assertFalse(((String) p2.get("name")).contains("Doe"));
assertFalse(p1.getEdges(DIRECTION.OUT, "Knows").iterator().next().get("since").toString().contains("2024"));
}
}
}
Loading
Loading