@mriekki/project-forge 4.26.1 → 4.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/assets/authentication/distribution.json +90 -0
  2. package/assets/components.json +17 -3
  3. package/assets/core/distribution.json +12 -0
  4. package/assets/deployment/distribution.json +292 -68
  5. package/assets/generated/ASSEMBLY.json +150 -4
  6. package/assets/generated/authentication/docs/AUTH_RECOVERY_RETENTION.md +101 -0
  7. package/assets/generated/authentication/docs/HOUSEHOLD_INFRASTRUCTURE_ADOPTION.md +55 -0
  8. package/assets/generated/authentication/docs/HOUSEHOLD_REGISTRY.md +246 -0
  9. package/assets/generated/authentication/docs/PROJECT_AUTH_REFERENCE.md +878 -0
  10. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/Program.cs +40 -0
  11. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/ProjectForgeAuth.Api.csproj +13 -0
  12. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/auth.js +65 -0
  13. package/assets/generated/authentication/samples/ProjectForgeAuth.Api/wwwroot/index.html +44 -0
  14. package/assets/generated/authentication/src/ProjectForge.Auth/AdmissionPolicy.cs +167 -0
  15. package/assets/generated/authentication/src/ProjectForge.Auth/AuthenticationDiagnostics.cs +159 -0
  16. package/assets/generated/authentication/src/ProjectForge.Auth/ProjectForge.Auth.csproj +9 -0
  17. package/assets/generated/authentication/src/ProjectForge.Auth/PwaExistingProjectCompatibility.cs +64 -0
  18. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectAuthStore.cs +471 -0
  19. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForge.Auth.Runtime.csproj +15 -0
  20. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ProjectForgeAuthRuntimeExtensions.cs +472 -0
  21. package/assets/generated/authentication/src/ProjectForge.Auth.Runtime/ResendProjectInvitationDelivery.cs +105 -0
  22. package/assets/generated/authentication/src/ProjectForge.Transport/ProjectForge.Transport.csproj +11 -0
  23. package/assets/generated/authentication/src/ProjectForge.Transport/StrictPublicOriginTransport.cs +95 -0
  24. package/assets/generated/authentication/tools/household_connect.py +145 -0
  25. package/assets/generated/authentication/tools/project_auth_recovery.py +716 -0
  26. package/assets/generated/core/core/process/ENGINEERING_BACKLOG.md +150 -0
  27. package/assets/generated/core/core/skills/manage-backlog/SKILL.md +115 -0
  28. package/assets/generated/core/core/skills/review-user-feedback/SKILL.md +50 -0
  29. package/assets/generated/deployment/docs/RECOVERY_READINESS.md +103 -0
  30. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_capture.py +164 -0
  31. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_guard.py +225 -0
  32. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_lifecycle.py +455 -0
  33. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_retention.py +220 -0
  34. package/assets/generated/deployment/src/project_forge_deployment/auth_recovery_schedule.py +489 -0
  35. package/package.json +1 -1
  36. package/src/cli.mjs +32 -3
  37. package/src/engine.mjs +87 -5
@@ -0,0 +1,716 @@
1
+ #!/usr/bin/env python3
2
+ """Fail-closed Auth Kit recovery capture, isolated materialization and verification.
3
+
4
+ Low-level library only: never stops a service, replaces live Project data, or
5
+ claims application/session readiness. Deployment must independently verify
6
+ quiescence and perform dynamic protector/policy checks before live traffic.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import re
15
+ import sqlite3
16
+ import stat
17
+ import sys
18
+
19
+ SHA = re.compile(r"^[0-9a-f]{64}$")
20
+ ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
21
+ ALLOWED_POLICIES = {"preserve-authentication", "invalidate-authentication"}
22
+ MAX_KEYS = 128
23
+ MAX_KEY_BYTES = 1024 * 1024
24
+ MAX_DB_BYTES = 1024 * 1024 * 1024 * 8
25
+
26
+
27
+ class RecoveryError(ValueError):
28
+ """A recovery qualification invariant has failed."""
29
+
30
+
31
+ def _object(value: object, fields: set[str], context: str) -> dict:
32
+ if type(value) is not dict or set(value) != fields:
33
+ raise RecoveryError(f"{context}: unexpected or missing fields")
34
+ return value
35
+
36
+
37
+ def _regular(path: Path, *, directory: bool = False) -> os.stat_result:
38
+ """Never follow a symlink in an owned recovery-set path."""
39
+ for parent in (path, *path.parents):
40
+ if parent.is_symlink():
41
+ raise RecoveryError("Recovery set contains a symlink")
42
+ try:
43
+ status = path.lstat()
44
+ except OSError as exc:
45
+ raise RecoveryError("Recovery set member is missing") from exc
46
+ if (stat.S_ISDIR(status.st_mode) if directory else
47
+ stat.S_ISREG(status.st_mode)) is not True:
48
+ raise RecoveryError("Unexpected recovery-set filesystem member")
49
+ return status
50
+
51
+
52
+ def validate_declaration(value: object) -> dict:
53
+ """Validate non-secret Project-owned recovery intent, not runtime paths."""
54
+ row = _object(value, {
55
+ "schemaVersion", "projectId", "authenticationPolicy",
56
+ "database", "keyRing", "protectorGeneration",
57
+ "providerRecoveryDependencies"
58
+ }, "declaration")
59
+ if type(row["schemaVersion"]) is not int or row["schemaVersion"] != 1:
60
+ raise RecoveryError("Unsupported recovery declaration version")
61
+ if type(row["projectId"]) is not str or not ID.fullmatch(row["projectId"]):
62
+ raise RecoveryError("Invalid Project identity")
63
+ if type(row["authenticationPolicy"]) is not str or row["authenticationPolicy"] not in ALLOWED_POLICIES:
64
+ raise RecoveryError("Explicit authentication recovery policy required")
65
+ database = _object(row["database"], {"artifact", "format"}, "database")
66
+ if database != {"artifact": "auth.sqlite", "format": "sqlite3"}:
67
+ raise RecoveryError("Unsupported database artifact")
68
+ ring = _object(row["keyRing"], {"directory", "required"}, "keyRing")
69
+ if ring != {"directory": "keys", "required": True}:
70
+ raise RecoveryError("Persistent DataProtection key ring is required")
71
+ generation = row["protectorGeneration"]
72
+ if type(generation) is not str or not re.fullmatch(r"[0-9a-f]{32}", generation):
73
+ raise RecoveryError("Invalid protector generation fingerprint")
74
+ dependencies = row["providerRecoveryDependencies"]
75
+ if type(dependencies) is not list or len(dependencies) > 16:
76
+ raise RecoveryError("Invalid external dependency declaration")
77
+ if (any(type(x) is not str or not ID.fullmatch(x) for x in dependencies) or
78
+ len(set(dependencies)) != len(dependencies)):
79
+ raise RecoveryError("External dependencies must be unique non-secret IDs")
80
+ return row
81
+
82
+
83
+ def _sha256(path: Path, limit: int) -> str:
84
+ status = _regular(path)
85
+ if status.st_size <= 0 or status.st_size > limit or status.st_nlink != 1:
86
+ raise RecoveryError("Recovery artifact size/link count not accepted")
87
+ digest = hashlib.sha256()
88
+ with path.open("rb") as stream:
89
+ while chunk := stream.read(1024 * 1024):
90
+ digest.update(chunk)
91
+ if _regular(path).st_size != status.st_size:
92
+ raise RecoveryError("Recovery artifact changed during validation")
93
+ return digest.hexdigest()
94
+
95
+
96
+ def inspect_isolated_set(root: Path, declaration: dict, receipt: object) -> dict:
97
+ """Check inert set integrity; NEVER report applicationRecoveryReady=true.
98
+
99
+ root must be a private, isolated, disposable directory unrelated to the
100
+ live Project data. The caller is responsible for verifying that placement.
101
+ """
102
+ validate_declaration(declaration)
103
+ record = _object(receipt, {
104
+ "schemaVersion", "projectId", "protectorGeneration", "policy",
105
+ "databaseSha256", "keys"
106
+ }, "receipt")
107
+ if type(record["schemaVersion"]) is not int or record["schemaVersion"] != 1:
108
+ raise RecoveryError("Unsupported recovery receipt")
109
+ if (type(record["projectId"]) is not str or
110
+ type(record["protectorGeneration"]) is not str or
111
+ type(record["policy"]) is not str or
112
+ record["projectId"] != declaration["projectId"] or
113
+ record["protectorGeneration"] != declaration["protectorGeneration"] or
114
+ record["policy"] != declaration["authenticationPolicy"]):
115
+ raise RecoveryError("Recovery policy/identity/generation mismatch")
116
+ root = Path(root)
117
+ _regular(root, directory=True)
118
+ db = root / "auth.sqlite"
119
+ keys = root / "keys"
120
+ _regular(keys, directory=True)
121
+ if type(record["databaseSha256"]) is not str or not SHA.fullmatch(record["databaseSha256"]):
122
+ raise RecoveryError("Invalid database hash")
123
+ members = sorted(x.name for x in root.iterdir())
124
+ if members not in (["auth.sqlite", "keys"], ["auth.sqlite", "keys", "receipt.json"]):
125
+ raise RecoveryError("Unknown or partial recovery-set top-level files")
126
+ if "receipt.json" in members:
127
+ receipt_path = root / "receipt.json"
128
+ if _regular(receipt_path).st_size > 32 * 1024:
129
+ raise RecoveryError("Recovery receipt is oversized")
130
+ try:
131
+ stored = json.loads(receipt_path.read_text(encoding="utf-8"))
132
+ except (OSError, ValueError, UnicodeError) as exc:
133
+ raise RecoveryError("Recovery receipt cannot be read") from exc
134
+ if stored != receipt:
135
+ raise RecoveryError("Private recovery receipt mismatch")
136
+ if any((root / x).exists() or (root / x).is_symlink() for x in
137
+ ("auth.sqlite-wal", "auth.sqlite-shm", "auth.sqlite-journal")):
138
+ raise RecoveryError("Uncheckpointed database transaction artifacts")
139
+ if _sha256(db, MAX_DB_BYTES) != record["databaseSha256"]:
140
+ raise RecoveryError("Database artifact hash mismatch")
141
+ entries = record["keys"]
142
+ if type(entries) is not dict or not 1 <= len(entries) <= MAX_KEYS:
143
+ raise RecoveryError("Missing or excessive key material")
144
+ if any(type(name) is not str for name in entries):
145
+ raise RecoveryError("Malformed key inventory names")
146
+ observed = sorted(x.name for x in keys.iterdir())
147
+ if observed != sorted(entries):
148
+ raise RecoveryError("Unrecognized or missing key material")
149
+ for name, digest in entries.items():
150
+ if (type(name) is not str or not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", name)
151
+ or type(digest) is not str or not SHA.fullmatch(digest)):
152
+ raise RecoveryError("Malformed key inventory")
153
+ if _sha256(keys / name, MAX_KEY_BYTES) != digest:
154
+ raise RecoveryError("Key material hash mismatch")
155
+
156
+ # SQLite URI read-only mode prevents a verifier from accidentally creating
157
+ # a fresh database; mode=ro must not be construed as application validation.
158
+ conn = None
159
+ try:
160
+ conn = sqlite3.connect(db.as_uri() + "?mode=ro&immutable=1", uri=True)
161
+ integrity = conn.execute("PRAGMA integrity_check").fetchone()
162
+ fk = conn.execute("PRAGMA foreign_key_check").fetchone()
163
+ version = conn.execute("PRAGMA user_version").fetchone()
164
+ tables = {
165
+ item[0] for item in conn.execute(
166
+ "SELECT name FROM sqlite_master WHERE type='table'"
167
+ ).fetchall()
168
+ }
169
+ expected_columns = {
170
+ "accounts": {
171
+ "id", "email", "normalized_email", "email_verified",
172
+ "is_admin", "disabled", "created_utc",
173
+ },
174
+ "external_identities": {"account_id", "issuer", "subject"},
175
+ "invitations": {
176
+ "id", "recipient_email", "recipient_normalized", "token_hash",
177
+ "expires_utc", "consumed_utc", "created_by", "created_utc",
178
+ },
179
+ "sessions": {
180
+ "token_hash", "account_id", "created_utc", "expires_utc",
181
+ "revoked_utc",
182
+ },
183
+ }
184
+ if (integrity != ("ok",) or fk is not None or version != (1,) or
185
+ tables != set(expected_columns)):
186
+ raise RecoveryError("Auth Kit schema or SQLite integrity invalid")
187
+ for name, columns in expected_columns.items():
188
+ found = {
189
+ row[1] for row in conn.execute(
190
+ "PRAGMA table_info(" + name + ")").fetchall()
191
+ }
192
+ if found != columns:
193
+ raise RecoveryError("Auth Kit schema column mismatch")
194
+ except sqlite3.DatabaseError as exc:
195
+ raise RecoveryError("SQLite recovery artifact is invalid") from exc
196
+ finally:
197
+ if conn is not None:
198
+ conn.close()
199
+ return {
200
+ "backupArtifactIntegrityVerified": True,
201
+ "isolatedArtifactInspectionComplete": True,
202
+ "protectorViabilityVerified": False,
203
+ "authenticationPolicyVerified": False,
204
+ "applicationRecoveryReady": False,
205
+ "scheduledBackupVerified": False,
206
+ "alertInboxReceiptVerified": False,
207
+ "unresolvedRequiredDependencies": [
208
+ "dynamic-protector-proof", "authentication-policy-proof",
209
+ *declaration["providerRecoveryDependencies"],
210
+ ],
211
+ }
212
+
213
+
214
+
215
+ def _exclusive_publish_directory(stage: Path, destination: Path) -> None:
216
+ """Atomic directory publication with OS-level NO-REPLACE semantics.
217
+
218
+ A preceding exists() check is insufficient: on POSIX, rename() can
219
+ silently replace an existing *empty directory*. Unsupported filesystems
220
+ fail closed; we do not fall back to an unsafe check-then-rename.
221
+ """
222
+ import ctypes
223
+ import errno
224
+ import sys
225
+
226
+ source = os.fsencode(stage)
227
+ target = os.fsencode(destination)
228
+ if sys.platform.startswith("linux"):
229
+ libc = ctypes.CDLL(None, use_errno=True)
230
+ renameat2 = getattr(libc, "renameat2", None)
231
+ if renameat2 is None:
232
+ raise RecoveryError("Atomic no-replace directory rename unavailable")
233
+ renameat2.argtypes = [
234
+ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p,
235
+ ctypes.c_uint,
236
+ ]
237
+ renameat2.restype = ctypes.c_int
238
+ status = renameat2(-100, source, -100, target, 1) # RENAME_NOREPLACE
239
+ if status != 0:
240
+ failure = ctypes.get_errno()
241
+ if failure in (errno.EEXIST, errno.ENOTEMPTY):
242
+ raise RecoveryError("Recovery destination already exists")
243
+ raise RecoveryError("Atomic recovery publication unavailable")
244
+ elif sys.platform == "darwin":
245
+ libc = ctypes.CDLL(None, use_errno=True)
246
+ renamex = getattr(libc, "renamex_np", None)
247
+ if renamex is None:
248
+ raise RecoveryError("Atomic no-replace directory rename unavailable")
249
+ renamex.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint]
250
+ renamex.restype = ctypes.c_int
251
+ if renamex(source, target, 0x00000004) != 0: # RENAME_EXCL
252
+ failure = ctypes.get_errno()
253
+ if failure in (errno.EEXIST, errno.ENOTEMPTY):
254
+ raise RecoveryError("Recovery destination already exists")
255
+ raise RecoveryError("Atomic recovery publication unavailable")
256
+ elif os.name == "nt":
257
+ # Windows MoveFile-style os.rename refuses an existing destination.
258
+ try:
259
+ os.rename(stage, destination)
260
+ except FileExistsError as exc:
261
+ raise RecoveryError("Recovery destination already exists") from exc
262
+ except OSError as exc:
263
+ raise RecoveryError("Atomic recovery publication unavailable") from exc
264
+ else:
265
+ raise RecoveryError("Atomic no-replace directory rename unsupported")
266
+
267
+
268
+ def _disjoint(a: Path, b: Path) -> bool:
269
+ """Compare normalized lexical paths; any symlink ancestor is rejected separately."""
270
+ x = Path(os.path.abspath(a))
271
+ y = Path(os.path.abspath(b))
272
+ return x != y and x not in y.parents and y not in x.parents
273
+
274
+
275
+ def _copy_verified_file(source: Path, destination: Path, expected_hash: str,
276
+ limit: int) -> None:
277
+ """Create an exclusive private file from a no-follow source descriptor."""
278
+ import shutil
279
+
280
+ if _sha256(source, limit) != expected_hash:
281
+ raise RecoveryError("Recovery source changed before copy")
282
+ before = _regular(source)
283
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
284
+ handle = os.open(source, flags)
285
+ try:
286
+ opened = os.fstat(handle)
287
+ if (opened.st_dev, opened.st_ino, opened.st_size) != (
288
+ before.st_dev, before.st_ino, before.st_size
289
+ ):
290
+ raise RecoveryError("Recovery artifact changed during open")
291
+ with os.fdopen(os.dup(handle), "rb") as inp, destination.open("xb") as out:
292
+ if os.name != "nt":
293
+ os.chmod(destination, 0o600)
294
+ shutil.copyfileobj(inp, out, 1024 * 1024)
295
+ out.flush()
296
+ os.fsync(out.fileno())
297
+ finally:
298
+ os.close(handle)
299
+ if _sha256(destination, limit) != expected_hash:
300
+ raise RecoveryError("Recovery copy is incomplete or corrupted")
301
+ if _sha256(source, limit) != expected_hash:
302
+ raise RecoveryError("Recovery source changed during copy")
303
+
304
+
305
+ def materialize_disposable_restore(
306
+ backup_set: Path, destination: Path, live_data_root: Path,
307
+ declaration: dict, receipt: dict,
308
+ ) -> dict:
309
+ """Materialize a hash-verified private restore into a NEW disposable path.
310
+
311
+ Does NOT run an app, rewrite live data, rotate keys, preserve sessions by
312
+ itself or assert application recovery. The caller must supply the exact
313
+ current live-data root and isolate the new destination from it and the
314
+ backup set. No Production restore CLI is exposed.
315
+ """
316
+ import shutil
317
+ import tempfile
318
+
319
+ validate_declaration(declaration)
320
+ backup_set = Path(os.path.abspath(backup_set))
321
+ destination = Path(os.path.abspath(destination))
322
+ live_data_root = Path(os.path.abspath(live_data_root))
323
+ _regular(backup_set, directory=True)
324
+ _regular(live_data_root, directory=True)
325
+ parent = destination.parent
326
+ _regular(parent, directory=True)
327
+ if (not _disjoint(destination, live_data_root) or
328
+ not _disjoint(destination, backup_set) or
329
+ not _disjoint(parent, live_data_root) or
330
+ not _disjoint(parent, backup_set) or
331
+ not _disjoint(backup_set, live_data_root)):
332
+ raise RecoveryError("Restore, backup and live data must be isolated")
333
+ if os.name != "nt" and stat.S_IMODE(parent.stat().st_mode) & 0o077:
334
+ raise RecoveryError("Disposable restore parent must be owner-private")
335
+ if destination.exists() or destination.is_symlink():
336
+ raise RecoveryError("Refusing to replace existing disposable data")
337
+ if not (backup_set / "receipt.json").is_file():
338
+ raise RecoveryError("Immutable recovery set requires a private receipt")
339
+ inspect_isolated_set(backup_set, declaration, receipt)
340
+ stage = Path(tempfile.mkdtemp(prefix=".auth-restore-", dir=parent))
341
+ try:
342
+ if os.name != "nt":
343
+ os.chmod(stage, 0o700)
344
+ (stage / "keys").mkdir(mode=0o700)
345
+ _copy_verified_file(backup_set / "auth.sqlite", stage / "auth.sqlite",
346
+ receipt["databaseSha256"], MAX_DB_BYTES)
347
+ for name, digest in receipt["keys"].items():
348
+ _copy_verified_file(backup_set / "keys" / name, stage / "keys" / name,
349
+ digest, MAX_KEY_BYTES)
350
+ _copy_verified_file(
351
+ backup_set / "receipt.json", stage / "receipt.json",
352
+ _sha256(backup_set / "receipt.json", 32 * 1024), 32 * 1024)
353
+ inspect_isolated_set(stage, declaration, receipt)
354
+ _exclusive_publish_directory(stage, destination)
355
+ return {
356
+ "isolatedRestoreMaterialized": True,
357
+ "backupArtifactIntegrityVerified": True,
358
+ "protectorViabilityVerified": False,
359
+ "authenticationPolicyVerified": False,
360
+ "applicationRecoveryReady": False,
361
+ "normalTrafficAllowed": False,
362
+ }
363
+ finally:
364
+ if stage.exists():
365
+ shutil.rmtree(stage)
366
+
367
+
368
+
369
+ RECOVERY_SET_NAME = re.compile(r"^(20[0-9]{6}T[0-9]{6}Z)-[0-9a-f]{8,32}$")
370
+ MAX_INVENTORY_SETS = 1000
371
+
372
+
373
+ def _valid_set_name(name: str) -> bool:
374
+ from datetime import datetime
375
+
376
+ if type(name) is not str:
377
+ return False
378
+ match = RECOVERY_SET_NAME.fullmatch(name)
379
+ if match is None:
380
+ return False
381
+ try:
382
+ datetime.strptime(match.group(1), "%Y%m%dT%H%M%SZ")
383
+ except ValueError:
384
+ return False
385
+ return True
386
+
387
+
388
+ def plan_verified_retention(
389
+ sets_root: Path, declaration: dict, *, keep_last: int = 7
390
+ ) -> dict:
391
+ """Read-only retention preview of fully verified immutable recovery sets.
392
+
393
+ Fail closed if any set is corrupt, incomplete, symlinked, misidentified or
394
+ ambiguously named; never delete a set, stage, or application directory.
395
+ A retention plan is NOT a scheduled backup/restore/notification proof.
396
+ """
397
+ validate_declaration(declaration)
398
+ if type(keep_last) is not int or not 2 <= keep_last <= 365:
399
+ raise RecoveryError("Retention minimum must preserve at least two sets")
400
+ sets_root = Path(sets_root)
401
+ root_status = _regular(sets_root, directory=True)
402
+ if os.name != "nt" and stat.S_IMODE(root_status.st_mode) & 0o077:
403
+ raise RecoveryError("Recovery-set inventory must be owner-private")
404
+ names = sorted(p.name for p in sets_root.iterdir())
405
+ if len(names) > MAX_INVENTORY_SETS:
406
+ raise RecoveryError("Recovery-set inventory count exceeds safe limit")
407
+ if any(not _valid_set_name(name) for name in names):
408
+ raise RecoveryError("Unknown, staging or malformed recovery-set name")
409
+ verified = []
410
+ for name in names:
411
+ candidate = sets_root / name
412
+ _regular(candidate, directory=True)
413
+ receipt_path = candidate / "receipt.json"
414
+ if _regular(receipt_path).st_size > 32 * 1024:
415
+ raise RecoveryError("Recovery receipt exceeds size limit")
416
+ try:
417
+ receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
418
+ except (OSError, ValueError, UnicodeError) as exc:
419
+ raise RecoveryError("Recovery set receipt is not readable") from exc
420
+ report = inspect_isolated_set(candidate, declaration, receipt)
421
+ if report["backupArtifactIntegrityVerified"] is not True:
422
+ raise RecoveryError("Unverified recovery set in retention inventory")
423
+ verified.append(name)
424
+ eligible = verified[:-keep_last] if len(verified) > keep_last else []
425
+ return {
426
+ "recoverySetCount": len(verified),
427
+ "verifiedRecoverySetNames": verified,
428
+ "retainedRecoverySetNames": verified[-keep_last:],
429
+ "retentionCandidateNames": eligible,
430
+ "retentionPlanOnly": True,
431
+ "deletionPerformed": False,
432
+ "scheduledBackupVerified": False,
433
+ "alertInboxReceiptVerified": False,
434
+ "applicationRecoveryReady": False,
435
+ }
436
+
437
+
438
+
439
+ def apply_reviewed_disposable_retention(
440
+ sets_root: Path, lock_directory: Path, declaration: dict,
441
+ reviewed_plan: dict, *, keep_last: int = 7,
442
+ environment: str, authorize_disposable_delete: bool = False,
443
+ max_delete: int = 32,
444
+ ) -> dict:
445
+ """Prune only a reviewed subset of verified *disposable* backup sets.
446
+
447
+ This is a low-level, opt-in Linux primitive, not a scheduled operation.
448
+ The caller must independently prove the actual generated-Project owner,
449
+ disposal-only environment, quiesced-capture success, separation from live
450
+ data, and application-level recovery readiness. A complete, unchanged,
451
+ read-only retention plan must have been reviewed before calling.
452
+ A partial filesystem failure is fail-closed and requires manual inspection;
453
+ this function cannot make multi-directory deletion atomic.
454
+ """
455
+ if (sys.platform != "linux" or environment != "disposable" or
456
+ authorize_disposable_delete is not True):
457
+ raise RecoveryError("Explicit disposable Linux retention authorization required")
458
+ import fcntl
459
+
460
+ if type(max_delete) is not int or not 1 <= max_delete <= 32:
461
+ raise RecoveryError("Disposable retention deletion bound is invalid")
462
+ sets_root = Path(sets_root)
463
+ lock_directory = Path(lock_directory)
464
+ if (not sets_root.is_absolute() or not lock_directory.is_absolute()
465
+ or sets_root == lock_directory or sets_root in lock_directory.parents
466
+ or lock_directory in sets_root.parents):
467
+ raise RecoveryError("Reviewed backup inventory and lock must be isolated absolute paths")
468
+ root_info = _regular(sets_root, directory=True)
469
+ lock_info = _regular(lock_directory, directory=True)
470
+ if (stat.S_IMODE(root_info.st_mode) & 0o077 or
471
+ stat.S_IMODE(lock_info.st_mode) & 0o077 or
472
+ root_info.st_uid != os.geteuid() or
473
+ lock_info.st_uid != os.geteuid()):
474
+ raise RecoveryError("Disposable retention directories must be owner-owned and private")
475
+ lock_path = lock_directory / "auth-retention.lock"
476
+ lock_fd = -1
477
+ root_fd = -1
478
+ deleted: list[str] = []
479
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
480
+
481
+ def checked_regular(directory_fd: int, name: str) -> None:
482
+ info = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
483
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
484
+ raise RecoveryError("Unexpected retention set member; deletion blocked")
485
+
486
+ try:
487
+ lock_fd = os.open(
488
+ lock_path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
489
+ lock_stat = os.fstat(lock_fd)
490
+ if (not stat.S_ISREG(lock_stat.st_mode) or lock_stat.st_nlink != 1 or
491
+ stat.S_IMODE(lock_stat.st_mode) & 0o077):
492
+ raise RecoveryError("Disposable retention lock is not owner-private")
493
+ try:
494
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
495
+ except BlockingIOError:
496
+ raise RecoveryError("Another disposable retention operation is running") from None
497
+
498
+ # No unlink, rename or rmdir occurs until every set has been checked,
499
+ # and the inventory is identical to the caller's exact reviewed plan.
500
+ plan = plan_verified_retention(sets_root, declaration, keep_last=keep_last)
501
+ if type(reviewed_plan) is not dict or reviewed_plan != plan:
502
+ raise RecoveryError("Disposable retention inventory differs from reviewed plan")
503
+ selected = plan["retentionCandidateNames"][:max_delete]
504
+ root_fd = os.open(sets_root, flags)
505
+ opened = os.fstat(root_fd)
506
+ if (opened.st_dev, opened.st_ino) != (root_info.st_dev, root_info.st_ino):
507
+ raise RecoveryError("Disposable retention inventory changed during open")
508
+
509
+ for name in selected:
510
+ # Anchor all destructive operations to no-follow directory file
511
+ # descriptors. Never call recursive deletion on a path.
512
+ source_info = os.stat(name, dir_fd=root_fd, follow_symlinks=False)
513
+ if not stat.S_ISDIR(source_info.st_mode):
514
+ raise RecoveryError("Disposable retention candidate is not a directory")
515
+ set_fd = os.open(name, flags, dir_fd=root_fd)
516
+ try:
517
+ opened_set = os.fstat(set_fd)
518
+ if (opened_set.st_dev, opened_set.st_ino) != (
519
+ source_info.st_dev, source_info.st_ino):
520
+ raise RecoveryError("Disposable retention candidate changed during open")
521
+ if sorted(os.listdir(set_fd)) != ["auth.sqlite", "keys", "receipt.json"]:
522
+ raise RecoveryError("Disposable retention candidate changed after verification")
523
+ checked_regular(set_fd, "auth.sqlite")
524
+ checked_regular(set_fd, "receipt.json")
525
+ key_fd = os.open("keys", flags, dir_fd=set_fd)
526
+ try:
527
+ key_names = sorted(os.listdir(key_fd))
528
+ if not 1 <= len(key_names) <= MAX_KEYS:
529
+ raise RecoveryError("Disposable retention key inventory changed")
530
+ for key_name in key_names:
531
+ if not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", key_name):
532
+ raise RecoveryError("Disposable retention key name changed")
533
+ checked_regular(key_fd, key_name)
534
+ # Verify all candidate names BEFORE the first unlink.
535
+ for key_name in key_names:
536
+ os.unlink(key_name, dir_fd=key_fd)
537
+ os.fsync(key_fd)
538
+ finally:
539
+ os.close(key_fd)
540
+ os.rmdir("keys", dir_fd=set_fd)
541
+ os.unlink("receipt.json", dir_fd=set_fd)
542
+ os.unlink("auth.sqlite", dir_fd=set_fd)
543
+ os.fsync(set_fd)
544
+ finally:
545
+ os.close(set_fd)
546
+ os.rmdir(name, dir_fd=root_fd)
547
+ os.fsync(root_fd)
548
+ deleted.append(name)
549
+
550
+ # No assumption that deleting a valid old set proves application
551
+ # restore, scheduler operation or delivered alerts.
552
+ after = plan_verified_retention(sets_root, declaration, keep_last=keep_last)
553
+ return {
554
+ "deletedRecoverySetNames": deleted,
555
+ "retainedRecoverySetNames": after["verifiedRecoverySetNames"],
556
+ "deletionPerformed": bool(deleted),
557
+ "retentionPlanOnly": False,
558
+ "scheduledBackupVerified": False,
559
+ "alertInboxReceiptVerified": False,
560
+ "applicationRecoveryReady": False,
561
+ }
562
+ except OSError:
563
+ raise RecoveryError(
564
+ "Disposable retention filesystem operation failed; inspect the private inventory"
565
+ ) from None
566
+ finally:
567
+ if root_fd != -1:
568
+ os.close(root_fd)
569
+ if lock_fd != -1:
570
+ os.close(lock_fd)
571
+
572
+
573
+
574
+ def capture_quiesced_set(
575
+ source: Path, destination: Path, declaration: dict, runtime_guard
576
+ ) -> dict:
577
+ """Stage a private, complete SQLite/key-ring set and atomically publish it.
578
+
579
+ This is a low-level primitive, NOT a service-quiescence mechanism. The
580
+ caller must supply a non-mutating guard backed by verified runtime
581
+ introspection, rechecking the exact stopped service before and after
582
+ capture. This function cannot establish that the guard itself is honest;
583
+ it must not be used directly as an operator-ready restore/backup CLI.
584
+ Never call on a Production host without a reviewed Deployment Kit driver.
585
+ """
586
+ import shutil
587
+ import tempfile
588
+
589
+ validate_declaration(declaration)
590
+ if not callable(runtime_guard):
591
+ raise RecoveryError("An independently verified runtime guard is required")
592
+ source = Path(source)
593
+ destination = Path(destination)
594
+ if not _valid_set_name(destination.name):
595
+ raise RecoveryError("Recovery set name must be a valid UTC timestamp and random suffix")
596
+ _regular(source, directory=True)
597
+ parent = destination.parent
598
+ _regular(parent, directory=True)
599
+ if destination.exists() or destination.is_symlink():
600
+ raise RecoveryError("Refusing to overwrite an existing recovery set")
601
+ # Backup sets must not live within or contain the live application's data.
602
+ if source == destination or source in destination.parents or destination in source.parents:
603
+ raise RecoveryError("Recovery set and source storage must be isolated")
604
+ if os.name != "nt" and stat.S_IMODE(parent.stat().st_mode) & 0o077:
605
+ raise RecoveryError("Recovery-set parent must be owner-private")
606
+ if sorted(p.name for p in source.iterdir()) != ["auth.sqlite", "keys"]:
607
+ raise RecoveryError("Unexpected source artifact or uncheckpointed WAL")
608
+ db = source / "auth.sqlite"
609
+ keys = source / "keys"
610
+ db_status = _regular(db)
611
+ _regular(keys, directory=True)
612
+ if db_status.st_size <= 0 or db_status.st_size > MAX_DB_BYTES:
613
+ raise RecoveryError("Invalid source database size")
614
+ key_names = sorted(p.name for p in keys.iterdir())
615
+ if not 1 <= len(key_names) <= MAX_KEYS:
616
+ raise RecoveryError("Persistent key ring is missing or excessive")
617
+ for name in key_names:
618
+ if not re.fullmatch(r"key-[A-Za-z0-9-]{1,120}\.xml", name):
619
+ raise RecoveryError("Unrecognized keyring file")
620
+ st = _regular(keys / name)
621
+ if st.st_nlink != 1 or not 0 < st.st_size <= MAX_KEY_BYTES:
622
+ raise RecoveryError("Unsupported keyring file size or hard link")
623
+
624
+ def guard() -> dict:
625
+ observed = runtime_guard()
626
+ if (type(observed) is not dict or
627
+ set(observed) != {"stopped", "runtimeId", "imageDigest"} or
628
+ observed["stopped"] is not True or
629
+ type(observed["runtimeId"]) is not str or
630
+ re.fullmatch(r"[0-9a-f]{64}", observed["runtimeId"]) is None or
631
+ type(observed["imageDigest"]) is not str or
632
+ re.fullmatch(r"sha256:[0-9a-f]{64}", observed["imageDigest"]) is None):
633
+ raise RecoveryError("Runtime quiescence/identity was not proven")
634
+ return observed
635
+
636
+ before = guard()
637
+ stage = Path(tempfile.mkdtemp(prefix=".auth-recovery-", dir=parent))
638
+ try:
639
+ os.chmod(stage, 0o700)
640
+ (stage / "keys").mkdir(mode=0o700)
641
+ # Read-only immutable source rejects accidental live SQLite creation.
642
+ # SQLite backup produces a single consistent database without a WAL.
643
+ conn = None
644
+ dest_db = None
645
+ try:
646
+ conn = sqlite3.connect(db.as_uri() + "?mode=ro&immutable=1", uri=True)
647
+ dest_db = sqlite3.connect(stage / "auth.sqlite")
648
+ conn.backup(dest_db)
649
+ dest_db.commit()
650
+ except sqlite3.DatabaseError as exc:
651
+ raise RecoveryError("Quiesced SQLite capture failed") from exc
652
+ finally:
653
+ if dest_db is not None:
654
+ dest_db.close()
655
+ if conn is not None:
656
+ conn.close()
657
+ os.chmod(stage / "auth.sqlite", 0o600)
658
+ receipt_keys: dict[str, str] = {}
659
+ for name in key_names:
660
+ source_file = keys / name
661
+ expected = _regular(source_file)
662
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
663
+ handle = os.open(source_file, flags)
664
+ try:
665
+ opened = os.fstat(handle)
666
+ if ((opened.st_dev, opened.st_ino, opened.st_size) !=
667
+ (expected.st_dev, expected.st_ino, expected.st_size)):
668
+ raise RecoveryError("Keyring changed during capture")
669
+ dest_file = stage / "keys" / name
670
+ with os.fdopen(os.dup(handle), "rb") as inp, dest_file.open("xb") as out:
671
+ os.chmod(dest_file, 0o600)
672
+ shutil.copyfileobj(inp, out, 1024 * 1024)
673
+ out.flush()
674
+ os.fsync(out.fileno())
675
+ receipt_keys[name] = _sha256(dest_file, MAX_KEY_BYTES)
676
+ finally:
677
+ os.close(handle)
678
+ if guard() != before:
679
+ raise RecoveryError("Stopped runtime identity changed during capture")
680
+ receipt = {
681
+ "schemaVersion": 1,
682
+ "projectId": declaration["projectId"],
683
+ "protectorGeneration": declaration["protectorGeneration"],
684
+ "policy": declaration["authenticationPolicy"],
685
+ "databaseSha256": _sha256(stage / "auth.sqlite", MAX_DB_BYTES),
686
+ "keys": receipt_keys,
687
+ }
688
+ # Inspect the private set before publication. Runtime/key continuity
689
+ # and application readiness still demand separate dynamic validation.
690
+ # Keep the non-secret manifest inside the private recovery directory,
691
+ # so a later isolated verifier does not depend on a transient return.
692
+ receipt_path = stage / "receipt.json"
693
+ with receipt_path.open("x", encoding="utf-8") as output:
694
+ os.chmod(receipt_path, 0o600)
695
+ json.dump(receipt, output, sort_keys=True, separators=(",", ":"))
696
+ output.write("\n")
697
+ output.flush()
698
+ os.fsync(output.fileno())
699
+ inspect_isolated_set(stage, declaration, receipt)
700
+ if destination.exists() or destination.is_symlink():
701
+ raise RecoveryError("Recovery destination appeared during staging")
702
+ # Publish exclusively, even if another process races to create an
703
+ # empty destination directory between preflight and publication.
704
+ _exclusive_publish_directory(stage, destination)
705
+ return {
706
+ "setPath": str(destination),
707
+ "receipt": receipt,
708
+ "backupArtifactIntegrityVerified": True,
709
+ "applicationRecoveryReady": False,
710
+ "dynamicProtectorProofRequired": True,
711
+ "quiescenceGuardRuntimeId": before["runtimeId"],
712
+ "quiescenceGuardImageDigest": before["imageDigest"],
713
+ }
714
+ finally:
715
+ if stage.exists():
716
+ shutil.rmtree(stage)