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