/*******************************************************************************
 * Copyright (c) 2007, 2019 IBM Corporation and others.
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which accompanies this distribution,
 * and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors: IBM Corporation - initial API and implementation
 ******************************************************************************/
package org.eclipse.osgi.internal.signedcontent;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SignatureException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.eclipse.osgi.framework.log.FrameworkLogEntry;
import org.eclipse.osgi.signedcontent.SignerInfo;
import org.eclipse.osgi.storage.bundlefile.BundleEntry;
import org.eclipse.osgi.storage.bundlefile.BundleFile;
import org.eclipse.osgi.util.NLS;

public class SignatureBlockProcessor implements SignedContentConstants {
	private final SignedBundleFile signedBundle;
	private List<SignerInfo> signerInfos = new ArrayList<>();
	private Map<String, Object> contentMDResults = new HashMap<>();
	// map of tsa singers keyed by SignerInfo -> {tsa_SignerInfo, signingTime}
	private Map<SignerInfo, Object[]> tsaSignerInfos;
	private final int supportFlags;
	private final SignedBundleHook signedBundleHook;

	public SignatureBlockProcessor(SignedBundleFile signedContent, int supportFlags, SignedBundleHook signedBundleHook) {
		this.signedBundle = signedContent;
		this.supportFlags = supportFlags;
		this.signedBundleHook = signedBundleHook;
	}

	public SignedContentImpl process() throws IOException, InvalidKeyException, SignatureException, CertificateException, NoSuchAlgorithmException, NoSuchProviderException {
		BundleFile wrappedBundleFile = signedBundle.getBundleFile();
		BundleEntry be = wrappedBundleFile.getEntry(META_INF_MANIFEST_MF);
		if (be == null)
			return createUnsignedContent();

		// read all the signature block file names into a list
		Enumeration<String> en = wrappedBundleFile.getEntryPaths(META_INF);
		List<String> signers = new ArrayList<>(2);
		while (en.hasMoreElements()) {
			String name = en.nextElement();
			if ((name.endsWith(DOT_DSA) || name.endsWith(DOT_RSA)) && name.indexOf('/') == name.lastIndexOf('/'))
				signers.add(name);
		}

		// this means the jar is not signed
		if (signers.size() == 0)
			return createUnsignedContent();

		byte manifestBytes[] = readIntoArray(be);
		// process the signers

		for (Iterator<String> iSigners = signers.iterator(); iSigners.hasNext();)
			processSigner(wrappedBundleFile, manifestBytes, iSigners.next());

		// done processing now create a SingedContent to return
		SignerInfo[] allSigners = signerInfos.toArray(new SignerInfo[signerInfos.size()]);
		for (Map.Entry<String, Object> entry : contentMDResults.entrySet()) {
			@SuppressWarnings("unchecked")
			List<Object>[] value = (List<Object>[]) entry.getValue();
			SignerInfo[] entrySigners = value[0].toArray(new SignerInfo[value[0].size()]);
			byte[][] entryResults = value[1].toArray(new byte[value[1].size()][]);
			entry.setValue(new Object[] {entrySigners, entryResults});
		}
		SignedContentImpl result = new SignedContentImpl(allSigners, (supportFlags & SignedBundleHook.VERIFY_RUNTIME) != 0 ? contentMDResults : null);
		result.setContent(signedBundle);
		result.setTSASignerInfos(tsaSignerInfos);
		return result;
	}

	private SignedContentImpl createUnsignedContent() {
		SignedContentImpl result = new SignedContentImpl(new SignerInfo[0], contentMDResults);
		result.setContent(signedBundle);
		return result;
	}

	private void processSigner(BundleFile bf, byte[] manifestBytes, String signer) throws IOException, SignatureException, InvalidKeyException, CertificateException, NoSuchAlgorithmException, NoSuchProviderException {
		BundleEntry be = bf.getEntry(signer);
		byte pkcs7Bytes[] = readIntoArray(be);
		int dotIndex = signer.lastIndexOf('.');
		be = bf.getEntry(signer.substring(0, dotIndex) + DOT_SF);
		byte sfBytes[] = readIntoArray(be);

		// Step 1, verify the .SF file is signed by the private key that corresponds to the public key
		// in the .RSA/.DSA file
		String baseFile = String.valueOf(bf.getBaseFile());
		PKCS7Processor processor = new PKCS7Processor(pkcs7Bytes, 0, pkcs7Bytes.length, signer, baseFile);
		// call the Step 1 in the Jar File Verification algorithm
		processor.verifySFSignature(sfBytes, 0, sfBytes.length);
		// algorithm used
		String digAlg = getDigAlgFromSF(sfBytes);
		if (digAlg == null)
			throw new SignatureException(NLS.bind(SignedContentMessages.SF_File_Parsing_Error, new String[] {bf.toString()}));
		// get the digest results
		// Process the Step 2 in the Jar File Verification algorithm
		// Get the manifest out of the signature file and make sure
		// it matches MANIFEST.MF
		verifyManifestAndSignatureFile(manifestBytes, sfBytes);

		// create a SignerInfo with the processed information
		SignerInfoImpl signerInfo = new SignerInfoImpl(processor.getCertificates(), null, digAlg);
		if ((supportFlags & SignedBundleHook.VERIFY_RUNTIME) != 0)
			// only populate the manifests digest information for verifying content at runtime
			populateMDResults(manifestBytes, signerInfo);
		signerInfos.add(signerInfo);
		// check for tsa signers
		Certificate[] tsaCerts = processor.getTSACertificates();
		Date signingTime = processor.getSigningTime();
		if (tsaCerts != null && signingTime != null) {
			SignerInfoImpl tsaSignerInfo = new SignerInfoImpl(tsaCerts, null, digAlg);
			if (tsaSignerInfos == null)
				tsaSignerInfos = new HashMap<>(2);
			tsaSignerInfos.put(signerInfo, new Object[] {tsaSignerInfo, signingTime});
		}
	}

	/**
	 * Verify the digest listed in each entry in the .SF file with corresponding section in the manifest
	 * @throws SignatureException
	 */
	private void verifyManifestAndSignatureFile(byte[] manifestBytes, byte[] sfBytes) throws SignatureException {

		String sf = new String(sfBytes, StandardCharsets.UTF_8);
		sf = stripContinuations(sf);

		// check if there -Digest-Manfiest: header in the file
		int off = sf.indexOf(digestManifestSearch);
		if (off != -1) {
			int start = sf.lastIndexOf('\n', off);
			String manifestDigest = null;
			if (start != -1) {
				// Signature-Version has to start the file, so there
				// should always be a newline at the start of
				// Digest-Manifest
				String digestName = sf.substring(start + 1, off);
				if (digestName.equalsIgnoreCase(MD5_STR))
					manifestDigest = calculateDigest(getMessageDigest(MD5_STR), manifestBytes);
				else if (digestName.equalsIgnoreCase(SHA1_STR))
					manifestDigest = calculateDigest(getMessageDigest(SHA1_STR), manifestBytes);
				else
					manifestDigest = calculateDigest(getMessageDigest(digestName), manifestBytes);
				off += digestManifestSearchLen;

				// find out the index of first '\n' after the -Digest-Manifest:
				int nIndex = sf.indexOf('\n', off);
				String digestValue = sf.substring(off, nIndex - 1);

				// check if the the computed digest value of manifest file equals to the digest value in the .sf file
				if (!digestValue.equals(manifestDigest)) {
					SignatureException se = new SignatureException(NLS.bind(SignedContentMessages.Security_File_Is_Tampered, new String[] {String.valueOf(signedBundle.getBaseFile())}));
					signedBundleHook.log(se.getMessage(), FrameworkLogEntry.ERROR, se);
					throw se;
				}
			}
		}
	}

	private void populateMDResults(byte mfBuf[], SignerInfo signerInfo) {
		// need to make a string from the MF file data bytes
		String mfStr = new String(mfBuf, StandardCharsets.UTF_8);

		// start parsing each entry in the MF String
		int entryStartOffset = mfStr.indexOf(MF_ENTRY_NEWLN_NAME);
		int length = mfStr.length();

		while ((entryStartOffset != -1) && (entryStartOffset < length)) {

			// get the start of the next 'entry', i.e. the end of this entry
			int entryEndOffset = mfStr.indexOf(MF_ENTRY_NEWLN_NAME, entryStartOffset + 1);
			if (entryEndOffset == -1) {
				// if there is no next entry, then the end of the string
				// is the end of this entry
				entryEndOffset = mfStr.length();
			}

			// get the string for this entry only, since the entryStartOffset
			// points to the '\n' before the 'Name: ' we increase it by 1
			// this is guaranteed to not go past end-of-string and be less
			// then entryEndOffset.
			String entryStr = mfStr.substring(entryStartOffset + 1, entryEndOffset);
			entryStr = stripContinuations(entryStr);

			// increment the offset to the ending entry for the next iteration of the loop ...
			entryStartOffset = entryEndOffset;

			// entry points to the start of the next 'entry'
			String entryName = getEntryFileName(entryStr);

			// if we could retrieve an entry name, then we will extract
			// digest type list, and the digest value list
			if (entryName != null) {
				String aDigestLine = getDigestLine(entryStr, signerInfo.getMessageDigestAlgorithm());

				if (aDigestLine != null) {
					String msgDigestAlgorithm = getDigestAlgorithmFromString(aDigestLine);
					if (!msgDigestAlgorithm.equalsIgnoreCase(signerInfo.getMessageDigestAlgorithm()))
						continue; // TODO log error?
					byte digestResult[] = getDigestResultsList(aDigestLine);

					//
					// only insert this entry into the table if its
					// "well-formed",
					// i.e. only if we could extract its name, digest types, and
					// digest-results
					//
					// sanity check, if the 2 lists are non-null, then their
					// counts must match
					//
					//					if ((msgDigestObj != null) && (digestResultsList != null)
					//							&& (1 != digestResultsList.length)) {
					//						throw new RuntimeException(
					//								"Errors occurs when parsing the manifest file stream!"); //$NON-NLS-1$
					//					}
					@SuppressWarnings("unchecked")
					List<Object>[] mdResult = (List<Object>[]) contentMDResults.get(entryName);
					if (mdResult == null) {
						@SuppressWarnings("unchecked")
						List<Object>[] arrayLists = new ArrayList[2];
						mdResult = arrayLists;
						mdResult[0] = new ArrayList<>();
						mdResult[1] = new ArrayList<>();
						contentMDResults.put(entryName, mdResult);
					}
					mdResult[0].add(signerInfo);
					mdResult[1].add(digestResult);
				} // could get lines of digest entries in this MF file entry
			} // could retrieve entry name
		}
	}

	private static byte[] getDigestResultsList(String digestLines) {
		byte resultsList[] = null;
		if (digestLines != null) {
			// for each digest-line retrieve the digest result
			// for (int i = 0; i < digestLines.length; i++) {
			String sDigestLine = digestLines;
			int indexDigest = sDigestLine.indexOf(MF_DIGEST_PART);
			indexDigest += MF_DIGEST_PART.length();
			// if there is no data to extract for this digest value
			// then we will fail...
			if (indexDigest >= sDigestLine.length()) {
				resultsList = null;
				// break;
			}
			// now attempt to base64 decode the result
			String sResult = sDigestLine.substring(indexDigest);
			try {
				resultsList = Base64.decode(sResult.getBytes());
			} catch (Throwable t) {
				// malformed digest result, no longer processing this entry
				resultsList = null;
			}
		}
		return resultsList;
	}

	private static String getDigestAlgorithmFromString(String digestLines) {
		if (digestLines != null) {
			// String sDigestLine = digestLines[i];
			int indexDigest = digestLines.indexOf(MF_DIGEST_PART);
			String sDigestAlgType = digestLines.substring(0, indexDigest);
			if (sDigestAlgType.equalsIgnoreCase(MD5_STR)) {
				// remember the "algorithm type"
				return MD5_STR;
			} else if (sDigestAlgType.equalsIgnoreCase(SHA1_STR)) {
				// remember the "algorithm type" object
				return SHA1_STR;
			} else {
				return sDigestAlgType;
			}
		}
		return null;
	}

	private static String getEntryFileName(String manifestEntry) {
		// get the beginning of the name
		int nameStart = manifestEntry.indexOf(MF_ENTRY_NAME);
		if (nameStart == -1) {
			return null;
		}
		// check where the name ends
		int nameEnd = manifestEntry.indexOf('\n', nameStart);
		if (nameEnd == -1) {
			return null;
		}
		// if there is a '\r' before the '\n', then we'll strip it
		if (manifestEntry.charAt(nameEnd - 1) == '\r') {
			nameEnd--;
		}
		// get to the beginning of the actual name...
		nameStart += MF_ENTRY_NAME.length();
		if (nameStart >= nameEnd) {
			return null;
		}
		return manifestEntry.substring(nameStart, nameEnd);
	}

	/**
	 * Returns the Base64 encoded digest of the passed set of bytes.
	 */
	private static String calculateDigest(MessageDigest digest, byte[] bytes) {
		return new String(Base64.encode(digest.digest(bytes)), StandardCharsets.UTF_8);
	}

	synchronized MessageDigest getMessageDigest(String algorithm) {
		try {
			return MessageDigest.getInstance(algorithm);
		} catch (NoSuchAlgorithmException e) {
			signedBundleHook.log(e.getMessage(), FrameworkLogEntry.ERROR, e);
		}
		return null;
	}

	/**
	 * Read the .SF file abd assuming that same digest algorithm will be used through out the whole
	 * .SF file.  That digest algorithm name in the last entry will be returned.
	 *
	 * @param SFBuf			a .SF file in bytes
	 * @return				the digest algorithm name used in the .SF file
	 */
	private static String getDigAlgFromSF(byte SFBuf[]) {
		// need to make a string from the MF file data bytes
		String mfStr = new String(SFBuf, StandardCharsets.UTF_8);
		String entryStr = null;

		// start parsing each entry in the MF String
		int entryStartOffset = mfStr.indexOf(MF_ENTRY_NEWLN_NAME);
		int length = mfStr.length();

		if ((entryStartOffset != -1) && (entryStartOffset < length)) {

			// get the start of the next 'entry', i.e. the end of this entry
			int entryEndOffset = mfStr.indexOf(MF_ENTRY_NEWLN_NAME, entryStartOffset + 1);
			if (entryEndOffset == -1) {
				// if there is no next entry, then the end of the string
				// is the end of this entry
				entryEndOffset = mfStr.length();
			}

			// get the string for this entry only, since the entryStartOffset
			// points to the '\n' before the 'Name: ' we increase it by 1
			// this is guaranteed to not go past end-of-string and be less
			// then entryEndOffset.
			entryStr = mfStr.substring(entryStartOffset + 1, entryEndOffset);
			entryStr = stripContinuations(entryStr);
		}

		if (entryStr != null) {
			// process the entry to retrieve the digest algorith name
			String digestLine = getDigestLine(entryStr, null);

			// throw parsing
			return getMessageDigestName(digestLine);
		}
		return null;
	}

	/**
	 *
	 * @param manifestEntry contains a single MF file entry of the format
	 * 				   "Name: foo"
	 * 				   "MD5-Digest: [base64 encoded MD5 digest data]"
	 * 				   "SHA1-Digest: [base64 encoded SHA1 digest dat]"
	 *
	 * @param	desireDigestAlg	a string representing the desire digest value to be returned if there are
	 * 							multiple digest lines.
	 * 							If this value is null, return whatever digest value is in the entry.
	 *
	 * @return this function returns a digest line based on the desire digest algorithm value
	 * 		   (since only MD5 and SHA1 are recognized here),
	 * 		   or a 'null' will be returned if none of the digest algorithms
	 * 		   were recognized.
	 */
	private static String getDigestLine(String manifestEntry, String desireDigestAlg) {
		String result = null;

		// find the first digest line
		int indexDigest = manifestEntry.indexOf(MF_DIGEST_PART);
		// if we didn't find any digests at all, then we are done
		if (indexDigest == -1)
			return null;

		// while we continue to find digest entries
		// note: in the following loop we bail if any of the lines
		//		 look malformed...
		while (indexDigest != -1) {
			// see where this digest line begins (look to left)
			int indexStart = manifestEntry.lastIndexOf('\n', indexDigest);
			if (indexStart == -1)
				return null;
			// see where it ends (look to right)
			int indexEnd = manifestEntry.indexOf('\n', indexDigest);
			if (indexEnd == -1)
				return null;
			// strip off ending '\r', if any
			int indexEndToUse = indexEnd;
			if (manifestEntry.charAt(indexEndToUse - 1) == '\r')
				indexEndToUse--;
			// indexStart points to the '\n' before this digest line
			int indexStartToUse = indexStart + 1;
			if (indexStartToUse >= indexEndToUse)
				return null;

			// now this may be a valid digest line, parse it a bit more
			// to see if this is a preferred digest algorithm
			String digestLine = manifestEntry.substring(indexStartToUse, indexEndToUse);
			String digAlg = getMessageDigestName(digestLine);
			if (desireDigestAlg != null) {
				if (desireDigestAlg.equalsIgnoreCase(digAlg))
					return digestLine;
			}

			// desireDigestAlg is null, always return the digestLine
			result = digestLine;

			// iterate to next digest line in this entry
			indexDigest = manifestEntry.indexOf(MF_DIGEST_PART, indexEnd);
		}

		// if we couldn't find any digest lines, then we are done
		return result;
	}

	/**
	 * Return the Message Digest name
	 *
	 * @param digLine		the message digest line is in the following format.  That is in the
	 * 						following format:
	 * 								DIGEST_NAME-digest: digest value
	 * @return				a string representing a message digest.
	 */
	private static String getMessageDigestName(String digLine) {
		String rtvValue = null;
		if (digLine != null) {
			int indexDigest = digLine.indexOf(MF_DIGEST_PART);
			if (indexDigest != -1) {
				rtvValue = digLine.substring(0, indexDigest);
			}
		}
		return rtvValue;
	}

	private static String stripContinuations(String entry) {
		if (entry.indexOf("\n ") < 0 && entry.indexOf("\r ") < 0) //$NON-NLS-1$//$NON-NLS-2$
			return entry;
		StringBuilder buffer = new StringBuilder(entry);
		removeAll(buffer, "\r\n "); //$NON-NLS-1$
		removeAll(buffer, "\n "); //$NON-NLS-1$
		removeAll(buffer, "\r "); //$NON-NLS-1$
		return buffer.toString();
	}

	private static StringBuilder removeAll(StringBuilder buffer, String toRemove) {
		int index = buffer.indexOf(toRemove);
		int length = toRemove.length();
		while (index > 0) {
			buffer.replace(index, index + length, ""); //$NON-NLS-1$
			index = buffer.indexOf(toRemove, index);
		}
		return buffer;
	}

	private static byte[] readIntoArray(BundleEntry be) throws IOException {
		int size = (int) be.getSize();
		InputStream is = be.getInputStream();
		try {
			byte b[] = new byte[size];
			int rc = readFully(is, b);
			if (rc != size) {
				throw new IOException("Couldn't read all of " + be.getName() + ": " + rc + " != " + size); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
			}
			return b;
		} finally {
			try {
				is.close();
			} catch (IOException e) {
				// do nothing;
			}
		}
	}

	private static int readFully(InputStream is, byte b[]) throws IOException {
		int count = b.length;
		int offset = 0;
		int rc;
		while ((rc = is.read(b, offset, count)) > 0) {
			count -= rc;
			offset += rc;
		}
		return offset;
	}
}
