From: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [PATCH proxmox v8 3/6] token shadow: invalidate token-secret cache on token.shadow changes
Date: Thu, 9 Apr 2026 17:54:23 +0200 [thread overview]
Message-ID: <20260409155437.312760-4-s.rufinatscha@proxmox.com> (raw)
In-Reply-To: <20260409155437.312760-1-s.rufinatscha@proxmox.com>
This patch adds manual/direct file change detection by tracking the
mtime and length of token.shadow and clears the in-memory token secret
cache whenever these values change.
Signed-off-by: Samuel Rufinatscha <s.rufinatscha@proxmox.com>
---
Changes from v7 to v8:
* Merge refresh_cache_if_file_changed() and cache_try_secret_matches()
into a single cached_secret_valid() function
* Move secret comparison logic into secret_matches() method on
ApiTokenSecretCache
* Rename shadow field -> file_info
Changes from v6 to v7:
* Rebased
Changes from v5 to v6:
* Rebased
Changes from v4 to v5:
* Rebased
Changes from v3 to v4:
* make use of .replace() in refresh_cache_if_file_changed to get
previous state
* Group file stats with ShadowFileInfo
* Return false in refresh_cache_if_file_changed to avoid unnecessary cache
queries
* Adjusted commit message
Changes from v2 to v3:
* Cache now tracks last_checked (epoch seconds).
* Simplified refresh_cache_if_file_changed, removed
FILE_GENERATION logic
* On first load, initializes file metadata and keeps empty cache.
Changes from v1 to v2:
* Add file metadata tracking (file_mtime, file_len) and
FILE_GENERATION.
* Store file_gen in CachedSecret and verify it against the current
FILE_GENERATION to ensure cached entries belong to the current file
state.
* Add shadow_mtime_len() helper and convert refresh to best-effort
(try_write, returns bool).
* Pass a pre-write metadata snapshot into apply_api_mutation and
clear/bump generation if the cache metadata indicates missed external
edits.
proxmox-access-control/src/token_shadow.rs | 167 +++++++++++++++++----
1 file changed, 136 insertions(+), 31 deletions(-)
diff --git a/proxmox-access-control/src/token_shadow.rs b/proxmox-access-control/src/token_shadow.rs
index d0bf43d7..810ff0c3 100644
--- a/proxmox-access-control/src/token_shadow.rs
+++ b/proxmox-access-control/src/token_shadow.rs
@@ -1,5 +1,8 @@
use std::collections::HashMap;
+use std::fs;
+use std::io::ErrorKind;
use std::sync::LazyLock;
+use std::time::SystemTime;
use anyhow::{bail, format_err, Error};
use parking_lot::RwLock;
@@ -7,6 +10,7 @@ use serde_json::{from_value, Value};
use proxmox_auth_api::types::Authid;
use proxmox_product_config::{open_api_lockfile, replace_config, ApiLockGuard};
+use proxmox_time::epoch_i64;
use crate::init::access_backend;
use crate::init::impl_feature::{token_shadow, token_shadow_lock};
@@ -20,6 +24,7 @@ static TOKEN_SECRET_CACHE: LazyLock<RwLock<ApiTokenSecretCache>> = LazyLock::new
RwLock::new(ApiTokenSecretCache {
secrets: HashMap::new(),
cached_gen: 0,
+ file_info: None,
})
});
@@ -45,6 +50,62 @@ fn write_file(data: HashMap<Authid, String>) -> Result<(), Error> {
replace_config(token_shadow(), &json)
}
+/// Tries to match the given token secret against the cached secret.
+///
+/// Verifies the generation/version before doing the constant-time
+/// comparison to reduce TOCTOU risk. During token rotation or deletion
+/// tokens for in-flight requests may still validate against the previous
+/// generation.
+///
+/// Returns true if secret is cached and cache is still valid
+fn cached_secret_valid(tokenid: &Authid, secret: &str) -> bool {
+ let now = epoch_i64();
+
+ // Best-effort refresh under write lock.
+ let Some(mut cache) = TOKEN_SECRET_CACHE.try_write() else {
+ return false;
+ };
+
+ let Some(current_gen) = token_shadow_generation() else {
+ return false;
+ };
+
+ // If another process bumped the generation, we don't know what changed -> clear cache
+ if cache.cached_gen != current_gen {
+ cache.reset_and_set_gen(current_gen);
+ }
+
+ // Stat the file to detect manual edits.
+ let Ok((new_mtime, new_len)) = shadow_mtime_len() else {
+ return false;
+ };
+
+ // If the file didn't change, only update last_checked
+ if let Some(shadow) = cache.file_info.as_mut() {
+ if shadow.mtime == new_mtime && shadow.len == new_len {
+ shadow.last_checked = now;
+ return cache.secret_matches(tokenid, secret);
+ }
+ }
+
+ cache.secrets.clear();
+
+ let prev = cache.file_info.replace(ShadowFileInfo {
+ mtime: new_mtime,
+ len: new_len,
+ last_checked: now,
+ });
+
+ if prev.is_some() {
+ // Best-effort propagation to other processes if a change was detected
+ if let Some(new_gen) = bump_token_shadow_generation() {
+ cache.cached_gen = new_gen;
+ }
+ }
+
+ false
+}
+
/// Verifies that an entry for given tokenid / API token secret exists
pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
if !tokenid.is_token() {
@@ -52,7 +113,7 @@ pub fn verify_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
}
// Fast path
- if cache_try_secret_matches(tokenid, secret) {
+ if cached_secret_valid(tokenid, secret) {
return Ok(());
}
@@ -84,12 +145,15 @@ pub fn set_secret(tokenid: &Authid, secret: &str) -> Result<(), Error> {
let guard = lock_config()?;
+ // Capture state before we write to detect external edits.
+ let pre_meta = shadow_mtime_len().unwrap_or((None, None));
+
let mut data = read_file()?;
let hashed_secret = proxmox_sys::crypt::encrypt_pw(secret)?;
data.insert(tokenid.clone(), hashed_secret);
write_file(data)?;
- apply_api_mutation(guard, tokenid, Some(secret));
+ apply_api_mutation(guard, tokenid, Some(secret), pre_meta);
Ok(())
}
@@ -102,11 +166,14 @@ pub fn delete_secret(tokenid: &Authid) -> Result<(), Error> {
let guard = lock_config()?;
+ // Capture state before we write to detect external edits.
+ let pre_meta = shadow_mtime_len().unwrap_or((None, None));
+
let mut data = read_file()?;
data.remove(tokenid);
write_file(data)?;
- apply_api_mutation(guard, tokenid, None);
+ apply_api_mutation(guard, tokenid, None, pre_meta);
Ok(())
}
@@ -133,6 +200,8 @@ struct ApiTokenSecretCache {
secrets: HashMap<Authid, CachedSecret>,
/// token.shadow generation of cached secrets.
cached_gen: usize,
+ /// Shadow file info to detect changes
+ file_info: Option<ShadowFileInfo>,
}
impl ApiTokenSecretCache {
@@ -140,6 +209,7 @@ impl ApiTokenSecretCache {
fn reset_and_set_gen(&mut self, new_gen: usize) {
self.secrets.clear();
self.cached_gen = new_gen;
+ self.file_info = None;
}
/// Caches a secret and sets/updates the cache generation.
@@ -153,6 +223,28 @@ impl ApiTokenSecretCache {
self.secrets.remove(tokenid);
self.cached_gen = new_gen;
}
+
+ /// Returns true if there is a matching cached entry
+ fn secret_matches(&self, tokenid: &Authid, secret: &str) -> bool {
+ let Some(entry) = self.secrets.get(tokenid) else {
+ return false;
+ };
+ let cached_secret_bytes = entry.secret.as_bytes();
+ let secret_bytes = secret.as_bytes();
+
+ cached_secret_bytes.len() == secret_bytes.len()
+ && openssl::memcmp::eq(cached_secret_bytes, secret_bytes)
+ }
+}
+
+/// Shadow file info
+struct ShadowFileInfo {
+ // shadow file mtime to detect changes
+ mtime: Option<SystemTime>,
+ // shadow file length to detect changes
+ len: Option<u64>,
+ // last time the file metadata was checked
+ last_checked: i64,
}
fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
@@ -175,35 +267,14 @@ fn cache_try_insert_secret(tokenid: Authid, secret: String, gen_before: usize) {
}
}
-/// Tries to match the given token secret against the cached secret.
-///
-/// Verifies the generation/version before doing the constant-time
-/// comparison to reduce TOCTOU risk. During token rotation or deletion
-/// tokens for in-flight requests may still validate against the previous
-/// generation.
-fn cache_try_secret_matches(tokenid: &Authid, secret: &str) -> bool {
- let Some(cache) = TOKEN_SECRET_CACHE.try_read() else {
- return false;
- };
- let Some(entry) = cache.secrets.get(tokenid) else {
- return false;
- };
- let Some(current_gen) = token_shadow_generation() else {
- return false;
- };
-
- if current_gen == cache.cached_gen {
- let cached_secret_bytes = entry.secret.as_bytes();
- let secret_bytes = secret.as_bytes();
-
- return cached_secret_bytes.len() == secret_bytes.len()
- && openssl::memcmp::eq(cached_secret_bytes, secret_bytes);
- }
-
- false
-}
+fn apply_api_mutation(
+ _guard: ApiLockGuard,
+ tokenid: &Authid,
+ secret: Option<&str>,
+ pre_write_meta: (Option<SystemTime>, Option<u64>),
+) {
+ let now = epoch_i64();
-fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&str>) {
// Signal cache invalidation to other processes (best-effort).
let bumped_gen = bump_token_shadow_generation();
let mut cache = TOKEN_SECRET_CACHE.write();
@@ -221,6 +292,16 @@ fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&st
return;
}
+ // If our cached file metadata does not match the on-disk state before our write,
+ // we likely missed an external/manual edit. We can no longer trust any cached secrets.
+ if cache
+ .file_info
+ .as_ref()
+ .is_some_and(|s| (s.mtime, s.len) != pre_write_meta)
+ {
+ cache.secrets.clear();
+ }
+
// Apply the new mutation.
match secret {
Some(secret) => {
@@ -231,6 +312,22 @@ fn apply_api_mutation(_guard: ApiLockGuard, tokenid: &Authid, secret: Option<&st
}
None => cache.evict_and_set_gen(tokenid, current_gen),
}
+
+ // Update our view of the file metadata to the post-write state (best-effort).
+ // (If this fails, drop local cache so callers fall back to slow path until refreshed.)
+ match shadow_mtime_len() {
+ Ok((mtime, len)) => {
+ cache.file_info = Some(ShadowFileInfo {
+ mtime,
+ len,
+ last_checked: now,
+ });
+ }
+ Err(_) => {
+ // If we cannot validate state, do not trust cache.
+ cache.reset_and_set_gen(current_gen);
+ }
+ }
}
/// Get the current generation.
@@ -245,3 +342,11 @@ fn bump_token_shadow_generation() -> Option<usize> {
.ok()
.map(|prev| prev + 1)
}
+
+fn shadow_mtime_len() -> Result<(Option<SystemTime>, Option<u64>), Error> {
+ match fs::metadata(token_shadow()) {
+ Ok(meta) => Ok((meta.modified().ok(), Some(meta.len()))),
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok((None, None)),
+ Err(e) => Err(e.into()),
+ }
+}
--
2.47.3
next prev parent reply other threads:[~2026-04-09 15:54 UTC|newest]
Thread overview: 10+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-09 15:54 [PATCH proxmox{,-datacenter-manager} v8 0/9] token-shadow: reduce api token verification overhead Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 1/6] token shadow: split AccessControlConfig and add token.shadow generation Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 2/6] token shadow: cache verified API token secrets Samuel Rufinatscha
2026-04-09 15:54 ` Samuel Rufinatscha [this message]
2026-04-09 15:54 ` [PATCH proxmox v8 4/6] token shadow: add TTL window to token secret cache Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 5/6] token shadow: inline set_secret fn Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox v8 6/6] token shadow: deduplicate more code into apply_api_mutation Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 1/3] pdm-config: implement access control backend hooks Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 2/3] pdm-config: wire user and ACL cache generation Samuel Rufinatscha
2026-04-09 15:54 ` [PATCH proxmox-datacenter-manager v8 3/3] pdm-config: wire token.shadow generation Samuel Rufinatscha
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260409155437.312760-4-s.rufinatscha@proxmox.com \
--to=s.rufinatscha@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox