Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
16 changes: 13 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<profile>.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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
98 changes: 69 additions & 29 deletions THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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
`<profile>.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
`<profile>.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
`<profile>.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

Expand Down
59 changes: 41 additions & 18 deletions awsenc-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
7 changes: 6 additions & 1 deletion awsenc-cli/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Loading