File size: 17,240 Bytes
2f1532d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3aa614
 
 
 
 
2f1532d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3aa614
2f1532d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3aa614
 
 
 
 
 
 
 
 
 
 
 
 
2f1532d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a3aa614
 
 
 
 
 
2f1532d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
#!/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()