diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java index 7de36424..68554181 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Connection.java @@ -28,6 +28,14 @@ import org.shredzone.acme4j.util.JSONBuilder; */ public interface Connection extends AutoCloseable { + /** + * Resets the session nonce, by fetching a new one. + * + * @param session + * {@link Session} instance to fetch a nonce for + */ + void resetNonce(Session session) throws AcmeException; + /** * Sends a simple GET request. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java index 6ba604f5..29d714f9 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java @@ -90,6 +90,38 @@ public class DefaultConnection implements Connection { this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector"); } + @Override + public void resetNonce(Session session) throws AcmeException { + assertConnectionIsClosed(); + + try { + session.setNonce(null); + + URI newNonceUri = session.resourceUri(Resource.NEW_NONCE); + + conn = httpConnector.openConnection(newNonceUri); + conn.setRequestMethod("HEAD"); + conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag()); + conn.connect(); + + int rc = conn.getResponseCode(); + if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_NO_CONTENT) { + throw new AcmeProtocolException("Fetching a nonce returned " + rc + " " + + conn.getResponseMessage()); + } + + updateSession(session); + + if (session.getNonce() == null) { + throw new AcmeProtocolException("Server did not provide a nonce"); + } + } catch (IOException ex) { + throw new AcmeNetworkException(ex); + } finally { + conn = null; + } + } + @Override public void sendRequest(URI uri, Session session) throws AcmeException { Objects.requireNonNull(uri, "uri"); @@ -124,17 +156,7 @@ public class DefaultConnection implements Connection { KeyPair keypair = session.getKeyPair(); if (session.getNonce() == null) { - LOG.debug("Getting initial nonce, HEAD {}", uri); - conn = httpConnector.openConnection(uri); - conn.setRequestMethod("HEAD"); - conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag()); - conn.connect(); - updateSession(session); - conn = null; - } - - if (session.getNonce() == null) { - throw new AcmeProtocolException("Server did not provide a nonce"); + resetNonce(session); } LOG.debug("POST {} with claims: {}", uri, claims); diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java index bf06361f..a756b424 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java @@ -22,6 +22,7 @@ public enum Resource { NEW_REG("new-reg"), NEW_AUTHZ("new-authz"), NEW_CERT("new-cert"), + NEW_NONCE("new-nonce"), REVOKE_CERT("revoke-cert"); private final String path; diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index 2d42e9f0..6ca7605c 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -46,6 +46,7 @@ import org.shredzone.acme4j.exception.AcmeNetworkException; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.exception.AcmeServerException; +import org.shredzone.acme4j.provider.AcmeProvider; import org.shredzone.acme4j.util.JSON; import org.shredzone.acme4j.util.JSONBuilder; import org.shredzone.acme4j.util.TestUtils; @@ -61,13 +62,19 @@ public class DefaultConnectionTest { private Session session; @Before - public void setup() throws IOException { + public void setup() throws AcmeException, IOException { mockUrlConnection = mock(HttpURLConnection.class); mockHttpConnection = mock(HttpConnector.class); when(mockHttpConnection.openConnection(requestUri)).thenReturn(mockUrlConnection); - session = TestUtils.session(); + final AcmeProvider mockProvider = mock(AcmeProvider.class); + when(mockProvider.directory( + ArgumentMatchers.any(Session.class), + ArgumentMatchers.eq(URI.create(TestUtils.ACME_SERVER_URI)))) + .thenReturn(TestUtils.getJsonAsObject("directory")); + + session = TestUtils.session(mockProvider); session.setLocale(Locale.JAPAN); } @@ -133,6 +140,47 @@ public class DefaultConnectionTest { verifyNoMoreInteractions(mockUrlConnection); } + /** + * Test that {@link DefaultConnection#resetNonce(Session)} fetches a new nonce via + * new-nonce resource and a HEAD request. + */ + @Test + public void testResetNonce() throws AcmeException, IOException { + byte[] nonce = "foo-nonce-foo".getBytes(); + + when(mockHttpConnection.openConnection(URI.create("https://example.com/acme/new-nonce"))) + .thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()) + .thenReturn(HttpURLConnection.HTTP_NO_CONTENT); + + assertThat(session.getNonce(), is(nullValue())); + + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { + conn.resetNonce(session); + fail("missing Replay-Nonce header not detected"); + } catch (AcmeProtocolException ex) { + // expected + } + + assertThat(session.getNonce(), is(nullValue())); + + when(mockUrlConnection.getHeaderField("Replay-Nonce")) + .thenReturn(Base64Url.encode(nonce)); + + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { + conn.resetNonce(session); + } + + assertThat(session.getNonce(), is(nonce)); + + verify(mockUrlConnection, atLeastOnce()).setRequestMethod("HEAD"); + verify(mockUrlConnection, atLeastOnce()).setRequestProperty("Accept-Language", "ja-JP"); + verify(mockUrlConnection, atLeastOnce()).connect(); + verify(mockUrlConnection, atLeastOnce()).getResponseCode(); + verify(mockUrlConnection, atLeastOnce()).getHeaderField("Replay-Nonce"); + verifyNoMoreInteractions(mockUrlConnection); + } + /** * Test that an absolute Location header is evaluated. */ @@ -512,11 +560,19 @@ public class DefaultConnectionTest { try (DefaultConnection conn = new DefaultConnection(mockHttpConnection) { @Override - public void updateSession(Session session) { + public void resetNonce(Session session) throws AcmeException { assertThat(session, is(sameInstance(DefaultConnectionTest.this.session))); if (session.getNonce() == null) { session.setNonce(nonce1); - } else if (session.getNonce() == nonce1) { + } else { + fail("unknown nonce"); + } + }; + + @Override + public void updateSession(Session session) { + assertThat(session, is(sameInstance(DefaultConnectionTest.this.session))); + if (session.getNonce() == nonce1) { session.setNonce(nonce2); } else { fail("unknown nonce"); @@ -528,14 +584,12 @@ public class DefaultConnectionTest { conn.sendSignedRequest(requestUri, cb, DefaultConnectionTest.this.session); } - verify(mockUrlConnection).setRequestMethod("HEAD"); - verify(mockUrlConnection, times(2)).setRequestProperty("Accept-Language", "ja-JP"); - verify(mockUrlConnection, times(2)).connect(); - verify(mockUrlConnection).setRequestMethod("POST"); verify(mockUrlConnection).setRequestProperty("Accept", "application/json"); verify(mockUrlConnection).setRequestProperty("Accept-Charset", "utf-8"); + verify(mockUrlConnection).setRequestProperty("Accept-Language", "ja-JP"); verify(mockUrlConnection).setRequestProperty("Content-Type", "application/jose+json"); + verify(mockUrlConnection).connect(); verify(mockUrlConnection).setDoOutput(true); verify(mockUrlConnection).setFixedLengthStreamingMode(outputStream.toByteArray().length); verify(mockUrlConnection).getOutputStream(); @@ -574,6 +628,11 @@ public class DefaultConnectionTest { */ @Test(expected = AcmeProtocolException.class) public void testSendSignedRequestNoNonce() throws Exception { + when(mockHttpConnection.openConnection(URI.create("https://example.com/acme/new-nonce"))) + .thenReturn(mockUrlConnection); + when(mockUrlConnection.getResponseCode()) + .thenReturn(HttpURLConnection.HTTP_NOT_FOUND); + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { JSONBuilder cb = new JSONBuilder(); conn.sendSignedRequest(requestUri, cb, DefaultConnectionTest.this.session); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java index 9349fea0..ed1afb51 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DummyConnection.java @@ -28,6 +28,11 @@ import org.shredzone.acme4j.util.JSONBuilder; */ public class DummyConnection implements Connection { + @Override + public void resetNonce(Session session) throws AcmeException { + throw new UnsupportedOperationException(); + } + @Override public void sendRequest(URI uri, Session session) { throw new UnsupportedOperationException(); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java index 26074d5d..5eb4fbab 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java @@ -31,11 +31,12 @@ public class ResourceTest { assertThat(Resource.KEY_CHANGE.path(), is("key-change")); assertThat(Resource.NEW_AUTHZ.path(), is("new-authz")); assertThat(Resource.NEW_CERT.path(), is("new-cert")); + assertThat(Resource.NEW_NONCE.path(), is("new-nonce")); assertThat(Resource.NEW_REG.path(), is("new-reg")); assertThat(Resource.REVOKE_CERT.path(), is("revoke-cert")); // fails if there are untested future Resource values - assertThat(Resource.values().length, is(5)); + assertThat(Resource.values().length, is(6)); } } diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index 79fa75fa..7e1a7ce1 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -17,6 +17,7 @@ directory = \ "new-reg": "https://example.com/acme/new-reg",\ "new-authz": "https://example.com/acme/new-authz",\ "new-cert": "https://example.com/acme/new-cert",\ + "new-nonce": "https://example.com/acme/new-nonce",\ "meta": {\ "terms-of-service": "https://example.com/acme/terms",\ "website": "https://www.example.com/",\