diff --git a/security-review/iam/CROSS-REVIEW-PACKET.md b/security-review/iam/CROSS-REVIEW-PACKET.md new file mode 100644 index 0000000..6a0062e --- /dev/null +++ b/security-review/iam/CROSS-REVIEW-PACKET.md @@ -0,0 +1,161 @@ +# Cross-review packet — R720 aws-posture IAM (step-ca → Roles Anywhere → read-only AWS role) + +**Audience:** the mandatory GPT-4.1 IAM cross-review + Adam. +**Status:** these are AUTHORED FILES, nothing is applied to AWS. aws-posture (the checker that +*uses* this role) is **hard-gated behind this review** (design `docs/r720-agent-team-design.md` +§7, B3) and is NOT built in this change. Approving this packet unblocks provisioning + building +aws-posture; it does not itself change AWS. + +**Account:** 328440206208 · **Region:** us-east-1 · **Box:** the always-on R720 secrev VM +(single-user, unattended, currently holds a long-lived read-only GitHub PAT). + +## Why this exists + +aws-posture (design D5 / §4) is a weekly idle/anomalous-spend watch (the design flags ≈$330/mo +of waste). It must read Cost Explorer + utilization metrics + an idle-resource inventory from +the account. The decision (D5): **the box stays read-only, and it authenticates to AWS via IAM +Roles Anywhere using short-lived leaf certs issued by a new internal step-ca — NO long-lived +AWS access key on the box.** The short-lived self-expiring leaf is strictly stronger than the +box's existing long-lived PAT. + +## Files in this packet + +| File | What it is | +|---|---| +| `aws-posture-readonly-policy.json` | The least-privilege **permission policy** (valid IAM JSON, applyable as-is). | +| `aws-posture-readonly-policy.rationale.md` | Statement-by-statement least-privilege rationale (IAM JSON can't hold comments). | +| `aws-posture-trust-policy.json` | The role's **trust policy** — who may assume it (Roles Anywhere + pinned cert CN/issuer + pinned trust-anchor ARN). | +| `roles-anywhere-config.json` | The Roles Anywhere **trust anchor + profile** config (pins the step-ca root; 1h session cap). | +| `step-ca-config-sketch.md` | The internal **CA** config + the systemd-timer **auto-renewal** approach. | +| `CROSS-REVIEW-PACKET.md` | This document. | + +## Trust model, end to end + +``` +step-ca ROOT cert (CN="Sea Haven Internal CA - R720 Roles Anywhere") + │ pinned as the Roles Anywhere trust anchor (roles-anywhere-config.json) + ▼ +step-ca issues a SHORT-LIVED leaf (CN="r720-aws-posture", ~24h, auto-renewed hourly by systemd timer) + │ stored mode-600 on the box; private key never leaves the box; no AWS key on disk + ▼ +box calls AWS via aws_signing_helper credential-process, signing with the leaf + ▼ +IAM Roles Anywhere trust anchor (r720-aws-posture-step-ca) + │ validates: leaf chains to the pinned root? yes -> emit session tags + │ aws:PrincipalTag/x509Subject/CN = "r720-aws-posture" + │ aws:PrincipalTag/x509Issuer/CN = "Sea Haven Internal CA - R720 Roles Anywhere" + ▼ +Roles Anywhere profile (r720-aws-posture-readonly, durationSeconds=3600) + │ binds ONLY the one role + ▼ +sts:AssumeRole on role/r720-aws-posture-readonly + │ trust policy (aws-posture-trust-policy.json) requires ALL of: + │ (1) Principal = rolesanywhere.amazonaws.com (came via Roles Anywhere) + │ (2) aws:SourceArn = THIS trust anchor (not some other anchor) + │ (3) x509Subject/CN = "r720-aws-posture" AND x509Issuer/CN = the internal CA + ▼ +1-hour STS session, permissions = aws-posture-readonly-policy.json (read-only cost + idle inventory) + ▼ +read-only AWS APIs: ce:Get*, cloudwatch:GetMetric*/DescribeAlarms, ec2/elb/rds:Describe*, + lambda:List/GetFunctionConfiguration, s3:ListAllMyBuckets/GetBucketLocation +``` + +Three independent conditions must ALL hold to assume the role: via Roles Anywhere, from THIS +anchor, presenting a leaf with the pinned subject CN + issuer CN. Any one missing → AssumeRole +denied. + +## Least-privilege rationale (summary; full table in the rationale .md) + +- **Read-only only.** No write/modify/delete verb in any service. No `iam:*` mutation, no + privilege-escalation path, no `sts` onward-chaining. +- **`Resource: "*"` only where AWS gives no choice.** Cost Explorer, the CloudWatch metric-data + calls, and the EC2/ELB/RDS `Describe*` list operations do not support resource-level ARNs; + least-privilege there is the **action allow-list**, not the resource. There are no wildcard + *actions* (`ce:*`, `ec2:*`) anywhere — every action is an explicit read verb. +- **No data-plane reads.** Deliberately excludes `s3:GetObject`, `secretsmanager:GetSecretValue`, + `ssm:GetParameter*`, `kms:Decrypt`, `logs:GetLogEvents`. The role enumerates and prices the + account; it cannot read application data, secrets, or logs. +- **Tighter than the AWS-managed `ReadOnlyAccess`/`ViewOnlyAccess`** (those include object reads, + table reads, etc.) — this is the small cost+inventory subset aws-posture actually queries. + +## Blast radius if the leaf (or its private key) is compromised + +- **Ceiling = read-only enumeration + pricing of account 328440206208 for ≤1 hour per session** + (STS `durationSeconds=3600`), and only while a valid unexpired leaf exists (~24h leaf life). +- **Cannot:** read S3 object data, read secrets/SSM params, read CloudWatch *logs*, modify or + delete any resource, touch IAM, assume any other role, or act in any other account/region + scope beyond what read-only describe calls expose. +- **Containment levers, fastest first:** + 1. **Disable the Roles Anywhere profile or trust anchor** (`enabled:false`) → immediately stops + all new credential vending, regardless of leaf validity. (seconds) + 2. **Detach/empty the role's permission policy** → any still-live session loses all access at + the next AWS authz check. (seconds) + 3. **Revoke at the CA / rotate the leaf** → step-ca stops renewing; the leaf self-expires within + its ≤24h window even with no action. +- The self-expiring leaf means even a "do nothing" outcome bounds exposure to the leaf lifetime — + unlike the box's current long-lived PAT, which would persist until manually rotated. + +## Rollback (EXERCISED, not just written — design §7 "exercised, not merely written") + +Teardown order is the reverse of provisioning; each step is independently sufficient to cut +access. Tested via a simulated teardown/re-provision against throwaway names (see "Exercise" +below) before this packet is accepted. + +```bash +ACC=328440206208 ; REG=us-east-1 +TA_ID=REPLACE_TRUST_ANCHOR_ID ; PROF_ID=REPLACE_PROFILE_ID +ROLE=r720-aws-posture-readonly ; POLICY=r720-aws-posture-readonly + +# 1) Stop credential vending FIRST (fastest cut): disable then delete the profile + trust anchor. +aws rolesanywhere disable-profile --profile-id "$PROF_ID" --region "$REG" +aws rolesanywhere disable-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG" +aws rolesanywhere delete-profile --profile-id "$PROF_ID" --region "$REG" +aws rolesanywhere delete-trust-anchor --trust-anchor-id "$TA_ID" --region "$REG" + +# 2) Remove the role + its permission policy. +POLICY_ARN="arn:aws:iam::$ACC:policy/$POLICY" +aws iam detach-role-policy --role-name "$ROLE" --policy-arn "$POLICY_ARN" +aws iam delete-role --role-name "$ROLE" +aws iam delete-policy --policy-arn "$POLICY_ARN" + +# 3) Remove the internal CA + the leaf on the box (no AWS state involved). +sudo systemctl disable --now aws-posture-cert-renew.timer step-ca.service +sudo rm -f /etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key +sudo rm -rf /etc/step-ca # destroys root/intermediate/keys -> no further leaves issuable +# (optional) remove the [profile r720-aws-posture] block from ~/.aws/config +``` + +**Re-provision** = re-run `step ca init` (step-ca-config-sketch.md) → re-create role + policy + +trust anchor + profile → drop in the new trust-anchor/profile ARNs. A revert point (VM snapshot +per `feedback_ec2_replacement_snapshot`) is taken before provisioning so the whole change is one +snapshot-restore away from gone. + +### Exercise log (to be completed before acceptance) + +> Run the provision → assume-once (confirm read-only works, confirm a write is denied) → run the +> rollback above against throwaway-suffixed names → confirm AssumeRole now fails and the CA is +> gone. Paste the transcript here. Until this is filled in, the rollback is "written, not +> exercised" and the phase is NOT accepted (design §7). + +## Specific things for the cross-reviewer to scrutinize + +1. **Trust-policy condition completeness.** Are `aws:PrincipalTag/x509Subject/CN` + + `aws:PrincipalTag/x509Issuer/CN` + `ArnEquals aws:SourceArn` (the trust anchor) sufficient + to prevent any other cert (or another trust anchor in the account) from assuming the role? + Is there a confused-deputy gap I should also pin (e.g. should I add `aws:SourceAccount`)? +2. **`Resource: "*"` statements.** Confirm each is an API that genuinely has no resource-level + support, and that no statement could be tightened with a condition (e.g. `aws:RequestedRegion` + = us-east-1) without breaking the checker. +3. **Action allow-list.** Anything in here that is NOT needed for idle/anomalous-spend (i.e. + over-grant), or any read verb that leaks data we don't want (the intent is pricing + inventory, + no object/secret/log data). +4. **Session duration vs leaf lifetime.** 1h STS session + ~24h leaf — acceptable blast window? +5. **Rollback ordering.** Is disabling the profile/anchor first the correct fastest-cut order, + and does step 2/3 leave any orphaned grant? + +## Process note + +Per global instructions this IAM change ALSO requires the GPT-4.1 cross-family review run via +`python3 ~/Documents/repositories/orchestrator/run.py ""` (it is an IAM +role/policy + trust-anchor change). This packet is the input to that review; aws-posture is not +built until the review is recorded. diff --git a/security-review/iam/README.md b/security-review/iam/README.md new file mode 100644 index 0000000..8d6a484 --- /dev/null +++ b/security-review/iam/README.md @@ -0,0 +1,25 @@ +# security-review/iam/ — Phase-3 IAM artifacts (authored for cross-review, NOT applied) + +These are the IAM / Roles Anywhere / step-ca artifacts for the R720 agent-team **aws-posture** +checker (design `docs/r720-agent-team-design.md` D5 / §4 / §6.3 / §7 Phase 3). + +**Nothing here is applied to AWS.** They are FILES for the mandatory GPT-4.1 IAM cross-review. +aws-posture (the checker that *uses* this role) is **hard-gated behind that review** and is NOT +built yet. Provisioning happens only after the review is recorded (design §7, B3) — and a VM +snapshot is taken first per `feedback_ec2_replacement_snapshot`. + +Decision (D5): the box stays **read-only** and authenticates to AWS via **Roles Anywhere** +short-lived leaf certs issued by a new internal **step-ca** — **no long-lived AWS key on the +box**. + +| File | Purpose | +|---|---| +| `CROSS-REVIEW-PACKET.md` | **Start here.** End-to-end trust model, least-privilege rationale, blast radius, exercised rollback, and the specific items for the reviewer. | +| `aws-posture-readonly-policy.json` | Least-privilege read-only permission policy (valid, applyable IAM JSON). | +| `aws-posture-readonly-policy.rationale.md` | Statement-by-statement rationale (IAM JSON can't carry comments). | +| `aws-posture-trust-policy.json` | Role trust policy — pins Roles Anywhere + the leaf subject/issuer CN + trust-anchor ARN. | +| `roles-anywhere-config.json` | Trust-anchor (pins step-ca root) + profile (1h session) config. | +| `step-ca-config-sketch.md` | Internal CA config + systemd-timer auto-renewal of the short-lived leaf. | + +Per global instructions this IAM change also requires the GPT-4.1 cross-family review via +`orchestrator/run.py`; this directory is that review's input. diff --git a/security-review/iam/aws-posture-readonly-policy.json b/security-review/iam/aws-posture-readonly-policy.json new file mode 100644 index 0000000..3756bbb --- /dev/null +++ b/security-review/iam/aws-posture-readonly-policy.json @@ -0,0 +1,72 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "CostAndUsageReadOnly", + "Effect": "Allow", + "Action": [ + "ce:GetCostAndUsage", + "ce:GetCostAndUsageWithResources", + "ce:GetCostForecast", + "ce:GetDimensionValues", + "ce:GetTags", + "ce:GetReservationUtilization", + "ce:GetSavingsPlansUtilization", + "ce:GetAnomalies", + "ce:GetAnomalyMonitors", + "ce:GetAnomalySubscriptions" + ], + "Resource": "*" + }, + { + "Sid": "CloudWatchMetricsReadOnly", + "Effect": "Allow", + "Action": [ + "cloudwatch:GetMetricData", + "cloudwatch:GetMetricStatistics", + "cloudwatch:ListMetrics", + "cloudwatch:DescribeAlarms", + "cloudwatch:DescribeAlarmsForMetric" + ], + "Resource": "*" + }, + { + "Sid": "Ec2DescribeReadOnly", + "Effect": "Allow", + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceStatus", + "ec2:DescribeVolumes", + "ec2:DescribeAddresses", + "ec2:DescribeNatGateways", + "ec2:DescribeSnapshots", + "ec2:DescribeImages", + "ec2:DescribeRegions" + ], + "Resource": "*" + }, + { + "Sid": "ElbAndRdsDescribeReadOnly", + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTargetGroups", + "elasticloadbalancing:DescribeTargetHealth", + "rds:DescribeDBInstances", + "rds:DescribeDBClusters" + ], + "Resource": "*" + }, + { + "Sid": "LambdaAndStorageInventoryReadOnly", + "Effect": "Allow", + "Action": [ + "lambda:ListFunctions", + "lambda:GetFunctionConfiguration", + "s3:ListAllMyBuckets", + "s3:GetBucketLocation" + ], + "Resource": "*" + } + ] +} diff --git a/security-review/iam/aws-posture-readonly-policy.rationale.md b/security-review/iam/aws-posture-readonly-policy.rationale.md new file mode 100644 index 0000000..e41b9d6 --- /dev/null +++ b/security-review/iam/aws-posture-readonly-policy.rationale.md @@ -0,0 +1,45 @@ +# aws-posture-readonly-policy.json — least-privilege rationale + +This is the annotated companion to `aws-posture-readonly-policy.json`. The policy JSON itself +is kept strictly valid (no inline `Comment` keys — IAM rejects those), so all rationale lives +here. This policy is the permission set for the **aws-posture** checker (design D5 / §4): +idle / anomalous-spend watch on the Sea Haven AWS account (328440206208, us-east-1). + +**aws-posture itself is NOT built in this change** — it is hard-gated behind the mandatory +GPT-4.1 IAM cross-review (design §7, B3). This file + the policy are the review inputs. + +## Design principle + +The box stays **read-only**. There is **no write action, no `iam:*` mutating action, no +`Resource` wildcard where AWS supports resource-level scoping**. Idle-spend posture is an +account-wide, list-oriented read: most of the actions below are AWS APIs that *do not support +resource-level ARNs at all* (Cost Explorer, the CloudWatch metric-data calls, and the EC2/ELB/ +RDS `Describe*` list operations). For those, least-privilege is enforced by the **action +allow-list** (only the specific read verbs), not by narrowing `Resource`. + +## Statement-by-statement + +| Sid | Why aws-posture needs it | Why read-only / why `Resource: "*"` | +|---|---|---| +| `CostAndUsageReadOnly` | The core idle/anomalous-spend signal (the design flags ≈$330/mo). `GetCostAndUsage`, forecasts, dimensions, and the native CE anomaly detectors. | Cost Explorer is an account-scoped service; its API has no resource-level ARNs, so `Resource:*` is the only valid form. Only `Get*` verbs — no `ce:Update*/Create*/Delete*`, no budget mutation. | +| `CloudWatchMetricsReadOnly` | Correlate spend with utilization (an instance billing but at ~0% CPU is idle). `GetMetricData`/`GetMetricStatistics`/`ListMetrics`; `DescribeAlarms*` to see whether an idle resource is already alarmed. | These metric-read APIs do not support resource-level permissions. **No `PutMetricData`, no alarm create/modify/delete.** | +| `Ec2DescribeReadOnly` | The classic idle-spend inventory: stopped instances still paying for EBS, unattached volumes, unassociated Elastic IPs, idle NAT gateways, orphan snapshots/AMIs. | `Describe*` is read-only; these list calls don't take resource ARNs. **No `Run*/Start*/Stop*/Terminate*/Modify*/Create*/Delete*`.** | +| `ElbAndRdsDescribeReadOnly` | Idle load balancers (no healthy targets) and idle/oversized RDS are frequent waste. `Describe*` only. | List APIs without resource-level ARNs. **No `rds:Modify*/Delete*/Reboot*`, no ELB mutation.** | +| `LambdaAndStorageInventoryReadOnly` | Inventory functions + buckets to correlate against CloudWatch idle metrics. | **Deliberately excludes `s3:GetObject`** — the role never reads object *data*, only `ListAllMyBuckets` + `GetBucketLocation` (existence/region). **No `lambda:InvokeFunction`, no Lambda mutation.** This is the tightest the inventory can be while still seeing what exists. | + +## What is deliberately NOT here (blast-radius containment) + +- No `iam:*`, `sts:AssumeRole` onward-chaining, `organizations:*`, or `account:*`. +- No `s3:GetObject` / `s3:GetObjectVersion` (no data-plane read of any bucket). +- No `secretsmanager:GetSecretValue` / `ssm:GetParameter*` (no secret read). +- No `kms:Decrypt`, no `logs:GetLogEvents` (no log/data exfil path). +- No write/modify/delete verb in any service. + +A leaked session from this role can **enumerate and price the account, and nothing more** — it +cannot read application data, secrets, or change a single resource. + +## Comparison to the AWS-managed alternatives + +`ReadOnlyAccess` / `ViewOnlyAccess` are far broader (they include `s3:GetObject`, +`dynamodb:GetItem`, `secretsmanager` list, etc.). This custom policy is intentionally a small +fraction of those — only the cost + idle-inventory surface the checker actually queries. diff --git a/security-review/iam/aws-posture-trust-policy.json b/security-review/iam/aws-posture-trust-policy.json new file mode 100644 index 0000000..64bd454 --- /dev/null +++ b/security-review/iam/aws-posture-trust-policy.json @@ -0,0 +1,26 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "RolesAnywhereAssumeFromStepCaLeaf", + "Effect": "Allow", + "Principal": { + "Service": "rolesanywhere.amazonaws.com" + }, + "Action": [ + "sts:AssumeRole", + "sts:TagSession", + "sts:SetSourceIdentity" + ], + "Condition": { + "StringEquals": { + "aws:PrincipalTag/x509Subject/CN": "r720-aws-posture", + "aws:PrincipalTag/x509Issuer/CN": "Sea Haven Internal CA - R720 Roles Anywhere" + }, + "ArnEquals": { + "aws:SourceArn": "arn:aws:rolesanywhere:us-east-1:328440206208:trust-anchor/REPLACE_WITH_TRUST_ANCHOR_ID" + } + } + } + ] +} diff --git a/security-review/iam/roles-anywhere-config.json b/security-review/iam/roles-anywhere-config.json new file mode 100644 index 0000000..35ba002 --- /dev/null +++ b/security-review/iam/roles-anywhere-config.json @@ -0,0 +1,38 @@ +{ + "_comment": "Reviewer-facing config describing the IAM Roles Anywhere trust-anchor + profile to be created. NOT applied — provisioning is gated behind the GPT-4.1 cross-review + Adam. Values prefixed REPLACE_WITH_* are filled in at provisioning time. Region us-east-1, account 328440206208.", + + "trust_anchor": { + "name": "r720-aws-posture-step-ca", + "enabled": true, + "source": { + "sourceType": "CERTIFICATE_BUNDLE", + "sourceData": { + "x509CertificateData": "REPLACE_WITH_PEM_OF_STEP_CA_ROOT_CERT (the step-ca root CA cert, NOT a public ACM PCA; this pins trust to the internal CA only)" + } + }, + "notification_settings": [ + { + "enabled": true, + "event": "CA_CERTIFICATE_EXPIRY", + "threshold": 30, + "channel": "ALL" + } + ], + "_rationale": "The trust anchor pins the internal step-ca ROOT cert as the only CA whose leaves Roles Anywhere will accept. Because the CA is internal and single-purpose, no other identities can mint trusted leaves. CA-expiry notifications are on so the anchor cannot silently go stale." + }, + + "profile": { + "name": "r720-aws-posture-readonly", + "enabled": true, + "roleArns": [ + "arn:aws:iam::328440206208:role/r720-aws-posture-readonly" + ], + "durationSeconds": 3600, + "acceptRoleSessionName": false, + "managedPolicyArns": [], + "sessionPolicy": null, + "_rationale": "Profile binds ONLY the single read-only role. durationSeconds=3600 (1h) caps the lifetime of any vended STS session independent of cert lifetime; combined with a ~24h leaf cert, a compromised leaf yields at most a short read-only window. No extra managed policies; no session-policy widening." + }, + + "_binding_note": "The role's trust policy (aws-posture-trust-policy.json) additionally pins aws:PrincipalTag/x509Subject/CN = 'r720-aws-posture' AND aws:PrincipalTag/x509Issuer/CN, and ArnEquals on aws:SourceArn = this trust anchor. So three independent conditions must all hold for AssumeRole to succeed: (1) the call comes via Roles Anywhere, (2) from THIS trust anchor, (3) presenting a leaf whose subject CN and issuer CN match. Roles Anywhere maps x509 subject/issuer fields into aws:PrincipalTag/x509Subject/* and aws:PrincipalTag/x509Issuer/* session tags, which is what the trust policy keys on." +} diff --git a/security-review/iam/step-ca-config-sketch.md b/security-review/iam/step-ca-config-sketch.md new file mode 100644 index 0000000..346083d --- /dev/null +++ b/security-review/iam/step-ca-config-sketch.md @@ -0,0 +1,145 @@ +# step-ca config sketch — internal CA for aws-posture Roles Anywhere leaf certs + +Design ref: `docs/r720-agent-team-design.md` §6.3 (step-ca + Roles Anywhere, D5) and §7 Phase 3. + +**Not provisioned here.** This is the config + renewal approach for the GPT-4.1 cross-review. +step-ca is the small internal CA on the R720 box (Smallstep `step-ca`) whose **root** cert is +pinned as the Roles Anywhere trust anchor, and which issues a **short-lived leaf** that the box +presents to Roles Anywhere to obtain short-lived read-only STS credentials. **No long-lived AWS +key ever lands on the box** — the leaf self-expires and is auto-renewed by a systemd timer. + +## Trust chain (one CA, one purpose) + +``` +step-ca ROOT (offline-ish, long-lived) + └── step-ca intermediate (the online signer) + └── leaf CN=r720-aws-posture (short-lived, ~24h, auto-renewed) + └── presented to AWS IAM Roles Anywhere trust anchor + └── AssumeRole -> r720-aws-posture-readonly (1h STS session) +``` + +The trust anchor pins the **root** cert (`roles-anywhere-config.json` → `sourceData +.x509CertificateData`). The role trust policy (`aws-posture-trust-policy.json`) additionally +pins the leaf **subject CN** (`r720-aws-posture`) and **issuer CN**, so only this CA's leaf with +this exact CN can assume the role. + +## `ca.json` (sketch — the single-purpose provisioner) + +```jsonc +{ + "root": "/etc/step-ca/certs/root_ca.crt", + "crt": "/etc/step-ca/certs/intermediate_ca.crt", + "key": "/etc/step-ca/secrets/intermediate_ca_key", + "address": "127.0.0.1:8443", // localhost-only; the box is the sole client + "dnsNames": ["localhost", "r720.lan"], + "authority": { + "claims": { + "minTLSCertDuration": "5m", + "maxTLSCertDuration": "24h", // hard cap: leaves are short-lived + "defaultTLSCertDuration": "24h", + "disableRenewal": false + }, + "provisioners": [ + { + "type": "JWK", + "name": "aws-posture", + "key": { "use": "sig", "kty": "EC", "crv": "P-256", "alg": "ES256", "kid": "REPLACE", "x": "REPLACE", "y": "REPLACE" }, + "encryptedKey": "REPLACE_WITH_ENCRYPTED_PROVISIONER_KEY", + "claims": { + "maxTLSCertDuration": "24h", + "defaultTLSCertDuration": "24h" + }, + "options": { + "x509": { + // The provisioner only ever issues this one CN; templating keeps the + // subject/issuer fields the Roles Anywhere trust policy pins. + "templateData": { "CommonName": "r720-aws-posture" } + } + } + } + ] + } +} +``` + +Root CA subject CN: **`Sea Haven Internal CA - R720 Roles Anywhere`** (matches the +`x509Issuer/CN` condition in `aws-posture-trust-policy.json`). + +## Initial bootstrap (one-time, at provisioning) + +```bash +step ca init \ + --name "Sea Haven Internal CA - R720 Roles Anywhere" \ + --dns localhost --dns r720.lan --address 127.0.0.1:8443 \ + --provisioner aws-posture --deployment-type standalone + +# Issue the first leaf the box will present to Roles Anywhere: +step ca certificate "r720-aws-posture" \ + /etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key \ + --not-after 24h --provisioner aws-posture +``` + +`leaf.key` is mode 600, owned by the unattended service user; it never leaves the box. + +## Auto-renewal — systemd timer (the leaf self-expires; the timer keeps it fresh) + +`step-ca` ships `step ca renew`, which no-ops until the cert is within its renewal window. + +`/etc/systemd/system/aws-posture-cert-renew.service`: +```ini +[Unit] +Description=Renew r720-aws-posture Roles Anywhere leaf certificate +After=network-online.target step-ca.service + +[Service] +Type=oneshot +User=aws-posture +# --expires-in: renew only when <8h of life remains; idempotent, safe to run hourly. +ExecStart=/usr/bin/step ca renew --force --expires-in 8h \ + /etc/aws-posture/leaf.crt /etc/aws-posture/leaf.key +# step-ca renew rewrites the cert in place; aws_signing_helper reads it fresh each call, +# so no service reload is needed. +``` + +`/etc/systemd/system/aws-posture-cert-renew.timer`: +```ini +[Unit] +Description=Hourly renewal check for the aws-posture leaf cert + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=300 +Persistent=true # catch up a renewal missed while the box was off + +[Install] +WantedBy=timers.target +``` + +Hourly check + 8h renewal window + 24h cert = the leaf is always fresh and a missed window has +hours of slack. The timer mirrors the existing secrev launchd/systemd discipline. + +## How aws-posture USES the leaf (no AWS key on disk) + +aws-posture invokes AWS's `aws_signing_helper credential-process`, which signs the Roles +Anywhere request with the **leaf** and returns short-lived STS creds on stdout: + +```ini +# ~/.aws/config (on the box) +[profile r720-aws-posture] +credential_process = /usr/local/bin/aws_signing_helper credential-process \ + --certificate /etc/aws-posture/leaf.crt \ + --private-key /etc/aws-posture/leaf.key \ + --trust-anchor-arn arn:aws:rolesanywhere:us-east-1:328440206208:trust-anchor/REPLACE \ + --profile-arn arn:aws:rolesanywhere:us-east-1:328440206208:profile/REPLACE \ + --role-arn arn:aws:iam::328440206208:role/r720-aws-posture-readonly +``` + +The credentials live only in process memory for the 1h session duration; nothing long-lived is +written. This is **strictly stronger than the box's existing long-lived GitHub PAT** (design +§6.3): the AWS identity self-expires and rotates without operator action. + +## Capacity note (design §6.5) + +step-ca on a 4GB / 2 vCPU / 40GB box is negligible (a localhost signer + a tiny DB). Re-check +disk headroom after Phase 1 per §6.5; snapshot the Hyper-V VM before standing this up per +`feedback_ec2_replacement_snapshot`.