Repository navigation
fix(security): never print full partner API key in bootstrap script (code-scanning #13-#15) #243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
4c0f3d3
41a5b22
0f915f9
86c7c8d
ede288a
5f09cfb
d9941fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,13 +20,16 @@ Never commit `VALID_API_KEYS` to git. | |
| export API_URL="${PUBLIC_URL:-https://api.thisisatest.tech}" | ||
| export BOOTSTRAP_KEY=... # from secret manager / Railway variables — not a demo string | ||
|
|
||
| # One-shot provisioner (prints agent key once to stdout): | ||
| # One-shot provisioner. The minted secret is never printed; it is written | ||
| # to --key-file (created mode 0600, refused if the path already exists). | ||
| # Without --key-file, stdout shows only non-secret identifiers (key id). | ||
| uv run --with-requirements requirements.txt \ | ||
| python scripts/partner_api_key_bootstrap.py \ | ||
| --api-url "$API_URL" \ | ||
| --sponsor-name "Partner Co" \ | ||
| --agent-id "partner-agent-001" \ | ||
| --budget-credits 1000 | ||
| --budget-credits 1000 \ | ||
| --key-file "$HOME/.config/amw/partner-agent-001.key" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Create the key-file directory in the operator example. If
|
||
| ``` | ||
|
|
||
| Optional bounds on what you mint (all default to unlimited): | ||
|
|
@@ -43,10 +46,11 @@ uv run --with-requirements requirements.txt \ | |
| python scripts/partner_api_key_bootstrap.py \ | ||
| --api-url "$API_URL" \ | ||
| --sponsor-wallet-id "$SPONSOR_WALLET_ID" \ | ||
| --agent-wallet-id "$AGENT_WALLET_ID" | ||
| --agent-wallet-id "$AGENT_WALLET_ID" \ | ||
| --key-file "$HOME/.config/amw/partner-agent-001.key" | ||
| ``` | ||
|
|
||
| Hand the **agent** key (and wallet id) to the partner over a secure channel. | ||
| Read the agent key from that file and hand it (and the wallet id) to the partner over a secure channel. | ||
| Revoke when the engagement ends: | ||
|
|
||
| ```bash | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,10 @@ | |
| Uses a bootstrap/admin key (VALID_API_KEYS) only to provision: | ||
| sponsor wallet → agent wallet → DB-scoped agent API key. | ||
|
|
||
| The agent secret is printed once. Requires network access to --api-url. | ||
| The minted agent secret is never printed to stdout or stderr. Pass --key-file | ||
| PATH to write it (created mode 0600, fails if PATH exists); stdout shows | ||
| only non-secret identifiers (key id, wallet ids, key prefix). | ||
| Requires network access to --api-url. | ||
| Does not mint keys without a bootstrap secret (fail closed). | ||
|
|
||
| Resume: pass --sponsor-wallet-id / --agent-wallet-id to skip earlier creates | ||
|
|
@@ -28,6 +31,23 @@ def _require(condition: bool, message: str) -> None: | |
| raise SystemExit(f"error: {message}") | ||
|
|
||
|
|
||
| def _write_key_file(path: str, api_key: str) -> None: | ||
| """Write the minted key to PATH created 0600; refuse if it already exists.""" | ||
| flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | ||
| try: | ||
| fd = os.open(path, flags, 0o600) | ||
| except FileExistsError: | ||
| raise SystemExit(f"error: --key-file {path} already exists") | ||
| except OSError as exc: | ||
| raise SystemExit(f"error: cannot write --key-file {path}: {exc}") from exc | ||
| try: | ||
| os.write(fd, api_key.encode("utf-8")) | ||
| except OSError as exc: | ||
| raise SystemExit(f"error: cannot write --key-file {path}: {exc}") from exc | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Complete the write and remove a failed key file.
|
||
| finally: | ||
| os.close(fd) | ||
|
|
||
|
|
||
| def _require_safe_api_url(api_url: str) -> str: | ||
| """Require HTTPS for remote hosts; allow http:// for loopback only.""" | ||
| base = api_url.strip().rstrip("/") | ||
|
|
@@ -92,7 +112,8 @@ def provision( | |
| Args: | ||
| json_mode: If True, print only machine-readable status to stderr (never | ||
| the api_key or bootstrap_key). Human text goes to stderr so | ||
| `jq -r .api_key` works on stdout JSON output. | ||
| stdout JSON stays pipeable. The minted secret is never in that JSON; | ||
| use --key-file to persist it. | ||
| """ | ||
| base = _require_safe_api_url(api_url) | ||
| headers = { | ||
|
|
@@ -160,7 +181,9 @@ def provision( | |
| key_body["max_uses"] = max_uses | ||
| key = _post(client, "/v1/api-keys", key_body) | ||
| api_key = key.get("api_key") | ||
| _require(bool(api_key), "api_key missing (shown only once)") | ||
| _require( | ||
| bool(api_key), "api_key missing (write with --key-file; never printed)" | ||
| ) | ||
|
|
||
| return { | ||
| "api_url": base, | ||
|
|
@@ -224,22 +247,37 @@ def main() -> int: | |
| parser.add_argument( | ||
| "--json", | ||
| action="store_true", | ||
| help="Print full JSON including api_key (default: human summary)", | ||
| help=( | ||
| "Print JSON identity fields to stdout without the secret " | ||
| "(and key_file when --key-file is given). Default: human summary." | ||
| ), | ||
| ) | ||
| parser.add_argument( | ||
| "--key-only", | ||
| action="store_true", | ||
| help=( | ||
| "Print only the minted api_key to stdout. Lets the secret be " | ||
| "piped straight into a secret store without a jq dependency, and " | ||
| "without the surrounding metadata landing wherever stdout goes." | ||
| "Print only the --key-file path, or the key_id when --key-file " | ||
| "is omitted. Never prints the minted secret." | ||
| ), | ||
| ) | ||
| parser.add_argument( | ||
| "--key-file", | ||
| default=None, | ||
| metavar="PATH", | ||
| help=( | ||
| "Write the minted api_key to PATH (created mode 0600 with " | ||
| "O_CREAT|O_EXCL|O_WRONLY; refused if PATH already exists). " | ||
| "Stdout prints PATH in --key-only mode, and JSON includes key_file." | ||
| ), | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| if args.key_only and args.json: | ||
| raise SystemExit("error: --key-only and --json are mutually exclusive") | ||
|
|
||
| if args.key_file and os.path.exists(args.key_file): | ||
| raise SystemExit(f"error: --key-file {args.key_file} already exists") | ||
|
|
||
| if args.agent_wallet_id and not args.sponsor_wallet_id: | ||
| raise SystemExit( | ||
| "error: --agent-wallet-id resume requires --sponsor-wallet-id " | ||
|
|
@@ -262,21 +300,47 @@ def main() -> int: | |
| json_mode=args.json, | ||
| ) | ||
|
|
||
| # Non-secret identifiers for stdout. The minted secret below flows | ||
| # only to --key-file and never to print/log. | ||
| api_url = str(result["api_url"]) | ||
| sponsor_wallet_id = str(result["sponsor_wallet_id"]) | ||
| agent_wallet_id = str(result["agent_wallet_id"]) | ||
| wallet_id = str(result["wallet_id"]) | ||
| agent_id = str(result["agent_id"]) | ||
| key_id = result.get("key_id") | ||
| key_prefix = result.get("key_prefix") | ||
| api_key = str(result["api_key"]) | ||
| key_file = args.key_file | ||
| if key_file: | ||
| _write_key_file(key_file, api_key) | ||
|
cursor[bot] marked this conversation as resolved.
Outdated
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Require a destination before minting an API key. If an operator omits
|
||
|
|
||
| if args.key_only: | ||
| print(result["api_key"]) | ||
| print(key_file if key_file else key_id) | ||
| elif args.json: | ||
| # Machine-readable: JSON to stdout, all status to stderr. | ||
| # Never print bootstrap_key. This lets `jq -r .api_key` work. | ||
| print(json.dumps(result, indent=2)) | ||
| # Never print bootstrap_key or the minted api_key. | ||
| payload: dict[str, Any] = { | ||
| "api_url": api_url, | ||
| "sponsor_wallet_id": sponsor_wallet_id, | ||
| "agent_wallet_id": agent_wallet_id, | ||
| "wallet_id": wallet_id, | ||
| "agent_id": agent_id, | ||
| "key_id": key_id, | ||
| "key_prefix": key_prefix, | ||
| } | ||
| if key_file: | ||
| payload["key_file"] = key_file | ||
| print(json.dumps(payload, indent=2)) | ||
|
github-advanced-security[bot] marked this conversation as resolved.
Fixed
|
||
| else: | ||
| # Human-readable summary to stdout (legacy behavior). | ||
| # Human-readable summary to stdout. Non-secret identifiers only. | ||
| print("Partner API key bootstrap OK") | ||
| print(f" api_url: {result['api_url']}") | ||
| print(f" sponsor_wallet_id: {result['sponsor_wallet_id']}") | ||
| print(f" agent_wallet_id: {result['agent_wallet_id']}") | ||
| print(f" key_id: {result['key_id']}") | ||
| print(f" key_prefix: {result['key_prefix']}") | ||
| print(f" api_key (once): {result['api_key']}") | ||
| print(f" api_url: {api_url}") | ||
| print(f" sponsor_wallet_id: {sponsor_wallet_id}") | ||
| print(f" agent_wallet_id: {agent_wallet_id}") | ||
| print(f" key_id: {key_id}") | ||
| print(f" key_prefix: {key_prefix}") | ||
| if key_file: | ||
| print(f" key_file: {key_file}") | ||
| return 0 | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep the key file if secret storage fails.
If
gh secret setfails, the next command still removes the only local copy of the minted key. Delete the file only after the secret-store command succeeds, for example withgh secret set ... < "$KEY_FILE" && rm -f "$KEY_FILE".