From 44d0d95b9f89331b95e60abd96ee73cea48063ec Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 17:07:08 +0000 Subject: [PATCH 01/10] Adopt the open GET /who and the verifier model for endorsements Syncs the registry to the revised GOBL Net spec: - /who serves the lookup's static self-signed party envelope with a plain GET (the old authenticated POST exchange is gone). - The scope claim is replaced by structural verification: a registration countersignature alone asserts a registered identity, and `gobl.lookup verify` marks a registration as identity-verified by re-countersigning with a `verifier` claim. --verifier names an external verifying authority (its own countersignature must already be on the stored envelope); the default is the lookup itself, whose single countersignature carries both attestations. - Registration records store `verifier` instead of `scope`; renewals with an unchanged party keep it, changed party data drops it (KYC must be repeated). The verifier's own countersignature may be much longer-lived than the 90-day registration cycle. - Generated keys floor valid_from to the second so signatures made within the same second verify. Pinned to the local gobl via a replace directive until the next gobl release; drop it before merging. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + README.md | 75 ++++++--- cmd/gobl.lookup/main.go | 2 +- cmd/gobl.lookup/verify.go | 30 +++- go.mod | 2 + go.sum | 2 - internal/domain/delivery/delivery_test.go | 5 +- internal/domain/errors.go | 5 +- internal/domain/identity.go | 100 +++++------- internal/domain/identity_test.go | 23 ++- internal/domain/models/identity.go | 15 +- internal/domain/models/registration.go | 3 +- internal/domain/registrations.go | 140 +++++++++++++---- internal/domain/repos/identity.go | 38 +---- internal/domain/repos/identity_test.go | 39 ----- internal/interfaces/web/web.go | 2 +- internal/interfaces/web/web_test.go | 181 +++++++++++++++------- internal/interfaces/web/who.go | 52 ++----- 18 files changed, 401 insertions(+), 318 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7249626..e4260b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ ### Changed +- Every Authority countersignature now carries a 90-day `exp` claim; parties renew by re-registering before it passes. A renewal with an unchanged party document (same digest) is countersigned at the party's current scope — `verified` stays `verified` — while changed party data drops back to `registered` and clears `verified_at`. +- `/.well-known/gobl/who` is now an open `GET` serving the lookup's self-signed party envelope (signed once per process, `Cache-Control: max-age=300`), replacing the authenticated `POST` exchange. +- Registration now requires the sender to serve its own public identity: the inbox resolves `GET /who` on the sender's address (re-fetching its published key) before countersigning. A `204 No Content` — a receive-only account — or an unresolvable identity rejects the registration with `403 Forbidden`. +- Allow-list support (`allow.json`, `models.Identity.Allow`) is removed; the sender `/who` check is the gate on registrations. +- Updated to the current `gobl` net API: `Envelope.Sign` options (`head.WithIssuer`/`WithAudience`/`WithScope`) and `net.Client.Who`. - Identity key files under `keys/` no longer need to be named after their kid — the kid is read from the JWK itself, and any `*.json` file is accepted (`Init` still writes `.json`). This lets a deployment mount the published key at a fixed path (e.g. `keys/public.json`) without encoding the kid in the filename. - Persistence now uses the shared [`github.com/invopop/couch`](https://github.com/invopop/couch) library: `models.Registration` embeds `couch.Model` (gaining `created_at`/`updated_at` and revision handling), and the CouchDB store uses `couch.Client`/`couch.Store`/`couch.Fetch` and a `couch.Design` for the by-UUID view. The registration database is the couch client's prefix (`COUCHDB_DATABASE`). - Configuration is now read from the environment (`CONFIG_DIR`, `COUCHDB_URL` or the split `COUCHDB_SCHEME/HOST/PORT/USERNAME/PASSWORD`, `COUCHDB_DATABASE`, `HTTP_PORT`/`PORT`, `PUBLIC_BASE_URL`, `LOG_JSON`) so the service can be configured — and its CouchDB password injected from a secret — the way the cluster provides config. The equivalent CLI flags still work and override the environment. Env var names match the sibling services (silo/access). diff --git a/README.md b/README.md index 6eefc10..7431448 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ A reference [GOBL Net](https://github.com/invopop/gobl/blob/net/net/README.md) **Authority** registry service. Accepts party registrations from -GOBL Net nodes, countersigns each party envelope with an -Authority-level scope (`registered` → `verified` after KYC), and -posts the countersigned envelope back to the sender's own inbox. +GOBL Net nodes, countersigns each party envelope as the network's +default registration Authority (adding a `verifier` claim once the +party passes KYC/KYB), and posts the countersigned envelope back to +the sender's own inbox. > ⚠️ **EXPERIMENTAL** — GOBL Net is under active development. The > wire protocol may change without notice. @@ -20,17 +21,43 @@ Copyright 2026 [Invopop S.L.](https://invopop.com). and POSTs it to lookup's `/.well-known/gobl/inbox`. The envelope's signed `iss=gobl:alice.example`, `aud=gobl:lookup.gobl.org`. -2. Lookup verifies the signature, persists the envelope in - CouchDB, countersigns it with an Authority signature - (`iss=gobl:lookup.gobl.org`, `aud=gobl:alice.example`, - `scope=registered`), and POSTs the **countersigned** envelope - back to `https://alice.example/.well-known/gobl/inbox`. -3. Alice publishes the countersigned envelope on her `/who`. Any +2. Lookup verifies the signature, then confirms the sender checks + out as a *sending* participant: it performs `GET /who` on + `alice.example` (re-fetching her published key in the process) + and requires a verified, self-signed party. A `204 No Content` + marks a receive-only account and rejects the registration with + `403 Forbidden`. +3. Lookup persists the envelope in CouchDB, countersigns it with + an Authority signature (`iss=gobl:lookup.gobl.org`, + `aud=gobl:alice.example`, `exp` = 90 days + out), and POSTs the **countersigned** envelope back to + `https://alice.example/.well-known/gobl/inbox` as a follow-up + message. The original POST is acknowledged with the standard + empty `202 Accepted` used for all inbox deliveries. +4. Alice publishes the countersigned envelope on her `/who`. Any GOBL Net verifier can now confirm that lookup has attested to - her registration via `net.Client.VerifyAuthority`. + her registration via `net.Client.VerifyAuthority` / + `net.Client.VerifySender`. No new protocol endpoints — registration uses the standard GOBL -Net `/inbox` POST in both directions. +Net `/inbox` POST in both directions. A future revision will add a +link on the countersigned envelope pointing the subject at a full +KYC flow to mark the registration as verified (spec §5.3, the +`verifier` claim); for now the upgrade is operator-driven via +`gobl.lookup verify`. + +### Endorsement lifetime and renewal + +Every countersignature carries a 90-day `exp` claim — verifiers +reject it after that, so parties renew by re-registering before it +passes (Let's Encrypt style). A renewal that re-submits the +**unchanged** party document (same digest) is countersigned with +the party's current `verifier` claim: verified stays verified, +while the verifier's own longer-lived countersignature (a year or +more, in the spirit of EV certificates) continues to evidence the +KYC. Submitting changed party data is a fresh registration — the +verifier is dropped and KYC must be repeated. + ## Architecture @@ -42,7 +69,7 @@ internal/ config/ runtime configuration (populated from CLI flags) domain/ business logic, orchestrated by domain.Setup domain.go Setup: wires repos + services together - identity.go Identity service: countersign, /who exchange, keys + identity.go Identity service: countersign, /who identity, keys registrations.go Registrations service: register, verify, find errors.go typed domain errors (mapped to HTTP statuses) models/ data structures (Registration, Identity) @@ -62,7 +89,7 @@ The domain never imports the transport layer. | Method | Path | Purpose | |--------|-------------------------------------|----------------------------------------------------------| | POST | `/.well-known/gobl/inbox` | Registration entry — must carry an `org.Party` document. | -| POST | `/.well-known/gobl/who` | Authenticated mutual party exchange (lookup's identity). | +| GET | `/.well-known/gobl/who` | Lookup's public identity (self-signed party envelope). | | GET | `/.well-known/gobl/keys/` | Single published key. | | GET | `/.well-known/jwks.json` | Bulk JWK Set (for jwt.io-style tooling). | | GET | `/parties/
` | Public registration record by address. | @@ -99,7 +126,6 @@ registered address with id `registration:
`: { "_id": "registration:alice.example", "address": "alice.example", - "scope": "registered", "status": "delivered", "incoming_envelope_uuid": "...", "received_at": "2026-06-07T...", @@ -120,7 +146,7 @@ preserve the audit trail across re-registrations. |--------------------------------|-----------------------------------------------------------------------------------------| | `gobl.lookup init ` | Scaffold keypair + `party.json` + `keys/.json`. | | `gobl.lookup serve` | Run the HTTP server (terminates HTTP only; deploy behind a TLS proxy). | -| `gobl.lookup verify
` | Bump a registration to `head.ScopeVerified` after out-of-band KYC and re-deliver. | +| `gobl.lookup verify
` | Mark a registration as identity-verified after out-of-band KYC and re-deliver. `--verifier` names an external verifying authority (default: the lookup itself). | | `gobl.lookup version` | Print service + core gobl versions. | The top-level `--json` flag switches operator logs from text to @@ -153,18 +179,21 @@ arrives from a secret independently of the host). ## Operations -- **Re-registration**: a fresh registration for an existing - address resets `scope` to `registered` and clears `verified_at`. - Verifying again requires another `gobl.lookup verify
` - call. Audit trail lives in CouchDB revisions. +- **Re-registration vs renewal**: re-submitting the unchanged party + document (same digest) is a renewal — it keeps the current + `verifier` and `verified_at`, stamping a fresh 90-day + countersignature. Submitting **changed** party data drops the + `verifier` and clears `verified_at`; verifying again requires + another `gobl.lookup verify
` call. Audit trail lives in + CouchDB revisions. - **Delivery failures**: the inbox handler responds `202` once the record is persisted; delivery to the sender's `/inbox` happens asynchronously. Failures land on `Record.LastDeliveryError`; re-running `gobl.lookup verify` retries. -- **Allow-list**: the optional `/allow.json` (an array - of addresses) gates `/inbox` and `/who` requests. Empty / absent - means accept any verified caller — the right default for a - public registry. +- **Sender eligibility**: registrations are only accepted from + addresses that serve a verifiable `GET /who` identity of their + own. Receive-only accounts (whose `/who` returns `204`) cannot + register — they can receive documents without any registration. ## Deployment diff --git a/cmd/gobl.lookup/main.go b/cmd/gobl.lookup/main.go index 90c911c..c30653e 100644 --- a/cmd/gobl.lookup/main.go +++ b/cmd/gobl.lookup/main.go @@ -1,6 +1,6 @@ // gobl.lookup is the GOBL Net Authority registry service. It // accepts party registrations on the standard `/inbox` endpoint, -// countersigns the envelope with an Authority-level scope, and +// countersigns the envelope as a registration Authority, and // posts the result back to the sender's own `/inbox`. The // registry is backed by CouchDB. package main diff --git a/cmd/gobl.lookup/verify.go b/cmd/gobl.lookup/verify.go index e091cfe..5a6fcaa 100644 --- a/cmd/gobl.lookup/verify.go +++ b/cmd/gobl.lookup/verify.go @@ -8,7 +8,6 @@ import ( "github.com/spf13/cobra" "github.com/invopop/gobl" - "github.com/invopop/gobl/head" goblnet "github.com/invopop/gobl/net" "github.com/invopop/gobl.lookup/internal/config" @@ -17,13 +16,20 @@ import ( func verifyCmd() *cobra.Command { cfg := config.FromEnv() + var verifier string cmd := &cobra.Command{ Use: "verify
", - Short: "Bump a registration's scope to `verified` after out-of-band KYC", + Short: "Mark a registration as identity-verified after out-of-band KYC", Long: `Load the existing registration for
, countersign the -stored party envelope with head.ScopeVerified, deliver the new -envelope to the subject's /inbox, and update the registry record -(scope=verified, verified_at=now). +stored party envelope with a verifier claim naming the authority +that performed the KYC/KYB check, deliver the new envelope to the +subject's /inbox, and update the registry record (verifier=, +verified_at=now). + +By default the lookup names itself as the verifier, so its own +countersignature carries both attestations. Pass --verifier to name +an external verifying authority instead; that authority's own +countersignature must already be present on the stored envelope. The original Authority countersignature on the previous record remains in the audit history (CouchDB revisions). This command @@ -40,6 +46,13 @@ issues a fresh signature; the subject can publish either or both.`, if err != nil { return gobl.ErrInput.WithCause(err) } + var verifierAddr goblnet.Address + if verifier != "" { + verifierAddr, err = goblnet.ParseAddress(verifier) + if err != nil { + return gobl.ErrInput.WithCause(err) + } + } ctx := cmd.Context() setup, cleanup, err := buildDomain(ctx, cfg) @@ -48,7 +61,7 @@ issues a fresh signature; the subject can publish either or both.`, } defer cleanup() - rec, err := setup.Registrations().Verify(ctx, addr) + rec, err := setup.Registrations().Verify(ctx, addr, verifierAddr) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrValidation) { return gobl.ErrInput.WithCause(err) @@ -58,14 +71,15 @@ issues a fresh signature; the subject can publish either or both.`, slog.Info("verified registration", "address", string(addr), "envelope", rec.IncomingEnvelopeUUID.String(), - "scope", string(head.ScopeVerified), + "verifier", string(rec.Verifier), ) - _, _ = fmt.Fprintf(stdOut(cmd), "verified %s (envelope %s)\n", addr, rec.IncomingEnvelopeUUID) + _, _ = fmt.Fprintf(stdOut(cmd), "verified %s by %s (envelope %s)\n", addr, rec.Verifier, rec.IncomingEnvelopeUUID) return nil }, } cmd.Flags().StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "directory holding the lookup identity (env CONFIG_DIR)") cmd.Flags().StringVar(&cfg.CouchURL, "couchdb", cfg.CouchURL, "full CouchDB URL (env COUCHDB_URL; overrides the COUCHDB_* parts)") cmd.Flags().StringVar(&cfg.CouchDatabase, "couchdb-database", cfg.CouchDatabase, "CouchDB database name (env COUCHDB_DATABASE)") + cmd.Flags().StringVar(&verifier, "verifier", "", "address of the authority that performed the verification (default: the lookup itself)") return cmd } diff --git a/go.mod b/go.mod index 79b4c22..d594f7b 100644 --- a/go.mod +++ b/go.mod @@ -34,3 +34,5 @@ require ( golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/invopop/gobl => ../gobl diff --git a/go.sum b/go.sum index 1a6c46f..fa066b1 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/couch v0.1.0 h1:ctMKLeIxnab9KW11KtEAQSl7RzGgMxndavLphx99LpY= github.com/invopop/couch v0.1.0/go.mod h1:xzBNVglDnLcpf1Z9BJxiIG1liESSIkMuvIczSl4zcls= -github.com/invopop/gobl v0.403.1-0.20260607084143-424395a8cae9 h1:7VGn19ifza2A38AfojbZpyViNrv4QCQ8t55WSuyHCr0= -github.com/invopop/gobl v0.403.1-0.20260607084143-424395a8cae9/go.mod h1:TbKLNcMKzvCB57izBR4UkWG90z+DZ3KJu1DbASWEf4A= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= diff --git a/internal/domain/delivery/delivery_test.go b/internal/domain/delivery/delivery_test.go index 5d98057..dfb2f11 100644 --- a/internal/domain/delivery/delivery_test.go +++ b/internal/domain/delivery/delivery_test.go @@ -11,6 +11,7 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl/dsig" + "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" "github.com/invopop/gobl/note" "github.com/invopop/gobl/uuid" @@ -25,7 +26,9 @@ func buildEnvelope(t *testing.T) *gobl.Envelope { env, err := gobl.Envelop(msg) require.NoError(t, err) key := dsig.NewES256Key() - require.NoError(t, env.Sign(key, net.Address("alice.example").URI(), net.Address("lookup.example").URI())) + require.NoError(t, env.Sign(key, + head.WithIssuer(net.Address("alice.example").URI()), + head.WithAudience(net.Address("lookup.example").URI()))) return env } diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 2892f6e..e848c79 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -21,8 +21,9 @@ var ( // ErrUnauthorized is returned when an envelope's signature or // audience does not authenticate the caller. ErrUnauthorized = NewError("unauthorized") - // ErrForbidden is returned when an authenticated caller is not - // permitted by the allow-list. + // ErrForbidden is returned when an authenticated caller does not + // qualify (e.g. a registration from an address that publishes no + // public identity). ErrForbidden = NewError("forbidden") // ErrNotFound is returned when no record matches the request. ErrNotFound = NewError("not-found") diff --git a/internal/domain/identity.go b/internal/domain/identity.go index a70749a..579a1a8 100644 --- a/internal/domain/identity.go +++ b/internal/domain/identity.go @@ -1,10 +1,12 @@ package domain import ( - "context" + "encoding/json" "errors" "fmt" "log/slog" + "sync" + "time" "github.com/invopop/gobl" "github.com/invopop/gobl/cbc" @@ -17,13 +19,17 @@ import ( // Identity is the domain service wrapping the lookup's loaded // identity. It owns the signing behaviour (countersigning subject -// envelopes, signing the service's own party), backs the mutual /who -// exchange, and exposes the identity's published-key data to the +// envelopes, signing the service's own party), backs the open GET +// /who lookup, and exposes the identity's published-key data to the // transport layer. type Identity struct { model *models.Identity client *goblnet.Client log *slog.Logger + + partyOnce sync.Once + partyData []byte + partyErr error } // newIdentity wraps a loaded identity model. @@ -40,9 +46,6 @@ func (d *Identity) Address() goblnet.Address { return d.model.Address() } // URI returns the gobl: URI form of the lookup's address. func (d *Identity) URI() cbc.URI { return d.model.URI() } -// Allowed reports whether caller is permitted by the allow-list. -func (d *Identity) Allowed(caller goblnet.Address) bool { return d.model.Allowed(caller) } - // FindKey returns the published key with the given kid, or nil. func (d *Identity) FindKey(kid string) *dsig.PublicKey { return d.model.FindKey(kid) } @@ -52,49 +55,24 @@ func (d *Identity) JWKS() ([]byte, error) { return d.model.JWKS() } // PublicKeys returns every key the lookup has published. func (d *Identity) PublicKeys() []*dsig.PublicKey { return d.model.PublicKeys } -// PartyEnvelope returns the lookup's party wrapped in a freshly -// signed envelope, suitable for serving at /.well-known/gobl/who. -// `aud` is the caller's address (the /who exchange is mutual; the -// response is bound to the caller). -func (d *Identity) PartyEnvelope(aud cbc.URI) (*gobl.Envelope, error) { - env, err := gobl.Envelop(d.model.Party) - if err != nil { - return nil, fmt.Errorf("identity: envelop party: %w", err) - } - if err := env.Sign(d.model.PrivateKey, d.URI(), aud); err != nil { - return nil, fmt.Errorf("identity: sign party: %w", err) - } - return env, nil -} - -// Exchange backs the authenticated mutual party exchange (/who): it -// verifies the caller's signed envelope (which must be addressed to -// this lookup), applies the allow-list, and returns the lookup's own -// party wrapped in a fresh envelope bound to the caller. -func (d *Identity) Exchange(ctx context.Context, env *gobl.Envelope) (*gobl.Envelope, error) { - caller, err := d.client.VerifyEnvelope(ctx, env, d.URI()) - if err != nil { - d.log.Warn("who.rejected", "reason", "verify_failed", "error", err.Error()) - return nil, ErrUnauthorized.WithMessage("signature verification failed") - } - // Require an explicit aud match so the log carries the right - // reason (mirrors the inbox tightening). - p, err := head.SignedPayload(env.Signatures[0]) - if err != nil || p.Aud != d.URI() { - d.log.Warn("who.rejected", "reason", "aud_mismatch", "caller", string(caller)) - return nil, ErrUnauthorized.WithMessage("envelope audience does not match this lookup") - } - if !d.Allowed(caller) { - d.log.Warn("who.rejected", "reason", "not_allowed", "caller", string(caller)) - return nil, ErrForbidden.WithMessage("caller not accepted") - } - out, err := d.PartyEnvelope(caller.URI()) - if err != nil { - d.log.Error("who.sign_failed", "caller", string(caller), "error", err.Error()) - return nil, ErrInternal.WithCause(err) - } - d.log.Info("who.exchange", "caller", string(caller)) - return out, nil +// PartyEnvelope returns the JSON of the lookup's party wrapped in a +// self-signed envelope (iss = the lookup's address, no aud), served +// at GET /.well-known/gobl/who. The response is a static document: +// it is signed once per process and cached. +func (d *Identity) PartyEnvelope() ([]byte, error) { + d.partyOnce.Do(func() { + env, err := gobl.Envelop(d.model.Party) + if err != nil { + d.partyErr = fmt.Errorf("identity: envelop party: %w", err) + return + } + if err := env.Sign(d.model.PrivateKey, head.WithIssuer(d.URI())); err != nil { + d.partyErr = fmt.Errorf("identity: sign party: %w", err) + return + } + d.partyData, d.partyErr = json.Marshal(env) + }) + return d.partyData, d.partyErr } // CounterSignOptions configure a CounterSign call. @@ -103,22 +81,28 @@ type CounterSignOptions struct { // countersigned; copied into the signed `aud` field so the // resulting signature is bound to that specific subject. Subject goblnet.Address - // Scope is the Authority's confidence assertion (typically - // head.ScopeRegistered for an initial registration, - // head.ScopeVerified after KYC). Empty leaves Scope unset. - Scope cbc.Key + // Verifier names the authority that performed identity + // verification (KYC/KYB) of the subject, carried as the signed + // `verifier` claim. The lookup names itself when it performed the + // verification. Empty asserts registration only. + Verifier goblnet.Address } -// CounterSign adds a fresh Authority countersignature to env. The +// CounterSign adds a fresh Authority countersignature to env, valid +// for endorsementTTL (carried as the signed `exp` claim). The // envelope's UUID and Digest are unchanged — only the Signatures // slice grows. func (d *Identity) CounterSign(env *gobl.Envelope, opts CounterSignOptions) error { if env == nil || env.Head == nil { return errors.New("identity: cannot countersign a nil envelope") } - signOpts := []head.SignOption{} - if opts.Scope != "" { - signOpts = append(signOpts, head.WithScope(opts.Scope)) + signOpts := []head.SignOption{ + head.WithIssuer(d.URI()), + head.WithAudience(opts.Subject.URI()), + head.WithExpiration(time.Now().Add(endorsementTTL)), + } + if opts.Verifier != "" { + signOpts = append(signOpts, head.WithVerifier(opts.Verifier.URI())) } - return env.Sign(d.model.PrivateKey, d.URI(), opts.Subject.URI(), signOpts...) + return env.Sign(d.model.PrivateKey, signOpts...) } diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go index 18e3193..07134c5 100644 --- a/internal/domain/identity_test.go +++ b/internal/domain/identity_test.go @@ -1,6 +1,7 @@ package domain_test import ( + "encoding/json" "io" "log/slog" "testing" @@ -39,14 +40,22 @@ func newTestIdentity(t *testing.T) *domain.Identity { func TestPartyEnvelopeIsSelfSigned(t *testing.T) { id := newTestIdentity(t) - env, err := id.PartyEnvelope(cbc.URI("gobl:alice.example")) + data, err := id.PartyEnvelope() require.NoError(t, err) + env := new(gobl.Envelope) + require.NoError(t, json.Unmarshal(data, env)) require.Len(t, env.Signatures, 1) p, err := head.SignedPayload(env.Signatures[0]) require.NoError(t, err) assert.Equal(t, id.URI(), p.Iss) - assert.Equal(t, cbc.URI("gobl:alice.example"), p.Aud) + assert.Empty(t, p.Aud, "a GET who response has no caller to bind to") + + // The envelope is signed once and cached: a second call returns + // the identical bytes. + again, err := id.PartyEnvelope() + require.NoError(t, err) + assert.Equal(t, data, again) } func TestCounterSign(t *testing.T) { @@ -57,12 +66,14 @@ func TestCounterSign(t *testing.T) { msg.SetUUID(uuid.V7()) env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(id.Model().PrivateKey, cbc.URI("gobl:alice.example"), id.URI())) + require.NoError(t, env.Sign(id.Model().PrivateKey, + head.WithIssuer(cbc.URI("gobl:alice.example")), + head.WithAudience(id.URI()))) // Authority countersignature. require.NoError(t, id.CounterSign(env, domain.CounterSignOptions{ - Subject: net.Address("alice.example"), - Scope: head.ScopeRegistered, + Subject: net.Address("alice.example"), + Verifier: net.Address("kyc.example"), })) require.Len(t, env.Signatures, 2) @@ -70,7 +81,7 @@ func TestCounterSign(t *testing.T) { require.NoError(t, err) assert.Equal(t, id.URI(), p.Iss) assert.Equal(t, cbc.URI("gobl:alice.example"), p.Aud) - assert.Equal(t, head.ScopeRegistered, p.Scope) + assert.Equal(t, cbc.URI("gobl:kyc.example"), p.Verifier) } func TestCounterSignNilEnvelope(t *testing.T) { diff --git a/internal/domain/models/identity.go b/internal/domain/models/identity.go index 149efc8..282b54e 100644 --- a/internal/domain/models/identity.go +++ b/internal/domain/models/identity.go @@ -3,7 +3,6 @@ package models import ( "encoding/json" "fmt" - "slices" "sort" "github.com/invopop/gobl/cbc" @@ -27,11 +26,8 @@ type Identity struct { // /.well-known/gobl/keys/ and aggregated at /.well-known/jwks.json. PublicKeys []*dsig.PublicKey // Party is the lookup's own org.Party served at /.well-known/gobl/who. - // Stored unsigned on disk; signed per-request. + // Stored unsigned on disk; signed once at first serve. Party *org.Party - // Allow gates inbox / who requests by caller address. Empty means - // "accept any verified caller". - Allow []net.Address } // Address returns the lookup's address. @@ -40,15 +36,6 @@ func (i *Identity) Address() net.Address { return i.Domain } // URI returns the gobl: URI form of the lookup's address. func (i *Identity) URI() cbc.URI { return i.Domain.URI() } -// Allowed reports whether caller is permitted by the allow-list. An -// empty allow-list permits any caller. -func (i *Identity) Allowed(caller net.Address) bool { - if len(i.Allow) == 0 { - return true - } - return slices.Contains(i.Allow, caller) -} - // FindKey returns the public key whose kid matches, or nil. func (i *Identity) FindKey(kid string) *dsig.PublicKey { for _, k := range i.PublicKeys { diff --git a/internal/domain/models/registration.go b/internal/domain/models/registration.go index 1c1c18e..4ce73ae 100644 --- a/internal/domain/models/registration.go +++ b/internal/domain/models/registration.go @@ -7,7 +7,6 @@ import ( "github.com/invopop/couch" "github.com/invopop/gobl" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/net" "github.com/invopop/gobl/uuid" ) @@ -40,7 +39,7 @@ const ( type Registration struct { couch.Model Address net.Address `json:"address"` - Scope cbc.Key `json:"scope,omitempty"` + Verifier net.Address `json:"verifier,omitempty"` Status Status `json:"status"` IncomingEnvelopeUUID uuid.UUID `json:"incoming_envelope_uuid"` ReceivedAt time.Time `json:"received_at"` diff --git a/internal/domain/registrations.go b/internal/domain/registrations.go index cb5aa9f..cd7284b 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -21,9 +21,15 @@ import ( // deliveryTimeout bounds a single outbound POST to a subject's inbox. const deliveryTimeout = 30 * time.Second +// endorsementTTL is the lifetime of every Authority countersignature +// this lookup issues, carried as the signed `exp` claim. Subjects +// renew by re-registering before it passes; an unchanged party +// renews with its current verifier. +const endorsementTTL = 90 * 24 * time.Hour + // Registrations manages the business logic for party registrations: -// verifying an incoming envelope, countersigning it with the -// Authority scope, persisting the record, and delivering the result +// verifying an incoming envelope, countersigning it as the +// Authority, persisting the record, and delivering the result // back to the subject's own inbox. type Registrations struct { store RegistrationStore @@ -48,8 +54,9 @@ func newRegistrations(store RegistrationStore, identity *Identity, client *gobln // Register processes a registration request: a signed envelope // containing the sender's org.Party. It verifies the signature and -// audience, applies the allow-list, countersigns the envelope with -// head.ScopeRegistered, persists the record, and queues asynchronous +// audience, resolves the sender's own GET /who to confirm the address +// serves a public identity, countersigns the envelope as the +// Authority, persists the record, and queues asynchronous // delivery back to the sender's /inbox. The persisted record is // returned once stored; delivery happens in the background. func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*models.Registration, error) { @@ -78,11 +85,6 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode d.log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", string(p.Aud)) return nil, ErrUnauthorized.WithMessage("envelope audience does not match this lookup") } - if !d.identity.Allowed(sender) { - d.log.Warn("inbox.rejected", "reason", "not_allowed", "caller", string(sender)) - return nil, ErrForbidden.WithMessage("sender not accepted") - } - // Registration entry must carry an org.Party — that's the // document we're attesting to. if _, ok := env.Extract().(*org.Party); !ok { @@ -90,11 +92,32 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode return nil, ErrValidation.WithMessage("registration envelope must contain an org.Party document") } + // The subject of a registration is a *sending* participant, so it + // must serve a public identity of its own: GET /who on the sender + // (which also re-fetches its published key) must return a verified + // party. A 204 marks a receive-only account, which cannot register. + if _, err := d.client.Who(ctx, sender); err != nil { + reason := "who_failed" + msg := "could not resolve sender's public identity" + if errors.Is(err, goblnet.ErrNoContent) { + reason = "who_no_content" + msg = "sender does not publish a public identity; senders must serve GET /who" + } + d.log.Warn("inbox.rejected", "reason", reason, "caller", string(sender), "error", err.Error()) + return nil, ErrForbidden.WithMessage("%s", msg) + } + + // An unchanged party re-registering before its endorsement expires + // is a renewal and keeps its current verifier; anything else + // starts as registered only. + verifier, renewal := d.renewalVerifier(ctx, sender, env) + // Countersign: adds Authority signature with iss=lookup, - // aud=sender, scope=registered. UUID + digest unchanged. + // aud=sender, any preserved verifier claim, and a 90-day exp. + // UUID + digest unchanged. if err := d.identity.CounterSign(env, CounterSignOptions{ - Subject: sender, - Scope: head.ScopeRegistered, + Subject: sender, + Verifier: verifier, }); err != nil { d.log.Error("inbox.countersign_failed", "caller", string(sender), "error", err.Error()) return nil, ErrInternal.WithCause(err) @@ -113,7 +136,7 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode // Persist before delivery so the record exists even if the // downstream POST fails. - rec, err := d.upsert(ctx, sender, env) + rec, err := d.upsert(ctx, sender, env, verifier, renewal) if err != nil { d.log.Error("inbox.persist_failed", "caller", string(sender), "error", err.Error()) return nil, ErrInternal.WithCause(err) @@ -121,7 +144,8 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode d.log.Info("inbox.accepted", "caller", string(sender), "envelope", env.Head.UUID.String(), - "scope", string(head.ScopeRegistered), + "verifier", string(verifier), + "renewal", renewal, ) // Fire-and-forget delivery: the caller is acknowledged as soon @@ -134,10 +158,16 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode return rec, nil } -// Verify bumps an existing registration's scope to head.ScopeVerified -// after out-of-band KYC, re-countersigns the stored envelope, and -// delivers it synchronously to the subject's inbox. -func (d *Registrations) Verify(ctx context.Context, addr goblnet.Address) (*models.Registration, error) { +// Verify marks an existing registration as identity-verified after +// out-of-band KYC/KYB: it re-countersigns the stored envelope with a +// `verifier` claim naming the verifying authority and delivers it +// synchronously to the subject's inbox. An empty verifier defaults to +// the lookup itself — its own countersignature then serves as both +// attestations (spec §5.3). Naming an external verifier requires that +// verifier's countersignature to already be present on the stored +// envelope, since only its own signature can evidence the +// verification. +func (d *Registrations) Verify(ctx context.Context, addr, verifier goblnet.Address) (*models.Registration, error) { rec, err := d.store.Get(ctx, addr) if errors.Is(err, repos.ErrNotFound) { return nil, ErrNotFound.WithMessage("no registration for %s", addr) @@ -150,15 +180,22 @@ func (d *Registrations) Verify(ctx context.Context, addr goblnet.Address) (*mode } env := rec.CountersignedEnvelope - // Stamp a fresh Authority signature with the verified scope onto - // the existing envelope. + if verifier == "" { + verifier = d.identity.Address() + } + if verifier != d.identity.Address() && !carriesSignatureFrom(env, verifier) { + return nil, ErrValidation.WithMessage("verifier %s has not countersigned the registration envelope", verifier) + } + + // Stamp a fresh Authority signature carrying the verifier claim + // onto the existing envelope. if err := d.identity.CounterSign(env, CounterSignOptions{ - Subject: addr, - Scope: head.ScopeVerified, + Subject: addr, + Verifier: verifier, }); err != nil { return nil, ErrInternal.WithCause(err) } - rec.Scope = head.ScopeVerified + rec.Verifier = verifier now := time.Now().UTC() rec.VerifiedAt = &now rec.Status = models.StatusCountersigned @@ -183,11 +220,27 @@ func (d *Registrations) Verify(ctx context.Context, addr goblnet.Address) (*mode d.log.Info("verified registration", "address", string(addr), "envelope", env.Head.UUID.String(), - "scope", string(head.ScopeVerified), + "verifier", string(verifier), ) return rec, nil } +// carriesSignatureFrom reports whether the envelope has a signature +// whose signed iss names addr. Presence only — consumers perform the +// cryptographic verification against the verifier's published key. +func carriesSignatureFrom(env *gobl.Envelope, addr goblnet.Address) bool { + for _, sig := range env.Signatures { + p, err := head.SignedPayload(sig) + if err != nil { + continue + } + if issuer, err := goblnet.ParseAddress(p.Iss.Opaque()); err == nil && issuer == addr { + return true + } + } + return false +} + // Find resolves a public lookup key — either an envelope UUID or a // GOBL Net address — to its registration record. The path syntax is // overloaded deliberately: operators can deep-link by UUID (immutable @@ -214,17 +267,40 @@ func (d *Registrations) found(rec *models.Registration, err error) (*models.Regi return rec, nil } +// renewalVerifier resolves the verifier claim for a registration's +// countersignature. An existing record whose countersigned envelope +// has the same digest as the incoming one is renewing an unchanged +// party and keeps its current verifier; any other case starts as +// registered only (no verifier). +func (d *Registrations) renewalVerifier(ctx context.Context, sender goblnet.Address, env *gobl.Envelope) (goblnet.Address, bool) { + prev, err := d.store.Get(ctx, sender) + if err != nil || prev.CountersignedEnvelope == nil || !sameDigest(prev.CountersignedEnvelope, env) { + return "", false + } + return prev.Verifier, true +} + +// sameDigest reports whether both envelopes carry the same document +// digest. +func sameDigest(a, b *gobl.Envelope) bool { + if a == nil || b == nil || a.Head == nil || b.Head == nil { + return false + } + da, db := a.Head.Digest, b.Head.Digest + return da != nil && db != nil && da.Algorithm == db.Algorithm && da.Value == db.Value +} + // upsert reads any existing record for sender (preserving the store's // _rev token for optimistic concurrency), then writes the new state -// with the freshly countersigned envelope. Re-registration drops -// scope back to "registered" — if the party data has changed, prior -// KYC no longer applies. -func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env *gobl.Envelope) (*models.Registration, error) { +// with the freshly countersigned envelope. A renewal keeps the +// record's verification timestamp; a re-registration with changed +// party data clears it — prior KYC no longer applies. +func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env *gobl.Envelope, verifier goblnet.Address, renewal bool) (*models.Registration, error) { prev, err := d.store.Get(ctx, sender) switch { case errors.Is(err, repos.ErrNotFound): r := models.NewRegistration(sender, env.Head.UUID) - r.Scope = head.ScopeRegistered + r.Verifier = verifier r.Status = models.StatusCountersigned r.CountersignedEnvelope = env if err := d.store.Put(ctx, r); err != nil { @@ -236,13 +312,15 @@ func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env } prev.IncomingEnvelopeUUID = env.Head.UUID prev.ReceivedAt = time.Now().UTC() - prev.Scope = head.ScopeRegistered + prev.Verifier = verifier prev.Status = models.StatusCountersigned prev.CountersignedEnvelope = env prev.DeliveryAttempts = 0 prev.LastDeliveryError = "" prev.LastDeliveryAt = nil - prev.VerifiedAt = nil + if !renewal { + prev.VerifiedAt = nil + } if err := d.store.Put(ctx, prev); err != nil { return nil, err } diff --git a/internal/domain/repos/identity.go b/internal/domain/repos/identity.go index c4af997..4de3eae 100644 --- a/internal/domain/repos/identity.go +++ b/internal/domain/repos/identity.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/invopop/gobl/cal" "github.com/invopop/gobl/dsig" @@ -23,13 +24,11 @@ import ( // ~/.config/gobl.lookup/ // ├── private.jwk active signing key (mode 0600) // ├── party.json lookup's own org.Party (served at /who) -// ├── keys/.json each published JWK (served at /keys/) -// └── allow.json optional caller allow-list (usually absent) +// └── keys/.json each published JWK (served at /keys/) const ( PrivateKeyFile = "private.jwk" PartyFile = "party.json" KeysDirName = "keys" - AllowFile = "allow.json" ) // LoadIdentity reads an identity from configDir. Returns an error if @@ -89,11 +88,6 @@ func LoadIdentity(configDir string) (*models.Identity, error) { } id.PublicKeys = keys - id.Allow, err = loadAllow(filepath.Join(configDir, AllowFile)) - if err != nil { - return nil, err - } - return id, nil } @@ -131,7 +125,10 @@ func InitIdentity(opts ScaffoldOptions) (*models.Identity, error) { priv := dsig.NewES256Key() pub := priv.Public() - from := cal.TimestampNow() + // Floor valid_from to the second: signature `iat` claims carry + // whole seconds, so a sub-second valid_from would reject a + // signature made within the same second the key was generated. + from := cal.TimestampOf(time.Now().UTC().Truncate(time.Second)) pub.ValidFrom = &from if err := writeJSON(filepath.Join(opts.ConfigDir, PrivateKeyFile), priv, 0o600); err != nil { @@ -184,29 +181,6 @@ func loadKeys(dir string) ([]*dsig.PublicKey, error) { return out, nil } -func loadAllow(path string) ([]net.Address, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("repos: read allow.json: %w", err) - } - var raw []string - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("repos: parse allow.json: %w", err) - } - out := make([]net.Address, 0, len(raw)) - for _, s := range raw { - a, err := net.ParseAddress(s) - if err != nil { - return nil, fmt.Errorf("repos: allow.json contains invalid address %q: %w", s, err) - } - out = append(out, a) - } - return out, nil -} - func writeJSON(path string, v any, mode os.FileMode) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/internal/domain/repos/identity_test.go b/internal/domain/repos/identity_test.go index d21e1c7..5ffba6f 100644 --- a/internal/domain/repos/identity_test.go +++ b/internal/domain/repos/identity_test.go @@ -109,45 +109,6 @@ func TestLoadAcceptsAnyKeyFilename(t *testing.T) { assert.NotNil(t, id2.FindKey(kid), "key still loaded, keyed by its JWK kid") } -func TestLoadAllowList(t *testing.T) { - dir := t.TempDir() - id, err := repos.InitIdentity(repos.ScaffoldOptions{ - Domain: net.Address("lookup.example"), - ConfigDir: dir, - }) - require.NoError(t, err) - assert.Empty(t, id.Allow, "allow defaults to nil when allow.json is absent") - - require.NoError(t, os.WriteFile( - filepath.Join(dir, repos.AllowFile), - []byte(`["alice.example","bob.example"]`), - 0o644, - )) - id, err = repos.LoadIdentity(dir) - require.NoError(t, err) - assert.Equal(t, - []net.Address{"alice.example", "bob.example"}, - id.Allow, - ) -} - -func TestLoadAllowListRejectsInvalidAddress(t *testing.T) { - dir := t.TempDir() - _, err := repos.InitIdentity(repos.ScaffoldOptions{ - Domain: net.Address("lookup.example"), - ConfigDir: dir, - }) - require.NoError(t, err) - require.NoError(t, os.WriteFile( - filepath.Join(dir, repos.AllowFile), - []byte(`["not a domain"]`), - 0o644, - )) - _, err = repos.LoadIdentity(dir) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid address") -} - func TestFindKey(t *testing.T) { dir := t.TempDir() id, err := repos.InitIdentity(repos.ScaffoldOptions{ diff --git a/internal/interfaces/web/web.go b/internal/interfaces/web/web.go index 2dff2b5..894a3c1 100644 --- a/internal/interfaces/web/web.go +++ b/internal/interfaces/web/web.go @@ -28,7 +28,7 @@ func NewMux(setup *domain.Setup, log *slog.Logger) http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST "+goblnet.InboxPath, handleInbox(setup, log)) - mux.HandleFunc("POST "+goblnet.WhoPath, handleWho(setup, log)) + mux.HandleFunc("GET "+goblnet.WhoPath, handleWho(setup, log)) mux.HandleFunc("GET "+goblnet.KeysPath+"/{kid}", handleKey(setup, log)) mux.HandleFunc("GET "+goblnet.JWKSPath, handleJWKS(setup, log)) mux.HandleFunc("GET /parties/{key}", handleParty(setup, log)) diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index c5476ae..772f8a1 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -27,13 +27,18 @@ import ( "github.com/invopop/gobl.lookup/internal/interfaces/web" ) -// mockFetcher serves a map[url]bytes. Used by the goblnet.Client the -// domain uses to verify incoming envelopes. +// mockFetcher serves a map[url]bytes, with optional per-URL errors. +// Used by the goblnet.Client the domain uses to verify incoming +// envelopes and resolve sender identities. type mockFetcher struct { data map[string][]byte + errs map[string]error } -func (m *mockFetcher) Fetch(_ context.Context, url string) ([]byte, error) { +func (m *mockFetcher) Fetch(_ context.Context, url string, _ http.Header) ([]byte, error) { + if err, ok := m.errs[url]; ok { + return nil, err + } if d, ok := m.data[url]; ok { return d, nil } @@ -73,13 +78,15 @@ func discardLogger() *slog.Logger { } // fixture spins up a lookup identity in a tempdir + an in-memory -// registry + a mock fetcher that serves the subject's published key. -// Returns everything the inbox test needs to POST a registration. +// registry + a mock fetcher that serves the subject's published key +// and its GET /who identity. Returns everything the inbox test needs +// to POST a registration. type fixture struct { t *testing.T lookup *models.Identity subject *dsig.PrivateKey subAddr goblnet.Address + fetcher *mockFetcher registry *repos.MemoryRegistrations sender *mockSender mux http.Handler @@ -98,9 +105,12 @@ func newFixture(t *testing.T) *fixture { pub, _ := json.Marshal(subKey.Public()) subAddr := goblnet.Address("alice.example") - fetcher := &mockFetcher{data: map[string][]byte{ - subAddr.KeyURL(subKey.ID()): pub, - }} + fetcher := &mockFetcher{ + data: map[string][]byte{ + subAddr.KeyURL(subKey.ID()): pub, + }, + errs: map[string]error{}, + } client := goblnet.NewClient(goblnet.WithFetcher(fetcher)) reg := repos.NewMemoryRegistrations() send := &mockSender{} @@ -114,7 +124,12 @@ func newFixture(t *testing.T) *fixture { Logger: discardLogger(), }) mux := web.NewMux(setup, discardLogger()) - return &fixture{t: t, lookup: lookup, subject: subKey, subAddr: subAddr, registry: reg, sender: send, mux: mux} + f := &fixture{t: t, lookup: lookup, subject: subKey, subAddr: subAddr, fetcher: fetcher, registry: reg, sender: send, mux: mux} + // The registration flow resolves the sender's own GET /who, so the + // fixture serves a self-signed identity for the subject by default. + who, _ := json.Marshal(f.signPartyEnvelope(subAddr.URI(), "")) + fetcher.data[subAddr.WhoURL()] = who + return f } // signPartyEnvelope builds a fresh signed envelope from subject with @@ -127,7 +142,11 @@ func (f *fixture) signPartyEnvelope(iss, aud cbc.URI) *gobl.Envelope { } env, err := gobl.Envelop(party) require.NoError(f.t, err) - require.NoError(f.t, env.Sign(f.subject, iss, aud)) + opts := []head.SignOption{head.WithIssuer(iss)} + if aud != "" { + opts = append(opts, head.WithAudience(aud)) + } + require.NoError(f.t, env.Sign(f.subject, opts...)) return env } @@ -174,7 +193,7 @@ func TestInboxAcceptsRegistration(t *testing.T) { rec, err := f.registry.Get(context.Background(), f.subAddr) require.NoError(t, err) assert.Equal(t, models.StatusCountersigned, rec.Status) - assert.Equal(t, head.ScopeRegistered, rec.Scope) + assert.Empty(t, rec.Verifier, "initial registration carries no verifier") require.NotNil(t, rec.CountersignedEnvelope) require.Len(t, rec.CountersignedEnvelope.Signatures, 2, "original subject signature + lookup countersignature") @@ -185,7 +204,7 @@ func TestInboxAcceptsRegistration(t *testing.T) { require.NoError(t, err) assert.Equal(t, f.lookup.URI(), p.Iss) assert.Equal(t, f.subAddr.URI(), p.Aud) - assert.Equal(t, head.ScopeRegistered, p.Scope) + assert.Empty(t, p.Verifier, "initial countersignature carries no verifier claim") // Discovery link stamped on the (mutable) header. require.NotEmpty(t, rec.CountersignedEnvelope.Head.Links) @@ -225,7 +244,9 @@ func TestInboxRejectsNonPartyDocument(t *testing.T) { msg := &org.Inbox{Code: "x"} env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(f.subject, f.subAddr.URI(), f.lookup.URI())) + require.NoError(t, env.Sign(f.subject, + head.WithIssuer(f.subAddr.URI()), + head.WithAudience(f.lookup.URI()))) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -257,38 +278,94 @@ func TestInboxRejectsUnknownSigner(t *testing.T) { } env, err := gobl.Envelop(party) require.NoError(t, err) - require.NoError(t, env.Sign(other, goblnet.Address("mallory.example").URI(), f.lookup.URI())) + require.NoError(t, env.Sign(other, + head.WithIssuer(goblnet.Address("mallory.example").URI()), + head.WithAudience(f.lookup.URI()))) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } -func TestReRegistrationDropsScopeToRegistered(t *testing.T) { +func TestReRegistrationDropsVerifier(t *testing.T) { f := newFixture(t) // First registration. env1 := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) body1, _ := json.Marshal(env1) f.post(goblnet.InboxPath, body1).Body.Close() //nolint:errcheck - // Manually bump scope to verified to simulate the admin path. + // Manually mark as verified to simulate the admin path. rec, _ := f.registry.Get(context.Background(), f.subAddr) - rec.Scope = head.ScopeVerified + rec.Verifier = "kyc.example" now := time.Now().UTC() rec.VerifiedAt = &now err := f.registry.Put(context.Background(), rec) require.NoError(t, err) - // Re-register: scope must drop back to registered. + // Re-register: the verifier must be dropped — prior KYC no + // longer applies to changed party data. env2 := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) body2, _ := json.Marshal(env2) f.post(goblnet.InboxPath, body2).Body.Close() //nolint:errcheck rec2, _ := f.registry.Get(context.Background(), f.subAddr) - assert.Equal(t, head.ScopeRegistered, rec2.Scope) + assert.Empty(t, rec2.Verifier) assert.Nil(t, rec2.VerifiedAt, "verified timestamp cleared on re-registration") } +// signSameParty envelopes and signs the given party without altering +// it, so repeated calls produce envelopes with identical digests — +// the renewal case. +func (f *fixture) signSameParty(party *org.Party) *gobl.Envelope { + f.t.Helper() + env, err := gobl.Envelop(party) + require.NoError(f.t, err) + require.NoError(f.t, env.Sign(f.subject, + head.WithIssuer(f.subAddr.URI()), + head.WithAudience(f.lookup.URI()))) + return env +} + +func TestRenewalPreservesVerifier(t *testing.T) { + f := newFixture(t) + party := &org.Party{ + Name: "Alice", + Endpoints: []*org.Endpoint{{URI: f.subAddr.URI()}}, + } + env1 := f.signSameParty(party) // assigns the party's UUID + body1, _ := json.Marshal(env1) + f.post(goblnet.InboxPath, body1).Body.Close() //nolint:errcheck + + // Simulate out-of-band KYC. + rec, err := f.registry.Get(context.Background(), f.subAddr) + require.NoError(t, err) + rec.Verifier = "kyc.example" + now := time.Now().UTC() + rec.VerifiedAt = &now + require.NoError(t, f.registry.Put(context.Background(), rec)) + + // Renew with the unchanged party document (same digest). + env2 := f.signSameParty(party) + body2, _ := json.Marshal(env2) + resp := f.post(goblnet.InboxPath, body2) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + rec2, err := f.registry.Get(context.Background(), f.subAddr) + require.NoError(t, err) + assert.Equal(t, goblnet.Address("kyc.example"), rec2.Verifier, "renewal keeps the verifier") + assert.NotNil(t, rec2.VerifiedAt, "renewal keeps the verification timestamp") + + // The renewal countersignature asserts the preserved verifier and + // a fresh ~90 day expiry. + sigs := rec2.CountersignedEnvelope.Signatures + p, err := head.SignedPayload(sigs[len(sigs)-1]) + require.NoError(t, err) + assert.Equal(t, cbc.URI("gobl:kyc.example"), p.Verifier) + assert.Greater(t, p.ExpiresAt, time.Now().Add(89*24*time.Hour).Unix()) + assert.Less(t, p.ExpiresAt, time.Now().Add(91*24*time.Hour).Unix()) +} + func TestPartiesLookupByUUID(t *testing.T) { f := newFixture(t) env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) @@ -353,17 +430,12 @@ func TestJWKSEndpoint(t *testing.T) { require.Len(t, set.Keys, 1) } -func TestWhoExchange(t *testing.T) { +func TestWhoGet(t *testing.T) { f := newFixture(t) - party := &org.Party{Name: "Alice", Endpoints: []*org.Endpoint{{URI: f.subAddr.URI()}}} - env, err := gobl.Envelop(party) - require.NoError(t, err) - require.NoError(t, env.Sign(f.subject, f.subAddr.URI(), f.lookup.URI())) - body, _ := json.Marshal(env) - - resp := f.post(goblnet.WhoPath, body) + resp := f.get(goblnet.WhoPath) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotEmpty(t, resp.Header.Get("Cache-Control")) var got gobl.Envelope require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) @@ -371,7 +443,7 @@ func TestWhoExchange(t *testing.T) { p, err := head.SignedPayload(got.Signatures[0]) require.NoError(t, err) assert.Equal(t, f.lookup.URI(), p.Iss) - assert.Equal(t, f.subAddr.URI(), p.Aud) + assert.Empty(t, p.Aud, "GET who response is not bound to a caller") } func TestHealth(t *testing.T) { @@ -381,39 +453,28 @@ func TestHealth(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestAllowListBlocksUnknownSender(t *testing.T) { - dir := t.TempDir() - lookup, err := repos.InitIdentity(repos.ScaffoldOptions{ - Domain: goblnet.Address("lookup.example"), - ConfigDir: dir, - }) - require.NoError(t, err) - // Restrict the allow-list to exclude alice.example. - lookup.Allow = []goblnet.Address{"bob.example"} - - subKey := dsig.NewES256Key() - pub, _ := json.Marshal(subKey.Public()) - subAddr := goblnet.Address("alice.example") - client := goblnet.NewClient(goblnet.WithFetcher(&mockFetcher{ - data: map[string][]byte{subAddr.KeyURL(subKey.ID()): pub}, - })) - setup := domain.New(domain.Deps{ - Identity: lookup, - Registrations: repos.NewMemoryRegistrations(), - Client: client, - Sender: &mockSender{}, - Logger: discardLogger(), - }) - mux := web.NewMux(setup, discardLogger()) +func TestInboxRejectsSenderWithoutWho(t *testing.T) { + f := newFixture(t) + // The sender's key resolves, but its GET /who does not. + delete(f.fetcher.data, f.subAddr.WhoURL()) - party := &org.Party{Name: "Alice", Endpoints: []*org.Endpoint{{URI: subAddr.URI()}}} - env, err := gobl.Envelop(party) - require.NoError(t, err) - require.NoError(t, env.Sign(subKey, subAddr.URI(), lookup.URI())) + env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} - req := httptest.NewRequest(http.MethodPost, goblnet.InboxPath, bytes.NewReader(body)) - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - assert.Equal(t, http.StatusForbidden, rec.Result().StatusCode) +func TestInboxRejectsReceiveOnlySender(t *testing.T) { + f := newFixture(t) + // A 204 from the sender's who marks a receive-only account, which + // cannot register as a sender. + delete(f.fetcher.data, f.subAddr.WhoURL()) + f.fetcher.errs[f.subAddr.WhoURL()] = goblnet.ErrNoContent + + env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode) } diff --git a/internal/interfaces/web/who.go b/internal/interfaces/web/who.go index 8e75e5e..ac4d1c5 100644 --- a/internal/interfaces/web/who.go +++ b/internal/interfaces/web/who.go @@ -1,53 +1,29 @@ package web import ( - "encoding/json" - "errors" - "io" "log/slog" "net/http" - "github.com/invopop/gobl" - "github.com/invopop/gobl.lookup/internal/domain" ) -// handleWho implements the authenticated mutual party exchange: the -// caller POSTs a signed envelope (iss=caller, aud=lookup); the domain -// verifies it, applies the allow-list, and returns the lookup's own -// party envelope signed with iss/aud reversed. +// whoCacheControl allows clients to cache the static party envelope +// briefly; the TTL bounds how quickly key or party changes are +// observed by verifiers. +const whoCacheControl = "public, max-age=300" + +// handleWho serves the lookup's public identity: the party envelope +// self-signed by the lookup, the same static document for every +// caller. func handleWho(s *domain.Setup, log *slog.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - r.Body = http.MaxBytesReader(w, r.Body, inboxMaxBody) - body, err := io.ReadAll(r.Body) - if err != nil { - var maxErr *http.MaxBytesError - if errors.As(err, &maxErr) { - log.Warn("who.rejected", "reason", "body_too_large", "remote", r.RemoteAddr) - http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) - return - } - log.Warn("who.rejected", "reason", "read_body", "remote", r.RemoteAddr, "error", err.Error()) - http.Error(w, "could not read body", http.StatusBadRequest) - return - } - env := new(gobl.Envelope) - if err := json.Unmarshal(body, env); err != nil { - log.Warn("who.rejected", "reason", "bad_body", "remote", r.RemoteAddr) - http.Error(w, "invalid envelope JSON", http.StatusBadRequest) - return - } - out, err := s.Identity().Exchange(r.Context(), env) - if err != nil { - writeError(w, err) - return - } - resp, err := json.Marshal(out) + return func(w http.ResponseWriter, _ *http.Request) { + body, err := s.Identity().PartyEnvelope() if err != nil { - log.Error("who.encode_failed", "error", err.Error()) - http.Error(w, "could not encode response", http.StatusInternalServerError) + log.Error("who.sign_failed", "error", err.Error()) + http.Error(w, "could not prepare identity", http.StatusInternalServerError) return } - writeJSON(w, http.StatusOK, resp) + w.Header().Set("Cache-Control", whoCacheControl) + writeJSON(w, http.StatusOK, body) } } From 464f89bee669f4fcc5b64d60efbba46aceb95d94 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 17:17:32 +0000 Subject: [PATCH 02/10] Require bearer request tokens on the who and inbox endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the registry with the revised protocol (spec §5.5): - A requireAuth middleware verifies the Authorization header of every /who and /inbox request against the requester's published key, audience, and freshness window; failures get 401 with token_missing / token_invalid / token_expired audit reasons, and authenticated requests are logged with the requester address. Key discovery and the public /parties directory stay open. - /who serves the static envelope with Cache-Control: private; the token's issuer may be a trusted intermediary — the registration subject still comes from the envelope's own signature. - Outbound requests authenticate as the lookup: the sender GET /who eligibility check (via the client identity) and countersigned envelope deliveries (HTTPSender mints a token per POST) both carry bearer tokens. A registrant deferring its own /who disclosure (202) is rejected with 403 — senders must disclose openly. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +- README.md | 31 +++++--- cmd/gobl.lookup/setup.go | 6 +- internal/domain/delivery/delivery.go | 21 ++++- internal/domain/delivery/delivery_test.go | 64 +++++++++++++-- internal/domain/identity.go | 9 +++ internal/domain/registrations.go | 6 +- internal/interfaces/web/auth.go | 52 +++++++++++++ internal/interfaces/web/inbox.go | 4 + internal/interfaces/web/web.go | 12 +-- internal/interfaces/web/web_test.go | 95 +++++++++++++++++++++-- internal/interfaces/web/who.go | 18 +++-- 12 files changed, 281 insertions(+), 45 deletions(-) create mode 100644 internal/interfaces/web/auth.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e4260b2..776d898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,13 @@ ### Changed -- Every Authority countersignature now carries a 90-day `exp` claim; parties renew by re-registering before it passes. A renewal with an unchanged party document (same digest) is countersigned at the party's current scope — `verified` stays `verified` — while changed party data drops back to `registered` and clears `verified_at`. -- `/.well-known/gobl/who` is now an open `GET` serving the lookup's self-signed party envelope (signed once per process, `Cache-Control: max-age=300`), replacing the authenticated `POST` exchange. +- Every Authority countersignature now carries a 90-day `exp` claim; parties renew by re-registering before it passes. A renewal with an unchanged party document (same digest) is countersigned with the party's current `verifier` claim — verified stays verified — while changed party data drops the verifier and clears `verified_at`. +- The `scope` claim is replaced by structural verification (spec §5.3): a registration countersignature alone asserts a registered identity, and `gobl.lookup verify` re-countersigns with a `verifier` claim naming the KYC/KYB authority. `--verifier` names an external verifying authority (its own countersignature must already be on the stored envelope); the default is the lookup itself, whose single countersignature carries both attestations. The verifier signature's `exp` is independent of the 90-day registration cycle, so verifications can be much longer-lived. `Registration.Verifier` replaces the stored `scope` field. +- `/.well-known/gobl/who` is now a `GET` serving the lookup's self-signed party envelope (signed once per process), replacing the authenticated `POST` exchange. Together with `/inbox` it requires a bearer request token (spec §5.5): the requester's token is verified against its published key, audience, and freshness window, and requests without a valid token are rejected with `401` (`auth.rejected` log reasons `token_missing`/`token_invalid`/`token_expired`). The response is served with `Cache-Control: private, max-age=300`; authenticated requests are logged with the requester address as the request audit log. Key discovery and `/parties` stay open. +- Outbound requests authenticate as the lookup itself: the sender `GET /who` eligibility check and countersigned-envelope deliveries carry bearer request tokens. A registrant answering its own `/who` with `202` (deferred disclosure) is rejected with `403` — senders must disclose openly to register. - Registration now requires the sender to serve its own public identity: the inbox resolves `GET /who` on the sender's address (re-fetching its published key) before countersigning. A `204 No Content` — a receive-only account — or an unresolvable identity rejects the registration with `403 Forbidden`. - Allow-list support (`allow.json`, `models.Identity.Allow`) is removed; the sender `/who` check is the gate on registrations. -- Updated to the current `gobl` net API: `Envelope.Sign` options (`head.WithIssuer`/`WithAudience`/`WithScope`) and `net.Client.Who`. +- Updated to the current `gobl` net API: `Envelope.Sign` options (`head.WithIssuer`/`WithAudience`/`WithVerifier`) and `net.Client.Who`. - Identity key files under `keys/` no longer need to be named after their kid — the kid is read from the JWK itself, and any `*.json` file is accepted (`Init` still writes `.json`). This lets a deployment mount the published key at a fixed path (e.g. `keys/public.json`) without encoding the kid in the filename. - Persistence now uses the shared [`github.com/invopop/couch`](https://github.com/invopop/couch) library: `models.Registration` embeds `couch.Model` (gaining `created_at`/`updated_at` and revision handling), and the CouchDB store uses `couch.Client`/`couch.Store`/`couch.Fetch` and a `couch.Design` for the by-UUID view. The registration database is the couch client's prefix (`COUCHDB_DATABASE`). - Configuration is now read from the environment (`CONFIG_DIR`, `COUCHDB_URL` or the split `COUCHDB_SCHEME/HOST/PORT/USERNAME/PASSWORD`, `COUCHDB_DATABASE`, `HTTP_PORT`/`PORT`, `PUBLIC_BASE_URL`, `LOG_JSON`) so the service can be configured — and its CouchDB password injected from a secret — the way the cluster provides config. The equivalent CLI flags still work and override the environment. Env var names match the sibling services (silo/access). diff --git a/README.md b/README.md index 7431448..0a48230 100644 --- a/README.md +++ b/README.md @@ -86,15 +86,28 @@ The domain never imports the transport layer. ## Endpoints -| Method | Path | Purpose | -|--------|-------------------------------------|----------------------------------------------------------| -| POST | `/.well-known/gobl/inbox` | Registration entry — must carry an `org.Party` document. | -| GET | `/.well-known/gobl/who` | Lookup's public identity (self-signed party envelope). | -| GET | `/.well-known/gobl/keys/` | Single published key. | -| GET | `/.well-known/jwks.json` | Bulk JWK Set (for jwt.io-style tooling). | -| GET | `/parties/
` | Public registration record by address. | -| GET | `/parties/` | Public registration record by envelope UUID. | -| GET | `/healthz` | Liveness check. | +| Method | Path | Auth | Purpose | +|--------|-------------------------------------|-------|----------------------------------------------------------| +| POST | `/.well-known/gobl/inbox` | token | Registration entry — must carry an `org.Party` document. | +| GET | `/.well-known/gobl/who` | token | Lookup's identity (self-signed party envelope). | +| GET | `/.well-known/gobl/keys/` | open | Single published key. | +| GET | `/.well-known/jwks.json` | open | Bulk JWK Set (for jwt.io-style tooling). | +| GET | `/parties/
` | open | Public registration record by address. | +| GET | `/parties/` | open | Public registration record by envelope UUID. | +| GET | `/healthz` | open | Liveness check. | + +The who and inbox endpoints require a bearer request token (spec +§5.5) minted from the caller's own published key; requests without +a valid token get `401`. The token's issuer may be a trusted +intermediary transmitting on the registrant's behalf — the +registration subject always comes from the envelope's own +signature. Key discovery stays open (it is what makes token +verification possible), and `/parties` remains an open directory of +registered — hence deliberately public — identities. The lookup's +own outbound requests (the sender `GET /who` eligibility check and +countersigned-envelope deliveries) authenticate the same way, as +`lookup.gobl.org`. Authenticated requests are logged with the +requester address, forming the request audit log. ## Quickstart (local dev) diff --git a/cmd/gobl.lookup/setup.go b/cmd/gobl.lookup/setup.go index 6635e92..1455977 100644 --- a/cmd/gobl.lookup/setup.go +++ b/cmd/gobl.lookup/setup.go @@ -45,8 +45,10 @@ func buildDomain(ctx context.Context, cfg config.Config) (*domain.Setup, func(), setup := domain.New(domain.Deps{ Identity: id, Registrations: reg, - Client: goblnet.NewClient(), - Sender: delivery.New(), + // The client and sender authenticate outbound requests as the + // lookup itself (bearer request tokens, spec §5.5). + Client: goblnet.NewClient(goblnet.WithIdentity(id.Address(), id.PrivateKey)), + Sender: delivery.New(id.Address(), id.PrivateKey), // domain.New defaults this to https:// when empty. PublicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"), Logger: slog.Default(), diff --git a/internal/domain/delivery/delivery.go b/internal/domain/delivery/delivery.go index 5a2a8b8..1a797a0 100644 --- a/internal/domain/delivery/delivery.go +++ b/internal/domain/delivery/delivery.go @@ -19,6 +19,7 @@ import ( "time" "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/net" ) @@ -45,20 +46,25 @@ type Sender interface { } // HTTPSender is the HTTP implementation of Sender. Construct with New; -// the zero value is not usable. +// the zero value is not usable. Every request carries a bearer +// request token (spec §5.5) minted from the lookup's own identity. type HTTPSender struct { client *http.Client + self net.Address + key *dsig.PrivateKey } // New returns an HTTPSender whose transport refuses to dial any // resolved IP that is loopback, private, link-local, multicast, or -// unspecified. -func New() *HTTPSender { return newSender(false) } +// unspecified, and which authenticates its requests as self. +func New(self net.Address, key *dsig.PrivateKey) *HTTPSender { + return newSender(self, key, false) +} // newSender builds an HTTPSender; allowLoopback bypasses the SSRF // guard, intended only for tests that talk to httptest servers bound // to 127.0.0.1. There is no public wrapper that exposes it. -func newSender(allowLoopback bool) *HTTPSender { +func newSender(self net.Address, key *dsig.PrivateKey, allowLoopback bool) *HTTPSender { transport := &http.Transport{ ForceAttemptHTTP2: true, MaxIdleConns: 50, @@ -74,6 +80,8 @@ func newSender(allowLoopback bool) *HTTPSender { // caller's context (see Send), keeping it in step with the // domain's delivery timeout. client: &http.Client{Transport: transport}, + self: self, + key: key, } } @@ -98,6 +106,11 @@ func (s *HTTPSender) Send(ctx context.Context, addr net.Address, env *gobl.Envel } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") + token, err := net.NewToken(s.key, s.self, addr, 0) + if err != nil { + return fmt.Errorf("%w: mint request token: %v", ErrSendFailed, err) + } + req.Header.Set("Authorization", "Bearer "+token) resp, err := s.client.Do(req) if err != nil { return fmt.Errorf("%w: %v", ErrSendFailed, err) diff --git a/internal/domain/delivery/delivery_test.go b/internal/domain/delivery/delivery_test.go index dfb2f11..42caffd 100644 --- a/internal/domain/delivery/delivery_test.go +++ b/internal/domain/delivery/delivery_test.go @@ -2,6 +2,7 @@ package delivery import ( "context" + "encoding/json" "errors" stdnet "net" "net/http" @@ -19,6 +20,11 @@ import ( "github.com/stretchr/testify/require" ) +var ( + testSelf = net.Address("lookup.example") + testSelfKey = dsig.NewES256Key() +) + func buildEnvelope(t *testing.T) *gobl.Envelope { t.Helper() msg := ¬e.Message{Content: "hi"} @@ -48,7 +54,7 @@ func TestSenderSend202(t *testing.T) { // normally refuse). We use the same internal-only constructor // trick gobl/net does for its tests, then exercise the transport // directly via a one-off request matching what Send constructs. - s := newSender(true) + s := newSender(testSelf, testSelfKey, true) req, _ := http.NewRequest(http.MethodPost, srv.URL+net.InboxPath, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) @@ -65,7 +71,7 @@ func TestSenderRejectsLoopbackByDefault(t *testing.T) { })) defer srv.Close() - s := New() + s := New(testSelf, testSelfKey) // httptest binds 127.0.0.1 — the default Sender MUST refuse. req, _ := http.NewRequest(http.MethodPost, srv.URL+net.InboxPath, strings.NewReader(`{}`)) _, err := s.client.Do(req) @@ -84,13 +90,13 @@ func TestSafeDialContextRejectsLoopback(t *testing.T) { } func TestSendNilEnvelope(t *testing.T) { - err := New().Send(context.Background(), net.Address("alice.example"), nil) + err := New(testSelf, testSelfKey).Send(context.Background(), net.Address("alice.example"), nil) require.Error(t, err) assert.True(t, errors.Is(err, ErrSendFailed)) } func TestSendInvalidAddress(t *testing.T) { - err := New().Send(context.Background(), net.Address("not a domain"), buildEnvelope(t)) + err := New(testSelf, testSelfKey).Send(context.Background(), net.Address("not a domain"), buildEnvelope(t)) require.Error(t, err) assert.True(t, errors.Is(err, ErrSendFailed)) } @@ -104,7 +110,7 @@ func TestTransportSurfacesUpstreamStatus(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) })) defer srv.Close() - s := newSender(true) + s := newSender(testSelf, testSelfKey, true) req, _ := http.NewRequest(http.MethodPost, srv.URL+net.InboxPath, strings.NewReader(`{}`)) req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) @@ -133,3 +139,51 @@ func TestIsPublicIP(t *testing.T) { } assert.False(t, isPublicIP(nil)) } + +// roundTripFunc lets a test intercept the sender's outbound request. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestSendMintsRequestToken(t *testing.T) { + var got *http.Request + s := &HTTPSender{ + self: testSelf, + key: testSelfKey, + client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + got = r + return &http.Response{ + StatusCode: http.StatusAccepted, + Body: http.NoBody, + Request: r, + }, nil + })}, + } + + require.NoError(t, s.Send(context.Background(), net.Address("alice.example"), buildEnvelope(t))) + require.NotNil(t, got) + auth := got.Header.Get("Authorization") + require.True(t, strings.HasPrefix(auth, "Bearer "), "request carries a bearer token") + + // The token must verify as self → alice.example. + pub, err := json.Marshal(testSelfKey.Public()) + require.NoError(t, err) + client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ + testSelf.KeyURL(testSelfKey.ID()): pub, + }})) + iss, err := client.VerifyToken(context.Background(), strings.TrimPrefix(auth, "Bearer "), "alice.example") + require.NoError(t, err) + assert.Equal(t, testSelf, iss) +} + +// mapFetcher serves a URL-keyed byte map for token verification. +type mapFetcher struct { + data map[string][]byte +} + +func (m *mapFetcher) Fetch(_ context.Context, url string, _ http.Header) ([]byte, error) { + if d, ok := m.data[url]; ok { + return d, nil + } + return nil, net.ErrFetchFailed +} diff --git a/internal/domain/identity.go b/internal/domain/identity.go index 579a1a8..54fde5e 100644 --- a/internal/domain/identity.go +++ b/internal/domain/identity.go @@ -1,6 +1,7 @@ package domain import ( + "context" "encoding/json" "errors" "fmt" @@ -55,6 +56,14 @@ func (d *Identity) JWKS() ([]byte, error) { return d.model.JWKS() } // PublicKeys returns every key the lookup has published. func (d *Identity) PublicKeys() []*dsig.PublicKey { return d.model.PublicKeys } +// VerifyRequest verifies the Authorization header of an inbound who +// or inbox request (a bearer request token, spec §5.5) and returns +// the verified requester address. The token's audience must be this +// lookup and its freshness window must include the current time. +func (d *Identity) VerifyRequest(ctx context.Context, header string) (goblnet.Address, error) { + return d.client.VerifyAuthorization(ctx, header, d.Address()) +} + // PartyEnvelope returns the JSON of the lookup's party wrapped in a // self-signed envelope (iss = the lookup's address, no aud), served // at GET /.well-known/gobl/who. The response is a static document: diff --git a/internal/domain/registrations.go b/internal/domain/registrations.go index cd7284b..99bb980 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -99,9 +99,13 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode if _, err := d.client.Who(ctx, sender); err != nil { reason := "who_failed" msg := "could not resolve sender's public identity" - if errors.Is(err, goblnet.ErrNoContent) { + switch { + case errors.Is(err, goblnet.ErrNoContent): reason = "who_no_content" msg = "sender does not publish a public identity; senders must serve GET /who" + case errors.Is(err, goblnet.ErrPending): + reason = "who_pending" + msg = "sender defers identity disclosure; senders must serve GET /who openly to register" } d.log.Warn("inbox.rejected", "reason", reason, "caller", string(sender), "error", err.Error()) return nil, ErrForbidden.WithMessage("%s", msg) diff --git a/internal/interfaces/web/auth.go b/internal/interfaces/web/auth.go new file mode 100644 index 0000000..ac57376 --- /dev/null +++ b/internal/interfaces/web/auth.go @@ -0,0 +1,52 @@ +package web + +import ( + "context" + "errors" + "log/slog" + "net/http" + + goblnet "github.com/invopop/gobl/net" + + "github.com/invopop/gobl.lookup/internal/domain" +) + +// requesterKey is the context key under which requireAuth stores the +// verified requester address for downstream handlers. +type requesterKey struct{} + +// requesterFrom returns the verified requester address stored by +// requireAuth, or "" when the request was not authenticated. +func requesterFrom(ctx context.Context) goblnet.Address { + addr, _ := ctx.Value(requesterKey{}).(goblnet.Address) + return addr +} + +// requireAuth verifies the bearer request token (spec §5.5) on every +// request before handing off to next, rejecting failures with 401. +// The auth.rejected / handler log entries carrying the requester +// address double as the request audit log. +func requireAuth(s *domain.Setup, log *slog.Logger, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + requester, err := s.Identity().VerifyRequest(r.Context(), header) + if err != nil { + reason := "token_invalid" + switch { + case header == "": + reason = "token_missing" + case errors.Is(err, goblnet.ErrTokenExpired): + reason = "token_expired" + } + log.Warn("auth.rejected", + "path", r.URL.Path, + "reason", reason, + "remote", r.RemoteAddr, + "error", err.Error(), + ) + http.Error(w, "a valid bearer request token is required", http.StatusUnauthorized) + return + } + next(w, r.WithContext(context.WithValue(r.Context(), requesterKey{}, requester))) + } +} diff --git a/internal/interfaces/web/inbox.go b/internal/interfaces/web/inbox.go index 3e3bea8..56b13b0 100644 --- a/internal/interfaces/web/inbox.go +++ b/internal/interfaces/web/inbox.go @@ -39,10 +39,14 @@ func handleInbox(s *domain.Setup, log *slog.Logger) http.HandlerFunc { http.Error(w, "invalid envelope JSON", http.StatusBadRequest) return } + // The requester (token iss) may be a trusted intermediary + // transmitting on the registrant's behalf; the domain resolves + // the subject from the envelope's own signature. if _, err := s.Registrations().Register(r.Context(), env); err != nil { writeError(w, err) return } + log.Info("inbox.received", "requester", string(requesterFrom(r.Context()))) w.WriteHeader(http.StatusAccepted) } } diff --git a/internal/interfaces/web/web.go b/internal/interfaces/web/web.go index 894a3c1..234dc4f 100644 --- a/internal/interfaces/web/web.go +++ b/internal/interfaces/web/web.go @@ -1,8 +1,10 @@ // Package web is the HTTP transport adapter for the lookup service. // It exposes the standard GOBL Net well-known endpoints (inbox / who -// / keys / jwks) plus the public /parties registry record. Handlers -// are thin: they parse the request, delegate to the domain services, -// and map domain errors onto HTTP status codes. +// / keys / jwks) plus the public /parties registry record. The who +// and inbox endpoints require a bearer request token (spec §5.5); +// key discovery and the /parties directory stay open. Handlers are +// thin: they parse the request, delegate to the domain services, and +// map domain errors onto HTTP status codes. package web import ( @@ -27,8 +29,8 @@ func NewMux(setup *domain.Setup, log *slog.Logger) http.Handler { } mux := http.NewServeMux() - mux.HandleFunc("POST "+goblnet.InboxPath, handleInbox(setup, log)) - mux.HandleFunc("GET "+goblnet.WhoPath, handleWho(setup, log)) + mux.HandleFunc("POST "+goblnet.InboxPath, requireAuth(setup, log, handleInbox(setup, log))) + mux.HandleFunc("GET "+goblnet.WhoPath, requireAuth(setup, log, handleWho(setup, log))) mux.HandleFunc("GET "+goblnet.KeysPath+"/{kid}", handleKey(setup, log)) mux.HandleFunc("GET "+goblnet.JWKSPath, handleJWKS(setup, log)) mux.HandleFunc("GET /parties/{key}", handleParty(setup, log)) diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index 772f8a1..4f1ae6f 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -164,18 +164,37 @@ func (f *fixture) waitForDelivery(d time.Duration) []sentEnvelope { return f.sender.records() } -func (f *fixture) post(path string, body []byte) *http.Response { +// bearer mints a request token from the subject for the lookup, as +// any conforming client would attach to a who or inbox request. +func (f *fixture) bearer() string { f.t.Helper() - req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - f.mux.ServeHTTP(rec, req) - return rec.Result() + token, err := goblnet.NewToken(f.subject, f.subAddr, f.lookup.Address(), 0) + require.NoError(f.t, err) + return "Bearer " + token +} + +func (f *fixture) post(path string, body []byte) *http.Response { + return f.do(http.MethodPost, path, body, f.bearer()) } func (f *fixture) get(path string) *http.Response { + return f.do(http.MethodGet, path, nil, f.bearer()) +} + +// do performs a request with an explicit Authorization value; empty +// auth sends the request bare. +func (f *fixture) do(method, path string, body []byte, auth string) *http.Response { f.t.Helper() - req := httptest.NewRequest(http.MethodGet, path, nil) + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + if auth != "" { + req.Header.Set("Authorization", auth) + } rec := httptest.NewRecorder() f.mux.ServeHTTP(rec, req) return rec.Result() @@ -435,7 +454,7 @@ func TestWhoGet(t *testing.T) { resp := f.get(goblnet.WhoPath) defer resp.Body.Close() //nolint:errcheck require.Equal(t, http.StatusOK, resp.StatusCode) - assert.NotEmpty(t, resp.Header.Get("Cache-Control")) + assert.Equal(t, "private, max-age=300", resp.Header.Get("Cache-Control"), "authorized response must not land in shared caches") var got gobl.Envelope require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) @@ -446,6 +465,66 @@ func TestWhoGet(t *testing.T) { assert.Empty(t, p.Aud, "GET who response is not bound to a caller") } +func TestRequestAuth(t *testing.T) { + f := newFixture(t) + + t.Run("who without a token is rejected", func(t *testing.T) { + resp := f.do(http.MethodGet, goblnet.WhoPath, nil, "") + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("inbox without a token is rejected", func(t *testing.T) { + env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + body, _ := json.Marshal(env) + resp := f.do(http.MethodPost, goblnet.InboxPath, body, "") + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("non-bearer scheme is rejected", func(t *testing.T) { + resp := f.do(http.MethodGet, goblnet.WhoPath, nil, "Basic dXNlcjpwdw==") + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("token bound to another audience is rejected", func(t *testing.T) { + token, err := goblnet.NewToken(f.subject, f.subAddr, "other.example", 0) + require.NoError(t, err) + resp := f.do(http.MethodGet, goblnet.WhoPath, nil, "Bearer "+token) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("token from an unresolvable issuer is rejected", func(t *testing.T) { + other := dsig.NewES256Key() + token, err := goblnet.NewToken(other, "unknown.example", f.lookup.Address(), 0) + require.NoError(t, err) + resp := f.do(http.MethodGet, goblnet.WhoPath, nil, "Bearer "+token) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("keys and parties stay open", func(t *testing.T) { + resp := f.do(http.MethodGet, goblnet.KeyPath(f.lookup.PrivateKey.ID()), nil, "") + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) +} + +func TestInboxRejectsPendingWho(t *testing.T) { + // A registrant deferring its own /who disclosure (202) cannot be + // confirmed as a sending participant. + f := newFixture(t) + f.fetcher.errs[f.subAddr.WhoURL()] = goblnet.ErrPending + + env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} + func TestHealth(t *testing.T) { f := newFixture(t) resp := f.get("/healthz") diff --git a/internal/interfaces/web/who.go b/internal/interfaces/web/who.go index ac4d1c5..ff49ff2 100644 --- a/internal/interfaces/web/who.go +++ b/internal/interfaces/web/who.go @@ -7,22 +7,24 @@ import ( "github.com/invopop/gobl.lookup/internal/domain" ) -// whoCacheControl allows clients to cache the static party envelope -// briefly; the TTL bounds how quickly key or party changes are -// observed by verifiers. -const whoCacheControl = "public, max-age=300" +// whoCacheControl allows the authenticated caller to cache the static +// party envelope briefly; the response requires authorization so it +// must not land in shared caches. The TTL bounds how quickly key or +// party changes are observed by verifiers. +const whoCacheControl = "private, max-age=300" -// handleWho serves the lookup's public identity: the party envelope -// self-signed by the lookup, the same static document for every -// caller. +// handleWho serves the lookup's identity to an authenticated +// requester: the party envelope self-signed by the lookup, the same +// static document for every authorized caller. func handleWho(s *domain.Setup, log *slog.Logger) http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { body, err := s.Identity().PartyEnvelope() if err != nil { log.Error("who.sign_failed", "error", err.Error()) http.Error(w, "could not prepare identity", http.StatusInternalServerError) return } + log.Info("who.served", "requester", string(requesterFrom(r.Context()))) w.Header().Set("Cache-Control", whoCacheControl) writeJSON(w, http.StatusOK, body) } From 7dd2054f6ec34649a4e53e48454a5acf32e9e8c0 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 18:03:51 +0000 Subject: [PATCH 03/10] Signed claims carry bare addresses, not gobl: URIs Follows the gobl core change dropping the URI scheme from signed iss/aud/verifier claims: countersigning and the party self-signature use Address.String(), and the registration aud check canonicalizes both sides with net.ParseAddress, mirroring gobl's VerifyEnvelope. The gobl: scheme remains on org.Endpoint URIs (Identity.URI stays for endpoint scaffolding). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +- README.md | 9 +++-- internal/domain/delivery/delivery_test.go | 4 +- internal/domain/identity.go | 8 ++-- internal/domain/identity_test.go | 13 +++---- internal/domain/models/identity.go | 3 ++ internal/domain/registrations.go | 8 ++-- internal/interfaces/web/web_test.go | 46 +++++++++++------------ 8 files changed, 50 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 776d898..822f474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Outbound requests authenticate as the lookup itself: the sender `GET /who` eligibility check and countersigned-envelope deliveries carry bearer request tokens. A registrant answering its own `/who` with `202` (deferred disclosure) is rejected with `403` — senders must disclose openly to register. - Registration now requires the sender to serve its own public identity: the inbox resolves `GET /who` on the sender's address (re-fetching its published key) before countersigning. A `204 No Content` — a receive-only account — or an unresolvable identity rejects the registration with `403 Forbidden`. - Allow-list support (`allow.json`, `models.Identity.Allow`) is removed; the sender `/who` check is the gate on registrations. -- Updated to the current `gobl` net API: `Envelope.Sign` options (`head.WithIssuer`/`WithAudience`/`WithVerifier`) and `net.Client.Who`. +- Updated to the current `gobl` net API: `Envelope.Sign` options (`head.WithIssuer`/`WithAudience`/`WithVerifier`) and `net.Client.Who`. Signed `iss`/`aud`/`verifier` claims carry bare GOBL Net addresses (FQDNs) rather than `gobl:` URIs; the scheme remains only on `org.Endpoint` URIs. - Identity key files under `keys/` no longer need to be named after their kid — the kid is read from the JWK itself, and any `*.json` file is accepted (`Init` still writes `.json`). This lets a deployment mount the published key at a fixed path (e.g. `keys/public.json`) without encoding the kid in the filename. - Persistence now uses the shared [`github.com/invopop/couch`](https://github.com/invopop/couch) library: `models.Registration` embeds `couch.Model` (gaining `created_at`/`updated_at` and revision handling), and the CouchDB store uses `couch.Client`/`couch.Store`/`couch.Fetch` and a `couch.Design` for the by-UUID view. The registration database is the couch client's prefix (`COUCHDB_DATABASE`). - Configuration is now read from the environment (`CONFIG_DIR`, `COUCHDB_URL` or the split `COUCHDB_SCHEME/HOST/PORT/USERNAME/PASSWORD`, `COUCHDB_DATABASE`, `HTTP_PORT`/`PORT`, `PUBLIC_BASE_URL`, `LOG_JSON`) so the service can be configured — and its CouchDB password injected from a secret — the way the cluster provides config. The equivalent CLI flags still work and override the environment. Env var names match the sibling services (silo/access). @@ -34,5 +34,5 @@ ### Security -- Inbox `aud` is required: an envelope POSTed to `/inbox` MUST be signed with `aud == gobl:lookup.`. Envelopes without an audience, or bound to a different audience, are rejected with 401 (replay protection — mirrors the recent gobl.dev inbox tightening). +- Inbox `aud` is required: an envelope POSTed to `/inbox` MUST be signed with `aud` equal to the lookup’s address. Envelopes without an audience, or bound to a different audience, are rejected with 401 (replay protection — mirrors the recent gobl.dev inbox tightening). - Registration envelope MUST contain an `org.Party` document; anything else is rejected with 422. diff --git a/README.md b/README.md index 0a48230..fae7992 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ Copyright 2026 [Invopop S.L.](https://invopop.com). 1. A GOBL Net node (e.g. a `gobl.dev` operator at `alice.example`) signs an envelope containing its `org.Party` and POSTs it to lookup's `/.well-known/gobl/inbox`. The - envelope's signed `iss=gobl:alice.example`, - `aud=gobl:lookup.gobl.org`. + envelope's signed `iss=alice.example`, + `aud=lookup.gobl.org` (signed claims carry bare addresses — GOBL + Net is implied). 2. Lookup verifies the signature, then confirms the sender checks out as a *sending* participant: it performs `GET /who` on `alice.example` (re-fetching her published key in the process) @@ -28,8 +29,8 @@ Copyright 2026 [Invopop S.L.](https://invopop.com). marks a receive-only account and rejects the registration with `403 Forbidden`. 3. Lookup persists the envelope in CouchDB, countersigns it with - an Authority signature (`iss=gobl:lookup.gobl.org`, - `aud=gobl:alice.example`, `exp` = 90 days + an Authority signature (`iss=lookup.gobl.org`, + `aud=alice.example`, `exp` = 90 days out), and POSTs the **countersigned** envelope back to `https://alice.example/.well-known/gobl/inbox` as a follow-up message. The original POST is acknowledged with the standard diff --git a/internal/domain/delivery/delivery_test.go b/internal/domain/delivery/delivery_test.go index 42caffd..e3f6367 100644 --- a/internal/domain/delivery/delivery_test.go +++ b/internal/domain/delivery/delivery_test.go @@ -33,8 +33,8 @@ func buildEnvelope(t *testing.T) *gobl.Envelope { require.NoError(t, err) key := dsig.NewES256Key() require.NoError(t, env.Sign(key, - head.WithIssuer(net.Address("alice.example").URI()), - head.WithAudience(net.Address("lookup.example").URI()))) + head.WithIssuer("alice.example"), + head.WithAudience("lookup.example"))) return env } diff --git a/internal/domain/identity.go b/internal/domain/identity.go index 54fde5e..50f4b69 100644 --- a/internal/domain/identity.go +++ b/internal/domain/identity.go @@ -75,7 +75,7 @@ func (d *Identity) PartyEnvelope() ([]byte, error) { d.partyErr = fmt.Errorf("identity: envelop party: %w", err) return } - if err := env.Sign(d.model.PrivateKey, head.WithIssuer(d.URI())); err != nil { + if err := env.Sign(d.model.PrivateKey, head.WithIssuer(d.Address().String())); err != nil { d.partyErr = fmt.Errorf("identity: sign party: %w", err) return } @@ -106,12 +106,12 @@ func (d *Identity) CounterSign(env *gobl.Envelope, opts CounterSignOptions) erro return errors.New("identity: cannot countersign a nil envelope") } signOpts := []head.SignOption{ - head.WithIssuer(d.URI()), - head.WithAudience(opts.Subject.URI()), + head.WithIssuer(d.Address().String()), + head.WithAudience(opts.Subject.String()), head.WithExpiration(time.Now().Add(endorsementTTL)), } if opts.Verifier != "" { - signOpts = append(signOpts, head.WithVerifier(opts.Verifier.URI())) + signOpts = append(signOpts, head.WithVerifier(opts.Verifier.String())) } return env.Sign(d.model.PrivateKey, signOpts...) } diff --git a/internal/domain/identity_test.go b/internal/domain/identity_test.go index 07134c5..c086129 100644 --- a/internal/domain/identity_test.go +++ b/internal/domain/identity_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/invopop/gobl" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" "github.com/invopop/gobl/note" @@ -48,7 +47,7 @@ func TestPartyEnvelopeIsSelfSigned(t *testing.T) { p, err := head.SignedPayload(env.Signatures[0]) require.NoError(t, err) - assert.Equal(t, id.URI(), p.Iss) + assert.Equal(t, id.Address().String(), p.Iss) assert.Empty(t, p.Aud, "a GET who response has no caller to bind to") // The envelope is signed once and cached: a second call returns @@ -67,8 +66,8 @@ func TestCounterSign(t *testing.T) { env, err := gobl.Envelop(msg) require.NoError(t, err) require.NoError(t, env.Sign(id.Model().PrivateKey, - head.WithIssuer(cbc.URI("gobl:alice.example")), - head.WithAudience(id.URI()))) + head.WithIssuer("alice.example"), + head.WithAudience(id.Address().String()))) // Authority countersignature. require.NoError(t, id.CounterSign(env, domain.CounterSignOptions{ @@ -79,9 +78,9 @@ func TestCounterSign(t *testing.T) { p, err := head.SignedPayload(env.Signatures[1]) require.NoError(t, err) - assert.Equal(t, id.URI(), p.Iss) - assert.Equal(t, cbc.URI("gobl:alice.example"), p.Aud) - assert.Equal(t, cbc.URI("gobl:kyc.example"), p.Verifier) + assert.Equal(t, id.Address().String(), p.Iss) + assert.Equal(t, "alice.example", p.Aud) + assert.Equal(t, "kyc.example", p.Verifier) } func TestCounterSignNilEnvelope(t *testing.T) { diff --git a/internal/domain/models/identity.go b/internal/domain/models/identity.go index 282b54e..6e878a4 100644 --- a/internal/domain/models/identity.go +++ b/internal/domain/models/identity.go @@ -34,6 +34,9 @@ type Identity struct { func (i *Identity) Address() net.Address { return i.Domain } // URI returns the gobl: URI form of the lookup's address. +// URI returns the gobl: scheme form of the lookup's address, for +// multi-scheme contexts such as org.Endpoint lists. Signed claims +// carry the bare address instead. func (i *Identity) URI() cbc.URI { return i.Domain.URI() } // FindKey returns the public key whose kid matches, or nil. diff --git a/internal/domain/registrations.go b/internal/domain/registrations.go index 99bb980..f1e47a5 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -81,8 +81,10 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode d.log.Warn("inbox.rejected", "reason", "aud_missing", "caller", string(sender)) return nil, ErrUnauthorized.WithMessage("envelope must carry an aud equal to this lookup") } - if p.Aud != d.identity.URI() { - d.log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", string(p.Aud)) + // Canonicalize so U-Label or trailing-dot forms compare equal, + // mirroring gobl's VerifyEnvelope. + if aud, aerr := goblnet.ParseAddress(p.Aud); aerr != nil || aud != d.identity.Address() { + d.log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", p.Aud) return nil, ErrUnauthorized.WithMessage("envelope audience does not match this lookup") } // Registration entry must carry an org.Party — that's the @@ -238,7 +240,7 @@ func carriesSignatureFrom(env *gobl.Envelope, addr goblnet.Address) bool { if err != nil { continue } - if issuer, err := goblnet.ParseAddress(p.Iss.Opaque()); err == nil && issuer == addr { + if issuer, err := goblnet.ParseAddress(p.Iss); err == nil && issuer == addr { return true } } diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index 4f1ae6f..13c13db 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -127,14 +127,14 @@ func newFixture(t *testing.T) *fixture { f := &fixture{t: t, lookup: lookup, subject: subKey, subAddr: subAddr, fetcher: fetcher, registry: reg, sender: send, mux: mux} // The registration flow resolves the sender's own GET /who, so the // fixture serves a self-signed identity for the subject by default. - who, _ := json.Marshal(f.signPartyEnvelope(subAddr.URI(), "")) + who, _ := json.Marshal(f.signPartyEnvelope(subAddr.String(), "")) fetcher.data[subAddr.WhoURL()] = who return f } // signPartyEnvelope builds a fresh signed envelope from subject with // the given iss/aud. -func (f *fixture) signPartyEnvelope(iss, aud cbc.URI) *gobl.Envelope { +func (f *fixture) signPartyEnvelope(iss, aud string) *gobl.Envelope { f.t.Helper() party := &org.Party{ Name: "Alice", @@ -202,7 +202,7 @@ func (f *fixture) do(method, path string, body []byte, auth string) *http.Respon func TestInboxAcceptsRegistration(t *testing.T) { f := newFixture(t) - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -221,8 +221,8 @@ func TestInboxAcceptsRegistration(t *testing.T) { authSig := rec.CountersignedEnvelope.Signatures[1] p, err := head.SignedPayload(authSig) require.NoError(t, err) - assert.Equal(t, f.lookup.URI(), p.Iss) - assert.Equal(t, f.subAddr.URI(), p.Aud) + assert.Equal(t, f.lookup.Address().String(), p.Iss) + assert.Equal(t, f.subAddr.String(), p.Aud) assert.Empty(t, p.Verifier, "initial countersignature carries no verifier claim") // Discovery link stamped on the (mutable) header. @@ -241,7 +241,7 @@ func TestInboxAcceptsRegistration(t *testing.T) { func TestInboxRejectsMissingAud(t *testing.T) { f := newFixture(t) - env := f.signPartyEnvelope(f.subAddr.URI(), "") + env := f.signPartyEnvelope(f.subAddr.String(), "") body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -250,7 +250,7 @@ func TestInboxRejectsMissingAud(t *testing.T) { func TestInboxRejectsWrongAud(t *testing.T) { f := newFixture(t) - env := f.signPartyEnvelope(f.subAddr.URI(), goblnet.Address("someone.else").URI()) + env := f.signPartyEnvelope(f.subAddr.String(), "someone.else") body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -264,8 +264,8 @@ func TestInboxRejectsNonPartyDocument(t *testing.T) { env, err := gobl.Envelop(msg) require.NoError(t, err) require.NoError(t, env.Sign(f.subject, - head.WithIssuer(f.subAddr.URI()), - head.WithAudience(f.lookup.URI()))) + head.WithIssuer(f.subAddr.String()), + head.WithAudience(f.lookup.Address().String()))) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -298,8 +298,8 @@ func TestInboxRejectsUnknownSigner(t *testing.T) { env, err := gobl.Envelop(party) require.NoError(t, err) require.NoError(t, env.Sign(other, - head.WithIssuer(goblnet.Address("mallory.example").URI()), - head.WithAudience(f.lookup.URI()))) + head.WithIssuer("mallory.example"), + head.WithAudience(f.lookup.Address().String()))) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -309,7 +309,7 @@ func TestInboxRejectsUnknownSigner(t *testing.T) { func TestReRegistrationDropsVerifier(t *testing.T) { f := newFixture(t) // First registration. - env1 := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env1 := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body1, _ := json.Marshal(env1) f.post(goblnet.InboxPath, body1).Body.Close() //nolint:errcheck @@ -323,7 +323,7 @@ func TestReRegistrationDropsVerifier(t *testing.T) { // Re-register: the verifier must be dropped — prior KYC no // longer applies to changed party data. - env2 := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env2 := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body2, _ := json.Marshal(env2) f.post(goblnet.InboxPath, body2).Body.Close() //nolint:errcheck @@ -340,8 +340,8 @@ func (f *fixture) signSameParty(party *org.Party) *gobl.Envelope { env, err := gobl.Envelop(party) require.NoError(f.t, err) require.NoError(f.t, env.Sign(f.subject, - head.WithIssuer(f.subAddr.URI()), - head.WithAudience(f.lookup.URI()))) + head.WithIssuer(f.subAddr.String()), + head.WithAudience(f.lookup.Address().String()))) return env } @@ -380,14 +380,14 @@ func TestRenewalPreservesVerifier(t *testing.T) { sigs := rec2.CountersignedEnvelope.Signatures p, err := head.SignedPayload(sigs[len(sigs)-1]) require.NoError(t, err) - assert.Equal(t, cbc.URI("gobl:kyc.example"), p.Verifier) + assert.Equal(t, "kyc.example", p.Verifier) assert.Greater(t, p.ExpiresAt, time.Now().Add(89*24*time.Hour).Unix()) assert.Less(t, p.ExpiresAt, time.Now().Add(91*24*time.Hour).Unix()) } func TestPartiesLookupByUUID(t *testing.T) { f := newFixture(t) - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) f.post(goblnet.InboxPath, body).Body.Close() //nolint:errcheck _ = f.waitForDelivery(2 * time.Second) @@ -403,7 +403,7 @@ func TestPartiesLookupByUUID(t *testing.T) { func TestPartiesLookupByAddress(t *testing.T) { f := newFixture(t) - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) f.post(goblnet.InboxPath, body).Body.Close() //nolint:errcheck @@ -461,7 +461,7 @@ func TestWhoGet(t *testing.T) { require.Len(t, got.Signatures, 1) p, err := head.SignedPayload(got.Signatures[0]) require.NoError(t, err) - assert.Equal(t, f.lookup.URI(), p.Iss) + assert.Equal(t, f.lookup.Address().String(), p.Iss) assert.Empty(t, p.Aud, "GET who response is not bound to a caller") } @@ -475,7 +475,7 @@ func TestRequestAuth(t *testing.T) { }) t.Run("inbox without a token is rejected", func(t *testing.T) { - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) resp := f.do(http.MethodPost, goblnet.InboxPath, body, "") defer resp.Body.Close() //nolint:errcheck @@ -518,7 +518,7 @@ func TestInboxRejectsPendingWho(t *testing.T) { f := newFixture(t) f.fetcher.errs[f.subAddr.WhoURL()] = goblnet.ErrPending - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -537,7 +537,7 @@ func TestInboxRejectsSenderWithoutWho(t *testing.T) { // The sender's key resolves, but its GET /who does not. delete(f.fetcher.data, f.subAddr.WhoURL()) - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck @@ -551,7 +551,7 @@ func TestInboxRejectsReceiveOnlySender(t *testing.T) { delete(f.fetcher.data, f.subAddr.WhoURL()) f.fetcher.errs[f.subAddr.WhoURL()] = goblnet.ErrNoContent - env := f.signPartyEnvelope(f.subAddr.URI(), f.lookup.URI()) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck From ca33576e91c48eacf10f66d40594588b47051d13 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 18:04:46 +0000 Subject: [PATCH 04/10] README: rewrite the local-dev registration note for HTTPS-only clients The --insecure client flag no longer exists; local registration needs a domain-shaped hostname and a TLS-terminating proxy. Co-Authored-By: Claude Fable 5 --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fae7992..d394dd5 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,14 @@ go run ./cmd/gobl.lookup serve \ --public-base-url http://localhost:8081 ``` -Then from a `gobl.dev` node, point `gobl net send` at lookup with -the `--insecure` flag (HTTP-only, dev-only). The countersigned -envelope lands in your node's `inbox/` directory. +GOBL Net clients always dial `https://
`, so to register +from a `gobl.dev` node against a local lookup you need a +domain-shaped hostname (e.g. `lookup.local` via `/etc/hosts`) and a +TLS-terminating proxy in front of the HTTP port with a locally +trusted certificate (e.g. Caddy or mkcert + nginx). The +countersigned envelope lands in your node's `inbox/` directory. +End-to-end behaviour without TLS plumbing is covered by the test +suites, which inject fetchers instead of dialing. ## CouchDB schema From 1d41510b54112ce1d9756ade4277cc21e91bb542 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 21:47:44 +0000 Subject: [PATCH 05/10] Answer 503 for transient verification and eligibility failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Absorb gobl's review fixes: transient conditions (network failures, 429, 5xx) now surface as net.ErrUnavailable, distinct from invalid tokens and definitive rejections. - requireAuth answers 503 (reason token_unavailable) when the requester's key endpoint cannot be reached, instead of 401. - Registration answers 503 (new domain kind ErrUnavailable, reason who_unavailable) when the sender's /who cannot be reached during the eligibility check — a 403 would make the sender stop retrying a registration that would succeed moments later. - Outbound deliveries return the retryable net.ErrUnavailable for transport failures, 429, and 5xx; ErrInboxRejected is reserved for definitive 4xx rejections. - Test fetchers implement the Fetcher interface's new Post method. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 ++++++ internal/domain/delivery/delivery.go | 21 ++++++++++--- internal/domain/delivery/delivery_test.go | 38 +++++++++++++++++++++++ internal/domain/errors.go | 4 +++ internal/domain/registrations.go | 7 +++++ internal/interfaces/web/auth.go | 13 ++++++++ internal/interfaces/web/web.go | 2 ++ internal/interfaces/web/web_test.go | 29 +++++++++++++++++ 8 files changed, 118 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822f474..dbe4969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ### Changed +- Transient failures now answer `503 Service Unavailable` instead of + a definitive 4xx: an unreachable requester key endpoint during + token verification (log reason `token_unavailable`), and an + unreachable sender `/who` during registration eligibility (log + reason `who_unavailable`) — senders stop retrying on 4xx, so + outages must not permanently reject. Outbound deliveries likewise + distinguish retryable conditions (transport failures, 429, 5xx → + `net.ErrUnavailable`) from definitive inbox rejections. + - Every Authority countersignature now carries a 90-day `exp` claim; parties renew by re-registering before it passes. A renewal with an unchanged party document (same digest) is countersigned with the party's current `verifier` claim — verified stays verified — while changed party data drops the verifier and clears `verified_at`. - The `scope` claim is replaced by structural verification (spec §5.3): a registration countersignature alone asserts a registered identity, and `gobl.lookup verify` re-countersigns with a `verifier` claim naming the KYC/KYB authority. `--verifier` names an external verifying authority (its own countersignature must already be on the stored envelope); the default is the lookup itself, whose single countersignature carries both attestations. The verifier signature's `exp` is independent of the 90-day registration cycle, so verifications can be much longer-lived. `Registration.Verifier` replaces the stored `scope` field. - `/.well-known/gobl/who` is now a `GET` serving the lookup's self-signed party envelope (signed once per process), replacing the authenticated `POST` exchange. Together with `/inbox` it requires a bearer request token (spec §5.5): the requester's token is verified against its published key, audience, and freshness window, and requests without a valid token are rejected with `401` (`auth.rejected` log reasons `token_missing`/`token_invalid`/`token_expired`). The response is served with `Cache-Control: private, max-age=300`; authenticated requests are logged with the requester address as the request audit log. Key discovery and `/parties` stay open. diff --git a/internal/domain/delivery/delivery.go b/internal/domain/delivery/delivery.go index 1a797a0..09943ac 100644 --- a/internal/domain/delivery/delivery.go +++ b/internal/domain/delivery/delivery.go @@ -32,9 +32,14 @@ const dialTimeout = 5 * time.Second // Errors returned by Send. var ( // ErrInboxRejected matches gobl/net's sentinel — the remote - // /inbox returned anything other than 202. + // /inbox definitively rejected the envelope (a non-429 4xx); + // retrying will not help. ErrInboxRejected = net.ErrInboxRejected - // ErrSendFailed wraps transport / encoding errors during send. + // ErrUnavailable matches gobl/net's sentinel — the remote /inbox + // could not be reached or answered 429/5xx; the delivery should + // be retried. + ErrUnavailable = net.ErrUnavailable + // ErrSendFailed wraps input / encoding errors during send. ErrSendFailed = errors.New("delivery: send failed") ) @@ -113,13 +118,19 @@ func (s *HTTPSender) Send(ctx context.Context, addr net.Address, env *gobl.Envel req.Header.Set("Authorization", "Bearer "+token) resp, err := s.client.Do(req) if err != nil { - return fmt.Errorf("%w: %v", ErrSendFailed, err) + // Transport failures are transient: the async delivery loop + // retries them, unlike definitive inbox rejections. + return fmt.Errorf("%w: %v", ErrUnavailable, err) } defer resp.Body.Close() //nolint:errcheck - if resp.StatusCode != http.StatusAccepted { + switch { + case resp.StatusCode == http.StatusAccepted: + return nil + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return fmt.Errorf("%w: HTTP %d from %s", ErrUnavailable, resp.StatusCode, url) + default: return fmt.Errorf("%w: HTTP %d from %s", ErrInboxRejected, resp.StatusCode, url) } - return nil } // safeDialContext is the DialContext used by the default HTTPSender's diff --git a/internal/domain/delivery/delivery_test.go b/internal/domain/delivery/delivery_test.go index e3f6367..4ac414b 100644 --- a/internal/domain/delivery/delivery_test.go +++ b/internal/domain/delivery/delivery_test.go @@ -187,3 +187,41 @@ func (m *mapFetcher) Fetch(_ context.Context, url string, _ http.Header) ([]byte } return nil, net.ErrFetchFailed } + +func (m *mapFetcher) Post(_ context.Context, _ string, _ []byte, _ http.Header) error { + return net.ErrFetchFailed +} + +func TestSendRetryableTaxonomy(t *testing.T) { + send := func(t *testing.T, status int) error { + t.Helper() + s := &HTTPSender{ + self: testSelf, + key: testSelfKey, + client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Body: http.NoBody, Request: r}, nil + })}, + } + return s.Send(context.Background(), net.Address("alice.example"), buildEnvelope(t)) + } + + t.Run("5xx is retryable", func(t *testing.T) { + err := send(t, http.StatusBadGateway) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable)) + assert.False(t, errors.Is(err, ErrInboxRejected)) + }) + + t.Run("429 is retryable", func(t *testing.T) { + err := send(t, http.StatusTooManyRequests) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnavailable)) + }) + + t.Run("4xx is a definitive rejection", func(t *testing.T) { + err := send(t, http.StatusUnauthorized) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrInboxRejected)) + assert.False(t, errors.Is(err, ErrUnavailable)) + }) +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index e848c79..848816d 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -30,6 +30,10 @@ var ( // ErrConflict is returned when an optimistic-concurrency write // loses a race; callers should re-read and retry. ErrConflict = NewError("conflict") + // ErrUnavailable is returned when a remote dependency needed to + // process the request (a key or who endpoint) could not be + // reached: a transient condition the caller should retry. + ErrUnavailable = NewError("unavailable") // ErrInternal wraps an unexpected failure. ErrInternal = NewError("internal") ) diff --git a/internal/domain/registrations.go b/internal/domain/registrations.go index f1e47a5..f75d874 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -99,6 +99,13 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode // (which also re-fetches its published key) must return a verified // party. A 204 marks a receive-only account, which cannot register. if _, err := d.client.Who(ctx, sender); err != nil { + // A transient outage while resolving the sender's who must not + // permanently reject the registration: senders stop retrying + // on 4xx, so surface a retryable condition instead. + if errors.Is(err, goblnet.ErrUnavailable) { + d.log.Warn("inbox.rejected", "reason", "who_unavailable", "caller", string(sender), "error", err.Error()) + return nil, ErrUnavailable.WithMessage("could not reach sender's public identity; retry later") + } reason := "who_failed" msg := "could not resolve sender's public identity" switch { diff --git a/internal/interfaces/web/auth.go b/internal/interfaces/web/auth.go index ac57376..1642bab 100644 --- a/internal/interfaces/web/auth.go +++ b/internal/interfaces/web/auth.go @@ -31,6 +31,19 @@ func requireAuth(s *domain.Setup, log *slog.Logger, next http.HandlerFunc) http. header := r.Header.Get("Authorization") requester, err := s.Identity().VerifyRequest(r.Context(), header) if err != nil { + // A token that cannot be *checked* (the issuer's key + // endpoint is unreachable) is not an invalid token: + // answer 503 so the client retries. + if errors.Is(err, goblnet.ErrUnavailable) { + log.Warn("auth.rejected", + "path", r.URL.Path, + "reason", "token_unavailable", + "remote", r.RemoteAddr, + "error", err.Error(), + ) + http.Error(w, "could not verify request token", http.StatusServiceUnavailable) + return + } reason := "token_invalid" switch { case header == "": diff --git a/internal/interfaces/web/web.go b/internal/interfaces/web/web.go index 234dc4f..da3ac18 100644 --- a/internal/interfaces/web/web.go +++ b/internal/interfaces/web/web.go @@ -75,6 +75,8 @@ func statusForError(err error) int { return http.StatusNotFound case errors.Is(err, domain.ErrConflict): return http.StatusConflict + case errors.Is(err, domain.ErrUnavailable): + return http.StatusServiceUnavailable default: return http.StatusInternalServerError } diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index 13c13db..c738bcf 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "log/slog" "net/http" @@ -45,6 +46,10 @@ func (m *mockFetcher) Fetch(_ context.Context, url string, _ http.Header) ([]byt return nil, goblnet.ErrFetchFailed } +func (m *mockFetcher) Post(_ context.Context, _ string, _ []byte, _ http.Header) error { + return goblnet.ErrFetchFailed +} + // mockSender records send attempts. By default it succeeds; set `err` // to make Send fail. type mockSender struct { @@ -557,3 +562,27 @@ func TestInboxRejectsReceiveOnlySender(t *testing.T) { defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusForbidden, resp.StatusCode) } + +func TestRequestAuthUnavailable(t *testing.T) { + // The requester's key endpoint is unreachable: the lookup answers + // 503 so the caller retries, not a definitive 401. + f := newFixture(t) + f.fetcher.errs[f.subAddr.KeyURL(f.subject.ID())] = fmt.Errorf("%w: HTTP 503", goblnet.ErrUnavailable) + + resp := f.get(goblnet.WhoPath) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +func TestInboxWhoUnavailable(t *testing.T) { + // A transient outage resolving the sender's who must answer 503 — + // a 4xx would make the sender stop retrying its registration. + f := newFixture(t) + f.fetcher.errs[f.subAddr.WhoURL()] = fmt.Errorf("%w: HTTP 503", goblnet.ErrUnavailable) + + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} From e614ccfa99c553c70594de53a79228e1dbdbf626 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sat, 25 Jul 2026 21:48:03 +0000 Subject: [PATCH 06/10] README: document the 503 answer for unverifiable tokens Co-Authored-By: Claude Fable 5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d394dd5..19da394 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ The domain never imports the transport layer. The who and inbox endpoints require a bearer request token (spec §5.5) minted from the caller's own published key; requests without -a valid token get `401`. The token's issuer may be a trusted +a valid token get `401`, and `503` when the token cannot be checked +because the issuer's key endpoint is unreachable (retry). The token's issuer may be a trusted intermediary transmitting on the registrant's behalf — the registration subject always comes from the envelope's own signature. Key discovery stays open (it is what makes token From 7e90131cc4b07951af11caa8483b163750d1be5c Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sun, 26 Jul 2026 14:08:59 +0000 Subject: [PATCH 07/10] README: specify the self-service verification flow Documents the target design for upgrading a registration to verified via https://lookup.gobl.org/verify/
: email OTP gate against the party's published mailbox, a trusted-verifier picker, a background session hand-off pinning the stored envelope's uuid+dig, the provider's own KYC/KYB process (payment included as a fraud signal, keeping the registry free), the countersigned renewal back through the standard inbox, auto-verify when a trusted verifier's countersignature arrives, and delivery + self-publishing by the subject. Ends with the implementation gap list. Co-Authored-By: Claude Fable 5 --- README.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 19da394..6cb15f6 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,11 @@ Copyright 2026 [Invopop S.L.](https://invopop.com). `net.Client.VerifySender`. No new protocol endpoints — registration uses the standard GOBL -Net `/inbox` POST in both directions. A future revision will add a -link on the countersigned envelope pointing the subject at a full -KYC flow to mark the registration as verified (spec §5.3, the -`verifier` claim); for now the upgrade is operator-driven via -`gobl.lookup verify`. +Net `/inbox` POST in both directions. Marking a registration as +verified (spec §5.3, the `verifier` claim) is operator-driven via +`gobl.lookup verify` today; the self-service flow it will grow into +is specified in [How verification works](#how-verification-works) +below. ### Endorsement lifetime and renewal @@ -60,6 +60,111 @@ KYC. Submitting changed party data is a fresh registration — the verifier is dropped and KYC must be repeated. +## How verification works + +> **Status: design.** The flow below is the agreed target for +> self-service verification. Today only the final signing step +> exists (the operator-driven `gobl.lookup verify` command); the +> web flow, email OTP, verifier hand-off, and auto-verify are not +> yet implemented — see the gap list at the end of this section. + +Verification upgrades a *registered* identity to *verified* by +adding two things to the party envelope: a countersignature from a +KYC/KYB provider ("verifier", e.g. `didit.gobl.org`), and a fresh +lookup countersignature carrying the `verifier` claim that points +at it. The registry orchestrates; the verifier performs and charges +for the actual checks; the protocol carries the results as plain +inbox deliveries. + +1. **Start.** A representative of the registered party opens + `https://lookup.gobl.org/verify/
`. The address MUST + already be registered (unregistered addresses are pointed at + the registration flow first) and its party MUST publish at + least one email address — the flow has no other way to reach a + human connected to the party. +2. **Email OTP.** Lookup sends a one-time code to an email chosen + from the party's published `emails`. This does not verify the + *business* — that is the verifier's job — it gates the flow: + only someone with access to the party's own published mailbox + can start (and pay for) verification, so third parties cannot + spend a subject's verification budget or spam providers on its + behalf. Domain control was already proven at registration by + the signed envelope. OTP attempts are rate-limited per address. +3. **Choose a verifier.** The user picks from the registry's + configured trusted-verifier list (the same list the registry is + willing to name in `verifier` claims). Each entry shows the + provider's published verification policy — which checks are + performed, price, and countersignature lifetime — so the trust + a receiver will later infer from the verifier's name is + inspectable up front. +4. **Session hand-off.** Lookup POSTs the party's **stored, + countersigned envelope** to the verifier's session API in the + background, authenticated with a request token for + `lookup.gobl.org` (spec §5.5). Sending the stored envelope + pins the verification to an exact `uuid` + `dig`: the verifier + MUST countersign those bytes, so the checks and the eventual + signature cannot drift apart. The verifier answers with an + opaque, single-use session URL (or an error if it cannot serve + the party's jurisdiction); lookup redirects the user's browser + to it. Session URLs are unguessable on purpose — + `/verify/
` alone would let anyone walk into a session + holding another party's data. +5. **The verifier's process.** On the verifier's own pages (e.g. + `https://didit.gobl.org/...`), the user provides the company + representative's personal contact details and payment. Payment + is deliberately part of the KYC surface — cardholder data is + itself a fraud signal — and keeps verification revenue entirely + on the verifier's side: the registry stays free. From here the + provider runs its usual process (document checks, liveness, + registry/UBO lookups, AML screening). +6. **Countersign and return.** On success the verifier + countersigns the envelope from step 4 (`iss=`, + `aud=`, `exp` a year or more — spec §5.3) and POSTs + the envelope back to lookup's standard `/inbox` with its own + request token. To the inbox this is an ordinary renewal: same + digest, first signature still the subject's, one extra + countersignature aboard. Failed or abandoned sessions simply + never produce a countersignature; sessions expire after a few + days and the verifier SHOULD notify lookup so the flow's status + page can say so. +7. **Auto-verify.** When a renewal arrives carrying a valid + countersignature from a verifier on the trusted-verifier list, + lookup re-countersigns with `verifier=` + automatically — the same act as `gobl.lookup verify`, without + the operator. The stored record gains `verifier` + + `verified_at`. +8. **Deliver and publish.** The now twice-countersigned envelope + (subject self-signature, verifier countersignature, lookup + countersignature naming the verifier) is delivered to the + subject's own `/inbox` — the standard registration delivery + path. The subject publishes it at its `/who`; only then do + receivers see the verified status. The verify status page tells + the user this last step is theirs. + +Renewal interplay is unchanged: re-registering the identical party +document keeps the verifier claim; changed party data drops it and +the party goes through verification again (the verifier's own +countersignature attests to the *checked* data, not to future +edits). + +**Implementation gaps** (in rough order): + +- Trusted-verifier list: registry configuration naming the + verifier addresses lookup will offer in step 3 and accept in + step 7, with their policy/pricing metadata. +- Auto-verify on renewal (step 7) — the logic exists as + `Registrations.Verify`; it needs the trigger from the inbox path + plus the trusted-list check. +- The `/verify/
` web flow: OTP issue/check, verifier + picker, redirect, and a status page for pending sessions. +- The verifier session API contract (step 4): create-session + request/response shape shared with bridge implementations like + `didit.gobl.org`, including session expiry and failure + callbacks. +- A `head.Link` on the registration delivery pointing the subject + at `verify/
`, so every registered party discovers the + upgrade path. + ## Architecture The code follows the standard Invopop layered layout (cf. `silo`, From 2188ac1249505dc9ec71a3458e926941a9fd0500 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sun, 26 Jul 2026 14:29:43 +0000 Subject: [PATCH 08/10] Pin gobl to the net-request-tokens branch pseudo-version Replace the local ../gobl filesystem pin with the pushed branch commit (v0.503.1-0.20260725214141-df55536c0206) so CI can resolve the module and run tests and the linter. Swap for the tagged release once it ships. Also settle a racy assertion in TestInboxAcceptsRegistration: the async delivery goroutine is instant with the mock sender, so the record may legitimately read countersigned or delivered. Co-Authored-By: Claude Fable 5 --- go.mod | 4 +--- go.sum | 2 ++ internal/interfaces/web/web_test.go | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index d594f7b..939e3bc 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/go-kivik/kivik/v4 v4.5.2 github.com/invopop/couch v0.1.0 - github.com/invopop/gobl v0.403.1-0.20260607084143-424395a8cae9 + github.com/invopop/gobl v0.503.1-0.20260725214141-df55536c0206 github.com/magefile/mage v1.17.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 @@ -34,5 +34,3 @@ require ( golang.org/x/text v0.38.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) - -replace github.com/invopop/gobl => ../gobl diff --git a/go.sum b/go.sum index fa066b1..639feaa 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/couch v0.1.0 h1:ctMKLeIxnab9KW11KtEAQSl7RzGgMxndavLphx99LpY= github.com/invopop/couch v0.1.0/go.mod h1:xzBNVglDnLcpf1Z9BJxiIG1liESSIkMuvIczSl4zcls= +github.com/invopop/gobl v0.503.1-0.20260725214141-df55536c0206 h1:kohIkkrvv3jwsp3Z3XSMthOmHAwjzaUIxMoCA/4JEb8= +github.com/invopop/gobl v0.503.1-0.20260725214141-df55536c0206/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index c738bcf..dc9bf85 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -213,10 +213,13 @@ func TestInboxAcceptsRegistration(t *testing.T) { defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusAccepted, resp.StatusCode) - // Record persisted with the Authority countersignature. + // Record persisted with the Authority countersignature. Delivery + // happens asynchronously and the mock sender is instant, so the + // record may already have advanced from countersigned to + // delivered by the time we read it. rec, err := f.registry.Get(context.Background(), f.subAddr) require.NoError(t, err) - assert.Equal(t, models.StatusCountersigned, rec.Status) + assert.Contains(t, []models.Status{models.StatusCountersigned, models.StatusDelivered}, rec.Status) assert.Empty(t, rec.Verifier, "initial registration carries no verifier") require.NotNil(t, rec.CountersignedEnvelope) require.Len(t, rec.CountersignedEnvelope.Signatures, 2, From bbf095ad91324c23585e2ce05fccf104b94d6841 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sun, 26 Jul 2026 14:43:51 +0000 Subject: [PATCH 09/10] Derive verifiers from a configured provider list; drop --verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification becomes list-driven: the registry accepts a configured set of verification providers (--verifiers / VERIFIERS) and derives the verifier claim from the countersignatures already on the envelope instead of taking it as input. - Auto-verify: a registration or renewal carrying a valid countersignature from an accepted provider is verified on arrival. The countersignature is cryptographically checked against the provider's published key before being named — a forged signature claiming a provider's address is ignored, and an unreachable key endpoint registers unverified with an inbox.auto_verify_unavailable log rather than rejecting. - gobl.lookup verify
loses --verifier and becomes a recovery command: it re-runs the same derivation on the stored envelope, e.g. after a provider joins the accepted list. With no accepted countersignature it fails; with an unreachable provider key endpoint it returns 503-mapped ErrUnavailable. - The verified_at timestamp follows the verifier: kept across renewals with the same provider, stamped fresh on a new one, cleared when the verifier is dropped. - The discovery link stamped on countersigned envelopes uses the standard `verification` category; the previous `authority` value failed envelope validation as soon as the stored envelope was re-signed. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + README.md | 26 ++--- cmd/gobl.lookup/serve.go | 1 + cmd/gobl.lookup/setup.go | 10 ++ cmd/gobl.lookup/verify.go | 33 +++---- internal/config/config.go | 19 ++++ internal/domain/domain.go | 6 +- internal/domain/registrations.go | 121 ++++++++++++++++++----- internal/interfaces/web/web_test.go | 147 +++++++++++++++++++++++++--- 9 files changed, 295 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbe4969..ef5b8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Changed +- Verification is now list-driven and automatic: the registry accepts a configured set of verification providers (`--verifiers` / `VERIFIERS`), and a registration or renewal arriving with a valid countersignature from one of them is verified on arrival — the countersignature is cryptographically checked against the provider's published key before being named (an unreachable key endpoint registers unverified and logs `inbox.auto_verify_unavailable`; the derivation re-runs on renewal). `gobl.lookup verify
` loses the `--verifier` flag and becomes a recovery command that re-derives the verifier from the stored envelope, e.g. after a provider is added to the accepted list. The discovery link stamped on countersigned envelopes uses the standard `verification` category (the previous `authority` value failed envelope validation on re-signing). + - Transient failures now answer `503 Service Unavailable` instead of a definitive 4xx: an unreachable requester key endpoint during token verification (log reason `token_unavailable`), and an diff --git a/README.md b/README.md index 6cb15f6..b41f14b 100644 --- a/README.md +++ b/README.md @@ -62,11 +62,12 @@ verifier is dropped and KYC must be repeated. ## How verification works -> **Status: design.** The flow below is the agreed target for -> self-service verification. Today only the final signing step -> exists (the operator-driven `gobl.lookup verify` command); the -> web flow, email OTP, verifier hand-off, and auto-verify are not -> yet implemented — see the gap list at the end of this section. +> **Status: partial.** The registry side is implemented: the +> accepted-verifier list (`--verifiers` / `VERIFIERS`), auto-verify +> on registrations carrying a provider countersignature, and the +> `gobl.lookup verify` recovery command. The web flow, email OTP, +> and verifier session hand-off are not yet implemented — see the +> gap list at the end of this section. Verification upgrades a *registered* identity to *verified* by adding two things to the party envelope: a countersignature from a @@ -149,12 +150,12 @@ edits). **Implementation gaps** (in rough order): -- Trusted-verifier list: registry configuration naming the - verifier addresses lookup will offer in step 3 and accept in - step 7, with their policy/pricing metadata. -- Auto-verify on renewal (step 7) — the logic exists as - `Registrations.Verify`; it needs the trigger from the inbox path - plus the trusted-list check. +- Policy/pricing metadata for the accepted-verifier list (the + addresses themselves are configured via `--verifiers` / + `VERIFIERS`, and step 7's auto-verify is implemented: a + registration or renewal carrying a valid countersignature from an + accepted provider is verified on arrival, with the crypto checked + against the provider's published key first). - The `/verify/
` web flow: OTP issue/check, verifier picker, redirect, and a status page for pending sessions. - The verifier session API contract (step 4): create-session @@ -271,7 +272,7 @@ preserve the audit trail across re-registrations. |--------------------------------|-----------------------------------------------------------------------------------------| | `gobl.lookup init ` | Scaffold keypair + `party.json` + `keys/.json`. | | `gobl.lookup serve` | Run the HTTP server (terminates HTTP only; deploy behind a TLS proxy). | -| `gobl.lookup verify
` | Mark a registration as identity-verified after out-of-band KYC and re-deliver. `--verifier` names an external verifying authority (default: the lookup itself). | +| `gobl.lookup verify
` | Recovery: re-derive verified status from the accepted-provider countersignatures already on the stored envelope and re-deliver. Normally automatic at registration. | | `gobl.lookup version` | Print service + core gobl versions. | The top-level `--json` flag switches operator logs from text to @@ -295,6 +296,7 @@ equivalent flags override the environment for local use. | `COUCHDB_DATABASE` | `--couchdb-database` | `gobl-lookup` | Database name. | | `HTTP_PORT`/`PORT` | `--http-port` | `8080` | HTTP listen port (`HTTP_PORT` wins over `PORT`). | | `PUBLIC_BASE_URL` | `--public-base-url` | `https://` | Canonical URL for `/parties/` discovery links. | +| `VERIFIERS` | `--verifiers` | — | Comma-separated addresses of accepted verification providers (e.g. `didit.gobl.org`). | | `LOG_JSON` | `--json` | `false` | Emit structured JSON logs on stderr. | Supply the CouchDB connection either as a single `COUCHDB_URL` diff --git a/cmd/gobl.lookup/serve.go b/cmd/gobl.lookup/serve.go index eee6375..21a400f 100644 --- a/cmd/gobl.lookup/serve.go +++ b/cmd/gobl.lookup/serve.go @@ -87,5 +87,6 @@ Deploy behind a reverse proxy that handles TLS termination.`, cmd.Flags().StringVar(&cfg.CouchDatabase, "couchdb-database", cfg.CouchDatabase, "CouchDB database name (env COUCHDB_DATABASE)") cmd.Flags().IntVar(&cfg.HTTPPort, "http-port", cfg.HTTPPort, "HTTP listen port (env HTTP_PORT or PORT)") cmd.Flags().StringVar(&cfg.PublicBaseURL, "public-base-url", cfg.PublicBaseURL, "canonical https URL used to build /parties/ links, defaults to https:// (env PUBLIC_BASE_URL)") + cmd.Flags().StringSliceVar(&cfg.Verifiers, "verifiers", cfg.Verifiers, "accepted verification-provider addresses (env VERIFIERS, comma-separated)") return cmd } diff --git a/cmd/gobl.lookup/setup.go b/cmd/gobl.lookup/setup.go index 1455977..b74da55 100644 --- a/cmd/gobl.lookup/setup.go +++ b/cmd/gobl.lookup/setup.go @@ -42,9 +42,19 @@ func buildDomain(ctx context.Context, cfg config.Config) (*domain.Setup, func(), return nil, nil, gobl.ErrInternal.WithCause(err) } + var verifiers []goblnet.Address + for _, v := range cfg.Verifiers { + addr, err := goblnet.ParseAddress(v) + if err != nil { + return nil, nil, gobl.ErrInput.WithReason("invalid verifier address %q", v) + } + verifiers = append(verifiers, addr) + } + setup := domain.New(domain.Deps{ Identity: id, Registrations: reg, + Verifiers: verifiers, // The client and sender authenticate outbound requests as the // lookup itself (bearer request tokens, spec §5.5). Client: goblnet.NewClient(goblnet.WithIdentity(id.Address(), id.PrivateKey)), diff --git a/cmd/gobl.lookup/verify.go b/cmd/gobl.lookup/verify.go index 5a6fcaa..d732bc3 100644 --- a/cmd/gobl.lookup/verify.go +++ b/cmd/gobl.lookup/verify.go @@ -16,20 +16,20 @@ import ( func verifyCmd() *cobra.Command { cfg := config.FromEnv() - var verifier string cmd := &cobra.Command{ Use: "verify
", - Short: "Mark a registration as identity-verified after out-of-band KYC", - Long: `Load the existing registration for
, countersign the -stored party envelope with a verifier claim naming the authority -that performed the KYC/KYB check, deliver the new envelope to the -subject's /inbox, and update the registry record (verifier=, -verified_at=now). + Short: "Re-derive a registration's verified status from its countersignatures", + Long: `Load the existing registration for
, find the most recent +countersignature from an accepted verification provider (the +configured --verifiers list) on the stored party envelope, +countersign with a verifier claim naming it, deliver the new +envelope to the subject's /inbox, and update the registry record +(verifier=, verified_at=now). -By default the lookup names itself as the verifier, so its own -countersignature carries both attestations. Pass --verifier to name -an external verifying authority instead; that authority's own -countersignature must already be present on the stored envelope. +The same derivation runs automatically when a registration arrives +carrying a provider countersignature; this command exists for +recovery — e.g. a provider added to the accepted list after its +countersignature was received. The original Authority countersignature on the previous record remains in the audit history (CouchDB revisions). This command @@ -46,13 +46,6 @@ issues a fresh signature; the subject can publish either or both.`, if err != nil { return gobl.ErrInput.WithCause(err) } - var verifierAddr goblnet.Address - if verifier != "" { - verifierAddr, err = goblnet.ParseAddress(verifier) - if err != nil { - return gobl.ErrInput.WithCause(err) - } - } ctx := cmd.Context() setup, cleanup, err := buildDomain(ctx, cfg) @@ -61,7 +54,7 @@ issues a fresh signature; the subject can publish either or both.`, } defer cleanup() - rec, err := setup.Registrations().Verify(ctx, addr, verifierAddr) + rec, err := setup.Registrations().Verify(ctx, addr) if err != nil { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrValidation) { return gobl.ErrInput.WithCause(err) @@ -80,6 +73,6 @@ issues a fresh signature; the subject can publish either or both.`, cmd.Flags().StringVar(&cfg.ConfigDir, "config-dir", cfg.ConfigDir, "directory holding the lookup identity (env CONFIG_DIR)") cmd.Flags().StringVar(&cfg.CouchURL, "couchdb", cfg.CouchURL, "full CouchDB URL (env COUCHDB_URL; overrides the COUCHDB_* parts)") cmd.Flags().StringVar(&cfg.CouchDatabase, "couchdb-database", cfg.CouchDatabase, "CouchDB database name (env COUCHDB_DATABASE)") - cmd.Flags().StringVar(&verifier, "verifier", "", "address of the authority that performed the verification (default: the lookup itself)") + cmd.Flags().StringSliceVar(&cfg.Verifiers, "verifiers", cfg.Verifiers, "accepted verification-provider addresses (env VERIFIERS, comma-separated)") return cmd } diff --git a/internal/config/config.go b/internal/config/config.go index ec42f11..762c722 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "strconv" + "strings" "time" "github.com/invopop/couch" @@ -45,6 +46,11 @@ type Config struct { // /parties/ discovery links. Empty defaults to // https://. PublicBaseURL string + // Verifiers lists the GOBL Net addresses of the verification + // providers this registry accepts: their countersignatures are + // named in `verifier` claims, both automatically at registration + // and by the verify command. + Verifiers []string // ShutdownTimeout bounds graceful shutdown of the HTTP server. ShutdownTimeout time.Duration // JSONLogs switches operator logs from text to JSON. @@ -68,6 +74,7 @@ func FromEnv() Config { HTTPPort: httpPortFromEnv(), PublicBaseURL: Env("PUBLIC_BASE_URL", ""), + Verifiers: EnvList("VERIFIERS"), ShutdownTimeout: 10 * time.Second, JSONLogs: EnvBool("LOG_JSON", false), } @@ -156,6 +163,18 @@ func Env(key, fallback string) string { return fallback } +// EnvList parses a comma-separated environment variable into a slice, +// trimming whitespace and dropping empty entries. +func EnvList(key string) []string { + var out []string + for _, v := range strings.Split(os.Getenv(key), ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} + // EnvBool parses a boolean environment variable, falling back on unset // or unparseable values. func EnvBool(key string, fallback bool) bool { diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 5cf34cb..ac64460 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -42,6 +42,10 @@ type Deps struct { Client *goblnet.Client // Sender delivers the countersigned envelope to the subject. Sender delivery.Sender + // Verifiers lists the verification-provider addresses whose + // countersignatures the registry accepts and names in `verifier` + // claims. + Verifiers []goblnet.Address // PublicBaseURL is the canonical https URL clients use to fetch // this lookup (e.g. "https://lookup.gobl.org"); used to build the // head.Link to the public registration record. When empty, New @@ -72,7 +76,7 @@ func New(d Deps) *Setup { if s.publicBaseURL == "" { s.publicBaseURL = "https://" + string(s.identity.Address()) } - s.registrations = newRegistrations(d.Registrations, s.identity, d.Client, d.Sender, s.publicBaseURL, d.Logger) + s.registrations = newRegistrations(d.Registrations, s.identity, d.Client, d.Sender, d.Verifiers, s.publicBaseURL, d.Logger) return s } diff --git a/internal/domain/registrations.go b/internal/domain/registrations.go index f75d874..ecf4a44 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -36,17 +36,27 @@ type Registrations struct { identity *Identity client *goblnet.Client sender delivery.Sender + verifiers map[goblnet.Address]bool publicBaseURL string log *slog.Logger } -// newRegistrations instantiates the registrations domain service. -func newRegistrations(store RegistrationStore, identity *Identity, client *goblnet.Client, sender delivery.Sender, publicBaseURL string, log *slog.Logger) *Registrations { +// newRegistrations instantiates the registrations domain service. The +// verifiers list is canonicalized into the set of provider addresses +// whose countersignatures the registry accepts. +func newRegistrations(store RegistrationStore, identity *Identity, client *goblnet.Client, sender delivery.Sender, verifiers []goblnet.Address, publicBaseURL string, log *slog.Logger) *Registrations { + accepted := make(map[goblnet.Address]bool, len(verifiers)) + for _, v := range verifiers { + if canon, err := goblnet.ParseAddress(string(v)); err == nil { + accepted[canon] = true + } + } return &Registrations{ store: store, identity: identity, client: client, sender: sender, + verifiers: accepted, publicBaseURL: publicBaseURL, log: log, } @@ -122,8 +132,20 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode // An unchanged party re-registering before its endorsement expires // is a renewal and keeps its current verifier; anything else - // starts as registered only. + // starts as registered only. Either way, a countersignature from + // an accepted verification provider on the incoming envelope wins: + // it attests to exactly this digest, so the registration is + // verified from the start (auto-verify — the manual verify command + // only re-triggers this same derivation). verifier, renewal := d.renewalVerifier(ctx, sender, env) + if av, err := d.acceptedVerifier(ctx, env); av != "" { + verifier = av + } else if err != nil { + // A provider's key endpoint was unreachable: register + // unverified rather than reject — the derivation re-runs on + // the next renewal, or via the verify command. + d.log.Warn("inbox.auto_verify_unavailable", "caller", string(sender), "error", err.Error()) + } // Countersign: adds Authority signature with iss=lookup, // aud=sender, any preserved verifier claim, and a 90-day exp. @@ -141,7 +163,7 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode // discovery hint, not part of the trust claim. if d.publicBaseURL != "" { env.Head.Links = append(env.Head.Links, &head.Link{ - Category: "authority", + Category: head.LinkCategoryKeyVerification, Key: "lookup", URL: d.publicBaseURL + "/parties/" + env.Head.UUID.String(), }) @@ -171,16 +193,17 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode return rec, nil } -// Verify marks an existing registration as identity-verified after -// out-of-band KYC/KYB: it re-countersigns the stored envelope with a -// `verifier` claim naming the verifying authority and delivers it -// synchronously to the subject's inbox. An empty verifier defaults to -// the lookup itself — its own countersignature then serves as both -// attestations (spec §5.3). Naming an external verifier requires that -// verifier's countersignature to already be present on the stored -// envelope, since only its own signature can evidence the -// verification. -func (d *Registrations) Verify(ctx context.Context, addr, verifier goblnet.Address) (*models.Registration, error) { +// Verify marks an existing registration as identity-verified: it +// derives the verifier from the countersignatures already on the +// stored envelope — the most recent one from an accepted verification +// provider — re-countersigns with the `verifier` claim naming it, and +// delivers the result synchronously to the subject's inbox. The same +// derivation runs automatically when a registration arrives carrying +// a provider countersignature, so this command exists for recovery: +// e.g. a provider added to the accepted list after its +// countersignature was received. The registry names itself only when +// it is on its own accepted list. +func (d *Registrations) Verify(ctx context.Context, addr goblnet.Address) (*models.Registration, error) { rec, err := d.store.Get(ctx, addr) if errors.Is(err, repos.ErrNotFound) { return nil, ErrNotFound.WithMessage("no registration for %s", addr) @@ -193,11 +216,12 @@ func (d *Registrations) Verify(ctx context.Context, addr, verifier goblnet.Addre } env := rec.CountersignedEnvelope + verifier, aerr := d.acceptedVerifier(ctx, env) if verifier == "" { - verifier = d.identity.Address() - } - if verifier != d.identity.Address() && !carriesSignatureFrom(env, verifier) { - return nil, ErrValidation.WithMessage("verifier %s has not countersigned the registration envelope", verifier) + if aerr != nil { + return nil, ErrUnavailable.WithMessage("could not check verifier countersignatures: %s", aerr.Error()) + } + return nil, ErrValidation.WithMessage("no countersignature from an accepted verifier on the registration envelope for %s", addr) } // Stamp a fresh Authority signature carrying the verifier claim @@ -238,20 +262,53 @@ func (d *Registrations) Verify(ctx context.Context, addr, verifier goblnet.Addre return rec, nil } -// carriesSignatureFrom reports whether the envelope has a signature -// whose signed iss names addr. Presence only — consumers perform the -// cryptographic verification against the verifier's published key. -func carriesSignatureFrom(env *gobl.Envelope, addr goblnet.Address) bool { +// acceptedVerifier returns the address behind the most recent +// unexpired countersignature on env whose signer is an accepted +// verification provider and whose signature verifies against the +// provider's published key, or "". The crypto check matters here: +// naming a verifier whose signature consumers would reject makes the +// registry attest to garbage. Latest-iat wins when several providers +// have countersigned. The error is non-nil only when a candidate's +// key endpoint was unreachable (net.ErrUnavailable) — the caller +// decides whether that degrades or aborts. +func (d *Registrations) acceptedVerifier(ctx context.Context, env *gobl.Envelope) (goblnet.Address, error) { + var ( + best goblnet.Address + bestIat int64 = -1 + unavailable error + ) + now := time.Now().UTC().Unix() for _, sig := range env.Signatures { p, err := head.SignedPayload(sig) if err != nil { continue } - if issuer, err := goblnet.ParseAddress(p.Iss); err == nil && issuer == addr { - return true + issuer, err := goblnet.ParseAddress(p.Iss) + if err != nil || !d.verifiers[issuer] { + continue } + if p.ExpiresAt != 0 && now >= p.ExpiresAt { + continue + } + if p.IssuedAt <= bestIat { + continue + } + pub, err := d.client.FetchKey(ctx, issuer, sig.KeyID()) + if err != nil { + if errors.Is(err, goblnet.ErrUnavailable) { + unavailable = err + } + continue + } + if err := env.Head.Verify(sig, pub); err != nil { + continue + } + best, bestIat = issuer, p.IssuedAt + } + if best == "" && unavailable != nil { + return "", unavailable } - return false + return best, nil } // Find resolves a public lookup key — either an envelope UUID or a @@ -314,6 +371,10 @@ func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env case errors.Is(err, repos.ErrNotFound): r := models.NewRegistration(sender, env.Head.UUID) r.Verifier = verifier + if verifier != "" { + now := time.Now().UTC() + r.VerifiedAt = &now + } r.Status = models.StatusCountersigned r.CountersignedEnvelope = env if err := d.store.Put(ctx, r); err != nil { @@ -323,6 +384,7 @@ func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env case err != nil: return nil, err } + prevVerifier := prev.Verifier prev.IncomingEnvelopeUUID = env.Head.UUID prev.ReceivedAt = time.Now().UTC() prev.Verifier = verifier @@ -331,8 +393,15 @@ func (d *Registrations) upsert(ctx context.Context, sender goblnet.Address, env prev.DeliveryAttempts = 0 prev.LastDeliveryError = "" prev.LastDeliveryAt = nil - if !renewal { + // The verification timestamp follows the verifier: dropped when + // the verifier is dropped, kept across renewals with the same + // verifier, and stamped fresh when a (new) provider verifies. + switch { + case verifier == "": prev.VerifiedAt = nil + case prev.VerifiedAt == nil || verifier != prevVerifier: + now := time.Now().UTC() + prev.VerifiedAt = &now } if err := d.store.Put(ctx, prev); err != nil { return nil, err diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index dc9bf85..4909a51 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -87,14 +88,16 @@ func discardLogger() *slog.Logger { // and its GET /who identity. Returns everything the inbox test needs // to POST a registration. type fixture struct { - t *testing.T - lookup *models.Identity - subject *dsig.PrivateKey - subAddr goblnet.Address - fetcher *mockFetcher - registry *repos.MemoryRegistrations - sender *mockSender - mux http.Handler + t *testing.T + lookup *models.Identity + subject *dsig.PrivateKey + subAddr goblnet.Address + verifier *dsig.PrivateKey + verifAddr goblnet.Address + fetcher *mockFetcher + registry *repos.MemoryRegistrations + sender *mockSender + mux http.Handler } func newFixture(t *testing.T) *fixture { @@ -110,9 +113,13 @@ func newFixture(t *testing.T) *fixture { pub, _ := json.Marshal(subKey.Public()) subAddr := goblnet.Address("alice.example") + verifKey := dsig.NewES256Key() + verifPub, _ := json.Marshal(verifKey.Public()) + verifAddr := goblnet.Address("verify.example") fetcher := &mockFetcher{ data: map[string][]byte{ - subAddr.KeyURL(subKey.ID()): pub, + subAddr.KeyURL(subKey.ID()): pub, + verifAddr.KeyURL(verifKey.ID()): verifPub, }, errs: map[string]error{}, } @@ -125,11 +132,12 @@ func newFixture(t *testing.T) *fixture { Registrations: reg, Client: client, Sender: send, + Verifiers: []goblnet.Address{verifAddr}, PublicBaseURL: "https://lookup.example", Logger: discardLogger(), }) mux := web.NewMux(setup, discardLogger()) - f := &fixture{t: t, lookup: lookup, subject: subKey, subAddr: subAddr, fetcher: fetcher, registry: reg, sender: send, mux: mux} + f := &fixture{t: t, lookup: lookup, subject: subKey, subAddr: subAddr, verifier: verifKey, verifAddr: verifAddr, fetcher: fetcher, registry: reg, sender: send, mux: mux} // The registration flow resolves the sender's own GET /who, so the // fixture serves a self-signed identity for the subject by default. who, _ := json.Marshal(f.signPartyEnvelope(subAddr.String(), "")) @@ -236,7 +244,7 @@ func TestInboxAcceptsRegistration(t *testing.T) { // Discovery link stamped on the (mutable) header. require.NotEmpty(t, rec.CountersignedEnvelope.Head.Links) link := rec.CountersignedEnvelope.Head.Links[0] - assert.Equal(t, cbc.Key("authority"), link.Category) + assert.Equal(t, head.LinkCategoryKeyVerification, link.Category) assert.Equal(t, cbc.Key("lookup"), link.Key) assert.Contains(t, link.URL, env.Head.UUID.String()) @@ -589,3 +597,120 @@ func TestInboxWhoUnavailable(t *testing.T) { defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) } + +// counterSignAsVerifier stamps the fixture verifier's countersignature +// onto env, as a provider would after completing its checks. +func (f *fixture) counterSignAsVerifier(env *gobl.Envelope) { + f.t.Helper() + require.NoError(f.t, env.Sign(f.verifier, + head.WithIssuer(f.verifAddr.String()), + head.WithAudience(f.subAddr.String()), + head.WithExpiration(time.Now().Add(365*24*time.Hour)))) +} + +func TestAutoVerifyOnRegistration(t *testing.T) { + // A registration arriving with an accepted provider's + // countersignature is verified from the start: the lookup's own + // countersignature names the verifier without operator action. + f := newFixture(t) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) + f.counterSignAsVerifier(env) + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + rec, err := f.registry.Get(context.Background(), f.subAddr) + require.NoError(t, err) + assert.Equal(t, f.verifAddr, rec.Verifier) + assert.NotNil(t, rec.VerifiedAt) + + // The lookup countersignature carries the verifier claim. + sigs := rec.CountersignedEnvelope.Signatures + p, err := head.SignedPayload(sigs[len(sigs)-1]) + require.NoError(t, err) + assert.Equal(t, f.verifAddr.String(), p.Verifier) +} + +func TestAutoVerifyRejectsForgedCountersignature(t *testing.T) { + // A countersignature claiming the provider's address but made with + // a different key must not be named: consumers would reject it. + f := newFixture(t) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) + forged := dsig.NewES256Key() + require.NoError(t, env.Sign(forged, + head.WithIssuer(f.verifAddr.String()), + head.WithAudience(f.subAddr.String()))) + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + require.Equal(t, http.StatusAccepted, resp.StatusCode, "registration proceeds unverified") + + rec, err := f.registry.Get(context.Background(), f.subAddr) + require.NoError(t, err) + assert.Empty(t, rec.Verifier) + assert.Nil(t, rec.VerifiedAt) +} + +func TestVerifyDerivesFromStoredEnvelope(t *testing.T) { + // A provider countersignature received while the provider was not + // yet on the accepted list is picked up later by the verify + // command — the recovery path. + f := newFixture(t) + + // A parallel setup with no accepted verifiers registers the party + // with the countersignature aboard but unverified. + bare := domain.New(domain.Deps{ + Identity: f.lookup, + Registrations: f.registry, + Client: goblnet.NewClient(goblnet.WithFetcher(f.fetcher)), + Sender: f.sender, + PublicBaseURL: "https://lookup.example", + Logger: discardLogger(), + }) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) + f.counterSignAsVerifier(env) + _, err := bare.Registrations().Register(context.Background(), env) + require.NoError(t, err) + // Let the async delivery goroutine finish its Put before Verify + // reads and rewrites the record. + require.NotEmpty(t, f.waitForDelivery(2*time.Second)) + rec, err := f.registry.Get(context.Background(), f.subAddr) + require.NoError(t, err) + require.Empty(t, rec.Verifier) + + // The fixture setup accepts verify.example: Verify derives it. + f2 := domain.New(domain.Deps{ + Identity: f.lookup, + Registrations: f.registry, + Client: goblnet.NewClient(goblnet.WithFetcher(f.fetcher)), + Sender: f.sender, + Verifiers: []goblnet.Address{f.verifAddr}, + PublicBaseURL: "https://lookup.example", + Logger: discardLogger(), + }) + rec, err = f2.Registrations().Verify(context.Background(), f.subAddr) + require.NoError(t, err) + assert.Equal(t, f.verifAddr, rec.Verifier) + assert.NotNil(t, rec.VerifiedAt) +} + +func TestVerifyWithoutAcceptedCountersignature(t *testing.T) { + f := newFixture(t) + env := f.signPartyEnvelope(f.subAddr.String(), f.lookup.Address().String()) + body, _ := json.Marshal(env) + f.post(goblnet.InboxPath, body).Body.Close() //nolint:errcheck + + setup := domain.New(domain.Deps{ + Identity: f.lookup, + Registrations: f.registry, + Client: goblnet.NewClient(goblnet.WithFetcher(f.fetcher)), + Sender: f.sender, + Verifiers: []goblnet.Address{f.verifAddr}, + PublicBaseURL: "https://lookup.example", + Logger: discardLogger(), + }) + _, err := setup.Registrations().Verify(context.Background(), f.subAddr) + require.Error(t, err) + assert.True(t, errors.Is(err, domain.ErrValidation)) +} From 169ae94695b01eb3e999fd12dcdf2dd51bbb1a92 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Sun, 26 Jul 2026 14:44:07 +0000 Subject: [PATCH 10/10] README: registration section reflects automatic verification Co-Authored-By: Claude Fable 5 --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b41f14b..bd1aa42 100644 --- a/README.md +++ b/README.md @@ -42,10 +42,10 @@ Copyright 2026 [Invopop S.L.](https://invopop.com). No new protocol endpoints — registration uses the standard GOBL Net `/inbox` POST in both directions. Marking a registration as -verified (spec §5.3, the `verifier` claim) is operator-driven via -`gobl.lookup verify` today; the self-service flow it will grow into -is specified in [How verification works](#how-verification-works) -below. +verified (spec §5.3, the `verifier` claim) happens automatically +when the envelope carries a countersignature from an accepted +verification provider; the full self-service flow is specified in +[How verification works](#how-verification-works) below. ### Endorsement lifetime and renewal