3333from traitlets .traitlets import (
3434 Any ,
3535 Bool ,
36+ Bytes ,
3637 Dict ,
3738 DottedObjectName ,
3839 Instance ,
@@ -158,6 +159,11 @@ class IPKernelApp(BaseIPythonApplication, InteractiveShellApp, ConnectionFileMix
158159 # connection info:
159160 connection_dir = Unicode ()
160161
162+ # Optional CurveZMQ keys loaded from the connection file (Z85-encoded bytes).
163+ # None when the kernel was not started with CurveZMQ enabled.
164+ curve_publickey : Bytes | None = Bytes (allow_none = True , default_value = None )
165+ curve_secretkey : Bytes | None = Bytes (allow_none = True , default_value = None )
166+
161167 @default ("connection_dir" )
162168 def _default_connection_dir (self ):
163169 return jupyter_runtime_dir ()
@@ -211,6 +217,25 @@ def excepthook(self, etype, evalue, tb):
211217 # write uncaught traceback to 'real' stderr, not zmq-forwarder
212218 traceback .print_exception (etype , evalue , tb , file = sys .__stderr__ )
213219
220+ def _apply_curve_server_options (self , socket : zmq .Socket [t .Any ]) -> None :
221+ """Set CurveZMQ server-side options on *socket* before it is bound.
222+
223+ This is a no-op when Curve keys are not available yet, so it is safe
224+ to call unconditionally.
225+ """
226+ if self .curve_secretkey is not None :
227+ socket .curve_secretkey = self .curve_secretkey
228+ socket .curve_publickey = self .curve_publickey
229+ socket .curve_server = True
230+
231+ def _apply_curve_client_options (self , socket : zmq .Socket [t .Any ]) -> None :
232+ """Set CurveZMQ client-side options on *socket* before it connects."""
233+ if self .curve_secretkey is not None :
234+ socket .curve_serverkey = self .curve_publickey
235+ # Reuse manager-provisioned keypair for the in-kernel client socket.
236+ socket .curve_secretkey = self .curve_secretkey
237+ socket .curve_publickey = self .curve_publickey
238+
214239 def init_poller (self ):
215240 """Initialize the poller."""
216241 if sys .platform == "win32" :
@@ -274,6 +299,9 @@ def write_connection_file(self, **kwargs: Any) -> None:
274299 iopub_port = self .iopub_port ,
275300 control_port = self .control_port ,
276301 )
302+ if self .curve_publickey is not None :
303+ connection_info ["curve_publickey" ] = self .curve_publickey
304+ connection_info ["curve_secretkey" ] = self .curve_secretkey
277305 if Path (cf ).exists ():
278306 # If the file exists, merge our info into it. For example, if the
279307 # original file had port number 0, we update with the actual port
@@ -328,13 +356,26 @@ def init_sockets(self):
328356 self .context = context = zmq .Context ()
329357 atexit .register (self .close )
330358
359+ if self .curve_secretkey is not None :
360+ self .log .info ("Detected CurveZMQ secret key; using transport encryption" )
361+ elif self .transport == "tcp" :
362+ self .log .warning (
363+ "Kernel is running over TCP without encryption."
364+ " All communication (including code and outputs) is sent in plain text"
365+ " and is susceptible to eavesdropping."
366+ " Use IPC transport or launch with kernel manager-provisioned"
367+ " CurveZMQ keys to enable transport encryption."
368+ )
369+
331370 self .shell_socket = context .socket (zmq .ROUTER )
332371 self .shell_socket .linger = 1000
372+ self ._apply_curve_server_options (self .shell_socket )
333373 self .shell_port = self ._bind_socket (self .shell_socket , self .shell_port )
334374 self .log .debug ("shell ROUTER Channel on port: %i" , self .shell_port )
335375
336376 self .stdin_socket = context .socket (zmq .ROUTER )
337377 self .stdin_socket .linger = 1000
378+ self ._apply_curve_server_options (self .stdin_socket )
338379 self .stdin_port = self ._bind_socket (self .stdin_socket , self .stdin_port )
339380 self .log .debug ("stdin ROUTER Channel on port: %i" , self .stdin_port )
340381
@@ -351,6 +392,7 @@ def init_control(self, context):
351392 """Initialize the control channel."""
352393 self .control_socket = context .socket (zmq .ROUTER )
353394 self .control_socket .linger = 1000
395+ self ._apply_curve_server_options (self .control_socket )
354396 self .control_port = self ._bind_socket (self .control_socket , self .control_port )
355397 self .log .debug ("control ROUTER Channel on port: %i" , self .control_port )
356398
@@ -359,6 +401,7 @@ def init_control(self, context):
359401
360402 self .debug_shell_socket = context .socket (zmq .DEALER )
361403 self .debug_shell_socket .linger = 1000
404+ self ._apply_curve_client_options (self .debug_shell_socket )
362405 if self .shell_socket .getsockopt (zmq .LAST_ENDPOINT ):
363406 self .debug_shell_socket .connect (self .shell_socket .getsockopt (zmq .LAST_ENDPOINT ))
364407
@@ -379,6 +422,7 @@ def init_iopub(self, context):
379422 """Initialize the iopub channel."""
380423 self .iopub_socket = context .socket (zmq .XPUB )
381424 self .iopub_socket .linger = 1000
425+ self ._apply_curve_server_options (self .iopub_socket )
382426 self .iopub_port = self ._bind_socket (self .iopub_socket , self .iopub_port )
383427 self .log .debug ("iopub PUB Channel on port: %i" , self .iopub_port )
384428 self .configure_tornado_logger ()
@@ -392,7 +436,12 @@ def init_heartbeat(self):
392436 # heartbeat doesn't share context, because it mustn't be blocked
393437 # by the GIL, which is accessed by libzmq when freeing zero-copy messages
394438 hb_ctx = zmq .Context ()
395- self .heartbeat = Heartbeat (hb_ctx , (self .transport , self .ip , self .hb_port ))
439+ self .heartbeat = Heartbeat (
440+ hb_ctx ,
441+ (self .transport , self .ip , self .hb_port ),
442+ curve_publickey = self .curve_publickey ,
443+ curve_secretkey = self .curve_secretkey ,
444+ )
396445 self .hb_port = self .heartbeat .port
397446 self .log .debug ("Heartbeat REP Channel on port: %i" , self .hb_port )
398447 self .heartbeat .start ()
0 commit comments