fix(security): replace predictable HMAC unsubscribe token with randomopaque token (#896)#1058
Merged
hman38705 merged 1 commit intoJun 30, 2026
Conversation
… 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
|
@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! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_tokenstable with expiry and single-use enforcement.Security properties of the new scheme
Changes
services/api/Cargo.tomlrand = "0.8"(features = [getrandom]) forOsRngservices/api/src/newsletter.rsgenerate_unsubscribe_token(email, secret)andvalidate_unsubscribe_token(token, secret)generate_opaque_unsubscribe_token() -> (raw_token, token_hash)OsRng, hex-encodes to 64-char raw tokenhash_unsubscribe_token(raw) -> String— hashes an inbound token for DB lookupUnsubscribeTokenResultenum:Valid { subscriber_id },AlreadyUsed,InvalidOrExpiredOpaqueTokenStorein-memory struct (mirrors DB contract, used by tests):insert(hash, subscriber_id),redeem(raw_token)with expiry + single-use enforcementopaque_token_generation_produces_unique_tokens— entropy and uniquenessopaque_token_hash_is_sha256_of_raw— hash correctnessopaque_token_hash_does_not_contain_raw— hash ≠ raw tokenopaque_token_replay_detection_via_in_memory_store— single-use enforcementopaque_token_expiry_enforced— expired token returns InvalidOrExpiredunknown_opaque_token_rejected— unknown token returns InvalidOrExpiredservices/api/database/migrations/017_create_unsubscribe_tokens.sqlNew table:
token_hashfor fast lookup on every unsubscribe requestexpires_at WHERE used_at IS NULLfor housekeeping queriesAcceptance criteria
Migration notes for operators