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.
1. Generate a replacement key locally
Section titled “1. Generate a replacement key locally”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.
python3 -m venv .venv. .venv/bin/activatepython -m pip install trustplane-auth-sdk==0.2.3umask 077export TP_NEW_KEY_FILE='./client-private-key-v2'python - <<'PY'import osfrom pathlib import Pathfrom 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)PY2. Add the replacement in Control
Section titled “2. Add the replacement in Control”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.
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.
send_signed_echo() { python - <<'PY'import osfrom pathlib import Pathfrom urllib.error import HTTPErrorfrom 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_echoFor 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.
3. Revoke only the original key
Section titled “3. Revoke only the original key”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:
export TP_KID="$TP_OLD_KID" TP_KEY_FILE="$TP_OLD_KEY_FILE"send_signed_echoexport TP_KID="$TP_NEW_KID" TP_KEY_FILE="$TP_NEW_KEY_FILE"send_signed_echoExpect 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.
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.