From 35ba254f6ae16fff165c1f564e85964608772769 Mon Sep 17 00:00:00 2001 From: Robert Leifke Date: Thu, 17 Sep 2026 18:13:38 -0400 Subject: [PATCH] fix(infra): stop a flagless plan from deleting the executor KMS key executor_kms_enabled and executor_kms_signing defaulted to false while the live stack has both true -- they were passed as -var during the rollout in #62-#64 and persisted nowhere. Any plan in infra/aws that omitted them proposed: aws_kms_key.executor[0] will be destroyed aws_kms_alias.executor[0] will be destroyed aws_iam_role_policy.executor_kms_sign[0] will be destroyed aws_ecs_task_definition.execution must be replaced Plan: 3 to add, 1 to change, 4 to destroy. That schedules the settlement key for deletion and drops EXECUTOR_KMS_KEY_ID from execution-service. Since #64 deleted the stored PRIVATE_KEY there is no second signing path, so the replacement task would carry no key at all and fail its config schema at boot. Two changes, because either alone leaves a gap: - the defaults are true, so a plan with no flags is a no-op - both KMS keys carry lifecycle.prevent_destroy, so an explicit -var executor_kms_enabled=false errors instead of destroying Verified against the live state: a plan with no KMS flags reports "No changes"; the same plan with executor_kms_enabled=false now fails with "Instance cannot be destroyed" rather than producing an appliable plan. Also adds aws_kms_key.rebalance (alias numo-exchange-rebalance), applied 2026-09-17 and deriving to 0x1661AA54fA390cd916722F971e4A9Fe4c01889fB, which signs USDC->cNGN swaps on the HyperFX IntentGateway for the cNGN rebalance. Deliberately not the executor key: a compromise there costs the float, not the venue's settlement authority. Co-authored-by: Claude Opus 5 (1M context) --- infra/aws/secrets.tf | 38 ++++++++++++++++++++++++++++++++++++++ infra/aws/variables.tf | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/infra/aws/secrets.tf b/infra/aws/secrets.tf index 7b1c082..d6c0f55 100644 --- a/infra/aws/secrets.tf +++ b/infra/aws/secrets.tf @@ -115,6 +115,13 @@ resource "aws_kms_key" "executor" { # means a setTradeExecutor call, not a terraform destroy. deletion_window_in_days = 30 enable_key_rotation = false + # Terraform must never be able to delete this: the venue settles every trade and every + # withdrawal with this key, and Matching gates settlement on its address. + # Retiring it is a deliberate act (sweep the balance, re-point what signs), which means + # removing this block first -- not a flag someone forgot on a routine plan. + lifecycle { + prevent_destroy = true + } } resource "aws_kms_alias" "executor" { @@ -140,3 +147,34 @@ resource "aws_iam_role_policy" "executor_kms_sign" { role = aws_iam_role.task.id policy = data.aws_iam_policy_document.executor_kms_sign[0].json } + +# Working capital for the cNGN rebalance. The market maker's flow is one-directional -- it sells +# cNGN for USDC -- so without a routine that buys cNGN back the bid side eventually goes dark. +# HyperFX fills that swap same-chain on Base against the identical cNGN the venue settles +# (0x46C85152bFe9f96829aA94755D9f915F9B10EF5F), so the round trip never leaves the chain. +# +# The address this key derives to needs ETH for gas and its own USDC allowance to the gateway +# before it can swap. Neither is terraform's to give it. +resource "aws_kms_key" "rebalance" { + count = var.rebalance_kms_enabled ? 1 : 0 + description = "${var.name} cNGN rebalance signing key (secp256k1)" + key_usage = "SIGN_VERIFY" + customer_master_key_spec = "ECC_SECG_P256K1" + # Same reasoning as the executor key: a signing key is an on-chain identity holding value, and + # deleting it strands whatever it holds. Retiring it means sweeping the balance first. + deletion_window_in_days = 30 + enable_key_rotation = false + # Terraform must never be able to delete this: it holds the rebalance float, and deleting it + # strands whatever USDC and cNGN sit at its address. + # Retiring it is a deliberate act (sweep the balance, re-point what signs), which means + # removing this block first -- not a flag someone forgot on a routine plan. + lifecycle { + prevent_destroy = true + } +} + +resource "aws_kms_alias" "rebalance" { + count = var.rebalance_kms_enabled ? 1 : 0 + name = "alias/${var.name}-rebalance" + target_key_id = aws_kms_key.rebalance[0].key_id +} diff --git a/infra/aws/variables.tf b/infra/aws/variables.tf index 50192b4..2779f5f 100644 --- a/infra/aws/variables.tf +++ b/infra/aws/variables.tf @@ -227,9 +227,13 @@ variable "executor_kms_enabled" { The two are separate because the new address must be funded and authorised on Matching with setTradeExecutor (owner-only) BEFORE it signs anything, and that happens between the two applies. + + Defaults true because the key exists and is authorised on Matching. A plan that resolves + this to false proposes destroying the venue's settlement key; aws_kms_key.executor carries + prevent_destroy so that fails loudly instead of succeeding quietly. EOT type = bool - default = false + default = true } variable "executor_kms_signing" { @@ -249,7 +253,32 @@ variable "executor_kms_signing" { Rollback is this flag back to false plus a rollout; the old key stays authorised until it is retired deliberately. + + Defaults true because #64 deleted the stored PRIVATE_KEY. There is no second signing path: + resolved false, execution-service gets neither EXECUTOR_KMS_KEY_ID nor PRIVATE_KEY and + refuses to boot. The rollout sequence above is history, kept because it explains the split. EOT type = bool - default = false + default = true +} + +variable "rebalance_kms_enabled" { + description = <<-EOT + Create the KMS signing key the cNGN rebalance uses to swap USDC for cNGN on the HyperFX + IntentGateway (0xAe041F7B0CB581876832830baeB6a2Aa2a3C9716 on Base). + + Deliberately NOT the executor key. The executor is authorised on Matching by + setTradeExecutor and settles every trade and withdrawal on the venue; this one only ever + holds a few hundred dollars of working capital and signs placeOrder. Keeping them apart + means a compromised rebalance key costs the float, not the venue's settlement authority -- + the same reason the executor was moved off the personal wallet on 2026-09-16. + + Creating the key grants nothing and starts nothing. Until the loop is automated the swap is + run as a one-shot by an operator, who already holds kms:Sign through their own role, so no + task role gets Sign on this key yet. + Defaults true for the same reason as the executor key: it exists, it holds working capital, + and a plan that resolves it to false proposes deleting it out from under that balance. + EOT + type = bool + default = true }