seahaven-door-unlock-api/RUNBOOK-token-rotation.md
Adam Moussa c23f990c6f
feat(phones): add T57W door-unlock-with-sp template (#87)
* feat(phones): add T57W door-unlock-with-sp template

* feat: add firmware dir and add latest 3cx supported yealink firmware
2026-09-03 22:56:04 +00:00

5.9 KiB
Raw Blame History

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
  • yealinkT57W-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

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.

Render each template with the new token (do NOT commit the rendered output):

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

# 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.