337 lines
14 KiB
Python
Executable File
337 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3.7
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
TFC - Onion-routed, endpoint secure messaging system
|
|
Copyright (C) 2013-2020 Markus Ottela
|
|
|
|
This file is part of TFC.
|
|
|
|
TFC is free software: you can redistribute it and/or modify it under the terms
|
|
of the GNU General Public License as published by the Free Software Foundation,
|
|
either version 3 of the License, or (at your option) any later version.
|
|
|
|
TFC 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 General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with TFC. If not, see <https://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import math
|
|
import multiprocessing
|
|
import os.path
|
|
import random
|
|
import time
|
|
|
|
from typing import List, Optional, Tuple
|
|
|
|
from src.common.crypto import argon2_kdf, blake2b, csprng
|
|
from src.common.database import TFCUnencryptedDatabase
|
|
from src.common.encoding import bytes_to_int, int_to_bytes
|
|
from src.common.exceptions import CriticalError, graceful_exit, SoftError
|
|
from src.common.input import pwd_prompt
|
|
from src.common.misc import ensure_dir, reset_terminal, separate_headers
|
|
from src.common.output import clear_screen, m_print, phase, print_on_previous_line
|
|
from src.common.word_list import eff_wordlist
|
|
from src.common.statics import (ARGON2_MIN_MEMORY_COST, ARGON2_MIN_PARALLELISM, ARGON2_MIN_TIME_COST,
|
|
ARGON2_SALT_LENGTH, BLAKE2_DIGEST_LENGTH, DIR_USER_DATA, DONE,
|
|
ENCODED_INTEGER_LENGTH, GENERATE, MASTERKEY_DB_SIZE, MAX_KEY_DERIVATION_TIME,
|
|
MIN_KEY_DERIVATION_TIME, PASSWORD_MIN_BIT_STRENGTH)
|
|
|
|
|
|
class MasterKey(object):
|
|
"""\
|
|
MasterKey object manages the 32-byte master key and methods related
|
|
to it. Master key is the key that protects all data written on disk.
|
|
"""
|
|
|
|
def __init__(self, operation: str, local_test: bool) -> None:
|
|
"""Create a new MasterKey object."""
|
|
self.operation = operation
|
|
self.file_name = f'{DIR_USER_DATA}{operation}_login_data'
|
|
self.database = TFCUnencryptedDatabase(self.file_name)
|
|
self.local_test = local_test
|
|
self.database_data = None # type: Optional[bytes]
|
|
|
|
ensure_dir(DIR_USER_DATA)
|
|
try:
|
|
if os.path.isfile(self.file_name):
|
|
self.master_key = self.load_master_key()
|
|
else:
|
|
self.master_key = self.new_master_key()
|
|
except (EOFError, KeyboardInterrupt):
|
|
graceful_exit()
|
|
|
|
@staticmethod
|
|
def timed_key_derivation(password: str,
|
|
salt: bytes,
|
|
time_cost: int,
|
|
memory_cost: int,
|
|
parallelism: int
|
|
) -> Tuple[bytes, float]:
|
|
"""Derive key and measure its derivation time."""
|
|
time_start = time.monotonic()
|
|
master_key = argon2_kdf(password, salt, time_cost, memory_cost, parallelism)
|
|
kd_time = time.monotonic() - time_start
|
|
|
|
return master_key, kd_time
|
|
|
|
def get_available_memory(self) -> int:
|
|
"""Return the amount of available memory in the system."""
|
|
fields = os.popen("/bin/cat /proc/meminfo").read().splitlines()
|
|
field = [f for f in fields if f.startswith("MemAvailable")][0]
|
|
mem_avail = int(field.split()[1])
|
|
|
|
if self.local_test:
|
|
mem_avail //= 2
|
|
|
|
return mem_avail
|
|
|
|
@staticmethod
|
|
def generate_master_password() -> Tuple[int, str]:
|
|
"""Generate a strong password using the EFF wordlist."""
|
|
word_space = len(eff_wordlist)
|
|
sys_rand = random.SystemRandom()
|
|
|
|
pwd_bit_strength = 0.0
|
|
password_words = [] # type: List[str]
|
|
|
|
while pwd_bit_strength < PASSWORD_MIN_BIT_STRENGTH:
|
|
password_words.append(sys_rand.choice(eff_wordlist))
|
|
pwd_bit_strength = math.log2(word_space ** len(password_words))
|
|
|
|
password = ' '.join(password_words)
|
|
|
|
return int(pwd_bit_strength), password
|
|
|
|
def new_master_key(self, replace: bool = True) -> bytes:
|
|
"""Create a new master key from password and salt.
|
|
|
|
The generated master key depends on a 256-bit salt and the
|
|
password entered by the user. Additional computational strength
|
|
is added by the slow hash function (Argon2id). The more cores and
|
|
the faster each core is, and the more memory the system has, the
|
|
more secure TFC data is under the same password.
|
|
|
|
This method automatically tweaks the Argon2 time and memory cost
|
|
parameters according to best practices as determined in
|
|
|
|
https://tools.ietf.org/html/draft-irtf-cfrg-argon2-04#section-4
|
|
|
|
1) For Argon2 type (y), Argon2id was selected because the
|
|
adversary might be able to run arbitrary code on Destination
|
|
Computer and thus perform a side-channel attack against the
|
|
function.
|
|
|
|
2) The maximum number of threads (h) is determined by the number
|
|
available in the system. However, during local testing this
|
|
number is reduced to half to allow simultaneous login to
|
|
Transmitter and Receiver Program.
|
|
|
|
3) The maximum amount of memory (m) is what the system has to
|
|
offer. For hard-drive encryption purposes, the recommendation
|
|
is 6GiB. TFC will use that amount (or even more) if available.
|
|
However, on less powerful systems, it will settle for less.
|
|
|
|
4) For key derivation time (x), the value is set to at least 3
|
|
seconds, with the maximum being 4 seconds. The minimum value
|
|
is the same as the recommendation for hard-drive encryption.
|
|
|
|
5) The salt length is set to 256-bits which is double the
|
|
recommended length. The salt size ensures that even in a
|
|
group of 4.8*10^29 users, the probability that two users
|
|
share the same salt is just 10^(-18).*
|
|
* https://en.wikipedia.org/wiki/Birthday_attack
|
|
|
|
The salt does not need additional protection as the security it
|
|
provides depends on the salt space in relation to the number of
|
|
attacked targets (i.e. if two or more physically compromised
|
|
systems happen to share the same salt, the attacker can speed up
|
|
the attack against those systems with time-memory-trade-off
|
|
attack).
|
|
|
|
6) The tag length isn't utilized. The result of the key derivation is
|
|
the master encryption key itself, which is set to 32 bytes for
|
|
use in XChaCha20-Poly1305.
|
|
|
|
7) Memory wiping feature is not provided.
|
|
|
|
To recognize the password is correct, the BLAKE2b hash of the master
|
|
key is stored together with key derivation parameters into the
|
|
login database.
|
|
The preimage resistance of BLAKE2b prevents derivation of master
|
|
key from the stored hash, and Argon2id ensures brute force and
|
|
dictionary attacks against the master password are painfully
|
|
slow even with GPUs/ASICs/FPGAs, as long as the password is
|
|
sufficiently strong.
|
|
"""
|
|
password = MasterKey.new_password()
|
|
salt = csprng(ARGON2_SALT_LENGTH)
|
|
time_cost = ARGON2_MIN_TIME_COST
|
|
|
|
# Determine the amount of memory used from the amount of free RAM in the system.
|
|
memory_cost = self.get_available_memory()
|
|
|
|
# Determine the amount of threads to use
|
|
parallelism = multiprocessing.cpu_count()
|
|
if self.local_test:
|
|
parallelism = max(ARGON2_MIN_PARALLELISM, parallelism // 2)
|
|
|
|
# Initial key derivation
|
|
phase("Deriving master key", head=2, offset=0)
|
|
master_key, kd_time = self.timed_key_derivation(password, salt, time_cost, memory_cost, parallelism)
|
|
phase("", done=True, tail=1)
|
|
|
|
# If derivation was too fast, increase time_cost
|
|
while kd_time < MIN_KEY_DERIVATION_TIME:
|
|
print_on_previous_line()
|
|
phase(f"Trying time cost {time_cost+1}")
|
|
time_cost += 1
|
|
master_key, kd_time = self.timed_key_derivation(password, salt, time_cost, memory_cost, parallelism)
|
|
phase(f"{kd_time:.1f}s", done=True)
|
|
|
|
# At this point time_cost may have value of 1 or it may have increased to e.g. 3, which might make it take
|
|
# longer than MAX_KEY_DERIVATION_TIME. If that's the case, it makes no sense to lower it back to 2 because even
|
|
# with all memory, time_cost=2 will still be too fast. We therefore accept the time_cost whatever it is.
|
|
|
|
# If the key derivation time is too long, we do a binary search on the amount
|
|
# of memory to use until we hit the desired key derivation time range.
|
|
middle = None
|
|
|
|
if kd_time > MAX_KEY_DERIVATION_TIME:
|
|
|
|
lower_bound = ARGON2_MIN_MEMORY_COST
|
|
upper_bound = memory_cost
|
|
|
|
while kd_time < MIN_KEY_DERIVATION_TIME or kd_time > MAX_KEY_DERIVATION_TIME:
|
|
|
|
middle = (lower_bound + upper_bound) // 2
|
|
|
|
print_on_previous_line()
|
|
phase(f"Trying memory cost {middle} KiB")
|
|
master_key, kd_time = self.timed_key_derivation(password, salt, time_cost, middle, parallelism)
|
|
phase(f"{kd_time:.1f}s", done=True)
|
|
|
|
# The search might fail e.g. if external CPU load causes delay in key derivation, which causes the
|
|
# search to continue into wrong branch. In such a situation the search is restarted. The binary search
|
|
# is problematic with tight key derivation time target ranges, so if the search keeps restarting,
|
|
# increasing MAX_KEY_DERIVATION_TIME (and thus expanding the range) will help finding suitable
|
|
# memory_cost value faster. Increasing MAX_KEY_DERIVATION_TIME slightly affects security (positively)
|
|
# and user experience (negatively).
|
|
if middle == lower_bound or middle == upper_bound:
|
|
lower_bound = ARGON2_MIN_MEMORY_COST
|
|
upper_bound = self.get_available_memory()
|
|
continue
|
|
|
|
if kd_time < MIN_KEY_DERIVATION_TIME:
|
|
lower_bound = middle
|
|
|
|
elif kd_time > MAX_KEY_DERIVATION_TIME:
|
|
upper_bound = middle
|
|
|
|
memory_cost = middle if middle is not None else memory_cost
|
|
|
|
# Store values to database
|
|
database_data = (salt
|
|
+ blake2b(master_key)
|
|
+ int_to_bytes(time_cost)
|
|
+ int_to_bytes(memory_cost)
|
|
+ int_to_bytes(parallelism))
|
|
|
|
if replace:
|
|
self.database.store_unencrypted_database(database_data)
|
|
else:
|
|
# When replacing the master key, the new master key needs to be generated before
|
|
# databases are encrypted. However, storing the new master key shouldn't be done
|
|
# before all new databases have been successfully written. We therefore just cache
|
|
# the database data.
|
|
self.database_data = database_data
|
|
|
|
print_on_previous_line(2)
|
|
phase("Deriving master key")
|
|
phase(DONE, delay=1)
|
|
|
|
return master_key
|
|
|
|
def replace_database_data(self) -> None:
|
|
"""Store cached database data into database."""
|
|
if self.database_data is not None:
|
|
self.database.store_unencrypted_database(self.database_data)
|
|
self.database_data = None
|
|
|
|
def load_master_key(self) -> bytes:
|
|
"""Derive the master key from password and salt.
|
|
|
|
Load the salt, hash, and key derivation settings from the login
|
|
database. Derive the purported master key from the salt and
|
|
entered password. If the BLAKE2b hash of derived master key
|
|
matches the hash in the login database, accept the derived
|
|
master key.
|
|
"""
|
|
database_data = self.database.load_database()
|
|
|
|
if len(database_data) != MASTERKEY_DB_SIZE:
|
|
raise CriticalError(f"Invalid {self.file_name} database size.")
|
|
|
|
salt, key_hash, time_bytes, memory_bytes, parallelism_bytes \
|
|
= separate_headers(database_data, [ARGON2_SALT_LENGTH, BLAKE2_DIGEST_LENGTH,
|
|
ENCODED_INTEGER_LENGTH, ENCODED_INTEGER_LENGTH])
|
|
|
|
time_cost = bytes_to_int(time_bytes)
|
|
memory_cost = bytes_to_int(memory_bytes)
|
|
parallelism = bytes_to_int(parallelism_bytes)
|
|
|
|
while True:
|
|
password = MasterKey.get_password()
|
|
phase("Deriving master key", head=2, offset=len("Password correct"))
|
|
purp_key = argon2_kdf(password, salt, time_cost, memory_cost, parallelism)
|
|
|
|
if blake2b(purp_key) == key_hash:
|
|
phase("Password correct", done=True, delay=1)
|
|
clear_screen()
|
|
return purp_key
|
|
|
|
phase("Invalid password", done=True, delay=1)
|
|
print_on_previous_line(reps=5)
|
|
|
|
@classmethod
|
|
def new_password(cls, purpose: str = "master password") -> str:
|
|
"""Prompt the user to enter and confirm a new password."""
|
|
password_1 = pwd_prompt(f"Enter a new {purpose}: ")
|
|
|
|
if password_1 == GENERATE:
|
|
pwd_bit_strength, password_1 = MasterKey.generate_master_password()
|
|
|
|
m_print([f"Generated a {pwd_bit_strength}-bit password:",
|
|
'', password_1, '',
|
|
"Write down this password and dispose of the copy once you remember it.",
|
|
"Press <Enter> to continue."], manual_proceed=True, box=True, head=1, tail=1)
|
|
reset_terminal()
|
|
|
|
password_2 = password_1
|
|
else:
|
|
password_2 = pwd_prompt(f"Confirm the {purpose}: ", repeat=True)
|
|
|
|
if password_1 == password_2:
|
|
return password_1
|
|
|
|
m_print("Error: Passwords did not match. Try again.", head=1, tail=1)
|
|
print_on_previous_line(delay=1, reps=7)
|
|
return cls.new_password(purpose)
|
|
|
|
@classmethod
|
|
def get_password(cls, purpose: str = "master password") -> str:
|
|
"""Prompt the user to enter a password."""
|
|
return pwd_prompt(f"Enter {purpose}: ")
|
|
|
|
def authenticate_action(self) -> bool:
|
|
"""Return True if user entered correct master password to authenticate an action."""
|
|
try:
|
|
authenticated = self.load_master_key() == self.master_key
|
|
except (EOFError, KeyboardInterrupt):
|
|
raise SoftError(f"Authentication aborted.", tail_clear=True, head=2, delay=1)
|
|
|
|
return authenticated
|