diff --git a/securedrop/sass/source.sass b/securedrop/sass/source.sass index 56a08ab247..788a8f8773 100644 --- a/securedrop/sass/source.sass +++ b/securedrop/sass/source.sass @@ -43,9 +43,6 @@ h1, h2, h3, h4 h1, h2 font-size: 24px -#source-lookup h2 - font-size: 18px - p margin: 0 0 12px 0 @@ -211,7 +208,7 @@ header width: 150px padding-top: 60px - 17px padding-bottom: 30px - margin: 0 30px 0 15px + margin: 0 30px 0 25px #source-index .content @@ -473,18 +470,18 @@ mark background: $passphrase_masked_color clip-path: polygon(0% 0%, 0% 100%, 20% 100%, 20% 0%, 23% 0%, 23% 100%, 32% 100%, 32% 0%, 35% 0%, 35% 100%, 42% 100%, 42% 0%, 45% 0%, 45% 100%, 64% 100%, 64% 0%, 67% 0%, 67% 100%, 74% 100%, 74% 0%, 77% 0%, 77% 100%, 90% 100%, 90% 0%, 93% 0%, 93% 100%, 100% 100%, 100% 0%) -#codename-reminder +#passphrase-reminder font-size: 18px color: $heading_alt_color -#codename-show-checkbox +#passphrase-show-checkbox position: absolute @include ltr left: -999rem @include rtl right: -999rem - &~ #codename-show + &~ #passphrase-show font-size: 11px line-height: 14px margin-top: -2px @@ -501,7 +498,7 @@ mark border-color: $checkbox_hover_border box-shadow: inset 0 0 0 1px $checkbox_hover_border_inside - @mixin codename-show-checkbox + @mixin passphrase-show-checkbox content: "" background: $body_bg display: block @@ -511,10 +508,10 @@ mark &::before @include ltr - @include codename-show-checkbox + @include passphrase-show-checkbox &::after @include rtl - @include codename-show-checkbox + @include passphrase-show-checkbox &::before @include ltr @@ -525,7 +522,7 @@ mark margin-left: 12px float: right - &:focus-visible ~ #codename-show + &:focus-visible ~ #passphrase-show box-shadow: 0 0 0 3px $focus_shadow &:checked ~ mark > span @@ -543,14 +540,14 @@ mark height: 13px border: 0 - &:checked ~ #codename-show::before + &:checked ~ #passphrase-show::before @include ltr @include checked - &:checked ~ #codename-show::after + &:checked ~ #passphrase-show::after @include rtl @include checked -#codename-show +#passphrase-show display: block #upload @@ -944,18 +941,23 @@ header img, #locales-menu, #locales-menu-list main width: calc(100% - 210px) - border: 1px solid $main_border - padding: 50px 30px 24px 30px + padding: 50px 20px 0 15px section[aria-labelledby="submit-heading"] clear: both -section[aria-labelledby="codename-reminder"] + section h2 +section[aria-labelledby="welcome-heading"] + margin-top: 50px + +section[aria-labelledby="passphrase-reminder"] + section h2 padding-top: 26px #source-lookup main, #source-logout main padding-top: 22px +#source-lookup #encrypt-before-sending + display: none + #source-logout main nav + section margin-top: 44px @@ -1138,11 +1140,11 @@ section[aria-labelledby="codename-reminder"] + section h2 .info padding: 9px -#flashed + section, #codename-hint + section:not(#flashed) +#flashed + section, #passphrase-hint + section:not(#flashed) margin-top: 3em #source-lookup nav + section:not(#flashed) - margin-top: 53px + margin-top: 47px nav + section#flashed margin-top: -1em diff --git a/securedrop/source_app/decorators.py b/securedrop/source_app/decorators.py index 5c1cbdbb89..3e879b9071 100644 --- a/securedrop/source_app/decorators.py +++ b/securedrop/source_app/decorators.py @@ -3,28 +3,49 @@ from db import db from flask import redirect, url_for, request, session +import werkzeug from functools import wraps -from typing import Callable +from typing import Callable, Union, Optional from source_app.utils import clear_session_and_redirect_to_logged_out_page from source_app.session_manager import SessionManager, UserNotLoggedIn, \ UserSessionExpired, UserHasBeenDeleted +from source_user import SourceUser -def login_required(f: Callable) -> Callable: +def _source_user() -> Optional[Union[SourceUser, werkzeug.Response]]: + try: + return SessionManager.get_logged_in_user(db_session=db.session) + + except (UserSessionExpired, UserHasBeenDeleted): + return clear_session_and_redirect_to_logged_out_page(flask_session=session) + + except UserNotLoggedIn: + return None + + +def login_possible(f: Callable) -> Callable: @wraps(f) - def decorated_function(*args: Any, **kwargs: Any) -> Any: - try: - logged_in_source = SessionManager.get_logged_in_user(db_session=db.session) + def decorated_function(*args: Any, **kwargs: Any) -> Union[str, werkzeug.Response]: + result = _source_user() + if isinstance(result, werkzeug.Response): + return result + + return f(*args, **kwargs, logged_in_source=result) + return decorated_function - except (UserSessionExpired, UserHasBeenDeleted): - return clear_session_and_redirect_to_logged_out_page(flask_session=session) - except UserNotLoggedIn: +def login_required(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args: Any, **kwargs: Any) -> Union[str, werkzeug.Response]: + result = _source_user() + if result is None: return redirect(url_for("main.login")) + elif isinstance(result, werkzeug.Response): + return result - return f(*args, **kwargs, logged_in_source=logged_in_source) + return f(*args, **kwargs, logged_in_source=result) return decorated_function diff --git a/securedrop/source_app/forms.py b/securedrop/source_app/forms.py index d03dda79ba..52658c983b 100644 --- a/securedrop/source_app/forms.py +++ b/securedrop/source_app/forms.py @@ -11,14 +11,15 @@ class LoginForm(FlaskForm): - codename = PasswordField('codename', validators=[ + passphrase = PasswordField('passphrase', validators=[ InputRequired(message=gettext('This field is required.')), Length(1, PassphraseGenerator.MAX_PASSPHRASE_LENGTH, message=gettext( 'Field must be between 1 and ' - '{max_codename_len} characters long.'.format( - max_codename_len=PassphraseGenerator.MAX_PASSPHRASE_LENGTH))), + '{max_passphrase_len} characters long.'.format( + max_passphrase_len=PassphraseGenerator.MAX_PASSPHRASE_LENGTH))), # Make sure to allow dashes since some words in the wordlist have them + # TODO Regexp(r'[\sA-Za-z0-9-]+$', message=gettext('Invalid input.')) ]) @@ -31,15 +32,12 @@ class SubmissionForm(FlaskForm): def validate_msg(self, field: wtforms.Field) -> None: if len(field.data) > Submission.MAX_MESSAGE_LEN: - message = gettext("Message text too long.") + err = gettext("The message you submit can be at most " + "{} characters long.").format(Submission.MAX_MESSAGE_LEN) if InstanceConfig.get_default().allow_document_uploads: - message = "{} {}".format( - message, - gettext( - "Large blocks of text must be uploaded as a file, not copied and pasted." - ) - ) - raise ValidationError(message) + err = "{} {}".format(err, gettext("If you need to submit large blocks of text, " + "you can upload them as a file.")) + raise ValidationError(err) def validate_antispam(self, field: wtforms.Field) -> None: """If the antispam field has any contents, abort with a 403""" diff --git a/securedrop/source_app/main.py b/securedrop/source_app/main.py index 298c6fab6c..ccfaf99860 100644 --- a/securedrop/source_app/main.py +++ b/securedrop/source_app/main.py @@ -1,10 +1,9 @@ import operator import os import io +from datetime import datetime, timezone -from base64 import urlsafe_b64encode -from datetime import datetime, timedelta, timezone -from typing import Union +from typing import Optional, Union import werkzeug from flask import (Blueprint, render_template, redirect, url_for, @@ -21,10 +20,9 @@ from models import Submission, Reply, get_one_or_else, InstanceConfig from passphrases import PassphraseGenerator, DicewarePassphrase from sdconfig import SDConfig -from source_app.decorators import login_required +from source_app.decorators import login_required, login_possible from source_app.session_manager import SessionManager -from source_app.utils import normalize_timestamps, fit_codenames_into_cookie, \ - clear_session_and_redirect_to_logged_out_page, codename_detected, flash_msg +from source_app.utils import normalize_timestamps, passphrase_detected, flash_msg from source_app.forms import LoginForm, SubmissionForm from source_user import InvalidPassphraseError, create_source_user, \ SourcePassphraseCollisionError, SourceDesignationCollisionError, SourceUser @@ -34,11 +32,15 @@ def make_blueprint(config: SDConfig) -> Blueprint: view = Blueprint('main', __name__) @view.route('/') - def index() -> str: + def index() -> Union[str, werkzeug.Response]: + # Behave like a webmail application + if SessionManager.is_user_logged_in(db_session=db.session): + return redirect(url_for('.lookup')) return render_template('index.html') - @view.route('/generate', methods=('POST', 'GET')) - def generate() -> Union[str, werkzeug.Response]: + @view.route('/lookup', methods=('POST', 'GET')) + @login_possible + def lookup(logged_in_source: 'Optional[SourceUser]') -> Union[str, werkzeug.Response]: if request.method == 'POST': # Try to detect Tor2Web usage by looking to see if tor2web_check got mangled tor2web_check = request.form.get('tor2web_check') @@ -48,137 +50,75 @@ def generate() -> Union[str, werkzeug.Response]: elif tor2web_check != 'href="fake.onion"': return redirect(url_for('info.tor2web_warning')) - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You were redirected because you are already logged in. " - "If you want to create a new account, you should log out first.")) - return redirect(url_for('.lookup')) - codename = PassphraseGenerator.get_default().generate_passphrase( - preferred_language=g.localeinfo.language - ) - - # Generate a unique id for each browser tab and associate the codename with this id. - # This will allow retrieval of the codename displayed in the tab from which the source has - # clicked to proceed to /generate (ref. issue #4458) - tab_id = urlsafe_b64encode(os.urandom(64)).decode() - codenames = session.get('codenames', {}) - codenames[tab_id] = codename - session['codenames'] = fit_codenames_into_cookie(codenames) - session["codenames_expire"] = datetime.now(timezone.utc) + timedelta( - minutes=config.SESSION_EXPIRATION_MINUTES - ) - return render_template('generate.html', codename=codename, tab_id=tab_id) - - @view.route('/create', methods=['POST']) - def create() -> werkzeug.Response: - if SessionManager.is_user_logged_in(db_session=db.session): - flash_msg("notification", None, gettext( - "You are already logged in. Please verify your codename as it " - "may differ from the one displayed on the previous page.")) - else: - # Ensure the codenames have not expired - date_codenames_expire = session.get("codenames_expire") - if not date_codenames_expire or datetime.now(timezone.utc) >= date_codenames_expire: - return clear_session_and_redirect_to_logged_out_page(flask_session=session) - - tab_id = request.form['tab_id'] - codename = session['codenames'][tab_id] - del session['codenames'] - - try: - current_app.logger.info("Creating new source user...") - create_source_user( - db_session=db.session, - source_passphrase=codename, - source_app_storage=Storage.get_default(), - ) - except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e: - current_app.logger.error("Could not create a source: {}".format(e)) - flash_msg("error", None, gettext( - "There was a temporary problem creating your account. Please try again.")) - return redirect(url_for('.index')) - - # All done - source user was successfully created - current_app.logger.info("New source user created") - session['new_user_codename'] = codename - SessionManager.log_user_in(db_session=db.session, - supplied_passphrase=DicewarePassphrase(codename)) - - return redirect(url_for('.lookup')) - - @view.route('/lookup', methods=('GET',)) - @login_required - def lookup(logged_in_source: SourceUser) -> str: replies = [] - logged_in_source_in_db = logged_in_source.get_db_record() - source_inbox = Reply.query.filter_by( - source_id=logged_in_source_in_db.id, deleted_by_source=False - ).all() - - first_submission = logged_in_source_in_db.interaction_count == 0 - - if first_submission: - min_message_length = InstanceConfig.get_default().initial_message_min_len - else: - min_message_length = 0 - - for reply in source_inbox: - reply_path = Storage.get_default().path( - logged_in_source.filesystem_id, - reply.filename, - ) - try: - with io.open(reply_path, "rb") as f: - contents = f.read() - decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( - for_source_user=logged_in_source, - ciphertext_in=contents + if logged_in_source is not None: + logged_in_source_in_db = logged_in_source.get_db_record() + source_inbox = Reply.query.filter_by( + source_id=logged_in_source_in_db.id, deleted_by_source=False + ).all() + + for reply in source_inbox: + reply_path = Storage.get_default().path( + logged_in_source.filesystem_id, + reply.filename, ) - reply.decrypted = decrypted_reply - except UnicodeDecodeError: - current_app.logger.error("Could not decode reply %s" % - reply.filename) - except FileNotFoundError: - current_app.logger.error("Reply file missing: %s" % - reply.filename) - else: - reply.date = datetime.utcfromtimestamp( - os.stat(reply_path).st_mtime) - replies.append(reply) - - # Sort the replies by date - replies.sort(key=operator.attrgetter('date'), reverse=True) + try: + with io.open(reply_path, "rb") as f: + contents = f.read() + decrypted_reply = EncryptionManager.get_default().decrypt_journalist_reply( + for_source_user=logged_in_source, + ciphertext_in=contents + ) + reply.decrypted = decrypted_reply + except UnicodeDecodeError: + current_app.logger.error("Could not decode reply %s" % + reply.filename) + except FileNotFoundError: + current_app.logger.error("Reply file missing: %s" % + reply.filename) + else: + reply.date = datetime.utcfromtimestamp( + os.stat(reply_path).st_mtime) + replies.append(reply) + + # Sort the replies by date + replies.sort(key=operator.attrgetter('date'), reverse=True) + + # If not done yet, generate a keypair to encrypt replies from the journalist + encryption_mgr = EncryptionManager.get_default() + try: + encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) + except GpgKeyNotFoundError: + encryption_mgr.generate_source_key_pair(logged_in_source) - # If not done yet, generate a keypair to encrypt replies from the journalist - encryption_mgr = EncryptionManager.get_default() - try: - encryption_mgr.get_source_public_key(logged_in_source.filesystem_id) - except GpgKeyNotFoundError: - encryption_mgr.generate_source_key_pair(logged_in_source) + # If the source is logged in, they already submitted at least once + min_message_length = 0 + new_user_passphrase = session.get('new_user_passphrase', None) + else: + min_message_length = InstanceConfig.get_default().initial_message_min_len + new_user_passphrase = None return render_template( 'lookup.html', - is_user_logged_in=True, + is_user_logged_in=logged_in_source is not None, allow_document_uploads=InstanceConfig.get_default().allow_document_uploads, replies=replies, min_len=min_message_length, - new_user_codename=session.get('new_user_codename', None), + new_user_passphrase=new_user_passphrase, form=SubmissionForm(), ) @view.route('/submit', methods=('POST',)) - @login_required - def submit(logged_in_source: SourceUser) -> werkzeug.Response: - allow_document_uploads = InstanceConfig.get_default().allow_document_uploads - form = SubmissionForm() - if not form.validate(): - for field, errors in form.errors.items(): - for error in errors: - flash_msg("error", None, error) - return redirect(url_for('main.lookup')) + @login_possible + def submit(logged_in_source: 'Optional[SourceUser]') -> werkzeug.Response: + # Flow inversion: generate passphrase create source user before processing their + # submission, rather than on a separate screen + # Handle as much validation as possible upfront, as we only want to generate sources if + # necessary msg = request.form['msg'] fh = None + allow_document_uploads = InstanceConfig.get_default().allow_document_uploads if allow_document_uploads and 'fh' in request.files: fh = request.files['fh'] @@ -192,34 +132,69 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("error", None, html_contents) return redirect(url_for('main.lookup')) - fnames = [] - logged_in_source_in_db = logged_in_source.get_db_record() - first_submission = logged_in_source_in_db.interaction_count == 0 + form = SubmissionForm() + if not form.validate(): + for field, errors in form.errors.items(): + for error in errors: + flash_msg("error", None, error) + return redirect(url_for('main.lookup')) - if first_submission: + if logged_in_source is None: min_len = InstanceConfig.get_default().initial_message_min_len if (min_len > 0) and (msg and not fh) and (len(msg) < min_len): flash_msg("error", None, gettext( "Your first message must be at least {} characters long.").format(min_len)) return redirect(url_for('main.lookup')) - # if the new_user_codename key is not present in the session, this is - # not a first session - new_codename = session.get('new_user_codename', None) + passphrase = PassphraseGenerator.get_default().generate_passphrase( + preferred_language=g.localeinfo.language + ) + try: + current_app.logger.info("Creating new source user...") + create_source_user( + db_session=db.session, + source_passphrase=passphrase, + source_app_storage=Storage.get_default(), + ) + except (SourcePassphraseCollisionError, SourceDesignationCollisionError) as e: + current_app.logger.error("Could not create a source: {}".format(e)) + flash_msg("error", None, gettext( + "There was a temporary problem creating your account. Please try again.")) + return redirect(url_for('.index')) - codenames_rejected = InstanceConfig.get_default().reject_message_with_codename - if new_codename is not None: - if codenames_rejected and codename_detected(msg, new_codename): - flash_msg("error", None, gettext("Please do not submit your codename!"), - gettext("Keep your codename secret, and use it to log in later to " - "check for replies.")) - return redirect(url_for('main.lookup')) + # All done - source user was successfully created + current_app.logger.info("New source user created") + # Track that this user was generated during this session + session['new_user_passphrase'] = passphrase + logged_in_source = SessionManager.log_user_in( + db_session=db.session, + supplied_passphrase=DicewarePassphrase(passphrase) + ) + + # if the new_user_passphrase key is not present in the session, this is + # not a first session - during the first session, the passphrase is displayed + # throughout but we want to discourage sources from sharing it + new_passphrase = session.get('new_user_passphrase', None) + passphrases_rejected = InstanceConfig.get_default().reject_message_with_codename + + if new_passphrase is not None \ + and passphrases_rejected \ + and passphrase_detected(msg, new_passphrase): + flash_msg("error", None, gettext("Please do not submit your passphrase!"), + gettext("Keep your passphrase secret, and use it to log in later to " + "check for replies.")) + return redirect(url_for('main.lookup')) + + logged_in_source_in_db = logged_in_source.get_db_record() if not os.path.exists(Storage.get_default().path(logged_in_source.filesystem_id)): current_app.logger.debug("Store directory not found for source '{}', creating one." .format(logged_in_source_in_db.journalist_designation)) os.mkdir(Storage.get_default().path(logged_in_source.filesystem_id)) + fnames = [] + first_submission = logged_in_source_in_db.interaction_count == 0 + if msg: logged_in_source_in_db.interaction_count += 1 fnames.append( @@ -252,6 +227,7 @@ def submit(logged_in_source: SourceUser) -> werkzeug.Response: flash_msg("success", gettext("Success!"), html_contents) new_submissions = [] + for fname in fnames: submission = Submission(logged_in_source_in_db, fname, Storage.get_default()) db.session.add(submission) @@ -312,11 +288,11 @@ def login() -> Union[str, werkzeug.Response]: try: SessionManager.log_user_in( db_session=db.session, - supplied_passphrase=DicewarePassphrase(request.form['codename'].strip()) + supplied_passphrase=DicewarePassphrase(request.form['passphrase'].strip()) ) except InvalidPassphraseError: - current_app.logger.info("Login failed for invalid codename") - flash_msg("error", None, gettext("Sorry, that is not a recognized codename.")) + current_app.logger.info("Login failed for invalid passphrase") + flash_msg("error", None, gettext("Sorry, that is not a recognized passphrase.")) else: # Success: a valid passphrase was supplied return redirect(url_for('.lookup', from_login='1')) diff --git a/securedrop/source_app/session_manager.py b/securedrop/source_app/session_manager.py index 7459a58ab6..812649c292 100644 --- a/securedrop/source_app/session_manager.py +++ b/securedrop/source_app/session_manager.py @@ -30,7 +30,7 @@ class SessionManager: """Helper to manage the user's session cookie accessible via flask.session.""" # The keys in flask.session for the user's passphrase and expiration date - _SESSION_COOKIE_KEY_FOR_CODENAME = "codename" + _SESSION_COOKIE_KEY_FOR_CODENAME = "passphrase" _SESSION_COOKIE_KEY_FOR_EXPIRATION_DATE = "expires" @classmethod diff --git a/securedrop/source_app/utils.py b/securedrop/source_app/utils.py index 3c5cf45ac3..f4cd0cf2af 100644 --- a/securedrop/source_app/utils.py +++ b/securedrop/source_app/utils.py @@ -1,36 +1,27 @@ -import json import subprocess +import re +from hmac import compare_digest + +from typing import Optional import werkzeug -from flask import flash -from flask import redirect -from flask import render_template -from flask import current_app -from flask import url_for +from flask import flash, redirect, render_template, current_app, url_for from flask.sessions import SessionMixin from markupsafe import Markup, escape -from store import Storage -from hmac import compare_digest from flask_babel import gettext - -import typing - -import re +from store import Storage from source_user import SourceUser -if typing.TYPE_CHECKING: - from typing import Optional - -def codename_detected(message: str, codename: str) -> bool: +def passphrase_detected(message: str, passphrase: str) -> bool: """ - Check for codenames in incoming messages. including case where user copy/pasted - from /generate or the codename widget on the same page + Check for passphrases in incoming messages. including case where user copy/pasted + from the passphrase widget on the same page """ message = message.strip() - return compare_digest(message.strip(), codename) + return compare_digest(message.strip(), passphrase) def flash_msg( @@ -110,23 +101,3 @@ def check_url_file(path: str, regexp: str) -> 'Optional[str]': def get_sourcev3_url() -> 'Optional[str]': return check_url_file("/var/lib/securedrop/source_v3_url", r"^[a-z0-9]{56}\.onion$") - - -def fit_codenames_into_cookie(codenames: dict) -> dict: - """ - If `codenames` will approach `werkzeug.Response.max_cookie_size` once - serialized, incrementally pop off the oldest codename until the remaining - (newer) ones will fit. - """ - - serialized = json.dumps(codenames).encode() - if len(codenames) > 1 and len(serialized) > 4000: # werkzeug.Response.max_cookie_size = 4093 - if current_app: - current_app.logger.warn(f"Popping oldest of {len(codenames)} " - f"codenames ({len(serialized)} bytes) to " - f"fit within maximum cookie size") - del codenames[list(codenames)[0]] # FIFO - - return fit_codenames_into_cookie(codenames) - - return codenames diff --git a/securedrop/source_templates/error.html b/securedrop/source_templates/error.html index 082832161e..0075847757 100644 --- a/securedrop/source_templates/error.html +++ b/securedrop/source_templates/error.html @@ -4,5 +4,5 @@
{{ gettext('Sorry, the website encountered an error and was unable to complete your request.') }}
-{{ gettext('Look up a codename...') }}
+{{ gettext('Look up a passphrase...') }}
{% endblock %} diff --git a/securedrop/source_templates/footer.html b/securedrop/source_templates/footer.html index a91cfe7ea9..fb850068ad 100644 --- a/securedrop/source_templates/footer.html +++ b/securedrop/source_templates/footer.html @@ -3,9 +3,9 @@ {{ gettext('Powered by') }} SecureDrop {{ version }}. - \ No newline at end of file + diff --git a/securedrop/source_templates/generate.html b/securedrop/source_templates/generate.html deleted file mode 100644 index 7f2e381fd7..0000000000 --- a/securedrop/source_templates/generate.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends "base.html" %} -{% import 'utils.html' as utils %} - -{% block body %} -- {{ gettext('A codename in SecureDrop functions as both your username and your password.') }} -
-- {{ gettext('You will need this codename to log into our SecureDrop later:') }} -
- -{{ utils.codename(codename) }} - -{{ gettext('First time submitting to our SecureDrop? Start here.') }}
-