/* Copyright 2014 Duncan Jones * * 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. */ package org.cryptonode.jncryptor; import java.io.EOFException; import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.PushbackInputStream; import java.security.GeneralSecurityException; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.Mac; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; /** * Reads RNCryptor-format data in a stream fashion. This class only * supports the v3 data format. The entire stream must be read in order * to trigger the validation of the HMAC value. * * @since 1.1.0 */ public class AES256JNCryptorInputStream extends InputStream { private static final int END_OF_STREAM = -1; private final boolean isPasswordEncrypted; private final InputStream in; private char[] password; private SecretKey decryptionKey; private SecretKey hmacKey; private boolean endOfStreamHandled = false; private PushbackInputStream pushbackInputStream; private TrailerInputStream trailerIn; private Mac mac; /** * Creates an input stream for password-encrypted data. * * @param in * the {@code InputStream} to read * @param password * the password */ public AES256JNCryptorInputStream(InputStream in, char[] password) { isPasswordEncrypted = true; this.password = password; this.in = in; } /** * Creates an input stream for key-encrypted data. * * @param in * the {@code InputStream} to read * @param decryptionKey * the key to decrypt with * @param hmacKey * the key to calculate the HMAC with */ public AES256JNCryptorInputStream(InputStream in, SecretKey decryptionKey, SecretKey hmacKey) { isPasswordEncrypted = false; this.decryptionKey = decryptionKey; this.hmacKey = hmacKey; this.in = in; } /** * Mark and reset methods are not supported in this input stream. * * @return <code>false</code> */ @Override public boolean markSupported() { return false; } /** * Reads the header data, derives keys if necessary and creates the input * streams. * * @throws IOException * if an error occurs * @throws EOFException * if we run out of data before reading the header */ private void initializeStream() throws IOException { int headerDataSize; if (isPasswordEncrypted) { headerDataSize = AES256v3Ciphertext.HEADER_SIZE + AES256v3Ciphertext.ENCRYPTION_SALT_LENGTH + AES256v3Ciphertext.HMAC_SALT_LENGTH + AES256v3Ciphertext.AES_BLOCK_SIZE; } else { headerDataSize = AES256v3Ciphertext.HEADER_SIZE + AES256v3Ciphertext.AES_BLOCK_SIZE; } byte[] headerData = new byte[headerDataSize]; StreamUtils.readAllBytesOrFail(in, headerData); // throws EOF if insufficient data int offset = 0; byte version = headerData[offset++]; if (version != AES256v3Ciphertext.EXPECTED_VERSION) { throw new IOException(String.format("Expected version %d but found %d.", AES256v3Ciphertext.EXPECTED_VERSION, version)); } byte options = headerData[offset++]; if (isPasswordEncrypted) { if (options != AES256v3Ciphertext.FLAG_PASSWORD) { throw new IOException("Expected password flag missing."); } byte[] decryptionSalt = new byte[AES256v3Ciphertext.ENCRYPTION_SALT_LENGTH]; System.arraycopy(headerData, offset, decryptionSalt, 0, decryptionSalt.length); offset += decryptionSalt.length; byte[] hmacSalt = new byte[AES256v3Ciphertext.HMAC_SALT_LENGTH]; System.arraycopy(headerData, offset, hmacSalt, 0, hmacSalt.length); offset += hmacSalt.length; // Derive keys JNCryptor cryptor = new AES256JNCryptor(); try { decryptionKey = cryptor.keyForPassword(password, decryptionSalt); hmacKey = cryptor.keyForPassword(password, hmacSalt); } catch (CryptorException e) { throw new IOException("Failed to derive keys from password.", e); } } else { if (options != 0) { throw new IOException("Expected options byte to be zero."); } } byte[] iv = new byte[AES256v3Ciphertext.AES_BLOCK_SIZE]; System.arraycopy(headerData, offset, iv, 0, iv.length); trailerIn = new TrailerInputStream(in, AES256v3Ciphertext.HMAC_SIZE); try { Cipher decryptCipher = Cipher .getInstance(AES256JNCryptor.AES_CIPHER_ALGORITHM); decryptCipher.init(Cipher.DECRYPT_MODE, decryptionKey, new IvParameterSpec(iv)); mac = Mac.getInstance(AES256JNCryptor.HMAC_ALGORITHM); mac.init(hmacKey); // MAC the header mac.update(headerData); // The decryption stream will write the non-decrypted bytes to the mac // stream pushbackInputStream = new PushbackInputStream(new CipherInputStream( new MacUpdateInputStream(trailerIn, mac), decryptCipher), 1); } catch (GeneralSecurityException e) { throw new IOException("Failed to initiate cipher.", e); } } /** * Reads the next byte from the input stream. If this is the last byte in the * stream (determined by peeking ahead to the next byte), the value of the * HMAC is verified. If the verification fails an exception is thrown. * * @return the next byte from the input stream, or {@code -1} if the end of * the stream has been reached * @throws IOException * if an I/O error occurs. * @throws StreamIntegrityException * if the final byte has been read and the HMAC fails validation */ @Override public int read() throws IOException, StreamIntegrityException { if (trailerIn == null) { initializeStream(); } int result = pushbackInputStream.read(); return completeRead(result); } /** * The {@code read(b)} method for class {@code AES256JNCryptorInputStream} has * the same effect as: * <p> * {@code read(b, 0, b.length)} * * @param b * the buffer into which the data is read. * @return the total number of bytes read into the buffer, or {@code -1} if * there is no more data because the end of the stream has been * reached. * * @throws IOException * if an I/O error occurs. * @throws StreamIntegrityException * if the final byte has been read and the HMAC fails validation */ @Override public int read(byte[] b) throws IOException, StreamIntegrityException { Validate.notNull(b, "Array cannot be null."); return read(b, 0, b.length); } /** * Reads a number of bytes into the byte array. If this includes the last byte * in the stream (determined by peeking ahead to the next byte), the value of * the HMAC is verified. If the verification fails an exception is thrown. * * @param b * the buffer into which the data is read. * @param off * the start offset in array <code>b</code> at which the data is * written. * @param len * the maximum number of bytes to read. * @return the total number of bytes read into the buffer, or <code>-1</code> * if there is no more data because the end of the stream has been * reached. * @throws IOException * If the first byte cannot be read for any reason other than end of * file, or if the input stream has been closed, or if some other * I/O error occurs. * @throws NullPointerException * If <code>b</code> is <code>null</code>. * @throws IndexOutOfBoundsException * If <code>off</code> is negative, <code>len</code> is negative, or * <code>len</code> is greater than <code>b.length - off</code> * @throws StreamIntegrityException * if the final byte has been read and the HMAC fails validation */ @Override public int read(byte[] b, int off, int len) throws IOException { Validate.notNull(b, "Byte array cannot be null."); Validate.isTrue(off >= 0, "Offset cannot be negative."); Validate.isTrue(len >= 0, "Length cannot be negative."); Validate.isTrue(len + off <= b.length, "Length plus offset cannot be longer than byte array."); if (len == 0) { return 0; } if (trailerIn == null) { initializeStream(); } int result = pushbackInputStream.read(b, off, len); return completeRead(result); } /** * Updates the HMAC value and handles the end of stream. * * @param b * the result of a read operation * @return the value {@code b} * @throws IOException * @throws StreamIntegrityException */ private int completeRead(int b) throws IOException, StreamIntegrityException { if (b == END_OF_STREAM) { handleEndOfStream(); } else { // Have we reached the end of the stream? int c = pushbackInputStream.read(); if (c == END_OF_STREAM) { handleEndOfStream(); } else { pushbackInputStream.unread(c); } } return b; } /** * Verifies the HMAC value and throws an exception if it fails. * * @throws IOException * if the HMAC value is incorrect */ private void handleEndOfStream() throws StreamIntegrityException { if (endOfStreamHandled) { return; } endOfStreamHandled = true; byte[] originalHMAC = trailerIn.getTrailer(); byte[] calculateHMAC = mac.doFinal(); if (! AES256JNCryptor.arraysEqual(originalHMAC, calculateHMAC)) { throw new StreamIntegrityException("MAC validation failed."); } } /** * Closes the underlying input stream. */ @Override public void close() throws IOException { try { closeIfNotNull(pushbackInputStream); } finally { closeIfNotNull(trailerIn); } } private static void closeIfNotNull(InputStream in) throws IOException { if (in != null) { in.close(); } } private static class MacUpdateInputStream extends FilterInputStream { Mac mac; private MacUpdateInputStream(InputStream in, Mac mac) { super(in); this.mac = mac; } public int read() throws IOException { int b = super.read(); if (b >= 0) mac.update((byte)b); return b; } public int read(byte[] b, int off, int len) throws IOException { int n = super.read(b, off, len); if (n > 0) mac.update(b, off, n); return n; } } }