Gateway
Gatewaybeta10 min

Encrypted remote config

Boot a gateway from a config the config host cannot read. The mcpg+enc source form, the mcpg-config-v1 construction, the ways to supply the secret and why they rank in that order, expiry and one-time reads, and the wire contract for running your own store.

A gateway can boot from a config that lives somewhere else and is encrypted before it gets there. You publish a config, get back an address and a secret, and hand the gateway both — the address on the command line, the secret through a channel of your choosing:

bash
export MCPG_CONFIG_SECRET_FILE=/run/secrets/mcpg-config-secret
mcpg --config 'mcpg+enc:https://config.example.com/c/<id>'

The host that served that address stores ciphertext and nothing else. It has no key column, no decrypt path, and was never given the secret in any form. The key is derived where the config was written and again where the gateway boots; between those two points the config is opaque — to the host's operator, to anyone holding a database dump, and to anyone who intercepts the fetch.

This is the layer for a config you do not want to commit, mount, or paste into a CI variable: a fleet base config with real hostnames in it, a config handed to a customer's gateway, a config an agent wrote for you and you want to run once. For the ordinary unencrypted forms — files, https:// URLs, inline base64 — see configuration sources; everything on this page is one more --config layer that merges the same way.

The gateway half of this ships in mcpg. The hosted store at https://mcpg.cloud/configs is not open to the public yet. The address form is not tied to it: a gateway will boot from any host, at any path prefix, that answers the payload contract at the end of this page.

The shape of it

Three steps, and only the middle one involves a config host.

1. Write the config and validate it locally. An encrypted config is an ordinary gateway config; nothing about it changes.

bash
mcpg config check gateway.yaml

2. Publish it. The config is encrypted where it was written, and the ciphertext is uploaded. You get back two things:

ExampleTreat it like
The addressmcpg+enc:https://config.example.com/c/0k3n8xq2vftr7bdyw5m1jhpz6ca URL. On its own it decrypts nothing.
The secretmcpg_sk_ + 52 charactersa private key. It is shown once.

3. Boot. The gateway fetches the ciphertext over one ordinary unauthenticated GET, derives the key from the secret you supplied, and opens it in memory. The plaintext is never written to disk.

What the guarantee is

The construction is named mcpg-config-v1, and it is the same on both sides:

text
secret  32 random bytes, written as mcpg_sk_ + 52 Crockford base32 characters
key     HKDF-SHA256(ikm = secret, salt = kdf_salt, info = "mcpg-config-v1/key")
aad     "mcpg-config-v1|{id}|{version}|{expires_at}|{one_time}|{kdf_salt}"
ct      AES-256-GCM(key, nonce[12], aad, plaintext) || tag[16]

Two properties follow, and they are the reasons to use this rather than a config URL with a bearer token in front of it.

The host cannot read the config. It holds the blob, the nonce, the KDF parameters a client needs to re-derive its own key, and two SHA-256 digests that stand in for an account. The secret is not the key: three values come out of the same HKDF under three different labels — the AES key, an edit token, and a burn receipt — and the host is only ever given digests of the last two. A value the host has seen is never a value that decrypts.

The host cannot rewrite the terms either. Everything a reader acts on is inside the AEAD tag. Moving a blob onto another config's id, rewinding a version under a different number, extending an expiry, flipping a one-time config to permanent, or swapping in a salt of its own all fail GCM authentication rather than quietly booting something the publisher never wrote. The gateway builds that string from the id in the address it fetched, never the id in the response, so a host that answers one config's address with another config's genuinely authentic blob fails the tag instead of booting the wrong config under the right label.

What it does not protect

Say this part out loud before you build on it.

  • Anyone holding the secret. The secret is full access: read the config, and re-publish it. It cannot be rotated in place — there is no key to rotate to. If one leaks, publish a new config and delete the old one. Send the address and the secret through two different channels.
  • The plaintext where it was written. Encryption starts at publish time. The editor you wrote the config in, the file you kept, the clipboard, the browser tab — all still hold plaintext.
  • A page that reports its own URL fragment. The secret rides in the fragment precisely because browsers never send it to a server — but a script running in the page can read it. Analytics is the usual way this happens: Plausible ships script.js, which never transmits the fragment, and script.hash.js, which exists to transmit it, and the two differ by one word in a src. Swapping them looks like a routing fix. If you host a page that can hold a config link — a redeem page, a publish screen, a console taking a hand-off — keep hash-reporting analytics off it, and prefer an event that carries a path rather than a URL. A key that reaches an analytics database is in every backup of it, and there is no key to rotate to.
  • Availability. The host can refuse to serve, or delete. Client-side encryption buys confidentiality and integrity, never uptime. A gateway that must boot without reaching a config host needs a local layer.
  • Rollback. A host can answer "latest" with an older, genuinely authentic version, and no tag can see that — the old blob is correct for its own version number. Pinning the version in the address is the complete defence; see versions below.
  • Metadata. A dump still reveals approximate size, publish and update timing, read counts, how many versions a config has been through, whether it is one-time, and the optional display name if one was set. None of it yields content.
  • Secrets inside the config. This is confidentiality for the config, not a vault. A config should still carry ${env.VAR} and cred:// references rather than literal credentials, exactly as an unencrypted one does — see configuration sources. Encryption at rest is not a reason to inline a password.

Giving the gateway the secret

Four ways to hand it over, best first. That is a preference ranking, not the resolution order — the gateway takes a #k= fragment first when the address carries one, then MCPG_CONFIG_SECRETS_FILE, then MCPG_CONFIG_SECRET_FILE / MCPG_CONFIG_SECRET, and only then prompts. First hit wins, and a miss is a hard error naming the config id — never a silent skip, and never a fallthrough to plaintext.

1. A file — preferred

bash
# <secret> is the value you were given at publish: mcpg_sk_ + 52 characters
mkdir -p ~/.config/mcpg
printf '%s' '<secret>' > ~/.config/mcpg/config.secret
chmod 600 ~/.config/mcpg/config.secret

export MCPG_CONFIG_SECRET_FILE="$HOME/.config/mcpg/config.secret"
mcpg --config 'mcpg+enc:https://config.example.com/c/<id>'

A file, because a Kubernetes Secret mounts as exactly this — point MCPG_CONFIG_SECRET_FILE at /run/secrets/… and nothing else changes. The gateway does not enforce permissions on this one: a Secret volume is world-readable by default, and refusing to boot on the recommended shape would push you back to the env var this form exists to replace. chmod 600 is yours to get right on a host you own.

2. An environment variable

bash
export MCPG_CONFIG_SECRET='<secret>'
mcpg --config 'mcpg+enc:https://config.example.com/c/<id>'

Workable, and second for a reason: an environment variable is readable through /proc/<pid>/environ for the whole life of the process and lands in core dumps. The gateway does not try to scrub it afterwards — unsetting a variable while other threads may be reading the environment is undefined behaviour, and a documented exposure beats one papered over with a data race.

Setting MCPG_CONFIG_SECRET_FILE and MCPG_CONFIG_SECRET to different values is a boot error, not a precedence question. You meant one of them.

3. All in one — the leaky one

bash
mcpg --config 'mcpg+enc:https://config.example.com/c/<id>#k=<secret>'

This is the whole share link, and it works. It also puts the secret in your shell history and in ps, where every user on the host can read it — the gateway logs a warning about it on every boot. Use it for a one-off run on a machine you own, and not in a unit file, a Dockerfile, a CI job, or a Deployment.

The #k= fragment is split off before anything else touches the value, so the secret never reaches the fetch, an error message, or the mcpg.config.loaded audit event; a failed boot echoes the spec back with k=***. That limits the damage — it does not undo ps.

4. Nothing, on a terminal

With no secret configured and stdin attached to a TTY, the gateway prompts for it with echo off. Convenient for a laptop; a non-interactive boot gets the hard error instead.

More than one encrypted layer

--config is repeatable, and two encrypted layers need two secrets. Resolve which applies to which explicitly rather than trying every secret against every blob:

bash
cat > secrets.json <<'EOF'
{
  "0k3n8xq2vftr7bdyw5m1jhpz6c": "mcpg_sk_...",
  "1a2b3c4d5e6f7g8h9j0kmnpqrs": "mcpg_sk_..."
}
EOF
chmod 600 secrets.json

MCPG_CONFIG_SECRETS_FILE=$PWD/secrets.json mcpg \
  --config 'mcpg+enc:https://config.example.com/c/0k3n8xq2vftr7bdyw5m1jhpz6c' \
  --config 'mcpg+enc:https://config.example.com/c/1a2b3c4d5e6f7g8h9j0kmnpqrs'

This one is permission-checked: a secret map is written by hand on a workstation, and a world-readable one hands over every config at once. Anything other than owner-only fails the boot with the chmod command in the message.

In a container

The published image presets MCPG_CONFIG=/etc/mcpg/config.yaml and ships no file there, and a missing file layer aborts the boot — so a container booting only from an encrypted address has to clear that variable. Setting it empty does not work (an empty value is still one, empty, path); unset it:

bash
printf '%s' '<secret>' > mcpg-config.secret

docker run --rm -p 8787:8787 \
  -v "$PWD/mcpg-config.secret:/run/secrets/mcpg-config-secret:ro" \
  -e MCPG_CONFIG_SECRET_FILE=/run/secrets/mcpg-config-secret \
  --entrypoint /usr/bin/env \
  ghcr.io/mcpg-dev/mcpg:<version> \
  -u MCPG_CONFIG tini -- mcpg --config 'mcpg+enc:https://config.example.com/c/<id>'

In Kubernetes the same shape is a Secret volume mounted at /run/secrets/mcpg-config-secret, MCPG_CONFIG_SECRET_FILE pointing at it, and the encrypted address in args — with MCPG_CONFIG set to a path that exists, or the encrypted layer carrying the whole config.

Expiry, one-time reads, and a lost secret

Every config has an absolute expiry, chosen at publish time — the hosted store offers 1 hour, 1 day, 7 days (the default) and 30 days, with a 60-second floor and a 30-day ceiling. The expiry is inside the tag, so only the secret holder can move it, and moving it means publishing a new version. Once it passes, the host stops serving the ciphertext and the gateway refuses the payload even if it somehow has the bytes.

A one-time config is destroyed after a confirmed decrypt — and the gateway never triggers that. Burning takes a receipt derived from the secret, and the gateway derives only the AES key; a fetch alone never burns, so a truncated payload cannot destroy a config the user never got to read. The host's own backstop closes the loop after a handful of reads, or shortly after the first one.

The practical consequence: a one-time config is for handing a config to a person or an agent once, not for running a service. A Deployment that restarts — a rollout, a node drain, a crash loop — fetches again, and one of those fetches lands after the config is gone.

A lost secret is the end of that config. Nobody can reset it, resend it, or recover the plaintext: there is no key stored anywhere to recover it with. That is the same property that makes the guarantee true. Publish a new config.

Versions, reloads, and the audit trail

An encrypted layer is resolved once at boot and held in memory, exactly like an https:// or base64: layer. SIGHUP, the admin reload endpoint, and the config-file poller all reuse that boot snapshot — to pick up a changed remote config, restart the gateway.

Pin the version when you care which one you got:

bash
mcpg --config 'mcpg+enc:https://config.example.com/c/<id>/v/7'

A pinned address is only pinned if the answer is checked, and the gateway checks it: a host that answers version 7 with anything else fails the boot by name. This is the complete defence against rollback. When you cannot pin — a fleet that follows "latest" — MCPG_CONFIG_MIN_VERSION=7 is the partial one: it refuses anything published below that number, before a secret is even resolved.

The mcpg.config.loaded audit event records the layer's origin as mcpg+enc:<address>@v<n>. The version rides along, so a rollback a pinned address would have prevented is at least visible after the fact. The secret never does.

Running your own store

The half that carries the guarantee is open source and is the half you already run: the gateway derives the key and opens the payload locally, in src/config/encrypted.rs (the construction) and src/config/source.rs (the fetch) of the gateway repository. Nothing in the guarantee depends on trusting a particular host — which is the point, and also why pointing a gateway at your own store is a matter of serving one route.

An address resolves to a payload route, and any path prefix is preserved:

AddressFetched
mcpg+enc:https://h/c/<id>https://h/v1/configs/<id>/ciphertext
mcpg+enc:https://h/c/<id>/v/<n>https://h/v1/configs/<id>/versions/<n>/ciphertext
mcpg+enc:https://h/configs/c/<id>https://h/configs/v1/configs/<id>/ciphertext

The gateway sends Accept: application/octet-stream and expects the raw ciphertext (with its 16-byte tag appended) as the body, plus these headers:

HeaderValue
X-Mcpg-Versionversion number, starting at 1
X-Mcpg-Expires-Atunix seconds UTC (0 for none)
X-Mcpg-One-Timetrue / false
X-Mcpg-Kdfhkdf-sha256
X-Mcpg-Kdf-Salt16 bytes, standard base64
X-Mcpg-Nonce12 bytes, standard base64
X-Mcpg-Aadoptional; the AAD string, compared against the locally rebuilt one and reported as a construction mismatch if it differs

Decrypted, the body is a small JSON envelope — {"doc":"mcpg.config/v1", "format":"yaml","config":"…","name":"…"} — rather than raw YAML, because the config's display name has to be hidden from the host too, and the only place to hide it is inside the ciphertext.

Three things still apply to a store you run yourself:

  • HTTPS. mcpg+enc:http://… is refused unless MCPG_CONFIG_ALLOW_INSECURE_HTTP=1, same as any other remote layer. The tag means a network attacker cannot forge or alter a payload — but it can still serve an older authentic version, and watch which config id you fetch.
  • Never reuse a nonce. The key is stable for the life of a config, so "never twice" spans its whole history, not just its current version. A store that remembers every nonce it has issued turns a client retry bug into a loud error instead of a silent break; nonces are public, so it can police that while holding no secret.
  • A pinned read route. /v1/configs/<id>/versions/<n>/ciphertext is what makes the rollback defence above possible. Without it, "latest" is the only thing a caller can ask for.

https://mcpg.cloud/configs is MCPG's hosted instance of this contract; its server is not part of the open-source distribution.

Troubleshooting

SymptomCause
no config secret for <id>none of MCPG_CONFIG_SECRET_FILE / MCPG_CONFIG_SECRET / MCPG_CONFIG_SECRETS_FILE / #k= supplied one, and stdin is not a terminal
wrong config secret for <id>: AES-GCM authentication failedwrong secret — or a secret minted for a different config; the id, version, expiry, one-time flag and salt are authenticated together
a config secret is mcpg_sk_ and 52 characterstruncated or mistyped secret. The alphabet excludes i, l, o and u on purpose
config <id> expired at unix <t>the authenticated expiry passed; re-publish with a new one
config at … was burneda one-time config that has already been consumed. No secret recovers it
… and … are both set and hold different secrets — unset oneMCPG_CONFIG_SECRET_FILE and MCPG_CONFIG_SECRET disagree
… is mode 0644; a file holding config secrets must be readable by its owner onlychmod 600 the MCPG_CONFIG_SECRETS_FILE map
the host answered … without the x-mcpg-version headerthat address is not a payload route — a /c/<id> page returns HTML
asked … for version 7 and got version 5a pinned address, answered with a different version
this config was published with pbkdf2-sha256a passphrase-derived config. The gateway opens hkdf-sha256 only; decrypt it where the passphrase is
config file not found: after adding an encrypted layer in a containerthe image presets MCPG_CONFIG; see in a container
refusing to fetch config over plaintext http://use https://, or MCPG_CONFIG_ALLOW_INSECURE_HTTP=1 on a trusted network
A change to the published config did not take effect on reloadencrypted layers are boot snapshots — restart to re-fetch

See also