You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SecretKeys.java 17KB

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