Skip to main content

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}