WalkEncryption.java

/*
 * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */

package org.eclipse.jgit.transport;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.security.AlgorithmParameters;
import java.security.GeneralSecurityException;
import java.security.spec.AlgorithmParameterSpec;
import java.security.spec.KeySpec;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.util.Base64;
import org.eclipse.jgit.util.Hex;

abstract class WalkEncryption {
	static final WalkEncryption NONE = new NoEncryption();

	static final String JETS3T_CRYPTO_VER = "jets3t-crypto-ver"; //$NON-NLS-1$

	static final String JETS3T_CRYPTO_ALG = "jets3t-crypto-alg"; //$NON-NLS-1$

	// Note: encrypt -> request state machine, step 1.
	abstract OutputStream encrypt(OutputStream output) throws IOException;

	// Note: encrypt -> request state machine, step 2.
	abstract void request(HttpURLConnection conn, String prefix) throws IOException;

	// Note: validate -> decrypt state machine, step 1.
	abstract void validate(HttpURLConnection conn, String prefix) throws IOException;

	// Note: validate -> decrypt state machine, step 2.
	abstract InputStream decrypt(InputStream input) throws IOException;


	// TODO mixed ciphers
	// consider permitting mixed ciphers to facilitate algorithm migration
	// i.e. user keeps the password, but changes the algorithm
	// then existing remote entries will still be readable
	/**
	 * Validate
	 *
	 * @param u
	 *            a {@link java.net.HttpURLConnection} object.
	 * @param prefix
	 *            a {@link java.lang.String} object.
	 * @param version
	 *            a {@link java.lang.String} object.
	 * @param name
	 *            a {@link java.lang.String} object.
	 * @throws java.io.IOException
	 *             if any.
	 */
	protected void validateImpl(final HttpURLConnection u, final String prefix,
			final String version, final String name) throws IOException {
		String v;

		v = u.getHeaderField(prefix + JETS3T_CRYPTO_VER);
		if (v == null)
			v = ""; //$NON-NLS-1$
		if (!version.equals(v))
			throw new IOException(MessageFormat.format(JGitText.get().unsupportedEncryptionVersion, v));

		v = u.getHeaderField(prefix + JETS3T_CRYPTO_ALG);
		if (v == null)
			v = ""; //$NON-NLS-1$
		// Standard names are not case-sensitive.
		// http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html
		if (!name.equalsIgnoreCase(v))
			throw new IOException(MessageFormat.format(JGitText.get().unsupportedEncryptionAlgorithm, v));
	}

	IOException error(Throwable why) {
		return new IOException(MessageFormat
				.format(JGitText.get().encryptionError,
				why.getMessage()), why);
	}

	private static class NoEncryption extends WalkEncryption {
		@Override
		void request(HttpURLConnection u, String prefix) {
			// Don't store any request properties.
		}

		@Override
		void validate(HttpURLConnection u, String prefix)
				throws IOException {
			validateImpl(u, prefix, "", ""); //$NON-NLS-1$ //$NON-NLS-2$
		}

		@Override
		InputStream decrypt(InputStream in) {
			return in;
		}

		@Override
		OutputStream encrypt(OutputStream os) {
			return os;
		}
	}

	/**
	 * JetS3t compatibility reference: <a href=
	 * "https://bitbucket.org/jmurty/jets3t/src/156c00eb160598c2e9937fd6873f00d3190e28ca/src/org/jets3t/service/security/EncryptionUtil.java">
	 * EncryptionUtil.java</a>
	 * <p>
	 * Note: EncryptionUtil is inadequate:
	 * <li>EncryptionUtil.isCipherAvailableForUse checks encryption only which
	 * "always works", but in JetS3t both encryption and decryption use non-IV
	 * aware algorithm parameters for all PBE specs, which breaks in case of AES
	 * <li>that means that only non-IV algorithms will work round trip in
	 * JetS3t, such as PBEWithMD5AndDES and PBEWithSHAAndTwofish-CBC
	 * <li>any AES based algorithms such as "PBE...With...And...AES" will not
	 * work, since they need proper IV setup
	 */
	static class JetS3tV2 extends WalkEncryption {

		static final String VERSION = "2"; //$NON-NLS-1$

		static final String ALGORITHM = "PBEWithMD5AndDES"; //$NON-NLS-1$

		static final int ITERATIONS = 5000;

		static final int KEY_SIZE = 32;

		static final byte[] SALT = { //
				(byte) 0xA4, (byte) 0x0B, (byte) 0xC8, (byte) 0x34, //
				(byte) 0xD6, (byte) 0x95, (byte) 0xF3, (byte) 0x13 //
		};

		// Size 16, see com.sun.crypto.provider.AESConstants.AES_BLOCK_SIZE
		static final byte[] ZERO_AES_IV = new byte[16];

		private static final String CRYPTO_VER = VERSION;

		private final String cryptoAlg;

		private final SecretKey secretKey;

		private final AlgorithmParameterSpec paramSpec;

		JetS3tV2(final String algo, final String key)
				throws GeneralSecurityException {
			cryptoAlg = algo;

			// Verify if cipher is present.
			Cipher cipher = InsecureCipherFactory.create(cryptoAlg);

			// Standard names are not case-sensitive.
			// http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html
			String cryptoName = cryptoAlg.toUpperCase(Locale.ROOT);

			if (!cryptoName.startsWith("PBE")) //$NON-NLS-1$
				throw new GeneralSecurityException(JGitText.get().encryptionOnlyPBE);

			PBEKeySpec keySpec = new PBEKeySpec(key.toCharArray(), SALT, ITERATIONS, KEY_SIZE);
			secretKey = SecretKeyFactory.getInstance(algo).generateSecret(keySpec);

			// Detect algorithms which require initialization vector.
			boolean useIV = cryptoName.contains("AES"); //$NON-NLS-1$

			// PBEParameterSpec algorithm parameters are supported from Java 8.
			if (useIV) {
				// Support IV where possible:
				// * since JCE provider uses random IV for PBE/AES
				// * and there is no place to store dynamic IV in JetS3t V2
				// * we use static IV, and tolerate increased security risk
				// TODO back port this change to JetS3t V2
				// See:
				// https://bitbucket.org/jmurty/jets3t/raw/156c00eb160598c2e9937fd6873f00d3190e28ca/src/org/jets3t/service/security/EncryptionUtil.java
				// http://cr.openjdk.java.net/~mullan/webrevs/ascarpin/webrev.00/raw_files/new/src/share/classes/com/sun/crypto/provider/PBES2Core.java
				IvParameterSpec paramIV = new IvParameterSpec(ZERO_AES_IV);
				paramSpec = new PBEParameterSpec(SALT, ITERATIONS, paramIV);
			} else {
				// Strict legacy JetS3t V2 compatibility, with no IV support.
				paramSpec = new PBEParameterSpec(SALT, ITERATIONS);
			}

			// Verify if cipher + key are allowed by policy.
			cipher.init(Cipher.ENCRYPT_MODE, secretKey, paramSpec);
			cipher.doFinal();
		}

		@Override
		void request(HttpURLConnection u, String prefix) {
			u.setRequestProperty(prefix + JETS3T_CRYPTO_VER, CRYPTO_VER);
			u.setRequestProperty(prefix + JETS3T_CRYPTO_ALG, cryptoAlg);
		}

		@Override
		void validate(HttpURLConnection u, String prefix)
				throws IOException {
			validateImpl(u, prefix, CRYPTO_VER, cryptoAlg);
		}

		@Override
		OutputStream encrypt(OutputStream os) throws IOException {
			try {
				final Cipher cipher = InsecureCipherFactory.create(cryptoAlg);
				cipher.init(Cipher.ENCRYPT_MODE, secretKey, paramSpec);
				return new CipherOutputStream(os, cipher);
			} catch (GeneralSecurityException e) {
				throw error(e);
			}
		}

		@Override
		InputStream decrypt(InputStream in) throws IOException {
			try {
				final Cipher cipher = InsecureCipherFactory.create(cryptoAlg);
				cipher.init(Cipher.DECRYPT_MODE, secretKey, paramSpec);
				return new CipherInputStream(in, cipher);
			} catch (GeneralSecurityException e) {
				throw error(e);
			}
		}
	}

	/** Encryption property names. */
	interface Keys {
		// Remote S3 meta: V1 algorithm name or V2 profile name.
		String JGIT_PROFILE = "jgit-crypto-profile"; //$NON-NLS-1$

		// Remote S3 meta: JGit encryption implementation version.
		String JGIT_VERSION = "jgit-crypto-version"; //$NON-NLS-1$

		// Remote S3 meta: base-64 encoded cipher algorithm parameters.
		String JGIT_CONTEXT = "jgit-crypto-context"; //$NON-NLS-1$

		// Amazon S3 connection configuration file profile property suffixes:
		String X_ALGO = ".algo"; //$NON-NLS-1$
		String X_KEY_ALGO = ".key.algo"; //$NON-NLS-1$
		String X_KEY_SIZE = ".key.size"; //$NON-NLS-1$
		String X_KEY_ITER = ".key.iter"; //$NON-NLS-1$
		String X_KEY_SALT = ".key.salt"; //$NON-NLS-1$
	}

	/** Encryption constants and defaults. */
	interface Vals {
		// Compatibility defaults.
		String DEFAULT_VERS = "0"; //$NON-NLS-1$
		String DEFAULT_ALGO = JetS3tV2.ALGORITHM;
		String DEFAULT_KEY_ALGO = JetS3tV2.ALGORITHM;
		String DEFAULT_KEY_SIZE = Integer.toString(JetS3tV2.KEY_SIZE);
		String DEFAULT_KEY_ITER = Integer.toString(JetS3tV2.ITERATIONS);
		String DEFAULT_KEY_SALT = Hex.toHexString(JetS3tV2.SALT);

		String EMPTY = ""; //$NON-NLS-1$

		// Match white space.
		String REGEX_WS = "\\s+"; //$NON-NLS-1$

		// Match PBE ciphers, i.e: PBEWithMD5AndDES
		String REGEX_PBE = "(PBE).*(WITH).+(AND).+"; //$NON-NLS-1$

		// Match transformation ciphers, i.e: AES/CBC/PKCS5Padding
		String REGEX_TRANS = "(.+)/(.+)/(.+)"; //$NON-NLS-1$
	}

	static GeneralSecurityException securityError(String message,
			Throwable cause) {
		GeneralSecurityException e = new GeneralSecurityException(
				MessageFormat.format(JGitText.get().encryptionError, message));
		e.initCause(cause);
		return e;
	}

	/**
	 * Base implementation of JGit symmetric encryption. Supports V2 properties
	 * format.
	 */
	abstract static class SymmetricEncryption extends WalkEncryption
			implements Keys, Vals {

		/** Encryption profile, root name of group of related properties. */
		final String profile;

		/** Encryption version, reflects actual implementation class. */
		final String version;

		/** Full cipher algorithm name. */
		final String cipherAlgo;

		/** Cipher algorithm name for parameters lookup. */
		final String paramsAlgo;

		/** Generated secret key. */
		final SecretKey secretKey;

		SymmetricEncryption(Properties props) throws GeneralSecurityException {

			profile = props.getProperty(AmazonS3.Keys.CRYPTO_ALG);
			version = props.getProperty(AmazonS3.Keys.CRYPTO_VER);
			String pass = props.getProperty(AmazonS3.Keys.PASSWORD);

			cipherAlgo = props.getProperty(profile + X_ALGO, DEFAULT_ALGO);

			String keyAlgo = props.getProperty(profile + X_KEY_ALGO, DEFAULT_KEY_ALGO);
			String keySize = props.getProperty(profile + X_KEY_SIZE, DEFAULT_KEY_SIZE);
			String keyIter = props.getProperty(profile + X_KEY_ITER, DEFAULT_KEY_ITER);
			String keySalt = props.getProperty(profile + X_KEY_SALT, DEFAULT_KEY_SALT);

			// Verify if cipher is present.
			Cipher cipher = InsecureCipherFactory.create(cipherAlgo);

			// Verify if key factory is present.
			SecretKeyFactory factory = SecretKeyFactory.getInstance(keyAlgo);

			final int size;
			try {
				size = Integer.parseInt(keySize);
			} catch (Exception e) {
				throw securityError(X_KEY_SIZE + EMPTY + keySize, e);
			}

			final int iter;
			try {
				iter = Integer.parseInt(keyIter);
			} catch (Exception e) {
				throw securityError(X_KEY_ITER + EMPTY + keyIter, e);
			}

			final byte[] salt;
			try {
				salt = Hex.decode(keySalt.replaceAll(REGEX_WS, EMPTY));
			} catch (Exception e) {
				throw securityError(X_KEY_SALT + EMPTY + keySalt, e);
			}

			KeySpec keySpec = new PBEKeySpec(pass.toCharArray(), salt, iter, size);

			SecretKey keyBase = factory.generateSecret(keySpec);

			String name = cipherAlgo.toUpperCase(Locale.ROOT);
			Matcher matcherPBE = Pattern.compile(REGEX_PBE).matcher(name);
			Matcher matcherTrans = Pattern.compile(REGEX_TRANS).matcher(name);
			if (matcherPBE.matches()) {
				paramsAlgo = cipherAlgo;
				secretKey = keyBase;
			} else if (matcherTrans.find()) {
				paramsAlgo = matcherTrans.group(1);
				secretKey = new SecretKeySpec(keyBase.getEncoded(), paramsAlgo);
			} else {
				throw new GeneralSecurityException(MessageFormat.format(
						JGitText.get().unsupportedEncryptionAlgorithm,
						cipherAlgo));
			}

			// Verify if cipher + key are allowed by policy.
			cipher.init(Cipher.ENCRYPT_MODE, secretKey);
			cipher.doFinal();

		}

		// Shared state encrypt -> request.
		volatile String context;

		@Override
		OutputStream encrypt(OutputStream output) throws IOException {
			try {
				Cipher cipher = InsecureCipherFactory.create(cipherAlgo);
				cipher.init(Cipher.ENCRYPT_MODE, secretKey);
				AlgorithmParameters params = cipher.getParameters();
				if (params == null) {
					context = EMPTY;
				} else {
					context = Base64.encodeBytes(params.getEncoded());
				}
				return new CipherOutputStream(output, cipher);
			} catch (Exception e) {
				throw error(e);
			}
		}

		@Override
		void request(HttpURLConnection conn, String prefix) throws IOException {
			conn.setRequestProperty(prefix + JGIT_PROFILE, profile);
			conn.setRequestProperty(prefix + JGIT_VERSION, version);
			conn.setRequestProperty(prefix + JGIT_CONTEXT, context);
			// No cleanup:
			// single encrypt can be followed by several request
			// from the AmazonS3.putImpl() multiple retry attempts
			// context = null; // Cleanup encrypt -> request transition.
			// TODO re-factor AmazonS3.putImpl to be more transaction-like
		}

		// Shared state validate -> decrypt.
		volatile Cipher decryptCipher;

		@Override
		void validate(HttpURLConnection conn, String prefix)
				throws IOException {
			String prof = conn.getHeaderField(prefix + JGIT_PROFILE);
			String vers = conn.getHeaderField(prefix + JGIT_VERSION);
			String cont = conn.getHeaderField(prefix + JGIT_CONTEXT);

			if (prof == null) {
				throw new IOException(MessageFormat
						.format(JGitText.get().encryptionError, JGIT_PROFILE));
			}
			if (vers == null) {
				throw new IOException(MessageFormat
						.format(JGitText.get().encryptionError, JGIT_VERSION));
			}
			if (cont == null) {
				throw new IOException(MessageFormat
						.format(JGitText.get().encryptionError, JGIT_CONTEXT));
			}
			if (!profile.equals(prof)) {
				throw new IOException(MessageFormat.format(
						JGitText.get().unsupportedEncryptionAlgorithm, prof));
			}
			if (!version.equals(vers)) {
				throw new IOException(MessageFormat.format(
						JGitText.get().unsupportedEncryptionVersion, vers));
			}
			try {
				decryptCipher = InsecureCipherFactory.create(cipherAlgo);
				if (cont.isEmpty()) {
					decryptCipher.init(Cipher.DECRYPT_MODE, secretKey);
				} else {
					AlgorithmParameters params = AlgorithmParameters
							.getInstance(paramsAlgo);
					params.init(Base64.decode(cont));
					decryptCipher.init(Cipher.DECRYPT_MODE, secretKey, params);
				}
			} catch (Exception e) {
				throw error(e);
			}
		}

		@Override
		InputStream decrypt(InputStream input) throws IOException {
			try {
				return new CipherInputStream(input, decryptCipher);
			} finally {
				decryptCipher = null; // Cleanup validate -> decrypt transition.
			}
		}
	}

	/**
	 * Provides JetS3t-like encryption with AES support. Uses V1 connection file
	 * format. For reference, see: 'jgit-s3-connection-v-1.properties'.
	 */
	static class JGitV1 extends SymmetricEncryption {

		static final String VERSION = "1"; //$NON-NLS-1$

		// Re-map connection properties V1 -> V2.
		static Properties wrap(String algo, String pass) {
			Properties props = new Properties();
			props.put(AmazonS3.Keys.CRYPTO_ALG, algo);
			props.put(AmazonS3.Keys.CRYPTO_VER, VERSION);
			props.put(AmazonS3.Keys.PASSWORD, pass);
			props.put(algo + Keys.X_ALGO, algo);
			props.put(algo + Keys.X_KEY_ALGO, algo);
			props.put(algo + Keys.X_KEY_ITER, DEFAULT_KEY_ITER);
			props.put(algo + Keys.X_KEY_SIZE, DEFAULT_KEY_SIZE);
			props.put(algo + Keys.X_KEY_SALT, DEFAULT_KEY_SALT);
			return props;
		}

		JGitV1(String algo, String pass)
				throws GeneralSecurityException {
			super(wrap(algo, pass));
			String name = cipherAlgo.toUpperCase(Locale.ROOT);
			Matcher matcherPBE = Pattern.compile(REGEX_PBE).matcher(name);
			if (!matcherPBE.matches())
				throw new GeneralSecurityException(
						JGitText.get().encryptionOnlyPBE);
		}

	}

	/**
	 * Supports both PBE and non-PBE algorithms. Uses V2 connection file format.
	 * For reference, see: 'jgit-s3-connection-v-2.properties'.
	 */
	static class JGitV2 extends SymmetricEncryption {

		static final String VERSION = "2"; //$NON-NLS-1$

		JGitV2(Properties props)
				throws GeneralSecurityException {
			super(props);
		}
	}

	/**
	 * Encryption factory.
	 *
	 * @param props
	 * @return instance
	 * @throws GeneralSecurityException
	 */
	static WalkEncryption instance(Properties props)
			throws GeneralSecurityException {

		String algo = props.getProperty(AmazonS3.Keys.CRYPTO_ALG, Vals.DEFAULT_ALGO);
		String vers = props.getProperty(AmazonS3.Keys.CRYPTO_VER, Vals.DEFAULT_VERS);
		String pass = props.getProperty(AmazonS3.Keys.PASSWORD);

		if (pass == null) // Disable encryption.
			return WalkEncryption.NONE;

		switch (vers) {
		case Vals.DEFAULT_VERS:
			return new JetS3tV2(algo, pass);
		case JGitV1.VERSION:
			return new JGitV1(algo, pass);
		case JGitV2.VERSION:
			return new JGitV2(props);
		default:
			throw new GeneralSecurityException(MessageFormat.format(
					JGitText.get().unsupportedEncryptionVersion, vers));
		}
	}
}