Summary
In a daemon process where LIBREFANG_VAULT_KEY is present in the kernel env at startup (verified via /proc/<pid>/environ), the first vault_set call against a non-existent vault.enc succeeds (calls init() + persist), but the next vault_set against the now-existing vault.enc fails inside unlock() with:
Vault error: Decryption failed: aead::Error
Running the CLI (librefang vault init then librefang vault set foo) with the same LIBREFANG_VAULT_KEY in env round-trips correctly. So the same code, same env, same key produces a vault file the daemon can't decrypt back.
Affects MCP OAuth flow end-to-end. auth_start calls vault_set for pkce_verifier, then pkce_state, then redirect_uri in sequence:
- First call:
vault.exists() is false → init() writes empty vault.enc → set() succeeds.
- Second call:
vault.exists() is now true → unlock() calls load(master_key) → AEAD failure on the file the same process wrote ~10ms earlier.
User-visible symptom: dashboard Authorize on a [[mcp_servers]] with oauth = { ... } returns 500 Failed to store auth state: Vault unlock failed: Vault error: Decryption failed: aead::Error. Ensure LIBREFANG_VAULT_KEY is set in Docker.
Repro
Built from feat/mcp-oauth-client-secret + feat/dotenv-vault-key-bootstrap (PRs #5060 + #5065), but the bug reproduces against vanilla main — those two PRs only make the failure mode reachable on container hosts.
# Inside the container, daemon already running with VAULT_KEY in env:
cat /proc/$(pgrep -f 'librefang start')/environ | tr '\0' '\n' | grep VAULT_KEY
# LIBREFANG_VAULT_KEY=GNTYsbsWZijklydfyPftpxKsq9TH1HT4Fp8vjUTB0T0=
# CLI side — works:
rm -f /data/vault.enc
gosu librefang env LIBREFANG_VAULT_KEY=$KEY LIBREFANG_HOME=/data HOME=/data librefang vault init
# ✔ Credential vault initialized.
echo testval | gosu librefang env LIBREFANG_VAULT_KEY=$KEY LIBREFANG_HOME=/data HOME=/data librefang vault set k
# ✔ Stored 'k' in vault.
gosu librefang env LIBREFANG_VAULT_KEY=$KEY LIBREFANG_HOME=/data HOME=/data librefang vault list
# Stored credentials (1): k
# Daemon side — fails. Hit POST /api/mcp/servers/gmail/auth/start with the api_key bearer:
# HTTP 500
# "error": "Failed to store auth state: Vault unlock failed: Vault error: Decryption failed: aead::Error"
Side-by-side: same binary, same LIBREFANG_VAULT_KEY value, same LIBREFANG_HOME=/data, same uid (librefang). CLI invocation round-trips through init → set → unlock → list. Daemon invocation fails on the second touch of the same file it just wrote.
What I tried
- Verified
LIBREFANG_VAULT_KEY decodes to exactly 32 bytes (echo $KEY | base64 -d | wc -c is 32).
- Verified daemon's
/proc/$PID/environ contains LIBREFANG_VAULT_KEY with the same byte-for-byte value as the CLI shell.
- Wiped
vault.enc so the daemon's vault_set walks the init branch (not stale-file decryption).
- Confirmed the second
vault_set call inside the same auth_start handler is what fails (Failed to store PKCE verifier in vault immediately follows the created entry SsCredential keyring debug line).
- D-Bus is not available inside the container, so the keyring backend has nothing to load from — and yet the failure mode looks like "key changed between init and unlock", not "no key available".
Hypotheses
Plausible causes I couldn't fully verify from the source:
init() and resolve_master_key() diverge on key bytes. If init() derives the file's encryption key via a path different from what resolve_master_key() returns on subsequent unlocks, the file is decrypt-incompatible with itself one call later.
- AAD path differs between the two calls.
aad_bytes(path, schema_version) keys on path.to_string_lossy().as_bytes(). If one code path canonicalizes (resolves symlinks / strips ./) and the other doesn't, the AAD differs and AEAD verification fails with aead::Error.
init() leaves the new file in a "post-init salt unwritten" state. If init() writes the ciphertext with an in-memory salt but only flushes the salt header on the next set() call within the same instance, a fresh instance from mcp_oauth_provider::vault_set (which constructs a new CredentialVault per call) reads a salt-less file and AEAD-fails.
The CLI succeeds because librefang vault init and librefang vault set each construct the vault, complete the operation, and exit — there's no fresh-instance/second-touch path within a single process.
Impact
This blocks MCP OAuth onboarding entirely on container hosts whose dotenv layer is the only way to inject LIBREFANG_VAULT_KEY (Lazycat, k8s file-mounted Secrets, Render persistent volume, etc.). It also affects desktop hosts the moment they have no SecretService running (CI, Docker-on-Linux, WSL without dbus-launch).
I'm happy to instrument the vault crate locally and follow up with the actual divergence point if a maintainer can sketch which of the three hypotheses is most likely — I'd rather not patch around it blind.
Adjacent PRs
Summary
In a daemon process where
LIBREFANG_VAULT_KEYis present in the kernel env at startup (verified via/proc/<pid>/environ), the firstvault_setcall against a non-existentvault.encsucceeds (callsinit()+ persist), but the nextvault_setagainst the now-existingvault.encfails insideunlock()with:Running the CLI (
librefang vault initthenlibrefang vault set foo) with the sameLIBREFANG_VAULT_KEYin env round-trips correctly. So the same code, same env, same key produces a vault file the daemon can't decrypt back.Affects MCP OAuth flow end-to-end.
auth_startcallsvault_setforpkce_verifier, thenpkce_state, thenredirect_uriin sequence:vault.exists()is false →init()writes emptyvault.enc→set()succeeds.vault.exists()is now true →unlock()callsload(master_key)→ AEAD failure on the file the same process wrote ~10ms earlier.User-visible symptom: dashboard
Authorizeon a[[mcp_servers]]withoauth = { ... }returns500 Failed to store auth state: Vault unlock failed: Vault error: Decryption failed: aead::Error. Ensure LIBREFANG_VAULT_KEY is set in Docker.Repro
Built from
feat/mcp-oauth-client-secret+feat/dotenv-vault-key-bootstrap(PRs #5060 + #5065), but the bug reproduces against vanillamain— those two PRs only make the failure mode reachable on container hosts.Side-by-side: same binary, same
LIBREFANG_VAULT_KEYvalue, sameLIBREFANG_HOME=/data, same uid (librefang). CLI invocation round-trips throughinit → set → unlock → list. Daemon invocation fails on the second touch of the same file it just wrote.What I tried
LIBREFANG_VAULT_KEYdecodes to exactly 32 bytes (echo $KEY | base64 -d | wc -cis 32)./proc/$PID/environcontainsLIBREFANG_VAULT_KEYwith the same byte-for-byte value as the CLI shell.vault.encso the daemon'svault_setwalks theinitbranch (not stale-file decryption).vault_setcall inside the sameauth_starthandler is what fails (Failed to store PKCE verifier in vaultimmediately follows thecreated entry SsCredentialkeyring debug line).Hypotheses
Plausible causes I couldn't fully verify from the source:
init()andresolve_master_key()diverge on key bytes. Ifinit()derives the file's encryption key via a path different from whatresolve_master_key()returns on subsequent unlocks, the file is decrypt-incompatible with itself one call later.aad_bytes(path, schema_version)keys onpath.to_string_lossy().as_bytes(). If one code path canonicalizes (resolves symlinks / strips./) and the other doesn't, the AAD differs and AEAD verification fails withaead::Error.init()leaves the new file in a "post-init salt unwritten" state. Ifinit()writes the ciphertext with an in-memory salt but only flushes the salt header on the nextset()call within the same instance, a fresh instance frommcp_oauth_provider::vault_set(which constructs a newCredentialVaultper call) reads a salt-less file and AEAD-fails.The CLI succeeds because
librefang vault initandlibrefang vault seteach construct the vault, complete the operation, and exit — there's no fresh-instance/second-touch path within a single process.Impact
This blocks MCP OAuth onboarding entirely on container hosts whose dotenv layer is the only way to inject
LIBREFANG_VAULT_KEY(Lazycat, k8s file-mounted Secrets, Render persistent volume, etc.). It also affects desktop hosts the moment they have noSecretServicerunning (CI, Docker-on-Linux, WSL withoutdbus-launch).I'm happy to instrument the vault crate locally and follow up with the actual divergence point if a maintainer can sketch which of the three hypotheses is most likely — I'd rather not patch around it blind.
Adjacent PRs
client_secret_envfor confidential MCP OAuth.prime_vault_key_from_dotenv()so the vault unlock attempt seesLIBREFANG_VAULT_KEYfromsecrets.env. That PR is necessary for the chicken-and-egg fix, but this issue is what the user hits after the bootstrap fix lands.