package com.kryptnostic.kodex.v1.serialization.crypto; import java.io.IOException; import java.io.Serializable; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; import java.util.concurrent.ExecutionException; import org.apache.commons.codec.binary.StringUtils; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Optional; import com.google.common.base.Preconditions; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.hash.HashFunction; import com.google.common.hash.Hashing; import com.kryptnostic.kodex.v1.constants.Names; import com.kryptnostic.kodex.v1.crypto.ciphers.BlockCiphertext; import com.kryptnostic.kodex.v1.crypto.ciphers.CryptoService; import com.kryptnostic.kodex.v1.crypto.ciphers.PasswordCryptoService; import com.kryptnostic.kodex.v1.crypto.keys.CryptoServiceLoader; import com.kryptnostic.kodex.v1.exceptions.types.SecurityConfigurationException; import com.kryptnostic.kodex.v1.models.blocks.ChunkingStrategy; import com.kryptnostic.kodex.v1.serialization.jackson.CryptoServiceLoaderHolder; import com.kryptnostic.kodex.v1.serialization.jackson.KodexObjectMapperFactory; import com.kryptnostic.storage.v1.models.EncryptableBlock; /** * An Encryptable object wraps any Java object and allows for deferred encryption of the bytes of that object * <p> * This implementation uses Jackson's ObjectMapper to serialize the bytes of the data object * <p> * <h2>Two States</h2> * <p> * An Encryptable is immutable and can only be instantiated in one of two states: encrypted or decrypted * <p> * Encryption and decryption are not destructive operations and do not change the state of the Encryptable the operation * is called on. Encryption/decryption will simply generate and return new Encryptables without modifying and state * within the original Encryptable. Specifically, when an Encryptable is encrypted from a decrypted state, a new * Encryptable (in the encrypted state) is generated and returned. Similarly, when an Encryptable is decrypted from an * encrypted state, a new Encryptable (in the decrypted state) is generated and returned. The exception to this is if * one tries to encrypt an Encryptable that is already in an Encrypted state, the original Encryptable will be returned * (no-op). Similarly, if one tries to decrypt an Encryptable that is already in a decrypted state, the original * Encryptable will be returned (no-op). * <p> * This is useful for deferring potentially expensive encryption and serialization operations while still being able to * pass data around in memory * <p> * <h2>Automatic Decryption/Encryption</h2> * <p> * Encryptable supports automatic decryption and encryption when a respective deserialization or serialization operation * occurs. This is accomplished by registering the Encryptable with a CryptoServiceId. The CryptoServiceId is a string * key that maps to a CryptoService. A CryptoService stores keys for performing encryption/decryption operations. By * registering a @JacksonInjectable CryptoServiceLoader with Jackson's ObjectMapper (@see KodexModule) we are able to * accomplish seamless encryption/decryption upon serialization/deserialization with Jackson * <p> * If a configured Jackson ObjectMapper attempts to deserialize/decrypt an Encryptable, but doesn't have an appropriate * CryptoService to do so, Jackson will return a deserialized Encryptable in an ENCRYPTED state. This means that * decryption only occurs when the security configuration is properly registered, and if not, decryption will fail * silently. * <p> * <h2>Blocks</h2> * <p> * Data bytes are automatically divided into blocks for efficient server-side storage and more reliable transmission. * This subdivision automatically occurs upon encryption. The process is reversed upon decryption to provide a java * object representation of the plaintext data * <p> * <h2>Notes</h2> * <p> * Equals is not overridden because the encrypted bytes are random and comparing two Encryptables while in an encrypted * state may consistently return false although their underlying plaintext data may be equal * * @author sinaiman * * @param <T> The type of the wrapped data you want to encrypt */ public class Encryptable<T> implements Serializable { private static final long serialVersionUID = 5128167833341065251L; private static final Logger logger = LoggerFactory.getLogger( Encryptable.class ); /** * This hash function is used to validate block integrity */ public static final HashFunction hashFunction = Hashing.sha256(); protected static ObjectMapper mapper = KodexObjectMapperFactory.getObjectMapper(); @JsonIgnore private final boolean encrypted; @JsonIgnore private transient final T data; @JsonIgnore private transient final String className; @JsonProperty( Names.DATA_FIELD ) protected final EncryptableBlock[] encryptedData; @JsonProperty( Names.USERNAME_FIELD ) protected final BlockCiphertext encryptedClassName; @JsonProperty( Names.KEY_FIELD ) protected final String cryptoServiceId; @JsonIgnore protected transient ByteBuffer plaintext; @JsonProperty( Names.STRATEGY_FIELD ) protected ChunkingStrategy chunkingStrategy; /** * @param data A plaintext java object representation of data that will later be encrypted if serialized * @param cryptoServiceId A string key that maps this object to its appropriate CryptoService for * decryption/encryption operations */ public Encryptable( T data, String cryptoServiceId ) { this( data, cryptoServiceId, new DefaultChunkingStrategy() ); } protected Encryptable( T data, String cryptoServiceId, ChunkingStrategy chunkingStrategy ) { this.encrypted = false; this.data = data; this.className = data.getClass().getName(); this.encryptedData = null; this.encryptedClassName = null; this.cryptoServiceId = cryptoServiceId; this.chunkingStrategy = chunkingStrategy; } /** * @param data A plaintext java object representation of data that will later be encrypted if serialized */ public Encryptable( T data ) { this( data, PasswordCryptoService.class.getCanonicalName(), new DefaultChunkingStrategy() ); } protected Encryptable( EncryptableBlock[] ciphertext, BlockCiphertext className, ChunkingStrategy chunkingStrategy ) throws ClassNotFoundException, SecurityConfigurationException, IOException { this( ciphertext, className, PasswordCryptoService.class.getCanonicalName(), chunkingStrategy ); } /** * @param ciphertext An array of encrypted byte[] representations of data * @param className An encrypted string representation of the data's target java class * @throws SecurityConfigurationException If any crypto operations fail * @throws IOException If block split fails * @throws ClassNotFoundException If target class doesn't exist on local JVM */ public Encryptable( EncryptableBlock[] ciphertext, BlockCiphertext className ) throws IOException, ClassNotFoundException, SecurityConfigurationException { this( ciphertext, className, new DefaultChunkingStrategy() ); } protected Encryptable( EncryptableBlock[] ciphertext, BlockCiphertext className, String cryptoServiceId, ChunkingStrategy chunkingStrategy ) throws ClassNotFoundException, SecurityConfigurationException, IOException { this( ciphertext, className, cryptoServiceId, CryptoServiceLoaderHolder.getEmptyHolder(), chunkingStrategy ); } /** * @param ciphertext An array of encrypted byte[] representations of data * @param className An encrypted string representation of the data's target java class * @param cryptoServiceId A string key that maps this object to its appropriate CryptoService for * decryption/encryption operations * @throws SecurityConfigurationException If any crypto operations fail * @throws IOException If block split fails * @throws ClassNotFoundException If target class doesn't exist on local JVM */ public Encryptable( EncryptableBlock[] ciphertext, BlockCiphertext className, String cryptoServiceId ) throws ClassNotFoundException, SecurityConfigurationException, IOException { this( ciphertext, className, cryptoServiceId, CryptoServiceLoaderHolder.getEmptyHolder(), new DefaultChunkingStrategy() ); } /** * @param ciphertext An array of encrypted byte[] representations of data * @param className An encrypted string representation of the data's target java class * @param cryptoServiceId A string key that maps this object to its appropriate CryptoService for * decryption/encryption operations * @param loader A CryptoServiceLaoder is a key/value store for String=CryptoService * @param chunkingStrategy Strategy for splitting and joining blocks * @throws SecurityConfigurationException If any crypto operations fail * @throws IOException If block split fails * @throws ClassNotFoundException If target class doesn't exist on local JVM */ public Encryptable( EncryptableBlock[] ciphertext, BlockCiphertext className, String cryptoServiceId, CryptoServiceLoaderHolder loader, ChunkingStrategy chunkingStrategy ) throws SecurityConfigurationException, ClassNotFoundException, IOException { Preconditions.checkNotNull( loader, "CryptoServiceLoaderHolder must be present." ); this.cryptoServiceId = cryptoServiceId; this.chunkingStrategy = chunkingStrategy; Optional<CryptoService> crypto; if ( loader.isPresent() ) { // Crypto on serialization is enabled so let's try and get a CryptoService. crypto = getCryptoService( loader.get() ); if ( crypto.isPresent() ) { Encryptable<T> encrypted = new Encryptable<T>( ciphertext, className, cryptoServiceId ); Encryptable<T> decrypted; decrypted = encrypted.decryptWith( crypto.get() ); this.encrypted = false; this.data = decrypted.getData(); this.className = decrypted.getClassName(); this.encryptedData = null; this.encryptedClassName = null; } else { throw new SecurityConfigurationException( "Unable to decrypt because no crypto service was loadable." ); } } else { this.encrypted = true; this.data = null; this.className = null; this.encryptedData = ciphertext; this.encryptedClassName = className; } } /** * @param ciphertext An array of encrypted byte[] representations of data * @param className An encrypted string representation of the data's target java class * @param cryptoServiceId A string key that maps this object to its appropriate CryptoService for * decryption/encryption operations * @param loader A CryptoServiceLaoder is a key/value store for String=CryptoService * @throws SecurityConfigurationException If any crypto operations fail * @throws IOException If block split fails * @throws ClassNotFoundException If target class doesn't exist on local JVM */ @JsonCreator public Encryptable( @JsonProperty( Names.DATA_FIELD ) EncryptableBlock[] ciphertext, @JsonProperty( Names.USERNAME_FIELD ) BlockCiphertext className, @JsonProperty( Names.KEY_FIELD ) String cryptoServiceId, @JacksonInject CryptoServiceLoaderHolder loader ) throws SecurityConfigurationException, ClassNotFoundException, IOException { this( ciphertext, className, cryptoServiceId, loader, new DefaultChunkingStrategy() ); } /** * @param loader Key/value store that provides CryptoServices * @return An Encryptable in an encrypted state * @throws SecurityConfigurationException If something goes wrong with encryption * @throws IOException If block split fails * @throws ClassNotFoundException */ public final Encryptable<T> encrypt( CryptoServiceLoader loader ) throws SecurityConfigurationException, IOException, ClassNotFoundException { if ( this.encrypted ) { return this; } Preconditions.checkNotNull( this.data ); Preconditions.checkNotNull( this.className ); Preconditions.checkState( this.encryptedData == null ); Preconditions.checkState( this.encryptedClassName == null ); Optional<CryptoService> crypto = getCryptoService( loader ); if ( crypto.isPresent() ) { return encryptWith( crypto.get() ); } else { throw new SecurityConfigurationException( "Unable to encrypt because no crypto service was loadable." ); } } protected Encryptable<T> encryptWith( CryptoService crypto ) throws SecurityConfigurationException, IOException, ClassNotFoundException { Preconditions.checkNotNull( crypto ); int total = getBlockCount(); List<BlockCiphertext> ciphertextBlocks = Lists.newArrayListWithCapacity( total ); // encrypt all our plaintext blocks Iterable<byte[]> plainBlocks = getChunkingStrategy().split( getData() ); for ( byte[] block : plainBlocks ) { ciphertextBlocks.add( crypto.encrypt( block ) ); } Preconditions.checkState( ciphertextBlocks.size() == total, "Block count doesn't match iterable length" ); EncryptableBlock[] blocks = new EncryptableBlock[ total ]; BlockCiphertext encryptedClassName = crypto.encrypt( StringUtils.getBytesUtf8( getClassName() ) ); for ( int i = 0; i < total; ++i ) { BlockCiphertext ciphertext = ciphertextBlocks.get( i ); boolean isLast = i == total - 1; blocks[ i ] = new EncryptableBlock( ciphertext, hashFunction.hashBytes( ciphertext.getContents() ) .asBytes(), i, isLast, encryptedClassName, getChunkingStrategy(), DateTime.now() ); } return new Encryptable<T>( blocks, encryptedClassName, cryptoServiceId ); } /** * @param loader Key/value store that provides CryptoServices * @return If the current object is encrypted, returns new Encryptable equivalent to the current object, but in a * decrypted state. If the current object is decrypted, returns itself * @throws SecurityConfigurationException If anything goes wrong with decrypting the Encryptable * @throws IOException If there was an error with block joining * @throws ClassNotFoundException If the target data object is not found in the local JVM */ public final Encryptable<T> decrypt( CryptoServiceLoader loader ) throws SecurityConfigurationException, ClassNotFoundException, IOException { if ( !this.encrypted ) { return this; } Preconditions.checkState( this.data == null ); Preconditions.checkState( this.className == null ); Preconditions.checkNotNull( this.encryptedData ); Preconditions.checkNotNull( this.encryptedClassName ); Optional<CryptoService> crypto = getCryptoService( loader ); if ( crypto.isPresent() ) { return decryptWith( crypto.get() ); } else { throw new SecurityConfigurationException( "Unable to decrypt because no crypto service was loadable." ); } } @SuppressWarnings( "unchecked" ) protected Encryptable<T> decryptWith( CryptoService crypto ) throws SecurityConfigurationException, IOException, ClassNotFoundException { byte[] bytes = crypto.decryptBytes( getEncryptedClassName() ); String className = StringUtils.newStringUtf8( bytes ); Class<T> klass = (Class<T>) Class.forName( className ); // Lazy evaluated transformation to re-construct object. T joinedData = getChunkingStrategy().join( Iterables.transform( Arrays.asList( getEncryptedData() ), new BlockDecrypter( crypto ) ), klass ); return new Encryptable<T>( joinedData, cryptoServiceId ); } /** * @return Number of blocks this Encryptable is using to store plaintext bytes * @throws JsonProcessingException If anything goes wrong writing bytes to figure out how many blocks there are TODO * this method is undefined if the object is in an encrypted state, add safeguards for this case */ @JsonIgnore public int getBlockCount() throws JsonProcessingException { if ( encrypted ) { return encryptedData.length; } else { if ( plaintext == null ) { byte[] bytes = null; if ( getData() instanceof String ) { bytes = StringUtils.getBytesUtf8( (String) getData() ); } else { bytes = mapper.writeValueAsBytes( getData() ); } plaintext = ByteBuffer.wrap( bytes ); } double remaining = plaintext.remaining(); double blockLen = getChunkingStrategy().getLength(); return (int) Math.ceil( remaining / blockLen ); } } protected Optional<CryptoService> getCryptoService( CryptoServiceLoader loader ) throws SecurityConfigurationException { Preconditions.checkNotNull( loader, "CryptoServiceLoader cannot be null." ); try { return loader.get( cryptoServiceId ); } catch ( ExecutionException e ) { logger.error( "Something went wrong with the crypto service loader.", e ); return Optional.absent(); } } /** * @return Plaintext data representation of object type T */ @JsonIgnore public T getData() { return data; } /** * @return Plaintext representation of the target class name. If the Encryptable is in an encrypted state, this * returns null */ @JsonIgnore public String getClassName() { return className; } /** * @return Encrypted representation of data payload. If the Encryptable is in a decrypted state, this returns null */ @JsonProperty( Names.DATA_FIELD ) public EncryptableBlock[] getEncryptedData() { return encryptedData; } /** * * @return Encrypted representation of target class for data payload. If the Encryptable is in a decrypted state, * this returns null */ @JsonProperty( Names.USERNAME_FIELD ) public BlockCiphertext getEncryptedClassName() { return encryptedClassName; } /** * @return True if the Encryptable is in an encrypted state, false if it is in a decrypted state */ @JsonIgnore public boolean isEncrypted() { return encrypted; } /** * @return The key corresponding to this Encryptable's CryptoService to be used to load a CryptoService from an * appropriate CryptoServiceLoader */ @JsonProperty( Names.KEY_FIELD ) public String getCryptoServiceId() { return cryptoServiceId; } @JsonProperty( Names.STRATEGY_FIELD ) public ChunkingStrategy getChunkingStrategy() { return chunkingStrategy; } /** * @return An ObjectMapper that is compatible with Encryptables */ @JsonIgnore public static ObjectMapper getMapper() { return mapper; } // TODO FIXME wtf code smell public static void setMapper( ObjectMapper mapper ) { // Encryptable.mapper = mapper; } }