diff --git a/keyserver/addons/rust-node-addon/rust-binding-types.js b/keyserver/addons/rust-node-addon/rust-binding-types.js index d089f3f9d..fde79d7a7 100644 --- a/keyserver/addons/rust-node-addon/rust-binding-types.js +++ b/keyserver/addons/rust-node-addon/rust-binding-types.js @@ -1,86 +1,88 @@ // @flow import type { SignedIdentityKeysBlob } from 'lib/types/crypto-types.js'; import type { InboundKeyInfoResponse, FarcasterUser, UserIdentitiesResponse, } from 'lib/types/identity-service-types.js'; import type { IdentityInfo } from '../../src/user/identity.js'; type RustNativeBindingAPI = { +loginUser: ( username: string, password: string, signedIdentityKeysBlob: SignedIdentityKeysBlob, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, + contentOneTimeKeys: $ReadOnlyArray, + notifOneTimeKeys: $ReadOnlyArray, force: ?boolean, ) => Promise, +registerUser: ( username: string, password: string, signedIdentityKeysBlob: SignedIdentityKeysBlob, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, contentOneTimeKeys: $ReadOnlyArray, notifOneTimeKeys: $ReadOnlyArray, ) => Promise, +addReservedUsernames: (message: string, signature: string) => Promise, +removeReservedUsername: ( message: string, signature: string, ) => Promise, +publishPrekeys: ( userId: string, deviceId: string, accessToken: string, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, ) => Promise, +uploadOneTimeKeys: ( userId: string, deviceId: string, accessToken: string, contentOneTimePrekeys: $ReadOnlyArray, notifOneTimePrekeys: $ReadOnlyArray, ) => Promise, +getInboundKeysForUserDevice: ( authUserId: string, authDeviceId: string, authAccessToken: string, userId: string, deviceId: string, ) => Promise, +getFarcasterUsers: ( farcasterIds: $ReadOnlyArray, ) => Promise<$ReadOnlyArray>, +generateNonce: () => Promise, +uploadSecondaryDeviceKeysAndLogIn: ( userId: string, nonce: string, nonceSignature: string, signedIdentityKeysBlob: SignedIdentityKeysBlob, contentPrekey: string, contentPrekeySignature: string, notifPrekey: string, notifPrekeySignature: string, contentOneTimeKeys: $ReadOnlyArray, notifOneTimeKeys: $ReadOnlyArray, ) => Promise, +findUserIdentities: ( authUserId: string, authDeviceId: string, authAccessToken: string, userIds: $ReadOnlyArray, ) => Promise, }; export type { RustNativeBindingAPI }; diff --git a/keyserver/addons/rust-node-addon/src/identity_client/login.rs b/keyserver/addons/rust-node-addon/src/identity_client/login.rs index 6434a195b..c132cbd59 100644 --- a/keyserver/addons/rust-node-addon/src/identity_client/login.rs +++ b/keyserver/addons/rust-node-addon/src/identity_client/login.rs @@ -1,149 +1,151 @@ use super::*; use comm_opaque2::client::Login; use grpc_clients::identity::protos::{ unauth::SecondaryDeviceKeysUploadRequest, unauthenticated::{OpaqueLoginFinishRequest, OpaqueLoginStartRequest}, }; use tracing::debug; #[napi] #[instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn login_user( username: String, password: String, signed_identity_keys_blob: SignedIdentityKeysBlob, content_prekey: String, content_prekey_signature: String, notif_prekey: String, notif_prekey_signature: String, + content_one_time_keys: Vec, + notif_one_time_keys: Vec, force: Option, ) -> Result { debug!("Attempting to log in user: {}", username); // Set up the gRPC client that will be used to talk to the Identity service let mut identity_client = get_identity_client().await?; // Start OPAQUE registration and send initial registration request let mut client_login = Login::new(); let opaque_login_request = client_login .start(&password) .map_err(|_| Error::from_reason("Failed to create opaque login request"))?; let login_start_request = OpaqueLoginStartRequest { opaque_login_request, username, device_key_upload: Some(DeviceKeyUpload { device_key_info: Some(IdentityKeyInfo { payload: signed_identity_keys_blob.payload, payload_signature: signed_identity_keys_blob.signature, }), content_upload: Some(Prekey { prekey: content_prekey, prekey_signature: content_prekey_signature, }), notif_upload: Some(Prekey { prekey: notif_prekey, prekey_signature: notif_prekey_signature, }), - one_time_content_prekeys: Vec::new(), - one_time_notif_prekeys: Vec::new(), + one_time_content_prekeys: content_one_time_keys, + one_time_notif_prekeys: notif_one_time_keys, device_type: DeviceType::Keyserver.into(), }), force, }; debug!("Starting login to identity service"); let response = identity_client .log_in_password_user_start(login_start_request) .await .map_err(handle_grpc_error)?; debug!("Received login response from identity service"); let login_start_response = response.into_inner(); let opaque_login_upload = client_login .finish(&login_start_response.opaque_login_response) .map_err(|_| Error::from_reason("Failed to finish opaque login request"))?; let login_finish_request = OpaqueLoginFinishRequest { session_id: login_start_response.session_id, opaque_login_upload, }; debug!("Attempting to finalize opaque login exchange with identity service"); let login_finish_response = identity_client .log_in_password_user_finish(login_finish_request) .await .map_err(handle_grpc_error)? .into_inner(); debug!("Finished login with identity service"); let user_info = UserLoginInfo { user_id: login_finish_response.user_id, access_token: login_finish_response.access_token, }; Ok(user_info) } #[napi] #[instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub async fn upload_secondary_device_keys_and_log_in( user_id: String, nonce: String, nonce_signature: String, signed_identity_keys_blob: SignedIdentityKeysBlob, content_prekey: String, content_prekey_signature: String, notif_prekey: String, notif_prekey_signature: String, content_one_time_keys: Vec, notif_one_time_keys: Vec, ) -> Result { let device_key_upload = DeviceKeyUpload { device_key_info: Some(IdentityKeyInfo { payload: signed_identity_keys_blob.payload, payload_signature: signed_identity_keys_blob.signature, }), content_upload: Some(Prekey { prekey: content_prekey, prekey_signature: content_prekey_signature, }), notif_upload: Some(Prekey { prekey: notif_prekey, prekey_signature: notif_prekey_signature, }), one_time_content_prekeys: content_one_time_keys, one_time_notif_prekeys: notif_one_time_keys, device_type: DeviceType::Keyserver.into(), }; let mut identity_client = get_identity_client().await?; let request = SecondaryDeviceKeysUploadRequest { user_id, nonce, nonce_signature, device_key_upload: Some(device_key_upload), }; let response = identity_client .upload_keys_for_registered_device_and_log_in(request) .await .map_err(|_| { Error::from_reason( "Failed to upload keys for registered device and log in", ) })? .into_inner(); let user_info = UserLoginInfo { user_id: response.user_id, access_token: response.access_token, }; Ok(user_info) } diff --git a/keyserver/src/user/login.js b/keyserver/src/user/login.js index bb8b4907f..b0720c2a2 100644 --- a/keyserver/src/user/login.js +++ b/keyserver/src/user/login.js @@ -1,266 +1,260 @@ // @flow import type { Account as OlmAccount } from '@commapp/olm'; import { getRustAPI } from 'rust-node-addon'; -import { ONE_TIME_KEYS_NUMBER } from 'lib/types/identity-service-types.js'; import { getCommConfig } from 'lib/utils/comm-config.js'; import { ServerError } from 'lib/utils/errors.js'; -import { - retrieveIdentityKeysAndPrekeys, - getAccountOneTimeKeys, -} from 'lib/utils/olm-utils.js'; +import { retrieveAccountKeysSet } from 'lib/utils/olm-utils.js'; import type { UserCredentials } from './checks.js'; import { saveIdentityInfo, fetchIdentityInfo, type IdentityInfo, } from './identity.js'; import { getMessageForException } from '../responders/utils.js'; import { fetchCallUpdateOlmAccount } from '../updaters/olm-account-updater.js'; import { unpickleOlmAccount } from '../utils/olm-utils.js'; import type { PickledOlmAccount } from '../utils/olm-utils.js'; // After register or login is successful function markPrekeyAsPublished(account: OlmAccount) { account.mark_prekey_as_published(); } // Before registration function markOneTimeKeysAsPublished(account: OlmAccount) { account.mark_keys_as_published(); } async function getUserCredentials(): Promise { const userInfo = await getCommConfig({ folder: 'secrets', name: 'user_credentials', }); if (!userInfo) { throw new ServerError('missing_user_credentials'); } if ( userInfo.usingIdentityCredentials === undefined && process.env.NODE_ENV === 'development' ) { console.warn( 'Keyserver is not set up to use identity credentials' + '\nUsing identity credentials is optional for now' + '\nYou can restart nix to set up a new keyserver ' + 'with identity credentials' + '\nFor keyservers running in Docker, refer to ' + 'https://www.notion.so/commapp/Running-two-keyservers-4295f98e7b0547d4ba027ba52c2d2e80?pvs=4#1f4178200d2b442bb7fa05dca447f406', ); } return userInfo; } async function verifyUserLoggedIn(): Promise { const userInfoPromise = getUserCredentials(); const result = await fetchIdentityInfo(); if (result) { return result; } const userInfo = await userInfoPromise; const identityInfo = await registerOrLogIn(userInfo); await saveIdentityInfo(identityInfo); return identityInfo; } async function unpickleAndUseCallback( pickledOlmAccount: PickledOlmAccount, callback: (account: OlmAccount, picklingKey: string) => Promise | T, ): Promise<{ result: T, pickledOlmAccount: PickledOlmAccount }> { const { picklingKey, pickledAccount } = pickledOlmAccount; const account = await unpickleOlmAccount({ picklingKey, pickledAccount, }); let result; try { result = await callback(account, picklingKey); } catch (e) { throw new ServerError(getMessageForException(e) ?? 'unknown_error'); } const updatedAccount = account.pickle(picklingKey); return { result, pickledOlmAccount: { ...pickledOlmAccount, pickledAccount: updatedAccount }, }; } async function verifyUserLoggedInWithoutDB( pickledContentAccount: PickledOlmAccount, pickledNotificationsAccount: PickledOlmAccount, ): Promise<{ identityInfo: IdentityInfo, pickledContentAccount: PickledOlmAccount, pickledNotificationsAccount: PickledOlmAccount, }> { const userInfo = await getUserCredentials(); const identityInfo = await registerOrLogInBase( userInfo, async callback => { const { result, pickledOlmAccount } = await unpickleAndUseCallback( pickledContentAccount, callback, ); pickledContentAccount = pickledOlmAccount; return result; }, async callback => { const { result, pickledOlmAccount } = await unpickleAndUseCallback( pickledNotificationsAccount, callback, ); pickledNotificationsAccount = pickledOlmAccount; return result; }, ); return { identityInfo, pickledContentAccount, pickledNotificationsAccount }; } async function registerOrLogIn( userInfo: UserCredentials, ): Promise { return registerOrLogInBase( userInfo, callback => fetchCallUpdateOlmAccount('content', callback), callback => fetchCallUpdateOlmAccount('notifications', callback), ); } async function registerOrLogInBase( userInfo: UserCredentials, getUpdateContentAccount: ( callback: (account: OlmAccount, picklingKey: string) => Promise | T, ) => Promise, getUpdateNotificationsAccount: ( callback: (account: OlmAccount, picklingKey: string) => Promise | T, ) => Promise, ): Promise { const rustAPIPromise = getRustAPI(); const { identityKeys: notificationsIdentityKeys, prekey: notificationsPrekey, prekeySignature: notificationsPrekeySignature, - } = await getUpdateNotificationsAccount(retrieveIdentityKeysAndPrekeys); + oneTimeKeys: notificationsOneTimeKeys, + } = await fetchCallUpdateOlmAccount('notifications', retrieveAccountKeysSet); - const contentAccountCallback = (account: OlmAccount) => { + const contentAccountCallback = async (account: OlmAccount) => { const { identityKeys: contentIdentityKeys, + oneTimeKeys, prekey, prekeySignature, - } = retrieveIdentityKeysAndPrekeys(account); + } = await retrieveAccountKeysSet(account); const identityKeysBlob = { primaryIdentityPublicKeys: JSON.parse(contentIdentityKeys), notificationIdentityPublicKeys: JSON.parse(notificationsIdentityKeys), }; const identityKeysBlobPayload = JSON.stringify(identityKeysBlob); const signedIdentityKeysBlob = { payload: identityKeysBlobPayload, signature: account.sign(identityKeysBlobPayload), }; return { signedIdentityKeysBlob, + oneTimeKeys, prekey, prekeySignature, }; }; const [ rustAPI, { signedIdentityKeysBlob, prekey: contentPrekey, prekeySignature: contentPrekeySignature, + oneTimeKeys: contentOneTimeKeys, }, ] = await Promise.all([ rustAPIPromise, getUpdateContentAccount(contentAccountCallback), ]); try { const identity_info = await rustAPI.loginUser( userInfo.username, userInfo.password, signedIdentityKeysBlob, contentPrekey, contentPrekeySignature, notificationsPrekey, notificationsPrekeySignature, + contentOneTimeKeys, + notificationsOneTimeKeys, userInfo.forceLogin, ); await Promise.all([ getUpdateContentAccount(markPrekeyAsPublished), getUpdateNotificationsAccount(markPrekeyAsPublished), ]); return identity_info; } catch (e) { console.warn('Failed to login user: ' + getMessageForException(e)); - const [contentOneTimeKeys, notificationsOneTimeKeys] = await Promise.all([ - getUpdateContentAccount((account: OlmAccount) => - getAccountOneTimeKeys(account, ONE_TIME_KEYS_NUMBER), - ), - getUpdateNotificationsAccount((account: OlmAccount) => - getAccountOneTimeKeys(account, ONE_TIME_KEYS_NUMBER), - ), - ]); try { await Promise.all([ getUpdateContentAccount(markOneTimeKeysAsPublished), getUpdateNotificationsAccount(markOneTimeKeysAsPublished), ]); const identity_info = await rustAPI.registerUser( userInfo.username, userInfo.password, signedIdentityKeysBlob, contentPrekey, contentPrekeySignature, notificationsPrekey, notificationsPrekeySignature, contentOneTimeKeys, notificationsOneTimeKeys, ); await Promise.all([ getUpdateContentAccount(markPrekeyAsPublished), getUpdateNotificationsAccount(markPrekeyAsPublished), ]); return identity_info; } catch (err) { console.warn('Failed to register user: ' + getMessageForException(err)); if (userInfo.usingIdentityCredentials) { if (process.env.NODE_ENV === 'development') { console.warn( 'Please re-enter `nix develop` and provide new user credentials', ); } else { console.warn( 'For keyservers running in Docker, set different ' + 'user credentials in COMM_JSONCONFIG_secrets_user_credentials, ' + 'remove all tables from the database, and restart.' + '\nFor keyservers outside of Docker, re-enter `nix develop` ' + 'and provide new user credentials', ); } } throw new ServerError('identity_auth_failed'); } } } export { verifyUserLoggedIn, verifyUserLoggedInWithoutDB }; diff --git a/lib/utils/olm-utils.js b/lib/utils/olm-utils.js index 50e34ca18..53a2edc69 100644 --- a/lib/utils/olm-utils.js +++ b/lib/utils/olm-utils.js @@ -1,161 +1,147 @@ // @flow import type { Account as OlmAccount } from '@commapp/olm'; import { getOneTimeKeyValuesFromBlob, getPrekeyValueFromBlob, } from '../shared/crypto-utils.js'; import { ONE_TIME_KEYS_NUMBER } from '../types/identity-service-types.js'; const maxPublishedPrekeyAge = 30 * 24 * 60 * 60 * 1000; const maxOldPrekeyAge = 24 * 60 * 60 * 1000; type AccountKeysSet = { +identityKeys: string, +prekey: string, +prekeySignature: string, +oneTimeKeys: $ReadOnlyArray, }; -type IdentityKeysAndPrekeys = { - +identityKeys: string, - +prekey: string, - +prekeySignature: string, -}; - function validateAccountPrekey(account: OlmAccount) { if (shouldRotatePrekey(account)) { account.generate_prekey(); } if (shouldForgetPrekey(account)) { account.forget_old_prekey(); } } function shouldRotatePrekey(account: OlmAccount): boolean { // Our fork of Olm only remembers two prekeys at a time. // If the new one hasn't been published, then the old one is still active. // In that scenario, we need to avoid rotating the prekey because it will // result in the old active prekey being discarded. if (account.unpublished_prekey()) { return false; } const currentDate = new Date(); const lastPrekeyPublishDate = getLastPrekeyPublishTime(account); return ( currentDate.getTime() - lastPrekeyPublishDate.getTime() >= maxPublishedPrekeyAge ); } function shouldForgetPrekey(account: OlmAccount): boolean { // Our fork of Olm only remembers two prekeys at a time. // We have to hold onto the old one until the new one is published. if (account.unpublished_prekey()) { return false; } const currentDate = new Date(); const lastPrekeyPublishDate = getLastPrekeyPublishTime(account); return ( currentDate.getTime() - lastPrekeyPublishDate.getTime() >= maxOldPrekeyAge ); } function getLastPrekeyPublishTime(account: OlmAccount): Date { const olmLastPrekeyPublishTime = account.last_prekey_publish_time(); // Olm uses seconds, while the Date() constructor expects milliseconds. return new Date(olmLastPrekeyPublishTime * 1000); } function getAccountPrekeysSet(account: OlmAccount): { +prekey: string, +prekeySignature: ?string, } { const prekey = getPrekeyValueFromBlob(account.prekey()); const prekeySignature = account.prekey_signature(); return { prekey, prekeySignature }; } function getAccountOneTimeKeys( account: OlmAccount, numberOfKeys: number = ONE_TIME_KEYS_NUMBER, ): $ReadOnlyArray { let oneTimeKeys = getOneTimeKeyValuesFromBlob(account.one_time_keys()); if (oneTimeKeys.length < numberOfKeys) { account.generate_one_time_keys(numberOfKeys - oneTimeKeys.length); oneTimeKeys = getOneTimeKeyValuesFromBlob(account.one_time_keys()); } return oneTimeKeys; } function retrieveAccountKeysSet(account: OlmAccount): AccountKeysSet { - const { identityKeys, prekey, prekeySignature } = - retrieveIdentityKeysAndPrekeys(account); - const oneTimeKeys = getAccountOneTimeKeys(account, ONE_TIME_KEYS_NUMBER); - return { identityKeys, oneTimeKeys, prekey, prekeySignature }; -} - -function retrieveIdentityKeysAndPrekeys( - account: OlmAccount, -): IdentityKeysAndPrekeys { const identityKeys = account.identity_keys(); validateAccountPrekey(account); const { prekey, prekeySignature } = getAccountPrekeysSet(account); if (!prekeySignature || !prekey) { throw new Error('invalid_prekey'); } - return { identityKeys, prekey, prekeySignature }; + const oneTimeKeys = getAccountOneTimeKeys(account, ONE_TIME_KEYS_NUMBER); + + return { identityKeys, oneTimeKeys, prekey, prekeySignature }; } export const OLM_SESSION_ERROR_PREFIX = 'OLM_'; const olmSessionErrors = Object.freeze({ // Two clients send the session request to each other at the same time, // we choose which session to keep based on `deviceID`. raceCondition: `${OLM_SESSION_ERROR_PREFIX}SESSION_CREATION_RACE_CONDITION`, // The client received a session request with a lower session version, // this request can be ignored. alreadyCreated: `${OLM_SESSION_ERROR_PREFIX}SESSION_ALREADY_CREATED`, // Error thrown when attempting to encrypt/decrypt, indicating that // the session for a given deviceID is not created. // This definition should remain in sync with the value defined in // the corresponding .cpp file // at `native/cpp/CommonCpp/CryptoTools/CryptoModule.cpp`. sessionDoesNotExist: 'SESSION_DOES_NOT_EXIST', // Error thrown when attempting to decrypt a message encrypted // with an already replaced old session. // This definition should remain in sync with the value defined in // the corresponding .cpp file // at `native/cpp/CommonCpp/CryptoTools/CryptoModule.cpp`. invalidSessionVersion: 'INVALID_SESSION_VERSION', }); function hasHigherDeviceID( currenDeviceID: string, otherDeviceID: string, ): boolean { const compareResult = currenDeviceID.localeCompare(otherDeviceID); if (compareResult === 0) { throw new Error('Comparing the same deviceIDs'); } return compareResult === 1; } export { retrieveAccountKeysSet, getAccountPrekeysSet, shouldForgetPrekey, shouldRotatePrekey, getAccountOneTimeKeys, - retrieveIdentityKeysAndPrekeys, hasHigherDeviceID, olmSessionErrors, };