Protection with 2-man-rule
In the previous step, we had achieved a satisfying version from a security point of view.
Nevertheless, this version still has a user experience drawback: if the user loses their password, they can no longer recover their Seald identity, and thus lose the ability to decrypt their data.
In this step, we will replace the password-protection with the 2-man rule protection method. This method requires a challenge to be sent to the user. Initially, this challenge will be sent by email, then we will see how to send it by SMS.
The branch on which this step is based is 3-localstorage
, the result with the email challenge is 4-two-man-rule
. The implementation with the SMS challenge is 5-two-man-rule-sms
.
Explanation
As explained in the dedicated guide, the purpose of the 2-man-rule protection mode is to give users "the right" to forget their password. The downside is that this mode is not strictly end-to-end.
It works by "splitting" the identity in two, and placing one half - called twoManRuleKey
- on the backend subject to authentication, and the other half - called encryptedIdentity
- on Seald SDK Key Storage (SSKS) subject to independent email confirmation authentication.
To implement 2-man-rule protection you need to:
- modify the backend to:
- store a
twoManRuleKey
for each user in database ; - expose an authenticated API point to generate, re-engage and retrieve the
twoManRuleKey
, and for the frontend to interact with SSKS to store & retrieve the second half of theencryptedIdentity
key;
- store a
- modify the frontend to:
- replace the password protection plugin with the
@seald-io/sdk-plugin-ssks-2mr
plugin for 2-man-rule protection; - manage the independent authentication step by SSKS.
- replace the password protection plugin with the
TIP
For faster execution, you can use a rawTwoManRuleKey
instead of the twoManRuleKey
. For more details, see the "Using a raw TwoManRule Key" paragraph of the identities guide.
Modifying the backend
We will do the following:
- modify the
User
model to be able to store atwoManRuleKey
for each user in database; - add variables in the configuration file to authenticate the backend connection to SSKS;
- expose a
sendChallenge2MR
API point to generate/retrieve thetwoManRuleKey
and create a session between the frontend and SSKS to drop/retrieve the second half of theencryptedIdentity
key.
Modification of the User
model
In the backend/models.js
file, we add the twoManRuleKey
property to the User
model initialization:
const { Sequelize, DataTypes, Model, ValidationError } = require('sequelize')
/* ... */
class User extends Model { /* ... */ }
/* ... */
User.init(
{
/* ... */
twoManRuleKey: {
type: DataTypes.STRING
}
/* ... */
}
)
/* ... */
Configuration file
We add two variables to the backend configuration file:
Name | Default value | Required | Description |
---|---|---|---|
KEY_STORAGE_URL | undefined | Yes | URL of SSKS, available on the Seald administration panel |
KEY_STORAGE_URL_APP_KEY | undefined | Yes | API key ssksAppKey , available on the Seald administration panel |
To learn where you can get these, you can check the paragraph about it in the 2-man-rule integration guide.
API point sendChallenge2MR
This API point does in one request the two things described in the dedicated integration guide:
- it generates & stores if needed, then returns the
twoManRuleKey
associated with a user in your application; - it uses the SSKS API for the user to authenticate to SSKS.
In the backend/routes/account.js
file, we will add an authenticated POST route '/sendChallenge2MR'
.
To interact with SSKS we will use node-fetch
, so we need to install it:
cd backend
npm install node-fetch
Then import it in the file backend/routes/account.js
:
import fetch from 'node-fetch'
Finally, we create the /sendChallenge2MR
route which will:
- instruct SSKS, if the user authentication is necessary, to send an email in the format of the
template
to the user's email address that will contain achallenge
(valid for 6h) that the user will return to SSKS in the session identified by thetwoManRuleSessionId
; - generate, store & return the
twoManRuleKey
to the user; - inform the frontend if an authentication is necessary to store the identity on SSKS.
// We create the authenticated route `/sendChallenge2MR`
router.post('/sendChallenge2MR',
isAuthenticatedMiddleware,
async (req, res, next) => {
try {
// we retrieve the user from the database using their ID in session
const user = await User.findByPk(req.session.user.id)
// if we don't have a `twoManRuleKey` for this user, it means that we have
// to create a user on SSKS
const createUser = user.twoManRuleKey == null
// we make a POST on SSKS_URL/tmr/back/challenge_send/
const sendChallengeResult = await fetch(
`${settings.KEY_STORAGE_URL}tmr/back/challenge_send/`,
{
method: 'POST',
credentials: 'omit',
headers: {
'Content-Type': 'application/json',
'X-SEALD-APPID': settings.APP_ID,
'X-SEALD-APIKEY': settings.KEY_STORAGE_APP_KEY
},
body: JSON.stringify({
create_user: createUser, // boolean determined above
user_id: user.id, // unique identifier for the user in this app
auth_factor: {
type: 'EM',
value: user.emailAddress // email address of the user
},
template: '<html><body>Challenge: $$CHALLENGE$$</body></html>' // email template to use
})
}
)
// handling the error case
if (!sendChallengeResult.ok) {
const responseText = await sendChallengeResult.text()
throw new Error(`Error in SSKS createUser: ${sendChallengeResult.status} ${responseText}`)
}
// retrieval of the session id which will be used by the user
const { session_id: twoManRuleSessionId, must_authenticate: mustAuthenticate } = await sendChallengeResult.json()
// if there is no `twoManRuleKey` stored yet, we generate a new one
if (createUser) {
user.twoManRuleKey = (await randomBytes(64)).toString('base64')
await user.save()
}
// response to the user
res.status(200).json({
twoManRuleSessionId,
twoManRuleKey: user.twoManRuleKey,
mustAuthenticate
})
} catch (error) {
next(error)
}
})
TIP
It is important to give a valid email address or phone number, as well as a template containing $$CHALLENGE$$
in a visible way, since SSKS will replace $$CHALLENGE$$
with a random challenge that the user will have to copy.
Now that we have made all the necessary changes to the backend, let's move on to the frontend.
Modification of the frontend
We will do the following:
- modify the API client
frontend/src/services/api.ts
to include this new API point, and remove the previously added password pre-derivation, which is useless with the 2-man-rule protection mode; - modify the Seald service
frontend/src/services/seald.ts
to modify the identity creation and retrieval functions; - add, if requested by the backend (if
mustAuthenticate
istrue
), the email confirmation by SSKS step in the UI in theSignIn.tsx
andSignUp.tsx
.
Client API
In the frontend/src/services/api.ts
file, add the sendChallenge2MR: body => POST('/account/sendChallenge2MR', body)
route to the APIClient
, then add a static sendChallenge2MR
method to the User
class:
class User {
/* ... */
static async sendChallenge2MR (): Promise<{ twoManRuleSessionId: string, twoManRuleKey: string, mustAuthenticate: boolean }> {
return await apiClient.rest.account.sendChallenge2MR()
}
/* ... */
}
Then, we can remove the pre-derivation of the password in the createAccount
and login
methods.
To ease future calls, we create a function sendChallenge2MR
which is an alias for the above function:
export const sendChallenge2MR = async () => {
return User.sendChallenge2MR()
}
Seald service
Configuring
We start by installing the plugin @seald-io/sdk-plugin-ssks-2mr
, remove the plugin @seald-io/sdk-plugin-ssks-password
and scyrpt
:
cd frontend
npm install @seald-io/sdk-plugin-ssks-2mr
npm uninstall @seald-io/sdk-plugin-ssks-password scrypt
Then, in the file frontend/src/services/seald.ts
, import it and add it to the SDK. We also import User
which we will need later:
import SealdSDKPluginSSKS2MR from '@seald-io/sdk-plugin-ssks-2mr/browser'
import { User } from './api.ts'
const instantiateSealdSDK = async ({ databaseKey, sessionID }: { databaseKey: string, sessionID: string }): Promise<void> => {
sealdSDKInstance = SealdSDKConstructor({
appId: await getSetting('APP_ID'),
apiURL: await getSetting('API_URL'), // Optional. If not set, defaults to public apiURL https://api.seald.io
databaseKey,
databasePath: `seald-example-project-session-${sessionID}`,
plugins: [SealdSDKPluginSSKS2MR(await getSetting('KEY_STORAGE_URL'))] // Optional. If not set, defaults to public keyStorageURL https://ssks.seald.io
})
}
Identity creation and storage
Saving the identity is now done in two steps:
- the request to send the challenge;
- storing the identity on SSKS, with or without a challenge depending on the value of
mustAuthenticate
.
To do this, we modify the createIdentity
function which no longer needs the password
as an argument:
export const createIdentity = async ({ signupJWT, databaseKey, sessionID }: { signupJWT: string, databaseKey: string, sessionID: string }): Promise<string> => {
await instantiateSealdSDK({ databaseKey, sessionID })
const accountInfo = await sealdSDKInstance.initiateIdentity({ signupJWT })
return accountInfo.sealdId
}
Also, immediately after its call in frontend/src/containers/SignUp.tsx
, we request the sending of a challenge using the API point created above:
import { sendChallenge2MR } from '../services/seald'
/* ... */
const { twoManRuleSessionId, twoManRuleKey, mustAuthenticate } = await sendChallenge2MR()
This function returns an object containing twoManRuleKey
, twoManRuleSessionId
(not to confuse with the sessionID
of the client authentication on the backend), and mustAuthenticate
, sent by the backend.
If mustAuthenticate
is true
, SSKS sends an email. This email or text message will contain a challenge
valid for 6h, which will need to be copied by the user.
If mustAuthenticate
is false
, storing the identity on SSKS can be done without a challenge
.
We create a saveIdentity2MR
function that will store the identity on SSKS:
export const saveIdentity2MR = async ({ userId, twoManRuleKey, emailAddress, twoManRuleSessionId, challenge }: { userId: string, twoManRuleKey: string, emailAddress: string, twoManRuleSessionId: string, challenge?: string }): Promise<void> => {
await sealdSDKInstance.ssks2MR.saveIdentity({
challenge,
authFactor: {
type: 'EM',
value: emailAddress
},
twoManRuleKey,
userId,
sessionId: twoManRuleSessionId
})
}
Once this function is called, the identity will be saved on SSKS.
Identity Recovery
When recovering an indentity, authentication with SSKS is mandatory.
The identity recovery is now done in two steps:
- sending the challenge;
- using the challenge to retrieve the identity from SSKS.
We change function retrieveIdentity
to retrieveIdentity2MR
which doesn't take password
as argument anymore, but takes as new arguemtns:
emailAddress
: email address of the user;twoManRuleKey
: key stored by the backend;twoManRuleSessionId
: session id created by the backend for the user at SSKS;challenge
: random challenge sent by e-mail.
And we simply change the plugin used to call the sealdSDKInstance.ssks2MR.retrieveIdentity
function with these arguments:
export const retrieveIdentity2MR = async ({ userId, emailAddress, twoManRuleKey, twoManRuleSessionId, challenge, databaseKey, sessionID }: { userId: string, twoManRuleKey: string, emailAddress: string, twoManRuleSessionId: string, challenge: string, databaseKey: string, sessionID: string }): Promise<string> => {
await instantiateSealdSDK({ databaseKey, sessionID })
await sealdSDKInstance.ssks2MR.retrieveIdentity({
challenge,
authFactor: {
type: 'EM',
value: emailAddress
},
twoManRuleKey,
userId,
sessionId: twoManRuleSessionId
})
}
After calling this function, the identity will be retrieved and the SDK will be usable.
WARNING
It is recommended not to retrieve the same identity with ssks2MR.retrieveIdentity
on multiple devices at the same time, at the same exact instant, for example during automated tests. Please wait until one of the devices has finished retrieving the identity before starting the retrieval on another device.
Modification of the user interface
From now on, you have to call these functions:
- at account creation;
- at login.
Account creation SignUp.tsx
We start by adding two status hooks:
const [challengeSession, setChallengeSession] = useState<{
twoManRuleSessionId: string
twoManRuleKey: string
emailAddress: string
} | null>(null)
const [currentUser, setCurrentUser] = useState<User | null>(null)
Then, we add a ChallengeForm
component which is a form allowing the user, when mustAuthenticate
is true
, to type the challenge
and which calls handleChallengeSubmit
when the form is validated:
function ChallengeForm () {
return (
<form className={classes.form} onSubmit={handleChallengeSubmit}>
<TextField
variant='outlined'
margin='normal'
required
fullWidth
id='challenge'
label='challenge'
name='challenge'
autoFocus
/>
<div className={classes.wrapperButton}>
<Button type='submit' disabled={isLoading} fullWidth variant='contained' color='primary' className={classes.submit}>
Sign up
</Button>
{isLoading && <CircularProgress size={24} className={classes.buttonProgress} />}
</div>
</form>
)
}
This ChallengeForm
will be used instead of the SignupForm
if challengeSession
is defined:
return (
<Container component='main' maxWidth='xs'>
{/* ... */}
{challengeSession == null ? <SignupForm /> : <ChallengeForm />}
{/* ... */}
</Container>
)
Next, we modify the end of the handleSignupSubmit
so that it displays the ChallengeForm
to the user if mustAuthenticate
is true
, and so that it stores the identity directly otherwise:
const handleSignupSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsLoading(true)
const asyncHandleSignupSubmit = async (): Promise<void> => {
const formData = new FormData(event.target as HTMLFormElement)
try {
const emailAddress = formData.get('emailAddress') as string
const password = formData.get('password') as string
const name = formData.get('name') as string
const currentUser = await User.createAccount({ emailAddress, password, name })
// we don't call `createIdentity` with the password anymore
// and we get three elements we'll store for the next step:
// `twoManRuleSessionId`, `twoManRuleKey`, and `mustAuthenticate`
const sealdId = await createIdentity({
signupJWT: currentUser.signupJWT!,
databaseKey: currentUser.databaseKey!,
sessionID: currentUser.sessionID! // This sessionID is unrelated to 2-man-rule, it is used for database caching in localStorage
})
await currentUser.setSealdId(sealdId)
const { twoManRuleSessionId, twoManRuleKey, mustAuthenticate } = await sendChallenge2MR()
// We store the currentUser in its state hook
setCurrentUser(currentUser)
// If `mustAuthenticate` is `true`, we store the challenge info in its state hook to display the ChallengeForm
if (mustAuthenticate) {
setChallengeSession({
twoManRuleSessionId,
twoManRuleKey,
emailAddress
})
console.log('session set')
} else { // If `mustAuthenticate` is `false`, we can directly save the identity
await saveIdentity2MR({
userId: currentUser.id,
emailAddress,
twoManRuleKey,
twoManRuleSessionId,
challenge: undefined
})
dispatch({ type: SocketActionKind.SET_AUTH, payload: { currentUser } })
dispatch({
type: SocketActionKind.SET_ROOMS,
payload: {
rooms: await Room.list()
}
})
}
} catch (error) {
enqueueSnackbar(getMessageFromUnknownError(error), {
variant: 'error'
})
} finally {
setIsLoading(false)
}
}
void asyncHandleSignupSubmit()
},
[enqueueSnackbar, dispatch, setCurrentUser, setChallengeSession]
)
Finally, we create handleChallengeSubmit
which uses the challenge
completed by the user to save his identity on SSKS:
import { saveIdentity2MR } from '../services/seald'
/* ... */
const handleChallengeSubmit = useCallback((event: FormEvent<HTMLFormElement>): void => {
event.preventDefault()
setIsLoading(true)
const handleChallengeSubmitAsync = async (): Promise<void> => {
const formData = new FormData(event.target as HTMLFormElement)
try {
setIsLoading(true)
const challenge = formData.get('challenge') as string ?? undefined
if (currentUser == null) throw new Error('currentUser is not defined')
if (challengeSession == null) throw new Error('challengeSession is not defined')
await saveIdentity2MR({
userId: currentUser.id,
emailAddress: challengeSession.emailAddress,
twoManRuleKey: challengeSession.twoManRuleKey,
twoManRuleSessionId: challengeSession.twoManRuleSessionId,
challenge
})
// the end of this function `handleChallengeSubmit` is identical to the end
// of the previous version of `handleSignupSubmit`
dispatch({ type: SocketActionKind.SET_AUTH, payload: { currentUser } })
dispatch({
type: SocketActionKind.SET_ROOMS,
payload: {
rooms: await Room.list()
}
})
} catch (error) {
enqueueSnackbar(getMessageFromUnknownError(error), {
variant: 'error'
})
} finally {
setIsLoading(false)
}
}
void handleChallengeSubmitAsync()
},
[enqueueSnackbar, dispatch, challengeSession, currentUser]
)
That's it, we have implemented all the changes in the frontend.
2-man-rule by SMS
Once the two-man-rule with an email challenge has been implemented, it is very easy to switch to the SMS challenge. We will need to change the frontend to ask the user for a phone number, and then record it in the backend database. We will then make some minor changes to the @seald-io/sdk-plugin-ssks-2mr
plugin.
Modifying the User
model in the backend
In the backend/models.js
file, we add the phoneNumber
property to the User
model initialization:
const { Sequelize, DataTypes, Model, ValidationError } = require('sequelize')
/* ... */
class User extends Model { /* ... */ }
/* ... */
User.init(
{
/* ... */
phoneNumber: {
type: DataTypes.STRING,
allowNull: false
}
/* ... */
}
)
/* ... */
Modification of the API point and the associated validator
We need to add a phoneNumber
field in the query validator:
const createAccountValidator = {
body: Joi.object({
emailAddress: Joi.string().email().required().max(255),
name: Joi.string().required().max(255),
phoneNumber: Joi.string().required().max(15),
password: Joi.string().required().max(255)
})
}
The phoneNumber
field should then be retrieved and passed to the model:
router.post('/', validate(createAccountValidator), async (req, res, next) => {
/* ... */
const { emailAddress, password, phoneNumber, name } = req.body
const user = await User.create({ emailAddress, password, phoneNumber, name })
/* ... */
}
Modifying the sendChallenge2MR
API point
The changes here are easy. The auth_factor
needs to be changed. Its type
changes from 'EM'
to 'SMS'
, and its value
changes to contain the phone number.
Secondly, the template
field needs to be changed. This no longer contains the HTML code of the email, but the text of the SMS. Again the template must contain the key $$CHALLENGE$$
which will be replaced by the challenge.
router.post('/', validate(createAccountValidator), async (req, res, next) => {
/* ... */
body: JSON.stringify({
create_user: createUser,
user_id: user.id,
auth_factor: {
type: 'SMS',
value: user.phoneNumber
},
template: 'Challenge: $$CHALLENGE$$'
})
/* ... */
}
Fronted modifications
The modifications on the frontend are very similar to those on the backend. You will need to:
- Add a
phone number
field to theSignupForm
. - Add a
phoneNumber
key in the user model. - Modify the account creation API point and call to include the phone number.
Once these three changes have been made, change the saveIdentity2MR
and retrieveIdentity2MR
functions in the seald.js
file. We need to add a phoneNumber
argument to these functions, and then change the authFactor
argument when calling the plugin functions. Again, the type of the authentication factor becomes 'SMS'
, and its value becomes the phone number.
export const saveIdentity2MR = async ({ userId, twoManRuleKey, phoneNumber, twoManRuleSessionId, challenge }: { userId: string, twoManRuleKey: string, phoneNumber: string, twoManRuleSessionId: string, challenge?: string }): Promise<void> => {
await sealdSDKInstance.ssks2MR.saveIdentity({
challenge,
authFactor: {
type: 'SMS',
value: phoneNumber
},
twoManRuleKey,
userId,
sessionId: twoManRuleSessionId
})
}
export const retrieveIdentity2MR = async ({ userId, phoneNumber, twoManRuleKey, twoManRuleSessionId, challenge, databaseKey, sessionID }: { userId: string, twoManRuleKey: string, phoneNumber: string, twoManRuleSessionId: string, challenge: string, databaseKey: string, sessionID: string }): Promise<string> => {
await instantiateSealdSDK({ databaseKey, sessionID })
await sealdSDKInstance.ssks2MR.retrieveIdentity({
challenge,
authFactor: {
type: 'SMS',
value: phoneNumber
},
twoManRuleKey,
userId,
sessionId: twoManRuleSessionId
})
}
Conclusion
We were able to implement the 2-man-rule protection completely, improvements can still be made, for example instead of asking the user to copy the challenge
it is possible to include it in a magic link to the application (be careful not to use a GET variable but a URL fragment so that the server does not know the challenge
).