letsencrypt / letsencrypt/boulder
ACME semantic variances in WFE2
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 5.8k
- Forks
- 649
- Avg merge
- 3d 23h
- Merged PRs (30d)
- 24
Description
This is an issue I have asked Claude to put together as a result of its audit of Boulder against the RFCs Boulder implements.
Checklist of cases where Boulder emits a field the RFC defines but its
meaning, status code, or population logic diverges from the spec.
Distinct from the "non-standard fields to remove" and "missing fields to
add" lists — every item here is a field Boulder already implements and
already sends on the wire, but with the wrong semantics.
-
unauthorizedproblem returns HTTP 403 instead of 401 for
deactivated accounts. RFC 8555 §7.3.6: "If a server receives a
POST or POST-as-GET from a deactivated account, it MUST return an
error response with status code 401 (Unauthorized) and type
urn:ietf:params:acme:error:unauthorized." Boulder's
Unauthorizedhelper hardcodeshttp.StatusForbidden, and the JWS
verification layer raisesberrors.UnauthorizedErrorfor
deactivated accounts, which the web layer maps to HTTP 403. The
type URN is correct; the status code is not. Deactivated-account
rejections are the specific case the RFC calls out as MUST-401.
probs.Unauthorized
·wfe2/verify.godeactivated-account check
·web/probs.goberrors→HTTP mapping -
NotFoundhelper emitsmalformedproblem type on 404
responses. RFC 8555 §6.7 definesmalformedas "The request
message was malformed" — a 400-class validation error. Boulder's
probs.NotFoundbuilds aProblemDetailswith
Type: MalformedProblemandHTTPStatus: 404. Every 404 raised
from WFE2 (finalize, certificate download, authorization lookup,
order lookup, revocation) carries atypeURN that tells the
client "your request was syntactically bad" when the actual
condition is "that resource does not exist".
probs.NotFound -
prepChallengeForDisplaymarks every challengeinvalidwhen
the authz is invalid. RFC 8555 §7.1.4: "For invalid
authorizations, the challenge that was attempted and failed." The
RFC models a single failing challenge driving the authz to
invalid. Boulder instead overwrites the status of every
challenge on the authz toinvalid, including challenges the
client never attempted. Theerrorfield is still only populated
on the actually-attempted challenge, so a client cannot tell from
statusalone which challenge failed, and the status/error fields
become inconsistent across the challenge array.
prepChallengeForDisplayinvalid broadcast -
order.expiresserializes zero-valued time when unset. RFC
8555 §7.1.3: "expires (optional, string): … This field is
REQUIRED for objects with 'pending' or 'valid' in the status
field." Optional fields that are unset should be absent from the
response, not serialized as a placeholder. Boulder's
orderJSON.Expiresis a baretime.Timewith noomitemptyand
no pointer indirection, so any code path producing an order
without an expiry emits"expires": "0001-01-01T00:00:00Z",
claiming the order expired in year 1 AD. In practice every
Boulder order has a real expiry; the type permits a state that
would silently publish nonsense.
orderJSON.Expires -
updateAccountreturns 200 OK for contact updates it silently
discards. RFC 8555 §7.3.2 describes the account-update flow: a
client POSTs changed fields and the server returns 200 (OK) with
"the resulting account object". A 200 with the updated object
implies the update was accepted. Boulder'supdateAccount
dispatches onstatus: fordeactivatedit deactivates, for
validor empty it returns the account unchanged with the
explanatory comment "They probably intended to update their
contact address, but we don't do that anymore, so simply return
their account as-is. We don't error out here because it would
break too many clients." A client comparing the echoed account
against what it sent has no signal that its contact update was
ignored.
updateAccountvalid branch -
KeyRollover 409 uses the non-standard
conflictURN. RFC
8555 §7.3.5: "If there is an existing account with the new key
provided, then the server SHOULD use status code 409 (Conflict)
and provide the URL of that account in the Location header
field." The RFC pins the HTTP status and theLocationheader
but does not define a problem type for this condition, and §6.7
has no registered URN for it. Boulder callsprobs.Conflicton
both the pre-check and the post-update-race branch of
KeyRollover, emittingurn:ietf:params:acme:error:conflict— a
URN not in the §6.7 registry. The 409 status and Location header
are correct; the URN is semantically a Boulder invention for a
well-defined RFC condition.
KeyRolloverpre-check
·KeyRolloverrace branch
·probs.ConflictProblem -
RenewalInfo endpoint accepts POST in addition to GET. RFC 9773
§4.1: "the client sends an unauthenticated GET request to a path
under the server's renewalInfo URL." §6 reinforces: "RenewalInfo
resources are exposed and accessed via unauthenticated GET
requests, a departure from the requirement in RFC 8555 that
clients send POST-as-GET requests to fetch resources from the
server." The RFC's request-method contract is singular: GET only,
unauthenticated. Boulder registers the renewalInfo endpoint for
both GET and POST. Accepting POST isn't explicitly forbidden, but
it departs from the stated method contract, and the POST path
bypasses the caching-friendliness rationale §6 gives for making
the endpoint GET-only.
RenewalInfohandler registration -
Certificate download ignores the
Acceptheader. RFC 8555
§7.4.2: "The ACME client MAY request other formats by including
an Accept header field in its request… Server support for
alternate formats is OPTIONAL." HTTP content negotiation
semantics (RFC 9110) additionally imply that a server which cannot
satisfy a specific Accept value should respond 406 (Not
Acceptable). Boulder'sCertificatehandler ignores theAccept
header entirely and unconditionally sets
Content-Type: application/pem-certificate-chain. A client
sendingAccept: application/pkix-cert(or any other value
excluding pem-certificate-chain) receives a 200 with PEM anyway,
with no signal that its stated preference was ignored.
Alternate-format support being optional is fine; silently
overriding the client's explicit Accept value is not the
content-negotiation contract the RFC points at.
Certificatehandler Content-Type
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reading the cited entry points in probs/probs.go, wfe2/verify.go, wfe2/wfe.go, and web/probs.go, then compare each unchecked behavior with its referenced RFC section. The work is complete when every selected variance has an agreed correction and its response status, problem details, serialization, or method behavior is verified against the specification.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- api, backend, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100