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 java.io.UnsupportedEncodingException;
017import java.net.IDN;
018import java.security.MessageDigest;
019import java.security.NoSuchAlgorithmException;
020import java.time.Instant;
021import java.time.ZoneId;
022import java.time.ZonedDateTime;
023import java.util.regex.Matcher;
024import java.util.regex.Pattern;
025
026import org.jose4j.base64url.Base64Url;
027import org.jose4j.jwk.EllipticCurveJsonWebKey;
028import org.jose4j.jwk.JsonWebKey;
029import org.jose4j.jwk.RsaJsonWebKey;
030import org.jose4j.jws.AlgorithmIdentifiers;
031import org.jose4j.jws.JsonWebSignature;
032import org.shredzone.acme4j.exception.AcmeProtocolException;
033
034/**
035 * Contains utility methods that are frequently used for the ACME protocol.
036 * <p>
037 * This class is internal. You may use it in your own code, but be warned that methods may
038 * change their signature or disappear without prior announcement.
039 */
040public final class AcmeUtils {
041    private static final char[] HEX = "0123456789abcdef".toCharArray();
042    private static final String ACME_ERROR_PREFIX = "urn:ietf:params:acme:error:";
043    private static final String ACME_ERROR_PREFIX_DEPRECATED = "urn:acme:error:";
044
045    private static final Pattern DATE_PATTERN = Pattern.compile(
046                    "^(\\d{4})-(\\d{2})-(\\d{2})T"
047                  + "(\\d{2}):(\\d{2}):(\\d{2})"
048                  + "(?:\\.(\\d{1,3})\\d*)?"
049                  + "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE);
050
051    private static final Pattern TZ_PATTERN = Pattern.compile(
052                "([+-])(\\d{2}):?(\\d{2})$");
053
054    private static final Pattern CONTENT_TYPE_PATTERN = Pattern.compile(
055                "([^;]+)(?:;.*?charset=(\"?)([a-z0-9_-]+)(\\2))?.*", Pattern.CASE_INSENSITIVE);
056
057    private AcmeUtils() {
058        // Utility class without constructor
059    }
060
061    /**
062     * Computes a SHA-256 hash of the given string.
063     *
064     * @param z
065     *            String to hash
066     * @return Hash
067     */
068    public static byte[] sha256hash(String z) {
069        try {
070            MessageDigest md = MessageDigest.getInstance("SHA-256");
071            md.update(z.getBytes("UTF-8"));
072            return md.digest();
073        } catch (NoSuchAlgorithmException | UnsupportedEncodingException ex) {
074            throw new AcmeProtocolException("Could not compute hash", ex);
075        }
076    }
077
078    /**
079     * Hex encodes the given byte array.
080     *
081     * @param data
082     *            byte array to hex encode
083     * @return Hex encoded string of the data (with lower case characters)
084     */
085    public static String hexEncode(byte[] data) {
086        char[] result = new char[data.length * 2];
087        for (int ix = 0; ix < data.length; ix++) {
088            int val = data[ix] & 0xFF;
089            result[ix * 2] = HEX[val >>> 4];
090            result[ix * 2 + 1] = HEX[val & 0x0F];
091        }
092        return new String(result);
093    }
094
095    /**
096     * Base64 encodes the given byte array, using URL style encoding.
097     *
098     * @param data
099     *            byte array to base64 encode
100     * @return base64 encoded string
101     */
102    public static String base64UrlEncode(byte[] data) {
103        return Base64Url.encode(data);
104    }
105
106    /**
107     * ASCII encodes a domain name.
108     * <p>
109     * The conversion is done as described in
110     * <a href="http://www.ietf.org/rfc/rfc3490.txt">RFC 3490</a>. Additionally, all
111     * leading and trailing white spaces are trimmed, and the result is lowercased.
112     * <p>
113     * It is safe to pass in ACE encoded domains, they will be returned unchanged.
114     *
115     * @param domain
116     *            Domain name to encode
117     * @return Encoded domain name, white space trimmed and lower cased. {@code null} if
118     *         {@code null} was passed in.
119     */
120    public static String toAce(String domain) {
121        if (domain == null) {
122            return null;
123        }
124        return IDN.toASCII(domain.trim()).toLowerCase();
125    }
126
127    /**
128     * Analyzes the key used in the {@link JsonWebKey}, and returns the key algorithm
129     * identifier for {@link JsonWebSignature}.
130     *
131     * @param jwk
132     *            {@link JsonWebKey} to analyze
133     * @return algorithm identifier
134     * @throws IllegalArgumentException
135     *             there is no corresponding algorithm identifier for the key
136     */
137    public static String keyAlgorithm(JsonWebKey jwk) {
138        if (jwk instanceof EllipticCurveJsonWebKey) {
139            EllipticCurveJsonWebKey ecjwk = (EllipticCurveJsonWebKey) jwk;
140
141            switch (ecjwk.getCurveName()) {
142                case "P-256":
143                    return AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256;
144
145                case "P-384":
146                    return AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384;
147
148                case "P-521":
149                    return AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512;
150
151                default:
152                    throw new IllegalArgumentException("Unknown EC name "
153                        + ecjwk.getCurveName());
154            }
155
156        } else if (jwk instanceof RsaJsonWebKey) {
157            return AlgorithmIdentifiers.RSA_USING_SHA256;
158
159        } else {
160            throw new IllegalArgumentException("Unknown algorithm " + jwk.getAlgorithm());
161        }
162    }
163
164    /**
165     * Parses a RFC 3339 formatted date.
166     *
167     * @param str
168     *            Date string
169     * @return {@link Instant} that was parsed
170     * @throws IllegalArgumentException
171     *             if the date string was not RFC 3339 formatted
172     * @see <a href="https://www.ietf.org/rfc/rfc3339.txt">RFC 3339</a>
173     */
174    public static Instant parseTimestamp(String str) {
175        Matcher m = DATE_PATTERN.matcher(str);
176        if (!m.matches()) {
177            throw new IllegalArgumentException("Illegal date: " + str);
178        }
179
180        int year = Integer.parseInt(m.group(1));
181        int month = Integer.parseInt(m.group(2));
182        int dom = Integer.parseInt(m.group(3));
183        int hour = Integer.parseInt(m.group(4));
184        int minute = Integer.parseInt(m.group(5));
185        int second = Integer.parseInt(m.group(6));
186
187        StringBuilder msStr = new StringBuilder();
188        if (m.group(7) != null) {
189            msStr.append(m.group(7));
190        }
191        while (msStr.length() < 3) {
192            msStr.append('0');
193        }
194        int ms = Integer.parseInt(msStr.toString());
195
196        String tz = m.group(8);
197        if ("Z".equalsIgnoreCase(tz)) {
198            tz = "GMT";
199        } else {
200            tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3");
201        }
202
203        return ZonedDateTime.of(
204                year, month, dom, hour, minute, second, ms * 1_000_000,
205                ZoneId.of(tz)).toInstant();
206    }
207
208    /**
209     * Strips the acme error prefix from the error string.
210     * <p>
211     * For example, for "urn:ietf:params:acme:error:conflict", "conflict" is returned.
212     * <p>
213     * This method also handles the deprecated prefix "urn:acme:error:" that is still in
214     * use at Let's Encrypt.
215     *
216     * @param type
217     *            Error type to strip the prefix from. {@code null} is safe.
218     * @return Stripped error type, or {@code null} if the prefix was not found.
219     */
220    public static String stripErrorPrefix(String type) {
221        if (type != null && type.startsWith(ACME_ERROR_PREFIX)) {
222            return type.substring(ACME_ERROR_PREFIX.length());
223        } else if (type != null && type.startsWith(ACME_ERROR_PREFIX_DEPRECATED)) {
224            return type.substring(ACME_ERROR_PREFIX_DEPRECATED.length());
225        } else {
226            return null;
227        }
228    }
229
230    /**
231     * Extracts the content type of a Content-Type header.
232     *
233     * @param header
234     *            Content-Type header
235     * @return Content-Type, or {@code null} if the header was invalid or empty
236     * @throws AcmeProtocolException
237     *             if the Content-Type header contains a different charset than "utf-8".
238     */
239    public static String getContentType(String header) {
240        if (header != null) {
241            Matcher m = CONTENT_TYPE_PATTERN.matcher(header);
242            if (m.matches()) {
243                String charset = m.group(3);
244                if (charset != null && !"utf-8".equalsIgnoreCase(charset)) {
245                    throw new AcmeProtocolException("Unsupported charset " + charset);
246                }
247                return m.group(1).trim().toLowerCase();
248            }
249        }
250        return null;
251    }
252
253}