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/*`.
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}