Files
certctl/internal/api/acme/account.go
T
shankar0123 44a85d6f85 acme-server: account resource + JWS verifier (Phase 1b/7)
Layers JWS-authenticated POST machinery onto the Phase 1a foundation
(commit ec88a61). After this commit, an ACME client can run

  POST /acme/profile/<id>/new-account

against certctl and successfully register an account. Account update
+ deactivation via POST /acme/profile/<id>/account/<acc-id> work.
Orders + challenges remain Phase 2 / 3.

Background:
  Two prior dispatch attempts at the original Phase 1 ("skeleton +
  directory + new-nonce + new-account" as a single commit) failed on
  go-jose v4 API speculation (jws.GetPayload, sig.Algorithm,
  jose.SHA256, etc. — none of those exist in v4). Splitting Phase 1
  into 1a (foundation, no go-jose) and 1b (this commit, all go-jose
  in one place) concentrated the JWS work where attention pays off.
  The verifier reads the actual go-jose v4 surface — ParseSigned with
  closed alg allow-list, Header struct fields (Algorithm, KeyID,
  JSONWebKey, Nonce, ExtraHeaders[HeaderKey]), JWK.Thumbprint with
  stdlib crypto.SHA256.

What ships:
  - internal/api/acme/jws.go: 487-line verifier + sentinel error
    family. Enforces RFC 8555 §6.2 + §6.4 + §6.5 invariants:
      - alg in {RS256, ES256, EdDSA} (closed allow-list passed to
        jose.ParseSigned — HS256 / none / etc. rejected at parse time)
      - exactly one of `kid` / `jwk` in protected header (per
        endpoint policy — new-account demands jwk, others demand kid)
      - protected `url` matches request URL exactly
      - protected `nonce` consumed against acme_nonces (badNonce on
        miss/replay/expiry per RFC 8555 §6.5.1)
      - kid round-trips against canonical AccountKID(accountID) URL
        (catches cross-profile / cross-host replay)
      - kid path: account exists + status=valid (deactivated /
        revoked accounts cannot authenticate)
      - signature verifies; post-Verify payload bytes equal
        UnsafePayloadWithoutVerification (defense in depth)
    + JWK persistence helpers (JWKToPEM / ParseJWKFromPEM round-
    trip a public-only JWK as a PEM-wrapped JSON envelope; stored
    as TEXT in acme_accounts.jwk_pem for diff-friendliness) +
    JWKThumbprint per RFC 7638.
  - internal/api/acme/jws_test.go: 16 cases covering happy paths
    (RS256 kid, ES256 jwk, EdDSA kid) + every named failure mode
    (alg-not-allowed, bad-sig, missing-nonce, unknown-nonce,
    replay, url-mismatch, mixed kid+jwk, deactivated-account,
    cross-host kid). Uses real keypairs + real go-jose Signer to
    build JWS objects.
  - internal/api/acme/account.go: NewAccountRequest /
    AccountUpdateRequest payload shapes (RFC 8555 §7.3 + §7.3.2 +
    §7.3.6) + AccountResponseJSON wire shape + MarshalAccount
    helper.
  - internal/domain/acme.go: ACMEAccount struct + ACMEAccountStatus
    closed enum (valid / deactivated / revoked).
  - internal/repository/postgres/acme.go: full account CRUD path
    (CreateAccountWithTx with 23505-unique-violation sentinel
    translation, GetAccountByID, GetAccountByThumbprint,
    UpdateAccountContactWithTx, UpdateAccountStatusWithTx) +
    sql.ErrNoRows-wrapped repository.ErrNotFound on lookup misses.
  - internal/service/acme.go: ACMERepo interface extended;
    SetTransactor + SetAuditService wires; NewAccount (idempotent
    re-registration per RFC 8555 §7.3.1 — same JWK returns existing
    row without an update or new audit event); LookupAccount;
    UpdateAccount; DeactivateAccount; VerifyJWS adapter that bridges
    api/acme.VerifierConfig to the service-layer ACMERepo; per-op
    metrics extended (new_account_total + _failures_total +
    _idempotent_total + update_account_total + _failures_total +
    deactivate_account_total).
  - internal/service/acme_test.go: 8 new tests covering
    new-account happy path / idempotent re-registration / only-
    return-existing match + no-match / contact update / deactivate
    / lookup-not-found / requires-transactor.
  - internal/api/handler/acme.go: NewAccount + Account handlers.
    Account dispatches POST-as-GET (RFC 8555 §6.3 — empty body or
    {} payload returns the account row), contact update, and
    deactivation from the same endpoint. Defense-in-depth check
    that the kid path-segment matches the URL path-segment (the
    verifier already round-tripped the kid against canonical URL,
    but the handler re-asserts to catch any future verifier
    refactor).
  - internal/api/handler/acme_handler_test.go: 7 new cases
    covering happy-create, idempotent-200, only-return-existing-
    no-match-400, malformed-JWS-400, kid-URL-mismatch-401,
    deactivate, contact-update, POST-as-GET.
  - internal/api/router/router.go: 4 new Register calls (per-
    profile + shorthand for new-account and account/{acc_id}).
  - internal/api/router/openapi_parity_test.go: SpecParityExceptions
    extended with the 4 new routes (RFC 8555 wire-protocol surface,
    not OpenAPI-shaped — same precedent as Phase 1a).
  - cmd/server/main.go: SetTransactor + SetAuditService on
    acmeService at startup so the WithinTx-based new-account /
    update / deactivate paths run with the same transactor instance
    shared across CertificateService / RevocationSvc / RenewalService.
  - docs/acme-server.md: Phase status updated; endpoints table grows
    new-account + account/<acc_id> rows; new "JWS verification
    (Phase 1b)" section enumerates the 7 invariants the verifier
    enforces; phases-cross-reference table marks 1b live.
  - go.mod / go.sum: github.com/go-jose/go-jose/v4 v4.0.4 added.

Atomicity: every account-state mutation writes its acme_accounts row
+ its audit_events row inside one repository.Transactor.WithinTx
call — the canonical certctl atomicity contract (matches
CertificateService.Create at internal/service/certificate.go:131).
Idempotent re-registration explicitly does NOT write an audit row
(RFC 8555 §7.3.1 returns the existing row unmodified).

Tests: 16 jws_test.go cases + 11 service tests + 11 handler tests
all pass under -short. Bad-signature test uses a real registered
account whose stored JWK is a different keypair from the signer's,
so the JWS parses cleanly but jose.Verify rejects — exercises the
ErrJWSSignatureInvalid path directly.

Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1b".
2026-05-03 13:21:56 +00:00

83 lines
3.6 KiB
Go

// Copyright (c) certctl
// SPDX-License-Identifier: BSL-1.1
package acme
import (
"github.com/shankar0123/certctl/internal/domain"
)
// AccountResponseJSON is the wire shape RFC 8555 §7.1.2 mandates for
// account-resource responses (new-account success, account update,
// per-account GET POST-as-GET).
//
// The orders URL is mandatory per RFC 8555 §7.1.2.1; it points at the
// per-account orders list endpoint that Phase 2 implements. Phase 1b
// emits it as an empty placeholder ("orders not yet implemented") so
// the directory + new-account flow round-trips against ACME clients
// that expect the field present.
type AccountResponseJSON struct {
Status string `json:"status"`
Contact []string `json:"contact,omitempty"`
Orders string `json:"orders"`
}
// MarshalAccount renders an ACMEAccount in RFC 8555 §7.1.2 wire shape.
// `ordersURL` is the per-account orders list URL the handler computes
// from the inbound request (scheme + host + profile path + account
// id); Phase 1b's handler passes it but Phase 2 wires the actual
// /acme/profile/<id>/account/<acc-id>/orders endpoint.
func MarshalAccount(acct *domain.ACMEAccount, ordersURL string) AccountResponseJSON {
contact := acct.Contact
if contact == nil {
// RFC 8555 doesn't require contact be present, but cert-manager
// + lego both expect a stable shape. Emit [] rather than null.
contact = []string{}
}
return AccountResponseJSON{
Status: string(acct.Status),
Contact: contact,
Orders: ordersURL,
}
}
// NewAccountRequest is the payload shape RFC 8555 §7.3 mandates for
// new-account requests. The handler json.Unmarshals VerifiedRequest.Payload
// into this struct after JWS verify succeeds.
type NewAccountRequest struct {
// Contact is a list of mailto: / tel: URIs. Optional per RFC 8555
// but operators typically supply at least one mailto:.
Contact []string `json:"contact,omitempty"`
// TermsOfServiceAgreed signals client consent to the operator's
// ToS document (advertised via meta.termsOfService). Phase 1b
// records the value but does NOT enforce — the meta field is
// informational only at this stage.
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed,omitempty"`
// OnlyReturnExisting, when true, asks the server to return the
// existing account row for this JWK (RFC 8555 §7.3.1). When
// true and no account exists, the server MUST return 400 +
// urn:ietf:params:acme:error:accountDoesNotExist.
OnlyReturnExisting bool `json:"onlyReturnExisting,omitempty"`
// ExternalAccountBinding (EAB) is RFC 8555 §7.3.4. Phase 1b
// accepts the field but does NOT validate — EAB enforcement is
// a deliberate out-of-scope per the master prompt and lands as a
// follow-up if there's demand. Storing the raw envelope means a
// future phase can backfill validation against historical accounts.
ExternalAccountBinding map[string]interface{} `json:"externalAccountBinding,omitempty"`
}
// AccountUpdateRequest is the payload shape for the account-update
// endpoint POST /acme/profile/<id>/account/<acc-id> (RFC 8555 §7.3.2 +
// §7.3.6). Only `contact` and `status` are mutable per the spec.
type AccountUpdateRequest struct {
// Contact, when non-nil, replaces the account's contact list.
// nil means "leave unchanged" (distinct from empty []string{}
// which means "clear contacts" — cert-manager doesn't issue
// either, but the spec permits both).
Contact []string `json:"contact,omitempty"`
// Status, when set to "deactivated", retires the account per
// RFC 8555 §7.3.6. Other values are rejected — the operator
// path for revoked is via certctl's API, not via ACME.
Status string `json:"status,omitempty"`
}