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}