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}