From 350294670ddf71538dd49ce7902e0670d3e182b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 20 Aug 2026 08:20:16 +0200 Subject: [PATCH] Write the published demo password on every migration instead of carrying one The edge01 demo refresh copied `password_hash` straight from the source account, and the README beside it asserted "demo login works". That only held while the source account happened to use the password the project publishes. The source is the operator's own instance, so the moment that password was rotated the demo started rejecting the credentials the README hands to every visitor, and nothing anywhere noticed. Someone eventually filed it as a bug (#805) after being locked out of the shop window. The load now hands the row an explicit password: the same Argon2id literal the seed script writes, applied right after the copy, then read back and compared byte for byte. A mismatch aborts the run rather than waiting for a stranger to discover it at a login screen. The statement goes in on psql's stdin. Interpolating it into the ssh command string would let the remote shell expand the hash's `$argon2id`, `$v`, `$m`, `$t` and `$p` segments to empty strings and store something no password can ever match, which is the same outage wearing a different hat. A guard test pins that too, alongside pinning the literal to the seed script's copy so the two cannot drift apart again. --- .../demo-published-password-guard.test.ts | 73 +++++++++++++++++++ scripts/migrate-demo-edge01.README.md | 12 ++- scripts/migrate-demo-edge01.sh | 49 +++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/demo-published-password-guard.test.ts diff --git a/scripts/__tests__/demo-published-password-guard.test.ts b/scripts/__tests__/demo-published-password-guard.test.ts new file mode 100644 index 000000000..e9167a684 --- /dev/null +++ b/scripts/__tests__/demo-published-password-guard.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * The demo password is published in the README so anyone can sign in to + * demo.healthlog.dev. Its Argon2id hash lives in two places that must agree: + * + * - scripts/seed-demo.ts writes it when a demo tenant is seeded + * - scripts/migrate-demo-edge01.sh overwrites it after a migration load + * + * Public issue #805: the migration used to carry the SOURCE account's hash + * across instead, so once the operator rotated their own demo password the + * credentials the README advertises stopped working, and nothing noticed until + * a visitor filed a bug. These assertions make the two copies move together. + */ + +const REPO_ROOT = join(__dirname, "..", ".."); +const SEED = join(REPO_ROOT, "scripts", "seed-demo.ts"); +const MIGRATE = join(REPO_ROOT, "scripts", "migrate-demo-edge01.sh"); + +/** Every Argon2id hash literal in a file, in source order. */ +function argon2Hashes(path: string): string[] { + const source = readFileSync(path, "utf8"); + return source.match(/\$argon2id\$[^"'\s]+/g) ?? []; +} + +describe("published demo password", () => { + it("is the same hash in the seed script and the edge01 migration", () => { + const seeded = argon2Hashes(SEED); + const migrated = argon2Hashes(MIGRATE); + + // An empty match set would make every assertion below vacuously true — + // the failure mode this repo keeps rediscovering. Demand a hit first. + expect(seeded.length).toBeGreaterThan(0); + expect(migrated.length).toBeGreaterThan(0); + + // Each file states the hash exactly once, so a stray second copy (the + // thing that lets the two drift again) is a failure in its own right. + expect(new Set(seeded).size).toBe(1); + expect(new Set(migrated).size).toBe(1); + + expect(migrated[0]).toBe(seeded[0]); + }); + + it("is written by the migration rather than carried from the source", () => { + const script = readFileSync(MIGRATE, "utf8"); + + // The overwrite must exist... + expect(script).toMatch( + /UPDATE users SET password_hash = '\$\{?PUBLIC_DEMO_PASSWORD_HASH\}?'/, + ); + + // ...and it must be proven, not merely attempted. Without the check a + // mangled hash would reach a visitor's login screen instead of the script. + expect(script).toContain( + "ABORT/FAIL: demo password_hash on edge01 is not the published literal", + ); + }); + + it("keeps the overwrite off the ssh command line", () => { + const script = readFileSync(MIGRATE, "utf8"); + + // The hash carries '$argon2id', '$v', '$m', '$t', '$p'. Interpolated into + // a double-quoted remote command those expand to empty strings and the + // stored hash matches nothing. It has to travel on stdin. + const sshWithHashInline = new RegExp( + String.raw`ssh\s+"\$DST_SSH"\s+"[^"]*PUBLIC_DEMO_PASSWORD_HASH`, + ); + expect(script).not.toMatch(sshWithHashInline); + }); +}); diff --git a/scripts/migrate-demo-edge01.README.md b/scripts/migrate-demo-edge01.README.md index 80eac968c..35dd6cd15 100644 --- a/scripts/migrate-demo-edge01.README.md +++ b/scripts/migrate-demo-edge01.README.md @@ -68,7 +68,17 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`, Forced flags: `telegram_enabled=false`, `role='ADMIN'`. -**Kept:** `password_hash` (demo login works), `avatar_bytes` / +**Overwritten:** `password_hash`. The row arrives carrying the source +account's hash, and the load is immediately followed by an `UPDATE` that sets +the Argon2id hash of the demo password the README publishes, then asserts the +stored value matches it byte for byte and aborts if it does not. The source is +the operator's own instance, so its demo password can be rotated at any time; +carrying that hash across is what silently broke the advertised demo login in +issue #805. The statement travels on psql's stdin, never inside the ssh command +string, because the hash contains `$argon2id` / `$v` / `$m` / `$t` / `$p` +segments that a remote shell would expand to nothing. + +**Kept:** `avatar_bytes` / `avatar_content_type` / `avatar_updated_at` (profile photo), and all display / preference / layout JSON (`thresholds_json`, `dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`, diff --git a/scripts/migrate-demo-edge01.sh b/scripts/migrate-demo-edge01.sh index 4ecf556c3..e91c66d92 100755 --- a/scripts/migrate-demo-edge01.sh +++ b/scripts/migrate-demo-edge01.sh @@ -102,6 +102,23 @@ readonly SRC_DB_CONTAINER readonly PG_USER="healthlog" readonly PG_DB="healthlog" +# Argon2id hash of the demo password the README publishes ("demo123demo123"). +# Not a secret: it is printed in the README so anyone can sign in to the demo, +# and it is the same literal that scripts/seed-demo.ts writes on a fresh seed. +# The transfer still carries the source column, but the load is followed by an +# explicit overwrite with this literal (see "FORCE THE PUBLISHED DEMO PASSWORD" +# below). The source is the operator's own instance and its demo password can +# be rotated at any time; carrying it across silently invalidated the published +# credentials once already (public issue #805). Overwriting afterwards makes the +# documented login true by construction after every migration. +# +# The overwrite travels on psql's STDIN, never inside the ssh command string: +# the hash contains '$' segments that a remote shell would expand away. +# +# scripts/__tests__/demo-published-password-guard.test.ts pins this literal to +# the one in scripts/seed-demo.ts, so editing one without the other fails. +readonly PUBLIC_DEMO_PASSWORD_HASH='$argon2id$v=19$m=65536,t=3,p=4$Kips6OxPAl0vmspO9SoKZQ$oX9gLgwHVnnENCqBloyM13ewuqmhPnw8EpLoemS3MNI' + # psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero # exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc. psql_src() { @@ -466,6 +483,38 @@ copy_table() { copy_table "users" "$USERS_SELECT" \ "users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS" +# ---------------------------------------------------------------------------- +# FORCE THE PUBLISHED DEMO PASSWORD +# ---------------------------------------------------------------------------- +# The row just loaded carries the SOURCE account's password_hash. The source is +# the operator's own instance, so that hash is whatever the operator last set — +# not necessarily the password the README publishes. Overwrite it here so the +# documented demo login works after every migration, not just the first one. +# +# The statement goes in on psql's STDIN. It must NOT be interpolated into the +# ssh command string: the Argon2id hash contains '$argon2id', '$v', '$m', '$t' +# and '$p' segments, and a remote shell would expand each of those to an empty +# string and store a mangled hash that no password can ever match. +echo +echo "## FORCE published demo password on the loaded row ..." +printf '%s\n' \ + "UPDATE users SET password_hash = '$PUBLIC_DEMO_PASSWORD_HASH' WHERE id = '$DEMO_USER_ID';" \ + | ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \ + psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" + +# Prove the stored hash is byte-for-byte the published one. A mangled or +# carried-over hash fails here instead of at some visitor's login screen. +PW_OK="$(printf '%s\n' \ + "SELECT count(*) FROM users WHERE id = '$DEMO_USER_ID' AND password_hash = '$PUBLIC_DEMO_PASSWORD_HASH';" \ + | ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER \ + psql -X -tA -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB" | tr -d '[:space:]')" +if [[ "$PW_OK" != "1" ]]; then + echo "ABORT/FAIL: demo password_hash on edge01 is not the published literal (matched rows: $PW_OK)." + echo " The demo login would be broken. Fix before serving the demo." + exit 1 +fi +echo " demo password_hash matches the published literal." + # 2. measurements (live rows only). copy_table "measurements" "$MEAS_COLS" \ "measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"