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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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.20260811215754-c231ee2feae5
github.com/magefile/mage v1.17.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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.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=
Expand Down
40 changes: 19 additions & 21 deletions internal/domain/registrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -63,30 +62,35 @@ 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())
}

// 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): 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 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:
Expand All @@ -97,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
Expand Down
50 changes: 46 additions & 4 deletions internal/interfaces/web/web_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -805,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)
}
Loading