From e6f3645771dcafc53ac0233c6886879f6effd6c3 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Tue, 11 Aug 2026 21:38:35 +0000 Subject: [PATCH 1/2] Registration accepts bearer party envelopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Party envelopes carry no audience binding (spec §8.3): the request token is the delivery intent and who eligibility gates registration. Audiences on subject signatures are ignored as legacy hop artifacts. Co-Authored-By: Claude Fable 5 --- go.mod | 2 +- go.sum | 4 ++-- internal/domain/registrations.go | 11 +++++------ internal/interfaces/web/web_test.go | 12 ++++++++---- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index ae328f8..00b5d85 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.504.1-0.20260811142942-19e8feaa0aaf + github.com/invopop/gobl v0.504.1-0.20260811145041-210f7aece6eb github.com/magefile/mage v1.17.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 8526f73..c52fa21 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +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.504.1-0.20260811142942-19e8feaa0aaf h1:royiAy8OOJj+RJbNB6zNcUU+xYTswD3fsq9m7L5LM58= -github.com/invopop/gobl v0.504.1-0.20260811142942-19e8feaa0aaf/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl v0.504.1-0.20260811145041-210f7aece6eb h1:dcECyBmyBABVLhDAr212VGejhkm8w4rfpJS/FPjTYVc= +github.com/invopop/gobl v0.504.1-0.20260811145041-210f7aece6eb/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/domain/registrations.go b/internal/domain/registrations.go index a24d0ae..121c4ac 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -75,18 +75,17 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode return nil, ErrValidation.WithMessage("envelope failed validation: %s", err.Error()) } - // Intent binding: at least one of the sender's signatures must be - // bound to this lookup (searched — the subject appends one - // audience-bound signature per delivery hop, and a returned - // envelope keeps its original registration signature aboard). - sender, err := d.client.VerifyEnvelope(ctx, env, d.identity.Address()) + // Party envelopes are bearer documents (spec §8.3): no audience + // binding is required — the request token carries delivery intent, + // and the who eligibility check below gates who can register. + sender, err := d.client.VerifyEnvelope(ctx, env, "") if err != nil { if errors.Is(err, goblnet.ErrUnavailable) { d.log.Warn("inbox.rejected", "reason", "verify_unavailable", "error", err.Error()) return nil, ErrUnavailable.WithMessage("could not reach the sender's key endpoint; retry later") } d.log.Warn("inbox.rejected", "reason", "verify_failed", "error", err.Error()) - return nil, ErrUnauthorized.WithMessage("signature verification failed or envelope not signed for this lookup") + return nil, ErrUnauthorized.WithMessage("signature verification failed") } // Any signature claiming to be this lookup's must actually be one: diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index 892e24f..f84f0fb 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -255,22 +255,26 @@ func TestInboxAcceptsRegistration(t *testing.T) { assert.Equal(t, env.Head.UUID, sent[0].env.Head.UUID) } -func TestInboxRejectsMissingAud(t *testing.T) { +func TestInboxAcceptsBearerEnvelope(t *testing.T) { + // Party envelopes are bearer documents (spec §8.3): the canonical + // registration is the audience-free publication signature alone. f := newFixture(t) env := f.signPartyEnvelope(f.subAddr.String(), "") body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) } -func TestInboxRejectsWrongAud(t *testing.T) { +func TestInboxIgnoresForeignAud(t *testing.T) { + // An audience on the subject's signature is a legacy hop artifact + // and carries no meaning here; the request token is the intent. f := newFixture(t) env := f.signPartyEnvelope(f.subAddr.String(), "someone.else") body, _ := json.Marshal(env) resp := f.post(goblnet.InboxPath, body) defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) } func TestInboxRejectsNonPartyDocument(t *testing.T) { From f2599be72b90cf2bc098a8c5e8fe8864bbda4885 Mon Sep 17 00:00:00 2001 From: Sam Lown Date: Tue, 11 Aug 2026 21:59:33 +0000 Subject: [PATCH 2/2] Register derives the subject from the party document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VerifyParty reads the subject from the party's own gobl: endpoint, attested by a valid self-signature — signature order carries no meaning, and a party document declaring an address its signer does not control can no longer be registered. Co-Authored-By: Claude Fable 5 --- go.mod | 2 +- go.sum | 4 +-- internal/domain/registrations.go | 37 ++++++++++++++-------------- internal/interfaces/web/web_test.go | 38 +++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index 00b5d85..60f4c65 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.504.1-0.20260811145041-210f7aece6eb + github.com/invopop/gobl v0.504.1-0.20260811215754-c231ee2feae5 github.com/magefile/mage v1.17.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index c52fa21..85f05ed 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +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.504.1-0.20260811145041-210f7aece6eb h1:dcECyBmyBABVLhDAr212VGejhkm8w4rfpJS/FPjTYVc= -github.com/invopop/gobl v0.504.1-0.20260811145041-210f7aece6eb/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= +github.com/invopop/gobl v0.504.1-0.20260811215754-c231ee2feae5 h1:C124OmPhan8ocPR78006Aa/dCEdzPNlSMJZik5yTghM= +github.com/invopop/gobl v0.504.1-0.20260811215754-c231ee2feae5/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/domain/registrations.go b/internal/domain/registrations.go index 121c4ac..a688693 100644 --- a/internal/domain/registrations.go +++ b/internal/domain/registrations.go @@ -10,7 +10,6 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl/head" goblnet "github.com/invopop/gobl/net" - "github.com/invopop/gobl/org" "github.com/invopop/gobl/uuid" "github.com/invopop/gobl.lookup/internal/domain/delivery" @@ -63,26 +62,32 @@ 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, 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. +// containing the subject's org.Party. It establishes the subject from +// the party document itself, resolves the subject'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 subject'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) { if err := env.Validate(); err != nil { d.log.Warn("inbox.rejected", "reason", "validation", "error", err.Error()) return nil, ErrValidation.WithMessage("envelope failed validation: %s", err.Error()) } - // Party envelopes are bearer documents (spec §8.3): no audience - // binding is required — the request token carries delivery intent, - // and the who eligibility check below gates who can register. - sender, err := d.client.VerifyEnvelope(ctx, env, "") + // Party envelopes are bearer documents (spec §8.3): the subject is + // the address the party document itself declares, attested by a + // valid self-signature — no audience binding, no significance to + // signature order. The request token carries delivery intent and + // the who eligibility check below gates who can register. + sender, err := d.client.VerifyParty(ctx, env) if err != nil { - if errors.Is(err, goblnet.ErrUnavailable) { + switch { + case errors.Is(err, goblnet.ErrUnavailable): d.log.Warn("inbox.rejected", "reason", "verify_unavailable", "error", err.Error()) - return nil, ErrUnavailable.WithMessage("could not reach the sender's key endpoint; retry later") + return nil, ErrUnavailable.WithMessage("could not reach the subject's key endpoint; retry later") + case errors.Is(err, goblnet.ErrPartyMissing): + d.log.Warn("inbox.rejected", "reason", "not_a_party", "error", err.Error()) + return nil, ErrValidation.WithMessage("registration envelope must contain an org.Party declaring a gobl: endpoint") } d.log.Warn("inbox.rejected", "reason", "verify_failed", "error", err.Error()) return nil, ErrUnauthorized.WithMessage("signature verification failed") @@ -96,12 +101,6 @@ func (d *Registrations) Register(ctx context.Context, env *gobl.Envelope) (*mode d.log.Warn("inbox.rejected", "reason", "own_signature_invalid", "caller", string(sender), "error", err.Error()) return nil, ErrUnauthorized.WithMessage("envelope carries an invalid countersignature claiming this lookup") } - // Registration entry must carry an org.Party — that's the - // document we're attesting to. - if _, ok := env.Extract().(*org.Party); !ok { - d.log.Warn("inbox.rejected", "reason", "not_a_party", "caller", string(sender)) - 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 diff --git a/internal/interfaces/web/web_test.go b/internal/interfaces/web/web_test.go index f84f0fb..4e6b8d2 100644 --- a/internal/interfaces/web/web_test.go +++ b/internal/interfaces/web/web_test.go @@ -809,3 +809,41 @@ func TestRegistrationSignatureMayFollowPublicationSignature(t *testing.T) { defer resp.Body.Close() //nolint:errcheck assert.Equal(t, http.StatusAccepted, resp.StatusCode) } + +func TestInboxIgnoresSignatureOrder(t *testing.T) { + // The subject comes from the party document, never from signature + // position: a permuted returned envelope registers identically. + f := newFixture(t) + env := f.signPartyEnvelope(f.subAddr.String(), "") + f.counterSignAsLookup(env) + f.counterSignAsVerifier(env) + env.Signatures[0], env.Signatures[2] = env.Signatures[2], env.Signatures[0] + + 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) +} + +func TestInboxRejectsForeignPartyDocument(t *testing.T) { + // An envelope whose party document declares someone else's address + // has no valid self-signature by its subject: nobody can register + // a party document for an address they do not control. + f := newFixture(t) + party := &org.Party{ + Name: "Impostor", + Endpoints: []*org.Endpoint{{URI: goblnet.Address("victim.example").URI()}}, + } + env, err := gobl.Envelop(party) + require.NoError(t, err) + require.NoError(t, env.Sign(f.subject, head.WithIssuer(f.subAddr.String()))) + + body, _ := json.Marshal(env) + resp := f.post(goblnet.InboxPath, body) + defer resp.Body.Close() //nolint:errcheck + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +}