Skip to main content

qualia_dik/
device.rs

1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! 设备身份:一台设备用来向其它设备证明「我是我」的长期密钥。
17//!
18//! # 它解决什么问题
19//!
20//! 下游应用没有中心服务器,也没有账号密码。所以「这台设备是可信的」
21//! 这件事不能靠任何第三方断言,只能靠**它自己持有的密钥**。设备身份就是那对密钥:
22//! 私钥在本机硬件里生成、永不导出;公钥可以在设备之间自由传播。
23//!
24//! 于是「认证」有了一个具体的、可验证的含义:**对方能对一段它事先没见过的数据
25//! 产出一个用某个公钥能验证通过的签名,且那个公钥的指纹与我们要找的设备一致。**
26//!
27//! # 为什么是 ECDSA P-256,而不是 Ed25519
28//!
29//! 这是一个**强约束,不是偏好**,因为私钥要放进平台硬件:
30//!
31//! - **Android**:KeyMint HAL 的 `Algorithm.aidl` 里只有 `RSA=1, EC=3, AES=32,
32//!   TRIPLE_DES=33, HMAC=128`——**没有 EdDSA**。`KeyProperties.java` 里也没有
33//!   Ed25519 常量。注意 `CURVE_25519` 确实存在,但它是 **XDH 密钥协商曲线,不是
34//!   签名算法**——这是二手资料里最常见的错误。
35//! - **iOS**:Apple 文档明确写着 Secure Enclave 只用 NIST P-256。
36//!
37//! 所以 P-256/ECDSA 是**两个移动平台上唯一能进硬件的签名算法**。选 Ed25519 就
38//! 等于接受「移动端身份密钥只能软件持有」——那必须是一个显式决定,不能是事故。
39//!
40//! # 本模块不做什么
41//!
42//! 不持有私钥,也不做签名。私钥住在硬件里,签名由 [`crate::keystore`] 发起。
43//! 本模块只负责**身份的公钥侧**:表示、指纹、以及**验签**——因为验证签名必须能被
44//! 任何一台设备执行,包括那些自己根本不持有任何硬件密钥的设备。
45//!
46//! # 本模块不做 I/O,也不读时钟
47//!
48//! 时间一律由调用方传入,与库内其它模块一致。
49
50use crate::{Error, Result};
51use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
52use sha2::{Digest, Sha256};
53
54/// SEC1 未压缩点编码的固定长度:`0x04 || X(32) || Y(32)`。
55///
56/// 为什么固定成未压缩形式而不是压缩形式(33 字节):未压缩形式是 Windows CNG
57/// (`BCRYPT_ECCPUBLICBLOB`)、Android Keystore 与 Secure Enclave 共同的自然输出,
58/// 直接取用不需要实现点压缩。省下的 32 字节在二维码里毫无意义。
59pub const PUBLIC_KEY_LEN: usize = 65;
60
61/// ECDSA P-256 原始签名的固定长度:`r(32) || s(32)`。
62///
63/// # 为什么线上格式用原始 `r||s` 而不是 DER
64///
65/// DER 编码是**变长**的:`r` 或 `s` 的最高位为 1 时前面要补一个 `0x00`,于是同一
66/// 个签名的 DER 长度在 70–72 字节之间浮动。这带来两个麻烦:
67///
68/// 1. 长度泄漏:观察者能从签名长度反推出 `r`/`s` 的最高字节,虽然对 ECDSA 的
69///    安全性没有已知影响,但这是一条没有必要的旁路。
70/// 2. **可塑性**:DER 有冗余表示,同一个数学签名可以有多种合法编码,于是「签名
71///    唯一」这件事需要额外论证。
72///
73/// 原始 `r||s` 是定长的、每个签名只有一种表示。DER 只在两个平台的边界上出现,
74/// **不进协议**(Windows CNG 的 `NCryptSignHash` 恰好也直接返回 `r||s`)。
75pub const SIGNATURE_LEN: usize = 64;
76
77/// 设备身份公钥的指纹长度。
78pub const DEVICE_ID_LEN: usize = 32;
79
80/// 一台设备的身份标识:设备身份公钥的 SHA-256 指纹。
81///
82/// # 为什么是公钥的哈希,而不是随机数或自增计数
83///
84/// 设备身份是**密码学**身份:两台设备没有任何共享状态就可以各自动算出同一个
85/// `DeviceId`。如果改用随机数,设备就需要一个注册表来记住「哪个 ID 对应哪把公钥」;
86/// 而在一个没有中心服务器的系统里,那份注册表本身就是需要同步、需要认证、也最难
87/// 保证一致的东西。
88///
89/// 用哈希则把「ID 与公钥必须一致」变成**结构性事实**:拿到 ID 就能从公钥重算出来
90/// 并比对,无法伪造,也不需要维护。
91///
92/// SHA-256 在这里只用于**标识**,不承担抗碰撞以外的密码学职责——设备身份的真实性
93/// 由签名保证,指纹只是一个便于比较的短名字。
94#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
95pub struct DeviceId([u8; DEVICE_ID_LEN]);
96
97impl DeviceId {
98    /// 从设备身份公钥计算指纹。
99    #[must_use]
100    pub fn from_public_key(public_key: &DevicePublicKey) -> Self {
101        let digest = Sha256::digest(public_key.as_sec1_bytes());
102        let mut bytes = [0_u8; DEVICE_ID_LEN];
103        bytes.copy_from_slice(&digest);
104        Self(bytes)
105    }
106
107    /// 从 32 字节原始指纹构造。
108    #[must_use]
109    pub const fn from_bytes(bytes: [u8; DEVICE_ID_LEN]) -> Self {
110        Self(bytes)
111    }
112
113    /// 取原始指纹字节。
114    #[must_use]
115    pub const fn as_bytes(&self) -> &[u8; DEVICE_ID_LEN] {
116        &self.0
117    }
118
119    /// 转为小写十六进制。
120    #[must_use]
121    pub fn to_hex(&self) -> String {
122        let mut out = String::with_capacity(DEVICE_ID_LEN * 2);
123        for byte in self.0 {
124            // 手工格式化,避免为这一个用途引入 hex 依赖。
125            out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
126            out.push(char::from_digit(u32::from(byte & 0x0F), 16).unwrap_or('0'));
127        }
128        out
129    }
130}
131
132impl core::fmt::Display for DeviceId {
133    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134        f.write_str(&self.to_hex())
135    }
136}
137
138impl core::fmt::Debug for DeviceId {
139    /// 十六进制显示,便于在日志与测试失败信息里比对。
140    ///
141    /// 设备身份**不是秘密**——它本来就要印在二维码上给对方看——所以这里不需要
142    /// 脱敏。这是与 `OtpSecret` 一类类型的关键区别。
143    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
144        write!(f, "DeviceId({})", self.to_hex())
145    }
146}
147
148/// 设备身份公钥(SEC1 未压缩点,65 字节)。
149///
150/// 公钥不是秘密:它会被放进二维码、写进日志、在设备之间传播。所以这个类型
151/// **实现 `Debug` 的完整输出**,也**可以 `Clone`**,与秘密类型相反。
152#[derive(Clone, PartialEq, Eq)]
153pub struct DevicePublicKey {
154    /// `0x04 || X || Y`。构造时已验证是曲线上的合法点。
155    sec1: [u8; PUBLIC_KEY_LEN],
156}
157
158impl DevicePublicKey {
159    /// 从 SEC1 未压缩点构造,并**验证它确实是 P-256 曲线上的点**。
160    ///
161    /// # 为什么必须验证
162    ///
163    /// 公钥来自外部输入(二维码、网络)。P-256 上存在**非法曲线攻击**:把一个
164    /// 不属于曲线、而属于某个小子群的点交给对方,可以从对方的运算结果里反推出
165    /// 私钥信息。虽然 ECDSA 验签本身不像 ECDH 那样直接暴露私钥,但让一个非法点
166    /// 进入后续任何运算都是不必要的风险。
167    ///
168    /// 这里用 `p256` 的完整解析(而不是只检查长度与前缀),它内部会做曲线方程
169    /// 校验。因此**非法点根本无法构造出本类型的值**。
170    ///
171    /// # 错误
172    ///
173    /// 若长度不对、前缀不是 `0x04`、或该点不在 P-256 上,返回
174    /// [`Error::DevicePublicKeyInvalid`]。
175    pub fn from_sec1_bytes(bytes: &[u8]) -> Result<Self> {
176        if bytes.len() != PUBLIC_KEY_LEN {
177            return Err(Error::DevicePublicKeyInvalid {
178                reason: "长度不是 65 字节的 SEC1 未压缩点",
179            });
180        }
181        if bytes[0] != 0x04 {
182            return Err(Error::DevicePublicKeyInvalid {
183                reason: "SEC1 前缀不是 0x04(未压缩形式)",
184            });
185        }
186        // 这一步做真正的曲线校验:点不在曲线上就会失败。
187        if VerifyingKey::from_sec1_bytes(bytes).is_err() {
188            return Err(Error::DevicePublicKeyInvalid {
189                reason: "该点不在 P-256 曲线上",
190            });
191        }
192
193        let mut sec1 = [0_u8; PUBLIC_KEY_LEN];
194        sec1.copy_from_slice(bytes);
195        Ok(Self { sec1 })
196    }
197
198    /// 取 SEC1 未压缩点字节。
199    #[must_use]
200    pub const fn as_sec1_bytes(&self) -> &[u8; PUBLIC_KEY_LEN] {
201        &self.sec1
202    }
203
204    /// 这台设备的身份标识。
205    #[must_use]
206    pub fn device_id(&self) -> DeviceId {
207        DeviceId::from_public_key(self)
208    }
209}
210
211impl core::fmt::Debug for DevicePublicKey {
212    /// 输出为十六进制。公钥不是秘密,不需要脱敏。
213    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
214        write!(f, "DevicePublicKey(")?;
215        for byte in self.sec1 {
216            write!(f, "{byte:02x}")?;
217        }
218        write!(f, ")")
219    }
220}
221
222/// 设备身份签名(ECDSA P-256 原始 `r||s`,64 字节)。
223#[derive(Clone, PartialEq, Eq)]
224pub struct DeviceSignature([u8; SIGNATURE_LEN]);
225
226impl DeviceSignature {
227    /// 从原始 `r||s` 构造。
228    ///
229    /// 不在这里做范围校验:`r`/`s` 必须是 `[1, n-1]` 内的整数这一约束由
230    /// **验签**负责,而验签本来就要重新计算并比对,重复一次没有意义。构造这个
231    /// 类型只是说「这 64 个字节声称是一个签名」。
232    ///
233    /// # 错误
234    ///
235    /// 长度不是 64 字节时返回 [`Error::DeviceSignatureInvalid`]。
236    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
237        let raw: [u8; SIGNATURE_LEN] =
238            bytes
239                .try_into()
240                .map_err(|_| Error::DeviceSignatureInvalid {
241                    reason: "长度不是 64 字节的原始 r||s",
242                })?;
243        Ok(Self(raw))
244    }
245
246    /// 取原始 `r||s` 字节。
247    #[must_use]
248    pub const fn as_bytes(&self) -> &[u8; SIGNATURE_LEN] {
249        &self.0
250    }
251}
252
253impl core::fmt::Debug for DeviceSignature {
254    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
255        write!(f, "DeviceSignature(")?;
256        for byte in self.0 {
257            write!(f, "{byte:02x}")?;
258        }
259        write!(f, ")")
260    }
261}
262
263/// 用某个设备公钥验证它对 `message` 的签名。
264///
265/// # 验证的是消息,不是摘要
266///
267/// 平台硬件只接受**摘要**(Windows CNG 的 `NCryptSignHash` 形参就叫
268/// `pbHashValue`),所以签名侧必须先做一次 SHA-256。本函数接收**原始消息**并
269/// 自己做同样的哈希,这样调用方不需要在两处保持哈希算法一致——那是很容易写错的
270/// 地方,而且错了以后表现为「签名验证莫名失败」。
271///
272/// 需要明确:这里用的是 ECDSA 的**标准哈希模式**(`Signer`/`Verifier`),它内部
273/// 完成哈希。**不要**把已经算好的摘要再传进来。
274///
275/// # 错误
276///
277/// 签名格式非法或验证不通过,都返回 [`Error::DeviceSignatureInvalid`]。
278/// **两者刻意不区分**:调用方能做的应对完全相同(拒绝这次认证),而区分它们
279/// 只会给攻击者一个微小的预言机。
280#[must_use = "验证结果必须被检查,丢弃它等于没有验证"]
281pub fn verify(
282    public_key: &DevicePublicKey,
283    message: &[u8],
284    signature: &DeviceSignature,
285) -> Result<()> {
286    // 公钥在 `from_sec1_bytes` 时已验证过是曲线上的点,这里的失败属于内部矛盾,
287    // 但仍然返回错误而不是 panic——库内任何路径都不得 panic。
288    let verifying_key =
289        VerifyingKey::from_sec1_bytes(public_key.as_sec1_bytes()).map_err(|_| {
290            Error::DeviceSignatureInvalid {
291                reason: "公钥无法解析(不应发生:构造时已验证)",
292            }
293        })?;
294
295    let signature =
296        Signature::from_slice(signature.as_bytes()).map_err(|_| Error::DeviceSignatureInvalid {
297            reason: "签名不是合法的 P-256 r||s",
298        })?;
299
300    verifying_key
301        .verify(message, &signature)
302        .map_err(|_| Error::DeviceSignatureInvalid {
303            reason: "签名与公钥或消息不匹配",
304        })
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use p256::ecdsa::{SigningKey, signature::Signer};
311
312    /// 造一对测试用密钥(软件生成,只用于测试)。
313    fn test_key() -> (SigningKey, DevicePublicKey) {
314        // 固定标量,保证测试可复现。
315        let signing = SigningKey::from_slice(&[7_u8; 32]).expect("合法标量");
316        let sec1 = signing.verifying_key().to_sec1_point(false);
317        let public = DevicePublicKey::from_sec1_bytes(sec1.as_bytes()).expect("合法点");
318        (signing, public)
319    }
320
321    /// 用软件密钥对消息签名,产出我们的线格式。
322    fn sign(signing: &SigningKey, message: &[u8]) -> DeviceSignature {
323        let sig: Signature = signing.sign(message);
324        DeviceSignature::from_bytes(&sig.to_bytes()).expect("64 字节")
325    }
326
327    /// 完整往返:签了就能验过。
328    #[test]
329    fn valid_signature_verifies() {
330        let (signing, public) = test_key();
331        let message = b"\x01Qualia2FA test message";
332        let signature = sign(&signing, message);
333        assert!(verify(&public, message, &signature).is_ok());
334    }
335
336    /// 改一个 bit 的消息必须验不过。
337    #[test]
338    fn modified_message_fails() {
339        let (signing, public) = test_key();
340        let mut message = *b"hello qualia2fa!";
341        let signature = sign(&signing, &message);
342        message[0] ^= 0x01;
343        assert!(verify(&public, &message, &signature).is_err());
344    }
345
346    /// 改一个 bit 的签名必须验不过。
347    #[test]
348    fn modified_signature_fails() {
349        let (signing, public) = test_key();
350        let message = b"hello qualia2fa!";
351        let signature = sign(&signing, message);
352        for index in [0_usize, 31, 32, 63] {
353            let mut raw = *signature.as_bytes();
354            raw[index] ^= 0x01;
355            let tampered = DeviceSignature::from_bytes(&raw).expect("仍是 64 字节");
356            assert!(
357                verify(&public, message, &tampered).is_err(),
358                "第 {index} 字节被改动却通过了验证"
359            );
360        }
361    }
362
363    /// 用另一把密钥的公钥验证必须失败。
364    #[test]
365    fn wrong_public_key_fails() {
366        let (signing, _) = test_key();
367        let other = SigningKey::from_slice(&[9_u8; 32]).expect("合法标量");
368        let other_sec1 = other.verifying_key().to_sec1_point(false);
369        let other_public = DevicePublicKey::from_sec1_bytes(other_sec1.as_bytes()).expect("合法点");
370
371        let message = b"hello qualia2fa!";
372        let signature = sign(&signing, message);
373        assert!(verify(&other_public, message, &signature).is_err());
374    }
375
376    /// **非法曲线攻击**:不在 P-256 上的点必须被拒绝,而不是被接受。
377    #[test]
378    fn off_curve_point_is_rejected() {
379        // 取一个几乎肯定不在曲线上的点:X 为 1,Y 为 1。
380        let mut bytes = [0_u8; PUBLIC_KEY_LEN];
381        bytes[0] = 0x04;
382        bytes[32] = 1;
383        bytes[64] = 1;
384        assert!(
385            DevicePublicKey::from_sec1_bytes(&bytes).is_err(),
386            "不在曲线上的点被接受了——这会导致非法曲线攻击"
387        );
388    }
389
390    /// 长度与前缀都要检查。
391    #[test]
392    fn malformed_public_key_is_rejected() {
393        assert!(DevicePublicKey::from_sec1_bytes(&[]).is_err());
394        assert!(DevicePublicKey::from_sec1_bytes(&[0x04; 64]).is_err());
395        assert!(DevicePublicKey::from_sec1_bytes(&[0x04; 66]).is_err());
396
397        // 压缩形式(0x02/0x03 前缀)不被接受:我们的线格式是未压缩点。
398        let (_, public) = test_key();
399        let mut compressed = *public.as_sec1_bytes();
400        compressed[0] = 0x02;
401        assert!(
402            DevicePublicKey::from_sec1_bytes(&compressed).is_err(),
403            "压缩形式不应被接受"
404        );
405    }
406
407    /// 签名的长度必须严格是 64。
408    #[test]
409    fn malformed_signature_is_rejected() {
410        assert!(DeviceSignature::from_bytes(&[]).is_err());
411        assert!(DeviceSignature::from_bytes(&[0_u8; 63]).is_err());
412        assert!(DeviceSignature::from_bytes(&[0_u8; 65]).is_err());
413    }
414
415    /// `DeviceId` 必须由公钥唯一决定,且与公钥一致。
416    #[test]
417    fn device_id_is_derived_from_public_key() {
418        let (_, public) = test_key();
419        let id = public.device_id();
420        assert_eq!(id, DeviceId::from_public_key(&public));
421        assert_eq!(id.as_bytes().len(), DEVICE_ID_LEN);
422        assert_eq!(id.to_hex().len(), DEVICE_ID_LEN * 2);
423
424        // 另一把密钥必须给出不同的 ID。
425        let (_, other) = {
426            let signing = SigningKey::from_slice(&[9_u8; 32]).expect("合法标量");
427            let sec1 = signing.verifying_key().to_sec1_point(false);
428            let public = DevicePublicKey::from_sec1_bytes(sec1.as_bytes()).expect("合法点");
429            (signing, public)
430        };
431        assert_ne!(id, other.device_id());
432    }
433
434    /// `Debug` 输出包含完整的十六进制,用于日志与测试失败信息。
435    #[test]
436    fn debug_shows_hex() {
437        let (_, public) = test_key();
438        let text = format!("{public:?}");
439        assert!(text.starts_with("DevicePublicKey(04"), "实际: {text}");
440        assert_eq!(
441            text.len(),
442            "DevicePublicKey(".len() + PUBLIC_KEY_LEN * 2 + 1
443        );
444        assert_eq!(public.device_id().to_string().len(), DEVICE_ID_LEN * 2);
445    }
446}