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;
015
016import static java.util.Objects.requireNonNull;
017import static org.jose4j.jws.AlgorithmIdentifiers.*;
018import static org.shredzone.acme4j.toolbox.JoseUtils.macKeyAlgorithm;
019
020import java.net.URI;
021import java.security.KeyPair;
022import java.util.ArrayList;
023import java.util.List;
024import java.util.Optional;
025import java.util.Set;
026
027import javax.crypto.SecretKey;
028import javax.crypto.spec.SecretKeySpec;
029
030import edu.umd.cs.findbugs.annotations.Nullable;
031import org.shredzone.acme4j.connector.Resource;
032import org.shredzone.acme4j.exception.AcmeException;
033import org.shredzone.acme4j.toolbox.AcmeUtils;
034import org.shredzone.acme4j.toolbox.JSONBuilder;
035import org.shredzone.acme4j.toolbox.JoseUtils;
036import org.slf4j.Logger;
037import org.slf4j.LoggerFactory;
038
039/**
040 * A builder for registering a new account with the CA.
041 * <p>
042 * You need to create a new key pair and set it via {@link #useKeyPair(KeyPair)}. Your
043 * account will be identified by the public part of that key pair, so make sure to store
044 * it safely! There is no automatic way to regain access to your account if the key pair
045 * is lost.
046 * <p>
047 * Depending on the CA you register with, you might need to give additional information.
048 * <ul>
049 *     <li>You might need to agree to the terms of service via
050 *     {@link #agreeToTermsOfService()}.</li>
051 *     <li>You might need to give at least one contact URI.</li>
052 *     <li>You might need to provide a key identifier (e.g. your customer number) and
053 *     a shared secret via {@link #withKeyIdentifier(String, SecretKey)}.</li>
054 * </ul>
055 * <p>
056 * It is not possible to modify an existing account with the {@link AccountBuilder}. To
057 * modify an existing account, use {@link Account#modify()} and
058 * {@link Account#changeKey(KeyPair)}.
059 */
060public class AccountBuilder {
061    private static final Logger LOG = LoggerFactory.getLogger(AccountBuilder.class);
062    private static final Set<String> VALID_ALGORITHMS = Set.of(HMAC_SHA256, HMAC_SHA384, HMAC_SHA512);
063
064    private final List<URI> contacts = new ArrayList<>();
065    private @Nullable Boolean termsOfServiceAgreed;
066    private @Nullable Boolean onlyExisting;
067    private @Nullable String keyIdentifier;
068    private @Nullable KeyPair keyPair;
069    private @Nullable SecretKey macKey;
070    private @Nullable String macAlgorithm;
071
072    /**
073     * Add a contact URI to the list of contacts.
074     * <p>
075     * A contact URI may be e.g. an email address or a phone number. It depends on the CA
076     * what kind of contact URIs are accepted, and how many must be provided as minimum.
077     *
078     * @param contact
079     *         Contact URI
080     * @return itself
081     */
082    public AccountBuilder addContact(URI contact) {
083        AcmeUtils.validateContact(contact);
084        contacts.add(contact);
085        return this;
086    }
087
088    /**
089     * Add a contact address to the list of contacts.
090     * <p>
091     * This is a convenience call for {@link #addContact(URI)}.
092     *
093     * @param contact
094     *         Contact URI as string
095     * @return itself
096     * @throws IllegalArgumentException
097     *         if there is a syntax error in the URI string
098     */
099    public AccountBuilder addContact(String contact) {
100        addContact(URI.create(contact));
101        return this;
102    }
103
104    /**
105     * Add an email address to the list of contacts.
106     * <p>
107     * This is a convenience call for {@link #addContact(String)} that doesn't require
108     * to prepend the "mailto" scheme to an email address.
109     *
110     * @param email
111     *         Contact email without "mailto" scheme (e.g. test@gmail.com)
112     * @return itself
113     * @throws IllegalArgumentException
114     *         if there is a syntax error in the URI string
115     */
116    public AccountBuilder addEmail(String email) {
117        if (email.startsWith("mailto:")) {
118            addContact(email);
119        } else {
120            addContact("mailto:" + email);
121        }
122        return this;
123    }
124
125    /**
126     * Documents that the user has agreed to the terms of service.
127     * <p>
128     * If the CA requires the user to agree to the terms of service, it is your
129     * responsibility to present them to the user, and actively ask for their agreement. A
130     * link to the terms of service is provided via
131     * {@code session.getMetadata().getTermsOfService()}.
132     *
133     * @return itself
134     */
135    public AccountBuilder agreeToTermsOfService() {
136        this.termsOfServiceAgreed = true;
137        return this;
138    }
139
140    /**
141     * Signals that only an existing account should be returned. The server will not
142     * create a new account if the key is not known.
143     * <p>
144     * If you have lost your account's location URL, but still have your account's key
145     * pair, you can register your account again with the same key, and use
146     * {@link #onlyExisting()} to make sure that your existing account is returned. If
147     * your key is unknown to the server, an error is thrown once the account is to be
148     * created.
149     *
150     * @return itself
151     */
152    public AccountBuilder onlyExisting() {
153        this.onlyExisting = true;
154        return this;
155    }
156
157    /**
158     * Sets the {@link KeyPair} to be used for this account.
159     * <p>
160     * Only the public key of the pair is sent to the server for registration. acme4j will
161     * never send the private key part.
162     * <p>
163     * Make sure to store your key pair safely after registration! There is no automatic
164     * way to regain access to your account if the key pair is lost.
165     *
166     * @param keyPair
167     *         Account's {@link KeyPair}
168     * @return itself
169     */
170    public AccountBuilder useKeyPair(KeyPair keyPair) {
171        this.keyPair = requireNonNull(keyPair, "keyPair");
172        return this;
173    }
174
175    /**
176     * Sets a Key Identifier and MAC key provided by the CA. Use this if your CA requires
177     * an individual account identification (e.g. your customer number) and a shared
178     * secret for registration. See the documentation of your CA about how to retrieve the
179     * key identifier and MAC key.
180     *
181     * @param kid
182     *         Key Identifier
183     * @param macKey
184     *         MAC key
185     * @return itself
186     * @see #withKeyIdentifier(String, String)
187     */
188    public AccountBuilder withKeyIdentifier(String kid, SecretKey macKey) {
189        if (kid != null && kid.isEmpty()) {
190            throw new IllegalArgumentException("kid must not be empty");
191        }
192        this.macKey = requireNonNull(macKey, "macKey");
193        this.keyIdentifier = kid;
194        return this;
195    }
196
197    /**
198     * Sets a Key Identifier and MAC key provided by the CA. Use this if your CA requires
199     * an individual account identification (e.g. your customer number) and a shared
200     * secret for registration. See the documentation of your CA about how to retrieve the
201     * key identifier and MAC key.
202     * <p>
203     * This is a convenience call of {@link #withKeyIdentifier(String, SecretKey)} that
204     * accepts a base64url encoded MAC key, so both parameters can be passed in as
205     * strings.
206     *
207     * @param kid
208     *         Key Identifier
209     * @param encodedMacKey
210     *         Base64url encoded MAC key.
211     * @return itself
212     * @see #withKeyIdentifier(String, SecretKey)
213     */
214    public AccountBuilder withKeyIdentifier(String kid, String encodedMacKey) {
215        var encodedKey = AcmeUtils.base64UrlDecode(requireNonNull(encodedMacKey, "encodedMacKey"));
216        return withKeyIdentifier(kid, new SecretKeySpec(encodedKey, "HMAC"));
217    }
218
219    /**
220     * Sets the MAC key algorithm that is provided by the CA. To be used in combination
221     * with key identifier. By default, the algorithm is deduced from the size of the
222     * MAC key. If a different size is needed, it can be set using this method.
223     *
224     * @param macAlgorithm
225     *         the algorithm to be set in the {@code alg} field, e.g. {@code "HS512"}.
226     * @return itself
227     * @since 3.1.0
228     */
229    public AccountBuilder withMacAlgorithm(String macAlgorithm) {
230        var algorithm = requireNonNull(macAlgorithm, "macAlgorithm");
231        if (!VALID_ALGORITHMS.contains(algorithm)) {
232            throw new IllegalArgumentException("Invalid MAC algorithm: " + macAlgorithm);
233        }
234        this.macAlgorithm = algorithm;
235        return this;
236    }
237
238    /**
239     * Creates a new account.
240     * <p>
241     * Use this method to finally create your account with the given parameters. Do not
242     * use the {@link AccountBuilder} after invoking this method.
243     *
244     * @param session
245     *         {@link Session} to be used for registration
246     * @return {@link Account} referring to the new account
247     * @see #createLogin(Session)
248     */
249    public Account create(Session session) throws AcmeException {
250        return createLogin(session).getAccount();
251    }
252
253    /**
254     * Creates a new account.
255     * <p>
256     * This method is identical to {@link #create(Session)}, but returns a {@link Login}
257     * that is ready to be used.
258     *
259     * @param session
260     *         {@link Session} to be used for registration
261     * @return {@link Login} referring to the new account
262     */
263    public Login createLogin(Session session) throws AcmeException {
264        requireNonNull(session, "session");
265
266        if (keyPair == null) {
267            throw new IllegalStateException("Use AccountBuilder.useKeyPair() to set the account's key pair.");
268        }
269
270        LOG.debug("create");
271
272        try (var conn = session.connect()) {
273            var resourceUrl = session.resourceUrl(Resource.NEW_ACCOUNT);
274
275            var claims = new JSONBuilder();
276            if (!contacts.isEmpty()) {
277                claims.put("contact", contacts);
278            }
279            if (termsOfServiceAgreed != null) {
280                claims.put("termsOfServiceAgreed", termsOfServiceAgreed);
281            }
282            if (keyIdentifier != null && macKey != null) {
283                var algorithm = Optional.ofNullable(macAlgorithm)
284                        .or(session.provider()::getProposedEabMacAlgorithm)
285// FIXME: Cannot use a Supplier here due to a Spotbugs false positive "null pointer dereference"
286                        .orElse(macKeyAlgorithm(macKey));
287                claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding(
288                        keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl));
289            }
290            if (onlyExisting != null) {
291                claims.put("onlyReturnExisting", onlyExisting);
292            }
293
294            conn.sendSignedRequest(resourceUrl, claims, session, keyPair);
295
296            var login = new Login(conn.getLocation(), keyPair, session);
297            login.getAccount().setJSON(conn.readJsonResponse());
298            return login;
299        }
300    }
301
302}