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