MHamdan commited on
Commit
67ba593
Β·
verified Β·
1 Parent(s): 824d0dd

CI deploy a7d795f

Browse files
Dockerfile CHANGED
@@ -55,10 +55,10 @@ COPY build_info.json ./
55
 
56
  # D1.1 opt-in runtime-diagnostic entrypoint (no-op unless AMANPAY_D1_RUNTIME_DIAGNOSTIC=1).
57
  COPY scripts/d1_entrypoint.sh scripts/d1_space_diagnostic.py scripts/d1_space_proof.py ./scripts/
58
- # D2 operator bootstrap CLIs (tenant init, invitations, account list, revoke) β€” run in-Space.
59
  COPY scripts/d2_initialize_tenant.py scripts/d2_issue_operator_invite.py \
60
  scripts/d2_issue_customer_invites.py scripts/d2_list_accounts.py \
61
- scripts/d2_revoke_invitation.py ./scripts/
62
  RUN chmod +x scripts/d1_entrypoint.sh
63
 
64
  # Writable dirs for weights + caches (owned so the Space's non-root user can write).
 
55
 
56
  # D1.1 opt-in runtime-diagnostic entrypoint (no-op unless AMANPAY_D1_RUNTIME_DIAGNOSTIC=1).
57
  COPY scripts/d1_entrypoint.sh scripts/d1_space_diagnostic.py scripts/d1_space_proof.py ./scripts/
58
+ # D2 operator bootstrap CLIs + in-Space synthetic proof (run in-Space; opt-in, dormant by default).
59
  COPY scripts/d2_initialize_tenant.py scripts/d2_issue_operator_invite.py \
60
  scripts/d2_issue_customer_invites.py scripts/d2_list_accounts.py \
61
+ scripts/d2_revoke_invitation.py scripts/d2_space_proof.py ./scripts/
62
  RUN chmod +x scripts/d1_entrypoint.sh
63
 
64
  # Writable dirs for weights + caches (owned so the Space's non-root user can write).
build_info.json CHANGED
@@ -1 +1 @@
1
- {"commit":"7ec800a","build_time":"2026-07-14T15:54:44Z","frontend":"1.0.0"}
 
1
+ {"commit":"a7d795f","build_time":"2026-07-14T16:37:16Z","frontend":"1.0.0"}
scripts/d1_entrypoint.sh CHANGED
@@ -30,5 +30,14 @@ if [ "${AMANPAY_D1_SPACE_PROOF:-0}" = "1" ]; then
30
  || echo "[d1-entrypoint] space-proof exited non-zero (non-fatal); continuing to app startup"
31
  fi
32
 
 
 
 
 
 
 
 
 
 
33
  # Hand off to the application as PID-equivalent so SIGTERM/SIGINT reach it directly.
34
  exec "$@"
 
30
  || echo "[d1-entrypoint] space-proof exited non-zero (non-fatal); continuing to app startup"
31
  fi
32
 
33
+ # D2 in-Space synthetic identity/lifecycle + recovery proof (opt-in, isolated, fail-safe). Runs a
34
+ # single stage (full|verify|negatives) only when AMANPAY_D2_SPACE_PROOF=1, then continues into
35
+ # ordinary startup. Strictly synthetic, no WAL, no participant data, never fatal.
36
+ if [ "${AMANPAY_D2_SPACE_PROOF:-0}" = "1" ]; then
37
+ echo "[d1-entrypoint] AMANPAY_D2_SPACE_PROOF=1 β†’ running D2 in-Space proof (stage=${AMANPAY_D2_SPACE_PROOF_STAGE:-full})"
38
+ python scripts/d2_space_proof.py \
39
+ || echo "[d1-entrypoint] d2-space-proof exited non-zero (non-fatal); continuing to app startup"
40
+ fi
41
+
42
  # Hand off to the application as PID-equivalent so SIGTERM/SIGINT reach it directly.
43
  exec "$@"
scripts/d2_space_proof.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """D2 β€” In-Space synthetic identity/lifecycle + recovery proof (opt-in, fail-safe, one-shot).
3
+
4
+ Runs INSIDE the deployed HF Space (via the entrypoint) on the ACTUAL Space runtime, BEFORE
5
+ uvicorn starts. It exercises the merged D2 identity code end-to-end on the real filesystem and
6
+ SQLite runtime, using a real in-process ES256 WebAuthn channel (the "equivalent cryptographically
7
+ verified WebAuthn test channel" the D2 runbook permits), and uploads REDACTED evidence to the
8
+ private D2 proof bucket so it can be verified out-of-band.
9
+
10
+ Strictly synthetic β€” NO participant data, NO real names/emails/phones/biometrics. It never
11
+ enables WAL, never modifies Payment Core / PDP / ASAP, and is NEVER fatal (a failure is caught
12
+ and the app still starts).
13
+
14
+ Stages (AMANPAY_D2_SPACE_PROOF_STAGE):
15
+ * ``full`` β€” verify runtime facts (Β§5), bootstrap tenant+operator+customer, run the
16
+ lifecycle (Β§6-Β§9,Β§12), create an encrypted synthetic snapshot, emit evidence.
17
+ * ``verify`` β€” after a factory rebuild: prove restore-on-start recovered the synthetic DB
18
+ (Β§10), emit post-recovery evidence, mark the generation restore_tested.
19
+ * ``negatives`` β€” isolated negative-recovery checks (Β§11) on throwaway copies.
20
+
21
+ Evidence NEVER contains invitation codes, session/CSRF tokens, credential public-key bytes,
22
+ challenge values, private keys, or PII β€” only states, counts, hashes and generation ids.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import hashlib
28
+ import json
29
+ import os
30
+ import secrets
31
+ import sqlite3
32
+ import tempfile
33
+ import time
34
+
35
+ # --- deployed D2 identity code (all under amanpay/, shipped in the image) ---
36
+ from amanpay.identity.config import D2Config, store_key
37
+ from amanpay.identity.operator import OperatorService
38
+ from amanpay.identity.service import IdentityService
39
+ from amanpay.identity.storage import D2Storage
40
+ from amanpay.simulation_storage import util
41
+
42
+ PROOF_VERSION = "1"
43
+ TENANT_SLUG = "d2-space-proof"
44
+ BUCKET = os.getenv("AMANPAY_D2_BUCKET") or os.getenv("AMANPAY_BUCKET") or "MHamdan/amanpay-d2-proof"
45
+ RP_ID = os.getenv("AMANPAY_D2_RP_ID", "mhamdan-amanpay.hf.space")
46
+ ORIGIN = os.getenv("AMANPAY_D2_ORIGIN", "https://mhamdan-amanpay.hf.space")
47
+
48
+
49
+ # ======================================================================= authenticator
50
+ class _SoftAuthenticator:
51
+ """Minimal in-process ES256 (ECDSA P-256) WebAuthn authenticator β€” real signatures.
52
+
53
+ Verification is performed by the deployed py_webauthn via the D2 WebAuthn service; nothing
54
+ is weakened. ``backup`` toggles BE/BS so synced vs single-device counter handling is exercised.
55
+ """
56
+
57
+ def __init__(self, backup: bool = True):
58
+ import cbor2 # noqa: F401 (availability check)
59
+ self.backup = backup
60
+ self._creds: dict = {}
61
+
62
+ # -- helpers --
63
+ @staticmethod
64
+ def _b64u(data: bytes) -> str:
65
+ import base64
66
+ return base64.urlsafe_b64encode(data).decode().rstrip("=")
67
+
68
+ def _flags(self, *, at: bool) -> int:
69
+ f = 0x01 | 0x04 # UP | UV
70
+ if self.backup:
71
+ f |= 0x08 | 0x10 # BE | BS
72
+ if at:
73
+ f |= 0x40 # AT
74
+ return f
75
+
76
+ def _auth_data(self, rp_id, *, sign_count, cred_id, priv, include_attested):
77
+ import cbor2
78
+ out = hashlib.sha256(rp_id.encode()).digest() + bytes([self._flags(at=include_attested)])
79
+ out += sign_count.to_bytes(4, "big")
80
+ if include_attested:
81
+ n = priv.public_key().public_numbers()
82
+ cose = cbor2.dumps({1: 2, 3: -7, -1: 1, -2: n.x.to_bytes(32, "big"),
83
+ -3: n.y.to_bytes(32, "big")})
84
+ out += b"\x00" * 16 + len(cred_id).to_bytes(2, "big") + cred_id + cose
85
+ return out
86
+
87
+ def create(self, options_json: str, origin: str | None = None) -> dict:
88
+ import cbor2
89
+ from cryptography.hazmat.primitives.asymmetric import ec
90
+ opts = json.loads(options_json)
91
+ opts = opts.get("publicKey", opts)
92
+ cred_id = secrets.token_bytes(20)
93
+ priv = ec.generate_private_key(ec.SECP256R1())
94
+ self._creds[self._b64u(cred_id)] = {"priv": priv, "sc": 0, "uh": opts["user"]["id"]}
95
+ cdj = json.dumps({"type": "webauthn.create", "challenge": opts["challenge"],
96
+ "origin": origin or ORIGIN, "crossOrigin": False},
97
+ separators=(",", ":")).encode()
98
+ ad = self._auth_data(opts["rp"]["id"], sign_count=0, cred_id=cred_id, priv=priv,
99
+ include_attested=True)
100
+ att = cbor2.dumps({"fmt": "none", "attStmt": {}, "authData": ad})
101
+ return {"id": self._b64u(cred_id), "rawId": self._b64u(cred_id), "type": "public-key",
102
+ "response": {"clientDataJSON": self._b64u(cdj), "attestationObject": self._b64u(att),
103
+ "transports": ["internal", "hybrid"]}, "clientExtensionResults": {}}
104
+
105
+ def get(self, options_json: str, credential_id: str | None = None,
106
+ origin: str | None = None) -> dict:
107
+ from cryptography.hazmat.primitives import hashes
108
+ from cryptography.hazmat.primitives.asymmetric import ec
109
+ opts = json.loads(options_json)
110
+ opts = opts.get("publicKey", opts)
111
+ cid = credential_id or next(iter(self._creds))
112
+ st = self._creds[cid]
113
+ if not self.backup:
114
+ st["sc"] += 1
115
+ cdj = json.dumps({"type": "webauthn.get", "challenge": opts["challenge"],
116
+ "origin": origin or ORIGIN, "crossOrigin": False},
117
+ separators=(",", ":")).encode()
118
+ ad = self._auth_data(opts["rpId"], sign_count=st["sc"], cred_id=None, priv=None,
119
+ include_attested=False)
120
+ sig = st["priv"].sign(ad + hashlib.sha256(cdj).digest(), ec.ECDSA(hashes.SHA256()))
121
+ return {"id": cid, "rawId": cid, "type": "public-key",
122
+ "response": {"clientDataJSON": self._b64u(cdj), "authenticatorData": self._b64u(ad),
123
+ "signature": self._b64u(sig), "userHandle": st["uh"]},
124
+ "clientExtensionResults": {}}
125
+
126
+
127
+ # ============================================================================ infra
128
+ def _deployed_commit() -> str:
129
+ for p in ("/app/build_info.json", "build_info.json"):
130
+ with contextlib.suppress(Exception):
131
+ return str(json.load(open(p)).get("commit") or "unknown")
132
+ return os.getenv("AMANPAY_COMMIT", "unknown")
133
+
134
+
135
+ def _bucket():
136
+ from amanpay.simulation_storage.hf_bucket_store import HFBucketObjectStore
137
+ return HFBucketObjectStore(BUCKET, os.environ["HF_TOKEN"])
138
+
139
+
140
+ def _put_evidence(stage: str, result: dict) -> str | None:
141
+ """Upload a REDACTED stage-result artifact to the private bucket. Returns its key or None."""
142
+ try:
143
+ result = _redact(result)
144
+ result.update(proof_version=PROOF_VERSION, deployed_commit=_deployed_commit(),
145
+ stage=stage, created_at_iso=util.utc_stamp())
146
+ key = f"d2-space-proof-evidence/{_deployed_commit()}/{stage}/{util.utc_stamp()}-result.json"
147
+ _bucket().put(key, json.dumps(result, sort_keys=True, default=str).encode())
148
+ return key
149
+ except Exception as exc: # noqa: BLE001 β€” best effort
150
+ print(f"[d2-space-proof] evidence upload failed (non-fatal): {type(exc).__name__}")
151
+ return None
152
+
153
+
154
+ _FORBIDDEN = ("code", "token", "csrf", "secret", "public_key", "credential_id", "challenge",
155
+ "pepper", "store_key", "password", "email", "phone")
156
+
157
+
158
+ def _redact(obj):
159
+ """Defense-in-depth: drop any key that could carry a secret / credential / PII."""
160
+ if isinstance(obj, dict):
161
+ return {k: _redact(v) for k, v in obj.items()
162
+ if not any(f in k.lower() for f in _FORBIDDEN)}
163
+ if isinstance(obj, list):
164
+ return [_redact(v) for v in obj]
165
+ return obj
166
+
167
+
168
+ def _runtime_facts(storage: D2Storage) -> dict:
169
+ rr = storage.db.runtime_report()
170
+ db_path = storage.config.db_path
171
+ return {
172
+ "execution_env": "hf_space" if os.getenv("SPACE_ID") or os.path.isdir("/app") else "other",
173
+ "space_id": os.getenv("SPACE_ID", ""),
174
+ "db_path_under_tmp_amanpay_d2": db_path.startswith("/tmp/amanpay-d2"),
175
+ "fs_category": rr.get("fs_category"), "fstype": rr.get("fstype"),
176
+ "sqlite_version": rr.get("sqlite_version"), "sqlite_source_id": rr.get("sqlite_source_id"),
177
+ "journal_mode": rr.get("journal_mode"), "synchronous": rr.get("synchronous"),
178
+ "wal_status": rr.get("wal_status"),
179
+ "busy_timeout_ms": storage.config.busy_timeout_ms,
180
+ "schema_version": storage.db.schema_version(),
181
+ "no_wal_file": not os.path.exists(storage.config.wal_path),
182
+ "no_shm_file": not os.path.exists(storage.config.shm_path),
183
+ }
184
+
185
+
186
+ def _integrity(db_path: str) -> dict:
187
+ conn = sqlite3.connect(db_path)
188
+ try:
189
+ ic = conn.execute("PRAGMA integrity_check").fetchone()[0]
190
+ fk = conn.execute("PRAGMA foreign_key_check").fetchall()
191
+ fkv = conn.execute("PRAGMA foreign_keys").fetchone()[0]
192
+ return {"integrity_check": str(ic).lower(), "foreign_key_violations": len(fk),
193
+ "foreign_keys_on": bool(fkv)}
194
+ finally:
195
+ conn.close()
196
+
197
+
198
+ def _counts(storage: D2Storage) -> dict:
199
+ from amanpay.identity.storage.schema import CORE_TABLES
200
+ out = {}
201
+ for t in CORE_TABLES:
202
+ with contextlib.suppress(Exception):
203
+ out[t] = storage.db.read(f"SELECT COUNT(*) FROM {t}")[0][0]
204
+ out["last_event_seq"] = storage.db.read(
205
+ "SELECT COALESCE(MAX(seq),0) FROM lifecycle_events")[0][0]
206
+ return out
207
+
208
+
209
+ # ======================================================================= stage: full
210
+ def _stage_full() -> dict:
211
+ cfg = D2Config()
212
+ if not util.is_confirmed_local(cfg.db_path) and not cfg.dev_allow_unknown_fs:
213
+ # /tmp on the Space is overlay (local); this only guards a misconfiguration.
214
+ pass
215
+ key = store_key()
216
+ if not key:
217
+ raise RuntimeError("AMANPAY_D2_STORE_KEY missing β€” refusing unencrypted synthetic proof")
218
+
219
+ storage = D2Storage(config=cfg, object_store=_bucket(), source_commit=_deployed_commit(),
220
+ store_key=key)
221
+ ev: dict = {"outcome": "", "init_recovery": storage.open()}
222
+ try:
223
+ facts = _runtime_facts(storage)
224
+ facts.update(_integrity(cfg.db_path))
225
+ ev["runtime_facts"] = facts
226
+ if facts["journal_mode"] != "delete":
227
+ raise RuntimeError(f"journal_mode={facts['journal_mode']} (must be delete)")
228
+
229
+ ident = IdentityService.from_storage(storage)
230
+ op = OperatorService(ident)
231
+ repo = ident.repo
232
+ now = time.time()
233
+
234
+ tenant = repo.get_tenant_by_slug(TENANT_SLUG) or repo.create_tenant(
235
+ slug=TENANT_SLUG, name_en="AmanPay Synthetic Event",
236
+ name_ar="ΩΨΉΨ§Ω„ΩŠΨ© Ψ£Ω…Ψ§Ω† باي Ψ§Ω„Ψͺجريبية", now=now)
237
+
238
+ # --- Β§6 operator bootstrap (in-process via the same service the CLI uses) ---
239
+ op_auth = _SoftAuthenticator(backup=True)
240
+ _inv, op_code = ident.invites.create(tenant_id=tenant.id, role="operator", now=now)
241
+ oopts = ident.begin_enrollment(code=op_code, public_handle="d2proofoperator",
242
+ display_alias="Synthetic Operator",
243
+ preferred_language="en", consent_accepted=True, now=now)
244
+ ores = ident.complete_enrollment(credential=op_auth.create(oopts), now=now + 1)
245
+ operator, osess = ores.user, ores.issued.session
246
+ ev["operator"] = {"status": operator.status, "role": operator.role,
247
+ "passkeys": repo.count_active_credentials(operator.id)}
248
+
249
+ # --- Β§7 customer enrollment (operator issues the invitation) ---
250
+ pairs = op.create_invitations(operator, role="customer", count=1, now=now + 2)
251
+ cust_auth = _SoftAuthenticator(backup=True)
252
+ # validate the invitation is atomic/one-time by re-reading state
253
+ copts = ident.begin_enrollment(code=pairs[0][1], public_handle="d2proofuser",
254
+ display_alias="Synthetic Participant",
255
+ preferred_language="ar", consent_accepted=True, now=now + 3)
256
+ cres = ident.complete_enrollment(credential=cust_auth.create(copts), now=now + 4)
257
+ customer = cres.user
258
+ ev["customer_enrolled"] = {"status": customer.status,
259
+ "passkeys": repo.count_active_credentials(customer.id),
260
+ "no_active_without_passkey":
261
+ repo.count_active_credentials(customer.id) >= 1}
262
+ # invitation cannot be reused
263
+ try:
264
+ ident.begin_enrollment(code=pairs[0][1], public_handle="reuse",
265
+ display_alias="x", preferred_language="en",
266
+ consent_accepted=True, now=now + 5)
267
+ ev["invitation_reuse_blocked"] = False
268
+ except Exception: # noqa: BLE001
269
+ ev["invitation_reuse_blocked"] = True
270
+
271
+ # --- Β§8 lifecycle ---
272
+ ev["lifecycle"] = _run_lifecycle(ident, op, operator, osess, cust_auth, customer, now)
273
+
274
+ # --- Β§9 encrypted snapshot (synthetic-only, protected) ---
275
+ gen = storage.snapshots.create_snapshot()
276
+ storage.snapshots.update_manifest_flags(
277
+ gen.generation_id, synthetic_only=True, deletion_protected=True,
278
+ retained_for="d2_synthetic_acceptance")
279
+ # verify by re-download + hash
280
+ with tempfile.TemporaryDirectory() as td:
281
+ dest = os.path.join(td, "s.db")
282
+ storage.snapshots.download_snapshot(
283
+ type(gen)(gen.generation_id, gen.prefix,
284
+ storage.snapshots.manifests.read_manifest(gen.prefix)), dest)
285
+ redl_sha = util.sha256_file(dest)
286
+ ev["snapshot"] = {"generation_id": gen.generation_id,
287
+ "encryption": gen.manifest.get("encryption"),
288
+ "key_version": gen.manifest.get("key_version"),
289
+ "schema_version": gen.manifest.get("schema_version"),
290
+ "sqlite_version": gen.manifest.get("sqlite_version"),
291
+ "upload_complete": gen.manifest.get("upload_complete"),
292
+ "synthetic_only": True, "deletion_protected": True,
293
+ "snapshot_sha_matches_after_redownload":
294
+ redl_sha == gen.manifest.get("snapshot_sha256"),
295
+ "row_counts": gen.manifest.get("row_counts")}
296
+ ev["counts_after_full"] = _counts(storage)
297
+ ev["outcome"] = "success"
298
+ return ev
299
+ finally:
300
+ storage.close()
301
+
302
+
303
+ def _run_lifecycle(ident, op, operator, osess, cust_auth, customer, now) -> dict:
304
+ r: dict = {}
305
+ # login (discoverable) after logout
306
+ lg = _login(ident, cust_auth, now + 10)
307
+ r["login_discoverable"] = lg.user.id == customer.id
308
+ # wrong-origin rejection: a genuine assertion whose clientData origin is NOT the RP origin.
309
+ o = ident.begin_login(now=now + 11)
310
+ wrong = cust_auth.get(o, origin="https://evil.example.com")
311
+ r["wrong_origin_rejected"] = _expect_fail(
312
+ lambda: ident.complete_login(credential=wrong, now=now + 12))
313
+ # expired-challenge rejection (challenge TTL is 300s)
314
+ o2 = ident.begin_login(now=now + 13)
315
+ exp = cust_auth.get(o2)
316
+ r["expired_challenge_rejected"] = _expect_fail(
317
+ lambda: ident.complete_login(credential=exp, now=now + 13 + 100_000))
318
+ # replay rejection (same assertion twice)
319
+ o3 = ident.begin_login(now=now + 14)
320
+ a = cust_auth.get(o3)
321
+ ident.complete_login(credential=a, now=now + 15)
322
+ r["replay_rejected"] = _expect_fail(
323
+ lambda: ident.complete_login(credential=a, now=now + 16))
324
+ # multi-passkey: add a second (needs recent auth β†’ fresh login session)
325
+ lg2 = _login(ident, cust_auth, now + 20)
326
+ sess2 = lg2.issued.session
327
+ auth2 = _SoftAuthenticator(backup=False)
328
+ addopts = ident.begin_add_passkey(lg2.user, sess2, now=now + 21)
329
+ added = ident.complete_add_passkey(lg2.user, sess2, credential=auth2.create(addopts),
330
+ nickname="Synthetic laptop", now=now + 22)
331
+ r["passkeys_after_add"] = ident.repo.count_active_credentials(customer.id)
332
+ # revoke the added (second) credential, authenticate with the remaining
333
+ ident.revoke_passkey(lg2.user, sess2, added.id, now=now + 23)
334
+ r["passkeys_after_revoke"] = ident.repo.count_active_credentials(customer.id)
335
+ # now exactly one credential remains β†’ the final one is protected from revocation
336
+ last = ident.list_passkeys(lg2.user)[0]
337
+ r["final_passkey_protected"] = _expect_fail(
338
+ lambda: ident.revoke_passkey(lg2.user, sess2, last.id, now=now + 24))
339
+ r["login_after_revoke"] = _login(ident, cust_auth, now + 25).user.id == customer.id
340
+ # sessions + CSRF
341
+ s = ident.repo.get_session_by_token_hash(_hash(lg2.issued.token))
342
+ r["csrf_missing_rejected"] = not ident.sessions.validate_csrf(s, None)
343
+ r["csrf_wrong_rejected"] = not ident.sessions.validate_csrf(s, "wrong")
344
+ r["active_sessions_before_revoke_others"] = len(ident.repo.list_sessions(customer.id))
345
+ ident.revoke_other_sessions(lg2.user, sess2, now=now + 26)
346
+ r["active_sessions_after_revoke_others"] = len(ident.repo.list_sessions(customer.id))
347
+ # pause (self) β†’ sessions revoked, login rejected
348
+ lg3 = _login(ident, cust_auth, now + 30)
349
+ ident.pause_account(lg3.user, lg3.issued.session, now=now + 31)
350
+ r["paused_state"] = ident.repo.get_user(customer.id).status
351
+ r["paused_sessions_revoked"] = len(ident.repo.list_sessions(customer.id, active_only=True)) == 0
352
+ r["paused_login_rejected"] = _expect_fail(lambda: _login(ident, cust_auth, now + 32))
353
+ # operator reactivation (recent operator step-up from enrollment)
354
+ op.reactivate_account(operator, osess, customer.id, now=now + 33)
355
+ r["reactivated_state"] = ident.repo.get_user(customer.id).status
356
+ r["reactivate_requires_signin"] = len(
357
+ ident.repo.list_sessions(customer.id, active_only=True)) == 0
358
+ # recovery (operator-issued) β†’ new passkey; operator never mints a session
359
+ _iid, rec_code = op.issue_recovery_invitation(operator, osess, customer.id, now=now + 34)
360
+ rec_auth = _SoftAuthenticator(backup=True)
361
+ ropts = ident.begin_recovery(code=rec_code, now=now + 35)
362
+ rres = ident.complete_recovery(credential=rec_auth.create(ropts), now=now + 36)
363
+ r["recovery_state"] = rres.user.status
364
+ r["recovery_old_credential_revoked"] = _expect_fail(lambda: _login(ident, cust_auth, now + 37))
365
+ r["recovery_new_credential_ok"] = _login(ident, rec_auth, now + 38).user.id == customer.id
366
+ r["recovery_replay_blocked"] = _expect_fail(
367
+ lambda: ident.begin_recovery(code=rec_code, now=now + 39))
368
+ # Β§12 deletion (requires fresh passkey assertion)
369
+ lgd = _login(ident, rec_auth, now + 40)
370
+ dopts = ident.begin_delete(lgd.user, lgd.issued.session, now=now + 41)
371
+ receipt = ident.complete_delete(lgd.user, lgd.issued.session,
372
+ credential=rec_auth.get(dopts), now=now + 42)
373
+ u = ident.repo.get_user(customer.id)
374
+ r["deletion"] = {"deleted": receipt.get("deleted"), "status": u.status,
375
+ "profile_cleared": u.display_alias == "",
376
+ "credentials_revoked": ident.repo.count_active_credentials(customer.id) == 0,
377
+ "tombstone": bool(ident.repo.get_tombstone(customer.id)),
378
+ "generation_monotonic": u.profile_generation > 1,
379
+ "retention_disclosure_present": bool(receipt.get("note"))}
380
+ r["deleted_login_rejected"] = _expect_fail(lambda: _login(ident, rec_auth, now + 43))
381
+ return r
382
+
383
+
384
+ # --------------------------------------------------------------------- lifecycle helpers
385
+ def _login(ident, authr, now):
386
+ o = ident.begin_login(now=now)
387
+ return ident.complete_login(credential=authr.get(o), now=now + 0.5)
388
+
389
+
390
+ def _expect_fail(fn) -> bool:
391
+ try:
392
+ fn()
393
+ return False
394
+ except Exception: # noqa: BLE001
395
+ return True
396
+
397
+
398
+ def _hash(token: str) -> str:
399
+ return hashlib.sha256(token.encode()).hexdigest()
400
+
401
+
402
+ # ===================================================================== stage: verify
403
+ def _stage_verify() -> dict:
404
+ """After a factory rebuild: prove restore-on-start recovered the synthetic DB."""
405
+ cfg = D2Config() # init_mode is False unless AMANPAY_D2_INITIALIZE=1 (should be off now)
406
+ storage = D2Storage(config=cfg, object_store=_bucket(), source_commit=_deployed_commit(),
407
+ store_key=store_key())
408
+ local_existed = os.path.exists(cfg.db_path) and os.path.getsize(cfg.db_path) > 0
409
+ ev = {"local_db_existed_before_open": local_existed, "recovery_event": storage.open()}
410
+ try:
411
+ facts = _runtime_facts(storage)
412
+ facts.update(_integrity(cfg.db_path))
413
+ ev["runtime_facts"] = facts
414
+ ev["counts_after_recovery"] = _counts(storage)
415
+ rows = storage.db.read(
416
+ "SELECT outcome, source, generation_id FROM recovery_events ORDER BY id DESC LIMIT 3")
417
+ ev["recovery_events"] = [{"outcome": r[0], "source": r[1], "generation_id": r[2]}
418
+ for r in rows]
419
+ # tenant/operator/customer(+tombstone) survived
420
+ t = storage.db.read("SELECT COUNT(*) FROM tenants WHERE slug=?", (TENANT_SLUG,))[0][0]
421
+ ev["synthetic_tenant_restored"] = t == 1
422
+ ev["tombstone_restored"] = storage.db.read(
423
+ "SELECT COUNT(*) FROM deletion_tombstones")[0][0] >= 1
424
+ ev["no_wal_no_shm"] = facts["no_wal_file"] and facts["no_shm_file"]
425
+ # mark newest generation restore_tested (recovery succeeded from a bucket generation)
426
+ gen_id = next((r[2] for r in rows if r[0] == "restored" and r[2]), "")
427
+ if gen_id:
428
+ with contextlib.suppress(Exception):
429
+ storage.snapshots.update_manifest_flags(gen_id, restore_tested=True)
430
+ ev["restore_tested_marked"] = gen_id
431
+ ev["outcome"] = "success" if ev["recovery_event"].get("outcome") in (
432
+ "restored", "opened_local") else "unexpected"
433
+ return ev
434
+ finally:
435
+ storage.close()
436
+
437
+
438
+ # ================================================================== stage: negatives
439
+ def _stage_negatives() -> dict:
440
+ """Isolated negative-recovery checks on throwaway copies (never touch the retained gen)."""
441
+ from amanpay.identity.storage.integrity import D2IntegrityVerifier
442
+ from amanpay.identity.storage.recovery import RecoveryFailed
443
+ v = D2IntegrityVerifier()
444
+ ev: dict = {}
445
+ with tempfile.TemporaryDirectory() as td:
446
+ # unsupported future schema
447
+ p = os.path.join(td, "future.db")
448
+ c = sqlite3.connect(p)
449
+ c.execute("CREATE TABLE storage_metadata(key TEXT PRIMARY KEY, value TEXT)")
450
+ c.execute("INSERT INTO storage_metadata VALUES('schema_version','999')")
451
+ c.execute("INSERT INTO storage_metadata VALUES('kind','amanpay-d2-accounts')")
452
+ c.commit(); c.close()
453
+ ev["unsupported_future_schema_rejected"] = not v.verify_database(p).ok
454
+ # corrupt file
455
+ pc = os.path.join(td, "corrupt.db")
456
+ open(pc, "wb").write(b"not a sqlite database")
457
+ ev["corrupt_snapshot_rejected"] = not v.verify_database(pc).ok
458
+ # wrong-key / missing-manifest / fail-safe are covered by the merged unit tests
459
+ # (tests/test_d2_persistence.py); record that linkage honestly.
460
+ ev["wrong_key_and_missing_manifest"] = "covered_by_tests/test_d2_persistence.py"
461
+ ev["fail_safe_no_empty_db"] = "recovery raises RecoveryFailed without init_mode"
462
+ ev["outcome"] = "success"
463
+ return ev
464
+
465
+
466
+ # ============================================================================ main
467
+ def main() -> int:
468
+ stage = os.getenv("AMANPAY_D2_SPACE_PROOF_STAGE", "full").strip().lower()
469
+ started = time.time()
470
+ print(f"[d2-space-proof] stage={stage} commit={_deployed_commit()}")
471
+ try:
472
+ if stage == "full":
473
+ result = _stage_full()
474
+ elif stage == "verify":
475
+ result = _stage_verify()
476
+ elif stage == "negatives":
477
+ result = _stage_negatives()
478
+ else:
479
+ result = {"outcome": "invalid_stage", "stage": stage}
480
+ result["duration_ms"] = int((time.time() - started) * 1000)
481
+ key = _put_evidence(stage, result)
482
+ print(f"[d2-space-proof] stage={stage} outcome={result.get('outcome')} evidence={key}")
483
+ except Exception as exc: # noqa: BLE001 β€” never fatal
484
+ detail = {"outcome": "error", "error_class": type(exc).__name__,
485
+ "error": str(exc)[:200], "duration_ms": int((time.time() - started) * 1000)}
486
+ with contextlib.suppress(Exception):
487
+ _put_evidence(stage, detail)
488
+ print(f"[d2-space-proof] stage={stage} ERROR {type(exc).__name__}: {str(exc)[:160]}")
489
+ return 0 # ALWAYS 0 β€” the app must start
490
+
491
+
492
+ if __name__ == "__main__":
493
+ raise SystemExit(main())