Add "paranoid restore" mode

Having Admin API, it is possible to do this properly now:
 - create DisposableVM
 - assign it proper permissions to create VMs and control those created
   VMs
 - run restore process inside
 - cleanup DisposableVM afterwards

Since the RestoreInDisposableVM class contains de facto reverse parser
for qvm-backup-restore command line, add a test that will spot when it
gets out of sync.

This feature depends on modifications in various other components,
including:
 - linux-utils and core-agent-linux for update qfile-unpacker
 - core-admin for qrexec policy modification

QubesOS/qubes-issues#5310
This commit is contained in:
Marek Marczykowski-Górecki 2019-09-10 14:41:28 +02:00
parent 2089e9e730
commit cc71dd5876
No known key found for this signature in database
GPG Key ID: 063938BA42CFA724
4 changed files with 319 additions and 0 deletions

View File

@ -92,6 +92,14 @@ Options
Provided backup location is a qrexec service name (optionally with an Provided backup location is a qrexec service name (optionally with an
argument, separated by ``+``), instead of file path or a command. argument, separated by ``+``), instead of file path or a command.
.. option:: --paranoid-mode, --plan-b
Isolate restore process in a DisposableVM, defend against potentially
compromised backup. In this mode some parts of the backup are skipped,
specifically:
- dom0 home directory (desktop environment settings)
- PCI devices assignments
Authors Authors
======= =======

275
qubesadmin/backup/dispvm.py Normal file
View File

@ -0,0 +1,275 @@
#
# The Qubes OS Project, http://www.qubes-os.org
#
# Copyright (C) 2019 Marek Marczykowski-Górecki
# <marmarek@invisiblethingslab.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""Handle backup extraction using DisposableVM"""
import datetime
import logging
import string
import subprocess
#import typing
import qubesadmin
import qubesadmin.exc
import qubesadmin.utils
import qubesadmin.vm
LOCKFILE = '/var/run/qubes/backup-paranoid-restore.lock'
Option = collections.namedtuple('Option', ('opts', 'handler'))
# Convenient functions for 'handler' value of Option object
# (see RestoreInDisposableVM.arguments):
def handle_store_true(option, value):
"""Handle argument enabling an option (action="store_true")"""
if value:
return [option.opts[0]]
return []
def handle_store_false(option, value):
"""Handle argument disabling an option (action="false")"""
if not value:
return [option.opts[0]]
return []
def handle_verbose(option, value):
"""Handle argument --quiet / --verbose options (action="count")"""
if option.opts[0] == '--verbose':
value -= 1 # verbose defaults to 1
return [option.opts[0]] * value
def handle_store(option, value):
"""Handle argument with arbitrary string value (action="store")"""
if value:
return [option.opts[0], str(value)]
return []
def handle_append(option, value):
"""Handle argument with a list of values (action="append")"""
return itertools.chain(*([option.opts[0], v] for v in value))
def skip(_option, _value):
"""Skip argument"""
return []
def handle_unsupported(option, value):
"""Reject argument as unsupported"""
if value:
raise NotImplementedError(
'{} option is not supported with --paranoid-mode'.format(
option.opts[0]))
return []
class RestoreInDisposableVM:
"""Perform backup restore with actual archive extraction isolated
within DisposableVM"""
#dispvm: typing.Optional[qubesadmin.vm.QubesVM]
#: map of args attr -> original option
arguments = {
'quiet': Option(('--quiet', '-q'), handle_verbose),
'verbose': Option(('--verbose', '-v'), handle_verbose),
'verify_only': Option(('--verify-only',), handle_store_true),
'skip_broken': Option(('--skip-broken',), handle_store_true),
'ignore_missing': Option(('--ignore-missing',), handle_store_true),
'skip_conflicting': Option(('--skip-conflicting',), handle_store_true),
'rename_conflicting': Option(('--rename-conflicting',),
handle_store_true),
'exclude': Option(('--exclude', '-x'), handle_append),
'dom0_home': Option(('--skip-dom0-home',), handle_store_false),
'ignore_username_mismatch': Option(('--ignore-username-mismatch',),
handle_store_true),
'ignore_size_limit': Option(('--ignore-size-limit',),
handle_store_true),
'compression': Option(('--compression-filter', '-Z'), handle_store),
'appvm': Option(('--dest-vm', '-d'), handle_store),
'pass_file': Option(('--passphrase-file', '-p'), handle_unsupported),
'location_is_service': Option(('--location-is-service',),
handle_store_true),
'paranoid_mode': Option(('--paranoid-mode', '--plan-b',), skip),
# make the verification easier, those don't really matter
'help': Option(('--help', '-h'), skip),
'force_root': Option(('--force-root',), skip),
}
def __init__(self, app, args):
"""
:param app: Qubes() instance
:param args: namespace instance as with qvm-backup-restore arguments
parsed. See :py:module:`qubesadmin.tools.qvm_backup_restore`.
"""
self.app = app
self.args = args
# only one backup restore is allowed at the time, use constant names
#: name of DisposableVM using to extract the backup
self.dispvm_name = 'disp-backup-restore'
#: tag given to this DisposableVM - qrexec policy is configured for it
self.dispvm_tag = 'backup-restore-mgmt'
#: tag automatically added to restored VMs
self.restored_tag = 'backup-restore-in-progress'
#: tag added to a VM storing the backup archive
self.storage_tag = 'backup-restore-storage'
self.terminal_app = ('xterm', '-hold', '-title', 'Backup restore', '-e')
self.dispvm = None
if args.appvm:
self.backup_storage_vm = self.app.domains[args.appvm]
else:
self.backup_storage_vm = self.app.domains['dom0']
self.storage_access_proc = None
self.storage_access_id = None
self.log = logging.getLogger('qubesadmin.backup.dispvm')
def clear_old_tags(self):
"""Remove tags from old restore operation"""
for domain in self.app.domains:
domain.tags.discard(self.restored_tag)
domain.tags.discard(self.dispvm_tag)
domain.tags.discard(self.storage_tag)
def create_dispvm(self):
"""Create DisposableVM used to restore"""
self.dispvm = self.app.add_new_vm('DispVM', self.dispvm_name, 'red',
template=self.app.management_dispvm)
self.dispvm.auto_cleanup = True
self.dispvm.features['tag-created-vm-with'] = self.restored_tag
def register_backup_source(self):
"""Tell backup archive holding VM we want this content.
This function registers a backup source, receives a token needed to
access it (stored in *storage_access_id* attribute). The access is
revoked when connection referenced in *storage_access_proc* attribute
is closed.
"""
self.storage_access_proc = self.backup_storage_vm.run_service(
'qubes.RegisterBackupLocation', stdin=subprocess.PIPE,
stdout=subprocess.PIPE)
self.storage_access_proc.stdin.write(
(self.args.backup_location.
replace("\r", "").replace("\n", "") + "\n").encode())
self.storage_access_proc.stdin.flush()
storage_access_id = self.storage_access_proc.stdout.readline().strip()
allowed_chars = (string.ascii_letters + string.digits).encode()
if not storage_access_id or \
not all(c in allowed_chars for c in storage_access_id):
if self.storage_access_proc.returncode == 127:
raise qubesadmin.exc.QubesException(
'Backup source registration failed - qubes-core-agent '
'package too old?')
raise qubesadmin.exc.QubesException(
'Backup source registration failed - got invalid id')
self.storage_access_id = storage_access_id.decode('ascii')
# keep connection open, closing it invalidates the access
self.backup_storage_vm.tags.add(self.storage_tag)
def invalidate_backup_access(self):
"""Revoke access to backup archive"""
self.backup_storage_vm.tags.discard(self.storage_tag)
self.storage_access_proc.stdin.close()
self.storage_access_proc.wait()
def prepare_inner_args(self):
"""Prepare arguments for inner (in-DispVM) qvm-backup-restore command"""
new_options = []
new_positional_args = []
for attr, opt in self.arguments.items():
if not hasattr(self.args, attr):
continue
new_options.extend(opt.handler(opt, getattr(self.args, attr)))
new_options.append('--location-is-service')
# backup location, replace by qrexec service to be called
new_positional_args.append(
'qubes.RestoreById+' + self.storage_access_id)
if self.args.vms:
new_positional_args.extend(self.args.vms)
return new_options + new_positional_args
def finalize_tags(self):
"""Make sure all the restored VMs are marked with
restored-from-backup-xxx tag, then remove backup-restore-in-progress
tag"""
self.app.domains.clear_cache()
for domain in self.app.domains:
if 'backup-restore-in-progress' not in domain.tags:
continue
if not any(t.startswith('restored-from-backup-')
for t in domain.tags):
self.log.warning('Restored domain %s was not tagged with '
'restored-from-backup-* tag',
domain.name)
# add fallback tag
domain.tags.add('restored-from-backup-at-{}'.format(
datetime.date.strftime(datetime.date.today(), '%F')))
domain.tags.discard('backup-restore-in-progress')
def run(self):
"""Run the backup restore operation"""
lock = qubesadmin.utils.LockFile(LOCKFILE, True)
lock.acquire()
try:
self.create_dispvm()
self.clear_old_tags()
self.register_backup_source()
args = self.prepare_inner_args()
self.dispvm.start()
self.dispvm.run_service_for_stdio('qubes.WaitForSession')
self.dispvm.tags.add(self.dispvm_tag)
self.dispvm.run_with_args(*self.terminal_app,
'qvm-backup-restore', *args,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
if e.returncode == 127:
raise qubesadmin.exc.QubesException(
'qvm-backup-restore tool or {} '
'missing in {} template, install qubes-core-admin-client '
'package there'.format(self.terminal_app[0],
self.dispvm.template.template.name)
)
raise qubesadmin.exc.QubesException(
'qvm-backup-restore failed with {}'.format(e.returncode))
finally:
if self.dispvm is not None:
# first revoke permission, then cleanup
self.dispvm.tags.discard(self.dispvm_tag)
# autocleanup removes the VM
try:
self.dispvm.kill()
except qubesadmin.exc.QubesVMNotStartedError:
# delete it manually
del self.app.domains[self.dispvm]
self.finalize_tags()
lock.release()

View File

@ -17,12 +17,15 @@
# #
# You should have received a copy of the GNU Lesser General Public License along # You should have received a copy of the GNU Lesser General Public License along
# with this program; if not, see <http://www.gnu.org/licenses/>. # with this program; if not, see <http://www.gnu.org/licenses/>.
import itertools
import qubesadmin.tests import qubesadmin.tests
import qubesadmin.tests.tools import qubesadmin.tests.tools
import qubesadmin.tools.qvm_backup_restore import qubesadmin.tools.qvm_backup_restore
from unittest import mock from unittest import mock
from qubesadmin.backup import BackupVM from qubesadmin.backup import BackupVM
from qubesadmin.backup.restore import BackupRestore from qubesadmin.backup.restore import BackupRestore
from qubesadmin.backup.dispvm import RestoreInDisposableVM
class TC_00_qvm_backup_restore(qubesadmin.tests.QubesTestCase): class TC_00_qvm_backup_restore(qubesadmin.tests.QubesTestCase):
@ -231,3 +234,14 @@ class TC_00_qvm_backup_restore(qubesadmin.tests.QubesTestCase):
qubesadmin.tools.qvm_backup_restore.handle_broken( qubesadmin.tools.qvm_backup_restore.handle_broken(
self.app, mock_args, mock_restore_info) self.app, mock_args, mock_restore_info)
self.assertAppropriateLogging('NetVM', 'error') self.assertAppropriateLogging('NetVM', 'error')
def test_100_restore_in_dispvm_parser(self):
"""Verify if qvm-backup-restore tool options matches un-parser
for paranoid restore mode"""
parser = qubesadmin.tools.qvm_backup_restore.parser
actions = parser._get_optional_actions()
options_tool = set(itertools.chain(*(a.option_strings for a in actions)))
options_parser = set(itertools.chain(
*(o.opts for o in RestoreInDisposableVM.arguments.values())))
self.assertEqual(options_tool, options_parser)

View File

@ -24,12 +24,17 @@ import getpass
import sys import sys
from qubesadmin.backup.restore import BackupRestore from qubesadmin.backup.restore import BackupRestore
from qubesadmin.backup.dispvm import RestoreInDisposableVM
import qubesadmin.exc import qubesadmin.exc
import qubesadmin.tools import qubesadmin.tools
import qubesadmin.utils import qubesadmin.utils
parser = qubesadmin.tools.QubesArgumentParser() parser = qubesadmin.tools.QubesArgumentParser()
# WARNING:
# When adding options, update/verify also
# qubeadmin.restore.dispvm.RestoreInDisposableVM.arguments
#
parser.add_argument("--verify-only", action="store_true", parser.add_argument("--verify-only", action="store_true",
dest="verify_only", default=False, dest="verify_only", default=False,
help="Verify backup integrity without restoring any " help="Verify backup integrity without restoring any "
@ -88,6 +93,10 @@ parser.add_argument("--location-is-service", action="store_true",
help="Interpret backup location as a qrexec service name," help="Interpret backup location as a qrexec service name,"
"possibly with an argument separated by +.Requires -d option.") "possibly with an argument separated by +.Requires -d option.")
parser.add_argument('--paranoid-mode', '--plan-b', action="store_true",
help="Isolate restore process in a DispVM, defend against untrusted backup;"
"implies --skip-dom0-home")
parser.add_argument('backup_location', action='store', parser.add_argument('backup_location', action='store',
help="Backup directory name, or command to pipe from") help="Backup directory name, or command to pipe from")
@ -212,6 +221,19 @@ def main(args=None, app=None):
if args.location_is_service and not args.appvm: if args.location_is_service and not args.appvm:
parser.error('--location-is-service option requires -d') parser.error('--location-is-service option requires -d')
if args.paranoid_mode:
args.dom0_home = False
args.app.log.info("Starting restore process in a DisposableVM...")
args.app.log.info("When operation completes, close its window "
"manually.")
restore_in_dispvm = RestoreInDisposableVM(args.app, args)
try:
restore_in_dispvm.run()
except qubesadmin.exc.QubesException as e:
parser.error_runtime(str(e))
return 1
return
if args.pass_file is not None: if args.pass_file is not None:
pass_f = open(args.pass_file) if args.pass_file != "-" else sys.stdin pass_f = open(args.pass_file) if args.pass_file != "-" else sys.stdin
passphrase = pass_f.readline().rstrip() passphrase = pass_f.readline().rstrip()