All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.shibboleth.utilities.java.support.security.DataSealer Maven / Gradle / Ivy

There is a newer version: 8.0.0
Show newest version
/*
 * Licensed to the University Corporation for Advanced Internet Development,
 * Inc. (UCAID) under one or more contributor license agreements.  See the
 * NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The UCAID licenses this file to You 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.
 */

package net.shibboleth.utilities.java.support.security;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.KeyException;
import java.security.SecureRandom;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.SecretKey;

import net.shibboleth.utilities.java.support.annotation.constraint.NonnullAfterInit;
import net.shibboleth.utilities.java.support.annotation.constraint.NotEmpty;
import net.shibboleth.utilities.java.support.collection.Pair;
import net.shibboleth.utilities.java.support.component.AbstractInitializableComponent;
import net.shibboleth.utilities.java.support.component.ComponentInitializationException;
import net.shibboleth.utilities.java.support.component.ComponentSupport;
import net.shibboleth.utilities.java.support.logic.Constraint;
import net.shibboleth.utilities.java.support.logic.ConstraintViolationException;

import org.apache.commons.codec.BinaryDecoder;
import org.apache.commons.codec.BinaryEncoder;
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Base64;
import org.bouncycastle.crypto.InvalidCipherTextException;
import org.bouncycastle.crypto.engines.AESEngine;
import org.bouncycastle.crypto.modes.GCMBlockCipher;
import org.bouncycastle.crypto.params.AEADParameters;
import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.util.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;


/**
 * Applies a MAC to time-limited information and encrypts with a symmetric key.
 */
public class DataSealer extends AbstractInitializableComponent {

    /** Size of UTF-8 data chunks to read/write. */
    private static final int CHUNK_SIZE = 60000;
    
    /** Class logger. */
    @Nonnull private Logger log = LoggerFactory.getLogger(DataSealer.class);

    /** Source of keys. */
    @NonnullAfterInit private DataSealerKeyStrategy keyStrategy;

    /** Source of secure random data. */
    @NonnullAfterInit private SecureRandom random;

    /** Encodes encrypted bytes to string. */
    @Nonnull private BinaryEncoder encoder = new Base64(0, new byte[] { '\n' });

    /** Decodes encrypted string to bytes. */
    @Nonnull private BinaryDecoder decoder = (Base64) encoder;


    /**
     * Set the key strategy.
     * 
     * @param strategy key strategy
     */
    public void setKeyStrategy(@Nonnull final DataSealerKeyStrategy strategy) {
        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
        
        keyStrategy = Constraint.isNotNull(strategy, "DataSealerKeyStrategy cannot be null");
    }
    
    /**
     * Set the pseudorandom generator.
     * 
     * @param r the pseudorandom generator to set
     */
    public void setRandom(@Nonnull final SecureRandom r) {
        ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this);
        
        random = Constraint.isNotNull(r, "SecureRandom cannot be null");
    }

    /**
     * Sets the encoder to use to produce a ciphertext string from bytes. Default is standard base-64 encoding without
     * line breaks.
     *
     * @param e Byte-to-string encoder.
     */
    public void setEncoder(@Nonnull final BinaryEncoder e) {
        encoder = Constraint.isNotNull(e, "Encoder cannot be null");
    }

    /**
     * Sets the decoder to use to convert a ciphertext string to bytes. Default is standard base-64 decoding.
     *
     * @param d String-to-byte decoder.
     */
    public void setDecoder(@Nonnull final BinaryDecoder d) {
        decoder = Constraint.isNotNull(d, "Decoder cannot be null");
    }

    /** {@inheritDoc} */
    public void doInitialize() throws ComponentInitializationException {
        try {
            try {
                Constraint.isNotNull(keyStrategy, "DataSealerKeyStrategy cannot be null");
            } catch (final ConstraintViolationException e) {
                throw new ComponentInitializationException(e);
            }
            
            if (random == null) {
                random = new SecureRandom();
            }

            final SecretKey initialKey = keyStrategy.getDefaultKey().getSecond();

            // Before we finish initialization, make sure that things are working.
            testEncryption(initialKey);

        } catch (final KeyException e) {
            log.error(e.getMessage());
            throw new ComponentInitializationException("Exception loading the keystore", e);
        } catch (final DataSealerException e) {
            log.error(e.getMessage());
            throw new ComponentInitializationException("Exception testing the encryption settings used", e);
        }
    }

    /**
     * Decrypts and verifies an encrypted bundle created with {@link #wrap(String, long)}.
     * 
     * @param wrapped the encoded blob
     * 
     * @return the decrypted data, if it's unexpired
     * @throws DataSealerException if the data cannot be unwrapped and verified
     */
    @Nonnull public String unwrap(@Nonnull @NotEmpty final String wrapped) throws DataSealerException {

        return unwrap(wrapped, null);
    }
    
    /**
     * Decrypts and verifies an encrypted bundle created with {@link #wrap(String, long)}, optionally
     * returning the label of the key used to encrypt the data.
     * 
     * @param wrapped the encoded blob
     * @param keyUsed a buffer to receive the alias of the key used to encrypt the data
     * 
     * @return the decrypted data, if it's unexpired
     * @throws DataSealerException if the data cannot be unwrapped and verified
     */
    @Nonnull public String unwrap(@Nonnull @NotEmpty final String wrapped, @Nullable final StringBuffer keyUsed)
            throws DataSealerException {

        try {
            final byte[] in = decoder.decode(wrapped.getBytes(StandardCharsets.UTF_8));

            final ByteArrayInputStream inputByteStream = new ByteArrayInputStream(in);
            final DataInputStream inputDataStream = new DataInputStream(inputByteStream);
            
            // Extract alias of key, and load if necessary.
            final String keyAlias = inputDataStream.readUTF();
            log.trace("Data was encrypted by key named '{}'", keyAlias);
            if (keyUsed != null) {
                keyUsed.append(keyAlias);
            }
            final SecretKey key = keyStrategy.getKey(keyAlias);
            
            final GCMBlockCipher cipher = new GCMBlockCipher(new AESEngine());
            
            // Load the IV.
            final int ivSize = cipher.getUnderlyingCipher().getBlockSize();
            final byte[] iv = new byte[ivSize];
            inputDataStream.readFully(iv);

            final AEADParameters aeadParams =
                    new AEADParameters(new KeyParameter(key.getEncoded()), 128, iv, keyAlias.getBytes());
            cipher.init(false, aeadParams);

            // Data can't be any bigger than the original minus IV.
            final byte[] data = new byte[in.length - ivSize];
            final int dataSize = inputDataStream.read(data);
            
            final byte[] plaintext = new byte[cipher.getOutputSize(dataSize)];
            final int outputLen = cipher.processBytes(data, 0, dataSize, plaintext, 0);
            cipher.doFinal(plaintext, outputLen);
            
            // Pass the plaintext into the subroutine for processing.
            return extractAndCheckDecryptedData(plaintext);

        } catch (final IllegalStateException | InvalidCipherTextException| IOException | DecoderException e) {
            log.error("Exception unwrapping data", e);
            throw new DataSealerException("Exception unwrapping data", e);
        } catch (final KeyNotFoundException e) {
            if (keyUsed != null) {
                log.info("Data was wrapped with a key ({}) no longer available", keyUsed.toString());
            } else {
                log.info("Data was wrapped with a key no longer available");
            }
            throw new DataExpiredException("Data wrapped with expired key");
        } catch (final KeyException e) {
            log.error(e.getMessage());
            throw new DataSealerException("Exception loading key", e);
        }
    }

    /**
     * Extract the GZIP'd data and test for expiration before returning it.
     * 
     * @param decryptedBytes the data we are looking at
     * 
     * @return the decoded data if it is valid and unexpired
     * @throws DataSealerException if the data cannot be unwrapped and verified
     */
    @Nonnull private String extractAndCheckDecryptedData(@Nonnull @NotEmpty final byte[] decryptedBytes)
            throws DataSealerException {
        
        try {
            final ByteArrayInputStream byteStream = new ByteArrayInputStream(decryptedBytes);
            final GZIPInputStream compressedData = new GZIPInputStream(byteStream);
            final DataInputStream dataInputStream = new DataInputStream(compressedData);

            final long decodedExpirationTime = dataInputStream.readLong();
            if (System.currentTimeMillis() > decodedExpirationTime) {
                log.debug("Unwrapped data has expired");
                throw new DataExpiredException("Unwrapped data has expired");
            }

            final StringBuffer accumulator = new StringBuffer();
            
            int count = 0;
            while (true) {
                try {
                    final String decodedData = dataInputStream.readUTF();
                    accumulator.append(decodedData);
                    log.trace("Read chunk #{} from output stream", ++count);
                } catch (final EOFException e) {
                    break;
                }
            }

            log.trace("Unwrapped data verified");
            return accumulator.toString();
        } catch (final IOException e) {
            log.error(e.getMessage());
            throw new DataSealerException("Caught IOException unwrapping data", e);
        }
    }

    /**
     * Encodes data into an AEAD-encrypted blob, gzip(exp|data)
     * 
     * 
    *
  • exp = expiration time of the data; 8 bytes; Big-endian
  • *
  • data = the data; a UTF-8-encoded string
  • *
* *

As part of encryption, the key alias is supplied as additional authenticated data * to the cipher. Afterwards, the encrypted data is prepended by the IV and then again by the alias * (in length-prefixed UTF-8 format), which identifies the key used. Finally the result is base64-encoded.

* * @param data the data to wrap * @param exp expiration time * @return the encoded blob * @throws DataSealerException if the wrapping operation fails */ @Nonnull public String wrap(@Nonnull @NotEmpty final String data, final long exp) throws DataSealerException { if (data == null || data.length() == 0) { throw new IllegalArgumentException("Data must be supplied for the wrapping operation"); } try { final GCMBlockCipher cipher = new GCMBlockCipher(new AESEngine()); final byte[] iv = new byte[cipher.getUnderlyingCipher().getBlockSize()]; random.nextBytes(iv); final Pair defaultKey = keyStrategy.getDefaultKey(); final AEADParameters aeadParams = new AEADParameters(new KeyParameter(defaultKey.getSecond().getEncoded()), 128, iv, defaultKey.getFirst().getBytes()); cipher.init(true, aeadParams); final ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); final GZIPOutputStream compressedStream = new GZIPOutputStream(byteStream); final DataOutputStream dataStream = new DataOutputStream(compressedStream); dataStream.writeLong(exp); int count = 0; int start = 0; final int dataLength = data.length(); while (start < dataLength) { dataStream.writeUTF(data.substring(start, start + Math.min(dataLength - start, CHUNK_SIZE))); start += Math.min(dataLength - start, CHUNK_SIZE); log.trace("Wrote chunk #{} to output stream", ++count); } dataStream.flush(); compressedStream.flush(); compressedStream.finish(); byteStream.flush(); final byte[] plaintext = byteStream.toByteArray(); final byte[] encryptedData = new byte[cipher.getOutputSize(plaintext.length)]; int outputLen = cipher.processBytes(plaintext, 0, plaintext.length, encryptedData, 0); outputLen += cipher.doFinal(encryptedData, outputLen); final ByteArrayOutputStream finalByteStream = new ByteArrayOutputStream(); final DataOutputStream finalDataStream = new DataOutputStream(finalByteStream); finalDataStream.writeUTF(defaultKey.getFirst()); finalDataStream.write(iv); finalDataStream.write(encryptedData, 0, outputLen); finalDataStream.flush(); finalByteStream.flush(); return new String(encoder.encode(finalByteStream.toByteArray()), StandardCharsets.UTF_8); } catch (final Exception e) { log.error("Exception wrapping data", e); throw new DataSealerException("Exception wrapping data", e); } } /** * Run a test over the configured bean properties. * * @param key key to test * * @throws DataSealerException if the test fails */ private void testEncryption(@Nonnull final SecretKey key) throws DataSealerException { final String decrypted; try { final GCMBlockCipher cipher = new GCMBlockCipher(new AESEngine()); final byte[] iv = new byte[cipher.getUnderlyingCipher().getBlockSize()]; random.nextBytes(iv); final AEADParameters aeadParams = new AEADParameters( new KeyParameter(key.getEncoded()), 128, iv, "aad".getBytes(StandardCharsets.UTF_8)); cipher.init(true, aeadParams); byte[] plaintext = "test".getBytes(StandardCharsets.UTF_8); final byte[] encryptedData = new byte[cipher.getOutputSize(plaintext.length)]; int outputLen = cipher.processBytes(plaintext, 0, plaintext.length, encryptedData, 0); cipher.doFinal(encryptedData, outputLen); cipher.init(false, aeadParams); plaintext = new byte[cipher.getOutputSize(encryptedData.length)]; outputLen = cipher.processBytes(encryptedData, 0, encryptedData.length, plaintext, 0); cipher.doFinal(plaintext, outputLen); decrypted = Strings.fromUTF8ByteArray(plaintext); } catch (final IllegalStateException | InvalidCipherTextException e) { log.error("Round trip encryption/decryption test unsuccessful", e); throw new DataSealerException("Round trip encryption/decryption test unsuccessful", e); } if (decrypted == null || !"test".equals(decrypted)) { log.error("Round trip encryption/decryption test unsuccessful. Decrypted text did not match"); throw new DataSealerException("Round trip encryption/decryption test unsuccessful"); } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy