* 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.
5.8 KiB
Door-Unlock Token Rotation & History Scrub — INFRA-105
Status: EXECUTED 2026-07-06. Token rotated (SSM
/seahaven/door-unlock/auth-tokenv3, 16:23 ET), all phones re-provisioned, git history scrubbed withgit filter-repoand 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.xmlyealinkT54W-door-unlock.ph.xmlyealinkT54W-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.
2. Re-provision all Yealink phones
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 andgit 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.