Skip to content

DuckDB temporary files are not encrypted by default #645

Open

Description

@chriskillpack-argus

What happens?

Under certain conditions DuckDB generates short-lived temporary files when writing to a database. If the database is encrypted the temporary files are not encrypted, which is counter to the claimed behavior in the launch announcement. The fix is to issue SET temp_file_encryption=true.

To Reproduce

This reproduction script was generated with Claude Opus 5. We are reporting this issue in the Python repository because we reproduced it with Python bindings but I suspect this is not Python specific behavior. We reproduced on DuckDB stable releases 1.5.1, 1.5.5 and 1.5.6.

The presence of the configuration variable temp_file_encryption seems strange - in which situation would you want to set this to FALSE when working with an encrypted database? If you keep the variable I suggest the default changes to TRUE.

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13"
# dependencies = ["duckdb==1.5.6"]
# ///
"""Minimal reproduction: DuckDB does not encrypt temp spill files for an attached
encrypted database.

The documentation (https://duckdb.org/2025/11/19/encryption-in-duckdb) states:

    "Temporary files are encrypted (1) automatically when you attach an encrypted
     database or (2) when you use the setting SET temp_file_encryption = true."

As of duckdb 1.5.6, clause (1) does not hold. This was also reproduced on 1.5.1, 1.5.5 and 1.5.6.
Only the explicit setting takes effect; without it, data read from an encrypted database
spills to disk in plaintext.

How it works: each trial builds a table whose every row contains a known marker
string, then runs an aggregation under a memory limit low enough to force a spill
to a dedicated temp directory. DuckDB deletes spill files as soon as the query
finishes, so the temp directory is polled for the marker while the query is still
running, on a background thread.

Trials:
    unencrypted                 positive control -- MUST leak, else no spill happened
    attach+use                  the documented case that should be encrypted
    attach+use+DETACH memory    rules out the in-memory catalog keeping encryption off
    attach+use+explicit setting  control -- the documented workaround, should not leak
    attach+use +httpfs          rules out the crypto backend (OpenSSL instead of mbedtls)

Usage:
    uv run duckdb_temp_encryption_repro.py

Exit code 0 if DuckDB behaves as documented, 1 if the plaintext leak is present.
"""

import glob
import os
import platform
import shutil
import sys
import tempfile
import threading
import time
from pathlib import Path

import duckdb

MARKER = "ZZSECRETMARKERZZ"
ROWS = 4_000_000
MEMORY_LIMIT = "200MB"

# Big enough to spill under MEMORY_LIMIT; the marker rides along in every row.
SPILL_QUERY = "SELECT s, count(*) FROM t GROUP BY s ORDER BY s DESC"

POLL_INTERVAL = 0.003


def _files_in(directory: Path) -> list[Path]:
    return [
        Path(p)
        for p in glob.glob(str(directory / "**"), recursive=True)
        if os.path.isfile(p)
    ]


def run_trial(name: str, setup: list[str], work_dir: Path) -> int:
    """Force a spill and poll the temp dir for the marker while the query runs.

    Returns the number of spill files found containing plaintext.
    """
    temp_dir = work_dir / f"tmp_{name}"
    temp_dir.mkdir(parents=True, exist_ok=True)

    con = duckdb.connect(":memory:")
    for statement in setup:
        con.execute(statement)
    con.execute(f"SET temp_directory='{temp_dir}'")
    con.execute(f"SET memory_limit='{MEMORY_LIMIT}'")
    con.execute("SET threads=2")
    con.execute("SET preserve_insertion_order=false")
    con.execute(
        f"CREATE TABLE t AS SELECT i, '{MARKER}' || i::VARCHAR AS s FROM range({ROWS}) tbl(i)"
    )

    setting = bool(
        con.execute("SELECT current_setting('temp_file_encryption')").fetchone()[0]
    )
    catalog = con.execute("SELECT current_database()").fetchone()[0]

    seen: set[str] = set()
    leaking: set[str] = set()
    done = threading.Event()

    def query() -> None:
        try:
            con.execute(SPILL_QUERY).fetchall()
        finally:
            done.set()

    thread = threading.Thread(target=query)
    thread.start()
    # DuckDB deletes spill files when the query finishes, so sample while it runs.
    while not done.is_set():
        for path in _files_in(temp_dir):
            seen.add(path.name)
            try:
                blob = path.read_bytes()
            except OSError:
                continue  # file vanished mid-read
            if MARKER.encode() in blob:
                leaking.add(path.name)
        time.sleep(POLL_INTERVAL)
    thread.join()
    con.close()

    print(
        f"  {name:34} catalog={catalog:8} temp_file_encryption={str(setting):5} "
        f"spill_files={len(seen):3} leaking={len(leaking):3} {sorted(leaking)[:2]}"
    )
    return len(leaking)


def main() -> int:
    work_dir = Path(tempfile.mkdtemp(prefix="duckdb_tempenc_"))
    print(f"duckdb {duckdb.__version__}")
    print(f"python {platform.python_version()} on {platform.platform()}")
    print(f"scratch dir {work_dir}\n")

    def attach(name: str, *, key: bool) -> list[str]:
        db = work_dir / f"{name}.db"
        opts = " (ENCRYPTION_KEY 'k')" if key else ""
        return [f"ATTACH '{db}' AS d{opts}", "USE d"]

    try:
        # Positive control: nothing is encrypted, so plaintext MUST appear. If it
        # does not, the harness is not producing spills and the run proves nothing.
        control_leak = run_trial(
            "unencrypted (control)", attach("plain", key=False), work_dir
        )

        attach_leak = run_trial("attach+use", attach("enc1", key=True), work_dir)
        detach_leak = run_trial(
            "attach+use+DETACH memory",
            [*attach("enc2", key=True), "DETACH memory"],
            work_dir,
        )
        explicit_leak = run_trial(
            "attach+use+explicit setting",
            [*attach("enc3", key=True), "SET temp_file_encryption=true"],
            work_dir,
        )
        # httpfs swaps mbedtls for OpenSSL, ruling out the crypto backend as the
        # variable. It may not be installed on a fresh machine, so treat it as optional.
        try:
            duckdb.connect(":memory:").execute("LOAD httpfs")
        except duckdb.Error as exc:
            print(f"  {'attach+use +httpfs (OpenSSL)':34} SKIPPED ({exc})")
            httpfs_leak = 0
        else:
            httpfs_leak = run_trial(
                "attach+use +httpfs (OpenSSL)",
                ["LOAD httpfs", *attach("enc4", key=True)],
                work_dir,
            )
    finally:
        shutil.rmtree(work_dir, ignore_errors=True)

    print()
    if not control_leak:
        print(
            "INCONCLUSIVE: control did not leak, so no spill was produced. Raise ROWS."
        )
        return 1
    if explicit_leak:
        print("UNEXPECTED: explicit temp_file_encryption=true still leaked plaintext.")
        return 1
    if attach_leak or detach_leak or httpfs_leak:
        print(
            "BUG PRESENT: attaching an encrypted database did not encrypt spill files.\n"
            "Plaintext from the encrypted database was readable in the temp directory.\n"
            "Workaround: SET temp_file_encryption=true explicitly."
        )
        return 1
    print("AS DOCUMENTED: attaching an encrypted database encrypted the spill files.")
    return 0


if __name__ == "__main__":
    sys.exit(main())

The script output

duckdb 1.5.6
python 3.14.5 on macOS-27.0.1-arm64-arm-64bit-Mach-O
scratch dir /var/folders/62/rj1rzcwj3339x8wm28kz9t7w0000gn/T/duckdb_tempenc_614cpb9u

  unencrypted (control)              catalog=d        temp_file_encryption=False spill_files=  7 leaking=  6 ['duckdb_temp_storage_DEFAULT-0.tmp', 'duckdb_temp_storage_S128K-0.tmp']
  attach+use                         catalog=d        temp_file_encryption=False spill_files=  7 leaking=  7 ['duckdb_temp_storage_DEFAULT-0.tmp', 'duckdb_temp_storage_S128K-0.tmp']
  attach+use+DETACH memory           catalog=d        temp_file_encryption=False spill_files=  6 leaking=  6 ['duckdb_temp_storage_DEFAULT-0.tmp', 'duckdb_temp_storage_S128K-0.tmp']
  attach+use+explicit setting        catalog=d        temp_file_encryption=True  spill_files=  8 leaking=  0 []
  attach+use +httpfs (OpenSSL)       catalog=d        temp_file_encryption=False spill_files=  8 leaking=  7 ['duckdb_temp_storage_DEFAULT-0.tmp', 'duckdb_temp_storage_S128K-0.tmp']

OS:

OSX Apple Silicon

DuckDB Package Version:

1.5.6

Python Version:

3.14.5

Full Name:

Chris Killpack

Affiliation:

Argus Panoptes

What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.

I have tested with a stable release

Did you include all relevant data sets for reproducing the issue?

Yes

Did you include all code required to reproduce the issue?

  • Yes, I have

Did you include all relevant configuration to reproduce the issue?

  • Yes, I have
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions