Skip to main content

hsm_crypto_service/
encrypted_write.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//! Host-side cryptography for the ATECC's encrypted write flow.
17//!
18//! The chip can write a 32-byte slot only after the host has set up a
19//! shared "session key" in `TempKey` via `Nonce` + `GenDig`. The host must
20//! then encrypt the new value and produce a MAC that the chip can verify.
21//!
22//! The full sequence is:
23//!
24//! 1. Host generates a random 32-byte input and sends it as
25//!    `Nonce(passthrough)`. The chip's `TempKey` is now equal to that input.
26//! 2. Host issues `GenDig(zone=Data, key_id=io_slot)`. The chip updates
27//!    `TempKey` to
28//!    `SHA-256(io_key || opcode || param1 || param2 || sn[8] || sn[0..2]
29//!     || zeros(25) || TempKey_prev)`.
30//! 3. The host can recompute the same `TempKey` because it knows the
31//!    `io_key` (passed in at provisioning) and all the other inputs.
32//! 4. To write `plaintext` (32 bytes) into `target_slot`, the host sends
33//!    `Write` with `data = ciphertext || mac` where:
34//!    - `ciphertext[i] = plaintext[i] XOR TempKey[i]`
35//!    - `mac = SHA-256(io_key || opcode_write || param1 || param2
36//!       || sn[8] || sn[0..2] || zeros(25) || TempKey || plaintext)`
37//!
38//! The chip recomputes the MAC, validates it, decrypts, and stores.
39//!
40//! This module exposes the pure host-side helpers. The orchestration
41//! against the live chip lives in [`crate::service::CryptoService`].
42
43use atecc608b::command::read_write::data_address;
44use atecc608b::Slot;
45use sha2::{Digest, Sha256};
46
47/// Length of an ATECC chip serial number in bytes (as read from the
48/// config zone).
49pub(crate) const CHIP_SERIAL_LEN: usize = 9;
50
51/// Length of a slot value (32 bytes).
52pub(crate) const SLOT_VALUE_LEN: usize = 32;
53
54/// ATECC opcode for `GenDig`.
55pub(crate) const OP_GENDIG: u8 = 0x15;
56
57/// ATECC opcode for `Write`.
58pub(crate) const OP_WRITE: u8 = 0x12;
59
60/// Zone byte encoding `Data` in the `GenDig` and Write commands.
61pub(crate) const ZONE_DATA: u8 = 0x02;
62
63/// Compute the `TempKey` value that the chip ends up with after a
64/// `Nonce(passthrough, nonce_input)` followed by
65/// `GenDig(zone=Data, key_id=io_slot)`.
66///
67/// `io_key` is the 32-byte value stored in the I/O Protection slot
68/// (slot 8 by convention in this project).
69///
70/// `nonce_input` is the 32-byte passthrough nonce that was loaded into
71/// `TempKey` before the `GenDig`.
72///
73/// `io_slot` is the slot index of the I/O Protection Key (e.g. 8).
74///
75/// `chip_serial` is the 9-byte serial number of the chip.
76#[must_use]
77pub(crate) fn derive_session_key
78(
79    io_key: &[u8; SLOT_VALUE_LEN],
80    nonce_input: &[u8; SLOT_VALUE_LEN],
81    io_slot: u8,
82    chip_serial: &[u8; CHIP_SERIAL_LEN],
83) -> [u8; SLOT_VALUE_LEN]
84{
85    // GenDig parameters that the chip mixes in.
86    let param1 = ZONE_DATA;
87    let param2_lo = io_slot;
88    let param2_hi: u8 = 0x00;
89
90    let mut hasher = Sha256::new();
91    // 32 bytes: the slot value (the key itself).
92    hasher.update(io_key);
93    // Opcode (1 byte), param1 (1), param2 (2 little-endian).
94    hasher.update([OP_GENDIG, param1, param2_lo, param2_hi]);
95    // SN[8] (1 byte) then SN[0..2] (2 bytes). On the ATECC608B, SN[8] is
96    // chip_serial[8]. The byte at index 8 in the 9-byte serial we
97    // cache from the config zone. Indices 0..2 are then SN[0] and SN[1].
98    // Cross-checked against CryptoAuthLib `atcah_gen_dig`
99    // (lib/host/atca_host.c) which reads `param->sn[8]` then
100    // `param->sn[0]` then `param->sn[1]`.
101    hasher.update(&chip_serial[8..9]);
102    hasher.update(&chip_serial[0..2]);
103    // 25 zero bytes per the GenDig formula.
104    hasher.update([0u8; 25]);
105    // Previous TempKey (the nonce input).
106    hasher.update(nonce_input);
107
108    let mut out = [0u8; SLOT_VALUE_LEN];
109    out.copy_from_slice(hasher.finalize().as_slice());
110    out
111}
112
113/// XOR-encrypt a 32-byte plaintext with the session key.
114///
115/// The chip will XOR with the same `TempKey` to recover the plaintext.
116#[must_use]
117pub(crate) fn encrypt_payload
118(
119    plaintext: &[u8; SLOT_VALUE_LEN],
120    session_key: &[u8; SLOT_VALUE_LEN],
121) -> [u8; SLOT_VALUE_LEN]
122{
123    let mut ciphertext = [0u8; SLOT_VALUE_LEN];
124    // XOR pad: ciphertext[i] = plaintext[i] ^ session_key[i] for all i.
125    for ((dst, p), s) in ciphertext
126        .iter_mut()
127        .zip(plaintext.iter())
128        .zip(session_key.iter())
129    {
130        *dst = p ^ s;
131    }
132    ciphertext
133}
134
135/// Compute the MAC that the chip expects to find appended to the
136/// ciphertext in an encrypted write.
137///
138/// `session_key` is the value of `TempKey` *after* `Nonce + GenDig`,
139/// reproduced on the host side via [`derive_session_key`]. The `io_key`
140/// itself does not appear directly in this MAC: it has already been
141/// absorbed into the `session_key`, which is the actual block-1 input of
142/// the SHA-256 here (see `CryptoAuthLib` `atcah_write_auth_mac`).
143///
144/// `target_slot` is the slot being written (e.g. slot 5 for the PIN hash).
145/// `target_block` is which 32-byte block within the slot (always 0 for
146/// our single-block slots).
147///
148/// The slot/block address bytes are derived via
149/// [`atecc608b::command::read_write::data_address`] so this module never
150/// duplicates the chip's address-byte layout: a single source of truth
151/// lives in the driver.
152#[must_use]
153pub(crate) fn write_mac
154(
155    session_key: &[u8; SLOT_VALUE_LEN],
156    plaintext: &[u8; SLOT_VALUE_LEN],
157    target_slot: Slot,
158    target_block: u8,
159    chip_serial: &[u8; CHIP_SERIAL_LEN],
160) -> [u8; SLOT_VALUE_LEN]
161{
162    // Reconstruct the Write command parameters. The chip uses these
163    // when computing its own copy of the MAC. The Write parameters used
164    // for encrypted 32-byte writes set both the "32 byte" and
165    // "encrypted" flags in param1.
166    let param1 = ZONE_DATA | 0x80 | 0x40;
167    // param2 is the same little-endian u16 the driver puts on the wire:
168    // see `data_address` for the slot/block/offset bit layout.
169    let address = data_address(target_slot, target_block, 0);
170    let param2_lo = (address & 0xFF) as u8;
171    let param2_hi = (address >> 8) as u8;
172
173    let mut hasher = Sha256::new();
174    // CryptoAuthLib `atcah_write_auth_mac` (lib/host/atca_host.c).
175    hasher.update(session_key);
176    hasher.update([OP_WRITE, param1, param2_lo, param2_hi]);
177    hasher.update(&chip_serial[8..9]);
178    hasher.update(&chip_serial[0..2]);
179    hasher.update([0u8; 25]);
180    hasher.update(plaintext);
181
182    let mut out = [0u8; SLOT_VALUE_LEN];
183    out.copy_from_slice(hasher.finalize().as_slice());
184    out
185}
186
187/// Assemble the 64-byte payload (`ciphertext || mac`) that the driver
188/// expects in [`atecc608b::AteccChannel::write_32_encrypted`].
189#[must_use]
190pub(crate) fn build_encrypted_write_payload
191(
192    ciphertext: &[u8; SLOT_VALUE_LEN],
193    mac: &[u8; SLOT_VALUE_LEN],
194) -> [u8; 64]
195{
196    let mut out = [0u8; 64];
197    out[0..32].copy_from_slice(ciphertext);
198    out[32..64].copy_from_slice(mac);
199    out
200}
201
202#[cfg(test)]
203mod tests
204{
205    use super::*;
206
207    #[test]
208    fn session_key_is_deterministic()
209    {
210        let io_key = [0x11u8; SLOT_VALUE_LEN];
211        let nonce = [0x22u8; SLOT_VALUE_LEN];
212        let serial = [0x33u8; CHIP_SERIAL_LEN];
213        let k1 = derive_session_key(&io_key, &nonce, 8, &serial);
214        let k2 = derive_session_key(&io_key, &nonce, 8, &serial);
215        assert_eq!(k1, k2);
216    }
217
218    #[test]
219    fn session_key_changes_with_each_input()
220    {
221        let io_key = [0x11u8; SLOT_VALUE_LEN];
222        let nonce = [0x22u8; SLOT_VALUE_LEN];
223        let serial = [0x33u8; CHIP_SERIAL_LEN];
224        let base = derive_session_key(&io_key, &nonce, 8, &serial);
225
226        let mut io_key2 = io_key;
227        io_key2[0] ^= 0xFF;
228        assert_ne!(derive_session_key(&io_key2, &nonce, 8, &serial), base);
229
230        let mut nonce2 = nonce;
231        nonce2[0] ^= 0xFF;
232        assert_ne!(derive_session_key(&io_key, &nonce2, 8, &serial), base);
233
234        assert_ne!(derive_session_key(&io_key, &nonce, 9, &serial), base);
235
236        let mut serial2 = serial;
237        serial2[0] ^= 0xFF;
238        assert_ne!(derive_session_key(&io_key, &nonce, 8, &serial2), base);
239    }
240
241    #[test]
242    fn encrypt_is_xor_and_self_inverse()
243    {
244        let plaintext = [0xAAu8; SLOT_VALUE_LEN];
245        let key = [0x55u8; SLOT_VALUE_LEN];
246        let ciphertext = encrypt_payload(&plaintext, &key);
247        // 0xAA XOR 0x55 == 0xFF
248        assert!(ciphertext.iter().all(|&b| b == 0xFF));
249        // XOR is self-inverse: applying the same key recovers the
250        // plaintext.
251        let recovered = encrypt_payload(&ciphertext, &key);
252        assert_eq!(recovered, plaintext);
253    }
254
255    #[test]
256    fn write_mac_is_deterministic()
257    {
258        let session = [0x22u8; SLOT_VALUE_LEN];
259        let plaintext = [0x33u8; SLOT_VALUE_LEN];
260        let serial = [0x44u8; CHIP_SERIAL_LEN];
261        let slot = Slot::new(5).unwrap();
262        let m1 = write_mac(&session, &plaintext, slot, 0, &serial);
263        let m2 = write_mac(&session, &plaintext, slot, 0, &serial);
264        assert_eq!(m1, m2);
265    }
266
267    #[test]
268    fn write_mac_changes_with_target_slot()
269    {
270        let session = [0x22u8; SLOT_VALUE_LEN];
271        let plaintext = [0x33u8; SLOT_VALUE_LEN];
272        let serial = [0x44u8; CHIP_SERIAL_LEN];
273        let slot_5 = Slot::new(5).unwrap();
274        let slot_6 = Slot::new(6).unwrap();
275        let m_slot_5 = write_mac(&session, &plaintext, slot_5, 0, &serial);
276        let m_slot_6 = write_mac(&session, &plaintext, slot_6, 0, &serial);
277        assert_ne!(m_slot_5, m_slot_6);
278    }
279
280    #[test]
281    fn build_payload_concatenates_correctly()
282    {
283        let c = [0xAAu8; SLOT_VALUE_LEN];
284        let m = [0xBBu8; SLOT_VALUE_LEN];
285        let payload = build_encrypted_write_payload(&c, &m);
286        assert!(payload[0..32].iter().all(|&b| b == 0xAA));
287        assert!(payload[32..64].iter().all(|&b| b == 0xBB));
288    }
289}