diff --git a/docs/keycape-factor-recovery.md b/docs/keycape-factor-recovery.md new file mode 100644 index 0000000..84af686 --- /dev/null +++ b/docs/keycape-factor-recovery.md @@ -0,0 +1,70 @@ +# Attended lost-factor recovery + +Implementation tracked by KEY-WP-0036 and USER-WP-0030-T03. Provider-local +recovery passes isolated tests; the attended production wrapper and portal +integration still require acceptance. No real account has been recovered by +this procedure yet. Run only as an attended platform operator with Kubernetes +administration access. This is not a public recovery API. + +The wrapper derives the audit actor from an authenticated OpenBao entity with +the platform-admin policy. Root and workload credentials are not substitutes. +It checks the cluster identity and invokes the reviewed KeyCape provider-local +module, without exporting provider credentials. OpenBao identity does not grant +Kubernetes access: the operator must independently have that access. + +## Procedure + +1. Establish the exact directory login, realm, selected factor serial and support + reference. Verify ownership through the organization's account-recovery + procedure; knowing a username or password alone is not identity verification. +2. Review both scripts (`scripts/keycape_factor_recovery.py` here and + `../key-cape/scripts/factor_recovery.py`). Keep the sibling repositories at + their reviewed revisions. Route attended authentication with `warden route + show openbao-platform-admin-login --json` and the lane's contained execution + procedure. The owner command must remain silent; inspect only its receipt. +3. Preview using the owner command below inside the attended envelope. Replace + placeholders with metadata only. The receipt path must not already exist. + + ```sh + python3 scripts/keycape_factor_recovery.py --user LOGIN --serial SERIAL --reference SUPPORT-REFERENCE --receipt /tmp/recovery-preview.json + ``` + +4. Review the receipt's identity, factor, active state, version and scope. + `shared_identity_across_applications` means the change affects every application + using this identity, not just one tenant. Serialize recovery operations for + this identity/reference; this operator tool is not a concurrent request service. +5. After identity verification, invoke the same command with `--apply + --identity-verified --expected-version VERSION` and a fresh receipt path. + The verification flag records the operator's attestation; it does not perform + identity verification. Preserve the original reference and preview version. +6. Require a successful receipt and durable `keycape.factor.recovery` provider + audit entry for that reference. Recheck factor state. Guide the user through + a fresh login and replacement-factor enrollment, including possession proof. + Applications requiring AAL2 stay inaccessible until another confirmed factor + is usable. Disabling one factor alone does not promise restored access. + +## Failure and recovery + +- Ownership/realm mismatch, missing identity verification, stale preview or a + conflicting reference: no intended mutation; correct the target or obtain a + new preview. Do not silently substitute another account or factor. +- Failure to persist the initial audit entry prevents disabling the factor. +- A failure after disabling may mean the change succeeded but completion evidence + was interrupted. Retry the original approved request with the same reference + and version. Readback reconciles its audit without repeating the mutation. +- Replacement/reassignment or reactivation invalidates the old confirmation. +- Never automatically re-enable a lost factor as rollback. Escalate the support + case if the user cannot enroll a replacement through the supported flow. + +Recovery disables exactly one owned factor; it preserves account, password and +other factors. Receipts contain support/identity metadata and must be retained +with appropriate access controls. Never attach JWTs, OTP seeds or passwords. + +## Automated evidence + +`make keycape-factor-tests` tests authenticated actor derivation and denial for +reader/root identities. KeyCape's `test_factor_recovery.py` tests preview, +ownership, verification, stale/replaced factors, audit outages and retry. +`provider-onboarding-contract.py` repeats recovery against the installed provider +implementation with an isolated database and separate temporary audit database. +Production wrapper/browser acceptance remains a separate task in KEY-WP-0036. diff --git a/scripts/keycape_factor_recovery.py b/scripts/keycape_factor_recovery.py new file mode 100644 index 0000000..3f594f5 --- /dev/null +++ b/scripts/keycape_factor_recovery.py @@ -0,0 +1,36 @@ +"""Silent attended-owner front door. No provider administrative credential is exported.""" +import argparse,json,os,re,subprocess +from pathlib import Path +from keycape_factor_metadata import call +CLUSTER="a553c742-0115-43d4-99a4-a5ca56fe0786" +def owner_identity(): + data=call("token","lookup","-format=json")["data"] + policies=set(data.get("policies",[]))|set(data.get("identity_policies",[])) + entity=data.get("entity_id","") + if "platform-admin" not in policies or not re.fullmatch(r"[0-9a-f-]{36}",entity):raise ValueError("attended_platform_identity_required") + return "openbao:"+entity + +def execute(args): + actor=owner_identity() + r=subprocess.run(["kubectl","get","ns","kube-system","-o","json"],capture_output=True,check=True,timeout=20) + if json.loads(r.stdout)["metadata"]["uid"]!=CLUSTER:raise ValueError("wrong_cluster") + source=(Path(__file__).resolve().parents[2]/"key-cape/scripts/factor_recovery.py").read_text() + request=dict(user=args.user,serial=args.serial,realm="coulomb",actor=actor,reference=args.reference,apply=args.apply,expected_version=args.expected_version,identity_verified=args.identity_verified) + # The source is code only; identity input travels through stdin, never a shell. + r=subprocess.run(["kubectl","-n","mfa","exec","-i","deployment/privacyidea","-c","privacyidea","--","python3","-c",source],input=json.dumps(request).encode(),capture_output=True,timeout=45) + result=json.loads(r.stdout) + allowed={"success","status","user","serial","realm","actor","reference","scope","operation","version","active","changes_applied","replayed","failure"} + if not isinstance(result,dict) or set(result)-allowed:raise ValueError("unexpected_provider_receipt") + return result + +def main(): + p=argparse.ArgumentParser(description="Preview or perform one identity-verified factor recovery through attended OpenBao authentication") + p.add_argument("--user",required=True);p.add_argument("--serial",required=True);p.add_argument("--reference",required=True) + p.add_argument("--apply",action="store_true");p.add_argument("--identity-verified",action="store_true");p.add_argument("--expected-version") + p.add_argument("--receipt",type=Path,required=True);a=p.parse_args() + if any(os.environ.get(k) for k in ("BAO_TOKEN","VAULT_TOKEN","OPENBAO_TOKEN")):return 2 + fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600);result={"success":False} + try:result=execute(a);return 0 if result.get("success") else 1 + except Exception:result["failure"]="owner_operation_unavailable";return 1 + finally:os.write(fd,json.dumps(result,indent=2).encode());os.close(fd) +if __name__=="__main__":raise SystemExit(main()) diff --git a/tests/test_keycape_factor_recovery.py b/tests/test_keycape_factor_recovery.py new file mode 100644 index 0000000..1e6c350 --- /dev/null +++ b/tests/test_keycape_factor_recovery.py @@ -0,0 +1,13 @@ +import importlib.util,sys,unittest +from pathlib import Path +from unittest.mock import patch +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'scripts')) +import keycape_factor_recovery as m +class OwnerRecoveryTests(unittest.TestCase): + def test_actor_comes_from_authenticated_entity(self): + with patch.object(m,'call',return_value={'data':{'policies':['platform-admin'],'entity_id':'11111111-1111-1111-1111-111111111111'}}): + self.assertEqual(m.owner_identity(),'openbao:11111111-1111-1111-1111-111111111111') + def test_reader_or_missing_entity_cannot_run_recovery(self): + for data in [{'policies':['workload-kv-read-keycape-factor-read'],'entity_id':'11111111-1111-1111-1111-111111111111'},{'policies':['root'],'entity_id':''},{'policies':['root'],'entity_id':'11111111-1111-1111-1111-111111111111'}]: + with patch.object(m,'call',return_value={'data':data}): + with self.assertRaises(ValueError):m.owner_identity()