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}