Encryption at rest for master secrets¶
The optional webdav_key submodule encrypts per-user WebDAV master
secrets at rest using AES-256-GCM, with key material managed by the
drupal/key module.
Without this submodule, master secrets sit plain in the
keyvalue.expirable table inside Drupal's database. A DB dump leaks
every active master, and any leaked master lets the attacker mint
WebDAV URLs for every file the user could access — until the master
TTL expires (default 7 days) or the user logs out. The webdav_key
submodule moves the bytes that would let an attacker do that out of
the database trust boundary into wherever you tell drupal/key to
store them: a file outside the webroot, an environment variable,
HashiCorp Vault, AWS Secrets Manager, …. A DB dump alone is then
insufficient — the attacker also needs whichever credential controls
your drupal/key provider.
When to enable¶
Enable webdav_key when:
- A DB-only compromise is in your threat model (offsite backups, third-party hosting, multi-tenant shared infrastructure, …).
- You already use
drupal/keyfor other secret material and want consistent provider tooling. - Compliance frameworks (PCI, SOC 2, HIPAA, …) require cryptographic separation between application data and key material.
Skip it when:
- Your sole at-rest threat is "someone unauthorized accesses the server" — in that case the attacker has the key wherever it lives, and encryption-at-rest stops protecting you.
- You'd be putting the key file inside the same backup that captures the database (e.g. inside the docroot). That's worse than the default: same trust boundary, plus a brittle layer.
Setup¶
- Install and enable
drupal/key+webdav_key:
composer require drupal/key
drush en webdav_key -y
At this point /admin/reports/status flags a red error: the
submodule is enabled but no key is selected, so master writes
are skipped and WebDAV silently breaks. That's expected until
you finish the next steps.
- Generate 32 random bytes. Either via the settings form at
/admin/config/services/webdav/key(the "Need a key?" generator produces 32 random bytes and shows them base64-encoded for copy-paste — they are not stored), or via your shell:
openssl rand -base64 32
- Place the bytes where you want them to live. The location determines the trust boundary, so pick deliberately:
# File outside webroot (recommended for production):
echo "<base64 value>" | base64 -d > /etc/webdav-master.key
chmod 600 /etc/webdav-master.key
chown www-data:www-data /etc/webdav-master.key
# Environment variable (good for container deployments):
export WEBDAV_MASTER_KEY="<base64 value>"
# Drupal config (simplest but weakest — same trust boundary as
# the masters; the runtime requirement check will warn).
-
Create a Key entity at
/admin/config/system/keys/addthat reads from step 3. Two settings need changing from their defaults — both traps for the unwary: -
Key type: change from Authentication (default) to Encryption. Otherwise drupal/key's Configuration provider won't expose its "Base64-encoded" decode toggle, the pasted base64 string lands verbatim in storage, and AES rejects it as wrong-length.
- Key size: change from 128 bit (default) to 256 bit. AES-256-GCM requires exactly 32 bytes; a 128-bit key resolves to 16 bytes and fails validation.
- Key provider: File / Environment / Configuration, matching step 3. For File pointing at raw bytes, untick "Base64-encoded". For Environment / Configuration storing the base64 string, tick it (the toggle appears only after Key type = Encryption is set).
Give it a machine name (e.g. webdav_master) and save.
-
Select the key at
/admin/config/services/webdav/key. The dropdown filters to keys defined on the site; pick the one from step 4 and save. The status report should now show OK. -
(Optional) Wipe pre-existing plaintext master rows so users re-mint encrypted ones immediately rather than waiting for the TTL window:
drush sql:query "DELETE FROM key_value_expire WHERE collection = 'webdav_master_secret';"
What happens behind the scenes¶
webdav_key.services.yml decorates the webdav.master_secret_repository
service. Every set(uid, master, ttl) call now wraps the master in
AES-256-GCM before handing it to the inner keyvalue store; every
get(uid) unwraps. The HMAC-derivation in SessionPassword is
unchanged — only the layer that fetches the master bytes was
swapped.
On-disk blob layout:
v1\0 <12-byte nonce> <16-byte GCM tag> <ciphertext>
v1\0— version prefix. Future algorithm changes bump it, andget()refuses unknown versions (treats them as expired so users re-mint).- Nonce — 96 bits, freshly random per write. Two encryptions of the same master under the same key produce different ciphertexts.
- Tag — 128-bit GCM authenticator. Any byte-flip in nonce or ciphertext causes decryption to fail.
- AAD — the stringified uid. Swapping uid A's row into uid B's slot fails authentication.
Failure modes¶
All decryption failures collapse to the same behavior: get()
returns NULL, a watchdog entry lands on the webdav channel, and
SessionPassword reacts as it already does to "no master found" —
existing URLs become un-verifiable, the next URL render mints a
fresh master under the current key.
| Operator action | Effect on users |
|---|---|
| Delete key file / unset env var | All existing URLs reject; next render mints under the now-missing key and also fails — users effectively lose WebDAV access until the key is restored. |
Rotate the key (point key_id at a new Key, or change the underlying provider value) |
Existing URLs reject; users re-mint under the new key transparently on next render. LibreOffice surfaces a 403 on the next save, "Save As" works fine. |
Disable webdav_key |
Inner plaintext path resumes. Existing encrypted rows fail to decode as plaintext, get treated as expired, users re-mint plaintext. |
| Database backup leaked (key intact) | No master is recoverable. Forged URL attempts fail HMAC verification (the attacker doesn't have any master). |
| Key file leaked (DB intact at attacker's site) | No masters are recoverable without also having the DB rows. Attacker needs both. |
| Both leaked | Same outcome as the no-submodule case — back to the default trust boundary. |
What's deliberately not included¶
- Per-row key version dispatch. Master secrets are short-lived (≤ TTL hours, default 7 days). Letting old rows expire is cheaper than a per-row key id column.
- Built-in rewrap-on-rotate. Rotation = "set new
key_id", optionally followed by the SQL delete from step 6. No concurrency-safe rewrap loop to maintain. - HSM / KMS direct integration. The seam is the
MasterSecretRepositoryInterface. Anyone needing a PKCS#11 or cloud-KMS-direct backend can write a sibling repository class and swap the interface alias — no change towebdav_keyor the core module required.