feat(BRIDGE-14): HV3 implementation - GUI & CLI; ownership verification & CAPTCHA are supported

This commit is contained in:
Atanas Janeshliev 2024-04-01 16:29:15 +02:00
parent c692c21b87
commit 9552e72ba8
24 changed files with 1853 additions and 1151 deletions

2
go.mod
View File

@ -7,7 +7,7 @@ require (
github.com/Masterminds/semver/v3 v3.2.0
github.com/ProtonMail/gluon v0.17.1-0.20240227105633-3734c7694bcd
github.com/ProtonMail/go-autostart v0.0.0-20210130080809-00ed301c8e9a
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436
github.com/ProtonMail/gopenpgp/v2 v2.7.4-proton
github.com/PuerkitoBio/goquery v1.8.1
github.com/abiosoft/ishell v2.0.0+incompatible

2
go.sum
View File

@ -40,6 +40,8 @@ github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ek
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9 h1:tcQpGQljNsZmfuA6L4hAzio8/AIx5OXcU2JUdyX/qxw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240226161523-ec58ed7ea4b9/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436 h1:ej+W9+UQlb2owkT5arCegmUFkicwesMyFHgBp/wwNg8=
github.com/ProtonMail/go-proton-api v0.4.1-0.20240405124415-8f966ca60436/go.mod h1:t+hb0BfkmZ9fpvzVRpHC7limoowym6ln/j0XL9a8DDw=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865 h1:EP1gnxLL5Z7xBSymE9nSTM27nRYINuvssAtDmG0suD8=
github.com/ProtonMail/go-smtp v0.0.0-20231109081432-2b3d50599865/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/ProtonMail/go-srp v0.0.7 h1:Sos3Qk+th4tQR64vsxGIxYpN3rdnG9Wf9K4ZloC1JrI=

View File

@ -28,6 +28,7 @@ import (
"github.com/ProtonMail/gluon/reporter"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/logging"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/services/imapservice"
@ -123,15 +124,20 @@ func (bridge *Bridge) QueryUserInfo(query string) (UserInfo, error) {
}
// LoginAuth begins the login process. It returns an authorized client that might need 2FA.
func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte) (*proton.Client, proton.Auth, error) {
func (bridge *Bridge) LoginAuth(ctx context.Context, username string, password []byte, hvDetails *proton.APIHVDetails) (*proton.Client, proton.Auth, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Authorizing user for login")
if username == "crash@bandicoot" {
panic("Your wish is my command.. I crash!")
}
client, auth, err := bridge.api.NewClientWithLogin(ctx, username, password)
client, auth, err := bridge.api.NewClientWithLoginWithHVToken(ctx, username, password, hvDetails)
if err != nil {
if hv.IsHvRequest(err) {
logUser.WithFields(logrus.Fields{"username": logging.Sensitive(username),
"loginError": err.Error()}).Info("Human Verification requested for login")
return nil, proton.Auth{}, err
}
return nil, proton.Auth{}, fmt.Errorf("failed to create new API client: %w", err)
}
@ -154,12 +160,13 @@ func (bridge *Bridge) LoginUser(
client *proton.Client,
auth proton.Auth,
keyPass []byte,
hvDetails *proton.APIHVDetails,
) (string, error) {
logUser.WithField("userID", auth.UserID).Info("Logging in authorized user")
userID, err := try.CatchVal(
func() (string, error) {
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass)
return bridge.loginUser(ctx, client, auth.UID, auth.RefreshToken, keyPass, hvDetails)
},
)
@ -192,7 +199,8 @@ func (bridge *Bridge) LoginFull(
) (string, error) {
logUser.WithField("username", logging.Sensitive(username)).Info("Performing full user login")
client, auth, err := bridge.LoginAuth(ctx, username, password)
// (atanas) the following may need to be modified once HV is merged (its used only for testing; and depends on whether we will test HV related logic)
client, auth, err := bridge.LoginAuth(ctx, username, password, nil)
if err != nil {
return "", fmt.Errorf("failed to begin login process: %w", err)
}
@ -225,7 +233,7 @@ func (bridge *Bridge) LoginFull(
keyPass = password
}
userID, err := bridge.LoginUser(ctx, client, auth, keyPass)
userID, err := bridge.LoginUser(ctx, client, auth, keyPass, nil)
if err != nil {
if deleteErr := client.AuthDelete(ctx); deleteErr != nil {
logUser.WithError(err).Error("Failed to delete auth")
@ -374,8 +382,8 @@ func (bridge *Bridge) SendBadEventUserFeedback(_ context.Context, userID string,
}, bridge.usersLock)
}
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte) (string, error) {
apiUser, err := client.GetUser(ctx)
func (bridge *Bridge) loginUser(ctx context.Context, client *proton.Client, authUID, authRef string, keyPass []byte, hvDetails *proton.APIHVDetails) (string, error) {
apiUser, err := client.GetUserWithHV(ctx, hvDetails)
if err != nil {
return "", fmt.Errorf("failed to get API user: %w", err)
}

View File

@ -29,13 +29,11 @@ using namespace bridgepp;
namespace {
QString const defaultKeychain = "defaultKeychain"; ///< The default keychain.
QString const HV_ERROR_TEMPLATE = "failed to create new API client: 422 POST https://mail-api.proton.me/auth/v4: CAPTCHA validation failed (Code=12087, Status=422)";
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
@ -349,6 +347,7 @@ Status GRPCService::ForceLauncher(ServerContext *, StringValue const *request, E
/// \return The status for the call.
//****************************************************************************************************************************************************
Status GRPCService::SetMainExecutable(ServerContext *, StringValue const *request, Empty *) {
resetHv();
app().log().debug(__FUNCTION__);
app().log().info(QString("SetMainExecutable: %1").arg(QString::fromStdString(request->value())));
return Status::OK;
@ -418,7 +417,19 @@ Status GRPCService::Login(ServerContext *, LoginRequest const *request, Empty *)
return Status::OK;
}
if (usersTab.nextUserHvRequired() && !hvWasRequested_ && previousHvUsername_ != QString::fromStdString(request->username())) {
hvWasRequested_ = true;
previousHvUsername_ = QString::fromStdString(request->username());
qtProxy_.sendDelayedEvent(newLoginHvRequestedEvent());
return Status::OK;
} else {
hvWasRequested_ = false;
previousHvUsername_ = "";
}
if (usersTab.nextUserHvError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::HV_ERROR, HV_ERROR_TEMPLATE));
return Status::OK;
}
if (usersTab.nextUserUsernamePasswordError()) {
qtProxy_.sendDelayedEvent(newLoginError(LoginErrorType::USERNAME_PASSWORD_ERROR, usersTab.usernamePasswordErrorMessage()));
return Status::OK;
@ -495,6 +506,7 @@ Status GRPCService::Login2Passwords(ServerContext *, LoginRequest const *request
//****************************************************************************************************************************************************
Status GRPCService::LoginAbort(ServerContext *, LoginAbortRequest const *request, Empty *) {
app().log().debug(__FUNCTION__);
this->resetHv();
loginUsername_ = QString();
return Status::OK;
}
@ -953,3 +965,11 @@ void GRPCService::finishLogin() {
qtProxy_.sendDelayedEvent(newLoginFinishedEvent(user->id(), alreadyExist));
}
//****************************************************************************************************************************************************
//
//****************************************************************************************************************************************************
void GRPCService::resetHv() {
hvWasRequested_ = false;
previousHvUsername_ = "";
}

View File

@ -106,6 +106,7 @@ public: // member functions.
private: // member functions
void finishLogin(); ///< finish the login procedure once the credentials have been validated.
void resetHv(); ///< Resets the human verification state.
private: // data member
mutable QMutex eventStreamMutex_; ///< Mutex used to access eventQueue_, isStreaming_ and shouldStopStreaming_;
@ -113,6 +114,8 @@ private: // data member
bool isStreaming_; ///< Is the gRPC stream running. Access protected by eventStreamMutex_;
bool eventStreamShouldStop_; ///< Should the stream be stopped? Access protected by eventStreamMutex
QString loginUsername_; ///< The username used for the current login procedure.
QString previousHvUsername_; ///< The previous username used for HV.
bool hvWasRequested_ {false}; ///< Was human verification requested.
GRPCQtProxy qtProxy_; ///< Qt Proxy used to send signals, as this class is not a QObject.
};

View File

@ -277,6 +277,22 @@ bridgepp::SPUser UsersTab::userWithUsernameOrEmail(QString const &username) {
}
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification request
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvRequired() const {
return ui_.checkHV3Required->isChecked();
}
//****************************************************************************************************************************************************
/// \return true if the next login attempt should trigger a human verification error
//****************************************************************************************************************************************************
bool UsersTab::nextUserHvError() const {
return ui_.checkHV3Error->isChecked();
}
//****************************************************************************************************************************************************
/// \return true iff the next login attempt should trigger a username/password error.
//****************************************************************************************************************************************************

View File

@ -39,6 +39,8 @@ public: // member functions.
UserTable &userTable(); ///< Returns a reference to the user table.
bridgepp::SPUser userWithID(QString const &userID); ///< Get the user with the given ID.
bridgepp::SPUser userWithUsernameOrEmail(QString const &username); ///< Get the user with the given username.
bool nextUserHvRequired() const; ///< Check if next user login should trigger HV
bool nextUserHvError() const; ///< Check if next user login should trigger HV error
bool nextUserUsernamePasswordError() const; ///< Check if next user login should trigger a username/password error.
bool nextUserFreeUserError() const; ///< Check if next user login should trigger a Free user error.
bool nextUserTFARequired() const; ///< Check if next user login should requires 2FA.

View File

@ -290,6 +290,20 @@
</item>
</layout>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Required">
<property name="text">
<string>HV3 required</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkHV3Error">
<property name="text">
<string>HV3 error</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="checkFreeUserError">
<property name="text">

View File

@ -810,6 +810,18 @@ void QMLBackend::login(QString const &username, QString const &password) const {
)
}
void QMLBackend::loginHv(QString const &username, QString const &password) const {
HANDLE_EXCEPTION(
if (username.compare("coco@bandicoot", Qt::CaseInsensitive) == 0) {
throw Exception("User requested bridge-gui to crash by trying to log as coco@bandicoot",
"This error exists for test purposes and should be ignored.", __func__, tailOfLatestBridgeLog(app().sessionID()));
}
app().grpc().loginHv(username, password);
)
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
@ -1334,6 +1346,8 @@ void QMLBackend::connectGrpcEvents() {
connect(client, &GRPCClient::login2PasswordErrorAbort, this, &QMLBackend::login2PasswordErrorAbort);
connect(client, &GRPCClient::loginFinished, this, &QMLBackend::onLoginFinished);
connect(client, &GRPCClient::loginAlreadyLoggedIn, this, &QMLBackend::onLoginAlreadyLoggedIn);
connect(client, &GRPCClient::loginHvRequested, this, &QMLBackend::loginHvRequested);
connect(client, &GRPCClient::loginHvError, this, &QMLBackend::loginHvError);
// update events
connect(client, &GRPCClient::updateManualError, this, &QMLBackend::updateManualError);

View File

@ -183,6 +183,7 @@ public slots: // slot for signals received from QML -> To be forwarded to Bridge
void changeColorScheme(QString const &scheme); ///< Slot for the change of the theme.
void setDiskCachePath(QUrl const &path) const; ///< Slot for the change of the disk cache path.
void login(QString const &username, QString const &password) const; ///< Slot for the login button (initial login).
void loginHv(QString const &username, QString const &password) const; ///< Slot for the login button (after HV challenge completed).
void login2FA(QString const &username, QString const &code) const; ///< Slot for the login button (2FA login).
void login2Password(QString const &username, QString const &password) const; ///< Slot for the login button (mailbox password login).
void loginAbort(QString const &username) const; ///< Slot for the login abort procedure.
@ -238,6 +239,8 @@ signals: // Signals received from the Go backend, to be forwarded to QML
void login2PasswordErrorAbort(QString const &errorMsg); ///< Signal for the 'login2PasswordErrorAbort' gRPC stream event.
void loginFinished(int index, bool wasSignedOut); ///< Signal for the 'loginFinished' gRPC stream event.
void loginAlreadyLoggedIn(int index); ///< Signal for the 'loginAlreadyLoggedIn' gRPC stream event.
void loginHvRequested(QString const &hvUrl); ///< Signal for the 'loginHvRequested' gRPC stream event.
void loginHvError(QString const &errorMsg); ///< Signal for the 'loginHvError' gRPC stream event.
void updateManualReady(QString const &version); ///< Signal for the 'updateManualReady' gRPC stream event.
void updateManualRestartNeeded(); ///< Signal for the 'updateManualRestartNeeded' gRPC stream event.
void updateManualError(); ///< Signal for the 'updateManualError' gRPC stream event.

View File

@ -60,7 +60,7 @@ QtObject {
target: Backend
}
}
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion]
property var all: [root.noInternet, root.imapPortStartupError, root.smtpPortStartupError, root.imapPortChangeError, root.smtpPortChangeError, root.imapConnectionModeChangeError, root.smtpConnectionModeChangeError, root.updateManualReady, root.updateManualRestartNeeded, root.updateManualError, root.updateForce, root.updateForceError, root.updateSilentRestartNeeded, root.updateSilentError, root.updateIsLatestVersion, root.loginConnectionError, root.onlyPaidUsers, root.alreadyLoggedIn, root.enableBeta, root.bugReportSendSuccess, root.bugReportSendError, root.bugReportSendFallback, root.cacheCantMove, root.cacheLocationChangeSuccess, root.enableSplitMode, root.resetBridge, root.changeAllMailVisibility, root.deleteAccount, root.noKeychain, root.rebuildKeychain, root.addressChanged, root.apiCertIssue, root.userBadEvent, root.imapLoginWhileSignedOut, root.genericError, root.genericQuestion, root.hvErrorEvent]
property Notification alreadyLoggedIn: Notification {
brief: qsTr("Already signed in")
description: qsTr("This account is already signed in.")
@ -1130,6 +1130,27 @@ QtObject {
target: Backend
}
}
property Notification hvErrorEvent: Notification {
group: Notifications.Group.Configuration
icon: "./icons/ic-exclamation-circle-filled.svg"
type: Notification.NotificationType.Danger
action: Action {
text: qsTr("OK")
onTriggered: {
root.hvErrorEvent.active = false;
}
}
Connections {
function onLoginHvError(errorMsg) {
root.hvErrorEvent.active = true;
root.hvErrorEvent.description = errorMsg;
}
target: Backend
}
}
signal askChangeAllMailVisibility(var isVisibleNow)
signal askDeleteAccount(var user)

View File

@ -20,12 +20,14 @@ FocusScope {
enum RootStack {
Login,
TOTP,
MailboxPassword
MailboxPassword,
HV
}
property alias currentIndex: stackLayout.currentIndex
property alias username: usernameTextField.text
property var wizard
property string hvLinkUrl: ""
signal loginAbort(string username, bool wasSignedOut)
@ -47,6 +49,14 @@ FocusScope {
passwordTextField.hidePassword();
secondPasswordTextField.hidePassword();
}
function resetViaHv() {
usernameTextField.enabled = false;
passwordTextField.enabled = false;
signInButton.loading = true;
secondPasswordButton.loading = false;
secondPasswordTextField.enabled = true;
totpLayout.reset();
}
StackLayout {
id: stackLayout
@ -124,6 +134,18 @@ FocusScope {
else
errorLabel.text = qsTr("Incorrect login credentials");
}
function onLoginHvRequested(hvUrl) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected loginHvRequested");
stackLayout.currentIndex = Login.RootStack.HV;
hvUsernameLabel.text = usernameTextField.text;
hvLinkUrl = hvUrl;
}
function onLoginHvError(_) {
console.assert(stackLayout.currentIndex === Login.RootStack.Login || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected onLoginHvInvalidTokenError");
stackLayout.currentIndex = Login.RootStack.Login;
root.resetViaHv();
root.reset()
}
target: Backend
}
@ -475,5 +497,112 @@ FocusScope {
}
}
}
Item {
id: hvLayout
ColumnLayout {
Layout.fillWidth: true
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: ProtonStyle.wizard_spacing_extra_large
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_small
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Human verification")
type: Label.LabelType.Title
wrapMode: Text.WordWrap
}
Label {
id: hvUsernameLabel
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
color: wizard.colorScheme.text_weak
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
type: Label.LabelType.Body
}
}
Label {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignHCenter
text: qsTr("Please open the following link in your favourite web browser to verify you are human.")
type: Label.LabelType.Body
wrapMode: Text.WordWrap
}
}
Label {
id: hvRequestedUrlText
type: Label.LabelType.Lead
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
colorScheme: wizard.colorScheme
horizontalAlignment: Text.AlignLeft
text: "<a href='" + hvLinkUrl + "'>" + hvLinkUrl.replace("&", "&amp;")+ "</a>"
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
Qt.openUrlExternally(hvLinkUrl);
}
}
}
ColumnLayout {
spacing: ProtonStyle.wizard_spacing_medium
Button {
id: hVContinueButton
Layout.fillWidth: true
colorScheme: wizard.colorScheme
text: qsTr("Continue")
function checkAndSignInHv() {
console.assert(stackLayout.currentIndex === Login.RootStack.HV || stackLayout.currentIndex === Login.RootStack.MailboxPassword, "Unexpected checkInAndSignInHv")
stackLayout.currentIndex = Login.RootStack.Login
usernameTextField.validate();
passwordTextField.validate();
if (usernameTextField.error || passwordTextField.error) {
return;
}
root.resetViaHv();
Backend.loginHv(usernameTextField.text, Qt.btoa(passwordTextField.text));
}
onClicked: {
checkAndSignInHv()
}
}
Button {
Layout.fillWidth: true
colorScheme: wizard.colorScheme
secondary: true
secondaryIsOpaque: true
text: qsTr("Cancel")
onClicked: {
root.abort();
}
}
}
}
}
}
}

View File

@ -302,6 +302,18 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username) {
}
//****************************************************************************************************************************************************
/// \return The event.
//****************************************************************************************************************************************************
SPStreamEvent newLoginHvRequestedEvent() {
auto event = new ::grpc::LoginHvRequestedEvent;
event->set_hvurl("https://verify.proton.me/?methods=captcha&token=SOME_RANDOM_TOKEN");
auto loginEvent = new grpc::LoginEvent;
loginEvent->set_allocated_hvrequested(event);
return wrapLoginEvent(loginEvent);
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \return The event.

View File

@ -48,6 +48,7 @@ SPStreamEvent newLoginTfaRequestedEvent(QString const &username); ///< Create a
SPStreamEvent newLoginTwoPasswordsRequestedEvent(QString const &username); ///< Create a new LoginTwoPasswordsRequestedEvent event.
SPStreamEvent newLoginFinishedEvent(QString const &userID, bool wasSignedOut); ///< Create a new LoginFinishedEvent event.
SPStreamEvent newLoginAlreadyLoggedInEvent(QString const &userID); ///< Create a new LoginAlreadyLoggedInEvent event.
SPStreamEvent newLoginHvRequestedEvent(); ///< Create a new LoginHvRequestedEvent
// Update related events
SPStreamEvent newUpdateErrorEvent(grpc::UpdateErrorType errorType); ///< Create a new UpdateErrorEvent event.

View File

@ -23,7 +23,6 @@
#include "../ProcessMonitor.h"
#include "../Log/LogUtils.h"
using namespace google::protobuf;
using namespace grpc;
@ -607,6 +606,20 @@ grpc::Status GRPCClient::login(QString const &username, QString const &password)
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \param[in] password The password.
/// \return the status for the gRPC call.
//****************************************************************************************************************************************************
grpc::Status GRPCClient::loginHv(QString const &username, QString const &password) {
LoginRequest request;
request.set_username(username.toStdString());
request.set_password(password.toStdString());
request.set_usehvdetails(true);
return this->logGRPCCallStatus(stub_->Login(this->clientContext().get(), request, &empty), __FUNCTION__);
}
//****************************************************************************************************************************************************
/// \param[in] username The username.
/// \param[in] code The The 2FA code.
@ -1221,6 +1234,9 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
case TWO_PASSWORDS_ABORT:
emit login2PasswordErrorAbort(QString::fromStdString(error.message()));
break;
case HV_ERROR:
emit loginHvError(QString::fromStdString(error.message()));
break;
default:
this->logError("Unknown login error event received.");
break;
@ -1245,6 +1261,10 @@ void GRPCClient::processLoginEvent(LoginEvent const &event) {
this->logTrace("Login event received: AlreadyLoggedIn.");
emit loginAlreadyLoggedIn(QString::fromStdString(event.finished().userid()));
break;
case LoginEvent::kHvRequested:
this->logTrace("Login event Received: HvRequested");
emit loginHvRequested(QString::fromStdString(event.hvrequested().hvurl()));
break;
default:
this->logError("Unknown Login event received.");
break;

View File

@ -155,6 +155,7 @@ public: // login related calls
grpc::Status login2FA(QString const &username, QString const &code); ///< Performs the 'login2FA' call.
grpc::Status login2Passwords(QString const &username, QString const &password); ///< Performs the 'login2Passwords' call.
grpc::Status loginAbort(QString const &username); ///< Performs the 'loginAbort' call.
grpc::Status loginHv(QString const &username, QString const &password); ///< Performs the 'login' call with additional useHv flag
signals:
void loginUsernamePasswordError(QString const &errMsg);
@ -168,6 +169,8 @@ signals:
void login2PasswordErrorAbort(QString const &errMsg);
void loginFinished(QString const &userID, bool wasSignedOut);
void loginAlreadyLoggedIn(QString const &userID);
void loginHvRequested(QString const &hvUrl);
void loginHvError(QString const &errMsg);
public: // Update related calls
grpc::Status checkUpdate();

View File

@ -19,12 +19,14 @@ package cli
import (
"context"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/abiosoft/ishell"
)
@ -116,6 +118,13 @@ func (f *frontendCLI) showAccountAddressInfo(user bridge.UserInfo, address strin
f.Println("")
}
func (f *frontendCLI) promptHvURL(details *proton.APIHVDetails) {
hvURL := hv.FormatHvURL(details)
fmt.Print("\nHuman Verification requested. Please open the URL below in a browser and press ENTER when the challenge has been completed.\n\n", hvURL+"\n")
f.ReadLine()
fmt.Println("Authenticating ...")
}
func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.ShowPrompt(false)
defer f.ShowPrompt(true)
@ -144,7 +153,19 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
f.Println("Authenticating ... ")
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password))
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), nil)
var hvDetails *proton.APIHVDetails
hvDetails, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil || hvDetails != nil {
if hvErr != nil {
f.printAndLogError("Cannot login", hvErr)
return
}
f.promptHvURL(hvDetails)
client, auth, err = f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
}
if err != nil {
f.printAndLogError("Cannot login: ", err)
return
@ -175,7 +196,55 @@ func (f *frontendCLI) loginAccount(c *ishell.Context) {
keyPass = []byte(password)
}
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass)
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
hvDetails, hvErr = hv.VerifyAndExtractHvRequest(err)
if hvDetails != nil || hvErr != nil {
if hvErr != nil {
f.printAndLogError("Cannot login: ", hvErr)
return
}
f.loginAccountHv(c, loginName, password, keyPass, hvDetails)
return
}
if err != nil {
f.processAPIError(err)
return
}
user, err := f.bridge.GetUserInfo(userID)
if err != nil {
panic(err)
}
f.Printf("Account %s was added successfully.\n", bold(user.Username))
}
func (f *frontendCLI) loginAccountHv(c *ishell.Context, loginName string, password string, keyPass []byte, hvDetails *proton.APIHVDetails) {
f.promptHvURL(hvDetails)
client, auth, err := f.bridge.LoginAuth(context.Background(), loginName, []byte(password), hvDetails)
if err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
if auth.TwoFA.Enabled&proton.HasTOTP != 0 {
code := f.readStringInAttempts("Two factor code", c.ReadLine, isNotEmpty)
if code == "" {
f.printAndLogError("Cannot login: need two factor code")
return
}
if err := client.Auth2FA(context.Background(), proton.Auth2FAReq{TwoFactorCode: code}); err != nil {
f.printAndLogError("Cannot login: ", err)
return
}
}
userID, err := f.bridge.LoginUser(context.Background(), client, auth, keyPass, hvDetails)
if err != nil {
f.processAPIError(err)
return

File diff suppressed because it is too large Load Diff

View File

@ -168,6 +168,7 @@ message ReportBugRequest {
message LoginRequest {
string username = 1;
bytes password = 2;
optional bool useHvDetails = 3;
}
message LoginAbortRequest {
@ -308,6 +309,7 @@ message LoginEvent {
LoginTwoPasswordsRequestedEvent twoPasswordRequested = 3;
LoginFinishedEvent finished = 4;
LoginFinishedEvent alreadyLoggedIn = 5;
LoginHvRequestedEvent hvRequested = 6;
}
}
@ -319,6 +321,7 @@ enum LoginErrorType {
TFA_ABORT = 4;
TWO_PASSWORDS_ERROR = 5;
TWO_PASSWORDS_ABORT = 6;
HV_ERROR = 7;
}
message LoginErrorEvent {
@ -339,6 +342,10 @@ message LoginFinishedEvent {
bool wasSignedOut = 2;
}
message LoginHvRequestedEvent {
string hvUrl = 1;
}
//**********************************************************
// Update related events
//**********************************************************

View File

@ -100,6 +100,10 @@ func NewLoginAlreadyLoggedInEvent(userID string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_AlreadyLoggedIn{AlreadyLoggedIn: &LoginFinishedEvent{UserID: userID}}})
}
func NewLoginHvRequestedEvent(hvChallengeURL string) *StreamEvent {
return loginEvent(&LoginEvent{Event: &LoginEvent_HvRequested{HvRequested: &LoginHvRequestedEvent{HvUrl: hvChallengeURL}}})
}
func NewUpdateErrorEvent(errorType UpdateErrorType) *StreamEvent {
return updateEvent(&UpdateEvent{Event: &UpdateEvent_Error{Error: &UpdateErrorEvent{Type: errorType}}})
}

View File

@ -38,6 +38,7 @@ import (
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/certs"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/hv"
"github.com/ProtonMail/proton-bridge/v3/internal/safe"
"github.com/ProtonMail/proton-bridge/v3/internal/service"
"github.com/ProtonMail/proton-bridge/v3/internal/updater"
@ -95,6 +96,9 @@ type Service struct { // nolint:structcheck
parentPID int
parentPIDDoneCh chan struct{}
showOnStartup bool
hvDetails *proton.APIHVDetails
useHvDetails bool
}
// NewService returns a new instance of the service.
@ -412,6 +416,7 @@ func (s *Service) loginClean() {
s.password[i] = '\x00'
}
s.password = s.password[0:0]
s.useHvDetails = false
}
func (s *Service) finishLogin() {
@ -424,6 +429,11 @@ func (s *Service) finishLogin() {
wasSignedOut := s.bridge.HasUser(s.auth.UserID)
var hvDetails *proton.APIHVDetails
if s.useHvDetails {
hvDetails = s.hvDetails
}
if len(s.password) == 0 || s.auth.UID == "" || s.authClient == nil {
s.log.
WithField("hasPass", len(s.password) != 0).
@ -439,8 +449,20 @@ func (s *Service) finishLogin() {
defer done()
ctx := context.Background()
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password)
userID, err := s.bridge.LoginUser(ctx, s.authClient, s.auth, s.password, hvDetails)
if err != nil {
if hv.IsHvRequest(err) {
s.handleHvRequest(err)
performCleanup = false
return
}
if apiErr := new(proton.APIError); errors.As(err, &apiErr) && apiErr.Code == proton.HumanValidationInvalidToken {
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
return
}
s.log.WithError(err).Errorf("Finish login failed")
s.twoPasswordAttemptCount++
errType := LoginErrorType_TWO_PASSWORDS_ABORT
@ -614,6 +636,18 @@ func (s *Service) monitorParentPID() {
}
}
func (s *Service) handleHvRequest(err error) {
hvDet, hvErr := hv.VerifyAndExtractHvRequest(err)
if hvErr != nil {
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, hvErr.Error()))
return
}
s.hvDetails = hvDet
hvChallengeURL := hv.FormatHvURL(hvDet)
_ = s.SendEvent(NewLoginHvRequestedEvent(hvChallengeURL))
}
// computeFileSocketPath Return an available path for a socket file in the temp folder.
func computeFileSocketPath() (string, error) {
tempPath := os.TempDir()

View File

@ -396,6 +396,14 @@ func (s *Service) RequestKnowledgeBaseSuggestions(_ context.Context, userInput *
func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty, error) {
s.log.WithField("username", login.Username).Debug("Login")
var hvDetails *proton.APIHVDetails
if login.UseHvDetails != nil && *login.UseHvDetails {
hvDetails = s.hvDetails
s.useHvDetails = true
} else {
s.useHvDetails = false
}
go func() {
defer async.HandlePanic(s.panicHandler)
@ -407,7 +415,7 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
return
}
client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password)
client, auth, err := s.bridge.LoginAuth(context.Background(), login.Username, password, hvDetails)
if err != nil {
defer s.loginClean()
@ -421,6 +429,13 @@ func (s *Service) Login(_ context.Context, login *LoginRequest) (*emptypb.Empty,
case proton.PaidPlanRequired:
_ = s.SendEvent(NewLoginError(LoginErrorType_FREE_USER, ""))
case proton.HumanVerificationRequired:
s.handleHvRequest(apiErr)
case proton.HumanValidationInvalidToken:
s.hvDetails = nil
_ = s.SendEvent(NewLoginError(LoginErrorType_HV_ERROR, err.Error()))
default:
_ = s.SendEvent(NewLoginError(LoginErrorType_USERNAME_PASSWORD_ERROR, err.Error()))
}
@ -522,7 +537,6 @@ func (s *Service) LoginAbort(_ context.Context, loginAbort *LoginAbortRequest) (
go func() {
defer async.HandlePanic(s.panicHandler)
s.loginAbort()
}()

62
internal/hv/hv.go Normal file
View File

@ -0,0 +1,62 @@
// Copyright (c) 2024 Proton AG
// This file is part of Proton Mail Bridge.
// Proton Mail Bridge 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.
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package hv
import (
"errors"
"fmt"
"strings"
"github.com/ProtonMail/go-proton-api"
)
// VerifyAndExtractHvRequest expects an error request as input
// determines whether the given error is a Proton human verification request; if it isn't then it returns -> nil, nil (no details, no error)
// if it is a HV req. then it tries to parse the json data and verify that the captcha method is included; if either fails -> nil, err
// if the HV request was successfully decoded and the preconditions were met it returns the hv details -> hvDetails, nil.
func VerifyAndExtractHvRequest(err error) (*proton.APIHVDetails, error) {
if err == nil {
return nil, nil
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
hvDetails, hvErr := protonErr.GetHVDetails()
if hvErr != nil {
return nil, fmt.Errorf("received HV request, but can't decode HV details")
}
return hvDetails, nil
}
return nil, nil
}
func IsHvRequest(err error) bool {
if err == nil {
return false
}
var protonErr *proton.APIError
if errors.As(err, &protonErr) && protonErr.IsHVError() {
return true
}
return false
}
func FormatHvURL(details *proton.APIHVDetails) string {
return fmt.Sprintf("https://verify.proton.me/?methods=%v&token=%v",
strings.Join(details.Methods, ","),
details.Token)
}

144
internal/hv/hv_test.go Normal file
View File

@ -0,0 +1,144 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.Bridge.
//
// Proton Mail Bridge 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.
//
// Proton Mail Bridge 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 Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package hv
import (
"encoding/json"
"fmt"
"testing"
"github.com/ProtonMail/go-proton-api"
"github.com/stretchr/testify/require"
)
func TestVerifyAndExtractHvRequest(t *testing.T) {
det1, _ := json.Marshal("test")
det2, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email"}, Token: "test"})
det3, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha"}, Token: "test"})
det4, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"ownership-email", "test"}, Token: "test"})
det5, _ := json.Marshal(proton.APIHVDetails{Methods: []string{"captcha", "ownership-email"}, Token: "test"})
tests := []struct {
err error
hasHvDetails bool
hasErr bool
}{
{err: nil,
hasHvDetails: false,
hasErr: false},
{err: fmt.Errorf("test"),
hasHvDetails: false,
hasErr: false},
{err: new(proton.APIError),
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 429},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Status: 9001},
hasHvDetails: false,
hasErr: false},
{err: &proton.APIError{Code: 9001},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det1},
hasHvDetails: false,
hasErr: true},
{err: &proton.APIError{Code: 9001, Details: det2},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det3},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det4},
hasHvDetails: true,
hasErr: false},
{err: &proton.APIError{Code: 9001, Details: det5},
hasHvDetails: true,
hasErr: false},
}
for _, test := range tests {
hvDetails, err := VerifyAndExtractHvRequest(test.err)
hasHv := hvDetails != nil
hasErr := err != nil
require.True(t, hasHv == test.hasHvDetails && hasErr == test.hasErr)
}
}
func TestIsHvRequest(t *testing.T) {
tests := []struct {
err error
result bool
}{
{
err: nil,
result: false,
},
{
err: fmt.Errorf("test"),
result: false,
},
{
err: new(proton.APIError),
result: false,
},
{
err: &proton.APIError{Status: 429},
result: false,
},
{
err: &proton.APIError{Status: 9001},
result: false,
},
{
err: &proton.APIError{Code: 9001},
result: true,
},
}
for _, test := range tests {
isHvRequest := IsHvRequest(test.err)
require.Equal(t, test.result, isHvRequest)
}
}
func TestFormatHvURL(t *testing.T) {
tests := []struct {
details *proton.APIHVDetails
result string
}{
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: "test"},
result: "https://verify.proton.me/?methods=test&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{""}, Token: "test"},
result: "https://verify.proton.me/?methods=&token=test",
},
{
details: &proton.APIHVDetails{Methods: []string{"test"}, Token: ""},
result: "https://verify.proton.me/?methods=test&token=",
},
}
for _, el := range tests {
result := FormatHvURL(el.details)
require.Equal(t, el.result, result)
}
}