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//! Device identity: the long-term key a device uses to prove to other devices that it is
17//! itself.
18//!
19//! # What it is
20//!
21//! The device identity is an ECDSA P-256 key pair. The private key is generated inside local
22//! hardware and never leaves it; the public key may be propagated freely between devices.
23//! There is no central server and no account password, so "this device is trustworthy" rests
24//! on the key the device itself holds and cannot be asserted by a third party.
25//!
26//! Authentication therefore has a concrete, verifiable meaning: **the peer produces a
27//! signature over data it has not seen before that verifies under some public key, and the
28//! fingerprint of that public key matches the device being looked for.**
29//!
30//! # Signature algorithm: ECDSA P-256
31//!
32//! The algorithm is fixed, not preferred, because the private key lives in platform
33//! hardware:
34//!
35//! - **Android**: KeyMint HAL's `Algorithm.aidl` lists only `RSA=1, EC=3, AES=32,
36//!   TRIPLE_DES=33, HMAC=128`; there is no EdDSA, and `KeyProperties.java` defines no
37//!   Ed25519 constant. `CURVE_25519` exists, but it is an **XDH key-agreement curve, not a
38//!   signature algorithm**.
39//! - **iOS**: Apple documents the Secure Enclave as NIST P-256 only.
40//!
41//! P-256/ECDSA is therefore the only signature algorithm that can be held in hardware on
42//! both mobile platforms.
43//!
44//! # Scope
45//!
46//! This module holds no private keys and performs no signing: private keys live in hardware
47//! and signing is initiated by [`crate::keystore`]. It covers the **public-key side** of an
48//! identity only — representation, fingerprint, and signature verification, which any device
49//! must be able to perform, including one that holds no hardware key at all.
50//!
51//! # I/O and clock
52//!
53//! The module performs no I/O and reads no clock; time is always passed in by the caller.
54
55use crate::{Error, Result};
56use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
57use sha2::{Digest, Sha256};
58
59/// Fixed length of a SEC1 uncompressed point encoding: `0x04 || X(32) || Y(32)`.
60///
61/// The uncompressed form is the natural output of Windows CNG (`BCRYPT_ECCPUBLICBLOB`),
62/// Android Keystore, and the Secure Enclave, so it can be used directly without implementing
63/// point compression. The 32 bytes a compressed point would save do not matter in a QR code.
64pub const PUBLIC_KEY_LEN: usize = 65;
65
66/// Fixed length of a raw ECDSA P-256 signature: `r(32) || s(32)`.
67///
68/// The wire format is raw `r || s`, not DER. DER is variable length (70–72 bytes, depending
69/// on leading `0x00` padding), so the encoding length would reveal the high bytes of `r` and
70/// `s`, and a single mathematical signature would have several valid encodings. A raw fixed
71/// layout has exactly one representation per signature. DER appears only at platform
72/// boundaries and never in the protocol; Windows CNG's `NCryptSignHash` also returns
73/// `r || s` directly.
74pub const SIGNATURE_LEN: usize = 64;
75
76/// Length of the fingerprint of a device identity public key.
77pub const DEVICE_ID_LEN: usize = 32;
78
79/// Identity of one device: the SHA-256 fingerprint of its identity public key.
80///
81/// The identifier is derived from the public key rather than drawn at random or counted, so
82/// two devices with no shared state can each compute the same `DeviceId`: a device only has
83/// to be able to recompute the fingerprint from a public key and compare. "Identifier and
84/// public key agree" is therefore a structural fact that needs no registry to maintain and
85/// cannot be forged.
86///
87/// SHA-256 is used here for identification only and carries no cryptographic duty beyond
88/// collision resistance: the authenticity of a device identity is provided by signatures,
89/// and the fingerprint is just a short name that is convenient to compare.
90#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
91pub struct DeviceId([u8; DEVICE_ID_LEN]);
92
93impl DeviceId {
94    /// Computes the fingerprint from a device identity public key.
95    #[must_use]
96    pub fn from_public_key(public_key: &DevicePublicKey) -> Self {
97        let digest = Sha256::digest(public_key.as_sec1_bytes());
98        let mut bytes = [0_u8; DEVICE_ID_LEN];
99        bytes.copy_from_slice(&digest);
100        Self(bytes)
101    }
102
103    /// Constructs a fingerprint from 32 raw bytes.
104    #[must_use]
105    pub const fn from_bytes(bytes: [u8; DEVICE_ID_LEN]) -> Self {
106        Self(bytes)
107    }
108
109    /// Returns the raw fingerprint bytes.
110    #[must_use]
111    pub const fn as_bytes(&self) -> &[u8; DEVICE_ID_LEN] {
112        &self.0
113    }
114
115    /// Formats the fingerprint as lowercase hexadecimal.
116    #[must_use]
117    pub fn to_hex(&self) -> String {
118        let mut out = String::with_capacity(DEVICE_ID_LEN * 2);
119        for byte in self.0 {
120            out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
121            out.push(char::from_digit(u32::from(byte & 0x0F), 16).unwrap_or('0'));
122        }
123        out
124    }
125}
126
127impl core::fmt::Display for DeviceId {
128    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
129        f.write_str(&self.to_hex())
130    }
131}
132
133impl core::fmt::Debug for DeviceId {
134    /// Hexadecimal output, convenient for comparing values in logs and test-failure
135    /// messages.
136    ///
137    /// A device identity is **not** a secret — it is printed in a QR code for the peer to
138    /// read — so no redaction is needed here, unlike types that hold secret material.
139    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140        write!(f, "DeviceId({})", self.to_hex())
141    }
142}
143
144/// Device identity public key (SEC1 uncompressed point, 65 bytes).
145///
146/// A public key is not a secret: it is placed in QR codes, written to logs, and propagated
147/// between devices. This type therefore implements the full `Debug` output and is
148/// `Clone`able, unlike types that hold secrets.
149#[derive(Clone, PartialEq, Eq)]
150pub struct DevicePublicKey {
151    /// `0x04 || X || Y`. Verified to be a valid point on the curve at construction.
152    sec1: [u8; PUBLIC_KEY_LEN],
153}
154
155impl DevicePublicKey {
156    /// Constructs a public key from a SEC1 uncompressed point and **verifies that it really
157    /// is a point on the P-256 curve**.
158    ///
159    /// Public keys arrive as external input (QR codes, the network), and an invalid-curve
160    /// attack feeds a point that belongs to a small subgroup instead of the curve in order to
161    /// recover private-key information from the results of later operations. Letting such a
162    /// point reach any computation is an unnecessary risk even though ECDSA verification
163    /// exposes less than ECDH does.
164    ///
165    /// The input is parsed by `p256` in full, which checks the curve equation internally, so
166    /// **an invalid point cannot be turned into a value of this type**.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`Error::DevicePublicKeyInvalid`] if the length is wrong, the prefix is not
171    /// `0x04`, or the point is not on P-256.
172    pub fn from_sec1_bytes(bytes: &[u8]) -> Result<Self> {
173        if bytes.len() != PUBLIC_KEY_LEN {
174            return Err(Error::DevicePublicKeyInvalid {
175                reason: "length is not a 65-byte SEC1 uncompressed point",
176            });
177        }
178        if bytes[0] != 0x04 {
179            return Err(Error::DevicePublicKeyInvalid {
180                reason: "the SEC1 prefix is not 0x04 (uncompressed form)",
181            });
182        }
183        if VerifyingKey::from_sec1_bytes(bytes).is_err() {
184            return Err(Error::DevicePublicKeyInvalid {
185                reason: "the point is not on the P-256 curve",
186            });
187        }
188
189        let mut sec1 = [0_u8; PUBLIC_KEY_LEN];
190        sec1.copy_from_slice(bytes);
191        Ok(Self { sec1 })
192    }
193
194    /// Returns the SEC1 uncompressed point bytes.
195    #[must_use]
196    pub const fn as_sec1_bytes(&self) -> &[u8; PUBLIC_KEY_LEN] {
197        &self.sec1
198    }
199
200    /// Identity of this device.
201    #[must_use]
202    pub fn device_id(&self) -> DeviceId {
203        DeviceId::from_public_key(self)
204    }
205}
206
207impl core::fmt::Debug for DevicePublicKey {
208    /// Hexadecimal output. A public key is not a secret and needs no redaction.
209    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
210        write!(f, "DevicePublicKey(")?;
211        for byte in self.sec1 {
212            write!(f, "{byte:02x}")?;
213        }
214        write!(f, ")")
215    }
216}
217
218/// Device identity signature (raw ECDSA P-256 `r || s`, 64 bytes).
219#[derive(Clone, PartialEq, Eq)]
220pub struct DeviceSignature([u8; SIGNATURE_LEN]);
221
222impl DeviceSignature {
223    /// Constructs a signature from raw `r || s` bytes.
224    ///
225    /// No range check is done here: the requirement that `r` and `s` are integers in
226    /// `[1, n-1]` is the responsibility of **verification**, which recomputes and compares
227    /// anyway. Constructing this type only states that these 64 bytes claim to be a
228    /// signature.
229    ///
230    /// # Errors
231    ///
232    /// Returns [`Error::DeviceSignatureInvalid`] if the input is not 64 bytes long.
233    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
234        let raw: [u8; SIGNATURE_LEN] =
235            bytes
236                .try_into()
237                .map_err(|_| Error::DeviceSignatureInvalid {
238                    reason: "length is not 64 raw r||s bytes",
239                })?;
240        Ok(Self(raw))
241    }
242
243    /// Returns the raw `r || s` bytes.
244    #[must_use]
245    pub const fn as_bytes(&self) -> &[u8; SIGNATURE_LEN] {
246        &self.0
247    }
248}
249
250impl core::fmt::Debug for DeviceSignature {
251    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
252        write!(f, "DeviceSignature(")?;
253        for byte in self.0 {
254            write!(f, "{byte:02x}")?;
255        }
256        write!(f, ")")
257    }
258}
259
260/// Verifies a device's signature over `message` with its public key.
261///
262/// # The message, not a digest
263///
264/// Platform hardware accepts a **digest** only (the Windows CNG parameter of
265/// `NCryptSignHash` is named `pbHashValue`), so the signing side has to hash first. This
266/// function takes the **original message** and performs the same SHA-256 itself, so callers
267/// do not have to keep one hash algorithm consistent across two call sites.
268///
269/// The hash mode used here is ECDSA's standard one (`Signer`/`Verifier`), which hashes
270/// internally. Do **not** pass an already-computed digest.
271///
272/// # Errors
273///
274/// Returns [`Error::DeviceSignatureInvalid`] both for a malformed signature and for a
275/// verification failure. The two are deliberately not distinguished: the caller's response
276/// is identical (reject this authentication), and separating them would give an attacker a
277/// small oracle.
278///
279/// No input panics; every failure is returned as an error.
280#[must_use = "the verification result must be checked; discarding it is the same as not verifying"]
281pub fn verify(
282    public_key: &DevicePublicKey,
283    message: &[u8],
284    signature: &DeviceSignature,
285) -> Result<()> {
286    let verifying_key =
287        VerifyingKey::from_sec1_bytes(public_key.as_sec1_bytes()).map_err(|_| {
288            Error::DeviceSignatureInvalid {
289                reason: "the public key failed to parse (should not happen: it was validated at construction)",
290            }
291        })?;
292
293    let signature =
294        Signature::from_slice(signature.as_bytes()).map_err(|_| Error::DeviceSignatureInvalid {
295            reason: "the signature is not a valid P-256 r||s",
296        })?;
297
298    verifying_key
299        .verify(message, &signature)
300        .map_err(|_| Error::DeviceSignatureInvalid {
301            reason: "the signature does not match the public key or the message",
302        })
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use p256::ecdsa::{SigningKey, signature::Signer};
309
310    /// Builds a key pair for tests (generated in software, for tests only).
311    fn test_key() -> (SigningKey, DevicePublicKey) {
312        let signing = SigningKey::from_slice(&[7_u8; 32]).expect("valid scalar");
313        let sec1 = signing.verifying_key().to_sec1_point(false);
314        let public = DevicePublicKey::from_sec1_bytes(sec1.as_bytes()).expect("valid point");
315        (signing, public)
316    }
317
318    /// Signs a message with a software key and produces our wire format.
319    fn sign(signing: &SigningKey, message: &[u8]) -> DeviceSignature {
320        let sig: Signature = signing.sign(message);
321        DeviceSignature::from_bytes(&sig.to_bytes()).expect("64 bytes")
322    }
323
324    /// Round trip: a signature over a message verifies against that message.
325    #[test]
326    fn valid_signature_verifies() {
327        let (signing, public) = test_key();
328        let message = b"\x01Qualia2FA test message";
329        let signature = sign(&signing, message);
330        assert!(verify(&public, message, &signature).is_ok());
331    }
332
333    /// A message with one flipped bit must not verify.
334    #[test]
335    fn modified_message_fails() {
336        let (signing, public) = test_key();
337        let mut message = *b"hello qualia2fa!";
338        let signature = sign(&signing, &message);
339        message[0] ^= 0x01;
340        assert!(verify(&public, &message, &signature).is_err());
341    }
342
343    /// A signature with one flipped bit must not verify.
344    #[test]
345    fn modified_signature_fails() {
346        let (signing, public) = test_key();
347        let message = b"hello qualia2fa!";
348        let signature = sign(&signing, message);
349        for index in [0_usize, 31, 32, 63] {
350            let mut raw = *signature.as_bytes();
351            raw[index] ^= 0x01;
352            let tampered = DeviceSignature::from_bytes(&raw).expect("still 64 bytes");
353            assert!(
354                verify(&public, message, &tampered).is_err(),
355                "byte {index} was modified but still verified"
356            );
357        }
358    }
359
360    /// Verifying with the public key of a different key pair must fail.
361    #[test]
362    fn wrong_public_key_fails() {
363        let (signing, _) = test_key();
364        let other = SigningKey::from_slice(&[9_u8; 32]).expect("valid scalar");
365        let other_sec1 = other.verifying_key().to_sec1_point(false);
366        let other_public =
367            DevicePublicKey::from_sec1_bytes(other_sec1.as_bytes()).expect("valid point");
368
369        let message = b"hello qualia2fa!";
370        let signature = sign(&signing, message);
371        assert!(verify(&other_public, message, &signature).is_err());
372    }
373
374    /// **Invalid-curve attack**: a point that is not on P-256 must be rejected, not
375    /// accepted.
376    #[test]
377    fn off_curve_point_is_rejected() {
378        let mut bytes = [0_u8; PUBLIC_KEY_LEN];
379        bytes[0] = 0x04;
380        bytes[32] = 1;
381        bytes[64] = 1;
382        assert!(
383            DevicePublicKey::from_sec1_bytes(&bytes).is_err(),
384            "an off-curve point was accepted, which enables invalid-curve attacks"
385        );
386    }
387
388    /// Both the length and the SEC1 prefix are checked.
389    #[test]
390    fn malformed_public_key_is_rejected() {
391        assert!(DevicePublicKey::from_sec1_bytes(&[]).is_err());
392        assert!(DevicePublicKey::from_sec1_bytes(&[0x04; 64]).is_err());
393        assert!(DevicePublicKey::from_sec1_bytes(&[0x04; 66]).is_err());
394
395        let (_, public) = test_key();
396        let mut compressed = *public.as_sec1_bytes();
397        compressed[0] = 0x02;
398        assert!(
399            DevicePublicKey::from_sec1_bytes(&compressed).is_err(),
400            "the compressed form must not be accepted"
401        );
402    }
403
404    /// A signature must be exactly 64 bytes long.
405    #[test]
406    fn malformed_signature_is_rejected() {
407        assert!(DeviceSignature::from_bytes(&[]).is_err());
408        assert!(DeviceSignature::from_bytes(&[0_u8; 63]).is_err());
409        assert!(DeviceSignature::from_bytes(&[0_u8; 65]).is_err());
410    }
411
412    /// `DeviceId` is determined by the public key alone and is consistent with it.
413    #[test]
414    fn device_id_is_derived_from_public_key() {
415        let (_, public) = test_key();
416        let id = public.device_id();
417        assert_eq!(id, DeviceId::from_public_key(&public));
418        assert_eq!(id.as_bytes().len(), DEVICE_ID_LEN);
419        assert_eq!(id.to_hex().len(), DEVICE_ID_LEN * 2);
420
421        let (_, other) = {
422            let signing = SigningKey::from_slice(&[9_u8; 32]).expect("valid scalar");
423            let sec1 = signing.verifying_key().to_sec1_point(false);
424            let public = DevicePublicKey::from_sec1_bytes(sec1.as_bytes()).expect("valid point");
425            (signing, public)
426        };
427        assert_ne!(id, other.device_id());
428    }
429
430    /// `Debug` output contains the full hexadecimal value, for logs and test-failure
431    /// messages.
432    #[test]
433    fn debug_shows_hex() {
434        let (_, public) = test_key();
435        let text = format!("{public:?}");
436        assert!(text.starts_with("DevicePublicKey(04"), "actual: {text}");
437        assert_eq!(
438            text.len(),
439            "DevicePublicKey(".len() + PUBLIC_KEY_LEN * 2 + 1
440        );
441        assert_eq!(public.device_id().to_string().len(), DEVICE_ID_LEN * 2);
442    }
443}