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