Skip to main content

hsm_crypto_service/
pin.rs

1// Copyright (c) 2026 Tuloup Simon
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// any later version.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! PIN / PUK hashing and `CheckMac` MAC computation.
17//!
18//! The project stores `SHA-256(PIN || pin_salt)` in slot 5 and
19//! `SHA-256(PUK || puk_salt)` in slot 6. The salts are derived from the
20//! chip's unique serial number at provisioning so that two physically
21//! distinct tokens never share a hash even when the user picks the same
22//! PIN, without paying the cost of storing an explicit salt elsewhere.
23//!
24//! The `CheckMac` verification on slot 5 / 6 mirrors what the chip itself
25//! computes. The byte layout below is taken **verbatim** from
26//! `CryptoAuthLib`'s `atcah_check_mac` (`lib/host/atca_host.c`), which is
27//! the authoritative reference, and validated against it by 
28//! `checkmac_response_matches_cryptoauthlib_oracle_*` tests.
29//!
30//! ```text
31//! msg[0..32]   = slot_value         (32)
32//! msg[32..64]  = challenge          (32)
33//! msg[64..68]  = other_data[0..4]   ( 4)  OpCode, Mode, Param2 LE
34//! msg[68..76]  = OTP[0..8] or zero  ( 8)
35//! msg[76..79]  = other_data[4..7]   ( 3)
36//! msg[79]      = serial[8]          ( 1)  SN[8]
37//! msg[80..84]  = other_data[7..11]  ( 4)
38//! msg[84..86]  = serial[0..2]       ( 2)  SN[0..2]
39//! msg[86..88]  = other_data[11..13] ( 2)
40//! ```
41//!
42//! Total = 88 bytes (`ATCA_MSG_SIZE_MAC`).
43//!
44//! Notable surprises vs. the Microchip ASF documentation table:
45//! - SN[4..8] and SN[2..4] do **not** participate in the hash.
46//! - `other_data` is consumed in three discontinuous chunks: `[0..4]`,
47//!   `[4..7]`, `[7..11]`, `[11..13]`. All 13 bytes contribute.
48//!
49//! We pass OTP as zeros: PIN verification in this project is not coupled
50//! to the OTP zone.
51
52use sha2::{Digest, Sha256};
53
54/// Length of a SHA-256 digest.
55pub(crate) const HASH_LEN: usize = 32;
56
57/// PIN length in bytes (4 ASCII digits).
58pub(crate) const PIN_LEN: usize = 4;
59
60/// PUK length in bytes (8 ASCII digits).
61pub(crate) const PUK_LEN: usize = 8;
62
63/// Domain separation tag for the PIN salt.
64const PIN_SALT_DOMAIN: &[u8] = b"mini-hsm-pin-salt-v1";
65
66/// Domain separation tag for the PUK salt.
67const PUK_SALT_DOMAIN: &[u8] = b"mini-hsm-puk-salt-v1";
68
69/// Errors returned when a PIN or PUK does not match the expected format.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71#[cfg_attr(feature = "defmt", derive(defmt::Format))]
72pub enum FormatError
73{
74    /// One of the bytes was not an ASCII digit `'0'..='9'`.
75    NonDigit
76    {
77        /// Position of the offending byte.
78        position: usize,
79    },
80    /// A numeric argument fell outside its accepted range (e.g. a
81    /// config-zone block index greater than 3).
82    OutOfRange,
83}
84
85/// Validate that every byte of `code` is an ASCII digit `'0'..='9'`.
86///
87/// # Errors
88/// Returns [`FormatError::NonDigit`] at the position of the first non-digit
89/// byte.
90pub(crate) fn validate_digits(code: &[u8]) -> Result<(), FormatError>
91{
92    for (position, byte) in code.iter().enumerate()
93    {
94        if !byte.is_ascii_digit()
95        {
96            return Err(FormatError::NonDigit { position });
97        }
98    }
99    Ok(())
100}
101
102/// Compute the PIN salt deterministically from the chip serial number.
103///
104/// The serial is the 9-byte ATECC unique ID read from the config zone.
105#[must_use]
106pub(crate) fn pin_salt(chip_serial: &[u8; 9]) -> [u8; HASH_LEN]
107{
108    let mut hasher = Sha256::new();
109    hasher.update(PIN_SALT_DOMAIN);
110    hasher.update(chip_serial);
111    let mut out = [0u8; HASH_LEN];
112    out.copy_from_slice(hasher.finalize().as_slice());
113    out
114}
115
116/// Compute the PUK salt deterministically from the chip serial number.
117#[must_use]
118pub(crate) fn puk_salt(chip_serial: &[u8; 9]) -> [u8; HASH_LEN]
119{
120    let mut hasher = Sha256::new();
121    hasher.update(PUK_SALT_DOMAIN);
122    hasher.update(chip_serial);
123    let mut out = [0u8; HASH_LEN];
124    out.copy_from_slice(hasher.finalize().as_slice());
125    out
126}
127
128/// Compute `SHA-256(pin || pin_salt)`. This is the value stored in slot 5.
129#[must_use]
130pub(crate) fn pin_hash(pin: [u8; PIN_LEN], salt: &[u8; HASH_LEN]) -> [u8; HASH_LEN]
131{
132    let mut hasher = Sha256::new();
133    hasher.update(pin);
134    hasher.update(salt);
135    let mut out = [0u8; HASH_LEN];
136    out.copy_from_slice(hasher.finalize().as_slice());
137    out
138}
139
140/// Compute `SHA-256(puk || puk_salt)`. This is the value stored in slot 6.
141#[must_use]
142pub(crate) fn puk_hash(puk: [u8; PUK_LEN], salt: &[u8; HASH_LEN]) -> [u8; HASH_LEN]
143{
144    let mut hasher = Sha256::new();
145    hasher.update(puk);
146    hasher.update(salt);
147    let mut out = [0u8; HASH_LEN];
148    out.copy_from_slice(hasher.finalize().as_slice());
149    out
150}
151
152/// Compute the host-side `CheckMac` response for slot 5 or 6.
153///
154/// The MAC layout matches `CryptoAuthLib`'s `atcah_check_mac` exactly
155/// (`lib/host/atca_host.c`):
156///
157/// ```text
158/// msg[0..32]   = slot_value         (32)
159/// msg[32..64]  = challenge          (32)
160/// msg[64..68]  = other_data[0..4]   ( 4)
161/// msg[68..76]  = OTP[0..8] (zero)   ( 8)
162/// msg[76..79]  = other_data[4..7]   ( 3)
163/// msg[79]      = serial[8]          ( 1)
164/// msg[80..84]  = other_data[7..11]  ( 4)
165/// msg[84..86]  = serial[0..2]       ( 2)
166/// msg[86..88]  = other_data[11..13] ( 2)
167/// ```
168///
169/// All 13 bytes of `other_data` are consumed, in three discontinuous
170/// chunks. Only serial bytes 0, 1, and 8 contribute to the hash; the
171/// remaining serial bytes (2..8) are absent from the formula by design.
172///
173/// `chip_serial` must therefore have valid values at indices 0, 1, 8;
174/// the others are ignored.
175#[must_use]
176pub(crate) fn checkmac_response
177(
178    slot_value: &[u8; HASH_LEN],
179    challenge: &[u8; HASH_LEN],
180    other_data: &[u8; 13],
181    chip_serial: &[u8; 9],
182) -> [u8; HASH_LEN]
183{
184    let mut hasher = Sha256::new();
185    hasher.update(slot_value);
186    hasher.update(challenge);
187    hasher.update(&other_data[0..4]);
188    hasher.update([0u8; 8]);
189    hasher.update(&other_data[4..7]);
190    hasher.update(&chip_serial[8..9]);
191    hasher.update(&other_data[7..11]);
192    hasher.update(&chip_serial[0..2]);
193    hasher.update(&other_data[11..13]);
194    let mut out = [0u8; HASH_LEN];
195    out.copy_from_slice(hasher.finalize().as_slice());
196    out
197}
198
199/// Build the `other_data` block for a `CheckMac` call against `key_id` on
200/// this project's slots, with zero OTP coupling.
201///
202/// `other_data` is 13 bytes long. Bytes 0..4 carry the opcode, mode, and
203/// `key_id` that the chip substitutes into the hash at message offsets
204/// 84..88. Bytes 4..7 are reserved for OTP coupling, which we never use
205/// (kept at zero). Bytes 7..13 are six free-form bytes that the chip
206/// hashes verbatim at message offsets 80..84 and 88..96. Their actual
207/// values are not constrained by the protocol: any sequence works as long
208/// as both the host (in [`checkmac_response`]) and the chip-side computation
209/// receive the same bytes. We seed them from `chip_serial` to bind a
210/// successful `CheckMac` to a specific chip, defeating a replay of a
211/// pre-computed response against a different physical chip with the
212/// same slot value.
213#[must_use]
214pub(crate) fn checkmac_other_data(key_id: u8, chip_serial: &[u8; 9]) -> [u8; 13]
215{
216    let mut data = [0u8; 13];
217    // Bytes 0..4: command shape that the chip rebuilds for verification.
218    data[0] = 0x28; // OP_CHECKMAC
219    data[1] = 0x00; // CHECKMAC_MODE_CHALLENGE
220    data[2] = key_id;
221    data[3] = 0x00;
222    // Bytes 4..7: OTP coupling area. We do not use OTP, leave at zero.
223    // Bytes 7..13: free-form bytes hashed verbatim by the chip. Seeded
224    // from the chip serial so that the same slot value on a different
225    // physical chip cannot replay a previously captured response.
226    data[7] = chip_serial[0];
227    data[8] = chip_serial[0];
228    data[9] = chip_serial[1];
229    data[10] = chip_serial[2];
230    data[11] = chip_serial[3];
231    data[12] = 0x00;
232    data
233}
234
235#[cfg(test)]
236mod tests
237{
238    use super::*;
239
240    #[test]
241    fn validate_digits_accepts_ascii_digits()
242    {
243        assert!(validate_digits(b"0000").is_ok());
244        assert!(validate_digits(b"1234").is_ok());
245        assert!(validate_digits(b"99999999").is_ok());
246    }
247
248    #[test]
249    fn validate_digits_rejects_non_digit()
250    {
251        assert_eq!(
252            validate_digits(b"12A4"),
253            Err(FormatError::NonDigit { position: 2 })
254        );
255        assert_eq!(
256            validate_digits(b" 234"),
257            Err(FormatError::NonDigit { position: 0 })
258        );
259    }
260
261    #[test]
262    fn pin_salt_is_deterministic()
263    {
264        let serial = [0u8; 9];
265        assert_eq!(pin_salt(&serial), pin_salt(&serial));
266    }
267
268    #[test]
269    fn pin_salt_changes_with_serial()
270    {
271        let s1 = [1u8; 9];
272        let s2 = [2u8; 9];
273        assert_ne!(pin_salt(&s1), pin_salt(&s2));
274    }
275
276    #[test]
277    fn pin_salt_and_puk_salt_differ()
278    {
279        let serial = [0xAB; 9];
280        assert_ne!(pin_salt(&serial), puk_salt(&serial));
281    }
282
283    #[test]
284    fn pin_hash_is_deterministic()
285    {
286        let pin = *b"0000";
287        let salt = [0u8; HASH_LEN];
288        assert_eq!(pin_hash(pin, &salt), pin_hash(pin, &salt));
289    }
290
291    #[test]
292    fn pin_hash_changes_with_pin()
293    {
294        let salt = [0u8; HASH_LEN];
295        assert_ne!(pin_hash(*b"0000", &salt), pin_hash(*b"1234", &salt));
296    }
297
298    #[test]
299    fn pin_hash_changes_with_salt()
300    {
301        let pin = *b"0000";
302        let salt1 = [0u8; HASH_LEN];
303        let salt2 = [1u8; HASH_LEN];
304        assert_ne!(pin_hash(pin, &salt1), pin_hash(pin, &salt2));
305    }
306
307    #[test]
308    fn checkmac_response_is_deterministic()
309    {
310        let slot_value = [0xAAu8; HASH_LEN];
311        let challenge = [0xBBu8; HASH_LEN];
312        let other_data = [0xCCu8; 13];
313        let serial = [0xDDu8; 9];
314        let r1 = checkmac_response(&slot_value, &challenge, &other_data, &serial);
315        let r2 = checkmac_response(&slot_value, &challenge, &other_data, &serial);
316        assert_eq!(r1, r2);
317    }
318
319    /// `CryptoAuthLib` oracle match - vector 1 (uniform).
320    ///
321    /// Tests the host-side `CheckMac` formula against the digest produced
322    /// by Microchip's `CryptoAuthLib` for an identical input.
323    ///
324    /// This vector uses uniform bytes per region. It catches gross
325    /// formula errors (wrong total length, wrong segment sizes, OTP not
326    /// zero) but cannot catch swaps of equal-sized slices that happen to
327    /// hold the same byte. Vectors v2 and v3 cover that case.
328    ///
329    /// Inputs:
330    ///   `slot_value` = [0xAA; 32]
331    ///   challenge  = [0xBB; 32]
332    ///   `other_data` = [0xCC; 13]
333    ///   sn         = [0xDD;  9]
334    #[test]
335    fn checkmac_response_matches_cryptoauthlib_oracle_v1_uniform()
336    {
337        let slot_value = [0xAAu8; HASH_LEN];
338        let challenge = [0xBBu8; HASH_LEN];
339        let other_data = [0xCCu8; 13];
340        let serial = [0xDDu8; 9];
341        let got = checkmac_response(&slot_value, &challenge, &other_data, &serial);
342        let expected: [u8; HASH_LEN] = [
343            0xe6, 0x70, 0x6b, 0xdf, 0x1f, 0x6b, 0x55, 0x3f,
344            0xce, 0x61, 0xbb, 0x4c, 0xfe, 0x90, 0xa9, 0x2e,
345            0x19, 0x9e, 0x80, 0x04, 0x04, 0x87, 0x88, 0x34,
346            0xe5, 0xcc, 0x3c, 0x73, 0xde, 0xba, 0x24, 0xe9,
347        ];
348        assert_eq!(got, expected, "host CheckMac formula diverges from CryptoAuthLib (v1)");
349    }
350
351    /// `CryptoAuthLib` oracle match - vector 2 (linear).
352    ///
353    /// Every byte across `slot_value`, challenge, `other_data`, and serial
354    /// is distinct. Any swap of two slices in the formula's byte
355    /// layout would change the digest with overwhelming probability,
356    /// so this vector is the most powerful regression guard among the
357    /// three.
358    ///
359    /// Inputs:
360    ///   `slot_value`[i] = i             for i in 0..32   (0x00 .. 0x1F)
361    ///   challenge[i]  = i + 32        for i in 0..32   (0x20 .. 0x3F)
362    ///   `other_data`[i] = 0x80 + i      for i in 0..13   (0x80 .. 0x8C)
363    ///   serial[i]     = 0xE0 + i      for i in 0..9    (0xE0 .. 0xE8)
364    #[test]
365    fn checkmac_response_matches_cryptoauthlib_oracle_v2_linear()
366    {
367        let slot_value: [u8; HASH_LEN] = core::array::from_fn(|i| u8::try_from(i).unwrap());
368        let challenge: [u8; HASH_LEN]  = core::array::from_fn(|i| u8::try_from(i + 32).unwrap());
369        let other_data: [u8; 13]       = core::array::from_fn(|i| 0x80 + u8::try_from(i).unwrap());
370        let serial: [u8; 9]            = core::array::from_fn(|i| 0xE0 + u8::try_from(i).unwrap());
371
372        let got = checkmac_response(&slot_value, &challenge, &other_data, &serial);
373        let expected: [u8; HASH_LEN] = [
374            0x06, 0x78, 0x0a, 0x56, 0x55, 0x68, 0x0c, 0x31,
375            0x23, 0x89, 0x3d, 0xd3, 0x9b, 0x7f, 0x3f, 0x71,
376            0xfa, 0x8d, 0x37, 0x81, 0x98, 0x34, 0xfd, 0xf5,
377            0xf2, 0xe7, 0xf1, 0x1e, 0x26, 0xed, 0xad, 0xa8,
378        ];
379        assert_eq!(got, expected, "host CheckMac formula diverges from CryptoAuthLib (v2)");
380    }
381
382    /// `CryptoAuthLib` oracle match - vector 3 (realistic).
383    ///
384    /// Inputs are shaped like what the firmware will actually see at
385    /// runtime: a PIN-hash-looking `slot_value`, an entropy-looking
386    /// challenge, an `other_data` filled with the `CheckMac` opcode/mode/
387    /// `key_id` pattern (0x28 / 0x00 / 0x05 0x00 followed by zeros), and
388    /// a serial styled after a typical ATECC608 serial number.
389    #[test]
390    fn checkmac_response_matches_cryptoauthlib_oracle_v3_realistic()
391    {
392        let slot_value: [u8; HASH_LEN] = [
393            0x9b, 0x87, 0x1d, 0x4f, 0x3c, 0x2c, 0xa9, 0x2f,
394            0x14, 0xbd, 0xc3, 0xa6, 0xa6, 0x36, 0xa6, 0xa0,
395            0x4d, 0xaf, 0xfb, 0xc0, 0xff, 0x7c, 0xc2, 0x55,
396            0x68, 0xea, 0xf4, 0x36, 0x55, 0xb6, 0xa3, 0xe9,
397        ];
398        let challenge: [u8; HASH_LEN] = [
399            0x10, 0xd6, 0xf5, 0xc8, 0xb2, 0xa8, 0x60, 0xc5,
400            0x9a, 0xf7, 0xe7, 0x40, 0x4c, 0x21, 0x4a, 0x10,
401            0x6f, 0x07, 0xa7, 0x9d, 0x67, 0xeb, 0xfc, 0xee,
402            0xa6, 0xaf, 0xc9, 0x65, 0x88, 0x4f, 0x40, 0x12,
403        ];
404        let other_data: [u8; 13] = [
405            0x28, 0x00, 0x05, 0x00,
406            0x00, 0x00, 0x00,
407            0x00, 0x00, 0x00, 0x00,
408            0x00, 0x00,
409        ];
410        let serial: [u8; 9] = [
411            0x01, 0x23, 0xa1, 0xb2, 0xc3, 0xd4, 0xe5, 0xf6, 0xee,
412        ];
413
414        let got = checkmac_response(&slot_value, &challenge, &other_data, &serial);
415        let expected: [u8; HASH_LEN] = [
416            0xd8, 0xab, 0x16, 0xa7, 0x5d, 0xc3, 0xbc, 0x16,
417            0xca, 0xd1, 0xd9, 0x55, 0x4d, 0x24, 0xbb, 0x95,
418            0x47, 0x6f, 0x55, 0x90, 0x5e, 0x91, 0xb8, 0x1d,
419            0x86, 0x60, 0x4c, 0x32, 0x77, 0x18, 0x4d, 0x1f,
420        ];
421        assert_eq!(got, expected, "host CheckMac formula diverges from CryptoAuthLib (v3)");
422    }
423
424    #[test]
425    fn checkmac_response_changes_with_each_input()
426    {
427        let slot_value = [0xAAu8; HASH_LEN];
428        let challenge = [0xBBu8; HASH_LEN];
429        let other_data = [0xCCu8; 13];
430        let serial = [0xDDu8; 9];
431        let base = checkmac_response(&slot_value, &challenge, &other_data, &serial);
432
433        let mut v = slot_value;
434        v[0] ^= 0xFF;
435        assert_ne!(checkmac_response(&v, &challenge, &other_data, &serial), base);
436
437        let mut c = challenge;
438        c[0] ^= 0xFF;
439        assert_ne!(checkmac_response(&slot_value, &c, &other_data, &serial), base);
440
441        // All 13 bytes of other_data are consumed by the formula in
442        // three chunks: [0..4], [4..7], [7..11], [11..13]. Mutating any
443        // byte must flip the digest.
444        let mut o = other_data;
445        o[0] ^= 0xFF;
446        assert_ne!(checkmac_response(&slot_value, &challenge, &o, &serial), base);
447
448        // Only serial[0], [1], and [8] participate in the hash. Pick
449        // one that is in the formula to assert it matters.
450        let mut s = serial;
451        s[8] ^= 0xFF;
452        assert_ne!(checkmac_response(&slot_value, &challenge, &other_data, &s), base);
453    }
454
455    #[test]
456    fn checkmac_response_uses_every_other_data_byte()
457    {
458        // The CheckMac formula consumes all 13 bytes of other_data,
459        // split into [0..4], [4..7], [7..11], [11..13]. Document this
460        // by mutating each byte in turn and asserting the digest flips.
461        let slot_value = [0xAAu8; HASH_LEN];
462        let challenge = [0xBBu8; HASH_LEN];
463        let other_data = [0xCCu8; 13];
464        let serial = [0xDDu8; 9];
465        let base = checkmac_response(&slot_value, &challenge, &other_data, &serial);
466
467        for index in 0..13
468        {
469            let mut o = other_data;
470            o[index] ^= 0xFF;
471            assert_ne!(
472                checkmac_response(&slot_value, &challenge, &o, &serial),
473                base,
474                "other_data byte {index} should affect the MAC",
475            );
476        }
477    }
478
479    #[test]
480    fn checkmac_response_only_uses_serial_bytes_0_1_8()
481    {
482        // The CheckMac formula consumes serial[8] (1 byte) and
483        // serial[0..2] (2 bytes). Bytes 2..8 do NOT participate in the
484        // hash. Document this so any regression that mistakenly mixes
485        // in additional serial bytes gets caught here.
486        let slot_value = [0xAAu8; HASH_LEN];
487        let challenge = [0xBBu8; HASH_LEN];
488        let other_data = [0xCCu8; 13];
489        let serial = [0xDDu8; 9];
490        let base = checkmac_response(&slot_value, &challenge, &other_data, &serial);
491
492        // Bytes 2..=7 are ignored.
493        for ignored_index in 2..=7
494        {
495            let mut s = serial;
496            s[ignored_index] ^= 0xFF;
497            assert_eq!(
498                checkmac_response(&slot_value, &challenge, &other_data, &s),
499                base,
500                "serial byte {ignored_index} should NOT affect the MAC",
501            );
502        }
503
504        // Bytes 0, 1, 8 are consumed.
505        for used_index in [0, 1, 8]
506        {
507            let mut s = serial;
508            s[used_index] ^= 0xFF;
509            assert_ne!(
510                checkmac_response(&slot_value, &challenge, &other_data, &s),
511                base,
512                "serial byte {used_index} should affect the MAC",
513            );
514        }
515    }
516
517    #[test]
518    fn checkmac_other_data_populates_known_bytes()
519    {
520        let serial = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0x00];
521        let data = checkmac_other_data(5, &serial);
522        assert_eq!(data[0], 0x28);  // opcode
523        assert_eq!(data[1], 0x00);  // mode
524        assert_eq!(data[2], 5);     // key id lo
525        assert_eq!(data[3], 0x00);  // key id hi
526    }
527}