Skip to content

Commit 0d988d5

Browse files
committed
Implement create_source_user() to consolidate source creation
Fix test Fix tests
1 parent 46e993b commit 0d988d5

13 files changed

+367
-184
lines changed

securedrop/crypto_util.py

+7-29
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
# monkey patch to work with Focal gnupg.
2929
# https://github.com/isislovecruft/python-gnupg/issues/250
30+
from source_user import SourceUser
31+
3032
gnupg._parsers.Verify.TRUST_LEVELS["DECRYPTION_COMPLIANCE_MODE"] = 23
3133

3234
# to fix GPG error #78 on production
@@ -151,6 +153,7 @@ def display_id(self) -> str:
151153

152154
raise ValueError("Could not generate unique journalist designation for new source")
153155

156+
# TODO(AD): This will be removed in my next PR and replaced by SourceUser
154157
def hash_codename(self, codename: DicewarePassphrase, salt: Optional[str] = None) -> str:
155158
"""Salts and hashes a codename using scrypt.
156159
@@ -162,33 +165,14 @@ def hash_codename(self, codename: DicewarePassphrase, salt: Optional[str] = None
162165
salt = self.scrypt_id_pepper
163166
return b32encode(scrypt.hash(codename, salt, **self.scrypt_params)).decode('utf-8')
164167

165-
def genkeypair(self, name: str, secret: DicewarePassphrase) -> gnupg._parsers.GenKey:
166-
"""Generate a GPG key through batch file key generation. A source's
167-
codename is salted with SCRYPT_GPG_PEPPER and hashed with scrypt to
168-
provide the passphrase used to encrypt their private key. Their name
169-
should be their filesystem id.
170-
171-
>>> if not gpg.list_keys(hash_codename('randomid')):
172-
... genkeypair(hash_codename('randomid'), 'randomid').type
173-
... else:
174-
... u'P'
175-
u'P'
176-
177-
:param name: The source's filesystem id (their codename, salted
178-
with SCRYPT_ID_PEPPER, and hashed with scrypt).
179-
:param secret: The source's codename.
180-
:returns: a :class:`GenKey <gnupg._parser.GenKey>` object, on which
181-
the ``__str__()`` method may be called to return the
182-
generated key's fingeprint.
183-
168+
def genkeypair(self, source_user: SourceUser) -> gnupg._parsers.GenKey:
169+
"""Generate a GPG key through batch file key generation.
184170
"""
185-
_validate_name_for_diceware(name)
186-
hashed_secret = self.hash_codename(secret, salt=self.scrypt_gpg_pepper)
187171
genkey_obj = self.gpg.gen_key(self.gpg.gen_key_input(
188172
key_type=self.GPG_KEY_TYPE,
189173
key_length=self.__gpg_key_length,
190-
passphrase=hashed_secret,
191-
name_email=name,
174+
passphrase=source_user.gpg_secret,
175+
name_email=source_user.filesystem_id,
192176
name_real="Source Key",
193177
creation_date=self.DEFAULT_KEY_CREATION_DATE.isoformat(),
194178
expire_date=self.DEFAULT_KEY_EXPIRATION_DATE
@@ -309,9 +293,3 @@ def decrypt(self, secret: DicewarePassphrase, ciphertext: bytes) -> str:
309293
data = self.gpg.decrypt(ciphertext, passphrase=hashed_codename).data
310294

311295
return data.decode('utf-8')
312-
313-
314-
def _validate_name_for_diceware(name: str) -> None:
315-
for char in name:
316-
if char not in DICEWARE_SAFE_CHARS:
317-
raise CryptoException("invalid input: {0}".format(name))

securedrop/loaddata.py

+9-8
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434
from passphrases import PassphraseGenerator
3535
from sdconfig import config
36+
from source_user import create_source_user
3637
from specialstrings import strings
3738

3839
messages = cycle(strings)
@@ -254,18 +255,18 @@ def add_source() -> Tuple[Source, str]:
254255
Adds a single source.
255256
"""
256257
codename = PassphraseGenerator.get_default().generate_passphrase()
257-
filesystem_id = current_app.crypto_util.hash_codename(codename)
258-
journalist_designation = current_app.crypto_util.display_id()
259-
source = Source(filesystem_id, journalist_designation)
258+
source_user = create_source_user(
259+
db_session=db.session,
260+
source_passphrase=codename,
261+
source_app_crypto_util=current_app.crypto_util,
262+
source_app_storage=current_app.storage,
263+
)
264+
source = source_user.get_db_record()
260265
source.pending = False
261-
db.session.add(source)
262266
db.session.commit()
263267

264-
# Create source directory in store
265-
os.mkdir(current_app.storage.path(source.filesystem_id))
266-
267268
# Generate source key
268-
current_app.crypto_util.genkeypair(source.filesystem_id, codename)
269+
current_app.crypto_util.genkeypair(source)
269270

270271
return source, codename
271272

securedrop/models.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import datetime
44
import base64
55
import os
6-
import scrypt
76
import pyotp
87
import qrcode
98
# Using svg because it doesn't require additional dependencies
109
import qrcode.image.svg
1110
import uuid
1211
from io import BytesIO
1312

13+
from cryptography.hazmat.backends import default_backend
14+
from cryptography.hazmat.primitives.kdf import scrypt
1415
from flask import current_app, url_for
1516
from flask_babel import gettext, ngettext
1617
from itsdangerous import TimedJSONWebSignatureSerializer, BadData
@@ -452,10 +453,17 @@ def __repr__(self) -> str:
452453
self.username,
453454
" [admin]" if self.is_admin else "")
454455

455-
_LEGACY_SCRYPT_PARAMS = dict(N=2**14, r=8, p=1)
456-
457456
def _scrypt_hash(self, password: str, salt: bytes) -> bytes:
458-
return scrypt.hash(str(password), salt, **self._LEGACY_SCRYPT_PARAMS)
457+
backend = default_backend()
458+
scrypt_instance = scrypt.Scrypt(
459+
length=64,
460+
salt=salt,
461+
n=2**14,
462+
r=8,
463+
p=1,
464+
backend=backend,
465+
)
466+
return scrypt_instance.derive(password.encode("utf-8"))
459467

460468
MAX_PASSWORD_LEN = 128
461469
MIN_PASSWORD_LEN = 14

securedrop/passphrases.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
DicewarePassphrase = NewType("DicewarePassphrase", str)
1414

1515

16-
_current_generator = None # type: Optional["PassphraseGenerator"]
16+
_default_generator = None # type: Optional["PassphraseGenerator"]
1717

1818

1919
class InvalidWordListError(Exception):
@@ -52,7 +52,9 @@ def __init__(
5252
raise InvalidWordListError(
5353
"The word list for language '{}' only contains {} long-enough words;"
5454
" minimum required is {} words.".format(
55-
language, word_list_size, self._WORD_LIST_MINIMUM_SIZE,
55+
language,
56+
word_list_size,
57+
self._WORD_LIST_MINIMUM_SIZE,
5658
)
5759
)
5860

@@ -98,11 +100,11 @@ def __init__(
98100

99101
@classmethod
100102
def get_default(cls) -> "PassphraseGenerator":
101-
global _current_generator
102-
if _current_generator is None:
103+
global _default_generator
104+
if _default_generator is None:
103105
language_to_words = _parse_available_words_list(Path(config.SECUREDROP_ROOT))
104-
_current_generator = cls(language_to_words)
105-
return _current_generator
106+
_default_generator = cls(language_to_words)
107+
return _default_generator
106108

107109
@property
108110
def available_languages(self) -> Set[str]:

securedrop/source_app/main.py

+43-34
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@
1010
from flask import (Blueprint, render_template, flash, redirect, url_for, g,
1111
session, current_app, request, Markup, abort)
1212
from flask_babel import gettext
13-
from sqlalchemy.exc import IntegrityError
1413

1514
import store
1615

1716
from db import db
18-
from models import Source, Submission, Reply, get_one_or_else
17+
from models import Submission, Reply, get_one_or_else
18+
from passphrases import PassphraseGenerator
1919
from sdconfig import SDConfig
2020
from source_app.decorators import login_required
21-
from source_app.utils import (logged_in, generate_unique_codename,
22-
normalize_timestamps, valid_codename)
21+
from source_app.utils import (logged_in, normalize_timestamps,
22+
valid_codename)
2323
from source_app.forms import LoginForm, SubmissionForm
24+
from source_user import create_source_user, SourcePassphraseCollisionError, \
25+
SourceDesignationCollisionError
26+
27+
from securedrop.models import Source
28+
from securedrop.source_user import SourceUser
2429

2530

2631
def make_blueprint(config: SDConfig) -> Blueprint:
@@ -40,7 +45,9 @@ def generate() -> Union[str, werkzeug.Response]:
4045
"notification")
4146
return redirect(url_for('.lookup'))
4247

43-
codename = generate_unique_codename(config)
48+
codename = PassphraseGenerator.get_default().generate_passphrase(
49+
preferred_language=g.localeinfo.language
50+
)
4451

4552
# Generate a unique id for each browser tab and associate the codename with this id.
4653
# This will allow retrieval of the codename displayed in the tab from which the source has
@@ -55,53 +62,45 @@ def generate() -> Union[str, werkzeug.Response]:
5562

5663
@view.route('/create', methods=['POST'])
5764
def create() -> werkzeug.Response:
58-
if session.get('logged_in', False):
65+
if logged_in():
5966
flash(gettext("You are already logged in. Please verify your codename above as it " +
6067
"may differ from the one displayed on the previous page."),
6168
'notification')
6269
else:
6370
tab_id = request.form['tab_id']
6471
codename = session['codenames'][tab_id]
65-
session['codename'] = codename
66-
6772
del session['codenames']
6873

69-
filesystem_id = current_app.crypto_util.hash_codename(codename)
7074
try:
71-
source = Source(filesystem_id, current_app.crypto_util.display_id())
72-
except ValueError as e:
73-
current_app.logger.error(e)
74-
flash(
75-
gettext("There was a temporary problem creating your account. "
76-
"Please try again."
77-
),
78-
'error'
75+
current_app.logger.info("Creating new source user...")
76+
create_source_user(
77+
db_session=db.session,
78+
source_passphrase=codename,
79+
source_app_crypto_util=current_app.crypto_util,
80+
source_app_storage=current_app.storage,
7981
)
80-
return redirect(url_for('.index'))
81-
82-
db.session.add(source)
83-
try:
84-
db.session.commit()
85-
except IntegrityError as e:
86-
db.session.rollback()
87-
current_app.logger.error(
88-
"Attempt to create a source with duplicate codename: %s" %
89-
(e,))
90-
91-
# Issue 2386: don't log in on duplicates
92-
del session['codename']
82+
except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e:
83+
current_app.logger.error("Could not create a source: {}".format(e))
9384

9485
# Issue 4361: Delete 'logged_in' if it's in the session
9586
try:
9687
del session['logged_in']
9788
except KeyError:
9889
pass
9990

100-
abort(500)
101-
else:
102-
os.mkdir(current_app.storage.path(filesystem_id))
91+
flash(
92+
gettext(
93+
"There was a temporary problem creating your account. Please try again."
94+
),
95+
"error"
96+
)
97+
return redirect(url_for('.index'))
10398

99+
# All done - source user was successfully created
100+
current_app.logger.info("New source user created")
104101
session['logged_in'] = True
102+
session['codename'] = codename
103+
105104
return redirect(url_for('.lookup'))
106105

107106
@view.route('/lookup', methods=('GET',))
@@ -137,7 +136,17 @@ def lookup() -> str:
137136

138137
# Generate a keypair to encrypt replies from the journalist
139138
if not current_app.crypto_util.get_fingerprint(g.filesystem_id):
140-
current_app.crypto_util.genkeypair(g.filesystem_id, g.codename)
139+
# TODO(AD): Will be simplified in my next PR by using logged_in_user instead of
140+
# fetching it from the DB here
141+
source = session.query(Source).filter(Source.filesystem_id == g.filesystem_id).one()
142+
source_user = SourceUser(
143+
db_record=source,
144+
filesystem_id=g.filesystem_id,
145+
gpg_secret=current_app.crypto_util.hash_codename(
146+
g.codename, salt=current_app.crypto_util.scrypt_gpg_pepper
147+
)
148+
)
149+
current_app.crypto_util.genkeypair(source_user)
141150

142151
return render_template(
143152
'lookup.html',

securedrop/source_app/utils.py

+3-18
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99
from crypto_util import CryptoException
1010
from models import Source
11-
from passphrases import PassphraseGenerator, DicewarePassphrase
12-
from sdconfig import SDConfig
11+
from passphrases import DicewarePassphrase
12+
from source_user import SourceUser
1313

1414
if typing.TYPE_CHECKING:
15-
from typing import Optional # noqa: F401
15+
from typing import Optional
1616

1717

1818
def was_in_generate_flow() -> bool:
@@ -36,21 +36,6 @@ def valid_codename(codename: str) -> bool:
3636
return source is not None
3737

3838

39-
def generate_unique_codename(config: SDConfig) -> DicewarePassphrase:
40-
"""Generate random codenames until we get an unused one"""
41-
while True:
42-
passphrase = PassphraseGenerator.get_default().generate_passphrase(
43-
preferred_language=g.localeinfo.language
44-
)
45-
# scrypt (slow)
46-
filesystem_id = current_app.crypto_util.hash_codename(passphrase)
47-
48-
matching_sources = Source.query.filter(
49-
Source.filesystem_id == filesystem_id).all()
50-
if len(matching_sources) == 0:
51-
return passphrase
52-
53-
5439
def normalize_timestamps(filesystem_id: str) -> None:
5540
"""
5641
Update the timestamps on all of the source's submissions. This

0 commit comments

Comments
 (0)