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

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

The 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.GeneralSecurityException;
import java.security.KeyException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

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.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);

    /** Whether the key source is expected to be locked initially. */
    private boolean lockedAtStartup;
    
    /** 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;

    /** Decodes encrypted string to bytes. */
    @Nonnull private BinaryDecoder decoder;
    
    /** Constructor. */
    public DataSealer() {
        encoder = new Base64(0, new byte[] { '\n' });
        decoder = (Base64) encoder;
    }

    /**
     * Set whether the key source is expected to be locked at startup, and unlocked
     * later at runtime.
     * 
     * 

Defaults to false.

* * @param flag flag to set * * @since 7.4.0 */ public void setLockedAtStartup(final boolean flag) { ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this); lockedAtStartup = flag; } /** * 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) { ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this); 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) { ComponentSupport.ifInitializedThrowUnmodifiabledComponentException(this); decoder = Constraint.isNotNull(d, "Decoder cannot be null"); } /** {@inheritDoc} */ @Override 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(); } if (!lockedAtStartup) { // Before we finish initialization, make sure that things are working. testEncryption(keyStrategy.getDefaultKey().getSecond()); } } 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, Instant)}. * * @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, Instant)}, 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)); // Note: we don't technically need try-with-resources here b/c BAIS close() is a no-op // and DIS close() just calls close() on the wrapped stream. But do for consistency. try (final DataInputStream inputDataStream = new DataInputStream(new ByteArrayInputStream(in)) ){ // 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 Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); // Load the IV. final int ivSize = cipher.getBlockSize(); final byte[] iv = new byte[ivSize]; inputDataStream.readFully(iv); final GCMParameterSpec params = new GCMParameterSpec(128, iv); cipher.init(Cipher.DECRYPT_MODE, key, params); cipher.updateAAD(keyAlias.getBytes()); // 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.update(data, 0, dataSize, plaintext, 0); cipher.doFinal(plaintext, outputLen); // Pass the plaintext into the subroutine for processing. return extractAndCheckDecryptedData(plaintext); } } 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); } catch (final GeneralSecurityException | IOException | DecoderException | IllegalArgumentException e) { log.error("Exception unwrapping data: {}", e.getMessage()); throw new DataSealerException("Exception unwrapping data", 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 DataInputStream dataInputStream = new DataInputStream(new GZIPInputStream(new ByteArrayInputStream(decryptedBytes)))) { 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, @Nonnull final Instant exp) throws DataSealerException { if (data == null || data.length() == 0) { throw new IllegalArgumentException("Data must be supplied for the wrapping operation"); } try { final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); final byte[] iv = new byte[cipher.getBlockSize()]; random.nextBytes(iv); final GCMParameterSpec params = new GCMParameterSpec(128, iv); final Pair defaultKey = keyStrategy.getDefaultKey(); cipher.init(Cipher.ENCRYPT_MODE, defaultKey.getSecond(), params); cipher.updateAAD(defaultKey.getFirst().getBytes()); try (final ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); final GZIPOutputStream compressedStream = new GZIPOutputStream(byteStream); final DataOutputStream dataStream = new DataOutputStream(compressedStream)) { dataStream.writeLong(exp.toEpochMilli()); 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.update(plaintext, 0, plaintext.length, encryptedData, 0); outputLen += cipher.doFinal(encryptedData, outputLen); try (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.getMessage()); 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(@Nullable final SecretKey key) throws DataSealerException { if (key == null) { throw new DataSealerException("Secret key was null"); } final String decrypted; try { final Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); final byte[] iv = new byte[cipher.getBlockSize()]; random.nextBytes(iv); final GCMParameterSpec params = new GCMParameterSpec(128, iv); cipher.init(Cipher.ENCRYPT_MODE, key, params); cipher.updateAAD("aad".getBytes(StandardCharsets.UTF_8)); byte[] plaintext = "test".getBytes(StandardCharsets.UTF_8); final byte[] encryptedData = new byte[cipher.getOutputSize(plaintext.length)]; int outputLen = cipher.update(plaintext, 0, plaintext.length, encryptedData, 0); cipher.doFinal(encryptedData, outputLen); cipher.init(Cipher.DECRYPT_MODE, key, params); cipher.updateAAD("aad".getBytes(StandardCharsets.UTF_8)); plaintext = new byte[cipher.getOutputSize(encryptedData.length)]; outputLen = cipher.update(encryptedData, 0, encryptedData.length, plaintext, 0); cipher.doFinal(plaintext, outputLen); decrypted = new String(plaintext, StandardCharsets.UTF_8); } catch (final IllegalStateException | GeneralSecurityException e) { log.error("Round trip encryption/decryption test unsuccessful: {}", e.getMessage()); 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 - 2025 Weber Informatics LLC | Privacy Policy