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}