/*******************************************************************************
 * Copyright (c) 2007, 2016 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.service.security;

import java.io.*;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.*;
import org.eclipse.osgi.framework.log.FrameworkLogEntry;
import org.eclipse.osgi.internal.signedcontent.SignedBundleHook;
import org.eclipse.osgi.internal.signedcontent.SignedContentMessages;
import org.eclipse.osgi.service.security.TrustEngine;
import org.eclipse.osgi.util.NLS;

//*potential enhancements*
// 1. reloading from the backing file when it changes
// 3. methods to support lock/unlock
//  3a. Using a callback handler to collect the password
//  3b. managing lock/unlock between multiple threads. dealing with SWT UI thread
// 4. methods to support changing password, etc
// 5. methods to support export, etc
// 6. 'friendly-name' generator
// 7. Listeners for change events
public class KeyStoreTrustEngine extends TrustEngine {

	private KeyStore keyStore;

	private final String type;
	private final String path;
	private final char[] password;
	private final String name;

	private final SignedBundleHook signedBundleHook;

	/**
	 * Create a new KeyStoreTrustEngine that is backed by a KeyStore
	 * @param path - path to the keystore
	 * @param type - the type of keystore at the path location
	 * @param password - the password required to unlock the keystore
	 */
	public KeyStoreTrustEngine(String path, String type, char[] password, String name, SignedBundleHook signedBundleHook) { //TODO: This should be a *CallbackHandler*
		this.path = path;
		this.type = type;
		this.password = password;
		this.name = name;
		this.signedBundleHook = signedBundleHook;
	}

	/**
	 * Return the type
	 * @return type - the type for the KeyStore being managed
	 */
	private String getType() {
		return type;
	}

	/**
	 * Return the path
	 * @return - the path for the KeyStore being managed
	 */
	private String getPath() {
		return path;
	}

	/**
	 * Return the password
	 * @return password - the password as a char[]
	 */
	private char[] getPassword() {
		return password;
	}

	/**
	 * Return the KeyStore managed
	 * @return The KeyStore instance, initialized and loaded
	 * @throws KeyStoreException
	 */
	private synchronized KeyStore getKeyStore() throws IOException, GeneralSecurityException {
		if (null == keyStore) {
			keyStore = KeyStore.getInstance(getType());
			final InputStream in = getInputStream();
			try {
				loadStore(keyStore, in);
			} finally {
				try {
					in.close();
				} catch (IOException e) {
					//ignore secondary failure
				}
			}
		}

		if (keyStore == null)
			throw new KeyStoreException(NLS.bind(SignedContentMessages.Default_Trust_Keystore_Load_Failed, getPath()));

		return keyStore;
	}

	@Override
	public Certificate findTrustAnchor(Certificate[] certChain) throws IOException {

		if (certChain == null || certChain.length == 0)
			throw new IllegalArgumentException("Certificate chain is required"); //$NON-NLS-1$

		try {
			Certificate rootCert = null;
			KeyStore store = getKeyStore();
			for (int i = 0; i < certChain.length; i++) {
				if (certChain[i] instanceof X509Certificate) {
					if (i == certChain.length - 1) {
						// this is the last certificate in the chain
						// determine if we have a valid root
						X509Certificate cert = (X509Certificate) certChain[i];
						if (cert.getSubjectDN().equals(cert.getIssuerDN())) {
							cert.verify(cert.getPublicKey());
							rootCert = cert; // this is a self-signed certificate
						} else {
							// try to find a parent, we have an incomplete chain
							return findAlternativeRoot(cert, store);
						}
					} else {
						X509Certificate nextX509Cert = (X509Certificate) certChain[i + 1];
						certChain[i].verify(nextX509Cert.getPublicKey());
					}
				}

				synchronized (store) {
					String alias = rootCert == null ? null : store.getCertificateAlias(rootCert);
					if (alias != null)
						return store.getCertificate(alias);
					else if (rootCert != certChain[i]) {
						alias = store.getCertificateAlias(certChain[i]);
						if (alias != null)
							return store.getCertificate(alias);
					}
					// if we have reached the end and the last cert is not found to be a valid root CA
					// then we need to back off the root CA and try to find an alternative
					if (certChain.length > 1 && i == certChain.length - 1 && certChain[i - 1] instanceof X509Certificate)
						return findAlternativeRoot((X509Certificate) certChain[i - 1], store);
				}
			}
		} catch (KeyStoreException e) {
			throw new IOException(e.getMessage(), e);
		} catch (GeneralSecurityException e) {
			if (signedBundleHook != null) {
				signedBundleHook.log(e.getMessage(), FrameworkLogEntry.WARNING, e);
			}
			return null;
		}
		return null;
	}

	private Certificate findAlternativeRoot(X509Certificate cert, KeyStore store) throws InvalidKeyException, KeyStoreException, NoSuchAlgorithmException, NoSuchProviderException, SignatureException, CertificateException {
		synchronized (store) {
			for (Enumeration<String> e = store.aliases(); e.hasMoreElements();) {
				Certificate nextCert = store.getCertificate(e.nextElement());
				if (nextCert instanceof X509Certificate && ((X509Certificate) nextCert).getSubjectDN().equals(cert.getIssuerDN())) {
					cert.verify(nextCert.getPublicKey());
					return nextCert;
				}
			}
			return null;
		}
	}

	@Override
	protected String doAddTrustAnchor(Certificate cert, String alias) throws IOException, GeneralSecurityException {
		if (isReadOnly())
			throw new IOException(SignedContentMessages.Default_Trust_Read_Only);
		if (cert == null) {
			throw new IllegalArgumentException("Certificate must be specified"); //$NON-NLS-1$
		}
		try {
			KeyStore store = getKeyStore();
			synchronized (store) {
				String oldAlias = store.getCertificateAlias(cert);
				if (null != oldAlias)
					throw new CertificateException(SignedContentMessages.Default_Trust_Existing_Cert);
				Certificate oldCert = store.getCertificate(alias);
				if (null != oldCert)
					throw new CertificateException(SignedContentMessages.Default_Trust_Existing_Alias);
				store.setCertificateEntry(alias, cert);
				final OutputStream out = getOutputStream();
				try {
					saveStore(store, out);
				} finally {
					safeClose(out);
				}
			}
		} catch (KeyStoreException ke) {
			throw (CertificateException) new CertificateException(ke.getMessage()).initCause(ke);
		}
		return alias;
	}

	@Override
	protected void doRemoveTrustAnchor(Certificate cert) throws IOException, GeneralSecurityException {
		if (isReadOnly())
			throw new IOException(SignedContentMessages.Default_Trust_Read_Only);
		if (cert == null) {
			throw new IllegalArgumentException("Certificate must be specified"); //$NON-NLS-1$
		}
		try {
			KeyStore store = getKeyStore();
			synchronized (store) {
				String alias = store.getCertificateAlias(cert);
				if (alias == null) {
					throw new CertificateException(SignedContentMessages.Default_Trust_Cert_Not_Found);
				}
				removeTrustAnchor(alias);
			}
		} catch (KeyStoreException ke) {
			throw (CertificateException) new CertificateException(ke.getMessage()).initCause(ke);
		}
	}

	@Override
	protected void doRemoveTrustAnchor(String alias) throws IOException, GeneralSecurityException {

		if (alias == null) {
			throw new IllegalArgumentException("Alias must be specified"); //$NON-NLS-1$
		}
		try {
			KeyStore store = getKeyStore();
			synchronized (store) {
				Certificate oldCert = store.getCertificate(alias);
				if (oldCert == null)
					throw new CertificateException(SignedContentMessages.Default_Trust_Cert_Not_Found);
				store.deleteEntry(alias);
				final OutputStream out = getOutputStream();
				try {
					saveStore(store, out);
				} finally {
					safeClose(out);
				}
			}
		} catch (KeyStoreException ke) {
			throw (CertificateException) new CertificateException(ke.getMessage()).initCause(ke);
		}
	}

	@Override
	public Certificate getTrustAnchor(String alias) throws IOException, GeneralSecurityException {

		if (alias == null) {
			throw new IllegalArgumentException("Alias must be specified"); //$NON-NLS-1$
		}

		try {
			KeyStore store = getKeyStore();
			synchronized (store) {
				return store.getCertificate(alias);
			}
		} catch (KeyStoreException ke) {
			throw (CertificateException) new CertificateException(ke.getMessage()).initCause(ke);
		}
	}

	@Override
	public String[] getAliases() throws IOException, GeneralSecurityException {

		List<String> returnList = new ArrayList<>();
		try {
			KeyStore store = getKeyStore();
			synchronized (store) {
				for (Enumeration<String> aliases = store.aliases(); aliases.hasMoreElements();) {
					String currentAlias = aliases.nextElement();
					if (store.isCertificateEntry(currentAlias)) {
						returnList.add(currentAlias);
					}
				}
			}
		} catch (KeyStoreException ke) {
			throw (CertificateException) new CertificateException(ke.getMessage()).initCause(ke);
		}
		return returnList.toArray(new String[] {});
	}

	/**
	 * Load using the current password
	 */
	private void loadStore(KeyStore store, InputStream is) throws IOException, GeneralSecurityException {
		store.load(is, getPassword());
	}

	/**
	 * Save using the current password
	 */
	private void saveStore(KeyStore store, OutputStream os) throws IOException, GeneralSecurityException {
		store.store(os, getPassword());
	}

	/**
	 * Closes a stream and ignores any resulting exception. This is useful
	 * when doing stream cleanup in a finally block where secondary exceptions
	 * are not worth logging.
	 */
	private void safeClose(OutputStream out) {
		try {
			if (out != null)
				out.close();
		} catch (IOException e) {
			//ignore
		}
	}

	/**
	 * Get an input stream for the KeyStore managed
	 * @return inputstream - the stream
	 * @throws KeyStoreException
	 */
	private InputStream getInputStream() throws IOException {
		return new FileInputStream(new File(getPath()));
	}

	/**
	 * Get an output stream for the KeyStore managed
	 * @return outputstream - the stream
	 * @throws KeyStoreException
	 */
	private OutputStream getOutputStream() throws IOException {

		File file = new File(getPath());
		if (!file.exists())
			file.createNewFile();

		return new FileOutputStream(file);
	}

	@Override
	public boolean isReadOnly() {
		return getPassword() == null || !(new File(path).canWrite());
	}

	@Override
	public String getName() {
		return name;
	}
}
