Implement audited lost-factor recovery and track remaining P04 acceptance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
KeyCape factor custody acceptance / acceptance (push) Successful in 4s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
codex 2026-09-13 17:28:45 +02:00
parent 0c4db1ef0b
commit 9575b51d6e
3 changed files with 119 additions and 0 deletions

View file

@ -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.

View file

@ -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())

View file

@ -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()