Implement audited lost-factor recovery and track remaining P04 acceptance
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
0c4db1ef0b
commit
9575b51d6e
3 changed files with 119 additions and 0 deletions
70
docs/keycape-factor-recovery.md
Normal file
70
docs/keycape-factor-recovery.md
Normal 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.
|
||||
36
scripts/keycape_factor_recovery.py
Normal file
36
scripts/keycape_factor_recovery.py
Normal 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())
|
||||
13
tests/test_keycape_factor_recovery.py
Normal file
13
tests/test_keycape_factor_recovery.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue