001/* 002 * acme4j - Java ACME client 003 * 004 * Copyright (C) 2016 Richard "Shred" Körber 005 * http://acme4j.shredzone.org 006 * 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 013 */ 014package org.shredzone.acme4j.toolbox; 015 016import static java.nio.charset.StandardCharsets.UTF_8; 017 018import java.io.IOException; 019import java.io.UncheckedIOException; 020import java.io.Writer; 021import java.net.IDN; 022import java.net.URI; 023import java.nio.charset.StandardCharsets; 024import java.security.MessageDigest; 025import java.security.NoSuchAlgorithmException; 026import java.security.cert.X509Certificate; 027import java.time.Instant; 028import java.time.ZoneId; 029import java.time.ZonedDateTime; 030import java.util.Arrays; 031import java.util.Base64; 032import java.util.Locale; 033import java.util.Objects; 034import java.util.Optional; 035import java.util.regex.Pattern; 036 037import edu.umd.cs.findbugs.annotations.Nullable; 038import org.bouncycastle.asn1.ASN1Integer; 039import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; 040import org.bouncycastle.asn1.x509.Certificate; 041import org.bouncycastle.cert.X509CertificateHolder; 042import org.shredzone.acme4j.exception.AcmeProtocolException; 043 044/** 045 * Contains utility methods that are frequently used for the ACME protocol. 046 * <p> 047 * This class is internal. You may use it in your own code, but be warned that methods may 048 * change their signature or disappear without prior announcement. 049 */ 050public final class AcmeUtils { 051 private static final char[] HEX = "0123456789abcdef".toCharArray(); 052 private static final String ACME_ERROR_PREFIX = "urn:ietf:params:acme:error:"; 053 054 private static final Pattern DATE_PATTERN = Pattern.compile( 055 "^(\\d{4})-(\\d{2})-(\\d{2})T" 056 + "(\\d{2}):(\\d{2}):(\\d{2})" 057 + "(?:\\.(\\d{1,3})\\d*)?" 058 + "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE); 059 060 private static final Pattern TZ_PATTERN = Pattern.compile( 061 "([+-])(\\d{2}):?(\\d{2})$"); 062 063 private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile( 064 "([^;]+)(?:;.*?charset=(\"?)([a-z0-9_-]+)(\\2))?.*", Pattern.CASE_INSENSITIVE); 065 066 private static final Pattern MAIL_PATTERN = Pattern.compile("\\?|@.*,"); 067 068 private static final Pattern BASE64URL_PATTERN = Pattern.compile("[0-9A-Za-z_-]*"); 069 070 private static final Base64.Encoder PEM_ENCODER = Base64.getMimeEncoder(64, 071 "\n".getBytes(StandardCharsets.US_ASCII)); 072 private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); 073 private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); 074 075 private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray(); 076 077 /** 078 * Enumeration of PEM labels. 079 */ 080 public enum PemLabel { 081 CERTIFICATE("CERTIFICATE"), 082 CERTIFICATE_REQUEST("CERTIFICATE REQUEST"), 083 PRIVATE_KEY("PRIVATE KEY"), 084 PUBLIC_KEY("PUBLIC KEY"); 085 086 private final String label; 087 088 PemLabel(String label) { 089 this.label = label; 090 } 091 092 @Override 093 public String toString() { 094 return label; 095 } 096 } 097 098 099 private AcmeUtils() { 100 // Utility class without constructor 101 } 102 103 /** 104 * Computes a SHA-256 hash of the given string. 105 * 106 * @param z 107 * String to hash 108 * @return Hash 109 */ 110 public static byte[] sha256hash(String z) { 111 try { 112 var md = MessageDigest.getInstance("SHA-256"); 113 md.update(z.getBytes(UTF_8)); 114 return md.digest(); 115 } catch (NoSuchAlgorithmException ex) { 116 throw new AcmeProtocolException("Could not compute hash", ex); 117 } 118 } 119 120 /** 121 * Hex encodes the given byte array. 122 * 123 * @param data 124 * byte array to hex encode 125 * @return Hex encoded string of the data (with lower case characters) 126 */ 127 public static String hexEncode(byte[] data) { 128 var result = new char[data.length * 2]; 129 for (var ix = 0; ix < data.length; ix++) { 130 var val = data[ix] & 0xFF; 131 result[ix * 2] = HEX[val >>> 4]; 132 result[ix * 2 + 1] = HEX[val & 0x0F]; 133 } 134 return new String(result); 135 } 136 137 /** 138 * Base64 encodes the given byte array, using URL style encoding. 139 * 140 * @param data 141 * byte array to base64 encode 142 * @return base64 encoded string 143 */ 144 public static String base64UrlEncode(byte[] data) { 145 return URL_ENCODER.encodeToString(data); 146 } 147 148 /** 149 * Base64 decodes to a byte array, using URL style encoding. 150 * 151 * @param base64 152 * base64 encoded string 153 * @return decoded data 154 */ 155 public static byte[] base64UrlDecode(String base64) { 156 return URL_DECODER.decode(base64); 157 } 158 159 /** 160 * Base32 encodes a byte array. 161 * 162 * @param data Byte array to encode 163 * @return Base32 encoded data (includes padding) 164 * @since 4.0.0 165 */ 166 public static String base32Encode(byte[] data) { 167 var result = new StringBuilder(); 168 var unconverted = new int[5]; 169 var converted = new int[8]; 170 171 for (var ix = 0; ix < (data.length + 4) / 5; ix++) { 172 var blocklen = unconverted.length; 173 for (var pos = 0; pos < unconverted.length; pos++) { 174 if ((ix * 5 + pos) < data.length) { 175 unconverted[pos] = data[ix * 5 + pos] & 0xFF; 176 } else { 177 unconverted[pos] = 0; 178 blocklen--; 179 } 180 } 181 182 converted[0] = (unconverted[0] >> 3) & 0x1F; 183 converted[1] = ((unconverted[0] & 0x07) << 2) | ((unconverted[1] >> 6) & 0x03); 184 converted[2] = (unconverted[1] >> 1) & 0x1F; 185 converted[3] = ((unconverted[1] & 0x01) << 4) | ((unconverted[2] >> 4) & 0x0F); 186 converted[4] = ((unconverted[2] & 0x0F) << 1) | ((unconverted[3] >> 7) & 0x01); 187 converted[5] = (unconverted[3] >> 2) & 0x1F; 188 converted[6] = ((unconverted[3] & 0x03) << 3) | ((unconverted[4] >> 5) & 0x07); 189 converted[7] = unconverted[4] & 0x1F; 190 191 var padding = switch (blocklen) { 192 case 1 -> 6; 193 case 2 -> 4; 194 case 3 -> 3; 195 case 4 -> 1; 196 case 5 -> 0; 197 default -> throw new IllegalArgumentException("blocklen " + blocklen + " out of range"); 198 }; 199 200 Arrays.stream(converted) 201 .limit(converted.length - padding) 202 .map(v -> BASE32_ALPHABET[v]) 203 .forEach(v -> result.append((char) v)); 204 205 if (padding > 0) { 206 result.append("=".repeat(padding)); 207 } 208 } 209 return result.toString(); 210 } 211 212 /** 213 * Validates that the given {@link String} is a valid base64url encoded value. 214 * 215 * @param base64 216 * {@link String} to validate 217 * @return {@code true}: String contains a valid base64url encoded value. 218 * {@code false} if the {@link String} was {@code null} or contained illegal 219 * characters. 220 * @since 2.6 221 */ 222 public static boolean isValidBase64Url(@Nullable String base64) { 223 return base64 != null && BASE64URL_PATTERN.matcher(base64).matches(); 224 } 225 226 /** 227 * ASCII encodes a domain name. 228 * <p> 229 * The conversion is done as described in 230 * <a href="http://www.ietf.org/rfc/rfc3490.txt">RFC 3490</a>. Additionally, all 231 * leading and trailing white spaces are trimmed, and the result is lowercased. 232 * <p> 233 * It is safe to pass in ACE encoded domains, they will be returned unchanged. 234 * 235 * @param domain 236 * Domain name to encode 237 * @return Encoded domain name, white space trimmed and lower cased. 238 */ 239 public static String toAce(String domain) { 240 Objects.requireNonNull(domain, "domain"); 241 return IDN.toASCII(domain.trim()).toLowerCase(Locale.ENGLISH); 242 } 243 244 /** 245 * Parses a RFC 3339 formatted date. 246 * 247 * @param str 248 * Date string 249 * @return {@link Instant} that was parsed 250 * @throws IllegalArgumentException 251 * if the date string was not RFC 3339 formatted 252 * @see <a href="https://www.ietf.org/rfc/rfc3339.txt">RFC 3339</a> 253 */ 254 public static Instant parseTimestamp(String str) { 255 var m = DATE_PATTERN.matcher(str); 256 if (!m.matches()) { 257 throw new IllegalArgumentException("Illegal date: " + str); 258 } 259 260 var year = Integer.parseInt(m.group(1)); 261 var month = Integer.parseInt(m.group(2)); 262 var dom = Integer.parseInt(m.group(3)); 263 var hour = Integer.parseInt(m.group(4)); 264 var minute = Integer.parseInt(m.group(5)); 265 var second = Integer.parseInt(m.group(6)); 266 267 var msStr = new StringBuilder(); 268 if (m.group(7) != null) { 269 msStr.append(m.group(7)); 270 } 271 while (msStr.length() < 3) { 272 msStr.append('0'); 273 } 274 var ms = Integer.parseInt(msStr.toString()); 275 276 var tz = m.group(8); 277 if ("Z".equalsIgnoreCase(tz)) { 278 tz = "GMT"; 279 } else { 280 tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3"); 281 } 282 283 return ZonedDateTime.of( 284 year, month, dom, hour, minute, second, ms * 1_000_000, 285 ZoneId.of(tz)).toInstant(); 286 } 287 288 /** 289 * Converts the given locale to an Accept-Language header value. 290 * 291 * @param locale 292 * {@link Locale} to be used in the header 293 * @return Value that can be used in an Accept-Language header 294 */ 295 public static String localeToLanguageHeader(@Nullable Locale locale) { 296 if (locale == null || "und".equals(locale.toLanguageTag())) { 297 return "*"; 298 } 299 300 var langTag = locale.toLanguageTag(); 301 302 var header = new StringBuilder(langTag); 303 if (langTag.indexOf('-') >= 0) { 304 header.append(',').append(locale.getLanguage()).append(";q=0.8"); 305 } 306 header.append(",*;q=0.1"); 307 308 return header.toString(); 309 } 310 311 /** 312 * Strips the acme error prefix from the error string. 313 * <p> 314 * For example, for "urn:ietf:params:acme:error:unauthorized", "unauthorized" is 315 * returned. 316 * 317 * @param type 318 * Error type to strip the prefix from. {@code null} is safe. 319 * @return Stripped error type, or {@code null} if the prefix was not found. 320 */ 321 @Nullable 322 public static String stripErrorPrefix(@Nullable String type) { 323 if (type != null && type.startsWith(ACME_ERROR_PREFIX)) { 324 return type.substring(ACME_ERROR_PREFIX.length()); 325 } else { 326 return null; 327 } 328 } 329 330 /** 331 * Writes an encoded key or certificate to a file in PEM format. 332 * 333 * @param encoded 334 * Encoded data to write 335 * @param label 336 * {@link PemLabel} to be used 337 * @param out 338 * {@link Writer} to write to. It will not be closed after use! 339 */ 340 public static void writeToPem(byte[] encoded, PemLabel label, Writer out) 341 throws IOException { 342 out.append("-----BEGIN ").append(label.toString()).append("-----\n"); 343 out.append(new String(PEM_ENCODER.encode(encoded), StandardCharsets.US_ASCII)); 344 out.append("\n-----END ").append(label.toString()).append("-----\n"); 345 } 346 347 /** 348 * Extracts the content type of a Content-Type header. 349 * 350 * @param header 351 * Content-Type header 352 * @return Content-Type, or {@code null} if the header was invalid or empty 353 * @throws AcmeProtocolException 354 * if the Content-Type header contains a different charset than "utf-8". 355 */ 356 @Nullable 357 public static String getContentType(@Nullable String header) { 358 if (header != null) { 359 var m = CONTENT_TYPE_PATTERN.matcher(header); 360 if (m.matches()) { 361 var charset = m.group(3); 362 if (charset != null && !"utf-8".equalsIgnoreCase(charset)) { 363 throw new AcmeProtocolException("Unsupported charset " + charset); 364 } 365 return m.group(1).trim().toLowerCase(Locale.ENGLISH); 366 } 367 } 368 return null; 369 } 370 371 /** 372 * Validates a contact {@link URI}. 373 * 374 * @param contact 375 * Contact {@link URI} to validate 376 * @throws IllegalArgumentException 377 * if the contact {@link URI} is not suitable for account contacts. 378 */ 379 public static void validateContact(URI contact) { 380 if ("mailto".equalsIgnoreCase(contact.getScheme())) { 381 var address = contact.toString().substring(7); 382 if (MAIL_PATTERN.matcher(address).find()) { 383 throw new IllegalArgumentException( 384 "multiple recipients or hfields are not allowed: " + contact); 385 } 386 } 387 } 388 389 /** 390 * Returns the certificate's unique identifier for renewal. 391 * 392 * @param certificate 393 * Certificate to get the unique identifier for. 394 * @return Unique identifier 395 * @throws AcmeProtocolException 396 * if the certificate is invalid or does not provide the necessary 397 * information. 398 */ 399 public static String getRenewalUniqueIdentifier(X509Certificate certificate) { 400 try { 401 var cert = new X509CertificateHolder(certificate.getEncoded()); 402 403 var aki = Optional.of(cert) 404 .map(X509CertificateHolder::getExtensions) 405 .map(AuthorityKeyIdentifier::fromExtensions) 406 .map(AuthorityKeyIdentifier::getKeyIdentifier) 407 .map(AcmeUtils::base64UrlEncode) 408 .orElseThrow(() -> new AcmeProtocolException("Missing or invalid Authority Key Identifier")); 409 410 var sn = Optional.of(cert) 411 .map(X509CertificateHolder::toASN1Structure) 412 .map(Certificate::getSerialNumber) 413 .map(AcmeUtils::getRawInteger) 414 .map(AcmeUtils::base64UrlEncode) 415 .orElseThrow(() -> new AcmeProtocolException("Missing or invalid serial number")); 416 417 return aki + '.' + sn; 418 } catch (Exception ex) { 419 throw new AcmeProtocolException("Invalid certificate", ex); 420 } 421 } 422 423 /** 424 * Gets the raw integer array from ASN1Integer. This is done by encoding it to a byte 425 * array, and then skipping the INTEGER identifier. Other methods of ASN1Integer only 426 * deliver a parsed integer value that might have been mangled. 427 * 428 * @param integer 429 * ASN1Integer to convert to raw 430 * @return Byte array of the raw integer 431 */ 432 private static byte[] getRawInteger(ASN1Integer integer) { 433 try { 434 var encoded = integer.getEncoded(); 435 return Arrays.copyOfRange(encoded, 2, encoded.length); 436 } catch (IOException ex) { 437 throw new UncheckedIOException(ex); 438 } 439 } 440 441}