Skip to content

ACME semantic variances in WFE2 #8711

@mcpherrinm

Description

@mcpherrinm

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.

  • unauthorized problem 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
    Unauthorized helper hardcodes http.StatusForbidden, and the JWS
    verification layer raises berrors.UnauthorizedError for
    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.go deactivated-account check
    · web/probs.go berrors→HTTP mapping

  • NotFound helper emits malformed problem type on 404
    responses.
    RFC 8555 §6.7 defines malformed as "The request
    message was malformed"
    — a 400-class validation error. Boulder's
    probs.NotFound builds a ProblemDetails with
    Type: MalformedProblem and HTTPStatus: 404. Every 404 raised
    from WFE2 (finalize, certificate download, authorization lookup,
    order lookup, revocation) carries a type URN that tells the
    client "your request was syntactically bad" when the actual
    condition is "that resource does not exist".
    probs.NotFound

  • prepChallengeForDisplay marks every challenge invalid when
    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 to invalid, including challenges the
    client never attempted. The error field is still only populated
    on the actually-attempted challenge, so a client cannot tell from
    status alone which challenge failed, and the status/error fields
    become inconsistent across the challenge array.
    prepChallengeForDisplay invalid broadcast

  • order.expires serializes 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.Expires is a bare time.Time with no omitempty and
    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

  • updateAccount returns 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's updateAccount
    dispatches on status: for deactivated it deactivates, for
    valid or 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.
    updateAccount valid branch

  • KeyRollover 409 uses the non-standard conflict URN. 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 the Location header
    but does not define a problem type for this condition, and §6.7
    has no registered URN for it. Boulder calls probs.Conflict on
    both the pre-check and the post-update-race branch of
    KeyRollover, emitting urn: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.
    KeyRollover pre-check
    · KeyRollover race 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.
    RenewalInfo handler registration

  • Certificate download ignores the Accept header. 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's Certificate handler ignores the Accept
    header entirely and unconditionally sets
    Content-Type: application/pem-certificate-chain. A client
    sending Accept: 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.
    Certificate handler Content-Type

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions