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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 62 additions & 24 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,50 @@
`~/.config/gobl/<domain>/` (auto-generated keypair + a raw
`party.json` template with a pre-filled `gobl:<domain>` endpoint).
- `gobl net who <address> --from <domain>`: 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 <envelope> --to <domain> --from <domain>`:
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 <domain>` / `gobl net approve
<requester> --domain <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/<kid>`
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 `<domain>/` 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://<address>`. 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 `<domain>/` 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/<X>/` and stamps `iss=gobl:X` / `aud=gobl:Y` into
the signed payload.
`~/.config/gobl/<X>/` 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
Expand All @@ -35,30 +61,42 @@
`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`:
`<config>/<domain>/{private.jwk, keys/<kid>.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
`401 Unauthorized` (access log `inbox.rejected` carries
`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 <domain>`.
Expand Down
104 changes: 80 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>/` (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. |
Expand Down Expand Up @@ -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.
Expand All @@ -157,7 +159,9 @@ Scaffolds a per-domain identity under `~/.config/gobl/<domain>/`:
├── private.jwk ← active signing key (0600)
├── keys/<kid>.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/<requester>.json ← inbound /who requests recorded while deferred
├── who-pending/<target> ← outbound /who requests a peer answered 202
└── inbox/ ← envelopes received over /inbox land here
```

Expand All @@ -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/<X>/` and stamps `iss=gobl:X` /
`aud=gobl:Y` into the signed payload (alongside `uuid`, `dig`, and `iat`).
Signs with the key from `~/.config/gobl/<X>/` 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`

Expand All @@ -180,22 +185,41 @@ Two flags activate remote verification:

### `gobl net who <address> --from <domain>`

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 <envelope> --to <fqdn>`
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 <envelope> --to <fqdn> --from <domain>`

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 <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 <requester> --domain <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`

Expand All @@ -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 `<config-dir>/<domain>/` 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):

Expand All @@ -215,9 +239,33 @@ selects a single manual identity. ACME issues for every discovered domain.
- Every file in `keys/` MUST be named `<kid>.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 <fqdn>` (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 `<domain>/who-deferred` to answer `/who`
with `202` and record requests for `gobl net requests` /
`gobl net approve`.

**Ports:**

Expand Down Expand Up @@ -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=<gobl-error-key>` and (when present) `message=…`
and `faults=…`. With `--json` the same fields appear as a JSON object.
Expand Down
2 changes: 2 additions & 0 deletions cmd/gobl/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
76 changes: 76 additions & 0 deletions cmd/gobl/net_requests.go
Original file line number Diff line number Diff line change
@@ -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 <requester> --domain <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/<domain>/) 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 <requester>",
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/<domain>/) 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]),
})
}
Loading
Loading