Compare commits

...

122 Commits

Author SHA1 Message Date
Richard Körber dc7182ca1f
Increase default network timeout to 30 seconds 2025-08-16 10:43:31 +02:00
Richard Körber 294d977757
Remove unused field 2025-08-16 10:25:13 +02:00
Richard Körber 03f97d6bcb
Update to draft-aaron-acme-profiles-01 2025-08-13 20:50:24 +02:00
Richard Körber 88fe967bbf
Update to draft-ietf-acme-dns-account-label-01 2025-08-13 20:50:14 +02:00
Richard Körber 0c660946d0
Enhance CA docs 2025-08-13 20:50:06 +02:00
Richard Körber 6692e5bf8e
Extend FAQ 2025-08-13 20:49:51 +02:00
Richard Körber 2ca2f4b264
Add Actalis support (fixes #173) 2025-08-13 20:48:16 +02:00
Richard Körber 2a5df329bd
draft-ietf-acme-ari is RFC 9773 now
Also this feature ends experimental status.
2025-06-19 16:38:37 +02:00
Richard Körber ec726f6859
Fix IT for Pebble v2.8.0 2025-06-12 17:45:03 +02:00
Richard Körber 4829d0e70c
Gave up waiting for sponsors 2025-06-08 12:50:33 +02:00
Richard Körber 033f9701c0
Make some IT soft-fail 2025-05-25 15:44:18 +02:00
Richard Körber b62709470e
Replace new URL() with URI.create()
new URL() is deprecated starting Java 20
2025-05-18 10:19:40 +02:00
Richard Körber 29c6dc97a1
Extend timeout for ZeroSSL IT 2025-05-18 08:45:08 +02:00
Richard Körber ce60dc9368
Remove all deprecated code 2025-04-26 13:36:31 +02:00
Richard Körber feee96444f
Add missing @Serial annotations 2025-04-26 12:49:45 +02:00
Richard Körber ba50d4ec72
Remove deprecated method Connection.handleRetryAfter() 2025-04-26 12:46:07 +02:00
Richard Körber 1dc3c7ad64
Remove deprecated method Certificate.getCertID() 2025-04-26 12:42:29 +02:00
Richard Körber c0d96e709e
Add support for draft-ietf-acme-dns-account-label 2025-04-26 12:40:03 +02:00
Richard Körber 1ed293c5bb
Upgrade to Java 17 2025-04-26 09:22:18 +02:00
Richard Körber 1069bcc2ce
[maven-release-plugin] prepare for next development iteration 2025-02-18 06:14:11 +01:00
Richard Körber 9f07272180
[maven-release-plugin] prepare release v3.5.1 2025-02-18 06:14:11 +01:00
Richard Körber 8678f80ac6
Add missing providers to module-info 2025-02-17 20:10:14 +01:00
Richard Körber 333f798a72
Move to Codeberg
GitHub is staying as a fully functional mirror though. You can still
post issues and pull requests there.
2025-02-08 16:35:18 +01:00
Richard Körber 9d2087d2a6
Upgrade GitHub CI JDK
- Remove Java 11 (end of premier support)
- Add Java 21
2025-01-26 16:12:53 +01:00
Richard Körber 5c762dfe48
Fix spelling errors in documentation 2025-01-26 16:01:26 +01:00
Richard Körber 3adab36f05
Remove last references to javax.mail 2025-01-26 15:35:09 +01:00
Richard Körber 8bb6560ff8
[maven-release-plugin] prepare for next development iteration 2025-01-26 14:49:45 +01:00
Richard Körber 008ffc968f
[maven-release-plugin] prepare release v3.5.0 2025-01-26 14:49:44 +01:00
Richard Körber 6b0b0e68b6
Add IT for Pebble profile 2025-01-26 14:31:11 +01:00
Richard Körber f6a3bd618b
Fix Pebble IT after Pebble update 2025-01-26 11:42:48 +01:00
Richard Körber 0364acead3
Dependency updates 2025-01-18 12:24:04 +01:00
Richard Körber dec4a461ca
Update to draft-ietf-acme-ari-07
No changes to the protocol
2025-01-18 12:14:31 +01:00
Richard Körber 786a2d279d
Add documentation about profiles 2025-01-18 12:07:31 +01:00
Richard Körber 36363adfe2
Add method to get available profiles 2025-01-18 12:07:13 +01:00
Richard Körber 83d6f38ec7
Add method to return profile description 2025-01-18 11:47:16 +01:00
Richard Körber 43b6a7c7c6
Fix unit tests 2025-01-18 11:38:39 +01:00
Jared Crawford c0fede3b1a Add support for draft-aaron-acme-profiles 2025-01-18 10:37:20 +01:00
Jared Crawford 6e9c266b17 Add support for draft-aaron-acme-profiles 2025-01-18 10:37:20 +01:00
Jared Crawford c85f4a627b Add support for draft-aaron-acme-profiles 2025-01-18 10:37:20 +01:00
Jared Crawford 19371229b8 Add support for draft-aaron-acme-profiles 2025-01-18 10:37:20 +01:00
Richard Körber 318aeaab9d
Single method to get the certificate 2024-10-21 07:11:09 +02:00
Richard Körber 6a24d85364
ZeroSSL supports ARI now 2024-10-20 09:34:26 +02:00
Richard Körber 7a02a2f857
Update to draft-ietf-acme-ari-06
No changes to the protocol
2024-10-20 09:34:26 +02:00
Richard Körber c6f6ee9d07
Check if auto-renewal-get is supported by CA 2024-10-20 09:34:26 +02:00
Richard Körber e88b4ef68f
Add new CAs to list of supported CAs 2024-10-20 08:59:30 +02:00
Richard Körber d9186ede14
Fix outdated newAccount test response 2024-10-01 14:50:17 +02:00
Richard Körber 87bbb9efbf
Add Buypass provider 2024-09-22 16:54:17 +02:00
Richard Körber beec5156c2
Add Google CA provider 2024-09-22 16:32:00 +02:00
Richard Körber 0ccd68c09a
Update to draft-ietf-acme-ari-05 2024-08-24 12:19:13 +02:00
Richard Körber afa60ae76f
Document how to use different Pebble domain (#160) 2024-08-22 20:18:07 +02:00
Richard Körber e589b16d98
Allow custom pebble.minica.pem files
Also changes from a Java proprietary truststore file to the official
Pebble PEM file.
2024-08-22 20:16:35 +02:00
Richard Körber 793bcd7ce1
[maven-release-plugin] prepare for next development iteration 2024-08-18 12:20:45 +02:00
Richard Körber 21751be264
[maven-release-plugin] prepare release v3.4.0 2024-08-18 12:20:45 +02:00
Richard Körber 171ee474c0
Deprecate update() and AcmeRetryAfterException 2024-08-18 11:42:50 +02:00
Richard Körber 05d826d83e
Dependency updates 2024-08-17 17:29:11 +02:00
Richard Körber b897dc277d
Add new methods for status change busy waiting 2024-08-17 17:20:52 +02:00
Richard Körber ae60431a79
Disable ssl.com staging unit tests
The ssl.com staging server's certificate seems to be unmonitored,
causing the acme4j build chain to break from time to time when their
certificate has expired. As this is blocking development, I have
decided to disable all related unit tests, and add a corresponding
note to the documentation.

The acme4j ssl.com provider is marked as experimental now, since it
is not fully covered by unit tests anymore.
2024-06-30 10:43:36 +02:00
Richard Körber a9ce33a921
Update to draft-ietf-acme-ari-04
Only changes to the docs were necessary.
2024-06-11 18:54:31 +02:00
Richard Körber a85ff19cf8
[maven-release-plugin] prepare for next development iteration 2024-06-07 17:51:36 +02:00
Richard Körber 2bbe5c5815
[maven-release-plugin] prepare release v3.3.1 2024-06-07 17:51:35 +02:00
Richard Körber 5788b0e6dd
Update dependencies 2024-06-07 17:44:53 +02:00
Richard Körber 514b188c69
Remove workaround for Pebble container 2024-06-07 17:30:47 +02:00
Richard Körber 6120a2b476
Do not set autoRenewal on cert replacement (fixes #158) 2024-06-07 17:18:04 +02:00
Richard Körber 01249294c8
Mention Problem in docs 2024-05-15 18:43:49 +02:00
Richard Körber f9768d1793
[maven-release-plugin] prepare for next development iteration 2024-05-15 16:02:32 +02:00
Richard Körber feb3d59f7b
[maven-release-plugin] prepare release v3.3.0 2024-05-15 16:02:32 +02:00
Richard Körber a718d82db2
Next version is 3.3.0 2024-05-15 16:01:52 +02:00
Richard Körber 5b14d15854
Discontinue version 2 2024-05-15 15:58:28 +02:00
Richard Körber 6d5da63b8e
Handle HTTP errors when fetching a nonce
The nonce is fetched via HEAD request. Before this fix, if there was a
HTTP error, acme4j expected a Problem JSON body, which was not send
because of the HEAD request, and lead to an AcmeProtocolException.

Now either an AcmeException or AcmeRetryAfterException is thrown.
2024-05-15 15:39:56 +02:00
Richard Körber aeff12088f
Update spotbugs and related new warnings (fixes #157) 2024-05-10 16:07:41 +02:00
Richard Körber 57ec36054a
Use latest Pebble docker image for integration tests
- Updated to the latest pebble and challtestsrv images
- Could not use the docker images as intended, because I found no way to
  let the docker-maven-plugin setup a network with fixed IP addresses.
  The original images are based on scratch, so getent is not present
  there. The only fix was to build own images based on alpine, and copy
  the apps from the original images. Ugly, but working.
- Fixed broken integration tests
- Fixed an old bug: DNS records were removed with two trailing full
  stops.
2024-03-19 22:16:35 +01:00
Richard Körber 4f36055be5
Update wiremock dependency 2024-03-19 21:52:38 +01:00
Richard Körber 773cacde4f
Add subdomain validation support (RFC 9444) 2024-03-15 17:18:01 +01:00
Richard Körber b5a7e00ac3
Use example IPs according to RFC3849/RFC5737 2024-03-13 20:27:12 +01:00
Richard Körber 97a6708db3
[maven-release-plugin] prepare for next development iteration 2024-03-11 17:28:06 +01:00
Richard Körber 565eab9fa4
[maven-release-plugin] prepare release v3.2.1 2024-03-11 17:28:06 +01:00
Richard Körber e97ced5e45
Dependency updates 2024-03-11 17:26:16 +01:00
Richard Körber 511954171d
Use en locale for uppercase/lowercase (fixes #156) 2024-03-09 16:14:20 +01:00
Richard Körber bbc057b81f
Align unit test names 2024-02-29 17:06:18 +01:00
Richard Körber 65e6e28bff
[maven-release-plugin] prepare for next development iteration 2024-02-28 18:02:55 +01:00
Richard Körber c16d1a45cc
[maven-release-plugin] prepare release v3.2.0 2024-02-28 18:02:55 +01:00
Richard Körber fdbd82e887
Minor documentation fixes 2024-02-28 18:00:02 +01:00
Richard Körber d40e30ab56
Revert json-unit-assertj update
Reason: The new version would require JDK 17 for building
2024-02-26 20:04:30 +01:00
Richard Körber d57f4abb60
Update dependencies 2024-02-26 18:45:39 +01:00
Richard Körber f9d479a8f7
Simplify handling of Retry-After header 2024-02-26 18:26:45 +01:00
Richard Körber 908e11b152
Workaround for ssl.com metadata bug
ssl.com requires EAB for account creation, but the metadata's
"externalAccountRequired" property gives "false", indicating that no EAB
is used.

This fix patches the read directory's metadata if the ssl.com provider
is used.
2024-02-26 18:26:45 +01:00
Richard Körber 081e53f137
SSL.com: Add support for ECC and RSA mode 2024-02-26 18:26:45 +01:00
Richard Körber 98ef2b8466
Give instance URL if user action is required 2024-02-26 18:26:45 +01:00
Richard Körber 73c71be754
Documentation review 2024-02-26 18:26:45 +01:00
Richard Körber f2ae26b822
Make the example universal and CA neutral
I like to avoid having different examples for different CAs or
scenarios, as it takes unnecessary time to keep them in sync and
updated.

For this reason, I merged both examples back in a single example again,
which now also handles EAB if necessary.

I also used a generic example CA (example.org) so no CA is favored in
the source code. The desired connection URI must now be configured
first, in order to make the example run.

The documentation was updated accordingly. Rationale is that I don't
want the documentation to be cluttered with all possible CAs, so none of
them is favored now.
2024-02-26 18:26:45 +01:00
Richard Körber 7c17645212
Add missing ssl.com unit tests 2024-02-26 18:26:45 +01:00
Richard Körber c0b74bfc59
Add integration tests for the CA providers
These tests will fail if the directory URLs are changed, or if a
relevant part of the directory changes. If one of the tests should fail,
acme4j will need to be updated to the new directory URL or structure.
2024-02-26 18:26:45 +01:00
Richard Körber 60342c435f
Add ZeroSSL provider
As ZeroSSL makes use of the Retry-After header, the example
implementation has also been changed accordingly.
2024-02-26 18:26:45 +01:00
Dang Thanh 7118a454b2
Update acme4j-example/src/main/java/org/shredzone/acme4j/example/SSLClientWithEabTest.java
Co-authored-by: George Fergadis <55407250+fergadis@users.noreply.github.com>
2024-02-26 18:06:14 +01:00
Nguyen Dang Thanh 3a8a905d87
supports SSLCom acme server 2024-02-26 18:06:14 +01:00
George Fergadis 9c6eb5e610 Add SSL.com provider 2024-02-20 16:22:39 +01:00
Richard Körber 48c32f612d
Upgrade to draft-ietf-acme-ari-03 2024-02-19 07:44:40 +01:00
Richard Körber 6a4770c23a
Get unique identifier according to draft-ietf-acme-ari-03 2024-02-18 16:16:29 +01:00
Richard Körber edb7ec83b6
Generic ACME URIs forward query parameters (#152) 2024-02-06 18:20:44 +01:00
Richard Körber 216d30b600
Minor JavaDoc change 2023-11-24 11:56:12 +01:00
Richard Körber 67a90df47f
Do not set two CNs 2023-11-24 11:38:29 +01:00
Richard Körber 50a74251e0
setCommonName() sets CN only 2023-11-24 11:18:45 +01:00
Matthew McPherrin 278f9bd57b Test value changes
These are genuine functionality changes, and may represent unexpected
impact.  Having two CNs doesn't seem right, but that case is tested so
I'm leaving that here for discussion's sake.

The other test case doesn't have a CN anymore, as expected
2023-11-24 11:05:27 +01:00
Matthew McPherrin beb1d53dc0 Make setCommonName go through the addValue path
This ensures the CN is present as a SAN
2023-11-24 11:05:27 +01:00
Matthew McPherrin 78ccae6bc9 SubjectAlternativeName should be critical for empty subject
Required by Java as well as the Baseline Requirements, RFC5280, etc.

If the subject field of the certificate is an empty SEQUENCE, this
extension MUST be marked critical, as specified in RFC 5280, Section
4.2.1.6. Otherwise, this extension MUST NOT be marked critical.
2023-11-24 11:05:27 +01:00
Matthew McPherrin 1cf53b6cf4 Make the Common Name optional in CSRs
This change doesn't set it by default when adding domains, and adds a
method to explicitly set it if desired.
2023-11-24 11:05:27 +01:00
Richard Körber e26f8fc572
Add question to FAQ 2023-11-24 11:02:49 +01:00
Richard Körber f9b3242f4c
Improve documentation
- Rearranged all chapters. It makes content easier to find, as it is not
  buried in unrelated information now.
- Reviewed the content.
- Fixed broken links.
- Added documentation about Renewal Information and Exceptions
2023-11-24 11:00:29 +01:00
Richard Körber e3cc271cd8
Fix unit tests 2023-11-19 21:33:21 +01:00
Richard Körber f428f1be9c
[maven-release-plugin] prepare for next development iteration 2023-11-15 07:06:11 +01:00
Richard Körber 86c2647ff0
[maven-release-plugin] prepare release v3.1.1 2023-11-15 07:06:11 +01:00
Richard Körber be7e9a690a
Update dependencies 2023-11-15 07:04:52 +01:00
Richard Körber a9bfc8b46e
[maven-release-plugin] prepare for next development iteration 2023-10-11 07:20:24 +02:00
Richard Körber 04fe10c55b
[maven-release-plugin] prepare release v3.1.0 2023-10-11 07:20:24 +02:00
Richard Körber e041decf48
Mark ARI related methods as draft 2023-10-11 07:17:59 +02:00
Richard Körber 78d73d96aa
Update dependencies 2023-10-11 07:15:42 +02:00
aarcloudera f61ef3ede7
Accepting hmac key of all sizes (#144) 2023-10-11 07:09:55 +02:00
Richard Körber 5ef39534ec
Remove spotbugs workaround 2023-09-27 18:45:29 +02:00
Richard Körber 2485666b87
Add missing acme-ari-01 call 2023-09-27 18:45:20 +02:00
Richard Körber 3ad325782b
Add method to set arbitrary MAC algorithm (#141) 2023-09-22 11:20:31 +02:00
Richard Körber 4da80d4da7
Update dependencies 2023-09-21 12:03:45 +02:00
Richard Körber dd7c873750
[maven-release-plugin] prepare for next development iteration 2023-08-11 09:55:47 +02:00
184 changed files with 4660 additions and 1638 deletions

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
java: [ '11', '17' ]
java: [ '17', '21' ]
steps:
- uses: actions/checkout@v2
- name: Set up JDK

View File

@ -43,7 +43,7 @@ Good programming does not end with a clean source code, but should have pretty c
* Always put separate concerns into separate commits.
* If you have interim commits in your history, squash them with an interactive rebase before sending the pull request.
* Use present tense and imperative mood in commit messages ("fix bug #1234", not "fixed bug #1234").
* Always give meaningful commit messages (not just "bugfix").
* Always give meaningful commit messages (not just "bug fix").
* The commit message must be concise and should not exceed 50 characters. Further explanations may follow in subsequent lines, with an empty line as separator.
* Commits must compile and must not break unit tests.
* Do not commit IDE generated files and directories (like `.project` or `.idea`).

View File

@ -4,9 +4,7 @@ This is a Java client for the _Automatic Certificate Management Environment_ (AC
ACME is a protocol that a certificate authority (CA) and an applicant can use to automate the process of verification and certificate issuance.
This Java client helps connecting to an ACME server, and performing all necessary steps to manage certificates.
It is an independent open source implementation that is not affiliated with or endorsed by _Let's Encrypt_.
This Java client helps to connect to an ACME server, and performing all necessary steps to manage certificates.
## Features
@ -16,15 +14,17 @@ It is an independent open source implementation that is not affiliated with or e
* Supports [RFC 8738](https://tools.ietf.org/html/rfc8738) IP identifier validation
* Supports [RFC 8739](https://tools.ietf.org/html/rfc8739) short-term automatic certificate renewal (experimental)
* Supports [RFC 8823](https://tools.ietf.org/html/rfc8823) for S/MIME certificates (experimental)
* Supports [draft-ietf-acme-ari-01](https://www.ietf.org/id/draft-ietf-acme-ari-01.html) for renewal information
* Supports [RFC 9444](https://tools.ietf.org/html/rfc9444) for subdomain validation
* Supports [RFC 9773](https://tools.ietf.org/html/rfc9773) for renewal information
* Supports [draft-aaron-acme-profiles-01](https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/) for certificate profiles (experimental)
* Supports [draft-ietf-acme-dns-account-label-01](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-account-label/) for DNS labeled with ACME account ID challenges (experimental)
* Easy to use Java API
* Requires JRE 11 or higher
* Requires JRE 17 or higher
* Supports [Actalis](https://www.actalis.com/), [Buypass](https://buypass.com/), [Google Trust Services](https://pki.goog/), [Let's Encrypt](https://letsencrypt.org/), [SSL.com](https://www.ssl.com/), [ZeroSSL](https://zerossl.com/), and **all other CAs that comply with the ACME protocol (RFC 8555)**. Note that _acme4j_ is an independent project that is not supported or endorsed by any of the CAs.
* Built with maven, packages available at [Maven Central](http://search.maven.org/#search|ga|1|g%3A%22org.shredzone.acme4j%22)
* Extensive unit and integration tests
* Adheres to [Semantic Versioning](https://semver.org/)
If you require Java 8 or Android compatibility, you can use [acme4j v2](https://shredzone.org/maven/acme4j-v2/index.html) instead. However, v2 is not actively developed anymore and will only receive security fixes.
## Dependencies
* [Bouncy Castle](https://www.bouncycastle.org/)
@ -35,7 +35,7 @@ If you require Java 8 or Android compatibility, you can use [acme4j v2](https://
## Usage
* See the [online documentation](https://shredzone.org/maven/acme4j/) about how to use _acme4j_.
* For a quick start, have a look at [the source code of an example](https://shredzone.org/maven/acme4j/example.html).
* For a quick start, take a look at [the source code of an example](https://shredzone.org/maven/acme4j/example.html).
## Announcements
@ -46,21 +46,17 @@ Follow our Mastodon feed for release notes and other acme4j related news.
## Contribute
* Fork the [Source code at GitHub](https://github.com/shred/acme4j). Feel free to send pull requests (see [Contributing](CONTRIBUTING.md) for the rules).
* Found a bug? [File a bug report!](https://github.com/shred/acme4j/issues)
* Fork the [Source code at Codeberg](https://codeberg.org/shred/acme4j) or [GitHub](https://github.com/shred/acme4j). Feel free to send pull requests (see [Contributing](CONTRIBUTING.md) for the rules).
* Found a bug? [File a bug report!](https://codeberg.org/shred/acme4j/issues) ([GitHub](https://github.com/shred/acme4j/issues))
## License
_acme4j_ is open source software. The source code is distributed under the terms of [Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0).
## Donate
If you would like to support my work on _acme4j_, you can do so on at [GitHub Sponsors](https://github.com/sponsors/shred) or at [Ko-Fi](https://ko-fi.com/shredzone). Thank you!
## Acknowledgements
* I would like to thank Brian Campbell and all the other [jose4j](https://bitbucket.org/b_c/jose4j/wiki/Home) developers. _acme4j_ would not exist without your excellent work.
* Thanks to [Daniel McCarney](https://github.com/cpu) for his help with the ACME protocol, Pebble, and Boulder.
* [Ulrich Krause](https://github.com/eknori) for his help to make _acme4j_ run on IBM Java VMs.
* I also like to thank [everyone who contributed to _acme4j_](https://github.com/shred/acme4j/graphs/contributors).
* I also like to thank [everyone who contributed to _acme4j_](https://codeberg.org/shred/acme4j/activity/contributors).
* The Mastodon account is hosted by [foojay.io](https://foojay.io).

View File

@ -20,7 +20,7 @@
<parent>
<groupId>org.shredzone.acme4j</groupId>
<artifactId>acme4j</artifactId>
<version>3.0.0</version>
<version>3.5.2-SNAPSHOT</version>
</parent>
<artifactId>acme4j-client</artifactId>

View File

@ -36,6 +36,11 @@ module org.shredzone.acme4j {
provides org.shredzone.acme4j.provider.AcmeProvider
with org.shredzone.acme4j.provider.GenericAcmeProvider,
org.shredzone.acme4j.provider.actalis.ActalisAcmeProvider,
org.shredzone.acme4j.provider.buypass.BuypassAcmeProvider,
org.shredzone.acme4j.provider.google.GoogleAcmeProvider,
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider,
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider;
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider,
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider,
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider;
}

View File

@ -13,8 +13,7 @@
*/
package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.io.Serial;
import java.net.URI;
import java.security.KeyPair;
import java.util.ArrayList;
@ -24,6 +23,7 @@ import java.util.List;
import java.util.Objects;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.connector.ResourceIterator;
import org.shredzone.acme4j.exception.AcmeException;
@ -41,6 +41,7 @@ import org.slf4j.LoggerFactory;
* A representation of an account at the ACME server.
*/
public class Account extends AcmeJsonResource {
@Serial
private static final long serialVersionUID = 7042863483428051319L;
private static final Logger LOG = LoggerFactory.getLogger(Account.class);
@ -75,7 +76,7 @@ public class Account extends AcmeJsonResource {
.asArray()
.stream()
.map(Value::asURI)
.collect(toUnmodifiableList());
.toList();
}
/**
@ -189,6 +190,11 @@ public class Account extends AcmeJsonResource {
var newAuthzUrl = getSession().resourceUrl(Resource.NEW_AUTHZ);
if (identifier.toMap().containsKey(Identifier.KEY_SUBDOMAIN_AUTH_ALLOWED)
&& !getSession().getMetadata().isSubdomainAuthAllowed()) {
throw new AcmeNotSupportedException("subdomain-auth");
}
LOG.debug("preAuthorize {}", identifier);
try (var conn = getSession().connect()) {
var claims = new JSONBuilder();
@ -280,6 +286,7 @@ public class Account extends AcmeJsonResource {
* sure that they are valid according to the RFC. It is recommended to use
* the {@code addContact()} methods below to add new contacts to the list.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public List<URI> getContacts() {
return editContacts;
}

View File

@ -14,11 +14,15 @@
package org.shredzone.acme4j;
import static java.util.Objects.requireNonNull;
import static org.jose4j.jws.AlgorithmIdentifiers.*;
import static org.shredzone.acme4j.toolbox.JoseUtils.macKeyAlgorithm;
import java.net.URI;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
@ -55,6 +59,7 @@ import org.slf4j.LoggerFactory;
*/
public class AccountBuilder {
private static final Logger LOG = LoggerFactory.getLogger(AccountBuilder.class);
private static final Set<String> VALID_ALGORITHMS = Set.of(HMAC_SHA256, HMAC_SHA384, HMAC_SHA512);
private final List<URI> contacts = new ArrayList<>();
private @Nullable Boolean termsOfServiceAgreed;
@ -62,6 +67,7 @@ public class AccountBuilder {
private @Nullable String keyIdentifier;
private @Nullable KeyPair keyPair;
private @Nullable SecretKey macKey;
private @Nullable String macAlgorithm;
/**
* Add a contact URI to the list of contacts.
@ -210,6 +216,25 @@ public class AccountBuilder {
return withKeyIdentifier(kid, new SecretKeySpec(encodedKey, "HMAC"));
}
/**
* Sets the MAC key algorithm that is provided by the CA. To be used in combination
* with key identifier. By default, the algorithm is deduced from the size of the
* MAC key. If a different size is needed, it can be set using this method.
*
* @param macAlgorithm
* the algorithm to be set in the {@code alg} field, e.g. {@code "HS512"}.
* @return itself
* @since 3.1.0
*/
public AccountBuilder withMacAlgorithm(String macAlgorithm) {
var algorithm = requireNonNull(macAlgorithm, "macAlgorithm");
if (!VALID_ALGORITHMS.contains(algorithm)) {
throw new IllegalArgumentException("Invalid MAC algorithm: " + macAlgorithm);
}
this.macAlgorithm = algorithm;
return this;
}
/**
* Creates a new account.
* <p>
@ -254,9 +279,13 @@ public class AccountBuilder {
if (termsOfServiceAgreed != null) {
claims.put("termsOfServiceAgreed", termsOfServiceAgreed);
}
if (keyIdentifier != null) {
if (keyIdentifier != null && macKey != null) {
var algorithm = Optional.ofNullable(macAlgorithm)
.or(session.provider()::getProposedEabMacAlgorithm)
// FIXME: Cannot use a Supplier here due to a Spotbugs false positive "null pointer dereference"
.orElse(macKeyAlgorithm(macKey));
claims.put("externalAccountBinding", JoseUtils.createExternalAccountBinding(
keyIdentifier, keyPair.getPublic(), macKey, resourceUrl));
keyIdentifier, keyPair.getPublic(), macKey, algorithm, resourceUrl));
}
if (onlyExisting != null) {
claims.put("onlyReturnExisting", onlyExisting);

View File

@ -13,13 +13,15 @@
*/
package org.shredzone.acme4j;
import java.io.Serial;
import java.net.URL;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.toolbox.JSON;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -30,10 +32,12 @@ import org.slf4j.LoggerFactory;
* fetching it from the server if necessary.
*/
public abstract class AcmeJsonResource extends AcmeResource {
@Serial
private static final long serialVersionUID = -5060364275766082345L;
private static final Logger LOG = LoggerFactory.getLogger(AcmeJsonResource.class);
private @Nullable JSON data = null;
private @Nullable Instant retryAfter = null;
/**
* Create a new {@link AcmeJsonResource}.
@ -50,7 +54,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
/**
* Returns the JSON representation of the resource data.
* <p>
* If there is no data, {@link #update()} is invoked to fetch it from the server.
* If there is no data, {@link #fetch()} is invoked to fetch it from the server.
* <p>
* This method can be used to read proprietary data from the resources.
*
@ -62,15 +66,12 @@ public abstract class AcmeJsonResource extends AcmeResource {
public JSON getJSON() {
if (data == null) {
try {
update();
} catch (AcmeRetryAfterException ex) {
// ignore... The object was still updated.
LOG.debug("Retry-After", ex);
fetch();
} catch (AcmeException ex) {
throw new AcmeLazyLoadingException(this, ex);
}
}
return data;
return Objects.requireNonNull(data);
}
/**
@ -88,7 +89,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
* Checks if this resource is valid.
*
* @return {@code true} if the resource state has been loaded from the server. If
* {@code false}, {@link #getJSON()} would implicitly call {@link #update()}
* {@code false}, {@link #getJSON()} would implicitly call {@link #fetch()}
* to fetch the current state from the server.
*/
protected boolean isValid() {
@ -96,7 +97,7 @@ public abstract class AcmeJsonResource extends AcmeResource {
}
/**
* Invalidates the state of this resource. Enforces an {@link #update()} when
* Invalidates the state of this resource. Enforces a {@link #fetch()} when
* {@link #getJSON()} is invoked.
* <p>
* Subclasses can override this method to purge internal caches that are based on the
@ -104,28 +105,53 @@ public abstract class AcmeJsonResource extends AcmeResource {
*/
protected void invalidate() {
data = null;
retryAfter = null;
}
/**
* Updates this resource, by fetching the current resource data from the server.
*
* @return An {@link Optional} estimation when the resource status will change. If you
* are polling for the resource to complete, you should wait for the given instant
* before trying again. Empty if the server did not return a "Retry-After" header.
* @throws AcmeException
* if the resource could not be fetched.
* @throws AcmeRetryAfterException
* the resource is still being processed, and the server returned an
* estimated date when the process will be completed. If you are polling
* for the resource to complete, you should wait for the date given in
* {@link AcmeRetryAfterException#getRetryAfter()}. Note that the status
* of the resource is updated even if this exception was thrown.
* if the resource could not be fetched.
* @since 3.2.0
*/
public void update() throws AcmeException {
public Optional<Instant> fetch() throws AcmeException {
var resourceType = getClass().getSimpleName();
LOG.debug("update {}", resourceType);
try (var conn = getSession().connect()) {
conn.sendSignedPostAsGetRequest(getLocation(), getLogin());
setJSON(conn.readJsonResponse());
conn.handleRetryAfter(resourceType + " is not completed yet");
var retryAfterOpt = conn.getRetryAfter();
retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant));
setRetryAfter(retryAfterOpt.orElse(null));
return retryAfterOpt;
}
}
/**
* Sets a Retry-After instant.
*
* @since 3.2.0
*/
protected void setRetryAfter(@Nullable Instant retryAfter) {
this.retryAfter = retryAfter;
}
/**
* Gets an estimation when the resource status will change. If you are polling for
* the resource to complete, you should wait for the given instant before trying
* a status refresh.
* <p>
* This instant was sent with the Retry-After header at the last update.
*
* @return Retry-after {@link Instant}, or empty if there was no such header.
* @since 3.2.0
*/
public Optional<Instant> getRetryAfter() {
return Optional.ofNullable(retryAfter);
}
}

View File

@ -13,6 +13,7 @@
*/
package org.shredzone.acme4j;
import java.io.Serial;
import java.io.Serializable;
import java.net.URL;
import java.util.Objects;
@ -28,6 +29,7 @@ import edu.umd.cs.findbugs.annotations.Nullable;
* using {@link #rebind(Login)}.
*/
public abstract class AcmeResource implements Serializable {
@Serial
private static final long serialVersionUID = -7930580802257379731L;
private transient @Nullable Login login;
@ -87,4 +89,9 @@ public abstract class AcmeResource implements Serializable {
return location;
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}

View File

@ -15,8 +15,11 @@ package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.io.Serial;
import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@ -32,7 +35,8 @@ import org.slf4j.LoggerFactory;
/**
* Represents an authorization request at the ACME server.
*/
public class Authorization extends AcmeJsonResource {
public class Authorization extends AcmeJsonResource implements PollableResource {
@Serial
private static final long serialVersionUID = -3116928998379417741L;
private static final Logger LOG = LoggerFactory.getLogger(Authorization.class);
@ -60,6 +64,7 @@ public class Authorization extends AcmeJsonResource {
* {@link Status#INVALID}, {@link Status#DEACTIVATED}, {@link Status#EXPIRED},
* {@link Status#REVOKED}.
*/
@Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
@ -83,6 +88,18 @@ public class Authorization extends AcmeJsonResource {
.orElse(false);
}
/**
* Returns {@code true} if certificates for subdomains can be issued according to
* RFC9444.
*
* @since 3.3.0
*/
public boolean isSubdomainAuthAllowed() {
return getJSON().get("subdomainAuthAllowed")
.map(Value::asBoolean)
.orElse(false);
}
/**
* Gets a list of all challenges offered by the server, in no specific order.
*/
@ -139,6 +156,24 @@ public class Authorization extends AcmeJsonResource {
});
}
/**
* Waits until the authorization is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
/**
* Permanently deactivates the {@link Authorization}.
*/

View File

@ -15,15 +15,18 @@ package org.shredzone.acme4j;
import static java.util.Collections.unmodifiableList;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toUnmodifiableList;
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
import java.io.IOException;
import java.io.Serial;
import java.io.Writer;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.security.KeyPair;
import java.security.Principal;
import java.security.Security;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
@ -31,12 +34,7 @@ import java.util.List;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.ocsp.CertificateID;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
@ -54,6 +52,7 @@ import org.slf4j.LoggerFactory;
* ordered.
*/
public class Certificate extends AcmeResource {
@Serial
private static final long serialVersionUID = 7381527770159084201L;
private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
@ -130,9 +129,9 @@ public class Certificate extends AcmeResource {
var login = getLogin();
alternateCerts = getAlternates().stream()
.map(login::bindCertificate)
.collect(toUnmodifiableList());
.collect(toList());
}
return alternateCerts;
return unmodifiableList(alternateCerts);
}
/**
@ -189,35 +188,6 @@ public class Certificate extends AcmeResource {
}
}
/**
* Returns this certificate's CertID according to RFC 6960.
* <p>
* This method requires the {@link org.bouncycastle.jce.provider.BouncyCastleProvider}
* security provider.
*
* @see <a href="https://www.rfc-editor.org/rfc/rfc6960.html">RFC 6960</a>
* @since 3.0.0
*/
public String getCertID() {
var certChain = getCertificateChain();
if (certChain.size() < 2) {
throw new AcmeProtocolException("Certificate has no issuer");
}
try {
var builder = new JcaDigestCalculatorProviderBuilder();
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) != null) {
builder.setProvider(BouncyCastleProvider.PROVIDER_NAME);
}
var digestCalc = builder.build().get(new AlgorithmIdentifier(NISTObjectIdentifiers.id_sha256));
var issuerHolder = new X509CertificateHolder(certChain.get(1).getEncoded());
var certId = new CertificateID(digestCalc, issuerHolder, certChain.get(0).getSerialNumber());
return AcmeUtils.base64UrlEncode(certId.toASN1Primitive().getEncoded());
} catch (Exception ex) {
throw new AcmeProtocolException("Could not compute Certificate ID", ex);
}
}
/**
* Returns the location of the certificate's RenewalInfo. Empty if the CA does not
* provide this information.
@ -233,8 +203,8 @@ public class Certificate extends AcmeResource {
if (!url.endsWith("/")) {
url += '/';
}
url += getCertID();
return new URL(url);
url += getRenewalUniqueIdentifier(getCertificate());
return URI.create(url).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
}
@ -257,8 +227,10 @@ public class Certificate extends AcmeResource {
* Reads the RenewalInfo for this certificate.
*
* @return The {@link RenewalInfo} of this certificate.
* @throws AcmeNotSupportedException if the CA does not support renewal information.
* @since 3.0.0
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public RenewalInfo getRenewalInfo() {
if (renewalInfo == null) {
renewalInfo = getRenewalInfoLocation()

View File

@ -13,17 +13,19 @@
*/
package org.shredzone.acme4j;
import static java.util.Collections.unmodifiableMap;
import static java.util.Objects.requireNonNull;
import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce;
import java.io.Serial;
import java.io.Serializable;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Map;
import java.util.TreeMap;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
/**
* Represents an identifier.
@ -36,6 +38,7 @@ import org.shredzone.acme4j.toolbox.JSONBuilder;
* @since 2.3
*/
public class Identifier implements Serializable {
@Serial
private static final long serialVersionUID = -7777851842076362412L;
/**
@ -50,11 +53,12 @@ public class Identifier implements Serializable {
*/
public static final String TYPE_IP = "ip";
private static final String KEY_TYPE = "type";
private static final String KEY_VALUE = "value";
static final String KEY_TYPE = "type";
static final String KEY_VALUE = "value";
static final String KEY_ANCESTOR_DOMAIN = "ancestorDomain";
static final String KEY_SUBDOMAIN_AUTH_ALLOWED = "subdomainAuthAllowed";
private final String type;
private final String value;
private final Map<String, Object> content = new TreeMap<>();
/**
* Creates a new {@link Identifier}.
@ -71,8 +75,8 @@ public class Identifier implements Serializable {
* Identifier value
*/
public Identifier(String type, String value) {
this.type = requireNonNull(type, KEY_TYPE);
this.value = requireNonNull(value, KEY_VALUE);
content.put(KEY_TYPE, requireNonNull(type, KEY_TYPE));
content.put(KEY_VALUE, requireNonNull(value, KEY_VALUE));
}
/**
@ -82,7 +86,20 @@ public class Identifier implements Serializable {
* {@link JSON} containing the identifier data
*/
public Identifier(JSON json) {
this(json.get(KEY_TYPE).asString(), json.get(KEY_VALUE).asString());
if (!json.contains(KEY_TYPE)) {
throw new AcmeProtocolException("Required key " + KEY_TYPE + " is missing");
}
if (!json.contains(KEY_VALUE)) {
throw new AcmeProtocolException("Required key " + KEY_VALUE + " is missing");
}
content.putAll(json.toMap());
}
/**
* Makes a copy of the given Identifier.
*/
private Identifier(Identifier identifier) {
content.putAll(identifier.content);
}
/**
@ -123,18 +140,50 @@ public class Identifier implements Serializable {
}
}
/**
* Sets an ancestor domain, as required in RFC-9444.
*
* @param domain
* The ancestor domain to be set. Unicode domains are automatically ASCII
* encoded.
* @return An {@link Identifier} that contains the ancestor domain.
* @since 3.3.0
*/
public Identifier withAncestorDomain(String domain) {
expectType(TYPE_DNS);
var result = new Identifier(this);
result.content.put(KEY_ANCESTOR_DOMAIN, toAce(domain));
return result;
}
/**
* Gives the permission to authorize subdomains of this domain, as required in
* RFC-9444.
*
* @return An {@link Identifier} that allows subdomain auths.
* @since 3.3.0
*/
public Identifier allowSubdomainAuth() {
expectType(TYPE_DNS);
var result = new Identifier(this);
result.content.put(KEY_SUBDOMAIN_AUTH_ALLOWED, true);
return result;
}
/**
* Returns the identifier type.
*/
public String getType() {
return type;
return content.get(KEY_TYPE).toString();
}
/**
* Returns the identifier value.
*/
public String getValue() {
return value;
return content.get(KEY_VALUE).toString();
}
/**
@ -145,10 +194,8 @@ public class Identifier implements Serializable {
* if this is not a DNS identifier.
*/
public String getDomain() {
if (!TYPE_DNS.equals(type)) {
throw new AcmeProtocolException("expected 'dns' identifier, but found '" + type + "'");
}
return value;
expectType(TYPE_DNS);
return getValue();
}
/**
@ -159,11 +206,9 @@ public class Identifier implements Serializable {
* if this is not a DNS identifier.
*/
public InetAddress getIP() {
if (!TYPE_IP.equals(type)) {
throw new AcmeProtocolException("expected 'ip' identifier, but found '" + type + "'");
}
expectType(TYPE_IP);
try {
return InetAddress.getByName(value);
return InetAddress.getByName(getValue());
} catch (UnknownHostException ex) {
throw new AcmeProtocolException("bad ip identifier value", ex);
}
@ -173,27 +218,47 @@ public class Identifier implements Serializable {
* Returns the identifier as JSON map.
*/
public Map<String, Object> toMap() {
return new JSONBuilder().put(KEY_TYPE, type).put(KEY_VALUE, value).toMap();
return unmodifiableMap(content);
}
/**
* Makes sure this identifier is of the given type.
*
* @param type
* Expected type
* @throws AcmeProtocolException
* if this identifier is of a different type
*/
private void expectType(String type) {
if (!type.equals(getType())) {
throw new AcmeProtocolException("expected '" + type + "' identifier, but found '" + getType() + "'");
}
}
@Override
public String toString() {
return type + "=" + value;
if (content.size() == 2) {
return getType() + '=' + getValue();
}
return content.toString();
}
@Override
public boolean equals(Object obj) {
if (!(obj instanceof Identifier)) {
if (!(obj instanceof Identifier i)) {
return false;
}
var i = (Identifier) obj;
return type.equals(i.type) && value.equals(i.value);
return content.equals(i.content);
}
@Override
public int hashCode() {
return type.hashCode() ^ value.hashCode();
return content.hashCode();
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}

View File

@ -14,12 +14,18 @@
package org.shredzone.acme4j;
import static java.util.Objects.requireNonNull;
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.util.Objects;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
@ -70,6 +76,7 @@ public class Login {
/**
* Gets the {@link Session} that is used.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Session getSession() {
return session;
}
@ -93,6 +100,7 @@ public class Login {
*
* @return {@link Account} bound to the login
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Account getAccount() {
return account;
}
@ -145,6 +153,28 @@ public class Login {
return new RenewalInfo(this, requireNonNull(location, "location"));
}
/**
* Creates a new instance of an existing {@link RenewalInfo} and binds it to this
* login.
*
* @param certificate
* {@link X509Certificate} to get the {@link RenewalInfo} for
* @return {@link RenewalInfo} bound to the login
* @since 3.2.0
*/
public RenewalInfo bindRenewalInfo(X509Certificate certificate) throws AcmeException {
try {
var url = getSession().resourceUrl(Resource.RENEWAL_INFO).toExternalForm();
if (!url.endsWith("/")) {
url += '/';
}
url += getRenewalUniqueIdentifier(certificate);
return bindRenewalInfo(URI.create(url).toURL());
} catch (MalformedURLException ex) {
throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
}
}
/**
* Creates a new instance of an existing {@link Challenge} and binds it to this
* login. Use this method only if the resulting challenge type is unknown.

View File

@ -20,6 +20,7 @@ import java.net.URL;
import java.time.Duration;
import java.util.Collection;
import java.util.Optional;
import java.util.Set;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
import org.shredzone.acme4j.toolbox.JSON;
@ -131,6 +132,78 @@ public class Metadata {
.orElse(false);
}
/**
* Returns whether the CA supports the profile feature.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
* may be changed or removed without notice to reflect future changes to the draft.
* SemVer rules do not apply here.
*/
public boolean isProfileAllowed() {
return meta.get("profiles").isPresent();
}
/**
* Returns whether the CA supports the requested profile.
* <p>
* Also returns {@code false} if profiles are not allowed in general.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
* may be changed or removed without notice to reflect future changes to the draft.
* SemVer rules do not apply here.
*/
public boolean isProfileAllowed(String profile) {
return getProfiles().contains(profile);
}
/**
* Returns all profiles supported by the CA. May be empty if the CA does not support
* profiles.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
* may be changed or removed without notice to reflect future changes to the draft.
* SemVer rules do not apply here.
*/
public Set<String> getProfiles() {
return meta.get("profiles")
.optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.keySet();
}
/**
* Returns a description of the requested profile. This can be a human-readable string
* or a URL linking to a documentation.
* <p>
* Empty if the profile is not allowed.
*
* @since 3.5.0
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
* may be changed or removed without notice to reflect future changes to the draft.
* SemVer rules do not apply here.
*/
public Optional<String> getProfileDescription(String profile) {
return meta.get("profiles").optional()
.map(Value::asObject)
.orElseGet(JSON::empty)
.get(profile)
.optional()
.map(Value::asString);
}
/**
* Returns whether the CA supports subdomain auth according to RFC9444.
*
* @since 3.3.0
*/
public boolean isSubdomainAuthAllowed() {
return meta.get("subdomainAuthAllowed").map(Value::asBoolean).orElse(false);
}
/**
* Returns the JSON representation of the metadata. This is useful for reading
* proprietary metadata properties.

View File

@ -13,18 +13,22 @@
*/
package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import static java.util.Collections.unmodifiableList;
import static java.util.stream.Collectors.toList;
import java.io.IOException;
import java.io.Serial;
import java.net.URL;
import java.security.KeyPair;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNotSupportedException;
@ -38,12 +42,12 @@ import org.slf4j.LoggerFactory;
/**
* A representation of a certificate order at the CA.
*/
public class Order extends AcmeJsonResource {
public class Order extends AcmeJsonResource implements PollableResource {
@Serial
private static final long serialVersionUID = 5435808648658292177L;
private static final Logger LOG = LoggerFactory.getLogger(Order.class);
private transient @Nullable Certificate certificate = null;
private transient @Nullable Certificate autoRenewalCertificate = null;
private transient @Nullable List<Authorization> authorizations = null;
protected Order(Login login, URL location) {
@ -57,6 +61,7 @@ public class Order extends AcmeJsonResource {
* {@link Status#PROCESSING}, {@link Status#VALID}, {@link Status#INVALID}.
* If the server supports STAR, another possible value is {@link Status#CANCELED}.
*/
@Override
public Status getStatus() {
return getJSON().get("status").asStatus();
}
@ -85,7 +90,7 @@ public class Order extends AcmeJsonResource {
.asArray()
.stream()
.map(Value::asIdentifier)
.collect(toUnmodifiableList());
.toList();
}
/**
@ -114,9 +119,9 @@ public class Order extends AcmeJsonResource {
.stream()
.map(Value::asURL)
.map(login::bindAuthorization)
.collect(toUnmodifiableList());
.collect(toList());
}
return authorizations;
return unmodifiableList(authorizations);
}
/**
@ -135,9 +140,12 @@ public class Order extends AcmeJsonResource {
* if the order is not ready yet. You must finalize the order first, and wait
* for the status to become {@link Status#VALID}.
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public Certificate getCertificate() {
if (certificate == null) {
certificate = getJSON().get("certificate")
certificate = getJSON().get("star-certificate")
.optional()
.or(() -> getJSON().get("certificate").optional())
.map(Value::asURL)
.map(getLogin()::bindCertificate)
.orElseThrow(() -> new IllegalStateException("Order is not completed"));
@ -146,23 +154,13 @@ public class Order extends AcmeJsonResource {
}
/**
* Gets the STAR extension's {@link Certificate} if it is available.
* Returns whether this is a STAR certificate ({@code true}) or a standard certificate
* ({@code false}).
*
* @since 2.6
* @throws IllegalStateException
* if the order is not ready yet. You must finalize the order first, and wait
* for the status to become {@link Status#VALID}. It is also thrown if the
* order has been {@link Status#CANCELED}.
* @since 3.5.0
*/
public Certificate getAutoRenewalCertificate() {
if (autoRenewalCertificate == null) {
autoRenewalCertificate = getJSON().get("star-certificate")
.optional()
.map(Value::asURL)
.map(getLogin()::bindCertificate)
.orElseThrow(() -> new IllegalStateException("Order is in an invalid state"));
}
return autoRenewalCertificate;
public boolean isAutoRenewalCertificate() {
return getJSON().contains("star-certificate");
}
/**
@ -181,6 +179,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair, Consumer)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(KeyPair domainKeyPair) throws AcmeException {
@ -203,6 +203,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair)
* @see #execute(PKCS10CertificationRequest)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(KeyPair domainKeyPair, Consumer<CSRBuilder> builderConsumer) throws AcmeException {
@ -230,6 +232,8 @@ public class Order extends AcmeJsonResource {
* @see #execute(KeyPair)
* @see #execute(KeyPair, Consumer)
* @see #execute(byte[])
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
* @since 3.0.0
*/
public void execute(PKCS10CertificationRequest csr) throws AcmeException {
@ -251,6 +255,8 @@ public class Order extends AcmeJsonResource {
* @param csr
* Binary representation of a CSR containing the parameters for the
* certificate being requested, in DER format
* @see #waitUntilReady(Duration)
* @see #waitForCompletion(Duration)
*/
public void execute(byte[] csr) throws AcmeException {
LOG.debug("finalize");
@ -263,6 +269,43 @@ public class Order extends AcmeJsonResource {
invalidate();
}
/**
* Waits until the order is ready for finalization.
* <p>
* Is is ready if it reaches {@link Status#READY}. The method will also return if the
* order already has another terminal state, which is either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitUntilReady(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.READY, Status.VALID, Status.INVALID), timeout);
}
/**
* Waits until the order finalization is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
/**
* Checks if this order is auto-renewing, according to the ACME STAR specifications.
*
@ -371,11 +414,20 @@ public class Order extends AcmeJsonResource {
}
}
/**
* Returns the selected profile.
*
* @since 3.5.0
* @throws AcmeNotSupportedException if profile is not supported
*/
public String getProfile() {
return getJSON().getFeature("profile").asString();
}
@Override
protected void invalidate() {
super.invalidate();
certificate = null;
autoRenewalCertificate = null;
authorizations = null;
}
}

View File

@ -15,11 +15,14 @@ package org.shredzone.acme4j;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;
import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
import edu.umd.cs.findbugs.annotations.Nullable;
@ -44,12 +47,14 @@ public class OrderBuilder {
private final Set<Identifier> identifierSet = new LinkedHashSet<>();
private @Nullable Instant notBefore;
private @Nullable Instant notAfter;
private @Nullable String replaces;
private boolean autoRenewal;
private @Nullable Instant autoRenewalStart;
private @Nullable Instant autoRenewalEnd;
private @Nullable Duration autoRenewalLifetime;
private @Nullable Duration autoRenewalLifetimeAdjust;
private boolean autoRenewalGet;
private @Nullable String profile;
/**
* Create a new {@link OrderBuilder}.
@ -265,6 +270,74 @@ public class OrderBuilder {
return this;
}
/**
* Notifies the CA of the desired profile of the ordered certificate.
* <p>
* Optional, only supported if the CA supports profiles. However, in this case the
* client <em>may</em> include this field.
*
* @param profile
* Identifier of the desired profile
* @return itself
* @draft This method is currently based on RFC draft draft-aaron-acme-profiles. It
* may be changed or removed without notice to reflect future changes to the draft.
* SemVer rules do not apply here.
* @since 3.5.0
*/
public OrderBuilder profile(String profile) {
this.profile = Objects.requireNonNull(profile);
return this;
}
/**
* Notifies the CA that the ordered certificate will replace a previously issued
* certificate. The certificate is identified by its ARI unique identifier.
* <p>
* Optional, only supported if the CA provides renewal information. However, in this
* case the client <em>should</em> include this field.
*
* @param uniqueId
* Certificate's renewal unique identifier.
* @return itself
* @since 3.2.0
*/
public OrderBuilder replaces(String uniqueId) {
this.replaces = Objects.requireNonNull(uniqueId);
return this;
}
/**
* Notifies the CA that the ordered certificate will replace a previously issued
* certificate.
* <p>
* Optional, only supported if the CA provides renewal information. However, in this
* case the client <em>should</em> include this field.
*
* @param certificate
* Certificate to be replaced
* @return itself
* @since 3.2.0
*/
public OrderBuilder replaces(X509Certificate certificate) {
return replaces(getRenewalUniqueIdentifier(certificate));
}
/**
* Notifies the CA that the ordered certificate will replace a previously issued
* certificate.
* <p>
* Optional, only supported if the CA provides renewal information. However, in this
* case the client <em>should</em> include this field.
*
* @param certificate
* Certificate to be replaced
* @return itself
* @since 3.2.0
*/
public OrderBuilder replaces(Certificate certificate) {
return replaces(certificate.getCertificate());
}
/**
* Sends a new order to the server, and returns an {@link Order} object.
*
@ -281,6 +354,29 @@ public class OrderBuilder {
throw new AcmeNotSupportedException("auto-renewal");
}
if (autoRenewalGet && !session.getMetadata().isAutoRenewalGetAllowed()) {
throw new AcmeNotSupportedException("auto-renewal-get");
}
if (replaces != null && session.resourceUrlOptional(Resource.RENEWAL_INFO).isEmpty()) {
throw new AcmeNotSupportedException("renewal-information");
}
if (profile != null && !session.getMetadata().isProfileAllowed()) {
throw new AcmeNotSupportedException("profile");
}
if (profile != null && !session.getMetadata().isProfileAllowed(profile)) {
throw new AcmeNotSupportedException("profile: " + profile);
}
var hasAncestorDomain = identifierSet.stream()
.filter(id -> Identifier.TYPE_DNS.equals(id.getType()))
.anyMatch(id -> id.toMap().containsKey(Identifier.KEY_ANCESTOR_DOMAIN));
if (hasAncestorDomain && !login.getSession().getMetadata().isSubdomainAuthAllowed()) {
throw new AcmeNotSupportedException("ancestor-domain");
}
LOG.debug("create");
try (var conn = session.connect()) {
var claims = new JSONBuilder();
@ -312,6 +408,14 @@ public class OrderBuilder {
}
}
if (replaces != null) {
claims.put("replaces", replaces);
}
if (profile != null) {
claims.put("profile", profile);
}
conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, login);
var order = new Order(login, conn.getLocation());

View File

@ -0,0 +1,107 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j;
import static java.time.Instant.now;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.shredzone.acme4j.exception.AcmeException;
/**
* Marks an ACME Resource with a pollable status.
* <p>
* The resource provides a status, and a method for updating the internal cache to read
* the current status from the server.
*
* @since 3.4.0
*/
public interface PollableResource {
/**
* Default delay between status polls if there is no Retry-After header.
*/
Duration DEFAULT_RETRY_AFTER = Duration.ofSeconds(3L);
/**
* Returns the current status of the resource.
*/
Status getStatus();
/**
* Fetches the current status from the server.
*
* @return Retry-After time, if given by the CA, otherwise empty.
*/
Optional<Instant> fetch() throws AcmeException;
/**
* Waits until a terminal status has been reached, by polling until one of the given
* status or the given timeout has been reached. This call honors the Retry-After
* header if set by the CA.
* <p>
* This method is synchronous and blocks the current thread.
* <p>
* If the resource is already in a terminal status, the method returns immediately.
*
* @param statusSet
* Set of {@link Status} that are accepted as terminal
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
*/
default Status waitForStatus(Set<Status> statusSet, Duration timeout)
throws AcmeException, InterruptedException {
Objects.requireNonNull(timeout, "timeout");
Objects.requireNonNull(statusSet, "statusSet");
if (statusSet.isEmpty()) {
throw new IllegalArgumentException("At least one Status is required");
}
var currentStatus = getStatus();
if (statusSet.contains(currentStatus)) {
return currentStatus;
}
var timebox = now().plus(timeout);
Instant now;
while ((now = now()).isBefore(timebox)) {
// Poll status and get the time of the next poll
var retryAfter = fetch()
.orElse(now.plus(DEFAULT_RETRY_AFTER));
currentStatus = getStatus();
if (statusSet.contains(currentStatus)) {
return currentStatus;
}
// Preemptively end the loop if the next iteration would be after timebox
if (retryAfter.isAfter(timebox)) {
break;
}
// Wait until retryAfter is reached
Thread.sleep(now.until(retryAfter, ChronoUnit.MILLIS));
}
throw new AcmeException("Timeout has been reached");
}
}

View File

@ -13,8 +13,7 @@
*/
package org.shredzone.acme4j;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.io.Serial;
import java.io.Serializable;
import java.net.URI;
import java.net.URISyntaxException;
@ -33,6 +32,7 @@ import org.shredzone.acme4j.toolbox.JSON.Value;
* @see <a href="https://tools.ietf.org/html/rfc7807">RFC 7807</a>
*/
public class Problem implements Serializable {
@Serial
private static final long serialVersionUID = -8418248862966754214L;
private final URL baseUrl;
@ -122,7 +122,7 @@ public class Problem implements Serializable {
.asArray()
.stream()
.map(o -> o.asProblem(baseUrl))
.collect(toUnmodifiableList());
.toList();
}
/**

View File

@ -35,8 +35,6 @@ import org.slf4j.LoggerFactory;
public class RenewalInfo extends AcmeJsonResource {
private static final Logger LOG = LoggerFactory.getLogger(RenewalInfo.class);
private @Nullable Instant recheckAfter = null;
protected RenewalInfo(Login login, URL location) {
super(login, location);
}
@ -65,15 +63,6 @@ public class RenewalInfo extends AcmeJsonResource {
return getJSON().get("explanationURL").optional().map(Value::asURL);
}
/**
* An optional {@link Instant} that serves as recommendation when to re-check the
* renewal information of a certificate.
*/
public Optional<Instant> getRecheckAfter() {
getJSON(); // make sure resource is loaded
return Optional.ofNullable(recheckAfter);
}
/**
* Checks if the given {@link Instant} is before the suggested time window, so a
* certificate renewal is not required yet.
@ -172,16 +161,6 @@ public class RenewalInfo extends AcmeJsonResource {
end.toEpochMilli())));
}
@Override
public void update() throws AcmeException {
LOG.debug("update RenewalInfo");
try (Connection conn = getSession().connect()) {
conn.sendRequest(getLocation(), getSession(), null);
setJSON(conn.readJsonResponse());
recheckAfter = conn.getRetryAfter().orElse(null);
}
}
/**
* Asserts that the end of the suggested time window is after the start.
*/
@ -191,4 +170,17 @@ public class RenewalInfo extends AcmeJsonResource {
}
}
@Override
public Optional<Instant> fetch() throws AcmeException {
LOG.debug("update RenewalInfo");
try (Connection conn = getSession().connect()) {
conn.sendRequest(getLocation(), getSession(), null);
setJSON(conn.readJsonResponse());
var retryAfterOpt = conn.getRetryAfter();
retryAfterOpt.ifPresent(instant -> LOG.debug("Retry-After: {}", instant));
setRetryAfter(retryAfterOpt.orElse(null));
return retryAfterOpt;
}
}
}

View File

@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.StreamSupport;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.shredzone.acme4j.connector.Resource;
@ -200,6 +201,7 @@ public class Session {
* @return {@link NetworkSettings}
* @since 2.8
*/
@SuppressFBWarnings("EI_EXPOSE_REP") // behavior is intended
public NetworkSettings networkSettings() {
return networkSettings;
}
@ -368,4 +370,9 @@ public class Session {
resourceMap.set(map);
}
@Override
protected final void finalize() {
// CT_CONSTRUCTOR_THROW: Prevents finalizer attack
}
}

View File

@ -14,6 +14,7 @@
package org.shredzone.acme4j;
import java.util.Arrays;
import java.util.Locale;
/**
* An enumeration of status codes of challenges and authorizations.
@ -32,7 +33,7 @@ public enum Status {
/**
* The server is processing the resource. The client should invoke
* {@link AcmeJsonResource#update()} and re-check the status.
* {@link AcmeJsonResource#fetch()} and re-check the status.
*/
PROCESSING,
@ -84,7 +85,7 @@ public enum Status {
* no match
*/
public static Status parse(String str) {
var check = str.toUpperCase();
var check = str.toUpperCase(Locale.ENGLISH);
return Arrays.stream(values())
.filter(s -> s.name().equals(check))
.findFirst()

View File

@ -13,11 +13,15 @@
*/
package org.shredzone.acme4j.challenge;
import java.io.Serial;
import java.time.Duration;
import java.time.Instant;
import java.util.EnumSet;
import java.util.Optional;
import org.shredzone.acme4j.AcmeJsonResource;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.PollableResource;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeException;
@ -37,7 +41,8 @@ import org.slf4j.LoggerFactory;
* own type. {@link Challenge#prepareResponse(JSONBuilder)} can be overridden to put all
* required data to the challenge response.
*/
public class Challenge extends AcmeJsonResource {
public class Challenge extends AcmeJsonResource implements PollableResource {
@Serial
private static final long serialVersionUID = 2338794776848388099L;
private static final Logger LOG = LoggerFactory.getLogger(Challenge.class);
@ -76,6 +81,7 @@ public class Challenge extends AcmeJsonResource {
* A challenge is only completed when it reaches either status {@link Status#VALID} or
* {@link Status#INVALID}.
*/
@Override
public Status getStatus() {
return getJSON().get(KEY_STATUS).asStatus();
}
@ -155,6 +161,8 @@ public class Challenge extends AcmeJsonResource {
* If this method is invoked a second time, the ACME server is requested to retry the
* validation. This can be useful if the client state has changed, for example after a
* firewall rule has been updated.
*
* @see #waitForCompletion(Duration)
*/
public void trigger() throws AcmeException {
LOG.debug("trigger");
@ -167,4 +175,22 @@ public class Challenge extends AcmeJsonResource {
}
}
/**
* Waits until the challenge is completed.
* <p>
* Is is completed if it reaches either {@link Status#VALID} or
* {@link Status#INVALID}.
* <p>
* This method is synchronous and blocks the current thread.
*
* @param timeout
* Timeout until a terminal status must have been reached
* @return Status that was reached
* @since 3.4.0
*/
public Status waitForCompletion(Duration timeout)
throws AcmeException, InterruptedException {
return waitForStatus(EnumSet.of(Status.VALID, Status.INVALID), timeout);
}
}

View File

@ -16,6 +16,8 @@ package org.shredzone.acme4j.challenge;
import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash;
import java.io.Serial;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
@ -25,6 +27,7 @@ import org.shredzone.acme4j.toolbox.JSON;
* validation. See the acme4j documentation for a detailed explanation.
*/
public class Dns01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = 6964687027713533075L;
/**
@ -37,6 +40,18 @@ public class Dns01Challenge extends TokenChallenge {
*/
public static final String RECORD_NAME_PREFIX = "_acme-challenge";
/**
* Creates a new generic {@link Dns01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public Dns01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
* record.
@ -45,10 +60,10 @@ public class Dns01Challenge extends TokenChallenge {
* Domain {@link Identifier} of the domain to be validated
* @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note
* the trailing full stop character).
* @since 2.14
* @since 4.0.0
*/
public static String toRRName(Identifier identifier) {
return toRRName(identifier.getDomain());
public String getRRName(Identifier identifier) {
return getRRName(identifier.getDomain());
}
/**
@ -59,24 +74,12 @@ public class Dns01Challenge extends TokenChallenge {
* Domain name to be validated
* @return Resource Record name (e.g. {@code _acme-challenge.www.example.org.}, note
* the trailing full stop character).
* @since 2.14
* @since 4.0.0
*/
public static String toRRName(String domain) {
public String getRRName(String domain) {
return RECORD_NAME_PREFIX + '.' + domain + '.';
}
/**
* Creates a new generic {@link Dns01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public Dns01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Returns the digest string to be set in the domain's {@code _acme-challenge} TXT
* record.

View File

@ -0,0 +1,106 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2025 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.challenge;
import static org.shredzone.acme4j.toolbox.AcmeUtils.*;
import java.io.Serial;
import java.net.URL;
import java.util.Arrays;
import java.util.Locale;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
/**
* Implements the {@value TYPE} challenge. It requires a specific DNS record for domain
* validation. See the acme4j documentation for a detailed explanation.
*
* @draft This class is currently based on an RFC draft. It may be changed or removed
* without notice to reflect future changes to the draft. SemVer rules do not apply here.
* @since 4.0.0
*/
public class DnsAccount01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = -1098129409378900733L;
/**
* Challenge type name: {@value}
*/
public static final String TYPE = "dns-account-01";
/**
* Creates a new generic {@link DnsAccount01Challenge} object.
*
* @param login
* {@link Login} the resource is bound with
* @param data
* {@link JSON} challenge data
*/
public DnsAccount01Challenge(Login login, JSON data) {
super(login, data);
}
/**
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
* record.
*
* @param identifier
* {@link Identifier} to be validated
* @return Resource Record name (e.g.
* {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop
* character).
*/
public String getRRName(Identifier identifier) {
return getRRName(identifier.getDomain());
}
/**
* Converts a domain identifier to the Resource Record name to be used for the DNS TXT
* record.
*
* @param domain
* Domain name to be validated
* @return Resource Record name (e.g.
* {@code _ujmmovf2vn55tgye._acme-challenge.example.org.}, note the trailing full stop
* character).
*/
public String getRRName(String domain) {
return getPrefix(getLogin().getAccount().getLocation()) + '.' + domain + '.';
}
/**
* Returns the digest string to be set in the domain's TXT record.
*/
public String getDigest() {
return base64UrlEncode(sha256hash(getAuthorization()));
}
/**
* Returns the prefix of an account location.
*/
private String getPrefix(URL accountLocation) {
var urlHash = sha256hash(accountLocation.toExternalForm());
var hash = base32Encode(Arrays.copyOfRange(urlHash, 0, 10));
return "_" + hash.toLowerCase(Locale.ENGLISH)
+ "._acme-challenge";
}
@Override
protected boolean acceptable(String type) {
return TYPE.equals(type);
}
}

View File

@ -13,6 +13,8 @@
*/
package org.shredzone.acme4j.challenge;
import java.io.Serial;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.toolbox.JSON;
@ -22,6 +24,7 @@ import org.shredzone.acme4j.toolbox.JSON;
* detailed explanation.
*/
public class Http01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = 3322211185872544605L;
/**

View File

@ -16,6 +16,7 @@ package org.shredzone.acme4j.challenge;
import static org.shredzone.acme4j.toolbox.AcmeUtils.sha256hash;
import java.io.IOException;
import java.io.Serial;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
@ -32,6 +33,7 @@ import org.shredzone.acme4j.util.CertificateUtils;
* @since 2.1
*/
public class TlsAlpn01Challenge extends TokenChallenge {
@Serial
private static final long serialVersionUID = -5590351078176091228L;
/**

View File

@ -15,6 +15,8 @@ package org.shredzone.acme4j.challenge;
import static org.shredzone.acme4j.toolbox.AcmeUtils.base64UrlEncode;
import java.io.Serial;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
@ -26,6 +28,7 @@ import org.shredzone.acme4j.toolbox.JoseUtils;
* and {@code keyAuthorization}.
*/
public class TokenChallenge extends Challenge {
@Serial
private static final long serialVersionUID = 1634133407432681800L;
protected static final String KEY_TOKEN = "token";

View File

@ -26,7 +26,6 @@ import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -156,15 +155,6 @@ public interface Connection extends AutoCloseable {
*/
Optional<Instant> getRetryAfter();
/**
* Throws an {@link AcmeRetryAfterException} if the last status was HTTP Accepted and
* a Retry-After header was received.
*
* @param message
* Message to be sent along with the {@link AcmeRetryAfterException}
*/
void handleRetryAfter(String message) throws AcmeException;
/**
* Gets the nonce from the nonce header.
*

View File

@ -15,7 +15,6 @@ package org.shredzone.acme4j.connector;
import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toUnmodifiableList;
import java.io.IOException;
import java.io.InputStream;
@ -51,7 +50,6 @@ import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitedException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
@ -133,7 +131,7 @@ public class DefaultConnection implements Connection {
var rc = getResponse().statusCode();
if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
throwAcmeException();
throw new AcmeException("Server responded with HTTP " + rc + " while trying to retrieve a nonce");
}
session.setNonce(getNonce()
@ -223,7 +221,7 @@ public class DefaultConnection implements Connection {
var cf = CertificateFactory.getInstance("X.509");
return cf.generateCertificates(in).stream()
.map(X509Certificate.class::cast)
.collect(toUnmodifiableList());
.toList();
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
} catch (CertificateException ex) {
@ -231,14 +229,6 @@ public class DefaultConnection implements Connection {
}
}
@Override
public void handleRetryAfter(String message) throws AcmeException {
var retryAfter = getRetryAfter();
if (retryAfter.isPresent()) {
throw new AcmeRetryAfterException(message, retryAfter.get());
}
}
@Override
public Optional<String> getNonce() {
var nonceHeaderOpt = getResponse().headers()
@ -314,7 +304,7 @@ public class DefaultConnection implements Connection {
public Collection<URL> getLinks(String relation) {
return collectLinks(relation).stream()
.map(this::resolveRelative)
.collect(toUnmodifiableList());
.toList();
}
@Override
@ -623,7 +613,7 @@ public class DefaultConnection implements Connection {
.filter(Matcher::matches)
.map(m -> m.group(1))
.peek(location -> LOG.debug("Link: {} -> {}", relation, location))
.collect(toUnmodifiableList());
.toList();
}
/**

View File

@ -19,6 +19,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.util.Properties;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.slf4j.LoggerFactory;
/**
@ -62,6 +63,7 @@ public class HttpConnector {
* Creates a new {@link HttpConnector} that is using the given
* {@link NetworkSettings}.
*/
@SuppressFBWarnings("EI_EXPOSE_REP2") // behavior is intended
public HttpConnector(NetworkSettings networkSettings) {
this.networkSettings = networkSettings;
}

View File

@ -35,7 +35,7 @@ public class NetworkSettings {
public static final String GZIP_PROPERTY_NAME = "org.shredzone.acme4j.gzip_compression";
private ProxySelector proxySelector = HttpClient.Builder.NO_PROXY;
private Duration timeout = Duration.ofSeconds(10);
private Duration timeout = Duration.ofSeconds(30);
private @Nullable Authenticator authenticator = null;
private boolean compression = true;

View File

@ -13,12 +13,13 @@
*/
package org.shredzone.acme4j.connector;
import static java.util.Objects.requireNonNull;
import java.net.URL;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.function.BiFunction;
import edu.umd.cs.findbugs.annotations.Nullable;
@ -58,10 +59,10 @@ public class ResourceIterator<T extends AcmeResource> implements Iterator<T> {
* {@link Login} and {@link URL}.
*/
public ResourceIterator(Login login, String field, @Nullable URL start, BiFunction<Login, URL, T> creator) {
this.login = Objects.requireNonNull(login, "login");
this.field = Objects.requireNonNull(field, "field");
this.login = requireNonNull(login, "login");
this.field = requireNonNull(field, "field");
this.nextUrl = start;
this.creator = Objects.requireNonNull(creator, "creator");
this.creator = requireNonNull(creator, "creator");
}
/**
@ -141,7 +142,7 @@ public class ResourceIterator<T extends AcmeResource> implements Iterator<T> {
private void readAndQueue() throws AcmeException {
var session = login.getSession();
try (var conn = session.connect()) {
conn.sendSignedPostAsGetRequest(nextUrl, login);
conn.sendSignedPostAsGetRequest(requireNonNull(nextUrl), login);
fillUrlList(conn.readJsonResponse());
nextUrl = conn.getLinks("next").stream().findFirst().orElse(null);

View File

@ -13,10 +13,13 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
/**
* The root class of all checked acme4j exceptions.
*/
public class AcmeException extends Exception {
@Serial
private static final long serialVersionUID = -2935088954705632025L;
/**

View File

@ -15,6 +15,7 @@ package org.shredzone.acme4j.exception;
import static java.util.Objects.requireNonNull;
import java.io.Serial;
import java.net.URL;
import org.shredzone.acme4j.AcmeResource;
@ -26,6 +27,7 @@ import org.shredzone.acme4j.AcmeResource;
* thrown by getter methods, so the API is not polluted with checked exceptions.
*/
public class AcmeLazyLoadingException extends RuntimeException {
@Serial
private static final long serialVersionUID = 1000353433913721901L;
private final Class<? extends AcmeResource> type;

View File

@ -14,12 +14,14 @@
package org.shredzone.acme4j.exception;
import java.io.IOException;
import java.io.Serial;
/**
* A general network error has occured while communicating with the server (e.g. network
* timeout).
*/
public class AcmeNetworkException extends AcmeException {
@Serial
private static final long serialVersionUID = 2054398693543329179L;
/**

View File

@ -13,12 +13,15 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
/**
* A runtime exception that is thrown if the ACME server does not support a certain
* feature. It might be either because that feature is optional, or because the server
* is not fully RFC compliant.
*/
public class AcmeNotSupportedException extends AcmeProtocolException {
@Serial
private static final long serialVersionUID = 3434074002226584731L;
/**

View File

@ -13,12 +13,15 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
/**
* A runtime exception that is thrown when the response of the server is violating the
* RFC, and could not be handled or parsed for that reason. It is an indicator that the CA
* does not fully comply with the RFC, and is usually not expected to be thrown.
*/
public class AcmeProtocolException extends RuntimeException {
@Serial
private static final long serialVersionUID = 2031203835755725193L;
/**

View File

@ -13,6 +13,7 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
import java.net.URL;
import java.time.Instant;
import java.util.Collection;
@ -28,6 +29,7 @@ import org.shredzone.acme4j.Problem;
* further explains the rate limit that was exceeded.
*/
public class AcmeRateLimitedException extends AcmeServerException {
@Serial
private static final long serialVersionUID = 4150484059796413069L;
private final @Nullable Instant retryAfter;
@ -49,8 +51,7 @@ public class AcmeRateLimitedException extends AcmeServerException {
@Nullable Collection<URL> documents) {
super(problem);
this.retryAfter = retryAfter;
this.documents =
documents != null ? Collections.unmodifiableCollection(documents) : Collections.emptyList();
this.documents = documents != null ? documents : Collections.emptyList();
}
/**
@ -66,7 +67,7 @@ public class AcmeRateLimitedException extends AcmeServerException {
* Empty if the server did not provide such URLs.
*/
public Collection<URL> getDocuments() {
return documents;
return Collections.unmodifiableCollection(documents);
}
}

View File

@ -1,49 +0,0 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2016 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.exception;
import java.time.Instant;
import java.util.Objects;
/**
* A server side process has not been completed yet. The server also provides an estimate
* of when the process is expected to complete.
*/
public class AcmeRetryAfterException extends AcmeException {
private static final long serialVersionUID = 4461979121063649905L;
private final Instant retryAfter;
/**
* Creates a new {@link AcmeRetryAfterException}.
*
* @param msg
* Error details
* @param retryAfter
* retry-after date returned by the server
*/
public AcmeRetryAfterException(String msg, Instant retryAfter) {
super(msg);
this.retryAfter = Objects.requireNonNull(retryAfter);
}
/**
* Returns the retry-after instant returned by the server. This is only an estimate
* of when a retry attempt might succeed.
*/
public Instant getRetryAfter() {
return retryAfter;
}
}

View File

@ -13,6 +13,7 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
import java.net.URI;
import java.util.Objects;
@ -26,6 +27,7 @@ import org.shredzone.acme4j.Problem;
* individually.
*/
public class AcmeServerException extends AcmeException {
@Serial
private static final long serialVersionUID = 5971622508467042792L;
private final Problem problem;

View File

@ -13,6 +13,8 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
import org.shredzone.acme4j.Problem;
/**
@ -20,6 +22,7 @@ import org.shredzone.acme4j.Problem;
* will give further details (e.g. "client IP is blocked").
*/
public class AcmeUnauthorizedException extends AcmeServerException {
@Serial
private static final long serialVersionUID = 9064697508262919366L;
/**

View File

@ -13,6 +13,7 @@
*/
package org.shredzone.acme4j.exception;
import java.io.Serial;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
@ -28,6 +29,7 @@ import org.shredzone.acme4j.Problem;
* requires an agreement to the new terms before proceeding.
*/
public class AcmeUserActionRequiredException extends AcmeServerException {
@Serial
private static final long serialVersionUID = 7719055447283858352L;
private final @Nullable URI tosUri;
@ -69,4 +71,11 @@ public class AcmeUserActionRequiredException extends AcmeServerException {
}
}
@Override
public String toString() {
return getProblem().getInstance()
.map(uri -> "Please visit " + uri + " - details: " + getProblem())
.orElseGet(super::toString);
}
}

View File

@ -25,6 +25,7 @@ import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.DnsAccount01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.challenge.TokenChallenge;
@ -83,6 +84,7 @@ public abstract class AbstractAcmeProvider implements AcmeProvider {
var map = new HashMap<String, ChallengeProvider>();
map.put(Dns01Challenge.TYPE, Dns01Challenge::new);
map.put(DnsAccount01Challenge.TYPE, DnsAccount01Challenge::new);
map.put(Http01Challenge.TYPE, Http01Challenge::new);
map.put(TlsAlpn01Challenge.TYPE, TlsAlpn01Challenge::new);

View File

@ -15,6 +15,7 @@ package org.shredzone.acme4j.provider;
import java.net.URI;
import java.net.URL;
import java.util.Optional;
import java.util.ServiceLoader;
import edu.umd.cs.findbugs.annotations.Nullable;
@ -96,4 +97,17 @@ public interface AcmeProvider {
@Nullable
Challenge createChallenge(Login login, JSON data);
/**
* Returns a proposal for the EAB MAC algorithm to be used. Only set if the CA
* requires External Account Binding and the MAC algorithm cannot be correctly derived
* from the MAC key. Empty otherwise.
*
* @return Proposed MAC algorithm to be used for EAB, or empty for the default
* behavior.
* @since 3.5.0
*/
default Optional<String> getProposedEabMacAlgorithm() {
return Optional.empty();
}
}

View File

@ -34,7 +34,7 @@ public class GenericAcmeProvider extends AbstractAcmeProvider {
@Override
public URL resolve(URI serverUri) {
try {
return new URL(serverUri.getScheme(), serverUri.getHost(), serverUri.getPort(), serverUri.getPath());
return serverUri.toURL();
} catch (MalformedURLException ex) {
throw new IllegalArgumentException("Bad generic server URI", ex);
}

View File

@ -0,0 +1,87 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2025 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.actalis;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.toolbox.JSON;
/**
* An {@link AcmeProvider} for <em>Actalis</em>.
* <p>
* The {@code serverUri} is {@code "acme://actalis.com"} for the production server.
* <p>
* If you want to use <em>Actalis</em>, always prefer to use this provider.
*
* @see <a href="https://www.actalis.com/">Actalis S.p.A.</a>
* @since 4.0.0
*/
public class ActalisAcmeProvider extends AbstractAcmeProvider {
private static final String PRODUCTION_DIRECTORY_URL = "https://acme-api.actalis.com/acme/directory";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "actalis.com".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path)) {
directoryUrl = PRODUCTION_DIRECTORY_URL;
} else {
throw new IllegalArgumentException("Unknown URI " + serverUri);
}
try {
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}
}
@Override
@SuppressWarnings("unchecked")
public JSON directory(Session session, URI serverUri) throws AcmeException {
// This is a workaround as actalis.com uses "home" instead of "website" to
// refer to its homepage in the metadata.
var superdirectory = super.directory(session, serverUri);
if (superdirectory == null) {
return null;
}
var directory = superdirectory.toMap();
var meta = directory.get("meta");
if (meta instanceof Map) {
var metaMap = ((Map<String, Object>) meta);
if (metaMap.containsKey("home") && !metaMap.containsKey("website")) {
metaMap.put("website", metaMap.remove("home"));
}
}
return JSON.fromMap(directory);
}
}

View File

@ -0,0 +1,29 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2025 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* This package contains an {@link org.shredzone.acme4j.provider.AcmeProvider} for the
* Actalis server.
*
* @see <a href="https://www.actalis.com/">Actalis S.p.A.</a>
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.actalis;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -0,0 +1,63 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.buypass;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.shredzone.acme4j.provider.AcmeProvider;
/**
* An {@link AcmeProvider} for the <em>Buypass</em>.
* <p>
* The {@code serverUri} is {@code "acme://buypass.com"} for the production server,
* and {@code "acme://buypass.com/staging"} for the staging server.
*
* @see <a href="https://www.buypass.com/products/tls-ssl-certificates/go-ssl">https://www.buypass.com/products/tls-ssl-certificates/go-ssl</a>
* @since 3.5.0
*/
public class BuypassAcmeProvider extends AbstractAcmeProvider {
private static final String PRODUCTION_DIRECTORY_URL = "https://api.buypass.com/acme/directory";
private static final String STAGING_DIRECTORY_URL = "https://api.test4.buypass.no/acme/directory";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "buypass.com".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path)) {
directoryUrl = PRODUCTION_DIRECTORY_URL;
} else if ("/staging".equals(path)) {
directoryUrl = STAGING_DIRECTORY_URL;
} else {
throw new IllegalArgumentException("Unknown URI " + serverUri);
}
try {
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}
}
}

View File

@ -0,0 +1,29 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* This package contains the {@link org.shredzone.acme4j.provider.AcmeProvider} for+
* Buypass.
*
* @see <a href="https://www.buypass.com/products/tls-ssl-certificates/go-ssl">https://www.buypass.com/products/tls-ssl-certificates/go-ssl</a>
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.buypass;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -0,0 +1,70 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.google;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Optional;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.shredzone.acme4j.provider.AcmeProvider;
/**
* An {@link AcmeProvider} for the <em>Google Trust Services</em>.
* <p>
* The {@code serverUri} is {@code "acme://pki.goog"} for the production server,
* and {@code "acme://pki.goog/staging"} for the staging server.
*
* @see <a href="https://pki.goog/">https://pki.goog/</a>
* @since 3.5.0
*/
public class GoogleAcmeProvider extends AbstractAcmeProvider {
private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory";
private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "pki.goog".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path)) {
directoryUrl = PRODUCTION_DIRECTORY_URL;
} else if ("/staging".equals(path)) {
directoryUrl = STAGING_DIRECTORY_URL;
} else {
throw new IllegalArgumentException("Unknown URI " + serverUri);
}
try {
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}
}
@Override
public Optional<String> getProposedEabMacAlgorithm() {
return Optional.of(AlgorithmIdentifiers.HMAC_SHA256);
}
}

View File

@ -0,0 +1,29 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* This package contains the {@link org.shredzone.acme4j.provider.AcmeProvider} for the
* Google Trust Services.
*
* @see <a href="https://pki.goog/">https://pki.goog/</a>
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.google;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -46,7 +46,7 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || "".equals(path) || "/".equals(path) || "/v02".equals(path)) {
if (path == null || path.isEmpty() || "/".equals(path) || "/v02".equals(path)) {
directoryUrl = V02_DIRECTORY_URL;
} else if ("/staging".equals(path)) {
directoryUrl = STAGING_DIRECTORY_URL;
@ -55,7 +55,7 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider {
}
try {
return new URL(directoryUrl);
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}

View File

@ -15,6 +15,7 @@ package org.shredzone.acme4j.provider.pebble;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.regex.Pattern;
@ -50,7 +51,7 @@ public class PebbleAcmeProvider extends AbstractAcmeProvider {
var path = serverUri.getPath();
int port = serverUri.getPort() != -1 ? serverUri.getPort() : PEBBLE_DEFAULT_PORT;
var baseUrl = new URL("https://localhost:" + port + "/dir");
var baseUrl = URI.create("https://localhost:" + port + "/dir").toURL();
if (path != null && !path.isEmpty() && !"/".equals(path)) {
baseUrl = parsePath(path);
@ -77,7 +78,11 @@ public class PebbleAcmeProvider extends AbstractAcmeProvider {
if (m.group(2) != null) {
port = Integer.parseInt(m.group(2));
}
return new URL("https", host, port, "/dir");
try {
return new URI("https", null, host, port, "/dir", null, null).toURL();
} catch (URISyntaxException ex) {
throw new IllegalArgumentException("Malformed Pebble host/port: " + path);
}
} else {
throw new IllegalArgumentException("Invalid Pebble host/port: " + path);
}

View File

@ -20,21 +20,26 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.connector.HttpConnector;
import org.shredzone.acme4j.connector.NetworkSettings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* {@link HttpConnector} to be used for Pebble. Pebble uses a static, self-signed SSL
* certificate.
*/
public class PebbleHttpConnector extends HttpConnector {
private static @Nullable SSLContext sslContext = null;
private static final Logger LOG = LoggerFactory.getLogger(PebbleHttpConnector.class);
private static final AtomicReference<SSLContext> SSL_CONTEXT_REF = new AtomicReference<>();
public PebbleHttpConnector(NetworkSettings settings) {
super(settings);
@ -51,23 +56,52 @@ public class PebbleHttpConnector extends HttpConnector {
* Lazily creates an {@link SSLContext} that exclusively accepts the Pebble
* certificate.
*/
protected synchronized SSLContext createSSLContext() {
if (sslContext == null) {
try (var in = getClass().getResourceAsStream("/org/shredzone/acme4j/provider/pebble/pebble.truststore")) {
var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
keystore.load(in, "acme4j".toCharArray());
protected SSLContext createSSLContext() {
if (SSL_CONTEXT_REF.get() == null) {
try {
var keystore = readPemFile("/pebble.minica.pem")
.or(() -> readPemFile("/META-INF/pebble.minica.pem"))
.or(() -> readPemFile("/org/shredzone/acme4j/provider/pebble/pebble.minica.pem"))
.orElseThrow(() -> new RuntimeException("Could not find a Pebble root certificate"));
var tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keystore);
sslContext = SSLContext.getInstance("TLS");
var sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), null);
} catch (IOException | KeyStoreException | CertificateException
| NoSuchAlgorithmException | KeyManagementException ex) {
SSL_CONTEXT_REF.set(sslContext);
} catch (KeyStoreException | NoSuchAlgorithmException | KeyManagementException ex) {
throw new RuntimeException("Could not create truststore", ex);
}
}
return Objects.requireNonNull(sslContext);
return Objects.requireNonNull(SSL_CONTEXT_REF.get());
}
/**
* Reads a PEM file from a resource, and returns a {@link KeyStore} that uses this
* certificate as root CA.
*
* @param resource
* Resource name
* @return A {@link KeyStore} if the resource could be read successfully, otherwise
* empty.
*/
private Optional<KeyStore> readPemFile(String resource) {
try (var in = getClass().getResourceAsStream(resource)) {
if (in == null) {
return Optional.empty();
}
var cf = CertificateFactory.getInstance("X.509");
var cert = cf.generateCertificate(in);
var keystore = KeyStore.getInstance(KeyStore.getDefaultType());
keystore.load(null, "acme4j".toCharArray());
keystore.setCertificateEntry("pebble", cert);
return Optional.of(keystore);
} catch (IOException | KeyStoreException | CertificateException
| NoSuchAlgorithmException ex) {
LOG.error("Failed to read PEM from resource '{}'", resource, ex);
return Optional.empty();
}
}
}

View File

@ -0,0 +1,97 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.sslcom;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.shredzone.acme4j.provider.AcmeProvider;
import org.shredzone.acme4j.toolbox.JSON;
/**
* An {@link AcmeProvider} for <em>SSL.com</em>.
* <p>
* The {@code serverUri} is {@code "acme://ssl.com"} for the production server,
* and {@code "acme://acme-try.ssl.com"} for a testing server.
* <p>
* If you want to use <em>SSL.com</em>, always prefer to use this provider.
*
* @see <a href="https://ssl.com/">SSL.com</a>
* @since 3.2.0
*/
public class SslComAcmeProvider extends AbstractAcmeProvider {
private static final String PRODUCTION_ECC_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-ecc";
private static final String PRODUCTION_RSA_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-rsa";
private static final String STAGING_ECC_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-ecc";
private static final String STAGING_RSA_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-rsa";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "ssl.com".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path) || "/ecc".equals(path)) {
directoryUrl = PRODUCTION_ECC_DIRECTORY_URL;
} else if ("/rsa".equals(path)) {
directoryUrl = PRODUCTION_RSA_DIRECTORY_URL;
} else if ("/staging".equals(path) || "/staging/ecc".equals(path)) {
directoryUrl = STAGING_ECC_DIRECTORY_URL;
} else if ("/staging/rsa".equals(path)) {
directoryUrl = STAGING_RSA_DIRECTORY_URL;
} else {
throw new IllegalArgumentException("Unknown URI " + serverUri);
}
try {
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}
}
@Override
@SuppressWarnings("unchecked")
public JSON directory(Session session, URI serverUri) throws AcmeException {
// This is a workaround for a bug at SSL.com. It requires account registration
// by EAB, but the "externalAccountRequired" flag in the directory is set to
// false. This patch reads the directory and forcefully sets the flag to true.
// The entire method can be removed once it is fixed on SSL.com side.
var superdirectory = super.directory(session, serverUri);
if (superdirectory == null) {
return null;
}
var directory = superdirectory.toMap();
var meta = directory.get("meta");
if (meta instanceof Map) {
var metaMap = ((Map<String, Object>) meta);
metaMap.remove("externalAccountRequired");
metaMap.put("externalAccountRequired", true);
}
return JSON.fromMap(directory);
}
}

View File

@ -0,0 +1,29 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2020 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* This package contains the SSL.com
* {@link org.shredzone.acme4j.provider.AcmeProvider}.
*
* @see <a href="https://ssl.com/">SSL.com</a>
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.sslcom;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -0,0 +1,59 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.zerossl;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.provider.AbstractAcmeProvider;
import org.shredzone.acme4j.provider.AcmeProvider;
/**
* An {@link AcmeProvider} for <em>ZeroSSL</em>.
* <p>
* The {@code serverUri} is {@code "acme://zerossl.com"} for the production server.
*
* @see <a href="https://zerossl.com/">ZeroSSL</a>
* @since 3.2.0
*/
public class ZeroSSLAcmeProvider extends AbstractAcmeProvider {
private static final String V02_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90";
@Override
public boolean accepts(URI serverUri) {
return "acme".equals(serverUri.getScheme())
&& "zerossl.com".equals(serverUri.getHost());
}
@Override
public URL resolve(URI serverUri) {
var path = serverUri.getPath();
String directoryUrl;
if (path == null || path.isEmpty() || "/".equals(path)) {
directoryUrl = V02_DIRECTORY_URL;
} else {
throw new IllegalArgumentException("Unknown URI " + serverUri);
}
try {
return URI.create(directoryUrl).toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(directoryUrl, ex);
}
}
}

View File

@ -0,0 +1,28 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
/**
* This package contains the ZeroSSL {@link org.shredzone.acme4j.provider.AcmeProvider}.
*
* @see <a href="https://zerossl.com/">ZeroSSL</a>
*/
@ReturnValuesAreNonnullByDefault
@DefaultAnnotationForParameters(NonNull.class)
@DefaultAnnotationForFields(NonNull.class)
package org.shredzone.acme4j.provider.zerossl;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForFields;
import edu.umd.cs.findbugs.annotations.DefaultAnnotationForParameters;
import edu.umd.cs.findbugs.annotations.NonNull;
import edu.umd.cs.findbugs.annotations.ReturnValuesAreNonnullByDefault;

View File

@ -16,21 +16,29 @@ package org.shredzone.acme4j.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.net.IDN;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Base64;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Pattern;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.Certificate;
import org.bouncycastle.cert.X509CertificateHolder;
import org.shredzone.acme4j.exception.AcmeProtocolException;
/**
@ -64,6 +72,8 @@ public final class AcmeUtils {
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
private static final char[] BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
/**
* Enumeration of PEM labels.
*/
@ -146,6 +156,59 @@ public final class AcmeUtils {
return URL_DECODER.decode(base64);
}
/**
* Base32 encodes a byte array.
*
* @param data Byte array to encode
* @return Base32 encoded data (includes padding)
* @since 4.0.0
*/
public static String base32Encode(byte[] data) {
var result = new StringBuilder();
var unconverted = new int[5];
var converted = new int[8];
for (var ix = 0; ix < (data.length + 4) / 5; ix++) {
var blocklen = unconverted.length;
for (var pos = 0; pos < unconverted.length; pos++) {
if ((ix * 5 + pos) < data.length) {
unconverted[pos] = data[ix * 5 + pos] & 0xFF;
} else {
unconverted[pos] = 0;
blocklen--;
}
}
converted[0] = (unconverted[0] >> 3) & 0x1F;
converted[1] = ((unconverted[0] & 0x07) << 2) | ((unconverted[1] >> 6) & 0x03);
converted[2] = (unconverted[1] >> 1) & 0x1F;
converted[3] = ((unconverted[1] & 0x01) << 4) | ((unconverted[2] >> 4) & 0x0F);
converted[4] = ((unconverted[2] & 0x0F) << 1) | ((unconverted[3] >> 7) & 0x01);
converted[5] = (unconverted[3] >> 2) & 0x1F;
converted[6] = ((unconverted[3] & 0x03) << 3) | ((unconverted[4] >> 5) & 0x07);
converted[7] = unconverted[4] & 0x1F;
var padding = switch (blocklen) {
case 1 -> 6;
case 2 -> 4;
case 3 -> 3;
case 4 -> 1;
case 5 -> 0;
default -> throw new IllegalArgumentException("blocklen " + blocklen + " out of range");
};
Arrays.stream(converted)
.limit(converted.length - padding)
.map(v -> BASE32_ALPHABET[v])
.forEach(v -> result.append((char) v));
if (padding > 0) {
result.append("=".repeat(padding));
}
}
return result.toString();
}
/**
* Validates that the given {@link String} is a valid base64url encoded value.
*
@ -175,7 +238,7 @@ public final class AcmeUtils {
*/
public static String toAce(String domain) {
Objects.requireNonNull(domain, "domain");
return IDN.toASCII(domain.trim()).toLowerCase();
return IDN.toASCII(domain.trim()).toLowerCase(Locale.ENGLISH);
}
/**
@ -299,7 +362,7 @@ public final class AcmeUtils {
if (charset != null && !"utf-8".equalsIgnoreCase(charset)) {
throw new AcmeProtocolException("Unsupported charset " + charset);
}
return m.group(1).trim().toLowerCase();
return m.group(1).trim().toLowerCase(Locale.ENGLISH);
}
}
return null;
@ -323,4 +386,56 @@ public final class AcmeUtils {
}
}
/**
* Returns the certificate's unique identifier for renewal.
*
* @param certificate
* Certificate to get the unique identifier for.
* @return Unique identifier
* @throws AcmeProtocolException
* if the certificate is invalid or does not provide the necessary
* information.
*/
public static String getRenewalUniqueIdentifier(X509Certificate certificate) {
try {
var cert = new X509CertificateHolder(certificate.getEncoded());
var aki = Optional.of(cert)
.map(X509CertificateHolder::getExtensions)
.map(AuthorityKeyIdentifier::fromExtensions)
.map(AuthorityKeyIdentifier::getKeyIdentifier)
.map(AcmeUtils::base64UrlEncode)
.orElseThrow(() -> new AcmeProtocolException("Missing or invalid Authority Key Identifier"));
var sn = Optional.of(cert)
.map(X509CertificateHolder::toASN1Structure)
.map(Certificate::getSerialNumber)
.map(AcmeUtils::getRawInteger)
.map(AcmeUtils::base64UrlEncode)
.orElseThrow(() -> new AcmeProtocolException("Missing or invalid serial number"));
return aki + '.' + sn;
} catch (Exception ex) {
throw new AcmeProtocolException("Invalid certificate", ex);
}
}
/**
* Gets the raw integer array from ASN1Integer. This is done by encoding it to a byte
* array, and then skipping the INTEGER identifier. Other methods of ASN1Integer only
* deliver a parsed integer value that might have been mangled.
*
* @param integer
* ASN1Integer to convert to raw
* @return Byte array of the raw integer
*/
private static byte[] getRawInteger(ASN1Integer integer) {
try {
var encoded = integer.getEncoded();
return Arrays.copyOfRange(encoded, 2, encoded.length);
} catch (IOException ex) {
throw new UncheckedIOException(ex);
}
}
}

View File

@ -21,8 +21,7 @@ import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.net.URI;
@ -44,7 +43,6 @@ import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import edu.umd.cs.findbugs.annotations.Nullable;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import org.jose4j.json.JsonUtil;
import org.jose4j.lang.JoseException;
import org.shredzone.acme4j.Identifier;
@ -57,14 +55,13 @@ import org.shredzone.acme4j.exception.AcmeProtocolException;
* A model containing a JSON result. The content is immutable.
*/
public final class JSON implements Serializable {
private static final long serialVersionUID = 3091273044605709204L;
@Serial
private static final long serialVersionUID = 418332625174149030L;
private static final JSON EMPTY_JSON = new JSON(new HashMap<>());
private final String path;
@SuppressFBWarnings("JCIP_FIELD_ISNT_FINAL_IN_IMMUTABLE_CLASS")
private transient Map<String, Object> data; // Must not be final for deserialization
private final Map<String, Object> data;
/**
* Creates a new {@link JSON} root object.
@ -118,6 +115,21 @@ public final class JSON implements Serializable {
}
}
/**
* Creates a JSON object from a map.
* <p>
* The map's content is deeply copied. Changes to the map won't reflect in the created
* JSON structure.
*
* @param data
* Map structure
* @return {@link JSON} of the map's content.
* @since 3.2.0
*/
public static JSON fromMap(Map<String, Object> data) {
return JSON.parse(JsonUtil.toJson(data));
}
/**
* Returns a {@link JSON} of an empty document.
*
@ -192,26 +204,6 @@ public final class JSON implements Serializable {
return Collections.unmodifiableMap(data);
}
/**
* Serialize the data map in JSON.
*/
private void writeObject(ObjectOutputStream out) throws IOException {
out.writeUTF(JsonUtil.toJson(data));
out.defaultWriteObject();
}
/**
* Deserialize the JSON representation of the data map.
*/
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
try {
data = new HashMap<>(JsonUtil.parseJson(in.readUTF()));
in.defaultReadObject();
} catch (JoseException ex) {
throw new AcmeProtocolException("Cannot deserialize", ex);
}
}
/**
* Represents a JSON array.
*/
@ -354,20 +346,15 @@ public final class JSON implements Serializable {
* Returns the value as {@link String}.
*/
public String asString() {
required();
return val.toString();
return required().toString();
}
/**
* Returns the value as JSON object.
*/
@SuppressWarnings("unchecked")
public JSON asObject() {
required();
try {
return new JSON(path, (Map<String, Object>) val);
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": expected an object", ex);
}
return new JSON(path, (Map<String, Object>) required(Map.class));
}
/**
@ -376,9 +363,8 @@ public final class JSON implements Serializable {
* @since 2.8
*/
public JSON asEncodedObject() {
required();
try {
var raw = AcmeUtils.base64UrlDecode(val.toString());
var raw = AcmeUtils.base64UrlDecode(asString());
return new JSON(path, JsonUtil.parseJson(new String(raw, UTF_8)));
} catch (IllegalArgumentException | JoseException ex) {
throw new AcmeProtocolException(path + ": expected an encoded object", ex);
@ -392,7 +378,6 @@ public final class JSON implements Serializable {
* Base {@link URL} to resolve relative links against
*/
public Problem asProblem(URL baseUrl) {
required();
return new Problem(asObject(), baseUrl);
}
@ -402,7 +387,6 @@ public final class JSON implements Serializable {
* @since 2.3
*/
public Identifier asIdentifier() {
required();
return new Identifier(asObject());
}
@ -412,6 +396,7 @@ public final class JSON implements Serializable {
* Unlike the other getters, this method returns an empty array if the value is
* not set. Use {@link #isPresent()} to find out if the value was actually set.
*/
@SuppressWarnings("unchecked")
public Array asArray() {
if (val == null) {
return new Array(path, Collections.emptyList());
@ -428,33 +413,22 @@ public final class JSON implements Serializable {
* Returns the value as int.
*/
public int asInt() {
required();
try {
return ((Number) val).intValue();
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": bad number " + val, ex);
}
return (required(Number.class)).intValue();
}
/**
* Returns the value as boolean.
*/
public boolean asBoolean() {
required();
try {
return (Boolean) val;
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": bad boolean " + val, ex);
}
return required(Boolean.class);
}
/**
* Returns the value as {@link URI}.
*/
public URI asURI() {
required();
try {
return new URI(val.toString());
return new URI(asString());
} catch (URISyntaxException ex) {
throw new AcmeProtocolException(path + ": bad URI " + val, ex);
}
@ -464,9 +438,8 @@ public final class JSON implements Serializable {
* Returns the value as {@link URL}.
*/
public URL asURL() {
required();
try {
return new URL(val.toString());
return asURI().toURL();
} catch (MalformedURLException ex) {
throw new AcmeProtocolException(path + ": bad URL " + val, ex);
}
@ -476,9 +449,8 @@ public final class JSON implements Serializable {
* Returns the value as {@link Instant}.
*/
public Instant asInstant() {
required();
try {
return parseTimestamp(val.toString());
return parseTimestamp(asString());
} catch (IllegalArgumentException ex) {
throw new AcmeProtocolException(path + ": bad date " + val, ex);
}
@ -490,38 +462,52 @@ public final class JSON implements Serializable {
* @since 2.3
*/
public Duration asDuration() {
required();
try {
return Duration.ofSeconds(((Number) val).longValue());
} catch (ClassCastException ex) {
throw new AcmeProtocolException(path + ": bad duration " + val, ex);
}
return Duration.ofSeconds(required(Number.class).longValue());
}
/**
* Returns the value as base64 decoded byte array.
*/
public byte[] asBinary() {
required();
return AcmeUtils.base64UrlDecode(val.toString());
return AcmeUtils.base64UrlDecode(asString());
}
/**
* Returns the parsed {@link Status}.
*/
public Status asStatus() {
required();
return Status.parse(val.toString());
return Status.parse(asString());
}
/**
* Checks if the value is present. An {@link AcmeProtocolException} is thrown if
* the value is {@code null}.
*
* @return val that is guaranteed to be non-{@code null}
*/
private void required() {
if (!isPresent()) {
private Object required() {
if (val == null) {
throw new AcmeProtocolException(path + ": required, but not set");
}
return val;
}
/**
* Checks if the value is present. An {@link AcmeProtocolException} is thrown if
* the value is {@code null} or is not of the expected type.
*
* @param type
* expected type
* @return val that is guaranteed to be non-{@code null}
*/
private <T> T required(Class<T> type) {
if (val == null) {
throw new AcmeProtocolException(path + ": required, but not set");
}
if (!type.isInstance(val)) {
throw new AcmeProtocolException(path + ": cannot convert to " + type.getSimpleName());
}
return type.cast(val);
}
@Override

View File

@ -115,12 +115,14 @@ public final class JoseUtils {
* {@link PublicKey} of the account to register
* @param macKey
* {@link SecretKey} to sign the key identifier with
* @param macAlgorithm
* Algorithm of the MAC key
* @param resource
* "newAccount" resource URL
* @return Created JSON structure
*/
public static Map<String, Object> createExternalAccountBinding(String kid,
PublicKey accountKey, SecretKey macKey, URL resource) {
PublicKey accountKey, SecretKey macKey, String macAlgorithm, URL resource) {
try {
var keyJwk = PublicJsonWebKey.Factory.newPublicJwk(accountKey);
@ -128,7 +130,7 @@ public final class JoseUtils {
innerJws.setPayload(keyJwk.toJson());
innerJws.getHeaders().setObjectHeaderValue("url", resource);
innerJws.getHeaders().setObjectHeaderValue("kid", kid);
innerJws.setAlgorithmHeaderValue(macKeyAlgorithm(macKey));
innerJws.setAlgorithmHeaderValue(macAlgorithm);
innerJws.setKey(macKey);
innerJws.setDoKeyValidation(false);
innerJws.sign();
@ -201,23 +203,13 @@ public final class JoseUtils {
* there is no corresponding algorithm identifier for the key
*/
public static String keyAlgorithm(JsonWebKey jwk) {
if (jwk instanceof EllipticCurveJsonWebKey) {
var ecjwk = (EllipticCurveJsonWebKey) jwk;
switch (ecjwk.getCurveName()) {
case "P-256":
return AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256;
case "P-384":
return AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384;
case "P-521":
return AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512;
default:
throw new IllegalArgumentException("Unknown EC name "
+ ecjwk.getCurveName());
}
if (jwk instanceof EllipticCurveJsonWebKey ecjwk) {
return switch (ecjwk.getCurveName()) {
case "P-256" -> AlgorithmIdentifiers.ECDSA_USING_P256_CURVE_AND_SHA256;
case "P-384" -> AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384;
case "P-521" -> AlgorithmIdentifiers.ECDSA_USING_P521_CURVE_AND_SHA512;
default -> throw new IllegalArgumentException("Unknown EC name " + ecjwk.getCurveName());
};
} else if (jwk instanceof RsaJsonWebKey) {
return AlgorithmIdentifiers.RSA_USING_SHA256;
@ -243,19 +235,15 @@ public final class JoseUtils {
}
var size = macKey.getEncoded().length * 8;
switch (size) {
case 256:
return AlgorithmIdentifiers.HMAC_SHA256;
case 384:
return AlgorithmIdentifiers.HMAC_SHA384;
case 512:
return AlgorithmIdentifiers.HMAC_SHA512;
default:
throw new IllegalArgumentException("Bad key size: " + size);
if(size < 256) {
throw new IllegalArgumentException("Bad key size: " + size);
}
if (size >= 512) {
return AlgorithmIdentifiers.HMAC_SHA512;
} else if (size >= 384) {
return AlgorithmIdentifiers.HMAC_SHA384;
} else {
return AlgorithmIdentifiers.HMAC_SHA256;
}
}
}

View File

@ -64,25 +64,21 @@ public class CSRBuilder {
private final List<String> namelist = new ArrayList<>();
private final List<InetAddress> iplist = new ArrayList<>();
private @Nullable PKCS10CertificationRequest csr = null;
private boolean hasCnSet = false;
/**
* Adds a domain name to the CSR. The first domain name added will also be the
* <em>Common Name</em>. All domain names will be added as <em>Subject Alternative
* Name</em>.
* Adds a domain name to the CSR. All domain names will be added as <em>Subject
* Alternative Name</em>.
* <p>
* IDN domain names are ACE encoded automatically.
* <p>
* For wildcard certificates, the domain name must be prefixed with {@code "*."}.
*
* @param domain
* Domain name to add
* Domain name to add
*/
public void addDomain(String domain) {
var ace = toAce(requireNonNull(domain));
if (namelist.isEmpty()) {
namebuilder.addRDN(BCStyle.CN, ace);
}
namelist.add(ace);
namelist.add(toAce(requireNonNull(domain)));
}
/**
@ -217,11 +213,25 @@ public class CSRBuilder {
public void addValue(ASN1ObjectIdentifier oid, String value) {
if (requireNonNull(oid, "OID must not be null").equals(BCStyle.CN)) {
addDomain(value);
return;
if (hasCnSet) {
return;
}
hasCnSet = true;
}
namebuilder.addRDN(oid, requireNonNull(value, "attribute value must not be null"));
}
/**
* Sets the common name.
* <p>
* Note that it is at the discretion of the ACME server to accept this parameter.
*
* @since 3.2.0
*/
public void setCommonName(String cn) {
namebuilder.addRDN(BCStyle.CN, requireNonNull(cn));
}
/**
* Sets the organization.
* <p>

View File

@ -126,18 +126,11 @@ public final class CertificateUtils {
var gns = new GeneralName[1];
switch (id.getType()) {
case Identifier.TYPE_DNS:
gns[0] = new GeneralName(GeneralName.dNSName, id.getDomain());
break;
case Identifier.TYPE_IP:
gns[0] = new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress());
break;
default:
throw new IllegalArgumentException("Unsupported Identifier type " + id.getType());
}
gns[0] = switch (id.getType()) {
case Identifier.TYPE_DNS -> new GeneralName(GeneralName.dNSName, id.getDomain());
case Identifier.TYPE_IP -> new GeneralName(GeneralName.iPAddress, id.getIP().getHostAddress());
default -> throw new IllegalArgumentException("Unsupported Identifier type " + id.getType());
};
certBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(gns));
certBuilder.addExtension(ACME_VALIDATION, true, new DEROctetString(acmeValidation));
@ -268,9 +261,10 @@ public final class CertificateUtils {
var attr = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest);
if (attr.length > 0) {
var extensions = attr[0].getAttrValues().toArray();
if (extensions.length > 0 && extensions[0] instanceof Extensions) {
var san = GeneralNames.fromExtensions((Extensions) extensions[0], Extension.subjectAlternativeName);
certBuilder.addExtension(Extension.subjectAlternativeName, false, san);
if (extensions.length > 0 && extensions[0] instanceof Extensions extension0) {
var san = GeneralNames.fromExtensions(extension0, Extension.subjectAlternativeName);
var critical = csr.getSubject().getRDNs().length == 0;
certBuilder.addExtension(Extension.subjectAlternativeName, critical, san);
}
}

View File

@ -1,6 +1,22 @@
# Actalis: https://www.actalis.com/
org.shredzone.acme4j.provider.actalis.ActalisAcmeProvider
# Buypass: https://buypass.com/
org.shredzone.acme4j.provider.buypass.BuypassAcmeProvider
# Google Trust Services: https://pki.goog/
org.shredzone.acme4j.provider.google.GoogleAcmeProvider
# Let's Encrypt: https://letsencrypt.org
org.shredzone.acme4j.provider.letsencrypt.LetsEncryptAcmeProvider
# Pebble (ACME Test Server): https://github.com/letsencrypt/pebble
org.shredzone.acme4j.provider.pebble.PebbleAcmeProvider
# SSL.com: https://ssl.com
org.shredzone.acme4j.provider.sslcom.SslComAcmeProvider
# ZeroSSL: https://zerossl.com
org.shredzone.acme4j.provider.zerossl.ZeroSSLAcmeProvider

View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIIJOLbes8sTr4wDQYJKoZIhvcNAQELBQAwIDEeMBwGA1UE
AxMVbWluaWNhIHJvb3QgY2EgMjRlMmRiMCAXDTE3MTIwNjE5NDIxMFoYDzIxMTcx
MjA2MTk0MjEwWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSAyNGUyZGIwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC5WgZNoVJandj43kkLyU50vzCZ
alozvdRo3OFiKoDtmqKPNWRNO2hC9AUNxTDJco51Yc42u/WV3fPbbhSznTiOOVtn
Ajm6iq4I5nZYltGGZetGDOQWr78y2gWY+SG078MuOO2hyDIiKtVc3xiXYA+8Hluu
9F8KbqSS1h55yxZ9b87eKR+B0zu2ahzBCIHKmKWgc6N13l7aDxxY3D6uq8gtJRU0
toumyLbdzGcupVvjbjDP11nl07RESDWBLG1/g3ktJvqIa4BWgU2HMh4rND6y8OD3
Hy3H8MY6CElL+MOCbFJjWqhtOxeFyZZV9q3kYnk9CAuQJKMEGuN4GU6tzhW1AgMB
AAGjRTBDMA4GA1UdDwEB/wQEAwIChDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
BQUHAwIwEgYDVR0TAQH/BAgwBgEB/wIBADANBgkqhkiG9w0BAQsFAAOCAQEAF85v
d40HK1ouDAtWeO1PbnWfGEmC5Xa478s9ddOd9Clvp2McYzNlAFfM7kdcj6xeiNhF
WPIfaGAi/QdURSL/6C1KsVDqlFBlTs9zYfh2g0UXGvJtj1maeih7zxFLvet+fqll
xseM4P9EVJaQxwuK/F78YBt0tCNfivC6JNZMgxKF59h0FBpH70ytUSHXdz7FKwix
Mfn3qEb9BXSk0Q3prNV5sOV3vgjEtB4THfDxSz9z3+DepVnW3vbbqwEbkXdk3j82
2muVldgOUgTwK8eT+XdofVdntzU/kzygSAtAQwLJfn51fS1GvEcYGBc1bDryIqmF
p9BI7gVKtWSZYegicA==
-----END CERTIFICATE-----

View File

@ -15,15 +15,22 @@ package org.shredzone.acme4j;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatException;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.KeyPair;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.jose4j.jwx.CompactSerializer;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mockito;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
@ -105,11 +112,34 @@ public class AccountBuilderTest {
/**
* Test if a new account with Key Identifier can be created.
*/
@Test
public void testRegistrationWithKid() throws Exception {
@ParameterizedTest
@CsvSource({
// Derived from key size
"SHA-256,HS256,,",
"SHA-384,HS384,,",
"SHA-512,HS512,,",
// Enforced, but same as key size
"SHA-256,HS256,HS256,",
"SHA-384,HS384,HS384,",
"SHA-512,HS512,HS512,",
// Enforced, different from key size
"SHA-512,HS256,HS256,",
// Proposed by provider
"SHA-256,HS256,,HS256",
"SHA-512,HS256,,HS256",
"SHA-512,HS512,HS512,HS256",
})
public void testRegistrationWithKid(String keyAlg,
String expectedMacAlg,
@Nullable String macAlg,
@Nullable String providerAlg
) throws Exception {
var accountKey = TestUtils.createKeyPair();
var keyIdentifier = "NCC-1701";
var macKey = TestUtils.createSecretKey("SHA-256");
var macKey = TestUtils.createSecretKey(keyAlg);
var provider = new TestableConnectionProvider() {
@Override
@ -127,7 +157,7 @@ public class AccountBuilderTest {
var encodedPayload = binding.get("payload").asString();
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey);
JoseUtilsTest.assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, expectedMacAlg);
return HttpURLConnection.HTTP_CREATED;
}
@ -141,13 +171,22 @@ public class AccountBuilderTest {
public JSON readJsonResponse() {
return JSON.empty();
}
@Override
public Optional<String> getProposedEabMacAlgorithm() {
return Optional.ofNullable(providerAlg);
}
};
provider.putTestResource(Resource.NEW_ACCOUNT, resourceUrl);
provider.putMetadata("externalAccountRequired", true);
var builder = new AccountBuilder();
builder.useKeyPair(accountKey);
builder.withKeyIdentifier(keyIdentifier, AcmeUtils.base64UrlEncode(macKey.getEncoded()));
if (macAlg != null) {
builder.withMacAlgorithm(macAlg);
}
var session = provider.createSession();
var login = builder.createLogin(session);
@ -157,6 +196,18 @@ public class AccountBuilderTest {
provider.close();
}
/**
* Test if invalid mac algorithms are rejected.
*/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"foo", "null", "false", "none", "HS-256", "hs256", "HS128", "RS256"})
public void testRejectInvalidMacAlg(@Nullable String macAlg) {
assertThatException().isThrownBy(() -> {
new AccountBuilder().withMacAlgorithm(macAlg);
}).isInstanceOfAny(IllegalArgumentException.class, NullPointerException.class);
}
/**
* Test if an existing account is properly returned.
*/

View File

@ -96,16 +96,11 @@ public class AccountTest {
public Collection<URL> getLinks(String relation) {
return emptyList();
}
@Override
public void handleRetryAfter(String message) {
// do nothing
}
};
var login = provider.createLogin();
var account = new Account(login);
account.update();
account.fetch();
assertThat(login.getAccountLocation()).isEqualTo(locationUrl);
assertThat(account.getLocation()).isEqualTo(locationUrl);
@ -151,15 +146,10 @@ public class AccountTest {
@Override
public Collection<URL> getLinks(String relation) {
switch(relation) {
case "termsOfService": return singletonList(agreementUrl);
default: return emptyList();
}
}
@Override
public void handleRetryAfter(String message) {
// do nothing
return switch (relation) {
case "termsOfService" -> singletonList(agreementUrl);
default -> emptyList();
};
}
};
@ -213,7 +203,7 @@ public class AccountTest {
var domainName = "example.org";
var account = new Account(login);
var auth = account.preAuthorizeDomain(domainName);
var auth = account.preAuthorize(Identifier.dns(domainName));
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
@ -227,6 +217,77 @@ public class AccountTest {
provider.close();
}
/**
* Test that pre-authorization with subdomains fails if not supported.
*/
@Test
public void testPreAuthorizeDomainSubdomainsFails() throws Exception {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
var account = new Account(login);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
account.preAuthorize(Identifier.dns("example.org").allowSubdomainAuth())
);
provider.close();
}
/**
* Test that a domain can be pre-authorized, with allowed subdomains.
*/
@Test
public void testPreAuthorizeDomainSubdomains() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("newAuthorizationRequestSub").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("newAuthorizationResponseSub");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putMetadata("subdomainAuthAllowed", true);
provider.putTestResource(Resource.NEW_AUTHZ, resourceUrl);
provider.putTestChallenge(Dns01Challenge.TYPE, Dns01Challenge::new);
var domainName = "example.org";
var account = new Account(login);
var auth = account.preAuthorize(Identifier.dns(domainName).allowSubdomainAuth());
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isTrue();
assertThat(auth.getIdentifier().getDomain()).isEqualTo(domainName);
assertThat(auth.getStatus()).isEqualTo(Status.PENDING);
assertThat(auth.getExpires()).isEmpty();
assertThat(auth.getLocation()).isEqualTo(locationUrl);
assertThat(auth.isSubdomainAuthAllowed()).isTrue();
assertThat(auth.getChallenges()).containsExactlyInAnyOrder(
provider.getChallenge(Dns01Challenge.TYPE));
provider.close();
}
/**
* Test that a domain pre-authorization can fail.
*/

View File

@ -17,9 +17,14 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.Serial;
import java.net.URL;
import java.time.Instant;
import java.util.Optional;
import edu.umd.cs.findbugs.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.TestUtils;
@ -43,6 +48,7 @@ public class AcmeJsonResourceTest {
assertThat(resource.getSession()).isEqualTo(login.getSession());
assertThat(resource.getLocation()).isEqualTo(LOCATION_URL);
assertThat(resource.isValid()).isFalse();
assertThat(resource.getRetryAfter()).isEmpty();
assertUpdateInvoked(resource, 0);
assertThat(resource.getJSON()).isEqualTo(JSON_DATA);
@ -74,6 +80,25 @@ public class AcmeJsonResourceTest {
assertUpdateInvoked(resource, 0);
}
/**
* Test Retry-After
*/
@Test
public void testRetryAfter() {
var login = TestUtils.login();
var retryAfter = Instant.now().plusSeconds(30L);
var jsonData = getJSON("requestOrderResponse");
var resource = new DummyJsonResource(login, LOCATION_URL, jsonData, retryAfter);
assertThat(resource.isValid()).isTrue();
assertThat(resource.getJSON()).isEqualTo(jsonData);
assertThat(resource.getRetryAfter()).hasValue(retryAfter);
assertUpdateInvoked(resource, 0);
resource.setRetryAfter(null);
assertThat(resource.getRetryAfter()).isEmpty();
}
/**
* Test {@link AcmeJsonResource#invalidate()}.
*/
@ -116,6 +141,7 @@ public class AcmeJsonResourceTest {
* Minimum implementation of {@link AcmeJsonResource}.
*/
private static class DummyJsonResource extends AcmeJsonResource {
@Serial
private static final long serialVersionUID = -6459238185161771948L;
private int updateCount = 0;
@ -124,17 +150,19 @@ public class AcmeJsonResourceTest {
super(login, location);
}
public DummyJsonResource(Login login, URL location, JSON json) {
public DummyJsonResource(Login login, URL location, JSON json, @Nullable Instant retryAfter) {
super(login, location);
setJSON(json);
setRetryAfter(retryAfter);
}
@Override
public void update() {
// update() is tested individually in all AcmeJsonResource subclasses.
public Optional<Instant> fetch() throws AcmeException {
// fetch() is tested individually in all AcmeJsonResource subclasses.
// Here we just simulate the update, by setting a JSON.
updateCount++;
setJSON(JSON_DATA);
return Optional.empty();
}
}

View File

@ -20,6 +20,8 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.net.URI;
import java.net.URL;
import java.nio.charset.StandardCharsets;
@ -37,7 +39,7 @@ public class AcmeResourceTest {
@Test
public void testConstructor() throws Exception {
var login = TestUtils.login();
var location = new URL("http://example.com/acme/resource");
var location = URI.create("http://example.com/acme/resource").toURL();
assertThrows(NullPointerException.class, () -> new DummyResource(null, null));
@ -52,7 +54,7 @@ public class AcmeResourceTest {
@Test
public void testSerialization() throws Exception {
var login = TestUtils.login();
var location = new URL("http://example.com/acme/resource");
var location = URI.create("http://example.com/acme/resource").toURL();
// Create a Challenge for testing
var challenge = new DummyResource(login, location);
@ -99,7 +101,7 @@ public class AcmeResourceTest {
public void testRebind() {
assertThrows(IllegalStateException.class, () -> {
var login = TestUtils.login();
var location = new URL("http://example.com/acme/resource");
var location = URI.create("http://example.com/acme/resource").toURL();
var resource = new DummyResource(login, location);
assertThat(resource.getLogin()).isEqualTo(login);
@ -113,6 +115,7 @@ public class AcmeResourceTest {
* Minimum implementation of {@link AcmeResource}.
*/
private static class DummyResource extends AcmeResource {
@Serial
private static final long serialVersionUID = 7188822681353082472L;
public DummyResource(Login login, URL location) {
super(login, location);

View File

@ -25,6 +25,7 @@ import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
@ -32,9 +33,7 @@ import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -128,11 +127,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
};
var login = provider.createLogin();
@ -142,7 +136,7 @@ public class AuthorizationTest {
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
auth.update();
auth.fetch();
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
@ -174,11 +168,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() {
return getJSON("updateAuthorizationWildcardResponse");
}
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
};
var login = provider.createLogin();
@ -186,7 +175,7 @@ public class AuthorizationTest {
provider.putTestChallenge("dns-01", Dns01Challenge::new);
var auth = new Authorization(login, locationUrl);
auth.update();
auth.fetch();
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);
@ -219,11 +208,6 @@ public class AuthorizationTest {
public JSON readJsonResponse() {
return getJSON("updateAuthorizationResponse");
}
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
};
var login = provider.createLogin();
@ -270,8 +254,8 @@ public class AuthorizationTest {
}
@Override
public void handleRetryAfter(String message) throws AcmeException {
throw new AcmeRetryAfterException(message, retryAfter);
public Optional<Instant> getRetryAfter() {
return Optional.of(retryAfter);
}
};
@ -282,8 +266,8 @@ public class AuthorizationTest {
provider.putTestChallenge("tls-alpn-01", TlsAlpn01Challenge::new);
var auth = new Authorization(login, locationUrl);
var ex = assertThrows(AcmeRetryAfterException.class, auth::update);
assertThat(ex.getRetryAfter()).isEqualTo(retryAfter);
var returnedRetryAfter = auth.fetch();
assertThat(returnedRetryAfter).hasValue(retryAfter);
assertThat(auth.getIdentifier().getDomain()).isEqualTo("example.org");
assertThat(auth.getStatus()).isEqualTo(Status.VALID);

View File

@ -21,6 +21,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
@ -284,10 +285,10 @@ public class CertificateTest {
*/
@Test
public void testRenewalInfo() throws AcmeException, IOException {
var certId = "MFswCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCCD6jRWhlRB8c";
// certid-cert.pem and certId provided by draft-ietf-acme-ari-01 and known good
// certid-cert.pem and certId provided by ACME ARI specs and known good
var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId);
var certResourceUrl = URI.create(resourceUrl.toExternalForm() + "/" + certId).toURL();
var retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS);
var provider = new TestableConnectionProvider() {
@ -337,16 +338,13 @@ public class CertificateTest {
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
assertThat(cert.getCertID()).isEqualTo(certId);
assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation())
.isNotEmpty()
.contains(certResourceUrl);
.hasValue(certResourceUrl);
var renewalInfo = cert.getRenewalInfo();
assertThat(renewalInfo.getRecheckAfter())
.isNotEmpty()
.contains(retryAfterInstant);
assertThat(renewalInfo.getRetryAfter())
.isEmpty();
assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo("2021-01-03T00:00:00Z");
assertThat(renewalInfo.getSuggestedWindowEnd())
@ -355,6 +353,60 @@ public class CertificateTest {
.isNotEmpty()
.contains(url("https://example.com/docs/example-mass-reissuance-event"));
assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant);
assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant);
provider.close();
}
/**
* Test that a certificate is marked as replaced.
*/
@Test
public void testMarkedAsReplaced() throws AcmeException, IOException {
// certid-cert.pem and certId provided by ACME ARI specs and known good
var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE";
var certIdCert = TestUtils.createCertificate("/certid-cert.pem");
var certResourceUrl = URI.create(resourceUrl.toExternalForm() + "/" + certId).toURL();
var provider = new TestableConnectionProvider() {
private boolean certRequested = false;
@Override
public int sendCertificateRequest(URL url, Login login) {
assertThat(url).isEqualTo(locationUrl);
assertThat(login).isNotNull();
certRequested = true;
return HttpURLConnection.HTTP_OK;
}
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(certRequested).isTrue();
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("replacedCertificateRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_OK;
}
@Override
public List<X509Certificate> readCertificates() {
assertThat(certRequested).isTrue();
return certIdCert;
}
@Override
public Collection<URL> getLinks(String relation) {
return Collections.emptyList();
}
};
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
var cert = new Certificate(provider.createLogin(), locationUrl);
assertThat(cert.hasRenewalInfo()).isTrue();
assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl);
provider.close();
}

View File

@ -13,7 +13,7 @@
*/
package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.InetAddress;
@ -80,20 +80,20 @@ public class IdentifierTest {
@Test
public void testIp() throws UnknownHostException {
var id1 = Identifier.ip(InetAddress.getByName("192.168.1.2"));
var id1 = Identifier.ip(InetAddress.getByName("192.0.2.2"));
assertThat(id1.getType()).isEqualTo(Identifier.TYPE_IP);
assertThat(id1.getValue()).isEqualTo("192.168.1.2");
assertThat(id1.getIP().getHostAddress()).isEqualTo("192.168.1.2");
assertThat(id1.getValue()).isEqualTo("192.0.2.2");
assertThat(id1.getIP().getHostAddress()).isEqualTo("192.0.2.2");
var id2 = Identifier.ip(InetAddress.getByName("2001:db8:85a3::8a2e:370:7334"));
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_IP);
assertThat(id2.getValue()).isEqualTo("2001:db8:85a3:0:0:8a2e:370:7334");
assertThat(id2.getIP().getHostAddress()).isEqualTo("2001:db8:85a3:0:0:8a2e:370:7334");
var id3 = Identifier.ip("192.168.2.99");
var id3 = Identifier.ip("192.0.2.99");
assertThat(id3.getType()).isEqualTo(Identifier.TYPE_IP);
assertThat(id3.getValue()).isEqualTo("192.168.2.99");
assertThat(id3.getIP().getHostAddress()).isEqualTo("192.168.2.99");
assertThat(id3.getValue()).isEqualTo("192.0.2.99");
assertThat(id3.getIP().getHostAddress()).isEqualTo("192.0.2.99");
}
@Test
@ -103,12 +103,75 @@ public class IdentifierTest {
);
}
@Test
public void testAncestorDomain() {
var id1 = Identifier.dns("foo.bar.example.com");
var id1a = id1.withAncestorDomain("example.com");
assertThat(id1a).isNotSameAs(id1);
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
assertThat(id1a.getValue()).isEqualTo("foo.bar.example.com");
assertThat(id1a.getDomain()).isEqualTo("foo.bar.example.com");
assertThat(id1a.toMap()).contains(
entry("type", "dns"),
entry("value", "foo.bar.example.com"),
entry("ancestorDomain", "example.com")
);
assertThat(id1a.toString()).isEqualTo("{ancestorDomain=example.com, type=dns, value=foo.bar.example.com}");
var id2 = Identifier.dns("föö.ëxämþlë.com").withAncestorDomain("ëxämþlë.com");
assertThat(id2.getType()).isEqualTo(Identifier.TYPE_DNS);
assertThat(id2.getValue()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
assertThat(id2.getDomain()).isEqualTo("xn--f-1gaa.xn--xml-qla7ae5k.com");
assertThat(id2.toMap()).contains(
entry("type", "dns"),
entry("value", "xn--f-1gaa.xn--xml-qla7ae5k.com"),
entry("ancestorDomain", "xn--xml-qla7ae5k.com")
);
var id3 = Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com");
assertThat(id3.equals(id1)).isFalse();
assertThat(id3.equals(id1a)).isTrue();
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
Identifier.ip("192.0.2.99").withAncestorDomain("example.com")
);
assertThatNullPointerException().isThrownBy(() ->
Identifier.dns("example.org").withAncestorDomain(null)
);
}
@Test
public void testAllowSubdomainAuth() {
var id1 = Identifier.dns("example.com");
var id1a = id1.allowSubdomainAuth();
assertThat(id1a).isNotSameAs(id1);
assertThat(id1a.getType()).isEqualTo(Identifier.TYPE_DNS);
assertThat(id1a.getValue()).isEqualTo("example.com");
assertThat(id1a.getDomain()).isEqualTo("example.com");
assertThat(id1a.toMap()).contains(
entry("type", "dns"),
entry("value", "example.com"),
entry("subdomainAuthAllowed", true)
);
assertThat(id1a.toString()).isEqualTo("{subdomainAuthAllowed=true, type=dns, value=example.com}");
var id3 = Identifier.dns("example.com").allowSubdomainAuth();
assertThat(id3.equals(id1)).isFalse();
assertThat(id3.equals(id1a)).isTrue();
assertThatExceptionOfType(AcmeProtocolException.class).isThrownBy(() ->
Identifier.ip("192.0.2.99").allowSubdomainAuth()
);
}
@Test
public void testEquals() {
var idRef = new Identifier("foo", "123.456");
var id1 = new Identifier("foo", "123.456");
assertThat(idRef.equals(id1)).isTrue();
assertThat(id1.equals(idRef)).isTrue();
var id2 = new Identifier("bar", "654.321");
assertThat(idRef.equals(id2)).isFalse();

View File

@ -21,6 +21,7 @@ import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import org.junit.jupiter.api.Test;
@ -145,7 +146,7 @@ public class LoginTest {
*/
@Test
public void testBindChallenge() throws Exception {
var locationUrl = new URL("https://example.com/acme/challenge/1");
var locationUrl = URI.create("https://example.com/acme/challenge/1").toURL();
var mockChallenge = mock(Http01Challenge.class);
when(mockChallenge.getType()).thenReturn(Http01Challenge.TYPE);

View File

@ -14,12 +14,13 @@
package org.shredzone.acme4j;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.URL;
@ -83,7 +84,7 @@ public class OrderBuilderTest {
.identifier(Identifier.dns("d.example.com"))
.identifiers(Arrays.asList(
Identifier.dns("d2.example.com"),
Identifier.ip(InetAddress.getByName("192.168.1.2"))))
Identifier.ip(InetAddress.getByName("192.0.2.2"))))
.notBefore(notBefore)
.notAfter(notAfter)
.create();
@ -97,7 +98,7 @@ public class OrderBuilderTest {
Identifier.dns("m.example.org"),
Identifier.dns("d.example.com"),
Identifier.dns("d2.example.com"),
Identifier.ip(InetAddress.getByName("192.168.1.2")));
Identifier.ip(InetAddress.getByName("192.0.2.2")));
softly.assertThat(order.getNotBefore().orElseThrow())
.isEqualTo("2016-01-01T00:10:00Z");
softly.assertThat(order.getNotAfter().orElseThrow())
@ -116,6 +117,8 @@ public class OrderBuilderTest {
.isThrownBy(order::getAutoRenewalLifetimeAdjust);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::isAutoRenewalGetEnabled);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getProfile);
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
softly.assertThat(order.getAuthorizations()).isNotNull();
softly.assertThat(order.getAuthorizations()).hasSize(2);
@ -156,7 +159,9 @@ public class OrderBuilderTest {
var login = provider.createLogin();
provider.putMetadata("auto-renewal", JSON.empty());
provider.putMetadata("auto-renewal",JSON.parse(
"{\"allow-certificate-get\": true}"
).toMap());
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var account = new Account(login);
@ -186,6 +191,87 @@ public class OrderBuilderTest {
provider.close();
}
/**
* Test that a new {@link Order} with ancestor domain can be created.
*/
@Test
public void testOrderCertificateWithAncestor() throws Exception {
var notBefore = parseTimestamp("2016-01-01T00:00:00Z");
var notAfter = parseTimestamp("2016-01-08T00:00:00Z");
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("requestOrderRequestSub").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestOrderResponseSub");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
provider.putMetadata("subdomainAuthAllowed", true);
var account = new Account(login);
var order = account.newOrder()
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
.notBefore(notBefore)
.notAfter(notAfter)
.create();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getIdentifiers()).containsExactlyInAnyOrder(
Identifier.dns("foo.bar.example.com"));
softly.assertThat(order.getNotBefore().orElseThrow())
.isEqualTo("2016-01-01T00:10:00Z");
softly.assertThat(order.getNotAfter().orElseThrow())
.isEqualTo("2016-01-08T00:10:00Z");
softly.assertThat(order.getExpires().orElseThrow())
.isEqualTo("2016-01-10T00:00:00Z");
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
softly.assertThat(order.getLocation()).isEqualTo(locationUrl);
softly.assertThat(order.getAuthorizations()).isNotNull();
softly.assertThat(order.getAuthorizations()).hasSize(2);
}
provider.close();
}
/**
* Test that a new {@link Order} with ancestor domain fails if not supported.
*/
@Test
public void testOrderCertificateWithAncestorFails() throws Exception {
var provider = new TestableConnectionProvider();
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
assertThat(login.getSession().getMetadata().isSubdomainAuthAllowed()).isFalse();
var account = new Account(login);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() ->
account.newOrder()
.identifier(Identifier.dns("foo.bar.example.com").withAncestorDomain("example.com"))
.create()
);
provider.close();
}
/**
* Test that an auto-renewal {@link Order} cannot be created if unsupported by the CA.
*/
@ -252,4 +338,164 @@ public class OrderBuilderTest {
provider.close();
}
/**
* Test that a new profile {@link Order} can be created.
*/
@Test
public void testProfileOrderCertificate() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("requestProfileOrderRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestProfileOrderResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putMetadata("profiles",JSON.parse(
"{\"classic\": \"The same profile you're accustomed to\"}"
).toMap());
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var account = new Account(login);
var order = account.newOrder()
.domain("example.org")
.profile("classic")
.create();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getProfile()).isEqualTo("classic");
}
provider.close();
}
/**
* Test that a profile {@link Order} cannot be created if the profile is unsupported
* by the CA.
*/
@Test
public void testUnsupportedProfileOrderCertificateFails() throws Exception {
var provider = new TestableConnectionProvider();
provider.putMetadata("profiles",JSON.parse(
"{\"classic\": \"The same profile you're accustomed to\"}"
).toMap());
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var login = provider.createLogin();
var account = new Account(login);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
account.newOrder()
.domain("example.org")
.profile("invalid")
.create();
}).withMessage("Server does not support profile: invalid");
provider.close();
}
/**
* Test that a profile {@link Order} cannot be created if the feature is unsupported
* by the CA.
*/
@Test
public void testProfileOrderCertificateFails() throws IOException {
var provider = new TestableConnectionProvider();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var login = provider.createLogin();
var account = new Account(login);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
account.newOrder()
.domain("example.org")
.profile("classic")
.create();
}).withMessage("Server does not support profile");
provider.close();
}
/**
* Test that the ARI replaces field is set.
*/
@Test
public void testARIReplaces() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
assertThat(url).isEqualTo(resourceUrl);
assertThatJson(claims.toString()).isEqualTo(getJSON("requestReplacesRequest").toString());
assertThat(login).isNotNull();
return HttpURLConnection.HTTP_CREATED;
}
@Override
public JSON readJsonResponse() {
return getJSON("requestReplacesResponse");
}
@Override
public URL getLocation() {
return locationUrl;
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl);
var account = new Account(login);
account.newOrder()
.domain("example.org")
.replaces("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE")
.create();
provider.close();
}
/**
* Test that exception is thrown if the ARI replaces field is set but ARI is not
* supported.
*/
@Test
public void testARIReplaceFails() throws Exception {
var provider = new TestableConnectionProvider() {
@Override
public int sendSignedRequest(URL url, JSONBuilder claims, Login login) {
fail("Request was sent");
return HttpURLConnection.HTTP_FORBIDDEN;
}
};
var login = provider.createLogin();
provider.putTestResource(Resource.NEW_ORDER, resourceUrl);
var account = new Account(login);
assertThatExceptionOfType(AcmeNotSupportedException.class).isThrownBy(() -> {
account.newOrder()
.domain("example.org")
.replaces("aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE")
.create();
})
.withMessage("Server does not support renewal-information");
provider.close();
}
}

View File

@ -56,17 +56,12 @@ public class OrderTest {
public JSON readJsonResponse() {
return getJSON("updateOrderResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
};
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.update();
order.fetch();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.getStatus()).isEqualTo(Status.PENDING);
@ -95,6 +90,8 @@ public class OrderTest {
.isThrownBy(order::getAutoRenewalLifetimeAdjust);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::isAutoRenewalGetEnabled);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(order::getProfile);
softly.assertThat(order.getError()).isNotEmpty();
softly.assertThat(order.getError().orElseThrow().getType())
@ -133,11 +130,6 @@ public class OrderTest {
public JSON readJsonResponse() {
return getJSON("updateOrderResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
};
var login = provider.createLogin();
@ -192,11 +184,6 @@ public class OrderTest {
public JSON readJsonResponse() {
return getJSON(isFinalized ? "finalizeResponse" : "updateOrderResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
};
var login = provider.createLogin();
@ -216,10 +203,9 @@ public class OrderTest {
.isEqualTo("2016-01-01T00:00:00Z");
softly.assertThat(order.getNotAfter().orElseThrow())
.isEqualTo("2016-01-08T00:00:00Z");
softly.assertThat(order.isAutoRenewalCertificate()).isFalse();
softly.assertThat(order.getCertificate().getLocation())
.isEqualTo(url("https://example.com/acme/cert/1234"));
softly.assertThatIllegalStateException()
.isThrownBy(order::getAutoRenewalCertificate);
softly.assertThat(order.getFinalizeLocation()).isEqualTo(finalizeUrl);
var auths = order.getAuthorizations();
@ -250,11 +236,6 @@ public class OrderTest {
public JSON readJsonResponse() {
return getJSON("updateAutoRenewOrderResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
};
provider.putMetadata("auto-renewal", JSON.empty());
@ -262,7 +243,7 @@ public class OrderTest {
var login = provider.createLogin();
var order = new Order(login, locationUrl);
order.update();
order.fetch();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(order.isAutoRenewing()).isTrue();
@ -298,20 +279,14 @@ public class OrderTest {
public JSON readJsonResponse() {
return getJSON("finalizeAutoRenewResponse");
}
@Override
public void handleRetryAfter(String message) {
assertThat(message).isNotNull();
}
};
var login = provider.createLogin();
var order = login.bindOrder(locationUrl);
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThatIllegalStateException()
.isThrownBy(order::getCertificate);
softly.assertThat(order.getAutoRenewalCertificate().getLocation())
softly.assertThat(order.isAutoRenewalCertificate()).isTrue();
softly.assertThat(order.getCertificate().getLocation())
.isEqualTo(url("https://example.com/acme/cert/1234"));
softly.assertThat(order.isAutoRenewing()).isTrue();
softly.assertThat(order.getAutoRenewalStartDate().orElseThrow())

View File

@ -68,14 +68,12 @@ public class RenewalInfoTest {
var login = provider.createLogin();
var renewalInfo = new RenewalInfo(login, locationUrl);
renewalInfo.update();
var recheckAfter = renewalInfo.fetch();
assertThat(recheckAfter).hasValue(retryAfterInstant);
// Check getters
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(renewalInfo.getLocation()).isEqualTo(locationUrl);
softly.assertThat(renewalInfo.getRecheckAfter())
.isNotEmpty()
.contains(retryAfterInstant);
softly.assertThat(renewalInfo.getSuggestedWindowStart())
.isEqualTo(startWindow);
softly.assertThat(renewalInfo.getSuggestedWindowEnd())

View File

@ -21,7 +21,6 @@ import static org.shredzone.acme4j.toolbox.TestUtils.*;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Locale;
@ -150,15 +149,15 @@ public class SessionTest {
assertThat(session.hasDirectory()).isFalse();
assertThat(session.resourceUrl(Resource.NEW_ACCOUNT))
.isEqualTo(new URL("https://example.com/acme/new-account"));
.isEqualTo(URI.create("https://example.com/acme/new-account").toURL());
// There is a local copy of the directory now
assertThat(session.hasDirectory()).isTrue();
assertThat(session.resourceUrl(Resource.NEW_AUTHZ))
.isEqualTo(new URL("https://example.com/acme/new-authz"));
.isEqualTo(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrl(Resource.NEW_ORDER))
.isEqualTo(new URL("https://example.com/acme/new-order"));
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(() -> session.resourceUrl(Resource.REVOKE_CERT))
@ -166,7 +165,7 @@ public class SessionTest {
assertThat(session.resourceUrlOptional(Resource.NEW_AUTHZ))
.isNotEmpty()
.contains(new URL("https://example.com/acme/new-authz"));
.contains(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrlOptional(Resource.REVOKE_CERT))
.isEmpty();
@ -183,7 +182,16 @@ public class SessionTest {
softly.assertThat(meta.getAutoRenewalMaxDuration()).isEqualTo(Duration.ofDays(365));
softly.assertThat(meta.getAutoRenewalMinLifetime()).isEqualTo(Duration.ofHours(24));
softly.assertThat(meta.isAutoRenewalGetAllowed()).isTrue();
softly.assertThat(meta.isProfileAllowed()).isTrue();
softly.assertThat(meta.isProfileAllowed("classic")).isTrue();
softly.assertThat(meta.isProfileAllowed("custom")).isTrue();
softly.assertThat(meta.isProfileAllowed("invalid")).isFalse();
softly.assertThat(meta.getProfileDescription("classic")).contains("The profile you're accustomed to");
softly.assertThat(meta.getProfileDescription("custom")).contains("Some other profile");
softly.assertThat(meta.getProfiles()).contains("classic", "custom");
softly.assertThat(meta.getProfileDescription("invalid")).isEmpty();
softly.assertThat(meta.isExternalAccountRequired()).isTrue();
softly.assertThat(meta.isSubdomainAuthAllowed()).isTrue();
softly.assertThat(meta.getJSON()).isNotNull();
}
@ -214,11 +222,11 @@ public class SessionTest {
};
assertThat(session.resourceUrl(Resource.NEW_ACCOUNT))
.isEqualTo(new URL("https://example.com/acme/new-account"));
.isEqualTo(URI.create("https://example.com/acme/new-account").toURL());
assertThat(session.resourceUrl(Resource.NEW_AUTHZ))
.isEqualTo(new URL("https://example.com/acme/new-authz"));
.isEqualTo(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(session.resourceUrl(Resource.NEW_ORDER))
.isEqualTo(new URL("https://example.com/acme/new-order"));
.isEqualTo(URI.create("https://example.com/acme/new-order").toURL());
var meta = session.getMetadata();
try (var softly = new AutoCloseableSoftAssertions()) {
@ -227,12 +235,17 @@ public class SessionTest {
softly.assertThat(meta.getWebsite()).isEmpty();
softly.assertThat(meta.getCaaIdentities()).isEmpty();
softly.assertThat(meta.isAutoRenewalEnabled()).isFalse();
softly.assertThat(meta.isSubdomainAuthAllowed()).isFalse();
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(meta::getAutoRenewalMaxDuration);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(meta::getAutoRenewalMinLifetime);
softly.assertThatExceptionOfType(AcmeNotSupportedException.class)
.isThrownBy(meta::isAutoRenewalGetAllowed);
softly.assertThat(meta.isProfileAllowed()).isFalse();
softly.assertThat(meta.isProfileAllowed("classic")).isFalse();
softly.assertThat(meta.getProfileDescription("classic")).isEmpty();
softly.assertThat(meta.getProfiles()).isEmpty();
}
}

View File

@ -15,6 +15,8 @@ package org.shredzone.acme4j;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Locale;
import org.junit.jupiter.api.Test;
/**
@ -27,8 +29,11 @@ public class StatusTest {
*/
@Test
public void testParse() {
// Would break toUpperCase() if English locale is not set, see #156.
Locale.setDefault(new Locale("tr"));
for (var s : Status.values()) {
var parsed = Status.parse(s.name().toLowerCase());
var parsed = Status.parse(s.name().toLowerCase(Locale.ENGLISH));
assertThat(parsed).isEqualTo(s);
}

View File

@ -26,14 +26,13 @@ import java.net.URL;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
@ -89,7 +88,7 @@ public class ChallengeTest {
@Test
public void testNotAcceptable() {
assertThrows(AcmeProtocolException.class, () ->
new Http01Challenge(TestUtils.login(), getJSON("dnsChallenge"))
new Http01Challenge(TestUtils.login(), getJSON("dns01Challenge"))
);
}
@ -141,18 +140,13 @@ public class ChallengeTest {
public JSON readJsonResponse() {
return getJSON("updateHttpChallengeResponse");
}
@Override
public void handleRetryAfter(String message) {
// Just do nothing
}
};
var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
challenge.update();
challenge.fetch();
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
assertThat(challenge.getLocation()).isEqualTo(locationUrl);
@ -179,17 +173,17 @@ public class ChallengeTest {
return getJSON("updateHttpChallengeResponse");
}
@Override
public void handleRetryAfter(String message) throws AcmeException {
throw new AcmeRetryAfterException(message, retryAfter);
public Optional<Instant> getRetryAfter() {
return Optional.of(retryAfter);
}
};
var login = provider.createLogin();
var challenge = new Http01Challenge(login, getJSON("triggerHttpChallengeResponse"));
assertThrows(AcmeRetryAfterException.class, challenge::update);
var returnedRetryAfter = challenge.fetch();
assertThat(returnedRetryAfter).hasValue(retryAfter);
assertThat(challenge.getStatus()).isEqualTo(Status.VALID);
assertThat(challenge.getLocation()).isEqualTo(locationUrl);

View File

@ -29,7 +29,7 @@ import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Dns01Challenge}.
*/
public class DnsChallengeTest {
public class Dns01ChallengeTest {
private final Login login = TestUtils.login();
@ -38,29 +38,22 @@ public class DnsChallengeTest {
*/
@Test
public void testDnsChallenge() {
var challenge = new Dns01Challenge(login, getJSON("dnsChallenge"));
var challenge = new Dns01Challenge(login, getJSON("dns01Challenge"));
assertThat(challenge.getType()).isEqualTo(Dns01Challenge.TYPE);
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getDigest()).isEqualTo("rzMmotrIgsithyBYc0vgiLUEEKYx0WetQRgEF2JIozA");
assertThat(challenge.getAuthorization()).isEqualTo("pNvmJivs0WCko2suV7fhe-59oFqyYx_yB7tx6kIMAyE.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0");
assertThat(challenge.getRRName("www.example.org")).isEqualTo("_acme-challenge.www.example.org.");
assertThat(challenge.getRRName(Identifier.dns("www.example.org"))).isEqualTo("_acme-challenge.www.example.org.");
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10")));
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
@Test
public void testToRRName() {
assertThat(Dns01Challenge.toRRName("www.example.org"))
.isEqualTo("_acme-challenge.www.example.org.");
assertThat(Dns01Challenge.toRRName(Identifier.dns("www.example.org")))
.isEqualTo("_acme-challenge.www.example.org.");
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> Dns01Challenge.toRRName(Identifier.ip("127.0.0.10")));
assertThat(Dns01Challenge.RECORD_NAME_PREFIX)
.isEqualTo("_acme-challenge");
}
}

View File

@ -0,0 +1,61 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2025 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.challenge;
import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.shredzone.acme4j.toolbox.TestUtils.getJSON;
import org.junit.jupiter.api.Test;
import org.shredzone.acme4j.Identifier;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link DnsAccount01Challenge}.
*/
class DnsAccount01ChallengeTest {
private final Login login = TestUtils.login();
/**
* Test that {@link DnsAccount01Challenge} generates a correct authorization key.
*/
@Test
public void testDnsChallenge() {
var challenge = new DnsAccount01Challenge(login, getJSON("dnsAccount01Challenge"));
assertThat(challenge.getType()).isEqualTo(DnsAccount01Challenge.TYPE);
assertThat(challenge.getStatus()).isEqualTo(Status.PENDING);
assertThat(challenge.getDigest()).isEqualTo("MSB8ZUQOmbNfHors7PG580PBz4f9hDuOPDN_j1bNcXI");
assertThat(challenge.getAuthorization()).isEqualTo("ODE4OWY4NTktYjhmYS00YmY1LTk5MDgtZTFjYTZmNjZlYTUx.HnWjTDnyqlCrm6tZ-6wX-TrEXgRdeNu9G71gqxSO6o0");
assertThat(challenge.getRRName("www.example.org"))
.isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org.");
assertThat(challenge.getRRName(Identifier.dns("www.example.org")))
.isEqualTo("_agozs7u2dml4wbyd._acme-challenge.www.example.org.");
assertThatExceptionOfType(AcmeProtocolException.class)
.isThrownBy(() -> challenge.getRRName(Identifier.ip("127.0.0.10")));
var response = new JSONBuilder();
challenge.prepareResponse(response);
assertThatJson(response.toString()).isEqualTo("{}");
}
}

View File

@ -28,7 +28,7 @@ import org.shredzone.acme4j.toolbox.TestUtils;
/**
* Unit tests for {@link Http01Challenge}.
*/
public class HttpChallengeTest {
public class Http01ChallengeTest {
private static final String TOKEN =
"rSoI9JpyvFi-ltdnBW0W1DjKstzG7cHixjzcOjwzAEQ";
private static final String KEY_AUTHORIZATION =

View File

@ -52,7 +52,6 @@ import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitedException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
@ -89,9 +88,9 @@ public class DefaultConnectionTest {
@BeforeEach
public void setup(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
baseUrl = wmRuntimeInfo.getHttpBaseUrl();
directoryUrl = new URL(baseUrl + DIRECTORY_PATH);
newNonceUrl = new URL(baseUrl + NEW_NONCE_PATH);
requestUrl = new URL(baseUrl + REQUEST_PATH);
directoryUrl = URI.create(baseUrl + DIRECTORY_PATH).toURL();
newNonceUrl = URI.create(baseUrl + NEW_NONCE_PATH).toURL();
requestUrl = URI.create(baseUrl + REQUEST_PATH).toURL();
session = new Session(directoryUrl.toURI());
session.setLocale(Locale.JAPAN);
@ -143,6 +142,53 @@ public class DefaultConnectionTest {
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));
}
/**
* Test that {@link DefaultConnection#getNonce()} handles fails correctly.
*/
@Test
public void testGetNonceFromHeaderFailed() throws AcmeException {
var retryAfter = Instant.now().plusSeconds(30L).truncatedTo(SECONDS);
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_UNAVAILABLE)
.withHeader("Content-Type", "application/problem+json")
// do not send a body here because it is a HEAD request!
));
assertThat(session.getNonce()).isNull();
assertThatExceptionOfType(AcmeException.class).isThrownBy(() -> {
try (var conn = session.connect()) {
conn.resetNonce(session);
}
});
verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH)));
}
/**
* Test that {@link DefaultConnection#getNonce()} handles a general HTTP error
* correctly.
*/
@Test
public void testGetNonceFromHeaderHttpError() {
stubFor(head(urlEqualTo(NEW_NONCE_PATH)).willReturn(aResponse()
.withStatus(HttpURLConnection.HTTP_INTERNAL_ERROR)
// do not send a body here because it is a HEAD request!
));
assertThat(session.getNonce()).isNull();
var ex = assertThrows(AcmeException.class, () -> {
try (var conn = session.connect()) {
conn.resetNonce(session);
}
});
assertThat(ex.getMessage()).isEqualTo("Server responded with HTTP 500 while trying to retrieve a nonce");
verify(headRequestedFor(urlEqualTo(NEW_NONCE_PATH)));
}
/**
* Test that {@link DefaultConnection#getNonce()} fails on an invalid
* {@code Replay-Nonce} header.
@ -216,7 +262,7 @@ public class DefaultConnectionTest {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var location = conn.getLocation();
assertThat(location).isEqualTo(new URL("https://example.com/otherlocation"));
assertThat(location).isEqualTo(URI.create("https://example.com/otherlocation").toURL());
}
}
@ -232,7 +278,7 @@ public class DefaultConnectionTest {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
var location = conn.getLocation();
assertThat(location).isEqualTo(new URL(baseUrl + "/otherlocation"));
assertThat(location).isEqualTo(URI.create(baseUrl + "/otherlocation").toURL());
}
}
@ -249,9 +295,9 @@ public class DefaultConnectionTest {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getLinks("next")).containsExactly(new URL("https://example.com/acme/new-authz"));
assertThat(conn.getLinks("recover")).containsExactly(new URL(baseUrl + "/recover-acct"));
assertThat(conn.getLinks("terms-of-service")).containsExactly(new URL("https://example.com/acme/terms"));
assertThat(conn.getLinks("next")).containsExactly(URI.create("https://example.com/acme/new-authz").toURL());
assertThat(conn.getLinks("recover")).containsExactly(URI.create(baseUrl + "/recover-acct").toURL());
assertThat(conn.getLinks("terms-of-service")).containsExactly(URI.create("https://example.com/acme/terms").toURL());
assertThat(conn.getLinks("secret-stuff")).isEmpty();
}
}
@ -310,30 +356,24 @@ public class DefaultConnectionTest {
* Test if Retry-After header with absolute date is correctly parsed.
*/
@Test
public void testHandleRetryAfterHeaderDate() {
public void testHandleRetryAfterHeaderDate() throws AcmeException {
var retryDate = Instant.now().plus(Duration.ofHours(10)).truncatedTo(SECONDS);
var retryMsg = "absolute date";
stubFor(get(urlEqualTo(REQUEST_PATH)).willReturn(ok()
.withHeader("Retry-After", DATE_FORMATTER.format(retryDate))
));
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter(retryMsg);
}
});
assertThat(ex.getRetryAfter()).isEqualTo(retryDate);
assertThat(ex.getMessage()).isEqualTo(retryMsg);
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getRetryAfter()).hasValue(retryDate);
}
}
/**
* Test if Retry-After header with relative timespan is correctly parsed.
*/
@Test
public void testHandleRetryAfterHeaderDelta() {
public void testHandleRetryAfterHeaderDelta() throws AcmeException {
var delta = 10 * 60 * 60;
var now = Instant.now().truncatedTo(SECONDS);
var retryMsg = "relative time";
@ -343,15 +383,10 @@ public class DefaultConnectionTest {
.withHeader("Date", DATE_FORMATTER.format(now))
));
var ex = assertThrows(AcmeRetryAfterException.class, () -> {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter(retryMsg);
}
});
assertThat(ex.getRetryAfter()).isEqualTo(now.plusSeconds(delta));
assertThat(ex.getMessage()).isEqualTo(retryMsg);
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
assertThat(conn.getRetryAfter()).hasValue(now.plusSeconds(delta));
}
}
/**
@ -365,7 +400,7 @@ public class DefaultConnectionTest {
try (var conn = session.connect()) {
conn.sendRequest(requestUrl, session, null);
conn.handleRetryAfter("no header");
assertThat(conn.getRetryAfter()).isEmpty();
}
verify(getRequestedFor(urlEqualTo(REQUEST_PATH)));

View File

@ -80,11 +80,6 @@ public class DummyConnection implements Connection {
throw new UnsupportedOperationException();
}
@Override
public void handleRetryAfter(String message) throws AcmeException {
throw new UnsupportedOperationException();
}
@Override
public Optional<String> getNonce() {
throw new UnsupportedOperationException();

View File

@ -17,7 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.net.Authenticator;
import java.net.URL;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
@ -71,7 +71,7 @@ public class HttpConnectorTest {
*/
@Test
public void testRequestBuilderDefaultValues() throws Exception {
var url = new URL("http://example.org:123/foo");
var url = URI.create("http://example.org:123/foo").toURL();
var settings = new NetworkSettings();
var connector = new HttpConnector(settings);

View File

@ -36,7 +36,7 @@ public class NetworkSettingsTest {
public void testGettersAndSetters() {
var settings = new NetworkSettings();
var proxyAddress = new InetSocketAddress("10.0.0.1", 8080);
var proxyAddress = new InetSocketAddress("198.51.100.1", 8080);
var proxySelector = ProxySelector.of(proxyAddress);
assertThat(settings.getProxySelector()).isSameAs(HttpClient.Builder.NO_PROXY);
@ -45,7 +45,7 @@ public class NetworkSettingsTest {
settings.setProxySelector(null);
assertThat(settings.getProxySelector()).isEqualTo(HttpClient.Builder.NO_PROXY);
assertThat(settings.getTimeout()).isEqualTo(Duration.ofSeconds(10));
assertThat(settings.getTimeout()).isEqualTo(Duration.ofSeconds(30));
settings.setTimeout(Duration.ofMillis(5120));
assertThat(settings.getTimeout()).isEqualTo(Duration.ofMillis(5120));

View File

@ -16,6 +16,7 @@ package org.shredzone.acme4j.exception;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.io.Serial;
import java.net.URL;
import org.junit.jupiter.api.Test;
@ -47,6 +48,7 @@ public class AcmeLazyLoadingExceptionTest {
}
private static class TestResource extends AcmeResource {
@Serial
private static final long serialVersionUID = 1023419539450677538L;
public TestResource(Login login, URL location) {

View File

@ -1,53 +0,0 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2016 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.exception;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link AcmeRetryAfterException}.
*/
public class AcmeRetryAfterExceptionTest {
/**
* Test that parameters are correctly returned.
*/
@Test
public void testAcmeRetryAfterException() {
var detail = "Too early";
var retryAfter = Instant.now().plus(Duration.ofMinutes(1));
var ex = new AcmeRetryAfterException(detail, retryAfter);
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getRetryAfter()).isEqualTo(retryAfter);
}
/**
* Test that date is required.
*/
@Test
public void testRequiredAcmeRetryAfterException() {
assertThrows(NullPointerException.class, () -> {
throw new AcmeRetryAfterException("null-test", null);
});
}
}

View File

@ -18,7 +18,6 @@ import static org.shredzone.acme4j.toolbox.TestUtils.createProblem;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import org.junit.jupiter.api.Test;
@ -35,7 +34,7 @@ public class AcmeUserActionRequiredExceptionTest {
var type = URI.create("urn:ietf:params:acme:error:userActionRequired");
var detail = "Accept new TOS";
var tosUri = URI.create("http://example.com/agreement.pdf");
var instanceUrl = new URL("http://example.com/howToAgree.html");
var instanceUrl = URI.create("http://example.com/howToAgree.html").toURL();
var problem = createProblem(type, detail, instanceUrl);
@ -45,6 +44,7 @@ public class AcmeUserActionRequiredExceptionTest {
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getTermsOfServiceUri().orElseThrow()).isEqualTo(tosUri);
assertThat(ex.getInstance()).isEqualTo(instanceUrl);
assertThat(ex.toString()).isEqualTo("Please visit " + instanceUrl + " - details: " + detail);
}
/**
@ -54,7 +54,7 @@ public class AcmeUserActionRequiredExceptionTest {
public void testNullAcmeUserActionRequiredException() throws MalformedURLException {
var type = URI.create("urn:ietf:params:acme:error:userActionRequired");
var detail = "Call our service";
var instanceUrl = new URL("http://example.com/howToContactUs.html");
var instanceUrl = URI.create("http://example.com/howToContactUs.html").toURL();
var problem = createProblem(type, detail, instanceUrl);
@ -64,6 +64,7 @@ public class AcmeUserActionRequiredExceptionTest {
assertThat(ex.getMessage()).isEqualTo(detail);
assertThat(ex.getTermsOfServiceUri()).isEmpty();
assertThat(ex.getInstance()).isEqualTo(instanceUrl);
assertThat(ex.toString()).isEqualTo("Please visit " + instanceUrl + " - details: " + detail);
}
}

View File

@ -32,6 +32,7 @@ import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.DnsAccount01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsAlpn01Challenge;
import org.shredzone.acme4j.challenge.TokenChallenge;
@ -243,13 +244,17 @@ public class AbstractAcmeProviderTest {
var c2 = provider.createChallenge(login, getJSON("httpChallenge"));
assertThat(c2).isNotSameAs(c1);
var c3 = provider.createChallenge(login, getJSON("dnsChallenge"));
var c3 = provider.createChallenge(login, getJSON("dns01Challenge"));
assertThat(c3).isNotNull();
assertThat(c3).isInstanceOf(Dns01Challenge.class);
var c4 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
var c4 = provider.createChallenge(login, getJSON("dnsAccount01Challenge"));
assertThat(c4).isNotNull();
assertThat(c4).isInstanceOf(TlsAlpn01Challenge.class);
assertThat(c4).isInstanceOf(DnsAccount01Challenge.class);
var c5 = provider.createChallenge(login, getJSON("tlsAlpnChallenge"));
assertThat(c5).isNotNull();
assertThat(c5).isInstanceOf(TlsAlpn01Challenge.class);
var json6 = new JSONBuilder()
.put("type", "foobar-01")

View File

@ -44,7 +44,7 @@ public class GenericAcmeProviderTest {
*/
@Test
public void testResolve() throws URISyntaxException {
var serverUri = new URI("http://example.com/acme");
var serverUri = new URI("http://example.com/acme?foo=abc&bar=123");
var provider = new GenericAcmeProvider();

View File

@ -16,8 +16,10 @@ package org.shredzone.acme4j.provider;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.BiFunction;
import org.shredzone.acme4j.Login;
@ -106,7 +108,12 @@ public class TestableConnectionProvider extends DummyConnection implements AcmeP
*/
public Login createLogin() throws IOException {
var session = createSession();
return session.login(new URL(TestUtils.ACCOUNT_URL), TestUtils.createKeyPair());
return session.login(URI.create(TestUtils.ACCOUNT_URL).toURL(), TestUtils.createKeyPair());
}
@Override
public Optional<Instant> getRetryAfter() {
return Optional.empty();
}
@Override

View File

@ -0,0 +1,59 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2025 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.actalis;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.net.URISyntaxException;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
public class ActalisAcmeProviderTest {
private static final String PRODUCTION_DIRECTORY_URL = "https://acme-api.actalis.com/acme/directory";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new ActalisAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://actalis.com"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://actalis.com/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
}
}
/**
* Test if acme URIs are properly resolved.
*/
@Test
public void testResolve() throws URISyntaxException {
var provider = new ActalisAcmeProvider();
assertThat(provider.resolve(new URI("acme://actalis.com"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://actalis.com/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://letsencrypt.org/v99")));
}
}

View File

@ -0,0 +1,65 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.buypass;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.net.URISyntaxException;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link BuypassAcmeProvider}.
*/
public class BuypassAcmeProviderTest {
private static final String PRODUCTION_DIRECTORY_URL = "https://api.buypass.com/acme/directory";
private static final String STAGING_DIRECTORY_URL = "https://api.test4.buypass.no/acme/directory";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new BuypassAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://buypass.com"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://buypass.com/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://buypass.com/staging"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
}
}
/**
* Test if acme URIs are properly resolved.
*/
@Test
public void testResolve() throws URISyntaxException {
var provider = new BuypassAcmeProvider();
assertThat(provider.resolve(new URI("acme://buypass.com"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://buypass.com/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://buypass.com/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL));
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://buypass.com/v99")));
}
}

View File

@ -0,0 +1,75 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.google;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.net.URISyntaxException;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link GoogleAcmeProvider}.
*/
public class GoogleAcmeProviderTest {
private static final String PRODUCTION_DIRECTORY_URL = "https://dv.acme-v02.api.pki.goog/directory";
private static final String STAGING_DIRECTORY_URL = "https://dv.acme-v02.test-api.pki.goog/directory";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new GoogleAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://pki.goog"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pki.goog/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://pki.goog/staging"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
}
}
/**
* Test if acme URIs are properly resolved.
*/
@Test
public void testResolve() throws URISyntaxException {
var provider = new GoogleAcmeProvider();
assertThat(provider.resolve(new URI("acme://pki.goog"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://pki.goog/"))).isEqualTo(url(PRODUCTION_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://pki.goog/staging"))).isEqualTo(url(STAGING_DIRECTORY_URL));
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://pki.goog/v99")));
}
/**
* Test if correct MAC algorithm is proposed.
*/
@Test
public void testMacAlgorithm() {
var provider = new GoogleAcmeProvider();
assertThat(provider.getProposedEabMacAlgorithm()).isNotEmpty().contains("HS256");
}
}

View File

@ -0,0 +1,75 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.sslcom;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.net.URISyntaxException;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link SslComAcmeProviderTest}.
*/
public class SslComAcmeProviderTest {
private static final String PRODUCTION_ECC_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-ecc";
private static final String PRODUCTION_RSA_DIRECTORY_URL = "https://acme.ssl.com/sslcom-dv-rsa";
private static final String STAGING_ECC_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-ecc";
private static final String STAGING_RSA_DIRECTORY_URL = "https://acme-try.ssl.com/sslcom-dv-rsa";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new SslComAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://ssl.com"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/ecc"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/rsa"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging/ecc"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://ssl.com/staging/rsa"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
}
}
/**
* Test if acme URIs are properly resolved.
*/
@Test
public void testResolve() throws URISyntaxException {
var provider = new SslComAcmeProvider();
assertThat(provider.resolve(new URI("acme://ssl.com"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/ecc"))).isEqualTo(url(PRODUCTION_ECC_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/rsa"))).isEqualTo(url(PRODUCTION_RSA_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/staging"))).isEqualTo(url(STAGING_ECC_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/staging/ecc"))).isEqualTo(url(STAGING_ECC_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://ssl.com/staging/rsa"))).isEqualTo(url(STAGING_RSA_DIRECTORY_URL));
assertThatIllegalArgumentException().isThrownBy(() -> provider.resolve(new URI("acme://ssl.com/v99")));
}
}

View File

@ -0,0 +1,62 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2024 Richard "Shred" Körber
* http://acme4j.shredzone.org
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*/
package org.shredzone.acme4j.provider.zerossl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.shredzone.acme4j.toolbox.TestUtils.url;
import java.net.URI;
import java.net.URISyntaxException;
import org.assertj.core.api.AutoCloseableSoftAssertions;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link ZeroSSLAcmeProvider}.
*/
public class ZeroSSLAcmeProviderTest {
private static final String V02_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90";
/**
* Tests if the provider accepts the correct URIs.
*/
@Test
public void testAccepts() throws URISyntaxException {
var provider = new ZeroSSLAcmeProvider();
try (var softly = new AutoCloseableSoftAssertions()) {
softly.assertThat(provider.accepts(new URI("acme://zerossl.com"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://zerossl.com/"))).isTrue();
softly.assertThat(provider.accepts(new URI("acme://example.com"))).isFalse();
softly.assertThat(provider.accepts(new URI("http://example.com/acme"))).isFalse();
softly.assertThat(provider.accepts(new URI("https://example.com/acme"))).isFalse();
}
}
/**
* Test if acme URIs are properly resolved.
*/
@Test
public void testResolve() throws URISyntaxException {
var provider = new ZeroSSLAcmeProvider();
assertThat(provider.resolve(new URI("acme://zerossl.com"))).isEqualTo(url(V02_DIRECTORY_URL));
assertThat(provider.resolve(new URI("acme://zerossl.com/"))).isEqualTo(url(V02_DIRECTORY_URL));
assertThrows(IllegalArgumentException.class, () -> provider.resolve(new URI("acme://zerossl.com/v99")));
}
}

View File

@ -13,6 +13,7 @@
*/
package org.shredzone.acme4j.toolbox;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.within;
import static org.junit.jupiter.api.Assertions.assertThrows;
@ -35,6 +36,7 @@ import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
@ -88,6 +90,23 @@ public class AcmeUtilsTest {
assertThat(base64UrlDecode).isEqualTo(sha256hash("foobar"));
}
/**
* Test base32 encode.
*/
@ParameterizedTest
@CsvSource({ // Test vectors according to RFC 4648 section 10
"'',''",
"f,MY======",
"fo,MZXQ====",
"foo,MZXW6===",
"foob,MZXW6YQ=",
"fooba,MZXW6YTB",
"foobar,MZXW6YTBOI======",
})
public void testBase32Encode(String unencoded, String encoded) {
assertThat(base32Encode(unencoded.getBytes(UTF_8))).isEqualTo(encoded);
}
/**
* Test base64 URL validation for valid values
*/

View File

@ -30,6 +30,8 @@ import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwx.CompactSerializer;
import org.jose4j.lang.JoseException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
/**
* Unit tests for {@link JoseUtils}.
@ -159,22 +161,23 @@ public class JoseUtilsTest {
/**
* Test if an external account binding is correctly created.
*/
@Test
public void testCreateExternalAccountBinding() throws Exception {
@ParameterizedTest
@CsvSource({"SHA-256,HS256", "SHA-384,HS384", "SHA-512,HS512", "SHA-512,HS256"})
public void testCreateExternalAccountBinding(String keyAlg, String macAlg) throws Exception {
var accountKey = TestUtils.createKeyPair();
var keyIdentifier = "NCC-1701";
var macKey = TestUtils.createSecretKey("SHA-256");
var macKey = TestUtils.createSecretKey(keyAlg);
var resourceUrl = url("http://example.com/acme/resource");
var binding = JoseUtils.createExternalAccountBinding(
keyIdentifier, accountKey.getPublic(), macKey, resourceUrl);
keyIdentifier, accountKey.getPublic(), macKey, macAlg, resourceUrl);
var encodedHeader = binding.get("protected").toString();
var encodedSignature = binding.get("signature").toString();
var encodedPayload = binding.get("payload").toString();
var serialized = CompactSerializer.serialize(encodedHeader, encodedPayload, encodedSignature);
assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey);
assertExternalAccountBinding(serialized, resourceUrl, keyIdentifier, macKey, macAlg);
}
/**
@ -282,9 +285,12 @@ public class JoseUtilsTest {
* Expected key identifier
* @param macKey
* Expected {@link SecretKey}
* @param macAlg
* Expected algorithm
*/
public static void assertExternalAccountBinding(String serialized, URL resourceUrl,
String keyIdentifier, SecretKey macKey) {
String keyIdentifier, SecretKey macKey,
String macAlg) {
try {
var jws = new JsonWebSignature();
jws.setCompactSerialization(serialized);
@ -293,7 +299,7 @@ public class JoseUtilsTest {
assertThat(jws.getHeader("url")).isEqualTo(resourceUrl.toString());
assertThat(jws.getHeader("kid")).isEqualTo(keyIdentifier);
assertThat(jws.getHeader("alg")).isEqualTo("HS256");
assertThat(jws.getHeader("alg")).isEqualTo(macAlg);
var decodedPayload = jws.getPayload();
var expectedPayload = new StringBuilder();

Some files were not shown because too many files have changed in this diff Show More