Skip to main content

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/ed25519 is 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:

  1. Decodes bundle (base64) → raw bytes.
  2. Decodes signature (base64) → 64-byte Ed25519 sig.
  3. Looks up the public key by key_id in its cached JWKS.
  4. Calls ed25519.Verify(pub, bundleBytes, sig).
  5. 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)

  1. Generate a new Ed25519 keypair (PEM, PKCS#8).
  2. Update BUNDLE_SIGNING_KEYS_JSON to include BOTH keys — new one with verify_only: false at the front, old one with verify_only: true at the back. Redeploy policy-service.
  3. 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.
  4. 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

ScenarioAgent behavior
Signature validLoad bundle. Increment authzx_agent_bundle_signature_verifications_total.
Signature invalidReject. Serve previous bundle. Log ERROR. Audit event bundle_signature_invalid. Increment rejection metric with reason=invalid_signature.
Unknown kidRefresh JWKS once, retry. If still unknown → reject. Audit bundle_signature_unknown_kid.
Response has no signature + BUNDLE_SIGNATURE_REQUIRED=trueReject. Audit bundle_signature_missing.
Response has no signature + BUNDLE_SIGNATURE_REQUIRED=falseAccept (back-compat).
Malformed envelope JSONReject. Metric reason=verifier_error.
Verifier unconfigured + required=trueReject.
Verifier unconfigured + required=false + signed response arrivesAccept 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 in internal/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.pem
openssl pkey -in bundle-signing.pem -pubout -out bundle-signing-pub.pem