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.Writer; 020import java.net.IDN; 021import java.net.URI; 022import java.nio.charset.StandardCharsets; 023import java.security.MessageDigest; 024import java.security.NoSuchAlgorithmException; 025import java.time.Instant; 026import java.time.ZoneId; 027import java.time.ZonedDateTime; 028import java.util.Base64; 029import java.util.Objects; 030import java.util.regex.Matcher; 031import java.util.regex.Pattern; 032 033import edu.umd.cs.findbugs.annotations.Nullable; 034import org.shredzone.acme4j.exception.AcmeProtocolException; 035 036/** 037 * Contains utility methods that are frequently used for the ACME protocol. 038 * <p> 039 * This class is internal. You may use it in your own code, but be warned that methods may 040 * change their signature or disappear without prior announcement. 041 */ 042public final class AcmeUtils { 043 private static final char[] HEX = "0123456789abcdef".toCharArray(); 044 private static final String ACME_ERROR_PREFIX = "urn:ietf:params:acme:error:"; 045 046 private static final Pattern DATE_PATTERN = Pattern.compile( 047 "^(\\d{4})-(\\d{2})-(\\d{2})T" 048 + "(\\d{2}):(\\d{2}):(\\d{2})" 049 + "(?:\\.(\\d{1,3})\\d*)?" 050 + "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE); 051 052 private static final Pattern TZ_PATTERN = Pattern.compile( 053 "([+-])(\\d{2}):?(\\d{2})$"); 054 055 private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile( 056 "([^;]+)(?:;.*?charset=(\"?)([a-z0-9_-]+)(\\2))?.*", Pattern.CASE_INSENSITIVE); 057 058 private static final Pattern MAIL_PATTERN = Pattern.compile("\\?|@.*,"); 059 060 private static final Pattern BASE64URL_PATTERN = Pattern.compile("[0-9A-Za-z_-]*"); 061 062 private static final Base64.Encoder PEM_ENCODER = Base64.getMimeEncoder(64, 063 "\n".getBytes(StandardCharsets.US_ASCII)); 064 private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding(); 065 private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder(); 066 067 /** 068 * Enumeration of PEM labels. 069 */ 070 public enum PemLabel { 071 CERTIFICATE("CERTIFICATE"), 072 CERTIFICATE_REQUEST("CERTIFICATE REQUEST"), 073 PRIVATE_KEY("PRIVATE KEY"), 074 PUBLIC_KEY("PUBLIC KEY"); 075 076 private final String label; 077 078 PemLabel(String label) { 079 this.label = label; 080 } 081 082 @Override 083 public String toString() { 084 return label; 085 } 086 } 087 088 089 private AcmeUtils() { 090 // Utility class without constructor 091 } 092 093 /** 094 * Computes a SHA-256 hash of the given string. 095 * 096 * @param z 097 * String to hash 098 * @return Hash 099 */ 100 public static byte[] sha256hash(String z) { 101 try { 102 MessageDigest md = MessageDigest.getInstance("SHA-256"); 103 md.update(z.getBytes(UTF_8)); 104 return md.digest(); 105 } catch (NoSuchAlgorithmException ex) { 106 throw new AcmeProtocolException("Could not compute hash", ex); 107 } 108 } 109 110 /** 111 * Hex encodes the given byte array. 112 * 113 * @param data 114 * byte array to hex encode 115 * @return Hex encoded string of the data (with lower case characters) 116 */ 117 public static String hexEncode(byte[] data) { 118 char[] result = new char[data.length * 2]; 119 for (int ix = 0; ix < data.length; ix++) { 120 int val = data[ix] & 0xFF; 121 result[ix * 2] = HEX[val >>> 4]; 122 result[ix * 2 + 1] = HEX[val & 0x0F]; 123 } 124 return new String(result); 125 } 126 127 /** 128 * Base64 encodes the given byte array, using URL style encoding. 129 * 130 * @param data 131 * byte array to base64 encode 132 * @return base64 encoded string 133 */ 134 public static String base64UrlEncode(byte[] data) { 135 return URL_ENCODER.encodeToString(data); 136 } 137 138 /** 139 * Base64 decodes to a byte array, using URL style encoding. 140 * 141 * @param base64 142 * base64 encoded string 143 * @return decoded data 144 */ 145 public static byte[] base64UrlDecode(String base64) { 146 return URL_DECODER.decode(base64); 147 } 148 149 /** 150 * Validates that the given {@link String} is a valid base64url encoded value. 151 * 152 * @param base64 153 * {@link String} to validate 154 * @return {@code true}: String contains a valid base64url encoded value. 155 * {@code false} if the {@link String} was {@code null} or contained illegal 156 * characters. 157 * @since 2.6 158 */ 159 public static boolean isValidBase64Url(@Nullable String base64) { 160 return base64 != null && BASE64URL_PATTERN.matcher(base64).matches(); 161 } 162 163 /** 164 * ASCII encodes a domain name. 165 * <p> 166 * The conversion is done as described in 167 * <a href="http://www.ietf.org/rfc/rfc3490.txt">RFC 3490</a>. Additionally, all 168 * leading and trailing white spaces are trimmed, and the result is lowercased. 169 * <p> 170 * It is safe to pass in ACE encoded domains, they will be returned unchanged. 171 * 172 * @param domain 173 * Domain name to encode 174 * @return Encoded domain name, white space trimmed and lower cased. 175 */ 176 public static String toAce(String domain) { 177 Objects.requireNonNull(domain, "domain"); 178 return IDN.toASCII(domain.trim()).toLowerCase(); 179 } 180 181 /** 182 * Parses a RFC 3339 formatted date. 183 * 184 * @param str 185 * Date string 186 * @return {@link Instant} that was parsed 187 * @throws IllegalArgumentException 188 * if the date string was not RFC 3339 formatted 189 * @see <a href="https://www.ietf.org/rfc/rfc3339.txt">RFC 3339</a> 190 */ 191 public static Instant parseTimestamp(String str) { 192 Matcher m = DATE_PATTERN.matcher(str); 193 if (!m.matches()) { 194 throw new IllegalArgumentException("Illegal date: " + str); 195 } 196 197 int year = Integer.parseInt(m.group(1)); 198 int month = Integer.parseInt(m.group(2)); 199 int dom = Integer.parseInt(m.group(3)); 200 int hour = Integer.parseInt(m.group(4)); 201 int minute = Integer.parseInt(m.group(5)); 202 int second = Integer.parseInt(m.group(6)); 203 204 StringBuilder msStr = new StringBuilder(); 205 if (m.group(7) != null) { 206 msStr.append(m.group(7)); 207 } 208 while (msStr.length() < 3) { 209 msStr.append('0'); 210 } 211 int ms = Integer.parseInt(msStr.toString()); 212 213 String tz = m.group(8); 214 if ("Z".equalsIgnoreCase(tz)) { 215 tz = "GMT"; 216 } else { 217 tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3"); 218 } 219 220 return ZonedDateTime.of( 221 year, month, dom, hour, minute, second, ms * 1_000_000, 222 ZoneId.of(tz)).toInstant(); 223 } 224 225 /** 226 * Strips the acme error prefix from the error string. 227 * <p> 228 * For example, for "urn:ietf:params:acme:error:unauthorized", "unauthorized" is 229 * returned. 230 * 231 * @param type 232 * Error type to strip the prefix from. {@code null} is safe. 233 * @return Stripped error type, or {@code null} if the prefix was not found. 234 */ 235 @Nullable 236 public static String stripErrorPrefix(@Nullable String type) { 237 if (type != null && type.startsWith(ACME_ERROR_PREFIX)) { 238 return type.substring(ACME_ERROR_PREFIX.length()); 239 } else { 240 return null; 241 } 242 } 243 244 /** 245 * Writes an encoded key or certificate to a file in PEM format. 246 * 247 * @param encoded 248 * Encoded data to write 249 * @param label 250 * {@link PemLabel} to be used 251 * @param out 252 * {@link Writer} to write to. It will not be closed after use! 253 */ 254 public static void writeToPem(byte[] encoded, PemLabel label, Writer out) 255 throws IOException { 256 out.append("-----BEGIN ").append(label.toString()).append("-----\n"); 257 out.append(new String(PEM_ENCODER.encode(encoded), StandardCharsets.US_ASCII)); 258 out.append("\n-----END ").append(label.toString()).append("-----\n"); 259 } 260 261 /** 262 * Extracts the content type of a Content-Type header. 263 * 264 * @param header 265 * Content-Type header 266 * @return Content-Type, or {@code null} if the header was invalid or empty 267 * @throws AcmeProtocolException 268 * if the Content-Type header contains a different charset than "utf-8". 269 */ 270 @Nullable 271 public static String getContentType(@Nullable String header) { 272 if (header != null) { 273 Matcher m = CONTENT_TYPE_PATTERN.matcher(header); 274 if (m.matches()) { 275 String charset = m.group(3); 276 if (charset != null && !"utf-8".equalsIgnoreCase(charset)) { 277 throw new AcmeProtocolException("Unsupported charset " + charset); 278 } 279 return m.group(1).trim().toLowerCase(); 280 } 281 } 282 return null; 283 } 284 285 /** 286 * Validates a contact {@link URI}. 287 * 288 * @param contact 289 * Contact {@link URI} to validate 290 * @throws IllegalArgumentException 291 * if the contact {@link URI} is not suitable for account contacts. 292 */ 293 public static void validateContact(URI contact) { 294 if ("mailto".equalsIgnoreCase(contact.getScheme())) { 295 String address = contact.toString().substring(7); 296 if (MAIL_PATTERN.matcher(address).find()) { 297 throw new IllegalArgumentException( 298 "multiple recipients or hfields are not allowed: " + contact); 299 } 300 } 301 } 302 303}