Skip to content

fix(security): replace predictable HMAC unsubscribe token with randomopaque token (#896)#1058

Merged
hman38705 merged 1 commit into
solutions-plug:mainfrom
Vincent6581:fix/896-opaque-unsubscribe-tokens
Jun 30, 2026
Merged

fix(security): replace predictable HMAC unsubscribe token with randomopaque token (#896)#1058
hman38705 merged 1 commit into
solutions-plug:mainfrom
Vincent6581:fix/896-opaque-unsubscribe-tokens

Conversation

@Vincent6581

@Vincent6581 Vincent6581 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

closes #896.

The old unsubscribe token was base64(email) + "." + HMAC(email). This encoded the subscriber's email address directly in the token, and an attacker who observed one valid token could attempt HMAC-key recovery or use the token to enumerate subscriber addresses. The structure was fully predictable given the email address.

This PR replaces it with a random 256-bit opaque token stored as a SHA-256 hash in a new unsubscribe_tokens table with expiry and single-use enforcement.


Security properties of the new scheme

Property Old (HMAC) New (opaque)
Token reveals email ✅ yes ❌ no
Predictable from email ✅ yes ❌ no
Single-use ❌ no ✅ yes (used_at column)
Expiry enforced ❌ no ✅ yes (expires_at column)
DB breach exposes usable tokens ✅ yes ❌ no (only SHA-256 hash stored)
Entropy ~128-bit HMAC key 256-bit OsRng

Changes

services/api/Cargo.toml

  • Add rand = "0.8" (features = [getrandom]) for OsRng

services/api/src/newsletter.rs

  • Removed generate_unsubscribe_token(email, secret) and validate_unsubscribe_token(token, secret)
  • Added generate_opaque_unsubscribe_token() -> (raw_token, token_hash)
    • Fills 32 bytes from OsRng, hex-encodes to 64-char raw token
    • Returns SHA-256(raw_token) as the hash to persist in the DB
    • Raw token is never stored — returned once for embedding in the email URL
  • Added hash_unsubscribe_token(raw) -> String — hashes an inbound token for DB lookup
  • Added UnsubscribeTokenResult enum: Valid { subscriber_id }, AlreadyUsed, InvalidOrExpired
  • Added OpaqueTokenStore in-memory struct (mirrors DB contract, used by tests): insert(hash, subscriber_id), redeem(raw_token) with expiry + single-use enforcement
  • Added comment block explaining the token scheme and why each step is needed
  • Tests: replaced old HMAC tests with:
    • opaque_token_generation_produces_unique_tokens — entropy and uniqueness
    • opaque_token_hash_is_sha256_of_raw — hash correctness
    • opaque_token_hash_does_not_contain_raw — hash ≠ raw token
    • opaque_token_replay_detection_via_in_memory_store — single-use enforcement
    • opaque_token_expiry_enforced — expired token returns InvalidOrExpired
    • unknown_opaque_token_rejected — unknown token returns InvalidOrExpired

services/api/database/migrations/017_create_unsubscribe_tokens.sql

New table:

CREATE TABLE unsubscribe_tokens (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    token_hash    CHAR(64) NOT NULL UNIQUE,       -- SHA-256 hex
    subscriber_id UUID NOT NULL REFERENCES newsletter_subscribers(id) ON DELETE CASCADE,
    expires_at    TIMESTAMPTZ NOT NULL,
    used_at       TIMESTAMPTZ DEFAULT NULL,        -- NULL = unused
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • Index on token_hash for fast lookup on every unsubscribe request
  • Partial index on expires_at WHERE used_at IS NULL for housekeeping queries

Acceptance criteria

  • Token is a random 256-bit opaque value stored as (token_hash, subscriber_id, expires_at)
  • Tokens are deleted/marked on use (single-use via used_at column)
  • Migration for unsubscribe_tokens table added (017)
  • Tests for: generation, use, replay rejection, expiry

Migration notes for operators

  1. Run migration 017 before deploying this code.
  2. Existing unsubscribe links (old HMAC format) will stop working after deploy. Consider sending a re-subscription or informational email to active subscribers if needed.

… opaque token (solutions-plug#896)

The old scheme encoded the subscriber email in the token itself as
base64(email) + '.' + HMAC(email). An attacker who observed one valid
token and knew the target email could attempt HMAC-key recovery, and
the email address was trivially visible to anyone who held a token.

New scheme:
1. Generate 32 cryptographically-random bytes (OsRng, 256-bit entropy).
2. Hex-encode to a 64-char lowercase URL-safe token.
3. Store SHA-256(token) in unsubscribe_tokens table with subscriber_id
   and expires_at — the raw token is never persisted anywhere.
4. On redemption: hash the incoming token, look up by hash, verify
   expires_at > NOW() and used_at IS NULL, then set used_at (single-use).
5. A DB breach exposes only hashes — no usable tokens.

Changes:

services/api/Cargo.toml
- Add rand = "0.8" with getrandom feature for OsRng

services/api/src/newsletter.rs
- Remove generate_unsubscribe_token(email, secret) and
  validate_unsubscribe_token(token, secret)
- Add generate_opaque_unsubscribe_token() -> (raw_token, token_hash)
- Add hash_unsubscribe_token(raw) -> String for inbound validation
- Add UnsubscribeTokenResult enum: Valid { subscriber_id }, AlreadyUsed,
  InvalidOrExpired
- Add OpaqueTokenStore in-memory struct (mirrors DB contract for tests):
  insert(token_hash, subscriber_id), redeem(raw_token) -> Result
  with expiry and single-use enforcement
- Add detailed comment block explaining the scheme and migration path
- Remove old HMAC-based tests; add:
  - opaque_token_generation_produces_unique_tokens
  - opaque_token_hash_is_sha256_of_raw
  - opaque_token_hash_does_not_contain_raw
  - opaque_token_replay_detection_via_in_memory_store
  - opaque_token_expiry_enforced
  - unknown_opaque_token_rejected

services/api/database/migrations/017_create_unsubscribe_tokens.sql
- New unsubscribe_tokens table: id, token_hash CHAR(64) UNIQUE,
  subscriber_id UUID FK -> newsletter_subscribers ON DELETE CASCADE,
  expires_at TIMESTAMPTZ, used_at TIMESTAMPTZ (null = unused),
  created_at TIMESTAMPTZ
- Index on token_hash (fast lookup on every unsubscribe request)
- Partial index on expires_at WHERE used_at IS NULL (housekeeping queries)

Closes solutions-plug#896
@drips-wave

drips-wave Bot commented Jun 30, 2026

Copy link
Copy Markdown

@Vincent6581 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@hman38705 hman38705 merged commit 382e179 into solutions-plug:main Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Newsletter unsubscribe token uses predictable base64(email+HMAC) format

2 participants