Write the code to encrypt rc messages to kitty
This commit is contained in:
parent
e64b1ba67c
commit
2aee746da9
@ -28,6 +28,7 @@ _plat = sys.platform.lower()
|
|||||||
is_macos: bool = 'darwin' in _plat
|
is_macos: bool = 'darwin' in _plat
|
||||||
is_freebsd: bool = 'freebsd' in _plat
|
is_freebsd: bool = 'freebsd' in _plat
|
||||||
is_running_from_develop: bool = False
|
is_running_from_develop: bool = False
|
||||||
|
RC_ENCRYPTION_PROTOCOL_VERSION = '1'
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False):
|
||||||
extensions_dir: str = getattr(sys, 'kitty_run_data')['extensions_dir']
|
extensions_dir: str = getattr(sys, 'kitty_run_data')['extensions_dir']
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from time import monotonic
|
from time import monotonic, time_ns
|
||||||
from types import GeneratorType
|
from types import GeneratorType
|
||||||
from typing import (
|
from typing import (
|
||||||
Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union, cast
|
Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union, cast
|
||||||
@ -15,15 +16,18 @@ from typing import (
|
|||||||
|
|
||||||
from .cli import emph, parse_args
|
from .cli import emph, parse_args
|
||||||
from .cli_stub import RCOptions
|
from .cli_stub import RCOptions
|
||||||
from .constants import appname, version
|
from .constants import RC_ENCRYPTION_PROTOCOL_VERSION, appname, version
|
||||||
from .fast_data_types import get_boss, read_command_response, send_data_to_peer
|
from .fast_data_types import (
|
||||||
|
AES256GCMEncrypt, EllipticCurveKey, get_boss, read_command_response,
|
||||||
|
send_data_to_peer
|
||||||
|
)
|
||||||
from .rc.base import (
|
from .rc.base import (
|
||||||
NoResponse, ParsingOfArgsFailed, PayloadGetter, all_command_names,
|
NoResponse, ParsingOfArgsFailed, PayloadGetter, all_command_names,
|
||||||
command_for_name, parse_subcommand_cli
|
command_for_name, parse_subcommand_cli
|
||||||
)
|
)
|
||||||
from .types import AsyncResponse
|
from .types import AsyncResponse
|
||||||
from .typing import BossType, WindowType
|
from .typing import BossType, WindowType
|
||||||
from .utils import TTYIO, parse_address_spec
|
from .utils import TTYIO, parse_address_spec, resolve_custom_file
|
||||||
|
|
||||||
active_async_requests: Dict[str, float] = {}
|
active_async_requests: Dict[str, float] = {}
|
||||||
|
|
||||||
@ -89,6 +93,36 @@ An address for the kitty instance to control. Corresponds to the address
|
|||||||
given to the kitty instance via the :option:`kitty --listen-on` option. If not specified,
|
given to the kitty instance via the :option:`kitty --listen-on` option. If not specified,
|
||||||
messages are sent to the controlling terminal for this process, i.e. they
|
messages are sent to the controlling terminal for this process, i.e. they
|
||||||
will only work if this process is run within an existing kitty window.
|
will only work if this process is run within an existing kitty window.
|
||||||
|
|
||||||
|
|
||||||
|
--password
|
||||||
|
A password to use when contacting kitty. This will cause kitty to ask the user
|
||||||
|
for permission to perform the specified action, unless the password has been
|
||||||
|
accepted before or is pre-configured in :file:`kitty.conf`.
|
||||||
|
|
||||||
|
|
||||||
|
--password-file
|
||||||
|
completion=type:file relative:conf kwds:-
|
||||||
|
default=rc-pass
|
||||||
|
A file from which to read the password. Trailing whitespace is ignored. Relative
|
||||||
|
paths are resolved from the kitty configuration directory. Use - to read from STDIN.
|
||||||
|
Used if no :option:`kitty @ --password` is supplied. Defaults to checking for the
|
||||||
|
:file:`rc-pass` file in the kitty configuration directory.
|
||||||
|
|
||||||
|
|
||||||
|
--password-env
|
||||||
|
default=KITTY_RC_PASSWORD
|
||||||
|
The name of an environment variable to read the password from.
|
||||||
|
Used if no :option:`kitty @ --password-file` is supplied. Defaults
|
||||||
|
to checking the KITTY_RC_PASSWORD.
|
||||||
|
|
||||||
|
|
||||||
|
--use-password
|
||||||
|
default=if-available
|
||||||
|
choices=if-available,never,always
|
||||||
|
If no password is available, kitty will usually just send the remote control command
|
||||||
|
without a password. This option can be used to force it to :code:`always` or :code:`never` use
|
||||||
|
the supplied password.
|
||||||
'''.format, appname=appname)
|
'''.format, appname=appname)
|
||||||
|
|
||||||
|
|
||||||
@ -169,9 +203,9 @@ def do_io(to: Optional[str], send: Dict[str, Any], no_response: bool, response_t
|
|||||||
|
|
||||||
|
|
||||||
cli_msg = (
|
cli_msg = (
|
||||||
'Control {appname} by sending it commands. Set the'
|
'Control {appname} by sending it commands. Set the'
|
||||||
' :opt:`allow_remote_control` option in :file:`kitty.conf` or use a password, for this'
|
' :opt:`allow_remote_control` option in :file:`kitty.conf` or use a password, for this'
|
||||||
' to work.'
|
' to work.'
|
||||||
).format(appname=appname)
|
).format(appname=appname)
|
||||||
|
|
||||||
|
|
||||||
@ -185,6 +219,31 @@ def parse_rc_args(args: List[str]) -> Tuple[RCOptions, List[str]]:
|
|||||||
return parse_args(args[1:], global_options_spec, 'command ...', msg, f'{appname} @', result_class=RCOptions)
|
return parse_args(args[1:], global_options_spec, 'command ...', msg, f'{appname} @', result_class=RCOptions)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_as_base85(data: bytes) -> str:
|
||||||
|
return base64.b85encode(data).decode('ascii')
|
||||||
|
|
||||||
|
|
||||||
|
class CommandEncrypter:
|
||||||
|
|
||||||
|
def __init__(self, pubkey: bytes, encryption_version: str, password: str) -> None:
|
||||||
|
skey = EllipticCurveKey()
|
||||||
|
secret = skey.derive_secret(pubkey)
|
||||||
|
self.pubkey = skey.public
|
||||||
|
self.encrypter = AES256GCMEncrypt(secret)
|
||||||
|
self.encryption_version = encryption_version
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
def __call__(self, cmd: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
cmd['timestamp'] = time_ns()
|
||||||
|
cmd['password'] = self.password
|
||||||
|
raw = json.dumps(cmd).encode('utf-8')
|
||||||
|
encrypted = self.encrypter.add_data_to_be_encrypted(raw, finished=True)
|
||||||
|
return {
|
||||||
|
'version': version, 'iv': encode_as_base85(self.encrypter.iv), 'tag': encode_as_base85(self.encrypter.tag),
|
||||||
|
'pubkey': encode_as_base85(self.pubkey), 'encrypted': encode_as_base85(encrypted), 'enc_proto': self.encryption_version
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_basic_command(name: str, payload: Any = None, no_response: bool = False, is_asynchronous: bool = False) -> Dict[str, Any]:
|
def create_basic_command(name: str, payload: Any = None, no_response: bool = False, is_asynchronous: bool = False) -> Dict[str, Any]:
|
||||||
ans = {'cmd': name, 'version': version, 'no_response': no_response}
|
ans = {'cmd': name, 'version': version, 'no_response': no_response}
|
||||||
if payload is not None:
|
if payload is not None:
|
||||||
@ -211,12 +270,61 @@ def send_response_to_client(data: Any = None, error: str = '', peer_id: int = 0,
|
|||||||
w.send_cmd_response(response)
|
w.send_cmd_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password(opts: RCOptions) -> str:
|
||||||
|
if opts.use_password == 'never':
|
||||||
|
return ''
|
||||||
|
ans = ''
|
||||||
|
if opts.password:
|
||||||
|
ans = opts.password
|
||||||
|
if not ans and opts.password_file:
|
||||||
|
if opts.password_file == '-':
|
||||||
|
ans = sys.stdin.read().strip()
|
||||||
|
try:
|
||||||
|
tty_fd = os.open(os.ctermid(), os.O_RDONLY | os.O_CLOEXEC)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
os.dup2(tty_fd, sys.stdin.fileno())
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(resolve_custom_file(opts.password_file)) as f:
|
||||||
|
ans = f.read().strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not ans and opts.password_env:
|
||||||
|
ans = os.environ.get(opts.password_env, '')
|
||||||
|
if not ans and opts.use_password == 'always':
|
||||||
|
raise SystemExit('No password was found')
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
def get_pubkey() -> Tuple[str, bytes]:
|
||||||
|
raw = os.environ.get('KITTY_PUBLIC_KEY', '')
|
||||||
|
if not raw:
|
||||||
|
raise SystemExit('Password usage requested but KITTY_PUBLIC_KEY environment variable is not available')
|
||||||
|
version, pubkey = raw.split(':', 1)
|
||||||
|
if version != RC_ENCRYPTION_PROTOCOL_VERSION:
|
||||||
|
raise SystemExit('KITTY_PUBLIC_KEY has unknown version, if you are running on a remote system, update kitty on this system')
|
||||||
|
from base64 import b85decode
|
||||||
|
return version, b85decode(pubkey)
|
||||||
|
|
||||||
|
|
||||||
|
def adjust_response_timeout_for_password(response_timeout: float) -> float:
|
||||||
|
return max(response_timeout, 120)
|
||||||
|
|
||||||
|
|
||||||
def main(args: List[str]) -> None:
|
def main(args: List[str]) -> None:
|
||||||
global_opts, items = parse_rc_args(args)
|
global_opts, items = parse_rc_args(args)
|
||||||
|
password = get_password(global_opts)
|
||||||
|
if password:
|
||||||
|
encryption_version, pubkey = get_pubkey()
|
||||||
|
encrypter = CommandEncrypter(pubkey, encryption_version, password)
|
||||||
|
else:
|
||||||
|
encrypter = None
|
||||||
|
|
||||||
if not items:
|
if not items:
|
||||||
from kitty.shell import main as smain
|
from kitty.shell import main as smain
|
||||||
smain(global_opts)
|
smain(global_opts, encrypter)
|
||||||
return
|
return
|
||||||
cmd = items[0]
|
cmd = items[0]
|
||||||
try:
|
try:
|
||||||
@ -235,7 +343,11 @@ def main(args: List[str]) -> None:
|
|||||||
response_timeout = c.response_timeout
|
response_timeout = c.response_timeout
|
||||||
if hasattr(opts, 'response_timeout'):
|
if hasattr(opts, 'response_timeout'):
|
||||||
response_timeout = opts.response_timeout
|
response_timeout = opts.response_timeout
|
||||||
send = create_basic_command(cmd, payload=payload, no_response=no_response, is_asynchronous=c.is_asynchronous)
|
if encrypter is not None:
|
||||||
|
response_timeout = adjust_response_timeout_for_password(response_timeout)
|
||||||
|
send = original_send_cmd = create_basic_command(cmd, payload=payload, no_response=no_response, is_asynchronous=c.is_asynchronous)
|
||||||
|
if encrypter is not None:
|
||||||
|
send = encrypter(original_send_cmd)
|
||||||
listen_on_from_env = False
|
listen_on_from_env = False
|
||||||
if not global_opts.to and 'KITTY_LISTEN_ON' in os.environ:
|
if not global_opts.to and 'KITTY_LISTEN_ON' in os.environ:
|
||||||
global_opts.to = os.environ['KITTY_LISTEN_ON']
|
global_opts.to = os.environ['KITTY_LISTEN_ON']
|
||||||
@ -252,8 +364,10 @@ def main(args: List[str]) -> None:
|
|||||||
try:
|
try:
|
||||||
response = do_io(global_opts.to, send, no_response, response_timeout)
|
response = do_io(global_opts.to, send, no_response, response_timeout)
|
||||||
except (TimeoutError, socket.timeout):
|
except (TimeoutError, socket.timeout):
|
||||||
send.pop('payload', None)
|
original_send_cmd.pop('payload', None)
|
||||||
send['cancel_async'] = True
|
original_send_cmd['cancel_async'] = True
|
||||||
|
if encrypter is not None:
|
||||||
|
send = encrypter(original_send_cmd)
|
||||||
do_io(global_opts.to, send, True, 10)
|
do_io(global_opts.to, send, True, 10)
|
||||||
raise SystemExit(f'Timed out after {response_timeout} seconds waiting for response from kitty')
|
raise SystemExit(f'Timed out after {response_timeout} seconds waiting for response from kitty')
|
||||||
if no_response:
|
if no_response:
|
||||||
|
|||||||
@ -8,7 +8,9 @@ import sys
|
|||||||
import traceback
|
import traceback
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple
|
from typing import (
|
||||||
|
Any, Callable, Dict, Generator, Iterable, List, Optional, Tuple
|
||||||
|
)
|
||||||
|
|
||||||
from kittens.tui.operations import set_cursor_shape, set_window_title
|
from kittens.tui.operations import set_cursor_shape, set_window_title
|
||||||
|
|
||||||
@ -141,22 +143,33 @@ def print_help(which: Optional[str] = None) -> None:
|
|||||||
display_subcommand_help(func)
|
display_subcommand_help(func)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(global_opts: RCOptions, cmd: str, func: RemoteCommand, opts: Any, items: List[str]) -> None:
|
def run_cmd(
|
||||||
from .remote_control import create_basic_command, do_io
|
global_opts: RCOptions, cmd: str, func: RemoteCommand, opts: Any, items: List[str],
|
||||||
|
encrypter: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None
|
||||||
|
) -> None:
|
||||||
|
from .remote_control import (
|
||||||
|
adjust_response_timeout_for_password, create_basic_command, do_io
|
||||||
|
)
|
||||||
print(end=set_window_title(cmd) + output_prefix, flush=True)
|
print(end=set_window_title(cmd) + output_prefix, flush=True)
|
||||||
payload = func.message_to_kitty(global_opts, opts, items)
|
payload = func.message_to_kitty(global_opts, opts, items)
|
||||||
no_response = func.no_response
|
no_response = func.no_response
|
||||||
if hasattr(opts, 'no_response'):
|
if hasattr(opts, 'no_response'):
|
||||||
no_response = opts.no_response
|
no_response = opts.no_response
|
||||||
send = create_basic_command(cmd, payload=payload, is_asynchronous=func.is_asynchronous, no_response=no_response)
|
send = original_send_cmd = create_basic_command(cmd, payload=payload, is_asynchronous=func.is_asynchronous, no_response=no_response)
|
||||||
|
if encrypter is not None:
|
||||||
|
send = encrypter(original_send_cmd)
|
||||||
response_timeout = func.response_timeout
|
response_timeout = func.response_timeout
|
||||||
if hasattr(opts, 'response_timeout'):
|
if hasattr(opts, 'response_timeout'):
|
||||||
response_timeout = opts.response_timeout
|
response_timeout = opts.response_timeout
|
||||||
|
if encrypter is not None:
|
||||||
|
response_timeout = adjust_response_timeout_for_password(response_timeout)
|
||||||
try:
|
try:
|
||||||
response = do_io(global_opts.to, send, no_response, response_timeout)
|
response = do_io(global_opts.to, send, no_response, response_timeout)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
send.pop('payload', None)
|
original_send_cmd.pop('payload', None)
|
||||||
send['cancel_async'] = True
|
original_send_cmd['cancel_async'] = True
|
||||||
|
if encrypter is not None:
|
||||||
|
send = encrypter(original_send_cmd)
|
||||||
do_io(global_opts.to, send, True, 10)
|
do_io(global_opts.to, send, True, 10)
|
||||||
print_err(f'Timed out after {response_timeout} seconds waiting for response from kitty')
|
print_err(f'Timed out after {response_timeout} seconds waiting for response from kitty')
|
||||||
return
|
return
|
||||||
@ -169,7 +182,7 @@ def run_cmd(global_opts: RCOptions, cmd: str, func: RemoteCommand, opts: Any, it
|
|||||||
print(response['data'])
|
print(response['data'])
|
||||||
|
|
||||||
|
|
||||||
def real_main(global_opts: RCOptions) -> None:
|
def real_main(global_opts: RCOptions, encrypter: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None) -> None:
|
||||||
init_readline()
|
init_readline()
|
||||||
print_help_for_seq.allow_pager = False
|
print_help_for_seq.allow_pager = False
|
||||||
print('Welcome to the kitty shell!')
|
print('Welcome to the kitty shell!')
|
||||||
@ -230,7 +243,7 @@ def real_main(global_opts: RCOptions) -> None:
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
run_cmd(global_opts, cmd, func, opts, items)
|
run_cmd(global_opts, cmd, func, opts, items, encrypter)
|
||||||
except (SystemExit, ParsingOfArgsFailed) as e:
|
except (SystemExit, ParsingOfArgsFailed) as e:
|
||||||
print(end=output_prefix, flush=True)
|
print(end=output_prefix, flush=True)
|
||||||
print_err(e)
|
print_err(e)
|
||||||
@ -246,10 +259,10 @@ def real_main(global_opts: RCOptions) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
def main(global_opts: RCOptions) -> None:
|
def main(global_opts: RCOptions, encrypter: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None) -> None:
|
||||||
try:
|
try:
|
||||||
with Completer():
|
with Completer():
|
||||||
real_main(global_opts)
|
real_main(global_opts, encrypter)
|
||||||
except Exception:
|
except Exception:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
input('Press Enter to quit')
|
input('Press Enter to quit')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user