001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2015 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.stream.Collectors.toUnmodifiableList;
017
018import java.net.URI;
019import java.security.KeyPair;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Iterator;
023import java.util.List;
024import java.util.Objects;
025import java.util.Optional;
026
027import org.shredzone.acme4j.connector.Resource;
028import org.shredzone.acme4j.connector.ResourceIterator;
029import org.shredzone.acme4j.exception.AcmeException;
030import org.shredzone.acme4j.exception.AcmeNotSupportedException;
031import org.shredzone.acme4j.exception.AcmeProtocolException;
032import org.shredzone.acme4j.exception.AcmeServerException;
033import org.shredzone.acme4j.toolbox.AcmeUtils;
034import org.shredzone.acme4j.toolbox.JSON.Value;
035import org.shredzone.acme4j.toolbox.JSONBuilder;
036import org.shredzone.acme4j.toolbox.JoseUtils;
037import org.slf4j.Logger;
038import org.slf4j.LoggerFactory;
039
040/**
041 * A representation of an account at the ACME server.
042 */
043public class Account extends AcmeJsonResource {
044    private static final long serialVersionUID = 7042863483428051319L;
045    private static final Logger LOG = LoggerFactory.getLogger(Account.class);
046
047    private static final String KEY_TOS_AGREED = "termsOfServiceAgreed";
048    private static final String KEY_ORDERS = "orders";
049    private static final String KEY_CONTACT = "contact";
050    private static final String KEY_STATUS = "status";
051    private static final String KEY_EXTERNAL_ACCOUNT_BINDING = "externalAccountBinding";
052
053    protected Account(Login login) {
054        super(login, login.getAccountLocation());
055    }
056
057    /**
058     * Returns if the user agreed to the terms of service.
059     *
060     * @return {@code true} if the user agreed to the terms of service. May be
061     *         empty if the server did not provide such an information.
062     */
063    public Optional<Boolean> getTermsOfServiceAgreed() {
064        return getJSON().get(KEY_TOS_AGREED).map(Value::asBoolean);
065    }
066
067    /**
068     * List of registered contact addresses (emails, phone numbers etc).
069     * <p>
070     * This list is unmodifiable. Use {@link #modify()} to change the contacts. May be
071     * empty, but is never {@code null}.
072     */
073    public List<URI> getContacts() {
074        return getJSON().get(KEY_CONTACT)
075                .asArray()
076                .stream()
077                .map(Value::asURI)
078                .collect(toUnmodifiableList());
079    }
080
081    /**
082     * Returns the current status of the account.
083     * <p>
084     * Possible values are: {@link Status#VALID}, {@link Status#DEACTIVATED},
085     * {@link Status#REVOKED}.
086     */
087    public Status getStatus() {
088        return getJSON().get(KEY_STATUS).asStatus();
089    }
090
091    /**
092     * Returns {@code true} if the account is bound to an external non-ACME account.
093     *
094     * @since 2.8
095     */
096    public boolean hasExternalAccountBinding() {
097        return getJSON().contains(KEY_EXTERNAL_ACCOUNT_BINDING);
098    }
099
100    /**
101     * Returns the key identifier of the external non-ACME account. If this account is
102     * not bound to an external account, the result is empty.
103     *
104     * @since 2.8
105     */
106    public Optional<String> getKeyIdentifier() {
107        return getJSON().get(KEY_EXTERNAL_ACCOUNT_BINDING)
108                .optional().map(Value::asObject)
109                .map(j -> j.get("protected")).map(Value::asEncodedObject)
110                .map(j -> j.get("kid")).map(Value::asString);
111    }
112
113    /**
114     * Returns an {@link Iterator} of all {@link Order} belonging to this
115     * {@link Account}.
116     * <p>
117     * Using the iterator will initiate one or more requests to the ACME server.
118     *
119     * @return {@link Iterator} instance that returns {@link Order} objects in no specific
120     * sorting order. {@link Iterator#hasNext()} and {@link Iterator#next()} may throw
121     * {@link AcmeProtocolException} if a batch of authorization URIs could not be fetched
122     * from the server.
123     */
124    public Iterator<Order> getOrders() {
125        var ordersUrl = getJSON().get(KEY_ORDERS).optional().map(Value::asURL);
126        if (ordersUrl.isEmpty()) {
127            // Let's Encrypt does not provide this field at the moment, although it's required.
128            // See https://github.com/letsencrypt/boulder/issues/3335
129            throw new AcmeNotSupportedException("getOrders()");
130        }
131        return new ResourceIterator<>(getLogin(), KEY_ORDERS, ordersUrl.get(), Login::bindOrder);
132    }
133
134    /**
135     * Creates a builder for a new {@link Order}.
136     *
137     * @return {@link OrderBuilder} object
138     */
139    public OrderBuilder newOrder() {
140        return getLogin().newOrder();
141    }
142
143    /**
144     * Pre-authorizes a domain. The CA will check if it accepts the domain for
145     * certification, and returns the necessary challenges.
146     * <p>
147     * Some servers may not allow pre-authorization.
148     * <p>
149     * It is not possible to pre-authorize wildcard domains.
150     *
151     * @param domain
152     *            Domain name to be pre-authorized. IDN names are accepted and will be ACE
153     *            encoded automatically.
154     * @return {@link Authorization} object for this domain
155     * @throws AcmeException
156     *             if the server does not allow pre-authorization
157     * @throws AcmeServerException
158     *             if the server allows pre-authorization, but will refuse to issue a
159     *             certificate for this domain
160     */
161    public Authorization preAuthorizeDomain(String domain) throws AcmeException {
162        Objects.requireNonNull(domain, "domain");
163        if (domain.isEmpty()) {
164            throw new IllegalArgumentException("domain must not be empty");
165        }
166        return preAuthorize(Identifier.dns(domain));
167    }
168
169    /**
170     * Pre-authorizes an {@link Identifier}. The CA will check if it accepts the
171     * identifier for certification, and returns the necessary challenges.
172     * <p>
173     * Some servers may not allow pre-authorization.
174     * <p>
175     * It is not possible to pre-authorize wildcard domains.
176     *
177     * @param identifier
178     *            {@link Identifier} to be pre-authorized.
179     * @return {@link Authorization} object for this identifier
180     * @throws AcmeException
181     *             if the server does not allow pre-authorization
182     * @throws AcmeServerException
183     *             if the server allows pre-authorization, but will refuse to issue a
184     *             certificate for this identifier
185     * @since 2.3
186     */
187    public Authorization preAuthorize(Identifier identifier) throws AcmeException {
188        Objects.requireNonNull(identifier, "identifier");
189
190        var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
191
192        LOG.debug("preAuthorize {}", identifier);
193        try (var conn = getSession().connect()) {
194            var claims = new JSONBuilder();
195            claims.put("identifier", identifier.toMap());
196
197            conn.sendSignedRequest(newAuthzUrl, claims, getLogin());
198
199            var auth = getLogin().bindAuthorization(conn.getLocation());
200            auth.setJSON(conn.readJsonResponse());
201            return auth;
202        }
203    }
204
205    /**
206     * Changes the {@link KeyPair} associated with the account.
207     * <p>
208     * After a successful call, the new key pair is already set in the associated
209     * {@link Login}. The old key pair can be discarded.
210     *
211     * @param newKeyPair
212     *         new {@link KeyPair} to be used for identifying this account
213     */
214    public void changeKey(KeyPair newKeyPair) throws AcmeException {
215        Objects.requireNonNull(newKeyPair, "newKeyPair");
216        if (Arrays.equals(getLogin().getKeyPair().getPrivate().getEncoded(),
217                        newKeyPair.getPrivate().getEncoded())) {
218            throw new IllegalArgumentException("newKeyPair must actually be a new key pair");
219        }
220
221        LOG.debug("key-change");
222
223        try (var conn = getSession().connect()) {
224            var keyChangeUrl = getSession().resourceUrl(Resource.KEY_CHANGE);
225
226            var payloadClaim = new JSONBuilder();
227            payloadClaim.put("account", getLocation());
228            payloadClaim.putKey("oldKey", getLogin().getKeyPair().getPublic());
229
230            var jose = JoseUtils.createJoseRequest(keyChangeUrl, newKeyPair,
231                    payloadClaim, null, null);
232
233            conn.sendSignedRequest(keyChangeUrl, jose, getLogin());
234
235            getLogin().setKeyPair(newKeyPair);
236        }
237    }
238
239    /**
240     * Permanently deactivates an account. Related certificates may still be valid after
241     * account deactivation, and need to be revoked separately if neccessary.
242     * <p>
243     * A deactivated account cannot be reactivated!
244     */
245    public void deactivate() throws AcmeException {
246        LOG.debug("deactivate");
247        try (var conn = getSession().connect()) {
248            var claims = new JSONBuilder();
249            claims.put(KEY_STATUS, "deactivated");
250
251            conn.sendSignedRequest(getLocation(), claims, getLogin());
252            setJSON(conn.readJsonResponse());
253        }
254    }
255
256    /**
257     * Modifies the account data of the account.
258     *
259     * @return {@link EditableAccount} where the account can be modified
260     */
261    public EditableAccount modify() {
262        return new EditableAccount();
263    }
264
265    /**
266     * Provides editable properties of an {@link Account}.
267     */
268    public class EditableAccount {
269        private final List<URI> editContacts = new ArrayList<>();
270
271        private EditableAccount() {
272            editContacts.addAll(Account.this.getContacts());
273        }
274
275        /**
276         * Returns the list of all contact URIs for modification. Use the {@link List}
277         * methods to modify the contact list.
278         * <p>
279         * The modified list is not validated. If you change entries, you have to make
280         * sure that they are valid according to the RFC. It is recommended to use
281         * the {@code addContact()} methods below to add new contacts to the list.
282         */
283        public List<URI> getContacts() {
284            return editContacts;
285        }
286
287        /**
288         * Adds a new Contact to the account.
289         *
290         * @param contact
291         *            Contact URI
292         * @return itself
293         */
294        public EditableAccount addContact(URI contact) {
295            AcmeUtils.validateContact(contact);
296            editContacts.add(contact);
297            return this;
298        }
299
300        /**
301         * Adds a new Contact to the account.
302         * <p>
303         * This is a convenience call for {@link #addContact(URI)}.
304         *
305         * @param contact
306         *            Contact URI as string
307         * @return itself
308         */
309        public EditableAccount addContact(String contact) {
310            addContact(URI.create(contact));
311            return this;
312        }
313
314        /**
315         * Adds a new Contact email to the account.
316         * <p>
317         * This is a convenience call for {@link #addContact(String)} that doesn't
318         * require to prepend the email address with the "mailto" scheme.
319         *
320         * @param email
321         *            Contact email without "mailto" scheme (e.g. test@gmail.com)
322         * @return itself
323         */
324        public EditableAccount addEmail(String email) {
325            addContact("mailto:" + email);
326            return this;
327        }
328
329        /**
330         * Commits the changes and updates the account.
331         */
332        public void commit() throws AcmeException {
333            LOG.debug("modify/commit");
334            try (var conn = getSession().connect()) {
335                var claims = new JSONBuilder();
336                if (!editContacts.isEmpty()) {
337                    claims.put(KEY_CONTACT, editContacts);
338                }
339
340                conn.sendSignedRequest(getLocation(), claims, getLogin());
341                setJSON(conn.readJsonResponse());
342            }
343        }
344    }
345
346}