From 7331cb7d31c8fb0d98a6654f41f6aafed80c50a2 Mon Sep 17 00:00:00 2001 From: anonsaber Date: Mon, 12 Oct 2026 23:57:24 +0800 Subject: [PATCH] fix(push): verification key should never expire (TTL 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: any finite TTL causes push to silently stop working eventually. Stalwart does not re-verify (RFC 8620 §7.2 sends the verification request once at subscription creation). The key is explicitly deleted by forget_push_subscription when the subscription is retired, so no TTL is needed. Changes: - PUSH_SUBSCRIPTION_SECONDS: WEEK → 0 (never expires) - remember_push_subscription: ttl_seconds=0 → SET without EX (Redis) / expiry=i64::MAX (MemoryState) - ttl_contract_is_pinned test: assert 0 - docs: reference/opengaps/design updated to 'never expires' --- docs/design.md | 8 ++++--- docs/design.zh-CN.md | 8 ++++--- docs/opengaps.md | 2 +- docs/opengaps.zh-CN.md | 2 +- docs/reference.md | 2 +- docs/reference.zh-CN.md | 2 +- src/state.rs | 47 ++++++++++++++++++++++++++--------------- 7 files changed, 44 insertions(+), 27 deletions(-) diff --git a/docs/design.md b/docs/design.md index a619097..c8cbbec 100644 --- a/docs/design.md +++ b/docs/design.md @@ -639,7 +639,8 @@ The Push event is consumed by Streams and delivered to Telegram. Its key path de **The only authoritative source for keys and TTL is `docs/reference.md` (section Redis Keys and TTL). **The tables will not be repeated in this section - repeated tables Historical note: `push:subscription:{id}` was previously marked as 7d in docs but the code used 300s — a bug that caused event pushes to fail with 401 after 5 minutes -(because Stalwart only sends the verification request once, at subscription creation, and never re-verifies). Now corrected to 7d (`WEEK`), aligned with `PUSH_REGISTRATION_SECONDS`. +(because Stalwart only sends the verification request once, at subscription creation, and never re-verifies). Now corrected to **never expire (TTL 0)**: the key is explicitly deleted by +`forget_push_subscription` when the subscription is retired, so no TTL is needed. The `push:registration:{...}` was marked as 360s registration solo lock, which was also wrong; the real 360s solo lock is `lock:push-register:{sha256(callback_url)}`. All values have now converged to the single authority table in `docs/reference.md`. This section retains five "whys" that affect design judgment: @@ -647,8 +648,9 @@ All values have now converged to the single authority table in `docs/reference.m (`REQ-RECONCILE-IDEMPOTENCY`, `SAF-RECONCILE-LOCK`). - **push registration solo lock TTL 360s, deliberately larger than the upper limit of 300s for a single outbound request**, otherwise a slow registration will cause repeated requests (`SAF-AUTH-JMAP-PUSH`). -- **Push subscription verification key TTL 7d (`WEEK`)**: Stalwart sends the verification request (RFC 8620 §7.2) once at subscription creation; event pushes (§7.3) arrive without - `verificationCode` and rely on this key existing to confirm prior verification. The key must outlast any gap between subscription creation and events, and survive restarts — Stalwart does not re-verify. +- **Push subscription verification key has no TTL (0 = never expires)**: Stalwart sends the verification request (RFC 8620 §7.2) once at subscription creation; event pushes (§7.3) arrive without + `verificationCode` and rely on this key existing to confirm prior verification. Any finite TTL would cause push to silently stop working — Stalwart does not re-verify. + The key is explicitly deleted by `forget_push_subscription` when the subscription is retired. - **Reconciliation deduplication 24h is intentionally designed**: the same email will only be notified once within 24h (`MOD-DEDUP`, see 11.5 of this article for the scope). - **If the configuration key is missing, it will be regarded as failed (fail-closed)** and the business path will not be entered. diff --git a/docs/design.zh-CN.md b/docs/design.zh-CN.md index 0eb61f1..9839651 100644 --- a/docs/design.zh-CN.md +++ b/docs/design.zh-CN.md @@ -639,7 +639,8 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 **键与 TTL 的唯一权威来源是 `docs/reference.md`(Redis 键与 TTL 各节)。**本节不再重复表格——重复的表 历史说明:`push:subscription:{id}` 此前在文档中标为 7d 但代码实为 300s——这是一个 bug,导致事件 push 在 5 分钟后全部 401 失败 -(因为 Stalwart 仅在订阅创建时发送一次验证请求,之后不再重验)。现已修正为 7d(`WEEK`),与 `PUSH_REGISTRATION_SECONDS` 对齐。 +(因为 Stalwart 仅在订阅创建时发送一次验证请求,之后不再重验)。现已修正为**永不过期(TTL 0)**:订阅被注销时由 +`forget_push_subscription` 显式删除,不靠 TTL 兜底。 `push:registration:{...}` 曾被标为 360s 注册单飞锁,亦为误标;真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`。 所有值现已收敛到 `docs/reference.md` 的单一权威表。本节保留影响设计判断的五条"为什么": @@ -647,8 +648,9 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义 (`REQ-RECONCILE-IDEMPOTENCY`、`SAF-RECONCILE-LOCK`)。 - **push 注册单飞锁 TTL 360s,刻意大于单条出站请求上限 300s**,否则一次慢注册会放进重复请求 (`SAF-AUTH-JMAP-PUSH`)。 -- **push 订阅验证键 TTL 7d(`WEEK`)**:Stalwart 仅在订阅创建时发送验证请求(RFC 8620 §7.2),事件 push(§7.3)不带 - `verificationCode`,依赖此键存在来确认已验证过。TTL 必须长于订阅创建到首条事件之间的任何间隔,且能跨重启存活——Stalwart 不会重验。 +- **push 订阅验证键无 TTL(0 = 永不过期)**:Stalwart 仅在订阅创建时发送验证请求(RFC 8620 §7.2),事件 push(§7.3)不带 + `verificationCode`,依赖此键存在来确认已验证过。任何有限 TTL 都会导致 push 在某天静默失效——Stalwart 不会重验。 + 订阅被注销时由 `forget_push_subscription` 显式删除。 - **对账去重 24h 是有意设计**:同一封邮件 24h 内只通知一次(`MOD-DEDUP`,范围见本文 11.5)。 - **配置键缺键即视为关闭(fail-closed)**,不进入业务路径。 diff --git a/docs/opengaps.md b/docs/opengaps.md index 1e6a9c8..a4b4609 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -57,7 +57,7 @@ Contract baseline (see `docs/reference.md` §1 for the complete semantics of key | `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status` (`disabled`) | | `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status` (`pending`) | | `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status` (`verified`) | -| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 604_800 | `push:subscription:{id}` | +| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 0 (never expires) | `push:subscription:{id}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}` (also the `expires_in` returned by `/admin/session`) | diff --git a/docs/opengaps.zh-CN.md b/docs/opengaps.zh-CN.md index e45a1fd..e5d4209 100644 --- a/docs/opengaps.zh-CN.md +++ b/docs/opengaps.zh-CN.md @@ -57,7 +57,7 @@ TTL 那条真机绿灯已经用你给的 Upstash URL 跑过了(见 §1), | `ttl::PUSH_DISABLED_SECONDS` | 86_400 | `push:subscription:{id}:status`(`disabled`) | | `ttl::PUSH_STATUS_PENDING_SECONDS` | 900 | `push:subscription:{id}:status`(`pending`) | | `ttl::PUSH_STATUS_VERIFIED_SECONDS` | 300 | `push:subscription:{id}:status`(`verified`) | -| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 604_800 | `push:subscription:{id}` | +| `ttl::PUSH_SUBSCRIPTION_SECONDS` | 0(永不过期) | `push:subscription:{id}` | | `ttl::PUSH_REGISTRATION_SECONDS` | 604_800 | `push:registration:{sha256(callback_url)}` | | `ttl::PUSH_ORPHAN_SECONDS` | 604_800 | `push:orphan:{subscription_id}` | | `ttl::ADMIN_SESSION_SECONDS` | 1,800 | `admin-session:{token}`(同时是 `/admin/session` 返回的 `expires_in`) | diff --git a/docs/reference.md b/docs/reference.md index f6dc445..324e276 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -139,7 +139,7 @@ duplicate. Source comment: | Key | TTL | Writer | Reader | |---|---|---|---| -| `push:subscription:{id}` | EX 604_800 (caller-supplied, min 1) | `remember_push_subscription`, called from `jmap_push` verification path | `push_subscription_verified` | +| `push:subscription:{id}` | no TTL (0 = never expires) | `remember_push_subscription`, called from `jmap_push` verification path | `push_subscription_verified`, `forget_push_subscription` | | `push:subscription:id` | No EX | `remember_push_subscription_id` | current-subscription lookup | | `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status`; `pending` on verification request, `verified` on success, `disabled` on disable | **none** | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`, called from `register_push` | `get_push_subscription_for_callback`; removed by `remove_push_subscription_for_callback` | diff --git a/docs/reference.zh-CN.md b/docs/reference.zh-CN.md index 6d1e9bd..c854262 100644 --- a/docs/reference.zh-CN.md +++ b/docs/reference.zh-CN.md @@ -138,7 +138,7 @@ DLQ 是 **append-and-ack**:附加到 `stalwart:jmap:dlq` 的相同 Lua 脚本 | 键 | TTL | 写入方 | 读取方 | |---|---|---|---| -| `push:subscription:{id}` | EX 604_800(调用方提供,最小 1)| `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified` | +| `push:subscription:{id}` | 无 TTL(0 = 永不过期) | `remember_push_subscription`,从 `jmap_push` 验证路径调用 | `push_subscription_verified`、`forget_push_subscription` | | `push:subscription:id` | 无 EX | `remember_push_subscription_id` | 当前订阅查询 | | `push:subscription:{id}:status` | 900 / 300 / 86_400(强制最小 1)| `set_push_subscription_status`;验证请求时为 `pending`,成功时为 `verified`,禁用时为 `disabled` | **无** | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`,从 `register_push` 调用 | `get_push_subscription_for_callback`;通过 `remove_push_subscription_for_callback` 删除 | diff --git a/src/state.rs b/src/state.rs index ed1711c..9a0406c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -69,11 +69,11 @@ pub(crate) mod ttl { /// `session_digest(verification_code)`. Stalwart sends the verification /// request (RFC 8620 §7.2) once at subscription creation; event pushes /// (§7.3) arrive without `verificationCode` and rely on this key existing - /// to confirm prior verification. TTL must outlast any realistic gap - /// between subscription creation and the first event, and survive across - /// restarts — Stalwart does not re-verify. Aligned with - /// `PUSH_REGISTRATION_SECONDS` (1 week). - pub const PUSH_SUBSCRIPTION_SECONDS: u64 = WEEK; + /// to confirm prior verification. **TTL 0 = never expires**: Stalwart + /// does not re-verify, so any finite TTL would cause push to silently + /// stop working. The key is explicitly deleted by `forget_push_subscription` + /// when the subscription is retired. + pub const PUSH_SUBSCRIPTION_SECONDS: u64 = 0; /// Callback-URL -> subscription-id registration window. pub const PUSH_REGISTRATION_SECONDS: u64 = WEEK; /// Orphan callback attribution window: a callback with an unknown @@ -830,13 +830,24 @@ impl ReliableState for RedisState { ttl_seconds: u64, ) -> Result<(), StateError> { let mut connection = self.connection.clone(); - redis::cmd("SET") - .arg(format!("push:subscription:{subscription_id}")) - .arg(crate::config::session_digest(verification_code)) - .arg("EX") - .arg(ttl_seconds.max(1)) - .query_async::<()>(&mut connection) - .await?; + let key = format!("push:subscription:{subscription_id}"); + let value = crate::config::session_digest(verification_code); + if ttl_seconds == 0 { + // No expiration — Stalwart does not re-verify (RFC 8620 §7.2). + redis::cmd("SET") + .arg(&key) + .arg(&value) + .query_async::<()>(&mut connection) + .await?; + } else { + redis::cmd("SET") + .arg(&key) + .arg(&value) + .arg("EX") + .arg(ttl_seconds.max(1)) + .query_async::<()>(&mut connection) + .await?; + } Ok(()) } @@ -1357,16 +1368,18 @@ impl ReliableState for MemoryState { verification_code: &str, ttl_seconds: u64, ) -> Result<(), StateError> { + let expiry = if ttl_seconds == 0 { + i64::MAX + } else { + unix_now().saturating_add(ttl_seconds as i64) + }; self.inner .lock() .map_err(|_| StateError::Poisoned)? .push_subscriptions .insert( subscription_id.to_owned(), - ( - crate::config::session_digest(verification_code), - unix_now().saturating_add(ttl_seconds as i64), - ), + (crate::config::session_digest(verification_code), expiry), ); Ok(()) } @@ -1624,7 +1637,7 @@ mod tests { assert_eq!(ttl::PUSH_STATUS_PENDING_SECONDS, 900); assert_eq!(ttl::PUSH_STATUS_VERIFIED_SECONDS, 300); - assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 604_800); + assert_eq!(ttl::PUSH_SUBSCRIPTION_SECONDS, 0); assert_eq!(ttl::PUSH_REGISTRATION_SECONDS, 604_800); assert_eq!(ttl::PUSH_ORPHAN_SECONDS, 604_800); assert_eq!(ttl::ADMIN_SESSION_SECONDS, 1_800);