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/*`.
288///
289/// `DIGOP1` is not a [`crate::scheme::KeyScheme`] magic — it identifies the
290/// [`crate::opaque`] container (arbitrary-length password-sealed secrets, no
291/// typed public-key derivation). Registering it here is purely additive: it
292/// teaches the decoder a new accepted magic without touching how `DIGVK1`/
293/// `DIGLW1` are recognized or decoded (§5.1 backwards-compat spirit).
294fn is_known_magic(m: &[u8; 6]) -> bool {
295 // Matches MAGIC constants in the scheme impls. Kept inline here for
296 // decode-time validation without needing generic parameters.
297 matches!(m, b"DIGVK1" | b"DIGLW1" | b"DIGOP1")
298}
299
300/// Serialize the complete file: `header || ciphertext_and_tag || crc32`.
301pub(crate) fn encode_file(header: &KeystoreHeader, ciphertext_and_tag: &[u8]) -> Vec<u8> {
302 let mut out = Vec::with_capacity(HEADER_SIZE + ciphertext_and_tag.len() + FOOTER_SIZE);
303 out.extend_from_slice(&header.encode());
304 out.extend_from_slice(ciphertext_and_tag);
305 let crc = crc32fast::hash(&out);
306 out.extend_from_slice(&crc.to_be_bytes());
307 out
308}
309
310/// Parse a complete file: returns `(header, ciphertext_and_tag, header_bytes_for_aad)`.
311///
312/// The header bytes are returned separately so they can be fed to AES-GCM as AAD.
313pub(crate) fn decode_file(bytes: &[u8]) -> Result<(KeystoreHeader, Vec<u8>, [u8; HEADER_SIZE])> {
314 if bytes.len() < HEADER_SIZE + TAG_SIZE + FOOTER_SIZE {
315 return Err(KeystoreError::Truncated {
316 claimed: HEADER_SIZE + TAG_SIZE + FOOTER_SIZE,
317 available: bytes.len(),
318 });
319 }
320
321 // CRC32 is over everything except the trailing 4 bytes.
322 let crc_stored = u32::from_be_bytes(bytes[bytes.len() - 4..].try_into().unwrap());
323 let crc_computed = crc32fast::hash(&bytes[..bytes.len() - 4]);
324 if crc_stored != crc_computed {
325 return Err(KeystoreError::CrcMismatch {
326 stored: crc_stored,
327 computed: crc_computed,
328 });
329 }
330
331 let header_bytes: [u8; HEADER_SIZE] = bytes[..HEADER_SIZE].try_into().unwrap();
332 let header = KeystoreHeader::decode(&header_bytes)?;
333
334 let payload_start = HEADER_SIZE;
335 let payload_end = payload_start + header.payload_len as usize;
336 if payload_end + FOOTER_SIZE > bytes.len() {
337 return Err(KeystoreError::Truncated {
338 claimed: payload_end + FOOTER_SIZE,
339 available: bytes.len(),
340 });
341 }
342
343 let ciphertext_and_tag = bytes[payload_start..payload_end].to_vec();
344 Ok((header, ciphertext_and_tag, header_bytes))
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 fn sample_header() -> KeystoreHeader {
352 KeystoreHeader {
353 magic: *b"DIGVK1",
354 format_version: FORMAT_VERSION_V1,
355 scheme_id: 0x0001,
356 kdf: KdfParams::FAST_TEST,
357 cipher: CipherId::Aes256Gcm,
358 salt: [9u8; 16],
359 nonce: [2u8; 12],
360 payload_len: 48,
361 }
362 }
363
364 /// **Proves:** `KeystoreHeader::encode` then `KeystoreHeader::decode`
365 /// recovers every field of the header bit-exactly.
366 ///
367 /// **Why it matters:** The header carries the scheme id, KDF params,
368 /// salt, nonce, and payload length — every byte must round-trip so the
369 /// file's internal pointers stay valid. Also pins [`HEADER_SIZE`] to 53
370 /// bytes: the encoded form must exactly match the pre-declared size.
371 ///
372 /// **Catches:** an endian bug (writing LE but reading BE), a field
373 /// reordering, or a field accidentally dropped from either the encoder
374 /// or the decoder.
375 #[test]
376 fn header_roundtrip() {
377 let h = sample_header();
378 let bytes = h.encode();
379 assert_eq!(bytes.len(), HEADER_SIZE);
380 let h2 = KeystoreHeader::decode(&bytes).unwrap();
381 assert_eq!(h, h2);
382 }
383
384 /// **Proves:** a file whose first 6 bytes are not a recognized magic
385 /// (here we flip the first byte from `D` → `X`) is rejected with
386 /// [`KeystoreError::UnknownMagic`] before any cryptography runs.
387 ///
388 /// **Why it matters:** If a user points `Keystore::load` at an arbitrary
389 /// file on disk (a `.txt`, a deleted keystore, a different project's
390 /// wallet file), we must fail with a clear error rather than attempting
391 /// Argon2 + AES-GCM on garbage. This is also a cheap DoS guard —
392 /// bogus files reject in microseconds.
393 ///
394 /// **Catches:** a regression that skips the magic check and runs the
395 /// KDF anyway; a new scheme whose magic was registered in a constant
396 /// but not in `is_known_magic`.
397 #[test]
398 fn unknown_magic_rejected() {
399 let mut bytes = sample_header().encode();
400 bytes[0] = b'X';
401 let err = KeystoreHeader::decode(&bytes).unwrap_err();
402 assert!(matches!(err, KeystoreError::UnknownMagic { .. }));
403 }
404
405 /// **Proves:** `FORMAT_VERSION = 999` is rejected with
406 /// [`KeystoreError::UnsupportedFormat { found: 999 }`].
407 ///
408 /// **Why it matters:** Future versions (v2, v3) will bump the format
409 /// version. A v1-only binary must refuse v2 files with a clear error
410 /// rather than misinterpret their bytes. Conversely, a v2 binary will
411 /// see this test's behaviour as the correct template for how to handle
412 /// v1 files if we ever deprecate them.
413 ///
414 /// **Catches:** a decoder that silently assumes `FORMAT_VERSION_V1`
415 /// and ignores the field.
416 #[test]
417 fn bad_format_version_rejected() {
418 let mut h = sample_header();
419 h.format_version = 999;
420 let err = KeystoreHeader::decode(&h.encode()).unwrap_err();
421 assert!(matches!(
422 err,
423 KeystoreError::UnsupportedFormat { found: 999 }
424 ));
425 }
426
427 /// **Proves:** the KDF-id byte at offset 10 is checked — writing `0xFF`
428 /// (an unassigned value) is rejected with [`KeystoreError::UnsupportedKdf`].
429 ///
430 /// **Why it matters:** The KDF id is a forward-compatibility hinge —
431 /// when scrypt or a future KDF is added, files with the new id will
432 /// fail on older binaries this way. If the check were missing, the
433 /// decoder would silently try to use Argon2id on scrypt-derived
434 /// parameters, producing wrong keys.
435 ///
436 /// **Catches:** a decoder that hard-codes `KdfId::Argon2id` without
437 /// reading and validating the header byte.
438 #[test]
439 fn bad_kdf_id_rejected() {
440 let mut bytes = sample_header().encode();
441 // KDF id byte is at offset 6 + 2 + 2 = 10
442 bytes[10] = 0xFF;
443 let err = KeystoreHeader::decode(&bytes).unwrap_err();
444 assert!(matches!(err, KeystoreError::UnsupportedKdf(0xFF)));
445 }
446
447 /// **Proves:** the cipher-id byte at offset 20 is checked — writing
448 /// `0xFE` (an unassigned value) is rejected with
449 /// [`KeystoreError::UnsupportedCipher`].
450 ///
451 /// **Why it matters:** Parallels the KDF-id check. If someone swaps in
452 /// ChaCha20-Poly1305 as cipher id `0x02`, older binaries must reject
453 /// that file rather than attempt AES-256-GCM on it.
454 ///
455 /// **Catches:** a decoder that hard-codes `CipherId::Aes256Gcm`.
456 #[test]
457 fn bad_cipher_id_rejected() {
458 let mut bytes = sample_header().encode();
459 // Cipher id byte is at offset 6+2+2 + 1+4+4+1 = 20
460 bytes[20] = 0xFE;
461 let err = KeystoreHeader::decode(&bytes).unwrap_err();
462 assert!(matches!(err, KeystoreError::UnsupportedCipher(0xFE)));
463 }
464
465 /// **Proves:** whole-file round-trip (`encode_file` → `decode_file`)
466 /// recovers the header and the payload bit-exactly, and the CRC-32 is
467 /// validated.
468 ///
469 /// **Why it matters:** This is the unit-test-level equivalent of
470 /// "create a file, load it back, make sure nothing got corrupted."
471 /// Decouples the file-layout logic from all upstream cryptography so
472 /// format bugs surface in isolation.
473 ///
474 /// **Catches:** off-by-one in CRC coverage (e.g., crc computed over
475 /// the whole file including itself), header/payload size miscalculation.
476 #[test]
477 fn file_roundtrip_valid_crc() {
478 let h = sample_header();
479 let payload = vec![0x42u8; h.payload_len as usize];
480 let bytes = encode_file(&h, &payload);
481 let (h2, pl2, _) = decode_file(&bytes).unwrap();
482 assert_eq!(h2, h);
483 assert_eq!(pl2, payload);
484 }
485
486 /// **Proves:** flipping the last byte of a file (inside the 4-byte CRC-32
487 /// footer) causes [`KeystoreError::CrcMismatch`] at decode time.
488 ///
489 /// **Why it matters:** CRC is our fast-fail for bit-rot and accidental
490 /// truncation. It runs before we spend ~0.5s on Argon2id, so a torn
491 /// file errors in microseconds rather than forcing the user to wait for
492 /// a KDF run. This test pins the CRC coverage: the last byte must be
493 /// included in the input to the check.
494 ///
495 /// **Catches:** an off-by-one where CRC is computed over `&bytes[..len]`
496 /// instead of `&bytes[..len - 4]`, or where the stored-CRC is read
497 /// from the wrong offset.
498 #[test]
499 fn crc_mismatch_detected() {
500 let h = sample_header();
501 let payload = vec![0x42u8; h.payload_len as usize];
502 let mut bytes = encode_file(&h, &payload);
503 let last = bytes.len() - 1;
504 bytes[last] ^= 0xFF;
505 let err = decode_file(&bytes).unwrap_err();
506 assert!(matches!(err, KeystoreError::CrcMismatch { .. }));
507 }
508
509 /// **Proves:** a file shorter than `HEADER_SIZE + TAG_SIZE + FOOTER_SIZE`
510 /// is rejected with [`KeystoreError::Truncated`] rather than producing
511 /// a panic or an out-of-bounds slice.
512 ///
513 /// **Why it matters:** Partial writes, network transfers cut mid-file,
514 /// `truncate(path, n)` attacks — all should fail cleanly. A panic here
515 /// would crash the fullnode / validator binary at startup.
516 ///
517 /// **Catches:** a slice-index regression that would panic on truncated
518 /// input; e.g. `bytes[0..HEADER_SIZE]` without length check.
519 #[test]
520 fn truncated_file_rejected() {
521 let err = decode_file(&[0u8; 10]).unwrap_err();
522 assert!(matches!(err, KeystoreError::Truncated { .. }));
523 }
524
525 /// **Proves:** the header encoded to bytes is exactly 53 bytes long, and
526 /// the [`HEADER_SIZE`] constant equals 53.
527 ///
528 /// **Why it matters:** This is a wire-format constant — every keystore
529 /// file ever written has 53-byte header. If we change it, every
530 /// deployed keystore becomes unreadable. The test makes accidental
531 /// drift impossible without a visible test failure.
532 ///
533 /// **Catches:** adding a field to [`KeystoreHeader`] without updating
534 /// [`HEADER_SIZE`], or vice versa.
535 #[test]
536 fn header_size_constant_correct() {
537 assert_eq!(sample_header().encode().len(), HEADER_SIZE);
538 assert_eq!(HEADER_SIZE, 53);
539 }
540
541 /// **Proves:** a header stamped with the `DIGOP1` magic (the
542 /// [`crate::opaque`] container, scheme id `0x0004`) is recognized by
543 /// `is_known_magic` / `KeystoreHeader::decode` exactly like `DIGVK1` and
544 /// `DIGLW1` — round-trips through encode/decode bit-exactly.
545 ///
546 /// **Why it matters:** `crate::opaque` bypasses `Keystore<K: KeyScheme>`
547 /// (it has no typed public key), so it is not covered by the
548 /// `KeyScheme`-parameterized tests elsewhere. This pins that registering
549 /// a new magic for a non-`KeyScheme` container is additive: `DIGVK1` and
550 /// `DIGLW1` decoding is untouched (see `unknown_magic_rejected` above,
551 /// still green with the OLD unrecognized-`'X'` case).
552 ///
553 /// **Catches:** a future edit to `is_known_magic` that drops `DIGOP1`,
554 /// or a magic/scheme-id typo between `crate::opaque::MAGIC` and this
555 /// decoder's registration.
556 #[test]
557 fn opaque_magic_is_known_and_roundtrips() {
558 let mut h = sample_header();
559 h.magic = *b"DIGOP1";
560 h.scheme_id = 0x0004;
561 let bytes = h.encode();
562 let h2 = KeystoreHeader::decode(&bytes).unwrap();
563 assert_eq!(h, h2);
564 }
565}