From a86465d0e265979145e20d5a7af75f3d405fd01c Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:54:32 -0400 Subject: [PATCH] security: door-unlock token rotation runbook + executed cutover (INFRA-105) (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * security: placeholder committed door-unlock token + rotation runbook (INFRA-105) The live door-system auth token was hard-coded in the Yealink Push-XML provisioning templates (both /unlock and /lockdown) and present in git history since 2c9b863. Replace it with __DOOR_UNLOCK_TOKEN__ so future templates carry no secret; add RUNBOOK-token-rotation.md covering the rotation, phone re-provisioning, and history scrub. Rotation + history scrub are NOT performed here — staged for a scheduled phone re-provisioning window. The old token is compromised until rotated. * security: mark INFRA-105 rotation executed; correct false cache-expiry claim The runbook claimed the old token stops working ~5 min after SSM rotation. /sh-security-review (2026-07-06) confirmed this is false: the authorizer and both handlers cache the token in module scope with no TTL, so warm containers honor the old token until recycled (unbounded). Add the mandatory forced cold-start step, mark the cutover EXECUTED (token rotated to SSM v3, history scrubbed + force-pushed, Lambdas recycled), stop embedding partial token bytes, and note the accepted refs/pull/* residual. Design fix (cache TTL) tracked. --- RUNBOOK-token-rotation.md | 112 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 RUNBOOK-token-rotation.md diff --git a/RUNBOOK-token-rotation.md b/RUNBOOK-token-rotation.md new file mode 100644 index 0000000..bb9c854 --- /dev/null +++ b/RUNBOOK-token-rotation.md @@ -0,0 +1,112 @@ +# Door-Unlock Token Rotation & History Scrub — INFRA-105 + +> **Status: EXECUTED 2026-07-06.** Token rotated (SSM `/seahaven/door-unlock/auth-token` v3, +> 16:23 ET), all phones re-provisioned, git history scrubbed with `git filter-repo` and +> force-pushed (main + this branch + all tags), and the three token-validating Lambdas +> force-recycled to flush the never-expiring in-memory token cache (see step 1 note). The +> previously committed token is invalid and no longer present on any origin ref. +> +> **Residual (accepted):** GitHub-controlled read-only `refs/pull/*` refs still reference +> pre-scrub commits (only GitHub Support can purge); harmless since the token is rotated. + +## Background + +The Yealink Push-XML provisioning templates hard-coded the live door-system auth token in +plaintext, in both the `/unlock` and `/lockdown` query strings, across: + +- `yealinkT58W-door-unlock.ph.xml` +- `yealinkT54W-door-unlock.ph.xml` +- `yealinkT54W-door-unlock-with-sp.ph.xml` + +The token is the exact secret the API Gateway authorizer (`lambda/authorizer/authorizer-handler.ts`) +validates against SSM SecureString `/seahaven/door-unlock/auth-token` via `timingSafeEqual`. +It is present in git history since the first template commit `6d5f665`. Anyone with repo read +access (or history) could unlock the door or trigger building lockdown over the internet. + +This branch replaces the token with the placeholder `__DOOR_UNLOCK_TOKEN__`. The real value is +injected at provisioning time and must never be committed again. + +## Cutover procedure (run in a maintenance window) + +### 1. Rotate the token + +```sh +NEW_TOKEN=$(openssl rand -hex 32) +aws ssm put-parameter \ + --name /seahaven/door-unlock/auth-token \ + --type SecureString --overwrite \ + --value "$NEW_TOKEN" --region us-east-1 + +# CRITICAL: the SSM overwrite ALONE does NOT invalidate the old token. +# The authorizer + unlock + lockdown handlers cache the token in module scope with +# NO TTL (authorizer-handler.ts getAuthToken, unlock/lockdown loadSecrets), so any warm +# Lambda container keeps honoring the OLD token until AWS recycles it — unbounded, up to +# hours on a low-traffic API. You MUST force a cold start to guarantee invalidation: +for fn in door-unlock-api-authorizer door-unlock-api-unlock door-unlock-api-lockdown; do + aws lambda update-function-configuration --region us-east-1 \ + --function-name "$fn" --description "token-rotation $(date -u +%Y-%m-%dT%H%M%SZ)" +done +# (The API Gateway authorizer result cache is a separate 5-min TTL keyed on the presented +# token string; stale ALLOW entries for the old token expire within 5 min on their own.) +``` + +> **Design debt (INFRA-105 follow-up):** give the module-scope token cache a short TTL (or +> read SSM per-invocation) so future rotations are self-healing and this manual cold-start +> step is unnecessary. Tracked as a confirmed HIGH from the 2026-07-06 `/sh-security-review`. + +### 2. Re-provision all Yealink phones + +Render each template with the new token (do NOT commit the rendered output): + +```sh +for f in yealink*.ph.xml; do + sed "s/__DOOR_UNLOCK_TOKEN__/$NEW_TOKEN/g" "$f" > "/tmp/rendered-$f" +done +``` + +Push `/tmp/rendered-*` to the phones via the 3CX/Yealink provisioning path. Verify one phone's +`/unlock` key works against the new token before doing the rest. Delete the rendered files after. + +> The token must physically live on the phones — type-17 URL keys can't send headers — so the +> rendered XML always contains the secret. Keep it out of source control and off shared storage. + +### 3. Scrub git history + +```sh +# git-filter-repo (preferred) +# Put the old 64-hex token value in a gitignored/temp file, never inline in this doc: +git filter-repo --replace-text <(printf '%s==>__DOOR_UNLOCK_TOKEN__\n' "$OLD_TOKEN") +# then force-push every ref; coordinate with anyone holding clones (they must re-clone) +git push --force --all && git push --force --tags +``` + +> **Do it in a mirror clone** (`git clone --mirror`), scrub there, and force-push, rather than +> filter-repo'ing your working clone. Then re-sync your local clone: delete stale local tags and +> `git fetch --tags --force`, and delete any local branches whose tips predate the scrub (they +> retain the old blob even after the scrub). Verify: `for r in $(git for-each-ref +> --format='%(refname)'); do git grep -q "$OLD_TOKEN" "$r" && echo "HIT $r"; done`. + +After force-push, the old token is gone from history but is **already compromised** — rotation +(step 1, including the forced Lambda cold start) is what actually invalidates it, not the scrub. +Note `refs/pull/*` on GitHub are read-only and cannot be force-updated — they retain the old +blob until GitHub Support purges them; acceptable once the token is rotated. + +## Gates before merge/deploy + +- **/sh-security-review** — auth surface, required. Run 2026-07-06: gate = BLOCK on 5 confirmed + pre-existing auth-design HIGHs (never-expiring token cache ×2, no replay/IP restriction, + lockdown toggle de-escalation, unlock-during-lockdown). None are introduced by this branch, + which adds only this runbook. The residual-secret concern (SC-01) was REFUTED post-scrub. + Tracked for follow-up; see the project memory. Merge decision is Adam's given the block is on + legacy design, not this diff. +- **GPT-4.1 cross-review** — only if a handler/IAM change accompanies this (placeholdering alone does not). +- Deploy-then-merge per handbook; the pre-push scanner hook passes with machine-level suppressions + for the placeholder/CDK-bundling/LogRetention false-positives (see + `~/.config/sea-haven/security-review/seahaven-door-unlock-api/suppressions.json`). + +## Follow-up design fix + +Treat the provisioning XML as a generated/secret artifact: keep only the placeholdered template +in git, render with the SSM value at provisioning time. Consider a small provisioning script in +this repo that pulls the token from SSM and renders, so the secret is never written to disk longer +than the push requires.