/*
 * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> 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.gpg.bc.internal.keys;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.StreamCorruptedException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Arrays;

import org.bouncycastle.openpgp.PGPException;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPSecretKey;
import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory;
import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider;
import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory;
import org.bouncycastle.util.io.Streams;
import org.eclipse.jgit.api.errors.CanceledException;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.gpg.bc.internal.BCText;
import org.eclipse.jgit.util.RawParseUtils;

/**
 * Utilities for reading GPG secret keys from a gpg-agent key file.
 */
public final class SecretKeys {

	private SecretKeys() {
		// No instantiation.
	}

	/**
	 * Something that can supply a passphrase to decrypt an encrypted secret
	 * key.
	 */
	public interface PassphraseSupplier {

		/**
		 * Supplies a passphrase.
		 *
		 * @return the passphrase
		 * @throws PGPException
		 *             if no passphrase can be obtained
		 * @throws CanceledException
		 *             if the user canceled passphrase entry
		 * @throws UnsupportedCredentialItem
		 *             if an internal error occurred
		 * @throws URISyntaxException
		 *             if an internal error occurred
		 */
		char[] getPassphrase() throws PGPException, CanceledException,
				UnsupportedCredentialItem, URISyntaxException;
	}

	private static final byte[] PROTECTED_KEY = "protected-private-key" //$NON-NLS-1$
			.getBytes(StandardCharsets.US_ASCII);

	private static final byte[] OCB_PROTECTED = "openpgp-s2k3-ocb-aes" //$NON-NLS-1$
			.getBytes(StandardCharsets.US_ASCII);

	/**
	 * Reads a GPG secret key from the given stream.
	 *
	 * @param in
	 *            {@link InputStream} to read from, doesn't need to be buffered
	 * @param calculatorProvider
	 *            for checking digests
	 * @param passphraseSupplier
	 *            for decrypting encrypted keys
	 * @param publicKey
	 *            the secret key should be for
	 * @return the secret key
	 * @throws IOException
	 *             if the stream cannot be parsed
	 * @throws PGPException
	 *             if thrown by the underlying S-Expression parser, for instance
	 *             when the passphrase is wrong
	 * @throws CanceledException
	 *             if thrown by the {@code passphraseSupplier}
	 * @throws UnsupportedCredentialItem
	 *             if thrown by the {@code passphraseSupplier}
	 * @throws URISyntaxException
	 *             if thrown by the {@code passphraseSupplier}
	 */
	public static PGPSecretKey readSecretKey(InputStream in,
			PGPDigestCalculatorProvider calculatorProvider,
			PassphraseSupplier passphraseSupplier, PGPPublicKey publicKey)
			throws IOException, PGPException, CanceledException,
			UnsupportedCredentialItem, URISyntaxException {
		byte[] data = Streams.readAll(in);
		if (data.length == 0) {
			throw new EOFException();
		} else if (data.length < 4 + PROTECTED_KEY.length) {
			// +4 for "(21:" for a binary protected key
			throw new IOException(
					MessageFormat.format(BCText.get().secretKeyTooShort,
							Integer.toUnsignedString(data.length)));
		}
		SExprParser parser = new SExprParser(calculatorProvider);
		byte firstChar = data[0];
		try {
			if (firstChar == '(') {
				// Binary format.
				PBEProtectionRemoverFactory decryptor = null;
				if (matches(data, 4, PROTECTED_KEY)) {
					// AES/CBC encrypted.
					decryptor = new JcePBEProtectionRemoverFactory(
							passphraseSupplier.getPassphrase(),
							calculatorProvider);
				}
				try (InputStream sIn = new ByteArrayInputStream(data)) {
					return parser.parseSecretKey(sIn, decryptor, publicKey);
				}
			}
			// Assume it's the new key-value format.
			try (ByteArrayInputStream keyIn = new ByteArrayInputStream(data)) {
				byte[] rawData = keyFromNameValueFormat(keyIn);
				if (!matches(rawData, 1, PROTECTED_KEY)) {
					// Not encrypted human-readable format.
					try (InputStream sIn = new ByteArrayInputStream(
							convertSexpression(rawData))) {
						return parser.parseSecretKey(sIn, null, publicKey);
					}
				}
				// An encrypted key from a key-value file. Most likely AES/OCB
				// encrypted.
				boolean isOCB[] = { false };
				byte[] sExp = convertSexpression(rawData, isOCB);
				PBEProtectionRemoverFactory decryptor;
				if (isOCB[0]) {
					decryptor = new OCBPBEProtectionRemoverFactory(
							passphraseSupplier.getPassphrase(),
							calculatorProvider, getAad(sExp));
				} else {
					decryptor = new JcePBEProtectionRemoverFactory(
							passphraseSupplier.getPassphrase(),
							calculatorProvider);
				}
				try (InputStream sIn = new ByteArrayInputStream(sExp)) {
					return parser.parseSecretKey(sIn, decryptor, publicKey);
				}
			}
		} catch (IOException e) {
			throw new PGPException(e.getLocalizedMessage(), e);
		}
	}

	/**
	 * Extract the AAD for the OCB decryption from an s-expression.
	 *
	 * @param sExp
	 *            buffer containing a valid binary s-expression
	 * @return the AAD
	 */
	private static byte[] getAad(byte[] sExp) {
		// Given a key
		// @formatter:off
		// (protected-private-key (rsa ... (protected openpgp-s2k3-ocb-aes ... )(protected-at ...)))
		//                        A        B                                    C                  D
		// The AAD is [A..B)[C..D). (From the binary serialized form.)
		// @formatter:on
		int i = 1; // Skip initial '('
		while (sExp[i] != '(') {
			i++;
		}
		int aadStart = i++;
		int aadEnd = skip(sExp, aadStart);
		byte[] protectedPrefix = "(9:protected" //$NON-NLS-1$
				.getBytes(StandardCharsets.US_ASCII);
		while (!matches(sExp, i, protectedPrefix)) {
			i++;
		}
		int protectedStart = i;
		int protectedEnd = skip(sExp, protectedStart);
		byte[] aadData = new byte[aadEnd - aadStart
				- (protectedEnd - protectedStart)];
		System.arraycopy(sExp, aadStart, aadData, 0, protectedStart - aadStart);
		System.arraycopy(sExp, protectedEnd, aadData, protectedStart - aadStart,
				aadEnd - protectedEnd);
		return aadData;
	}

	/**
	 * Skips a list including nested lists.
	 *
	 * @param sExp
	 *            buffer containing valid binary s-expression data
	 * @param start
	 *            index of the opening '(' of the list to skip
	 * @return the index after the closing ')' of the skipped list
	 */
	private static int skip(byte[] sExp, int start) {
		int i = start + 1;
		int depth = 1;
		while (depth > 0) {
			switch (sExp[i]) {
			case '(':
				depth++;
				break;
			case ')':
				depth--;
				break;
			default:
				// We must be on a length
				int j = i;
				while (sExp[j] >= '0' && sExp[j] <= '9') {
					j++;
				}
				// j is on the colon
				int length = Integer.parseInt(
						new String(sExp, i, j - i, StandardCharsets.US_ASCII));
				i = j + length;
			}
			i++;
		}
		return i;
	}

	/**
	 * Checks whether the {@code needle} matches {@code src} at offset
	 * {@code from}.
	 *
	 * @param src
	 *            to match against {@code needle}
	 * @param from
	 *            position in {@code src} to start matching
	 * @param needle
	 *            to match against
	 * @return {@code true} if {@code src} contains {@code needle} at position
	 *         {@code from}, {@code false} otherwise
	 */
	private static boolean matches(byte[] src, int from, byte[] needle) {
		if (from < 0 || from + needle.length > src.length) {
			return false;
		}
		return org.bouncycastle.util.Arrays.constantTimeAreEqual(needle.length,
				src, from, needle, 0);
	}

	/**
	 * Converts a human-readable serialized s-expression into a binary
	 * serialized s-expression.
	 *
	 * @param humanForm
	 *            to convert
	 * @return the converted s-expression
	 * @throws IOException
	 *             if the conversion fails
	 */
	private static byte[] convertSexpression(byte[] humanForm)
			throws IOException {
		boolean[] isOCB = { false };
		return convertSexpression(humanForm, isOCB);
	}

	/**
	 * Converts a human-readable serialized s-expression into a binary
	 * serialized s-expression.
	 *
	 * @param humanForm
	 *            to convert
	 * @param isOCB
	 *            returns whether the s-expression specified AES/OCB encryption
	 * @return the converted s-expression
	 * @throws IOException
	 *             if the conversion fails
	 */
	private static byte[] convertSexpression(byte[] humanForm, boolean[] isOCB)
			throws IOException {
		int pos = 0;
		try (ByteArrayOutputStream out = new ByteArrayOutputStream(
				humanForm.length)) {
			while (pos < humanForm.length) {
				byte b = humanForm[pos];
				if (b == '(' || b == ')') {
					out.write(b);
					pos++;
				} else if (isGpgSpace(b)) {
					pos++;
				} else if (b == '#') {
					// Hex value follows up to the next #
					int i = ++pos;
					while (i < humanForm.length && isHex(humanForm[i])) {
						i++;
					}
					if (i == pos || humanForm[i] != '#') {
						throw new StreamCorruptedException(
								BCText.get().sexprHexNotClosed);
					}
					if ((i - pos) % 2 != 0) {
						throw new StreamCorruptedException(
								BCText.get().sexprHexOdd);
					}
					int l = (i - pos) / 2;
					out.write(Integer.toString(l)
							.getBytes(StandardCharsets.US_ASCII));
					out.write(':');
					while (pos < i) {
						int x = (nibble(humanForm[pos]) << 4)
								| nibble(humanForm[pos + 1]);
						pos += 2;
						out.write(x);
					}
					pos = i + 1;
				} else if (isTokenChar(b)) {
					// Scan the token
					int start = pos++;
					while (pos < humanForm.length
							&& isTokenChar(humanForm[pos])) {
						pos++;
					}
					int l = pos - start;
					if (pos - start == OCB_PROTECTED.length
							&& matches(humanForm, start, OCB_PROTECTED)) {
						isOCB[0] = true;
					}
					out.write(Integer.toString(l)
							.getBytes(StandardCharsets.US_ASCII));
					out.write(':');
					out.write(humanForm, start, pos - start);
				} else if (b == '"') {
					// Potentially quoted string.
					int start = ++pos;
					boolean escaped = false;
					while (pos < humanForm.length
							&& (escaped || humanForm[pos] != '"')) {
						int ch = humanForm[pos++];
						escaped = !escaped && ch == '\\';
					}
					if (pos >= humanForm.length) {
						throw new StreamCorruptedException(
								BCText.get().sexprStringNotClosed);
					}
					// start is on the first character of the string, pos on the
					// closing quote.
					byte[] dq = dequote(humanForm, start, pos);
					out.write(Integer.toString(dq.length)
							.getBytes(StandardCharsets.US_ASCII));
					out.write(':');
					out.write(dq);
					pos++;
				} else {
					throw new StreamCorruptedException(
							MessageFormat.format(BCText.get().sexprUnhandled,
									Integer.toHexString(b & 0xFF)));
				}
			}
			return out.toByteArray();
		}
	}

	/**
	 * GPG-style string de-quoting, which is basically C-style, with some
	 * literal CR/LF escaping.
	 *
	 * @param in
	 *            buffer containing the quoted string
	 * @param from
	 *            index after the opening quote in {@code in}
	 * @param to
	 *            index of the closing quote in {@code in}
	 * @return the dequoted raw string value
	 * @throws StreamCorruptedException
	 */
	private static byte[] dequote(byte[] in, int from, int to)
			throws StreamCorruptedException {
		// Result must be shorter or have the same length
		byte[] out = new byte[to - from];
		int j = 0;
		int i = from;
		while (i < to) {
			byte b = in[i++];
			if (b != '\\') {
				out[j++] = b;
				continue;
			}
			if (i == to) {
				throw new StreamCorruptedException(
						BCText.get().sexprStringInvalidEscapeAtEnd);
			}
			b = in[i++];
			switch (b) {
			case 'b':
				out[j++] = '\b';
				break;
			case 'f':
				out[j++] = '\f';
				break;
			case 'n':
				out[j++] = '\n';
				break;
			case 'r':
				out[j++] = '\r';
				break;
			case 't':
				out[j++] = '\t';
				break;
			case 'v':
				out[j++] = 0x0B;
				break;
			case '"':
			case '\'':
			case '\\':
				out[j++] = b;
				break;
			case '\r':
				// Escaped literal line end. If an LF is following, skip that,
				// too.
				if (i < to && in[i] == '\n') {
					i++;
				}
				break;
			case '\n':
				// Same for LF possibly followed by CR.
				if (i < to && in[i] == '\r') {
					i++;
				}
				break;
			case 'x':
				if (i + 1 >= to || !isHex(in[i]) || !isHex(in[i + 1])) {
					throw new StreamCorruptedException(
							BCText.get().sexprStringInvalidHexEscape);
				}
				out[j++] = (byte) ((nibble(in[i]) << 4) | nibble(in[i + 1]));
				i += 2;
				break;
			case '0':
			case '1':
			case '2':
			case '3':
				if (i + 2 >= to || !isOctal(in[i]) || !isOctal(in[i + 1])
						|| !isOctal(in[i + 2])) {
					throw new StreamCorruptedException(
							BCText.get().sexprStringInvalidOctalEscape);
				}
				out[j++] = (byte) (((((in[i] - '0') << 3)
						| (in[i + 1] - '0')) << 3) | (in[i + 2] - '0'));
				i += 3;
				break;
			default:
				throw new StreamCorruptedException(MessageFormat.format(
						BCText.get().sexprStringInvalidEscape,
						Integer.toHexString(b & 0xFF)));
			}
		}
		return Arrays.copyOf(out, j);
	}

	/**
	 * Extracts the key from a GPG name-value-pair key file.
	 * <p>
	 * Package-visible for tests only.
	 * </p>
	 *
	 * @param in
	 *            {@link InputStream} to read from; should be buffered
	 * @return the raw key data as extracted from the file
	 * @throws IOException
	 *             if the {@code in} stream cannot be read or does not contain a
	 *             key
	 */
	static byte[] keyFromNameValueFormat(InputStream in) throws IOException {
		// It would be nice if we could use RawParseUtils here, but GPG compares
		// names case-insensitively. We're only interested in the "Key:"
		// name-value pair.
		int[] nameLow = { 'k', 'e', 'y', ':' };
		int[] nameCap = { 'K', 'E', 'Y', ':' };
		int nameIdx = 0;
		for (;;) {
			int next = in.read();
			if (next < 0) {
				throw new EOFException();
			}
			if (next == '\n') {
				nameIdx = 0;
			} else if (nameIdx >= 0) {
				if (nameLow[nameIdx] == next || nameCap[nameIdx] == next) {
					nameIdx++;
					if (nameIdx == nameLow.length) {
						break;
					}
				} else {
					nameIdx = -1;
				}
			}
		}
		// We're after "Key:". Read the value as continuation lines.
		int last = ':';
		byte[] rawData;
		try (ByteArrayOutputStream out = new ByteArrayOutputStream(8192)) {
			for (;;) {
				int next = in.read();
				if (next < 0) {
					break;
				}
				if (last == '\n') {
					if (next == ' ' || next == '\t') {
						// Continuation line; skip this whitespace
						last = next;
						continue;
					}
					break; // Not a continuation line
				}
				out.write(next);
				last = next;
			}
			rawData = out.toByteArray();
		}
		// GPG trims off trailing whitespace, and a line having only whitespace
		// is a single LF.
		try (ByteArrayOutputStream out = new ByteArrayOutputStream(
				rawData.length)) {
			int lineStart = 0;
			boolean trimLeading = true;
			while (lineStart < rawData.length) {
				int nextLineStart = RawParseUtils.nextLF(rawData, lineStart);
				if (trimLeading) {
					while (lineStart < nextLineStart
							&& isGpgSpace(rawData[lineStart])) {
						lineStart++;
					}
				}
				// Trim trailing
				int i = nextLineStart - 1;
				while (lineStart < i && isGpgSpace(rawData[i])) {
					i--;
				}
				if (i <= lineStart) {
					// Empty line signifies LF
					out.write('\n');
					trimLeading = true;
				} else {
					out.write(rawData, lineStart, i - lineStart + 1);
					trimLeading = false;
				}
				lineStart = nextLineStart;
			}
			return out.toByteArray();
		}
	}

	private static boolean isGpgSpace(int ch) {
		return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
	}

	private static boolean isTokenChar(int ch) {
		switch (ch) {
		case '-':
		case '.':
		case '/':
		case '_':
		case ':':
		case '*':
		case '+':
		case '=':
			return true;
		default:
			if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
					|| (ch >= '0' && ch <= '9')) {
				return true;
			}
			return false;
		}
	}

	private static boolean isHex(int ch) {
		return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F')
				|| (ch >= 'a' && ch <= 'f');
	}

	private static boolean isOctal(int ch) {
		return (ch >= '0' && ch <= '7');
	}

	private static int nibble(int ch) {
		if (ch >= '0' && ch <= '9') {
			return ch - '0';
		} else if (ch >= 'A' && ch <= 'F') {
			return ch - 'A' + 10;
		} else if (ch >= 'a' && ch <= 'f') {
			return ch - 'a' + 10;
		}
		return -1;
	}
}
