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
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [2.2.4] - 2026-06-18

### Fixed

- **ZeroKMS authentication failures ~15 minutes after startup (access keys)**: Fixed the root cause of access tokens never being renewed when authenticating with an access key. The token's lifetime was misread, so renewal never triggered and every encrypt/decrypt operation began failing (`ZeroKMS error: Request not authorized`, "Could not decrypt data") roughly 15 minutes — the token lifetime — after connecting, recovering only on restart. Tokens now renew correctly ahead of expiry. This resolves the remaining cases not addressed by the 2.2.3 fix.

## [2.2.3] - 2026-06-17

### Fixed
Expand Down Expand Up @@ -267,7 +273,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Integration with CipherStash ZeroKMS.
- Encrypt Query Language (EQL) for indexing and searching encrypted data.

[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.3...HEAD
[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.4...HEAD
[2.2.4]: https://github.com/cipherstash/proxy/compare/v2.2.3...v2.2.4
[2.2.3]: https://github.com/cipherstash/proxy/compare/v2.2.2...v2.2.3
[2.2.2]: https://github.com/cipherstash/proxy/compare/v2.2.1...v2.2.2
[2.2.1]: https://github.com/cipherstash/proxy/compare/v2.2.0-alpha.1...v2.2.1
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ members = ["packages/*"]
exclude = ["vendor/stack-auth"]

[workspace.package]
version = "2.2.3"
version = "2.2.4"
edition = "2021"

[profile.dev]
Expand Down
76 changes: 67 additions & 9 deletions vendor/stack-auth/src/access_key_refresher.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use url::Url;

Expand Down Expand Up @@ -67,15 +66,17 @@ impl Refresher for AccessKeyRefresher {
}

let auth_resp: AuthoriseResponse = resp.json().await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();

Ok(Token {
access_token: auth_resp.access_token,
token_type: "Bearer".to_string(),
expires_at: now + auth_resp.expiry,
// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is
// the JWT `exp` claim), NOT a relative duration. The previous `now + expiry`
// pushed the local expiry decades into the future, so `AutoRefresh` never
// considered the token expired and never refreshed it — the token then
// silently died at its real (~15 min) `exp` and every request failed until
// the process restarted. Use the value as-is. See CIP-3233.
expires_at: auth_resp.expiry,
refresh_token: None,
region: None,
client_id: None,
Expand Down Expand Up @@ -107,10 +108,17 @@ mod tests {
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

fn auth_response_json(access: &str, expiry: u64) -> serde_json::Value {
/// Build a mock `/api/authorise` response. CTS returns `expiry` as an
/// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the
/// token is valid for `expires_in_secs` from now.
fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
serde_json::json!({
"accessToken": access,
"expiry": expiry
"expiry": now + expires_in_secs
})
}

Expand Down Expand Up @@ -146,6 +154,50 @@ mod tests {
}
}

// ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ----

/// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT
/// `exp` claim), not a relative duration. The refresher must use it as-is.
///
/// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands
/// ~decades in the future, so `is_expired()` is never true — the token never
/// refreshes and silently dies at its real ~15-minute `exp`. The assertion
/// below fails under the pre-fix arithmetic (`expires_in()` ≈ 1.7e9) and
/// passes with the fix (`expires_in()` ≈ 900).
#[tokio::test]
async fn access_key_expiry_is_absolute_epoch_not_relative() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch

let mut mocks = MockSet::new();
mocks.mock(move |when, then| {
when.post().path("/api/authorise");
then.json(serde_json::json!({
"accessToken": "tok",
"expiry": absolute_expiry
}));
});
let server = start_server(mocks).await;

let refresher =
AccessKeyRefresher::new(SecretToken::new("CSAKid.secret"), server.url(""), None);
let token = refresher.refresh(&()).await.unwrap();

assert!(
token.expires_in() <= 1000,
"expires_in should be ~900s (absolute `expiry` used as-is); got {} \
— pre-fix `now + expiry` yields ~1.7e9",
token.expires_in()
);
assert!(
!token.is_expired(),
"a fresh 15-minute token must not be reported as already expired"
);
}

// ---- Initial auth tests ----

#[tokio::test]
Expand Down Expand Up @@ -405,9 +457,15 @@ mod tests {
state.counting.enter();
tokio::time::sleep(state.delay).await;
state.counting.exit();
// CTS returns `expiry` as an absolute epoch (JWT `exp`); model a token
// valid for 1 hour from now.
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
axum::Json(serde_json::json!({
"accessToken": "refreshed-token",
"expiry": 3600
"expiry": now + 3600
}))
}

Expand Down
Loading