Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Transport encryption for ZMQ communication

Summary

We propose to support encrypting the traffic exchanged between the kernels and client, adding a dedicated stanza in kernelspec to enable kernels to communicate support for encryption, adding a new set of fields for key exchange in the connection file, and using CurveZMQ protocol as a default implementation of such an encryption.

Motivation

With the default TCP transport non-authenticated clients with access to ports on the same machine are currently able to monitor IOPub, accessing any secrets exchanged during kernel runtime (code, outputs, etc). It can be only prevented by using IPC transport and file permissions.

Guide-level explanation

The five ZMQ channels a kernel exposes (shell, iopub, stdin, control, and heartbeat) can now be wrapped in transport encryption: the connection between a client and a kernel is bidirectionally encrypted and the kernel is authenticated at the socket level, using ZeroMQ’s CurveZMQ protocol. CurveZMQ provides forward secrecy (it negotiates ephemeral per-connection session keys, so traffic captured today cannot be decrypted later even if the long-term keys leak) and connection-level authentication (a peer that does not hold the right key never gets its messages delivered, instead of being filtered out after the fact by an HMAC check).

Encryption is off by default and is controlled by a single operator-facing setting on the kernel manager, transport_encryption, which takes one of three values:

A kernel advertises that it understands encryption through a new key in its kernel.json metadata:

{
  "argv": ["...", "{connection_file}"],
  "display_name": "Python 3 (ipykernel)",
  "language": "python",
  "metadata": {
    "supported_encryption": ["curve"]
  }
}

ipykernel ships this stanza as of 7.3, so a default Python kernel is encrypted as soon as an operator sets transport_encryption to auto in their jupyter_server or jupyter_client configuration, for example:

# jupyter_server_config.py
c.MappingKernelManager.transport_encryption = "auto"

From a user’s point of view nothing in the notebook experience changes; the difference is purely on the wire. The concrete impact is that a second, unauthenticated process on the same machine can no longer subscribe to a kernel’s iopub channel and read the code, outputs, and any secrets a user printed; today that is only preventable by switching to the ipc:// transport and relying on filesystem permissions.

How others should think about the feature:

There are two new diagnostics a user may notice. When a kernel runs over TCP without encryption, ipykernel now logs a warning:

Kernel is running over TCP without encryption. All traffic, including any secrets in cell outputs, is readable by other processes on this machine. Use the IPC transport or start the kernel through a manager that provisions CurveZMQ keys.

And requesting transport_encryption of required for a kernel that does not advertise support fails at startup:

RuntimeError: transport_encryption=‘required’ but kernelspec does not declare ‘curve’ in metadata.supported_encryption.

We recommend other kernels and clients that decide to implement encryption to emit similar warning and error messages.

Reference-level explanation

Trust model and key ownership

The design deliberately reuses the trust model Jupyter already has for the HMAC message-signing key: secrets live in the connection file, which is protected by filesystem permissions, and any process that can read the connection file is treated as trusted. CurveZMQ adds transport encryption and connection authentication on top of that model; it does not change where the root secret lives.

Key generation is owned by the client side, concretely the kernel manager’s provisioner, not the kernel:

  1. Before launch, if the resolved policy is auto or required, the transport is tcp or ipc, and the kernel is eligible (the kernelspec advertises curve, or the policy is required), the provisioner calls zmq.curve_keypair() once to produce a public and secret key pair.

  2. It writes both into the connection file as two new fields, curve_publickey and curve_secretkey, each a Z85-encoded, 40-character ASCII string (the standard text encoding of a 32-byte Curve key).

  3. The kernel reads the connection file, sees both keys, and binds its sockets as a CurveZMQ server.

  4. The manager (and any other local client reading the same connection file) configures its connecting sockets as CurveZMQ clients.

Because the manager generates the keys before launch, it can configure its own sockets immediately and never has to re-read the file. Keys are generated once and reused across restarts: the connection file is preserved across a restart, exactly like session.key, so the post-restart kernel and the manager keep matching keys.

Socket configuration

On the kernel (server) side, for every socket it binds (shell, stdin, and control as ROUTER, iopub as XPUB, and heartbeat as ROUTER), the kernel sets, before binding:

socket.curve_secretkey = secret   # from connection file
socket.curve_publickey = public   # from connection file
socket.curve_server = True

On the client side, for the connecting sockets the behavior differs slightly by channel:

This “simplest version” (a single keypair used by both ends) choice from the variants considered in the pre-proposal is intentional. The connection file already carries the keypair, so reusing it avoids introducing a second key-distribution mechanism. curve_serverkey authenticates the connection; the rest configures encryption.

The transport_encryption policy

The transport_encryption setting is configurable on the kernel manager in both jupyter_client and jupyter_server, with the values disabled (the default), auto, and required. jupyter_server is a thin layer: when the policy is not disabled, it forwards the policy into the per-kernel launch; the actual key generation, kernelspec check, and connection-file writing happen in jupyter_client. Setting the policy to auto or required while the local libzmq lacks Curve support (zmq.has("curve") is false) is rejected at configuration time, so the misconfiguration surfaces immediately rather than at first kernel start.

The kernel itself has no encryption toggle of its own; it is purely data-driven. It should apply Curve options if the connection file it loads contains both curve_publickey and curve_secretkey. A file with only one of the two should lead to an error and the kernel refusing to connect.

Kernelspec capability advertisement

A kernel declares support with metadata.supported_encryption, a list of the encryption schemes it understands, for example ["curve"]. Entries are matched case-insensitively, and a kernel may advertise more than one, leaving room for additional schemes in future. The check happens before the connection is established: capability cannot be discovered at runtime via kernel_info_reply, because by the time a reply could arrive the (unencrypted) connection already exists. This mirrors how JEP 66 gates the handshake pattern on the static kernel_protocol_version field.

To implement this JEP we will reflect these additions in the official schemas (JEP 105/106). In the connection file (currently connectionfile-v1.0.schema.json, which composes a kernel_network and a signature group), the two Curve fields fit naturally as a new optional encryption group, following the precedent that the signature_scheme and key material lives in the connection file:

{
  "curve_publickey": "b{E{TUJ[Zxl1)]<>W(c*bEff[.[K]BGJE&4iF=oM",
  "curve_secretkey": "<dQ^&)<Lp#8IUJrWoOpOZ5+xjs]UV$ShhdqSEW))"
}

Both keys are optional and MUST appear together; their absence means no transport encryption, which is the legacy behavior. This would bump the schema to a new versioned $id (for example connectionfile-v1.1).

Backward compatibility and corner cases

Composition with the JEP 66 handshake pattern

Transport encryption and the JEP 66 handshake (registration-socket) pattern are orthogonal capabilities that compose without a new scheme. A kernel advertises encryption support through supported_encryption and handshake support through kernel_protocol_version, independently, and the launcher decides which connection pattern to use.

There are three ways the curve keypair could be combined with the handshake. This JEP specifies only the first; the other two are larger changes recorded under Future possibilities.

(a) Keys in the initial connection file (specified here). In the handshake pattern the kernel sets curve_server and binds its sockets before it reports its chosen ports back over the registration socket. The keys must therefore reach the kernel in the initial connection file (the one that carries the registration-socket address), just as ports do in the current pattern; they cannot be negotiated during the handshake itself. The manager-owned key distribution is unchanged, so the kernel’s curve logic (read the keys, apply them before binding) is identical for both connection patterns and needs no handshake-specific code. The trade-off is that this is only a consistency win: the secret still lives in a connection file, so for a local provisioner it is still persisted to disk. It makes encryption available to handshake-pattern kernels but does not harden key distribution. The registration socket stays plaintext: it carries only ports, not the keys or user data, and is HMAC-signed with the session key for integrity.

Two future variants would stop persisting the secret, at a higher cost:

(b) Keys sent over the registration socket. Rather than the connection file, the launcher delivers the curve keys to the kernel over the registration socket, so the kernel’s secret is never written to disk. Because that channel would then carry secret key material, the registration socket must itself be encrypted: it would use its own short-lived curve keypair, with the launcher’s registration public key distributed in the initial connection file, so the registration exchange cannot be eavesdropped either. This removes the persisted secret but introduces a second, bootstrap keypair. (The registration socket today carries connection metadata in plaintext and so is subject to the same exposure that motivates curve in the first place, which this variant also addresses.)

(c) Kernel-generated keys. The kernel generates its own curve keypair and returns only the public key over the registration socket; the secret never leaves the kernel process and is never shared or persisted. This is likely the best end state and makes remote key exchange tractable, but it has the steepest adoption cost: every kernel must generate its own keys (and so be built against a libsodium-enabled libzmq), and it depends on the JEP 66 handshake being implemented end to end. It is closely tied to the server-authentication model in Future possibilities.

Reference implementation

Rationale and alternatives

Rationale

Why CurveZMQ as the default implementation

It is the least invasive way to get transport encryption and connection authentication for Jupyter’s existing ZMQ topology. Enabling it touches only socket creation (three socket options on each end) and nothing in the message protocol itself: no new message types, no changes to signing, framing, or channel semantics. It is built into libzmq and pyzmq, provides forward secrecy, and slots directly into the connection-file key-distribution mechanism Jupyter already uses for the HMAC key. Connection-level authentication is also strictly simpler for kernels to reason about than message-by-message verification: an unauthenticated peer simply never gets through.

Why three states, not a boolean

transport_encryption is a three-valued setting (disabled, auto, required) rather than an on/off flag, so the same option can later select among multiple schemes (for example curve, a future TLS-based transport, or auth-only modes) without a breaking change, and so operators can express “encrypt where possible” versus “encrypt or refuse”, a distinction a boolean cannot capture.

Why the client owns key generation

Putting zmq.curve_keypair() in the manager means kernels need no key-generation code at all and stay pure consumers of the connection file. The manager already creates and owns the connection file, so this is where key material naturally originates.

Why kernelspec metadata for capability

Encryption must be negotiated before the connection exists, so it cannot be discovered via kernel_info_reply. A static kernelspec field is available to the launcher at the right moment, consistent with how JEP 66 uses kernel_protocol_version.

Alternatives considered

Impact of not doing this. Kernel traffic over TCP remains plaintext, and any local process can monitor iopub, reading code, outputs, and secrets. The only mitigations stay ipc:// with file permissions (local only) or external tunneling (manual).

Prior art

Unresolved questions

To be discussed during the JEP process:

Key isolation

The reference implementation reuses one keypair for both the kernel’s server identity and the local clients’ identity, so the secret key sits in the connection file and is loaded by every local client. This matches the session.key trust model and gives forward secrecy against passive capture, but the long-term secret in the file is sufficient for a live man-in-the-middle. Should we instead distribute only the server public key and have clients (and possibly the kernel) use distinct keypairs? The pre-proposal raised holding the secret in an environment variable (for example $JUPYTER_CURVE_SECRETKEY) so the connection file alone is no longer enough to impersonate either end. As of now this JEP leaves standardizing it to a follow-up work, but should it be deemed universally desirable, we could support alternative key distribution mechanisms.

HMAC message signing

Connection-level authentication can in principle remove the need for per-message HMAC verification, but the current implementation keeps signing as-is. For now we propose to keep it as defense-in-depth, but one could argue that opt-out of HMAC should be a part of this proposal to fully realise the promised benefits. While this is not currently part of the submitted PoCs, this JEP should encourage power users to experiment with opt-out, which may reduce the overhead of execution.

Connection-file schema

As of now the added fields are curve_publickey and curve_secretkey but we could consider a dedicated encryption group before we codify that in the schema.

Out of scope

Considered out of scope for this JEP (addressable independently later):

Future possibilities