add support for importing/exporting keys and backups (including password-protected backups)

close #61
close #13
This commit is contained in:
adbenitez
2022-05-28 02:06:00 -04:00
parent 85054096ca
commit 23940b0efc
5 changed files with 114 additions and 4 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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
+30 -1
View File
@@ -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(
+77 -2
View File
@@ -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)
+4
View File
@@ -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))