From a219a21db88b730550048dc8e753e3f0503c7acd Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 9 Nov 2026 02:33:08 +0800 Subject: [PATCH] =?UTF-8?q?fix(m190):=20r2=20=E5=A4=8D=E5=AE=A1=E2=80=94?= =?UTF-8?q?=E2=80=94dalek=20strict=20=E5=8F=A3=E5=BE=84=E7=85=A7=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E5=86=99=E6=AD=A3=E3=80=81=E8=A1=A5=E5=B0=8F=E9=98=B6?= =?UTF-8?q?=20R=20=E7=94=A8=E4=BE=8B=E3=80=81=E9=94=81=E9=87=8C=20toml=5Fd?= =?UTF-8?q?atetime=20=E9=80=80=E5=9B=9E=201.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条 P2 都是「文档/注释与事实不符 + 一条上报事实错了」,代码行为没变。 P2-1 dalek 的 strict vs 非 strict 口径改成正测试出来的样子: * `VerifyingKey::from_bytes` **不**校验编码规范性(`y = p` 的非规范写法返回 Ok,`is_weak() == true`), 先前写的「要求规范编码」是错的; * 非 strict 的 `verify` **也**查 `S` 规范性 —— 先前把它写成「不做可锻检查」是半错; * 于是这批拒绝类用例里,strict 与非 strict 唯一的分歧是**小阶点**:承重的是 `small_order_public_key_is_rejected`,其余几条(malleable / non_canonical_r / empty)换掉 strict 后 **仍然绿**,文件头的表与用例注释都改成这个口径,别让人误以为它们也是守卫。 * 新补 `small_order_r_is_rejected_only_by_strict`:R = 单位元 + S = k·a mod L 的伪造,**两边都断言** —— dalek 的非 strict `verify` 接受它、我们的 `verify` 拒绝。这是第二条「只由 strict 拒」的判据。 * `non_canonical_r_encoding_is_rejected` 里那条「非规范公钥编码」原来对两种实现都绿(等于没测), 现在断言 dalek 的**真实**行为(`from_bytes(...).is_ok()` + `is_weak()`),dalek 升级改口径会红。 P2-2 `packaging/sign_manifest.py` 里指向已删除 `ed25519/{field,group}.rs` 的旧注释删掉(那段还写着 「same formulas」,而两套实现现在是**互相独立**才构成交叉验证)。 P2-3 `Cargo.lock`:`toml_datetime` 从 1.1.3 **退回 1.1.2**(`cargo update -p toml_datetime --precise 1.1.2` 实测可降、`cargo metadata --locked` 自洽)。先前报告里「被 `toml` 拒绝所以无法避免」的理由 **不成立**,已在报告里更正。现在相对 `44a0c87` 的锁变更是**纯新增**:16 个包、零既有版本变动。 变异证明(本轮按要求重跑):把 `verify_strict` 换成 `verify`(并补 `Verifier` trait 使其可编译) ⇒ `test result: FAILED. 110 passed; 2 failed`,红的两条正是 `small_order_public_key_is_rejected` 与 `small_order_r_is_rejected_only_by_strict`;其余拒绝类用例全绿 (与修正后的口径一致)。恢复后 `112 passed; 0 failed`。 --- Cargo.lock | 4 +- crates/core/src/update/ed25519.rs | 28 ++++--- crates/core/src/update/tests/ed25519_cases.rs | 82 ++++++++++++++----- packaging/docs/signing.md | 6 +- packaging/sign_manifest.py | 4 - 5 files changed, 83 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a16ef25..694148f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4881,9 +4881,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.3+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] diff --git a/crates/core/src/update/ed25519.rs b/crates/core/src/update/ed25519.rs index 7758146..9863ce0 100644 --- a/crates/core/src/update/ed25519.rs +++ b/crates/core/src/update/ed25519.rs @@ -4,21 +4,23 @@ //! //! M190 第一版为了「scope 里不含 `crates/core/Cargo.toml`」把曲线算术写在了仓里 //! (5x51 素域 + 扩展坐标 + 平方-乘)。r1 复审的裁定是**不允许**:这是**信任锚**, -//! 非规范编码、小阶点、批量验证差异这类微妙错误的后果是「验签通过但本不该通过」, -//! 自写的向量覆盖再全也证明不了「严格性判据选对了」。当时的时机也最好:正式信任表 -//! (`trust::PRODUCTION`)还是空表,换实现**零兼容负担**。 +//! 非规范编码、小阶点这类微妙错误的后果是「验签通过但本不该通过」,自写的向量覆盖再全也证明不了 +//! 「严格性判据选对了」。当时的时机也最好:正式信任表(`trust::PRODUCTION`)还是空表,换实现 +//! **零兼容负担**。于是这里只剩一层薄封装。 //! -//! 于是这里只剩一层薄封装,判据全部交给 dalek: +//! # 判据分工(**实测口径**,`ed25519-dalek 2.2.0`;别照抄「常识」) //! -//! * `VerifyingKey::from_bytes` —— 公钥必须是**规范编码的合法点**(非规范 `y` / 不在曲线上都拒); -//! * `VerifyingKey::verify_strict` —— 比 RFC 8032 的最低要求更严:拒绝**小阶**公钥与小阶 `R` -//! (阻止「谁都能构造自洽四元组」的伪造族),并拒绝**非规范标量 `S`**(`S >= L` 的可锻变体); -//! * `Signature::from_bytes` —— 只搬运 64 字节;真正的判据在 `verify_strict` 里,**不能**换成 -//! `verify`(后者不做小阶/可锻检查)。 +//! | 步骤 | 它实际做什么 | 它**不**做什么 | +//! |---|---|---| +//! | `VerifyingKey::from_bytes` | 解压成曲线上的合法点;解不出来才 `Err` | **不**校验编码规范性:`y >= p` 这种第二种写法照样 `Ok`(此时 `is_weak() == true`)。M190 r2 复审实测并纠正了先前「要求规范编码」的说法 | +//! | `VerifyingKey::verify`(**非 strict**,本仓不用) | 验方程 `[S]B = R + [k]A`,并检查 `S` 是**规范标量**(`S >= L` 会拒) | **不**检查小阶点:小阶公钥 / 小阶 `R` 下「自洽的伪造四元组」能验过 | +//! | `VerifyingKey::verify_strict`(**本仓用的这个**) | 上面全部 + 拒**小阶公钥**与**小阶 `R`** | —— | //! -//! 这批行为由 `tests/ed25519_cases.rs` 逐条钉住(RFC 8032 四条向量 + 六条「该拒就拒」): -//! 那些用例原来用来测自写实现,现在改成**钉 dalek 的 strict 语义**——将来谁把 -//! `verify_strict` 换成 `verify`(或换回自写实现),它们会当场红。 +//! 换成大白话:在这批拒绝类用例里,strict 与**非** strict 唯一的分歧是**小阶点**(不是可锻性—— +//! `verify` 自己就查 `S` 规范性)。所以守卫里真正承重的是 +//! `tests/ed25519_cases.rs::small_order_public_key_is_rejected` 与 +//! `small_order_r_is_rejected_only_by_strict` 这两条;`malleable_s_plus_l_is_rejected` 等其余几条在 +//! 非 strict 下**照样绿**,它们钉的是「判据没被删成恒真」,不要误当成 strict 的守卫。 //! //! # 签名侧不在二进制里 //! @@ -42,7 +44,7 @@ pub(crate) fn verify( signature: &[u8; SIGNATURE_LEN], ) -> bool { let Ok(key) = VerifyingKey::from_bytes(public_key) else { - // 公钥都不是规范编码的合法点:直接拒(`from_bytes` 已经做了曲线方程与规范编码检查)。 + // 不是曲线上的合法点:直接拒。注意 `from_bytes` **不**管编码规范性(见模块文档的表)。 return false; }; key.verify_strict(message, &Signature::from_bytes(signature)) diff --git a/crates/core/src/update/tests/ed25519_cases.rs b/crates/core/src/update/tests/ed25519_cases.rs index 4b5008c..f76d956 100644 --- a/crates/core/src/update/tests/ed25519_cases.rs +++ b/crates/core/src/update/tests/ed25519_cases.rs @@ -3,17 +3,21 @@ //! M190 r1 复审把自写曲线算术整体换成了官方实现(理由见 `crate::update::ed25519` 的模块文档)。 //! 这批用例的定位因此从「测自己的实现」变成「**钉 dalek 的严格语义**」: //! -//! | 用例 | 它挡住什么 | -//! |---|---| -//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量是 RFC 附录的,不是自签自验) | -//! | `malleable_s_plus_l_is_rejected` | 把 `verify_strict` 换成 `verify`(后者不检查 `S` 规范性) | -//! | `small_order_public_key_is_rejected` | 同上:小阶公钥/小阶 `R` 在 `verify` 下能「自洽通过」 | -//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | -//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | -//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧是同一套语义 | +//! | 用例 | 它挡住什么 | 把 `verify_strict` 换成 `verify` 后会怎样(r2 实测) | +//! |---|---|---| +//! | `small_order_public_key_is_rejected` | 小阶公钥下「自洽的伪造」(谁都能凑) | **红** —— 这是**唯一**承重的守卫 | +//! | `small_order_r_is_rejected_only_by_strict` | 小阶 `R`(R = 单位元)的同一族伪造 | **红**(其余几条不会) | +//! | `rfc8032_vectors_verify` | 实现换成任何一个错的版本(向量来自 RFC 附录) | 仍绿(`verify` 也验方程) | +//! | `malleable_s_plus_l_is_rejected` | `S >= L` 的可锻变体 | **仍绿**:`verify` 自己就查 `S` 规范性,别把它当 strict 的守卫 | +//! | `non_canonical_r_encoding_is_rejected` | 非规范编码的点(`y >= p` 的第二种写法) | 仍绿(dalek 靠自己的下游判据拒,见该用例注释) | +//! | `empty_signature_is_rejected` / `message_bit_flip` / `signature_bit_flip` | 判据本身失效(恒真) | 仍绿 | +//! | `signing_key_round_trips` | 测试夹具(dalek `SigningKey`)与验签侧同一套语义 | 仍绿 | //! -//! 每条都实测过 dalek 2.2 的**实际**行为;与「RFC 最低要求」不同之处在用例注释里写明(见 -//! `non_canonical_r_encoding_is_rejected`:dalek 拒它的理由是**小阶**,不是「非规范编码」单独一条)。 +//! 口径来源:M190 r2 复审在 `ed25519-dalek 2.2.0` 上逐条实测(并把先前的四处错误说法打回)。 +//! **结论**:strict 与**非** strict 在这批输入上唯一的分歧是**小阶点**——所以想确认「strict 还开着」, +//! 看前两条;其余几条只在「判据被删成恒真」时才会红。 + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use crate::update::ed25519::{verify, PUBLIC_KEY_LEN, SIGNATURE_LEN}; @@ -130,8 +134,9 @@ fn signature_bit_flip_is_rejected() { #[test] fn malleable_s_plus_l_is_rejected() { - // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它, - // `verify_strict` 必须拒 —— 这条用例就是「别把 verify_strict 换成 verify」的判据。 + // 可锻性:把 S 换成 S + L,曲线方程照样成立(S 与 S+L 模 L 同余)。RFC 8032 的最低要求允许它。 + // ⚠ 这条**不是** strict 的守卫:dalek 的非 strict `verify` 自己就检查 `S` 规范性,换掉 strict 它 + // 仍绿(r2 实测)。它挡的是「判据被删成恒真」。strict 的守卫见 `small_order_*` 两条。 let (public, message, signature) = rfc_vectors()[0]; let mut signature = array64(&unhex(signature)); let mut carry = 0_u16; @@ -173,11 +178,13 @@ fn small_order_public_key_is_rejected() { #[test] fn non_canonical_r_encoding_is_rejected() { - // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同。 - // **dalek 的实际行为(M190 r1 实测)**:`CompressedEdwardsY::decompress` 不单独校验「规范编码」, - // 这个点解压出来是阶为 4 的点 ⇒ 被 `verify_strict` 的**小阶**判据拒。结论(拒)与自写实现的 - // 「非规范编码」理由不同,但对这份输入是同一条出路;下面把「公钥非规范编码」也一并钉住, - // 因为那条走的是 `VerifyingKey::from_bytes` 的编码校验。 + // R = p(= 2^255-19)的字节:它与 y = 0 同值但编码不同(非规范)。 + // dalek 的实际行为(r2 实测,与先前的说法不同): + // * `CompressedEdwardsY::decompress` **不**校验编码规范性 —— 这串字节解出来是**阶为 4**的点; + // * 于是它被 `verify_strict` 的**小阶**判据拒,而不是被「编码非规范」拒; + // * 公钥侧同理:`VerifyingKey::from_bytes` 对非规范编码返回 **Ok**(`is_weak() == true`), + // 所以要拒它只能靠下游判据 —— 下面把 dalek 的这个真实行为**断言下来**,将来 dalek 升级 + // 改了口径会红(否则这条断言对任何实现都绿,等于没测)。 let (public, message, _) = rfc_vectors()[0]; let p_bytes: [u8; 32] = [ 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, @@ -188,16 +195,51 @@ fn non_canonical_r_encoding_is_rejected() { signature[..32].copy_from_slice(&p_bytes); assert!( !verify(&array32(&unhex(public)), &unhex(message), &signature), - "非规范 R 编码必须被拒" + "非规范 R 编码必须被拒(dalek 的理由是小阶点)" ); - // 公钥换成同一串非规范编码:`VerifyingKey::from_bytes` 直接拒(连验签都不进)。 + + // dalek 的真实行为:非规范编码的公钥**能**通过 `from_bytes`,并被标成 weak。 + let weak = VerifyingKey::from_bytes(&p_bytes).expect("dalek 2.2 不校验编码规范性,应返回 Ok"); + assert!( + weak.is_weak(), + "这个非规范编码解出的是小阶点,dalek 应标记 is_weak" + ); + // 我们的 `verify` 对同一串公钥仍然拒(下游方程/小阶判据兜住)。 assert!( !verify( &p_bytes, &unhex(message), &array64(&unhex(rfc_vectors()[0].2)) ), - "非规范公钥编码必须被拒" + "非规范/小阶公钥不得验过" + ); +} + +#[test] +fn small_order_r_is_rejected_only_by_strict() { + // **第二条只由 strict 承重的判据**(r2 复审建议补的):小阶 `R`。 + // 构造:R = 单位元(阶 1),S = k·a mod L,其中 k = SHA-512(R‖A‖M) mod L、a 是测试钥的标量。 + // 这样方程 `[S]B = R + [k]A` 自洽 ⇒ **非 strict 的 `verify` 会通过**;`verify_strict` 因 + // 「R 是小阶点」拒。下面两边都断言,所以将来谁把 strict 换成非 strict,这条会红。 + let public = array32(&unhex( + "f3ea49a34ebda73d6500c9416f425e0358baf094b957baf3ea7daa6df63e876b", + )); + let message = b"manifest bytes"; + let forged = array64(&unhex(concat!( + "0100000000000000000000000000000000000000000000000000000000000000", + "698a94a4c82aa13c1a4da6248799849f2254ef333b5f5d69dcbd71b6320e5e0e" + ))); + + // 非 strict 侧:dalek 的 `verify` 会接受这条伪造(这就是 strict 存在的理由)。 + let key = VerifyingKey::from_bytes(&public).expect("合法公钥"); + assert!( + key.verify(message, &Signature::from_bytes(&forged)).is_ok(), + "非 strict 的 verify 应当接受这条伪造(构造前提;若 dalek 改了行为,这条先红)" + ); + // strict 侧:我们导出的 `verify` 必须拒。 + assert!( + !verify(&public, message, &forged), + "小阶 R 的伪造必须被 verify_strict 拒" ); } diff --git a/packaging/docs/signing.md b/packaging/docs/signing.md index 3386f2e..d7962fe 100644 --- a/packaging/docs/signing.md +++ b/packaging/docs/signing.md @@ -131,13 +131,15 @@ python3 packaging/sign_manifest.py --verify-pinned dist/manifest.json # ④ 用 | 哪一半 | 在哪 | 用什么 | |---|---|---| -| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(拒非规范编码 / 小阶点 / 非规范 `S`) | +| **验签**(进二进制) | `crates/core/src/update/ed25519.rs` | `ed25519-dalek::verify_strict`(在非 strict 的基础上额外拒**小阶公钥**与**小阶 `R`**;`S` 规范性是非 strict 的 `verify` 也查的) | | **签名**(只在发布侧) | `packaging/ed25519_ref.py` + `packaging/sign_manifest.py` | 纯 Python 参考实现,零第三方依赖(CI 不装 pip 包) | 两套实现互相独立,`--self-test` 用 RFC 8032 §7.1 的同一组向量把两边各自钉住。**第一版曾把验签写成 仓内自写**(5x51 素域 + 扩展坐标);r1 复审裁定不安全——信任锚的微妙错误后果是「验签通过但本不该通过」, 因此整段删除,改为官方实现。`crates/core/src/update/tests/ed25519_cases.rs` 现在钉的是 -**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`(不做小阶/可锻检查),那批用例当场红。 +**dalek 的 strict 语义**:谁把 `verify_strict` 换成 `verify`,`small_order_public_key_is_rejected` 与 +`small_order_r_is_rejected_only_by_strict` 会红(这是 r2 实测的结论——其余几条拒绝类用例在非 strict +下仍绿,别把它们当 strict 的守卫)。 ## 8. 本地自测(不需要私钥) diff --git a/packaging/sign_manifest.py b/packaging/sign_manifest.py index 6e65508..0984304 100644 --- a/packaging/sign_manifest.py +++ b/packaging/sign_manifest.py @@ -36,10 +36,6 @@ TRUST_RS = Path(__file__).resolve().parent.parent / "crates/core/src/update/trus PIN_BEGIN = ">>> PINNED-KEYS >>>" PIN_END = "<<< PINNED-KEYS <<<" KID_RE = re.compile(r'key_id:\s*"([^"]+)"\s*,\s*public_key_hex:\s*"([0-9a-fA-F]{64})"') -# Ed25519 (RFC 8032), reference-shaped implementation (same formulas as -# `crates/core/src/update/ed25519/*`). Validated against the RFC 8032 §7.1 vectors in -# `--self-test`; the Rust verifier is validated against the *same* vectors, so the two -# implementations cross-check each other. No third-party dependency: CI needs no pip install. # Ed25519 的参考实现搬到了 `packaging/ed25519_ref.py`(M190 r1:本文件贴着 400 行红线)。 # 目的与路径都写在那边的文件头:发布侧签名必须零第三方依赖,而仓里验签用的是 ed25519-dalek —— # 两套实现互相独立,`--self-test` 用 RFC 8032 的同一组向量把两边各自钉住。