Skip to content

6. Rotate a client key and verify the denial

Continue with the disposable Auth Site, private echo and exact GET /tutorial/echo grant from the first managed request. This chapter uses the released Python SDK 0.2.3 to send fresh signed requests. Keep the original private key until the old-key denial is verified.

In a trusted workload environment, run these commands. Choose a new private-key filename; the command refuses to overwrite an existing file. Keep that file out of source control and backups you do not trust. Copy only the printed public key and fingerprint into your operator notes.

Terminal window
python3 -m venv .venv
. .venv/bin/activate
python -m pip install trustplane-auth-sdk==0.2.3
umask 077
export TP_NEW_KEY_FILE='./client-private-key-v2'
python - <<'PY'
import os
from pathlib import Path
from trustplane_auth.keys import generate_local_ed25519_key
key = generate_local_ed25519_key()
with Path(os.environ["TP_NEW_KEY_FILE"]).open("x") as private_file:
private_file.write(key.private_key_b64url + "\n")
print("Public key:", key.public_key_b64url)
print("SHA-256 fingerprint:", key.fingerprint_sha256)
PY

In Control → Client Vault, select the existing client in the exact environment and Auth Site. Open Keys → Add key to client, paste the printed public key, review its fingerprint, and confirm with your operator passkey. Do not enter the private-key file or its contents in Control. Open the new key’s Inspect command for the exact GET /tutorial/echo route and save its active signing profile fields. Wait until the selected distribution target reports that the replacement material was published and acknowledged by its required Auth instances. A successful key-add message alone does not prove that the serving site loaded the new bundle.

For the request below, fill the fields from the new key’s active profile, as in the Python request chapter. The Auth base URL is https:// plus that profile’s Auth Site authority. Do not copy IDs from another tenant or site. Also save the old key’s KID before revoking it.

Terminal window
export TP_AUTH_BASE_URL='https://<Auth Site authority>'
export TP_ISSUER='<issuer>'
export TP_SUBJECT='<subject>'
export TP_TRUST_DOMAIN='<trust domain>'
export TP_AUDIENCE='<audience>'
export TP_ROUTE_ID='<exact GET route ID>'
export TP_NEW_KID='<replacement key KID>'
export TP_OLD_KID='<original key KID>'
export TP_OLD_KEY_FILE='./client-private-key'

Replace every angle-bracket value. Paste this complete shell function once in the same terminal. It signs a new request each time, refuses redirects, prints only the response and the short-lived Audit correlation JTI, and never prints the proof or private key.

Terminal window
send_signed_echo() {
python - <<'PY'
import os
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import HTTPRedirectHandler, build_opener
from trustplane_auth import ProtectedClient, SigningProfile, private_key_from_base64url
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
profile = SigningProfile.from_control({
"state": "active",
"key_origin": "manual",
"request_base_url": os.environ["TP_AUTH_BASE_URL"],
"kid": os.environ["TP_KID"],
"subject": os.environ["TP_SUBJECT"],
"trust_domain": os.environ["TP_TRUST_DOMAIN"],
"issuer": os.environ["TP_ISSUER"],
"route_id": os.environ["TP_ROUTE_ID"],
"canonical_route_id": os.environ["TP_ROUTE_ID"],
"route_match_type": "Exact",
"route_path": "/tutorial/echo",
"method": "GET",
"audience": os.environ["TP_AUDIENCE"],
})
private_text = Path(os.environ["TP_KEY_FILE"]).read_text().strip()
private_key = private_key_from_base64url(private_text)
prepared = ProtectedClient(profile, private_key).prepare(
"GET", "/tutorial/echo", headers={"Content-Type": "application/json"}
)
try:
response = build_opener(NoRedirect()).open(prepared.request, timeout=20)
except HTTPError as error:
print("HTTP", error.code)
print(error.read(400).decode("utf-8", errors="replace").strip())
else:
print("HTTP", response.status)
print(response.read(200).decode("utf-8", errors="replace").strip())
print("Audit correlation JTI:", prepared.passport.jti)
PY
}
export TP_KID="$TP_NEW_KID" TP_KEY_FILE="$TP_NEW_KEY_FILE"
send_signed_echo

For this example’s route, expect HTTP 200 and the private echo response. Open Control → Audit for this Auth Site and time. Compare the printed JTI with Raw evidence → Sanitized raw evidence on the allowed GET /tutorial/echo event. Check the client subject, exact route, Allowed — verified, target version and upstream HTTP 200. The Content-Type header is required by this example’s request transcript; use your own profile’s covered headers for any other route.

Return to Client Vault → Keys, select the original key and inspect its fingerprint and KID again. Expand Key lifecycle → Revoke, read the affected client and grants, enter the requested key ID, and confirm with your operator passkey. Do not revoke the replacement key. Wait for the same target to acknowledge the new bundle and for the original key to show revoked and no active route. Keep the original key file only as long as your verification and retention policy require, then dispose of it securely.

Send a fresh signed request with the original key and another with the replacement:

Terminal window
export TP_KID="$TP_OLD_KID" TP_KEY_FILE="$TP_OLD_KEY_FILE"
send_signed_echo
export TP_KID="$TP_NEW_KID" TP_KEY_FILE="$TP_NEW_KEY_FILE"
send_signed_echo

Expect the original key to be denied before the upstream and the replacement to remain allowed. In the isolated disposable-site verification, the fresh old-key request returned HTTP 403 with missing_issuer_key and upstream: not_reached; the fresh new-key request returned HTTP 200, and its JTI matched an Allowed — verified Audit event with upstream HTTP 200. Your denial reason can differ if another check fails earlier. Match each request by its time, route and Audit evidence; do not reuse a signed request, since replay protection can produce a different denial.

Open this walkthrough on YouTube

If the result differs, compare the site scope, public fingerprint, profile KID, exact route, target configuration version and loaded bundle in Auth Sites and Operate. If Control is unreachable, Auth enforces locally within the signed cache and lease bounds; unknown or expired material fails closed. Preserve the site’s retained state while investigating.