mirror of
https://github.com/Sea-Haven-Industries/seahaven-door-unlock-api.git
synced 2026-09-30 07:03:12 +00:00
security: door-unlock token rotation runbook + executed cutover (INFRA-105) (#47)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* 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.
This commit is contained in:
parent
2a20b68584
commit
a86465d0e2
1 changed files with 112 additions and 0 deletions
112
RUNBOOK-token-rotation.md
Normal file
112
RUNBOOK-token-rotation.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# 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`
|
||||
|
||||
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.
|
||||
Loading…
Add table
Reference in a new issue