dig_keystore/format.rs
1//! On-disk file format v1.
2//!
3//! # Byte layout
4//!
5//! ```text
6//! ┌─────────────────────────────────────────────────────────────┐
7//! │ 6 bytes MAGIC "DIGVK1" or "DIGLW1" │
8//! │ 2 bytes FORMAT_VERSION 0x0001 │
9//! │ 2 bytes KEY_SCHEME 0x0001=BlsSigning │
10//! │ 0x0003=L1WalletBls │
11//! │ 1 byte KDF_ID 0x01 = Argon2id │
12//! │ 4 bytes KDF_MEMORY_KIB u32 (default 65536 = 64 MiB) │
13//! │ 4 bytes KDF_ITERATIONS u32 (default 3) │
14//! │ 1 byte KDF_LANES u8 (default 4) │
15//! │ 1 byte CIPHER_ID 0x01 = AES-256-GCM │
16//! │ 16 bytes SALT random per file │
17//! │ 12 bytes NONCE random per file │
18//! │ 4 bytes PAYLOAD_LEN u32 (ciphertext+tag length) │
19//! │ N bytes CIPHERTEXT+TAG AES-256-GCM(plaintext) || tag │
20//! │ 4 bytes CRC32 over all preceding bytes │
21//! └─────────────────────────────────────────────────────────────┘
22//! ```
23//!
24//! Total header size is **53 bytes**. Total file size for a 32-byte secret
25//! (every shipped scheme) is `53 + 48 + 4 = 105 bytes`.
26//!
27//! # Encoding conventions
28//!
29//! - **All multi-byte integers big-endian.** Consistent with the Chia wire
30//! format which `dig-protocol` re-exports.
31//! - **Header bound into AES-GCM AAD.** The 53 bytes of header are fed to
32//! `aes-gcm::encrypt` as associated data so any header edit invalidates
33//! the authentication tag — no separate header MAC needed.
34//! - **Outer CRC-32.** Provides a fast-fail check before we spend ~0.5 s on
35//! Argon2id for a file that's been bit-rotten. CRC is **not** a security
36//! check — it catches accidents, not attacks.
37//!
38//! # Why not bincode / serde
39//!
40//! We hand-code the encoder/decoder so the byte layout is exact and stable
41//! across every Rust version and serde variant. Keystore files are meant to
42//! survive operator OS upgrades, Rust-toolchain churn, and occasionally
43//! cross-tool migration. A serde-derived format would couple the on-disk
44//! shape to the current serde conventions; hand-coding makes the format
45//! a proper specification (see `docs/resources/SPEC.md`).
46//!
47//! # Forward compatibility
48//!
49//! `FORMAT_VERSION` is a `u16`, giving room for 65 535 versions. This crate
50//! parses `0x0001` only; older or newer versions fail cleanly with
51//! [`KeystoreError::UnsupportedFormat`]. When v2 ships (e.g., to add a new
52//! KDF algorithm or extend `CipherId`), the decoder will route based on
53//! `FORMAT_VERSION` and the shipped v1 files will continue to load.
54//!
55//! # References
56//!
57//! - [IEEE 802.3 CRC-32](https://en.wikipedia.org/wiki/Cyclic_redundancy_check) — the
58//! CRC polynomial used by [`crc32fast`](https://docs.rs/crc32fast).
59//! - [`aes-gcm` AAD](https://docs.rs/aes-gcm/latest/aes_gcm/struct.Aes256Gcm.html#method.encrypt)
60//! — the associated-data semantics we rely on.
61//! - [RFC 5116 §3](https://datatracker.ietf.org/doc/html/rfc5116#section-3) —
62//! generic AEAD interface including AAD definition.
63
64use std::convert::TryInto;
65
66use crate::cipher::TAG_SIZE;
67use crate::error::{KeystoreError, Result};
68
69/// File format version supported by this library.
70pub const FORMAT_VERSION_V1: u16 = 0x0001;
71
72/// Header (fixed-size portion of the file).
73pub(crate) const HEADER_SIZE: usize = 6 // magic
74 + 2 // format version
75 + 2 // scheme id
76 + 1 // kdf id
77 + 4 // kdf memory
78 + 4 // kdf iterations
79 + 1 // kdf lanes
80 + 1 // cipher id
81 + 16 // salt
82 + 12 // nonce
83 + 4; // payload len
84 // 53 bytes total.
85
86/// Footer (CRC32) size.
87pub(crate) const FOOTER_SIZE: usize = 4;
88
89/// Identifies the symmetric cipher used.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[repr(u8)]
92pub enum CipherId {
93 /// AES-256 in Galois/Counter Mode (RFC 5116).
94 Aes256Gcm = 0x01,
95}
96
97impl CipherId {
98 fn from_byte(b: u8) -> Result<Self> {
99 match b {
100 0x01 => Ok(Self::Aes256Gcm),
101 other => Err(KeystoreError::UnsupportedCipher(other)),
102 }
103 }
104}
105
106/// Identifies the key derivation function used.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108#[repr(u8)]
109pub enum KdfId {
110 /// Argon2id per RFC 9106.
111 Argon2id = 0x01,
112}
113
114impl KdfId {
115 fn from_byte(b: u8) -> Result<Self> {
116 match b {
117 0x01 => Ok(Self::Argon2id),
118 other => Err(KeystoreError::UnsupportedKdf(other)),
119 }
120 }
121}
122
123/// Parameters for the key derivation function.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub struct KdfParams {
126 /// Function identifier.
127 pub id: KdfId,
128 /// Memory cost in KiB.
129 pub memory_kib: u32,
130 /// Iteration count.
131 pub iterations: u32,
132 /// Parallelism (lanes).
133 pub lanes: u8,
134}
135
136impl KdfParams {
137 /// Recommended default (matches `dig-l1-wallet`): 64 MiB / 3 iterations / 4 lanes.
138 pub const DEFAULT: Self = Self {
139 id: KdfId::Argon2id,
140 memory_kib: 65536,
141 iterations: 3,
142 lanes: 4,
143 };
144
145 /// Strong preset for high-value keys: 256 MiB / 4 iterations / 4 lanes.
146 pub const STRONG: Self = Self {
147 id: KdfId::Argon2id,
148 memory_kib: 262144,
149 iterations: 4,
150 lanes: 4,
151 };
152
153 /// Fast preset suitable only for tests: 8 MiB / 1 iteration / 1 lane.
154 /// Never use this for real keys.
155 #[doc(hidden)]
156 pub const FAST_TEST: Self = Self {
157 id: KdfId::Argon2id,
158 memory_kib: 8 * 1024,
159 iterations: 1,
160 lanes: 1,
161 };
162}
163
164impl Default for KdfParams {
165 fn default() -> Self {
166 Self::DEFAULT
167 }
168}
169
170/// Parsed file header.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct KeystoreHeader {
173 /// Magic bytes (identifies scheme family).
174 pub magic: [u8; 6],
175 /// Format version.
176 pub format_version: u16,
177 /// Scheme id — must match the `KeyScheme::SCHEME_ID` of the type parameter.
178 pub scheme_id: u16,
179 /// KDF parameters used to derive the encryption key.
180 pub kdf: KdfParams,
181 /// Symmetric cipher used.
182 pub cipher: CipherId,
183 /// Random salt for the KDF.
184 pub salt: [u8; 16],
185 /// Random nonce for AES-GCM.
186 pub nonce: [u8; 12],
187 /// Length of the following ciphertext + tag blob, in bytes.
188 pub payload_len: u32,
189}
190
191impl KeystoreHeader {
192 /// Serialize the header into `HEADER_SIZE` bytes.
193 pub(crate) fn encode(&self) -> [u8; HEADER_SIZE] {
194 let mut out = [0u8; HEADER_SIZE];
195 let mut i = 0;
196 out[i..i + 6].copy_from_slice(&self.magic);
197 i += 6;
198 out[i..i + 2].copy_from_slice(&self.format_version.to_be_bytes());
199 i += 2;
200 out[i..i + 2].copy_from_slice(&self.scheme_id.to_be_bytes());
201 i += 2;
202 out[i] = self.kdf.id as u8;
203 i += 1;
204 out[i..i + 4].copy_from_slice(&self.kdf.memory_kib.to_be_bytes());
205 i += 4;
206 out[i..i + 4].copy_from_slice(&self.kdf.iterations.to_be_bytes());
207 i += 4;
208 out[i] = self.kdf.lanes;
209 i += 1;
210 out[i] = self.cipher as u8;
211 i += 1;
212 out[i..i + 16].copy_from_slice(&self.salt);
213 i += 16;
214 out[i..i + 12].copy_from_slice(&self.nonce);
215 i += 12;
216 out[i..i + 4].copy_from_slice(&self.payload_len.to_be_bytes());
217 i += 4;
218 debug_assert_eq!(i, HEADER_SIZE);
219 out
220 }
221
222 /// Parse a header from raw bytes. Returns `UnknownMagic`, `UnsupportedFormat`,
223 /// `UnsupportedKdf`, or `UnsupportedCipher` on failure.
224 pub(crate) fn decode(bytes: &[u8]) -> Result<Self> {
225 if bytes.len() < HEADER_SIZE {
226 return Err(KeystoreError::Truncated {
227 claimed: HEADER_SIZE,
228 available: bytes.len(),
229 });
230 }
231 let mut i = 0;
232 let magic: [u8; 6] = bytes[i..i + 6].try_into().unwrap();
233 i += 6;
234 if !is_known_magic(&magic) {
235 return Err(KeystoreError::UnknownMagic { saw: magic });
236 }
237
238 let format_version = u16::from_be_bytes(bytes[i..i + 2].try_into().unwrap());
239 i += 2;
240 if format_version != FORMAT_VERSION_V1 {
241 return Err(KeystoreError::UnsupportedFormat {
242 found: format_version,
243 });
244 }
245
246 let scheme_id = u16::from_be_bytes(bytes[i..i + 2].try_into().unwrap());
247 i += 2;
248
249 let kdf_id = KdfId::from_byte(bytes[i])?;
250 i += 1;
251 let memory_kib = u32::from_be_bytes(bytes[i..i + 4].try_into().unwrap());
252 i += 4;
253 let iterations = u32::from_be_bytes(bytes[i..i + 4].try_into().unwrap());
254 i += 4;
255 let lanes = bytes[i];
256 i += 1;
257
258 let cipher = CipherId::from_byte(bytes[i])?;
259 i += 1;
260
261 let salt: [u8; 16] = bytes[i..i + 16].try_into().unwrap();
262 i += 16;
263 let nonce: [u8; 12] = bytes[i..i + 12].try_into().unwrap();
264 i += 12;
265 let payload_len = u32::from_be_bytes(bytes[i..i + 4].try_into().unwrap());
266 i += 4;
267 debug_assert_eq!(i, HEADER_SIZE);
268
269 Ok(Self {
270 magic,
271 format_version,
272 scheme_id,
273 kdf: KdfParams {
274 id: kdf_id,
275 memory_kib,
276 iterations,
277 lanes,
278 },
279 cipher,
280 salt,
281 nonce,
282 payload_len,
283 })
284 }
285}
286
287/// Known magic prefixes. Extended by schemes; see `scheme/*`.
288fn is_known_magic(m: &[u8; 6]) -> bool {
289 // Matches MAGIC constants in the scheme impls. Kept inline here for
290 // decode-time validation without needing generic parameters.
291 matches!(m, b"DIGVK1" | b"DIGLW1")
292}
293
294/// Serialize the complete file: `header || ciphertext_and_tag || crc32`.
295pub(crate) fn encode_file(header: &KeystoreHeader, ciphertext_and_tag: &[u8]) -> Vec<u8> {
296 let mut out = Vec::with_capacity(HEADER_SIZE + ciphertext_and_tag.len() + FOOTER_SIZE);
297 out.extend_from_slice(&header.encode());
298 out.extend_from_slice(ciphertext_and_tag);
299 let crc = crc32fast::hash(&out);
300 out.extend_from_slice(&crc.to_be_bytes());
301 out
302}
303
304/// Parse a complete file: returns `(header, ciphertext_and_tag, header_bytes_for_aad)`.
305///
306/// The header bytes are returned separately so they can be fed to AES-GCM as AAD.
307pub(crate) fn decode_file(bytes: &[u8]) -> Result<(KeystoreHeader, Vec<u8>, [u8; HEADER_SIZE])> {
308 if bytes.len() < HEADER_SIZE + TAG_SIZE + FOOTER_SIZE {
309 return Err(KeystoreError::Truncated {
310 claimed: HEADER_SIZE + TAG_SIZE + FOOTER_SIZE,
311 available: bytes.len(),
312 });
313 }
314
315 // CRC32 is over everything except the trailing 4 bytes.
316 let crc_stored = u32::from_be_bytes(bytes[bytes.len() - 4..].try_into().unwrap());
317 let crc_computed = crc32fast::hash(&bytes[..bytes.len() - 4]);
318 if crc_stored != crc_computed {
319 return Err(KeystoreError::CrcMismatch {
320 stored: crc_stored,
321 computed: crc_computed,
322 });
323 }
324
325 let header_bytes: [u8; HEADER_SIZE] = bytes[..HEADER_SIZE].try_into().unwrap();
326 let header = KeystoreHeader::decode(&header_bytes)?;
327
328 let payload_start = HEADER_SIZE;
329 let payload_end = payload_start + header.payload_len as usize;
330 if payload_end + FOOTER_SIZE > bytes.len() {
331 return Err(KeystoreError::Truncated {
332 claimed: payload_end + FOOTER_SIZE,
333 available: bytes.len(),
334 });
335 }
336
337 let ciphertext_and_tag = bytes[payload_start..payload_end].to_vec();
338 Ok((header, ciphertext_and_tag, header_bytes))
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn sample_header() -> KeystoreHeader {
346 KeystoreHeader {
347 magic: *b"DIGVK1",
348 format_version: FORMAT_VERSION_V1,
349 scheme_id: 0x0001,
350 kdf: KdfParams::FAST_TEST,
351 cipher: CipherId::Aes256Gcm,
352 salt: [9u8; 16],
353 nonce: [2u8; 12],
354 payload_len: 48,
355 }
356 }
357
358 /// **Proves:** `KeystoreHeader::encode` then `KeystoreHeader::decode`
359 /// recovers every field of the header bit-exactly.
360 ///
361 /// **Why it matters:** The header carries the scheme id, KDF params,
362 /// salt, nonce, and payload length — every byte must round-trip so the
363 /// file's internal pointers stay valid. Also pins [`HEADER_SIZE`] to 53
364 /// bytes: the encoded form must exactly match the pre-declared size.
365 ///
366 /// **Catches:** an endian bug (writing LE but reading BE), a field
367 /// reordering, or a field accidentally dropped from either the encoder
368 /// or the decoder.
369 #[test]
370 fn header_roundtrip() {
371 let h = sample_header();
372 let bytes = h.encode();
373 assert_eq!(bytes.len(), HEADER_SIZE);
374 let h2 = KeystoreHeader::decode(&bytes).unwrap();
375 assert_eq!(h, h2);
376 }
377
378 /// **Proves:** a file whose first 6 bytes are not a recognized magic
379 /// (here we flip the first byte from `D` → `X`) is rejected with
380 /// [`KeystoreError::UnknownMagic`] before any cryptography runs.
381 ///
382 /// **Why it matters:** If a user points `Keystore::load` at an arbitrary
383 /// file on disk (a `.txt`, a deleted keystore, a different project's
384 /// wallet file), we must fail with a clear error rather than attempting
385 /// Argon2 + AES-GCM on garbage. This is also a cheap DoS guard —
386 /// bogus files reject in microseconds.
387 ///
388 /// **Catches:** a regression that skips the magic check and runs the
389 /// KDF anyway; a new scheme whose magic was registered in a constant
390 /// but not in `is_known_magic`.
391 #[test]
392 fn unknown_magic_rejected() {
393 let mut bytes = sample_header().encode();
394 bytes[0] = b'X';
395 let err = KeystoreHeader::decode(&bytes).unwrap_err();
396 assert!(matches!(err, KeystoreError::UnknownMagic { .. }));
397 }
398
399 /// **Proves:** `FORMAT_VERSION = 999` is rejected with
400 /// [`KeystoreError::UnsupportedFormat { found: 999 }`].
401 ///
402 /// **Why it matters:** Future versions (v2, v3) will bump the format
403 /// version. A v1-only binary must refuse v2 files with a clear error
404 /// rather than misinterpret their bytes. Conversely, a v2 binary will
405 /// see this test's behaviour as the correct template for how to handle
406 /// v1 files if we ever deprecate them.
407 ///
408 /// **Catches:** a decoder that silently assumes `FORMAT_VERSION_V1`
409 /// and ignores the field.
410 #[test]
411 fn bad_format_version_rejected() {
412 let mut h = sample_header();
413 h.format_version = 999;
414 let err = KeystoreHeader::decode(&h.encode()).unwrap_err();
415 assert!(matches!(
416 err,
417 KeystoreError::UnsupportedFormat { found: 999 }
418 ));
419 }
420
421 /// **Proves:** the KDF-id byte at offset 10 is checked — writing `0xFF`
422 /// (an unassigned value) is rejected with [`KeystoreError::UnsupportedKdf`].
423 ///
424 /// **Why it matters:** The KDF id is a forward-compatibility hinge —
425 /// when scrypt or a future KDF is added, files with the new id will
426 /// fail on older binaries this way. If the check were missing, the
427 /// decoder would silently try to use Argon2id on scrypt-derived
428 /// parameters, producing wrong keys.
429 ///
430 /// **Catches:** a decoder that hard-codes `KdfId::Argon2id` without
431 /// reading and validating the header byte.
432 #[test]
433 fn bad_kdf_id_rejected() {
434 let mut bytes = sample_header().encode();
435 // KDF id byte is at offset 6 + 2 + 2 = 10
436 bytes[10] = 0xFF;
437 let err = KeystoreHeader::decode(&bytes).unwrap_err();
438 assert!(matches!(err, KeystoreError::UnsupportedKdf(0xFF)));
439 }
440
441 /// **Proves:** the cipher-id byte at offset 20 is checked — writing
442 /// `0xFE` (an unassigned value) is rejected with
443 /// [`KeystoreError::UnsupportedCipher`].
444 ///
445 /// **Why it matters:** Parallels the KDF-id check. If someone swaps in
446 /// ChaCha20-Poly1305 as cipher id `0x02`, older binaries must reject
447 /// that file rather than attempt AES-256-GCM on it.
448 ///
449 /// **Catches:** a decoder that hard-codes `CipherId::Aes256Gcm`.
450 #[test]
451 fn bad_cipher_id_rejected() {
452 let mut bytes = sample_header().encode();
453 // Cipher id byte is at offset 6+2+2 + 1+4+4+1 = 20
454 bytes[20] = 0xFE;
455 let err = KeystoreHeader::decode(&bytes).unwrap_err();
456 assert!(matches!(err, KeystoreError::UnsupportedCipher(0xFE)));
457 }
458
459 /// **Proves:** whole-file round-trip (`encode_file` → `decode_file`)
460 /// recovers the header and the payload bit-exactly, and the CRC-32 is
461 /// validated.
462 ///
463 /// **Why it matters:** This is the unit-test-level equivalent of
464 /// "create a file, load it back, make sure nothing got corrupted."
465 /// Decouples the file-layout logic from all upstream cryptography so
466 /// format bugs surface in isolation.
467 ///
468 /// **Catches:** off-by-one in CRC coverage (e.g., crc computed over
469 /// the whole file including itself), header/payload size miscalculation.
470 #[test]
471 fn file_roundtrip_valid_crc() {
472 let h = sample_header();
473 let payload = vec![0x42u8; h.payload_len as usize];
474 let bytes = encode_file(&h, &payload);
475 let (h2, pl2, _) = decode_file(&bytes).unwrap();
476 assert_eq!(h2, h);
477 assert_eq!(pl2, payload);
478 }
479
480 /// **Proves:** flipping the last byte of a file (inside the 4-byte CRC-32
481 /// footer) causes [`KeystoreError::CrcMismatch`] at decode time.
482 ///
483 /// **Why it matters:** CRC is our fast-fail for bit-rot and accidental
484 /// truncation. It runs before we spend ~0.5s on Argon2id, so a torn
485 /// file errors in microseconds rather than forcing the user to wait for
486 /// a KDF run. This test pins the CRC coverage: the last byte must be
487 /// included in the input to the check.
488 ///
489 /// **Catches:** an off-by-one where CRC is computed over `&bytes[..len]`
490 /// instead of `&bytes[..len - 4]`, or where the stored-CRC is read
491 /// from the wrong offset.
492 #[test]
493 fn crc_mismatch_detected() {
494 let h = sample_header();
495 let payload = vec![0x42u8; h.payload_len as usize];
496 let mut bytes = encode_file(&h, &payload);
497 let last = bytes.len() - 1;
498 bytes[last] ^= 0xFF;
499 let err = decode_file(&bytes).unwrap_err();
500 assert!(matches!(err, KeystoreError::CrcMismatch { .. }));
501 }
502
503 /// **Proves:** a file shorter than `HEADER_SIZE + TAG_SIZE + FOOTER_SIZE`
504 /// is rejected with [`KeystoreError::Truncated`] rather than producing
505 /// a panic or an out-of-bounds slice.
506 ///
507 /// **Why it matters:** Partial writes, network transfers cut mid-file,
508 /// `truncate(path, n)` attacks — all should fail cleanly. A panic here
509 /// would crash the fullnode / validator binary at startup.
510 ///
511 /// **Catches:** a slice-index regression that would panic on truncated
512 /// input; e.g. `bytes[0..HEADER_SIZE]` without length check.
513 #[test]
514 fn truncated_file_rejected() {
515 let err = decode_file(&[0u8; 10]).unwrap_err();
516 assert!(matches!(err, KeystoreError::Truncated { .. }));
517 }
518
519 /// **Proves:** the header encoded to bytes is exactly 53 bytes long, and
520 /// the [`HEADER_SIZE`] constant equals 53.
521 ///
522 /// **Why it matters:** This is a wire-format constant — every keystore
523 /// file ever written has 53-byte header. If we change it, every
524 /// deployed keystore becomes unreadable. The test makes accidental
525 /// drift impossible without a visible test failure.
526 ///
527 /// **Catches:** adding a field to [`KeystoreHeader`] without updating
528 /// [`HEADER_SIZE`], or vice versa.
529 #[test]
530 fn header_size_constant_correct() {
531 assert_eq!(sample_header().encode().len(), HEADER_SIZE);
532 assert_eq!(HEADER_SIZE, 53);
533 }
534}