Skip to content

Commit e458061

Browse files
authored
Add a toggle to enable curve encryption for all kernels that support it (#1638)
1 parent 0ceeb4f commit e458061

4 files changed

Lines changed: 256 additions & 2 deletions

File tree

docs/source/operators/security.rst

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,107 @@ The ``is_authorized()`` method will automatically be called whenever a handler i
366366
``@authenticated`` decorator for authentication (from ``tornado.web``).
367367

368368

369+
Kernel transport encryption
370+
---------------------------
371+
372+
.. versionadded:: 2.20
373+
374+
By default, ZMQ sockets used to communicate with kernels (shell, IOPub, stdin,
375+
control, heartbeat) are bound to TCP ports with no transport-level encryption.
376+
Any process on the same host that can reach those ports can connect and read
377+
messages, including all IOPub output.
378+
379+
`CurveZMQ <https://rfc.zeromq.org/spec/26/>`__ adds elliptic-curve
380+
encryption and authentication directly at the ZMQ transport layer.
381+
When enabled, the ``KernelManager`` generates a keypair, writes the keys into
382+
the kernel's connection file, and configures all sockets as a CurveZMQ server.
383+
Clients must present the correct server public key to connect; unauthenticated
384+
connections are silently dropped before any data is delivered.
385+
386+
.. note::
387+
388+
CurveZMQ is only available when pyzmq was compiled against a libzmq that
389+
includes libsodium. You can verify this with::
390+
391+
python -c "import zmq; print(zmq.has('curve'))"
392+
393+
If this prints ``False``, the ``transport_encryption`` setting has no
394+
effect and attempts to set it to ``'auto'`` or ``'required'`` will raise
395+
a configuration error.
396+
397+
The ``transport_encryption`` setting
398+
*************************************
399+
400+
Transport encryption is controlled by the ``KernelManager.transport_encryption``
401+
traitlet, which accepts three values:
402+
403+
``'disabled'`` (default)
404+
No CurveZMQ keys are generated. All kernel sockets are unencrypted.
405+
406+
``'auto'``
407+
Keys are generated **only when the kernelspec declares support** via
408+
``metadata.supported_encryption: 'curve'``. Kernelspecs that do not
409+
declare this field are started without encryption, so the setting is safe
410+
to enable globally without breaking existing kernels.
411+
412+
``'required'``
413+
Keys are always generated. Startup fails with a ``RuntimeError`` if the
414+
kernelspec does not declare ``metadata.supported_encryption: 'curve'``,
415+
so kernels that have not been updated to handle the connection-file keys
416+
are never started unencrypted.
417+
418+
.. note::
419+
420+
``transport_encryption`` applies to TCP transport only. IPC sockets
421+
already rely on filesystem permissions for access control and do not
422+
support CurveZMQ.
423+
424+
To enable encryption globally for all kernels that support it, add the
425+
following to your :file:`jupyter_server_config.py`:
426+
427+
.. sourcecode:: python
428+
429+
c.KernelManager.transport_encryption = "auto"
430+
431+
To enforce encryption and prevent unencrypted kernels from starting:
432+
433+
.. sourcecode:: python
434+
435+
c.KernelManager.transport_encryption = "required"
436+
437+
Kernelspec requirements
438+
***********************
439+
440+
A kernel must declare CurveZMQ support in its :file:`kernel.json` before the
441+
``KernelManager`` will provision keys for it:
442+
443+
.. sourcecode:: json
444+
445+
{
446+
"argv": ["python", "-m", "ipykernel_launcher", "-f", "{connection_file}"],
447+
"display_name": "Python 3",
448+
"language": "python",
449+
"metadata": {
450+
"supported_encryption": "curve"
451+
}
452+
}
453+
454+
When ``transport_encryption`` is ``'auto'``, kernelspecs without this field are
455+
started normally without encryption. When it is ``'required'``, their startup
456+
is refused.
457+
458+
The kernel itself must read ``curve_publickey`` and ``curve_secretkey`` from
459+
the connection file and apply them to its ZMQ sockets. Kernels based on
460+
ipykernel 7.3 do this automatically when the fields are present.
461+
462+
.. note::
463+
464+
When updating a previously installed kernel to a version that supports encryption
465+
you may need to re-install the kernelspec or manually add the ``supported_encryption``
466+
metadata field. If you subsequently decide to downgrade, you will need to
467+
remove this field as otherwise the kernel will silently fail to connect.
468+
469+
369470
Security in notebook documents
370471
==============================
371472

jupyter_server/services/kernels/kernelmanager.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from traitlets import (
3838
Any,
3939
Bool,
40+
CaselessStrEnum,
4041
Dict,
4142
Float,
4243
Instance,
@@ -67,6 +68,18 @@ def _default_kernel_manager_class(self):
6768

6869
kernel_argv = List(Unicode())
6970

71+
transport_encryption = CaselessStrEnum(
72+
["disabled", "auto", "required"],
73+
default_value="disabled",
74+
config=True,
75+
help=(
76+
"Transport encryption policy for manager-provisioned CurveZMQ keys for all managed kernels. "
77+
"'disabled' (default) does not provision Curve credentials, 'auto' provisions when the kernelspec "
78+
"declares support, and 'required' enforces provisioning and fails kernel startup if encryption "
79+
"cannot be applied."
80+
),
81+
)
82+
7083
root_dir = Unicode(config=True)
7184

7285
_kernel_connections = Dict()
@@ -204,6 +217,13 @@ def cwd_for_path(self, path, **kwargs):
204217
os_path = os.path.dirname(os_path)
205218
return os_path
206219

220+
def _kernel_start_kwargs(self, **kwargs: t.Any) -> dict[str, t.Any]:
221+
"""Build kernel launch kwargs with server-level policy applied."""
222+
launch_kwargs = dict(kwargs)
223+
if self.transport_encryption != "disabled":
224+
launch_kwargs["transport_encryption"] = self.transport_encryption
225+
return launch_kwargs
226+
207227
async def _remove_kernel_when_ready(self, kernel_id, kernel_awaitable):
208228
"""Remove a kernel when it is ready."""
209229
await super()._remove_kernel_when_ready(kernel_id, kernel_awaitable)
@@ -213,7 +233,7 @@ async def _remove_kernel_when_ready(self, kernel_id, kernel_awaitable):
213233
# TODO: DEC 2022: Revise the type-ignore once the signatures have been changed upstream
214234
# https://github.com/jupyter/jupyter_client/pull/905
215235
async def _async_start_kernel( # type:ignore[override]
216-
self, *, kernel_id: str | None = None, path: ApiPath | None = None, **kwargs: str
236+
self, *, kernel_id: str | None = None, path: ApiPath | None = None, **kwargs: t.Any
217237
) -> str:
218238
"""Start a kernel for a session and return its kernel_id.
219239
@@ -231,6 +251,7 @@ async def _async_start_kernel( # type:ignore[override]
231251
an existing kernel is returned, but it may be checked in the future.
232252
"""
233253
if kernel_id is None or kernel_id not in self:
254+
kwargs = self._kernel_start_kwargs(**kwargs)
234255
if path is not None:
235256
kwargs["cwd"] = self.cwd_for_path(path, env=kwargs.get("env", {}))
236257
if kernel_id is not None:

tests/services/kernels/test_config.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import pytest
22
from traitlets.config import Config
33

4-
from jupyter_server.services.kernels.kernelmanager import AsyncMappingKernelManager
4+
from jupyter_server.services.kernels.kernelmanager import (
5+
AsyncMappingKernelManager,
6+
MappingKernelManager,
7+
)
58

69

710
@pytest.fixture
@@ -29,3 +32,21 @@ def test_not_server_kernel_manager(jp_configurable_serverapp):
2932
]
3033
with pytest.warns(FutureWarning, match="is not a subclass of 'ServerKernelManager'"):
3134
jp_configurable_serverapp(argv=argv)
35+
36+
37+
def test_kernel_start_kwargs_transport_encryption_sets_flag():
38+
km = MappingKernelManager(transport_encryption="auto")
39+
40+
launch_kwargs = km._kernel_start_kwargs(env={"EXISTING": "1"})
41+
42+
assert launch_kwargs["transport_encryption"] == "auto"
43+
assert launch_kwargs["env"] == {"EXISTING": "1"}
44+
45+
46+
def test_kernel_start_kwargs_transport_encryption_required_sets_policy():
47+
km = MappingKernelManager(transport_encryption="required")
48+
49+
launch_kwargs = km._kernel_start_kwargs(env={"EXISTING": "1"})
50+
51+
assert launch_kwargs["transport_encryption"] == "required"
52+
assert launch_kwargs["env"] == {"EXISTING": "1"}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""End-to-end tests for MappingKernelManager.transport_encryption."""
2+
3+
import json
4+
import sys
5+
import warnings
6+
7+
import jupyter_client
8+
import pytest
9+
import zmq
10+
from traitlets.config import Config
11+
12+
TEST_TIMEOUT = 60
13+
14+
pytestmark = [
15+
pytest.mark.skipif(
16+
jupyter_client._version.version_info < (8, 9),
17+
reason="transport_encryption requires jupyter-client >= 8.9",
18+
),
19+
pytest.mark.skipif(
20+
not zmq.has("curve"),
21+
reason="CurveZMQ not available in this environment (zmq.has('curve') is False)",
22+
),
23+
]
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def suppress_deprecation_warnings():
28+
with warnings.catch_warnings():
29+
warnings.filterwarnings(
30+
"ignore",
31+
message="The synchronous MappingKernelManager",
32+
category=DeprecationWarning,
33+
)
34+
yield
35+
36+
37+
@pytest.fixture
38+
def non_curve_kernel_spec(jp_data_dir):
39+
"""Install a minimal kernel spec that does not declare curve encryption support."""
40+
kernel_dir = jp_data_dir / "kernels" / "no_curve"
41+
kernel_dir.mkdir(parents=True)
42+
(kernel_dir / "kernel.json").write_text(
43+
json.dumps(
44+
{
45+
"argv": [sys.executable, "-c", "pass"],
46+
"display_name": "No Curve",
47+
"language": "python",
48+
}
49+
)
50+
)
51+
return "no_curve"
52+
53+
54+
@pytest.mark.timeout(TEST_TIMEOUT)
55+
async def test_transport_encryption_disabled_no_curve_keys(jp_configurable_serverapp):
56+
"""Default 'disabled' policy never provisions curve keys, even for kernels that support them."""
57+
app = jp_configurable_serverapp()
58+
km = app.kernel_manager
59+
kernel_id = await km.start_kernel()
60+
try:
61+
kernel = km.get_kernel(kernel_id)
62+
info = kernel.get_connection_info()
63+
assert "curve_publickey" not in info
64+
assert "curve_secretkey" not in info
65+
finally:
66+
await km.shutdown_kernel(kernel_id, now=True)
67+
68+
69+
@pytest.mark.timeout(TEST_TIMEOUT)
70+
@pytest.mark.parametrize("policy", ["auto", "required"])
71+
async def test_transport_encryption_provisions_curve_keys(jp_configurable_serverapp, policy):
72+
"""'auto' and 'required' both provision curve keys for kernelspecs that declare curve support."""
73+
config = Config({"MappingKernelManager": {"transport_encryption": policy}})
74+
app = jp_configurable_serverapp(config=config)
75+
km = app.kernel_manager
76+
kernel_id = await km.start_kernel()
77+
try:
78+
kernel = km.get_kernel(kernel_id)
79+
info = kernel.get_connection_info()
80+
assert "curve_publickey" in info
81+
assert "curve_secretkey" in info
82+
finally:
83+
await km.shutdown_kernel(kernel_id, now=True)
84+
85+
86+
@pytest.mark.timeout(TEST_TIMEOUT)
87+
async def test_transport_encryption_auto_skips_keys_for_non_curve_kernel(
88+
non_curve_kernel_spec, jp_configurable_serverapp
89+
):
90+
"""'auto' silently skips key provisioning when the kernelspec lacks curve support metadata."""
91+
config = Config({"MappingKernelManager": {"transport_encryption": "auto"}})
92+
app = jp_configurable_serverapp(config=config)
93+
km = app.kernel_manager
94+
kernel_id = await km.start_kernel(kernel_name=non_curve_kernel_spec)
95+
try:
96+
kernel = km.get_kernel(kernel_id)
97+
info = kernel.get_connection_info()
98+
assert "curve_publickey" not in info
99+
assert "curve_secretkey" not in info
100+
finally:
101+
await km.shutdown_kernel(kernel_id, now=True)
102+
103+
104+
async def test_transport_encryption_required_raises_for_non_curve_kernel(
105+
non_curve_kernel_spec, jp_configurable_serverapp
106+
):
107+
"""'required' raises RuntimeError at startup when the kernelspec lacks curve support metadata."""
108+
config = Config({"MappingKernelManager": {"transport_encryption": "required"}})
109+
app = jp_configurable_serverapp(config=config)
110+
with pytest.raises(RuntimeError, match=r"metadata\.supported_encryption"):
111+
await app.kernel_manager.start_kernel(kernel_name=non_curve_kernel_spec)

0 commit comments

Comments
 (0)