Secure calls between processes

Once a frond runs in another process, the receiver has to know who is calling it. On one machine that is free; across a network it is a signature.

Loopback or signed

In one process a call is a function call: nothing crosses, nothing needs proving. Split a frond out and the call becomes a payload, which can say anything. So a receiver establishes who calls it rather than accepting what the payload claims.

It binds 127.0.0.1 by default. Widening the address is one line, and it comes with a condition, checked at boot:

await serve(runner, { hosts: ['0.0.0.0'] })            // refuses to start on its own
await serve(runner, { hosts: ['0.0.0.0'], verify })    // verifies every call
127.0.0.1                        starts, nothing to configure
0.0.0.0 + FOUGERE_ROOT_KEY       starts, verifies every call
0.0.0.0 with no root             does not start, and says what to do
0.0.0.0 + allowUnsigned: true    starts — something in front already authenticated

A receiver reachable from outside, that establishes nothing, would believe whatever state it is handed — so it does not start. Refusing at boot rather than per call means you find out at deployment, not in production.

A caller may send up to maxBodyBytes (2 MiB unless fougere.config.ts says otherwise); a hop keeps 64 KiB above it for what it adds around the call — the trace, the identity, the state — so a body that passes in one process passes split.

What travels

Every call across a link carries an envelope, signed by the caller:

{ "method": "post.list",
  "params": { "params": {}, "query": {}, "input": null,
              "identity": "eyJhbGciOiJFZERTQSIs…" } }

The envelope carries the state — who is signed in, if anyone — and the receiver takes it from there rather than from the payload. Sending both would leave every reader downstream choosing between a proof and a claim about the same thing.

It binds the whole call: address, op, params, query and state by value, the body by digest. Signing the state alone would prove who without proving what, and a captured envelope could be replayed against another operation for as long as it stayed valid — post.list re-sent as post.delete, same signature, still good.

Two signatures, one public key

GRANT      the root says "this key is blog"   — issued once, at deployment
ENVELOPE   blog says "here is my whole call"  — signed per call

A receiver holds the root's public key and nothing else. It admits a frond it has never seen, and a frond granted tomorrow is admitted by a receiver deployed today without its configuration being touched. That is the difference between an authority and a list of known callers: the list has to be updated everywhere, and it goes stale.

Verification is offline — no service is joined, on any call or at any boot.

Issuing

fougere keys              # once per system — writes fougere.key, prints FOUGERE_ROOT_KEY
fougere grant blog        # once per frond that CALLS — prints FOUGERE_KEY and FOUGERE_GRANT

fougere keys is a command, not a service: it runs at deployment time and exits. Nothing stays alive, nothing is joined at boot. The root's private key is written to fougere.key (mode 600, added to .gitignore) and never printed — it signs grants, and a grant is the only thing that has to travel.

fougere grant prints and stores nothing. Re-running it issues a new key rather than showing the old one, which is what rotation is: run it again, redeploy that frond, and nobody else's configuration moves.

What a deployment injects

Three variables, no configuration key — a private key does not belong in a committed file. PEM or base64, both are read.

WhereWhat it is
FOUGERE_ROOT_KEYevery frond that answerspublic — safe in an image or a manifest
FOUGERE_KEYevery frond that callssecret, shown once
FOUGERE_GRANTbeside itthe root's word that this key is that frond

A Frond may hold either half or both: one that only answers has no key, one that only calls trusts no root, one in the middle does both. Trusting a root is asking to refuse — there is no second flag, because a deployment that names an authority and then accepts unsigned calls has said two things at once.

What a handler sees

Nothing new to write. The state arrives where it always did, so a collector reading ctx.state.user is unchanged — in process, and split. What is added is one field:

invocation.caller   // 'blog' — the frond that signed, as the root named it

It is top-level and not a key of state, and the reason decides how to read it: state travels unverified when no verifier is wired, so a caller living inside it would be forgeable exactly where nothing checks. Absent means not established, and there is no spelling for a claim. A sender clears it on every hop, so it names the last one: shop → catalog → billing has billing read catalog.

What this does not do

Authorization. A signature says who is calling, never what they may call. if (user.role !== 'admin') throw is still yours to write.

A mesh's job. Under a service mesh the sidecar terminated mTLS before your process saw the request, and asking for a second signature would redo what was just done a centimetre away. That is what allowUnsigned: true is for — spelled separately from everything else, so it can only ever mean "I thought about this".

Cross-language bodies. The body is bound by digest over its JSON text, which is the one place key order matters. A sender written in another language must emit the body the way it hashed it. Everything else is bound by value, which is what keeps a canonical-JSON dependency out of the wire.

The browser is not in this

A browser sits outside the topology and holds no key. It never signs, and nothing it sends is taken as identity: the host resolves the session server-side from the request's own cookie, and the payload's state is dropped. This page is about the link between two processes.

Built with Fougere and Nuxt, hosted on GitHub Pages.