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}