Http Challenge failure

Hi, I'm working on a custom ACME client and am attempting to complete an HTTP challenge for domain danmacdonaldrenewaltest2.sigmanetcorp.us. I see the HTTP request come with the correct token and I'm responding with the provided authorization value in plain text. Since I"m trying to get this to work for the first time, I want to see what's wrong with how I'm returning our value. Is there any hint on the Let's Encrypt side about what is wrong with the value I'm returning today, August 8, 2021 at 12:27 PST?

You should build and test your client against Pebble - GitHub - letsencrypt/pebble: A miniature version of Boulder, Pebble is a small RFC 8555 ACME test serv

Once your client is working, then you can move to tests against your own version of Boulder or the LetsEncrypt staging service. You should not be doing any testing or work on a new client against the production ACME servers.

4 Likes

Thanks for your response. Except for this HTTP challenge bit, the development is completed and it is in a complex target environment that would make testing with our own ACME test server very difficult to be both useful and faithfully represent reality.

I'm preeeeetty sure the Let's Encrypt staff (deliberately not tagging them) aren't going to scour through their enormous logs of a multitude of MILLIONS of validations per day based on a single timestamp to try to help someone develop an ACME client when there are other methods to check it out. @jvanasco already clarified the proper way of developing an ACME client. The reason the "target environment" is complex should not be a reason not to first test it out using a surrogate ACME server. Also, the return error of the Let's Encrypt validation server should give you enough info to further debug the issue.

5 Likes

Yeah, what ^ @Osiris said.

4 Likes

Fair enough. For the purposes of this discussion, the code I've written is the response to Boulder's HTTP challenge request. So it is not technically the ACME client code but is, rather, my code on the target resource hosting the domain and responding to challenges. My ACME client code triggers the HTTP challenge and my server code responds to the HTTP Get for the well-known URL for the domain with the authorization value. My Acme client waits and polls Boulder for any status change but it does not change because Boulder doesn't like what I'm sending back. I know I'm getting the request and I know the token in the well-known request matches the token from the HTTP challenge. I have a guess that the media type should be application/octet-stream instead of text/plain based on section 8.3 in the RFC.

Anyhow, here is my server code - Java/SpringBoot, there's not much to it so maybe someone can spot my mistake or pile on to my media type theory:
@RequiresAuthenticatedSession(SessionType.ANY_OR_NONE)
@RequestMapping(value = URIs.WELL_KNOWN_ACME_CHALLENGE, method = RequestMethod.GET, produces = MediaType.TEXT_PLAIN_VALUE)
@ResponseBody
public String getAcmeHttpChallengeFile(@PathVariable("token") String token) {
String fileContent = acmeCacheHelper.getFileContent(token);
LOGGER.info("ACME HTTP challenge respond with content={} for token={}", fileContent, token);
return fileContent;
}

The response body you're sending back is not just the token value, right? It's the key authorization value as defined in section 8.1

All challenges defined in this document make use of a key
authorization string. A key authorization is a string that
concatenates the token for the challenge with a key fingerprint,
separated by a "." character:

keyAuthorization = token || '.' || base64url(Thumbprint(accountKey))

4 Likes

That's correct. I'm sending back the token + "." + base 64 url encoded thumbprint of the account key. I'm using the acme4J library which happily does this for me in their token challenge class.
public String getAuthorization() {
PublicKey pk = this.getLogin().getKeyPair().getPublic();
return this.getToken() + '.' + AcmeUtils.base64UrlEncode(JoseUtils.thumbprint(pk));
}

If you're not seeing a status change, then you most likely have not properly instructed Boulder to validate the challenge. See the RFC RFC 8555 - Automatic Certificate Management Environment (ACME) section on challenge object status changes.

Challenges start as "pending". If you successfully trigger a validation attempt, the challenge will transition to "processing". Boulder will then transition to "valid' or "invalid" based on the validation's success.

If you're not seeing a status change, for a variety of reasons I can only assume it's stuck in "pending" and not "processing". I would carefully debug your code and better understand the responses you are getting back from Boulder when you try to trigger the validation. This is a perfect use case for Pebble and for unit tests.

4 Likes

This goes into my key value stored named 'acmeCacheHelper' in my code above so it can be retrieved for the well known challenge response.

If I was not triggering the challenge properly, I would not expect to get the HTTP get on the well-known endpoint of my server. Since I am receiving the request after triggering the challenge, it would appear that the trigger is working properly but my server's response is not acceptable for some reason.

Boulder will fail your challenge if it doesn't like the response. It will not hang.

2 Likes

I didn't mention Boulder hanging. The response isn't deemed acceptable. Why it isn't acceptable is what I'm trying to figure out.

The best way to find out is querying the authorization(s) and looking at the error detail provided in the challenges array.

5 Likes

thanks I'll add that logging!

1 Like

Also, be aware when looking through your logs that you should be seeing multiple requests from Let's Encrypt, not just one, since they use multi-perspective validation to ensure that you own the name as seen from everywhere on the Internet.

2 Likes

Here is a snippet for a (working, C#) dynamic http challenge response, where value is the expected challenge response text:

server.Response.StatusCode = (int)HttpStatusCode.OK;
server.Response.ContentType = "text/plain";

using (var stream = new StreamWriter(server.Response.OutputStream))
 {
      stream.Write(value);
      stream.Flush();
      stream.Close();
  }

I assume based on your code that your response is formatting as plain text (not JSON). I don't know Spring but maybe try MediaType.PLAIN_TEXT instead of MediaType.PLAIN_TEXT_VALUE - I have no idea but I assume they have two different variations for a reason.

4 Likes

Thanks! That explains the dupe request I was seeing in my logs. I'm responding to them all, sadly incorrectly.

1 Like

Nice! Thanks, I was hoping for a working code example. In addition to the logging, I'll try to be more explicit in setting the content type in the response like you are doing.

2 Likes

Fixed it. The problem was the response format. I went with octet stream value due to that being referenced in the RFC and return a proper ResponseEntity. For anyone else with the same struggles this worked for me:

    @RequiresAuthenticatedSession(SessionType.ANY_OR_NONE)
    @RequestMapping(value = URIs.WELL_KNOWN_ACME_CHALLENGE, method = RequestMethod.GET, produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    @ResponseBody
    @ResponseBody
    public ResponseEntity<Object> getAcmeHttpChallengeFile(@PathVariable("token") String token) {
        String fileContent = acmeCacheHelper.getFileContent(token);
        LOGGER.info("ACME HTTP challenge respond with content={} for token={}", fileContent, token);
        final HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.valueOf(MediaType.APPLICATION_OCTET_STREAM_VALUE));
        return new ResponseEntity<>(fileContent, headers, HttpStatus.OK);
    }
1 Like