#!/usr/bin/env python3 """Build a no-secret checklist for the final Backblaze B2 application key.""" from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] SCHEMA = "proofframe.b2_key_scope_checklist.v1" DEFAULT_SETUP = ROOT / "docs" / "assets" / "b2-live-setup.json" DEFAULT_JSON = ROOT / "docs" / "assets" / "b2-key-scope-checklist.json" DEFAULT_MD = ROOT / "docs" / "assets" / "b2-key-scope-checklist.md" PROOFFRAME_B2_PREFIX = "campaigns/" RECOMMENDED_MAX_DURATION_SECONDS = 7 * 24 * 60 * 60 CONFIRMATION_PHRASE = ( "I confirm ProofFrame B2 key scope: standard key, bucket " "proofframe-demo-a6b4e49, prefix campaigns/, no all-bucket access, " "no delete/admin permissions, and no secrets in chat/docs/git." ) SAFE_PENDING_KEY_STATUSES = { "form_prepared_not_created", "created_outside_repo", "created_not_recorded", } SECRET_FIELD_NAMES = { "access_token", "api_key", "application_key", "authorization", "b2_application_key", "cookie", "key_id", "password", "refresh_token", "secret", "signed_url", "token", } ALLOWED_SECRET_POLICY_FIELDS = {"secret_policy"} def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") def load_json(path: Path) -> dict[str, Any]: try: return json.loads(path.read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError): return {} def find_forbidden_secret_fields(value: Any, path: str = "$") -> list[str]: findings: list[str] = [] if isinstance(value, dict): for key, child in value.items(): normalized = str(key).lower() child_path = f"{path}.{key}" if normalized in SECRET_FIELD_NAMES and normalized not in ALLOWED_SECRET_POLICY_FIELDS: findings.append(child_path) continue findings.extend(find_forbidden_secret_fields(child, child_path)) elif isinstance(value, list): for index, child in enumerate(value): findings.extend(find_forbidden_secret_fields(child, f"{path}[{index}]")) return findings def setup_summary(setup: dict[str, Any]) -> dict[str, Any]: bucket_name = setup.get("bucket_name") or setup.get("bucket") key_name = setup.get("application_key_name") or setup.get("prepared_application_key_name") key_status = setup.get("application_key_status") checks = [ { "id": "setup_present", "ok": bool(setup), "detail": "B2 live setup JSON is present and parseable.", }, { "id": "schema", "ok": setup.get("schema") == "proofframe.b2_live_setup.v1", "detail": "Setup record uses the expected schema.", }, { "id": "safe_to_commit", "ok": setup.get("safe_to_commit") is True, "detail": "Setup record declares that it contains no secrets.", }, { "id": "private_bucket", "ok": setup.get("bucket_type") == "private", "detail": "Live proof bucket remains private.", }, { "id": "single_bucket_target", "ok": bool(bucket_name), "detail": "A single target bucket name is recorded.", }, { "id": "s3_endpoint", "ok": bool(setup.get("endpoint")), "detail": "S3-compatible endpoint is recorded.", }, { "id": "application_key_name", "ok": bool(key_name), "detail": "Prepared application key name is recorded.", }, { "id": "application_key_status_safe", "ok": key_status in SAFE_PENDING_KEY_STATUSES, "detail": "The committed setup must not contain created key material.", }, ] return { "present": bool(setup), "schema": setup.get("schema"), "status": setup.get("status"), "bucket_name": bucket_name, "bucket_type": setup.get("bucket_type"), "endpoint": setup.get("endpoint"), "application_key_name": key_name, "application_key_status": key_status, "checks": checks, "ok": all(check["ok"] for check in checks), } def official_sources() -> list[dict[str, str]]: return [ { "label": "Backblaze B2 S3-Compatible App Keys", "url": "https://www.backblaze.com/docs/cloud-storage-s3-compatible-app-keys", "used_for": "Manual app key requirement, listAllBucketNames compatibility, and S3 capability mapping.", }, { "label": "Backblaze Cloud Storage Application Keys", "url": "https://www.backblaze.com/docs/cloud-storage-application-keys", "used_for": "Standard versus master application key, single-bucket scope, file prefix, and duration controls.", }, ] def required_capabilities() -> list[dict[str, str]]: return [ { "capability": "writeFiles", "why": "ProofFrame's B2 backend uploads generated media and manifests with S3 PutObject.", "proof_path": "src/proofframe/storage.py:B2StorageBackend.put_bytes", }, { "capability": "listAllBucketNames", "why": "Backblaze documents this as required for bucket-restricted app keys used with S3 SDKs and integrations.", "proof_path": "Backblaze S3-compatible app key documentation", }, ] def conditional_capabilities() -> list[dict[str, str]]: return [ { "capability": "readFiles", "allowed_only_if": "A final verification command is changed to perform HeadObject or GetObject against the uploaded proof objects.", "required_now": "false", }, { "capability": "listFiles", "allowed_only_if": "A final verification command is changed to list only the configured ProofFrame prefix.", "required_now": "false", }, ] def forbidden_capabilities() -> list[dict[str, str]]: return [ { "capability": "deleteFiles", "reason": "The live proof only uploads new media and manifest objects; deletion is unnecessary.", }, { "capability": "writeBuckets/deleteBuckets", "reason": "The bucket is already created; key must not create, modify, or delete buckets.", }, { "capability": "writeBucketLifecycleRules", "reason": "Lifecycle policy changes are outside the proof path.", }, { "capability": "writeBucketEncryption", "reason": "Encryption configuration is not needed for the one-bucket upload proof.", }, { "capability": "writeBucketRetentions/writeFileRetentions/bypassGovernance", "reason": "Object Lock and governance operations are not part of ProofFrame's proof.", }, { "capability": "writeFileLegalHolds", "reason": "Legal hold updates are not needed for submission evidence.", }, { "capability": "writeBucketReplications/writeBucketNotifications/writeBucketLogging", "reason": "Replication, notifications, and logging are admin features outside the live proof.", }, ] def operator_steps(setup: dict[str, Any]) -> list[str]: bucket = setup.get("bucket_name") or "the dedicated ProofFrame B2 bucket" key_name = setup.get("application_key_name") or "proofframe-demo-live-proof" return [ "Before creating the key, explicitly confirm the confirmation phrase from this checklist without adding any key values.", "Create a standard application key, not a master application key.", f"Set the key name to `{key_name}`.", f"Limit bucket access to the single bucket `{bucket}`; do not choose all buckets.", "Set the file name prefix to `campaigns/` so the key can only write ProofFrame proof objects.", "Use Write Only access for the upload proof; add read/list only if a changed verification command explicitly needs it.", "Enable `listAllBucketNames` for S3 SDK compatibility with the bucket-restricted key.", "Set an expiration no longer than 604800 seconds for the hackathon proof window.", "Copy the key id and application key only into `.env.final.local` through `python scripts/final_env_wizard.py --output .env.final.local --missing-only --force`.", "Immediately run `python scripts/live_env_handoff.py --env-file .env.final.local --strict` and then the B2 proof runner.", ] def stop_conditions(setup: dict[str, Any]) -> list[str]: bucket = setup.get("bucket_name") or "the dedicated ProofFrame B2 bucket" return [ "Stop if the UI asks for or displays a master application key.", f"Stop if bucket access cannot be limited to `{bucket}`.", "Stop if the file prefix cannot be set to `campaigns/` and ask before widening scope.", "Stop if the key requires all-bucket access, bucket write/delete permissions, or deleteFiles.", "Stop if a screenshot, recording, terminal, browser address bar, or chat message would expose the key id or application key.", "Stop if any key value appears in a commit diff, generated report, or Devpost field.", ] def build_expected_key(setup: dict[str, Any]) -> dict[str, Any]: bucket = setup.get("bucket_name") or "missing" key_name = setup.get("application_key_name") or "proofframe-demo-live-proof" return { "key_kind": "standard_application_key", "forbidden_key_kind": "master_application_key", "key_name": key_name, "bucket_scope": { "mode": "single_bucket", "bucket_name": bucket, "forbidden": "all_buckets", }, "file_name_prefix": { "value": PROOFFRAME_B2_PREFIX, "required": True, "matches_uploaded_keys": [ "campaigns/{campaign_id}/media/{filename}", "campaigns/{campaign_id}/manifests/{campaign_id}-manifest.json", ], }, "duration": { "recommended_max_seconds": RECOMMENDED_MAX_DURATION_SECONDS, "reason": "Short-lived proof key for final hackathon verification.", }, "web_ui_access": { "preferred": "Write Only", "upgrade_to_read_write_only_if": "Final verification is changed to perform HeadObject, GetObject, or ListObjects.", }, "required_capabilities": required_capabilities(), "conditional_capabilities": conditional_capabilities(), "forbidden_capabilities": forbidden_capabilities(), } def build_report(setup_path: Path = DEFAULT_SETUP) -> dict[str, Any]: setup_raw = load_json(setup_path) setup = setup_summary(setup_raw) forbidden_secret_fields = find_forbidden_secret_fields(setup_raw) scope_ready = bool(setup["ok"] and not forbidden_secret_fields) return { "schema": SCHEMA, "created_at": utc_now(), "mode": "scope_ready_key_not_created" if scope_ready else "scope_blocked", "ok": scope_ready, "safe_to_commit": not forbidden_secret_fields, "requires_user_confirmation_before_key_creation": True, "pre_key_creation_confirmation": { "status": "required_before_key_creation", "required_phrase": CONFIRMATION_PHRASE, "why": "The B2 application key is a real credential; ProofFrame must not create or use it from a vague instruction.", "safe_to_store": True, "forbidden_confirmation_contents": [ "B2 key id", "B2 application key", "Backblaze account identifiers", "browser cookies", "screenshots that show secrets", ], }, "setup_path": str(setup_path.relative_to(ROOT) if setup_path.is_relative_to(ROOT) else setup_path), "setup": setup, "expected_key": build_expected_key(setup), "operator_steps": operator_steps(setup), "stop_conditions": stop_conditions(setup), "secret_policy": { "allowed_destination": ".env.final.local via final_env_wizard", "forbidden_destinations": [ "git", "docs", "chat", "screenshots", "Devpost fields", "browser recordings", ], "forbidden_setup_fields": forbidden_secret_fields, }, "next_commands_after_key_entry": [ "python scripts/live_env_handoff.py --env-file .env.final.local --strict", "python scripts/run_b2_live_proof.py --env-file .env.final.local --evidence-out docs/assets/b2-live-proof-evidence.json", ], "official_sources": official_sources(), } def render_markdown(report: dict[str, Any]) -> str: setup = report["setup"] expected = report["expected_key"] lines = [ "# ProofFrame B2 Key Scope Checklist", "", f"Mode: `{report['mode']}`", f"OK: `{str(report['ok']).lower()}`", f"Safe to commit: `{str(report['safe_to_commit']).lower()}`", f"Requires user confirmation before key creation: `{str(report['requires_user_confirmation_before_key_creation']).lower()}`", "", "## Required Pre-Key Confirmation", "", f"- Status: `{report['pre_key_creation_confirmation']['status']}`", f"- Phrase: `{report['pre_key_creation_confirmation']['required_phrase']}`", "- Do not include any key id, application key, account identifier, cookie, or screenshot in the confirmation.", "", "## Target", "", f"- Bucket: `{setup.get('bucket_name')}`", f"- Bucket type: `{setup.get('bucket_type')}`", f"- Endpoint: `{setup.get('endpoint')}`", f"- Key name: `{expected['key_name']}`", f"- Key kind: `{expected['key_kind']}`", f"- Forbidden key kind: `{expected['forbidden_key_kind']}`", f"- File prefix: `{expected['file_name_prefix']['value']}`", f"- Preferred access: `{expected['web_ui_access']['preferred']}`", f"- Recommended max duration: `{expected['duration']['recommended_max_seconds']}` seconds", "", "## Required Capabilities", "", ] lines.extend( f"- `{item['capability']}` - {item['why']}" for item in expected["required_capabilities"] ) lines.extend(["", "## Conditional Only", ""]) lines.extend( f"- `{item['capability']}` - {item['allowed_only_if']}" for item in expected["conditional_capabilities"] ) lines.extend(["", "## Forbidden", ""]) lines.extend( f"- `{item['capability']}` - {item['reason']}" for item in expected["forbidden_capabilities"] ) lines.extend(["", "## Operator Steps", ""]) lines.extend(f"{index}. {step}" for index, step in enumerate(report["operator_steps"], start=1)) lines.extend(["", "## Stop Conditions", ""]) lines.extend(f"- {item}" for item in report["stop_conditions"]) lines.extend(["", "## Next Commands After Key Entry", ""]) lines.extend(f"- `{command}`" for command in report["next_commands_after_key_entry"]) lines.extend(["", "## Sources", ""]) lines.extend( f"- [{source['label']}]({source['url']}) - {source['used_for']}" for source in report["official_sources"] ) lines.append("") lines.append("No key id, application key, token, cookie, signed URL, or account secret is stored here.") return "\n".join(lines) + "\n" def write_outputs( report: dict[str, Any], *, json_path: Path = DEFAULT_JSON, markdown_path: Path = DEFAULT_MD, ) -> None: json_path.parent.mkdir(parents=True, exist_ok=True) markdown_path.parent.mkdir(parents=True, exist_ok=True) json_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") markdown_path.write_text(render_markdown(report), encoding="utf-8") def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Build the no-secret Backblaze B2 key scope checklist.") parser.add_argument("--setup", type=Path, default=DEFAULT_SETUP) parser.add_argument("--json-out", type=Path, default=DEFAULT_JSON) parser.add_argument("--markdown-out", type=Path, default=DEFAULT_MD) return parser def main() -> None: args = build_parser().parse_args() report = build_report(args.setup) write_outputs(report, json_path=args.json_out, markdown_path=args.markdown_out) print( json.dumps( { "ok": report["ok"], "mode": report["mode"], "json": str(args.json_out), "markdown": str(args.markdown_out), }, indent=2, ) ) raise SystemExit(0 if report["ok"] else 2) if __name__ == "__main__": main()