Skip to content

Commit

Permalink
Merge pull request #5 from piprate/recover-managed-account
Browse files Browse the repository at this point in the history
  • Loading branch information
sequel21 committed Apr 27, 2023
2 parents 807abe0 + 282ada6 commit edff500
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 16 deletions.
46 changes: 45 additions & 1 deletion model/account/recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type RecoveryRequest struct {
RecoveryCode string `json:"recoveryCode"`
VerificationSignature string `json:"signature"`
EncryptedPassword string `json:"encryptedPassword"`
ManagedCryptoKey string `json:"managedCryptoKey,omitempty"`
}

func (req *RecoveryRequest) Valid(recoveryPublicKey []byte) bool {
Expand All @@ -77,7 +78,12 @@ func (req *RecoveryRequest) Valid(recoveryPublicKey []byte) bool {
return ed25519.Verify(recoveryPublicKey, codeHash[:], sig)
}

func BuildRecoveryRequest(userID, recoveryCode string, privKey ed25519.PrivateKey, newPassphrase string) *RecoveryRequest {
// BuildRecoveryRequest creates a recovery request structure that can be sent to /v1/recover-account endpoint
// to regain access to a MetaLocker account.
// if cryptoKey is passed, the request will contain the account's managed crypto key in a cleartext form.
// This enables server side recovery for managed accounts for clients that don't have access to advanced
// cryptography.
func BuildRecoveryRequest(userID, recoveryCode string, privKey ed25519.PrivateKey, newPassphrase string, cryptoKey *model.AESKey) *RecoveryRequest {

codeHash := sha256.Sum256([]byte(recoveryCode))

Expand All @@ -90,6 +96,10 @@ func BuildRecoveryRequest(userID, recoveryCode string, privKey ed25519.PrivateKe
EncryptedPassword: HashUserPassword(newPassphrase),
}

if cryptoKey != nil {
req.ManagedCryptoKey = base64.StdEncoding.EncodeToString(GenerateManagedFromHostedKey(cryptoKey)[:])
}

return req
}

Expand Down Expand Up @@ -161,3 +171,37 @@ func Recover(acct *Account, cryptoKey *model.AESKey, newPassphrase string) (*Acc

return acct, nil
}

// RecoverManaged recovers a managed account for clients that don't have access to advanced cryptography.
func RecoverManaged(acct *Account, managedCryptoKey *model.AESKey, hashedNewPassphrase string) (*Account, error) {
var err error

if acct.AccessLevel != model.AccessLevelManaged {
return nil, fmt.Errorf("attempted to recoved account with access level %d as managed", acct.AccessLevel)
}

acct = acct.Copy()
acct.EncryptedPassword = hashedNewPassphrase

// update managed secret store

managedMasterPassphrase := []byte(hashedNewPassphrase)
newManagedMasterKey, err := newSecretKey(&managedMasterPassphrase, managedAccountConfig)
if err != nil {
log.Err(err).Msg("Failed to create master key")
return nil, err
}

// Encrypt the crypto keys with the associated master keys.
managedCryptoKeyEncrypted, err := newManagedMasterKey.Encrypt(managedCryptoKey.Bytes())
if err != nil {
return nil, err
}

acct.ManagedSecretStore.MasterKeyParams = base64.StdEncoding.EncodeToString(newManagedMasterKey.Marshal())
acct.ManagedSecretStore.EncryptedPayloadKey = base64.StdEncoding.EncodeToString(managedCryptoKeyEncrypted)

acct.State = StateActive

return acct, nil
}
91 changes: 88 additions & 3 deletions model/account/recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import (
func TestBuildAccountRecoveryRequest(t *testing.T) {
userID := "test@example.com"
recoveryCode := "53QPUKdVDjLEbxZA3BZWT7oLpsD1VjqGA7XnN3T21vcV"
newPassphrase := "new_password"
newPassphrase := "new_password" //nolint:goconst
privKeyBytes := base58.Decode("ucHoMKY1EVgGrEMg3aQejMDQvq6hrLcxSZ27eEvK3V3iPv4nxukQ7eLyMK4jGmjkRZpueFmChXNsEV3eawvYbHc")
var privKey ed25519.PrivateKey = privKeyBytes
req := BuildRecoveryRequest(userID, recoveryCode, privKey, newPassphrase)
req := BuildRecoveryRequest(userID, recoveryCode, privKey, newPassphrase, nil)

actualBytes, _ := jsonw.Marshal(req)

Expand Down Expand Up @@ -70,6 +70,47 @@ func TestGenerateMasterRecoveryKeyPair(t *testing.T) {
ld.PrintDocument("privateKey", base58.Encode(privateKey))
}

func TestFirstLevelRecoveryProcedure(t *testing.T) {
env := testbase.SetUpTestEnvironment(t)
defer func() { _ = env.Close() }()

// User registration

acct := &Account{
Email: "test@example.com",
Name: "Tester",
AccessLevel: model.AccessLevelManaged,
}

genResp, err := GenerateAccount(
acct,
WithPassphraseAuth("pass"))
require.NoError(t, err)

acct = genResp.Account
recoveryPhrase := genResp.RecoveryPhrase

// Account recovery

cryptoKey, _, _, err := GenerateKeysFromRecoveryPhrase(recoveryPhrase)
require.NoError(t, err)

newPassphrase := "new_password"

acct.EncryptedPassword = HashUserPassword(newPassphrase)

recoveredAcct, err := Recover(acct, cryptoKey, newPassphrase)
require.NoError(t, err)

managedKey, err := recoveredAcct.ExtractManagedKey(HashUserPassword(newPassphrase))
require.NoError(t, err)

dw := env.CreateDataWallet(t, recoveredAcct)

err = dw.UnlockAsManaged(managedKey)
require.NoError(t, err)
}

func TestSecondLevelRecoveryProcedure(t *testing.T) {
env := testbase.SetUpTestEnvironment(t)
defer func() { _ = env.Close() }()
Expand Down Expand Up @@ -114,7 +155,7 @@ func TestSecondLevelRecoveryProcedure(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, ed25519.PublicKey(recPubKey), privKey.Public())

req := BuildRecoveryRequest(acct.Email, recoveryCode, privKey, newPassphrase)
req := BuildRecoveryRequest(acct.Email, recoveryCode, privKey, newPassphrase, nil)

require.True(t, req.Valid(recPubKey))

Expand All @@ -140,3 +181,47 @@ func TestSecondLevelRecoveryProcedure(t *testing.T) {
err = dw.UnlockAsManaged(managedKey)
require.NoError(t, err)
}

func TestRecoverManaged(t *testing.T) {
env := testbase.SetUpTestEnvironment(t)
defer func() { _ = env.Close() }()

// User registration

acct := &Account{
Email: "test@example.com",
Name: "Tester",
AccessLevel: model.AccessLevelManaged,
}

genResp, err := GenerateAccount(
acct,
WithPassphraseAuth("pass"))
require.NoError(t, err)

acct = genResp.Account
recoveryPhrase := genResp.RecoveryPhrase

// Account recovery

cryptoKey, _, _, err := GenerateKeysFromRecoveryPhrase(recoveryPhrase)
require.NoError(t, err)

newPassphrase := "new_password"

managedCryptoKey := GenerateManagedFromHostedKey(cryptoKey)
hashedNewPassphrase := HashUserPassword(newPassphrase)

acct.EncryptedPassword = hashedNewPassphrase

recoveredAcct, err := RecoverManaged(acct, managedCryptoKey, hashedNewPassphrase)
require.NoError(t, err)

managedKey, err := recoveredAcct.ExtractManagedKey(hashedNewPassphrase)
require.NoError(t, err)

dw := env.CreateDataWallet(t, recoveredAcct)

err = dw.UnlockAsManaged(managedKey)
require.NoError(t, err)
}
52 changes: 44 additions & 8 deletions node/api/recover.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import (
"encoding/base64"
"errors"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/piprate/metalocker/model"
"github.com/piprate/metalocker/model/account"
"github.com/piprate/metalocker/sdk/apibase"
"github.com/piprate/metalocker/storage"
Expand All @@ -40,6 +42,11 @@ func GetRecoveryCodeHandler(identityBackend storage.IdentityBackend) gin.Handler
return func(c *gin.Context) {
email := c.Query("email")

if strings.Contains(email, "@") {
// if the username is an email, transform to lower case
email = strings.ToLower(email)
}

acct, err := identityBackend.GetAccount(email)
if err != nil {
log.Err(err).Msg("Error when retrieving account details")
Expand Down Expand Up @@ -89,6 +96,11 @@ func RecoverAccountHandler(identityBackend storage.IdentityBackend) gin.HandlerF
return
}

if strings.Contains(req.UserID, "@") {
// if the user id is an email, transform to lower case
req.UserID = strings.ToLower(req.UserID)
}

rc, err := identityBackend.GetRecoveryCode(req.RecoveryCode)
if err != nil {
if errors.Is(err, storage.ErrRecoveryCodeNotFound) {
Expand Down Expand Up @@ -130,7 +142,7 @@ func RecoverAccountHandler(identityBackend storage.IdentityBackend) gin.HandlerF
recPubKey, err := base64.StdEncoding.DecodeString(acct.RecoveryPublicKey)
if err != nil {
log.Err(err).Msg("Error when decoding recovery public key")
apibase.AbortWithError(c, http.StatusInternalServerError, "Bad recovery key")
apibase.AbortWithError(c, http.StatusBadRequest, "Bad recovery key")
return
}
if !req.Valid(recPubKey) {
Expand All @@ -139,20 +151,44 @@ func RecoverAccountHandler(identityBackend storage.IdentityBackend) gin.HandlerF
return
}

// We update the password to enable the user to log in and update the account properly,
// including internal secrets. Until then, the recorded password will be out of sync with the secrets.
// This is ok because we consider the password to be irretrievably lost.
acct.EncryptedPassword = req.EncryptedPassword
if req.ManagedCryptoKey != "" {
// perform full managed account recovery. The account will return to 'active' state

if acct.AccessLevel != model.AccessLevelManaged {
log.Error().Msg("Can't recover non-managed account using managed workflow")
apibase.AbortWithError(c, http.StatusBadRequest, "Can't use managed crypto key for non-managed account")
return
}

managedCryptoKeyBytes, err := base64.StdEncoding.DecodeString(req.ManagedCryptoKey)
if err != nil {
log.Err(err).Msg("Error when decoding managed crypto key")
apibase.AbortWithError(c, http.StatusBadRequest, "Bad managed crypto key")
return
}
managedCryptoKey := model.NewAESKey(managedCryptoKeyBytes)
acct, err = account.RecoverManaged(acct, managedCryptoKey, req.EncryptedPassword)
if err != nil {
log.Err(err).Msg("Error when recovering managed account")
apibase.AbortWithError(c, http.StatusInternalServerError, "Error when recovering managed account")
return
}
} else {
// We update the password to enable the user to log in and update the account properly,
// including internal secrets. Until then, the recorded password will be out of sync with the secrets.
// This is ok because we consider the password to be irretrievably lost.
acct.EncryptedPassword = req.EncryptedPassword

acct.State = account.StateRecovery
}

err = account.ReHashPassphrase(acct, nil)
if err != nil {
log.Err(err).Msg("Error when hashing password")
apibase.AbortWithError(c, http.StatusInternalServerError, "Bad account recovery request")
apibase.AbortWithError(c, http.StatusBadRequest, "Bad account recovery request")
return
}

acct.State = account.StateRecovery

err = identityBackend.UpdateAccount(acct)
if err != nil {
log.Err(err).Msg("Error updating account")
Expand Down
Loading

0 comments on commit edff500

Please sign in to comment.