Enrollment protocol (dashboard approval)
Who this is for: a developer (or operator) bootstrapping a fresh agent identity into a community Mastio bundle without enterprise Connector tooling. The result is a local identity directory (agent.key, agent.crt, dpop.jwk, meta.json) that the SDK reads on every subsequent run. agent.crt carries the full ADR-034 chain (leaf + Mastio Intermediate) inline, so no separate chain file is needed.
30-second TL;DR
- Agent dev calls
POST /v1/enrollment/startwith a freshly-generated public key, a proof of possession over that key, and a public DPoP JWK. The server returns asession_id. - Admin opens
https://<mastio>/proxy/enrollmentsand clicks Approve. The server signs a leaf cert against the Mastio Intermediate CA and pins the DPoP JKT to the row. - Agent dev polls
GET /v1/enrollment/{session_id}/statuswith anX-Enrollment-Proofheader (signed overenrollment-status:v1|<session_id>using the original enrollment private key). The server releasescert_pem(already concatenated as leaf + Mastio Intermediate),agent_id, andcapabilities. - Agent dev writes the identity directory and starts calling
chat_completion,list_mcp_tools, etc.
The proof header is the M-onb-1 audit gate: without it, an attacker who guesses or steals a session_id cannot exfiltrate the issued cert. The gate is non-negotiable, but as of v0.5.4 the server returns a detail hint in the status response when the proof header is missing, so the path is discoverable from the wire.
Python (SDK factory)
The cullis-sdk Python package ships a single-call factory that handles all five steps: keypair generation, fingerprint computation (SHA-256 of DER SubjectPublicKeyInfo, the trap that costs cold-readers half a day), pop signature, polling with the proof header, and atomic 0600 writes.
from cullis_sdk import CullisClient
client = CullisClient.enroll_via_dashboard_approval(
"https://mastio.example.com:9443",
requester_name="Alice Developer",
requester_email="alice@example.com",
reason="building an MCP agent for daily trading",
device_info="macOS 15.4 / cullis-sdk 0.5.4",
save_to="~/.cullis/agent-alice",
poll_interval_s=5.0,
timeout_s=600.0,
verify_tls=True,
ca_chain_path=None, # or path to a pinned org-ca.pem
on_pending=lambda sid, url: print(
f"Pending session {sid}. Ask the admin to approve at {url}"
),
)
# The returned client is fully wired: TLS client cert (the credential
# under ADR-014), persistent DPoP key, auto-login on first authed call.
print(client.chat_completion(model="gpt-4o-mini", messages=[{
"role": "user", "content": "hello",
}]))
The factory raises:
ConnectionError— Mastio unreachable (TLS / DNS / refused).PermissionError— start rejected (bad pop signature, rate limited) OR admin clicked Reject (therejection_reasonis in the message).TimeoutError— admin did not act beforetimeout_s(the server-side TTL is 30 minutes; pick something similar or shorter).ValueError— cryptographic failure (should not happen in normal use).
After enrollment completes the identity directory looks like:
~/.cullis/agent-alice/
├── agent.key # PKCS8 PEM, 0600: enrollment private key + signing key
├── agent.crt # PEM: leaf + Mastio Intermediate, ADR-034 chain inline
├── dpop.jwk # JSON JWK, 0600: runtime DPoP egress key
└── meta.json # {agent_id, capabilities, enrolled_at, mastio_url}
The server-side cert_pem already concatenates leaf || Mastio Intermediate (see sign_external_pubkey in mcp_proxy/egress/agent_manager.py), so the factory writes that single blob to agent.crt and the local-key login path walks the chain back to the Org Root without a sibling file.
Subsequent runs skip the dashboard dance entirely:
client = CullisClient.from_identity_dir(
"https://mastio.example.com:9443",
cert_path="~/.cullis/agent-alice/agent.crt",
key_path="~/.cullis/agent-alice/agent.key",
)
from_identity_dir reads the chained agent.crt, signs JWT assertions whose x5c header carries both certs, and the broker validates the path to the Org Root.
Curl + openssl (non-Python clients)
The same flow without the SDK, suitable for Go / Rust / shell agents.
Step 1: generate the enrollment keypair (EC P-256)
openssl ecparam -name prime256v1 -genkey -noout -out agent.key
openssl ec -in agent.key -pubout -out agent.pub
Step 2: compute the server-shape fingerprint
The Mastio computes SHA-256 over the DER SubjectPublicKeyInfo. PEM text does not work.
openssl pkey -in agent.pub -pubin -outform DER -out agent.pub.der
FP=$(openssl dgst -sha256 -binary agent.pub.der | xxd -p -c 64)
echo "fingerprint: $FP"
Step 3: sign the pop_signature
Domain-separated message enrollment-pop:v1|<fingerprint>:
printf 'enrollment-pop:v1|%s' "$FP" > pop.msg
openssl dgst -sha256 -sign agent.key -out pop.sig pop.msg
POP_B64URL=$(base64 -w0 pop.sig \
| tr '+/' '-_' \
| tr -d '=')
Step 4: POST start
PUBKEY_PEM=$(awk '{printf "%s\\n",$0}' agent.pub)
RESPONSE=$(curl -sS -X POST \
"https://mastio.example.com:9443/v1/enrollment/start" \
-H 'Content-Type: application/json' \
-d "{
\"pubkey_pem\": \"$PUBKEY_PEM\",
\"requester_name\": \"Alice\",
\"requester_email\": \"alice@example.com\",
\"reason\": \"shell-bootstrapped agent\",
\"device_info\": \"curl/openssl recipe\",
\"pop_signature\": \"$POP_B64URL\",
\"principal_type\": \"agent\"
}")
SESSION_ID=$(echo "$RESPONSE" | jq -r .session_id)
echo "session_id: $SESSION_ID"
echo "Ask the admin to approve at https://mastio.example.com:9443/proxy/enrollments"
(In production agents you would also generate a DPoP JWK and include it under dpop_jwk. Omitting it leaves the agent on the egress_dpop_mode=optional grace path; the admin endpoint can populate it post-hoc.)
Step 5: sign the proof header ONCE
Bind it to the session_id — non-replayable across sessions.
printf 'enrollment-status:v1|%s' "$SESSION_ID" > proof.msg
openssl dgst -sha256 -sign agent.key -out proof.sig proof.msg
PROOF=$(base64 -w0 proof.sig \
| tr '+/' '-_' \
| tr -d '=')
Step 6: poll for the admin decision
while true; do
BODY=$(curl -sS -H "X-Enrollment-Proof: $PROOF" \
"https://mastio.example.com:9443/v1/enrollment/$SESSION_ID/status")
STATUS=$(echo "$BODY" | jq -r .status)
case "$STATUS" in
approved) # cert_pem already carries leaf || Mastio Intermediate
echo "$BODY" | jq -r .cert_pem > agent.crt
break ;;
rejected) echo "rejected: $(echo "$BODY" | jq -r .rejection_reason)"
exit 1 ;;
expired) echo "expired"; exit 1 ;;
*) sleep 5 ;;
esac
done
If you forget the X-Enrollment-Proof header on the poll, the server returns status: "approved" with cert_pem: null AND a detail field describing the missing header. This is the v0.5.4 cold-reader fix: the wire now hints at the proof-of-possession gate instead of silently nulling everything out.
Why the proof header exists (M-onb-1 audit)
The session_id is a 128-bit URL-safe random token, but it travels through logs, CI pipelines, browser histories, and admin dashboards. Without an additional gate, anyone who shoulder-surfed or grepped a log could pull a freshly-approved cert just by knowing the session_id.
The proof header binds the /status poll to the original enrollment keypair: the server holds the public key on the pending row, the caller signs a domain-separated message (enrollment-status:v1|<session_id>) with the private half, and the server verifies before releasing cert_pem. The signature is non-replayable across sessions (binds to session_id) and across keys (binds via verification against the row’s pubkey_pem).
The gate is not optional and never will be. The v0.5.4 fix only adds discoverability: the detail field tells a caller who forgot the header what to send next, and links here.
See also
- Rotate keys — once enrolled, the cert is rotated via
POST /v1/registry/agents/{id}/rotate-cert. - Vault as Org CA private key store — how the Mastio loads the CA that signs the leaf during approve.
- Rego policies — the
capabilitiesgranted at approval drive policy evaluation on every call.