diff --git a/actions.go b/actions.go index a3f8ab829e6047f6e94d1994969cdcb27a3b1f59..e2e98a80f523f46476bfadf8e8e5f19314708696 100644 --- a/actions.go +++ b/actions.go @@ -7,7 +7,6 @@ import ( "errors" "git.autistici.org/ai3/go-common/pwhash" - "git.autistici.org/id/keystore/userenckey" "github.com/pquerna/otp/totp" ) @@ -154,105 +153,176 @@ func (s *AccountService) ChangeUserPassword(ctx context.Context, tx TX, req *Cha } return s.withRequest(ctx, req, func(ctx context.Context) error { - // If the user does not yet have an encryption key, generate one now. - if !user.HasEncryptionKeys { - err = s.initializeUserEncryptionKeys(ctx, tx, user, req.Password) - } else { - err = s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password, UserEncryptionKeyMainID) + return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.CurPassword, req.Password) + }) +} + +// PasswordRecoveryRequest is the request type for AccountService.RecoverPassword(). +// It is not authenticated with SSO. +type PasswordRecoveryRequest struct { + Username string `json:"username"` + RecoveryPassword string `json:"recovery_password"` + Password string `json:"password"` +} + +// Validate the request. +func (r *PasswordRecoveryRequest) Validate(ctx context.Context, s *AccountService) error { + return s.passwordValidator(ctx, r.Password) +} + +// RecoverPassword lets users reset their password by providing +// secondary credentials, which we authenticate ourselves. +// +// TODO: call out to auth-server for secondary authentication? +func (s *AccountService) RecoverPassword(ctx context.Context, tx TX, req *PasswordRecoveryRequest) error { + user, err := s.getUser(ctx, tx, req.Username) + if err != nil { + return err + } + // TODO: authenticate with the secret recovery password. + ctx = context.WithValue(ctx, authUserCtxKey, req.Username) + + return s.withRequest(ctx, req, func(ctx context.Context) error { + // Change the user password (the recovery password does not change). + return s.changeUserPasswordAndUpdateEncryptionKeys(ctx, tx, user, req.RecoveryPassword, req.Password) + }) +} + +// ResetPasswordRequest is the request type for AccountService.ResetPassword(). +type ResetPasswordRequest struct { + RequestBase + Password string `json:"password"` +} + +// Validate the request. +func (r *ResetPasswordRequest) Validate(ctx context.Context, s *AccountService) error { + return s.passwordValidator(ctx, r.Password) +} + +// ResetPassword is an admin operation to forcefully reset the +// password for an account. The user will lose access to all stored +// email (because the encryption keys will be reset) and to 2FA. +func (s *AccountService) ResetPassword(ctx context.Context, tx TX, req *ResetPasswordRequest) error { + ctx, user, err := s.authorizeAdmin(ctx, tx, req.RequestBase) + if err != nil { + return err + } + + return s.withRequest(ctx, req, func(ctx context.Context) error { + // Disable 2FA. + if err := tx.DeleteUserTOTPSecret(ctx, user); err != nil { + return err + } + // Reset encryption keys and set the new password. + return s.changeUserPasswordAndResetEncryptionKeys(ctx, tx, user, req.Password) + }) +} + +// SetPasswordRecoveryHintRequest is the request type for +// AccountService.SetPasswordRecoveryHint(). +type SetPasswordRecoveryHintRequest struct { + PrivilegedRequestBase + Hint string `json:"recovery_hint"` + Response string `json:"recovery_response"` +} + +// Validate the request. +func (r *SetPasswordRecoveryHintRequest) Validate(ctx context.Context, s *AccountService) error { + return s.passwordValidator(ctx, r.Response) +} + +// SetPasswordRecoveryHint lets users set the password recovery hint +// and expected response (secondary password). +func (s *AccountService) SetPasswordRecoveryHint(ctx context.Context, tx TX, req *SetPasswordRecoveryHintRequest) error { + ctx, user, err := s.authorizeUserWithPassword(ctx, tx, req.PrivilegedRequestBase) + if err != nil { + return err + } + + return s.withRequest(ctx, req, func(ctx context.Context) error { + // If the encryption keys are not set up yet, use the + // CurPassword to initialize them. + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword) + if err != nil { + return err } + + keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyRecoveryID, req.Response) if err != nil { return err } - // Set the encrypted password attribute on the user (will set it on emails too). - encPass := pwhash.Encrypt(req.Password) - if err := tx.SetUserPassword(ctx, user, encPass); err != nil { + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { return newBackendError(err) } - return nil + + encResponse := pwhash.Encrypt(req.Response) + return tx.SetPasswordRecoveryHint(ctx, user, req.Hint, encResponse) }) } -// Initialize the user encryption key list, by creating a new "main" key -// encrypted with the given password (which must be the primary password for the -// user). -func (s *AccountService) initializeUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword string) error { - // Generate a new key pair. - pub, priv, err := userenckey.GenerateKey() +// Change the user password and update encryption keys, provided we +// have a password that we can use to decrypt them first. +func (s *AccountService) changeUserPasswordAndUpdateEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) error { + // If the user does not yet have an encryption key, generate one now. + var err error + + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, oldPassword, newPassword) if err != nil { return err } - // Encrypt the private key with the password. - enc, err := userenckey.Encrypt(priv, []byte(curPassword)) + keys, err = updateEncryptionKey(keys, decrypted, UserEncryptionKeyMainID, newPassword) if err != nil { return err } - keys := []*UserEncryptionKey{ - &UserEncryptionKey{ - ID: UserEncryptionKeyMainID, - Key: enc, - }, - } - // Update the backend database. if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { return newBackendError(err) } - if err := tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil { + + // Set the encrypted password attribute on the user (will set it on emails too). + encPass := pwhash.Encrypt(newPassword) + if err := tx.SetUserPassword(ctx, user, encPass); err != nil { return newBackendError(err) } - return nil } -// Re-encrypt the specified user encryption key with newPassword. For this -// operation to succeed, we must be able to decrypt one of the keys (not -// necessarily the same one) with curPassword. -func (s *AccountService) updateUserEncryptionKeys(ctx context.Context, tx TX, user *User, curPassword, newPassword, keyID string) error { - keys, err := tx.GetUserEncryptionKeys(ctx, user) +// Change the user password and reset all encryption keys. Existing email +// won't be readable anymore. Existing 2FA credentials will be deleted. +func (s *AccountService) changeUserPasswordAndResetEncryptionKeys(ctx context.Context, tx TX, user *User, newPassword string) error { + // Calling initialize will wipe the current keys and replace + // them with a new one. + keys, _, err := s.initializeEncryptionKeys(ctx, tx, user, newPassword) if err != nil { - return newBackendError(err) - } - keys, err = reEncryptUserKeys(keys, curPassword, newPassword, keyID) - if err != nil { - return newRequestError(err) + return err } + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { return newBackendError(err) } - return nil -} -// Decode the user encyrption key using the given password, then generate a new -// list of encryption keys by replacing the specified encryption key with one -// encrypted with the given password (or adding it if it does not exist). -func reEncryptUserKeys(keys []*UserEncryptionKey, curPassword, newPassword, keyID string) ([]*UserEncryptionKey, error) { - // userenckey.Decrypt wants a slice of []byte. - var rawKeys [][]byte - for _, k := range keys { - rawKeys = append(rawKeys, k.Key) - } - decrypted, err := userenckey.Decrypt(rawKeys, []byte(curPassword)) - if err != nil { - return nil, err + // Wipe all 2FA credentials that had corresponding encryption keys as well. + if err := s.wipeApplicationSpecificPasswords(ctx, tx, user); err != nil { + return err } - encrypted, err := userenckey.Encrypt(decrypted, []byte(newPassword)) - if err != nil { - return nil, err + + // Set the encrypted password attribute on the user (will set it on emails too). + encPass := pwhash.Encrypt(newPassword) + if err := tx.SetUserPassword(ctx, user, encPass); err != nil { + return newBackendError(err) } + return nil +} - var keysOut []*UserEncryptionKey - for _, key := range keys { - if key.ID != keyID { - keysOut = append(keysOut, key) +func (s *AccountService) wipeApplicationSpecificPasswords(ctx context.Context, tx TX, user *User) error { + for _, asp := range user.AppSpecificPasswords { + if err := tx.DeleteApplicationSpecificPassword(ctx, user, asp.ID); err != nil { + return err } } - keysOut = append(keysOut, &UserEncryptionKey{ - ID: keyID, - Key: encrypted, - }) - return keysOut, nil + return nil } // CreateApplicationSpecificPasswordRequest is the request type for @@ -310,10 +380,18 @@ func (s *AccountService) CreateApplicationSpecificPassword(ctx context.Context, // this password. The user encryption key IDs for ASPs all // have an 'asp_' prefix, followed by the ASP ID. if user.HasEncryptionKeys { + keys, decrypted, err := s.readOrInitializeEncryptionKeys(ctx, tx, user, req.CurPassword, req.CurPassword) + if err != nil { + return err + } keyID := "asp_" + asp.ID - if err := s.updateUserEncryptionKeys(ctx, tx, user, req.CurPassword, password, keyID); err != nil { + keys, err = updateEncryptionKey(keys, decrypted, keyID, password) + if err != nil { return err } + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { + return newBackendError(err) + } } resp.Password = password diff --git a/actions_test.go b/actions_test.go index 3c8de5e372e3bf026fd50c685cba814edfc379d2..e3a353263b6e13f8f3c895a3c2ee98a6f5408a30 100644 --- a/actions_test.go +++ b/actions_test.go @@ -40,6 +40,10 @@ func (b *fakeBackend) SetUserPassword(_ context.Context, user *User, password st return nil } +func (b *fakeBackend) SetPasswordRecoveryHint(_ context.Context, user *User, hint, response string) error { + return nil +} + func (b *fakeBackend) SetResourcePassword(_ context.Context, r *Resource, password string) error { return nil } @@ -252,19 +256,25 @@ func TestService_EncryptionKeys(t *testing.T) { user, _ := svc.getUser(ctx, tx, "testuser") - if err := svc.initializeUserEncryptionKeys(ctx, tx, user, "password"); err != nil { + // Set the keys to something. + keys, _, err := svc.initializeEncryptionKeys(ctx, tx, user, "password") + if err != nil { t.Fatal("init", err) } - if err := svc.updateUserEncryptionKeys(ctx, tx, user, "BADPASS", "new_password", UserEncryptionKeyMainID); err == nil { - t.Fatal("update with bad password did not fail") - } - if err := svc.updateUserEncryptionKeys(ctx, tx, user, "password", "new_password", UserEncryptionKeyMainID); err != nil { - t.Fatal("update", err) + if err := tx.SetUserEncryptionKeys(ctx, user, keys); err != nil { + t.Fatal("SetUserEncryptionKeys", err) } - if n := len(fb.encryptionKeys["testuser"]); n != 1 { t.Fatalf("found %d encryption keys, expected 1", n) } + + // Try to read (decrypt) them again using bad / good passwords. + if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "BADPASS", "new_password"); err == nil { + t.Fatal("read with bad password did not fail") + } + if _, _, err := svc.readOrInitializeEncryptionKeys(ctx, tx, user, "password", "new_password"); err != nil { + t.Fatal("readOrInitialize", err) + } } // Try adding aliases to the email resource. diff --git a/backend/model.go b/backend/model.go index 17d1ecbbee67a1fb2c9ffe62fda7f699a34ec78f..bf071309a87f588a07e31ebad9f7b12653fe46a2 100644 --- a/backend/model.go +++ b/backend/model.go @@ -14,7 +14,8 @@ const ( // Names of some well-known LDAP attributes. totpSecretLDAPAttr = "totpSecret" preferredLanguageLDAPAttr = "preferredLanguage" - recoverQuestionLDAPAttr = "recoverQuestion" + recoveryHintLDAPAttr = "recoverQuestion" + recoveryResponseLDAPAttr = "recoverAnswer" aspLDAPAttr = "appSpecificPassword" storagePublicKeyLDAPAttr = "storagePublicKey" storagePrivateKeyLDAPAttr = "storageEncryptedSecretKey" @@ -144,7 +145,7 @@ func (tx *backendTX) GetUser(ctx context.Context, username string) (*accountserv // object, a shortcoming of the legacy A/I database model. Set // them on the main User object. if isObjectClass(entry, "virtualMailUser") { - user.PasswordRecoveryHint = entry.GetAttributeValue(recoverQuestionLDAPAttr) + user.PasswordRecoveryHint = entry.GetAttributeValue(recoveryHintLDAPAttr) user.AppSpecificPasswords = getASPInfo(decodeAppSpecificPasswords(entry.GetAttributeValues(aspLDAPAttr))) user.HasEncryptionKeys = (entry.GetAttributeValue(storagePublicKeyLDAPAttr) != "") } @@ -171,6 +172,13 @@ func (tx *backendTX) SetUserPassword(ctx context.Context, user *accountserver.Us return nil } +func (tx *backendTX) SetPasswordRecoveryHint(ctx context.Context, user *accountserver.User, hint, response string) error { + dn := tx.getUserDN(user) + tx.setAttr(dn, recoveryHintLDAPAttr, hint) + tx.setAttr(dn, recoveryResponseLDAPAttr, response) + return nil +} + func (tx *backendTX) GetUserEncryptionKeys(ctx context.Context, user *accountserver.User) ([]*accountserver.UserEncryptionKey, error) { r := user.GetSingleResourceByType(accountserver.ResourceTypeEmail) dn, _ := tx.backend.resources.GetDN(r.ID) diff --git a/encryption.go b/encryption.go new file mode 100644 index 0000000000000000000000000000000000000000..ed6549499b7671f039c69e99323957e6ca1e5ab1 --- /dev/null +++ b/encryption.go @@ -0,0 +1,71 @@ +package accountserver + +import ( + "context" + + "git.autistici.org/id/keystore/userenckey" +) + +func keysToBytes(keys []*UserEncryptionKey) [][]byte { + var rawKeys [][]byte + for _, k := range keys { + rawKeys = append(rawKeys, k.Key) + } + return rawKeys +} + +func (s *AccountService) initializeEncryptionKeys(ctx context.Context, tx TX, user *User, password string) (keys []*UserEncryptionKey, decrypted []byte, err error) { + // Create new keys + pub, priv, err := userenckey.GenerateKey() + if err != nil { + return nil, nil, err + } + decrypted = priv + enc, err := userenckey.Encrypt(priv, []byte(password)) + if err != nil { + return nil, nil, err + } + keys = append(keys, &UserEncryptionKey{ + ID: UserEncryptionKeyMainID, + Key: enc, + }) + + // Save the new public key. + if err = tx.SetUserEncryptionPublicKey(ctx, user, pub); err != nil { + err = newBackendError(err) + } + return +} + +func (s *AccountService) readOrInitializeEncryptionKeys(ctx context.Context, tx TX, user *User, oldPassword, newPassword string) (keys []*UserEncryptionKey, decrypted []byte, err error) { + if user.HasEncryptionKeys { + // Fetch the encryption keys from the database. + keys, err = tx.GetUserEncryptionKeys(ctx, user) + if err != nil { + return + } + decrypted, err = userenckey.Decrypt(keysToBytes(keys), []byte(oldPassword)) + return + } + return s.initializeEncryptionKeys(ctx, tx, user, newPassword) +} + +func updateEncryptionKey(keys []*UserEncryptionKey, decrypted []byte, keyID, password string) ([]*UserEncryptionKey, error) { + encrypted, err := userenckey.Encrypt(decrypted, []byte(password)) + if err != nil { + return nil, err + } + + // Replace the key with id 'keyID' in the in-memory list. + var keysOut []*UserEncryptionKey + for _, key := range keys { + if key.ID != keyID { + keysOut = append(keysOut, key) + } + } + keysOut = append(keysOut, &UserEncryptionKey{ + ID: keyID, + Key: encrypted, + }) + return keysOut, nil +} diff --git a/server/server.go b/server/server.go index c620db6a6526bd45d18bf8305feb2670e4483863..f4243d4647d5cb25338fde96333ed88a7cdb9359 100644 --- a/server/server.go +++ b/server/server.go @@ -44,6 +44,20 @@ func (s *AccountServer) handleChangeUserPassword(tx as.TX, w http.ResponseWriter }) } +func (s *AccountServer) handleSetPasswordRecoveryHint(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.SetPasswordRecoveryHintRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.SetPasswordRecoveryHint(ctx, tx, &req) + }) +} + +func (s *AccountServer) handleRecoverPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { + var req as.PasswordRecoveryRequest + return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { + return &emptyResponse, s.service.RecoverPassword(ctx, tx, &req) + }) +} + func (s *AccountServer) handleCreateApplicationSpecificPassword(tx as.TX, w http.ResponseWriter, r *http.Request) (interface{}, error) { var req as.CreateApplicationSpecificPasswordRequest return handleJSON(w, r, &req, func(ctx context.Context) (interface{}, error) { @@ -122,6 +136,7 @@ func (s *AccountServer) Handler() http.Handler { h := http.NewServeMux() h.HandleFunc("/api/user/get", s.withTx(s.handleGetUser)) h.HandleFunc("/api/user/change_password", s.withTx(s.handleChangeUserPassword)) + h.HandleFunc("/api/user/set_password_recovery_hint", s.withTx(s.handleSetPasswordRecoveryHint)) h.HandleFunc("/api/user/enable_otp", s.withTx(s.handleEnableOTP)) h.HandleFunc("/api/user/disable_otp", s.withTx(s.handleDisableOTP)) h.HandleFunc("/api/app_specific_password/create", s.withTx(s.handleCreateApplicationSpecificPassword)) @@ -129,6 +144,7 @@ func (s *AccountServer) Handler() http.Handler { h.HandleFunc("/api/resource/enable", s.withTx(s.handleEnableResource)) h.HandleFunc("/api/resource/disable", s.withTx(s.handleDisableResource)) h.HandleFunc("/api/resource/move", s.withTx(s.handleMoveResource)) + h.HandleFunc("/api/recover_password", s.withTx(s.handleRecoverPassword)) return h } diff --git a/service.go b/service.go index 90410d33715a7fb12e9f937d0b9fac1e405896a8..8f5d65806557ef943e7bee5a354553cfdf17b2b6 100644 --- a/service.go +++ b/service.go @@ -42,6 +42,7 @@ type TX interface { GetUser(context.Context, string) (*User, error) SetUserPassword(context.Context, *User, string) error + SetPasswordRecoveryHint(context.Context, *User, string, string) error GetUserEncryptionKeys(context.Context, *User) ([]*UserEncryptionKey, error) SetUserEncryptionKeys(context.Context, *User, []*UserEncryptionKey) error SetUserEncryptionPublicKey(context.Context, *User, []byte) error