Skip to content

Commit f2f69f3

Browse files
author
Vít Šesták
committed
Implemented BackupStorageVM. Closes #3
1 parent 6147c2b commit f2f69f3

13 files changed

+1586
-11
lines changed

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Status
22

3-
**Parts of implementation are missing. See the [“MVP” milestone](https://github.com/v6ak/qubes-incremental-backup-poc/issues?q=is%3Aopen+is%3Aissue+milestone%3AMVP) for details. Proof of concept. Backup format details will likely change.**
3+
**Proof of concept. Backup format details will likely change.**
44

55
## Goals
66

@@ -130,13 +130,15 @@ TODO
130130
## Limitations
131131

132132
* DVM template is somehow trusted.
133+
* Works with some (most?) Duplicity backends. It requires allowing directory-like URLs.
133134
* We assume that DVM template has drivers for all filesystems we need to backup
134135
* VM config backup is missing. User needs to backup `~/.v6-qubes-backup-poc/master` in order to be able to restore the backup.
135136
* The implementation assumes that attacker cannot read parameters of running applications (e.g., via /proc). This is justifiable in Qubes security model, especially in dom0, but it is not very nice.
136137
* VMs are identified by name. If you destroy a VM and create a VM of the same name then, it will use the same keys and it might have some security implications.
137138
* We strongly assume that attacker does not have any access to dom0. More specificaly, attacker cannot obtain a RAM snapshot of dom0 and attacker cannot list running applications of dom0 (e.g., via /proc). While those assumptions are standard in Qubes security model, failing to satisfy them can cause the backup security to break hardly. No serious attempt has been made to reduce number of copies of sensitive cryptographic material in RAM.
138139
* It assumes that we use locale utf-8. When one switches locale, it can cause issues, mostly with password. If you use another encoding, you might get troubles, especially if your password contains some characters that are represented differently in your encoding than in utf-8.
139140
* It assumes that user does not run the script multiple times in parallel. Various race conditions (cloned images, config files) could happen there.
141+
* https://github.com/v6ak/qubes-incremental-backup-poc/labels/limitation
140142
* (Probably incomplete)
141143

142144
## Human aspects for password

backup.py

+10-8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import os
1717
import sys
1818
import shutil
19+
import shlex
1920
import collections
2021
import argparse
2122
from backupsession import MasterBackupSession
@@ -77,23 +78,24 @@ def main():
7778
session = create_session_gui(config, args.passphrase)
7879
if session is None: return 1 # aborted
7980
vm_keys = session.vm_keys(vm)
80-
81+
backup_backend = config.get_backup_backend()
8182
volume_clone = Vm(vm).private_volume().clone("v6-qubes-backup-poc-cloned")
8283
try:
84+
backup_storage_vm = VmInstance(config.get_backup_storage_vm_name())
8385
dvm = DvmInstance.create()
8486
try:
8587
dvm.attach("xvdz", volume_clone) # --ro: 1. is not needed since it is a clone, 2. blocks repair procedures when mounting
8688
try:
8789
dvm.check_output("sudo mkdir /mnt/clone")
8890
dvm.check_output("sudo mount /dev/xvdz /mnt/clone") # TODO: consider -o nosuid,noexec – see issue #16
8991
try:
90-
with open(os.path.dirname(os.path.realpath(__file__))+"/vm-backup-agent", "rb") as inp:
91-
dvm.check_output("cat > /tmp/backup-agent", stdin = inp)
92-
dvm.check_output("chmod +x /tmp/backup-agent")
93-
with subprocess.Popen(dvm.create_command("/tmp/backup-agent "+vm_keys.encrypted_name), stdin = subprocess.PIPE) as proc:
94-
proc.stdin.write(vm_keys.key)
95-
proc.stdin.close()
96-
assert(proc.wait() == 0) # uarrgh, implemented by busy loop
92+
backup_backend.upload_agent(dvm)
93+
with backup_backend.add_permissions(backup_storage_vm, dvm, vm_keys.encrypted_name):
94+
# run the agent
95+
with subprocess.Popen(dvm.create_command("/tmp/backup-agent "+shlex.quote(backup_storage_vm.get_name())+" "+shlex.quote(vm_keys.encrypted_name)), stdin = subprocess.PIPE) as proc:
96+
proc.stdin.write(vm_keys.key)
97+
proc.stdin.close()
98+
assert(proc.wait() == 0) # uarrgh, implemented by busy loop
9799
# TODO: also copy ~/.v6-qubes-backup-poc/master to the backup in order to make it recoverable without additional data (except password). See issue #12.
98100
finally: dvm.check_output("sudo umount /mnt/clone")
99101
finally: dvm.detach_all()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/python2 -u
2+
# -*- coding=utf-8 -*-
3+
# In python 2, we cannot be so generic to use /usr/bin/env. If we were, we could not pass the -u parameter.
4+
5+
import sys
6+
import os
7+
from os import close
8+
import subprocess
9+
import base64
10+
import time
11+
import duplicity.backend
12+
import duplicity.log
13+
import duplicity.path
14+
from common import Commands, StatusCodes, read_until_zero, read_safe_filename
15+
from tempfile import NamedTemporaryFile
16+
from shutil import copyfileobj
17+
18+
def action_list(backend, inp, out):
19+
result = backend.list()
20+
out.write(StatusCodes.OK)
21+
for i in result:
22+
assert(i.find(b'\0') == -1)
23+
out.write(i.encode('ascii'))
24+
out.write(b'\0')
25+
26+
def action_put(backend, inp, out):
27+
filename = read_safe_filename(inp)
28+
tmp = NamedTemporaryFile(delete = False)
29+
try:
30+
try:
31+
copyfileobj(inp, tmp.file)
32+
finally:
33+
tmp.file.close()
34+
backend.put(duplicity.path.Path(tmp.name), filename)
35+
out.write(StatusCodes.OK)
36+
finally:
37+
os.remove(tmp.name)
38+
39+
def action_get(backend, inp, out):
40+
filename = read_safe_filename(inp)
41+
tmp = NamedTemporaryFile(delete = False)
42+
try:
43+
tmp.file.close()
44+
backend.get(filename, duplicity.path.Path(tmp.name))
45+
with open(tmp.name, "rb") as f:
46+
out.write(StatusCodes.OK)
47+
copyfileobj(f, out)
48+
finally:
49+
os.remove(tmp.name)
50+
51+
def action_delete(inp):
52+
raise Exception("Not implemented")
53+
54+
def main():
55+
with open("/tmp/backup-log-"+str(time.time()), "w+") as logfile:
56+
def error(s, additional_data = None):
57+
logfile.write(s+str(additional_data))
58+
print(StatusCodes.ERROR + s)
59+
exit(1)
60+
inp = sys.stdin# For Python 3: .buffer
61+
out = sys.stdout# For Python 3: .buffer
62+
remote = os.environ['QREXEC_REMOTE_DOMAIN']
63+
action_letter = inp.read(1)
64+
actions = {
65+
Commands.LIST: action_list,
66+
Commands.PUT: action_put,
67+
Commands.DELETE: action_delete,
68+
Commands.GET: action_get
69+
}
70+
duplicity.log._logger = duplicity.log.DupLogger("some logger")
71+
duplicity.backend.import_backends()
72+
with open("/rw/config/v6-qubes-backup-poc-duplicity-path", "r") as f:
73+
path_prefix = f.read().rstrip("\n")
74+
# The file-based auth was chosen in order to preserve state across processes. Yes, we could use IPC, but this would be probably more complex.
75+
with open("/var/run/v6-qubes-backup-poc-permissions/"+base64.b64encode(remote.encode("ascii")).decode("ascii"), "rb") as f:
76+
allowed_path = f.read()
77+
requested_path = read_until_zero(inp)
78+
if allowed_path != requested_path:
79+
error("bad path: " + str(allowed_path) + " vs " + str(requested_path))
80+
else:
81+
backend_url = path_prefix+"/"+requested_path
82+
backend = duplicity.backend.get_backend(backend_url)
83+
if backend is None:
84+
error("Don't know backend for the URL", backend_url)
85+
act = actions.get(action_letter)
86+
if act is None:
87+
error("bad command")
88+
act(backend, inp, out)
89+
90+
if __name__ == "__main__":
91+
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# python2
2+
3+
# Common file for both parts of the inter-VM backup protocol
4+
5+
class Commands:
6+
LIST = b'L'
7+
PUT = b'P'
8+
GET = b'G'
9+
DELETE = b'D'
10+
11+
class StatusCodes:
12+
OK = b'O'
13+
ERROR = b'E'
14+
15+
def sanitize_filename(filename):
16+
dot_allowed = True
17+
for c in filename:
18+
if (c == '.') and dot_allowed:
19+
dot_allowed = False
20+
continue
21+
dot_allowed = True # Will not read untill next iteration, so we can change it now. TODO: make the flow more clear
22+
if (c >= 'a') and (c <= 'z'): continue
23+
if (c >= 'A') and (c <= 'Z'): continue
24+
if (c >= '0') and (c <= '9'): continue
25+
if (c == '-') or (c == '_'): continue
26+
raise Exception("Unexpected character: "+str(ord(c)))
27+
return filename
28+
29+
def read_until_zero(inp, maxlen = None):
30+
# The bytearray wrap is needed in Python2; Without this, it behaves cucumbersome: It converts inp to string. In Python 3, this hack should not be needed.
31+
return bytes(bytearray(_read_until_zero_intgen(inp, maxlen)))
32+
33+
def _read_until_zero_intgen(inp, maxlen = None):
34+
n = 0
35+
while True:
36+
n += 1
37+
if (maxlen is not None) and (n > maxlen):
38+
raise Exception("Reading data longer than "+str(maxlen))
39+
chunk = inp.read(1)
40+
if len(chunk) == 1:
41+
if chunk == b'\0':
42+
break
43+
else:
44+
yield chunk[0] # contains int
45+
elif len(chunk) == 0:
46+
raise Exception("Reached EOF after reading "+str(n)+" bytes witout seeing any zero byte!")
47+
else:
48+
assert(False)
49+
50+
def read_safe_filename(inp):
51+
return sanitize_filename(read_until_zero(inp, 255))
52+
53+
def write_zero_terminated_ascii(f, bs):
54+
assert(bs.find(b'\0') == -1)
55+
f.write(bs.encode("ascii"))
56+
f.write(b'\0')
57+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# python2 module
2+
import re
3+
import duplicity.backend
4+
import duplicity.urlparse_2_5 as urlparser
5+
import subprocess
6+
from duplicity.backends.qubesintervmbackendprivate.common import Commands, StatusCodes, write_zero_terminated_ascii, sanitize_filename
7+
from os.path import basename
8+
from shutil import copyfileobj
9+
10+
11+
class QubesInterVmBackend(duplicity.backend.Backend):
12+
def __init__(self, parsed_url):
13+
duplicity.backend.Backend.__init__(self, parsed_url)
14+
print(parsed_url.path)
15+
pat = re.compile("^/+([^/]+)/([^/]+)$")
16+
parts = pat.match(parsed_url.path)
17+
self.vm = parts.group(1)
18+
self.path = parts.group(2)
19+
20+
def _call_command(self, command_letter):
21+
return _QubesInterVmBackendChannel(command_letter, self.path, self.vm);
22+
23+
def _check_for_error_message(self, inp):
24+
response = inp.read(1)
25+
if response == StatusCodes.ERROR:
26+
raise Exception("Error messge from the other end: "+inp.read(1024).decode('ascii'))
27+
elif response == StatusCodes.OK:
28+
print("got OK code")
29+
return inp # OK
30+
elif len(response) == 0:
31+
raise Exception("Unexpected empty response")
32+
else:
33+
raise Exception("Unexpected response code: "+str(ord(response)))
34+
35+
def _read_all(self, proc):
36+
return self._check_for_error_message(proc.stdout).read()
37+
38+
# Methods required for Duplicity:
39+
def _list(self):
40+
with self._call_command(Commands.LIST) as proc:
41+
return map(sanitize_filename, self._read_all(proc).decode('ascii').split('\0'))
42+
43+
def put(self, source_path, remote_filename = None):
44+
name = remote_filename or source_path.get_filename()
45+
with source_path.open("rb") as f, self._call_command(Commands.PUT) as proc:
46+
write_zero_terminated_ascii(proc.stdin, name)
47+
copyfileobj(f, proc.stdin)
48+
proc.stdin.close() # let the peer know we are finished. TODO: close automatically in _call_command?
49+
self._check_for_error_message(proc.stdout)
50+
51+
def get(self, remote_filename, local_path):
52+
with local_path.open("w") as f, self._call_command(Commands.GET) as proc:
53+
write_zero_terminated_ascii(proc.stdin, remote_filename)
54+
proc.stdin.close()
55+
self._check_for_error_message(proc.stdout)
56+
copyfileobj(proc.stdout, f)
57+
58+
# TODO:
59+
# def delete(self, filename_list) <-- not needed yet
60+
61+
62+
class _QubesInterVmBackendChannel:
63+
def __init__(self, command_letter, path, vm):
64+
self.command_letter = command_letter
65+
self.path = path
66+
self.vm = vm
67+
self.initialized = False
68+
def __enter__(self):
69+
self.proc = subprocess.Popen([
70+
"/usr/lib/qubes/qrexec-client-vm",
71+
self.vm,
72+
"v6ak.QubesInterVmBackupStorage"
73+
], stdin = subprocess.PIPE, stdout = subprocess.PIPE)# , stderr = subprocess.PIPE
74+
try:
75+
self.proc.stdin.write(self.command_letter)
76+
write_zero_terminated_ascii(self.proc.stdin, self.path)
77+
except:
78+
self.__exit__(*sys.exc_info())
79+
raise
80+
self.initialized = True
81+
return self.proc
82+
def __exit__(self, type, value, traceback):
83+
def close(f):
84+
if f is not None:
85+
f.close()
86+
def check_empty(f):
87+
if f is None: return
88+
res = f.read(1)
89+
if len(res) <> 0:
90+
raise Exception("Unexpected byte "+str(ord(res)))
91+
try:
92+
if self.initialized and (type is None): # Do not perform sanity checks when an exception is thrown, as they would likely fail and hide the root of cause
93+
# Assert that nothing remains
94+
check_empty(self.proc.stdout)
95+
check_empty(self.proc.stderr)
96+
finally:
97+
close(self.proc.stdin)
98+
close(self.proc.stderr)
99+
close(self.proc.stdout)
100+
return_code = self.proc.wait()
101+
if return_code <> 0:
102+
raise Exception("process did not finish with success: "+str(return_code))
103+
104+
duplicity.backend.register_backend('qubesintervm', QubesInterVmBackend)

vm-backup-agent backupbackends/duplicity-vm-files/vm-backup-agent

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ mkdir /tmp/backup-wapor/
1414

1515
PASSPHRASE="$(cat | base64)" # We'll use the key as password, because it seems to be the easiest way in Duplicity.
1616
[ "$(wc -c <<< $PASSPHRASE)" -ge 45 ] # Check the password/key has not been truncated. Key should be 32B, when base64'd and appended \n, it should be 45B.
17-
BUP_LOCATION="file:///tmp/backup-wapor/$1" # TODO: select proper backup location
17+
BUP_LOCATION="qubesintervm://$1/$2"
1818
BUP_SOURCE="/mnt/clone"
1919

2020
# Inspired by https://gist.github.com/bdsatish/5650178

backupbackends/duplicity.py

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# python3
2+
import os
3+
import subprocess
4+
import shlex
5+
import base64
6+
7+
# If you want to implement another backend, you can, but maybe you should wait a while until the API becomes more stable.
8+
# TODO: document interface
9+
class DuplicityBackupBackend:
10+
base_path = os.path.dirname(os.path.realpath(__file__))+"/duplicity-vm-files/"
11+
def upload_agent(self, vm):
12+
with open(self.base_path+"vm-backup-agent", "rb") as inp:
13+
vm.check_output("cat > /tmp/backup-agent", stdin = inp)
14+
vm.check_output("chmod +x /tmp/backup-agent")
15+
with open(self.base_path+"qubesintervmbackend.py", "rb") as inp:
16+
vm.check_output("sudo tee /usr/lib/python2.7/dist-packages/duplicity/backends/qubesintervmbackend.py", stdin = inp)
17+
with open(self.base_path+"common.py", "rb") as inp:
18+
vm.check_output("sudo mkdir /usr/lib/python2.7/dist-packages/duplicity/backends/qubesintervmbackendprivate")
19+
vm.check_output("sudo touch /usr/lib/python2.7/dist-packages/duplicity/backends/qubesintervmbackendprivate/__init__.py")
20+
vm.check_output("sudo tee /usr/lib/python2.7/dist-packages/duplicity/backends/qubesintervmbackendprivate/common.py", stdin = inp)
21+
22+
def install_dom0(self, vm):
23+
subprocess.check_call("echo "+shlex.quote("$anyvm "+vm.get_name()+" allow")+" | sudo tee /etc/qubes-rpc/policy/v6ak.QubesInterVmBackupStorage", shell=True, stdout=subprocess.DEVNULL)
24+
25+
def install_backup_storage_vm(self, vm):
26+
# TODO: make it persistent across reboots
27+
vm.check_output("sudo mkdir -p /usr/local/share/v6-qubes-backup-poc/")
28+
vm.check_output("echo /usr/local/share/v6-qubes-backup-poc/v6-qubes-backup-poc.py | sudo tee /etc/qubes-rpc/v6ak.QubesInterVmBackupStorage")
29+
with open(self.base_path+"backup-storage-agent/v6-qubes-backup-poc.py") as inp:
30+
vm.check_output("sudo tee /usr/local/share/v6-qubes-backup-poc/v6-qubes-backup-poc.py && sudo chmod +x /usr/local/share/v6-qubes-backup-poc/v6-qubes-backup-poc.py", stdin = inp)
31+
with open(self.base_path+"common.py") as inp:
32+
# FIXME: Don't be so aggressive!
33+
vm.check_output("sudo tee /usr/local/share/v6-qubes-backup-poc/common.py", stdin = inp)
34+
35+
def add_permissions(self, backup_storage_vm, dvm, encrypted_name):
36+
permission_file = "/var/run/v6-qubes-backup-poc-permissions/"+base64.b64encode(dvm.get_name().encode("ascii")).decode("ascii")
37+
return _DuplicityPermissionsContext(backup_storage_vm, encrypted_name, permission_file)
38+
39+
class _DuplicityPermissionsContext:
40+
def __init__(self, backup_storage_vm, encrypted_name, permission_file):
41+
self.permission_file = permission_file
42+
self.backup_storage_vm = backup_storage_vm
43+
self.encrypted_name = encrypted_name
44+
def __enter__(self):
45+
self.backup_storage_vm.check_output("sudo mkdir -p /var/run/v6-qubes-backup-poc-permissions")
46+
self.backup_storage_vm.check_output("echo -n "+shlex.quote(self.encrypted_name)+" | sudo tee "+shlex.quote(self.permission_file)+".ip")
47+
self.backup_storage_vm.check_output("sudo mv "+shlex.quote(self.permission_file)+".ip "+shlex.quote(self.permission_file)) # This way prevents race condition. I know, this is not the best approach for duraility, but that's not what we need. (Especially if those files don't survive reboot.)
48+
def __exit__(self, type, value, traceback):
49+
self.backup_storage_vm.check_output("sudo rm "+shlex.quote(self.permission_file)) # remove permissions (not strictly needed for security, just hygiene)

0 commit comments

Comments
 (0)