BYOCA enrollment
Who this is for: a platform engineer provisioning headless agents (CI/CD jobs, backend services, scheduled workflows) where the organization already runs an internal PKI. BYOCA — “bring your own CA” — lets you use the cert material your security team already manages.
If your agents already hold a SPIRE SVID, use SPIRE enrollment instead.
Prerequisites
- Your org’s Org CA has been uploaded to the Mastio (either at first-boot wizard time or via
POST /proxy/pki/attach-ca) - The Mastio admin secret (
$MASTIO_ADMIN_SECRET) - A cert + private key for the agent, signed by the Org CA
- Python 3.10+ with
pip install cullis-sdk— or any HTTP client if you’d rather script the endpoint directly
The protocol, at a glance
- Pre-flight — you hand the Mastio a cert + key. It:
- Verifies the cert chains to the current Org CA
- Checks the cert is not revoked and not expired
- Confirms the key proves possession of the cert (test signature)
- Extracts any SPIFFE URI in
SubjectAlternativeNameand pins it asspiffe_id
- Enroll — on success the Mastio:
- Inserts a row in
internal_agentswithenrollment_method='byoca' - Generates a DPoP-bound API key (
sk_local_...) - Returns the plaintext API key exactly once — it’s stored only as a bcrypt hash server-side
- Inserts a row in
- Persist — you write the API key, DPoP JWK, and
agent.jsonsomewhere the agent can read at runtime
Runtime auth is then identical to every other enrollment method: API key + DPoP proof, sent to the Mastio.
1. Enroll via the SDK
from cullis_sdk import CullisClient
CullisClient.enroll_via_byoca(
"https://mastio.acme.corp",
admin_secret="$MASTIO_ADMIN_SECRET",
agent_name="inventory-bot",
display_name="Inventory service",
cert_pem=open("agent.pem").read(),
private_key_pem=open("agent-key.pem").read(),
capabilities=["inventory.read", "inventory.write"],
persist_to="/etc/cullis/agent/",
)
Expected side effects:
/etc/cullis/agent/api-key # 0600 — the plaintext API key
/etc/cullis/agent/dpop.jwk # 0600 — private DPoP JWK (EC P-256)
/etc/cullis/agent/agent.json # 0644 — {agent_id, org_id, mastio_url}
The SDK returns the AgentEnrollResponse object so you can read the API key into your own secret manager instead of persisting to disk. If persist_to is omitted, the SDK doesn’t write anywhere — you own the storage.
Capabilities
The capabilities list is the set of tool-call scopes the agent is allowed to request. The Mastio enforces capability checks at session open time and on every MCP tool invocation. Common scopes:
oneshot.message— send A2A messages without opening a session (ADR-008)session.open— initiate stateful sessions with other agentsorder.read,order.write,inventory.*— domain-specific tool scopes
Agents get exactly the intersection of capabilities declared at enrollment and what the resource binding (POST /v1/admin/mcp-resources/bindings) allows.
SPIFFE URI auto-pickup
If your cert carries a SPIFFE URI in SubjectAlternativeName (e.g. spiffe://acme.corp/inventory-bot), the Mastio:
- Verifies the URI trust domain matches the org’s configured trust domain
- Pins the URI as
spiffe_idon theinternal_agentsrow - Uses it as the canonical sender id in audit events
This is transparent — nothing to configure on your side beyond issuing the cert with the right SAN.
2. Enroll via raw HTTP
Useful when the bootstrap runs in a language other than Python, inside a minimal container, or from a Helm hook. Same endpoint, same semantics.
curl -X POST https://mastio.acme.corp/v1/admin/agents/enroll/byoca \
-H "X-Admin-Secret: $MASTIO_ADMIN_SECRET" \
-H "Content-Type: application/json" \
-d '{
"agent_name": "inventory-bot",
"display_name": "Inventory service",
"capabilities": ["inventory.read", "inventory.write"],
"cert_pem": "-----BEGIN CERTIFICATE-----\n...",
"private_key_pem": "-----BEGIN PRIVATE KEY-----\n...",
"dpop_jwk": {"kty": "EC", "crv": "P-256", "x": "...", "y": "..."}
}'
Expected response (201):
{
"agent_id": "acme::inventory-bot",
"api_key": "sk_local_9f4a2b1e3c5d7e8f...",
"dpop_jkt": "3f:4a:9b:2b:1e:...",
"enrolled_at": "2026-04-23T19:12:08Z",
"mastio_url": "https://mastio.acme.corp"
}
The plaintext api_key is returned exactly once. The server stores only the bcrypt hash. Persist it to a 0600-mode file or to your secret manager; the agent needs it to mint DPoP-bound tokens on subsequent calls.
See Enrollment API reference for the full request/response schema.
3. Runtime
from cullis_sdk import CullisClient
client = CullisClient.from_api_key_file(
mastio_url="https://mastio.acme.corp",
api_key_path="/etc/cullis/agent/api-key",
dpop_key_path="/etc/cullis/agent/dpop.jwk",
)
client.send_oneshot("globex::fulfillment-bot", {"order_id": "A123"})
No cert on the wire at runtime. The DPoP proof binds every request to the keypair the Mastio pinned during enrollment — stolen API keys alone can’t impersonate the agent.
4. Re-enroll after Org CA rotation
An Org CA rotation (Rotate keys § 3) invalidates every agent’s leaf cert. For BYOCA-enrolled agents, the Mastio can’t auto-re-sign — the org holds the private keys. The re-enrollment flow:
- Your CA issues a fresh cert for each agent (same subject, same keypair is fine)
- Re-run
enroll_via_byocawith the newcert_pem - Pass the same
agent_name— the Mastio detects the existing row and updates the cert + chain in place rather than creating a duplicate - The
api_keystays valid; only the cert thumbprint changes
A worked example automating this for all BYOCA agents under one org:
from cullis_sdk import CullisClient
admin_secret = os.environ["MASTIO_ADMIN_SECRET"]
agents = CullisClient.list_agents(
"https://mastio.acme.corp",
admin_secret=admin_secret,
enrollment_method="byoca",
)
for agent in agents:
new_cert = issue_cert_from_vault(agent.agent_name) # your CI step
CullisClient.enroll_via_byoca(
"https://mastio.acme.corp",
admin_secret=admin_secret,
agent_name=agent.agent_name,
cert_pem=new_cert.cert_pem,
private_key_pem=new_cert.private_key_pem,
capabilities=agent.capabilities,
update_existing=True,
)
The update_existing=True flag is the contract: without it the Mastio refuses to overwrite an existing row to avoid accidental clobber.
Troubleshoot
400 cert_not_signed_by_org_ca
: The cert’s issuer chain doesn’t terminate at the Org CA the Mastio has loaded. Confirm with openssl verify -CAfile org-ca.pem agent.pem. If you recently rotated the Org CA, make sure the Mastio loaded the new one (/healthz should show no org_ca_legacy_pathlen_zero warning).
400 key_does_not_match_cert
: The private_key_pem doesn’t correspond to the cert_pem. Double-check with openssl x509 -in agent.pem -noout -pubkey | openssl md5 vs. openssl pkey -in agent-key.pem -pubout | openssl md5 — the two hashes must match.
403 admin_secret_invalid
: The X-Admin-Secret header is wrong or the Mastio admin secret was recently rotated. Pull the current value from your secret manager; the bcrypt hash in Vault is the authoritative source.
409 agent_already_enrolled
: A row with agent_id=acme::inventory-bot already exists. Pass update_existing=True (SDK) or "update_existing": true (raw HTTP) if you intended to re-enroll, or pick a different agent_name.
400 spiffe_uri_wrong_trust_domain
: Your cert’s SPIFFE SAN points at a trust domain the Mastio doesn’t trust (or the Mastio has no trust domain configured). Fix either the SAN or the Mastio’s CULLIS_TRUST_DOMAIN env var. See the SPIRE enrollment page for trust-bundle semantics.
Next
- Enrollment API reference — full request / response schemas
- Rotate keys § 3 — when Org CA rotation forces BYOCA re-enrollment
- SPIRE enrollment — if your agents already run under SPIRE
- SPIRE enrollment — the alternative for SPIRE-attested workloads