From bd68382921d30f3ac038eddabd368fac728aff6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 19 Oct 2026 18:59:56 -0400 Subject: [PATCH] C12.4: mail credentials and OAuth sign-in Passwords and refresh tokens go only to the system keychain; a refused keychain keeps them in memory until exit and says why. Gmail and Microsoft sign in by authorization code with S256 PKCE on a loopback redirect; refresh tokens are kept, and revoked grants ask to sign in again. Client IDs come from the build (env or oauth.env). ureq trace records never reach the log. Co-Authored-By: Claude --- Cargo.lock | 3 + agents.md | 2 +- crates/ely-mail-engine/Cargo.toml | 10 + crates/ely-mail-engine/build.rs | 39 +++ crates/ely-mail-engine/src/error.rs | 12 + crates/ely-mail-engine/src/lib.rs | 3 + crates/ely-mail-engine/src/oauth.rs | 277 ++++++++++++++++++ crates/ely-mail-engine/src/oauth/loopback.rs | 85 ++++++ crates/ely-mail-engine/src/oauth/token.rs | 110 +++++++ crates/ely-mail-engine/src/vault.rs | 106 +++++++ crates/ely-mail-engine/tests/oauth.rs | 143 +++++++++ .../ely-mail-engine/tests/oauth_fake/mod.rs | 99 +++++++ .../tests/private_oauth_logs.rs | 45 +++ crates/ely-mail-engine/tests/vault.rs | 86 ++++++ crates/ely-platform/src/logging.rs | 36 ++- tasks/TASKS.md | 2 +- tasks/reviews/C12.4-r1.md | 9 + tasks/reviews/C12.4-r2.md | 3 + tasks/reviews/C12.4-r3.md | 1 + 19 files changed, 1065 insertions(+), 6 deletions(-) create mode 100644 crates/ely-mail-engine/build.rs create mode 100644 crates/ely-mail-engine/src/oauth.rs create mode 100644 crates/ely-mail-engine/src/oauth/loopback.rs create mode 100644 crates/ely-mail-engine/src/oauth/token.rs create mode 100644 crates/ely-mail-engine/src/vault.rs create mode 100644 crates/ely-mail-engine/tests/oauth.rs create mode 100644 crates/ely-mail-engine/tests/oauth_fake/mod.rs create mode 100644 crates/ely-mail-engine/tests/private_oauth_logs.rs create mode 100644 crates/ely-mail-engine/tests/vault.rs create mode 100644 tasks/reviews/C12.4-r1.md create mode 100644 tasks/reviews/C12.4-r2.md create mode 100644 tasks/reviews/C12.4-r3.md diff --git a/Cargo.lock b/Cargo.lock index 90d83a2..df85cba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2135,9 +2135,11 @@ version = "0.1.8" dependencies = [ "base64 0.23.1", "ely-platform", + "form_urlencoded", "getrandom 0.4.3", "html5ever", "imap-proto", + "keyring", "log", "mail-builder", "mail-parser", @@ -2154,6 +2156,7 @@ dependencies = [ "tempfile", "tokio", "tokio-rustls", + "ureq", ] [[package]] diff --git a/agents.md b/agents.md index efb84ea..0d2556f 100644 --- a/agents.md +++ b/agents.md @@ -320,4 +320,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-18:Word 公式(R8.2,Gitea #9)。附件 math.docx 的六类公式逐类核对(本机无 Word,以文件所写为准):键盘输入、OMML(内核转为公式对象)、MathType(OLE,显示随带的 WMF 预览)、上下标、图片原本就对;差别在两处根因。其一,运行的 `w:position`(升降)原先只给文字,图片与 OLE 公式所在的运行失去它(25 张图片与一个 ½ 浮在行上):补丁 `0049-writer-inline-objects-move-with-their-run.patch` 把它作为固定升降写到对象的锚字符上(按锚字符自己的字号换算百分比,不扩展到其后的文字),版面让“作为字符”的对象随锚字符的固定升降移动(vertAlign 的自动上下标不动对象,同原样);各导出本就写出锚字符的升降,DOCX 往返仍为 `w:position`。其二,EQ 域原先只认 `\o`、`\s\do` 与注音,其余(如分数 `\f`)整个消失:补丁 `0050-writer-eq-field-parser.patch`(读法)与 `0051-writer-eq-fields-as-formulas.patch`(导入)把 EQ 域转为公式对象:`\f` 分数、`\r` 根式、`\s` 上下标、`\b` 括号、`\l` 列表、`\a` 阵列、`\i` 积分与求和求积;`\x` 与 `\o` 只留内容(无框、无叠印),`\d` 只前移,`\ai`、`\di` 只是行距(不升降),开关不分大小写;每个命令字符保留其运行的上下标(Word 照此画),同一段上下标里字样变了仍是一个上下标;斜体字母写成标识符(StarMath 与 OMML 都画成斜体,DOCX 往返不丢),斜体的数字与符号写成 `ital` 文字,存为 DOCX 后成正体(OMML 无此写法,已知限制);没有底的上下标以空文字为底(空的底在 OMML 里是占位符,重开成 ``);公式取域的字号;读不通的命令以其域代码显示为文字,不消失。只有整个域是 `\s\do N(文字)` 时照原样成为下移的文字,其余都经读法(原先小写的 `\s\do` 读到第一个括号即止,其后丢失)。`client/tests/word.rs` 以 `tests/fixtures/office/word.docx`(由 `.work/r8/gen/eq.py` 生成,不入库)核对公式文本(含开头的空格、大写开关、读不通的域、字样变化的下标、`\ai`、`\di`、小写 `\s\do` 的尾部与嵌套)、DOCX 往返后的斜体、下标与无占位符、锚字符的升降与图块里图片低于文字(去掉版面的升降即失败)。审查三轮:第 3 轮的四处已修并以上述测试核对,未再审查(每条至多三轮)。仍与 Word 不同:编辑区里公式与图片四周有内核的辅助框线(`ShowBoundaries`,视图辅助,不印出);DOCX 另存后重开行距变宽,因内核导出改写文档默认字体与段距,与公式无关。 - 2026-10-18:工作表过多时标签条(R8.3,Gitea #8)。组件库 `navigation::Tabs` 横排时可横向滚动(滚轮同样),标签可窄于自然宽度;所选标签在所选或标签条宽度变了时滚入视口,在绘制时以刚排好的宽度判定(原先只在首帧滚一次,其后右栏打开、标签条变窄,所选标签被挤出视口);手动滚动保留到二者之一再变;横排的标签至多与标签条同宽,名字过长以省略号结尾(组件库 e56a343、3a68d1b、7d704d9,同批带入上游列外壳拖动的四个提交)。ElyTable 的标签条随之收缩,添加按钮留在其旁;标签条的状态按工作簿各记一份(元素 id 带标签号),切到另一工作簿时其所选标签照样滚入。`navigation/tests/tabs.rs` 以 30 个标签核对:首帧滚到所选、滚轮回到第一个、新选的滚入、变窄后所选仍在末端(去掉按宽度判定即失败)、长名的所选标签整个在标签条内(去掉宽度上限即失败);ElyTable 的 `tests/strip.rs` 以 `sheets::strip` 画两个各有 30 张表、都选第 30 张的工作簿,滚回开头后来回切换,所选标签每次都在标签条内(两个工作簿共用一个 id 即失败)。Windows 真窗口(`.work/r8/strip3.png`):30 张表、第 30 张为当前,1100 宽时标签条末端为它。 - 2026-10-18:ElyMark 公式(R8.4,用户定两项都做)。行内公式照块公式由 RaTeX 排版(原只显示着色的 TeX 原文):gpui 的文本里放不进图片,组件库 `render/math_text.rs` 把每个行内公式换成等宽的不断行空格(U+00A0,按字体量出个数),排好的公式居中画在其上,行高随最高的公式撑开;空格串与词一样整个折到下一行,画在其所在行(折行处的序号 gpui 读作上一行末);按下与右键所在的字节经换算读回公式的原文;排不出的公式照旧显示 TeX 原文,其后以危险色写出 RaTeX 给的原因与位置,记警告(不含公式)。`\(…\)` 读作行内公式,`\[…\]` 读作块公式(ChatGPT 等常写的分隔符;GitHub 不认,Typora 默认不认):解析前逐字节把成对的分隔符换成 `$`(长度不变,位置照旧对得上)。成对只在同一段落、标题或单元格里(跨列表项、CR 空行不成对);不跨代码、公式、HTML 标签、链接地址与引用标签、图片、脚注引用,``、`` 之间(含嵌套;交叉闭合照可视的读法关闭与重开)的文字照原文;`\\(` 不换。公式里与段落里另有的 `$`(含 `\$`)解析时暂写成文本里没有的控制字节,解析后还原,不与换上的 `$` 配对;相邻的两个公式之间暂插一个空格,解析后去掉,仍是两个公式。公式节点的范围是整对分隔符,值是写下的 TeX 原文。独占一段的 `\[…\]` 为块公式(段首可有一至三个空格)。组件库公开 `documents::markdown_tree`,ElyMark 的 HTML、PDF、纯文本导出都经它解析(`markdown_options` 不再公开),纯文本里独占一段的公式照块公式写其 TeX。同时查出(组件库 e095f06):RaTeX 的 SVG 以 pt 定尺寸,usvg 按 px 量墨迹,两者差 4/3,每个公式的画面右侧与下方多出约四分之一的空白(块公式因此偏左)、导出的公式比正文大 4/3;现生成时即把根改为 px。`markdown/tex/tests.rs` 与 `tree/tests.rs` 核对分隔符的各种情形(相邻、`\$`、链接地址、``、跨块),`math_text.rs` 的窗口测试核对公式排进并读回原文、折到第二行的公式画在第二行首(改回按起点定位即失败)、排不出的公式写出原因(组件库 a20e712、5fba7b3、de961eb),`math/tests.rs` 核对画面与公式同宽(改回 pt 即失败),ElyMark 的 `visual.rs` 核对导出与纯文本。Windows 真窗口(`.work/r8/mark/visual2.png`):`$…$`、`\(…\)` 排在行内,`\[…\]` 居中成块,行内代码与转义的 `\\(` 照原文。 -- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。 +- 2026-10-19(用户定):ElyMail 的后端改为 Rust crate,取代 MailCore 2(其 Windows 构建是 VS 2013 工程加 2015 年前后的预编译依赖,POP 无 OAuth2):mail-send、mail-parser、mail-builder,在应用进程的后台任务里运行,不另起 worker;MailCore 2 与 libetpan 的取码、构建与探测随之删去。正文以 html5ever 解析、净化后按块的子集由 GPUI 绘制,不当作完整页面;写信用 Markdown 文档组件,发出纯文本与 HTML。Gmail 与 Microsoft 走 OAuth(授权码加 PKCE,本机回环),客户端 ID 在构建时注入:`ELY_GOOGLE_CLIENT_ID`、`ELY_GOOGLE_CLIENT_SECRET`、`ELY_MICROSOFT_CLIENT_ID`,本机写在项目根的 `oauth.env`(不入库),CI 为仓库机密;缺 ID 的构建里该登录写明不可用,不退回别的方式。iCloud 不给第三方 OAuth,走应用专用密码向导(`imap.mail.me.com:993`、`smtp.mail.me.com:587`,无 POP3)。注册由用户做(Google Desktop 客户端,Entra 公共客户端,委托权限 IMAP、POP、SMTP 与 offline_access)。POP3(C12.1 审查后)由引擎自写,不用 async-pop:它留着点填充、丢末尾 CRLF、吞掉登录后的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码。IMAP(第 2 轮审查后)同样自写,以 imap-proto 解析回复,不用 async-imap:它的 LIST、FETCH 等流式命令不看结尾的 NO 与 BAD(拒绝成了空结果),登录与命令中途的 `* BYE` 被丢掉(只剩断线),trace 日志写出密码与邮件。SMTP 的事务(MAIL、RCPT、DATA)也由引擎自走:mail-send 不给首行的点加点;正文有 8 位字节即带 `BODY=8BITMIME`,服务器不支持即拒绝。读信按 MIME 树与所在容器(C12.2),嵌套(多部分与内嵌邮件都算)深于 64 层即报错,超深的解析结果以迭代拆开再丢(直接丢弃会递归、栈溢出);mail-parser 开 `full_encoding`;SMTPUTF8 按每一层的头块判定。本地存储(C12.3):rusqlite 自带的 SQLite,`mail.db` 加内容目录(原文按 SHA-256),FTS5(trigram)可由索引与原文重建;`user_version` 记结构版本,升级前备份,失败即只读;原文文件在删去或替换邮件的事务提交前删去,删不掉即回滚。只有 HTML 的邮件经 html5ever 的树构建器取文字入索引,元素深过 512 层即停读(树构建器的开放元素栈扫描随深度平方增长)。凭据(C12.4):密码与刷新令牌只进系统凭据库(keyring),被拒即只留在内存到退出;OAuth 换令牌与刷新经 ureq,回跳在 127.0.0.1 随机端口;Google 授权带 `access_type=offline` 与 `prompt=consent`(每次同意都发刷新令牌),Microsoft 另要 `profile`(无 `email` 时以 `preferred_username` 为地址),换码没有刷新令牌即报错;客户端 ID 由 `crates/ely-mail-engine/build.rs` 取环境变量或 `oauth.env`,该文件不存在即未配置,读不出或写错即构建失败。ureq 在 trace 级逐字节记下请求与回复(令牌、客户端密钥),`ely_platform::logging` 在任何 `ELY_LOG` 下都不记 `ureq`、`ureq_proto` 低于 info 的日志。 diff --git a/crates/ely-mail-engine/Cargo.toml b/crates/ely-mail-engine/Cargo.toml index 26cebf1..f6399c9 100644 --- a/crates/ely-mail-engine/Cargo.toml +++ b/crates/ely-mail-engine/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] base64.workspace = true +form_urlencoded = "1" getrandom.workspace = true html5ever.workspace = true imap-proto.workspace = true @@ -25,6 +26,15 @@ sha2.workspace = true smtp-proto = "0.2" tokio.workspace = true tokio-rustls.workspace = true +ureq.workspace = true + +[dependencies.keyring] +version = "3" +features = ["apple-native", "windows-native"] + +[target.'cfg(target_os = "linux")'.dependencies.keyring] +version = "3" +features = ["linux-native-sync-persistent"] [dev-dependencies] ely-platform.workspace = true diff --git a/crates/ely-mail-engine/build.rs b/crates/ely-mail-engine/build.rs new file mode 100644 index 0000000..e13af0b --- /dev/null +++ b/crates/ely-mail-engine/build.rs @@ -0,0 +1,39 @@ +use std::{env, fs, io::ErrorKind, path::Path}; + +const KEYS: [&str; 3] = [ + "ELY_GOOGLE_CLIENT_ID", + "ELY_GOOGLE_CLIENT_SECRET", + "ELY_MICROSOFT_CLIENT_ID", +]; + +/// Client IDs: CI secrets, else the local oauth.env. +fn main() { + let file = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../oauth.env"); + println!("cargo:rerun-if-changed={}", file.display()); + for key in KEYS { + println!("cargo:rerun-if-env-changed={key}"); + } + let text = match fs::read_to_string(&file) { + Ok(text) => text, + Err(err) if err.kind() == ErrorKind::NotFound => return, + Err(err) => panic!("oauth.env unreadable: {err}"), + }; + for (number, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + panic!("oauth.env line {}: expected KEY=value", number + 1); + }; + let key = key.trim(); + assert!( + KEYS.contains(&key), + "oauth.env line {}: unknown key {key}", + number + 1 + ); + if env::var_os(key).is_none() { + println!("cargo:rustc-env={key}={}", value.trim()); + } + } +} diff --git a/crates/ely-mail-engine/src/error.rs b/crates/ely-mail-engine/src/error.rs index 905a90e..4767498 100644 --- a/crates/ely-mail-engine/src/error.rs +++ b/crates/ely-mail-engine/src/error.rs @@ -25,6 +25,14 @@ pub enum Error { Store(String), /// The store refuses writes; the reason says why. ReadOnly(String), + /// The system credential store failed. + Vault(String), + /// This build has no client ID for the provider. + Unavailable(&'static str), + /// Sign-in did not finish; the reason says why. + SignIn(String), + /// The provider revoked the refresh token. + SignInAgain(String), } impl fmt::Display for Error { @@ -40,6 +48,10 @@ impl fmt::Display for Error { Self::Mime(text) => write!(f, "unreadable message: {text}"), Self::Store(text) => write!(f, "local store failed: {text}"), Self::ReadOnly(text) => write!(f, "local store is read-only: {text}"), + Self::Vault(text) => write!(f, "credential store failed: {text}"), + Self::Unavailable(name) => write!(f, "{name} sign-in is not in this build"), + Self::SignIn(text) => write!(f, "sign-in failed: {text}"), + Self::SignInAgain(text) => write!(f, "sign in again: {text}"), } } } diff --git a/crates/ely-mail-engine/src/lib.rs b/crates/ely-mail-engine/src/lib.rs index d80b757..ecc2cd8 100644 --- a/crates/ely-mail-engine/src/lib.rs +++ b/crates/ely-mail-engine/src/lib.rs @@ -4,9 +4,11 @@ mod error; mod imap; mod mime; mod net; +pub mod oauth; mod pop; mod smtp; mod store; +mod vault; mod wire; pub use error::{Error, Result}; @@ -17,3 +19,4 @@ pub use pop::Pop; pub use smtp::Smtp; pub use store::{AccountId, Fetched, Key, MailboxId, MessageId, Sizes, Store, Stored}; pub use tokio_rustls::TlsConnector; +pub use vault::{Backend, Kept, Keyring, Vault}; diff --git a/crates/ely-mail-engine/src/oauth.rs b/crates/ely-mail-engine/src/oauth.rs new file mode 100644 index 0000000..5916d45 --- /dev/null +++ b/crates/ely-mail-engine/src/oauth.rs @@ -0,0 +1,277 @@ +use std::time::SystemTime; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; + +use crate::{Error, Result}; + +mod loopback; +mod token; + +/// One OAuth authorization server and this app's client. +pub struct Provider { + pub name: &'static str, + pub authorize: String, + pub token: String, + pub client_id: String, + /// Google's desktop clients carry one; Microsoft's do not. + pub client_secret: Option, + pub scope: String, + /// Authorize parameters this server adds. + pub extra: &'static [(&'static str, &'static str)], + /// The registered loopback host. + pub redirect_host: &'static str, +} + +/// Tokens from one exchange or refresh. +pub struct Tokens { + pub access: String, + pub expires_at: SystemTime, + /// A new refresh token; store it, drop the old one. + pub refresh: Option, + /// The signed-in address, from the ID token. + pub email: Option, +} + +/// Gmail: IMAP, POP3 and SMTP under one scope. +pub fn google() -> Result { + Ok(gmail( + built("google", option_env!("ELY_GOOGLE_CLIENT_ID"))?, + built("google", option_env!("ELY_GOOGLE_CLIENT_SECRET"))?, + )) +} + +fn gmail(client_id: String, secret: String) -> Provider { + Provider { + name: "google", + authorize: "https://accounts.google.com/o/oauth2/v2/auth".into(), + token: "https://oauth2.googleapis.com/token".into(), + client_id, + client_secret: Some(secret), + scope: "https://mail.google.com/ openid email".into(), + // Refresh token on every consent. + extra: &[("access_type", "offline"), ("prompt", "consent")], + redirect_host: "127.0.0.1", + } +} + +/// Outlook.com and Microsoft 365. +pub fn microsoft() -> Result { + Ok(outlook(built( + "microsoft", + option_env!("ELY_MICROSOFT_CLIENT_ID"), + )?)) +} + +fn outlook(client_id: String) -> Provider { + Provider { + name: "microsoft", + authorize: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize".into(), + token: "https://login.microsoftonline.com/common/oauth2/v2.0/token".into(), + client_id, + client_secret: None, + scope: [ + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/POP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + "offline_access openid email profile", + ] + .join(" "), + extra: &[], + // Port ignored only for localhost. + redirect_host: "localhost", + } +} + +fn built(name: &'static str, value: Option<&str>) -> Result { + match value { + Some(value) if !value.is_empty() => Ok(value.to_owned()), + _ => Err(Error::Unavailable(name)), + } +} + +/// A sign-in waiting for the browser to come back. +pub struct SignIn { + provider: Provider, + listener: TcpListener, + url: String, + redirect: String, + state: String, + verifier: String, +} + +impl SignIn { + /// Opens the loopback port and builds the browser URL. + pub async fn start(provider: Provider, hint: &str) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|err| Error::SignIn(format!("no loopback port: {err}")))?; + let port = listener + .local_addr() + .map_err(|err| Error::SignIn(format!("no loopback port: {err}")))? + .port(); + let redirect = format!("http://{}:{port}", provider.redirect_host); + let state = random(); + let verifier = random(); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + let mut query = vec![ + ("client_id", provider.client_id.as_str()), + ("response_type", "code"), + ("redirect_uri", redirect.as_str()), + ("scope", provider.scope.as_str()), + ("state", state.as_str()), + ("code_challenge", challenge.as_str()), + ("code_challenge_method", "S256"), + ]; + query.extend_from_slice(provider.extra); + if !hint.is_empty() { + query.push(("login_hint", hint)); + } + let url = format!("{}?{}", provider.authorize, encode(&query)); + log::info!("oauth: {} sign-in waits on 127.0.0.1:{port}", provider.name); + Ok(Self { + provider, + listener, + url, + redirect, + state, + verifier, + }) + } + + /// The page to open in the system browser. + pub fn url(&self) -> &str { + &self.url + } + + /// Waits for the redirect, then trades the code for tokens. + pub async fn finish(self) -> Result { + let code = loopback::code(&self.listener, &self.state).await?; + log::info!("oauth: {} returned a code", self.provider.name); + drop(self.listener); + let mut form = vec![ + ("grant_type", "authorization_code".to_owned()), + ("client_id", self.provider.client_id.clone()), + ("code", code), + ("code_verifier", self.verifier), + ("redirect_uri", self.redirect), + ]; + if let Some(secret) = &self.provider.client_secret { + form.push(("client_secret", secret.clone())); + } + let tokens = token::post(&self.provider, form, false).await?; + if tokens.refresh.is_none() { + return Err(Error::SignIn("no refresh token granted".into())); + } + Ok(tokens) + } +} + +/// A fresh access token; a refused refresh needs sign-in again. +pub async fn refresh(provider: &Provider, refresh: &str) -> Result { + let mut form = vec![ + ("grant_type", "refresh_token".to_owned()), + ("client_id", provider.client_id.clone()), + ("refresh_token", refresh.to_owned()), + ]; + if let Some(secret) = &provider.client_secret { + form.push(("client_secret", secret.clone())); + } + token::post(provider, form, true).await +} + +/// 32 random bytes, base64url: a PKCE verifier or a state. +fn random() -> String { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("system randomness"); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn encode(pairs: &[(&str, &str)]) -> String { + pairs + .iter() + .map(|(key, value)| format!("{}={}", escape(key), escape(value))) + .collect::>() + .join("&") +} + +fn escape(text: &str) -> String { + let mut out = String::new(); + for byte in text.bytes() { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { + out.push(byte as char); + } else { + out.push_str(&format!("%{byte:02X}")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_client_id_makes_the_provider_unavailable() { + assert!(matches!( + built("google", None), + Err(Error::Unavailable("google")) + )); + assert!(matches!( + built("google", Some("")), + Err(Error::Unavailable("google")) + )); + assert_eq!(built("google", Some("id-1")).unwrap(), "id-1"); + } + + async fn asked(provider: Provider) -> Vec<(String, String)> { + let sign_in = SignIn::start(provider, "").await.unwrap(); + let (_, query) = sign_in.url().split_once('?').unwrap(); + form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect() + } + + fn value<'a>(pairs: &'a [(String, String)], key: &str) -> Option<&'a str> { + pairs + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + } + + #[tokio::test] + async fn gmail_asks_for_a_refresh_token_every_time() { + let pairs = asked(gmail("id".into(), "s".into())).await; + assert_eq!(value(&pairs, "access_type"), Some("offline")); + assert_eq!(value(&pairs, "prompt"), Some("consent")); + assert!( + value(&pairs, "scope") + .unwrap() + .contains("https://mail.google.com/") + ); + } + + #[tokio::test] + async fn outlook_asks_for_offline_access_and_the_address_claims() { + let pairs = asked(outlook("id".into())).await; + let scope: Vec<&str> = value(&pairs, "scope").unwrap().split(' ').collect(); + for needed in ["offline_access", "openid", "email", "profile"] { + assert!(scope.contains(&needed), "{needed}"); + } + assert_eq!(value(&pairs, "access_type"), None); + assert!( + value(&pairs, "redirect_uri") + .unwrap() + .starts_with("http://localhost:") + ); + } + + #[test] + fn query_values_are_escaped() { + assert_eq!( + encode(&[("scope", "a b/c"), ("x", "é~")]), + "scope=a%20b%2Fc&x=%C3%A9~" + ); + } +} diff --git a/crates/ely-mail-engine/src/oauth/loopback.rs b/crates/ely-mail-engine/src/oauth/loopback.rs new file mode 100644 index 0000000..477459e --- /dev/null +++ b/crates/ely-mail-engine/src/oauth/loopback.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, +}; + +use crate::{Error, Result}; + +const LIMIT: usize = 8 * 1024; +const READ: Duration = Duration::from_secs(10); +const PAGE: &str = "ElyMail: sign-in finished. You can close this tab."; + +/// Serves redirects until one carries a code or error. +pub(super) async fn code(listener: &TcpListener, state: &str) -> Result { + loop { + let (mut stream, _) = listener + .accept() + .await + .map_err(|err| Error::SignIn(format!("loopback accept failed: {err}")))?; + let Some(target) = request(&mut stream).await else { + continue; + }; + let query = target.split_once('?').map_or("", |(_, query)| query); + let pairs: Vec<(String, String)> = form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect(); + let field = |name: &str| { + pairs + .iter() + .find(|(key, _)| key == name) + .map(|(_, v)| v.clone()) + }; + if field("code").is_none() && field("error").is_none() { + reply(&mut stream, "404 Not Found", "").await; + continue; + } + reply(&mut stream, "200 OK", PAGE).await; + if field("state").as_deref() != Some(state) { + return Err(Error::SignIn("redirect state does not match".into())); + } + if let Some(error) = field("error") { + return Err(Error::SignIn(match field("error_description") { + Some(text) => format!("{error}: {text}"), + None => error, + })); + } + return field("code").ok_or_else(|| Error::SignIn("redirect without a code".into())); + } +} + +/// The request target of one GET, None for anything else. +async fn request(stream: &mut TcpStream) -> Option { + let mut head = Vec::new(); + let mut chunk = [0u8; 1024]; + while !head.windows(4).any(|window| window == b"\r\n\r\n") { + let read = tokio::time::timeout(READ, stream.read(&mut chunk)).await; + match read { + Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None, + Ok(Ok(count)) => head.extend_from_slice(&chunk[..count]), + } + if head.len() > LIMIT { + log::warn!("oauth: loopback request over {LIMIT} bytes dropped"); + return None; + } + } + let line = head.split(|byte| *byte == b'\n').next()?; + let line = std::str::from_utf8(line).ok()?.trim_end(); + let mut parts = line.split(' '); + match (parts.next(), parts.next()) { + (Some("GET"), Some(target)) => Some(target.to_owned()), + _ => None, + } +} + +async fn reply(stream: &mut TcpStream, status: &str, body: &str) { + let text = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + if let Err(err) = stream.write_all(text.as_bytes()).await { + log::warn!("oauth: loopback reply failed: {err}"); + } +} diff --git a/crates/ely-mail-engine/src/oauth/token.rs b/crates/ely-mail-engine/src/oauth/token.rs new file mode 100644 index 0000000..c182ae9 --- /dev/null +++ b/crates/ely-mail-engine/src/oauth/token.rs @@ -0,0 +1,110 @@ +use std::time::{Duration, SystemTime}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde::Deserialize; +use ureq::Agent; + +use super::{Provider, Tokens}; +use crate::{Error, Result}; + +const TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Deserialize)] +struct Granted { + access_token: String, + expires_in: u64, + refresh_token: Option, + id_token: Option, +} + +#[derive(Deserialize)] +struct Refused { + error: String, + error_description: Option, +} + +#[derive(Deserialize)] +struct Claims { + email: Option, + preferred_username: Option, +} + +/// Posts to the token endpoint off the async thread. +pub(super) async fn post( + provider: &Provider, + form: Vec<(&'static str, String)>, + refreshing: bool, +) -> Result { + let url = provider.token.clone(); + let name = provider.name; + let started = SystemTime::now(); + let (status, body) = tokio::task::spawn_blocking(move || send(&url, &form)) + .await + .map_err(|err| Error::SignIn(format!("token request stopped: {err}")))??; + if status == 200 { + let tokens = granted(&body, started)?; + log::info!( + "oauth: {name} token granted, refresh {}", + tokens.refresh.is_some() + ); + return Ok(tokens); + } + let refused: Refused = serde_json::from_str(&body) + .map_err(|_| Error::SignIn(format!("token endpoint answered {status}")))?; + let reason = match refused.error_description { + Some(text) => format!("{}: {text}", refused.error), + None => refused.error.clone(), + }; + log::warn!("oauth: {name} token refused ({status}, {})", refused.error); + if refreshing + && matches!( + refused.error.as_str(), + "invalid_grant" | "interaction_required" + ) + { + return Err(Error::SignInAgain(reason)); + } + Err(Error::SignIn(reason)) +} + +fn send(url: &str, form: &[(&'static str, String)]) -> Result<(u16, String)> { + let agent: Agent = Agent::config_builder() + .timeout_global(Some(TIMEOUT)) + .http_status_as_error(false) + .build() + .into(); + let mut response = agent + .post(url) + .send_form(form.iter().map(|(key, value)| (*key, value.as_str()))) + .map_err(|err| Error::SignIn(format!("token endpoint unreachable: {err}")))?; + let status = response.status().as_u16(); + let body = response + .body_mut() + .read_to_string() + .map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?; + Ok((status, body)) +} + +fn granted(body: &str, started: SystemTime) -> Result { + let granted: Granted = serde_json::from_str(body) + .map_err(|err| Error::SignIn(format!("token reply unreadable: {err}")))?; + let email = match &granted.id_token { + Some(token) => email(token)?, + None => None, + }; + Ok(Tokens { + access: granted.access_token, + expires_at: started + Duration::from_secs(granted.expires_in), + refresh: granted.refresh_token, + email, + }) +} + +/// The address claim; the token came straight from the endpoint. +fn email(token: &str) -> Result> { + let unreadable = || Error::SignIn("ID token unreadable".into()); + let payload = token.split('.').nth(1).ok_or_else(unreadable)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| unreadable())?; + let claims: Claims = serde_json::from_slice(&bytes).map_err(|_| unreadable())?; + Ok(claims.email.or(claims.preferred_username)) +} diff --git a/crates/ely-mail-engine/src/vault.rs b/crates/ely-mail-engine/src/vault.rs new file mode 100644 index 0000000..8114a74 --- /dev/null +++ b/crates/ely-mail-engine/src/vault.rs @@ -0,0 +1,106 @@ +use std::{collections::HashMap, sync::Mutex}; + +use crate::{Error, Result}; + +/// A secret store backend, or a test double. +pub trait Backend: Send + Sync { + fn get(&self, name: &str) -> std::result::Result, String>; + fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String>; + fn delete(&self, name: &str) -> std::result::Result<(), String>; +} + +/// The system credential store (§18.2). +pub struct Keyring; + +const SERVICE: &str = "ElyMail"; + +impl Backend for Keyring { + fn get(&self, name: &str) -> std::result::Result, String> { + match entry(name)?.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(err.to_string()), + } + } + + fn set(&self, name: &str, secret: &str) -> std::result::Result<(), String> { + entry(name)? + .set_password(secret) + .map_err(|err| err.to_string()) + } + + fn delete(&self, name: &str) -> std::result::Result<(), String> { + match entry(name)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(err) => Err(err.to_string()), + } + } +} + +fn entry(name: &str) -> std::result::Result { + keyring::Entry::new(SERVICE, name).map_err(|err| err.to_string()) +} + +/// Where a kept secret lives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Kept { + System, + /// The system store refused; gone when the app quits. + Session(String), +} + +/// Passwords and refresh tokens: system store, else memory. +pub struct Vault { + backend: Box, + session: Mutex>, +} + +impl Vault { + pub fn system() -> Self { + Self::new(Box::new(Keyring)) + } + + pub fn new(backend: Box) -> Self { + Self { + backend, + session: Mutex::new(HashMap::new()), + } + } + + /// Stores a secret; never on disk outside the system store. + pub fn put(&self, name: &str, secret: &str) -> Kept { + self.session().remove(name); + match self.backend.set(name, secret) { + Ok(()) => { + log::info!("vault: {name} kept in the system store"); + Kept::System + } + Err(reason) => { + log::warn!("vault: system store refused {name}, this session only: {reason}"); + self.session().insert(name.to_owned(), secret.to_owned()); + Kept::Session(reason) + } + } + } + + /// The secret, None when never stored. + pub fn get(&self, name: &str) -> Result> { + if let Some(secret) = self.session().get(name) { + return Ok(Some(secret.clone())); + } + self.backend + .get(name) + .map_err(|reason| Error::Vault(format!("reading {name}: {reason}"))) + } + + pub fn remove(&self, name: &str) -> Result<()> { + self.session().remove(name); + self.backend + .delete(name) + .map_err(|reason| Error::Vault(format!("deleting {name}: {reason}"))) + } + + fn session(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.session.lock().expect("vault lock") + } +} diff --git a/crates/ely-mail-engine/tests/oauth.rs b/crates/ely-mail-engine/tests/oauth.rs new file mode 100644 index 0000000..bc815c6 --- /dev/null +++ b/crates/ely-mail-engine/tests/oauth.rs @@ -0,0 +1,143 @@ +mod oauth_fake; + +use std::{ + thread, + time::{Duration, SystemTime}, +}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ely_mail_engine::{ + Error, + oauth::{SignIn, refresh}, +}; +use oauth_fake::{browse, endpoint, id_token, provider, query, signed_in}; +use sha2::{Digest, Sha256}; + +#[tokio::test] +async fn sign_in_exchanges_the_code_with_pkce() { + let body = format!( + r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#, + id_token(r#"{"email":"ana@example.com"}"#) + ); + let (token, forms) = endpoint(200, body); + let sign_in = SignIn::start(provider(token, Some("shh")), "ana@example.com") + .await + .unwrap(); + let asked = query(sign_in.url()); + assert!(sign_in.url().starts_with("https://auth.example/authorize?")); + assert_eq!(asked["client_id"], "client-1"); + assert_eq!(asked["response_type"], "code"); + assert_eq!(asked["code_challenge_method"], "S256"); + assert_eq!(asked["login_hint"], "ana@example.com"); + assert_eq!(asked["scope"], "mail openid email"); + assert_eq!(asked["prompt"], "consent"); + let redirect = asked["redirect_uri"].clone(); + assert!(redirect.starts_with("http://127.0.0.1:")); + let state = asked["state"].clone(); + let browser = thread::spawn(move || { + let ignored = browse(&redirect, "/favicon.ico"); + let done = browse(&redirect, &format!("/?code=c%2B1&state={state}")); + (ignored, done, redirect) + }); + let before = SystemTime::now(); + let tokens = sign_in.finish().await.unwrap(); + let (ignored, done, redirect) = browser.join().unwrap(); + assert_eq!(ignored, "HTTP/1.1 404 Not Found"); + assert_eq!(done, "HTTP/1.1 200 OK"); + assert_eq!(tokens.access, "at-1"); + assert_eq!(tokens.refresh.as_deref(), Some("rt-1")); + assert_eq!(tokens.email.as_deref(), Some("ana@example.com")); + let left = tokens.expires_at.duration_since(before).unwrap(); + assert!(left >= Duration::from_secs(3600) && left < Duration::from_secs(3610)); + let form = forms.recv().unwrap(); + assert_eq!(form["grant_type"], "authorization_code"); + assert_eq!(form["code"], "c+1"); + assert_eq!(form["client_secret"], "shh"); + assert_eq!(form["redirect_uri"], redirect); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(form["code_verifier"].as_bytes())); + assert_eq!(challenge, asked["code_challenge"]); +} + +#[tokio::test] +async fn a_wrong_state_is_refused() { + let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "") + .await + .unwrap(); + let asked = query(sign_in.url()); + assert!(!asked.contains_key("login_hint")); + let redirect = asked["redirect_uri"].clone(); + let browser = thread::spawn(move || browse(&redirect, "/?code=x&state=forged")); + let err = sign_in.finish().await.err().unwrap(); + browser.join().unwrap(); + assert!(matches!(err, Error::SignIn(text) if text.contains("state"))); +} + +#[tokio::test] +async fn a_declined_consent_reports_the_reason() { + let sign_in = SignIn::start(provider("http://127.0.0.1:9/token".into(), None), "") + .await + .unwrap(); + let asked = query(sign_in.url()); + let (redirect, state) = (asked["redirect_uri"].clone(), asked["state"].clone()); + let path = format!("/?error=access_denied&error_description=user+said+no&state={state}"); + let browser = thread::spawn(move || browse(&redirect, &path)); + let err = sign_in.finish().await.err().unwrap(); + browser.join().unwrap(); + assert!(matches!(err, Error::SignIn(text) if text == "access_denied: user said no")); +} + +#[tokio::test] +async fn a_sign_in_without_a_refresh_token_fails() { + let body = r#"{"access_token":"at-1","expires_in":3600}"#.to_owned(); + let err = signed_in(body).await.err().unwrap(); + assert!(matches!(err, Error::SignIn(text) if text.contains("refresh token"))); +} + +#[tokio::test] +async fn the_username_stands_in_for_a_missing_email() { + let body = format!( + r#"{{"access_token":"at-1","expires_in":3600,"refresh_token":"rt-1","id_token":"{}"}}"#, + id_token(r#"{"preferred_username":"bo@outlook.com"}"#) + ); + let tokens = signed_in(body).await.unwrap(); + assert_eq!(tokens.email.as_deref(), Some("bo@outlook.com")); +} + +#[tokio::test] +async fn refresh_rotates_the_token() { + let body = r#"{"access_token":"at-2","expires_in":60,"refresh_token":"rt-2"}"#.to_owned(); + let (token, forms) = endpoint(200, body); + let tokens = refresh(&provider(token, None), "rt-1").await.unwrap(); + assert_eq!( + (tokens.access.as_str(), tokens.refresh.as_deref()), + ("at-2", Some("rt-2")) + ); + assert_eq!(tokens.email, None); + let form = forms.recv().unwrap(); + assert_eq!(form["grant_type"], "refresh_token"); + assert_eq!(form["refresh_token"], "rt-1"); + assert!(!form.contains_key("client_secret")); +} + +#[tokio::test] +async fn a_revoked_refresh_token_needs_sign_in_again() { + for error in ["invalid_grant", "interaction_required"] { + let body = format!(r#"{{"error":"{error}","error_description":"revoked"}}"#); + let (token, _) = endpoint(400, body); + let err = refresh(&provider(token, None), "rt-1").await.err().unwrap(); + assert!( + matches!(&err, Error::SignInAgain(text) if text == &format!("{error}: revoked")), + "{err}" + ); + } +} + +#[tokio::test] +async fn other_refusals_stay_sign_in_errors() { + let (token, _) = endpoint(401, r#"{"error":"invalid_client"}"#.to_owned()); + let err = refresh(&provider(token, None), "rt-1").await.err().unwrap(); + assert!(matches!(err, Error::SignIn(text) if text == "invalid_client")); + let (token, _) = endpoint(502, "bad gateway".to_owned()); + let err = refresh(&provider(token, None), "rt-1").await.err().unwrap(); + assert!(matches!(err, Error::SignIn(text) if text.contains("502"))); +} diff --git a/crates/ely-mail-engine/tests/oauth_fake/mod.rs b/crates/ely-mail-engine/tests/oauth_fake/mod.rs new file mode 100644 index 0000000..d4288a8 --- /dev/null +++ b/crates/ely-mail-engine/tests/oauth_fake/mod.rs @@ -0,0 +1,99 @@ +//! A fake token endpoint and browser for OAuth tests. +#![allow(dead_code)] + +use std::{ + collections::HashMap, + io::{Read, Write}, + net::TcpListener, + sync::mpsc, + thread, +}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use ely_mail_engine::oauth::{Provider, SignIn, Tokens}; + +/// A token endpoint that answers once and reports the form. +pub fn endpoint(status: u16, body: String) -> (String, mpsc::Receiver>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/token", listener.local_addr().unwrap()); + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut data = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let count = stream.read(&mut chunk).unwrap(); + data.extend_from_slice(&chunk[..count]); + let text = String::from_utf8_lossy(&data).to_string(); + if let Some((head, rest)) = text.split_once("\r\n\r\n") { + let length: usize = head + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|v| v.trim().parse().unwrap()) + }) + .unwrap(); + if rest.len() >= length { + let form = form_urlencoded::parse(rest.as_bytes()) + .into_owned() + .collect(); + let _ = sender.send(form); + break; + } + } + } + let reply = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(reply.as_bytes()).unwrap(); + }); + (url, receiver) +} + +pub fn provider(token: String, secret: Option<&str>) -> Provider { + Provider { + name: "test", + authorize: "https://auth.example/authorize".into(), + token, + client_id: "client-1".into(), + client_secret: secret.map(str::to_owned), + scope: "mail openid email".into(), + extra: &[("prompt", "consent")], + redirect_host: "127.0.0.1", + } +} + +pub fn id_token(claims: &str) -> String { + format!("e30.{}.sig", URL_SAFE_NO_PAD.encode(claims)) +} + +pub fn query(url: &str) -> HashMap { + let (_, query) = url.split_once('?').unwrap(); + form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect() +} + +/// The browser following the redirect; returns the status line. +pub fn browse(redirect: &str, path: &str) -> String { + let address = redirect.trim_start_matches("http://"); + let mut stream = std::net::TcpStream::connect(address).unwrap(); + write!(stream, "GET {path} HTTP/1.1\r\nHost: {address}\r\n\r\n").unwrap(); + let mut reply = String::new(); + stream.read_to_string(&mut reply).unwrap(); + reply.lines().next().unwrap().to_owned() +} + +/// Completes a sign-in whose endpoint answers with body. +pub async fn signed_in(body: String) -> ely_mail_engine::Result { + let (token, _) = endpoint(200, body); + let sign_in = SignIn::start(provider(token, None), "").await.unwrap(); + let asked = query(sign_in.url()); + let (redirect, state) = (asked["redirect_uri"].clone(), asked["state"].clone()); + let browser = thread::spawn(move || browse(&redirect, &format!("/?code=c&state={state}"))); + let result = sign_in.finish().await; + browser.join().unwrap(); + result +} diff --git a/crates/ely-mail-engine/tests/private_oauth_logs.rs b/crates/ely-mail-engine/tests/private_oauth_logs.rs new file mode 100644 index 0000000..684924f --- /dev/null +++ b/crates/ely-mail-engine/tests/private_oauth_logs.rs @@ -0,0 +1,45 @@ +//! The real logger at trace, around token requests. +mod oauth_fake; + +use ely_mail_engine::oauth::refresh; +use ely_platform::AppId; +use oauth_fake::{endpoint, provider, signed_in}; + +const SECRET: &str = "PRIVATE_SECRET"; +const CODE_TOKEN: &str = "PRIVATE_FIRST"; +const ROTATED: &str = "PRIVATE_ROTATED"; + +#[tokio::test] +async fn trace_logs_keep_no_token_or_client_secret() { + let dir = tempfile::tempdir().unwrap(); + // The only test here; no thread reads env. + unsafe { std::env::set_var("ELY_LOG", "trace") }; + ely_platform::logging::init_in(AppId::Mail, dir.path()).unwrap(); + + let body = format!( + r#"{{"access_token":"{CODE_TOKEN}","expires_in":60,"refresh_token":"{CODE_TOKEN}"}}"# + ); + signed_in(body).await.unwrap(); + let body = + format!(r#"{{"access_token":"{ROTATED}","expires_in":60,"refresh_token":"{ROTATED}"}}"#); + let (token, _) = endpoint(200, body); + refresh(&provider(token, Some(SECRET)), CODE_TOKEN) + .await + .unwrap(); + + log::logger().flush(); + let logged = std::fs::read_to_string(dir.path().join("ely.log")).unwrap(); + assert!(logged.contains("oauth: test token granted"), "{logged}"); + for secret in [SECRET, CODE_TOKEN, ROTATED, "PRIVATE"] { + assert!( + !logged.contains(secret), + "{secret} reached the log: +{logged}" + ); + } + assert!( + !logged.contains(" ureq"), + "HTTP library logged: +{logged}" + ); +} diff --git a/crates/ely-mail-engine/tests/vault.rs b/crates/ely-mail-engine/tests/vault.rs new file mode 100644 index 0000000..da1b441 --- /dev/null +++ b/crates/ely-mail-engine/tests/vault.rs @@ -0,0 +1,86 @@ +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use ely_mail_engine::{Backend, Error, Kept, Vault}; + +#[derive(Clone, Default)] +struct Fake { + saved: Arc>>, + refuse: Option<&'static str>, +} + +impl Backend for Fake { + fn get(&self, name: &str) -> Result, String> { + match self.refuse { + Some(reason) => Err(reason.into()), + None => Ok(self.saved.lock().unwrap().get(name).cloned()), + } + } + + fn set(&self, name: &str, secret: &str) -> Result<(), String> { + match self.refuse { + Some(reason) => Err(reason.into()), + None => { + self.saved + .lock() + .unwrap() + .insert(name.into(), secret.into()); + Ok(()) + } + } + } + + fn delete(&self, name: &str) -> Result<(), String> { + match self.refuse { + Some(reason) => Err(reason.into()), + None => { + self.saved.lock().unwrap().remove(name); + Ok(()) + } + } + } +} + +#[test] +fn secrets_go_to_the_system_store() { + let fake = Fake::default(); + let vault = Vault::new(Box::new(fake.clone())); + assert_eq!(vault.get("a").unwrap(), None); + assert_eq!(vault.put("a", "rt-1"), Kept::System); + assert_eq!(fake.saved.lock().unwrap()["a"], "rt-1"); + assert_eq!(vault.get("a").unwrap().as_deref(), Some("rt-1")); + vault.remove("a").unwrap(); + assert!(fake.saved.lock().unwrap().is_empty()); +} + +#[test] +fn a_refused_store_keeps_the_secret_for_this_session() { + let vault = Vault::new(Box::new(Fake { + refuse: Some("locked"), + ..Fake::default() + })); + assert_eq!(vault.put("a", "pw"), Kept::Session("locked".into())); + assert_eq!(vault.get("a").unwrap().as_deref(), Some("pw")); + let err = vault.get("b").err().unwrap(); + assert!(matches!(err, Error::Vault(text) if text.contains("locked"))); + let err = vault.remove("a").err().unwrap(); + assert!(matches!(err, Error::Vault(_))); + assert!( + vault.get("a").is_err(), + "the session copy is gone after remove" + ); +} + +#[test] +fn a_new_secret_replaces_the_session_copy() { + let fake = Fake { + refuse: Some("locked"), + ..Fake::default() + }; + let vault = Vault::new(Box::new(fake)); + vault.put("a", "old"); + assert_eq!(vault.put("a", "new"), Kept::Session("locked".into())); + assert_eq!(vault.get("a").unwrap().as_deref(), Some("new")); +} diff --git a/crates/ely-platform/src/logging.rs b/crates/ely-platform/src/logging.rs index c877fc5..65565c3 100644 --- a/crates/ely-platform/src/logging.rs +++ b/crates/ely-platform/src/logging.rs @@ -7,7 +7,8 @@ use std::{ }; use anyhow::{Context, Result}; -use env_logger::{Builder, Env, Target}; +use env_logger::{Builder, Env, Logger, Target}; +use log::{Level, Log, Metadata, Record}; use crate::{AppId, data_dir}; @@ -28,6 +29,31 @@ impl Write for Tee { } } +/// The HTTP library logs raw bytes, tokens too. +struct Private(Logger); + +fn wire(meta: &Metadata) -> bool { + let target = meta.target(); + let http = target == "ureq" || target.starts_with("ureq::") || target.starts_with("ureq_proto"); + http && meta.level() > Level::Info +} + +impl Log for Private { + fn enabled(&self, meta: &Metadata) -> bool { + !wire(meta) && self.0.enabled(meta) + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + self.0.log(record); + } + } + + fn flush(&self) { + self.0.flush(); + } +} + /// The file logging writes to; none until `init` succeeds. pub fn path() -> Option<&'static Path> { LOG_PATH.get().map(PathBuf::as_path) @@ -66,10 +92,12 @@ pub fn init_in(app: AppId, dir: &Path) -> Result<()> { .append(true) .open(&path) .with_context(|| format!("cannot open {}", path.display()))?; - Builder::from_env(Env::new().filter_or("ELY_LOG", "info")) + let logger = Builder::from_env(Env::new().filter_or("ELY_LOG", "info")) .target(Target::Pipe(Box::new(Tee(file)))) - .try_init() - .context("a logger is already set")?; + .build(); + let level = logger.filter(); + log::set_boxed_logger(Box::new(Private(logger))).context("a logger is already set")?; + log::set_max_level(level); LOG_PATH.set(path).expect("the logger was set once"); let previous = panic::take_hook(); diff --git a/tasks/TASKS.md b/tasks/TASKS.md index c2d5848..ead873f 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -137,7 +137,7 @@ - [x] C12.1 `crates/ely-mail-engine` 与连通(MAIL-01 前半):IMAP、POP3、SMTP 经 TLS(rustls,系统根证书)或 STARTTLS 连接,密码与 XOAUTH2 认证,失败带服务器原话;替身服务器测三协议的登录、拒绝、列邮箱、取信、发信。已做:`Imap`、`Pop`、`Smtp` 三个会话,`Server`(主机、端口、`Security::Tls` 或 `StartTls`,无明文)、`Login`(密码或 OAuth 令牌);系统信任库经 rustls-platform-verifier,测试换入只信测试证书的连接器;每步 60 秒超时;`Error` 分连接、TLS、凭据被拒(服务器原话)、命令被拒、读不懂、超时、发不出(信封地址或登录带换行等)。IMAP 由引擎自写,以 imap-proto 解析(`wire.rs` 收一个个完整回复,含字面量),换下 async-imap(它的 LIST、FETCH 不看结尾的 NO 与 BAD,拒绝成了空结果与“无此 UID”;登录与命令中途的 `* BYE` 被丢掉,只剩断线;trace 日志写出密码与邮件):每条命令看结尾的状态,NO 与 BAD 带原话报出(登录为凭据被拒,其余为命令被拒);问候或任何时候收到 `* BYE`,此后断线即带其原话;非 ASCII 或带换行的参数以字面量发出,等服务器的 `+`;STARTTLS 之后若已有明文字节即拒绝;LIST 里带引号的邮箱名去掉转义(imap-proto 照原样留着),字面量照原样,列出的名字可直接打开;日志只记标签与命令名。POP3 由引擎自写(RFC 1939:问候、STLS、USER/PASS、AUTH XOAUTH2、UIDL、RETR、QUIT),换下 async-pop(它留着点填充、丢末尾 CRLF、吞掉登录后 CAPA 的连接错误、不答 XOAUTH2 的错误挑战、在 trace 日志里写出密码与邮件);STLS 之后若已有明文字节即拒绝;用户名与密码带换行即拒绝。SMTP 的 XOAUTH2 与事务由引擎自走(mail-send 遇错误挑战会重发令牌;它的 DATA 不给首行的点加点,且自加末尾 CRLF):信封地址照 RFC 5321 的 Mailbox 核对(点串或带引号的本地部分,域名或地址字面量;地址或信头有 UTF-8 时,服务器报 SMTPUTF8 才发出并带此参数,否则拒绝(RFC 6531)),不合即拒绝、不发任何命令;DATA 每行开头的点(含首行)加一个点,裸 CR 或 LF 即拒绝。服务器拒绝事务中的一步后发 RSET,会话可再发信。三协议的 XOAUTH2 被拒都以空行答错误挑战、留下服务器最后的原话。34 个测试(`cargo test -p ely-mail-engine`,含以真日志器在 trace 下跑三协议、日志里无密码、令牌与正文),真服务器未连。审查三轮:第 3 轮的两处(带引号的邮箱名、信头的 UTF-8)已修并以测试核对,未再审查(每条至多三轮) - [x] C12.2 MIME(MAIL-04、05 的基础):mail-parser 读出头、纯文本、HTML、cid 内嵌图片、附件与原始头;mail-builder 写出多部分邮件、附件、Message-ID、In-Reply-To 与 References;样本 EML 往返测试。已做:`read` 得 `Mail`(主题、发件人、收件人、抄送(分组展开)、日期、Message-ID、In-Reply-To、References、纯文本、HTML、`inline`、`attachments`、原始头块);按 MIME 树与所在容器读(不用 mail-parser 摊平的列表):正文只取发件人写的部分(mail-parser 会把 HTML 转成文字或反之顶替缺的一种,不取);alternative 里纯文本与 HTML 各取最后一个(RFC 2046 §5.1.4),其余版本不并入,没有正文的版本(如日历)为附件;related 的根照 `start`,无 `start` 取首个(RFC 2387),其余带 Content-ID 的即 cid 目标,不论处置;mixed 等的部分依次并成正文;处置为 attachment 的实体整个是附件,多部分实体连同其头(RFC 2183 §2.9),子孙不进正文;附件字节只解传输编码,文字附件不按字符集转换;不在 related 里的部分,带 Content-ID 且不是 attachment 的为 cid 目标;无 Content-Type 照 RFC 2045 为 text/plain,digest 的子部分为 message/rfc822;解码有问题的部分与无地址的收件人记警告。`build` 由 `Draft` 写出(密送不进信头,留在 SMTP 信封):Message-ID 为 16 字节随机数加发件人的域(mail-builder 默认写本机主机名,不用),交回供发件记录;地址照 SMTP 的 Mailbox 核对,主题、姓名、Message-ID、文件名、类型带换行即拒绝;附件须有文件名,Content-ID 照写(带换行即拒绝);正文与附件自己组成 mixed 与 alternative。`tests/fixtures/mail/sample.eml` 读全;由它回复写出再读回,各字段与附件(名、类型、Content-ID、含 0 至 255 每个字节)相同;第 1 轮审查后另 7 个结构测试(`tests/mime_tree.rs`:Latin-1 文字附件、`start` 在图后、related 里标为附件的图、附件化的多部分、同型 alternative、digest),去掉各自的修复即失败;第 2 轮审查后:嵌套深于 64 层即报错(先以迭代算出深度,原递归在 2048 层时栈溢出、进程退出);mail-parser 开 `full_encoding`(原 GB2312 等多字节字符集读成替换字符);`Part::params` 带 Content-Type 的参数(不含 name),写出时照写,转发的 Latin-1 附件收件方读出 Café;多部分与 message 附件照原样写出(不编码,7bit 或 8bit,多部分带原分界,RFC 2045 §6.4),SMTP 遇 8 位正文即带 `BODY=8BITMIME`,服务器不支持即在发出前拒绝;related 的根不看处置,其余每个资源整个是一个实体(多部分资源带其 Content-ID 与文件名);带 Content-ID 的正文文字部分另为 cid 目标(related 的根除外);未知处置照附件(RFC 2183 §2.8)。另 9 个测试(`tests/mime_edges.rs`、`tests/mime_forward.rs`,SMTP 一个),去掉各自的修复即失败;第 3 轮审查(最后一轮,修完不再审查)后:深度也数内嵌的邮件(mail-parser 以迭代读出层层相套的 message/rfc822,丢弃时却递归,8192 层即栈溢出),超深的解析结果以迭代逐层拆开再丢;带 Content-ID 的多部分实体(related 的根除外)连同其头另为 cid 目标,子孙照常读;SMTP 按每一层的头块判定是否需 SMTPUTF8(转发的 message/global 里的中文主题),只深入未经传输编码的内嵌邮件;每次发送都先解析邮件,不是邮件即拒绝。另 5 个测试(`tests/mime_deep.rs` 四个、`tests/smtp_utf8.rs` 一个;SMTP 的编码测试与辅助函数移入 `tests/smtp_utf8.rs` 与 `tests/fake/smtp.rs`),去掉各自的修复即失败或栈溢出;原往返测试按子串查 bcc,随机的 Message-ID 约四十次一次含 bcc 而误报,改为查 `Bcc:` 头行;引擎共 61 个测试 - [x] C12.3 本地存储(§12.3、§18.1、§18.3):SQLite 索引(账号、邮箱、消息身份为账号加邮箱加 UIDVALIDITY 加 UID、标记、头字段)、原文按内容存进内容目录、FTS5 全文索引(可重建)、结构版本与升级前备份、升级失败只读;各类大小可查。已做:`Store`(rusqlite,自带 SQLite 3.53.2,FTS5 与 dbstat)在给定文件夹开 `mail.db`(WAL,外键开);表 accounts、mailboxes(账号加名字唯一)、messages(身份为邮箱加 UIDVALIDITY 加 UID,POP3 为邮箱加 UIDL,两者恰居其一,各有部分唯一索引;头字段:Message-ID、In-Reply-To、References、主题、发件人、收件人、抄送(JSON)、日期;附件数在整封到达前为空)、flags(每标记一行);原文按 SHA-256 存在 `content/前两位/散列`(暂存后改名,同内容只存一份,读时核对散列,不符即报“已损坏”,最后一封引用它的邮件删去时删文件);同一身份再来头部只改标记,整封到达即补原文、附件数与正文索引;FTS5 表 `search`(主题、人、正文、附件名,无内容副本,trigram 分词,删邮件的触发器同删索引行),HTML 独有的正文以 html5ever 分词器取文字(略去 script 与 style,标签处断词);`rebuild_search` 由索引行与原文重建;`PRAGMA user_version` 记结构版本,升级前以 `VACUUM INTO` 备份到 `backups/`,各步在一个事务里,备份或升级失败即以只读重开、写入报 `ReadOnly` 并带原因,更新的版本写的库同样只读;`sizes` 给索引、全文索引(dbstat)与原文三类字节数。测试:结构版本 5 个(新库、升级先备份、失败回滚且只读、更新的版本只读、无备份不升级),存储 6 个(整封、先头后整封、UIDVALIDITY 与 POP 身份、共用原文、损坏原文、HTML 正文与重建),去掉各自的规则即失败;引擎共 72 个测试。第 1 轮审查后:重建先删去整张全文表再按库里记的定义重建(损坏的段删不掉);存原文时已有的文件先核对散列,坏了即以本次收到的字节改写;写库失败时删去本次新建且无人引用的原文;`sizes` 的索引按 `mail.db`、`-wal`、`-shm` 的实际字节减去全文页;HTML 取文字照树构建器给分词器的状态(script 为脚本数据,style、iframe、noembed、noframes 为原始文本,title 与 textarea 为 RCDATA,xmp 与 plaintext 照显示,自闭合在 HTML 元素上无效;SVG 与 MathML 里自闭合有效、script 与 style 的文字略去);给不存在的邮件设标记即报错。`tests/store_repair.rs` 6 个,去掉各自的修复即失败;引擎共 78 个测试。第 2 轮审查后:HTML 取文字改走 html5ever 的整个树构建器(`store/html.rs`,自建的节点表作 `TreeSink`,脚本关闭),命名空间、整合点与插入模式全由它定,不再自仿分词器状态;不画的元素照 HTML 渲染规则(script、style、title、head、iframe、template 等)略去;树构建器每个开始标签都扫一遍开放元素栈,嵌套越深越慢(实测 release:十万层 div 28 秒),故按 4 KB 分块喂入,元素深过 512 层(同 Blink 的上限)即停,已读的文字照常入索引,警告写明哪封邮件;暂存原文写入或改名失败时删去暂存文件,删不掉即连同原因报错。`tests/store_repair.rs` 共 8 个;去掉清理、不画元素、MathML 整合点、深度上限即各自失败。第 3 轮审查后(最后一轮,修完不再审查):删除与替换邮件时,原文文件在同一事务提交之前删去,删不掉即整步回滚,邮件与原文都留着、可重试(原先先提交再删文件,失败后失去引用,文件永久残留);提交在删文件之后失败时,下次清理遇文件已不在只记警告;两处超长注释缩短。`tests/store_release.rs`(Windows,以只读共享占住文件)2 个,删去与替换各一,改回先提交即失败;引擎共 82 个测试 -- [ ] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试 +- [x] C12.4 凭据与 OAuth(MAIL-02,§18.2):密码与刷新令牌只进系统凭据库(不可用即只留到本次退出并写明);Gmail 与 Microsoft 的授权码加 PKCE、本机回环、换令牌与刷新;客户端 ID 编进构建,缺 ID 即写明不可用;刷新被拒转为“需重新登录”;替身授权端点测试。已做:`Vault`(keyring:钥匙串、Windows 凭据、Secret Service;拒绝时只在内存,`Kept::Session` 带原因);`oauth::SignIn` 在 127.0.0.1 随机端口等回跳(Google 写 127.0.0.1,Microsoft 写 localhost),state 与 S256 PKCE 各 32 随机字节,回跳的 state 不符或带 error 即报错,其余请求答 404;换令牌与刷新经 ureq(后台线程,30 秒),刷新答 invalid_grant 或 interaction_required 即 `SignInAgain`,其余为 `SignIn`;邮箱取 ID 令牌的 email,Microsoft 无则 preferred_username;客户端 ID 由 build.rs 读环境变量或项目根 `oauth.env`(坏行、未知键即构建失败),缺即 `Unavailable`;`tests/oauth.rs` 以替身令牌端点与替身浏览器核对,`tests/vault.rs` 以替身凭据库核对。审查三轮:第 1 轮四处(Google 授权带 `access_type=offline` 与 `prompt=consent`、换码无刷新令牌即报错;Microsoft 另要 `profile`;`oauth.env` 读不出即构建失败、不存在为未配置;超长注释)、第 2 轮一处(ureq 在 trace 级写出令牌与客户端密钥,`ely_platform::logging` 不记 `ureq`、`ureq_proto` 低于 info 的日志;`tests/private_oauth_logs.rs` 以真日志器在 trace 下核对,改回即失败)已修,第 3 轮通过 - [ ] C12.5 账号(MAIL-01、02):ElyMail 主窗口与添加账号对话框:Gmail、Microsoft、iCloud(应用专用密码向导)、其他 IMAP/SMTP、POP3/SMTP;服务器、端口、TLS 方式、认证方式;连通性检查逐项报结果;多账号;删除账号先写明删去的本地缓存、草稿与凭据,不动服务端 - [ ] C12.6 IMAP 同步(MAIL-14,§12.3,QA-18):文件夹列表与特殊用途;邮件头全部、正文只取 90 天内;UIDVALIDITY 变化即重建该邮箱;服务端删除与标记变化随之;后台任务执行,界面显示最后同步时间、下载范围、未缓存的数量与失败原因 - [ ] C12.7 POP3 收取(MAIL-01,§12.3):按 UIDL 去重下载到本地收件箱,“保留服务端副本”默认开,不做伪文件夹同步 diff --git a/tasks/reviews/C12.4-r1.md b/tasks/reviews/C12.4-r1.md new file mode 100644 index 0000000..5ff7f1e --- /dev/null +++ b/tasks/reviews/C12.4-r1.md @@ -0,0 +1,9 @@ +FAIL + +- crates/ely-mail-engine/src/oauth.rs:99 — [P1] Google 授权 URL 没有 `access_type=offline`。[Google 文档](https://developers.google.com/identity/openid-connect/openid-connect#refresh-tokens)规定,请求离线访问才会在换码时取得刷新令牌。首次添加 Gmail 时,即使用户授权成功,也可能只得到短期 access token;到期或重启后无法完成 C12.4 要求的刷新。隔离探针调用实际 `google()` 和 `SignIn::start`,断言该参数时得到 `None`(`.work/C12.4-r1/linux-probes.log`);现有测试的自定义 provider 掩盖了这个缺口。修复方向:Google 授权请求加入离线访问参数,并测试内置 provider 的授权参数与刷新令牌签发条件。 + +- crates/ely-mail-engine/src/oauth.rs:60 — [P2] Microsoft scope 缺少 `profile`,但邮箱读取依赖 `preferred_username` 作为 `email` 缺失时的回退。[Microsoft 文档](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference)明确该 claim 需要 `profile` scope。因此,未提供 `email` claim 的账号完成授权后,也拿不到承诺的回退值,`Tokens.email` 仍为空。实际 `microsoft()` 的参数探针确认没有申请此 scope(`.work/C12.4-r1/linux-probes.log`)。修复方向:申请该 claim 所需的 scope,补测 Microsoft 无 email、使用 preferred_username 的路径。 + +- crates/ely-mail-engine/build.rs:16 — [P2] `read_to_string` 的所有错误都直接返回,文件不存在与文件损坏、无法读取被混为一谈。复现:隔离编译原始 build.rs,让其读取含无效 UTF-8 的 `oauth.env`,退出码仍为 0,且没有配置错误提示(`.work/C12.4-r1/config-invalid-utf8.log`)。正常没有环境变量的本机构建会悄悄忽略已有配置,把登录变为 `Unavailable`,违反错误必须暴露与坏配置构建失败的要求。修复方向:只允许 NotFound 表示未配置,其余读取错误使构建失败并说明原因,补测损坏与不可读文件。 + +- crates/ely-mail-engine/src/oauth/loopback.rs:14 — [P3] 新增注释 `Serves the redirect until it carries a code or an error.` 共 11 词,超过 agents.md 的十词限制;运行项目 `tests/scripts/comments.py` 可复现该行失败。修复方向:缩短到十词以内。 diff --git a/tasks/reviews/C12.4-r2.md b/tasks/reviews/C12.4-r2.md new file mode 100644 index 0000000..15fcd8c --- /dev/null +++ b/tasks/reviews/C12.4-r2.md @@ -0,0 +1,3 @@ +FAIL + +- crates/ely-mail-engine/src/oauth/token.rs:81 — [P1] 读取令牌回复时,ureq-proto 会在 trace 级别逐字节记录整个 JSON,访问令牌与刷新令牌因此进入普通诊断日志,违反 §18.2 与 agents.md 的日志规则。复现:以 `ELY_LOG=trace` 初始化真实的 `ely_platform::logging`,调用实际 `oauth::refresh`,替身端点返回 `ACCESSMARKER` 与 `ROTATEDMARKER`;两者均出现在 `ely.log`(`.work/C12.4-r2/oauth-logger/ely.log`,核对输出在 `actual-logger.log`)。换码同样经过此读取路径;现有日志隐私测试只跑邮件协议,没有覆盖 OAuth,所以 Windows 97 个、Linux 94 个测试全部通过仍未发现泄漏。修复方向:阻止 HTTP 库在任何日志级别记录令牌回复的敏感载荷,并以真实日志器补测换码与刷新的令牌不进入日志。 diff --git a/tasks/reviews/C12.4-r3.md b/tasks/reviews/C12.4-r3.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/C12.4-r3.md @@ -0,0 +1 @@ +PASS