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}