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

113 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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