Skip to content

Volume Shield Architecture

Volume Shield extends CloudTaser's data sovereignty guarantee from secrets to volume data. It transparently encrypts PersistentVolumeClaim (PVC) contents at the filesystem layer, using per-volume versioned Data Encryption Keys (DEKs) that live only in EU-hosted OpenBao and in the memory of an unprivileged process inside your workload pod. The cloud provider's storage layer — disks, snapshots, backups, replication streams — only ever sees ciphertext.

Relationship to secret injection

Volume Shield is independent of (and composable with) CloudTaser's secret injection. Secret injection protects environment-delivered secrets; Volume Shield protects data written to disk. Both share the same EU vault, the same beacon/port connectivity, and the same "keys never touch the cloud provider" invariant.

The fd-pass model

Volume Shield's core design problem: FUSE mounting requires privilege (/dev/fuse access and mount(2)), but the process holding the DEK must not be privileged, and the privileged process must not hold the DEK. The fd-pass architecture splits these concerns across two processes:

       node (per-node, privileged)          workload pod (unprivileged)
  ┌────────────────────────────────┐   ┌───────────────────────────────────┐
  │  vs-broker DaemonSet           │   │  app container                    │
  │                                │   │    reads/writes /data (plaintext  │
  │  1. open /dev/fuse             │   │    view via FUSE)                 │
  │  2. mount(2) FUSE at target    │   │                                   │
  │  3. send fd via SCM_RIGHTS ────┼───┼─▶ vs-sidecar container (CapEff=0) │
  │                                │   │    4. receive FUSE fd             │
  │  never sees the DEK            │   │    5. fetch per-PVC DEK set       │
  │                                │   │       vault (own identity)        │
  └────────────────────────────────┘   │    6. serve encrypted FUSE I/O    │
                                       └───────────────────────────────────┘
  1. The broker (a per-node DaemonSet, privileged) opens /dev/fuse, performs the mount(2), and passes the resulting file descriptor to the sidecar over a Unix socket using SCM_RIGHTS. The kernel does not re-check capabilities on a received fd, so the recipient needs none.
  2. The sidecar (injected into the workload pod by the operator webhook) runs with zero Linux capabilities (CapEff=0, runAsNonRoot, readOnlyRootFilesystem, all capabilities dropped). It authenticates to the EU vault with its own Kubernetes ServiceAccount identity, fetches the per-PVC DEK set directly into process memory, and serves FUSE I/O on the received fd.

Two invariants fall out of this split:

  • The broker never sees a DEK. Only the FUSE fd and non-secret mount identifiers cross the broker↔sidecar socket. A compromised node agent cannot decrypt volume data.
  • No privileged containers in workload pods. The only privileged component is the per-node DaemonSet, which holds no key material.

The operator's mutating webhook wires all of this automatically for any pod that mounts a PVC annotated with cloudtaser.io/encrypt-pvc: "true": it shadow-remounts the PVC to a raw (ciphertext) path, injects the sidecar and an init container that prepares directories, and points the application's original mount path at the FUSE view.

Multi-PVC pods

A pod may mount several encrypted PVCs (for example, a database with separate data and WAL volumes). Each mount gets its own DEK set, its own encryption state, and its own FUSE server inside the single sidecar. Each fd passed by the broker is tagged with the PVC identity it serves; the sidecar refuses fds tagged for PVCs or pods it does not own. Per-mount I/O mode is selected with the cloudtaser.io/vs-io-mode PVC annotation (see the configuration reference).

On-disk format: CTVS v4

Encrypted files are stored in the CTVS container format (magic bytes CTVS) on the backing PVC:

  • Files are split into fixed-size chunks (default 64 KiB, configurable per PVC via cloudtaser.io/pvc-chunk-size-kb).
  • Each chunk is independently encrypted with AES-256-GCM and a unique random nonce.
  • Each chunk's GCM Additional Authenticated Data (AAD) binds fileID ‖ chunk_index ‖ version ‖ final_chunk ‖ plaintext_size. Only the final chunk carries the plaintext size; readers authenticate that final chunk before returning v4 data. Any chunk swap, reorder, splice, bit-flip, or whole-chunk tail truncation fails authentication on read — the application receives an I/O error instead of tampered data.
  • The per-file fileID lives in the file header, so hard links (which share one backing file) authenticate consistently.
  • Header bytes [6:8] carry the DEK version. New writes use the current version; reads accept every retained previous version. Legacy v3 files remain readable and are upgraded on mutation.

The chunked design means a small write does not re-encrypt the whole file — only the affected chunks (plus the length-committing final chunk) are re-stamped.

What CTVS alone does not detect

Chunk-level authentication is integrity within one version of a file. Two attacks require a trusted root stored off the CSP-writable medium:

  • Rollback/replay — restoring an older, correctly-encrypted version of a chunk or file.
  • Header-strip plaintext injection — replacing an encrypted file with attacker-chosen plaintext (Volume Shield reads files without the CTVS magic as legacy plaintext to support zero-downtime migration).

Both are closed under trusted freshness mode (volumeShield.freshness.mode: vault), which is the default since the 2026-07-06 default flip (roadmap#197) and verifies every ciphertext read against per-file digests stored in the EU vault, failing closed on mismatch, tombstone, or unauthenticated plaintext. Pre-existing populated volumes upgrading into the default get a bounded trust-on-first-use window rather than the protection applying retroactively and instantly — see the configuration reference for the exact cohort rules and the threat model for the full boundary discussion.

Key management

Property Behavior
DEK scope One versioned 256-bit DEK set per PVC, keyed by namespace and PVC UID
DEK storage EU-hosted OpenBao KV v2 (cloudtaser/volumes/<namespace>/<pvc-uid>, fields dek, dek_version, dek_versions)
DEK in transit TLS to vault (beacon/port path); never written to disk, etcd, or Kubernetes Secrets
DEK in memory Zeroed immediately after cipher initialization in the sidecar
Broker access None — the broker process never fetches or receives DEKs
Nonce lifetime Fails closed at the NIST SP 800-38D bound (2³² GCM seals); optional vault-leased high-water accounting across remounts (volumeShield.nonceBudget.mode: vault)

Failure semantics: fail closed

Volume Shield is deliberately crash-abort. If a DEK cannot be fetched, a FUSE mount cannot be established, an authentication check fails, or the nonce budget is exhausted, the affected component exits or returns EIO rather than degrading to plaintext or serving unverified bytes. A pod that cannot get its encrypted volumes never starts serving traffic — readiness probes hold it back until every configured mount is serving.

Component summary

Component Runs as Privilege Holds DEK Delivered by
vs-broker Per-node DaemonSet privileged (owns /dev/fuse) never Helm chart (volumeShield.broker)
vs-sidecar Container in workload pod CapEff=0, non-root, read-only rootfs in memory only Operator webhook injection
vs-init Init container in workload pod CAP_CHOWN + CAP_FOWNER only never Operator webhook injection
Operator webhook cloudtaser-operator never Helm chart

Continue with: