Skip to content

vault: init() + subsequent unlock() in daemon process fail with aead::Error despite identical LIBREFANG_VAULT_KEY env #5069

Description

@f-liva

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.encset() 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:

  1. 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.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    has-prA pull request has been linked to this issueneeds-triageAuto-applied when the issue title/body matched no area label — needs maintainer review

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions