You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
343 lines
10 KiB
343 lines
10 KiB
import os |
|
import shutil |
|
import atexit |
|
import bencoder |
|
import pyaes |
|
from hashlib import sha256 |
|
from argon2 import low_level |
|
from .support import get_random_bytes |
|
|
|
|
|
class Argon2Hash(object): |
|
def __init__(self, password, salt=None, hash_len=32, salt_len=16, |
|
time_cost=500, memory_cost=1000, parallelism=4, |
|
argon2_type=low_level.Type.I, version=19): |
|
""" |
|
args: |
|
password: password as bytes |
|
salt: salt in bytes or None to create random one, must have length >= 8 |
|
hash_len: generated hash length in bytes |
|
salt_len: salt length in bytes, ignored if salt is not None, must be >= 8 |
|
|
|
Other arguments are argon2 settings. Only change those if you know what |
|
you're doing. Optimized for slow hashing suitable for file encryption. |
|
""" |
|
# Default and recommended settings from argon2.PasswordHasher are for |
|
# interactive logins. For encryption we want something much slower. |
|
self.settings = { |
|
'time_cost': time_cost, |
|
'memory_cost': memory_cost, |
|
'parallelism': parallelism, |
|
'hash_len': hash_len, |
|
'type': argon2_type, |
|
'version': version |
|
} |
|
self.salt = salt if salt is not None else get_random_bytes(salt_len) |
|
self.hash = low_level.hash_secret_raw(password, self.salt, |
|
**self.settings) |
|
|
|
|
|
class StorageError(Exception): |
|
pass |
|
|
|
|
|
class RetryableStorageError(StorageError): |
|
pass |
|
|
|
|
|
class StoragePasswordError(RetryableStorageError): |
|
pass |
|
|
|
|
|
class Storage(object): |
|
""" |
|
Responsible for reading/writing [encrypted] data to disk. |
|
|
|
All data to be stored must be added to self.data which defaults to an |
|
empty dict. |
|
|
|
self.data must contain serializable data (dict, list, tuple, bytes, numbers) |
|
Having str objects anywhere in self.data will lead to undefined behaviour (py3). |
|
All dict keys must be bytes. |
|
|
|
KDF: argon2, ENC: AES-256-CBC |
|
""" |
|
MAGIC_UNENC = b'JMWALLET' |
|
MAGIC_ENC = b'JMENCWLT' |
|
MAGIC_DETECT_ENC = b'JMWALLET' |
|
|
|
ENC_KEY_BYTES = 32 # AES-256 |
|
SALT_LENGTH = 16 |
|
|
|
def __init__(self, path, password=None, create=False, read_only=False): |
|
""" |
|
args: |
|
path: file path to storage |
|
password: bytes or None for unencrypted file |
|
create: create file if it does not exist |
|
read_only: do not change anything on the file system |
|
""" |
|
self.path = path |
|
self._lock_file = None |
|
self._hash = None |
|
self._data_checksum = None |
|
self.data = None |
|
self.changed = False |
|
self.read_only = read_only |
|
self.newly_created = False |
|
|
|
if not os.path.isfile(path): |
|
if create and not read_only: |
|
self._create_new(password) |
|
self._save_file() |
|
self.newly_created = True |
|
else: |
|
raise StorageError("File not found.") |
|
elif create: |
|
raise StorageError("File already exists.") |
|
else: |
|
self._load_file(password) |
|
|
|
assert self.data is not None |
|
assert self._data_checksum is not None |
|
|
|
self._create_lock() |
|
|
|
def is_encrypted(self): |
|
return self._hash is not None |
|
|
|
def is_locked(self): |
|
return self._lock_file and os.path.exists(self._lock_file) |
|
|
|
def was_changed(self): |
|
""" |
|
return True if data differs from data on disk |
|
""" |
|
return self._data_checksum != self._get_data_checksum() |
|
|
|
def change_password(self, password): |
|
if self.read_only: |
|
raise StorageError("Cannot change password of read-only file.") |
|
self._set_hash(password) |
|
self._save_file() |
|
|
|
def save(self): |
|
""" |
|
Write file to disk if data was modified |
|
""" |
|
#if not self.was_changed(): |
|
# return |
|
if self.read_only: |
|
raise StorageError("Read-only storage cannot be saved.") |
|
self._save_file() |
|
|
|
@classmethod |
|
def is_storage_file(cls, path): |
|
return cls._get_file_magic(path) in (cls.MAGIC_ENC, cls.MAGIC_UNENC) |
|
|
|
@classmethod |
|
def is_encrypted_storage_file(cls, path): |
|
return cls._get_file_magic(path) == cls.MAGIC_ENC |
|
|
|
@classmethod |
|
def _get_file_magic(cls, path): |
|
assert len(cls.MAGIC_ENC) == len(cls.MAGIC_UNENC) |
|
with open(path, 'rb') as fh: |
|
return fh.read(len(cls.MAGIC_ENC)) |
|
|
|
def _get_data_checksum(self): |
|
if self.data is None: #pragma: no cover |
|
return None |
|
return sha256(self._serialize(self.data)).digest() |
|
|
|
def _update_data_hash(self): |
|
self._data_checksum = self._get_data_checksum() |
|
|
|
def _create_new(self, password): |
|
self.data = {} |
|
self._set_hash(password) |
|
|
|
def _set_hash(self, password): |
|
if password is None: |
|
self._hash = None |
|
else: |
|
self._hash = self._hash_password(password) |
|
|
|
def _save_file(self): |
|
assert self.read_only == False |
|
data = self._serialize(self.data) |
|
enc_data = self._encrypt_file(data) |
|
|
|
magic = self.MAGIC_UNENC if data is enc_data else self.MAGIC_ENC |
|
self._write_file(magic + enc_data) |
|
self._update_data_hash() |
|
|
|
def _load_file(self, password): |
|
data = self._read_file() |
|
assert len(self.MAGIC_ENC) == len(self.MAGIC_UNENC) == 8 |
|
magic = data[:8] |
|
|
|
if magic not in (self.MAGIC_ENC, self.MAGIC_UNENC): |
|
raise StorageError("File does not appear to be a joinmarket wallet.") |
|
|
|
data = data[8:] |
|
|
|
if magic == self.MAGIC_ENC: |
|
if password is None: |
|
raise RetryableStorageError("Password required to open wallet.") |
|
data = self._decrypt_file(password, data) |
|
else: |
|
assert magic == self.MAGIC_UNENC |
|
|
|
self.data = self._deserialize(data) |
|
self._update_data_hash() |
|
|
|
def _write_file(self, data): |
|
assert self.read_only is False |
|
|
|
if not os.path.exists(self.path): |
|
# newly created storage |
|
with open(self.path, 'wb') as fh: |
|
fh.write(data) |
|
return |
|
|
|
# using a tmpfile ensures the write is atomic |
|
tmpfile = '{}.tmp'.format(self.path) |
|
|
|
with open(tmpfile, 'wb') as fh: |
|
shutil.copystat(self.path, tmpfile) |
|
fh.write(data) |
|
|
|
#FIXME: behaviour with symlinks might be weird |
|
shutil.move(tmpfile, self.path) |
|
|
|
def _read_file(self): |
|
# this method mainly exists for easier mocking |
|
with open(self.path, 'rb') as fh: |
|
return fh.read() |
|
|
|
def get_location(self): |
|
return self.path |
|
|
|
@staticmethod |
|
def _serialize(data): |
|
return bencoder.bencode(data) |
|
|
|
@staticmethod |
|
def _deserialize(data): |
|
return bencoder.bdecode(data) |
|
|
|
def _encrypt_file(self, data): |
|
if not self.is_encrypted(): |
|
return data |
|
|
|
iv = get_random_bytes(16) |
|
container = { |
|
b'enc': {b'salt': self._hash.salt, b'iv': iv}, |
|
b'data': self._encrypt(data, iv) |
|
} |
|
return self._serialize(container) |
|
|
|
def _decrypt_file(self, password, data): |
|
assert password is not None |
|
|
|
container = self._deserialize(data) |
|
assert b'enc' in container |
|
assert b'data' in container |
|
|
|
self._hash = self._hash_password(password, container[b'enc'][b'salt']) |
|
|
|
return self._decrypt(container[b'data'], container[b'enc'][b'iv']) |
|
|
|
def _encrypt(self, data, iv): |
|
encrypter = pyaes.Encrypter( |
|
pyaes.AESModeOfOperationCBC(self._hash.hash, iv=iv)) |
|
enc_data = encrypter.feed(self.MAGIC_DETECT_ENC + data) |
|
enc_data += encrypter.feed() |
|
|
|
return enc_data |
|
|
|
def _decrypt(self, data, iv): |
|
decrypter = pyaes.Decrypter( |
|
pyaes.AESModeOfOperationCBC(self._hash.hash, iv=iv)) |
|
try: |
|
dec_data = decrypter.feed(data) |
|
dec_data += decrypter.feed() |
|
except ValueError: |
|
# in most "wrong password" cases the pkcs7 padding will be wrong |
|
raise StoragePasswordError("Wrong password.") |
|
|
|
if not dec_data.startswith(self.MAGIC_DETECT_ENC): |
|
raise StoragePasswordError("Wrong password.") |
|
return dec_data[len(self.MAGIC_DETECT_ENC):] |
|
|
|
@classmethod |
|
def _hash_password(cls, password, salt=None): |
|
return Argon2Hash(password, salt, |
|
hash_len=cls.ENC_KEY_BYTES, salt_len=cls.SALT_LENGTH) |
|
|
|
def _create_lock(self): |
|
if self.read_only: |
|
return |
|
(path_head, path_tail) = os.path.split(self.path) |
|
lock_filename = os.path.join(path_head, '.' + path_tail + '.lock') |
|
self._lock_file = lock_filename |
|
if os.path.exists(self._lock_file): |
|
with open(self._lock_file, 'r') as f: |
|
locked_by_pid = f.read() |
|
self._lock_file = None |
|
raise RetryableStorageError( |
|
"File is currently in use (locked by pid {}). " |
|
"If this is a leftover from a crashed instance " |
|
"you need to remove the lock file `{}` manually." . |
|
format(locked_by_pid, lock_filename)) |
|
#FIXME: in python >=3.3 use mode x |
|
with open(self._lock_file, 'w') as f: |
|
f.write(str(os.getpid())) |
|
|
|
atexit.register(self.close) |
|
|
|
def _remove_lock(self): |
|
if self._lock_file: |
|
os.remove(self._lock_file) |
|
self._lock_file = None |
|
|
|
def close(self): |
|
if not self.read_only and self.was_changed(): |
|
self._save_file() |
|
self._remove_lock() |
|
self.read_only = True |
|
|
|
def __del__(self): |
|
self.close() |
|
|
|
|
|
class VolatileStorage(Storage): |
|
""" |
|
Storage that is never actually written to disk and only kept in memory. |
|
|
|
This exists for easier testing. |
|
""" |
|
|
|
def __init__(self, password=None, data=None): |
|
self.file_data = None |
|
super(VolatileStorage, self).__init__('VOLATILE', password, |
|
create=True) |
|
if data: |
|
self.file_data = data |
|
self._load_file(password) |
|
|
|
def _create_lock(self): |
|
pass |
|
|
|
def _remove_lock(self): |
|
pass |
|
|
|
def _write_file(self, data): |
|
self.file_data = data |
|
|
|
def _read_file(self): |
|
return self.file_data |
|
|
|
def get_location(self): |
|
return None
|
|
|