feat: optional password protection per card #11

Closed
opened 2026-06-16 15:48:07 +00:00 by mAi · 3 comments
Collaborator

What

m wants an optional, simple password protection on a card. Sender can optionally set a password while composing; the recipient must enter it to view the card. Optional — cards without a password behave exactly as today.

Behaviour

  • Compose: optional 'Passwort (optional)' field. If set, the card is password-gated.
  • Storage: store a hashed password (bcrypt or argon2id) on the card — NEVER plaintext. Add a nullable password_hash column via idempotent migration (postgres-role ownership, see #1-#4).
  • View (/c/{token}): if the card has a password, serve a password prompt instead of the content. Verify server-side; only render the card (text/images/video) after successful auth — do NOT ship card content to the client before auth (no leak in initial HTML/JSON). On success, set a short-lived signed cookie / session for that card so a reload doesn't re-prompt.
  • Friendly German error on wrong password; mobile-first.

Security (non-negotiable)

  • Hash with a real KDF (bcrypt/argon2id), per-card salt. No plaintext, no reversible encoding.
  • Content must not be retrievable without the password (check the render path AND any image/video URLs — note: Supabase Storage objects are currently public-by-URL; for a truly gated card the media URLs are guessable if someone has the token. MVP: gate the card PAGE; flag that media objects remain public-by-URL as a known limitation, or proxy media through the app behind the password if m wants true media protection — call this out, don't silently leave a hole).
  • The unguessable token stays the baseline; password is an additional optional layer.

Sequencing

Touches compose + render + handlers + schema → same single-worker track as #8/#9/#10 (sequence, no parallel worker on the compose flow). otto-head assigns after the ahead-of-it items land.

DoD

  • Optional password at compose; gated cards prompt + verify server-side; correct password shows the card, wrong shows a friendly error; no-password cards unchanged.
  • Password stored hashed (KDF + salt); no content leak pre-auth on the page.
  • Media-object public-URL limitation either closed (proxied) or explicitly documented.
  • build/vet/test green; e2e verified (set pw, wrong pw blocked, right pw shows); test data cleaned. Commit references this issue.
## What m wants an **optional, simple password protection** on a card. Sender can optionally set a password while composing; the recipient must enter it to view the card. **Optional** — cards without a password behave exactly as today. ## Behaviour - **Compose**: optional 'Passwort (optional)' field. If set, the card is password-gated. - **Storage**: store a **hashed** password (bcrypt or argon2id) on the card — NEVER plaintext. Add a nullable `password_hash` column via idempotent migration (postgres-role ownership, see #1-#4). - **View** (`/c/{token}`): if the card has a password, serve a password prompt instead of the content. Verify server-side; only render the card (text/images/video) **after** successful auth — do NOT ship card content to the client before auth (no leak in initial HTML/JSON). On success, set a short-lived signed cookie / session for that card so a reload doesn't re-prompt. - Friendly German error on wrong password; mobile-first. ## Security (non-negotiable) - Hash with a real KDF (bcrypt/argon2id), per-card salt. No plaintext, no reversible encoding. - Content must not be retrievable without the password (check the render path AND any image/video URLs — note: Supabase Storage objects are currently public-by-URL; for a truly gated card the media URLs are guessable if someone has the token. MVP: gate the card PAGE; flag that media objects remain public-by-URL as a known limitation, or proxy media through the app behind the password if m wants true media protection — call this out, don't silently leave a hole). - The unguessable token stays the baseline; password is an *additional optional* layer. ## Sequencing Touches compose + render + handlers + schema → **same single-worker track as #8/#9/#10** (sequence, no parallel worker on the compose flow). otto-head assigns after the ahead-of-it items land. ## DoD - Optional password at compose; gated cards prompt + verify server-side; correct password shows the card, wrong shows a friendly error; no-password cards unchanged. - Password stored hashed (KDF + salt); no content leak pre-auth on the page. - Media-object public-URL limitation either closed (proxied) or explicitly documented. - build/vet/test green; e2e verified (set pw, wrong pw blocked, right pw shows); test data cleaned. Commit references this issue.
mAi self-assigned this 2026-06-16 16:28:07 +00:00
Author
Collaborator

#11 done — optional per-card password protection

Shipped on mai/hermes/issue-11-feat-optional (not yet merged/deployed — otto-head does that).

Commit: 2118ace7df

What

Sender can set an optional Passwort (optional) at compose. If set, the card is gated; the recipient must enter the password before the card renders. Cards without a password behave exactly as before.

How it meets the DoD

  • Optional at compose — new password field on the compose form. Empty = no protection (unchanged behaviour).
  • Stored hashed (KDF + salt) — bcrypt (golang.org/x/crypto), per-hash random salt, embedded cost; only the hash lands in a new nullable password_hash column. Verified in e2e: DB stores $2a$10$…, LIKE '%plaintext%' = false. bcrypt's 72-byte limit is surfaced as a friendly error, never silent truncation.
  • Server-side verify, no content leak pre-auth — a gated /c/{token} serves a minimal passwordprompt.html that ships no card content (message, sender, recipient, occasion badge, or any image/video URL). POST /c/{token}/unlock verifies server-side. A render test asserts the prompt contains none of those sentinels.
  • Short-lived signed session — success issues a 12 h, HMAC-SHA256-signed, per-card cookie, path-scoped to /c/{token}, HttpOnly, SameSite=Lax, Secure over HTTPS. The signed payload binds the cookie to its own token, so a valid cookie for one card cannot unlock another (unit-tested + e2e cross-card check). A reload within the window doesn't re-prompt.
  • Friendly German error — wrong password → Falsches Passwort. Bitte versuche es noch einmal. (HTTP 401), mobile-first.
  • Creator UX — the sender is auto-unlocked on creation (cookie set at the compose redirect), so they land on their own card + share banner instead of being prompted for the password they just set.
  • No side effects on bad input — the password is hashed before any side effect (slug reservation, media upload, insert), so a too-long password leaves no orphan card/slug/media. e2e confirmed: too-long attempt → 400, zero rows created.
  • Migration007_password.sql: idempotent ALTER TABLE … ADD COLUMN IF NOT EXISTS password_hash text. Keeps the postgres owner, additive, safe for the old running binary (same pattern as 002/003/005/006).
  • ConfigCARD_SESSION_SECRET (optional). Unset → a random per-process key (secure, but cookies don't survive a restart / don't validate across instances). Documented in README; set a stable value for persistence/scaling.

Media-object limitation (called out, not silently left)

The page is gated, but uploaded media stay in the public postcards Supabase bucket under a <token>/… key (and the local backend serves /uploads/ unauthenticated). Anyone who knows the card's token can still construct the media object's direct URL and fetch it without the password. So the password protects the card page, not the raw media objects.

Closing this fully requires making the bucket private + proxying every media fetch through the app behind the same cookie — an infra change (private bucket + migrating existing cards' public URLs) I've deferred pending your decision. Proxying alone while the bucket stays public would add complexity without closing the hole, so I intentionally didn't do it. This is documented in README.md under Password protection → Known limitation.

Question for m: want true media protection (flip the postcards bucket to private + app media-proxy, as a follow-up issue), or is page-gating + the documented limitation acceptable for now?

Verification

gofmt clean · go build ./... · go vet ./... · go test -race ./... all green. Full e2e against a throwaway Postgres (local storage, prod untouched): set-pw → prompt with no content leak; wrong pw → 401 + error; right pw → unlock + content; no-pw card → content directly (unchanged); creator auto-unlocked; cross-card cookie isolation; too-long pw rejected with no orphan. Test data + container cleaned up.

Files

  • migrations/007_password.sql (new)
  • internal/postcard/password.go (+ password_test.go) — bcrypt hash/verify/HasPassword
  • internal/server/cardauth.go (+ cardauth_test.go) — signed per-card cookie
  • internal/server/handlers.go, server.go — gate, unlock handler, route, creator auto-unlock
  • internal/config/config.goCARD_SESSION_SECRET
  • web/templates/passwordprompt.html (new), compose.html, web/static/style.css
  • internal/server/password_render_test.go (new) — no-content + field render tests
  • README.md — feature + limitation docs
## #11 done — optional per-card password protection Shipped on `mai/hermes/issue-11-feat-optional` (not yet merged/deployed — otto-head does that). **Commit:** https://mgit.msbls.de/m/postcards/commit/2118ace7dfa5ed71221381985e6d1f4f4c92337a ### What Sender can set an **optional** `Passwort (optional)` at compose. If set, the card is gated; the recipient must enter the password before the card renders. Cards **without** a password behave exactly as before. ### How it meets the DoD - **Optional at compose** — new password field on the compose form. Empty = no protection (unchanged behaviour). - **Stored hashed (KDF + salt)** — bcrypt (`golang.org/x/crypto`), per-hash random salt, embedded cost; only the hash lands in a new nullable `password_hash` column. Verified in e2e: DB stores `$2a$10$…`, `LIKE '%plaintext%'` = false. bcrypt's 72-byte limit is surfaced as a friendly error, never silent truncation. - **Server-side verify, no content leak pre-auth** — a gated `/c/{token}` serves a minimal `passwordprompt.html` that ships **no** card content (message, sender, recipient, occasion badge, or any image/video URL). `POST /c/{token}/unlock` verifies server-side. A render test asserts the prompt contains none of those sentinels. - **Short-lived signed session** — success issues a 12 h, HMAC-SHA256-signed, **per-card** cookie, path-scoped to `/c/{token}`, `HttpOnly`, `SameSite=Lax`, `Secure` over HTTPS. The signed payload binds the cookie to its own token, so a valid cookie for one card cannot unlock another (unit-tested + e2e cross-card check). A reload within the window doesn't re-prompt. - **Friendly German error** — wrong password → `Falsches Passwort. Bitte versuche es noch einmal.` (HTTP 401), mobile-first. - **Creator UX** — the sender is auto-unlocked on creation (cookie set at the compose redirect), so they land on their own card + share banner instead of being prompted for the password they just set. - **No side effects on bad input** — the password is hashed before any side effect (slug reservation, media upload, insert), so a too-long password leaves no orphan card/slug/media. e2e confirmed: too-long attempt → 400, zero rows created. - **Migration** — `007_password.sql`: idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS password_hash text`. Keeps the `postgres` owner, additive, safe for the old running binary (same pattern as 002/003/005/006). - **Config** — `CARD_SESSION_SECRET` (optional). Unset → a random per-process key (secure, but cookies don't survive a restart / don't validate across instances). Documented in README; set a stable value for persistence/scaling. ### Media-object limitation (called out, not silently left) The **page** is gated, but uploaded media stay in the **public** `postcards` Supabase bucket under a `<token>/…` key (and the `local` backend serves `/uploads/` unauthenticated). Anyone who knows the card's token can still construct the media object's direct URL and fetch it **without** the password. So the password protects the card *page*, not the raw media objects. Closing this fully requires making the bucket **private** + proxying every media fetch through the app behind the same cookie — an infra change (private bucket + migrating existing cards' public URLs) I've **deferred pending your decision**. Proxying alone while the bucket stays public would add complexity without closing the hole, so I intentionally didn't do it. This is documented in `README.md` under *Password protection → Known limitation*. **Question for m:** want true media protection (flip the `postcards` bucket to private + app media-proxy, as a follow-up issue), or is page-gating + the documented limitation acceptable for now? ### Verification `gofmt` clean · `go build ./...` · `go vet ./...` · `go test -race ./...` all green. Full e2e against a throwaway Postgres (local storage, prod untouched): set-pw → prompt with no content leak; wrong pw → 401 + error; right pw → unlock + content; no-pw card → content directly (unchanged); creator auto-unlocked; cross-card cookie isolation; too-long pw rejected with no orphan. Test data + container cleaned up. ### Files - `migrations/007_password.sql` (new) - `internal/postcard/password.go` (+ `password_test.go`) — bcrypt hash/verify/`HasPassword` - `internal/server/cardauth.go` (+ `cardauth_test.go`) — signed per-card cookie - `internal/server/handlers.go`, `server.go` — gate, unlock handler, route, creator auto-unlock - `internal/config/config.go` — `CARD_SESSION_SECRET` - `web/templates/passwordprompt.html` (new), `compose.html`, `web/static/style.css` - `internal/server/password_render_test.go` (new) — no-content + field render tests - `README.md` — feature + limitation docs
Author
Collaborator

Merged & deployed — password protection live on sendmy.cards

Merged 2118ace (merge 1e108ed), deployed. Migration 007 applied. Set a stable CARD_SESSION_SECRET in the Dokploy env (so unlock cookies survive restarts/deploys).

Verified live e2e on sendmy.cards: pw-protected card → fresh GET returns the prompt with no content leak (secret message absent from HTML), wrong password → 401, right password → 303 + signed cookie → content renders. No-password cards unchanged. bcrypt hashing, server-side verify, 12h per-card cookie. build/vet/test -race green.

Open question (worker flagged, now to m): the media-object public-URL limitation — page is gated but raw media stays fetchable by direct URL if you know the token. Asking m whether to do a follow-up (private bucket + media-proxy) or accept page-gating + documented limitation. Not closing — m closes.

## ✅ Merged & deployed — password protection live on sendmy.cards Merged `2118ace` (merge `1e108ed`), deployed. Migration 007 applied. Set a stable `CARD_SESSION_SECRET` in the Dokploy env (so unlock cookies survive restarts/deploys). **Verified live e2e on sendmy.cards:** pw-protected card → fresh GET returns the prompt with **no content leak** (secret message absent from HTML), wrong password → 401, right password → 303 + signed cookie → content renders. No-password cards unchanged. bcrypt hashing, server-side verify, 12h per-card cookie. build/vet/test -race green. **Open question (worker flagged, now to m):** the media-object public-URL limitation — page is gated but raw media stays fetchable by direct URL if you know the token. Asking m whether to do a follow-up (private bucket + media-proxy) or accept page-gating + documented limitation. Not closing — m closes.
Author
Collaborator

m decision (PWA, 2026-06-17): page-protection is sufficient — NO private bucket / proxy — PROVIDED media filenames are tokenized + unguessable.

Rationale: today media keys are {token}/0.png, /1.png, /video/0.mp4 — the per-file part is sequential, so anyone holding the card token can construct the media URL and fetch it past a password gate. If the filename gets its own random/unguessable component ({token}/{rand}.png), then knowing the token alone is NOT enough — the media URL is only revealed on the (password-gated) rendered page. That closes the #11 media leak for password-protected cards without a private bucket + proxy. Tracked as #21. The private-bucket/proxy option is dropped.

**m decision (PWA, 2026-06-17): page-protection is sufficient — NO private bucket / proxy — PROVIDED media filenames are tokenized + unguessable.** Rationale: today media keys are `{token}/0.png`, `/1.png`, `/video/0.mp4` — the per-file part is sequential, so anyone holding the card token can construct the media URL and fetch it past a password gate. If the filename gets its own random/unguessable component (`{token}/{rand}.png`), then knowing the token alone is NOT enough — the media URL is only revealed on the (password-gated) rendered page. That closes the #11 media leak for password-protected cards without a private bucket + proxy. Tracked as #21. The private-bucket/proxy option is dropped.
mAi closed this issue 2026-06-23 15:48:02 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/postcards#11
No description provided.