xref: /JGit/org.eclipse.jgit.gpg.bc/src/org/eclipse/jgit/gpg/bc/internal/keys/SecretKeys.java (revision 704ccdc096e4f5cf2670c5c58eaf19fe1fdf4df3)
1 /*
2  * Copyright (C) 2021 Thomas Wolf <thomas.wolf@paranor.ch> and others
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Distribution License v. 1.0 which is available at
6  * https://www.eclipse.org/org/documents/edl-v10.php.
7  *
8  * SPDX-License-Identifier: BSD-3-Clause
9  */
10 package org.eclipse.jgit.gpg.bc.internal.keys;
11 
12 import java.io.ByteArrayInputStream;
13 import java.io.ByteArrayOutputStream;
14 import java.io.EOFException;
15 import java.io.IOException;
16 import java.io.InputStream;
17 import java.io.StreamCorruptedException;
18 import java.net.URISyntaxException;
19 import java.nio.charset.StandardCharsets;
20 import java.text.MessageFormat;
21 import java.util.Arrays;
22 
23 import org.bouncycastle.openpgp.PGPException;
24 import org.bouncycastle.openpgp.PGPPublicKey;
25 import org.bouncycastle.openpgp.PGPSecretKey;
26 import org.bouncycastle.openpgp.operator.PBEProtectionRemoverFactory;
27 import org.bouncycastle.openpgp.operator.PGPDigestCalculatorProvider;
28 import org.bouncycastle.openpgp.operator.jcajce.JcePBEProtectionRemoverFactory;
29 import org.bouncycastle.util.io.Streams;
30 import org.eclipse.jgit.api.errors.CanceledException;
31 import org.eclipse.jgit.errors.UnsupportedCredentialItem;
32 import org.eclipse.jgit.gpg.bc.internal.BCText;
33 import org.eclipse.jgit.util.RawParseUtils;
34 
35 /**
36  * Utilities for reading GPG secret keys from a gpg-agent key file.
37  */
38 public final class SecretKeys {
39 
SecretKeys()40 	private SecretKeys() {
41 		// No instantiation.
42 	}
43 
44 	/**
45 	 * Something that can supply a passphrase to decrypt an encrypted secret
46 	 * key.
47 	 */
48 	public interface PassphraseSupplier {
49 
50 		/**
51 		 * Supplies a passphrase.
52 		 *
53 		 * @return the passphrase
54 		 * @throws PGPException
55 		 *             if no passphrase can be obtained
56 		 * @throws CanceledException
57 		 *             if the user canceled passphrase entry
58 		 * @throws UnsupportedCredentialItem
59 		 *             if an internal error occurred
60 		 * @throws URISyntaxException
61 		 *             if an internal error occurred
62 		 */
getPassphrase()63 		char[] getPassphrase() throws PGPException, CanceledException,
64 				UnsupportedCredentialItem, URISyntaxException;
65 	}
66 
67 	private static final byte[] PROTECTED_KEY = "protected-private-key" //$NON-NLS-1$
68 			.getBytes(StandardCharsets.US_ASCII);
69 
70 	private static final byte[] OCB_PROTECTED = "openpgp-s2k3-ocb-aes" //$NON-NLS-1$
71 			.getBytes(StandardCharsets.US_ASCII);
72 
73 	/**
74 	 * Reads a GPG secret key from the given stream.
75 	 *
76 	 * @param in
77 	 *            {@link InputStream} to read from, doesn't need to be buffered
78 	 * @param calculatorProvider
79 	 *            for checking digests
80 	 * @param passphraseSupplier
81 	 *            for decrypting encrypted keys
82 	 * @param publicKey
83 	 *            the secret key should be for
84 	 * @return the secret key
85 	 * @throws IOException
86 	 *             if the stream cannot be parsed
87 	 * @throws PGPException
88 	 *             if thrown by the underlying S-Expression parser, for instance
89 	 *             when the passphrase is wrong
90 	 * @throws CanceledException
91 	 *             if thrown by the {@code passphraseSupplier}
92 	 * @throws UnsupportedCredentialItem
93 	 *             if thrown by the {@code passphraseSupplier}
94 	 * @throws URISyntaxException
95 	 *             if thrown by the {@code passphraseSupplier}
96 	 */
readSecretKey(InputStream in, PGPDigestCalculatorProvider calculatorProvider, PassphraseSupplier passphraseSupplier, PGPPublicKey publicKey)97 	public static PGPSecretKey readSecretKey(InputStream in,
98 			PGPDigestCalculatorProvider calculatorProvider,
99 			PassphraseSupplier passphraseSupplier, PGPPublicKey publicKey)
100 			throws IOException, PGPException, CanceledException,
101 			UnsupportedCredentialItem, URISyntaxException {
102 		byte[] data = Streams.readAll(in);
103 		if (data.length == 0) {
104 			throw new EOFException();
105 		} else if (data.length < 4 + PROTECTED_KEY.length) {
106 			// +4 for "(21:" for a binary protected key
107 			throw new IOException(
108 					MessageFormat.format(BCText.get().secretKeyTooShort,
109 							Integer.toUnsignedString(data.length)));
110 		}
111 		SExprParser parser = new SExprParser(calculatorProvider);
112 		byte firstChar = data[0];
113 		try {
114 			if (firstChar == '(') {
115 				// Binary format.
116 				PBEProtectionRemoverFactory decryptor = null;
117 				if (matches(data, 4, PROTECTED_KEY)) {
118 					// AES/CBC encrypted.
119 					decryptor = new JcePBEProtectionRemoverFactory(
120 							passphraseSupplier.getPassphrase(),
121 							calculatorProvider);
122 				}
123 				try (InputStream sIn = new ByteArrayInputStream(data)) {
124 					return parser.parseSecretKey(sIn, decryptor, publicKey);
125 				}
126 			}
127 			// Assume it's the new key-value format.
128 			try (ByteArrayInputStream keyIn = new ByteArrayInputStream(data)) {
129 				byte[] rawData = keyFromNameValueFormat(keyIn);
130 				if (!matches(rawData, 1, PROTECTED_KEY)) {
131 					// Not encrypted human-readable format.
132 					try (InputStream sIn = new ByteArrayInputStream(
133 							convertSexpression(rawData))) {
134 						return parser.parseSecretKey(sIn, null, publicKey);
135 					}
136 				}
137 				// An encrypted key from a key-value file. Most likely AES/OCB
138 				// encrypted.
139 				boolean isOCB[] = { false };
140 				byte[] sExp = convertSexpression(rawData, isOCB);
141 				PBEProtectionRemoverFactory decryptor;
142 				if (isOCB[0]) {
143 					decryptor = new OCBPBEProtectionRemoverFactory(
144 							passphraseSupplier.getPassphrase(),
145 							calculatorProvider, getAad(sExp));
146 				} else {
147 					decryptor = new JcePBEProtectionRemoverFactory(
148 							passphraseSupplier.getPassphrase(),
149 							calculatorProvider);
150 				}
151 				try (InputStream sIn = new ByteArrayInputStream(sExp)) {
152 					return parser.parseSecretKey(sIn, decryptor, publicKey);
153 				}
154 			}
155 		} catch (IOException e) {
156 			throw new PGPException(e.getLocalizedMessage(), e);
157 		}
158 	}
159 
160 	/**
161 	 * Extract the AAD for the OCB decryption from an s-expression.
162 	 *
163 	 * @param sExp
164 	 *            buffer containing a valid binary s-expression
165 	 * @return the AAD
166 	 */
getAad(byte[] sExp)167 	private static byte[] getAad(byte[] sExp) {
168 		// Given a key
169 		// @formatter:off
170 		// (protected-private-key (rsa ... (protected openpgp-s2k3-ocb-aes ... )(protected-at ...)))
171 		//                        A        B                                    C                  D
172 		// The AAD is [A..B)[C..D). (From the binary serialized form.)
173 		// @formatter:on
174 		int i = 1; // Skip initial '('
175 		while (sExp[i] != '(') {
176 			i++;
177 		}
178 		int aadStart = i++;
179 		int aadEnd = skip(sExp, aadStart);
180 		byte[] protectedPrefix = "(9:protected" //$NON-NLS-1$
181 				.getBytes(StandardCharsets.US_ASCII);
182 		while (!matches(sExp, i, protectedPrefix)) {
183 			i++;
184 		}
185 		int protectedStart = i;
186 		int protectedEnd = skip(sExp, protectedStart);
187 		byte[] aadData = new byte[aadEnd - aadStart
188 				- (protectedEnd - protectedStart)];
189 		System.arraycopy(sExp, aadStart, aadData, 0, protectedStart - aadStart);
190 		System.arraycopy(sExp, protectedEnd, aadData, protectedStart - aadStart,
191 				aadEnd - protectedEnd);
192 		return aadData;
193 	}
194 
195 	/**
196 	 * Skips a list including nested lists.
197 	 *
198 	 * @param sExp
199 	 *            buffer containing valid binary s-expression data
200 	 * @param start
201 	 *            index of the opening '(' of the list to skip
202 	 * @return the index after the closing ')' of the skipped list
203 	 */
skip(byte[] sExp, int start)204 	private static int skip(byte[] sExp, int start) {
205 		int i = start + 1;
206 		int depth = 1;
207 		while (depth > 0) {
208 			switch (sExp[i]) {
209 			case '(':
210 				depth++;
211 				break;
212 			case ')':
213 				depth--;
214 				break;
215 			default:
216 				// We must be on a length
217 				int j = i;
218 				while (sExp[j] >= '0' && sExp[j] <= '9') {
219 					j++;
220 				}
221 				// j is on the colon
222 				int length = Integer.parseInt(
223 						new String(sExp, i, j - i, StandardCharsets.US_ASCII));
224 				i = j + length;
225 			}
226 			i++;
227 		}
228 		return i;
229 	}
230 
231 	/**
232 	 * Checks whether the {@code needle} matches {@code src} at offset
233 	 * {@code from}.
234 	 *
235 	 * @param src
236 	 *            to match against {@code needle}
237 	 * @param from
238 	 *            position in {@code src} to start matching
239 	 * @param needle
240 	 *            to match against
241 	 * @return {@code true} if {@code src} contains {@code needle} at position
242 	 *         {@code from}, {@code false} otherwise
243 	 */
matches(byte[] src, int from, byte[] needle)244 	private static boolean matches(byte[] src, int from, byte[] needle) {
245 		if (from < 0 || from + needle.length > src.length) {
246 			return false;
247 		}
248 		return org.bouncycastle.util.Arrays.constantTimeAreEqual(needle.length,
249 				src, from, needle, 0);
250 	}
251 
252 	/**
253 	 * Converts a human-readable serialized s-expression into a binary
254 	 * serialized s-expression.
255 	 *
256 	 * @param humanForm
257 	 *            to convert
258 	 * @return the converted s-expression
259 	 * @throws IOException
260 	 *             if the conversion fails
261 	 */
convertSexpression(byte[] humanForm)262 	private static byte[] convertSexpression(byte[] humanForm)
263 			throws IOException {
264 		boolean[] isOCB = { false };
265 		return convertSexpression(humanForm, isOCB);
266 	}
267 
268 	/**
269 	 * Converts a human-readable serialized s-expression into a binary
270 	 * serialized s-expression.
271 	 *
272 	 * @param humanForm
273 	 *            to convert
274 	 * @param isOCB
275 	 *            returns whether the s-expression specified AES/OCB encryption
276 	 * @return the converted s-expression
277 	 * @throws IOException
278 	 *             if the conversion fails
279 	 */
convertSexpression(byte[] humanForm, boolean[] isOCB)280 	private static byte[] convertSexpression(byte[] humanForm, boolean[] isOCB)
281 			throws IOException {
282 		int pos = 0;
283 		try (ByteArrayOutputStream out = new ByteArrayOutputStream(
284 				humanForm.length)) {
285 			while (pos < humanForm.length) {
286 				byte b = humanForm[pos];
287 				if (b == '(' || b == ')') {
288 					out.write(b);
289 					pos++;
290 				} else if (isGpgSpace(b)) {
291 					pos++;
292 				} else if (b == '#') {
293 					// Hex value follows up to the next #
294 					int i = ++pos;
295 					while (i < humanForm.length && isHex(humanForm[i])) {
296 						i++;
297 					}
298 					if (i == pos || humanForm[i] != '#') {
299 						throw new StreamCorruptedException(
300 								BCText.get().sexprHexNotClosed);
301 					}
302 					if ((i - pos) % 2 != 0) {
303 						throw new StreamCorruptedException(
304 								BCText.get().sexprHexOdd);
305 					}
306 					int l = (i - pos) / 2;
307 					out.write(Integer.toString(l)
308 							.getBytes(StandardCharsets.US_ASCII));
309 					out.write(':');
310 					while (pos < i) {
311 						int x = (nibble(humanForm[pos]) << 4)
312 								| nibble(humanForm[pos + 1]);
313 						pos += 2;
314 						out.write(x);
315 					}
316 					pos = i + 1;
317 				} else if (isTokenChar(b)) {
318 					// Scan the token
319 					int start = pos++;
320 					while (pos < humanForm.length
321 							&& isTokenChar(humanForm[pos])) {
322 						pos++;
323 					}
324 					int l = pos - start;
325 					if (pos - start == OCB_PROTECTED.length
326 							&& matches(humanForm, start, OCB_PROTECTED)) {
327 						isOCB[0] = true;
328 					}
329 					out.write(Integer.toString(l)
330 							.getBytes(StandardCharsets.US_ASCII));
331 					out.write(':');
332 					out.write(humanForm, start, pos - start);
333 				} else if (b == '"') {
334 					// Potentially quoted string.
335 					int start = ++pos;
336 					boolean escaped = false;
337 					while (pos < humanForm.length
338 							&& (escaped || humanForm[pos] != '"')) {
339 						int ch = humanForm[pos++];
340 						escaped = !escaped && ch == '\\';
341 					}
342 					if (pos >= humanForm.length) {
343 						throw new StreamCorruptedException(
344 								BCText.get().sexprStringNotClosed);
345 					}
346 					// start is on the first character of the string, pos on the
347 					// closing quote.
348 					byte[] dq = dequote(humanForm, start, pos);
349 					out.write(Integer.toString(dq.length)
350 							.getBytes(StandardCharsets.US_ASCII));
351 					out.write(':');
352 					out.write(dq);
353 					pos++;
354 				} else {
355 					throw new StreamCorruptedException(
356 							MessageFormat.format(BCText.get().sexprUnhandled,
357 									Integer.toHexString(b & 0xFF)));
358 				}
359 			}
360 			return out.toByteArray();
361 		}
362 	}
363 
364 	/**
365 	 * GPG-style string de-quoting, which is basically C-style, with some
366 	 * literal CR/LF escaping.
367 	 *
368 	 * @param in
369 	 *            buffer containing the quoted string
370 	 * @param from
371 	 *            index after the opening quote in {@code in}
372 	 * @param to
373 	 *            index of the closing quote in {@code in}
374 	 * @return the dequoted raw string value
375 	 * @throws StreamCorruptedException
376 	 */
dequote(byte[] in, int from, int to)377 	private static byte[] dequote(byte[] in, int from, int to)
378 			throws StreamCorruptedException {
379 		// Result must be shorter or have the same length
380 		byte[] out = new byte[to - from];
381 		int j = 0;
382 		int i = from;
383 		while (i < to) {
384 			byte b = in[i++];
385 			if (b != '\\') {
386 				out[j++] = b;
387 				continue;
388 			}
389 			if (i == to) {
390 				throw new StreamCorruptedException(
391 						BCText.get().sexprStringInvalidEscapeAtEnd);
392 			}
393 			b = in[i++];
394 			switch (b) {
395 			case 'b':
396 				out[j++] = '\b';
397 				break;
398 			case 'f':
399 				out[j++] = '\f';
400 				break;
401 			case 'n':
402 				out[j++] = '\n';
403 				break;
404 			case 'r':
405 				out[j++] = '\r';
406 				break;
407 			case 't':
408 				out[j++] = '\t';
409 				break;
410 			case 'v':
411 				out[j++] = 0x0B;
412 				break;
413 			case '"':
414 			case '\'':
415 			case '\\':
416 				out[j++] = b;
417 				break;
418 			case '\r':
419 				// Escaped literal line end. If an LF is following, skip that,
420 				// too.
421 				if (i < to && in[i] == '\n') {
422 					i++;
423 				}
424 				break;
425 			case '\n':
426 				// Same for LF possibly followed by CR.
427 				if (i < to && in[i] == '\r') {
428 					i++;
429 				}
430 				break;
431 			case 'x':
432 				if (i + 1 >= to || !isHex(in[i]) || !isHex(in[i + 1])) {
433 					throw new StreamCorruptedException(
434 							BCText.get().sexprStringInvalidHexEscape);
435 				}
436 				out[j++] = (byte) ((nibble(in[i]) << 4) | nibble(in[i + 1]));
437 				i += 2;
438 				break;
439 			case '0':
440 			case '1':
441 			case '2':
442 			case '3':
443 				if (i + 2 >= to || !isOctal(in[i]) || !isOctal(in[i + 1])
444 						|| !isOctal(in[i + 2])) {
445 					throw new StreamCorruptedException(
446 							BCText.get().sexprStringInvalidOctalEscape);
447 				}
448 				out[j++] = (byte) (((((in[i] - '0') << 3)
449 						| (in[i + 1] - '0')) << 3) | (in[i + 2] - '0'));
450 				i += 3;
451 				break;
452 			default:
453 				throw new StreamCorruptedException(MessageFormat.format(
454 						BCText.get().sexprStringInvalidEscape,
455 						Integer.toHexString(b & 0xFF)));
456 			}
457 		}
458 		return Arrays.copyOf(out, j);
459 	}
460 
461 	/**
462 	 * Extracts the key from a GPG name-value-pair key file.
463 	 * <p>
464 	 * Package-visible for tests only.
465 	 * </p>
466 	 *
467 	 * @param in
468 	 *            {@link InputStream} to read from; should be buffered
469 	 * @return the raw key data as extracted from the file
470 	 * @throws IOException
471 	 *             if the {@code in} stream cannot be read or does not contain a
472 	 *             key
473 	 */
keyFromNameValueFormat(InputStream in)474 	static byte[] keyFromNameValueFormat(InputStream in) throws IOException {
475 		// It would be nice if we could use RawParseUtils here, but GPG compares
476 		// names case-insensitively. We're only interested in the "Key:"
477 		// name-value pair.
478 		int[] nameLow = { 'k', 'e', 'y', ':' };
479 		int[] nameCap = { 'K', 'E', 'Y', ':' };
480 		int nameIdx = 0;
481 		for (;;) {
482 			int next = in.read();
483 			if (next < 0) {
484 				throw new EOFException();
485 			}
486 			if (next == '\n') {
487 				nameIdx = 0;
488 			} else if (nameIdx >= 0) {
489 				if (nameLow[nameIdx] == next || nameCap[nameIdx] == next) {
490 					nameIdx++;
491 					if (nameIdx == nameLow.length) {
492 						break;
493 					}
494 				} else {
495 					nameIdx = -1;
496 				}
497 			}
498 		}
499 		// We're after "Key:". Read the value as continuation lines.
500 		int last = ':';
501 		byte[] rawData;
502 		try (ByteArrayOutputStream out = new ByteArrayOutputStream(8192)) {
503 			for (;;) {
504 				int next = in.read();
505 				if (next < 0) {
506 					break;
507 				}
508 				if (last == '\n') {
509 					if (next == ' ' || next == '\t') {
510 						// Continuation line; skip this whitespace
511 						last = next;
512 						continue;
513 					}
514 					break; // Not a continuation line
515 				}
516 				out.write(next);
517 				last = next;
518 			}
519 			rawData = out.toByteArray();
520 		}
521 		// GPG trims off trailing whitespace, and a line having only whitespace
522 		// is a single LF.
523 		try (ByteArrayOutputStream out = new ByteArrayOutputStream(
524 				rawData.length)) {
525 			int lineStart = 0;
526 			boolean trimLeading = true;
527 			while (lineStart < rawData.length) {
528 				int nextLineStart = RawParseUtils.nextLF(rawData, lineStart);
529 				if (trimLeading) {
530 					while (lineStart < nextLineStart
531 							&& isGpgSpace(rawData[lineStart])) {
532 						lineStart++;
533 					}
534 				}
535 				// Trim trailing
536 				int i = nextLineStart - 1;
537 				while (lineStart < i && isGpgSpace(rawData[i])) {
538 					i--;
539 				}
540 				if (i <= lineStart) {
541 					// Empty line signifies LF
542 					out.write('\n');
543 					trimLeading = true;
544 				} else {
545 					out.write(rawData, lineStart, i - lineStart + 1);
546 					trimLeading = false;
547 				}
548 				lineStart = nextLineStart;
549 			}
550 			return out.toByteArray();
551 		}
552 	}
553 
isGpgSpace(int ch)554 	private static boolean isGpgSpace(int ch) {
555 		return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
556 	}
557 
isTokenChar(int ch)558 	private static boolean isTokenChar(int ch) {
559 		switch (ch) {
560 		case '-':
561 		case '.':
562 		case '/':
563 		case '_':
564 		case ':':
565 		case '*':
566 		case '+':
567 		case '=':
568 			return true;
569 		default:
570 			if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
571 					|| (ch >= '0' && ch <= '9')) {
572 				return true;
573 			}
574 			return false;
575 		}
576 	}
577 
isHex(int ch)578 	private static boolean isHex(int ch) {
579 		return (ch >= '0' && ch <= '9') || (ch >= 'A' && ch <= 'F')
580 				|| (ch >= 'a' && ch <= 'f');
581 	}
582 
isOctal(int ch)583 	private static boolean isOctal(int ch) {
584 		return (ch >= '0' && ch <= '7');
585 	}
586 
nibble(int ch)587 	private static int nibble(int ch) {
588 		if (ch >= '0' && ch <= '9') {
589 			return ch - '0';
590 		} else if (ch >= 'A' && ch <= 'F') {
591 			return ch - 'A' + 10;
592 		} else if (ch >= 'a' && ch <= 'f') {
593 			return ch - 'a' + 10;
594 		}
595 		return -1;
596 	}
597 }
598