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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ Thumbs.db
# Dependency directories
vendor/
AGENTS.md

# Demo outputs
scripts/demo/vlei/signify/out/
18 changes: 10 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,16 @@ 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
@# 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 cmd/* and scripts/* from the instrumented set. The
@# 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/. scripts/* holds demo-side
@# tooling (e.g. the signproof helper) exercised end-to-end by
@# the scripts/demo lifecycle runs, not unit tests. Counting
@# either's unexercised main() 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 -e '/cmd/' -e '/scripts/' | tr '\n' ',' | sed 's/,$$//'); \
go test ./... -count=1 -coverpkg=$$pkgs -coverprofile=coverage.out -covermode=atomic
@go tool cover -func=coverage.out
@echo ""
Expand Down
91 changes: 90 additions & 1 deletion cmd/ans-ra/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ import (

"github.com/godaddy/ans/internal/adapter/auth"
"github.com/godaddy/ans/internal/adapter/cert"
"github.com/godaddy/ans/internal/adapter/didresolver"
"github.com/godaddy/ans/internal/adapter/dns"
"github.com/godaddy/ans/internal/adapter/docsui"
"github.com/godaddy/ans/internal/adapter/eventbus"
"github.com/godaddy/ans/internal/adapter/keymanager"
"github.com/godaddy/ans/internal/adapter/leiverifier"
"github.com/godaddy/ans/internal/adapter/store/sqlite"
"github.com/godaddy/ans/internal/adapter/tlclient"
"github.com/godaddy/ans/internal/config"
Expand Down Expand Up @@ -95,6 +97,8 @@ func run(cfgPath string) error {
byoc := sqlite.NewByocCertificateStore(db)
renewals := sqlite.NewRenewalStore(db)
outbox := sqlite.NewOutboxStore(db)
identityStore := sqlite.NewIdentityStore(db)
identityLinks := sqlite.NewIdentityLinkStore(db)

// Crypto.
km, err := keymanager.NewFileKeyManager(cfg.Keys.File.Path)
Expand Down Expand Up @@ -149,6 +153,19 @@ func run(cfgPath string) error {
// DNS verifier.
var dnsVerifier = selectDNSVerifier(cfg)

// did:web resolver — noop (quickstart) or hardened web fetch,
// the identity surface's analog of the DNS verifier selection.
didResolver := selectDIDResolver(cfg)
// vLEI control verifier — noop (quickstart) or HTTP client for an internal vlei-verifier,
// the lei kind's analog of
// the did:web resolver selection.
leiVerifier := selectLEIVerifier(cfg)
logger.Info().
Str("resolver", cfg.Identity.Resolver.Type).
Str("vleiVerifier", cfg.VLEI.Type).
Dur("challengeTTL", cfg.Identity.ChallengeTTL).
Msg("verified-identity surface configured")

logger.Info().
Str("tlPublicBaseURL", cfg.TLClient.PublicBaseURL).
Str("tlBaseURL", cfg.TLClient.BaseURL).
Expand All @@ -174,6 +191,30 @@ func run(cfgPath string) error {
WithServerCertificateAuthority(serverCA).
WithTLPublicBaseURL(cfg.TLClient.PublicBaseURL)

// Verified identities — the "who" behind the agents. Shares the
// producer signer with the registration service: one RA, one
// producer identity on every TL lane. Identity events seal
// SYNCHRONOUSLY (seal-before-success, design §5.6.1): they never
// ride the outbox, so the service gets the TL client directly. A
// disabled TL client means identity sealing operations fail
// closed with TL_UNAVAILABLE — there is no "seal later" mode.
var identitySealer service.IdentityEventSealer
if !cfg.TLClient.Disabled {
identitySealer = tlclient.New(cfg.TLClient.BaseURL, cfg.TLClient.APIKey, cfg.TLClient.Timeout)
} else {
logger.Warn().Msg("TL client disabled — identity verify/revoke/link operations will fail with TL_UNAVAILABLE (seal-before-success)")
}
identitySvc := service.NewIdentityService(
identityStore, identityLinks, agents, didResolver, identitySealer, leiVerifier, db,
).WithSigner(service.EventSigner{
KeyManager: km,
KeyID: signerKeyID,
RaID: cfg.Signer.RaID,
}).WithChallengeTTL(cfg.Identity.ChallengeTTL).
WithRegisterRateLimit(cfg.Identity.RegisterRateLimit).
WithLinkRateLimit(cfg.Identity.LinkRateLimit).
WithSealTimeout(cfg.Identity.SealTimeout)

// HTTP.
r := chi.NewRouter()
r.Use(middleware.Recoverer)
Expand Down Expand Up @@ -203,7 +244,7 @@ func run(cfgPath string) error {
// routes (GET) 404 on not-owned to hide existence; write routes
// (POST) 403 so authenticated operators understand it's an
// authorization failure (spec §26, §370).
lifeH := handler.NewLifecycleHandler(regSvc)
lifeH := handler.NewLifecycleHandler(regSvc).WithIdentityViews(identitySvc)
r.Get("/v2/ans/agents", lifeH.List)

readOwnership := ramiddleware.ReadOwnership(agents)
Expand All @@ -226,6 +267,21 @@ func run(cfgPath string) error {
r.With(writeOwnership).Delete("/v2/ans/agents/{agentId}/certificates/server/renewal", lifeH.CancelServerCertRenewal)
r.With(writeOwnership).Post("/v2/ans/agents/{agentId}/certificates/server/renewal/verify-acme", lifeH.VerifyRenewalACME)

// Verified-identity routes (V2 only — the V1 lane is frozen).
// Ownership is enforced inside the IdentityService (reads hide
// existence with 404, writes split 404/403) because the owner
// gate is the link mechanism's security boundary and the service
// loads the aggregate anyway.
idH := handler.NewIdentityHandler(identitySvc)
r.Post("/v2/ans/identities", idH.Register)
r.Get("/v2/ans/identities", idH.List)
r.Get("/v2/ans/identities/{identityId}", idH.Detail)
r.Put("/v2/ans/identities/{identityId}", idH.Rotate)
r.Post("/v2/ans/identities/{identityId}/verify-control", idH.VerifyControl)
r.Post("/v2/ans/identities/{identityId}/revoke", idH.Revoke)
r.Post("/v2/ans/identities/{identityId}/links", idH.Link)
r.Delete("/v2/ans/identities/{identityId}/links/{agentId}", idH.Unlink)

// V1 RA surface — byte-for-byte parity with the reference V1 API
// spec. Shares the same RegistrationService as the V2 routes;
// only the DTO marshalling + TL-emit schema version differ. See
Expand Down Expand Up @@ -419,3 +475,36 @@ func selectDNSVerifier(cfg *config.RAConfig) port.DNSVerifier {
return dns.NewNoopVerifier()
}
}

// selectDIDResolver returns the configured did:web resolver adapter —
// the identity surface's analog of selectDNSVerifier. "web" performs
// the hardened HTTPS fetch (WebPKI + SSRF dialer guards); the default
// "noop" synthesizes documents from the submitted proofs' embedded
// keys for self-contained local development.
func selectDIDResolver(cfg *config.RAConfig) port.DIDResolver {
switch cfg.Identity.Resolver.Type {
case "web":
return didresolver.NewWebResolver()
default:
return didresolver.NewNoopResolver()
}
}

// selectLEIVerifier returns the configured vLEI control verifier — the
// lei kind's analog of selectDIDResolver. "verifier" is the hardened
// HTTP client for an internal vlei-verifier (config-validated base
// URL); the default "noop" runs real Ed25519 crypto over the signing
// input but waives the GLEIF authorization binding, for self-contained
// local development.
func selectLEIVerifier(cfg *config.RAConfig) port.LEIControlVerifier {
switch cfg.VLEI.Type {
case "verifier":
var opts []leiverifier.VerifierOption
if cfg.VLEI.PresentTimeout > 0 {
opts = append(opts, leiverifier.WithTimeout(cfg.VLEI.PresentTimeout))
}
return leiverifier.NewVerifier(cfg.VLEI.BaseURL, opts...)
default:
return leiverifier.NewNoop()
}
}
3 changes: 2 additions & 1 deletion cmd/ans-tl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func run(cfgPath string) error {
// underlying Tessera reader gets torn down.
defer logSvc.Close()
badgeSvc := service.NewBadgeService(logSvc)
identityBadgeSvc := service.NewIdentityBadgeService(logSvc, badgeSvc)

// Receipt + status-token generators reuse the single signing
// key. Matches the reference TL's deployed topology: one KMS key
Expand Down Expand Up @@ -253,7 +254,7 @@ func run(cfgPath string) error {
}

h := handler.NewHandlers(
logSvc, badgeSvc, receiptSvc, statusTokenSvc,
logSvc, badgeSvc, identityBadgeSvc, receiptSvc, statusTokenSvc,
checkpointSvc, schemaSvc, rootKeysBody,
)
h.Mount(r, lg.DataDir())
Expand Down
44 changes: 44 additions & 0 deletions config/ra-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,50 @@ dns:
type: noop
# server: "127.0.0.1:15353"

identity:
# Verified identities — the "who" behind agents, proven through a
# challenge-bound key proof and sealed on their own TL stream.
# The did:web resolver mirrors the DNS verifier's noop/real split:
# "noop" — no I/O; the DID document is synthesized from the keys
# embedded in the submitted proofs' `jwk` headers.
# Signature verification still genuinely runs; only the
# "does the live did.json list this key" binding is
# waived. Quickstart only — NOT for production.
# "web" — hardened HTTPS fetch of the DID document: WebPKI
# validation, SSRF dialer guards, 5s timeout, bounded
# same-site redirects.
# did:key identities never resolve anything — the key decodes from
# the identifier itself, so they work fully in both modes.
resolver:
type: noop
# challenge-ttl bounds the verify-control nonce (default 1h; 5m is
# the recommended floor for high-assurance deployments).
challenge-ttl: 1h
# register-rate-limit caps per-owner register/rotate calls per
# minute (each can trigger an outbound did:web fetch).
register-rate-limit: 10

vlei:
# The lei (vLEI) control verifier behind the `lei` identifier kind,
# mirroring the DNS verifier's and did:web resolver's noop/real split:
# "noop" — no I/O; runs REAL Ed25519 crypto over the served
# signingInput, but waives the GLEIF/vlei-verifier
# authorization binding (anyone can present any LEI).
# Zero-infra quickstart only — NOT for production.
# "verifier" — a hardened HTTP client for an internal GLEIF
# vlei-verifier (present / authorize / verify). The RA
# never parses KERI key state itself; the verifier is
# the authoritative key-state oracle. REQUIRED for the
# real end-to-end flow that scripts/demo/vlei/up.sh
# brings up (keria, vlei-server, vlei-verifier,
# witnesses, signify).
type: noop
# base-url: required when type is "verifier" — the internal
# vlei-verifier service URL.
# base-url: "http://localhost:7676"
# present-timeout bounds each verifier HTTP request (default 5s).
# present-timeout: 5s

keys:
type: file
file:
Expand Down
Loading