seahaven-door-unlock-api/RUNBOOK-token-rotation.md

114 lines
5.9 KiB
Markdown
Raw Normal View 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
```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.