From f9408f02d4301c2b4e94864759c291d1733c5139 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:50:00 +0800 Subject: [PATCH] fix(bark): rotate GCM IV per push --- README.md | 2 +- docs/design.md | 2 +- docs/notes.md | 4 ---- env.example | 3 ++- tests/test_bark_publisher.py | 19 +++++++++++++++---- weather_briefing/delivery/bark_crypto.py | 13 +++++++------ 6 files changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index cb8b0aac..d8d6724c 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ For private-chat delivery, open the bot in Telegram and send `/start` before the Bark sends plaintext when the encryption variables are absent. Encryption is recommended. -To enable it, set both `BARK_ENCRYPTION_KEY` and `BARK_ENCRYPTION_IV`. Follow the official documentation linked from [`env.example`](env.example) to generate the values and configure the Bark app. +To enable it, set both `BARK_ENCRYPTION_KEY` and `BARK_ENCRYPTION_IV`. Follow the official documentation linked from [`env.example`](env.example) to generate the initial values and configure the Bark app. Each encrypted push carries a newly generated IV that the app uses for that message. Model calls are handled by any-llm. The credential variables needed by each service follow the [any-llm provider documentation](https://docs.mozilla.ai/any-llm/providers). The official image ships with the components required for DeepSeek, OpenAI, and OpenRouter. diff --git a/docs/design.md b/docs/design.md index bdebbeaa..901f9c3e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -187,7 +187,7 @@ Telegram publisher 把静默标志转换为 `disable_notification=true`。INFO Telegram 拒绝请求时,publisher 以唯一一条 WARNING 记录 HTTP 状态、分块位置和安全错误类别。错误类别来自已知的 Telegram API 描述、`parameters.migrate_to_chat_id` 字段和 HTTP 状态映射,未知响应不会原样进入日志。投递异常携带结构化错误类别;当业务消息因目标会话、Bot 身份或发送权限不可用而失败,且运维告警复用同一投递对象时,服务不再尝试通过该对象发送失败告警,只记录跳过原因。 -Bark publisher 支持明文推送和可选的 AES-GCM 加密推送。只配置 device key 时,请求体始终包含 `body`,并在 rendered message 提供标题时包含 `title`;配置边界要求加密 key 和 IV 同时存在或同时缺失,并允许用不含凭据、query 或 fragment 的绝对 HTTP(S) `BARK_BASE_URL` 覆盖官方端点。加密 key 接受 16、24 或 32 个 ASCII 字符,对应 Bark 的 AES128、AES192 和 AES256;IV 是与 Bark App 设置一致的 12 字符固定值,App 端模式必须设为 GCM 和 noPadding。`cryptography` 的 `AESGCM` 直接生成 Bark 所需的 ciphertext 与 16 字节 tag 组合,再以 Base64 编码;正文始终加密,标题存在时随正文一起加密。两种模式都使用 `/push` JSON 请求体,避免 device key 进入 HTTP URL 日志。`BARK_GROUP` 原样映射到 `group`;普通投递的内部 level 固定为 `timeSensitive`,无声投递固定为 `passive`。 +Bark publisher 支持明文推送和可选的 AES-GCM 加密推送。只配置 device key 时,请求体始终包含 `body`,并在 rendered message 提供标题时包含 `title`;配置边界要求加密 key 和初始 IV 同时存在或同时缺失,并允许用不含凭据、query 或 fragment 的绝对 HTTP(S) `BARK_BASE_URL` 覆盖官方端点。加密 key 接受 16、24 或 32 个 ASCII 字符,对应 Bark 的 AES128、AES192 和 AES256;初始 IV 是与 Bark App 设置一致的 12 字符值,App 端模式必须设为 GCM 和 noPadding。publisher 每次加密生成新的 12 字符随机 IV,并在请求中与 ciphertext 一起发送;Bark App 优先使用请求携带的 IV 解密。`cryptography` 的 `AESGCM` 直接生成 Bark 所需的 ciphertext 与 16 字节 tag 组合,再以 Base64 编码;正文始终加密,标题存在时随正文一起加密。两种模式都使用 `/push` JSON 请求体,避免 device key 进入 HTTP URL 日志。`BARK_GROUP` 原样映射到 `group`;普通投递的内部 level 固定为 `timeSensitive`,无声投递固定为 `passive`。 Bark 内容和 APNs 元数据共享 4 KiB payload。`BRIEFING_MAX_CHARACTERS` 是优先压缩到一条消息的目标,默认且不得超过 650;确有必要时,服务允许简报达到目标的两倍,并最多投递两条。结构化 LLM 输出默认使用 4096 token,为 JSON 字段、来源 ID 和模型生成开销保留独立于可见正文限制的空间;显式的较低值仍会传入 prompt,要求模型连同 JSON 结构一并压缩。生成约束要求 forecast 的天气结论通常压缩到一至两项、每个生活建议主题只保留一个行动短句。模型在输出 token 上限被截断属于输出契约失败,参与有界修复重试;网络、鉴权等请求失败仍立即终止。Telegram 和 stdout 保持 3500 个简报字符和 8192 个输出 token。Bark renderer 省略天气段标题和多余空行,以短编号关联末尾的来源名称表;publisher 对每个分片共同计算标题和正文的字符预算,并使用保证最少分块数的换行优先算法。INFO 和 WARNING 日志只记录长度、分块位置、HTTP 状态和安全错误类别,不记录 device key、加密 key、标题、正文、ciphertext、IV 或第三方错误正文。HTTP 2xx 响应也必须是 `code=200` 的 JSON,防止把代理或兼容服务的异常响应误判为成功。 diff --git a/docs/notes.md b/docs/notes.md index 056c319c..37f97fa3 100644 --- a/docs/notes.md +++ b/docs/notes.md @@ -133,7 +133,3 @@ Telegram 投递目前只调用 Bot API 的 `sendMessage`。项目继续复用已 Telegram publisher 只负责构造请求、平台长度限制、HTML 分块和安全错误分类。它不实现更新轮询、Webhook、会话状态或 Bot 命令路由。 如果投递开始使用多个 Telegram 端点、需要接收更新,或者 Bot API 兼容工作持续增加,就重新评估维护活跃的 SDK,并删除能由 SDK 可靠承担的自定义协议代码。 - -## Bark 加密只支持 GCM 并直接调用 API - -Bark 官方把加密推送和自托管服务端都作为保护消息隐私的方案,因此 publisher 允许用户不配置加密参数并发送明文。Bark App 支持 AES 的 CBC、ECB 和 GCM 模式,但 CBC 和 ECB 只提供机密性,不验证密文完整性;ECB 还会暴露相同明文块的结构。项目启用加密时只支持 GCM 和 noPadding,不提供不安全模式的兼容开关。GCM 提供认证加密,但 Bark 使用固定预共享 key 和固定 IV;publisher 不自行轮转 IV,而是要求 `BARK_ENCRYPTION_IV` 与 App 设置完全一致,并在请求中显式携带它。重复使用 GCM IV 会破坏标准安全保证,而且该协议不具备前向保密或防重放能力,因此这里的加密只能作为 Bark 上游约束下的兼容功能。这项选择成立的条件是 Bark 继续使用 ciphertext 后附 16 字节 tag 的 GCM combined 格式;如果 Bark 支持安全的每消息 nonce 协商、官方 SDK 开始提供异步加密投递或协议格式改变,就重新评估适配器和依赖边界。 diff --git a/env.example b/env.example index 697777b0..5d27ebe6 100644 --- a/env.example +++ b/env.example @@ -27,7 +27,8 @@ PUBLISHER=telegram # Optional notification grouping. # BARK_GROUP=weather-briefing # Encryption is recommended. Configure the Bark app for AES256, GCM, noPadding, -# and the same fixed IV. See: https://bark.day.app/#/encryption +# and the same initial IV. Each push uses a new IV sent with its ciphertext. +# See: https://bark.day.app/#/encryption # Generate AES256 values on Linux: # openssl rand -hex 16 # key: 32 ASCII characters # openssl rand -hex 6 # IV: 12 ASCII characters diff --git a/tests/test_bark_publisher.py b/tests/test_bark_publisher.py index 50b300c0..3ccf2f43 100644 --- a/tests/test_bark_publisher.py +++ b/tests/test_bark_publisher.py @@ -1,5 +1,6 @@ import base64 import json +import re import httpx import pytest @@ -25,11 +26,17 @@ def rendered_text_logging_enabled(self) -> bool: ("k" * 32, "a9CkBbe9d0qkskyAzKyb4pV/WjH6D1J/JLm0EOUTMcoTpJEs10gqANkkLKaOPqVXL0yo0AaxElYjmLmynw=="), ), ) -def test_bark_encryptor_matches_the_app_gcm_wire_format(key: str, expected_ciphertext: str) -> None: - encryptor = BarkEncryptor(key, "fixed-iv-123") +def test_bark_encryptor_matches_the_app_gcm_wire_format( + key: str, + expected_ciphertext: str, + monkeypatch, +) -> None: + monkeypatch.setattr("secrets.token_urlsafe", lambda _: "fixed-iv-123") + encryptor = BarkEncryptor(key, "initial-iv-1") encrypted = encryptor.encrypt({"body": "Encrypted weather", "level": "active"}) + assert encrypted.iv == "fixed-iv-123" assert encrypted.ciphertext == expected_ciphertext plaintext = AESGCM(key.encode()).decrypt( encrypted.iv.encode(), @@ -136,6 +143,8 @@ def handler(request: httpx.Request) -> httpx.Response: request = requests[0] request_payload = json.loads(request.content) + assert len(request_payload["iv"]) == 12 + assert re.fullmatch(r"[A-Za-z0-9_-]{12}", request_payload["iv"]) plaintext = AESGCM(key.encode()).decrypt( request_payload["iv"].encode(), base64.b64decode(request_payload["ciphertext"]), @@ -158,8 +167,10 @@ def handler(request: httpx.Request) -> httpx.Response: assert "Bark chunk accepted: index=1/1 payload_characters=33" in caplog.text -async def test_bark_publisher_reuses_the_configured_iv_for_each_chunk() -> None: +async def test_bark_publisher_rotates_the_iv_for_each_chunk(monkeypatch) -> None: requests: list[httpx.Request] = [] + generated_ivs = iter(("abcdefghijkl", "mnopqrstuvwx")) + monkeypatch.setattr("secrets.token_urlsafe", lambda _: next(generated_ivs)) def handler(request: httpx.Request) -> httpx.Response: requests.append(request) @@ -171,7 +182,7 @@ def handler(request: httpx.Request) -> httpx.Response: await publisher.publish(RenderedMessage(body, len(body))) ivs = [json.loads(request.content)["iv"] for request in requests] - assert ivs == ["fixed-iv-123", "fixed-iv-123"] + assert ivs == ["abcdefghijkl", "mnopqrstuvwx"] async def test_bark_rejects_oversized_single_message_before_delivery() -> None: diff --git a/weather_briefing/delivery/bark_crypto.py b/weather_briefing/delivery/bark_crypto.py index f6ca550a..b8a884f6 100644 --- a/weather_briefing/delivery/bark_crypto.py +++ b/weather_briefing/delivery/bark_crypto.py @@ -4,6 +4,7 @@ import base64 import json +import secrets from collections.abc import Mapping from dataclasses import dataclass @@ -11,11 +12,12 @@ _BARK_GCM_IV_CHARACTERS = 12 _BARK_AES_KEY_LENGTHS = frozenset({16, 24, 32}) +_BARK_GCM_IV_RANDOM_BYTES = 9 @dataclass(frozen=True, slots=True) class EncryptedBarkPayload: - """Carry the Bark ciphertext and configured GCM IV.""" + """Carry the Bark ciphertext and per-message GCM IV.""" ciphertext: str iv: str @@ -25,7 +27,7 @@ class BarkEncryptor: """Encrypt Bark request parameters with AES-GCM.""" def __init__(self, key: str, iv: str) -> None: - """Validate and retain the Bark app encryption settings.""" + """Validate the Bark app encryption settings.""" try: key_bytes = key.encode("ascii") except UnicodeEncodeError as exc: @@ -39,14 +41,13 @@ def __init__(self, key: str, iv: str) -> None: if len(iv_bytes) != _BARK_GCM_IV_CHARACTERS: raise ValueError("Bark GCM IV must contain exactly 12 ASCII characters") self._cipher = AESGCM(key_bytes) - self._iv = iv - self._iv_bytes = iv_bytes def encrypt(self, parameters: Mapping[str, object]) -> EncryptedBarkPayload: """Serialize and encrypt one Bark parameter object.""" + iv = secrets.token_urlsafe(_BARK_GCM_IV_RANDOM_BYTES) plaintext = json.dumps(parameters, ensure_ascii=False, separators=(",", ":")).encode() - ciphertext_and_tag = self._cipher.encrypt(self._iv_bytes, plaintext, None) + ciphertext_and_tag = self._cipher.encrypt(iv.encode("ascii"), plaintext, None) return EncryptedBarkPayload( ciphertext=base64.b64encode(ciphertext_and_tag).decode("ascii"), - iv=self._iv, + iv=iv, )