diff --git a/Cargo.lock b/Cargo.lock index f5dd672..b737b2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -163,6 +163,7 @@ dependencies = [ "enclaveapp-build-support", "enclaveapp-core", "enclaveapp-wsl", + "fs4", "predicates", "regex", "rpassword", @@ -721,12 +722,15 @@ dependencies = [ name = "enclaveapp-apple" version = "0.1.0" dependencies = [ + "aes-gcm", "base64 0.22.1", "dirs 6.0.0", "enclaveapp-core", "libc", + "rand 0.9.3", "serde", "serde_json", + "tracing", ] [[package]] @@ -751,6 +755,8 @@ name = "enclaveapp-cache" version = "0.1.0" dependencies = [ "enclaveapp-core", + "fs4", + "sha2", ] [[package]] @@ -762,9 +768,11 @@ dependencies = [ "libc", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "toml 0.8.23", "tracing", + "windows", ] [[package]] @@ -781,6 +789,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "zeroize", ] [[package]] @@ -928,6 +937,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs4" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "futf" version = "0.1.5" @@ -1549,6 +1568,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2282,6 +2307,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2291,7 +2329,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -2724,7 +2762,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index e16cf85..1416497 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,6 @@ enclaveapp-apple = { path = "../libenclaveapp/crates/enclaveapp-apple" } enclaveapp-windows = { path = "../libenclaveapp/crates/enclaveapp-windows" } enclaveapp-bridge = { path = "../libenclaveapp/crates/enclaveapp-bridge" } enclaveapp-wsl = { path = "../libenclaveapp/crates/enclaveapp-wsl" } -enclaveapp-software = { path = "../libenclaveapp/crates/enclaveapp-software", features = ["encryption", "keyring-storage"] } enclaveapp-test-support = { path = "../libenclaveapp/crates/enclaveapp-test-support" } enclaveapp-app-storage = { path = "../libenclaveapp/crates/enclaveapp-app-storage" } enclaveapp-tpm-bridge = { path = "../libenclaveapp/crates/enclaveapp-tpm-bridge" } diff --git a/DESIGN.md b/DESIGN.md index 7cd190e..b20e81a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -283,11 +283,21 @@ Offset Length Field 6 8 Credential expiration (Unix epoch seconds, big-endian u64) 14 8 Okta session expiration (Unix epoch seconds, big-endian u64; 0 if no session) 22 4 AWS ciphertext length (big-endian u32) -26 var AWS credential ciphertext (ECIES blob) +26 var AWS credential ciphertext (ECIES blob wrapping the APL1 envelope) 26+N 4 Okta session ciphertext length (big-endian u32; 0 if no session) 30+N var Okta session ciphertext (ECIES blob; present iff FLAG_HAS_OKTA_SESSION) ``` +Before each payload (AWS credential JSON, Okta session JSON) is passed to `EncryptionStorage::encrypt`, it is wrapped in the shared **authenticated envelope** from `enclaveapp-cache::envelope`: + +``` +[4B "APL1"][32B SHA-256(cache header bytes)][8B BE u64 monotonic counter][payload] +``` + +The SHA-256 covers the unencrypted cache header (magic through `okta_session_expiration`). Any post-encryption header edit is detected on decrypt as a hash mismatch — the ECIES tag still validates the blob, but `unwrap_after_decrypt` rejects the envelope before yielding bytes to the caller. + +The 8-byte counter is bumped on every successful write and persisted in a sibling `.enc.counter` sidecar guarded by an `fs4` exclusive flock (`awsenc-cli/src/serve.rs`). On read, the embedded counter must be `>= sidecar`; older replayed ciphertexts are rejected as `Rollback { observed, expected_at_least }`. Pre-envelope caches (no `APL1` magic) are accepted transparently with `counter = 0` for migration — the next write upgrades them. + The Okta-session fields enable transparent re-auth (see below): when the AWS credentials approach expiration, `awsenc serve` decrypts the cached Okta session token and exchanges it for a fresh SAML assertion without prompting the user for MFA again. ### Cache File Locations @@ -744,8 +754,8 @@ fmt: cargo fmt --all -- --check clean: cargo clean ``` -This source tree currently builds from the enclosing `libenclaveapp` -checkout because the workspace depends on sibling crates under `../crates/`. +This source tree builds from the enclosing `libenclaveapp` checkout — +the workspace depends on sibling crates under `../libenclaveapp/crates/`. ### Dependencies diff --git a/README.md b/README.md index 3cab752..b6ee951 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ make build make install # installs to /usr/local/bin ``` -`awsenc` currently builds from the enclosing `libenclaveapp` checkout because -the workspace depends on sibling crates under `../crates/`. +`awsenc` builds from the enclosing `libenclaveapp` checkout — the workspace +depends on sibling crates under `../libenclaveapp/crates/`. ## Quick Start diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 0ae652a..5a9296e 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -144,17 +144,27 @@ in process memory. This is inherent to any credential passing scheme. ### T8: Encrypted cache tampering -**Threat:** An attacker modifies the `.enc` cache file to extend credential -validity or alter cached data. +**Threat:** An attacker modifies the `.enc` cache file (header fields or +ciphertext) to extend credential validity or replay an older cached +credential. **Mitigation:** -- ECIES ciphertext includes an AES-GCM authentication tag. Tampering with - the ciphertext causes decryption failure. -- The unencrypted cache header (expiration timestamps) controls client-side - caching only. AWS STS enforces its own credential expiration independently. - -**Residual risk:** Header tampering can extend client-side caching but -cannot create valid AWS credentials or extend their server-side validity. +- ECIES ciphertext includes an AES-GCM authentication tag. Tampering + with the ciphertext causes decryption failure. +- The previously-unauthenticated header (magic, version, flags, + credential expiration, Okta session expiration) is now bound into + the encrypted envelope via `awsenc_core::cache::wrap_for_encrypt` / + `unwrap_after_decrypt`. The envelope format is + `[4B "APL1"][32B SHA-256(header bytes)][8B u64 counter][payload]` + (`crates/enclaveapp-cache/src/envelope.rs`). Any edit to header + fields after encrypt is detected as a hash mismatch and the decrypt + result is rejected before bytes cross the caller boundary. +- AWS STS enforces its own credential expiration independently; + server-side `exp` is the ultimate authority on credential validity. + +**Residual risk:** Header tampering alone can no longer extend +client-side caching; it instead surfaces as a decrypt error. Replay of +an older still-valid ciphertext is addressed by T16 below. ### T9: WSL bridge compromise @@ -164,11 +174,20 @@ binary that returns fake credentials or steals them. **Mitigation:** - The bridge path points to `Program Files` which requires admin rights to modify on Windows. +- The bridge client (`crates/enclaveapp-bridge/src/client.rs`) no longer + has a `which`-based PATH fallback — discovery is restricted to fixed + admin install paths plus the `ENCLAVEAPP_BRIDGE_PATH` + (or `{APP}_BRIDGE_PATH`) env-var override. +- Before spawn, `require_bridge_is_authenticode_signed` parses the PE + header's `IMAGE_DIRECTORY_ENTRY_SECURITY` directory and refuses + binaries with no Authenticode signature block. Blocks the + "attacker compiled their own unsigned bridge" case. - The bridge is distributed alongside the main installer. -**Residual risk:** An attacker with admin rights on the Windows host could -replace the bridge binary. But an attacker with admin rights already -controls the TPM. +**Residual risk:** An attacker with Windows admin rights can still +replace the bridge with a validly-signed-but-malicious binary; full +`WinVerifyTrust` chain verification is not reachable from the WSL +side. An attacker with admin rights already controls the TPM anyway. ### T10: Software fallback weakness (Linux) @@ -273,31 +292,52 @@ OS-level file permissions and user-side hygiene. **Threat:** Two AWS CLI invocations trigger two concurrent `credential_process` → `awsenc serve` calls that each detect the cache in the Refresh / Expired -state. Both fire STS (and possibly the transparent reauth chain); one wins -the `atomic_write` rename, the other's work is silently discarded. +state. Both would otherwise fire STS (and possibly the transparent-reauth +chain); one wins the `atomic_write` rename, the other's work is silently +discarded. -**Mitigation:** `atomic_write` ensures the cache is never partially written. +**Mitigation:** +- `awsenc serve` acquires an exclusive `fs4` advisory lock on + `.enc.lock` before touching the cache + (`awsenc-cli/src/serve.rs::ServeLock`). The second caller blocks + until the first releases, then reads the now-fresh cache. +- `atomic_write` ensures the cache file is never partially written. -**Residual risk:** Duplicate STS/SAML traffic (minor rate-limit impact, -wasted Okta quota) and a small window where the losing writer's credentials -are returned to its caller even though they will be overwritten on the next -read. No cross-process serve lock is implemented today; adding one is a -candidate hardening. No credential leakage. +**Residual risk:** None for credential leakage. The lock file itself +is empty and per-profile; a concurrent removal is benign (the next +call re-creates it). Cross-host concurrent serve (e.g. NFS home dir) +is advisory-only — documented as unsupported. ### T16: Cache rollback **Threat:** An attacker with user-level write access replaces the current `.enc` with an older valid ciphertext they previously exfiltrated. -The old cache is still well-formed and authenticated; awsenc serves it -until server-side STS expiration catches up. +Before the monotonic counter landed, the old cache would be served until +server-side STS expiration caught up. -**Mitigation:** STS credentials are short-lived (typically 1–12 hours) and -the AWS service rejects them on the server side when their own `Expiration` -passes. There is no local anti-rollback counter. - -**Residual risk:** An attacker can replay an earlier intact cache for the -remainder of the STS credentials' server-side lifetime. Accepted risk; -operators needing shorter windows should shorten `DurationSeconds`. +**Mitigation:** +- The encrypted envelope + (`crates/enclaveapp-cache/src/envelope.rs`) embeds an 8-byte big-endian + monotonic counter in the plaintext before AES-GCM encryption. The + counter is bumped on every successful write and persisted in a + `.enc.counter` sidecar, protected by an exclusive `fs4` + flock. On decrypt, the embedded counter is compared against the + sidecar; if it is strictly less, the load is rejected as `Rollback + { observed, expected_at_least }`. +- STS `Expiration` on the server side remains authoritative, so even + a successful rollback within the counter window still expires at + the real AWS deadline. + +**Residual risk:** An attacker who writes BOTH the stale `.enc` and +the sidecar back simultaneously (or who rolled back the whole +filesystem snapshot) can still replay within the server-side +validity window. The sidecar is same-UID-writable by design; that's +a generic same-UID-compromise risk shared with config files. +Deletion of the sidecar alone does not help — `next_counter` seeds +from the highest counter observed in any successfully-decrypted +ciphertext, so forward progress is preserved after a delete + re-read +cycle. Operators needing tighter bounds should shorten +`DurationSeconds`. ### T17: Binary discovery / PATH hijack diff --git a/awsenc-cli/src/auth.rs b/awsenc-cli/src/auth.rs index 3124ea1..d1da01c 100644 --- a/awsenc-cli/src/auth.rs +++ b/awsenc-cli/src/auth.rs @@ -163,31 +163,54 @@ fn encrypt_and_cache( creds: &awsenc_core::credential::AwsCredentials, okta_session: Option<&OktaSession>, ) -> Result<()> { + // Build the cache header up front (without ciphertexts). Its + // serialized bytes are bound into the encrypted envelope so + // post-encryption header tampering is detected on decrypt. + let header = CacheHeader { + magic: MAGIC, + version: FORMAT_VERSION, + flags: if okta_session.is_some() { + FLAG_HAS_OKTA_SESSION + } else { + 0 + }, + credential_expiration: creds.expiration.timestamp() as u64, + okta_session_expiration: okta_session + .map_or(0, |session| session.expiration.timestamp() as u64), + }; + + // Allocate a monotonic counter for rollback detection. The + // sidecar may be missing on first write; `next_counter` then + // returns 1, which the read side accepts as >= 0. + let prior_counter = cache::read_counter(profile).unwrap_or(0); + let counter = cache::next_counter(prior_counter, 0); + let creds_json = serde_json::to_vec(creds)?; - let aws_ciphertext = storage.encrypt(&creds_json)?; + let aws_wrapped = cache::wrap_for_encrypt(&header, counter, &creds_json); + let aws_ciphertext = storage.encrypt(&aws_wrapped)?; + + let okta_session_ciphertext = match okta_session { + Some(session) => { + let okta_json = serde_json::to_vec(session)?; + let okta_wrapped = cache::wrap_for_encrypt(&header, counter, &okta_json); + Some(storage.encrypt(&okta_wrapped)?) + } + None => None, + }; let cache_file = CacheFile { - header: CacheHeader { - magic: MAGIC, - version: FORMAT_VERSION, - flags: if okta_session.is_some() { - FLAG_HAS_OKTA_SESSION - } else { - 0 - }, - credential_expiration: creds.expiration.timestamp() as u64, - okta_session_expiration: okta_session - .map_or(0, |session| session.expiration.timestamp() as u64), - }, + header, aws_ciphertext, - okta_session_ciphertext: okta_session - .map(serde_json::to_vec) - .transpose()? - .map(|json| storage.encrypt(&json)) - .transpose()?, + okta_session_ciphertext, }; cache::write_cache(profile, &cache_file)?; + // Only bump the counter after a successful write — if the write + // fails, the next encrypt reuses the same number and nothing is + // skipped. + if let Err(e) = cache::write_counter(profile, counter) { + tracing::warn!("failed to persist rollback-counter sidecar for profile '{profile}': {e}"); + } Ok(()) } diff --git a/awsenc-cli/src/exec.rs b/awsenc-cli/src/exec.rs index c70f948..f4be764 100644 --- a/awsenc-cli/src/exec.rs +++ b/awsenc-cli/src/exec.rs @@ -131,7 +131,12 @@ fn get_cached_credentials( let plaintext = storage .decrypt(&cache.aws_ciphertext) .map_err(|e| format!("failed to decrypt credentials: {e}"))?; - let creds: AwsCredentials = serde_json::from_slice(&plaintext)?; + // Verify the cache header's hash is bound into the decrypted + // envelope and that the rollback counter is at least what the + // sidecar last saw. Legacy pre-envelope caches pass through. + let min_counter = cache::read_counter(profile).unwrap_or(0); + let (_counter, payload) = cache::unwrap_after_decrypt(&cache.header, min_counter, &plaintext)?; + let creds: AwsCredentials = serde_json::from_slice(&payload)?; Ok(Some(creds)) } diff --git a/awsenc-cli/src/main.rs b/awsenc-cli/src/main.rs index 7a61543..19d6667 100644 --- a/awsenc-cli/src/main.rs +++ b/awsenc-cli/src/main.rs @@ -62,13 +62,22 @@ async fn dispatch(cli: Cli) -> Result<(), Box> { } Commands::Serve(args) => { - let biometric = resolve_biometric_from_serve(&args); + // Validate the profile arg before touching the keychain: this + // keeps `awsenc serve` without --profile failing fast at the + // arg layer instead of initializing hardware storage and then + // erroring. The keychain init (and any associated user- + // interaction fallback) is deferred to real work. + let profile = serve::resolve_serve_profile(&args)?; + let biometric = resolve_biometric_for_profile(&profile, false); let storage = create_storage(biometric, force_keyring)?; serve::run_serve(&args, &*storage).await } Commands::Exec(args) => { - let biometric = resolve_biometric_from_exec(&args); + // Same fail-fast pattern as Serve — validate the profile arg + // before touching the keychain. + let profile = exec::resolve_exec_profile(&args)?; + let biometric = resolve_biometric_for_profile(&profile, false); let storage = create_storage(biometric, force_keyring)?; exec::run_exec(&args, &*storage).await } @@ -197,12 +206,14 @@ fn resolve_biometric_for_profile(profile: &str, cli_biometric: bool) -> bool { .unwrap_or(false) } +#[cfg(test)] fn resolve_biometric_from_serve(args: &cli::ServeArgs) -> bool { serve::resolve_serve_profile(args) .map(|profile| resolve_biometric_for_profile(&profile, false)) .unwrap_or(false) } +#[cfg(test)] fn resolve_biometric_from_exec(args: &cli::ExecArgs) -> bool { exec::resolve_exec_profile(args) .map(|profile| resolve_biometric_for_profile(&profile, false)) diff --git a/awsenc-cli/src/serve.rs b/awsenc-cli/src/serve.rs index 6a47e97..9e5d2ca 100644 --- a/awsenc-cli/src/serve.rs +++ b/awsenc-cli/src/serve.rs @@ -59,7 +59,12 @@ pub async fn run_serve(args: &ServeArgs, storage: &dyn EncryptionStorage) -> Res match state { CredentialState::Fresh => { - let creds = decrypt_aws_credentials(storage, &cache.aws_ciphertext)?; + let creds = decrypt_aws_credentials_with_envelope( + &profile, + storage, + &cache.header, + &cache.aws_ciphertext, + )?; print_credentials(&creds)?; usage::record_usage(&profile); } @@ -70,7 +75,12 @@ pub async fn run_serve(args: &ServeArgs, storage: &dyn EncryptionStorage) -> Res } Err(e) => { tracing::debug!("transparent re-auth failed: {e}; using cached credentials"); - let creds = decrypt_aws_credentials(storage, &cache.aws_ciphertext)?; + let creds = decrypt_aws_credentials_with_envelope( + &profile, + storage, + &cache.header, + &cache.aws_ciphertext, + )?; print_credentials(&creds)?; } } @@ -119,10 +129,13 @@ pub(crate) fn resolve_serve_profile(args: &ServeArgs) -> Result { Err("no profile specified; use --profile or --active".into()) } +#[cfg(test)] fn decrypt_aws_credentials( storage: &dyn EncryptionStorage, ciphertext: &[u8], ) -> Result { + // Legacy path: no header-binding or anti-rollback check. Used by + // unit tests that construct ciphertexts without the envelope. let plaintext = storage .decrypt(ciphertext) .map_err(|e| format!("failed to decrypt credentials: {e}"))?; @@ -130,6 +143,21 @@ fn decrypt_aws_credentials( Ok(creds) } +fn decrypt_aws_credentials_with_envelope( + profile: &str, + storage: &dyn EncryptionStorage, + header: &CacheHeader, + ciphertext: &[u8], +) -> Result { + let plaintext = storage + .decrypt(ciphertext) + .map_err(|e| format!("failed to decrypt credentials: {e}"))?; + let min_counter = cache::read_counter(profile).unwrap_or(0); + let (_counter, payload) = cache::unwrap_after_decrypt(header, min_counter, &plaintext)?; + let creds: AwsCredentials = serde_json::from_slice(&payload)?; + Ok(creds) +} + #[allow(clippy::print_stdout)] fn print_credentials(creds: &AwsCredentials) -> Result<()> { let output = CredentialProcessOutput::from_credentials(creds); @@ -165,7 +193,10 @@ async fn try_transparent_reauth( let okta_plaintext = storage .decrypt(okta_ct) .map_err(|e| format!("failed to decrypt Okta session: {e}"))?; - let okta_session: OktaSession = serde_json::from_slice(&okta_plaintext)?; + let min_counter = cache::read_counter(profile).unwrap_or(0); + let (observed_counter, okta_payload) = + cache::unwrap_after_decrypt(&cache.header, min_counter, &okta_plaintext)?; + let okta_session: OktaSession = serde_json::from_slice(&okta_payload)?; let global = config::load_global_config()?; let profile_config = config::load_profile_config(profile)?; @@ -193,24 +224,48 @@ async fn try_transparent_reauth( ) .await?; + // Build the refreshed cache's new header first so we can bind its + // bytes into the re-encrypted credential envelope. + let new_header = CacheHeader { + magic: MAGIC, + version: FORMAT_VERSION, + flags: FLAG_HAS_OKTA_SESSION, + credential_expiration: creds.expiration.timestamp() as u64, + okta_session_expiration: okta_session.expiration.timestamp() as u64, + }; + + // Bump the monotonic rollback counter. `prior_observed` comes + // from the ciphertext we just successfully decrypted above — if + // an attacker deleted the sidecar to reset it to 0, the counter + // still can't go backwards, because we started from whatever was + // embedded in the last good cache. + let prior_sidecar = cache::read_counter(profile).unwrap_or(0); + let counter = cache::next_counter(prior_sidecar, observed_counter); + let creds_json = serde_json::to_vec(&creds)?; + let aws_wrapped = cache::wrap_for_encrypt(&new_header, counter, &creds_json); let new_aws_ct = storage - .encrypt(&creds_json) + .encrypt(&aws_wrapped) .map_err(|e| format!("failed to encrypt new credentials: {e}"))?; + // Re-wrap the okta session under the new header so its envelope + // stays consistent with the refreshed header hash. + let okta_json = serde_json::to_vec(&okta_session)?; + let okta_wrapped = cache::wrap_for_encrypt(&new_header, counter, &okta_json); + let new_okta_ct = storage + .encrypt(&okta_wrapped) + .map_err(|e| format!("failed to re-encrypt Okta session: {e}"))?; + let new_cache = CacheFile { - header: CacheHeader { - magic: MAGIC, - version: FORMAT_VERSION, - flags: FLAG_HAS_OKTA_SESSION, - credential_expiration: creds.expiration.timestamp() as u64, - okta_session_expiration: okta_session.expiration.timestamp() as u64, - }, + header: new_header, aws_ciphertext: new_aws_ct, - okta_session_ciphertext: cache.okta_session_ciphertext.clone(), + okta_session_ciphertext: Some(new_okta_ct), }; cache::write_cache(profile, &new_cache)?; + if let Err(e) = cache::write_counter(profile, counter) { + tracing::warn!("failed to persist rollback-counter sidecar for profile '{profile}': {e}"); + } Ok(creds) } diff --git a/awsenc-core/src/cache.rs b/awsenc-core/src/cache.rs index 69f24ea..5a8e8b8 100644 --- a/awsenc-core/src/cache.rs +++ b/awsenc-core/src/cache.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use enclaveapp_cache::envelope::{self, Unwrapped}; use enclaveapp_cache::{CacheEntry, CacheFormat}; use enclaveapp_core::metadata; @@ -34,6 +35,25 @@ impl CacheHeader { pub fn has_okta_session(&self) -> bool { self.flags & FLAG_HAS_OKTA_SESSION != 0 } + + /// Serialize the header to the exact byte sequence that's bound + /// into the encrypted payload via the + /// [`enclaveapp_cache::envelope`] module. + /// + /// Shape: `[4 magic][1 version][1 flags][8 credential_expiration][8 okta_session_expiration]` + /// (22 bytes). Any future header-field change that's expected to + /// be authenticated must be appended here and on both sides of + /// the envelope wrap/unwrap call chain. + #[must_use] + pub fn binding_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(4 + 1 + 1 + 8 + 8); + out.extend_from_slice(&self.magic); + out.push(self.version); + out.push(self.flags); + out.extend_from_slice(&self.credential_expiration.to_be_bytes()); + out.extend_from_slice(&self.okta_session_expiration.to_be_bytes()); + out + } } /// Complete cache file: header + encrypted payloads. @@ -182,6 +202,60 @@ pub fn sanitize_profile_name(name: &str) -> Result { Ok(crate::config::validate_profile_name(name)?.to_owned()) } +// --------------------------------------------------------------------------- +// Envelope helpers (header-binding + anti-rollback counter) +// --------------------------------------------------------------------------- + +/// Wrap `payload` in an envelope bound to `header`, using +/// `counter` as the rollback sequence number, then return the +/// bytes suitable for passing to `storage.encrypt(...)`. +/// +/// The caller is responsible for encrypting the returned buffer +/// and for bumping the counter sidecar on a successful write. +#[must_use] +pub fn wrap_for_encrypt(header: &CacheHeader, counter: u64, payload: &[u8]) -> Vec { + envelope::wrap_plaintext(&header.binding_bytes(), counter, payload) +} + +/// Unwrap the plaintext returned from `storage.decrypt(...)`. +/// +/// On legacy caches (no envelope magic) the bytes are passed +/// through verbatim with `counter = 0`. On new caches the header +/// binding is verified against `header` and the counter is +/// compared to `min_counter`. +pub fn unwrap_after_decrypt( + header: &CacheHeader, + min_counter: u64, + decrypted: &[u8], +) -> Result<(u64, Vec)> { + match envelope::unwrap_plaintext(&header.binding_bytes(), min_counter, decrypted) + .map_err(|e| Error::CacheFormat(e.to_string()))? + { + Unwrapped::Legacy { payload } => Ok((0, payload)), + Unwrapped::Versioned { counter, payload } => Ok((counter, payload)), + } +} + +/// Read the rollback-counter sidecar for a profile's cache file. +pub fn read_counter(profile: &str) -> Result { + let cache = cache_path(profile)?; + envelope::read_counter(&cache).map_err(|e| Error::CacheFormat(e.to_string())) +} + +/// Write the rollback-counter sidecar for a profile's cache file. +pub fn write_counter(profile: &str, counter: u64) -> Result<()> { + let cache = cache_path(profile)?; + envelope::write_counter(&cache, counter).map_err(|e| Error::CacheFormat(e.to_string())) +} + +/// Allocate the next monotonic counter given the last value +/// observed in the sidecar and the highest counter seen in a +/// successfully-decrypted ciphertext so far. +#[must_use] +pub fn next_counter(sidecar: u64, prior_observed: u64) -> u64 { + envelope::next_counter(sidecar, prior_observed) +} + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)]