feat: accept KeyCape approval clients with tested recovery
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
ce826cfa8d
commit
303a584bd0
8 changed files with 810 additions and 20 deletions
316
docs/evidence/2026-09-09-keycape-activation-attempts.json
Normal file
316
docs/evidence/2026-09-09-keycape-activation-attempts.json
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
{
|
||||||
|
"attempts": [
|
||||||
|
{
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "failed",
|
||||||
|
"lanes": [],
|
||||||
|
"started_at": "2026-09-08T23:34:28.315053+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": false,
|
||||||
|
"error": "command_failed",
|
||||||
|
"finished_at": "2026-09-08T23:34:28.559853+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "failed",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0017",
|
||||||
|
"source_sha256": "dc2bbd86e213edecc2f2f91a99d0f51136e9a0853a90e55f65dda2b942305416",
|
||||||
|
"policy_applied": true,
|
||||||
|
"role_applied": true,
|
||||||
|
"custody_seeded": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"request_id": "94c98373-33fa-591d-c201-d36dddf9879b",
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "34502b48-ed5d-4a1d-bc43-aa282392775e",
|
||||||
|
"secret_resource_version": "58736028",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0018",
|
||||||
|
"source_sha256": "99148133ddf1eb15dce3c03862b51dc001818ca3121045035a0b0e5897c55dad",
|
||||||
|
"policy_applied": true,
|
||||||
|
"role_applied": true,
|
||||||
|
"custody_seeded": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"request_id": "a62ee5c5-b5ba-b7dc-0f51-e6fd8a6dde32",
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "e6f42c4b-dbca-480d-96a4-c475f1298e7f",
|
||||||
|
"secret_resource_version": "58736036",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"started_at": "2026-09-08T23:36:01.727953+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": false,
|
||||||
|
"issuer_pin_revision": "58713343",
|
||||||
|
"phase": "keycape_rollout",
|
||||||
|
"coding_agent_boundary_extended_only_to_new_paths": true,
|
||||||
|
"initial_values_generation": "independent CSPRNG 48-byte values; memory-to-OpenBao stdin only; CAS=0",
|
||||||
|
"namespace_probe_cleanup_requested": true,
|
||||||
|
"keycape": {
|
||||||
|
"existing_human_login_before": true,
|
||||||
|
"protected_recovery_retained": true,
|
||||||
|
"compatible_pair_restored": true
|
||||||
|
},
|
||||||
|
"verifier_delivery_disabled_custody_versions_retained": true,
|
||||||
|
"error": "keycape_readiness_timeout",
|
||||||
|
"finished_at": "2026-09-08T23:39:24.210112+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "failed",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0017",
|
||||||
|
"source_sha256": "dc2bbd86e213edecc2f2f91a99d0f51136e9a0853a90e55f65dda2b942305416",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "94c98373-33fa-591d-c201-d36dddf9879b",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "b85ce6bb-45c4-49ab-81c9-e92f001500b2",
|
||||||
|
"secret_resource_version": "58741915",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0018",
|
||||||
|
"source_sha256": "99148133ddf1eb15dce3c03862b51dc001818ca3121045035a0b0e5897c55dad",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "a62ee5c5-b5ba-b7dc-0f51-e6fd8a6dde32",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "faad6d54-a6b1-4a3d-9102-4e35aeea67a5",
|
||||||
|
"secret_resource_version": "58741919",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"started_at": "2026-09-08T23:54:16.437123+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": false,
|
||||||
|
"issuer_pin_revision": "58737048",
|
||||||
|
"phase": "keycape_rollout",
|
||||||
|
"custody_versions_unchanged": true,
|
||||||
|
"namespace_probe_cleanup_requested": true,
|
||||||
|
"keycape": {
|
||||||
|
"existing_human_login_before": true,
|
||||||
|
"protected_recovery_retained": true,
|
||||||
|
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||||
|
"generation": 32,
|
||||||
|
"pod_uid": "28a62a5a-1ac6-4d6c-a1e1-4a414a621559",
|
||||||
|
"single_ready_replica": true,
|
||||||
|
"clients": [],
|
||||||
|
"compatible_pair_restored": true
|
||||||
|
},
|
||||||
|
"verifier_delivery_disabled_custody_versions_retained": true,
|
||||||
|
"error": "contained_operation_failed",
|
||||||
|
"finished_at": "2026-09-08T23:55:02.310001+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "failed",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0017",
|
||||||
|
"source_sha256": "dc2bbd86e213edecc2f2f91a99d0f51136e9a0853a90e55f65dda2b942305416",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "94c98373-33fa-591d-c201-d36dddf9879b",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "827b4e04-6727-4dd4-9cdb-2554ecc84441",
|
||||||
|
"secret_resource_version": "58743272",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0018",
|
||||||
|
"source_sha256": "99148133ddf1eb15dce3c03862b51dc001818ca3121045035a0b0e5897c55dad",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "a62ee5c5-b5ba-b7dc-0f51-e6fd8a6dde32",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "ca949cda-6a4f-4c24-9a06-6a9c7e00e4aa",
|
||||||
|
"secret_resource_version": "58743276",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"started_at": "2026-09-08T23:58:13.955525+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": false,
|
||||||
|
"issuer_pin_revision": "58742089",
|
||||||
|
"phase": "keycape_rollout",
|
||||||
|
"custody_versions_unchanged": true,
|
||||||
|
"namespace_probe_cleanup_requested": true,
|
||||||
|
"keycape": {
|
||||||
|
"existing_human_login_before": true,
|
||||||
|
"protected_recovery_retained": true,
|
||||||
|
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||||
|
"generation": 34,
|
||||||
|
"pod_uid": "dabd15a0-f9c3-47f2-82ee-e53590754558",
|
||||||
|
"single_ready_replica": true,
|
||||||
|
"acceptance_phase": "in_pod_verifier",
|
||||||
|
"clients": [],
|
||||||
|
"acceptance_client": "secrets-engine-approval",
|
||||||
|
"failure_class": "LaneError",
|
||||||
|
"compatible_pair_restored": true
|
||||||
|
},
|
||||||
|
"verifier_delivery_disabled_custody_versions_retained": true,
|
||||||
|
"error": "command_failed",
|
||||||
|
"finished_at": "2026-09-08T23:59:06.395926+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "failed",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0017",
|
||||||
|
"source_sha256": "dc2bbd86e213edecc2f2f91a99d0f51136e9a0853a90e55f65dda2b942305416",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "94c98373-33fa-591d-c201-d36dddf9879b",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "1e0c888a-c35b-4fe2-8fc5-c510256c19df",
|
||||||
|
"secret_resource_version": "58745995",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0018",
|
||||||
|
"source_sha256": "99148133ddf1eb15dce3c03862b51dc001818ca3121045035a0b0e5897c55dad",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "a62ee5c5-b5ba-b7dc-0f51-e6fd8a6dde32",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "50b18e82-6187-47d1-9b07-199f9b999f47",
|
||||||
|
"secret_resource_version": "58745999",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"started_at": "2026-09-09T00:06:30.650774+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": false,
|
||||||
|
"issuer_pin_revision": "58743475",
|
||||||
|
"phase": "keycape_rollout",
|
||||||
|
"custody_versions_unchanged": true,
|
||||||
|
"namespace_probe_cleanup_requested": true,
|
||||||
|
"keycape": {
|
||||||
|
"existing_human_login_before": true,
|
||||||
|
"protected_recovery_retained": true,
|
||||||
|
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||||
|
"generation": 36,
|
||||||
|
"pod_uid": "011de4b4-c2cd-429d-ba04-e7c72b2f1f9e",
|
||||||
|
"single_ready_replica": true,
|
||||||
|
"acceptance_phase": "signature_and_claims",
|
||||||
|
"clients": [],
|
||||||
|
"acceptance_client": "secrets-engine-approval",
|
||||||
|
"failure_class": "ImmatureSignatureError",
|
||||||
|
"compatible_pair_restored": true
|
||||||
|
},
|
||||||
|
"verifier_delivery_disabled_custody_versions_retained": true,
|
||||||
|
"error": "contained_operation_failed",
|
||||||
|
"finished_at": "2026-09-09T00:07:25.670140+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"all_sessions_revoked": true
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
{
|
{
|
||||||
|
"acceptance_phase": "passed",
|
||||||
"clients": [
|
"clients": [
|
||||||
{
|
{
|
||||||
"client_id": "secrets-engine-approval",
|
"client_id": "secrets-engine-approval",
|
||||||
"live_jwks_signature_verified": true,
|
"live_jwks_signature_verified": true,
|
||||||
"exact_claims_verified": true,
|
"exact_claims_verified": true,
|
||||||
"lifetime_seconds": 900,
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
"excess_scope_denied": true,
|
"excess_scope_denied": true,
|
||||||
"wrong_secret_denied": true,
|
"wrong_secret_denied": true,
|
||||||
"pod_verify_client_passed": false,
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
"real_predecessor_rotation_tested": false,
|
"real_predecessor_rotation_tested": false,
|
||||||
"observed_wall_clock_expiry": false
|
"observed_wall_clock_expiry": false
|
||||||
},
|
},
|
||||||
|
|
@ -16,15 +21,20 @@
|
||||||
"live_jwks_signature_verified": true,
|
"live_jwks_signature_verified": true,
|
||||||
"exact_claims_verified": true,
|
"exact_claims_verified": true,
|
||||||
"lifetime_seconds": 900,
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
"excess_scope_denied": true,
|
"excess_scope_denied": true,
|
||||||
"wrong_secret_denied": true,
|
"wrong_secret_denied": true,
|
||||||
"pod_verify_client_passed": false,
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
"real_predecessor_rotation_tested": false,
|
"real_predecessor_rotation_tested": false,
|
||||||
"observed_wall_clock_expiry": false
|
"observed_wall_clock_expiry": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"acceptance_client": "approval-engine-operator",
|
||||||
"human_client_consume_denied": true,
|
"human_client_consume_denied": true,
|
||||||
"target": "disposable pinned KeyCape image; synthetic keys and clients only",
|
"target": "disposable pinned KeyCape image; synthetic keys and clients only",
|
||||||
"pod_cli_verification": "deferred to live in-pod check",
|
"pinned_native_cli_verification": "passed against loopback HTTPS with synthetic credentials",
|
||||||
"cleanup_complete": true
|
"cleanup_complete": true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
docs/evidence/2026-09-09-keycape-verifier-admission.json
Normal file
220
docs/evidence/2026-09-09-keycape-verifier-admission.json
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
{
|
||||||
|
"schema": "helixforge.keycape-verifier-admission.v1",
|
||||||
|
"recorded_at": "2026-09-09T00:14:27.155338+00:00",
|
||||||
|
"approval": {
|
||||||
|
"schema": "railiance.keycape-custody-user-approval.v1",
|
||||||
|
"recorded_at": "2026-09-08T23:05:19.886216+00:00",
|
||||||
|
"user_response": "I approve, go on.",
|
||||||
|
"approved_question": "Do you approve CCR-2026-0017 and CCR-2026-0018, as platform operator and KeyCape owner, for the verifier-side credential delivery described in the review packet?",
|
||||||
|
"review_packet_revision": "52b24eab9a8aff3396e71b2296d0240a44f3b0db",
|
||||||
|
"roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
],
|
||||||
|
"scope": "Two verifier-side KeyCape client credential custody requests, including their governed attended provisioning and compatible rollout sequence.",
|
||||||
|
"client_side_read_authorized": false,
|
||||||
|
"factory_spending_authorized": false,
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"id": "CCR-2026-0017",
|
||||||
|
"decision_id": "b533a271-b704-4c5c-98a2-9a5951aadfb6",
|
||||||
|
"status": "approved",
|
||||||
|
"decision_status": "resolved",
|
||||||
|
"reviewed_roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CCR-2026-0018",
|
||||||
|
"decision_id": "efa90517-0cae-4eb6-a68d-5b0489c84d65",
|
||||||
|
"status": "approved",
|
||||||
|
"decision_status": "resolved",
|
||||||
|
"reviewed_roles": [
|
||||||
|
"platform-operator",
|
||||||
|
"key-cape-owner"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"activation": {
|
||||||
|
"schema": "platform.keycape-approval-custody.v1",
|
||||||
|
"status": "custody_and_service_acceptance_passed_pending_fresh_human_login",
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0017",
|
||||||
|
"source_sha256": "dc2bbd86e213edecc2f2f91a99d0f51136e9a0853a90e55f65dda2b942305416",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "94c98373-33fa-591d-c201-d36dddf9879b",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "eaa28bdf-04af-4e4e-a1fc-fe395c7689a7",
|
||||||
|
"secret_resource_version": "58747058",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ccr": "CCR-2026-0018",
|
||||||
|
"source_sha256": "99148133ddf1eb15dce3c03862b51dc001818ca3121045035a0b0e5897c55dad",
|
||||||
|
"custody_seeded": false,
|
||||||
|
"existing_version_reused": true,
|
||||||
|
"kv_version": 1,
|
||||||
|
"initial_request_id": "a62ee5c5-b5ba-b7dc-0f51-e6fd8a6dde32",
|
||||||
|
"policy_applied": false,
|
||||||
|
"role_applied": true,
|
||||||
|
"native_reader_verified": true,
|
||||||
|
"cross_path_denied": true,
|
||||||
|
"parent_listing_denied": true,
|
||||||
|
"auth_ttl": 900,
|
||||||
|
"reader_revocation_verified": true,
|
||||||
|
"wrong_service_account_denied": true,
|
||||||
|
"coding_agent_deny_wins": true,
|
||||||
|
"store_ready": true,
|
||||||
|
"external_secret_ready": true,
|
||||||
|
"delivery_matches": true,
|
||||||
|
"secret_uid": "599a61a7-8244-4618-8c44-6473195bbe4e",
|
||||||
|
"secret_resource_version": "58747062",
|
||||||
|
"wrong_namespace_denied": true,
|
||||||
|
"outside_namespace_store_denied": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"started_at": "2026-09-09T00:09:28.852280+00:00",
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"keycape_rollout_completed": true,
|
||||||
|
"issuer_pin_revision": "58746187",
|
||||||
|
"phase": "awaiting_fresh_human_login",
|
||||||
|
"custody_versions_unchanged": true,
|
||||||
|
"namespace_probe_cleanup_requested": true,
|
||||||
|
"keycape": {
|
||||||
|
"existing_human_login_before": true,
|
||||||
|
"protected_recovery_retained": true,
|
||||||
|
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||||
|
"generation": 38,
|
||||||
|
"pod_uid": "8d62e972-d466-413e-a2e4-08b9a08016bc",
|
||||||
|
"single_ready_replica": true,
|
||||||
|
"acceptance_phase": "passed",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"client_id": "secrets-engine-approval",
|
||||||
|
"live_jwks_signature_verified": true,
|
||||||
|
"exact_claims_verified": true,
|
||||||
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
|
"excess_scope_denied": true,
|
||||||
|
"wrong_secret_denied": true,
|
||||||
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
|
"real_predecessor_rotation_tested": false,
|
||||||
|
"observed_wall_clock_expiry": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "approval-engine-operator",
|
||||||
|
"live_jwks_signature_verified": true,
|
||||||
|
"exact_claims_verified": true,
|
||||||
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
|
"excess_scope_denied": true,
|
||||||
|
"wrong_secret_denied": true,
|
||||||
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
|
"real_predecessor_rotation_tested": false,
|
||||||
|
"observed_wall_clock_expiry": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"acceptance_client": "approval-engine-operator",
|
||||||
|
"human_client_consume_denied": true,
|
||||||
|
"status": "service_acceptance_passed_pending_fresh_human_login",
|
||||||
|
"image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611",
|
||||||
|
"config_resource_version": "58747126",
|
||||||
|
"signing_key_unchanged": true,
|
||||||
|
"unrelated_config_bytes_preserved": true,
|
||||||
|
"existing_human_login_after": false
|
||||||
|
},
|
||||||
|
"finished_at": "2026-09-09T00:10:09.099481+00:00"
|
||||||
|
},
|
||||||
|
"post_rollout_login": {
|
||||||
|
"schema": "netkingdom.keycape-approval-rollout.v1",
|
||||||
|
"status": "service_and_existing_human_login_acceptance_passed",
|
||||||
|
"values_emitted": false,
|
||||||
|
"deployment_uid": "99ddd83c-cb3f-4847-bcf8-35f1aa87627f",
|
||||||
|
"generation": 38,
|
||||||
|
"pod_uid": "8d62e972-d466-413e-a2e4-08b9a08016bc",
|
||||||
|
"single_ready_replica": true,
|
||||||
|
"acceptance_phase": "passed",
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"client_id": "secrets-engine-approval",
|
||||||
|
"live_jwks_signature_verified": true,
|
||||||
|
"exact_claims_verified": true,
|
||||||
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
|
"excess_scope_denied": true,
|
||||||
|
"wrong_secret_denied": true,
|
||||||
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
|
"real_predecessor_rotation_tested": false,
|
||||||
|
"observed_wall_clock_expiry": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_id": "approval-engine-operator",
|
||||||
|
"live_jwks_signature_verified": true,
|
||||||
|
"exact_claims_verified": true,
|
||||||
|
"lifetime_seconds": 900,
|
||||||
|
"maximum_future_iat_seconds": 30,
|
||||||
|
"expiry_leeway_seconds": 0,
|
||||||
|
"excess_scope_denied": true,
|
||||||
|
"wrong_secret_denied": true,
|
||||||
|
"pinned_artifact_verifier_passed": true,
|
||||||
|
"verifier_location": "attended owner process",
|
||||||
|
"verifier_sha256": "4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92",
|
||||||
|
"real_predecessor_rotation_tested": false,
|
||||||
|
"observed_wall_clock_expiry": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"acceptance_client": "approval-engine-operator",
|
||||||
|
"human_client_consume_denied": true,
|
||||||
|
"existing_human_login_after": true,
|
||||||
|
"receipt_written_at": "2026-09-09T00:11:59.113549+00:00"
|
||||||
|
},
|
||||||
|
"attended_envelope": {
|
||||||
|
"all_attempted_sessions_revoked": true,
|
||||||
|
"successful_activation_exit_code": 0,
|
||||||
|
"post_rollout_login_exit_code": 0
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"local_openbao_tests": 8,
|
||||||
|
"configuration_and_recovery_tests": 10,
|
||||||
|
"credential_change_tests": 53,
|
||||||
|
"pinned_image_synthetic_https_and_native_verifier": "passed"
|
||||||
|
},
|
||||||
|
"failed_attempt_receipts": [
|
||||||
|
"net-kingdom:docs/evidence/2026-09-09-keycape-activation-attempts.json"
|
||||||
|
],
|
||||||
|
"limits": {
|
||||||
|
"client_side_read_admitted": false,
|
||||||
|
"factory_spending_admitted": false,
|
||||||
|
"wall_clock_jwt_expiry_observed": false,
|
||||||
|
"actual_predecessor_rotation_observed": false
|
||||||
|
},
|
||||||
|
"temporary_probe_namespaces_remaining": 0,
|
||||||
|
"owner_manifest_api_defaults_match_live": true
|
||||||
|
}
|
||||||
78
docs/keycape-approval-clients-rollout.md
Normal file
78
docs/keycape-approval-clients-rollout.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# KeyCape approval-client rollout and recovery
|
||||||
|
|
||||||
|
Accepted 2026-09-09 under CCR-2026-0017 and CCR-2026-0018. Both service
|
||||||
|
registrations are live on one ready KeyCape replica. The existing human OpenBao
|
||||||
|
login passed after replacement. [Live receipt](evidence/2026-09-09-keycape-verifier-admission.json).
|
||||||
|
The [attempt receipts](evidence/2026-09-09-keycape-activation-attempts.json) retain
|
||||||
|
failed checks and confirmed compatible rollback; no failed attempt is counted
|
||||||
|
as activation. Both OpenBao values remain at their initial version 1.
|
||||||
|
|
||||||
|
The source image is the immutable manifest
|
||||||
|
`forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611`.
|
||||||
|
Deployment authority is `sso-mfa/k8s/keycape/deployment.yaml`; the live config
|
||||||
|
remains in `sso/keycape-config`. The contained helper appends only the two
|
||||||
|
reviewed client registrations and preserves the signing key and every unrelated
|
||||||
|
configuration byte. It uses UID/resourceVersion tests for config and deployment
|
||||||
|
mutations, and retains the previous compatible pair in protected owner storage.
|
||||||
|
|
||||||
|
The platform command `scripts/keycape_approval_custody.py` opens through
|
||||||
|
`openbao-attended-exec.py` and the governed Warden `openbao-platform-admin-login`
|
||||||
|
envelope. It owns approved policies/roles, CAS=0 first provision, ESO delivery,
|
||||||
|
native scope/auth/namespace denials and reader revocation. It invokes this
|
||||||
|
repository's `sso-mfa/k8s/keycape/approval-clients-rollout.py` for image/config
|
||||||
|
replacement and service acceptance. Do not run the platform child directly.
|
||||||
|
On this workstation the verified Railiance kubeconfig is
|
||||||
|
`/home/worsch/.kube/config-railiance01`; the default config targets another endpoint.
|
||||||
|
|
||||||
|
Use `activate` only for a first provision with absent custody and delivery.
|
||||||
|
`resume-activate --prior-receipt <metadata receipt>` requires a completed compatible
|
||||||
|
rollback, unchanged version-1 custody, unchanged policies and detached reader roles.
|
||||||
|
It reattaches the same readers without writing any value. Both actions require a
|
||||||
|
unique `--receipt` and `--recovery` path. Successful service acceptance is followed
|
||||||
|
by a fresh attended login running the owner helper's `verify-after-login` action.
|
||||||
|
Existing active custody can be rechecked with the platform helper's `verify`
|
||||||
|
action. Repeating first provision against active custody is deliberately refused.
|
||||||
|
|
||||||
|
The verifier binary comes from the pinned image, without starting its extraction
|
||||||
|
container. Install it in the owner-protected path
|
||||||
|
`~/.local/share/key-cape/verified-bin/dcebd46/keycape` with mode 0700. Its SHA-256
|
||||||
|
must be `4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92`;
|
||||||
|
the helper checks this before custody mutation and before each verifier run.
|
||||||
|
Extraction needs only `docker create`, `docker cp <container>:/keycape <private path>`,
|
||||||
|
and removal of that never-started container. No credential enters that container.
|
||||||
|
|
||||||
|
Run the native verifier in the existing attended owner process. It receives the
|
||||||
|
same verifier-side credential already read privately for independent JWT checks,
|
||||||
|
through a child environment that is never printed or written to a file. The
|
||||||
|
KeyCape pod's internal-only egress policy does not admit its public HTTPS issuer;
|
||||||
|
this rollout makes no network-policy change. Container readiness checks the
|
||||||
|
manifest digest in `imageID`, since containerd's `image` field can hold a different
|
||||||
|
runtime configuration digest.
|
||||||
|
|
||||||
|
Independent JWT checks match the existing native contract: at most 30 seconds
|
||||||
|
of future issued-at time, strict not-before and expiry, exact audience/subject/
|
||||||
|
tenant/role/scope and a 900-second lifetime. Global JWT leeway is not used.
|
||||||
|
Wrong secrets and excess scopes require the exact issuer HTTP status/error/feature.
|
||||||
|
The public human OpenBao client is also denied `approval:consume`.
|
||||||
|
|
||||||
|
Reproducible preparation checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -B sso-mfa/k8s/keycape/test_approval_clients_rollout.py
|
||||||
|
python3 -B sso-mfa/k8s/keycape/exercise-approval-clients.py --receipt /tmp/<unique-scratch-receipt>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The second command requires Docker, the installed pinned verifier, PyYAML,
|
||||||
|
PyJWT and cryptography. It runs the pinned image behind loopback HTTPS with
|
||||||
|
synthetic signing keys and client credentials, including the native verifier.
|
||||||
|
It removes its container, TLS server and temporary credential files. It neither
|
||||||
|
reads production config nor opens an attended login. The platform's
|
||||||
|
`tests/test_keycape_approval_custody.py` separately exercises real ACLs, CAS=0,
|
||||||
|
revocation and version-preserving resume against disposable local OpenBao.
|
||||||
|
|
||||||
|
KEY-WP-0013-T02 is complete. KEY-WP-0013-T05 retains the approval UI's callback
|
||||||
|
and MFA/PKCE acceptance. Separate client-side delivery remains RPF-WP-0035-T05;
|
||||||
|
audit custody remains AUDIT-WP-0009-T09; claim/consume and native execution proof
|
||||||
|
remain APPROVAL-WP-0002 and SECRETS-WP-0009-T03. Initial provisioning did not
|
||||||
|
exercise natural JWT expiry or actual predecessor rotation. HFACT-WP-0001 retains
|
||||||
|
factory execution/grant/spend admission and Railiance worker acceptance.
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
import base64
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -26,11 +27,18 @@ spec = importlib.util.spec_from_file_location('pin', Path(__file__).with_name('o
|
||||||
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
|
pin = importlib.util.module_from_spec(spec); spec.loader.exec_module(pin)
|
||||||
ISSUER = 'https://kc.coulomb.social'
|
ISSUER = 'https://kc.coulomb.social'
|
||||||
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611'
|
IMAGE = 'forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611'
|
||||||
|
VERIFIER = Path('/home/worsch/.local/share/key-cape/verified-bin/dcebd46/keycape')
|
||||||
|
VERIFIER_SHA256 = '4bf93bbe9afe0bf2e21d51c03373a7e2b4be6864586bcc2d472eeb9abc913d92'
|
||||||
PRIOR_IMAGE = 'forgejo.coulomb.social/coulomb/key-cape:main-153258b'
|
PRIOR_IMAGE = 'forgejo.coulomb.social/coulomb/key-cape:main-153258b'
|
||||||
IDS = ('secrets-engine-approval', 'approval-engine-operator')
|
IDS = ('secrets-engine-approval', 'approval-engine-operator')
|
||||||
SECRET_NAMES = ('keycape-secrets-engine-approval-client', 'keycape-approval-engine-operator-client')
|
SECRET_NAMES = ('keycape-secrets-engine-approval-client', 'keycape-approval-engine-operator-client')
|
||||||
|
|
||||||
|
|
||||||
|
def verify_artifact():
|
||||||
|
require(VERIFIER.is_file() and not VERIFIER.is_symlink()
|
||||||
|
and hashlib.sha256(VERIFIER.read_bytes()).hexdigest() == VERIFIER_SHA256, 'pinned_verifier_artifact_required')
|
||||||
|
|
||||||
|
|
||||||
def registrations():
|
def registrations():
|
||||||
source = yaml.safe_load((KEYCAPE / 'config/service-clients.example.yaml').read_text())['clients']
|
source = yaml.safe_load((KEYCAPE / 'config/service-clients.example.yaml').read_text())['clients']
|
||||||
result = [next(c for c in source if c['clientId'] == name) for name in IDS]
|
result = [next(c for c in source if c['clientId'] == name) for name in IDS]
|
||||||
|
|
@ -115,7 +123,9 @@ def ready(kube, image, timeout=150):
|
||||||
podlist = data(command(kube + ['-n', 'sso', 'get', 'pods', '-l', 'app.kubernetes.io/name=keycape', '-o', 'json']))['items']
|
podlist = data(command(kube + ['-n', 'sso', 'get', 'pods', '-l', 'app.kubernetes.io/name=keycape', '-o', 'json']))['items']
|
||||||
if len(podlist) == 1 and not podlist[0]['metadata'].get('deletionTimestamp'):
|
if len(podlist) == 1 and not podlist[0]['metadata'].get('deletionTimestamp'):
|
||||||
containers = podlist[0].get('status', {}).get('containerStatuses', [])
|
containers = podlist[0].get('status', {}).get('containerStatuses', [])
|
||||||
if any(c['name'] == 'keycape' and c['ready'] and c['image'] == image for c in containers):
|
if any(c['name'] == 'keycape' and c['ready'] and
|
||||||
|
(c.get('imageID', '').removeprefix('docker-pullable://') == image
|
||||||
|
if '@sha256:' in image else c.get('image') == image) for c in containers):
|
||||||
return {'deployment_uid': dep['metadata']['uid'], 'generation': dep['metadata']['generation'],
|
return {'deployment_uid': dep['metadata']['uid'], 'generation': dep['metadata']['generation'],
|
||||||
'pod_uid': podlist[0]['metadata']['uid'], 'single_ready_replica': True}
|
'pod_uid': podlist[0]['metadata']['uid'], 'single_ready_replica': True}
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
@ -141,7 +151,10 @@ def http(path, fields=None, credentials=None):
|
||||||
with opener.open(request, timeout=20) as response:
|
with opener.open(request, timeout=20) as response:
|
||||||
return response.status, json.load(response)
|
return response.status, json.load(response)
|
||||||
except urllib.error.HTTPError as error:
|
except urllib.error.HTTPError as error:
|
||||||
return error.code, json.load(error)
|
try:
|
||||||
|
return error.code, json.load(error)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise LaneError('http_' + str(error.code) + '_non_json_error') from None
|
||||||
|
|
||||||
|
|
||||||
def denied(result, status, feature):
|
def denied(result, status, feature):
|
||||||
|
|
@ -150,53 +163,79 @@ def denied(result, status, feature):
|
||||||
and body.get('feature') == feature, 'keycape_denial_inconclusive')
|
and body.get('feature') == feature, 'keycape_denial_inconclusive')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def verified_claims(token, public_key):
|
||||||
|
# Match authclient/client.go: tolerate at most 30s future iat, but no
|
||||||
|
# extension to expiry or not-before. PyJWT's global leeway would extend both.
|
||||||
|
claims = jwt.decode(token, public_key, algorithms=['RS256'], issuer=ISSUER, audience='approval-engine',
|
||||||
|
options={'verify_iat': False, 'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
|
||||||
|
require(type(claims['iat']) is int and type(claims['exp']) is int
|
||||||
|
and claims['iat'] <= int(time.time()) + 30 and claims['iat'] < claims['exp'], 'issued_at_binding_failed')
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
def acceptance(kube, receipt):
|
def acceptance(kube, receipt):
|
||||||
|
receipt['acceptance_phase'] = 'discovery'
|
||||||
code, discovery = http('/.well-known/openid-configuration')
|
code, discovery = http('/.well-known/openid-configuration')
|
||||||
require(code == 200 and discovery['issuer'] == ISSUER
|
require(code == 200 and discovery['issuer'] == ISSUER
|
||||||
and discovery['token_endpoint'] == ISSUER + '/token'
|
and discovery['token_endpoint'] == ISSUER + '/token'
|
||||||
and discovery['jwks_uri'].startswith(ISSUER + '/'), 'discovery_binding_mismatch')
|
and discovery['jwks_uri'].startswith(ISSUER + '/'), 'discovery_binding_mismatch')
|
||||||
|
receipt['acceptance_phase'] = 'jwks'
|
||||||
code, jwks = http(discovery['jwks_uri'].removeprefix(ISSUER))
|
code, jwks = http(discovery['jwks_uri'].removeprefix(ISSUER))
|
||||||
require(code == 200, 'jwks_failed')
|
require(code == 200, 'jwks_failed')
|
||||||
receipt['clients'] = []
|
receipt['clients'] = []
|
||||||
for client, secret_name in zip(registrations(), SECRET_NAMES):
|
for client, secret_name in zip(registrations(), SECRET_NAMES):
|
||||||
|
receipt['acceptance_client'] = client['clientId']
|
||||||
|
receipt['acceptance_phase'] = 'verifier_secret_read'
|
||||||
secret = get(kube, 'secret', secret_name)
|
secret = get(kube, 'secret', secret_name)
|
||||||
credential = base64.b64decode(secret['data']['client-secret'], validate=True).decode()
|
credential = base64.b64decode(secret['data']['client-secret'], validate=True).decode()
|
||||||
scopes = ' '.join(client['allowedScopes'])
|
scopes = ' '.join(client['allowedScopes'])
|
||||||
|
receipt['acceptance_phase'] = 'token_exchange'
|
||||||
result, response = http('/token', {'grant_type': 'client_credentials', 'scope': scopes}, (client['clientId'], credential))
|
result, response = http('/token', {'grant_type': 'client_credentials', 'scope': scopes}, (client['clientId'], credential))
|
||||||
require(result == 200 and response.get('token_type') == 'Bearer' and response.get('expires_in') == 900, 'service_issuance_failed')
|
require(result == 200 and response.get('token_type') == 'Bearer' and response.get('expires_in') == 900, 'service_issuance_failed')
|
||||||
|
receipt['acceptance_phase'] = 'signature_and_claims'
|
||||||
token = response['access_token']; header = jwt.get_unverified_header(token)
|
token = response['access_token']; header = jwt.get_unverified_header(token)
|
||||||
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
|
keys = [key for key in jwks['keys'] if key['kid'] == header.get('kid')]
|
||||||
require(header.get('alg') == 'RS256' and len(keys) == 1, 'signing_key_selection_failed')
|
require(header.get('alg') == 'RS256' and len(keys) == 1, 'signing_key_selection_failed')
|
||||||
claims = jwt.decode(token, jwt.PyJWK.from_dict(keys[0]).key, algorithms=['RS256'],
|
claims = verified_claims(token, jwt.PyJWK.from_dict(keys[0]).key)
|
||||||
issuer=ISSUER, audience='approval-engine',
|
|
||||||
options={'require': ['exp', 'iat', 'sub', 'iss', 'aud']})
|
|
||||||
require(claims['sub'] == client['serviceSubject'] and claims['tenant'] == 'tenant:platform'
|
require(claims['sub'] == client['serviceSubject'] and claims['tenant'] == 'tenant:platform'
|
||||||
|
and claims['aud'] == 'approval-engine'
|
||||||
and claims['roles'] == client['roles'] and claims['exp'] - claims['iat'] == 900
|
and claims['roles'] == client['roles'] and claims['exp'] - claims['iat'] == 900
|
||||||
and claims['principal_type'] == 'service'
|
and claims['principal_type'] == 'service'
|
||||||
and set(claims['scope'].split()) == set(client['allowedScopes']), 'exact_claims_mismatch')
|
and set(claims['scope'].split()) == set(client['allowedScopes']), 'exact_claims_mismatch')
|
||||||
excessive = 'approval:approve' if client['clientId'] == IDS[0] else 'approval:consume'
|
excessive = 'approval:approve' if client['clientId'] == IDS[0] else 'approval:consume'
|
||||||
|
receipt['acceptance_phase'] = 'excess_scope_denial'
|
||||||
denied(http('/token', {'grant_type': 'client_credentials', 'scope': excessive},
|
denied(http('/token', {'grant_type': 'client_credentials', 'scope': excessive},
|
||||||
(client['clientId'], credential)), 400, 'scope')
|
(client['clientId'], credential)), 400, 'scope')
|
||||||
|
receipt['acceptance_phase'] = 'wrong_secret_denial'
|
||||||
denied(http('/token', {'grant_type': 'client_credentials', 'scope': scopes},
|
denied(http('/token', {'grant_type': 'client_credentials', 'scope': scopes},
|
||||||
(client['clientId'], secrets.token_urlsafe(48))), 401, 'Authorization')
|
(client['clientId'], secrets.token_urlsafe(48))), 401, 'Authorization')
|
||||||
args = kube + ['-n', 'sso', 'exec', 'deployment/keycape', '--', '/keycape', 'verify-client',
|
args = [str(VERIFIER), 'verify-client',
|
||||||
'-issuer', ISSUER, '-client-id', client['clientId'], '-audience', 'approval-engine', '-scope', scopes,
|
'-issuer', ISSUER, '-client-id', client['clientId'], '-audience', 'approval-engine', '-scope', scopes,
|
||||||
'-secret-env', client['secretRef'].removeprefix('env:'), '-expect-subject', client['serviceSubject'],
|
'-secret-env', client['secretRef'].removeprefix('env:'), '-expect-subject', client['serviceSubject'],
|
||||||
'-expect-tenant', 'tenant:platform', '-expect-roles', ','.join(client['roles']), '-deny-scope', excessive]
|
'-expect-tenant', 'tenant:platform', '-expect-roles', ','.join(client['roles']), '-deny-scope', excessive]
|
||||||
command(args)
|
receipt['acceptance_phase'] = 'pinned_artifact_verifier'
|
||||||
|
verify_artifact()
|
||||||
|
verifier_env = os.environ.copy()
|
||||||
|
verifier_env[client['secretRef'].removeprefix('env:')] = credential
|
||||||
|
command(args, env=verifier_env)
|
||||||
receipt['clients'].append({'client_id': client['clientId'], 'live_jwks_signature_verified': True,
|
receipt['clients'].append({'client_id': client['clientId'], 'live_jwks_signature_verified': True,
|
||||||
'exact_claims_verified': True, 'lifetime_seconds': 900, 'excess_scope_denied': True,
|
'exact_claims_verified': True, 'lifetime_seconds': 900, 'maximum_future_iat_seconds': 30, 'expiry_leeway_seconds': 0, 'excess_scope_denied': True,
|
||||||
'wrong_secret_denied': True, 'pod_verify_client_passed': True,
|
'wrong_secret_denied': True, 'pinned_artifact_verifier_passed': True,
|
||||||
|
'verifier_location': 'attended owner process', 'verifier_sha256': VERIFIER_SHA256,
|
||||||
'real_predecessor_rotation_tested': False, 'observed_wall_clock_expiry': False})
|
'real_predecessor_rotation_tested': False, 'observed_wall_clock_expiry': False})
|
||||||
|
receipt['acceptance_phase'] = 'human_consume_denial'
|
||||||
query = urllib.parse.urlencode({'client_id': 'openbao-admin', 'response_type': 'code',
|
query = urllib.parse.urlencode({'client_id': 'openbao-admin', 'response_type': 'code',
|
||||||
'redirect_uri': 'http://localhost:8250/oidc/callback', 'scope': 'openid approval:consume',
|
'redirect_uri': 'http://localhost:8250/oidc/callback', 'scope': 'openid approval:consume',
|
||||||
'code_challenge_method': 'S256', 'code_challenge': 'A' * 43, 'state': secrets.token_urlsafe(32)})
|
'code_challenge_method': 'S256', 'code_challenge': 'A' * 43, 'state': secrets.token_urlsafe(32)})
|
||||||
denied(http('/authorize?' + query), 400, 'scope')
|
denied(http('/authorize?' + query), 400, 'scope')
|
||||||
receipt['human_client_consume_denied'] = True
|
receipt['human_client_consume_denied'] = True
|
||||||
|
receipt['acceptance_phase'] = 'passed'
|
||||||
|
|
||||||
|
|
||||||
def rollout(kube, receipt, recovery_path):
|
def rollout(kube, receipt, recovery_path):
|
||||||
assert_cluster(kube)
|
assert_cluster(kube)
|
||||||
|
verify_artifact()
|
||||||
before = get(kube, 'secret', 'keycape-config')
|
before = get(kube, 'secret', 'keycape-config')
|
||||||
deployment = get(kube, 'deployment', 'keycape')
|
deployment = get(kube, 'deployment', 'keycape')
|
||||||
require(before['metadata']['uid'] == '2e94519d-1550-41c7-9701-2efe47fe1fd3'
|
require(before['metadata']['uid'] == '2e94519d-1550-41c7-9701-2efe47fe1fd3'
|
||||||
|
|
@ -223,7 +262,8 @@ def rollout(kube, receipt, recovery_path):
|
||||||
receipt.update(status='service_acceptance_passed_pending_fresh_human_login', image=IMAGE,
|
receipt.update(status='service_acceptance_passed_pending_fresh_human_login', image=IMAGE,
|
||||||
config_resource_version=after['metadata']['resourceVersion'], signing_key_unchanged=True,
|
config_resource_version=after['metadata']['resourceVersion'], signing_key_unchanged=True,
|
||||||
unrelated_config_bytes_preserved=True, existing_human_login_after=False)
|
unrelated_config_bytes_preserved=True, existing_human_login_after=False)
|
||||||
except Exception:
|
except Exception as failure:
|
||||||
|
receipt['failure_class'] = type(failure).__name__
|
||||||
# Read after uncertain API outcomes too; never assume a timeout means no write.
|
# Read after uncertain API outcomes too; never assume a timeout means no write.
|
||||||
now = get(kube, 'secret', 'keycape-config')
|
now = get(kube, 'secret', 'keycape-config')
|
||||||
require(now['metadata']['uid'] == before['metadata']['uid'], 'rollback_config_identity_drift')
|
require(now['metadata']['uid'] == before['metadata']['uid'], 'rollback_config_identity_drift')
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Deployment + Service — KeyCape (namespace: sso)
|
# Deployment + Service — KeyCape (namespace: sso)
|
||||||
#
|
#
|
||||||
# KeyCape is the OIDC orchestration layer. It is stateless: all persistent
|
# KeyCape orchestrates OIDC. Pending logins and authorization codes are process-local;
|
||||||
# state lives in Authelia (session), LLDAP (users), and privacyIDEA (MFA tokens).
|
# use one replica with Recreate during replacement. Persistent identity state remains
|
||||||
# No PVC is required.
|
# in Authelia, LLDAP and privacyIDEA. No PVC is required.
|
||||||
#
|
#
|
||||||
# Configuration is stored entirely in the keycape-config Secret, which holds
|
# Configuration is stored entirely in the keycape-config Secret, which holds
|
||||||
# a complete config.yaml and the RSA private key used to sign OIDC tokens
|
# a complete config.yaml and the RSA private key used to sign OIDC tokens
|
||||||
|
|
@ -33,7 +33,7 @@ spec:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: keycape
|
app.kubernetes.io/name: keycape
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate # stateless — safe to roll
|
type: Recreate # one issuer instance; process-local login/code state
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
|
|
@ -50,7 +50,7 @@ spec:
|
||||||
- name: keycape
|
- name: keycape
|
||||||
# Image published to the self-hosted Forgejo OCI registry (KEY-WP-0002).
|
# Image published to the self-hosted Forgejo OCI registry (KEY-WP-0002).
|
||||||
# KEY-WP-0012: canonical OIDC subject resolution for /userinfo.
|
# KEY-WP-0012: canonical OIDC subject resolution for /userinfo.
|
||||||
image: forgejo.coulomb.social/coulomb/key-cape:main-153258b
|
image: forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -67,6 +67,17 @@ spec:
|
||||||
name: keycape-rapp-qonto-client
|
name: keycape-rapp-qonto-client
|
||||||
key: client-secret
|
key: client-secret
|
||||||
|
|
||||||
|
- name: KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycape-secrets-engine-approval-client
|
||||||
|
key: client-secret
|
||||||
|
- name: KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycape-approval-engine-operator-client
|
||||||
|
key: client-secret
|
||||||
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
# keycape-config Secret provides config.yaml and key.pem
|
# keycape-config Secret provides config.yaml and key.pem
|
||||||
- name: config-secret
|
- name: config-secret
|
||||||
|
|
@ -89,7 +100,7 @@ spec:
|
||||||
failureThreshold: 3
|
failureThreshold: 3
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /readyz
|
||||||
port: 8080
|
port: 8080
|
||||||
initialDelaySeconds: 0
|
initialDelaySeconds: 0
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
|
|
|
||||||
92
sso-mfa/k8s/keycape/exercise-approval-clients.py
Normal file
92
sso-mfa/k8s/keycape/exercise-approval-clients.py
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import base64, importlib.util, json, os, secrets, socket, subprocess, tempfile, time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
import yaml
|
||||||
|
import http.server, ssl, threading, urllib.request, urllib.error, datetime, ipaddress
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.x509.oid import NameOID
|
||||||
|
import argparse
|
||||||
|
parser=argparse.ArgumentParser(description='Exercise the pinned image and verifier over loopback HTTPS using synthetic credentials only.')
|
||||||
|
parser.add_argument('--receipt', required=True, type=Path)
|
||||||
|
args=parser.parse_args()
|
||||||
|
spec=importlib.util.spec_from_file_location('rollout',Path(__file__).with_name('approval-clients-rollout.py'))
|
||||||
|
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||||
|
m.verify_artifact()
|
||||||
|
def call(args):
|
||||||
|
p=subprocess.run(args,capture_output=True,timeout=45)
|
||||||
|
if p.returncode: raise RuntimeError('contained_docker_command_failed')
|
||||||
|
return p
|
||||||
|
sock=socket.socket(); sock.bind(('127.0.0.1',0)); port=sock.getsockname()[1]; sock.close()
|
||||||
|
class Proxy(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args): pass
|
||||||
|
def handle_proxy(self):
|
||||||
|
body = self.rfile.read(int(self.headers.get('Content-Length', 0))) if self.command == 'POST' else None
|
||||||
|
req = urllib.request.Request(f'http://127.0.0.1:{port}'+self.path, data=body,
|
||||||
|
headers={k:self.headers[k] for k in ['Content-Type','Authorization'] if k in self.headers})
|
||||||
|
try:
|
||||||
|
response=urllib.request.urlopen(req,timeout=20)
|
||||||
|
except urllib.error.HTTPError as error: response=error
|
||||||
|
with response:
|
||||||
|
self.send_response(response.code)
|
||||||
|
self.send_header('Content-Type', response.headers.get('Content-Type','application/json'))
|
||||||
|
self.end_headers(); self.wfile.write(response.read())
|
||||||
|
do_GET=handle_proxy
|
||||||
|
do_POST=handle_proxy
|
||||||
|
server=http.server.ThreadingHTTPServer(('127.0.0.1',0),Proxy)
|
||||||
|
https_port=server.server_address[1]
|
||||||
|
name='keycape-approval-exercise-'+secrets.token_hex(5)
|
||||||
|
with tempfile.TemporaryDirectory(prefix='keycape-private-exercise-') as temp:
|
||||||
|
root=Path(temp)
|
||||||
|
key=rsa.generate_private_key(public_exponent=65537,key_size=2048)
|
||||||
|
(root/'key.pem').write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||||
|
os.chmod(root/'key.pem',0o600)
|
||||||
|
name_attr=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,'KeyCape local exercise')])
|
||||||
|
cert=(x509.CertificateBuilder().subject_name(name_attr).issuer_name(name_attr).public_key(key.public_key())
|
||||||
|
.serial_number(x509.random_serial_number()).not_valid_before(datetime.datetime.now(datetime.timezone.utc)-datetime.timedelta(minutes=1))
|
||||||
|
.not_valid_after(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(hours=1))
|
||||||
|
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]),False)
|
||||||
|
.add_extension(x509.BasicConstraints(ca=True,path_length=None),True))
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
(root/'cert.pem').write_bytes(cert.sign(key,hashes.SHA256()).public_bytes(serialization.Encoding.PEM))
|
||||||
|
context=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(root/'cert.pem',root/'key.pem')
|
||||||
|
server.socket=context.wrap_socket(server.socket,server_side=True)
|
||||||
|
threading.Thread(target=server.serve_forever,daemon=True).start()
|
||||||
|
config=yaml.safe_load((m.KEYCAPE/'config/dev-config.yaml').read_text())
|
||||||
|
config.update(issuer=f'https://127.0.0.1:{https_port}')
|
||||||
|
config['authelia']['issuer']='https://auth.coulomb.social'
|
||||||
|
config['clients'].extend(m.registrations())
|
||||||
|
(root/'config.yaml').write_text(yaml.safe_dump(config,sort_keys=False))
|
||||||
|
os.chmod(root/'config.yaml',0o600)
|
||||||
|
values={c['clientId']:secrets.token_urlsafe(48) for c in m.registrations()}
|
||||||
|
(root/'env').write_text('KEYCAPE_CONFIG=/etc/keycape/config.yaml\n'+''.join(c['secretRef'].removeprefix('env:')+'='+values[c['clientId']]+'\n' for c in m.registrations()))
|
||||||
|
os.chmod(root/'env',0o600)
|
||||||
|
created=False
|
||||||
|
try:
|
||||||
|
call(['docker','run','-d','--rm','--name',name,'--user',str(os.getuid()),'--publish',f'127.0.0.1:{port}:8080',
|
||||||
|
'--env-file',str(root/'env'),'--mount','type=bind,source='+temp+',target=/etc/keycape,readonly',m.IMAGE])
|
||||||
|
created=True
|
||||||
|
with patch.object(m,'ISSUER',config['issuer']), patch.dict(os.environ,{'SSL_CERT_FILE':str(root/'cert.pem')}):
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
status,_=m.http('/.well-known/openid-configuration')
|
||||||
|
if status==200: break
|
||||||
|
except Exception: pass
|
||||||
|
time.sleep(.5)
|
||||||
|
else: raise RuntimeError('scratch_keycape_not_ready')
|
||||||
|
def fake_get(kube,kind,name):
|
||||||
|
index=m.SECRET_NAMES.index(name)
|
||||||
|
return {'data': {'client-secret':base64.b64encode(values[m.IDS[index]].encode()).decode()}}
|
||||||
|
receipt={}
|
||||||
|
with patch.object(m,'get',side_effect=fake_get):
|
||||||
|
m.acceptance([],receipt)
|
||||||
|
receipt.update(target='disposable pinned KeyCape image; synthetic keys and clients only',
|
||||||
|
pinned_native_cli_verification='passed against loopback HTTPS with synthetic credentials',cleanup_complete=False)
|
||||||
|
finally:
|
||||||
|
if created: call(['docker','stop','--time=5',name])
|
||||||
|
server.shutdown(); server.server_close()
|
||||||
|
receipt['cleanup_complete']=True
|
||||||
|
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||||
|
with os.fdopen(fd,'w') as out: out.write(json.dumps(receipt,indent=2)+'\n')
|
||||||
|
print('Pinned-image scratch acceptance passed; synthetic credentials removed.')
|
||||||
|
|
@ -83,7 +83,7 @@ class RolloutTests(unittest.TestCase):
|
||||||
changed['metadata']['resourceVersion'] = str(int(changed['metadata']['resourceVersion']) + 1)
|
changed['metadata']['resourceVersion'] = str(int(changed['metadata']['resourceVersion']) + 1)
|
||||||
state[kind] = changed
|
state[kind] = changed
|
||||||
return copy.deepcopy(changed)
|
return copy.deepcopy(changed)
|
||||||
with tempfile.TemporaryDirectory() as directory, patch.object(m, 'assert_cluster'), \
|
with tempfile.TemporaryDirectory() as directory, patch.object(m, 'assert_cluster'), patch.object(m, 'verify_artifact'), \
|
||||||
patch.object(m, 'get', side_effect=lambda kube, kind, name: copy.deepcopy(state[kind])), \
|
patch.object(m, 'get', side_effect=lambda kube, kind, name: copy.deepcopy(state[kind])), \
|
||||||
patch.object(m, 'patch_object', side_effect=fake_patch), patch.object(m, 'ready', return_value={}), \
|
patch.object(m, 'patch_object', side_effect=fake_patch), patch.object(m, 'ready', return_value={}), \
|
||||||
patch.object(m, 'acceptance', side_effect=m.LaneError('synthetic_acceptance_failure')):
|
patch.object(m, 'acceptance', side_effect=m.LaneError('synthetic_acceptance_failure')):
|
||||||
|
|
@ -95,6 +95,29 @@ class RolloutTests(unittest.TestCase):
|
||||||
self.assertEqual(state['deployment']['spec'], original['deployment']['spec'])
|
self.assertEqual(state['deployment']['spec'], original['deployment']['spec'])
|
||||||
self.assertEqual((Path(directory) / 'recovery.json').stat().st_mode & 0o777, 0o600)
|
self.assertEqual((Path(directory) / 'recovery.json').stat().st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
def test_ready_matches_manifest_imageid_not_runtime_config_id(self):
|
||||||
|
dep = deployment(); dep['metadata']['generation'] = 30
|
||||||
|
dep['status'] = {'observedGeneration': 30, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1, 'replicas': 1}
|
||||||
|
pod = {'metadata': {'uid': 'fixture-pod'}, 'status': {'containerStatuses': [
|
||||||
|
{'name': 'keycape', 'ready': True, 'image': 'sha256:runtime-config-id', 'imageID': m.IMAGE}]}}
|
||||||
|
from types import SimpleNamespace
|
||||||
|
with patch.object(m, 'get', return_value=dep), patch.object(m, 'command', return_value=SimpleNamespace(stdout=json.dumps({'items': [pod]}).encode())):
|
||||||
|
self.assertTrue(m.ready([], m.IMAGE, timeout=1)['single_ready_replica'])
|
||||||
|
|
||||||
|
def test_iat_skew_matches_native_contract_without_extending_expiry(self):
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
now = int(m.time.time())
|
||||||
|
claims = {'iss': m.ISSUER, 'aud': 'approval-engine', 'sub': 'synthetic-service', 'iat': now + 2, 'exp': now + 902}
|
||||||
|
encoded = m.jwt.encode(claims, key, algorithm='RS256')
|
||||||
|
self.assertEqual(m.verified_claims(encoded, key.public_key())['iat'], now + 2)
|
||||||
|
future = m.jwt.encode(dict(claims, iat=now+60), key, algorithm='RS256')
|
||||||
|
with self.assertRaisesRegex(m.LaneError, 'issued_at_binding_failed'):
|
||||||
|
m.verified_claims(future, key.public_key())
|
||||||
|
expired = m.jwt.encode(dict(claims, iat=now-900, exp=now-1), key, algorithm='RS256')
|
||||||
|
with self.assertRaises(m.jwt.ExpiredSignatureError):
|
||||||
|
m.verified_claims(expired, key.public_key())
|
||||||
|
|
||||||
def test_unrelated_refusal_never_passes(self):
|
def test_unrelated_refusal_never_passes(self):
|
||||||
for status, body in [(500, {}), (400, {'error': 'invalid_profile_usage', 'feature': 'client_id'}),
|
for status, body in [(500, {}), (400, {'error': 'invalid_profile_usage', 'feature': 'client_id'}),
|
||||||
(401, {'error': 'invalid_profile_usage', 'feature': 'scope'})]:
|
(401, {'error': 'invalid_profile_usage', 'feature': 'scope'})]:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue