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:
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 athttps://mcpg.cloud/configsis 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.
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:
| Example | Treat it like | |
|---|---|---|
| The address | mcpg+enc:https://config.example.com/c/0k3n8xq2vftr7bdyw5m1jhpz6c | a URL. On its own it decrypts nothing. |
| The secret | mcpg_sk_ + 52 characters | a 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:
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, andscript.hash.js, which exists to transmit it, and the two differ by one word in asrc. 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
nameif 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}andcred://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
# <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
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
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:
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:
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:
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:
| Address | Fetched |
|---|---|
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:
| Header | Value |
|---|---|
X-Mcpg-Version | version number, starting at 1 |
X-Mcpg-Expires-At | unix seconds UTC (0 for none) |
X-Mcpg-One-Time | true / false |
X-Mcpg-Kdf | hkdf-sha256 |
X-Mcpg-Kdf-Salt | 16 bytes, standard base64 |
X-Mcpg-Nonce | 12 bytes, standard base64 |
X-Mcpg-Aad | optional; 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 unlessMCPG_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>/ciphertextis 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
| Symptom | Cause |
|---|---|
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 failed | wrong 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 characters | truncated 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 burned | a one-time config that has already been consumed. No secret recovers it |
… and … are both set and hold different secrets — unset one | MCPG_CONFIG_SECRET_FILE and MCPG_CONFIG_SECRET disagree |
… is mode 0644; a file holding config secrets must be readable by its owner only | chmod 600 the MCPG_CONFIG_SECRETS_FILE map |
the host answered … without the x-mcpg-version header | that address is not a payload route — a /c/<id> page returns HTML |
asked … for version 7 and got version 5 | a pinned address, answered with a different version |
this config was published with pbkdf2-sha256 | a 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 container | the 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 reload | encrypted layers are boot snapshots — restart to re-fetch |
See also
- Configuration sources — the other
--configforms, how layers merge, and what hot-reloads. - Configuration reference — every config field.
mcpgCLI reference —--configand the environment variables on this page.- Publishing a config — the hosted store's wizard, on the MCPG Cloud docs.