Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,防止把代理或兼容服务的异常响应误判为成功。

Expand Down
4 changes: 0 additions & 4 deletions docs/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 开始提供异步加密投递或协议格式改变,就重新评估适配器和依赖边界。
3 changes: 2 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions tests/test_bark_publisher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import base64
import json
import re

import httpx
import pytest
Expand All @@ -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(),
Expand Down Expand Up @@ -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"]),
Expand All @@ -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)
Expand All @@ -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:
Expand Down
13 changes: 7 additions & 6 deletions weather_briefing/delivery/bark_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,20 @@

import base64
import json
import secrets
from collections.abc import Mapping
from dataclasses import dataclass

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

_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
Expand All @@ -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:
Expand All @@ -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,
)