Skip to main content

Unlock encrypted OpenZFS datasets over a restricted SSH receiver

Project description

ZFS Unlock

PyPI Python Nix Tests License

ZFS Unlock Logo

Unlock encrypted OpenZFS datasets over SSH, through a restricted receiver on the ZFS host, with passphrases kept on a separate trusted machine.

Why?

OpenZFS native encryption protects data at rest, but encrypted datasets still need their keys loaded after every reboot. The easy automation path is to store key files on the storage host, but that weakens the model: if the host is stolen, the attacker has both the encrypted data and the keys. zfs-unlock keeps those passphrases on a separate trusted machine and sends them only when it can reach a restricted SSH receiver on the ZFS host.

There are two roles:

  • the unlock device stores passphrases and runs zfs-unlock unlock, either once or as a daemon
  • the ZFS host runs a forced-command receiver that can only operate on explicitly allowed datasets

This gives you a practical second factor for storage unlocks:

  1. Run zfs-unlock on a separate device (Raspberry Pi, home server, etc.)
  2. Store encryption passphrases only on that device
  3. Datasets auto-unlock when both devices are on the network
  4. If the storage host is stolen, data remains encrypted and inaccessible

Unlike a plain root SSH key, the receiver path is intentionally narrow:

  • a dedicated zfs-unlock SSH user
  • an SSH key restricted with restrict, from=..., and command=...
  • sudo permission only for a root-owned receiver wrapper
  • a receiver-side dataset allowlist
  • a receiver parser that only accepts status, unlock, and lock

Think of it as a hardware security key for your storage—hidden somewhere in your house, it automatically unlocks your datasets whenever your ZFS host boots. No manual intervention required.

This project came from my own migration path: I happily used truenas-unlock on TrueNAS, then built this generic OpenZFS version after switching my storage host from TrueNAS to NixOS. Nix is optional; the Python CLI and restricted SSH receiver work without Nix, while the included NixOS modules provide declarative setup when you want it.

Table of Contents

Install

Nix is optional. zfs-unlock is a Python package; install it with uv or pip on the unlock device, and on the ZFS host if you configure the receiver manually. The NixOS modules below are convenience wrappers for creating the receiver account, forced command, sudo rule, allowlist, and client service.

# With uv (recommended)
uv tool install zfs-unlock

# With pip
pip install zfs-unlock

Setup

Generate a dedicated SSH key on the off-box unlock device:

zfs-unlock keygen --identity-file ~/.ssh/zfs-unlock-receiver --comment pi4-zfs-unlock

Add the printed public key to the receiver host's authorizedKeys list below, then create ~/.config/zfs-unlock/config.yaml on the off-box unlock device:

host: zfs-host.example.lan
user: zfs-unlock
identity_file: ~/.ssh/zfs-unlock-receiver
# command_timeout: 30

# secrets: auto  # auto (default) | files | inline

datasets:
  tank/syncthing: ~/.secrets/syncthing-key
  tank/photos: my-literal-passphrase

The secrets mode controls how values are interpreted:

  • auto (default): if file exists, read from it; otherwise use as literal
  • files: always treat values as file paths
  • inline: always treat values as literal secrets

On the ZFS host, enable the forced-command receiver. The receiver is just zfs-unlock receiver --allow-file ... --zfs-path ... behind a restricted SSH forced command. With NixOS flakes, the optional module can generate that setup:

{
  inputs.zfs-unlock.url = "github:basnijholt/zfs-unlock";

  outputs = { nixpkgs, zfs-unlock, ... }: {
    nixosConfigurations.storage = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        zfs-unlock.nixosModules.receiver
        ./hosts/storage/default.nix
      ];
    };
  };
}

Then configure only the receiver policy on the ZFS host:

{
  services.zfsUnlock.receiver = {
    enable = true;
    allowedFrom = [ "192.168.1.50" ];
    authorizedKeys = [
      "ssh-ed25519 AAAA... unlock-device"
    ];
    datasets = [
      "tank/syncthing"
      "tank/photos"
    ];
  };
}

The module creates the zfs-unlock SSH user, forced command, sudo rule, receiver wrapper, login shell, and /etc/zfs-unlock/allowed-datasets. The receiver still checks each requested dataset against that allowlist. By default the module also enables systemd linger for the receiver user. That keeps the receiver user's systemd user manager stable across short-lived forced-command SSH sessions and avoids NixOS switch-time D-Bus races after the receiver account has been used. Set services.zfsUnlock.receiver.enableLinger = false if you do not want the module to manage linger for that user.

On a NixOS unlock device, include the client module and enable the daemon:

{
  imports = [
    zfs-unlock.nixosModules.client
  ];

  services.zfsUnlock.client = {
    enable = true;
    user = "alice";
    group = "users";
  };
}

The client module creates a zfs-unlock.service system service, runs the packaged zfs-unlock executable, installs that CLI into the system profile, adds OpenSSH to the service PATH, and sets HOME/XDG_CONFIG_HOME so the normal user config is found.

After rebuilding the receiver host, verify the client and receiver path:

zfs-unlock doctor

doctor also checks that the configured SSH identity file and file-backed dataset secrets are private to the local user.

Usage

# Run once
zfs-unlock unlock

# Run as daemon
# (Checks every 1s if the receiver host is unreachable, otherwise every 30s)
zfs-unlock unlock --daemon

# Custom interval (for the "relaxed" state)
zfs-unlock unlock --daemon --interval 60

# Dry run
zfs-unlock unlock --dry-run

# Check config, key, network, and receiver status
zfs-unlock doctor

# Show configured dataset status
zfs-unlock status

# Lock a dataset after its services have stopped using it
zfs-unlock lock -D tank/photos

# Force-unmount mounted descendants before unloading the key
zfs-unlock lock --force -D tank/photos

zfs-unlock lock can fail with Key unload error: '<dataset>' is busy when a service still has files open on that dataset. Stop the service first, or use --force when you intentionally want to unmount the dataset and disrupt those processes. Even with --force, OpenZFS can refuse to unmount a dataset that is still held by NFS, SMB, client mounts, or kernel users. Unmount clients or stop exports first, then retry the lock.

Bare zfs-unlock shows help and does not unlock anything. Use the explicit unlock subcommand for state-changing unlock operations.

CLI

zfs-unlock --help
 Usage: zfs-unlock [OPTIONS] COMMAND [ARGS]...

 Unlock OpenZFS datasets over a restricted SSH receiver

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --version  -v        Show version and exit                                   │
│ --help     -h        Show this message and exit.                             │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Client Commands ────────────────────────────────────────────────────────────╮
│ unlock    Unlock configured datasets.                                        │
│ lock      Lock configured datasets.                                          │
│ status    Show lock status of configured datasets.                           │
│ doctor    Check client config, SSH key, host reachability, and receiver      │
│           status.                                                            │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Setup Commands ─────────────────────────────────────────────────────────────╮
│ keygen    Generate a dedicated SSH key for zfs-unlock.                       │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Receiver Commands ──────────────────────────────────────────────────────────╮
│ receiver  Run the restricted receiver.                                       │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Service Commands ───────────────────────────────────────────────────────────╮
│ service   Manage system service                                              │
╰──────────────────────────────────────────────────────────────────────────────╯

Running as a Service

On NixOS, prefer the services.zfsUnlock.client module shown above. The portable CLI installer requires uv and auto-detects Linux (systemd) or macOS (launchd):

# Install and start
zfs-unlock service install

# Check status
zfs-unlock service status

# View logs (follows by default)
zfs-unlock service logs

# Uninstall
zfs-unlock service uninstall

Development

# Clone and install
git clone https://github.com/basnijholt/zfs-unlock
cd zfs-unlock
uv sync --dev

# Run tests
uv run pytest

# Run lints
uv run ruff check .
uv run mypy zfs_unlock.py

Credits

Inspired by truenas-unlock.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zfs_unlock-0.5.4.tar.gz (81.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zfs_unlock-0.5.4-py3-none-any.whl (15.4 kB view details)

Uploaded Python 3

File details

Details for the file zfs_unlock-0.5.4.tar.gz.

File metadata

  • Download URL: zfs_unlock-0.5.4.tar.gz
  • Upload date:
  • Size: 81.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for zfs_unlock-0.5.4.tar.gz
Algorithm Hash digest
SHA256 370a1a4fc7ffc19547b99dbbaf242d8d981e4bd77ea11328710092e857c12d76
MD5 ff1c606e37ba896b717288a637d1b32e
BLAKE2b-256 8a54d329317988cd339a6e5a99cf15475a6b90e9e803664bce26aabcf6adb489

See more details on using hashes here.

Provenance

The following attestation bundles were made for zfs_unlock-0.5.4.tar.gz:

Publisher: release.yml on basnijholt/zfs-unlock

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zfs_unlock-0.5.4-py3-none-any.whl.

File metadata

  • Download URL: zfs_unlock-0.5.4-py3-none-any.whl
  • Upload date:
  • Size: 15.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for zfs_unlock-0.5.4-py3-none-any.whl
Algorithm Hash digest
SHA256 5637cbf1ebea70712c8c377d221c3c0fab9322be3b6411cfc7be73b59c024c53
MD5 c1fa71980ab5da70819cbe900902e1b5
BLAKE2b-256 b175be47fee488016627c1ef36b111f07795d3dad2195449d4672ab78ff45217

See more details on using hashes here.

Provenance

The following attestation bundles were made for zfs_unlock-0.5.4-py3-none-any.whl:

Publisher: release.yml on basnijholt/zfs-unlock

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page