User & Device Authentication
Cryptographic Dependencies and actual implementation
- opaque_login: OPAQUE login flow
- userChainAddDevice: see user-chain
- kdf:
sodium.crypto_kdf_hkdf_sha256_expand(sodium.crypto_kdf_hkdf_sha256_extract(key, subkeyId), context, crypto_aead_xchacha20poly1305_ietf_KEYBYTES)
- signingKeyPairGen:
sodium.crypto_sign_keypair()
- encryptionKeyPairGen:
sodium.crypto_box_keypair()
- sign:
sodium.crypto_sign_detached(message, privateKey)
- noncegen:
sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES)
- secretboxKeygen:
sodium.crypto_secretbox_keygen()
- encrypt:
sodium.crypto_secretbox_easy(message, nonce, key)
- decrypt:
sodium.crypto_secretbox_opne_easy(ciphertext, nonce, key)
Login
Using the OPAQUE protocol to authenticate the client with the server the MainDevice can be recovered. With the MainDevice the client automatically add the current client as new device to the user-chain. All workspace keys are decrypted and in case of a long-term session the workspace key boxes for the new device are created.
exportKey, sessionKey = opaque_login()
encryptionKey = kdf(1111, "m_device", exportKey);
mainDevice = decrypt(
ciphertext,
nonce,
encryptionKey
);
{
signingPublicKey,
signingPrivateKey,
encryptionPublicKey,
encryptionPrivateKey,
encryptionPublicKeySignature,
createdAt,
} = mainDevice;
// create device
deviceSigningKeyPair = signingKeyPairGen();
deviceEncryptionKeyPair = encryptionKeyPairGen();
deviceEncryptionPublicKeySignature = sign(
concat("user_device_encryption_public_key", deviceEncryptionKeyPair.publicKey),
deviceSigningKeyPair.privateKey
);
expiredAt = determineExpiredAt(); // undefined if permanent device (mobile), 30 days for long web session, 24h for short web session
userChainAddDeviceEvent = userChainAddDevice(mainDevice, deviceSigningKeyPair.publicKey, deviceEncryptionKeyPair.publicKey, deviceEncryptionPublicKeySignature, expiredAt);
sessionTokenSignature = sign(concat("login_session_key", sessionKey), deviceSigningKeyPair.privateKey);
// additional steps only done for web devices
webDeviceKey = secretboxKeygen();
webDeviceNonce = noncegen();
webDeviceCiphertext = encrypt(
{
deviceSigningKeyPair.publicKey,
deviceSigningKeyPair.privateKey,
deviceEncryptionKeyPair.publicKey,
deviceEncryptionKeyPair.privateKey,
deviceEncryptionPublicKeySignature,
},
webDeviceNonce,
webDeviceKey
);
load the latest user chain and decryptMainDevice https://github.com/serenity-kit/Serenity/blob/main/apps/app/utils/authentication/loginHelper.ts#L78-L87 (opens in a new tab)
UserChain.addDevice https://github.com/serenity-kit/Serenity/blob/main/apps/app/utils/authentication/loginHelper.ts#L124-L139 (opens in a new tab)
sign sessionToken to verify the client has access to the private key of the new device (to prevent a MITM attack replacing the device) https://github.com/serenity-kit/Serenity/blob/main/apps/app/utils/authentication/loginHelper.ts#L141-L146 (opens in a new tab)
After the device was added to the user, the client starts adding the device to known workspaces. This is triggered here:
The exact process is documented in the section Workspace Encryption
und Adding a new device to the workspace and creating the corresponding encryption box on a login
.
Data stored
On mobile and desktop with access to a secure storage the sessionKey
and device private keys are stored in the secure storage.
On web devices without access to a secure storage the sessionKey
, webDeviceKey
and webAccessToken
is stored in localstorage.
The mainDevice
is only stored in memory regardless of the environment.
More details on storage can be found in Secure Client Storage.
Recovering a web device
When a user opens the browser and still has a valid (non-expired and non-revoked session) then the client uses the stored webAccessToken
to retrieve the encrypted webDevice (ciphertext & nonce) from the backend. The client then decrypts the webDevice and stores the decrypted webDevice in memory.
webDevice = decrypt(webDeviceCiphertext, webDeviceNonce, webDeviceKey);
Active Session Authentication
An authorization token is constructed for every request.
sessionToken = kdf("AQEBAQEBAQEBAQEBAQEBAQ", "session_token", sessionKey);
datetime = generateDatetimeAsIsoString()
sessionDatetimeSubkey = kdf(datetime, "session_datetime", sessionKey);
authorizationToken = `${sessionToken}|${datetime}|${sessionDatetimeSubkey}`;
The authorizationToken
is sent along as Authorization
header. Based on that the backend can identify the user and verify the session.
Client code: https://github.com/serenity-kit/Serenity/blob/main/apps/app/utils/urqlClient/urqlClient.ts#L76-L81 (opens in a new tab) Server code: https://github.com/serenity-kit/Serenity/blob/main/apps/backend/src/createServer.ts#L39-L70 (opens in a new tab)
More details in Client Server (Authentication & Authorization).
Opaque Server Public Key Verification
In order to make sure the user is talking to the correct opaque server the server's public key is verified.
In addition also used when retrieving the main device in the password verification.