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 /** 076 * Enumeration of PEM labels. 077 */ 078 public enum PemLabel { 079 CERTIFICATE("CERTIFICATE"), 080 CERTIFICATE_REQUEST("CERTIFICATE REQUEST"), 081 PRIVATE_KEY("PRIVATE KEY"), 082 PUBLIC_KEY("PUBLIC KEY"); 083 084 private final String label; 085 086 PemLabel(String label) { 087 this.label = label; 088 } 089 090 @Override 091 public String toString() { 092 return label; 093 } 094 } 095 096 097 private AcmeUtils() { 098 // Utility class without constructor 099 } 100 101 /** 102 * Computes a SHA-256 hash of the given string. 103 * 104 * @param z 105 * String to hash 106 * @return Hash 107 */ 108 public static byte[] sha256hash(String z) { 109 try { 110 var md = MessageDigest.getInstance("SHA-256"); 111 md.update(z.getBytes(UTF_8)); 112 return md.digest(); 113 } catch (NoSuchAlgorithmException ex) { 114 throw new AcmeProtocolException("Could not compute hash", ex); 115 } 116 } 117 118 /** 119 * Hex encodes the given byte array. 120 * 121 * @param data 122 * byte array to hex encode 123 * @return Hex encoded string of the data (with lower case characters) 124 */ 125 public static String hexEncode(byte[] data) { 126 var result = new char[data.length * 2]; 127 for (var ix = 0; ix < data.length; ix++) { 128 var val = data[ix] & 0xFF; 129 result[ix * 2] = HEX[val >>> 4]; 130 result[ix * 2 + 1] = HEX[val & 0x0F]; 131 } 132 return new String(result); 133 } 134 135 /** 136 * Base64 encodes the given byte array, using URL style encoding. 137 * 138 * @param data 139 * byte array to base64 encode 140 * @return base64 encoded string 141 */ 142 public static String base64UrlEncode(byte[] data) { 143 return URL_ENCODER.encodeToString(data); 144 } 145 146 /** 147 * Base64 decodes to a byte array, using URL style encoding. 148 * 149 * @param base64 150 * base64 encoded string 151 * @return decoded data 152 */ 153 public static byte[] base64UrlDecode(String base64) { 154 return URL_DECODER.decode(base64); 155 } 156 157 /** 158 * Validates that the given {@link String} is a valid base64url encoded value. 159 * 160 * @param base64 161 * {@link String} to validate 162 * @return {@code true}: String contains a valid base64url encoded value. 163 * {@code false} if the {@link String} was {@code null} or contained illegal 164 * characters. 165 * @since 2.6 166 */ 167 public static boolean isValidBase64Url(@Nullable String base64) { 168 return base64 != null && BASE64URL_PATTERN.matcher(base64).matches(); 169 } 170 171 /** 172 * ASCII encodes a domain name. 173 * <p> 174 * The conversion is done as described in 175 * <a href="http://www.ietf.org/rfc/rfc3490.txt">RFC 3490</a>. Additionally, all 176 * leading and trailing white spaces are trimmed, and the result is lowercased. 177 * <p> 178 * It is safe to pass in ACE encoded domains, they will be returned unchanged. 179 * 180 * @param domain 181 * Domain name to encode 182 * @return Encoded domain name, white space trimmed and lower cased. 183 */ 184 public static String toAce(String domain) { 185 Objects.requireNonNull(domain, "domain"); 186 return IDN.toASCII(domain.trim()).toLowerCase(Locale.ENGLISH); 187 } 188 189 /** 190 * Parses a RFC 3339 formatted date. 191 * 192 * @param str 193 * Date string 194 * @return {@link Instant} that was parsed 195 * @throws IllegalArgumentException 196 * if the date string was not RFC 3339 formatted 197 * @see <a href="https://www.ietf.org/rfc/rfc3339.txt">RFC 3339</a> 198 */ 199 public static Instant parseTimestamp(String str) { 200 var m = DATE_PATTERN.matcher(str); 201 if (!m.matches()) { 202 throw new IllegalArgumentException("Illegal date: " + str); 203 } 204 205 var year = Integer.parseInt(m.group(1)); 206 var month = Integer.parseInt(m.group(2)); 207 var dom = Integer.parseInt(m.group(3)); 208 var hour = Integer.parseInt(m.group(4)); 209 var minute = Integer.parseInt(m.group(5)); 210 var second = Integer.parseInt(m.group(6)); 211 212 var msStr = new StringBuilder(); 213 if (m.group(7) != null) { 214 msStr.append(m.group(7)); 215 } 216 while (msStr.length() < 3) { 217 msStr.append('0'); 218 } 219 var ms = Integer.parseInt(msStr.toString()); 220 221 var tz = m.group(8); 222 if ("Z".equalsIgnoreCase(tz)) { 223 tz = "GMT"; 224 } else { 225 tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3"); 226 } 227 228 return ZonedDateTime.of( 229 year, month, dom, hour, minute, second, ms * 1_000_000, 230 ZoneId.of(tz)).toInstant(); 231 } 232 233 /** 234 * Converts the given locale to an Accept-Language header value. 235 * 236 * @param locale 237 * {@link Locale} to be used in the header 238 * @return Value that can be used in an Accept-Language header 239 */ 240 public static String localeToLanguageHeader(@Nullable Locale locale) { 241 if (locale == null || "und".equals(locale.toLanguageTag())) { 242 return "*"; 243 } 244 245 var langTag = locale.toLanguageTag(); 246 247 var header = new StringBuilder(langTag); 248 if (langTag.indexOf('-') >= 0) { 249 header.append(',').append(locale.getLanguage()).append(";q=0.8"); 250 } 251 header.append(",*;q=0.1"); 252 253 return header.toString(); 254 } 255 256 /** 257 * Strips the acme error prefix from the error string. 258 * <p> 259 * For example, for "urn:ietf:params:acme:error:unauthorized", "unauthorized" is 260 * returned. 261 * 262 * @param type 263 * Error type to strip the prefix from. {@code null} is safe. 264 * @return Stripped error type, or {@code null} if the prefix was not found. 265 */ 266 @Nullable 267 public static String stripErrorPrefix(@Nullable String type) { 268 if (type != null && type.startsWith(ACME_ERROR_PREFIX)) { 269 return type.substring(ACME_ERROR_PREFIX.length()); 270 } else { 271 return null; 272 } 273 } 274 275 /** 276 * Writes an encoded key or certificate to a file in PEM format. 277 * 278 * @param encoded 279 * Encoded data to write 280 * @param label 281 * {@link PemLabel} to be used 282 * @param out 283 * {@link Writer} to write to. It will not be closed after use! 284 */ 285 public static void writeToPem(byte[] encoded, PemLabel label, Writer out) 286 throws IOException { 287 out.append("-----BEGIN ").append(label.toString()).append("-----\n"); 288 out.append(new String(PEM_ENCODER.encode(encoded), StandardCharsets.US_ASCII)); 289 out.append("\n-----END ").append(label.toString()).append("-----\n"); 290 } 291 292 /** 293 * Extracts the content type of a Content-Type header. 294 * 295 * @param header 296 * Content-Type header 297 * @return Content-Type, or {@code null} if the header was invalid or empty 298 * @throws AcmeProtocolException 299 * if the Content-Type header contains a different charset than "utf-8". 300 */ 301 @Nullable 302 public static String getContentType(@Nullable String header) { 303 if (header != null) { 304 var m = CONTENT_TYPE_PATTERN.matcher(header); 305 if (m.matches()) { 306 var charset = m.group(3); 307 if (charset != null && !"utf-8".equalsIgnoreCase(charset)) { 308 throw new AcmeProtocolException("Unsupported charset " + charset); 309 } 310 return m.group(1).trim().toLowerCase(Locale.ENGLISH); 311 } 312 } 313 return null; 314 } 315 316 /** 317 * Validates a contact {@link URI}. 318 * 319 * @param contact 320 * Contact {@link URI} to validate 321 * @throws IllegalArgumentException 322 * if the contact {@link URI} is not suitable for account contacts. 323 */ 324 public static void validateContact(URI contact) { 325 if ("mailto".equalsIgnoreCase(contact.getScheme())) { 326 var address = contact.toString().substring(7); 327 if (MAIL_PATTERN.matcher(address).find()) { 328 throw new IllegalArgumentException( 329 "multiple recipients or hfields are not allowed: " + contact); 330 } 331 } 332 } 333 334 /** 335 * Returns the certificate's unique identifier for renewal. 336 * 337 * @param certificate 338 * Certificate to get the unique identifier for. 339 * @return Unique identifier 340 * @throws AcmeProtocolException 341 * if the certificate is invalid or does not provide the necessary 342 * information. 343 */ 344 public static String getRenewalUniqueIdentifier(X509Certificate certificate) { 345 try { 346 var cert = new X509CertificateHolder(certificate.getEncoded()); 347 348 var aki = Optional.of(cert) 349 .map(X509CertificateHolder::getExtensions) 350 .map(AuthorityKeyIdentifier::fromExtensions) 351 .map(AuthorityKeyIdentifier::getKeyIdentifier) 352 .map(AcmeUtils::base64UrlEncode) 353 .orElseThrow(() -> new AcmeProtocolException("Missing or invalid Authority Key Identifier")); 354 355 var sn = Optional.of(cert) 356 .map(X509CertificateHolder::toASN1Structure) 357 .map(Certificate::getSerialNumber) 358 .map(AcmeUtils::getRawInteger) 359 .map(AcmeUtils::base64UrlEncode) 360 .orElseThrow(() -> new AcmeProtocolException("Missing or invalid serial number")); 361 362 return aki + '.' + sn; 363 } catch (Exception ex) { 364 throw new AcmeProtocolException("Invalid certificate", ex); 365 } 366 } 367 368 /** 369 * Gets the raw integer array from ASN1Integer. This is done by encoding it to a byte 370 * array, and then skipping the INTEGER identifier. Other methods of ASN1Integer only 371 * deliver a parsed integer value that might have been mangled. 372 * 373 * @param integer 374 * ASN1Integer to convert to raw 375 * @return Byte array of the raw integer 376 */ 377 private static byte[] getRawInteger(ASN1Integer integer) { 378 try { 379 var encoded = integer.getEncoded(); 380 return Arrays.copyOfRange(encoded, 2, encoded.length); 381 } catch (IOException ex) { 382 throw new UncheckedIOException(ex); 383 } 384 } 385 386}