I'm writing a password manager application, mostly with the goal of learning about proper handling of encryption.
Here is my code responsible for encrypting/decrypting passwords and loading to/from file. The PasswordDataBase
class is pretty bare as is, but will incorporate more functionality going further (such as filtering records based on name or tags). I just want to make sure this more sensitive aspect of the code is done right before building upon it.
Dependencies: this code depends on the pyaes
module, which you can find on PyPI.
passworddb.py:
from dataclasses import dataclass, field
from hashlib import scrypt
import pickle
import secrets
from typing import List
import uuid
import pyaes
class InvalidPasswordException(Exception):
pass
class PasswordDataBase:
SALT_LENGTH = 16
@dataclass
class Record:
name: str = field(default='')
login: str = field(default='')
password: str = field(default='')
uid: uuid.UUID = field(default_factory=uuid.uuid4)
tags: List[str] = field(default_factory=list)
def __init__(self):
self.records = []
def save_to_file(self, path: str, password: str):
"""
Save the current instance to a file.
Parameters
----------
path : str
Path of the file to save.
password : str
The master password used for encryption.
Returns
-------
None.
"""
password = bytes(password, encoding='utf-8')
salt = secrets.token_bytes(self.SALT_LENGTH)
key = scrypt(password,
n=2**14,
r=8,
p=1,
salt=salt,
dklen=32)
cipher = pyaes.AESModeOfOperationCTR(key)
with open(path, 'wb') as file:
file.write(salt)
file.write(cipher.encrypt(pickle.dumps(self)))
@classmethod
def load_from_file(cls, path: str, password: str):
"""
Load an encrypted password database from a file.
Parameters
----------
path : str
Path to the file to load.
password : str
Master password used to decrypt the file.
Raises
------
InvalidPasswordException
The provided password was invalid.
Returns
-------
PasswordDataBase
A PasswordDatabase instance containing the decrypted data.
"""
password = bytes(password, encoding='utf-8')
with open(path, 'rb') as file:
salt = file.read(cls.SALT_LENGTH)
data = file.read()
key = scrypt(password,
n=2**14,
r=8,
p=1,
salt=salt,
dklen=32)
cipher = pyaes.AESModeOfOperationCTR(key)
try:
return pickle.loads(cipher.decrypt(data))
except pickle.UnpicklingError as error:
raise InvalidPasswordException("Invalid password") from error
def generate_password(length: int, charset: str) -> str:
"""
Generate a securely random password.
Parameters
----------
length : int
Generated password length.
charset : str
Allowable characters for password generation.
Returns
-------
str
The generated password.
"""
return ''.join(secrets.choice(charset) for _ in range(length))
passworddb_tests.py:
"""
Unit tests for the password_manager modules
"""
import os
from string import ascii_letters, digits, punctuation
import tempfile
import unittest
from passworddb import (PasswordDataBase,
InvalidPasswordException,
generate_password)
ALL_CHARS = ascii_letters + digits + punctuation
class PasswordDataBaseTests(unittest.TestCase):
def test_roundtrip(self):
"""
Verify that a password database written to a file is correctly
recovered upon loading.
"""
original = PasswordDataBase()
original.records.append(
PasswordDataBase.Record(name='foo',
login='bar',
password=generate_password(64, ALL_CHARS)))
original.records.append(
PasswordDataBase.Record(name='ga',
login='bu',
password=generate_password(64, ALL_CHARS),
tags=['zo', 'meu']))
original.records.append(PasswordDataBase.Record())
password = "Secr3t"
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, 'test')
original.save_to_file(path, password)
recovered = PasswordDataBase.load_from_file(path, password)
for expected, actual in zip(original.records, recovered.records):
self.assertEqual(expected, actual)
def test_roundtrip_empty(self):
"""
Verify that an empty password database written to a file is correctly
recovered upon loading.
"""
original = PasswordDataBase()
password = "Secr3t"
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, 'test')
original.save_to_file(path, password)
recovered = PasswordDataBase.load_from_file(path, password)
for expected, actual in zip(original.records, recovered.records):
self.assertEqual(expected, actual)
def test_load_fails_with_wrong_password(self):
"""
Ensure that records written to a file can't be recovered using an
incorrect password.
"""
database = PasswordDataBase()
database.records.append(
PasswordDataBase.Record(name='foo',
login='bar',
password=generate_password(64, ALL_CHARS)))
database.records.append(
PasswordDataBase.Record(name='ga',
login='bu',
password=generate_password(64, ALL_CHARS),
tags=['zo', 'meu']))
database.records.append(PasswordDataBase.Record())
with tempfile.TemporaryDirectory() as temp_dir:
path = os.path.join(temp_dir, 'test')
database.save_to_file(path, 'right')
self.assertRaises(InvalidPasswordException,
lambda: PasswordDataBase.load_from_file(path, 'wrong'))
if __name__ == '__main__':
unittest.main()
My main concerns are:
- Does this code follow best practices when it comes to security?
- Is the test coverage good enough? I feel like it could be improved, but I find it hard to come up with additional meaningful tests.
Any other remarks about my code are also welcome, of course.