Bundle Signing
The AuthzX agent pulls policy bundles from the control plane every ~30s and uses them to evaluate decisions locally. Until bundle signing was introduced (v1.1), nothing cryptographically tied a bundle's contents to AuthzX — a compromised proxy, a MITM on an unencrypted link, or a malicious sidecar could substitute a bundle that would grant access it shouldn't.
Bundle signing closes that gap. Every bundle response from the control plane carries an Ed25519 detached signature over the raw bundle bytes. The agent verifies the signature before loading the bundle. If verification fails, the agent rejects the bundle, continues serving the previously loaded one, logs the rejection, and emits an audit event. No decision path interruption, no loss of availability.
Why Ed25519
- 32-byte public keys, 64-byte signatures — small enough to inline in responses without bloating the wire.
- Deterministic signatures (no RNG failure modes like RSA-PSS).
crypto/ed25519is in the Go stdlib — no third-party crypto.- Sign / verify are ~40 µs on modern CPUs; the decision hot path pays essentially nothing.
Wire format
When bundle signing is enabled on the control plane, GET /policy-srv/v1/policies/bundle returns a JSON envelope:
{
"bundle": "<base64.StdEncoding of the raw bundle tar.gz bytes>",
"signature": "<base64.StdEncoding of the Ed25519 signature>",
"key_id": "k1",
"signed_at": "2026-04-23T15:00:00Z"
}
The agent:
- Decodes
bundle(base64) → raw bytes. - Decodes
signature(base64) → 64-byte Ed25519 sig. - Looks up the public key by
key_idin its cached JWKS. - Calls
ed25519.Verify(pub, bundleBytes, sig). - On success, loads the bundle. On any other outcome, rejects.
The signature is over the raw tar.gz bytes, not a re-serialized JSON projection. Signing the raw bytes avoids all JSON canonicalization ambiguity — both sides see the same canonical input.
When the control plane has no signing keys configured, it falls back to
the legacy application/gzip response (raw tar.gz in body). Agents
with BUNDLE_SIGNATURE_REQUIRED=false (the default) accept both
shapes. Agents with =true reject the unsigned response.
Public key distribution (JWKS)
The agent fetches GET /policy-srv/v1/bundles/keys once at startup and
refreshes every 10 minutes. The response is a JWKS-style document for
Ed25519 (RFC 8037, OKP key type):
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "<base64url of the 32-byte public key>",
"kid": "k1",
"use": "sig",
"alg": "EdDSA"
}
]
}
The endpoint is public and unauthenticated — agents need it before
they're trusted with anything else. It's rate-limited per-IP inside
the policy-service handler and sets Cache-Control: public, max-age=300
so CDNs / intermediaries cache client-side.
Configuration
Control plane (policy-service)
Two operator flows.
Single-key (getting started):
BUNDLE_SIGNING_PRIVATE_KEY_PEM="$(cat /etc/authzx/bundle-signing.pem)"
BUNDLE_SIGNING_KEY_ID="k1" # optional; defaults to "k1"
Multi-key rotation:
BUNDLE_SIGNING_KEYS_JSON='[
{"kid":"k2", "pem":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", "verify_only": false},
{"kid":"k1", "pem":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", "verify_only": true}
]'
The active signing key is the first entry with verify_only: false.
Verify-only keys are still published in JWKS so bundles signed with a
previous key continue to verify during the rotation window.
If no keys are configured, policy-service logs a warning at startup and
serves unsigned bundles for back-compat. The /bundles/keys
endpoint returns {"keys": []}.
Agent
# authzx-agent.yaml
bundle_signature_required: true # reject unsigned responses
trusted_keys_path: ~/.authzx/trusted_keys.json
Or via env:
BUNDLE_SIGNATURE_REQUIRED=true
AUTHZX_TRUSTED_KEYS_PATH=/etc/authzx/trusted_keys.json
Defaults: BUNDLE_SIGNATURE_REQUIRED=false, trusted-keys path
~/.authzx/trusted_keys.json (missing file is fine — treated as
empty).
Key rotation (no-downtime)
- Generate a new Ed25519 keypair (PEM, PKCS#8).
- Update
BUNDLE_SIGNING_KEYS_JSONto include BOTH keys — new one withverify_only: falseat the front, old one withverify_only: trueat the back. Redeploy policy-service. - Wait 48h. Agents pull JWKS every 10min and bundles every 30s, so within minutes every agent has the new pubkey cached and has received a bundle signed by it. 48h is belt-and-suspenders for agents with pathological clocks or extended disconnection.
- Remove the old key from
BUNDLE_SIGNING_KEYS_JSON. Redeploy.
Automatic rotation (rotating keys on a schedule without a redeploy) is deferred — the primary use case (SecNumCloud, DoD) wants a human signoff on every key rotation, so a TODO rather than an automated flow is the right call today.
Air-gap / offline verification
In regulated environments (DoD, SecNumCloud, finance) the agent may have no outbound network path to the control plane JWKS endpoint. Two paths make signed bundles still work:
Pinned trust keys
# on the air-gap host, after transferring a PEM pubkey file:
authzx-agent trust-key ./bundle-pub.pem --kid k1
This writes the key to ~/.authzx/trusted_keys.json. The agent's
Verifier consults the trusted store BEFORE the JWKS cache — so a
pinned key validates bundles even if the cloud JWKS endpoint is
unreachable. Pinned keys also override JWKS, so a compromised
cloud endpoint can't silently replace a pinned key.
Manual bundle verification
authzx-agent verify-bundle /var/authzx/cached-bundle.json --public-key ./bundle-pub.pem
# OK: signature verified (kid=k1, signed_at=2026-04-23T15:00:00Z, bundle_bytes=4821)
The input file must be a signed envelope (JSON with bundle +
signature + key_id). Useful for ad-hoc debugging ("did this bundle
file I copied onto the isolated host really come from our control
plane?").
Failure modes
| Scenario | Agent behavior |
|---|---|
| Signature valid | Load bundle. Increment authzx_agent_bundle_signature_verifications_total. |
| Signature invalid | Reject. Serve previous bundle. Log ERROR. Audit event bundle_signature_invalid. Increment rejection metric with reason=invalid_signature. |
Unknown kid | Refresh JWKS once, retry. If still unknown → reject. Audit bundle_signature_unknown_kid. |
Response has no signature + BUNDLE_SIGNATURE_REQUIRED=true | Reject. Audit bundle_signature_missing. |
Response has no signature + BUNDLE_SIGNATURE_REQUIRED=false | Accept (back-compat). |
| Malformed envelope JSON | Reject. Metric reason=verifier_error. |
| Verifier unconfigured + required=true | Reject. |
| Verifier unconfigured + required=false + signed response arrives | Accept unsafely with WARN log. |
The agent never crashes on a verification failure. It continues to serve the previously-loaded bundle, which is the right tradeoff for a PDP: a stale known-good policy is safer than an outage.
Metrics
authzx_agent_bundle_signature_verifications_total— successful verifies.authzx_agent_bundle_signature_rejections_total{reason}— rejections by reason (invalid_signature,unknown_kid,missing_signature,verifier_error).
A non-zero rate on rejections_total means either a MITM / misconfigured
proxy in the middle OR the operator needs to rotate / pin a new
trust key. Alert on it.
Follow-ups
- KMS-native signing (
BUNDLE_SIGNING_MODE=kms_native) so the private key never leaves the HSM. Scaffold is ininternal/config/bundle_signing.go; wire when a customer signs the HSM-pure commitment. - Secrets Manager mode (
kms_secret) for pulling the keys JSON from AWS Secrets Manager. Same scaffold; wire with the next security hardening pass. - Automatic rotation loop (no-redeploy): hot-swap the signer's key slice under a mutex so a scheduled rotator can promote/demote keys without bouncing policy-service.
Example dev keypair — DO NOT USE IN PRODUCTION
The following Ed25519 keypair is committed for dev / CI bootstrap only. Rotate on first deployment. It is public knowledge and offers zero security.
Private key (PKCS#8, PEM):
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIGExGZV5ynYHDR8vwxh2vHCo4VRtXZDZ2M2Q9YxlXYhT
-----END PRIVATE KEY-----
Public key (PKIX, PEM):
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAxWGQOxbrdq9s0y7a6P1f4hI2h2r2kU0gk1u3B5i1gYs=
-----END PUBLIC KEY-----
Note: these PEM blocks are placeholders — they illustrate the format but were not generated with a real seed. Operators should generate fresh keys during install. A Go snippet that does exactly this (uses stdlib only):
pub, priv, _ := ed25519.GenerateKey(rand.Reader)privDER, _ := x509.MarshalPKCS8PrivateKey(priv)pubDER, _ := x509.MarshalPKIXPublicKey(pub)fmt.Print(string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})))fmt.Print(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})))Or via OpenSSL:
openssl genpkey -algorithm Ed25519 -out bundle-signing.pemopenssl pkey -in bundle-signing.pem -pubout -out bundle-signing-pub.pem