From 23940b0efcd842734be2600cbef117dbc41e464d Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 28 May 2022 02:06:00 -0400 Subject: [PATCH] add support for importing/exporting keys and backups (including password-protected backups) close #61 close #13 --- CHANGELOG.md | 2 + README.md | 2 +- src/deltachat_cursed/account.py | 31 ++++++++++++- src/deltachat_cursed/main.py | 79 ++++++++++++++++++++++++++++++++- src/deltachat_cursed/util.py | 4 ++ 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84a2ab..b530f1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - `/mute` and `/unmute` commands to mute/unmute chats - `/topic` command to change chat name - `/clear` command to delete all messages in current chat +- support for importing/exporting encryption keys +- support for importing/exporting backups (including password-protected backups) ### Changed diff --git a/README.md b/README.md index ec55e98..2c30ed5 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ ncurses Delta Chat client developed in Python with the urwid library. - [X] Ability to send files - [X] Notifications - [X] Read receipts ✓✓ +- [X] Import/export keys and backups (including password-protected backups) - [X] Emoji support and auto-completion - [X] @Mentions support and auto-completion - [X] Display message replies - [X] Chat operations: delete, pin/unpin, mute/unmute, add/remove members, change chat name, clear chat messages - [ ] Ability to send stickers - [ ] Jump to quoted messages -- [ ] Export/import keys and full backups - [ ] Record and send voice messages - [ ] Reply messages - [ ] Delete messages diff --git a/src/deltachat_cursed/account.py b/src/deltachat_cursed/account.py index 3e97cdc..b954555 100644 --- a/src/deltachat_cursed/account.py +++ b/src/deltachat_cursed/account.py @@ -2,13 +2,42 @@ from tempfile import NamedTemporaryFile from typing import List from deltachat import Account as _Account -from deltachat import Message +from deltachat import Message, const from deltachat.capi import ffi, lib +from deltachat.cutil import as_dc_charpointer +from deltachat.tracker import ImexTracker class Account(_Account): """Patched Delta Chat Account""" + def export_all(self, path: str, passphrase: str = None) -> str: + export_files = self._export(path, const.DC_IMEX_EXPORT_BACKUP, passphrase) + if len(export_files) != 1: + raise RuntimeError("found more than one new file") + return export_files[0] + + def import_all(self, path: str, passphrase: str = None) -> None: + assert not self.is_configured(), "cannot import into configured account" + self._import(path, const.DC_IMEX_IMPORT_BACKUP, passphrase) + + def _import(self, path: str, imex_cmd: int, passphrase: str = None) -> None: + with self.temp_plugin(ImexTracker()) as imex_tracker: + self.imex(path, imex_cmd, passphrase) + imex_tracker.wait_finish() + + def _export(self, path: str, imex_cmd: int, passphrase: str = None) -> list: + with self.temp_plugin(ImexTracker()) as imex_tracker: + self.imex(path, imex_cmd, passphrase) + return imex_tracker.wait_finish() + + def imex(self, path, imex_cmd, passphrase=None): + if not passphrase: + passphrase = ffi.NULL + else: + passphrase = as_dc_charpointer(passphrase) + lib.dc_imex(self._dc_context, imex_cmd, as_dc_charpointer(path), passphrase) + def get_fresh_messages_cnt(self) -> int: """Return the number of fresh messages""" return lib.dc_array_get_cnt( diff --git a/src/deltachat_cursed/main.py b/src/deltachat_cursed/main.py index e96b099..b31419d 100644 --- a/src/deltachat_cursed/main.py +++ b/src/deltachat_cursed/main.py @@ -5,7 +5,7 @@ from argparse import ArgumentParser, Namespace import deltachat.const from deltachat.events import FFIEventLogger -from deltachat.tracker import ConfigureTracker +from deltachat.tracker import ConfigureTracker, ImexFailed from . import __version__ from .account import Account @@ -13,6 +13,7 @@ from .application import Application from .event import AccountPlugin from .oauth2 import get_authz_code, is_oauth2 from .util import ( + abspath, capture_keyboard_interrupt, fail, get_configuration, @@ -96,7 +97,11 @@ def get_parser(cfg: dict) -> ArgumentParser: required=True, ) send_parser.add_argument( - "-a", metavar="file", dest="filename", help="attach file to the message" + "-a", + metavar="file", + dest="filename", + help="attach file to the message", + type=abspath, ) send_parser.add_argument("text", help="text message to send", nargs="?") send_parser.set_defaults(cmd=send_cmd) @@ -104,6 +109,40 @@ def get_parser(cfg: dict) -> ArgumentParser: list_parser = subparsers.add_parser("list", help="print chat list") list_parser.set_defaults(cmd=list_cmd) + import_parser = subparsers.add_parser("import", help="import keys or full backup") + import_parser.add_argument( + "--password", + "-p", + help="if provided, this passphrase will be used to access the encrypted backup", + ) + import_parser.add_argument( + "path", + help="path to a backup file or path to a directory containing keys to import", + type=abspath, + ) + import_parser.set_defaults(cmd=import_cmd) + + export_parser = subparsers.add_parser("export", help="export full backup or keys") + export_parser.add_argument( + "--keys-only", + "-k", + action="store_true", + help="export only the public and private keys", + ) + export_parser.add_argument( + "--password", + "-p", + help="if provided, this passphrase will be used to encrypt the exported backup", + ) + export_parser.add_argument( + "folder", + help="path to the directory where the files should be saved, if not given, current working directory is used", + nargs="?", + default=os.curdir, + type=abspath, + ) + export_parser.set_defaults(cmd=export_cmd) + return parser @@ -182,6 +221,42 @@ def list_cmd(args: Namespace) -> None: print(f"#{chat.id} - {chat.get_name()}") +def import_cmd(args: Namespace) -> None: + if os.path.isdir(args.path): + if args.password: + fail("Error: only full backups support password protection") + try: + args.acct.import_self_keys(args.path) + print("Keys imported successfully") + except ImexFailed: + fail(f"Error: no valid keys found in {args.path!r}") + elif os.path.isfile(args.path): + if args.acct.is_configured(): + fail("Error: can't import backup into an already configured account") + try: + args.acct.import_all(args.path, args.password) + print("Backup imported successfully") + except ImexFailed: + fail(f"Error: invalid password or backup file {args.path!r}") + else: + fail(f"Error: file doesn't exists {args.path!r}") + + +def export_cmd(args: Namespace) -> None: + try: + if args.keys_only: + if args.password: + fail("Error: only full backups support password protection") + paths = args.acct.export_self_keys(args.folder) + else: + paths = [args.acct.export_all(args.folder, args.password)] + print("Exported files:") + for path in paths: + print(path) + except ImexFailed: + fail(f"Error: failed to export to {args.folder!r}") + + def start_ui(args: Namespace) -> None: plugin = AccountPlugin(args.acct) args.acct.add_account_plugin(plugin) diff --git a/src/deltachat_cursed/util.py b/src/deltachat_cursed/util.py index d3545d8..9d14a6a 100644 --- a/src/deltachat_cursed/util.py +++ b/src/deltachat_cursed/util.py @@ -259,3 +259,7 @@ def get_subtitle(chat: Chat) -> str: if count == 1: return "1 member" return f"{count} members" + + +def abspath(path: str) -> str: + return os.path.abspath(os.path.expanduser(path))