Unlock encrypted OpenZFS datasets over a restricted SSH receiver
Project description
ZFS Unlock
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:
- Storing keys on the NAS defeats the purpose—if it's stolen, the thief has both the encrypted data and the keys
- 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:
- Run
zfs-unlockon a separate device (Raspberry Pi, home server, etc.) - Store encryption passphrases only on that device
- Datasets auto-unlock when both devices are on the network
- 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-unlockSSH user - an SSH key restricted with
restrict,from=..., andcommand=... - sudo permission only for a root-owned receiver wrapper
- a NAS-side dataset allowlist
- a receiver parser that only accepts
status,unlock, andlock
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
Generate a dedicated SSH key on the off-box unlock device:
zfs-unlock keygen --identity-file ~/.ssh/zfs-unlock-nas --comment pi4-zfs-unlock
Add the printed public key to the NAS-side authorizedKeys list below, then
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, enable the forced-command receiver. With flakes, add zfs-unlock
as an input and include its NixOS module in the NAS module list:
{
inputs.zfs-unlock.url = "github:basnijholt/zfs-unlock";
outputs = { nixpkgs, zfs-unlock, ... }: {
nixosConfigurations.nas = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
zfs-unlock.nixosModules.receiver
./hosts/nas/default.nix
];
};
};
}
Then configure only the receiver policy on the NAS:
{
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.
After rebuilding the NAS, verify the client and receiver path:
zfs-unlock doctor
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
# Check config, key, network, and receiver status
zfs-unlock doctor
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 ───────────────────────────────────────────────────────────────────╮
│ keygen Generate a dedicated SSH key for zfs-unlock. │
│ doctor Check client config, SSH key, host reachability, and receiver │
│ status. │
│ 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zfs_unlock-0.3.0.tar.gz.
File metadata
- Download URL: zfs_unlock-0.3.0.tar.gz
- Upload date:
- Size: 75.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ede3b1f1f3600e20365bbd9af8ce2afa82b0fc3c27f136a499cf7f8879a83816
|
|
| MD5 |
d32702825c60a4bbe4d8165b48160b90
|
|
| BLAKE2b-256 |
51593d93f86a2e80e2829a1a519ad113dec3094b50fb7b9872734468f75d40d2
|
Provenance
The following attestation bundles were made for zfs_unlock-0.3.0.tar.gz:
Publisher:
release.yml on basnijholt/zfs-unlock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zfs_unlock-0.3.0.tar.gz -
Subject digest:
ede3b1f1f3600e20365bbd9af8ce2afa82b0fc3c27f136a499cf7f8879a83816 - Sigstore transparency entry: 2004442125
- Sigstore integration time:
-
Permalink:
basnijholt/zfs-unlock@a864858726b0a6cb7f6c057933bc33a5d4317163 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/basnijholt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a864858726b0a6cb7f6c057933bc33a5d4317163 -
Trigger Event:
release
-
Statement type:
File details
Details for the file zfs_unlock-0.3.0-py3-none-any.whl.
File metadata
- Download URL: zfs_unlock-0.3.0-py3-none-any.whl
- Upload date:
- Size: 13.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
518e248428f9df54121451136293d3d22aed7e53ab2a9d69d04281725c1397c3
|
|
| MD5 |
ea83e7b998cda336b4889e0d656efd95
|
|
| BLAKE2b-256 |
f311717219c9e24def4fb6464ca412f35c725417b98ac5c46b2f157abff92c8d
|
Provenance
The following attestation bundles were made for zfs_unlock-0.3.0-py3-none-any.whl:
Publisher:
release.yml on basnijholt/zfs-unlock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zfs_unlock-0.3.0-py3-none-any.whl -
Subject digest:
518e248428f9df54121451136293d3d22aed7e53ab2a9d69d04281725c1397c3 - Sigstore transparency entry: 2004442335
- Sigstore integration time:
-
Permalink:
basnijholt/zfs-unlock@a864858726b0a6cb7f6c057933bc33a5d4317163 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/basnijholt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@a864858726b0a6cb7f6c057933bc33a5d4317163 -
Trigger Event:
release
-
Statement type: