Skip to main content

Unlock encrypted OpenZFS datasets over a restricted SSH receiver

Project description

ZFS Unlock

PyPI Python Tests License

Unlock encrypted OpenZFS datasets over a restricted SSH receiver.

Why?

This is the NixOS/OpenZFS counterpart to truenas-unlock.

ZFS native encryption is useful, but:

  1. Storing keys on the NAS defeats the purpose—if it's stolen, the thief has both the encrypted data and the keys
  2. Manual unlocking is tedious—after every reboot, you need to manually decrypt each dataset

This tool solves both problems with the same "poor-man's second-factor" setup as truenas-unlock:

  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 NAS is stolen, data remains encrypted and inaccessible

Unlike a plain root SSH key, the NAS-side 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 NAS-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 NAS boots. No manual intervention required.

Table of Contents

Install

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

# With pip
pip install zfs-unlock

Setup

Create ~/.config/zfs-unlock/config.yaml on the off-box unlock device:

host: nas.local
user: zfs-unlock
identity_file: ~/.ssh/zfs-unlock-nas

# 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 NAS, install a forced-command receiver. A NixOS setup can look like this:

{ pkgs, ... }:

let
  zfsUnlock = pkgs.writeShellScriptBin "zfs-unlock" ''
    exec ${pkgs.uv}/bin/uv tool run zfs-unlock "$@"
  '';

  receiver = pkgs.writeShellScript "zfs-unlock-receiver" ''
    exec ${zfsUnlock}/bin/zfs-unlock receiver \
      --allow-file /etc/zfs-unlock/allowed-datasets "$@"
  '';

  sshWrapper = pkgs.writeShellScript "zfs-unlock-ssh-wrapper" ''
    set -eu
    exec ${pkgs.sudo}/bin/sudo -n ${receiver} "$SSH_ORIGINAL_COMMAND"
  '';
in
{
  users.groups.zfs-unlock = {};

  users.users.zfs-unlock = {
    isSystemUser = true;
    group = "zfs-unlock";
    home = "/var/lib/zfs-unlock";
    createHome = true;
    openssh.authorizedKeys.keys = [
      ''restrict,from="192.168.1.50",command="${sshWrapper}" ssh-ed25519 AAAA... unlock-device''
    ];
  };

  security.sudo.extraRules = [
    {
      users = [ "zfs-unlock" ];
      commands = [
        {
          command = "${receiver}";
          options = [ "NOPASSWD" ];
        }
      ];
    }
  ];

  environment.etc."zfs-unlock/allowed-datasets".text = ''
    tank/syncthing
    tank/photos
  '';
}

The wrapper captures SSH_ORIGINAL_COMMAND before sudo and passes it as one argument to the root receiver. The receiver still checks the requested dataset against /etc/zfs-unlock/allowed-datasets.

Usage

# Run once
zfs-unlock

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

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

# Dry run
zfs-unlock --dry-run

CLI

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

 Unlock OpenZFS datasets over a restricted SSH receiver

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --config    -c      PATH     Config file path                                │
│ --dry-run   -n               Show what would be done                         │
│ --daemon    -d               Run continuously                                │
│ --interval  -i      INTEGER  Seconds between checks (1s if unreachable)      │
│                              [default: 30]                                   │
│ --dataset   -D      TEXT     Filter by dataset path                          │
│ --version   -v               Show version and exit                           │
│ --help      -h               Show this message and exit.                     │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Commands ───────────────────────────────────────────────────────────────────╮
│ lock      Lock configured datasets.                                          │
│ status    Show lock status of configured datasets.                           │
│ receiver  Run the restricted NAS-side receiver.                              │
│ service   Manage system service                                              │
╰──────────────────────────────────────────────────────────────────────────────╯

Running as a Service

Requires uv to be installed. 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

Based on 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.1.0.tar.gz (69.5 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.1.0-py3-none-any.whl (12.0 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: zfs_unlock-0.1.0.tar.gz
  • Upload date:
  • Size: 69.5 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.1.0.tar.gz
Algorithm Hash digest
SHA256 75b8ce127c7a7af461a231c5e083cc36f6018cd33c0931cfa192437e2b08dbb3
MD5 a855e5ebd71163df3af73aa7d710df8a
BLAKE2b-256 67f9351cd5316af0169e4310909dc697d7a818738bc796ce59f6914ed9e0e32e

See more details on using hashes here.

Provenance

The following attestation bundles were made for zfs_unlock-0.1.0.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: zfs_unlock-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 12.0 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 11db0c64ffae1a3796cdabe2272db2ecc5dcf671739376a18d301220c0d81249
MD5 25422e1244efadf38d6e2d1a74a7f201
BLAKE2b-256 7e1e405814be89ce30ff97aeaf7f0b007b6d0ef53e4ab3e1016a9ff856e89a83

See more details on using hashes here.

Provenance

The following attestation bundles were made for zfs_unlock-0.1.0-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