Skip to main content

envvault/vault/
format.rs

1//! Binary vault file format and HMAC integrity verification.
2//!
3//! A `.vault` file has this layout:
4//!
5//! ```text
6//! [EVLT: 4 bytes][version: 1 byte][header_len: 4 bytes LE][header JSON][secrets JSON][HMAC-SHA256: 32 bytes]
7//! ```
8//!
9//! - **Magic** (`EVLT`): identifies the file as an EnvVault vault.
10//! - **Version**: format version (currently `1`).
11//! - **Header length**: little-endian u32 telling us where the header
12//!   JSON ends and the secrets JSON begins.
13//! - **Header JSON**: serialized `VaultHeader`.
14//! - **Secrets JSON**: serialized `Vec<Secret>`.
15//! - **HMAC-SHA256**: 32-byte tag computed over header + secrets bytes.
16
17use std::fs;
18use std::path::Path;
19
20use chrono::{DateTime, Utc};
21use hmac::{Hmac, Mac};
22use serde::{Deserialize, Serialize};
23use sha2::Sha256;
24
25use super::secret::Secret;
26use crate::errors::{EnvVaultError, Result};
27
28// ---------------------------------------------------------------------------
29// Constants
30// ---------------------------------------------------------------------------
31
32/// Magic bytes at the start of every vault file.
33const MAGIC: &[u8; 4] = b"EVLT";
34
35/// Current binary format version.
36pub const CURRENT_VERSION: u8 = 1;
37
38/// Size of the HMAC tag appended to the file (SHA-256 = 32 bytes).
39const HMAC_LEN: usize = 32;
40
41/// Fixed-size prefix: 4 (magic) + 1 (version) + 4 (header_len).
42const PREFIX_LEN: usize = 9;
43
44// ---------------------------------------------------------------------------
45// VaultHeader
46// ---------------------------------------------------------------------------
47
48/// Argon2 parameters stored in the vault header so the exact same
49/// KDF settings are used when re-opening.  Backward-compatible:
50/// if missing, defaults are used (m=64MB, t=3, p=4).
51#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
52pub struct StoredArgon2Params {
53    pub memory_kib: u32,
54    pub iterations: u32,
55    pub parallelism: u32,
56}
57
58impl Default for StoredArgon2Params {
59    fn default() -> Self {
60        Self {
61            memory_kib: 65_536,
62            iterations: 3,
63            parallelism: 4,
64        }
65    }
66}
67
68/// Metadata stored at the beginning of a vault file.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct VaultHeader {
71    /// Format version.
72    pub version: u8,
73
74    /// The salt used for Argon2id key derivation (base64 in JSON).
75    #[serde(serialize_with = "base64_encode", deserialize_with = "base64_decode")]
76    pub salt: Vec<u8>,
77
78    /// When this vault was first created.
79    pub created_at: DateTime<Utc>,
80
81    /// Environment name (e.g. "dev", "staging", "prod").
82    pub environment: String,
83
84    /// Argon2 params used at vault creation (stored so open uses the same).
85    /// Optional for backward compatibility with v0.1.0 vaults.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub argon2_params: Option<StoredArgon2Params>,
88
89    /// SHA-256 hash of the keyfile (base64), if one was used at creation.
90    /// Presence of this field means a keyfile is required to open the vault.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub keyfile_hash: Option<String>,
93}
94
95// ---------------------------------------------------------------------------
96// Public API
97// ---------------------------------------------------------------------------
98
99/// Write a vault file to disk **atomically**.
100///
101/// 1. Serialize header and secrets to JSON.
102/// 2. Compute HMAC over header + secrets bytes.
103/// 3. Write to a temp file in the same directory.
104/// 4. Rename temp file over the target path.
105///
106/// The rename ensures readers never see a half-written file.
107pub fn write_vault(
108    path: &Path,
109    header: &VaultHeader,
110    secrets: &[Secret],
111    hmac_key: &[u8],
112) -> Result<()> {
113    let header_bytes = serde_json::to_vec(header)
114        .map_err(|e| EnvVaultError::SerializationError(format!("header: {e}")))?;
115    let secrets_bytes = serde_json::to_vec(secrets)
116        .map_err(|e| EnvVaultError::SerializationError(format!("secrets: {e}")))?;
117
118    let hmac_tag = compute_hmac(hmac_key, &header_bytes, &secrets_bytes)?;
119
120    // Build the binary blob.
121    let header_len = u32::try_from(header_bytes.len()).map_err(|_| {
122        EnvVaultError::SerializationError(format!(
123            "header length {} exceeds u32::MAX",
124            header_bytes.len()
125        ))
126    })?;
127    let total = PREFIX_LEN + header_bytes.len() + secrets_bytes.len() + HMAC_LEN;
128    let mut buf = Vec::with_capacity(total);
129
130    buf.extend_from_slice(MAGIC); // 4 bytes
131    buf.push(CURRENT_VERSION); // 1 byte
132    buf.extend_from_slice(&header_len.to_le_bytes()); // 4 bytes LE
133    buf.extend_from_slice(&header_bytes); // header JSON
134    buf.extend_from_slice(&secrets_bytes); // secrets JSON
135    buf.extend_from_slice(&hmac_tag); // 32 bytes
136
137    // Atomic write: write to a temp file, then rename.
138    // The temp file is in the same directory so rename is guaranteed
139    // to be atomic on the same filesystem.
140    let parent = path.parent().unwrap_or(Path::new("."));
141    let tmp_path = parent.join(format!(
142        ".{}.tmp",
143        path.file_name().unwrap_or_default().to_string_lossy()
144    ));
145
146    fs::write(&tmp_path, &buf)?;
147    fs::rename(&tmp_path, path)?;
148
149    Ok(())
150}
151
152/// Raw data read from a vault file on disk.
153///
154/// Keeps the original bytes so the HMAC can be verified over the
155/// exact bytes that were written — no re-serialization needed.
156pub struct RawVault {
157    pub header: VaultHeader,
158    pub secrets: Vec<Secret>,
159    /// The raw header JSON bytes exactly as stored on disk.
160    pub header_bytes: Vec<u8>,
161    /// The raw secrets JSON bytes exactly as stored on disk.
162    pub secrets_bytes: Vec<u8>,
163    /// The HMAC tag stored at the end of the file.
164    pub stored_hmac: Vec<u8>,
165}
166
167/// Read a vault file from disk and return its parts **with raw bytes**.
168///
169/// The caller should verify the HMAC over `header_bytes` and
170/// `secrets_bytes` (the original bytes from disk) before trusting
171/// the deserialized data.
172pub fn read_vault(path: &Path) -> Result<RawVault> {
173    if !path.exists() {
174        return Err(EnvVaultError::VaultNotFound(path.to_path_buf()));
175    }
176
177    let data = fs::read(path)?;
178
179    // Minimum size: prefix + HMAC.
180    let min_size = PREFIX_LEN + HMAC_LEN;
181    if data.len() < min_size {
182        return Err(EnvVaultError::InvalidVaultFormat(
183            "file too small to be a valid vault".into(),
184        ));
185    }
186
187    // --- Parse the fixed-size prefix ---
188
189    if &data[0..4] != MAGIC {
190        return Err(EnvVaultError::InvalidVaultFormat(
191            "missing EVLT magic bytes".into(),
192        ));
193    }
194
195    let version = data[4];
196    if version != CURRENT_VERSION {
197        return Err(EnvVaultError::InvalidVaultFormat(format!(
198            "unsupported version {version}, expected {CURRENT_VERSION}"
199        )));
200    }
201
202    let header_len_u32 = u32::from_le_bytes(
203        data[5..9]
204            .try_into()
205            .map_err(|_| EnvVaultError::InvalidVaultFormat("bad header length".into()))?,
206    );
207    let header_len = usize::try_from(header_len_u32).map_err(|_| {
208        EnvVaultError::InvalidVaultFormat(format!(
209            "header length {header_len_u32} exceeds platform address space"
210        ))
211    })?;
212
213    let header_end = PREFIX_LEN + header_len;
214    if header_end + HMAC_LEN > data.len() {
215        return Err(EnvVaultError::InvalidVaultFormat(
216            "header length exceeds file size".into(),
217        ));
218    }
219
220    // --- Extract the three variable-length sections as raw bytes ---
221
222    let header_bytes = data[PREFIX_LEN..header_end].to_vec();
223    let secrets_end = data.len() - HMAC_LEN;
224    let secrets_bytes = data[header_end..secrets_end].to_vec();
225    let stored_hmac = data[secrets_end..].to_vec();
226
227    // --- Deserialize from the raw bytes ---
228
229    let header: VaultHeader = serde_json::from_slice(&header_bytes)
230        .map_err(|e| EnvVaultError::InvalidVaultFormat(format!("header JSON: {e}")))?;
231
232    let secrets: Vec<Secret> = serde_json::from_slice(&secrets_bytes)
233        .map_err(|e| EnvVaultError::InvalidVaultFormat(format!("secrets JSON: {e}")))?;
234
235    Ok(RawVault {
236        header,
237        secrets,
238        header_bytes,
239        secrets_bytes,
240        stored_hmac,
241    })
242}
243
244/// Compute HMAC-SHA256 over header + secrets bytes.
245pub fn compute_hmac(hmac_key: &[u8], header_bytes: &[u8], secrets_bytes: &[u8]) -> Result<Vec<u8>> {
246    let mut mac = Hmac::<Sha256>::new_from_slice(hmac_key)
247        .map_err(|e| EnvVaultError::HmacError(format!("invalid HMAC key: {e}")))?;
248
249    mac.update(header_bytes);
250    mac.update(secrets_bytes);
251
252    Ok(mac.finalize().into_bytes().to_vec())
253}
254
255/// Verify that the HMAC matches using constant-time comparison.
256///
257/// Uses `hmac::Mac::verify_slice` which is guaranteed constant-time,
258/// preventing timing side-channel attacks.
259pub fn verify_hmac(
260    hmac_key: &[u8],
261    header_bytes: &[u8],
262    secrets_bytes: &[u8],
263    expected_hmac: &[u8],
264) -> Result<()> {
265    let mut mac = Hmac::<Sha256>::new_from_slice(hmac_key)
266        .map_err(|e| EnvVaultError::HmacError(format!("invalid HMAC key: {e}")))?;
267
268    mac.update(header_bytes);
269    mac.update(secrets_bytes);
270
271    mac.verify_slice(expected_hmac)
272        .map_err(|_| EnvVaultError::HmacMismatch)
273}
274
275// ---------------------------------------------------------------------------
276// Serde helpers for base64-encoded Vec<u8> fields
277// ---------------------------------------------------------------------------
278
279use base64::engine::general_purpose::STANDARD as BASE64;
280use base64::Engine;
281
282pub(crate) fn base64_encode<S>(data: &[u8], serializer: S) -> std::result::Result<S::Ok, S::Error>
283where
284    S: serde::Serializer,
285{
286    let encoded = BASE64.encode(data);
287    serializer.serialize_str(&encoded)
288}
289
290pub(crate) fn base64_decode<'de, D>(deserializer: D) -> std::result::Result<Vec<u8>, D::Error>
291where
292    D: serde::Deserializer<'de>,
293{
294    let s = String::deserialize(deserializer)?;
295    BASE64.decode(&s).map_err(serde::de::Error::custom)
296}