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 org.shredzone.acme4j.toolbox.AcmeUtils.*;
017
018import java.net.HttpURLConnection;
019import java.net.URI;
020import java.net.URL;
021import java.security.KeyPair;
022import java.security.cert.X509Certificate;
023import java.time.Instant;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collections;
027import java.util.Iterator;
028import java.util.List;
029import java.util.Objects;
030
031import org.jose4j.jwk.PublicJsonWebKey;
032import org.jose4j.jws.JsonWebSignature;
033import org.jose4j.lang.JoseException;
034import org.shredzone.acme4j.connector.Connection;
035import org.shredzone.acme4j.connector.Resource;
036import org.shredzone.acme4j.connector.ResourceIterator;
037import org.shredzone.acme4j.exception.AcmeException;
038import org.shredzone.acme4j.exception.AcmeProtocolException;
039import org.shredzone.acme4j.exception.AcmeRetryAfterException;
040import org.shredzone.acme4j.toolbox.JSON;
041import org.shredzone.acme4j.toolbox.JSONBuilder;
042import org.slf4j.Logger;
043import org.slf4j.LoggerFactory;
044
045/**
046 * Represents a registration at the ACME server.
047 */
048public class Registration extends AcmeResource {
049    private static final long serialVersionUID = -8177333806740391140L;
050    private static final Logger LOG = LoggerFactory.getLogger(Registration.class);
051
052    private static final String KEY_AGREEMENT = "agreement";
053    private static final String KEY_AUTHORIZATIONS = "authorizations";
054    private static final String KEY_CERTIFICATES = "certificates";
055    private static final String KEY_CONTACT = "contact";
056    private static final String KEY_STATUS = "status";
057
058    private final List<URI> contacts = new ArrayList<>();
059    private URI agreement;
060    private URL authorizations;
061    private URL certificates;
062    private Status status;
063    private boolean loaded = false;
064
065    protected Registration(Session session, URL location) {
066        super(session);
067        setLocation(location);
068    }
069
070    protected Registration(Session session, URL location, URI agreement) {
071        super(session);
072        setLocation(location);
073        this.agreement = agreement;
074    }
075
076    /**
077     * Creates a new instance of {@link Registration} and binds it to the {@link Session}.
078     *
079     * @param session
080     *            {@link Session} to be used
081     * @param location
082     *            Location URL of the registration
083     * @return {@link Registration} bound to the session and location
084     */
085    public static Registration bind(Session session, URL location) {
086        return new Registration(session, location);
087    }
088
089    /**
090     * Returns the URI of the agreement document the user is required to accept.
091     */
092    public URI getAgreement() {
093        if (agreement == null) {
094            load();
095        }
096        return agreement;
097    }
098
099    /**
100     * List of contact addresses (emails, phone numbers etc).
101     */
102    public List<URI> getContacts() {
103        load();
104        return Collections.unmodifiableList(contacts);
105    }
106
107    /**
108     * Returns the current status of the registration.
109     */
110    public Status getStatus() {
111        load();
112        return status;
113    }
114
115    /**
116     * Returns an {@link Iterator} of all {@link Authorization} belonging to this
117     * {@link Registration}.
118     * <p>
119     * Using the iterator will initiate one or more requests to the ACME server.
120     *
121     * @return {@link Iterator} instance that returns {@link Authorization} objects.
122     *         {@link Iterator#hasNext()} and {@link Iterator#next()} may throw
123     *         {@link AcmeProtocolException} if a batch of authorization URIs could not be
124     *         fetched from the server.
125     */
126    public Iterator<Authorization> getAuthorizations() throws AcmeException {
127        LOG.debug("getAuthorizations");
128        load();
129        return new ResourceIterator<>(getSession(), KEY_AUTHORIZATIONS, authorizations, Authorization::bind);
130    }
131
132    /**
133     * Returns an {@link Iterator} of all {@link Certificate} belonging to this
134     * {@link Registration}.
135     * <p>
136     * Using the iterator will initiate one or more requests to the ACME server.
137     *
138     * @return {@link Iterator} instance that returns {@link Certificate} objects.
139     *         {@link Iterator#hasNext()} and {@link Iterator#next()} may throw
140     *         {@link AcmeProtocolException} if a batch of certificate URIs could not be
141     *         fetched from the server.
142     */
143    public Iterator<Certificate> getCertificates() throws AcmeException {
144        LOG.debug("getCertificates");
145        load();
146        return new ResourceIterator<>(getSession(), KEY_CERTIFICATES, certificates, Certificate::bind);
147    }
148
149    /**
150     * Updates the registration to the current account status.
151     */
152    public void update() throws AcmeException {
153        LOG.debug("update");
154        try (Connection conn = getSession().provider().connect()) {
155            JSONBuilder claims = new JSONBuilder();
156            claims.putResource("reg");
157
158            conn.sendSignedRequest(getLocation(), claims, getSession());
159            conn.accept(HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_ACCEPTED);
160
161            JSON json = conn.readJsonResponse();
162            unmarshal(json, conn);
163         }
164    }
165
166    /**
167     * Authorizes a domain. The domain is associated with this registration.
168     * <p>
169     * IDN domain names will be ACE encoded automatically.
170     *
171     * @param domain
172     *            Domain name to be authorized
173     * @return {@link Authorization} object for this domain
174     */
175    public Authorization authorizeDomain(String domain) throws AcmeException {
176        Objects.requireNonNull(domain, "domain");
177        if (domain.isEmpty()) {
178            throw new IllegalArgumentException("domain must not be empty");
179        }
180
181        LOG.debug("authorizeDomain {}", domain);
182        try (Connection conn = getSession().provider().connect()) {
183            JSONBuilder claims = new JSONBuilder();
184            claims.putResource(Resource.NEW_AUTHZ);
185            claims.object("identifier")
186                    .put("type", "dns")
187                    .put("value", toAce(domain));
188
189            conn.sendSignedRequest(getSession().resourceUrl(Resource.NEW_AUTHZ), claims, getSession());
190            conn.accept(HttpURLConnection.HTTP_CREATED);
191
192            JSON json = conn.readJsonResponse();
193
194            Authorization auth = new Authorization(getSession(), conn.getLocation());
195            auth.unmarshalAuthorization(json);
196            return auth;
197        }
198    }
199
200    /**
201     * Requests a certificate for the given CSR.
202     * <p>
203     * All domains given in the CSR must be authorized before.
204     *
205     * @param csr
206     *            PKCS#10 Certificate Signing Request to be sent to the server
207     * @return The {@link Certificate}
208     */
209    public Certificate requestCertificate(byte[] csr) throws AcmeException {
210        return requestCertificate(csr, null, null);
211    }
212
213    /**
214     * Requests a certificate for the given CSR.
215     * <p>
216     * All domains given in the CSR must be authorized before.
217     *
218     * @param csr
219     *            PKCS#10 Certificate Signing Request to be sent to the server
220     * @param notBefore
221     *            requested value of the notBefore field in the certificate, {@code null}
222     *            for default. May be ignored by the server.
223     * @param notAfter
224     *            requested value of the notAfter field in the certificate, {@code null}
225     *            for default. May be ignored by the server.
226     * @return The {@link Certificate}
227     */
228    public Certificate requestCertificate(byte[] csr, Instant notBefore, Instant notAfter)
229                throws AcmeException {
230        Objects.requireNonNull(csr, "csr");
231
232        LOG.debug("requestCertificate");
233        try (Connection conn = getSession().provider().connect()) {
234            JSONBuilder claims = new JSONBuilder();
235            claims.putResource(Resource.NEW_CERT);
236            claims.putBase64("csr", csr);
237            if (notBefore != null) {
238                claims.put("notBefore", notBefore);
239            }
240            if (notAfter != null) {
241                claims.put("notAfter", notAfter);
242            }
243
244            conn.sendSignedRequest(getSession().resourceUrl(Resource.NEW_CERT), claims, getSession());
245            int rc = conn.accept(HttpURLConnection.HTTP_CREATED, HttpURLConnection.HTTP_ACCEPTED);
246
247            X509Certificate cert = null;
248            if (rc == HttpURLConnection.HTTP_CREATED) {
249                try {
250                    cert = conn.readCertificate();
251                } catch (AcmeProtocolException ex) {
252                    LOG.warn("Could not parse attached certificate", ex);
253                }
254            }
255
256            URL chainCertUrl = conn.getLink("up");
257
258            return new Certificate(getSession(), conn.getLocation(), chainCertUrl, cert);
259        }
260    }
261
262    /**
263     * Changes the {@link KeyPair} associated with the registration.
264     * <p>
265     * After a successful call, the new key pair is used in the bound {@link Session},
266     * and the old key pair can be disposed of.
267     *
268     * @param newKeyPair
269     *            new {@link KeyPair} to be used for identifying this account
270     */
271    public void changeKey(KeyPair newKeyPair) throws AcmeException {
272        Objects.requireNonNull(newKeyPair, "newKeyPair");
273        if (Arrays.equals(getSession().getKeyPair().getPrivate().getEncoded(),
274                        newKeyPair.getPrivate().getEncoded())) {
275            throw new IllegalArgumentException("newKeyPair must actually be a new key pair");
276        }
277
278        LOG.debug("key-change");
279
280        try (Connection conn = getSession().provider().connect()) {
281            URL keyChangeUrl = getSession().resourceUrl(Resource.KEY_CHANGE);
282            PublicJsonWebKey newKeyJwk = PublicJsonWebKey.Factory.newPublicJwk(newKeyPair.getPublic());
283
284            JSONBuilder payloadClaim = new JSONBuilder();
285            payloadClaim.put("account", getLocation());
286            payloadClaim.putKey("newKey", newKeyPair.getPublic());
287
288            JsonWebSignature innerJws = new JsonWebSignature();
289            innerJws.setPayload(payloadClaim.toString());
290            innerJws.getHeaders().setObjectHeaderValue("url", keyChangeUrl);
291            innerJws.getHeaders().setJwkHeaderValue("jwk", newKeyJwk);
292            innerJws.setAlgorithmHeaderValue(keyAlgorithm(newKeyJwk));
293            innerJws.setKey(newKeyPair.getPrivate());
294            innerJws.sign();
295
296            JSONBuilder outerClaim = new JSONBuilder();
297            outerClaim.putResource(Resource.KEY_CHANGE); // Let's Encrypt needs the resource here
298            outerClaim.put("protected", innerJws.getHeaders().getEncodedHeader());
299            outerClaim.put("signature", innerJws.getEncodedSignature());
300            outerClaim.put("payload", innerJws.getEncodedPayload());
301
302            conn.sendSignedRequest(keyChangeUrl, outerClaim, getSession());
303            conn.accept(HttpURLConnection.HTTP_OK);
304
305            getSession().setKeyPair(newKeyPair);
306        } catch (JoseException ex) {
307            throw new AcmeProtocolException("Cannot sign key-change", ex);
308        }
309    }
310
311    /**
312     * Permanently deactivates an account. Related certificates may still be valid after
313     * account deactivation, and need to be revoked separately if neccessary.
314     * <p>
315     * A deactivated account cannot be reactivated!
316     */
317    public void deactivate() throws AcmeException {
318        LOG.debug("deactivate");
319        try (Connection conn = getSession().provider().connect()) {
320            JSONBuilder claims = new JSONBuilder();
321            claims.putResource("reg");
322            claims.put(KEY_STATUS, "deactivated");
323
324            conn.sendSignedRequest(getLocation(), claims, getSession());
325            conn.accept(HttpURLConnection.HTTP_OK, HttpURLConnection.HTTP_ACCEPTED);
326        }
327    }
328
329    /**
330     * Lazily updates the object's state when one of the getters is invoked.
331     */
332    protected void load() {
333        if (!loaded) {
334            try {
335                update();
336            } catch (AcmeRetryAfterException ex) {
337                // ignore... The object was still updated.
338                LOG.debug("Retry-After", ex);
339            } catch (AcmeException ex) {
340                throw new AcmeProtocolException("Could not load lazily", ex);
341            }
342        }
343    }
344
345    /**
346     * Sets registration properties according to the given JSON data.
347     *
348     * @param json
349     *            JSON data
350     * @param conn
351     *            {@link Connection} with headers to be evaluated
352     */
353    private void unmarshal(JSON json, Connection conn) {
354        if (json.contains(KEY_AGREEMENT)) {
355            this.agreement = json.get(KEY_AGREEMENT).asURI();
356        }
357
358        if (json.contains(KEY_CONTACT)) {
359            contacts.clear();
360            json.get(KEY_CONTACT).asArray().stream()
361                    .map(JSON.Value::asURI)
362                    .forEach(contacts::add);
363        }
364
365        this.authorizations = json.get(KEY_AUTHORIZATIONS).asURL();
366        this.certificates = json.get(KEY_CERTIFICATES).asURL();
367
368        if (json.contains(KEY_STATUS)) {
369            this.status = Status.parse(json.get(KEY_STATUS).asString());
370        }
371
372        URL location = conn.getLocation();
373        if (location != null) {
374            setLocation(location);
375        }
376
377        URI tos = conn.getLinkAsURI("terms-of-service");
378        if (tos != null) {
379            this.agreement = tos;
380        }
381
382        loaded = true;
383    }
384
385    /**
386     * Modifies the registration data of the account.
387     *
388     * @return {@link EditableRegistration} where the account can be modified
389     */
390    public EditableRegistration modify() {
391        return new EditableRegistration();
392    }
393
394    /**
395     * Editable {@link Registration}.
396     */
397    public class EditableRegistration {
398        private final List<URI> editContacts = new ArrayList<>();
399        private URI editAgreement;
400
401        private EditableRegistration() {
402            editContacts.addAll(Registration.this.contacts);
403            editAgreement = Registration.this.agreement;
404        }
405
406        /**
407         * Returns the list of all contact URIs for modification. Use the {@link List}
408         * methods to modify the contact list.
409         */
410        public List<URI> getContacts() {
411            return editContacts;
412        }
413
414        /**
415         * Adds a new Contact to the registration.
416         *
417         * @param contact
418         *            Contact URI
419         * @return itself
420         */
421        public EditableRegistration addContact(URI contact) {
422            editContacts.add(contact);
423            return this;
424        }
425
426        /**
427         * Adds a new Contact to the registration.
428         * <p>
429         * This is a convenience call for {@link #addContact(URI)}.
430         *
431         * @param contact
432         *            Contact URI as string
433         * @return itself
434         */
435        public EditableRegistration addContact(String contact) {
436            addContact(URI.create(contact));
437            return this;
438        }
439
440        /**
441         * Sets a new agreement URI.
442         *
443         * @param agreement
444         *            New agreement URI
445         * @return itself
446         */
447        public EditableRegistration setAgreement(URI agreement) {
448            this.editAgreement = agreement;
449            return this;
450        }
451
452        /**
453         * Commits the changes and updates the account.
454         */
455        public void commit() throws AcmeException {
456            LOG.debug("modify/commit");
457            try (Connection conn = getSession().provider().connect()) {
458                JSONBuilder claims = new JSONBuilder();
459                claims.putResource("reg");
460                if (!editContacts.isEmpty()) {
461                    claims.put(KEY_CONTACT, editContacts);
462                }
463                if (editAgreement != null) {
464                    claims.put(KEY_AGREEMENT, editAgreement);
465                }
466
467                conn.sendSignedRequest(getLocation(), claims, getSession());
468                conn.accept(HttpURLConnection.HTTP_ACCEPTED);
469
470                JSON json = conn.readJsonResponse();
471                unmarshal(json, conn);
472            }
473        }
474    }
475
476}