Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,9 @@ boundary without touching the service layer.
|---|---|---|
| Auth | Static API key (`Authorization: Bearer <apiKey>` and `Authorization: sso-key <apiKey>:<apiSecret>`) and OIDC | `port.Authenticator` |
| Identity certificate issuance | File-backed self-signed CA | `port.IdentityCertificateAuthority` |
| Server certificate issuance | File-backed self-signed CA (`serverCsrPEM` path) and BYOC (`serverCertificatePEM` + chain). Exactly one per registration/renewal. | `port.ServerCertificateAuthority` |
| Server certificate issuance | File-backed self-signed CA (`ca.server.type: self`) or external RFC 8555 ACME CA such as Let's Encrypt (`ca.server.type: acme`) for the `serverCsrPEM` path, plus BYOC (`serverCertificatePEM` + chain). Exactly one of CSR/BYOC per registration/renewal. Issuance runs through a certificate-order lifecycle: `CreateOrder` (at registration/renewal submission) returns the provider's domain-control challenges, which are relayed verbatim to the domain owner — ANS never writes DNS or serves challenge files on their behalf; `FinalizeOrder` (at verify-acme, gated on a verified challenge artifact) returns the cert, or `ErrOrderPending` for asynchronous providers (ACME CAs such as Let's Encrypt), in which case re-POSTing verify-acme re-drives the order. | `port.ServerCertificateIssuer` |
| DNS verification | `noop` (quickstart; accepts any state) and `lookup` (real miekg/dns queries with TXT / TLSA / HTTPS support; TLSA responses carry the resolver's DNSSEC AuthenticatedData bit through to the TL attestation as `dnsRecordsProvisioned[].dnssecVerified`) | `port.DNSVerifier` |
| HTTP-01 challenge verification | Plain-HTTP fetch of the owner-published challenge artifact (`/.well-known/acme-challenge/<token>` by default). The verify-acme gate passes when either the DNS-01 TXT record or the HTTP-01 resource verifies. | `port.HTTPChallengeVerifier` |
| Signing keys | File-based ECDSA P-256 PEM | `port.KeyManager` |
| Storage (RA) | SQLite | `port.AgentStore`, `port.CertificateStore`, `port.RenewalStore`, `port.OutboxStore`, `port.UnitOfWork` |
| Storage (TL) | SQLite + Tessera POSIX tile storage | `tl/event` codec interfaces |
Expand Down Expand Up @@ -197,7 +198,7 @@ verify-dns flow without touching real DNS infrastructure.
- `internal/domain/` + `internal/crypto/` — pure logic; 100%
coverage expected.
- `internal/port/` — adapter interfaces (KeyManager, AgentStore,
DNSVerifier, ServerCertificateAuthority, …).
DNSVerifier, ServerCertificateIssuer, …).
- `internal/adapter/` — concrete adapters (SQLite, file-KMS, OIDC,
static-key auth, miekg/dns, self-signed CA, docsui, …).
- `internal/ra/` + `internal/tl/` — service layer and HTTP
Expand Down
18 changes: 13 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,22 @@ test:

test-cover:
@echo "Running tests with coverage..."
@# Exclude cmd/* from the instrumented set. The three command
@# binaries (ans-ra, ans-tl, ans-verify) are thin glue: flag
@# parsing, config loading, dependency wiring, then hand off to
@# library code under internal/. We don't write unit tests for
@# Exclude cmd/* from the instrumented set. The four command
@# binaries (ans-ra, ans-tl, ans-verify, ans-dns) are thin glue:
@# flag parsing, config loading, dependency wiring, then hand off
@# to library code under internal/. We don't write unit tests for
@# main() — counting those ~30 unexercised statements toward the
@# 90% gate would only penalize real logic coverage. The library
@# packages under internal/ are where the gate has teeth.
@pkgs=$$(go list ./... | grep -v '/cmd/' | tr '\n' ',' | sed 's/,$$//'); \
@#
@# Exclude acmetest for the same reason: it is a test double (an
@# in-process fake RFC 8555 server) imported only by _test.go
@# files and never compiled into a production binary. Its fault-
@# injection knobs are exercised selectively per test, so counting
@# its unused branches as "production" statements would penalize
@# real coverage exactly the way main() would. Test scaffolding is
@# not the system under test.
@pkgs=$$(go list ./... | grep -v -e '/cmd/' -e '/acmetest' | tr '\n' ',' | sed 's/,$$//'); \
go test ./... -count=1 -coverpkg=$$pkgs -coverprofile=coverage.out -covermode=atomic
@go tool cover -func=coverage.out
@echo ""
Expand Down
65 changes: 48 additions & 17 deletions cmd/ans-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (

"github.com/godaddy/ans/internal/adapter/auth"
"github.com/godaddy/ans/internal/adapter/cert"
"github.com/godaddy/ans/internal/adapter/challenge"
"github.com/godaddy/ans/internal/adapter/dns"
"github.com/godaddy/ans/internal/adapter/docsui"
"github.com/godaddy/ans/internal/adapter/eventbus"
Expand Down Expand Up @@ -123,24 +124,21 @@ func run(cfgPath string) error {
if err != nil {
return fmt.Errorf("init identity ca: %w", err)
}
// Optional server CA — enables the serverCsrPEM path at
// registration and renewal. When the config block is absent the
// RA accepts only BYOC (serverCertificatePEM).
var serverCA port.ServerCertificateAuthority
if cfg.CA.Server != nil && cfg.CA.Server.DataDir != "" {
sca, caErr := cert.NewServerSelfCA(
cfg.CA.Server.DataDir, cfg.CA.Server.Org, cfg.CA.Server.ValidityDays)
if caErr != nil {
return fmt.Errorf("init server ca: %w", caErr)
// Optional server certificate issuer — enables the serverCsrPEM
// path at registration and renewal. When the config block is
// absent the RA accepts only BYOC (serverCertificatePEM).
// `ca.server.type` selects the adapter: "self" (default) is the
// in-process self-signed CA; "acme" is an external RFC 8555
// provider such as Let's Encrypt. Both implement the same
// port.ServerCertificateIssuer order lifecycle.
var serverCA port.ServerCertificateIssuer
if cfg.CA.Server != nil {
serverCA, err = buildServerIssuer(cfg.CA.Server, logger)
if err != nil {
return err
}
serverCA = sca
logger.Info().
Str("dataDir", cfg.CA.Server.DataDir).
Str("org", cfg.CA.Server.Org).
Int("validityDays", cfg.CA.Server.ValidityDays).
Msg("server CA ready — serverCsrPEM path enabled")
} else {
logger.Info().Msg("no server CA configured — serverCsrPEM path disabled (BYOC-only)")
logger.Info().Msg("no server issuer configured — serverCsrPEM path disabled (BYOC-only)")
}
// In local-dev, accept self-signed BYOC certs. Production must
// remove WithSkipChainVerify in its config factory.
Expand Down Expand Up @@ -171,7 +169,8 @@ func run(cfgPath string) error {
KeyID: signerKeyID,
RaID: cfg.Signer.RaID,
}).WithDNSVerifier(dnsVerifier).
WithServerCertificateAuthority(serverCA).
WithHTTPChallengeVerifier(challenge.NewHTTPVerifier()).
WithServerCertificateIssuer(serverCA).
WithTLPublicBaseURL(cfg.TLClient.PublicBaseURL)

// HTTP.
Expand Down Expand Up @@ -295,6 +294,12 @@ func run(cfgPath string) error {
go service.RunExpiryChecker(expctx, renewals, certsStore, logger, service.ExpiryCheckerOptions{
Interval: 5 * time.Minute,
})
// Registration-side twin: PENDING_VALIDATION registrations whose
// challenge window lapsed flip to EXPIRED, per the spec's
// "not cancellable and will auto-expire" contract.
go service.RunAgentExpiryChecker(expctx, agents, logger, service.ExpiryCheckerOptions{
Interval: 5 * time.Minute,
})
defer expCancel()

addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
Expand Down Expand Up @@ -399,6 +404,32 @@ type providerWithAnonymous interface {
Middleware() func(http.Handler) http.Handler
}

// buildServerIssuer builds the configured server certificate issuer.
// Config validation has already checked the per-type required fields.
func buildServerIssuer(s *config.CAServer, logger zerolog.Logger) (port.ServerCertificateIssuer, error) {
if s.IsACME() {
issuer, err := cert.NewACMEIssuer(s.ACME.DirectoryURL, s.ACME.Email, s.ACME.DataDir)
if err != nil {
return nil, fmt.Errorf("init acme issuer: %w", err)
}
logger.Info().
Str("directoryURL", s.ACME.DirectoryURL).
Str("dataDir", s.ACME.DataDir).
Msg("ACME issuer ready — serverCsrPEM path enabled (provider-issued certs)")
return issuer, nil
}
sca, err := cert.NewServerSelfCA(s.DataDir, s.Org, s.ValidityDays)
if err != nil {
return nil, fmt.Errorf("init server ca: %w", err)
}
logger.Info().
Str("dataDir", s.DataDir).
Str("org", s.Org).
Int("validityDays", s.ValidityDays).
Msg("server CA ready — serverCsrPEM path enabled")
return sca, nil
}

// selectDNSVerifier returns the configured DNS adapter. Returns a
// port.DNSVerifier so the service layer can wire it directly.
//
Expand Down
29 changes: 29 additions & 0 deletions config/ra-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,35 @@ ca:
org: "ANS Local Dev CA"
validity-days: 365
data-dir: "./data/ra/ca"
# server: optional server certificate issuer. When omitted the RA
# accepts only BYOC server certs (serverCertificatePEM). Two
# adapter types implement the same order lifecycle:
#
# In-process self-signed CA (type self, the default):
# server:
# type: self
# org: "ANS Local Dev Server CA"
# validity-days: 365
# data-dir: "./data/ra/server-ca"
#
# External RFC 8555 CA — Let's Encrypt et al. (type acme). Use the
# staging directory for testing; production rate limits are
# unforgiving. Selecting this type auto-accepts the provider's
# terms of service on account registration. The relayed challenges
# in pending responses become the provider's own (token + key
# authorization + computed DNS digest) — the domain owner publishes
# them, exactly as with the self-signed issuer.
#
# REQUIRES dns.type: lookup (below). With a real public CA the
# verify-acme gate must check the owner's published artifact before
# answering the provider; the noop verifier would answer blindly and
# invalidate every order, so config validation rejects acme + noop.
# server:
# type: acme
# acme:
# directory-url: "https://acme-staging-v02.api.letsencrypt.org/directory"
# email: "ops@example.com"
# data-dir: "./data/ra/acme"

dns:
# "noop" (default) accepts any DNS state — the quickstart demo uses
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/rs/zerolog v1.35.1
github.com/stretchr/testify v1.11.1
github.com/transparency-dev/tessera v1.0.2
golang.org/x/crypto v0.50.0
golang.org/x/mod v0.36.0
modernc.org/sqlite v1.51.0
)
Expand Down Expand Up @@ -49,7 +50,6 @@ require (
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
Expand Down
Loading