/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com> 
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package org.syncany.crypto;

import static org.syncany.crypto.CipherParams.CRYPTO_PROVIDER_ID;

import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;

import javax.crypto.Mac;
import javax.crypto.SecretKey;

public class MultiCipherInputStream extends InputStream {
	private InputStream underlyingInputStream;

	private InputStream cipherInputStream;
	private CipherSession cipherSession;
	
	private boolean headerRead;
	private Mac headerHmac;
		
	public MultiCipherInputStream(InputStream in, CipherSession cipherSession) throws IOException {
		this.underlyingInputStream = in;		

		this.cipherInputStream = null;
		this.cipherSession = cipherSession;
		
		this.headerRead = false;		
		this.headerHmac = null;		
	}

	@Override
	public int read() throws IOException {
		readHeader();
		return cipherInputStream.read();
	}
	
	@Override
	public int read(byte[] b) throws IOException {
		readHeader();
		return cipherInputStream.read(b, 0, b.length);
	}
	
	@Override
	public int read(byte[] b, int off, int len) throws IOException {
		readHeader();
		return cipherInputStream.read(b, off, len);
	}
	
	@Override
	public void close() throws IOException {
		cipherInputStream.close();
	}	
	
	private void readHeader() throws IOException {
		if (!headerRead) {
			try {
				readAndVerifyMagicNoHmac(underlyingInputStream);
				readAndVerifyVersionNoHmac(underlyingInputStream);

				headerHmac = readHmacSaltAndInitHmac(underlyingInputStream, cipherSession);				
				cipherInputStream = readCipherSpecsAndUpdateHmac(underlyingInputStream, headerHmac, cipherSession);

				readAndVerifyHmac(underlyingInputStream, headerHmac);			
			}
			catch (Exception e) {
				throw new IOException(e);
			}
			
			headerRead = true;
		}
	}

	private void readAndVerifyMagicNoHmac(InputStream inputStream) throws IOException {
		byte[] streamMagic = new byte[MultiCipherOutputStream.STREAM_MAGIC.length];
		inputStream.read(streamMagic);
		
		if (!Arrays.equals(MultiCipherOutputStream.STREAM_MAGIC, streamMagic)) {
			throw new IOException("Not a Syncany-encrypted file, no magic!");
		}
	}

	private void readAndVerifyVersionNoHmac(InputStream inputStream) throws IOException {
		byte streamVersion = (byte) inputStream.read();
		
		if (streamVersion != MultiCipherOutputStream.STREAM_VERSION) {
			throw new IOException("Stream version not supported: "+streamVersion);
		}		
	}
	
	private Mac readHmacSaltAndInitHmac(InputStream inputStream, CipherSession cipherSession) throws Exception {
		byte[] hmacSalt = readNoHmac(inputStream, MultiCipherOutputStream.SALT_SIZE);
		SecretKey hmacSecretKey = cipherSession.getReadSecretKey(MultiCipherOutputStream.HMAC_SPEC, hmacSalt);
		
		Mac hmac = Mac.getInstance(MultiCipherOutputStream.HMAC_SPEC.getAlgorithm(), CRYPTO_PROVIDER_ID);
		hmac.init(hmacSecretKey);	
		
		return hmac;
	}
	
	private InputStream readCipherSpecsAndUpdateHmac(InputStream underlyingInputStream, Mac hmac, CipherSession cipherSession) throws Exception {
		int cipherSpecCount = readByteAndUpdateHmac(underlyingInputStream, hmac);		
		InputStream nestedCipherInputStream = underlyingInputStream;
		
		for (int i=0; i<cipherSpecCount; i++) {
			int cipherSpecId = readByteAndUpdateHmac(underlyingInputStream, hmac);				
			CipherSpec cipherSpec = CipherSpecs.getCipherSpec(cipherSpecId);
			
			if (cipherSpec == null) {
				throw new IOException("Cannot find cipher spec with ID "+cipherSpecId);
			}

			byte[] salt = readAndUpdateHmac(underlyingInputStream, MultiCipherOutputStream.SALT_SIZE, hmac);
			byte[] iv = readAndUpdateHmac(underlyingInputStream, cipherSpec.getIvSize()/8, hmac);
			
			SecretKey secretKey = cipherSession.getReadSecretKey(cipherSpec, salt);			
			nestedCipherInputStream = cipherSpec.newCipherInputStream(nestedCipherInputStream, secretKey.getEncoded(), iv);		
		}	 
		
		return nestedCipherInputStream;
	}

	private void readAndVerifyHmac(InputStream inputStream, Mac hmac) throws Exception {
		byte[] calculatedHeaderHmac = hmac.doFinal();
		byte[] readHeaderHmac = readNoHmac(inputStream, calculatedHeaderHmac.length);
		
		if (!Arrays.equals(calculatedHeaderHmac, readHeaderHmac)) {
			throw new Exception("Integrity exception: Calculated HMAC and read HMAC do not match.");
		}			
	}

	private byte[] readNoHmac(InputStream inputStream, int size) throws IOException {
		byte[] bytes = new byte[size];		
		inputStream.read(bytes);	
		
		return bytes;
	}

	private byte[] readAndUpdateHmac(InputStream inputStream, int size, Mac hmac) throws IOException {
		byte[] bytes = readNoHmac(inputStream, size);		
		hmac.update(bytes);
		
		return bytes;
	}

	private int readByteAndUpdateHmac(InputStream inputStream, Mac hmac) throws IOException {
		int abyte = inputStream.read();
		hmac.update((byte) abyte);
		
		return abyte;
	}
}