diff --git a/CHANGELOG.md b/CHANGELOG.md index be9e1f5..8cdddf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,24 +8,50 @@ `~/.config/gobl//` (auto-generated keypair + a raw `party.json` template with a pre-filled `gobl:` endpoint). - `gobl net who
--from `: performs an authenticated - mutual party exchange — POSTs a signed request and returns the - target's verified `org.Party` (full envelope, including any - authority countersignatures present). + identity lookup — GETs the target's `/who` with a bearer request + token minted from the `--from` identity and returns the target's + verified `org.Party` (full envelope, including any authority + countersignatures present). A `202` (deferred disclosure) is + recorded under `who-pending/` so the inbox accepts the party the + target may deliver later. - `gobl net send --to --from `: - delivers a signed envelope to a remote `/inbox`. + delivers a signed envelope to a remote `/inbox` with a request + token minted from the `--from` identity, which may differ from the + envelope's signer (trusted-intermediary transmission). +- `gobl net requests --domain ` / `gobl net approve + --domain `: list and approve deferred `/who` + requests — approval signs the domain's party for the requester + (`aud=requester`) and delivers it to the requester's inbox. - `gobl net serve`: HTTPS server with per-key `/.well-known/gobl/keys/` lookups, a bulk `/.well-known/jwks.json` endpoint for browser-based - JOSE tooling (`jwt.io`-style verifiers), `/who` (authenticated mutual - party exchange) and `/inbox` (signed envelope delivery). Open CORS - (`Access-Control-Allow-Origin: *` plus OPTIONS preflight → 204) is - enabled so JOSE tooling can fetch the JWKS from a browser context. - Multi-tenant: auto-discovers every `/` directory under the - config dir and routes by HTTP `Host`. ACME issues for every - discovered domain. Optional per-domain `allow.json` gates `/who` and - `/inbox` by signer address. + JOSE tooling (`jwt.io`-style verifiers), `/who` (authenticated + identity lookup) and `/inbox` (signed envelope delivery). `/who` + and `/inbox` require a bearer request token (spec §5.5) and reject + requests without one with `401`; key endpoints stay open. The + static `/who` response is self-signed once at startup and served + with `Cache-Control: private`; a missing `party.json` makes the + domain receive-only (`204`). Deferred disclosure via the + `who-deferred` marker answers `202` and records requests for + approval. Sender endorsement is always enforced on the inbox + (`403` `not_endorsed`): senders must be endorsed by a trusted + authority — `lookup.gobl.org` by default, `--authority` adds more — + with a confirmed verifier, unless `--allow-unverified` relaxes the + verifier requirement for sandboxes; party envelopes answering a + pending `/who` request are exempt. The manual single-identity mode + (`--party`/`--keys-dir`/`--private-key`/`--inbox`/`--who-deferred`) + and the `--insecure` client flags are removed: domains come from the + config dir, requests are always authenticated, and clients always + dial `https://
`. Open CORS + (`Access-Control-Allow-Origin: *`, including the `Authorization` + header, plus OPTIONS preflight → 204) is enabled so JOSE tooling + can fetch the JWKS from a browser context. Multi-tenant: + auto-discovers every `/` directory under the config dir and + routes by HTTP `Host`. ACME issues for every discovered domain. - `gobl sign --domain X [--to Y]`: signs with the key from - `~/.config/gobl//` and stamps `iss=gobl:X` / `aud=gobl:Y` into - the signed payload. + `~/.config/gobl//` and stamps `iss=X` / `aud=Y` into the signed + payload — signed claims carry bare GOBL Net addresses (FQDNs); the + `gobl:` scheme remains only on endpoint URIs and the unsigned + header `from`/`to`. - `gobl verify`: gains `--address` / `--remote` flags for remote key discovery via the new GOBL Net per-key endpoint. - Top-level `--json` flag: all operator-facing log output flows @@ -35,22 +61,34 @@ `version` JSON) stays on **stdout**. - HTTP access logs on `gobl net serve`: structured `http_request` entries for every request plus handler-specific - `keys.lookup`, `jwks.served`, `who.exchange` / `who.rejected`, - `inbox.accepted` / `inbox.rejected`, `inbox.write_failed` events - with high-signal fields (`caller`, `envelope`, `reason`, `status`, - `duration_ms`). Startup messages (`generated keypair`, - `initialised domain`, `GOBL Net listening`, `ACME enabled`, - `Shutting down`) are also structured. + `keys.lookup`, `jwks.served`, `auth.rejected` (`token_missing` / + `token_invalid` / `token_expired` / `token_unavailable`), + `who.served` / `who.deferred` / + `who.approved` / `who.fulfilled`, `inbox.accepted` / + `inbox.rejected` (incl. `not_endorsed` and `verify_unavailable`), + `inbox.write_failed` + events with high-signal fields (`requester`, `caller`, `envelope`, + `reason`, `status`, `duration_ms`). The authenticated entries + double as a request audit log. Startup messages (`generated + keypair`, `initialised domain`, `GOBL Net listening`, `ACME + enabled`, `Shutting down`) are also structured. - CLI errors are emitted as a single `command failed` log entry with `key` / `message` / `faults` fields. - On-disk layout for `gobl net serve`: `//{private.jwk, keys/.json, party.json, - allow.json, inbox/}`. One file per `kid` (filename equals `kid`, - validated at startup) — the model maps 1-to-1 to a future - row-per-kid database. Rotation is filesystem ops. + who-deferred, who-requests/, who-pending/, inbox/}`. One file per + `kid` (filename equals `kid`, validated at startup) — the model + maps 1-to-1 to a future row-per-kid database. Rotation is + filesystem ops. ### Changed +- `gobl net serve`: transient verification failures — the requester's + or sender's key/who endpoint unreachable — now answer + `503 Service Unavailable` (log reasons `token_unavailable` / + `verify_unavailable`) instead of `401`/`403`, so clients retry + rather than treating the rejection as final. + - `gobl net serve` `/inbox`: an envelope MUST now be signed with an `aud` equal to the inbox owner's address. Envelopes signed without an audience, or bound to a different audience, are rejected with @@ -58,7 +96,7 @@ `reason=aud_missing` or `reason=aud_mismatch`). This prevents a valid envelope from being replayed against multiple inboxes — signers must know the recipient at sign time. `gobl sign --domain - X --to Y` already stamps `aud=gobl:Y` into the signed payload, so + X --to Y` already stamps `aud=Y` into the signed payload, so the operator workflow is unchanged; callers that previously sent audience-less envelopes to an inbox MUST start setting `--to`. - `gobl keygen`: deprecated in favour of `gobl init `. diff --git a/README.md b/README.md index 960ba02..a3b566f 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,10 @@ Commands: | `gobl replicate` | Clone a document with a fresh UUID. | | `gobl keygen` | Generate an ES256 key pair. *(Deprecated: prefer `gobl init`.)* | | `gobl init` | Scaffold a GOBL Net domain identity under `~/.config/gobl//` (keypair + party template). See [GOBL Net](#gobl-net). | -| `gobl net who` | Authenticated mutual party exchange with a remote GOBL Net address. | -| `gobl net send` | POST a signed envelope to a remote `/inbox`. | +| `gobl net who` | Authenticated identity lookup on a remote GOBL Net address. | +| `gobl net send` | POST a signed envelope to a remote `/inbox` with a request token. | +| `gobl net requests` | List deferred `/who` requests awaiting approval. | +| `gobl net approve` | Approve a deferred `/who` request and deliver the party envelope. | | `gobl net serve` | Run the GOBL Net HTTPS server (keys + `/who` + `/inbox` + bulk JWKS). | | `gobl serve` | Launch the HTTP API server (see [API](#http-api)). | | `gobl mcp` | Launch a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio for AI tools and editors. | @@ -143,7 +145,7 @@ documents: a signer's identity is an FQDN (e.g. `billing.invopop.com`), and verifying keys, an endorsed identity, and a delivery inbox are all served from well-known HTTPS endpoints at that domain. The protocol itself lives in the core library at -[`github.com/invopop/gobl/net`](https://github.com/invopop/gobl/blob/net/net/README.md) — +[`github.com/invopop/gobl/net`](https://github.com/invopop/gobl/blob/main/net/README.md) — that file is the authoritative spec for addresses, the signed `iss`/`aud`/`iat` payload, the per-key and JWKS endpoints, `/who`, and `/inbox`. This section covers only the CLI / server side. @@ -157,7 +159,9 @@ Scaffolds a per-domain identity under `~/.config/gobl//`: ├── private.jwk ← active signing key (0600) ├── keys/.json ← published JWK (stamped valid_from=now) ├── party.json ← party template with a pre-filled gobl: endpoint -├── allow.json ← optional: gates /who and /inbox by signer +├── who-deferred ← optional marker: /who answers 202, requests await approval +├── who-requests/.json ← inbound /who requests recorded while deferred +├── who-pending/ ← outbound /who requests a peer answered 202 └── inbox/ ← envelopes received over /inbox land here ``` @@ -167,8 +171,9 @@ retire it, `rm` to remove it (future requests for that `kid` return `404`). ### `gobl sign --domain X [--to Y]` -Signs with the key from `~/.config/gobl//` and stamps `iss=gobl:X` / -`aud=gobl:Y` into the signed payload (alongside `uuid`, `dig`, and `iat`). +Signs with the key from `~/.config/gobl//` and stamps `iss=X` / +`aud=Y` into the signed payload (alongside `uuid`, `dig`, and `iat`). +Signed claims carry bare addresses — GOBL Net is implied. ### `gobl verify` @@ -180,22 +185,41 @@ Two flags activate remote verification: ### `gobl net who
--from ` -Authenticated mutual party exchange: POSTs a signed request (`iss=gobl:from`, -`aud=gobl:address`) and prints the target's verified `org.Party` envelope — -including any authority countersignatures the target serves alongside its -self-signature. +Authenticated identity lookup: GETs the target's `/who` with a bearer +request token minted from the `--from` identity (`iss=from`, +`aud=address`, short-lived) and prints the target's verified +`org.Party` envelope — including any authority countersignatures the +target serves alongside its self-signature. -### `gobl net send --to ` +A `202 Accepted` response means the target defers disclosure: the +request was recorded for its operator to approve, and the command notes +the pending state under `who-pending/` so your inbox will accept the +party envelope the target may deliver later. -Reads a signed envelope from a file (or stdin), POSTs it to the destination's -`/inbox`. Exits 0 on `202 Accepted`; otherwise `ErrInboxRejected`. +### `gobl net send --to --from ` + +Reads a signed envelope from a file (or stdin), POSTs it to the +destination's `/inbox` with a request token minted from the `--from` +identity. Exits 0 on `202 Accepted`; otherwise `ErrInboxRejected`. + +The token's issuer may differ from the envelope's signer — a trusted +intermediary transmitting a document on the signer's behalf +authenticates the request with its own identity. The envelope's signed `aud` MUST equal `--to`: receiving inboxes reject envelopes signed without an audience or bound to a different one (replay -protection). `gobl sign --domain X --to Y` stamps `aud=gobl:Y` for you. +protection). `gobl sign --domain X --to Y` stamps `aud=Y` for you. + +### `gobl net requests --domain ` -- `--insecure` — use `http://` and permit `host:port` form in `--to` - (development only). +Lists the deferred `/who` requests (requester + time) a +deferred-disclosure domain has answered `202` and recorded. + +### `gobl net approve --domain ` + +Approves a deferred `/who` request: signs the domain's party envelope +for the requester (`iss=domain`, `aud=requester`), delivers it +to the requester's inbox, and clears the recorded request. ### `gobl net serve` @@ -204,8 +228,8 @@ TLS source is configured it also listens on the HTTPS port (default 443), serving identical content — no redirect, senders choose the scheme. **Multi-tenant.** Auto-discovers every `//` directory and -routes by HTTP `Host`. `--domain` restricts to one; `--party` + `--keys-dir` -selects a single manual identity. ACME issues for every discovered domain. +routes by HTTP `Host`. `--domain` restricts to one. ACME issues for every +discovered domain. **Startup checks** (each is a hard error with a clear message): @@ -215,9 +239,33 @@ selects a single manual identity. ACME issues for every discovered domain. - Every file in `keys/` MUST be named `.json` where `kid` equals the JWK's `kid` field. Non-`.json` entries and subdirectories are ignored. - The active `private.jwk`'s `kid` MUST be one of the published kids. -- The party envelope MUST contain at least one signature whose `kid` is - published and which verifies against that key. Endorser signatures are - allowed alongside. +- A `party.json` (raw `org.Party` or pre-signed envelope) is self-signed + once at startup and served as the static `/who` response. A missing + party file is not an error: the domain is receive-only and `/who` + answers `204`. + +**Request authentication.** `/who` and `/inbox` require an +`Authorization: Bearer` request token (see the spec §5.5): the server +resolves the requester's published key from the token's `iss`, checks +the audience and freshness, and rejects everything else with `401`. Key +endpoints stay open. + +**Endorsement policy.** The inbox always requires incoming envelopes' +senders to be endorsed: their who identity must carry a +countersignature from a trusted authority — `lookup.gobl.org` by +default (its reference implementation lives in +[`gobl.lookup`](https://github.com/invopop/gobl.lookup)), with +`--authority ` (repeatable) supplementing the list — naming a +verifier (KYC/KYB, spec §5.3) confirmed by the verifier's own +countersignature. Unendorsed or unverified senders get `403`. Pass +`--allow-unverified` to accept registered-but-unverified senders in +sandbox environments and tests. A self-signed party envelope answering +one of the domain's own pending `/who` requests is accepted without +endorsement. + +**Deferred disclosure.** Touch `/who-deferred` to answer `/who` +with `202` and record requests for `gobl net requests` / +`gobl net approve`. **Ports:** @@ -301,12 +349,20 @@ The top-level `--json` flag toggles the format: | `http_request` | INFO | `method`, `path`, `host`, `remote`, `status`, `duration_ms` | | `keys.lookup` | INFO | `kid`, `found` | | `jwks.served` | INFO | `count` | -| `who.exchange` | INFO | `caller` (verified `iss` as FQDN) | -| `who.rejected` | WARN | `reason` (`bad_body`/`verify_failed`/`not_allowed`), `remote`/`caller`/`error` | +| `auth.rejected` | WARN | `path`, `reason` (`token_missing`/`token_invalid`/`token_expired`/`token_unavailable` — the last answers `503`, retry), `remote`, `error` | +| `who.served` | INFO | `requester` (verified token `iss` as FQDN), `status` (200/204) | +| `who.deferred` | INFO | `requester` — request recorded, answered 202 | +| `who.approved` | INFO | `requester` — party delivered by `gobl net approve` | +| `who.fulfilled` | INFO | `caller` — party envelope answering a pending /who request accepted | | `inbox.accepted` | INFO | `caller`, `envelope` (UUID) | -| `inbox.rejected` | WARN | `reason` (`bad_body`/`validation`/`verify_failed`/`aud_missing`/`aud_mismatch`/`not_allowed`) | +| `inbox.rejected` | WARN | `reason` (`bad_body`/`validation`/`verify_failed`/`aud_missing`/`aud_mismatch`/`not_endorsed`/`verify_unavailable` — the last answers `503`, retry) | | `inbox.write_failed` | ERROR | `caller`, `envelope`, `error` | +The `auth.rejected` and `who.served`/`who.deferred` entries double as +the server's request audit log: every authenticated request names a +verified requester. The log is itself personal data — bound retention +accordingly. + **Error reporting.** A CLI command that fails emits a single `command failed` entry on stderr with `key=` and (when present) `message=…` and `faults=…`. With `--json` the same fields appear as a JSON object. diff --git a/cmd/gobl/net.go b/cmd/gobl/net.go index 8c59954..1a21e3b 100644 --- a/cmd/gobl/net.go +++ b/cmd/gobl/net.go @@ -21,5 +21,7 @@ func (n *netOpts) cmd() *cobra.Command { cmd.AddCommand(netServe(n.rootOpts).cmd()) cmd.AddCommand(netSend(n.rootOpts).cmd()) cmd.AddCommand(netWho(n.rootOpts).cmd()) + cmd.AddCommand(netRequests(n.rootOpts).cmd()) + cmd.AddCommand(netApprove(n.rootOpts).cmd()) return cmd } diff --git a/cmd/gobl/net_requests.go b/cmd/gobl/net_requests.go new file mode 100644 index 0000000..f2e806d --- /dev/null +++ b/cmd/gobl/net_requests.go @@ -0,0 +1,76 @@ +package main + +import ( + "github.com/spf13/cobra" + + "github.com/invopop/gobl.dev/internal/ops" + goblnet "github.com/invopop/gobl/net" +) + +type netRequestsOpts struct { + *rootOpts + domain string +} + +func netRequests(root *rootOpts) *netRequestsOpts { + return &netRequestsOpts{rootOpts: root} +} + +func (o *netRequestsOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "requests", + Short: "List deferred /who requests awaiting approval (EXPERIMENTAL)", + Long: "List the authenticated /who requests a deferred-disclosure domain has\n" + + "answered 202 and recorded for approval. Approve one with\n" + + "`gobl net approve --domain `.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", + Args: cobra.NoArgs, + RunE: o.runE, + } + f := cmd.Flags() + f.StringVar(&o.domain, "domain", "", "Local domain identity (~/.config/gobl//) whose requests to list") + _ = cmd.MarkFlagRequired("domain") + return cmd +} + +func (o *netRequestsOpts) runE(cmd *cobra.Command, _ []string) error { + return ops.NetRequests(&ops.NetRequestsOptions{ + ConfigDir: defaultConfigDir(), + Domain: o.domain, + Out: cmd.OutOrStdout(), + }) +} + +type netApproveOpts struct { + *rootOpts + domain string +} + +func netApprove(root *rootOpts) *netApproveOpts { + return &netApproveOpts{rootOpts: root} +} + +func (o *netApproveOpts) cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "approve ", + Short: "Approve a deferred /who request (EXPERIMENTAL)", + Long: "Approve a deferred /who request: sign the domain's party envelope for\n" + + "the requester (aud=requester) and deliver it to the requester's inbox,\n" + + "then clear the recorded request.\n\n" + + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", + Args: cobra.ExactArgs(1), + RunE: o.runE, + } + f := cmd.Flags() + f.StringVar(&o.domain, "domain", "", "Local domain identity (~/.config/gobl//) approving the request") + _ = cmd.MarkFlagRequired("domain") + return cmd +} + +func (o *netApproveOpts) runE(cmd *cobra.Command, args []string) error { + return ops.NetApprove(commandContext(cmd), &ops.NetApproveOptions{ + ConfigDir: defaultConfigDir(), + Domain: o.domain, + Requester: goblnet.Address(args[0]), + }) +} diff --git a/cmd/gobl/net_send.go b/cmd/gobl/net_send.go index eb3e6fe..fd545ac 100644 --- a/cmd/gobl/net_send.go +++ b/cmd/gobl/net_send.go @@ -1,6 +1,9 @@ package main import ( + "errors" + "path/filepath" + "github.com/spf13/cobra" "github.com/invopop/gobl.dev/internal/ops" @@ -9,8 +12,8 @@ import ( type netSendOpts struct { *rootOpts - to string - insecure bool + to string + from string } func netSend(root *rootOpts) *netSendOpts { @@ -21,21 +24,33 @@ func (s *netSendOpts) cmd() *cobra.Command { cmd := &cobra.Command{ Use: "send [infile]", Short: "Send a signed GOBL envelope to a GOBL Net inbox (EXPERIMENTAL)", - Long: "Send a signed GOBL envelope to a GOBL Net inbox.\n\n" + + Long: "Send a signed GOBL envelope to a GOBL Net inbox. The request carries\n" + + "a bearer request token minted from the --from domain identity, which\n" + + "may differ from the envelope's signer when transmitting on another\n" + + "party's behalf.\n\n" + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", Args: cobra.MaximumNArgs(1), RunE: s.runE, } f := cmd.Flags() - f.StringVarP(&s.to, "to", "t", "", "Destination GOBL Net address (FQDN, or host:port with --insecure)") - f.BoolVar(&s.insecure, "insecure", false, "Use plain HTTP and permit host:port form in --to (development)") + f.StringVarP(&s.to, "to", "t", "", "Destination GOBL Net address (FQDN)") + f.StringVar(&s.from, "from", "", "Local domain identity (~/.config/gobl//) used to mint the request token") _ = cmd.MarkFlagRequired("to") + _ = cmd.MarkFlagRequired("from") return cmd } func (s *netSendOpts) runE(cmd *cobra.Command, args []string) error { ctx := commandContext(cmd) + if s.from == "" { + return errors.New("--from is required to authenticate the request") + } + key, err := loadPrivateKey(filepath.Join(defaultConfigDir(), s.from, "private.jwk")) + if err != nil { + return err + } + input, err := openInput(cmd, args) if err != nil { return err @@ -43,8 +58,9 @@ func (s *netSendOpts) runE(cmd *cobra.Command, args []string) error { defer input.Close() // nolint:errcheck return ops.NetSend(ctx, &ops.NetSendOptions{ - Input: input, - To: goblnet.Address(s.to), - Insecure: s.insecure, + Input: input, + To: goblnet.Address(s.to), + From: goblnet.Address(s.from), + FromKey: key, }) } diff --git a/cmd/gobl/net_serve.go b/cmd/gobl/net_serve.go index 7ebd28d..669e8c3 100644 --- a/cmd/gobl/net_serve.go +++ b/cmd/gobl/net_serve.go @@ -8,15 +8,12 @@ import ( "github.com/spf13/cobra" "github.com/invopop/gobl.dev/internal/ops" + goblnet "github.com/invopop/gobl/net" ) type netServeOpts struct { *rootOpts - configDir string - partyFile string - keysDir string - privateKey string - inboxDir string + configDir string httpPort int httpsPort int @@ -29,6 +26,9 @@ type netServeOpts struct { tlsCert string tlsKey string + + authorities []string + allowUnverified bool } func netServe(root *rootOpts) *netServeOpts { @@ -47,22 +47,21 @@ func (s *netServeOpts) cmd() *cobra.Command { f := cmd.Flags() f.StringVar(&s.configDir, "config-dir", configDir, "Base directory; its / subdirectories are auto-discovered and served, routed by Host") - f.StringVar(&s.partyFile, "party", "", "Manual single-identity mode: party.json (raw org.Party or signed envelope) served at /.well-known/gobl/who") - f.StringVarP(&s.keysDir, "keys-dir", "k", "", "Manual single-identity mode: directory of .json public keys published at /.well-known/gobl/keys/") - f.StringVar(&s.privateKey, "private-key", "", "Manual single-identity mode: private key paired with the JWKS") - f.StringVar(&s.inboxDir, "inbox", "", "Manual single-identity mode: directory to write accepted envelopes into") f.IntVar(&s.httpPort, "http-port", 80, "HTTP listen port") f.IntVar(&s.httpsPort, "https-port", 443, "HTTPS listen port (used only when a TLS source is configured)") f.BoolVar(&s.acmeLive, "acme-live", false, "Activate HTTPS via Let's Encrypt production directory") f.BoolVar(&s.acmeTest, "acme-test", false, "Activate HTTPS via Let's Encrypt staging directory (for testing)") - f.StringVar(&s.domain, "domain", "", "Hostname the ACME client is allowed to issue for; MUST match the participant's GOBL Net address") + f.StringVar(&s.domain, "domain", "", "Serve a single domain from the config dir; also the hostname the ACME client is allowed to issue for") f.StringVar(&s.acmeEmail, "acme-email", "", "Account email for ACME registration") f.StringVar(&s.certDir, "cert-dir", "", "Directory to cache ACME-issued certificates (default /certs)") f.StringVar(&s.tlsCert, "tls-cert", "", "PEM-encoded TLS certificate; activates HTTPS with file-based TLS") f.StringVar(&s.tlsKey, "tls-key", "", "PEM-encoded TLS private key paired with --tls-cert") + f.StringSliceVar(&s.authorities, "authority", nil, "Additional trusted Authority address (repeatable; supplements the default lookup.gobl.org)") + f.BoolVar(&s.allowUnverified, "allow-unverified", false, "Accept senders whose endorsement lacks a confirmed verifier — for sandbox environments and testing") + return cmd } @@ -92,29 +91,17 @@ func (s *netServeOpts) runE(cmd *cobra.Command, _ []string) error { CertFile: s.tlsCert, KeyFile: s.tlsKey, - } - // Manual single-identity mode: triggered by an explicit --party or - // --keys-dir. Unset companion paths default to the flat config-dir - // layout. - if cmd.Flags().Changed("party") || cmd.Flags().Changed("keys-dir") { - opts.PartyFile = orDefault(s.partyFile, filepath.Join(s.configDir, "party.json")) - opts.KeysDir = orDefault(s.keysDir, filepath.Join(s.configDir, "keys")) - opts.PrivateKeyFile = orDefault(s.privateKey, filepath.Join(s.configDir, "private.jwk")) - opts.InboxDir = orDefault(s.inboxDir, filepath.Join(s.configDir, "inbox")) + AllowUnverified: s.allowUnverified, + } + for _, a := range s.authorities { + opts.Authorities = append(opts.Authorities, goblnet.Address(a)) } ctx := commandContext(cmd) return ops.NetServe(ctx, opts) } -func orDefault(v, def string) string { - if v != "" { - return v - } - return def -} - func (s *netServeOpts) validate() error { if s.acmeLive && s.acmeTest { return errors.New("--acme-live and --acme-test are mutually exclusive") diff --git a/cmd/gobl/net_test.go b/cmd/gobl/net_test.go index e329fc1..a761f1d 100644 --- a/cmd/gobl/net_test.go +++ b/cmd/gobl/net_test.go @@ -5,12 +5,9 @@ import ( "context" "encoding/json" stdnet "net" - "net/http" - "net/http/httptest" "os" "path/filepath" "strconv" - "strings" "testing" "time" @@ -24,7 +21,6 @@ import ( "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" "github.com/invopop/gobl/note" - "github.com/invopop/gobl/org" "github.com/invopop/gobl/uuid" ) @@ -52,6 +48,8 @@ func TestNetCmdSubcommands(t *testing.T) { assert.True(t, have["serve"]) assert.True(t, have["send"]) assert.True(t, have["who"]) + assert.True(t, have["requests"]) + assert.True(t, have["approve"]) } // ---------- net send ----------- @@ -64,8 +62,8 @@ func signedNoteBody(t *testing.T) []byte { env, err := gobl.Envelop(msg) require.NoError(t, err) require.NoError(t, env.Sign(priv, - head.WithIssuer(net.Address("peer.example").URI()), - head.WithAudience(net.Address("acme.example").URI()))) + head.WithIssuer(net.Address("peer.example").String()), + head.WithAudience(net.Address("acme.example").String()))) out, err := json.Marshal(env) require.NoError(t, err) return out @@ -81,14 +79,14 @@ func TestNetSendCmdMissingTo(t *testing.T) { require.Error(t, err) } -func TestNetSendCmdSuccess(t *testing.T) { +func TestNetSendCmdInvalidAddress(t *testing.T) { + // The request token's aud must be a valid GOBL Net address, so raw + // IP targets are rejected before any transport happens. (The full + // send flow is covered in internal/ops with injected fetchers.) body := signedNoteBody(t) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - defer srv.Close() - tmp := t.TempDir() + t.Setenv("HOME", tmp) + initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") infile := filepath.Join(tmp, "env.json") require.NoError(t, os.WriteFile(infile, body, 0o644)) @@ -96,17 +94,33 @@ func TestNetSendCmdSuccess(t *testing.T) { c := o.cmd() c.SetOut(new(bytes.Buffer)) c.SetErr(new(bytes.Buffer)) - u := strings.TrimPrefix(srv.URL, "http://") - c.SetArgs([]string{"--to", u, "--insecure", infile}) - require.NoError(t, c.Execute()) + c.SetArgs([]string{"--to", "127.0.0.1:8080", "--from", "from.example", infile}) + err := c.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid address") } func TestNetSendCmdBadInput(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") + o := netSend(&rootOpts{}) + c := o.cmd() + c.SetOut(new(bytes.Buffer)) + c.SetErr(new(bytes.Buffer)) + c.SetArgs([]string{"--to", "acme.example", "--from", "from.example", "/no/such/file.json"}) + err := c.Execute() + require.Error(t, err) +} + +func TestNetSendCmdMissingKey(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) o := netSend(&rootOpts{}) c := o.cmd() c.SetOut(new(bytes.Buffer)) c.SetErr(new(bytes.Buffer)) - c.SetArgs([]string{"--to", "acme.example", "--insecure", "/no/such/file.json"}) + c.SetArgs([]string{"--to", "acme.example", "--from", "missing.example", "-"}) err := c.Execute() require.Error(t, err) } @@ -135,33 +149,31 @@ func TestNetWhoCmdMissingKey(t *testing.T) { require.Error(t, err) } -func TestNetWhoCmdMissingParty(t *testing.T) { +// ---------- net requests / approve ----------- + +func TestNetRequestsCmdEmpty(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) - initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") - require.NoError(t, os.Remove(filepath.Join(tmp, ".config", "gobl", "from.example", "party.json"))) + initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "mine.example") - o := netWho(&rootOpts{}) + o := netRequests(&rootOpts{}) c := o.cmd() - c.SetOut(new(bytes.Buffer)) + out := new(bytes.Buffer) + c.SetOut(out) c.SetErr(new(bytes.Buffer)) - c.SetArgs([]string{"--from", "from.example", "target.example"}) - err := c.Execute() - require.Error(t, err) + c.SetArgs([]string{"--domain", "mine.example"}) + require.NoError(t, c.Execute()) + assert.JSONEq(t, "[]", out.String()) } -func TestNetWhoCmdBadPartyJSON(t *testing.T) { +func TestNetApproveCmdMissingIdentity(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) - initDomainForCLI(t, filepath.Join(tmp, ".config", "gobl"), "from.example") - pj := filepath.Join(tmp, ".config", "gobl", "from.example", "party.json") - require.NoError(t, os.WriteFile(pj, []byte("not json"), 0o644)) - - o := netWho(&rootOpts{}) + o := netApprove(&rootOpts{}) c := o.cmd() c.SetOut(new(bytes.Buffer)) c.SetErr(new(bytes.Buffer)) - c.SetArgs([]string{"--from", "from.example", "target.example"}) + c.SetArgs([]string{"--domain", "missing.example", "peer.example"}) err := c.Execute() require.Error(t, err) } @@ -208,23 +220,12 @@ func TestNetServeCmdNoDomainsErrors(t *testing.T) { require.Error(t, err) } -func TestNetServeCmdManualMode(t *testing.T) { - // Manual mode wires --party + --keys-dir + --private-key explicitly. +func TestNetServeCmdConfigDir(t *testing.T) { + // A config-dir domain serves until the command context is + // cancelled; --allow-unverified and --authority just plumb through. tmp := t.TempDir() - priv := dsig.NewES256Key() - keysDir := filepath.Join(tmp, "keys") - require.NoError(t, os.MkdirAll(keysDir, 0o755)) - pub, err := json.Marshal(priv.Public()) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(keysDir, priv.ID()+".json"), pub, 0o644)) - privBytes, err := json.MarshalIndent(priv, "", " ") - require.NoError(t, err) - privFile := filepath.Join(tmp, "private.jwk") - require.NoError(t, os.WriteFile(privFile, privBytes, 0o600)) - partyFile := filepath.Join(tmp, "party.json") - partyBytes, err := json.Marshal(&org.Party{Name: "Solo"}) - require.NoError(t, err) - require.NoError(t, os.WriteFile(partyFile, partyBytes, 0o644)) + configDir := filepath.Join(tmp, ".config", "gobl") + initDomainForCLI(t, configDir, "solo.example") o := netServe(&rootOpts{}) c := o.cmd() @@ -232,10 +233,9 @@ func TestNetServeCmdManualMode(t *testing.T) { c.SetErr(new(bytes.Buffer)) port := freeCLIPort(t) c.SetArgs([]string{ - "--keys-dir", keysDir, - "--party", partyFile, - "--private-key", privFile, - "--inbox", filepath.Join(tmp, "inbox"), + "--config-dir", configDir, + "--authority", "sandbox.example", + "--allow-unverified", "--http-port", strconv.Itoa(port), }) diff --git a/cmd/gobl/net_who.go b/cmd/gobl/net_who.go index c96461a..5e49fd9 100644 --- a/cmd/gobl/net_who.go +++ b/cmd/gobl/net_who.go @@ -3,20 +3,18 @@ package main import ( "encoding/json" "errors" - "os" + "fmt" "path/filepath" "github.com/spf13/cobra" "github.com/invopop/gobl.dev/internal/ops" goblnet "github.com/invopop/gobl/net" - "github.com/invopop/gobl/org" ) type netWhoOpts struct { *rootOpts - from string - insecure bool + from string } func netWho(root *rootOpts) *netWhoOpts { @@ -28,15 +26,14 @@ func (w *netWhoOpts) cmd() *cobra.Command { Use: "who
", Short: "Look up the party a GOBL Net domain belongs to (EXPERIMENTAL)", Long: "Fetch and verify the org.Party published at a GOBL Net address's\n" + - "/.well-known/gobl/who endpoint. The request is authenticated as the\n" + - "--from domain, so /who is a mutual party exchange.\n\n" + + "/.well-known/gobl/who endpoint. The request carries a bearer request\n" + + "token minted from the --from domain identity.\n\n" + "EXPERIMENTAL: GOBL Net is under active development and may change without notice.", Args: cobra.ExactArgs(1), RunE: w.runE, } f := cmd.Flags() - f.StringVar(&w.from, "from", "", "Local domain identity (~/.config/gobl//) used to sign the request") - f.BoolVar(&w.insecure, "insecure", false, "Query over plain HTTP and permit host:port (development)") + f.StringVar(&w.from, "from", "", "Local domain identity (~/.config/gobl//) used to mint the request token") _ = cmd.MarkFlagRequired("from") return cmd } @@ -47,29 +44,24 @@ func (w *netWhoOpts) runE(cmd *cobra.Command, args []string) error { if w.from == "" { return errors.New("--from is required to authenticate the request") } - dir := filepath.Join(defaultConfigDir(), w.from) - - key, err := loadPrivateKey(filepath.Join(dir, "private.jwk")) - if err != nil { - return err - } - partyData, err := os.ReadFile(filepath.Join(dir, "party.json")) + configDir := defaultConfigDir() + key, err := loadPrivateKey(filepath.Join(configDir, w.from, "private.jwk")) if err != nil { return err } - party := new(org.Party) - if err := json.Unmarshal(partyData, party); err != nil { - return err - } result, err := ops.NetWho(ctx, &ops.NetWhoOptions{ Target: goblnet.Address(args[0]), From: goblnet.Address(w.from), FromKey: key, - FromParty: party, - Insecure: w.insecure, + ConfigDir: configDir, }) if err != nil { + if errors.Is(err, goblnet.ErrPending) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), + "request accepted (202): the owner may deliver their party to your inbox once approved") + return nil + } return err } diff --git a/cmd/gobl/sign.go b/cmd/gobl/sign.go index 32606dc..7b4aa5d 100644 --- a/cmd/gobl/sign.go +++ b/cmd/gobl/sign.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/invopop/gobl.dev/internal/ops" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/dsig" goblnet "github.com/invopop/gobl/net" ) @@ -86,16 +85,16 @@ func (opts *signOpts) runE(cmd *cobra.Command, args []string) error { defer out.Close() // nolint:errcheck keyFile := opts.privateKeyFile - var iss, aud cbc.URI + var iss, aud string if opts.domain != "" { if cmd.Flags().Changed("key") { return errors.New("--domain and --key are mutually exclusive") } keyFile = filepath.Join(defaultConfigDir(), opts.domain, "private.jwk") - iss = goblnet.Address(opts.domain).URI() + iss = goblnet.Address(opts.domain).String() } if opts.audience != "" { - aud = goblnet.Address(opts.audience).URI() + aud = goblnet.Address(opts.audience).String() } key, err := loadPrivateKey(keyFile) diff --git a/go.mod b/go.mod index 009b641..b95acf8 100644 --- a/go.mod +++ b/go.mod @@ -56,3 +56,5 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect ) + +replace github.com/invopop/gobl => ../gobl diff --git a/go.sum b/go.sum index c023849..95808d1 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/invopop/gobl v0.502.2 h1:guH++uYsy5RjCjn7qGNwFq1xWLceNx0Pmjz/Fm2KCRw= -github.com/invopop/gobl v0.502.2/go.mod h1:HmiEdQreTSQYyNbhs81VKTmI7BAJKYC/6enh9RDwnE0= github.com/invopop/gobl.br.nfe v0.0.1 h1:ywJycz2wiyeNygbRhuRJg52gTnIECsbZ5+pGFqh3eyE= github.com/invopop/gobl.br.nfe v0.0.1/go.mod h1:ZIJSVz5257xciD+RkBhyvqjtS46Pgw23idTca6A8ZIk= github.com/invopop/gobl.br.nfse v0.0.1 h1:BdwNiG7vk7bPMgE4m102P+UysOxmYWhOKIDfFI6RKEA= diff --git a/internal/ops/bulk_test.go b/internal/ops/bulk_test.go index fe39ae3..50ec28f 100644 --- a/internal/ops/bulk_test.go +++ b/internal/ops/bulk_test.go @@ -618,7 +618,7 @@ func TestBulk(t *testing.T) { //nolint:gocyclo // Following raw message is copied and pasted! (sorry!) Payload: json.RawMessage(`{ "list": [ - "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" + "https://gobl.org/draft-0/bill/action", "https://gobl.org/draft-0/bill/charge", "https://gobl.org/draft-0/bill/correction-options", "https://gobl.org/draft-0/bill/delivery", "https://gobl.org/draft-0/bill/delivery-details", "https://gobl.org/draft-0/bill/discount", "https://gobl.org/draft-0/bill/fault", "https://gobl.org/draft-0/bill/invoice", "https://gobl.org/draft-0/bill/line", "https://gobl.org/draft-0/bill/order", "https://gobl.org/draft-0/bill/ordering", "https://gobl.org/draft-0/bill/payment", "https://gobl.org/draft-0/bill/payment-details", "https://gobl.org/draft-0/bill/reason", "https://gobl.org/draft-0/bill/status", "https://gobl.org/draft-0/bill/status-line", "https://gobl.org/draft-0/bill/tax", "https://gobl.org/draft-0/bill/totals", "https://gobl.org/draft-0/cal/date", "https://gobl.org/draft-0/cal/date-time", "https://gobl.org/draft-0/cal/period", "https://gobl.org/draft-0/cal/time", "https://gobl.org/draft-0/cal/timestamp", "https://gobl.org/draft-0/cbc/code", "https://gobl.org/draft-0/cbc/code-map", "https://gobl.org/draft-0/cbc/definition", "https://gobl.org/draft-0/cbc/key", "https://gobl.org/draft-0/cbc/meta", "https://gobl.org/draft-0/cbc/source", "https://gobl.org/draft-0/cbc/uri", "https://gobl.org/draft-0/currency/amount", "https://gobl.org/draft-0/currency/code", "https://gobl.org/draft-0/currency/exchange-rate", "https://gobl.org/draft-0/dsig/digest", "https://gobl.org/draft-0/dsig/signature", "https://gobl.org/draft-0/envelope", "https://gobl.org/draft-0/head/header", "https://gobl.org/draft-0/head/link", "https://gobl.org/draft-0/head/stamp", "https://gobl.org/draft-0/i18n/string", "https://gobl.org/draft-0/l10n/code", "https://gobl.org/draft-0/l10n/iso-country-code", "https://gobl.org/draft-0/l10n/tax-country-code", "https://gobl.org/draft-0/note/message", "https://gobl.org/draft-0/num/amount", "https://gobl.org/draft-0/num/percentage", "https://gobl.org/draft-0/org/address", "https://gobl.org/draft-0/org/attachment", "https://gobl.org/draft-0/org/attribute", "https://gobl.org/draft-0/org/coordinates", "https://gobl.org/draft-0/org/document-ref", "https://gobl.org/draft-0/org/email", "https://gobl.org/draft-0/org/endpoint", "https://gobl.org/draft-0/org/identity", "https://gobl.org/draft-0/org/image", "https://gobl.org/draft-0/org/inbox", "https://gobl.org/draft-0/org/item", "https://gobl.org/draft-0/org/name", "https://gobl.org/draft-0/org/note", "https://gobl.org/draft-0/org/party", "https://gobl.org/draft-0/org/person", "https://gobl.org/draft-0/org/registration", "https://gobl.org/draft-0/org/telephone", "https://gobl.org/draft-0/org/unit", "https://gobl.org/draft-0/org/website", "https://gobl.org/draft-0/pay/card", "https://gobl.org/draft-0/pay/credit-transfer", "https://gobl.org/draft-0/pay/direct-debit", "https://gobl.org/draft-0/pay/instructions", "https://gobl.org/draft-0/pay/online", "https://gobl.org/draft-0/pay/record", "https://gobl.org/draft-0/pay/terms", "https://gobl.org/draft-0/regimes/mx/food-vouchers", "https://gobl.org/draft-0/regimes/mx/fuel-account-balance", "https://gobl.org/draft-0/schema/object", "https://gobl.org/draft-0/tax/addon-def", "https://gobl.org/draft-0/tax/addon-list", "https://gobl.org/draft-0/tax/catalogue-def", "https://gobl.org/draft-0/tax/correction-definition", "https://gobl.org/draft-0/tax/correction-set", "https://gobl.org/draft-0/tax/extensions", "https://gobl.org/draft-0/tax/identity", "https://gobl.org/draft-0/tax/note", "https://gobl.org/draft-0/tax/regime-code", "https://gobl.org/draft-0/tax/regime-def", "https://gobl.org/draft-0/tax/scenario", "https://gobl.org/draft-0/tax/scenario-set", "https://gobl.org/draft-0/tax/set", "https://gobl.org/draft-0/tax/tag-set", "https://gobl.org/draft-0/tax/total" ] }`), IsFinal: false, diff --git a/internal/ops/net_access_log.go b/internal/ops/net_access_log.go index 204945f..05abf6d 100644 --- a/internal/ops/net_access_log.go +++ b/internal/ops/net_access_log.go @@ -59,14 +59,15 @@ func accessLog(log *slog.Logger, next http.Handler) http.Handler { // JWT tooling (jwt.io, OIDC consumers) can fetch /.well-known/jwks.json // and the per-kid endpoint from any origin. /who and /inbox also pick // up the same headers — those endpoints are authenticated by the -// signed request body, not the browser origin, so opening CORS does -// not weaken anything. OPTIONS preflight short-circuits to 204. +// Authorization request token, which the server verifies regardless of +// the browser origin, so opening CORS does not weaken anything. +// OPTIONS preflight short-circuits to 204. func corsAllowAll(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Access-Control-Allow-Origin", "*") h.Set("Access-Control-Allow-Methods", "GET, POST, HEAD, OPTIONS") - h.Set("Access-Control-Allow-Headers", "Content-Type, Accept") + h.Set("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization") h.Set("Access-Control-Max-Age", "86400") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) diff --git a/internal/ops/net_access_log_test.go b/internal/ops/net_access_log_test.go index 20f2e24..75e5625 100644 --- a/internal/ops/net_access_log_test.go +++ b/internal/ops/net_access_log_test.go @@ -2,50 +2,32 @@ package ops import ( "bytes" - "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" - "path/filepath" - "strings" "testing" - "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/org" - "github.com/invopop/gobl/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // setupServerWithLog stands up the test domain handler chain with a // captured logger so individual test cases can assert on log lines. -func setupServerWithLog(t *testing.T) (*httptest.Server, *bytes.Buffer, string) { +func setupServerWithLog(t *testing.T) (*httptest.Server, *bytes.Buffer, domainConfig) { t.Helper() - cfg := t.TempDir() - dc := domainConfigFor(cfg, testServeDomain) - require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - net.Address(testServeDomain).KeyURL(privateKey.ID()): jwkBytes(t, privateKey), - }})) - + dc := writeServeDomain(t, t.TempDir()) buf := new(bytes.Buffer) - log := slog.New(slog.NewTextHandler(buf, nil)) - h, err := buildDomainHandler(dc, client, log) + opts := serveOpts(peerFetcher(t)) + opts.Log = slog.New(slog.NewTextHandler(buf, nil)) + h, err := buildDomainHandler(dc, opts) require.NoError(t, err) srv := httptest.NewServer(h) t.Cleanup(srv.Close) - return srv, buf, dc.InboxDir + return srv, buf, dc } func TestAccessLogKeysLookup(t *testing.T) { @@ -75,87 +57,89 @@ func TestAccessLogKeysLookup(t *testing.T) { assert.Contains(t, out, "status=404") } -func TestAccessLogWhoRejectsBadBody(t *testing.T) { +func TestAccessLogAuthTokenMissing(t *testing.T) { srv, buf, _ := setupServerWithLog(t) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", strings.NewReader("not json")) - require.NoError(t, err) - _ = resp.Body.Close() + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) out := buf.String() - assert.Contains(t, out, "who.rejected") - assert.Contains(t, out, "reason=bad_body") - assert.Contains(t, out, "status=400") + assert.Contains(t, out, "auth.rejected") + assert.Contains(t, out, "reason=token_missing") + assert.Contains(t, out, "status=401") } -func TestAccessLogWhoRejectsVerifyFailed(t *testing.T) { +func TestAccessLogAuthTokenInvalid(t *testing.T) { srv, buf, _ := setupServerWithLog(t) - // Signed by an iss we don't have keys for. + // Token minted by an issuer whose keys the server cannot resolve. other := dsig.NewES256Key() - env, err := gobl.Envelop(&org.Party{Name: "Stranger"}) - require.NoError(t, err) - require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, _ := json.Marshal(env) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) + token, err := net.NewToken(other, "unknown.example", testServeDomain, 0) require.NoError(t, err) - _ = resp.Body.Close() + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "Bearer "+token) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) out := buf.String() - assert.Contains(t, out, "who.rejected") - assert.Contains(t, out, "reason=verify_failed") + assert.Contains(t, out, "auth.rejected") + assert.Contains(t, out, "reason=token_invalid") assert.Contains(t, out, "status=401") } -func TestAccessLogWhoNotAllowed(t *testing.T) { - cfg := t.TempDir() - dc := domainConfigFor(cfg, testServeDomain) - require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - // allow-list excludes the peer. - require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) +func TestAccessLogWhoServed(t *testing.T) { + srv, buf, _ := setupServerWithLog(t) + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) + assert.Equal(t, http.StatusOK, resp.StatusCode) + out := buf.String() + assert.Contains(t, out, "who.served") + assert.Contains(t, out, "requester="+testPeerDomain) + assert.Contains(t, out, "status=200") +} - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - }})) +func TestAccessLogWhoDeferred(t *testing.T) { + dc := writeServeDomain(t, t.TempDir()) + require.NoError(t, os.WriteFile(dc.WhoDeferredFile, nil, 0o644)) buf := new(bytes.Buffer) - log := slog.New(slog.NewTextHandler(buf, nil)) - h, err := buildDomainHandler(dc, client, log) + opts := serveOpts(peerFetcher(t)) + opts.Log = slog.New(slog.NewTextHandler(buf, nil)) + h, err := buildDomainHandler(dc, opts) require.NoError(t, err) srv := httptest.NewServer(h) defer srv.Close() - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) - require.NoError(t, err) - _ = resp.Body.Close() + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) out := buf.String() - assert.Contains(t, out, "who.rejected") - assert.Contains(t, out, "reason=not_allowed") - assert.Contains(t, out, "status=403") + assert.Contains(t, out, "who.deferred") + assert.Contains(t, out, "requester="+testPeerDomain) + assert.Contains(t, out, "status=202") } -func TestAccessLogWhoExchangeSuccess(t *testing.T) { - srv, buf, _ := setupServerWithLog(t) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) +func TestAccessLogInboxNotEndorsed(t *testing.T) { + authorityKey := dsig.NewES256Key() + const authority = "kyc.example" + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.data[net.Address(authority).KeyURL(authorityKey.ID())] = jwkBytes(t, authorityKey) + fetcher.data[net.Address(testPeerDomain).WhoURL()] = peerWhoBytes(t, nil, "", "") + buf := new(bytes.Buffer) + opts := serveOpts(fetcher) + opts.Log = slog.New(slog.NewTextHandler(buf, nil)) + opts.Authorities = []net.Address{authority} + h, err := buildDomainHandler(dc, opts) require.NoError(t, err) - _ = resp.Body.Close() + srv := httptest.NewServer(h) + defer srv.Close() + + env := signedNoteTo(t, "unendorsed", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) out := buf.String() - assert.Contains(t, out, "who.exchange") - assert.Contains(t, out, "caller="+testPeerDomain) - assert.Contains(t, out, "status=200") + assert.Contains(t, out, "inbox.rejected") + assert.Contains(t, out, "reason=not_endorsed") + assert.Contains(t, out, "status=403") } func TestAccessLogInboxAccepted(t *testing.T) { - srv, buf, inboxDir := setupServerWithLog(t) - - msg := ¬e.Message{Content: "logged"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, _ := json.Marshal(env) + srv, buf, dc := setupServerWithLog(t) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - _ = resp.Body.Close() + env := signedNoteTo(t, "logged", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusAccepted, resp.StatusCode) out := buf.String() assert.Contains(t, out, "inbox.accepted") @@ -163,21 +147,15 @@ func TestAccessLogInboxAccepted(t *testing.T) { assert.Contains(t, out, "status=202") // Sanity: the envelope was persisted. - files, _ := os.ReadDir(inboxDir) + files, _ := os.ReadDir(dc.InboxDir) require.Len(t, files, 1) } func TestAccessLogInboxAudMismatch(t *testing.T) { srv, buf, _ := setupServerWithLog(t) - msg := ¬e.Message{Content: "wrong aud"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) - body, _ := json.Marshal(env) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - _ = resp.Body.Close() + env := signedNoteTo(t, "wrong aud", "other.example") + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) out := buf.String() assert.Contains(t, out, "inbox.rejected") assert.Contains(t, out, "reason=aud_mismatch") @@ -222,6 +200,7 @@ func TestCORSAllowAll(t *testing.T) { assert.Equal(t, "*", resp.Header.Get("Access-Control-Allow-Origin")) assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET") assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Authorization") assert.NotEmpty(t, resp.Header.Get("Access-Control-Max-Age")) }) diff --git a/internal/ops/net_jwks_test.go b/internal/ops/net_jwks_test.go index c82c0c5..c3995c4 100644 --- a/internal/ops/net_jwks_test.go +++ b/internal/ops/net_jwks_test.go @@ -1,10 +1,8 @@ package ops import ( - "bytes" "encoding/json" "io" - "log/slog" "net/http" "net/http/httptest" "os" @@ -70,8 +68,7 @@ func TestJWKSEndpointNewestFirst(t *testing.T) { newerKey := writeKeyWithValidFrom(t, dc.KeysDir, privateKey, &newer) writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{}})) - h, err := buildDomainHandler(dc, client, slog.New(slog.NewTextHandler(new(bytes.Buffer), nil))) + h, err := buildDomainHandler(dc, serveOpts(&mapFetcher{data: map[string][]byte{}})) require.NoError(t, err) srv := httptest.NewServer(h) defer srv.Close() diff --git a/internal/ops/net_requests.go b/internal/ops/net_requests.go new file mode 100644 index 0000000..aa16f27 --- /dev/null +++ b/internal/ops/net_requests.go @@ -0,0 +1,131 @@ +package ops + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/invopop/gobl" + "github.com/invopop/gobl/head" + "github.com/invopop/gobl/net" + "github.com/invopop/gobl/org" +) + +// NetRequestsOptions configures NetRequests. +type NetRequestsOptions struct { + ConfigDir string + Domain string + Out io.Writer +} + +// NetRequests writes the domain's pending deferred who requests — +// requesters answered 202 and awaiting operator approval — as a JSON +// array to opts.Out. +func NetRequests(opts *NetRequestsOptions) error { + if opts.Domain == "" { + return gobl.ErrInput.WithReason("a --domain identity is required") + } + dc := domainConfigFor(opts.ConfigDir, opts.Domain) + requests, err := listWhoRequests(dc.WhoRequestsDir) + if err != nil { + return err + } + enc := json.NewEncoder(opts.Out) + enc.SetIndent("", " ") + return enc.Encode(requests) +} + +// listWhoRequests reads every .json record in dir. An +// absent directory means no pending requests. +func listWhoRequests(dir string) ([]whoRequest, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return []whoRequest{}, nil + } + return nil, fmt.Errorf("net requests: %w", err) + } + out := make([]whoRequest, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, e.Name())) + if err != nil { + return nil, fmt.Errorf("net requests: %w", err) + } + req := new(whoRequest) + if err := json.Unmarshal(data, req); err != nil { + return nil, fmt.Errorf("net requests: %s: %w", e.Name(), err) + } + out = append(out, *req) + } + return out, nil +} + +// NetApproveOptions configures NetApprove. +type NetApproveOptions struct { + ConfigDir string + Domain string // the identity whose who request is being approved + Requester net.Address // the requester to disclose the party to + Fetcher net.Fetcher + Log *slog.Logger +} + +// NetApprove approves a deferred who request: the domain's party +// envelope is signed with iss=self and aud=requester and delivered to +// the requester's inbox, then the pending request record is removed. +func NetApprove(ctx context.Context, opts *NetApproveOptions) error { + if opts.Domain == "" { + return gobl.ErrInput.WithReason("a --domain identity is required") + } + if opts.Requester == "" { + return gobl.ErrInput.WithReason("a requester address is required") + } + log := logger(opts.Log) + dc := domainConfigFor(opts.ConfigDir, opts.Domain) + self := net.Address(dc.Domain) + + priv, err := loadPrivateKeyFile(dc.PrivateKeyFile) + if err != nil { + return err + } + env, err := readPartyEnvelope(dc) + if err != nil { + return err + } + // Receiving inboxes check the first signature's audience, so a + // pre-signed envelope is rebuilt around its party document to make + // the audience-bound signature the first one. + if env.Signed() { + party, ok := env.Extract().(*org.Party) + if !ok { + return fmt.Errorf("net approve: party file does not contain an org.Party") + } + if env, err = gobl.Envelop(party); err != nil { + return fmt.Errorf("net approve: %w", err) + } + } + if err := env.Sign(priv, + head.WithIssuer(self.String()), + head.WithAudience(opts.Requester.String())); err != nil { + return fmt.Errorf("net approve: sign party: %w", err) + } + + client := netClientFor(self, priv, opts.Fetcher) + if err := client.Send(ctx, opts.Requester, env); err != nil { + return err + } + + record := filepath.Join(dc.WhoRequestsDir, string(opts.Requester)+".json") + if err := os.Remove(record); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("net approve: clear request record: %w", err) + } + log.Info("who.approved", "requester", string(opts.Requester)) + return nil +} diff --git a/internal/ops/net_send.go b/internal/ops/net_send.go index 07958ff..9008261 100644 --- a/internal/ops/net_send.go +++ b/internal/ops/net_send.go @@ -1,32 +1,36 @@ package ops import ( - "bytes" "context" "encoding/json" - "fmt" "io" - "net/http" - "time" "github.com/invopop/gobl" + "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/net" ) -const netSendTimeout = 10 * time.Second - // NetSendOptions configures the gobl net send command. type NetSendOptions struct { - Input io.Reader - To net.Address - Insecure bool // when true: use http:// and accept host:port form - Client *http.Client // optional; defaults to a 10s-timeout client + Input io.Reader + To net.Address + From net.Address // sender's GOBL Net address (mints the request token) + FromKey *dsig.PrivateKey // sender's signing key + Fetcher net.Fetcher // optional; defaults to net.NewHTTPFetcher() } // NetSend reads a GOBL envelope from opts.Input and POSTs it to the -// destination address's inbox endpoint. Returns ErrInboxRejected if -// the inbox does not respond with 202. +// destination address's inbox endpoint with a request token minted +// from the --from identity. Returns net.ErrInboxRejected if the inbox +// does not respond with 202. func NetSend(ctx context.Context, opts *NetSendOptions) error { + if opts.To == "" { + return gobl.ErrInput.WithReason("destination address is required") + } + if opts.From == "" || opts.FromKey == nil { + return gobl.ErrInput.WithReason("a --from identity (with its private key) is required to authenticate the request") + } + body, err := io.ReadAll(cancelableReader(ctx, opts.Input)) if err != nil { return gobl.ErrInput.WithCause(err) @@ -40,45 +44,6 @@ func NetSend(ctx context.Context, opts *NetSendOptions) error { return gobl.ErrValidation.WithCause(err) } - url, err := inboxURL(opts.To, opts.Insecure) - if err != nil { - return err - } - - client := opts.Client - if client == nil { - client = &http.Client{Timeout: netSendTimeout} - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("net send: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("net send: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode == http.StatusAccepted { - return nil - } - - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("%w: HTTP %d: %s", net.ErrInboxRejected, resp.StatusCode, bytes.TrimSpace(respBody)) -} - -func inboxURL(addr net.Address, insecure bool) (string, error) { - if insecure { - if addr == "" { - return "", net.ErrAddressEmpty - } - return "http://" + string(addr) + net.InboxPath, nil - } - if err := addr.Validate(); err != nil { - return "", err - } - return addr.InboxURL(), nil + client := netClientFor(opts.From, opts.FromKey, opts.Fetcher) + return client.Send(ctx, opts.To, env) } diff --git a/internal/ops/net_send_test.go b/internal/ops/net_send_test.go index 759470f..621cd22 100644 --- a/internal/ops/net_send_test.go +++ b/internal/ops/net_send_test.go @@ -8,7 +8,8 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" + "os" + "path/filepath" "strings" "testing" @@ -27,47 +28,51 @@ func signedNoteEnvelope(t *testing.T, content string) []byte { msg.SetUUID(uuid.V7()) env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).String()), head.WithAudience(net.Address(testServeDomain).String()))) body, err := json.Marshal(env) require.NoError(t, err) return body } +func sendOpts(t *testing.T, body []byte, srvURL string) *NetSendOptions { + t.Helper() + return &NetSendOptions{ + Input: bytes.NewReader(body), + To: net.Address(testServeDomain), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + Fetcher: routeTo(srvURL), + } +} + func TestNetSendSuccess(t *testing.T) { body := signedNoteEnvelope(t, "round trip") var received []byte - var receivedContentType string + var receivedContentType, receivedAuth string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, net.InboxPath, r.URL.Path) assert.Equal(t, http.MethodPost, r.Method) receivedContentType = r.Header.Get("Content-Type") + receivedAuth = r.Header.Get("Authorization") received, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusAccepted) })) defer srv.Close() - to := hostFromURL(t, srv.URL) - err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader(body), - To: net.Address(to), - Insecure: true, - }) + err := NetSend(context.Background(), sendOpts(t, body, srv.URL)) require.NoError(t, err) assert.Equal(t, "application/json", receivedContentType) + assert.True(t, strings.HasPrefix(receivedAuth, "Bearer "), "the request carries a bearer token") assert.JSONEq(t, string(body), string(received)) } func TestNetSendRejectsBadEnvelope(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - })) - defer srv.Close() - err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader([]byte("not json")), - To: net.Address(hostFromURL(t, srv.URL)), - Insecure: true, + Input: bytes.NewReader([]byte("not json")), + To: net.Address(testServeDomain), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, }) require.Error(t, err) } @@ -80,63 +85,47 @@ func TestNetSendNon202(t *testing.T) { })) defer srv.Close() - err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader(body), - To: net.Address(hostFromURL(t, srv.URL)), - Insecure: true, - }) + err := NetSend(context.Background(), sendOpts(t, body, srv.URL)) require.Error(t, err) assert.True(t, errors.Is(err, net.ErrInboxRejected)) } -func TestNetSendInsecureURL(t *testing.T) { - got, err := inboxURL("localhost:8080", true) - require.NoError(t, err) - assert.Equal(t, "http://localhost:8080/.well-known/gobl/inbox", got) -} - -func TestNetSendSecureURL(t *testing.T) { - got, err := inboxURL("example.com", false) - require.NoError(t, err) - assert.Equal(t, "https://example.com/.well-known/gobl/inbox", got) -} - -// hostFromURL strips the scheme from an httptest.NewServer URL so it -// can be used as a `host:port`-form GOBL Net address in --insecure mode. -func hostFromURL(t *testing.T, raw string) string { - t.Helper() - u, err := url.Parse(raw) - require.NoError(t, err) - return strings.TrimPrefix(u.Host, "") +func TestNetSendMissingFrom(t *testing.T) { + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(signedNoteEnvelope(t, "x")), + To: net.Address(testServeDomain), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "--from identity") } -func TestNetSendInboxURLErrors(t *testing.T) { - t.Run("insecure empty address", func(t *testing.T) { - _, err := inboxURL("", true) - require.Error(t, err) - assert.True(t, errors.Is(err, net.ErrAddressEmpty)) - }) - t.Run("secure invalid FQDN", func(t *testing.T) { - _, err := inboxURL("localhost", false) - require.Error(t, err) +func TestNetSendMissingTo(t *testing.T) { + err := NetSend(context.Background(), &NetSendOptions{ + Input: bytes.NewReader(signedNoteEnvelope(t, "x")), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, }) + require.Error(t, err) + assert.Contains(t, err.Error(), "destination address is required") } func TestNetSendInvalidEnvelopeJSON(t *testing.T) { // Looks like JSON but fails to unmarshal into Envelope. err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader([]byte("[1,2,3]")), - To: net.Address("example.com"), - Insecure: true, + Input: bytes.NewReader([]byte("[1,2,3]")), + To: net.Address("example.com"), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, }) require.Error(t, err) } func TestNetSendInputReadError(t *testing.T) { err := NetSend(context.Background(), &NetSendOptions{ - Input: errReader{}, - To: net.Address("example.com"), - Insecure: true, + Input: errReader{}, + To: net.Address("example.com"), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, }) require.Error(t, err) } @@ -145,42 +134,159 @@ type errReader struct{} func (errReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } -func TestNetSendInboxURLError(t *testing.T) { +func TestNetSendInvalidAddress(t *testing.T) { body := signedNoteEnvelope(t, "x") err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader(body), - To: net.Address("localhost"), // single label fails FQDN validation - Insecure: false, + Input: bytes.NewReader(body), + To: net.Address("localhost"), // single label fails FQDN validation + From: net.Address(testPeerDomain), + FromKey: testPeerKey, }) require.Error(t, err) } func TestNetSendTransportError(t *testing.T) { body := signedNoteEnvelope(t, "x") - // Send to a closed loopback port so client.Do returns an error. + // Route to a closed loopback port so the POST fails. err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader(body), - To: net.Address("127.0.0.1:1"), - Insecure: true, - Client: &http.Client{Timeout: 100 * 1000 * 1000}, // 100ms + Input: bytes.NewReader(body), + To: net.Address(testServeDomain), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + Fetcher: routeTo("http://127.0.0.1:1"), }) require.Error(t, err) } func TestNetSendRoundTrip(t *testing.T) { - srv, inboxDir := setupNetServer(t) + srv, dc := setupNetServer(t) body := signedNoteEnvelope(t, "round trip via serve") - err := NetSend(context.Background(), &NetSendOptions{ - Input: bytes.NewReader(body), - To: net.Address(hostFromURL(t, srv.URL)), - Insecure: true, - }) + err := NetSend(context.Background(), sendOpts(t, body, srv.URL)) require.NoError(t, err) // Confirm the envelope landed in the inbox directory. - files, err := readDirNames(inboxDir) + files, err := readDirNames(dc.InboxDir) require.NoError(t, err) require.Len(t, files, 1) assert.True(t, strings.HasSuffix(files[0], ".json")) } + +func TestNetRequestsAndApprove(t *testing.T) { + // Two identities: owner defers /who disclosure; requester asks, + // gets 202, and later receives the owner's party in its inbox once + // the owner approves. + ctx := context.Background() + ownerCfg := t.TempDir() + requesterCfg := t.TempDir() + const owner = "owner.example" + const requester = "requester.example" + initTestDomain(t, ownerCfg, owner) + initTestDomain(t, requesterCfg, requester) + ownerKey := domainPrivateKey(t, ownerCfg, owner) + requesterKey := domainPrivateKey(t, requesterCfg, requester) + + ownerDC := domainConfigFor(ownerCfg, owner) + requesterDC := domainConfigFor(requesterCfg, requester) + require.NoError(t, os.WriteFile(ownerDC.WhoDeferredFile, nil, 0o644)) + + // The owner's server verifies the requester's tokens; the + // requester's server verifies the owner's token and party + // signature. + ownerSrvH, err := buildDomainHandler(ownerDC, serveOpts(&mapFetcher{data: map[string][]byte{ + net.Address(requester).KeyURL(requesterKey.ID()): jwkBytes(t, requesterKey), + }})) + require.NoError(t, err) + ownerSrv := httptest.NewServer(ownerSrvH) + defer ownerSrv.Close() + + requesterSrvH, err := buildDomainHandler(requesterDC, serveOpts(&mapFetcher{data: map[string][]byte{ + net.Address(owner).KeyURL(ownerKey.ID()): jwkBytes(t, ownerKey), + }})) + require.NoError(t, err) + requesterSrv := httptest.NewServer(requesterSrvH) + defer requesterSrv.Close() + + // 1. The requester asks who the owner is and is deferred. + _, err = NetWho(ctx, &NetWhoOptions{ + Target: owner, + From: requester, + FromKey: requesterKey, + ConfigDir: requesterCfg, + Fetcher: routeTo(ownerSrv.URL), + }) + require.Error(t, err) + require.True(t, errors.Is(err, net.ErrPending)) + + // 2. The owner sees the pending request. + out := new(bytes.Buffer) + require.NoError(t, NetRequests(&NetRequestsOptions{ + ConfigDir: ownerCfg, + Domain: owner, + Out: out, + })) + var requests []whoRequest + require.NoError(t, json.Unmarshal(out.Bytes(), &requests)) + require.Len(t, requests, 1) + assert.Equal(t, net.Address(requester), requests[0].Requester) + + // 3. The owner approves: its party envelope is delivered to the + // requester's inbox, bound to the requester. + require.NoError(t, NetApprove(ctx, &NetApproveOptions{ + ConfigDir: ownerCfg, + Domain: owner, + Requester: requester, + Fetcher: routeTo(requesterSrv.URL), + Log: discardLog(), + })) + + // The request record is cleared... + out.Reset() + require.NoError(t, NetRequests(&NetRequestsOptions{ + ConfigDir: ownerCfg, + Domain: owner, + Out: out, + })) + requests = nil + require.NoError(t, json.Unmarshal(out.Bytes(), &requests)) + assert.Empty(t, requests) + + // ...the requester's pending marker was consumed by the delivery... + assert.NoFileExists(t, filepath.Join(requesterDC.WhoPendingDir, owner)) + + // ...and the owner's party envelope landed in the requester's + // inbox, audience-bound to the requester. + files, err := os.ReadDir(requesterDC.InboxDir) + require.NoError(t, err) + require.Len(t, files, 1) + data, err := os.ReadFile(filepath.Join(requesterDC.InboxDir, files[0].Name())) + require.NoError(t, err) + env := new(gobl.Envelope) + require.NoError(t, json.Unmarshal(data, env)) + p, err := headSignedPayload(env) + require.NoError(t, err) + assert.Equal(t, net.Address(owner).String(), p.Iss) + assert.Equal(t, net.Address(requester).String(), p.Aud) +} + +func TestNetRequestsEmpty(t *testing.T) { + cfg := t.TempDir() + initTestDomain(t, cfg, "quiet.example") + out := new(bytes.Buffer) + require.NoError(t, NetRequests(&NetRequestsOptions{ + ConfigDir: cfg, + Domain: "quiet.example", + Out: out, + })) + assert.JSONEq(t, "[]", out.String()) +} + +func TestNetRequestsMissingDomain(t *testing.T) { + err := NetRequests(&NetRequestsOptions{ConfigDir: t.TempDir(), Out: new(bytes.Buffer)}) + require.Error(t, err) +} + +func TestNetApproveMissingArgs(t *testing.T) { + require.Error(t, NetApprove(context.Background(), &NetApproveOptions{ConfigDir: t.TempDir()})) + require.Error(t, NetApprove(context.Background(), &NetApproveOptions{ConfigDir: t.TempDir(), Domain: "a.example"})) +} diff --git a/internal/ops/net_serve.go b/internal/ops/net_serve.go index 8d74621..73117ca 100644 --- a/internal/ops/net_serve.go +++ b/internal/ops/net_serve.go @@ -23,7 +23,6 @@ import ( "github.com/invopop/gobl" "github.com/invopop/gobl/cal" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" @@ -44,19 +43,23 @@ const ( // NetServeOptions configures the GOBL Net HTTP server. type NetServeOptions struct { // ConfigDir is the base directory whose / subdirectories are - // auto-discovered when no explicit single identity is provided. + // auto-discovered and served. ConfigDir string - // Explicit single-identity ("manual") mode: when PartyFile or KeysDir - // is set, exactly one identity is served from these paths. - PartyFile string - KeysDir string // directory of .json public JWK files - PrivateKeyFile string - InboxDir string - - Client *net.Client // optional; defaults to net.NewClient() - Out io.Writer // optional; defaults to os.Stdout (reserved for results, currently unused) - Log *slog.Logger // optional; defaults to slog.Default() + // Authorities supplements the default trusted authority list + // (net.Authorities, i.e. lookup.gobl.org) for the inbox's + // sender-endorsement policy: the server trusts the default plus + // these extras. Incoming envelopes are accepted only from senders + // whose who identity carries a countersignature from a trusted + // authority with a confirmed verifier. AllowUnverified relaxes + // the verifier requirement (sandbox environments and testing); + // endorsement itself is always required. + Authorities []net.Address + AllowUnverified bool + + Fetcher net.Fetcher // optional; defaults to net.NewHTTPFetcher() + Out io.Writer // optional; defaults to os.Stdout (reserved for results, currently unused) + Log *slog.Logger // optional; defaults to slog.Default() // Port overrides (zero means use the default — 80 / 443). HTTPPort int @@ -65,7 +68,7 @@ type NetServeOptions struct { // ACME options. ACMELive and ACMETest are mutually exclusive. ACMELive bool ACMETest bool - Domain string // restricts multi-domain discovery to one, or names the manual identity + Domain string // restricts multi-domain discovery to one domain ACMEEmail string CertDir string @@ -77,12 +80,14 @@ type NetServeOptions struct { // domainConfig groups the on-disk paths that make up one GOBL Net // identity. The directory name is the domain. type domainConfig struct { - Domain string - KeysDir string // directory of .json public JWK files - PrivateKeyFile string - PartyFile string - InboxDir string - AllowFile string + Domain string + KeysDir string // directory of .json public JWK files + PrivateKeyFile string + PartyFile string + InboxDir string + WhoRequestsDir string // inbound who requests answered 202, awaiting approval + WhoPendingDir string // outbound who requests answered 202 by the peer + WhoDeferredFile string // marker file: defer /who disclosure to operator approval } // logger returns the configured slog.Logger, falling back to slog.Default() @@ -99,42 +104,15 @@ func (o *NetServeOptions) logger() *slog.Logger { func domainConfigFor(configDir, domain string) domainConfig { dir := filepath.Join(configDir, domain) return domainConfig{ - Domain: domain, - KeysDir: filepath.Join(dir, "keys"), - PrivateKeyFile: filepath.Join(dir, "private.jwk"), - PartyFile: filepath.Join(dir, "party.json"), - InboxDir: filepath.Join(dir, "inbox"), - AllowFile: filepath.Join(dir, "allow.json"), - } -} - -// loadAllowList reads /allow.json (a JSON array of GOBL Net -// addresses). It returns the set of accepted addresses and whether a -// list is configured at all. An absent file means "accept any verified -// caller" (present == false). -func loadAllowList(dc domainConfig) (map[net.Address]bool, bool, error) { - if dc.AllowFile == "" || !fileExists(dc.AllowFile) { - return nil, false, nil - } - data, err := os.ReadFile(dc.AllowFile) - if err != nil { - return nil, false, fmt.Errorf("net serve: read allow list: %w", err) - } - var addrs []net.Address - if err := json.Unmarshal(data, &addrs); err != nil { - return nil, false, fmt.Errorf("net serve: invalid allow list: %w", err) - } - set := make(map[net.Address]bool, len(addrs)) - for _, a := range addrs { - set[a] = true + Domain: domain, + KeysDir: filepath.Join(dir, "keys"), + PrivateKeyFile: filepath.Join(dir, "private.jwk"), + PartyFile: filepath.Join(dir, "party.json"), + InboxDir: filepath.Join(dir, "inbox"), + WhoRequestsDir: filepath.Join(dir, "who-requests"), + WhoPendingDir: filepath.Join(dir, "who-pending"), + WhoDeferredFile: filepath.Join(dir, "who-deferred"), } - return set, true, nil -} - -// allowed reports whether addr may call a protected endpoint: any -// verified caller when no list is configured, otherwise only listed ones. -func allowed(set map[net.Address]bool, present bool, addr net.Address) bool { - return !present || set[addr] } // discoverDomains lists the immediate subdirectories of configDir (skipping @@ -161,32 +139,18 @@ func discoverDomains(configDir string) ([]domainConfig, error) { return out, nil } -// NetServeHandler builds a single-identity HTTP handler from explicit -// options (manual mode). Multi-domain serving uses buildRouter. It is -// exported so tests can drive the resulting handler via httptest. -func NetServeHandler(opts *NetServeOptions) (http.Handler, error) { - client := opts.Client - if client == nil { - client = net.NewClient() - } - dc := domainConfig{ - Domain: opts.Domain, - KeysDir: opts.KeysDir, - PrivateKeyFile: opts.PrivateKeyFile, - PartyFile: opts.PartyFile, - InboxDir: opts.InboxDir, - } - return buildDomainHandler(dc, client, opts.logger()) -} - // buildDomainHandler prepares one domain's on-disk state (keys, party, -// inbox, allow-list) and returns its mux. +// inbox) and returns its mux. +// +// - GET /keys — open, serves the published keys. +// - GET /who — authenticated identity lookup (see handleWho). +// - POST /inbox — authenticated envelope delivery (see handleInbox). // -// - GET /keys — open, serves the public JWKS. -// - POST /who — authenticated party exchange (see handleWho). -// - POST /inbox — authenticated envelope delivery (see handleInbox). -func buildDomainHandler(dc domainConfig, client *net.Client, log *slog.Logger) (http.Handler, error) { - keysByKID, err := ensureKeys(dc, log) +// The who and inbox routes require a request token; keys stay open so +// peers can verify this domain's signatures and tokens. +func buildDomainHandler(dc domainConfig, opts *NetServeOptions) (http.Handler, error) { + l := opts.logger() + keysByKID, err := ensureKeys(dc, l) if err != nil { return nil, err } @@ -194,27 +158,47 @@ func buildDomainHandler(dc domainConfig, client *net.Client, log *slog.Logger) ( if err != nil { return nil, err } - partyEnv, err := readPartyEnvelope(dc) - if err != nil { - return nil, err - } - partyEnvBytes, err := json.Marshal(partyEnv) // canonical, unsigned, stable UUID - if err != nil { - return nil, fmt.Errorf("net serve: marshal party: %w", err) + self := net.Address(dc.Domain) + if self == "" { + return nil, errors.New("net serve: domain is required") + } + + fetcher := opts.Fetcher + if fetcher == nil { + fetcher = net.NewHTTPFetcher() + } + // WithAuthorities replaces the client's trust list, and this + // server's contract is to supplement the default, so the list is + // built as default-plus-extras. + authorities := append(append([]net.Address{}, net.Authorities...), opts.Authorities...) + client := net.NewClient( + net.WithFetcher(fetcher), + net.WithIdentity(self, priv), + net.WithAuthorities(authorities...), + ) + + // A domain without a party file is a receive-only account: /who + // answers 204 and deliveries are unaffected. + var partyEnvBytes []byte + if fileExists(dc.PartyFile) { + partyEnv, err := readPartyEnvelope(dc) + if err != nil { + return nil, err + } + partyEnvBytes, err = signedPartyBytes(partyEnv, priv, self) + if err != nil { + return nil, err + } + } else { + l.Info("who.no_party", "domain", dc.Domain, "party_file", dc.PartyFile) } + if err := os.MkdirAll(dc.InboxDir, 0o755); err != nil { return nil, fmt.Errorf("net serve: create inbox dir: %w", err) } - allow, present, err := loadAllowList(dc) - if err != nil { - return nil, err - } - var self cbc.URI - if dc.Domain != "" { - self = net.Address(dc.Domain).URI() - } - l := logger(log) + deferred := fileExists(dc.WhoDeferredFile) + jwksBytes, keyCount, err := buildJWKS(keysByKID) if err != nil { return nil, err @@ -222,11 +206,89 @@ func buildDomainHandler(dc domainConfig, client *net.Client, log *slog.Logger) ( mux := http.NewServeMux() mux.HandleFunc("GET "+net.KeysPath+"/{kid}", handleKey(l, keysByKID)) mux.HandleFunc("GET "+net.JWKSPath, handleJWKS(l, jwksBytes, keyCount)) - mux.HandleFunc("POST "+net.WhoPath, handleWho(l, client, partyEnvBytes, priv, self, allow, present)) - mux.HandleFunc("POST "+net.InboxPath, handleInbox(l, client, dc.InboxDir, self, allow, present)) + mux.Handle("GET "+net.WhoPath, requireAuth(l, client, self, + handleWho(l, partyEnvBytes, deferred, dc.WhoRequestsDir))) + mux.Handle("POST "+net.InboxPath, requireAuth(l, client, self, + handleInbox(l, client, dc, self, opts.AllowUnverified))) return accessLog(l, corsAllowAll(mux)), nil } +// signedPartyBytes returns the static /who response body: the party +// envelope self-signed by this domain. An envelope already carrying the +// domain's self-signature as its first signature (e.g. countersigned by +// an Authority out-of-band) is served verbatim; anything else is signed +// once at startup with iss=self and no audience. +func signedPartyBytes(env *gobl.Envelope, priv *dsig.PrivateKey, self net.Address) ([]byte, error) { + signed := false + if env.Signed() && self != "" { + if p, err := head.SignedPayload(env.Signatures[0]); err == nil && p.Aud == "" { + if got, gerr := net.ParseAddress(p.Iss); gerr == nil && got == self { + signed = true + } + } + } + if !signed { + opts := []head.SignOption{} + if self != "" { + opts = append(opts, head.WithIssuer(self.String())) + } + if err := env.Sign(priv, opts...); err != nil { + return nil, fmt.Errorf("net serve: sign party: %w", err) + } + } + out, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("net serve: marshal party: %w", err) + } + return out, nil +} + +// requesterCtxKey carries the verified requester Address through the +// request context. +type requesterCtxKey struct{} + +// requesterFrom returns the requester Address stashed by requireAuth, +// or "" when authentication is disabled. +func requesterFrom(r *http.Request) net.Address { + a, _ := r.Context().Value(requesterCtxKey{}).(net.Address) + return a +} + +// requireAuth enforces the request token (spec §5.5) on who and inbox +// requests: the Authorization bearer token must verify against the +// issuer's published key, be bound to this domain, and be fresh. The +// verified requester is stashed in the request context. +func requireAuth(log *slog.Logger, client *net.Client, self net.Address, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if header == "" { + log.Warn("auth.rejected", "path", r.URL.Path, "reason", "token_missing", "remote", r.RemoteAddr) + http.Error(w, "authorization required", http.StatusUnauthorized) + return + } + requester, err := client.VerifyAuthorization(r.Context(), header, self) + 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, net.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: "+err.Error(), http.StatusServiceUnavailable) + return + } + reason := "token_invalid" + if errors.Is(err, net.ErrTokenExpired) { + reason = "token_expired" + } + log.Warn("auth.rejected", "path", r.URL.Path, "reason", reason, "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "invalid request token: "+err.Error(), http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), requesterCtxKey{}, requester) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + // buildJWKS materialises the bulk JWK Set response by sorting the // published keys newest-first (by valid_from descending, with // UUIDv7 kid descending as a tie-breaker) and wrapping them in the @@ -306,15 +368,11 @@ func handleKey(log *slog.Logger, keysByKID map[string][]byte) http.HandlerFunc { } // buildRouter returns an HTTP handler dispatching by the request Host -// header to the matching domain's handler. A single unnamed identity -// (manual mode without a domain) is served for all hosts. -func buildRouter(domains []domainConfig, client *net.Client, log *slog.Logger) (http.Handler, error) { - if len(domains) == 1 && domains[0].Domain == "" { - return buildDomainHandler(domains[0], client, log) - } +// header to the matching domain's handler. +func buildRouter(domains []domainConfig, opts *NetServeOptions) (http.Handler, error) { handlers := make(map[string]http.Handler, len(domains)) for _, dc := range domains { - h, err := buildDomainHandler(dc, client, log) + h, err := buildDomainHandler(dc, opts) if err != nil { return nil, err } @@ -462,7 +520,10 @@ func dirExists(path string) bool { } // publishedKeyBytes marshals the public counterpart of priv as a -// dsig.PublicKey with valid_from stamped to the current UTC time. +// dsig.PublicKey with valid_from stamped to the current UTC time, +// floored to the second: signature `iat` claims carry whole seconds, +// so a sub-second valid_from would reject signatures made immediately +// after key generation. func publishedKeyBytes(priv *dsig.PrivateKey) ([]byte, error) { pubJSON, err := json.Marshal(priv.Public()) if err != nil { @@ -472,7 +533,7 @@ func publishedKeyBytes(priv *dsig.PrivateKey) ([]byte, error) { if err := json.Unmarshal(pubJSON, pk); err != nil { return nil, err } - now := cal.TimestampNow() + now := cal.TimestampOf(time.Now().UTC().Truncate(time.Second)) pk.ValidFrom = &now return json.Marshal(pk) } @@ -531,15 +592,6 @@ func fileExists(path string) bool { // identity (manual mode) when PartyFile/KeysDir are set, otherwise the // domains discovered under ConfigDir (optionally filtered by Domain). func resolveDomains(opts *NetServeOptions) ([]domainConfig, error) { - if opts.PartyFile != "" || opts.KeysDir != "" { - return []domainConfig{{ - Domain: opts.Domain, - KeysDir: opts.KeysDir, - PrivateKeyFile: opts.PrivateKeyFile, - PartyFile: opts.PartyFile, - InboxDir: opts.InboxDir, - }}, nil - } if opts.ConfigDir == "" { return nil, errors.New("net serve: no config dir configured") } @@ -578,20 +630,17 @@ func NetServe(ctx context.Context, opts *NetServeOptions) error { if opts.Out == nil { opts.Out = os.Stdout } - if opts.Client == nil { - opts.Client = net.NewClient() - } domains, err := resolveDomains(opts) if err != nil { return err } if len(domains) == 0 { - return gobl.ErrInput.WithReason("net serve: no domains configured — run `gobl init ` or pass --party/--keys") + return gobl.ErrInput.WithReason("net serve: no domains configured — run `gobl init ` first") } log := opts.logger() - router, err := buildRouter(domains, opts.Client, log) + router, err := buildRouter(domains, opts) if err != nil { return err } @@ -602,9 +651,6 @@ func NetServe(ctx context.Context, opts *NetServeOptions) error { switch { case opts.ACMELive || opts.ACMETest: names := domainNames(domains) - if len(names) == 0 { - return gobl.ErrInput.WithReason("net serve: ACME requires named domains — use --domain or per-domain config directories") - } m := newAutocertManager(opts, names) httpHandler = m.HTTPHandler(router) tlsConfig = m.TLSConfig() @@ -767,60 +813,78 @@ func serveBytes(body []byte) http.HandlerFunc { } } -// handleWho answers an authenticated party-exchange request. The caller -// POSTs a signed envelope (iss=gobl:caller, aud=gobl:self); the server -// verifies it, allow-lists the caller, and responds with its own party -// envelope signed with iss/aud reversed (iss=gobl:self, aud=gobl:caller). -func handleWho(log *slog.Logger, client *net.Client, partyEnvBytes []byte, priv *dsig.PrivateKey, self cbc.URI, allow map[net.Address]bool, present bool) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, netInboxMaxBody)) - if err != nil { - log.Warn("who.rejected", "reason", "read_body", "remote", r.RemoteAddr, "error", err.Error()) - http.Error(w, "could not read body", http.StatusBadRequest) - return - } - req := new(gobl.Envelope) - if err := json.Unmarshal(body, req); err != nil { - log.Warn("who.rejected", "reason", "bad_body", "remote", r.RemoteAddr) - http.Error(w, "invalid envelope JSON", http.StatusBadRequest) - return - } - caller, err := client.VerifyEnvelope(r.Context(), req, self) - if err != nil { - log.Warn("who.rejected", "reason", "verify_failed", "remote", r.RemoteAddr, "error", err.Error()) - http.Error(w, "request verification failed: "+err.Error(), http.StatusUnauthorized) - return - } - if !allowed(allow, present, caller) { - log.Warn("who.rejected", "reason", "not_allowed", "caller", string(caller)) - http.Error(w, "caller not accepted", http.StatusForbidden) - return - } +// whoRequest is the record written for each deferred /who request so +// the operator can review and approve it later (`gobl net requests`, +// `gobl net approve`). +type whoRequest struct { + Requester net.Address `json:"requester"` + Time string `json:"time"` +} - resp := new(gobl.Envelope) - if err := json.Unmarshal(partyEnvBytes, resp); err != nil { - log.Error("who.party_load_failed", "caller", string(caller), "error", err.Error()) - http.Error(w, "could not load party", http.StatusInternalServerError) - return - } - if err := resp.Sign(priv, head.WithIssuer(self), head.WithAudience(caller.URI())); err != nil { - log.Error("who.sign_failed", "caller", string(caller), "error", err.Error()) - http.Error(w, "could not sign party: "+err.Error(), http.StatusInternalServerError) +// recordWhoRequest persists a deferred who request as +// /.json. The requester is a canonicalized FQDN, so it +// is safe as a filename component. +func recordWhoRequest(dir string, requester net.Address) error { + if dir == "" { + return fmt.Errorf("net serve: no who-requests directory configured") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.Marshal(whoRequest{ + Requester: requester, + Time: time.Now().UTC().Format(time.RFC3339), + }) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, string(requester)+".json"), data, 0o644) +} + +// handleWho answers an authenticated identity lookup (GET). The +// response is the domain's static self-signed party envelope; the +// request token identifies the requester for the audit log. A domain +// without a party file answers 204 (receive-only); a domain with +// deferred disclosure records the request and answers 202 — the owner +// may later deliver its party to the requester's inbox (`gobl net +// approve`). +func handleWho(log *slog.Logger, partyEnvBytes []byte, deferred bool, requestsDir string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requester := requesterFrom(r) + if len(partyEnvBytes) == 0 { + log.Info("who.served", "requester", string(requester), "status", http.StatusNoContent) + w.WriteHeader(http.StatusNoContent) return } - out, err := json.Marshal(resp) - if err != nil { - log.Error("who.encode_failed", "caller", string(caller), "error", err.Error()) - http.Error(w, "could not encode party", http.StatusInternalServerError) + if deferred { + if err := recordWhoRequest(requestsDir, requester); err != nil { + log.Error("who.request_write_failed", "requester", string(requester), "error", err.Error()) + http.Error(w, "could not record request", http.StatusInternalServerError) + return + } + log.Info("who.deferred", "requester", string(requester)) + w.WriteHeader(http.StatusAccepted) return } - log.Info("who.exchange", "caller", string(caller)) - serveBytes(out)(w, r) - } + log.Info("who.served", "requester", string(requester), "status", http.StatusOK) + w.Header().Set("Cache-Control", "private") + serveBytes(partyEnvBytes)(w, r) + }) } -func handleInbox(log *slog.Logger, client *net.Client, dir string, self cbc.URI, allow map[net.Address]bool, present bool) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { +// handleInbox accepts a signed envelope delivery. The request token +// (checked by requireAuth) authenticates the transmitting peer — which +// may differ from the envelope's signer when a trusted intermediary +// delivers on the signer's behalf. The envelope's own signature and +// audience are verified independently, and the sender-endorsement +// policy applies to the envelope's signer: an authority +// countersignature is always required, with a confirmed verifier +// unless allowUnverified relaxes it. A party envelope answering one of +// our own deferred who requests (who-pending) is accepted without +// endorsement. +func handleInbox(log *slog.Logger, client *net.Client, dc domainConfig, selfAddr net.Address, allowUnverified bool) http.Handler { + dir := dc.InboxDir + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, netInboxMaxBody)) if err != nil { log.Warn("inbox.rejected", "reason", "read_body", "remote", r.RemoteAddr, "error", err.Error()) @@ -843,6 +907,11 @@ func handleInbox(log *slog.Logger, client *net.Client, dir string, self cbc.URI, sender, err := client.VerifyEnvelope(r.Context(), env, "") if err != nil { + if errors.Is(err, net.ErrUnavailable) { + log.Warn("inbox.rejected", "reason", "verify_unavailable", "remote", r.RemoteAddr, "error", err.Error()) + http.Error(w, "could not verify envelope: "+err.Error(), http.StatusServiceUnavailable) + return + } log.Warn("inbox.rejected", "reason", "verify_failed", "remote", r.RemoteAddr, "error", err.Error()) http.Error(w, "signature verification failed: "+err.Error(), http.StatusUnauthorized) return @@ -850,27 +919,42 @@ func handleInbox(log *slog.Logger, client *net.Client, dir string, self cbc.URI, // Inboxes require the envelope to be bound to this address. A // missing or mismatched aud is rejected so the same valid // envelope cannot be replayed against a different inbox. - if self != "" { - p, perr := head.SignedPayload(env.Signatures[0]) - if perr != nil { - log.Warn("inbox.rejected", "reason", "verify_failed", "caller", string(sender), "error", perr.Error()) - http.Error(w, "could not read signed payload", http.StatusUnauthorized) - return - } - if p.Aud == "" { - log.Warn("inbox.rejected", "reason", "aud_missing", "caller", string(sender)) - http.Error(w, "envelope must be signed with an audience matching this inbox", http.StatusUnauthorized) - return - } - if p.Aud != self { - log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", string(p.Aud)) - http.Error(w, "envelope audience does not match this inbox", http.StatusUnauthorized) + p, perr := head.SignedPayload(env.Signatures[0]) + if perr != nil { + log.Warn("inbox.rejected", "reason", "verify_failed", "caller", string(sender), "error", perr.Error()) + http.Error(w, "could not read signed payload", http.StatusUnauthorized) + return + } + if p.Aud == "" { + log.Warn("inbox.rejected", "reason", "aud_missing", "caller", string(sender)) + http.Error(w, "envelope must be signed with an audience matching this inbox", http.StatusUnauthorized) + return + } + // Canonicalize both sides so U-Label or trailing-dot forms + // compare equal, mirroring gobl's VerifyEnvelope. + if aud, aerr := net.ParseAddress(p.Aud); aerr != nil || aud != selfAddr { + log.Warn("inbox.rejected", "reason", "aud_mismatch", "caller", string(sender), "aud", p.Aud) + http.Error(w, "envelope audience does not match this inbox", http.StatusUnauthorized) + return + } + // A self-signed party envelope from an address we have an + // outstanding who request to fulfils that request (spec §8.3) + // and needs no endorsement — it carries exactly what a 200 who + // response would. + pendingFile := filepath.Join(dc.WhoPendingDir, string(sender)) + if _, isParty := env.Extract().(*org.Party); isParty && dc.WhoPendingDir != "" && fileExists(pendingFile) { + _ = os.Remove(pendingFile) + log.Info("who.fulfilled", "caller", string(sender)) + } else if _, err := client.VerifySender(r.Context(), sender, !allowUnverified); err != nil { + // A transient failure to resolve the sender's who or a + // verifier key must not read as a permanent rejection. + if errors.Is(err, net.ErrUnavailable) { + log.Warn("inbox.rejected", "reason", "verify_unavailable", "caller", string(sender), "error", err.Error()) + http.Error(w, "could not verify sender endorsement: "+err.Error(), http.StatusServiceUnavailable) return } - } - if !allowed(allow, present, sender) { - log.Warn("inbox.rejected", "reason", "not_allowed", "caller", string(sender)) - http.Error(w, "sender not accepted", http.StatusForbidden) + log.Warn("inbox.rejected", "reason", "not_endorsed", "caller", string(sender), "error", err.Error()) + http.Error(w, "sender is not endorsed: "+err.Error(), http.StatusForbidden) return } @@ -902,5 +986,5 @@ func handleInbox(log *slog.Logger, client *net.Client, dir string, self cbc.URI, log.Info("inbox.accepted", "caller", string(sender), "envelope", parsedUUID.String()) w.WriteHeader(http.StatusAccepted) - } + }) } diff --git a/internal/ops/net_serve_multidomain_test.go b/internal/ops/net_serve_multidomain_test.go index e2a89d7..1323b23 100644 --- a/internal/ops/net_serve_multidomain_test.go +++ b/internal/ops/net_serve_multidomain_test.go @@ -11,7 +11,6 @@ import ( "testing" "github.com/invopop/gobl" - "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" "github.com/invopop/gobl/note" @@ -175,28 +174,26 @@ func TestMultiDomainRouter(t *testing.T) { domains, err := discoverDomains(configDir) require.NoError(t, err) - peerKey := dsig.NewES256Key() - const peer = "peer.example" - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(peer).KeyURL(peerKey.ID()): jwkBytes(t, peerKey), - }})) + // The shared test peer is endorsed and verified via peerFetcher, so + // inbox deliveries pass the always-on endorsement policy. + peerKey := testPeerKey + const peer = testPeerDomain + opts := serveOpts(peerFetcher(t)) - router, err := buildRouter(domains, client, discardLog()) + router, err := buildRouter(domains, opts) require.NoError(t, err) srv := httptest.NewServer(router) defer srv.Close() - // POST /who on each host returns a party signed by that host, bound to peer. + // GET /who on each host returns that host's static self-signed + // party; the request token binds to the host being asked. for _, host := range []string{"a.example", "b.example"} { - reqEnv, err := gobl.Envelop(&org.Party{Name: "Peer"}) - require.NoError(t, err) - require.NoError(t, reqEnv.Sign(peerKey, head.WithIssuer(net.Address(peer).URI()), head.WithAudience(net.Address(host).URI()))) - body, err := json.Marshal(reqEnv) + token, err := net.NewToken(peerKey, peer, net.Address(host), 0) require.NoError(t, err) - req, _ := http.NewRequest(http.MethodPost, srv.URL+net.WhoPath, bytes.NewReader(body)) + req, _ := http.NewRequest(http.MethodGet, srv.URL+net.WhoPath, nil) req.Host = host - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) @@ -205,12 +202,12 @@ func TestMultiDomainRouter(t *testing.T) { _ = resp.Body.Close() p, err := headSignedPayload(env) require.NoError(t, err) - assert.Equal(t, net.Address(host).URI(), p.Iss) - assert.Equal(t, net.Address(peer).URI(), p.Aud) + assert.Equal(t, net.Address(host).String(), p.Iss) + assert.Empty(t, p.Aud, "static who response is not audience-bound") } // Unknown host -> 404. - req, _ := http.NewRequest(http.MethodPost, srv.URL+net.WhoPath, bytes.NewReader(signedRequest(t, "zzz.example"))) + req, _ := http.NewRequest(http.MethodGet, srv.URL+net.WhoPath, nil) req.Host = "zzz.example" resp, err := http.DefaultClient.Do(req) require.NoError(t, err) @@ -222,13 +219,16 @@ func TestMultiDomainRouter(t *testing.T) { msg.SetUUID(uuid.V7()) denv, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, denv.Sign(peerKey, head.WithIssuer(net.Address(peer).URI()), head.WithAudience(net.Address("a.example").URI()))) + require.NoError(t, denv.Sign(peerKey, head.WithIssuer(net.Address(peer).String()), head.WithAudience(net.Address("a.example").String()))) body, err := json.Marshal(denv) require.NoError(t, err) + itoken, err := net.NewToken(peerKey, peer, "a.example", 0) + require.NoError(t, err) ireq, _ := http.NewRequest(http.MethodPost, srv.URL+net.InboxPath, bytes.NewReader(body)) ireq.Host = "a.example" ireq.Header.Set("Content-Type", "application/json") + ireq.Header.Set("Authorization", "Bearer "+itoken) iresp, err := http.DefaultClient.Do(ireq) require.NoError(t, err) assert.Equal(t, http.StatusAccepted, iresp.StatusCode) diff --git a/internal/ops/net_serve_startup_test.go b/internal/ops/net_serve_startup_test.go index 965f6cd..9cb3dd9 100644 --- a/internal/ops/net_serve_startup_test.go +++ b/internal/ops/net_serve_startup_test.go @@ -61,12 +61,14 @@ func writePrivate(t *testing.T, path string, key *dsig.PrivateKey) { func dcFor(dir, domain string) domainConfig { return domainConfig{ - Domain: domain, - KeysDir: filepath.Join(dir, "keys"), - PrivateKeyFile: filepath.Join(dir, "private.jwk"), - PartyFile: filepath.Join(dir, "party.json"), - InboxDir: filepath.Join(dir, "inbox"), - AllowFile: filepath.Join(dir, "allow.json"), + Domain: domain, + KeysDir: filepath.Join(dir, "keys"), + PrivateKeyFile: filepath.Join(dir, "private.jwk"), + PartyFile: filepath.Join(dir, "party.json"), + InboxDir: filepath.Join(dir, "inbox"), + WhoRequestsDir: filepath.Join(dir, "who-requests"), + WhoPendingDir: filepath.Join(dir, "who-pending"), + WhoDeferredFile: filepath.Join(dir, "who-deferred"), } } @@ -156,23 +158,6 @@ func TestEnsureKeysRejectsMismatchedFilename(t *testing.T) { assert.Contains(t, err.Error(), "does not match JWK kid") } -func TestNetServeHandlerPartyMissing(t *testing.T) { - dir := t.TempDir() - dc := dcFor(dir, "") - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - - _, err := NetServeHandler(&NetServeOptions{ - PartyFile: dc.PartyFile, - KeysDir: dc.KeysDir, - PrivateKeyFile: dc.PrivateKeyFile, - InboxDir: dc.InboxDir, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "party file not found") - assert.Contains(t, err.Error(), "gobl init") -} - func TestReadPartyEnvelopeRaw(t *testing.T) { dir := t.TempDir() dc := dcFor(dir, "d.example.com") @@ -180,7 +165,7 @@ func TestReadPartyEnvelopeRaw(t *testing.T) { env, err := readPartyEnvelope(dc) require.NoError(t, err) - require.False(t, env.Signed(), "party is returned unsigned; /who signs per request") + require.False(t, env.Signed(), "party is returned unsigned; serve self-signs it at startup") party, ok := env.Extract().(*org.Party) require.True(t, ok) assert.Equal(t, "Acme", party.Name) @@ -214,8 +199,8 @@ func TestReadPartyEnvelopeSignedEnvelopeRoundTrip(t *testing.T) { env, err := gobl.Envelop(&org.Party{Name: "Pre-signed"}) require.NoError(t, err) require.NoError(t, env.Sign(privateKey, - head.WithIssuer(net.Address("d.example.com").URI()), - head.WithAudience(net.Address("other.example").URI()))) + head.WithIssuer(net.Address("d.example.com").String()), + head.WithAudience(net.Address("other.example").String()))) data, err := json.Marshal(env) require.NoError(t, err) require.NoError(t, os.WriteFile(dc.PartyFile, data, 0o644)) @@ -236,54 +221,6 @@ func TestReadPartyEnvelopeInvalidJSON(t *testing.T) { assert.Contains(t, err.Error(), "invalid JSON") } -func TestLoadAllowList(t *testing.T) { - dir := t.TempDir() - dc := dcFor(dir, "x.example") - - t.Run("absent file: present=false", func(t *testing.T) { - set, present, err := loadAllowList(dc) - require.NoError(t, err) - assert.False(t, present) - assert.Nil(t, set) - }) - - t.Run("empty AllowFile path: present=false", func(t *testing.T) { - bare := domainConfig{} // AllowFile == "" - set, present, err := loadAllowList(bare) - require.NoError(t, err) - assert.False(t, present) - assert.Nil(t, set) - }) - - t.Run("valid list", func(t *testing.T) { - require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["a.example","b.example"]`), 0o644)) - set, present, err := loadAllowList(dc) - require.NoError(t, err) - assert.True(t, present) - assert.True(t, set["a.example"]) - assert.True(t, set["b.example"]) - assert.False(t, set["c.example"]) - }) - - t.Run("invalid JSON", func(t *testing.T) { - require.NoError(t, os.WriteFile(dc.AllowFile, []byte("not json"), 0o644)) - _, _, err := loadAllowList(dc) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid allow list") - }) -} - -func TestAllowed(t *testing.T) { - t.Run("no list present: any caller accepted", func(t *testing.T) { - assert.True(t, allowed(nil, false, "any.example")) - }) - t.Run("list present: only listed caller accepted", func(t *testing.T) { - set := map[net.Address]bool{"a.example": true} - assert.True(t, allowed(set, true, "a.example")) - assert.False(t, allowed(set, true, "b.example")) - }) -} - func TestDiscoverDomainsMissingConfigDir(t *testing.T) { // Non-existent config dir returns nil slice, no error. dcs, err := discoverDomains(filepath.Join(t.TempDir(), "does-not-exist")) @@ -330,18 +267,6 @@ func TestLoadPrivateKeyFileErrors(t *testing.T) { } func TestResolveDomains(t *testing.T) { - t.Run("manual mode via KeysDir", func(t *testing.T) { - dcs, err := resolveDomains(&NetServeOptions{KeysDir: "/keys", Domain: "x"}) - require.NoError(t, err) - require.Len(t, dcs, 1) - assert.Equal(t, "x", dcs[0].Domain) - assert.Equal(t, "/keys", dcs[0].KeysDir) - }) - t.Run("manual mode via PartyFile", func(t *testing.T) { - dcs, err := resolveDomains(&NetServeOptions{PartyFile: "/p"}) - require.NoError(t, err) - require.Len(t, dcs, 1) - }) t.Run("no config dir", func(t *testing.T) { _, err := resolveDomains(&NetServeOptions{}) require.Error(t, err) @@ -425,35 +350,31 @@ func TestReadKeysDirIgnoresNonJSON(t *testing.T) { assert.Empty(t, got) } -func TestNetServeHandlerDefaultsClient(t *testing.T) { - // Omitting Out + Client routes to defaults without panic. +func TestBuildDomainHandlerDefaultsFetcher(t *testing.T) { + // Omitting Out + Fetcher routes to defaults without panic. dir := t.TempDir() - dc := dcFor(dir, "") + dc := dcFor(dir, "solo.example") writeKey(t, dc.KeysDir, privateKey) writePrivate(t, dc.PrivateKeyFile, privateKey) writeRawParty(t, dc.PartyFile, &org.Party{Name: "X"}) - h, err := NetServeHandler(&NetServeOptions{ - PartyFile: dc.PartyFile, - KeysDir: dc.KeysDir, - PrivateKeyFile: dc.PrivateKeyFile, - InboxDir: dc.InboxDir, - }) + h, err := buildDomainHandler(dc, &NetServeOptions{}) require.NoError(t, err) require.NotNil(t, h) } -func TestBuildRouterSingleUnnamed(t *testing.T) { - // One unnamed identity: router shortcircuits to a single handler. +func TestBuildDomainHandlerRequiresDomain(t *testing.T) { + // A domainConfig without a domain cannot bind request tokens and is + // rejected at startup. dir := t.TempDir() dc := dcFor(dir, "") writeKey(t, dc.KeysDir, privateKey) writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Solo"}) + writeRawParty(t, dc.PartyFile, &org.Party{Name: "X"}) - h, err := buildRouter([]domainConfig{dc}, nil, discardLog()) - require.NoError(t, err) - require.NotNil(t, h) + _, err := buildDomainHandler(dc, &NetServeOptions{Log: discardLog()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "domain is required") } func TestNetServeRunCancel(t *testing.T) { @@ -540,25 +461,6 @@ func TestNetServeWithACMETest(t *testing.T) { assert.Contains(t, buf.String(), "ACME enabled") } -func TestNetServeACMEManualMode(t *testing.T) { - // Manual mode (no Domain) + ACME requires named domains -> error. - dir := t.TempDir() - dc := dcFor(dir, "") - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Solo"}) - - err := NetServe(context.Background(), &NetServeOptions{ - KeysDir: dc.KeysDir, - PrivateKeyFile: dc.PrivateKeyFile, - PartyFile: dc.PartyFile, - InboxDir: dc.InboxDir, - ACMETest: true, - }) - require.Error(t, err) - assert.Contains(t, err.Error(), "ACME requires named domains") -} - func TestNetServeCertFileMissing(t *testing.T) { configDir := t.TempDir() initTestDomain(t, configDir, "x.example") @@ -617,19 +519,6 @@ func TestReadKeysDirNonExistent(t *testing.T) { assert.Contains(t, err.Error(), "read keys dir") } -func TestLoadAllowListReadError(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("read-permission tests do not apply when running as root") - } - dir := t.TempDir() - dc := dcFor(dir, "") - require.NoError(t, os.WriteFile(dc.AllowFile, []byte("[]"), 0o000)) - t.Cleanup(func() { _ = os.Chmod(dc.AllowFile, 0o644) }) - _, _, err := loadAllowList(dc) - require.Error(t, err) - assert.Contains(t, err.Error(), "read allow list") -} - func TestDiscoverDomainsReadError(t *testing.T) { if os.Geteuid() == 0 { t.Skip("read-permission tests do not apply when running as root") @@ -664,54 +553,45 @@ func TestBuildRouterPropagatesDomainError(t *testing.T) { dc := dcFor(dir, "broken.example") // Write keys/ dir without private.jwk to trigger the "inconsistent" path. writeKey(t, dc.KeysDir, dsig.NewES256Key()) - _, err := buildRouter([]domainConfig{dc}, nil, discardLog()) + _, err := buildRouter([]domainConfig{dc}, &NetServeOptions{Log: discardLog()}) require.Error(t, err) } // TestBuildDomainHandlerErrors covers the buildDomainHandler error -// branches that come after ensureKeys: bad private key, missing party, -// malformed allow-list, and inbox-mkdir-fail. +// branches that come after ensureKeys: bad private key, malformed +// party, and inbox-mkdir-fail. func TestBuildDomainHandlerErrors(t *testing.T) { + opts := &NetServeOptions{Log: discardLog()} + t.Run("bad private key", func(t *testing.T) { dir := t.TempDir() dc := dcFor(dir, "") writeKey(t, dc.KeysDir, privateKey) require.NoError(t, os.WriteFile(dc.PrivateKeyFile, []byte("not json"), 0o600)) - _, err := buildDomainHandler(dc, nil, discardLog()) + _, err := buildDomainHandler(dc, opts) require.Error(t, err) }) - t.Run("missing party", func(t *testing.T) { + t.Run("invalid party JSON", func(t *testing.T) { dir := t.TempDir() - dc := dcFor(dir, "") + dc := dcFor(dir, "d.example.com") writeKey(t, dc.KeysDir, privateKey) writePrivate(t, dc.PrivateKeyFile, privateKey) - _, err := buildDomainHandler(dc, nil, discardLog()) + require.NoError(t, os.WriteFile(dc.PartyFile, []byte("not json"), 0o644)) + _, err := buildDomainHandler(dc, opts) require.Error(t, err) - assert.Contains(t, err.Error(), "party file not found") - }) - - t.Run("bad allow list", func(t *testing.T) { - dir := t.TempDir() - dc := dcFor(dir, "") - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - require.NoError(t, os.WriteFile(dc.AllowFile, []byte("not json"), 0o644)) - _, err := buildDomainHandler(dc, nil, discardLog()) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid allow list") + assert.Contains(t, err.Error(), "invalid JSON") }) t.Run("inbox is a file", func(t *testing.T) { dir := t.TempDir() - dc := dcFor(dir, "") + dc := dcFor(dir, "d.example.com") writeKey(t, dc.KeysDir, privateKey) writePrivate(t, dc.PrivateKeyFile, privateKey) writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) // Pre-create dc.InboxDir as a regular file so MkdirAll fails. require.NoError(t, os.WriteFile(dc.InboxDir, []byte("x"), 0o644)) - _, err := buildDomainHandler(dc, nil, discardLog()) + _, err := buildDomainHandler(dc, opts) require.Error(t, err) assert.Contains(t, err.Error(), "create inbox dir") }) diff --git a/internal/ops/net_serve_test.go b/internal/ops/net_serve_test.go index ad8818e..ff48e52 100644 --- a/internal/ops/net_serve_test.go +++ b/internal/ops/net_serve_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -24,9 +25,13 @@ import ( type mapFetcher struct { data map[string][]byte + errs map[string]error } -func (m *mapFetcher) Fetch(_ context.Context, url string) ([]byte, error) { +func (m *mapFetcher) Fetch(_ context.Context, url string, _ http.Header) ([]byte, error) { + if err, ok := m.errs[url]; ok { + return nil, err + } body, ok := m.data[url] if !ok { return nil, net.ErrFetchFailed @@ -34,6 +39,10 @@ func (m *mapFetcher) Fetch(_ context.Context, url string) ([]byte, error) { return body, nil } +func (m *mapFetcher) Post(_ context.Context, _ string, _ []byte, _ http.Header) error { + return net.ErrFetchFailed +} + // jwkBytes returns the single-JWK bytes served at the per-key endpoint // for this key. func jwkBytes(t *testing.T, key *dsig.PrivateKey) []byte { @@ -50,37 +59,103 @@ const ( var testPeerKey = dsig.NewES256Key() -// setupNetServer stands up a single-domain handler for testServeDomain -// (signed by the package privateKey) whose client can resolve both the -// served domain's and the peer's /keys. Returns the server and inbox dir. -func setupNetServer(t *testing.T) (*httptest.Server, string) { +// testAuthority endorses the shared test peer; naming itself as +// verifier makes the peer verified with a single countersignature. +const testAuthority = "authority.example" + +var testAuthorityKey = dsig.NewES256Key() + +// serveOpts builds NetServeOptions with the given fetcher, the shared +// test authority, and a discarded log — the standard fixture for +// handler tests. +func serveOpts(fetcher net.Fetcher) *NetServeOptions { + return &NetServeOptions{ + Fetcher: fetcher, + Authorities: []net.Address{testAuthority}, + Log: discardLog(), + } +} + +// peerFetcher resolves the peer's, the authority's, and the served +// domain's published keys plus the peer's endorsed-and-verified who — +// everything the served domain's client needs to verify request +// tokens, envelope signatures, and the sender endorsement. +func peerFetcher(t *testing.T) *mapFetcher { + t.Helper() + return &mapFetcher{data: map[string][]byte{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), + net.Address(testServeDomain).KeyURL(privateKey.ID()): jwkBytes(t, privateKey), + net.Address(testAuthority).KeyURL(testAuthorityKey.ID()): jwkBytes(t, testAuthorityKey), + net.Address(testPeerDomain).WhoURL(): peerWhoBytes(t, testAuthorityKey, testAuthority, testAuthority), + }} +} + +// bearer mints a request token for the peer targeting the given +// address and returns it as an Authorization header value. +func bearer(t *testing.T, to net.Address) string { + t.Helper() + token, err := net.NewToken(testPeerKey, testPeerDomain, to, 0) + require.NoError(t, err) + return "Bearer " + token +} + +// doReq performs an HTTP request with an optional Authorization value. +func doReq(t *testing.T, method, url string, body []byte, auth string) *http.Response { + t.Helper() + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + require.NoError(t, err) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if auth != "" { + req.Header.Set("Authorization", auth) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp +} + +// writeServeDomain lays down the on-disk state for testServeDomain. +func writeServeDomain(t *testing.T, cfg string) domainConfig { t.Helper() - cfg := t.TempDir() dc := domainConfigFor(cfg, testServeDomain) require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) writeKey(t, dc.KeysDir, privateKey) writePrivate(t, dc.PrivateKeyFile, privateKey) writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) + return dc +} - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - net.Address(testServeDomain).KeyURL(privateKey.ID()): jwkBytes(t, privateKey), - }})) - - h, err := buildDomainHandler(dc, client, discardLog()) +// setupNetServer stands up a single-domain handler for testServeDomain +// (signed by the package privateKey) whose client can resolve both the +// served domain's and the peer's /keys. Returns the server and config. +func setupNetServer(t *testing.T) (*httptest.Server, domainConfig) { + t.Helper() + dc := writeServeDomain(t, t.TempDir()) + h, err := buildDomainHandler(dc, serveOpts(peerFetcher(t))) require.NoError(t, err) srv := httptest.NewServer(h) t.Cleanup(srv.Close) - return srv, dc.InboxDir + return srv, dc } -// signedRequest builds an envelope wrapping the peer's party, signed -// iss=peer, aud=. -func signedRequest(t *testing.T, aud net.Address) []byte { +// signedNoteTo wraps a note.Message in an envelope signed by the peer +// and bound to the given audience. +func signedNoteTo(t *testing.T, content string, aud net.Address) *gobl.Envelope { t.Helper() - env, err := gobl.Envelop(&org.Party{Name: "Peer"}) + msg := ¬e.Message{Content: content} + msg.SetUUID(uuid.V7()) + env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(aud.URI()))) + require.NoError(t, env.Sign(testPeerKey, + head.WithIssuer(net.Address(testPeerDomain).String()), + head.WithAudience(aud.String()))) + return env +} + +func marshalEnv(t *testing.T, env *gobl.Envelope) []byte { + t.Helper() body, err := json.Marshal(env) require.NoError(t, err) return body @@ -89,7 +164,7 @@ func signedRequest(t *testing.T, aud net.Address) []byte { func TestNetServeKeys(t *testing.T) { srv, _ := setupNetServer(t) - // Per-key endpoint: known kid returns the single JWK. + // Per-key endpoint: known kid returns the single JWK, no auth needed. resp, err := http.Get(srv.URL + net.KeyPath(privateKey.ID())) require.NoError(t, err) defer resp.Body.Close() //nolint:errcheck @@ -111,14 +186,12 @@ func TestNetServeKeys(t *testing.T) { assert.Equal(t, http.StatusNotFound, respBulk.StatusCode) } -func TestNetServeWhoExchange(t *testing.T) { +func TestNetServeWho(t *testing.T) { srv, _ := setupNetServer(t) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", - bytes.NewReader(signedRequest(t, testServeDomain))) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) require.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "private", resp.Header.Get("Cache-Control")) env := new(gobl.Envelope) require.NoError(t, json.NewDecoder(resp.Body).Decode(env)) @@ -126,85 +199,246 @@ func TestNetServeWhoExchange(t *testing.T) { p, err := headSignedPayload(env) require.NoError(t, err) - assert.Equal(t, net.Address(testServeDomain).URI(), p.Iss, "response signed by the served domain") - assert.Equal(t, net.Address(testPeerDomain).URI(), p.Aud, "response bound to the caller") + assert.Equal(t, net.Address(testServeDomain).String(), p.Iss, "response is the domain's self-signature") + assert.Empty(t, p.Aud, "the static who response is not audience-bound") party, ok := env.Extract().(*org.Party) require.True(t, ok) assert.Equal(t, "Me", party.Name) } -func TestNetServeWhoUnauthenticated(t *testing.T) { +func TestNetServeWhoRequiresToken(t *testing.T) { srv, _ := setupNetServer(t) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader([]byte("not json"))) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + t.Run("missing token", func(t *testing.T) { + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("token bound to another audience", func(t *testing.T) { + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, "other.example")) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("token from an unresolvable issuer", func(t *testing.T) { + other := dsig.NewES256Key() + token, err := net.NewToken(other, "unknown.example", testServeDomain, 0) + require.NoError(t, err) + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "Bearer "+token) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) + + t.Run("not a bearer header", func(t *testing.T) { + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "Basic dXNlcjpwdw==") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) } -func TestNetServeInboxAccepts(t *testing.T) { - srv, inboxDir := setupNetServer(t) +func TestNetServeWhoNoParty(t *testing.T) { + // A domain without a party file is receive-only: /who answers 204. + cfg := t.TempDir() + dc := domainConfigFor(cfg, testServeDomain) + require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) + writeKey(t, dc.KeysDir, privateKey) + writePrivate(t, dc.PrivateKeyFile, privateKey) - msg := ¬e.Message{Content: "hello inbox"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) + h, err := buildDomainHandler(dc, serveOpts(peerFetcher(t))) require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, err := json.Marshal(env) + srv := httptest.NewServer(h) + defer srv.Close() + + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) + assert.Equal(t, http.StatusNoContent, resp.StatusCode) +} + +func TestNetServeWhoDeferred(t *testing.T) { + cfg := t.TempDir() + dc := writeServeDomain(t, cfg) + require.NoError(t, os.WriteFile(dc.WhoDeferredFile, nil, 0o644)) + + h, err := buildDomainHandler(dc, serveOpts(peerFetcher(t))) require.NoError(t, err) + srv := httptest.NewServer(h) + defer srv.Close() + + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) + // The request was recorded for the operator to approve. + data, err := os.ReadFile(filepath.Join(dc.WhoRequestsDir, testPeerDomain+".json")) require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + req := new(whoRequest) + require.NoError(t, json.Unmarshal(data, req)) + assert.Equal(t, net.Address(testPeerDomain), req.Requester) + assert.NotEmpty(t, req.Time) + + // Still 401 without a token: deferral does not open the endpoint. + resp401 := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, "") + assert.Equal(t, http.StatusUnauthorized, resp401.StatusCode) +} + +func TestNetServeInboxAccepts(t *testing.T) { + srv, dc := setupNetServer(t) + + env := signedNoteTo(t, "hello inbox", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusAccepted, resp.StatusCode) - files, err := os.ReadDir(inboxDir) + files, err := os.ReadDir(dc.InboxDir) require.NoError(t, err) require.Len(t, files, 1) assert.Equal(t, env.Head.UUID.String()+".json", files[0].Name()) } -// callHandleWho drives the handleWho factory directly so we can craft -// corrupt internal state (bad partyEnvBytes, bad signing key) that the -// HTTP-level tests cannot reach via setupNetServer. -func callHandleWho(t *testing.T, partyEnvBytes []byte, priv *dsig.PrivateKey) *httptest.ResponseRecorder { - t.Helper() - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - }})) - self := net.Address(testServeDomain).URI() - h := handleWho(discardLog(), client, partyEnvBytes, priv, self, nil, false) - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, net.WhoPath, bytes.NewReader(signedRequest(t, testServeDomain))) - h(rec, req) - return rec -} - -func TestHandleWhoBadPartyBytes(t *testing.T) { - rec := callHandleWho(t, []byte("not json"), privateKey) - assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Contains(t, rec.Body.String(), "could not load party") -} - -func TestHandleWhoSignFails(t *testing.T) { - // Valid party bytes but a zero-value PrivateKey so resp.Sign errors. - env, err := gobl.Envelop(&org.Party{Name: "Me"}) +func TestNetServeInboxRequiresToken(t *testing.T) { + srv, dc := setupNetServer(t) + + env := signedNoteTo(t, "no token", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), "") + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + files, err := os.ReadDir(dc.InboxDir) require.NoError(t, err) - partyBytes, err := json.Marshal(env) + assert.Empty(t, files, "nothing is persisted before authentication") +} + +func TestNetServeInboxIntermediaryToken(t *testing.T) { + // The request token may name a different party than the envelope's + // signer: a trusted intermediary transmitting on the signer's + // behalf. The server resolves the intermediary's key to verify the + // token and the signer's key to verify the envelope. + intermediaryKey := dsig.NewES256Key() + const intermediary = "carrier.example" + + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.data[net.Address(intermediary).KeyURL(intermediaryKey.ID())] = jwkBytes(t, intermediaryKey) + h, err := buildDomainHandler(dc, serveOpts(fetcher)) require.NoError(t, err) - rec := callHandleWho(t, partyBytes, &dsig.PrivateKey{}) - assert.Equal(t, http.StatusInternalServerError, rec.Code) - assert.Contains(t, rec.Body.String(), "could not sign party") + srv := httptest.NewServer(h) + defer srv.Close() + + token, err := net.NewToken(intermediaryKey, intermediary, testServeDomain, 0) + require.NoError(t, err) + env := signedNoteTo(t, "via intermediary", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), "Bearer "+token) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +// peerWhoBytes builds the peer's self-signed who envelope, optionally +// countersigned by an authority key naming a verifier. +func peerWhoBytes(t *testing.T, authKey *dsig.PrivateKey, authority, verifier net.Address) []byte { + t.Helper() + party := &org.Party{Name: "Peer"} + party.SetUUID(uuid.V7()) + env, err := gobl.Envelop(party) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).String()))) + if authKey != nil { + opts := []head.SignOption{ + head.WithIssuer(authority.String()), + head.WithAudience(net.Address(testPeerDomain).String()), + } + if verifier != "" { + opts = append(opts, head.WithVerifier(verifier.String())) + } + require.NoError(t, env.Sign(authKey, opts...)) + } + return marshalEnv(t, env) +} + +func TestNetServeInboxEndorsementPolicy(t *testing.T) { + authorityKey := dsig.NewES256Key() + const authority = "kyc.example" + + setup := func(t *testing.T, whoBytes []byte, allowUnverified bool) (*httptest.Server, domainConfig) { + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.data[net.Address(authority).KeyURL(authorityKey.ID())] = jwkBytes(t, authorityKey) + fetcher.data[net.Address(testPeerDomain).WhoURL()] = whoBytes + opts := serveOpts(fetcher) + opts.Authorities = []net.Address{authority} + opts.AllowUnverified = allowUnverified + h, err := buildDomainHandler(dc, opts) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv, dc + } + + t.Run("unendorsed sender is rejected even when unverified is allowed", func(t *testing.T) { + srv, dc := setup(t, peerWhoBytes(t, nil, "", ""), true) + env := signedNoteTo(t, "unendorsed", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + files, err := os.ReadDir(dc.InboxDir) + require.NoError(t, err) + assert.Empty(t, files) + }) + + t.Run("registered-only sender is rejected by default", func(t *testing.T) { + srv, dc := setup(t, peerWhoBytes(t, authorityKey, authority, ""), false) + env := signedNoteTo(t, "registered only", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + files, err := os.ReadDir(dc.InboxDir) + require.NoError(t, err) + assert.Empty(t, files) + }) + + t.Run("registered-only sender is accepted with AllowUnverified", func(t *testing.T) { + srv, dc := setup(t, peerWhoBytes(t, authorityKey, authority, ""), true) + env := signedNoteTo(t, "sandbox", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + files, err := os.ReadDir(dc.InboxDir) + require.NoError(t, err) + assert.Len(t, files, 1) + }) + + t.Run("verified sender is accepted by default", func(t *testing.T) { + // The authority names itself as verifier, so its single + // countersignature carries both attestations. + srv, dc := setup(t, peerWhoBytes(t, authorityKey, authority, authority), false) + env := signedNoteTo(t, "verified", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + files, err := os.ReadDir(dc.InboxDir) + require.NoError(t, err) + assert.Len(t, files, 1) + }) + + t.Run("party envelope fulfilling a pending who request skips endorsement", func(t *testing.T) { + srv, dc := setup(t, peerWhoBytes(t, nil, "", ""), false) + // Mark an outstanding who request to the peer. + require.NoError(t, os.MkdirAll(dc.WhoPendingDir, 0o755)) + pending := filepath.Join(dc.WhoPendingDir, testPeerDomain) + require.NoError(t, os.WriteFile(pending, nil, 0o644)) + + party := &org.Party{Name: "Peer"} + party.SetUUID(uuid.V7()) + env, err := gobl.Envelop(party) + require.NoError(t, err) + require.NoError(t, env.Sign(testPeerKey, + head.WithIssuer(net.Address(testPeerDomain).String()), + head.WithAudience(net.Address(testServeDomain).String()))) + + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + assert.NoFileExists(t, pending, "the pending marker is consumed") + + files, err := os.ReadDir(dc.InboxDir) + require.NoError(t, err) + assert.Len(t, files, 1) + }) } func TestNetServeInboxValidationFails(t *testing.T) { srv, _ := setupNetServer(t) // Envelope JSON that parses but lacks required fields (digest, etc.) // so env.Validate fails with 422. - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", - bytes.NewReader([]byte(`{"$schema":"https://gobl.org/draft-0/envelope","head":{"uuid":"01906c00-0000-7000-0000-000000000000","dig":{"alg":"sha256","val":"x"}},"doc":null}`))) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + body := []byte(`{"$schema":"https://gobl.org/draft-0/envelope","head":{"uuid":"01906c00-0000-7000-0000-000000000000","dig":{"alg":"sha256","val":"x"}},"doc":null}`) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, body, bearer(t, testServeDomain)) // 422 (validation), or other 4xx — anything that isn't 202. assert.NotEqual(t, http.StatusAccepted, resp.StatusCode) } @@ -214,27 +448,20 @@ func TestNetServeInboxValidationFails(t *testing.T) { // (env.Validate enforces UUID format; handleInbox re-parses as // defence-in-depth) and that no file is written outside the inbox dir. func TestNetServeInboxRejectsTraversalUUID(t *testing.T) { - srv, inboxDir := setupNetServer(t) + srv, dc := setupNetServer(t) - // Send a fully-formed envelope but with a path-traversal payload - // in head.uuid. Since UUIDs are signed (the digest covers the - // header), the signature won't match — but Validate / the UUID - // re-parse fires before signature verification anyway, so the - // 422 is what we expect. body := []byte(`{"$schema":"https://gobl.org/draft-0/envelope","head":{"uuid":"../../etc/passwd","dig":{"alg":"sha256","val":"x"}},"doc":{}}`) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, body, bearer(t, testServeDomain)) assert.NotEqual(t, http.StatusAccepted, resp.StatusCode) // Nothing was written inside the inbox dir... - files, err := os.ReadDir(inboxDir) + files, err := os.ReadDir(dc.InboxDir) require.NoError(t, err) assert.Empty(t, files) // ...nor anywhere up the path. Walk a few levels above and assert // no "passwd"-like artefacts appeared. - parent := filepath.Dir(filepath.Dir(inboxDir)) + parent := filepath.Dir(filepath.Dir(dc.InboxDir)) for _, suspect := range []string{"passwd", "passwd.json", "etc"} { _, statErr := os.Stat(filepath.Join(parent, suspect)) assert.True(t, os.IsNotExist(statErr), "traversal artefact at %s/%s should not exist", parent, suspect) @@ -245,116 +472,29 @@ func TestNetServeInboxWriteFails(t *testing.T) { if os.Geteuid() == 0 { t.Skip("write-permission tests do not apply when running as root") } - cfg := t.TempDir() - dc := domainConfigFor(cfg, testServeDomain) - require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - }})) - h, err := buildDomainHandler(dc, client, discardLog()) - require.NoError(t, err) - srv := httptest.NewServer(h) - defer srv.Close() + srv, dc := setupNetServer(t) // Make the inbox directory read-only so os.Create fails. require.NoError(t, os.Chmod(dc.InboxDir, 0o500)) t.Cleanup(func() { _ = os.Chmod(dc.InboxDir, 0o755) }) - msg := ¬e.Message{Content: "fail to write"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + env := signedNoteTo(t, "fail to write", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusInternalServerError, resp.StatusCode) } func TestNetServeInboxRejectsBadJSON(t *testing.T) { srv, _ := setupNetServer(t) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader([]byte("not json"))) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, []byte("not json"), bearer(t, testServeDomain)) assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } -func TestNetServeWhoUnauthorizedSignature(t *testing.T) { - srv, _ := setupNetServer(t) - // Build a request signed by an iss whose /keys the server can't resolve. - other := dsig.NewES256Key() - env, err := gobl.Envelop(&org.Party{Name: "Stranger"}) - require.NoError(t, err) - require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -} - -func TestNetServeWhoForbidden(t *testing.T) { - // Allow list rejecting the peer triggers 403. - cfg := t.TempDir() - dc := domainConfigFor(cfg, testServeDomain) - require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - // Allow-list contains a different caller. - require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) - - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - }})) - h, err := buildDomainHandler(dc, client, discardLog()) - require.NoError(t, err) - srv := httptest.NewServer(h) - defer srv.Close() - - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(signedRequest(t, testServeDomain))) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusForbidden, resp.StatusCode) -} - -func TestNetServeWhoUnauthorized(t *testing.T) { - srv, _ := setupNetServer(t) - // Signed but with aud != self -> /who server rejects with 401. - env, err := gobl.Envelop(&org.Party{Name: "Peer"}) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - resp, err := http.Post(srv.URL+net.WhoPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) -} - func TestNetServeInboxAudMismatch(t *testing.T) { srv, _ := setupNetServer(t) // An envelope bound to a different recipient is rejected — prevents // replay against an inbox the signer didn't intend. - msg := ¬e.Message{Content: "wrong aud"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + env := signedNoteTo(t, "wrong aud", "other.example") + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } @@ -367,47 +507,13 @@ func TestNetServeInboxAudMissing(t *testing.T) { msg.SetUUID(uuid.V7()) env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).String()))) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } -func TestNetServeInboxForbidden(t *testing.T) { - cfg := t.TempDir() - dc := domainConfigFor(cfg, testServeDomain) - require.NoError(t, os.MkdirAll(filepath.Join(cfg, testServeDomain), 0o700)) - writeKey(t, dc.KeysDir, privateKey) - writePrivate(t, dc.PrivateKeyFile, privateKey) - writeRawParty(t, dc.PartyFile, &org.Party{Name: "Me"}) - require.NoError(t, os.WriteFile(dc.AllowFile, []byte(`["other.example"]`), 0o644)) - - client := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ - net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), - }})) - h, err := buildDomainHandler(dc, client, discardLog()) - require.NoError(t, err) - srv := httptest.NewServer(h) - defer srv.Close() - - msg := ¬e.Message{Content: "rejected"} - msg.SetUUID(uuid.V7()) - env, err := gobl.Envelop(msg) - require.NoError(t, err) - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck - assert.Equal(t, http.StatusForbidden, resp.StatusCode) -} - func TestNetServeInboxRejectsBadSignature(t *testing.T) { - srv, inboxDir := setupNetServer(t) + srv, dc := setupNetServer(t) // Signed by a key whose /keys the server cannot resolve for the iss. other := dsig.NewES256Key() @@ -415,20 +521,42 @@ func TestNetServeInboxRejectsBadSignature(t *testing.T) { msg.SetUUID(uuid.V7()) env, err := gobl.Envelop(msg) require.NoError(t, err) - require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").URI()), head.WithAudience(net.Address(testServeDomain).URI()))) - body, err := json.Marshal(env) - require.NoError(t, err) + require.NoError(t, env.Sign(other, head.WithIssuer(net.Address("unknown.example").String()), head.WithAudience(net.Address(testServeDomain).String()))) - resp, err := http.Post(srv.URL+net.InboxPath, "application/json", bytes.NewReader(body)) - require.NoError(t, err) - defer resp.Body.Close() //nolint:errcheck + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) - files, err := os.ReadDir(inboxDir) + files, err := os.ReadDir(dc.InboxDir) require.NoError(t, err) assert.Empty(t, files) } +func TestSignedPartyBytesPreSigned(t *testing.T) { + // A party file already carrying the domain's self-signature (no + // aud) is served verbatim — countersignatures survive. + party := &org.Party{Name: "Me"} + party.SetUUID(uuid.V7()) + env, err := gobl.Envelop(party) + require.NoError(t, err) + require.NoError(t, env.Sign(privateKey, head.WithIssuer(net.Address(testServeDomain).String()))) + want := marshalEnv(t, env) + + got, err := signedPartyBytes(env, privateKey, testServeDomain) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + assert.Len(t, env.Signatures, 1, "no second signature is added") +} + +func TestSignedPartyBytesSignFails(t *testing.T) { + party := &org.Party{Name: "Me"} + party.SetUUID(uuid.V7()) + env, err := gobl.Envelop(party) + require.NoError(t, err) + _, err = signedPartyBytes(env, &dsig.PrivateKey{}, testServeDomain) + require.Error(t, err) + assert.Contains(t, err.Error(), "sign party") +} + func headSignedPayload(env *gobl.Envelope) (*head.SigningPayload, error) { return head.SignedPayload(env.Signatures[0]) } @@ -444,3 +572,61 @@ func readDirNames(dir string) ([]string, error) { } return names, nil } + +func TestNetServeTokenVerificationUnavailable(t *testing.T) { + // The requester's key endpoint is unreachable (503): the server + // must answer 503 so the client retries, not 401. + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.errs = map[string]error{ + net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): fmt.Errorf("%w: HTTP 503", net.ErrUnavailable), + } + h, err := buildDomainHandler(dc, serveOpts(fetcher)) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + resp := doReq(t, http.MethodGet, srv.URL+net.WhoPath, nil, bearer(t, testServeDomain)) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +func TestNetServeAuthoritySupplementsDefault(t *testing.T) { + // --authority supplements the default trust list: a sender + // endorsed by the DEFAULT authority (lookup.gobl.org) must still + // be accepted when extras are configured. + defaultAuthority := net.Address("lookup.gobl.org") + defaultKey := dsig.NewES256Key() + + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.data[defaultAuthority.KeyURL(defaultKey.ID())] = jwkBytes(t, defaultKey) + fetcher.data[net.Address(testPeerDomain).WhoURL()] = peerWhoBytes(t, defaultKey, defaultAuthority, defaultAuthority) + + opts := serveOpts(fetcher) // configures the testAuthority extra + h, err := buildDomainHandler(dc, opts) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + env := signedNoteTo(t, "endorsed by the default authority", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +func TestNetServeEndorsementUnavailable(t *testing.T) { + // The sender's who endpoint cannot be reached while checking the + // endorsement: 503, not a permanent 403. + dc := writeServeDomain(t, t.TempDir()) + fetcher := peerFetcher(t) + fetcher.errs = map[string]error{ + net.Address(testPeerDomain).WhoURL(): fmt.Errorf("%w: HTTP 503", net.ErrUnavailable), + } + h, err := buildDomainHandler(dc, serveOpts(fetcher)) + require.NoError(t, err) + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + + env := signedNoteTo(t, "transient who outage", testServeDomain) + resp := doReq(t, http.MethodPost, srv.URL+net.InboxPath, marshalEnv(t, env), bearer(t, testServeDomain)) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} diff --git a/internal/ops/net_serve_tls_test.go b/internal/ops/net_serve_tls_test.go index c1e6d99..a0161d6 100644 --- a/internal/ops/net_serve_tls_test.go +++ b/internal/ops/net_serve_tls_test.go @@ -21,7 +21,6 @@ import ( "time" "github.com/invopop/gobl/net" - "github.com/invopop/gobl/org" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,13 +65,13 @@ func writeSelfSignedCert(t *testing.T, dir string) (certPath, keyPath string) { return certPath, keyPath } -// runServeOnListeners spins up NetServe with the given options against -// the supplied listeners and returns a stop function plus the listener +// runServeOnListeners spins up NetServe for a domain config against +// fresh listeners and returns a stop function plus the listener // addresses for client use. -func runServeOnListeners(t *testing.T, opts *NetServeOptions, tlsConfig *tls.Config) (httpAddr, httpsAddr string, stop func()) { +func runServeOnListeners(t *testing.T, dc domainConfig, opts *NetServeOptions, tlsConfig *tls.Config) (httpAddr, httpsAddr string, stop func()) { t.Helper() - handler, err := NetServeHandler(opts) + handler, err := buildDomainHandler(dc, opts) require.NoError(t, err) httpHandler := handler @@ -113,31 +112,21 @@ func TestNetServeFileTLS(t *testing.T) { dir := t.TempDir() certPath, keyPath := writeSelfSignedCert(t, dir) - // Reuse the party + keys setup from net_serve_test.go. - partyFile := filepath.Join(dir, "party.json") - keysDir := filepath.Join(dir, "keys") - privFile := filepath.Join(dir, "private.jwk") - inboxDir := filepath.Join(dir, "inbox") - + // Reuse the standard domain layout from net_serve_test.go. signKey := privateKey - writeRawParty(t, partyFile, &org.Party{Name: "TLS Party"}) - writeKey(t, keysDir, signKey) - writePrivate(t, privFile, signKey) + dc := writeServeDomain(t, dir) cert, err := tls.LoadX509KeyPair(certPath, keyPath) require.NoError(t, err) tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}} opts := &NetServeOptions{ - PartyFile: partyFile, - KeysDir: keysDir, - PrivateKeyFile: privFile, - InboxDir: inboxDir, - CertFile: certPath, - KeyFile: keyPath, + CertFile: certPath, + KeyFile: keyPath, + Log: discardLog(), } - httpAddr, httpsAddr, stop := runServeOnListeners(t, opts, tlsConfig) + httpAddr, httpsAddr, stop := runServeOnListeners(t, dc, opts, tlsConfig) defer stop() // HTTP path: plain request works. diff --git a/internal/ops/net_who.go b/internal/ops/net_who.go index 252f25f..98e26c5 100644 --- a/internal/ops/net_who.go +++ b/internal/ops/net_who.go @@ -1,162 +1,73 @@ package ops import ( - "bytes" "context" - "encoding/json" + "errors" "fmt" - "io" - "net/http" - "net/url" - "time" + "os" + "path/filepath" "github.com/invopop/gobl" "github.com/invopop/gobl/dsig" - "github.com/invopop/gobl/head" "github.com/invopop/gobl/net" - "github.com/invopop/gobl/org" ) -// schemeRewriteFetcher rewrites well-known https:///... URLs to -// the given http:// base so --insecure mode reuses net.Client logic -// (which always builds https URLs) over plain HTTP. -type schemeRewriteFetcher struct { - base string // e.g. http://acme.example - inner net.Fetcher -} - -func (s *schemeRewriteFetcher) Fetch(ctx context.Context, raw string) ([]byte, error) { - u, err := url.Parse(raw) - if err != nil { - return s.inner.Fetch(ctx, raw) - } - bu, err := url.Parse(s.base) - if err == nil { - u.Scheme = bu.Scheme - u.Host = bu.Host - raw = u.String() +// netClientFor builds a net.Client authenticated as from. +func netClientFor(from net.Address, key *dsig.PrivateKey, fetcher net.Fetcher) *net.Client { + if fetcher == nil { + fetcher = net.NewHTTPFetcher() } - return s.inner.Fetch(ctx, raw) + return net.NewClient( + net.WithFetcher(fetcher), + net.WithIdentity(from, key), + ) } -const netWhoTimeout = 10 * time.Second - // NetWhoOptions configures NetWho. type NetWhoOptions struct { Target net.Address // domain being queried - From net.Address // caller's GOBL Net address (signs the request) + From net.Address // caller's GOBL Net address (mints the request token) FromKey *dsig.PrivateKey // caller's signing key - FromParty *org.Party // caller's party, sent as the request document - Insecure bool // query over http:// and permit host:port - Fetcher net.Fetcher // optional (for /keys); defaults to net.NewHTTPFetcher() - Client *http.Client // optional (for POST /who); defaults to 10s timeout + ConfigDir string // optional; records deferred (202) requests under //who-pending/ + Fetcher net.Fetcher // optional; defaults to net.NewHTTPFetcher() } -// NetWho performs an authenticated GOBL Net party exchange: it POSTs a -// signed request envelope (the caller's party, iss=gobl:from, -// aud=gobl:target) to the target's /who endpoint, verifies the response -// is signed by the target (iss=gobl:target) and bound to the caller -// (aud=gobl:from), and returns the verified envelope. Callers that -// only need the party can read it via `env.Extract().(*org.Party)`; -// returning the whole envelope preserves the signature and signed -// `iss`/`aud`/`ts` so the artifact remains independently verifiable. +// NetWho performs an authenticated GOBL Net identity lookup: it GETs +// the target's /who endpoint with a request token minted from the +// --from identity and verifies the returned party envelope (signature, +// issuer, document type) via net.Client.Who. On a 202 the request was +// recorded by the owner for deferred disclosure: the pending state is +// noted under the caller's config directory — so the inbox accepts the +// party envelope the owner may deliver later — and net.ErrPending is +// returned for the caller to handle. func NetWho(ctx context.Context, opts *NetWhoOptions) (*gobl.Envelope, error) { if opts.Target == "" { return nil, gobl.ErrInput.WithReason("target address is required") } - if opts.From == "" || opts.FromKey == nil || opts.FromParty == nil { - return nil, gobl.ErrInput.WithReason("a --from identity (key + party) is required to authenticate the request") - } - - scheme := "https" - if opts.Insecure { - scheme = "http" - } - base := scheme + "://" + string(opts.Target) - - // Build and sign the request envelope: iss=from, aud=target. - reqEnv, err := gobl.Envelop(opts.FromParty) - if err != nil { - return nil, fmt.Errorf("net who: build request: %w", err) - } - if err := reqEnv.Sign(opts.FromKey, head.WithIssuer(opts.From.URI()), head.WithAudience(opts.Target.URI())); err != nil { - return nil, fmt.Errorf("net who: sign request: %w", err) - } - reqBody, err := json.Marshal(reqEnv) - if err != nil { - return nil, fmt.Errorf("net who: encode request: %w", err) + if opts.From == "" || opts.FromKey == nil { + return nil, gobl.ErrInput.WithReason("a --from identity (with its private key) is required to authenticate the request") } - client := opts.Client - if client == nil { - client = &http.Client{Timeout: netWhoTimeout} - } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+net.WhoPath, bytes.NewReader(reqBody)) - if err != nil { - return nil, fmt.Errorf("net who: %w", err) - } - httpReq.Header.Set("Content-Type", "application/json") - resp, err := client.Do(httpReq) + client := netClientFor(opts.From, opts.FromKey, opts.Fetcher) + env, err := client.Who(ctx, opts.Target) if err != nil { - return nil, fmt.Errorf("net who: %w", err) - } - defer resp.Body.Close() //nolint:errcheck - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, netInboxMaxBody)) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("net who: %w: HTTP %d: %s", net.ErrFetchFailed, resp.StatusCode, bytes.TrimSpace(respBody)) - } - - respEnv := new(gobl.Envelope) - if err := json.Unmarshal(respBody, respEnv); err != nil { - return nil, fmt.Errorf("net who: invalid /who response: %w", err) - } - if !respEnv.Signed() { - return nil, fmt.Errorf("net who: /who response is not signed") - } - - // Verify the response is signed by the target, bound to us. The - // fetcher is wrapped so /key/ URLs honour --insecure by being - // rewritten to the http:// base. - fetcher := opts.Fetcher - if fetcher == nil { - fetcher = net.NewHTTPFetcher() - } - if opts.Insecure { - fetcher = &schemeRewriteFetcher{base: base, inner: fetcher} - } - verifyClient := net.NewClient(net.WithFetcher(fetcher)) - - wantIss := opts.Target.URI() - wantAud := opts.From.URI() - verified := false - for _, sig := range respEnv.Signatures { - p, perr := head.SignedPayload(sig) - if perr != nil || p.Iss != wantIss { - continue - } - pubKey, kerr := verifyClient.FetchKey(ctx, opts.Target, sig.KeyID()) - if kerr != nil { - continue - } - // VerifySignature enforces the key's validity window via - // head.Header.Verify, so no extra Allows call is needed here. - if respEnv.VerifySignature(sig, pubKey) != nil { - continue + if errors.Is(err, net.ErrPending) && opts.ConfigDir != "" { + if werr := recordWhoPending(opts.ConfigDir, opts.From, opts.Target); werr != nil { + return nil, fmt.Errorf("net who: record pending request: %w", werr) + } } - if p.Aud != "" && p.Aud != wantAud { - return nil, fmt.Errorf("net who: response audience mismatch (got %q, want %q)", p.Aud, wantAud) - } - verified = true - break - } - if !verified { - return nil, fmt.Errorf("net who: response not signed by %s with a published key", wantIss) + return nil, err } + return env, nil +} - // Sanity check: the protocol defines /who responses to wrap an - // org.Party. Other document types indicate a misbehaving peer. - if _, ok := respEnv.Extract().(*org.Party); !ok { - return nil, fmt.Errorf("net who: /who response document is not an org.Party") - } - return respEnv, nil +// recordWhoPending marks an outbound who request as deferred (202) so +// the domain's inbox will accept the party envelope the target may +// deliver later without requiring endorsement. +func recordWhoPending(configDir string, from, target net.Address) error { + dir := filepath.Join(configDir, string(from), "who-pending") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, string(target)), nil, 0o644) } diff --git a/internal/ops/net_who_test.go b/internal/ops/net_who_test.go index 0710fdb..4ff403b 100644 --- a/internal/ops/net_who_test.go +++ b/internal/ops/net_who_test.go @@ -3,11 +3,14 @@ package ops import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "net/url" "os" + "path/filepath" "testing" + "time" "github.com/invopop/gobl" "github.com/invopop/gobl/dsig" @@ -18,63 +21,94 @@ import ( "github.com/stretchr/testify/require" ) -// hostRewrite routes every request to base, regardless of the request's -// host, so a test can use a real domain identity while talking to an -// httptest server. -type hostRewrite struct{ base string } +// testRewriteFetcher rewrites every well-known https URL to a fixed +// test-server base so ops-layer tests can exercise real HTTP without +// TLS. Test-only: production clients always dial the address itself. +type testRewriteFetcher struct { + base string + inner *net.HTTPFetcher +} -func (h hostRewrite) RoundTrip(req *http.Request) (*http.Response, error) { - u, _ := url.Parse(h.base) - req.URL.Scheme = u.Scheme - req.URL.Host = u.Host - return http.DefaultTransport.RoundTrip(req) +func (f *testRewriteFetcher) rewrite(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + bu, err := url.Parse(f.base) + if err != nil { + return raw + } + u.Scheme = bu.Scheme + u.Host = bu.Host + return u.String() } -func TestNetWho(t *testing.T) { - configDir := t.TempDir() - initTestDomain(t, configDir, "acme.example") - dc := domainConfigFor(configDir, "acme.example") +func (f *testRewriteFetcher) Fetch(ctx context.Context, raw string, header http.Header) ([]byte, error) { + return f.inner.Fetch(ctx, f.rewrite(raw), header) +} + +func (f *testRewriteFetcher) Post(ctx context.Context, raw string, body []byte, header http.Header) error { + return f.inner.Post(ctx, f.rewrite(raw), body, header) +} + +// routeTo returns a fetcher that rewrites every well-known URL to the +// given httptest server, permitting loopback dials. +func routeTo(srvURL string) net.Fetcher { + return &testRewriteFetcher{ + base: srvURL, + inner: &net.HTTPFetcher{Client: &http.Client{Timeout: 5 * time.Second}}, + } +} + +// domainPrivateKey reads the private key InitDomain generated for a +// domain under configDir. +func domainPrivateKey(t *testing.T, configDir, domain string) *dsig.PrivateKey { + t.Helper() + dc := domainConfigFor(configDir, domain) + privBytes, err := os.ReadFile(dc.PrivateKeyFile) + require.NoError(t, err) + key := new(dsig.PrivateKey) + require.NoError(t, json.Unmarshal(privBytes, key)) + return key +} - // The served domain's client resolves the caller's per-key endpoint - // to verify the incoming request. - serverClient := net.NewClient(net.WithFetcher(&mapFetcher{data: map[string][]byte{ +// serveDomainFrom stands up the handler for an InitDomain-scaffolded +// domain whose client resolves the peer's published key. +func serveDomainFrom(t *testing.T, configDir, domain string) *httptest.Server { + t.Helper() + dc := domainConfigFor(configDir, domain) + h, err := buildDomainHandler(dc, serveOpts(&mapFetcher{data: map[string][]byte{ net.Address(testPeerDomain).KeyURL(testPeerKey.ID()): jwkBytes(t, testPeerKey), }})) - handler, err := buildDomainHandler(dc, serverClient, discardLog()) require.NoError(t, err) - srv := httptest.NewServer(handler) - defer srv.Close() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} - // Read the freshly-generated private key for acme.example so the - // test fetcher can serve its public counterpart at /keys/. - privBytes, err := os.ReadFile(dc.PrivateKeyFile) - require.NoError(t, err) - targetKey := new(dsig.PrivateKey) - require.NoError(t, json.Unmarshal(privBytes, targetKey)) +func TestNetWho(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "acme.example") + srv := serveDomainFrom(t, configDir, "acme.example") env, err := NetWho(context.Background(), &NetWhoOptions{ - Target: "acme.example", - From: net.Address(testPeerDomain), - FromKey: testPeerKey, - FromParty: &org.Party{Name: "Peer"}, - Insecure: true, - // POSTs to http://acme.example/... but routed to the test server. - Client: &http.Client{Transport: hostRewrite{base: srv.URL}}, - // Resolves the target's per-key endpoint (the served domain's - // published key). - Fetcher: &mapFetcher{data: map[string][]byte{ - "http://acme.example" + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), - }}, + Target: "acme.example", + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + // Routes every well-known URL — the who lookup and the + // target's per-key endpoint — to the test server. + Fetcher: routeTo(srv.URL), }) require.NoError(t, err) require.NotNil(t, env) require.True(t, env.Signed(), "returned envelope retains the target's signature") - // The signed payload binds the response to the caller. + // The static who response is the target's self-signature, not + // bound to any caller. p, err := head.SignedPayload(env.Signatures[0]) require.NoError(t, err) - assert.Equal(t, net.Address("acme.example").URI(), p.Iss) - assert.Equal(t, net.Address(testPeerDomain).URI(), p.Aud) + assert.Equal(t, net.Address("acme.example").String(), p.Iss) + assert.Empty(t, p.Aud) party, ok := env.Extract().(*org.Party) require.True(t, ok) @@ -83,6 +117,30 @@ func TestNetWho(t *testing.T) { assert.Equal(t, "gobl:acme.example", party.Endpoints[0].URI.String()) } +func TestNetWhoPending(t *testing.T) { + configDir := t.TempDir() + initTestDomain(t, configDir, "acme.example") + // Mark the served domain for deferred disclosure. + dc := domainConfigFor(configDir, "acme.example") + require.NoError(t, os.WriteFile(dc.WhoDeferredFile, nil, 0o644)) + srv := serveDomainFrom(t, configDir, "acme.example") + + callerDir := t.TempDir() + _, err := NetWho(context.Background(), &NetWhoOptions{ + Target: "acme.example", + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + ConfigDir: callerDir, + Fetcher: routeTo(srv.URL), + }) + require.Error(t, err) + assert.True(t, errors.Is(err, net.ErrPending)) + + // The pending state was recorded so the caller's inbox will accept + // the party envelope the target may deliver later. + assert.FileExists(t, filepath.Join(callerDir, testPeerDomain, "who-pending", "acme.example")) +} + func TestNetWhoMissingFrom(t *testing.T) { _, err := NetWho(context.Background(), &NetWhoOptions{Target: "acme.example"}) require.Error(t, err) @@ -105,13 +163,10 @@ func staticHandler(status int, body string) http.Handler { func newWhoOpts(t *testing.T, target string, srvURL string) *NetWhoOptions { t.Helper() return &NetWhoOptions{ - Target: net.Address(target), - From: net.Address(testPeerDomain), - FromKey: testPeerKey, - FromParty: &org.Party{Name: "Peer"}, - Insecure: true, - Client: &http.Client{Transport: hostRewrite{base: srvURL}}, - Fetcher: &mapFetcher{data: map[string][]byte{}}, + Target: net.Address(target), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + Fetcher: routeTo(srvURL), } } @@ -128,7 +183,7 @@ func TestNetWhoInvalidResponseJSON(t *testing.T) { defer srv.Close() _, err := NetWho(context.Background(), newWhoOpts(t, "acme.example", srv.URL)) require.Error(t, err) - assert.Contains(t, err.Error(), "invalid /who response") + assert.Contains(t, err.Error(), "invalid who envelope") } func TestNetWhoUnsignedResponse(t *testing.T) { @@ -140,69 +195,65 @@ func TestNetWhoUnsignedResponse(t *testing.T) { } // TestNetWhoResponseWrongIssuer: response is signed but by the peer -// (i.e., not by the target). Verification loop finds no matching iss. +// (i.e., not by the target) — the verified issuer does not match the +// fetched address. func TestNetWhoResponseWrongIssuer(t *testing.T) { - // Build a signed envelope where iss/aud are reversed from what NetWho - // expects to find on a /who response. env, err := gobl.Envelop(&org.Party{Name: "Wrong"}) require.NoError(t, err) - // iss = peer (caller) — but NetWho expects iss=target. - require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).URI()), head.WithAudience(net.Address(testServeDomain).URI()))) + require.NoError(t, env.Sign(testPeerKey, head.WithIssuer(net.Address(testPeerDomain).String()))) body, err := json.Marshal(env) require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == net.KeyPath(testPeerKey.ID()) { + _, _ = w.Write(jwkBytes(t, testPeerKey)) + return + } _, _ = w.Write(body) })) defer srv.Close() _, err = NetWho(context.Background(), newWhoOpts(t, testServeDomain, srv.URL)) require.Error(t, err) - assert.Contains(t, err.Error(), "not signed by") + assert.Contains(t, err.Error(), "does not match address") } -// TestNetWhoTransportError exercises the default http.Client + default -// Fetcher branches plus the client.Do error path. Uses port 1 which is -// closed on a non-root host. +// TestNetWhoTransportError exercises the transport error path. Uses +// port 1 which is closed on a non-root host. func TestNetWhoTransportError(t *testing.T) { _, err := NetWho(context.Background(), &NetWhoOptions{ - Target: net.Address("127.0.0.1:1"), - From: net.Address(testPeerDomain), - FromKey: testPeerKey, - FromParty: &org.Party{Name: "Peer"}, - Insecure: true, - // Client + Fetcher omitted to exercise the default branches. + Target: net.Address(testServeDomain), + From: net.Address(testPeerDomain), + FromKey: testPeerKey, + Fetcher: routeTo("http://127.0.0.1:1"), }) require.Error(t, err) } -// TestNetWhoResponseAudMismatch: response is correctly signed by the -// target but the aud names someone else. -func TestNetWhoResponseAudMismatch(t *testing.T) { +// TestNetWhoResponseAudBound: a who response bound to a caller (aud +// set) is not a conforming public identity and is rejected. +func TestNetWhoResponseAudBound(t *testing.T) { configDir := t.TempDir() initTestDomain(t, configDir, testServeDomain) - dc := domainConfigFor(configDir, testServeDomain) - privBytes, err := os.ReadFile(dc.PrivateKeyFile) - require.NoError(t, err) - targetKey := new(dsig.PrivateKey) - require.NoError(t, json.Unmarshal(privBytes, targetKey)) + targetKey := domainPrivateKey(t, configDir, testServeDomain) env, err := gobl.Envelop(&org.Party{Name: "X"}) require.NoError(t, err) - require.NoError(t, env.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).URI()), head.WithAudience(net.Address("other.example").URI()))) + require.NoError(t, env.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).String()), head.WithAudience(net.Address(testPeerDomain).String()))) body, err := json.Marshal(env) require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == net.KeyPath(targetKey.ID()) { + _, _ = w.Write(jwkBytes(t, targetKey)) + return + } _, _ = w.Write(body) })) defer srv.Close() - opts := newWhoOpts(t, testServeDomain, srv.URL) - opts.Fetcher = &mapFetcher{data: map[string][]byte{ - "http://" + testServeDomain + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), - }} - _, err = NetWho(context.Background(), opts) + + _, err = NetWho(context.Background(), newWhoOpts(t, testServeDomain, srv.URL)) require.Error(t, err) - assert.Contains(t, err.Error(), "audience mismatch") + assert.Contains(t, err.Error(), "audience-bound") } // TestNetWhoResponseDocNotParty: response is correctly signed by the @@ -210,62 +261,25 @@ func TestNetWhoResponseAudMismatch(t *testing.T) { func TestNetWhoResponseDocNotParty(t *testing.T) { configDir := t.TempDir() initTestDomain(t, configDir, testServeDomain) - dc := domainConfigFor(configDir, testServeDomain) - privBytes, err := os.ReadFile(dc.PrivateKeyFile) - require.NoError(t, err) - targetKey := new(dsig.PrivateKey) - require.NoError(t, json.Unmarshal(privBytes, targetKey)) + targetKey := domainPrivateKey(t, configDir, testServeDomain) // Wrap a non-party document. wrap, err := gobl.Envelop(&org.Endpoint{URI: "gobl:x.example"}) require.NoError(t, err) - require.NoError(t, wrap.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).URI()), head.WithAudience(net.Address(testPeerDomain).URI()))) + require.NoError(t, wrap.Sign(targetKey, head.WithIssuer(net.Address(testServeDomain).String()))) body, err := json.Marshal(wrap) require.NoError(t, err) - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == net.KeyPath(targetKey.ID()) { + _, _ = w.Write(jwkBytes(t, targetKey)) + return + } _, _ = w.Write(body) })) defer srv.Close() - opts := newWhoOpts(t, testServeDomain, srv.URL) - opts.Fetcher = &mapFetcher{data: map[string][]byte{ - "http://" + testServeDomain + net.KeyPath(targetKey.ID()): jwkBytes(t, targetKey), - }} - _, err = NetWho(context.Background(), opts) + _, err = NetWho(context.Background(), newWhoOpts(t, testServeDomain, srv.URL)) require.Error(t, err) - assert.Contains(t, err.Error(), "not an org.Party") + assert.True(t, errors.Is(err, net.ErrPartyMissing)) } - -// TestSchemeRewriteFetcher confirms scheme/host rewriting plus error -// passthrough for malformed input. -func TestSchemeRewriteFetcher(t *testing.T) { - t.Run("rewrites scheme + host", func(t *testing.T) { - var seen string - inner := stubFetcher(func(_ context.Context, u string) ([]byte, error) { - seen = u - return []byte("ok"), nil - }) - f := &schemeRewriteFetcher{base: "http://localhost:1234", inner: inner} - body, err := f.Fetch(context.Background(), "https://acme.example/.well-known/gobl/keys/abc") - require.NoError(t, err) - assert.Equal(t, "ok", string(body)) - assert.Equal(t, "http://localhost:1234/.well-known/gobl/keys/abc", seen) - }) - - t.Run("invalid raw URL falls through unchanged", func(t *testing.T) { - var seen string - inner := stubFetcher(func(_ context.Context, u string) ([]byte, error) { - seen = u - return []byte("x"), nil - }) - f := &schemeRewriteFetcher{base: "http://localhost:1234", inner: inner} - _, err := f.Fetch(context.Background(), "://broken") - require.NoError(t, err) - assert.Equal(t, "://broken", seen) - }) -} - -type stubFetcher func(context.Context, string) ([]byte, error) - -func (s stubFetcher) Fetch(ctx context.Context, u string) ([]byte, error) { return s(ctx, u) } diff --git a/internal/ops/sign.go b/internal/ops/sign.go index c1d88bc..346c500 100644 --- a/internal/ops/sign.go +++ b/internal/ops/sign.go @@ -4,7 +4,6 @@ import ( "context" "github.com/invopop/gobl" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/head" ) @@ -14,11 +13,11 @@ type SignOptions struct { *ParseOptions PrivateKey *dsig.PrivateKey - // Issuer is the signer's verifiable GOBL Net address (a gobl: URI) and - // Audience is the optional GOBL Net audience the signature is bound to; - // either may be empty. - Issuer cbc.URI - Audience cbc.URI + // Issuer is the signer's verifiable GOBL Net address (a bare + // FQDN) and Audience is the optional GOBL Net audience the + // signature is bound to; either may be empty. + Issuer string + Audience string } // Sign parses a GOBL document into an envelope, performs calculations, diff --git a/internal/ops/sign_test.go b/internal/ops/sign_test.go index 940fd2a..b4f4ccc 100644 --- a/internal/ops/sign_test.go +++ b/internal/ops/sign_test.go @@ -9,7 +9,6 @@ import ( "time" "github.com/invopop/gobl" - "github.com/invopop/gobl/cbc" "github.com/invopop/gobl/dsig" "github.com/invopop/gobl/head" "github.com/stretchr/testify/assert" @@ -21,7 +20,7 @@ const noteMessageJSON = `{"$schema":"https://gobl.org/draft-0/note/message","con // signIss signs the note message with the given iss/aud and round-trips // the envelope through JSON (the signed payload is read after parse). -func signIss(t *testing.T, iss, aud cbc.URI) *gobl.Envelope { +func signIss(t *testing.T, iss, aud string) *gobl.Envelope { t.Helper() env, err := Sign(context.Background(), &SignOptions{ ParseOptions: &ParseOptions{Input: strings.NewReader(noteMessageJSON)}, @@ -38,12 +37,12 @@ func signIss(t *testing.T, iss, aud cbc.URI) *gobl.Envelope { } func TestSignWithIss(t *testing.T) { - env := signIss(t, "gobl:billing.invopop.com", "gobl:acme.example") + env := signIss(t, "billing.invopop.com", "acme.example") require.True(t, env.Signed()) p, err := head.SignedPayload(env.Signatures[0]) require.NoError(t, err) - assert.Equal(t, cbc.URI("gobl:billing.invopop.com"), p.Iss) - assert.Equal(t, cbc.URI("gobl:acme.example"), p.Aud) + assert.Equal(t, "billing.invopop.com", p.Iss) + assert.Equal(t, "acme.example", p.Aud) } func TestSignWithoutIss(t *testing.T) { diff --git a/internal/ops/verify_test.go b/internal/ops/verify_test.go index d3e870c..587e1e5 100644 --- a/internal/ops/verify_test.go +++ b/internal/ops/verify_test.go @@ -113,7 +113,7 @@ func TestVerifyRemote(t *testing.T) { }, }, PrivateKey: privateKey, - Issuer: addr.URI(), + Issuer: addr.String(), }) require.NoError(t, err) body, err := json.Marshal(env)