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