Skip to content

Commit 4facf29

Browse files
authored
Merge pull request #408 from cipherstash/james/cip-3233-access-key-expiry
fix: access-key token expiry parsed as relative — tokens never refresh (CIP-3233); release 2.2.4
2 parents 1e2807c + a239ebc commit 4facf29

4 files changed

Lines changed: 79 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [2.2.4] - 2026-06-18
10+
11+
### Fixed
12+
13+
- **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.
14+
915
## [2.2.3] - 2026-06-17
1016

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

270-
[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.3...HEAD
276+
[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.4...HEAD
277+
[2.2.4]: https://github.com/cipherstash/proxy/compare/v2.2.3...v2.2.4
271278
[2.2.3]: https://github.com/cipherstash/proxy/compare/v2.2.2...v2.2.3
272279
[2.2.2]: https://github.com/cipherstash/proxy/compare/v2.2.1...v2.2.2
273280
[2.2.1]: https://github.com/cipherstash/proxy/compare/v2.2.0-alpha.1...v2.2.1

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ members = ["packages/*"]
55
exclude = ["vendor/stack-auth"]
66

77
[workspace.package]
8-
version = "2.2.3"
8+
version = "2.2.4"
99
edition = "2021"
1010

1111
[profile.dev]

vendor/stack-auth/src/access_key_refresher.rs

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use std::sync::Arc;
2-
use std::time::{SystemTime, UNIX_EPOCH};
32

43
use url::Url;
54

@@ -67,15 +66,17 @@ impl Refresher for AccessKeyRefresher {
6766
}
6867

6968
let auth_resp: AuthoriseResponse = resp.json().await?;
70-
let now = SystemTime::now()
71-
.duration_since(UNIX_EPOCH)
72-
.unwrap_or_default()
73-
.as_secs();
7469

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

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

@@ -146,6 +154,50 @@ mod tests {
146154
}
147155
}
148156

157+
// ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ----
158+
159+
/// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT
160+
/// `exp` claim), not a relative duration. The refresher must use it as-is.
161+
///
162+
/// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands
163+
/// ~decades in the future, so `is_expired()` is never true — the token never
164+
/// refreshes and silently dies at its real ~15-minute `exp`. The assertion
165+
/// below fails under the pre-fix arithmetic (`expires_in()` ≈ 1.7e9) and
166+
/// passes with the fix (`expires_in()` ≈ 900).
167+
#[tokio::test]
168+
async fn access_key_expiry_is_absolute_epoch_not_relative() {
169+
let now = SystemTime::now()
170+
.duration_since(UNIX_EPOCH)
171+
.unwrap()
172+
.as_secs();
173+
let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch
174+
175+
let mut mocks = MockSet::new();
176+
mocks.mock(move |when, then| {
177+
when.post().path("/api/authorise");
178+
then.json(serde_json::json!({
179+
"accessToken": "tok",
180+
"expiry": absolute_expiry
181+
}));
182+
});
183+
let server = start_server(mocks).await;
184+
185+
let refresher =
186+
AccessKeyRefresher::new(SecretToken::new("CSAKid.secret"), server.url(""), None);
187+
let token = refresher.refresh(&()).await.unwrap();
188+
189+
assert!(
190+
token.expires_in() <= 1000,
191+
"expires_in should be ~900s (absolute `expiry` used as-is); got {} \
192+
— pre-fix `now + expiry` yields ~1.7e9",
193+
token.expires_in()
194+
);
195+
assert!(
196+
!token.is_expired(),
197+
"a fresh 15-minute token must not be reported as already expired"
198+
);
199+
}
200+
149201
// ---- Initial auth tests ----
150202

151203
#[tokio::test]
@@ -405,9 +457,15 @@ mod tests {
405457
state.counting.enter();
406458
tokio::time::sleep(state.delay).await;
407459
state.counting.exit();
460+
// CTS returns `expiry` as an absolute epoch (JWT `exp`); model a token
461+
// valid for 1 hour from now.
462+
let now = SystemTime::now()
463+
.duration_since(UNIX_EPOCH)
464+
.unwrap()
465+
.as_secs();
408466
axum::Json(serde_json::json!({
409467
"accessToken": "refreshed-token",
410-
"expiry": 3600
468+
"expiry": now + 3600
411469
}))
412470
}
413471

0 commit comments

Comments
 (0)