#!/usr/bin/env bash # scripts/rotate_spike_key.sh - rotate the nova-spike-runner IAM access key. # # Uses the bootstrap root key (NOVA_BOOTSTRAP_AWS_* # fallback) from the env to: # 1. List nova-spike-runner's access keys. # 2. Create a new key. # 3. Write the new key to gitignored .env.secrets (chmod 600). # 4. Upload the new key to the consumer's Actions secret store + verify # (GET) that it propagated (SPEC §5.9 idempotency). # 5. Deactivate + delete the old key(s) ONLY after the upload is verified. # If the upload/verify fails, the old key stays Active + the run exits # non-zero (the consumer's deploy keeps a working credential). # # Env vars (forge coords): NOVA_FORGE_TOKEN / NOVA_FORGE_BASE_URL / # NOVA_FORGE_OWNER / NOVA_CONSUMER_REPO (the scheduled workflow passes these # forge-agnostic names, REQ-230). NOVA_GITEA_* are a backward-compat # fallback for ad-hoc local runs. # # Idempotent: re-running always ends with exactly 1 active key for the user # (once the new key has propagated to the secret store). # Does NOT rotate the bootstrap root key (D-034 closure = manual user step). # # Spike scope (D-039): the spike user key is per-run-rotated; real OIDC is # v1.2 (blocked on go-gitea/gitea#36988). # Nova rebrand (P4, REQ-163): IAM user renamed acdl-spike-runner → # nova-spike-runner. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" ENV_FILE="$ROOT/.env.secrets" fail() { echo "FAIL: $*" >&2; exit 1; } # Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (removed in P5). : "${NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID:?set NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID to the root key}" : "${NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY:?set NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY to the root key}" REGION="${AWS_DEFAULT_REGION:-us-east-1}" USER_NAME="nova-spike-runner" # Confirm .env.secrets is gitignored before writing to it. git check-ignore -q "$ENV_FILE" || fail "$ENV_FILE is not gitignored — refusing to write the key" python3 - <<'PY' import os import sys import json import boto3 region = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") user = "nova-spike-runner" env_file = os.path.join(os.getcwd(), ".env.secrets") # Dual-read bootstrap creds: NOVA_* preferred, ACDL_* fallback (G-106, removed in P5). bootstrap_key = os.environ["NOVA_BOOTSTRAP_AWS_ACCESS_KEY_ID"] bootstrap_secret = os.environ["NOVA_BOOTSTRAP_AWS_SECRET_ACCESS_KEY"] session = boto3.Session( aws_access_key_id=bootstrap_key, aws_secret_access_key=bootstrap_secret, region_name=region, ) iam = session.client("iam") # List current keys. keys = iam.list_access_keys(UserName=user).get("AccessKeyMetadata", []) active = [k for k in keys if k["Status"] == "Active"] # Create a new key first (so the user always has a working key during rotation). new = iam.create_access_key(UserName=user)["AccessKey"] new_id = new["AccessKeyId"] new_secret = new["SecretAccessKey"] print(f"iam: created new key {new_id} for {user}", file=sys.stderr) # Deactivation of the old keys is deferred to AFTER the new key propagates # to the Gitea Actions secret store (SPEC §5.9 idempotency — see below). # Writing .env.secrets first keeps the local operator's working key current. # Write the new key to gitignored .env.secrets (chmod 600). # Nova rebrand (P2): keys are NOVA_*; the ACDL_* legacy keys are the # dual-read fallback source until P5 (kept as comments in .env.secrets). with open(env_file, "w") as fh: fh.write(f"NOVA_AWS_ACCESS_KEY_ID={new_id}\n") fh.write(f"NOVA_AWS_SECRET_ACCESS_KEY={new_secret}\n") fh.write(f"AWS_DEFAULT_REGION={region}\n") os.chmod(env_file, 0o600) print(f"rotated key written to {env_file} (chmod 600)", file=sys.stderr) # Upload the new key to the consumer's Actions secret store BEFORE # deactivating the old key (SPEC §5.9 — idempotency: the old key is # deactivated only after the new one propagates). If the upload or the # post-upload verification fails, the old key is left Active so the # consumer's deploy still has a working credential; the run exits non-zero # so the scheduled workflow surfaces the failure (rather than silently # stranding the consumer with a key that never reached the secret store). # # Forge + consumer coords come from env vars. The scheduled workflow passes # forge-agnostic NOVA_FORGE_* names (REQ-230 — no forge hostnames in the # synced workflow file); NOVA_GITEA_* are accepted as a backward-compat # fallback for ad-hoc local runs. Defaults keep the legacy platform-repo # target when nothing is set. # Dual-read token: NOVA_FORGE_TOKEN preferred, NOVA_GITEA_TOKEN fallback (G-106). gitea_token = os.environ.get("NOVA_FORGE_TOKEN") or os.environ.get("NOVA_GITEA_TOKEN") gitea_base = ( os.environ.get("NOVA_FORGE_BASE_URL") or os.environ.get("NOVA_GITEA_BASE_URL") or "https://git.cloudinit.dev" ).rstrip("/") gitea_owner = ( os.environ.get("NOVA_FORGE_OWNER") or os.environ.get("NOVA_GITEA_OWNER") or "continuous-intelligence" ) gitea_repo = ( os.environ.get("NOVA_CONSUMER_REPO") or os.environ.get("NOVA_GITEA_REPO") or "acdl" ) secrets_api = f"{gitea_base}/api/v1/repos/{gitea_owner}/{gitea_repo}/actions/secrets" if gitea_token: import urllib.request import urllib.error import time def _put_secret(name, value): req = urllib.request.Request( f"{secrets_api}/{name}", data=json.dumps({"value": value}).encode(), method="PUT", headers={"Authorization": f"token {gitea_token}", "Content-Type": "application/json"}, ) urllib.request.urlopen(req).read() print(f"gitea: secret {name} uploaded to {gitea_owner}/{gitea_repo}", file=sys.stderr) def _verify_secret(name): # Gitea does not return secret *values*; a 200 confirms the secret # exists with the expected name. Retry briefly so eventual # consistency on the secrets API settles (observed sub-second lag). for attempt in range(5): req = urllib.request.Request( f"{secrets_api}/{name}", method="GET", headers={"Authorization": f"token {gitea_token}"}, ) try: with urllib.request.urlopen(req) as resp: if resp.status == 200: print(f"gitea: secret {name} verified present", file=sys.stderr) return True except urllib.error.HTTPError as e: if e.code == 404: time.sleep(0.5) continue raise return False try: _put_secret("NOVA_AWS_ACCESS_KEY_ID", new_id) _put_secret("NOVA_AWS_SECRET_ACCESS_KEY", new_secret) ok = _verify_secret("NOVA_AWS_ACCESS_KEY_ID") and \ _verify_secret("NOVA_AWS_SECRET_ACCESS_KEY") if not ok: raise RuntimeError("gitea secret verification failed (404 after PUT)") except Exception as e: # Upload/verify failed: leave the old key Active so the consumer's # deploy still works. Surface non-zero so the schedule is noisy. print(f"gitea: secret upload/verify FAILED ({e}); old key left Active", file=sys.stderr) sys.exit(2) else: print("gitea: NOVA_FORGE_TOKEN/NOVA_GITEA_TOKEN not set; secret upload skipped (v1.2 hardening)", file=sys.stderr) # No forge target → the new key is already in .env.secrets, so the # operator's local env works. The old key is deactivated below so the # user ends with exactly 1 active key (D-039 local-rotation contract). # Deactivate + delete the old keys. When a forge token was set, this runs # ONLY after the new key propagated to the consumer's secret store (the # sys.exit(2) above prevents reaching here on upload/verify failure). When # no token was set, the new key is already in .env.secrets so deactivating # is safe (D-039 local-rotation contract). for k in active: old_id = k["AccessKeyId"] if old_id == new_id: continue iam.update_access_key(UserName=user, AccessKeyId=old_id, Status="Inactive") iam.delete_access_key(UserName=user, AccessKeyId=old_id) print(f"iam: deactivated+deleted old key {old_id} (after propagation)", file=sys.stderr) print(f"OK: {user} now has exactly 1 active key: {new_id}") PY