An official developer portal of the Government of the Republic of Zambia ยท Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework

Trust: signed messages, keys and JWKS

A hub and its nodes are separate systems, often run by different teams, talking across a trust boundary. Nothing they send each other is trusted because of where it came from on the network. Every message is signed, bound to its context, single use and versioned. In this lesson you learn how that works, which keys are involved, how to rotate them, and how citizen assertions carry identity to a node.

Discovery documents

Every instance publishes two public documents:

GET /.well-known/gsb-federation
{
  "contract": "v1",
  "role": "node",
  "nodeKey": "example-mda",
  "jwksUri": "/.well-known/jwks.json",
  "api": "/fed/v1",
  "signing": { "alg": "ES256", "header": "X-GSB-Fed-Signature", "messageTtlSeconds": 300 },
  "hub": { "nodeKey": "hub", "url": "https://fed.<domain>" }
}

GET /.well-known/jwks.json        the public ES256 keys this instance signs with

Neither document holds anything secret or anything about cases or people. The JWKS contains only federation signing keys, never a session-token key.

Who trusts whom

  • A node trusts its hub from configuration: HUB_URL, HUB_NODE_KEY and optionally pinned hub keys

    (HUB_JWKS).

  • The hub trusts the nodes in its registry (System administration โ†’ Federation): each entry holds the NODE_KEY,

    name, ministries served, base URL, JWKS URL or pinned keys, an optional certificate fingerprint, scopes, the citizen claims it may receive, and a status. Every registry change needs a second administrator and is audited. Only active peers are trusted, and a suspension takes effect on the next message.

Scopes say what a peer may ask, for example ping, catalogue:write, submissions:write, events:write, applicant:act, documents:read, status:read. A scope never decides what a citizen may do: the node's own policy decision point decides on its own records. A hub's "allowed" is never a permission.

The signature

Every /fed/v1 request carries a detached compact JWS in X-GSB-Fed-Signature:

X-GSB-Fed-Signature: <base64url(protected header)>..<base64url(signature)>
signing input = base64url(protected header) "." base64url(canonical JSON of the body)

The canonical JSON has keys sorted at every depth and no white space; the sender sends exactly the bytes it signed. The protected header carries alg (only ES256), kid, typ (gsb-fed+jws), v (contract version 1), iss and aud (the sender's and receiver's NODE_KEY), iat, a single-use jti, the HTTP method and path (htm, htu), and the Idempotency-Key (idk) and federation id (fid) when present.

The receiver refuses, among others:

CodeWhy
FED_WRONG_AUDIENCEThe message was meant for another instance
FED_EXPIRED / FED_NOT_YET_VALIDOlder than 300 seconds, or more than 60 seconds in the future
FED_REPLAYThe same signed message delivered twice
FED_WRONG_TARGETMethod or path does not match the signature
FED_INVALIDThe body does not match its JSON Schema (unknown fields are refused)

Answers are signed too (typ gsb-fed-response+jws, carrying the request's jti and the status). The caller refuses an unsigned or mismatched answer with FED_RESPONSE_UNVERIFIED.

Transport is mTLS. Each instance has one certificate from the federation CA, used both as its server certificate and as its client certificate. With FED_MTLS_REQUIRED=1, a peer must present a CA-verified certificate whose CN is its NODE_KEY, or exactly the pinned certificate when the registry pins one.

Idempotency and retries

Every write needs an Idempotency-Key. A retry is a new signed message with the same key:

  • after the first attempt finished: the first answer again, with Idempotent-Replayed: true;
  • while the first runs: 409 IDEMPOTENCY_IN_PROGRESS with Retry-After;
  • the same key with another body: 422 IDEMPOTENCY_KEY_REUSED.

The federation client retries network errors and 502, 503 and 504 with a fresh signature and the same key. Because of this, a retry after a lost answer never makes a second case or a second payment.

Correlation and audit

X-GSB-Federation-Id (inside the signature as fid) and traceparent travel on every hop. The sender audits every write it sends (federation.sent), the receiver every write it accepts (federation.received) and every refusal (federation.refused, with the code and claimed issuer, never the body). One federation id finds a message in both audit trails.

Signing keys and rotation

Each instance has its own ES256 key in the secret store, as FEDERATION_SIGNING_KEY with a key id FEDERATION_SIGNING_KID. Generate it where it will live and never e-mail it. Production refuses to start with a development key.

Rotation needs no downtime, because messages live 300 seconds and peers refetch the JWKS at once on an unknown key id:

  1. Generate the new key under a new key id.
  2. Publish first: add its public JWK to FEDERATION_PUBLISHED_KEYS and roll out. The JWKS now lists both.
  3. Switch: make the new key the signing key, keep the old public key published, roll out.
  4. After at least 15 minutes, remove the old key and roll out.
  5. If the hub pins this node's keys, propose registry changes (with a second administrator) before step 3 and after

    step 4.

For a compromised key, switch at once without the overlap, suspend the node on the hub until it is done, and open an incident.

Citizen assertions

ZamPass sign-in happens only at the hub. To act for a citizen on a node, the hub issues a citizen assertion: a compact JWS signed with the hub's federation key, typ gsb-citizen+jwt, valid for 120 seconds, single use at the node.

  • sub is a pairwise subject: different for each node, so no node learns the hub account id and two nodes cannot

    link a person.

  • acr is the assurance level (low, substantial, high); nrc_verified says whether the NRC was verified against

    the national register. A node treats a missing nrc_verified as false.

  • Name, contacts and NRC are released only when the node's registry entry (sharedClaims) allows them and the

    call asks for them. A new node gets only name.

The node verifies issuer, key, signature, audience, expiry, lifetime, assurance and single use, then maps the subject to a shadow applicant account. That session is always clamped to an applicant, whatever the account row says, so a hub assertion can never act as staff. Accounts are never linked by e-mail or by a self-declared NRC.

When a citizen signs out at the hub, the hub sends session.revoked to every node the citizen was asserted to, and those node sessions end on their next request.

Check your understanding

  • What do /.well-known/gsb-federation and /.well-known/jwks.json publish, and what do they never contain?
  • Name five members of the signature's protected header and what each binds the message to.
  • How does a retry differ from a replay, and what does the receiver answer to each?
  • Describe the order of a no-downtime signing-key rotation.
  • Why is the citizen's subject pairwise per node?
  • Why can a node session created from a citizen assertion never act as staff?

Sign in to record your progress.

Previous: Hub and nodes Next: Catalogue sync and relaying applications, payments and documents