mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
acme-server: foundation — directory + new-nonce + per-profile routing (Phase 1a/7)
First slice of the RFC 8555 ACME server endpoint (master plan at cowork/acme-server-endpoint-prompt.md, per-phase prompts at cowork/acme-server-prompts/). This commit lands the smallest viable end-to-end deployable slice: an ACME client running curl -sk https://certctl/acme/profile/<id>/directory curl -sk -I https://certctl/acme/profile/<id>/new-nonce successfully fetches the directory document and a Replay-Nonce. Account creation, JWS verification, orders, challenges, and revocation are all out of scope for this phase and arrive in Phases 1b–4. Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research (cowork/infisical-deep-research-results.md). Pre-fix, certctl was an ACME consumer only — no /acme/directory endpoint, no JWS verifier, no challenge validators. K8s customers running cert-manager could not point at certctl as an ACME issuer; they had to deploy a certctl agent on every node. What ships: - internal/api/acme/{directory,nonce,errors}.go (+ tests). - internal/api/handler/acme.go + acme_handler_test.go. - internal/repository/postgres/acme.go (nonce ops only — Phase 1b extends with account CRUD; Phases 2-4 extend with order / authz / challenge CRUD). - internal/service/acme.go (BuildDirectory + IssueNonce stubs; Phase 1b adds VerifyJWS / NewAccount / etc.). - migrations/000025_acme_server.{up,down}.sql ships the full 5-table ACME schema (acme_accounts / acme_orders / acme_authorizations / acme_challenges / acme_nonces) PLUS the per-profile certificate_profiles.acme_auth_mode column. Phase 1a actively uses only acme_nonces; remaining tables are empty until Phases 1b-4 plug in. - internal/config/config.go: ACMEServerConfig struct + ACMEServer field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to avoid colliding with the existing consumer-side ACMEConfig at config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE / CHALLENGE_TYPE etc.). Phase 1a wires Enabled + DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta; Order/Authz TTLs + per-challenge-type concurrency caps + DNS01 resolver are reserved fields parsed in 1a so operators can set them ahead of Phases 2/3. - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry literal alongside the existing certificate / EST / SCEP / etc. handlers. - internal/api/router/router.go: HandlerRegistry.ACME field + 6 Register calls (3 per-profile + 3 shorthand). - internal/api/router/openapi_parity_test.go: 6 new entries in SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed JSON over HTTPS per RFC 7515) whose semantics are dictated by RFC 8555 + RFC 9773 rather than by an OpenAPI document, same precedent as SCEP/EST. The canonical reference is docs/acme-server.md. - docs/acme-server.md: Phase-1a-shaped reference. Configuration table for every CERTCTL_ACME_SERVER_* env var. Per-profile auth-mode decision tree skeleton. TLS trust bootstrap section flagging cert-manager's ClusterIssuer.spec.acme.caBundle requirement (the single biggest first-time-deploy footgun; the full cert-manager walkthrough lands in Phase 6 but the requirement is documented up front). Architecture decisions baked in: - URL family is /acme/profile/<id>/* (per-profile, canonical) with /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set. Path matches existing per-profile precedent in EST + SCEP. - Auth mode is per-profile (acme_auth_mode column on certificate_profiles), NOT server-wide. One certctl-server can serve trust_authenticated for an internal-PKI profile and challenge for a public-trust-style profile simultaneously. The column is read at request time, not cached at server start — operators flipping a profile's mode via SQL take effect on the next order without restart. - Nonces are DB-backed (acme_nonces table). Survive server restart. The RFC 8555 §6.5 replay defense requires the store to outlast the client's nonce caching window; an in-memory-only nonce store would lose every in-flight order on restart. - Per-op atomic counters on service.ACMEService.Metrics() — certctl_acme_directory_total, certctl_acme_directory_failures_total, certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total. Naming follows certctl frozen decision 0.10 cardinality discipline. Phase 1b will extend with new_account counters; Phase 2 with order / finalize / cert; Phase 3 with per-challenge-type counters. Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md) applied: - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side CERTCTL_ACME_* namespace collision. - #12: prior-attempt WIP from two failed Phase-1 dispatches was discarded at phase start; this commit starts from a clean tree. Tests: - 14 unit tests in internal/api/acme/ (directory, nonce, errors). - 7 handler-level tests via httptest.NewServer + mockACMEService (mirrors the mockSCEPService pattern at scep_handler_test.go). - 7 service-layer tests with mocked repo + injected profileLookup. - All pass under -race -count=1 -short. Deferred to Phase 1b: - JWS verification (go-jose v4 — see master-prompt §8a for the API surface and audit doc for the speculation pitfalls). - new-account / account/<id> endpoints + AccountService. - Nonce *consumption* path (issue path is in this commit; consume is only invoked by JWS-verified POSTs which Phase 1b adds). Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a". Per-phase implementation plan: cowork/acme-server-prompts/. Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md + cowork/acme-server-prompt-audit.md + cowork/acme-server-prompts/audit-additions.md.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
// Package acme implements the ACME server-side protocol surface (RFC 8555
|
||||
// + RFC 9773 ARI). It is deliberately separate from
|
||||
// internal/connector/issuer/acme/ which is the consumer surface (certctl
|
||||
// talks UP to Let's Encrypt / ZeroSSL / pebble). The two surfaces share
|
||||
// no types — the consumer's data model is client-shaped; the server's
|
||||
// is request-handler-shaped.
|
||||
//
|
||||
// Phase 1a: directory + nonce + JSON-Problem (RFC 7807) error envelopes
|
||||
// only. JWS verification, account resource, orders, challenges, key
|
||||
// rollover, revocation, ARI all land in subsequent phases (1b → 4).
|
||||
package acme
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Directory is the JSON document RFC 8555 §7.1.1 mandates the server
|
||||
// publish at /acme/profile/<id>/directory (and at /acme/directory when
|
||||
// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set).
|
||||
//
|
||||
// Each URL is the per-profile path the ACME client POSTs against. Even
|
||||
// though Phase 1a only wires up new-nonce, the directory advertises
|
||||
// the full surface — RFC 8555 doesn't permit a partial directory and
|
||||
// clients use the directory's URL fields exclusively (they don't
|
||||
// hand-construct paths from a base URL).
|
||||
type Directory struct {
|
||||
NewNonce string `json:"newNonce"`
|
||||
NewAccount string `json:"newAccount"`
|
||||
NewOrder string `json:"newOrder"`
|
||||
RevokeCert string `json:"revokeCert"`
|
||||
KeyChange string `json:"keyChange"`
|
||||
// RenewalInfo (RFC 9773 ARI) lands in Phase 4. Omitted now via the
|
||||
// `,omitempty` tag so the JSON output stays clean for clients that
|
||||
// don't yet support ARI.
|
||||
RenewalInfo string `json:"renewalInfo,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Meta is the optional metadata block per RFC 8555 §7.1.1. Every field
|
||||
// is operator-supplied via CERTCTL_ACME_SERVER_* env vars; an empty
|
||||
// Meta is omitted from the marshaled directory.
|
||||
type Meta struct {
|
||||
TermsOfService string `json:"termsOfService,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
CAAIdentities []string `json:"caaIdentities,omitempty"`
|
||||
ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"`
|
||||
}
|
||||
|
||||
// BuildDirectory constructs the per-profile directory document.
|
||||
//
|
||||
// baseURL is the per-profile base path (no trailing slash, e.g.
|
||||
// "https://certctl.example.com/acme/profile/prof-corp"). The default-
|
||||
// profile shorthand path passes the same baseURL — clients writing
|
||||
// their config against the shorthand naturally re-derive the per-
|
||||
// profile URLs from the directory.
|
||||
//
|
||||
// All five canonical RFC 8555 endpoints are populated; renewalInfo is
|
||||
// populated only when ARIEnabled=true so Phase 1a (where ARI is
|
||||
// non-functional) doesn't advertise a 404-returning URL. ARI flips on
|
||||
// in Phase 4 along with the actual handler.
|
||||
func BuildDirectory(baseURL, tos, website string, caa []string, eabRequired, ariEnabled bool) *Directory {
|
||||
dir := &Directory{
|
||||
NewNonce: fmt.Sprintf("%s/new-nonce", baseURL),
|
||||
NewAccount: fmt.Sprintf("%s/new-account", baseURL),
|
||||
NewOrder: fmt.Sprintf("%s/new-order", baseURL),
|
||||
RevokeCert: fmt.Sprintf("%s/revoke-cert", baseURL),
|
||||
KeyChange: fmt.Sprintf("%s/key-change", baseURL),
|
||||
}
|
||||
if ariEnabled {
|
||||
// RFC 9773 §4.1 publishes ARI as `renewalInfo`. Phase 4 wires
|
||||
// the actual handler; until then, BuildDirectory callers pass
|
||||
// ariEnabled=false.
|
||||
dir.RenewalInfo = fmt.Sprintf("%s/renewal-info", baseURL)
|
||||
}
|
||||
if tos != "" || website != "" || len(caa) > 0 || eabRequired {
|
||||
dir.Meta = &Meta{
|
||||
TermsOfService: tos,
|
||||
Website: website,
|
||||
CAAIdentities: caa,
|
||||
ExternalAccountRequired: eabRequired,
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildDirectory_FullMeta(t *testing.T) {
|
||||
d := BuildDirectory(
|
||||
"https://server/acme/profile/prof-corp",
|
||||
"https://example.com/tos",
|
||||
"https://example.com",
|
||||
[]string{"example.com"},
|
||||
true,
|
||||
false,
|
||||
)
|
||||
if got, want := d.NewNonce, "https://server/acme/profile/prof-corp/new-nonce"; got != want {
|
||||
t.Errorf("NewNonce = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := d.NewAccount, "https://server/acme/profile/prof-corp/new-account"; got != want {
|
||||
t.Errorf("NewAccount = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := d.NewOrder, "https://server/acme/profile/prof-corp/new-order"; got != want {
|
||||
t.Errorf("NewOrder = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := d.RevokeCert, "https://server/acme/profile/prof-corp/revoke-cert"; got != want {
|
||||
t.Errorf("RevokeCert = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := d.KeyChange, "https://server/acme/profile/prof-corp/key-change"; got != want {
|
||||
t.Errorf("KeyChange = %q, want %q", got, want)
|
||||
}
|
||||
if d.RenewalInfo != "" {
|
||||
t.Errorf("RenewalInfo should be empty when ariEnabled=false; got %q", d.RenewalInfo)
|
||||
}
|
||||
if d.Meta == nil {
|
||||
t.Fatal("Meta should be populated when any meta field is set")
|
||||
}
|
||||
if d.Meta.TermsOfService != "https://example.com/tos" {
|
||||
t.Errorf("TermsOfService = %q", d.Meta.TermsOfService)
|
||||
}
|
||||
if d.Meta.Website != "https://example.com" {
|
||||
t.Errorf("Website = %q", d.Meta.Website)
|
||||
}
|
||||
if !d.Meta.ExternalAccountRequired {
|
||||
t.Error("ExternalAccountRequired should be true")
|
||||
}
|
||||
if len(d.Meta.CAAIdentities) != 1 || d.Meta.CAAIdentities[0] != "example.com" {
|
||||
t.Errorf("CAAIdentities = %v", d.Meta.CAAIdentities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectory_NoMeta(t *testing.T) {
|
||||
d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, false)
|
||||
if d.Meta != nil {
|
||||
t.Errorf("Meta should be nil when all meta fields zero; got %+v", d.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectory_EABRequiredOnly(t *testing.T) {
|
||||
d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, true, false)
|
||||
if d.Meta == nil {
|
||||
t.Fatal("Meta should be populated when EAB is required")
|
||||
}
|
||||
if !d.Meta.ExternalAccountRequired {
|
||||
t.Error("ExternalAccountRequired should be true")
|
||||
}
|
||||
if d.Meta.TermsOfService != "" || d.Meta.Website != "" || len(d.Meta.CAAIdentities) != 0 {
|
||||
t.Errorf("only EAB should be set; meta = %+v", d.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectory_ARIEnabled(t *testing.T) {
|
||||
d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, true)
|
||||
if d.RenewalInfo == "" {
|
||||
t.Fatal("RenewalInfo should be populated when ariEnabled=true")
|
||||
}
|
||||
if !strings.HasSuffix(d.RenewalInfo, "/renewal-info") {
|
||||
t.Errorf("RenewalInfo = %q; expected suffix /renewal-info", d.RenewalInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectory_JSONShape(t *testing.T) {
|
||||
// RFC 8555 §7.1.1 dictates the JSON field names. A regression here
|
||||
// would break every ACME client.
|
||||
d := BuildDirectory("https://server/acme/profile/prof-corp", "", "", nil, false, false)
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"newNonce":"https://server/acme/profile/prof-corp/new-nonce"`,
|
||||
`"newAccount":"https://server/acme/profile/prof-corp/new-account"`,
|
||||
`"newOrder":"https://server/acme/profile/prof-corp/new-order"`,
|
||||
`"revokeCert":"https://server/acme/profile/prof-corp/revoke-cert"`,
|
||||
`"keyChange":"https://server/acme/profile/prof-corp/key-change"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("JSON missing %q\nGot: %s", want, got)
|
||||
}
|
||||
}
|
||||
// renewalInfo + meta should be omitted.
|
||||
if strings.Contains(got, "renewalInfo") {
|
||||
t.Errorf("renewalInfo should be omitted when ARI disabled; got %s", got)
|
||||
}
|
||||
if strings.Contains(got, `"meta"`) {
|
||||
t.Errorf("meta should be omitted when no fields set; got %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// ProblemContentType is the MIME type RFC 7807 §3 mandates for the
|
||||
// JSON-Problem error envelope. ACME inherits this from RFC 8555 §6.7.
|
||||
const ProblemContentType = "application/problem+json"
|
||||
|
||||
// ACME error type URN prefix per RFC 8555 §6.7.
|
||||
const acmeErrorPrefix = "urn:ietf:params:acme:error:"
|
||||
|
||||
// Problem is the RFC 7807 Problem Details document. ACME extends it
|
||||
// per RFC 8555 §6.7 with subproblems (per-identifier-rejection
|
||||
// breakdowns) and identifier (the failing identifier on
|
||||
// rejectedIdentifier). Both extension fields land in Phase 2 along
|
||||
// with the order endpoints; Phase 1a only emits the base shape.
|
||||
type Problem struct {
|
||||
Type string `json:"type"`
|
||||
Detail string `json:"detail"`
|
||||
Status int `json:"status"`
|
||||
Subproblems []Problem `json:"subproblems,omitempty"`
|
||||
Identifier *Identifier `json:"identifier,omitempty"`
|
||||
}
|
||||
|
||||
// Identifier is the ACME identifier shape (RFC 8555 §7.4). Defined here
|
||||
// (rather than in a Phase-2-only file) so Phase 1a's Problem struct can
|
||||
// reference *Identifier without a forward-package-dependency.
|
||||
type Identifier struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Malformed is RFC 8555 §6.7's "request body did not parse / decode" /
|
||||
// "the JWS was malformed" / "payload JSON was malformed" error. HTTP
|
||||
// status 400.
|
||||
func Malformed(detail string) Problem {
|
||||
return Problem{
|
||||
Type: acmeErrorPrefix + "malformed",
|
||||
Detail: detail,
|
||||
Status: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// ServerInternal is the catch-all for unexpected server-side errors.
|
||||
// HTTP status 500. The detail string is operator-facing; per the
|
||||
// master prompt's acquisition-readiness criterion #10 it MUST NOT
|
||||
// echo SQL errors, internal trace IDs, or credential bytes.
|
||||
func ServerInternal(detail string) Problem {
|
||||
return Problem{
|
||||
Type: acmeErrorPrefix + "serverInternal",
|
||||
Detail: detail,
|
||||
Status: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
// UserActionRequired is RFC 8555 §6.7's "the user has to do something
|
||||
// out of band before this request will succeed" error. We return it
|
||||
// from the /acme/* shorthand path family when
|
||||
// CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is not set — the operator
|
||||
// has to either set the env var or update the client to use
|
||||
// /acme/profile/<id>/*. HTTP status 403 per RFC 8555.
|
||||
func UserActionRequired(detail string) Problem {
|
||||
return Problem{
|
||||
Type: acmeErrorPrefix + "userActionRequired",
|
||||
Detail: detail,
|
||||
Status: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
// UnsupportedContentType is RFC 7807-shaped (no ACME error type) for
|
||||
// requests with a Content-Type the endpoint doesn't accept. Phase 1b
|
||||
// will switch the JWS endpoints to require
|
||||
// "application/jose+json" specifically; Phase 1a's directory + nonce
|
||||
// have no Content-Type requirements and never emit this.
|
||||
func UnsupportedContentType(got string) Problem {
|
||||
return Problem{
|
||||
Type: "about:blank",
|
||||
Detail: "unsupported content type: " + got,
|
||||
Status: http.StatusUnsupportedMediaType,
|
||||
}
|
||||
}
|
||||
|
||||
// AccountDoesNotExist (RFC 8555 §7.3.1) is what the JWS verifier returns
|
||||
// when the request's `kid` points at an unknown account. Phase 1b
|
||||
// implements the verifier; this shape is exposed in Phase 1a for the
|
||||
// errors_test.go round-trip cases.
|
||||
func AccountDoesNotExist(detail string) Problem {
|
||||
return Problem{
|
||||
Type: acmeErrorPrefix + "accountDoesNotExist",
|
||||
Detail: detail,
|
||||
Status: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// BadNonce is what the JWS verifier returns on a missing / replayed /
|
||||
// expired nonce per RFC 8555 §6.5.1. Phase 1b wires the verifier;
|
||||
// shape exposed now so errors_test.go can round-trip it.
|
||||
func BadNonce(detail string) Problem {
|
||||
return Problem{
|
||||
Type: acmeErrorPrefix + "badNonce",
|
||||
Detail: detail,
|
||||
Status: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// WriteProblem renders a Problem as RFC 7807 JSON to w, with the
|
||||
// appropriate Content-Type and status. Any nil-Problem is rendered as
|
||||
// 500 + serverInternal so the handler never panics on a forgotten
|
||||
// error path.
|
||||
func WriteProblem(w http.ResponseWriter, p Problem) {
|
||||
if p.Status == 0 {
|
||||
p = ServerInternal("unspecified error")
|
||||
}
|
||||
w.Header().Set("Content-Type", ProblemContentType)
|
||||
w.WriteHeader(p.Status)
|
||||
// Marshaling can only fail on un-encodable types; Problem only
|
||||
// uses primitives + slices so json.Marshal cannot fail. The
|
||||
// _ = ... discard mirrors how response.go handles json.Encoder
|
||||
// errors.
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProblem_Malformed_Shape(t *testing.T) {
|
||||
p := Malformed("payload was not valid JSON")
|
||||
if p.Status != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", p.Status, http.StatusBadRequest)
|
||||
}
|
||||
if p.Type != "urn:ietf:params:acme:error:malformed" {
|
||||
t.Errorf("type = %q", p.Type)
|
||||
}
|
||||
if p.Detail != "payload was not valid JSON" {
|
||||
t.Errorf("detail = %q", p.Detail)
|
||||
}
|
||||
// Subproblems and Identifier are Phase-2 extensions; both stay empty
|
||||
// for a Phase-1a-emitted problem.
|
||||
if len(p.Subproblems) != 0 {
|
||||
t.Errorf("subproblems should be empty; got %v", p.Subproblems)
|
||||
}
|
||||
if p.Identifier != nil {
|
||||
t.Errorf("identifier should be nil; got %+v", p.Identifier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblem_AllHelperShapes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
p Problem
|
||||
wantType string
|
||||
wantStatus int
|
||||
}{
|
||||
{"Malformed", Malformed("x"), "urn:ietf:params:acme:error:malformed", http.StatusBadRequest},
|
||||
{"ServerInternal", ServerInternal("x"), "urn:ietf:params:acme:error:serverInternal", http.StatusInternalServerError},
|
||||
{"UserActionRequired", UserActionRequired("x"), "urn:ietf:params:acme:error:userActionRequired", http.StatusForbidden},
|
||||
{"AccountDoesNotExist", AccountDoesNotExist("x"), "urn:ietf:params:acme:error:accountDoesNotExist", http.StatusBadRequest},
|
||||
{"BadNonce", BadNonce("x"), "urn:ietf:params:acme:error:badNonce", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.p.Type != tc.wantType {
|
||||
t.Errorf("type = %q, want %q", tc.p.Type, tc.wantType)
|
||||
}
|
||||
if tc.p.Status != tc.wantStatus {
|
||||
t.Errorf("status = %d, want %d", tc.p.Status, tc.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblem_UnsupportedContentType(t *testing.T) {
|
||||
p := UnsupportedContentType("application/json")
|
||||
if p.Status != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("status = %d, want 415", p.Status)
|
||||
}
|
||||
if p.Type != "about:blank" {
|
||||
t.Errorf("UnsupportedContentType uses RFC 7807 about:blank; got %q", p.Type)
|
||||
}
|
||||
if !strings.Contains(p.Detail, "application/json") {
|
||||
t.Errorf("detail should echo content-type; got %q", p.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProblem_Headers(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
WriteProblem(rec, Malformed("oops"))
|
||||
|
||||
if got, want := rec.Code, http.StatusBadRequest; got != want {
|
||||
t.Errorf("status = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := rec.Header().Get("Content-Type"), ProblemContentType; got != want {
|
||||
t.Errorf("content-type = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
var p Problem
|
||||
if err := json.NewDecoder(rec.Body).Decode(&p); err != nil {
|
||||
t.Fatalf("Decode: %v", err)
|
||||
}
|
||||
if p.Type != "urn:ietf:params:acme:error:malformed" {
|
||||
t.Errorf("decoded type = %q", p.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteProblem_NilStatusFallsBackTo500(t *testing.T) {
|
||||
// Defensive check: a hand-constructed Problem with Status=0 (e.g.
|
||||
// from a forgotten error path) still renders cleanly as 500 +
|
||||
// serverInternal rather than emitting an HTTP/0 response.
|
||||
rec := httptest.NewRecorder()
|
||||
WriteProblem(rec, Problem{})
|
||||
|
||||
if got, want := rec.Code, http.StatusInternalServerError; got != want {
|
||||
t.Errorf("status = %d, want %d", got, want)
|
||||
}
|
||||
if got, want := rec.Header().Get("Content-Type"), ProblemContentType; got != want {
|
||||
t.Errorf("content-type = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NonceStore is the persistence-layer contract for ACME nonces. The
|
||||
// production implementation lives at internal/repository/postgres/acme.go
|
||||
// and is DB-backed (NOT in-memory) — replay protection requires the
|
||||
// store to outlast the client's nonce caching window.
|
||||
//
|
||||
// Issue creates a new nonce and stores it with a TTL. The string return
|
||||
// is what the handler echoes in the Replay-Nonce response header.
|
||||
//
|
||||
// Consume marks a nonce used and returns an error if the nonce is
|
||||
// missing, already used, or expired. The handler maps that error to
|
||||
// urn:ietf:params:acme:error:badNonce per RFC 8555 §6.5.1.
|
||||
//
|
||||
// Phase 1a: Issue is wired (every directory + new-nonce response carries
|
||||
// a Replay-Nonce header). Consume is exposed but not yet invoked —
|
||||
// JWS-authenticated POSTs (which consume nonces) arrive in Phase 1b.
|
||||
type NonceStore interface {
|
||||
Issue(ctx context.Context, ttl time.Duration) (string, error)
|
||||
Consume(ctx context.Context, nonce string) error
|
||||
}
|
||||
|
||||
// nonceByteLen is 32 bytes (256 bits) of entropy. RFC 8555 §6.5.1 only
|
||||
// requires nonces to be hard-to-guess; 256 bits is overkill on purpose
|
||||
// (matches the consumer-side ACME library + every other ACME server).
|
||||
const nonceByteLen = 32
|
||||
|
||||
// GenerateNonce returns 32 cryptographically-random bytes encoded as
|
||||
// base64url-no-padding per RFC 7515 §2 (the encoding ACME wire format
|
||||
// uses for the protected-header nonce field).
|
||||
func GenerateNonce() (string, error) {
|
||||
b := make([]byte, nonceByteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) certctl
|
||||
// SPDX-License-Identifier: BSL-1.1
|
||||
|
||||
package acme
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateNonce_LengthAndCharset(t *testing.T) {
|
||||
n, err := GenerateNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateNonce: %v", err)
|
||||
}
|
||||
// base64.RawURLEncoding emits ceil(N*8/6) chars = ceil(32*8/6) = 43.
|
||||
if got, want := len(n), 43; got != want {
|
||||
t.Errorf("nonce length = %d, want %d", got, want)
|
||||
}
|
||||
// Charset must decode under base64url-no-padding.
|
||||
raw, err := base64.RawURLEncoding.DecodeString(n)
|
||||
if err != nil {
|
||||
t.Fatalf("nonce did not decode under base64url-no-padding: %v", err)
|
||||
}
|
||||
if len(raw) != nonceByteLen {
|
||||
t.Errorf("decoded nonce = %d bytes, want %d", len(raw), nonceByteLen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateNonce_Distinct(t *testing.T) {
|
||||
// Statistical sanity check, NOT cryptographic strength proof.
|
||||
// 256 bits of entropy means the probability of two consecutive
|
||||
// values colliding is ~2^-256 — well below the threshold for a
|
||||
// flaky-test-on-the-cosmos timeline.
|
||||
a, err := GenerateNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateNonce a: %v", err)
|
||||
}
|
||||
b, err := GenerateNonce()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateNonce b: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Errorf("two consecutive nonces collided: %q", a)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user