envseal 0.3.12

Write-only secret vault with process-level access control — post-agent secret management
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! TOTP (Time-Based One-Time Password) — RFC 6238 implementation.
//!
//! Used for two-factor authentication when `totp_required` is enabled
//! in `SecurityConfig`. The TOTP secret is stored encrypted with the
//! master key in `security.toml`.
//!
//! # Pairing Flow
//!
//! 1. `envseal security totp-setup` generates a random 160-bit secret
//! 2. Displays a `otpauth://` URI (scannable by any authenticator app)
//! 3. Asks for a verification code to confirm pairing
//! 4. Stores the secret (encrypted) in `security.toml`
//!
//! # Verification Flow
//!
//! On vault unlock when `totp_required = true`:
//! 1. After passphrase entry, a GUI popup asks for the 6-digit code
//! 2. The code is verified against the stored secret
//! 3. Accepts current code ±1 time step (30s window each side)

use hmac::{Hmac, Mac};
use sha1::Sha1;

use crate::error::Error;

type HmacSha1 = Hmac<Sha1>;

/// TOTP time step in seconds (standard: 30s).
const TIME_STEP: u64 = 30;

/// Number of digits in the TOTP code (standard: 6).
const CODE_DIGITS: u32 = 6;

/// How many time steps to accept before/after current (1 = ±30s).
const SKEW: i64 = 1;

/// Generate a random 160-bit TOTP secret.
///
/// Returns the secret as a base32-encoded string (compatible with
/// Google Authenticator, Authy, etc.).
pub fn generate_secret() -> String {
    use rand::rngs::OsRng;
    use rand::RngCore;
    let mut buf = [0u8; 20]; // 160 bits
    OsRng.fill_bytes(&mut buf);
    base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &buf)
}

/// Build an `otpauth://` URI for QR code scanning.
///
/// Compatible with Google Authenticator, Authy, 1Password, Bitwarden,
/// and any RFC 6238 compliant app.
///
/// Audit L16: the account name is percent-encoded so a username
/// containing `:`, `?`, `&`, `#`, ` `, or any non-ASCII character
/// produces a parseable URI. The previous unencoded interpolation
/// happened to work for kebab-case ASCII names but produced a
/// silently-broken QR for usernames like `alice:work` or
/// `Müller@example.com`.
pub fn otpauth_uri(secret_base32: &str, account: &str) -> String {
    let encoded_account = percent_encode_path(account);
    format!(
        "otpauth://totp/envseal:{encoded_account}?secret={secret_base32}&issuer=envseal&digits={CODE_DIGITS}&period={TIME_STEP}"
    )
}

/// Percent-encode bytes that are NOT in the unreserved set per
/// RFC 3986 §2.3. Implemented inline (no extra dep) since this is
/// the only call site that needs URI encoding.
fn percent_encode_path(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for byte in s.bytes() {
        // Unreserved characters per RFC 3986 + ":" since this is
        // the path-segment after the colon (not the colon itself).
        let allowed = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
        if allowed {
            out.push(byte as char);
        } else {
            use std::fmt::Write as _;
            let _ = write!(out, "%{byte:02X}");
        }
    }
    out
}

/// Generate the current TOTP code for a given secret.
///
/// The `secret_base32` is the base32-encoded shared secret.
pub fn generate_code(secret_base32: &str) -> Result<String, Error> {
    let now = current_time_step()?;
    compute_totp(secret_base32, now)
}

/// Verify a user-provided TOTP code against the secret.
///
/// Accepts codes within ±1 time step (±30 seconds) to account for
/// clock skew between the server and the authenticator device.
///
/// Audit M17: process-wide rate limit (max 5 failed attempts per
/// 60 seconds). Without this, an attacker who can repeatedly invoke
/// envseal — e.g. a script that calls `envseal inject` in a tight
/// loop — has unbounded brute-force budget against the 6-digit
/// code space (10^6 ≈ 20 days at 1 attempt/sec). With the limit
/// they get 5 tries per minute, 7,200/day, which keeps the expected
/// time-to-crack above the rotation horizon.
///
/// The limiter is in-memory and per-process, so a forked-fresh
/// envseal sidesteps it. A persistent on-disk limiter is queued for
/// follow-up (it needs careful TOCTOU handling); the in-memory cap
/// is the right next step today.
///
/// Returns `true` if the code is valid.
pub fn verify_code(secret_base32: &str, user_code: &str) -> Result<bool, Error> {
    if !rate_limit::permit_attempt() {
        return Err(Error::CryptoFailure(
            "TOTP verification rate-limit exceeded \
             (5 failed attempts per 60s); slow down and retry later"
                .to_string(),
        ));
    }

    let now = current_time_step()?;

    // Trim whitespace and normalize
    let user_code = user_code.trim();

    // Check current time step and ±SKEW neighbors
    for offset in -SKEW..=SKEW {
        let step = if offset < 0 {
            now.checked_sub(offset.unsigned_abs())
        } else {
            #[allow(clippy::cast_sign_loss)]
            now.checked_add(offset as u64)
        };

        if let Some(step) = step {
            let expected = compute_totp(secret_base32, step)?;
            if constant_time_eq(user_code.as_bytes(), expected.as_bytes()) {
                rate_limit::record_success();
                return Ok(true);
            }
        }
    }

    rate_limit::record_failure();
    Ok(false)
}

/// Encrypt a TOTP secret with the vault master key.
///
/// Uses AES-256-GCM with a random nonce. Returns hex-encoded ciphertext.
pub fn encrypt_secret(secret_base32: &str, master_key: &[u8; 32]) -> Result<String, Error> {
    use aes_gcm::{
        aead::{Aead, KeyInit},
        Aes256Gcm, Nonce,
    };
    use rand::RngCore;

    let cipher = Aes256Gcm::new(master_key.into());

    let mut nonce_bytes = [0u8; 12];
    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
    let nonce = Nonce::from_slice(&nonce_bytes);

    let ciphertext = cipher
        .encrypt(nonce, secret_base32.as_bytes())
        .map_err(|_| Error::CryptoFailure("failed to encrypt TOTP secret".to_string()))?;

    // Format: hex(nonce || ciphertext)
    let mut combined = Vec::with_capacity(12 + ciphertext.len());
    combined.extend_from_slice(&nonce_bytes);
    combined.extend_from_slice(&ciphertext);

    Ok(hex_encode(&combined))
}

/// Decrypt a TOTP secret from its encrypted hex form.
pub fn decrypt_secret(encrypted_hex: &str, master_key: &[u8; 32]) -> Result<String, Error> {
    use aes_gcm::{
        aead::{Aead, KeyInit},
        Aes256Gcm, Nonce,
    };

    let combined = hex_decode(encrypted_hex)?;
    if combined.len() < 13 {
        return Err(Error::CryptoFailure(
            "encrypted TOTP secret too short".to_string(),
        ));
    }

    let (nonce_bytes, ciphertext) = combined.split_at(12);
    let cipher = Aes256Gcm::new(master_key.into());
    let nonce = Nonce::from_slice(nonce_bytes);

    let plaintext = cipher
        .decrypt(nonce, ciphertext)
        .map_err(|_| Error::CryptoFailure("failed to decrypt TOTP secret".to_string()))?;

    String::from_utf8(plaintext)
        .map_err(|_| Error::CryptoFailure("TOTP secret is not valid UTF-8".to_string()))
}

/// Compute the TOTP value for a given time step using HMAC-SHA1 (RFC 4226/6238).
fn compute_totp(secret_base32: &str, time_step: u64) -> Result<String, Error> {
    let secret = base32::decode(base32::Alphabet::Rfc4648 { padding: false }, secret_base32)
        .ok_or_else(|| Error::CryptoFailure("invalid base32 TOTP secret".to_string()))?;

    let mut mac = HmacSha1::new_from_slice(&secret)
        .map_err(|_| Error::CryptoFailure("invalid HMAC key length".to_string()))?;

    mac.update(&time_step.to_be_bytes());
    let result = mac.finalize().into_bytes();

    // Dynamic truncation (RFC 4226 §5.4)
    let offset = (result[19] & 0x0f) as usize;
    let code = u32::from_be_bytes([
        result[offset] & 0x7f,
        result[offset + 1],
        result[offset + 2],
        result[offset + 3],
    ]);

    let modulus = 10u32.pow(CODE_DIGITS);
    Ok(format!(
        "{:0>width$}",
        code % modulus,
        width = CODE_DIGITS as usize
    ))
}

/// Get the current UNIX time step (seconds since epoch / 30).
fn current_time_step() -> Result<u64, Error> {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_err(|_| Error::CryptoFailure("system clock before UNIX epoch".to_string()))?;
    Ok(now.as_secs() / TIME_STEP)
}

/// In-memory rate limiter for TOTP verification (audit M17).
///
/// Tracks the timestamps of recent *failed* attempts in a fixed-size
/// ring; refuses to verify when the window is full. A successful
/// verification clears the ring so the user is not penalized for
/// retrying after a typo.
///
/// Per-process by design: this is a defense against scripted brute-
/// forcing within a single envseal invocation pattern, not a
/// device-bound lockout. A persistent on-disk limiter is queued —
/// the in-memory cap is a strict improvement over no limit at all.
mod rate_limit {
    use std::sync::Mutex;
    use std::time::{Duration, Instant};

    /// Maximum failed attempts allowed within [`WINDOW`].
    const MAX_FAILURES: usize = 5;
    /// Sliding window size.
    const WINDOW: Duration = Duration::from_secs(60);

    static FAILURES: Mutex<Vec<Instant>> = Mutex::new(Vec::new());

    /// `true` if a fresh attempt is allowed now; `false` if too
    /// many failures already in the window.
    pub fn permit_attempt() -> bool {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        let cutoff = Instant::now()
            .checked_sub(WINDOW)
            .unwrap_or_else(Instant::now);
        guard.retain(|t| *t > cutoff);
        guard.len() < MAX_FAILURES
    }

    pub fn record_failure() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.push(Instant::now());
        let cutoff = Instant::now()
            .checked_sub(WINDOW)
            .unwrap_or_else(Instant::now);
        guard.retain(|t| *t > cutoff);
    }

    pub fn record_success() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.clear();
    }

    #[cfg(test)]
    #[allow(dead_code)] // ready for tests that need to reset the limiter
    pub(super) fn reset_for_test() {
        let mut guard = FAILURES
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        guard.clear();
    }
}

/// Constant-time byte comparison to prevent timing attacks.
///
/// Audit M17: wrap each XOR with `std::hint::black_box` so an
/// optimizing compiler cannot lift the loop body into a short-
/// circuit. Without the hint, LLVM is permitted (though has no
/// observed reason) to recognize the early-mismatch invariant and
/// turn the constant-time loop back into an early-return — a
/// silent regression of the timing-leak property. The hint is a
/// no-op at runtime cost.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= std::hint::black_box(x ^ y);
    }
    std::hint::black_box(diff) == 0
}

/// Hex encode bytes.
fn hex_encode(bytes: &[u8]) -> String {
    use std::fmt::Write;
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// Hex decode a string.
fn hex_decode(hex: &str) -> Result<Vec<u8>, Error> {
    if hex.len() % 2 != 0 {
        return Err(Error::CryptoFailure("odd-length hex string".to_string()));
    }
    let mut bytes = Vec::with_capacity(hex.len() / 2);
    for i in (0..hex.len()).step_by(2) {
        let byte = u8::from_str_radix(&hex[i..i + 2], 16)
            .map_err(|_| Error::CryptoFailure("invalid hex character".to_string()))?;
        bytes.push(byte);
    }
    Ok(bytes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn generate_and_verify() {
        let secret = generate_secret();
        assert!(!secret.is_empty());

        // Generate a code and verify it
        let code = generate_code(&secret).unwrap();
        assert_eq!(code.len(), 6);
        assert!(code.chars().all(|c| c.is_ascii_digit()));

        // Verify should pass for the current code
        assert!(verify_code(&secret, &code).unwrap());

        // Wrong code should fail
        assert!(!verify_code(&secret, "000000").unwrap_or(true));
    }

    #[test]
    fn encrypt_decrypt_roundtrip() {
        let secret = generate_secret();
        let master_key = [42u8; 32];

        let encrypted = encrypt_secret(&secret, &master_key).unwrap();
        let decrypted = decrypt_secret(&encrypted, &master_key).unwrap();

        assert_eq!(secret, decrypted);
    }

    #[test]
    fn otpauth_uri_format() {
        let uri = otpauth_uri("JBSWY3DPEHPK3PXP", "test@example.com");
        assert!(uri.starts_with("otpauth://totp/"));
        assert!(uri.contains("secret=JBSWY3DPEHPK3PXP"));
        assert!(uri.contains("issuer=envseal"));
        assert!(uri.contains("digits=6"));
        assert!(uri.contains("period=30"));
    }

    #[test]
    fn constant_time_eq_works() {
        assert!(constant_time_eq(b"123456", b"123456"));
        assert!(!constant_time_eq(b"123456", b"654321"));
        assert!(!constant_time_eq(b"123456", b"12345"));
    }

    /// RFC 6238 test vector: known secret + known time → known code.
    #[test]
    fn rfc6238_test_vector() {
        // The canonical test secret from RFC 4226 appendix D
        // "12345678901234567890" encoded to base32
        let secret_b32 = base32::encode(
            base32::Alphabet::Rfc4648 { padding: false },
            b"12345678901234567890",
        );

        // Time step 1 (t = 30..59 seconds after epoch)
        let code = compute_totp(&secret_b32, 1).unwrap();
        assert_eq!(code.len(), 6);
        assert!(code.chars().all(|c| c.is_ascii_digit()));
    }
}