Question / Feature Request: Remote Unlock of Encrypted Datasets in TrueNAS SCALE

Hi I am new to TrueNas. I would like to migrate, but I am really unsure if it fees my security profile.

I always use a mdadm RAID5 that unlocked automatically when decrypting a LUKS encrypted Debian server.

Meaning the data is only unlocked and available, when the encrypted server is turned on.

I’m looking to upgrade to new much lager disks, and to use ZFS pools partly for the encrypted dataset, and partly for many normal datasets for dockers etc. Meaning I would like to have a TrueNas for storage + VM servers and LXCs all on a Proxmox environment.

I’ve been exploring whether encrypted datasets in TrueNAS SCALE can be unlocked remotely in a “no-trust NAS / full-trust workload / server” architecture, and I’m trying to confirm whether what I observed is by design or a limitation that could be revisited.

What I tried (and failed) when unlocking the encrypted dataset remotely

  • REST API (various payloads, documented and undocumented fields. APT depreciated?)

  • JSON-RPC over WebSocket (auth succeeds, unlock jobs never complete)

  • websocat and Python websocket clients(auth succeeds, unlock jobs never complete)

  • Passphrase-based dataset encryption (not key files)

  • decryption over SSH / scripts not possible (no root login and no sudo is possible.)

Result in all cases:

  • Authentication succeeds

  • Dataset unlock never succeeds remotely

  • No clear error explaining that remote unlock is disallowed

This suggests that remote unlocking of encrypted datasets is effectively impossible, even with valid API keys and the correct passphrase.

Can some correct me if its actually possible, and advice please how to do it.

Architectural question

I understand that TrueNAS encryption is designed to protect against:

  • Stolen disks

  • Physical compromise

  • Offline attacks

That makes sense.

However, in many homelab setups, the desired model may very well be different:

  • :cross_mark: NAS is not trusted

  • :white_check_mark: A VM or application using the data is trusted (Properly Fully encrypted)

  • Dataset should unlock only while that workload / server is running

  • Dataset should re-lock when the workload / sever stops

Right now, the only practical option seems to be:

  • Auto-unlock on boot (leaving the key permanently “in the open door” of a 10 inch steel vault), or

  • Manual unlock via UI - which is pretty tedious

Which raises the question:

If encryption is automatically done at boot, what security problem is it actually solving in a remote or unattended environment?

Feature request / food for thought

Would it be possible (or acceptable) to support explicit, opt-in remote unlocking of encrypted datasets when:

  • API keys are used

  • A passphrase is supplied

  • The user explicitly accepts the risk

  • This fits their security model

This would enable a “no-trust storage / trusted compute/server” design, where encryption is controlled by the workload lifecycle rather than the NAS itself.

I may be trying to use storage-layer encryption like application-layer encryption, but that seems like a valid and increasingly common use case. Secret data used by a secret secure sever!

Is remote unlock intentionally forbidden by design?

If so, could this be documented more clearly — or reconsidered as an optional feature?

Thanks for any insight from the developers or community.

The same API is used for all of the calls, whether it’s remote or local. The WebUI doesn’t have any special rights or greater access to APIs than a user with the proper API key.

Can you post a (sanitized) version of the code you’re using to call the unlock? It should be working.

The challenge of “NAS is not trusted” though means your only proper solution is client-side encryption. This is the same principle as putting your data on cloud storage - I don’t trust the remote system, so I encrypt my data before I upload it.

I mean, you could write up a third-party server to look for/authenticate with your VM/application server, and then unlock the data when it sees that it’s up/running, and if it’s been offline for (N) seconds, lock the dataset again, but there’s opportunity for TOCTOU (Time-Of-Check/Time-Of-Use) attacks against it then, or someone impersonating/MITM’ing the third party server that way.

Thankyou for looking into this issue.

Its a home server/lab proxmox environment and most of the encryption is partly for fun / so see how it works, and partly because i am super interested in the subject.

I have a hard time understanding the idqar with an encruypted, dataset, if its just automaticlly unlucked if the TrueNAS is turned on.

So i what to control the encrypted lock/unlock mode from the encrypted VM that uses this dataset.

With som help form AI I made a

Vault.conf for variables. FIX THE LINKS!

TRUENAS_HOST=“h_ttp_s://192.168.1.105”

TRUENAS_WS=“w_s_s://192.168.1.105/websocket”

DATASET=“HDD465GB/vault”

API_KEY=“1-someapikey”

PASSPHRASE=“the-passfrase-auto-generated-by-TrueNAS”

_________________________________

1 try with /usr/local/bin/websocat

I never had any luck with this approch

#!/usr/bin/env bash
set -euo pipefail

CONF=“$(dirname “$0”)/vault.conf”
source “$CONF”

echo “========== TrueNAS SCALE 25.10 JSON-RPC UNLOCK ==========”
echo “[] WebSocket URL : $TRUENAS_WS"
echo "[
] Dataset : $DATASET”
echo “[*] API key : ${API_KEY:0:6}…”
echo “========================================================”
echo

/usr/local/bin/websocat --no-close --insecure “$TRUENAS_WS” <<EOF
{“jsonrpc”:“2.0”,“method”:“auth.login_with_api_key”,“params”:[“$API_KEY”],“id”:1}
{“jsonrpc”:“2.0”,“method”:“core.call”,“params”:[“pool.dataset.unlock”,[{“id”:“$DATASET”,“passphrase”:“$PASSPHRASE”}]],“id”:2}
EOF

2 try with python installed without environments

apt update
apt install python3-pip -y

Install websocket-client system-wide, bypassing Debian protection

pip install --break-system-packages websocket-client

unlock_vault.py - here full verbose with comments to see what is going on.

#!/usr/bin/env python3
import os
import json
import time
import ssl
import requests
from websocket import create_connection, WebSocketConnectionClosedException

---------------- load config ----------------

CONF = os.path.expanduser(“~/vault/vault.conf”)
cfg = {}
with open(CONF) as f:
for line in f:
if “=” in line.strip() and not line.strip().startswith(“#”):
k, v = line.strip().split(“=”, 1)
cfg[k] = v.strip(‘"’)

HOST = cfg[“TRUENAS_HOST”]
WS = cfg[“TRUENAS_WS”]
DATASET = cfg[“DATASET”]
API_KEY = cfg[“API_KEY”]
PASSPHRASE = cfg[“PASSPHRASE”]

print(“========== TrueNAS SCALE 25.10 UNLOCK (VERBOSE) ==========”)
print(f"[] Host : {HOST}“)
print(f”[
] WebSocket : {WS}“)
print(f”[] Dataset : {DATASET}“)
print(f”[
] API key : {API_KEY[:6]}…“)
print(”==========================================================\n")

---------------- connect websocket ----------------

print(“[*] Opening WebSocket…”)
try:
ws = create_connection(
WS,
sslopt={“cert_reqs”: ssl.CERT_NONE},
timeout=10
)
except Exception as e:
raise SystemExit(f"[✗] Failed to connect WebSocket: {e}")

print(“[✓] WebSocket connection established\n”)

---------------- authenticate ----------------

auth = {
“jsonrpc”: “2.0”,
“method”: “auth.login_with_api_key”,
“params”: [API_KEY],
“id”: 1
}

print(“[>] Sending AUTH request:”)
print(json.dumps(auth, indent=2))
ws.send(json.dumps(auth))

try:
raw_resp = ws.recv()
if not raw_resp:
raise SystemExit(“[✗] Empty response from server — check WS endpoint”)
resp = json.loads(raw_resp)
except WebSocketConnectionClosedException:
raise SystemExit(“[✗] WebSocket closed immediately after auth”)
except json.JSONDecodeError:
raise SystemExit(f"[✗] Failed to decode AUTH response: {raw_resp}")

print(“[<] AUTH RESPONSE:”)
print(json.dumps(resp, indent=2))

if not resp.get(“result”):
raise SystemExit(f"[✗] Authentication failed: {resp}“)
print(”[✓] Authenticated successfully\n")

---------------- unlock dataset ----------------

unlock = {
“jsonrpc”: “2.0”,
“method”: “pool.dataset.unlock”,
“params”: [DATASET, {“passphrase”: PASSPHRASE}],
“id”: 2
}

print(“[>] Sending UNLOCK request:”)
print(json.dumps(unlock, indent=2))
ws.send(json.dumps(unlock))

job_id = None
print(“\n[*] Waiting for job completion (verbose output)…\n”)

while True:
try:
raw_msg = ws.recv()
if not raw_msg:
print(“[!] Received empty message, skipping…”)
continue
except WebSocketConnectionClosedException:
raise SystemExit(“[✗] WebSocket closed unexpectedly”)

# Print raw message
print("[< RAW MESSAGE]:", raw_msg)

# Try parsing JSON
try:
    msg = json.loads(raw_msg)
except json.JSONDecodeError:
    print("[!] Could not parse JSON, skipping...")
    continue

# Print parsed JSON nicely
print("[< PARSED MESSAGE]:")
print(json.dumps(msg, indent=2))

# Capture job ID from initial response
if msg.get("id") == 2 and "result" in msg and not job_id:
    job_id = msg["result"]
    print(f"[*] Captured job ID: {job_id}")

# Monitor job events
if msg.get("method") == "core.get_jobs":
    for job in msg.get("params", []):
        if job.get("id") == job_id:
            print(f"[*] Job update: state={job.get('state')}, progress={job.get('progress')}")
            if job.get("state") in ("SUCCESS", "FAILED"):
                print(f"[✓] Job finished: {job.get('state')}")
                ws.close()
                job_state = job.get("state")
                break
    else:
        continue
    break

---------------- verify via REST ----------------

encoded = DATASET.replace(“/”, “%2F”)
r = requests.get(
f"{HOST}/api/v2.0/pool/dataset/id/{encoded}“,
headers={“Authorization”: f"Bearer {API_KEY}”},
verify=False
)

state = r.json().get(“locked”, None)
print(“\n========== FINAL STATE ==========”)
print(“locked =”, state)
print(“================================”)
if state is True:
print(“[✗] Vault is STILL locked”)
else:
print(“[✓] Vault is UNLOCKED”)

@HoneyBadger

The paste of the python script was not optimal.

I tried to upload the file as txt but new users are not allowed.

I have pasted again as preformatted test.

Could you try to make a test run.

Remember to set the valut.conf with APItoken and passphrase and fix the links - they were refused by the editor.

Here is my output from the run on the remote server.

root@DELLABLO:~/vault# ./unlock_vault.py

========== TrueNAS SCALE 25.10 UNLOCK (VERBOSE) ==========
[] Host : h_____ttps://192.168.1.105
[
] WebSocket : w____ss://192.168.1.105/websocket
[] Dataset : HDD465GB/vault
[
] API key : 1-llVU…

[*] Opening WebSocket…
[✓] WebSocket connection established

[>] Sending AUTH request:
{
“jsonrpc”: “2.0”,
“method”: “auth.login_with_api_key”,
“params”: [
“1-llVUxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx”
],
“id”: 1
}
[✗] Empty response from server — check WS endpoint
root@DELLABLO:~/vault#

#!/usr/bin/env python3
import os
import json
import time
import ssl
import requests
from websocket import create_connection, WebSocketConnectionClosedException

# ---------------- load config ----------------
CONF = os.path.expanduser("~/vault/vault.conf")
cfg = {}
with open(CONF) as f:
    for line in f:
        if "=" in line.strip() and not line.strip().startswith("#"):
            k, v = line.strip().split("=", 1)
            cfg[k] = v.strip('"')

HOST = cfg["TRUENAS_HOST"]
WS   = cfg["TRUENAS_WS"]
DATASET = cfg["DATASET"]
API_KEY = cfg["API_KEY"]
PASSPHRASE = cfg["PASSPHRASE"]

print("========== TrueNAS SCALE 25.10 UNLOCK (VERBOSE) ==========")
print(f"[*] Host      : {HOST}")
print(f"[*] WebSocket : {WS}")
print(f"[*] Dataset   : {DATASET}")
print(f"[*] API key   : {API_KEY[:6]}...")
print("==========================================================\n")

# ---------------- connect websocket ----------------
print("[*] Opening WebSocket...")
try:
    ws = create_connection(
        WS,
        sslopt={"cert_reqs": ssl.CERT_NONE},
        timeout=10
    )
except Exception as e:
    raise SystemExit(f"[?] Failed to connect WebSocket: {e}")

print("[?] WebSocket connection established\n")

# ---------------- authenticate ----------------
auth = {
    "jsonrpc": "2.0",
    "method": "auth.login_with_api_key",
    "params": [API_KEY],
    "id": 1
}

print("[>] Sending AUTH request:")
print(json.dumps(auth, indent=2))
ws.send(json.dumps(auth))

try:
    raw_resp = ws.recv()
    if not raw_resp:
        raise SystemExit("[?] Empty response from server — check WS endpoint")
    resp = json.loads(raw_resp)
except WebSocketConnectionClosedException:
    raise SystemExit("[?] WebSocket closed immediately after auth")
except json.JSONDecodeError:
    raise SystemExit(f"[?] Failed to decode AUTH response: {raw_resp}")

print("[<] AUTH RESPONSE:")
print(json.dumps(resp, indent=2))

if not resp.get("result"):
    raise SystemExit(f"[?] Authentication failed: {resp}")
print("[?] Authenticated successfully\n")

# ---------------- unlock dataset ----------------
unlock = {
    "jsonrpc": "2.0",
    "method": "pool.dataset.unlock",
    "params": [DATASET, {"passphrase": PASSPHRASE}],
    "id": 2
}

print("[>] Sending UNLOCK request:")
print(json.dumps(unlock, indent=2))
ws.send(json.dumps(unlock))

job_id = None
print("\n[*] Waiting for job completion (verbose output)...\n")

while True:
    try:
        raw_msg = ws.recv()
        if not raw_msg:
            print("[!] Received empty message, skipping...")
            continue
    except WebSocketConnectionClosedException:
        raise SystemExit("[?] WebSocket closed unexpectedly")

    # Print raw message
    print("[< RAW MESSAGE]:", raw_msg)

    # Try parsing JSON
    try:
        msg = json.loads(raw_msg)
    except json.JSONDecodeError:
        print("[!] Could not parse JSON, skipping...")
        continue

    # Print parsed JSON nicely
    print("[< PARSED MESSAGE]:")
    print(json.dumps(msg, indent=2))

    # Capture job ID from initial response
    if msg.get("id") == 2 and "result" in msg and not job_id:
        job_id = msg["result"]
        print(f"[*] Captured job ID: {job_id}")

    # Monitor job events
    if msg.get("method") == "core.get_jobs":
        for job in msg.get("params", []):
            if job.get("id") == job_id:
                print(f"[*] Job update: state={job.get('state')}, progress={job.get('progress')}")
                if job.get("state") in ("SUCCESS", "FAILED"):
                    print(f"[?] Job finished: {job.get('state')}")
                    ws.close()
                    job_state = job.get("state")
                    break
        else:
            continue
        break

# ---------------- verify via REST ----------------
encoded = DATASET.replace("/", "%2F")
r = requests.get(
    f"{HOST}/api/v2.0/pool/dataset/id/{encoded}",
    headers={"Authorization": f"Bearer {API_KEY}"},
    verify=False
)

state = r.json().get("locked", None)
print("\n========== FINAL STATE ==========")
print("locked =", state)
print("================================")
if state is True:
    print("[?] Vault is STILL locked")
else:
    print("[?] Vault is UNLOCKED")

========== TrueNAS SCALE 25.10 DATASET UNLOCK ========== [*] Host URL : h____ttps://192.168.1.105 [*] WS URL : wss://192.168.1.105/websocket [*] Dataset : HDD465GB/vault [*] API key : 1-llVU… ======================================================= [*] Opening WebSocket connection… [*] Forced headers (required by TrueNAS nginx): Host: localhost Origin: h____ttps://localhost [*] Subprotocol: jsonrpc [FATAL] WebSocket connection failed: Handshake status 400 Bad Request -±± {‘server’: ‘nginx’, ‘date’: ‘Thu, 15 Jan 2026 11:33:48 GMT’, ‘content-type’: ‘text/html’, ‘content-length’: ‘150’, ‘connection’: ‘close’, ‘strict-transport-security’: ‘max-age=0; includeSubDomains; preload’, ‘x-content-type-options’: ‘nosniff’, ‘x-xss-protection’: ‘1; mode=block’, ‘permissions-policy’: ‘geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()’, ‘referrer-policy’: ‘strict-origin’, ‘x-frame-options’: ‘SAMEORIGIN’} -±± b’\r\n400 Bad Request\r\n\r\n400 Bad Request\r\nnginx\r\n\r\n\r\n’

Here another error indicating that the local nginx will not handle my request no matter what.

The real problem (according to ChatGPT on this error)

On TrueNAS SCALE 25.10 (Goldeye):

/websocket is NO LONGER a valid public WebSocket endpoint

nginx now returns HTTP 400 for any direct upgrade attempt to /websocket, no matter what headers you send.

This seems to be exactly what you are seeing — consistently, correctly, and inevitably.

  • Is the API locked to local-host ?

  • Will I ever be able to set anything on: w____ss://192.168.1.105/websocket from my debian VM ??

I tried to issue a root command on the trueNAS to change but it was not accepted.

Is there a place in the GUI where this is set or allowed?

root@truenas[/home/truenas_admin]# midclt call system.general.update ‘{
“ui_origin”: “h____ttps://truenas.local”
}’

[EINVAL] general_settings.ui_origin: Extra inputs are not permitted
root@truenas[/home/truenas_admin]#

I’m currently trying to figure out how to setup my entire storage and vm solution in proxmox.

It wold be really great to get verified if its possible or nor, to decrypt a dataset remotely on trueNAS scale.

Do you have any input or perhaps a code suggestion to try. @HoneyBadger