backblaze-proofframe / scripts /b2_key_scope_checklist.py
ADJCJH's picture
Sync B2 pre-key confirmation gate
a3aa614 verified
Raw
History Blame Contribute Delete
17.2 kB
#!/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()