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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p

### Changed

- `net`: signature checks search instead of indexing. The first signature still names the subject, but `Client.VerifyEnvelope` satisfies `expectedAud` with any valid subject signature carrying that audience, and `Client.Who` requires one audience-free self-signature while ignoring audience-bound ones. The endorsed envelope an Authority delivers can now be published at `/who` as-is.
- `net`: signature order is never significant — signatures all cover the same payload, so position cannot be trusted. `Client.VerifyEnvelope` is replaced by `Client.VerifyParty` (the subject is the address the party document declares as its `gobl:` endpoint, attested by a valid self-signature — the endpoint/subject match is now enforced) and `Client.VerifyDelivery` (the sender is the single issuer with a valid signature bound to the receiving inbox). `Client.Who` additionally requires one audience-free self-signature. The endorsed envelope an Authority delivers can be published at `/who` as-is.
- `net`: party envelopes in the registration and verification flows are bearer documents (spec §8.3): the subject signs once, audience-free, and the same envelope registers, publishes, and verifies. Delivery intent comes from the request token; Authority and verifier countersignatures carry the directed `iss`/`aud`. Deferred who disclosures remain audience-bound. Document deliveries keep the signed `aud` requirement.
- `net`: `Client.VerifyAuthority` returns `ErrUnavailable` when a candidate authority's key endpoint cannot be reached and no endorsement was found, instead of `ErrVerifyFailed`.

## [v0.504.0]
Expand Down
137 changes: 79 additions & 58 deletions net/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,15 @@ document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174).
optional `valid_from` / `valid_until` extension members) served at
`/.well-known/gobl/keys/<kid>`.
- **Party Envelope** — A signed GOBL Envelope whose document is an
`org.Party`, served at the who endpoint. The first signature is the
subject's self-signature, establishing whose identity it is; the
subject appends one audience-bound self-signature per delivery hop
(a registration, a verification request), and Authority and
verifier countersignatures accumulate alongside. Signature order
beyond the first is not significant: consumers search rather than
index.
`org.Party`, served at the who endpoint. Its subject is the address
the party document itself declares as its `gobl:` endpoint, attested
by the subject's audience-free self-signature; Authority and
verifier countersignatures (each bound to the subject with `iss` +
`aud`) accumulate alongside. The party envelope is a bearer
document: the same envelope registers, publishes, and verifies
without per-hop signatures — delivery intent is carried by the
request token (§5.5). Signature order is not significant: consumers
search, never index.
- **Sender / Receiver** — The two roles in a document exchange. A
receiver only needs a domain, TLS, and (if it signs or makes
authenticated requests — §5.5) published keys. A sender is
Expand Down Expand Up @@ -384,12 +386,18 @@ the verifier's inbox, the verifier countersigns that exact envelope
Authority's inbox, and the Authority re-countersigns with the
pointer and re-delivers to the subject — whose published envelope
then carries both countersignatures, each with its own lifetime.
The audience rule (§8.3) holds at every hop without new signatures
from the verifier's side: the subject's registration signature
(`aud` = the Authority) remains aboard the append-only envelope, so
the returned delivery still binds to the Authority's inbox, and the
The same bearer envelope flows through every hop with no per-hop
subject signatures (§8.3): delivery intent is each request's token,
while the countersignatures carry the directed statements (`iss` +
`aud` = the subject). Each role gates its own inbox: the Authority
requires the subject's who eligibility (§6.2), the verifier
requires the Authority's countersignature, and the subject's inbox
accepts party envelopes whose subject is itself — the returns. The
Authority SHOULD verify its own earlier countersignature on the
returned envelope before re-countersigning (§8.3).
returned envelope before re-countersigning. Anyone may re-submit a
published envelope, but every outcome is delivered to the subject's
own inbox and endorsements are scoped by each client's trust list,
so third-party submission is at worst an unsolicited renewal.

The two countersignatures carry independent `exp` claims and
deliberately independent lifecycles. A registration
Expand Down Expand Up @@ -505,38 +513,45 @@ returns the verified requester Address.

### 6.1 Envelope Verification Flow

`Client.VerifyEnvelope(ctx, env, expectedAud)` returns the verified
issuer address:

1. The envelope MUST be signed; otherwise `ErrVerifyFailed`.
2. The first signature's signed payload is read; `iss` MUST be a
valid bare Address (else `ErrVerifyFailed`).
3. `FetchKey(ctx, iss-host, kid)` fetches the issuer's published key
from `/.well-known/gobl/keys/<kid>` (including its optional
`valid_from` / `valid_until`).
4. The envelope is verified against that public key.
5. If `expectedAud` is non-empty, at least one valid signature by
the same subject MUST carry that signed `aud` — searched across
all signatures, since the subject appends one audience-bound
signature per delivery hop and their order is not significant.
6. If the key declares a validity window, the signed `iat` MUST fall
within `[valid_from, valid_until]` (each bound optional).
7. The verified issuer address is returned.
Verification never depends on signature order — any holder of an
envelope can permute its signatures without invalidating them, so
nothing may be read from position. Two entry points cover the two
envelope roles.

`Client.VerifyParty(ctx, env)` establishes the subject of an
identity envelope:

1. The envelope MUST be signed and its document MUST be an
`org.Party` declaring a `gobl:` endpoint — that address is the
subject.
2. At least one signature whose `iss` is the subject MUST verify
against the subject's published keys (`FetchKey`, including the
key's optional `valid_from` / `valid_until` window against the
signed `iat`).
3. The subject address is returned.

`Client.VerifyDelivery(ctx, env, self)` establishes the sender of a
document delivery:

1. The envelope MUST be signed.
2. Among all signatures, those whose signed `aud` equals `self` and
which verify against their issuer's published keys are the
delivery bindings. Exactly one issuer may bind: none rejects the
delivery, more than one is ambiguous.
3. That issuer — the sender — is returned.

### 6.2 Identity lookup (`GET /who`)

`/who` is an authenticated GET (see §8.2): the caller presents a
request token (§5.5) identifying itself. The response is the
target's party envelope: document = the target's `org.Party`,
first signature = the target's self-signature with `iss=target`
(the response is the same signed document for every authorized
caller). The envelope MUST carry at least one valid audience-free
self-signature — the subject's publication assertion. Audience-
bound self-signatures (delivery-hop artifacts, e.g. the
registration signature) and Authority or verifier countersignatures
MAY also be aboard and do not disqualify the response; this is what
lets the endorsed envelope be published exactly as the Authority
delivered it.
target's party envelope (the same signed document for every
authorized caller): its document MUST declare the target as its
`gobl:` endpoint, and the envelope MUST carry at least one valid
audience-free self-signature — the subject's publication assertion.
Authority or verifier countersignatures and audience-bound
self-signatures (from older protocol revisions) MAY also be aboard
and do not disqualify the response; the endorsed envelope is
published exactly as the Authority delivered it.

`Client.Who(ctx, addr)` performs the lookup and verifies it:

Expand All @@ -547,16 +562,15 @@ delivered it.
details (a receive-only account). A `202` returns `ErrPending` —
the request was recorded and the owner may deliver its party
envelope to the caller's inbox later (§8.2).
2. The response envelope's first signature is verified via
`VerifyEnvelope` (the signed `iss` resolved to a published key).
3. The verified issuer MUST equal the fetched address — a valid
2. The subject is established via `VerifyParty` (§6.1): the party
document's declared endpoint, attested by a valid
self-signature. It MUST equal the fetched address — a valid
envelope for a *different* identity served at this URL is
rejected.
4. The envelope MUST carry at least one valid audience-free
3. The envelope MUST carry at least one valid audience-free
self-signature; an envelope with only caller-bound signatures
(e.g. a deferred disclosure minted for someone else, §8.2) is
not a public identity and is rejected.
5. The document MUST be an `org.Party`, else `ErrPartyMissing`.

The response body is still a static signed document — the request
token controls *access*; it does not bind the response to the
Expand Down Expand Up @@ -761,19 +775,26 @@ transmitting documents its customers signed) authenticates the
document signer's. The two layers are independent and both
required.

The envelope layer is unchanged by the token: the subject (the
first signature's `iss`) is verified against its published key
(fetched from `<iss>/.well-known/gobl/keys/<kid>`); at least one
valid signature by the subject MUST carry `aud` equal to this
inbox's Address — searched across all signatures, so an envelope
that legitimately accumulated hop signatures (§5.3) still binds.
Envelopes where the subject never signed for this inbox MUST be
rejected. An inbox that finds its *own* earlier countersignature
aboard (an Authority receiving back the envelope it endorsed)
SHOULD verify that signature against its own keys and reject the
envelope when it does not hold — a broken or forged copy of the
Authority's signature means the envelope is not what it endorsed.
The inbox SHOULD then apply its sender-endorsement policy: resolve the sender's who (§6.4) and require an Authority
The envelope layer is unchanged by the token. For document
deliveries the sender is established by the delivery binding
(§6.1): the issuer of a valid signature whose signed `aud` equals
this inbox's Address — searched, so a sender signs once per
intended recipient and each inbox finds its own binding; exactly
one issuer may bind. Envelopes nobody signed for this inbox MUST be
rejected.

Party envelopes in the registration and verification flows are the
exception: bearer documents with no audience binding — the request
token carries delivery intent, and each receiving role's own rule
is defined with that role (registration §5.3, verification §5.3,
returns to the subject's inbox §5.3, deferred disclosures §8.2).

An inbox that finds its *own* earlier countersignature aboard (an
Authority receiving back the envelope it endorsed) SHOULD verify
that signature against its own keys and reject the envelope when it
does not hold — a broken or forged copy of the Authority's
signature means the envelope is not what it endorsed. The inbox
SHOULD then apply its sender-endorsement policy: resolve the sender's who (§6.4) and require an Authority
countersignature — optionally with a confirmed verifier (§5.3) when
the operator demands verified identities. Endorsement attaches to
the envelope's signer, never to the token's issuer. Status codes:
Expand All @@ -782,7 +803,7 @@ the envelope's signer, never to the token's issuer. Status codes:
|------------------------------|------------------------------------------------------|
| `202 Accepted` | Envelope parsed, validated, signature verified, persisted. |
| `400 Bad Request` | Body could not be read or did not decode as JSON. |
| `401 Unauthorized` | Missing or invalid request token (§5.5); envelope signature did not verify; or no subject signature carries `aud` equal to this inbox. |
| `401 Unauthorized` | Missing or invalid request token (§5.5); envelope signature did not verify; or (documents) no subject signature carries `aud` equal to this inbox. |
| `403 Forbidden` | Sender (`iss`) is not endorsed by an Authority this inbox trusts, or lacks the verified status the operator requires (§5.3). |
| `422 Unprocessable Entity` | Envelope failed structural validation. |
| `500 Internal Server Error` | Persistence failed. |
Expand Down
117 changes: 70 additions & 47 deletions net/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,74 +7,97 @@ import (

"github.com/invopop/gobl"
"github.com/invopop/gobl/head"
"github.com/invopop/gobl/org"
)

// VerifyEnvelope performs remote verification of a signed GOBL envelope.
// The first signature names the subject: its iss is resolved to a
// published key and the signature verified. When expectedAud is
// non-empty, at least one valid subject signature must carry that
// audience — searched, since the subject appends one audience-bound
// signature per delivery hop. Returns the verified subject address.
// Other parties' signatures are not checked here; use VerifyAuthority.
func (c *Client) VerifyEnvelope(ctx context.Context, env *gobl.Envelope, expectedAud Address) (Address, error) {
// A malformed envelope may carry signatures without a header;
// reject rather than let header verification panic.
// VerifyParty establishes the subject of a party envelope: the address
// the party document itself declares as its gobl: endpoint. The
// envelope must carry at least one valid self-signature by that
// address; signature order is not significant. Returns the subject.
// Countersignatures are not checked here; use VerifyAuthority.
func (c *Client) VerifyParty(ctx context.Context, env *gobl.Envelope) (Address, error) {
if env == nil || env.Head == nil || !env.Signed() {
return "", fmt.Errorf("%w: envelope is not signed", ErrVerifyFailed)
}

sig := env.Signatures[0]
p, err := head.SignedPayload(sig)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrVerifyFailed, err)
party, ok := env.Extract().(*org.Party)
if !ok {
return "", ErrPartyMissing
}
if p.Iss == "" {
return "", fmt.Errorf("%w: signature has no iss", ErrVerifyFailed)
ep := party.Endpoint(Scheme)
if ep == nil {
return "", fmt.Errorf("%w: party declares no gobl: endpoint", ErrPartyMissing)
}
// Canonicalize the issuer so key-fetch URLs and comparisons use
// the ASCII form regardless of how the iss was written. Anything
// that is not a bare FQDN — including URI forms — is rejected.
issuer, err := ParseAddress(p.Iss)
subject, err := ParseAddress(ep.URI.Opaque())
if err != nil {
return "", fmt.Errorf("%w: %v", ErrVerifyFailed, err)
return "", fmt.Errorf("%w: party endpoint %q is not a valid address: %v", ErrVerifyFailed, ep.URI, err)
}

kid := sig.KeyID()
if kid == "" {
return "", fmt.Errorf("%w: signature has no key ID", ErrVerifyFailed)
ok, err = c.subjectSignatureFor(ctx, env, subject, func(*head.SigningPayload) bool {
return true
})
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("%w: no valid self-signature by %q", ErrVerifyFailed, subject)
}
return subject, nil
}

pubKey, err := c.FetchKey(ctx, issuer, kid)
// VerifyDelivery establishes the sender of a document delivery: the
// issuer of a valid signature whose signed aud equals self, the
// receiving inbox. Exactly one issuer may bind — none rejects the
// delivery, more than one is ambiguous. Signature order is not
// significant.
func (c *Client) VerifyDelivery(ctx context.Context, env *gobl.Envelope, self Address) (Address, error) {
if env == nil || env.Head == nil || !env.Signed() {
return "", fmt.Errorf("%w: envelope is not signed", ErrVerifyFailed)
}
want, err := ParseAddress(string(self))
if err != nil {
if errors.Is(err, ErrUnavailable) {
return "", err
}
return "", fmt.Errorf("%w: %v", ErrVerifyFailed, err)
}
// VerifySignature enforces the key's validity window against the
// signed `iat` via head.Header.Verify.
if err := env.VerifySignature(sig, pubKey); err != nil {
return "", fmt.Errorf("%w: %v", ErrVerifyFailed, err)
if len(env.Signatures) > maxEnvelopeSignatures {
return "", fmt.Errorf("%w: envelope carries %d signatures (max %d)",
ErrVerifyFailed, len(env.Signatures), maxEnvelopeSignatures)
}

if expectedAud != "" {
want, err := ParseAddress(string(expectedAud))
var sender Address
var unavailable error
for _, sig := range env.Signatures {
p, err := head.SignedPayload(sig)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrVerifyFailed, err)
continue
}
ok, err := c.subjectSignatureFor(ctx, env, issuer, func(sp *head.SigningPayload) bool {
aud, aerr := ParseAddress(sp.Aud)
return aerr == nil && aud == want
})
aud, err := ParseAddress(p.Aud)
if err != nil || aud != want {
continue
}
iss, err := ParseAddress(p.Iss)
if err != nil {
return "", err
continue
}
pub, err := c.FetchKey(ctx, iss, sig.KeyID())
if err != nil {
if errors.Is(err, ErrUnavailable) {
unavailable = err
}
continue
}
if !ok {
return "", fmt.Errorf("%w: no valid signature by %q for audience %q", ErrVerifyFailed, issuer, want)
if err := env.VerifySignature(sig, pub); err != nil {
continue
}
if sender != "" && sender != iss {
return "", fmt.Errorf("%w: ambiguous delivery: bound to %q by both %q and %q",
ErrVerifyFailed, want, sender, iss)
}
sender = iss
}

return issuer, nil
if sender == "" {
if unavailable != nil {
return "", unavailable
}
return "", fmt.Errorf("%w: no valid signature bound to %q", ErrVerifyFailed, want)
}
return sender, nil
}

// subjectSignatureFor reports whether the envelope carries a valid
Expand Down
Loading
Loading