Skip to content

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/key for 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

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

  1. 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
  1. 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).
  1. Create a Key entity at /admin/config/system/keys/add that reads from step 3. Two settings need changing from their defaults — both traps for the unwary:

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

  3. 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.
  4. 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.

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

  2. (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, and get() 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 to webdav_key or the core module required.