Secret Rotation Checklist
Operator-facing rotation procedures for every secret QTG holds. Apply when:- a secret may have been exposed (terminal scrollback, screen share, leaked log, lost device)
- a key reaches its scheduled rotation window
- an operator with key access offboards
- post-incident hardening calls for cycling everything in a class
Pro boundary. AWS KMS signer lifecycle administration and its operator runbook are Pro. This Free checklist covers generic credential exposure and incident handling only; it does not link to or reproduce the Pro procedure.
Principles
- Issue first, retire after. Never delete an active credential before confirming the replacement works end-to-end. The window where both are accepted is your safety net — keep it explicit, never implicit.
- Rotation is a runtime change. Every rotation requires a config reload or process restart somewhere. Plan the restart window before you start.
- Health check is part of the procedure, not optional. A rotation is not complete until the new credential has signed a real call (HMAC verify, exchange order list, callback dispatch, signer health). If you cannot prove the new credential works, you have not rotated — you have created a broken state.
- Audit trail goes in the incident log. When rotating due to suspected
exposure, write an incident entry —
<UTC>-secret-rotation-<scope>— into whatever incident log your deployment keeps, alongside the rotation. The log is the only record after the secret values themselves are gone. - Local secrets never enter
git. If a secret appears in a commit (even a deleted line in a later commit), it is permanently exposed — see § Git history secret audit for triage.
Secret inventory
Callback HMAC v3 secret
MG_CALLBACK_HMAC_SECRET signs every callback outbox dispatch QTG sends to the
external receiver. The receiver’s matching secret must rotate atomically.
- Pre-flight. Confirm the callback receiver is rotation-aware (accepts an N+1 secret in parallel with N for a window). If not, you will need a brief downtime window — coordinate with the receiver owner first.
- Generate.
python3 -c "import secrets; print(secrets.token_hex(32))"— produces a 64-char hex string. Do not reuse old values. - Apply to the receiver first. Add the new secret as the secondary accepting key on the receiver side. Do NOT remove the old key yet.
- Apply to QTG. Edit
.env, setMG_CALLBACK_HMAC_SECRETto the new value. Restart QTG (docker compose -f infra/docker-compose.yml restart qtg-mainnet— orqtg-testnet— or the equivalentuvicornprocess). - Validate. Trigger any callback-producing path (a movement state change is the cheapest — approve any pending movement, or retry a callback that’s already in the outbox). Confirm the receiver returns 200 with the new key.
- Retire the old key on the receiver. Only after step 5 passes.
- Document. If rotating due to exposure: incident log, plus a changelog note if it is release-relevant.
docs/callback-verification-contract.md.
The v3 scheme includes a nonce (replay defense) — see
src/qtg/callback_auth.py for the canonical signing surface.
Exchange API keys
Per-exchange procedure. Each exchange has its own console UX; the QTG side of the rotation is identical across all of them.Generic procedure (Upbit / Binance / Bybit / OKX)
- Generate at the exchange. Create a new API key with the same scope as the existing one (typically: read, withdraw, deposit-address-query, and travel-rule beneficiary if applicable). Note any IP allowlist requirement — re-attach the same allowlist before saving.
- Whitelist verification. For exchanges that require withdrawal-address whitelist on the key (Bybit V5 universal book, Upbit), confirm that destination addresses are still attached to the new key. If the key inherits the account-level allowlist, no extra step. If per-key, re-add every required destination before retiring the old key.
- Apply to
.env. Replace the matching access/secret pair. Two-line edit:MG_<EX>_ACCESS_KEY=...andMG_<EX>_SECRET_KEY=.... - Restart QTG. Adapters cache the credentials at construction; a runtime reload is required.
- Validate (read-only first). Run a non-mutating call against the venue.
Exit 0 means every read-only probe passed; exit 2 means missing credentials or an unknown venue. Same form for
binance/bybit/okx/coinbase. Do not reach forrun_cex_live_drillhere — it executes a live withdrawal. - Validate (tiny mutation, optional but recommended). If rotation is due
to suspected exposure, also confirm a withdrawal preflight passes (e.g.
submit_withdrawalagainst a known-whitelisted destination withdry_runsemantics where supported). Do not broadcast a real withdrawal just to prove the key works — read calls + preflight are sufficient. - Retire the old key. Disable, then delete, in the exchange console. The disable-before-delete gap exists so you can quickly re-enable if step 5 or 6 surfaces a problem.
Exchange-specific notes
- Upbit: secret returns to you only once at creation. Capture into 1Password or the equivalent immediately. IP allowlist is mandatory — match the host.
- Bybit V5: travel-rule beneficiary plumbing (
MG_BYBIT_BENEFICIARY_NAME) is not a secret but is required for KR-destination withdrawals; it does not rotate. Thevasp/listcache is per-ClassVarand survives key rotation.
ApiClientKey HMAC secrets (admin_cli / dashboard_writer / agent / operator)
These keys are stored inapi_client_keys.hmac_secret as plaintext text in
the application DB. Rotation is live-safe (no restart needed) because the
HMAC middleware reads from DB on every request.
Issue a new key alongside the old one
- Bootstrap a replacement key with a new
key_idbut the same role and client_name: - The CLI prints
key_id+hmac_secretonce. Capture both into 1Password. - Validate with the new key. Run any HMAC-gated request (e.g., a
GET /v3/movements/...for admin_cli, or unlock writes in the dashboard with the new key). Confirmdashboard_writer_auth_ok(or the equivalentadmin_cli_auth_ok) log line emits with the newkey_id. - Revoke the old key. Use the committed CLI, not raw SQL:
It exits 0 only once the row reads
status = 'revoked'. The HMAC middleware filters onstatus = 'active', so the key stops working on the very next request — no restart needed. - Decrement the expected-count gate (if it was bumped). Update
.env:MG_DASHBOARD_WRITER_EXPECTED_KEY_COUNT/MG_CLI_AUTH_EXPECTED_KEY_COUNTback to the live count. The boot gate fail-closes on mismatch at next restart, so leaving it inflated will surface as a startup error on the next restart for any reason.
Operator offboarding
When an operator leaves, revoke everykey_id they owned:
When DB read is suspected exposed
Plaintext HMAC secret storage is an accepted residual risk (tracked asdocs/reference/dashboard-auth.md known limitation #7). If a DB backup is
suspected leaked, every active HMAC key is exposed — issue replacements
for every api_client_keys row, then revoke all old keys. Same procedure as
above, applied N times.
Local signer key material (Tier-3)
Backendplain and op rotate differently because the key lives in
fundamentally different storage.
plain backend (MG_LOCAL_SIGNER_KEY_SOURCE=plain)
- Generate the new key off-device (hardware wallet, air-gapped machine, or
eth_keysin a one-shot script you immediately discard). - Capture the new signer address. You will need to register this address with whatever on-chain allowlist or whitelist the signer participates in (CCTP burn allowlist, Stargate trusted-pool destination, etc.) before the rotation, not after.
- Edit
.env: replaceMG_LOCAL_SIGNER_PRIVATE_KEY_HEX(or whichever env theMG_LOCAL_SIGNER_KEY_REFpoints to). - Restart QTG. The signer rebootstraps and logs the new address; confirm it matches step 2.
- Validate: any signer health check or preflight that exercises EVM signing.
uv run python -m qtg.interfaces.tools.cctp_live_preflightis the most commonly exercised path. - Wipe the old key — overwrite the file or env var with zeros and unset it.
On macOS/Linux:
unset MG_LOCAL_SIGNER_PRIVATE_KEY_HEXthen remove the line from.env.
plainis dev-only. If you reach this section in a production rollout, migrate toopor AWS KMS instead. Seedocs/reference/signers/local-private-key-signer.md§ Migration.
op backend (MG_LOCAL_SIGNER_KEY_SOURCE=op)
- Generate the new key in 1Password (new item under the same vault, distinct
ref). Note the new
op://Vault/Item/private-keyreference. - Confirm the new signer address (decrypt and derive locally if you have a secure environment; otherwise let step 4 surface the address through QTG’s signer health log).
- Update on-chain allowlists for the new address (see
plainstep 2). - Edit
.env: updateMG_LOCAL_SIGNER_KEY_REFto the newop://...URI. Restart QTG. - Validate: signer health log line shows the new address; run a preflight.
- Wipe: delete the old 1Password item. The on-disk
bytearraywas already wiped per-sign (refetch_per_sign=truedefault) — no host cleanup needed.
op binary via MG_LOCAL_SIGNER_OP_PATH, the binary
path is not a secret and does not rotate with the key.
CCIP sidecar auth token
MG_CCIP_SIDECAR_AUTH_TOKEN is shared between QTG and the CCIP sidecar
deployment. The sidecar verifies the token on every refresh / send.
- Generate (same
secrets.token_hex(32)pattern). - Update the sidecar deployment first (e.g. its
.envor k8s secret) so it accepts the new token. If the sidecar supports dual-accept, add the new value as secondary; otherwise plan a brief downtime window. - Update
.envon the QTG side; restart QTG. - Validate:
uv run python -m qtg.interfaces.tools.refresh_ccip_registry(or whichever sidecar-hitting CLI is available). Confirm 200. - Retire the old token on the sidecar.
Remote signer auth token
MG_REMOTE_SIGNER_AUTH_TOKEN is the bearer token QTG presents to a
RemoteSignerProxy upstream. The upstream’s view of the token must rotate
atomically.
Same shape as the CCIP sidecar procedure. The upstream owner is the source of
truth — coordinate the window first.
Post-rotation validation
After any single rotation, before declaring the procedure complete:-
dashboard_writer_auth_ok/admin_cli_auth_oklog line emits with the newkey_id(for ApiClientKey rotations) - Affected adapter / signer health check passes (signer bootstrap log line,
executor
health()if exposed) - At least one read-only call against the rotated surface returns 2xx
- Old credential is disabled (not just retired in memory)
- If rotation was triggered by suspected exposure: incident log committed
Git history secret audit
A secret that ever appeared in a commit is permanently exposed viagit log -p, even if the file was later deleted or the value overwritten. There is
no in-place fix — only rotate and accept the loss.
Triage when you find one
- Rotate immediately. Run the matching subprocedure above for that secret class. Do this before any source-tree cleanup.
- Decide on history rewrite.
- Pre-public repo (this Pro repo, never published): a history rewrite
(
git filter-repo --replace-text) is technically possible but rarely worth it for a private 1-operator repo. Document the exposure window as an incident entry —<UTC>-git-history-secret-<scope>— and move on. - Public/OSS repo (
github.com/jephalabs/quant-transfer-guard): the OSS publish is squash-only (scripts/publish_oss.sh), so OSS history never carries Pro history. Confirm bygit log --all --source -p -S '<secret-fragment>'against the OSS clone. If the leak made it into the OSS squash baseline, force-publish a new baseline. Pre-1.0 immutable-tag policy still applies to new tags; the affected tag stays as-is for clone provenance.
- Pre-public repo (this Pro repo, never published): a history rewrite
(
- Re-audit. Run a repo-wide scan after rotation to catch sibling leaks:
- Lock the door. If the leak vector is a config file ever-tracked, add
it to
.gitignore(most are already there —.env,.env.local,.env.*.local). If the leak was a paste-in-source mistake, consider a pre-commitgitleakshook.
Known safe items in git
MG_CALLBACK_HMAC_SECRET=CHANGE_MEplaceholder in.env.exampleis not a leak — the literal valueCHANGE_MEis documented intests/qtg/test_settings_load.pyas the sentinel that the boot gate rejects.TEST_PRIVATE_KEY_HEX = "0x" + "a" * 64intests/qtg/test_local_private_key_signer.pyis a throwaway test key with zero on-chain assets ever attached. Documented in the test file header.
See also
docs/reference/dashboard-auth.md— dashboard HMAC scheme + known limitations (incl. plaintext secret storage)docs/runbooks/dashboard-auth-v1-to-hmac.md— historical migration from the legacy bundle-baked token (deprecated)docs/callback-verification-contract.md— callback v3 signing contractdocs/reference/signers/local-private-key-signer.md— Tier-3 signer internals + migration to AWS KMS