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 according to
336     * draft-ietf-acme-ari-03.
337     *
338     * @param certificate
339     *         Certificate to get the unique identifier for.
340     * @return Unique identifier
341     * @throws AcmeProtocolException
342     *         if the certificate is invalid or does not provide the necessary
343     *         information.
344     */
345    public static String getRenewalUniqueIdentifier(X509Certificate certificate) {
346        try {
347            var cert = new X509CertificateHolder(certificate.getEncoded());
348
349            var aki = Optional.of(cert)
350                    .map(X509CertificateHolder::getExtensions)
351                    .map(AuthorityKeyIdentifier::fromExtensions)
352                    .map(AuthorityKeyIdentifier::getKeyIdentifier)
353                    .map(AcmeUtils::base64UrlEncode)
354                    .orElseThrow(() -> new AcmeProtocolException("Missing or invalid Authority Key Identifier"));
355
356            var sn = Optional.of(cert)
357                    .map(X509CertificateHolder::toASN1Structure)
358                    .map(Certificate::getSerialNumber)
359                    .map(AcmeUtils::getRawInteger)
360                    .map(AcmeUtils::base64UrlEncode)
361                    .orElseThrow(() -> new AcmeProtocolException("Missing or invalid serial number"));
362
363            return aki + '.' + sn;
364        } catch (Exception ex) {
365            throw new AcmeProtocolException("Invalid certificate", ex);
366        }
367    }
368
369    /**
370     * Gets the raw integer array from ASN1Integer. This is done by encoding it to a byte
371     * array, and then skipping the INTEGER identifier. Other methods of ASN1Integer only
372     * deliver a parsed integer value that might have been mangled.
373     *
374     * @param integer
375     *         ASN1Integer to convert to raw
376     * @return Byte array of the raw integer
377     */
378    private static byte[] getRawInteger(ASN1Integer integer) {
379        try {
380            var encoded = integer.getEncoded();
381            return Arrays.copyOfRange(encoded, 2, encoded.length);
382        } catch (IOException ex) {
383            throw new UncheckedIOException(ex);
384        }
385    }
386
387}