123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597 |
- /*
- * 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;
- }
- }
|