Skip to content

Commit

Permalink
Add support for SSO login authentication (#168)
Browse files Browse the repository at this point in the history
Relates to #103
  • Loading branch information
Mikescops authored Aug 16, 2023
1 parent 36092eb commit dea9710
Show file tree
Hide file tree
Showing 21 changed files with 335 additions and 59 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"inquirer-search-list": "^1.2.6",
"jsonpath-plus": "^7.2.0",
"otplib": "^12.0.1",
"playwright": "^1.37.0",
"winston": "^3.10.0",
"xml-js": "^1.6.11",
"zlib": "^1.0.5"
Expand Down
4 changes: 2 additions & 2 deletions src/command-handlers/configure.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import winston from 'winston';
import { encryptAES } from '../modules/crypto/encrypt';
import { encryptAesCbcHmac256 } from '../modules/crypto/encrypt';
import { deleteLocalKey, setLocalKey, warnUnreachableKeychainDisabled } from '../modules/crypto/keychainManager';
import { connectAndPrepare } from '../modules/database';
import { parseBooleanString } from '../utils';
Expand Down Expand Up @@ -31,7 +31,7 @@ export const configureSaveMasterPassword = async (boolean: string) => {
masterPasswordEncrypted = null;
} else {
// Set encrypted master password in the DB
masterPasswordEncrypted = encryptAES(secrets.localKey, Buffer.from(secrets.masterPassword));
masterPasswordEncrypted = encryptAesCbcHmac256(secrets.localKey, Buffer.from(secrets.masterPassword));

if (!shouldNotSaveMasterPassword) {
// Set local key in the OS keychain
Expand Down
4 changes: 2 additions & 2 deletions src/command-handlers/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Database from 'better-sqlite3';
import winston from 'winston';
import { connectAndPrepare } from '../modules/database';
import { decrypt } from '../modules/crypto/decrypt';
import { encryptAES } from '../modules/crypto/encrypt';
import { encryptAesCbcHmac256 } from '../modules/crypto/encrypt';
import { replaceMasterPassword } from '../modules/crypto/keychainManager';
import { getLatestContent } from '../endpoints';
import type { DeviceConfiguration, Secrets } from '../types';
Expand Down Expand Up @@ -73,7 +73,7 @@ export const sync = async (params: Sync) => {
}
return null;
}
const encryptedTransactionContent = encryptAES(secrets.localKey, transactionContent);
const encryptedTransactionContent = encryptAesCbcHmac256(secrets.localKey, transactionContent);
return [
secrets.login,
transac.identifier,
Expand Down
2 changes: 1 addition & 1 deletion src/endpoints/getAuthenticationMethodsForDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface GetAuthenticationMethodsForDeviceParams {
supportedMethods?: SupportedAuthenticationMethod[];
}

interface GetAuthenticationMethodsForDeviceResult {
export interface GetAuthenticationMethodsForDeviceResult {
/** The authentication methods available for the user */
verifications: (
| {
Expand Down
22 changes: 22 additions & 0 deletions src/endpoints/performSSOVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { requestAppApi } from '../requestApi';

interface PerformSsoVerificationPayload {
/** The login of the user */
login: string;
/** The SSO token */
ssoToken: string;
}

export interface PerformSsoVerificationBodyData {
/** Authentication ticket usable several time */
authTicket: string;
}

export const performSSOVerification = (params: PerformSsoVerificationPayload) =>
requestAppApi<PerformSsoVerificationBodyData>({
path: 'authentication/PerformSsoVerification',
payload: {
login: params.login,
ssoToken: params.ssoToken,
},
});
43 changes: 29 additions & 14 deletions src/modules/auth/registerDevice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import winston from 'winston';
import { doSSOVerification } from './sso';
import {
CompleteDeviceRegistrationWithAuthTicketOutput,
completeDeviceRegistration,
performDashlaneAuthenticatorVerification,
performDuoPushVerification,
Expand All @@ -10,16 +10,13 @@ import {
import { askOtp, askToken, askVerificationMethod } from '../../utils';
import { getAuthenticationMethodsForDevice } from '../../endpoints/getAuthenticationMethodsForDevice';
import { requestEmailTokenVerification } from '../../endpoints/requestEmailTokenVerification';
import type { SupportedAuthenticationMethod } from '../../types';

interface RegisterDevice {
login: string;
deviceName: string;
}

export const registerDevice = async (
params: RegisterDevice
): Promise<CompleteDeviceRegistrationWithAuthTicketOutput> => {
export const registerDevice = async (params: RegisterDevice) => {
const { login, deviceName } = params;
winston.debug('Registering the device...');

Expand All @@ -30,36 +27,54 @@ export const registerDevice = async (
throw new Error('Master password-less is currently not supported');
}

const nonEmptyVerifications = verifications.filter((method) => method.type);

const selectedVerificationMethod =
verifications.length > 1
? await askVerificationMethod(verifications.map((method) => method.type as SupportedAuthenticationMethod))
: verifications[0].type;
nonEmptyVerifications.length > 1
? await askVerificationMethod(nonEmptyVerifications)
: nonEmptyVerifications[0];

let authTicket: string;
if (selectedVerificationMethod === 'duo_push') {
let ssoSpKey: string | null = null;
if (!selectedVerificationMethod || Object.keys(selectedVerificationMethod).length === 0) {
throw new Error('No verification method selected');
}

if (selectedVerificationMethod.type === 'duo_push') {
winston.info('Please accept the Duo push notification on your phone');
({ authTicket } = await performDuoPushVerification({ login }));
} else if (selectedVerificationMethod === 'dashlane_authenticator') {
} else if (selectedVerificationMethod.type === 'dashlane_authenticator') {
winston.info('Please accept the Dashlane Authenticator push notification on your phone');
({ authTicket } = await performDashlaneAuthenticatorVerification({ login }));
} else if (selectedVerificationMethod === 'totp') {
} else if (selectedVerificationMethod.type === 'totp') {
const otp = await askOtp();
({ authTicket } = await performTotpVerification({
login,
otp,
}));
} else if (selectedVerificationMethod === 'email_token') {
} else if (selectedVerificationMethod.type === 'email_token') {
await requestEmailTokenVerification({ login });

const token = await askToken();
({ authTicket } = await performEmailTokenVerification({
login,
token,
}));
} else if (selectedVerificationMethod.type === 'sso') {
if (selectedVerificationMethod.ssoInfo.isNitroProvider) {
throw new Error('Confidential SSO is currently not supported');
}

({ authTicket, ssoSpKey } = await doSSOVerification({
requestedLogin: login,
serviceProviderURL: selectedVerificationMethod.ssoInfo.serviceProviderUrl,
}));
} else {
throw new Error('Auth verification method not supported: ' + verifications[0].type);
throw new Error('Auth verification method not supported: ' + selectedVerificationMethod.type);
}

// Complete the device registration and save the result
return completeDeviceRegistration({ login, deviceName, authTicket });
const completeDeviceRegistrationResponse = await completeDeviceRegistration({ login, deviceName, authTicket });

return { ...completeDeviceRegistrationResponse, ssoSpKey };
};
63 changes: 63 additions & 0 deletions src/modules/auth/sso/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { chromium } from 'playwright';
import { DASHLANE_APP_REGEX, extractSsoInfoFromUrl } from './utils';
import { performSSOVerification } from '../../../endpoints/performSSOVerification';

interface SSOParams {
requestedLogin: string;
serviceProviderURL: string;
}

const openIdPAndWaitForRedirectURL = async (serviceProviderURL: string, userLogin: string): Promise<URL> => {
return new Promise((resolve, reject) => {
void (async () => {
try {
const browser = await chromium.launch({ headless: false, channel: 'chrome' });
const context = await browser.newContext();
const page = await context.newPage();

page.on('framenavigated', (frame) => {
const url = page.url();
if (frame === page.mainFrame() && url.match(DASHLANE_APP_REGEX)) {
void browser.close();
resolve(new URL(url));
}
});

browser.on('disconnected', () => {
reject(new Error('Browser closed before SSO login'));
});

await page.goto(serviceProviderURL);

// attempt to fill the login field
await page
.getByLabel('email')
.or(page.getByLabel('Username'))
.fill(userLogin)
.catch(() => null);
} catch (error) {
reject(error);
}
})();
});
};

export const doSSOVerification = async ({ requestedLogin, serviceProviderURL }: SSOParams) => {
const redirectURL = await openIdPAndWaitForRedirectURL(serviceProviderURL, requestedLogin);
const ssoInfo = extractSsoInfoFromUrl(redirectURL);

if (requestedLogin !== ssoInfo.login) {
throw new Error('Login received from IdP does not match');
}

if (ssoInfo.currentAuths !== ssoInfo.expectedAuths) {
throw new Error('SSO Migration is not supported');
}

const ssoVerificationResult = await performSSOVerification({
login: ssoInfo.login,
ssoToken: ssoInfo.ssoToken,
});

return { ...ssoVerificationResult, ssoSpKey: ssoInfo.key };
};
13 changes: 13 additions & 0 deletions src/modules/auth/sso/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export enum SsoMigrationServerMethod {
SSO = 'sso',
MP = 'master_password',
}

export interface SSOAuthenticationInfo {
login: string;
ssoToken: string;
key: string;
exists: string;
currentAuths: SsoMigrationServerMethod;
expectedAuths: SsoMigrationServerMethod;
}
25 changes: 25 additions & 0 deletions src/modules/auth/sso/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { SSOAuthenticationInfo } from './types';

export const DASHLANE_APP_REGEX = /https:\/\/app.dashlane.com/;

/**
* Convert a URL into SSOAuthenticationInfo structure
* @param url Redirection URL provided by the IdP with required data for SSO login
* @returns SSOAuthenticationInfo data structure
*/
export const extractSsoInfoFromUrl = (url: URL): SSOAuthenticationInfo => {
// Data is available in URL hash, within the form of search parameters.
const search_params = new URLSearchParams(url.hash.substring(1));

// To be rewritten properly
return Object.fromEntries(
Object.entries({
login: '',
ssoToken: '',
key: '',
exists: '',
currentAuths: '',
expectedAuths: '',
}).map(([k]) => [k, search_params.get(k)])
) as unknown as SSOAuthenticationInfo;
};
44 changes: 44 additions & 0 deletions src/modules/crypto/buildSsoRemoteKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { decryptAesCbcHmac256 } from './decrypt';
import { deserializeEncryptedData } from './encryptedDataDeserialization';
import { RemoteKey } from './types';
import { xor } from './xor';

interface BuildSsoRemoteKey {
ssoServerKey: string | undefined;
ssoSpKey: string | undefined | null;
remoteKeys: RemoteKey[] | undefined;
}

export const decryptSsoRemoteKey = (params: BuildSsoRemoteKey) => {
const { ssoServerKey, ssoSpKey, remoteKeys } = params;

if (!ssoServerKey) {
throw new Error('SSO server key is missing');
}
if (!ssoSpKey) {
throw new Error('SSO Service Provider key is missing');
}
if (!remoteKeys || remoteKeys.length === 0) {
throw new Error('Remote keys are missing');
}

const remoteKey = remoteKeys.filter((key) => key.type === 'sso')[0];

if (!remoteKey) {
throw new Error('Remote SSO key is missing');
}

const ssoKeysXored = xor(Buffer.from(ssoServerKey, 'base64'), Buffer.from(ssoSpKey, 'base64'));

const remoteKeyBase64 = Buffer.from(remoteKey.key, 'base64');
const decodedBase64 = remoteKeyBase64.toString('ascii');
const { encryptedData } = deserializeEncryptedData(decodedBase64, remoteKeyBase64);

const decryptedRemoteKey = decryptAesCbcHmac256({
cipherData: encryptedData.cipherData,
originalKey: ssoKeysXored,
inflatedKey: true,
});

return decryptedRemoteKey.toString('base64');
};
40 changes: 34 additions & 6 deletions src/modules/crypto/decrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,31 @@ import { hmacSha256, sha512 } from './hash';
import { deserializeEncryptedData } from './encryptedDataDeserialization';
import { BackupEditTransaction, Secrets, SymmetricKeyGetter } from '../../types';

const decryptCipherData = (cipherData: CipherData, originalKey: Buffer): Buffer => {
const combinedKey = sha512(originalKey);
interface DecryptAesCbcHmac256Params {
/** The cipher data to decrypt */
cipherData: CipherData;
/** The original key used to encrypt the cipher data */
originalKey: Buffer;
/** If `true`, the originalKey is already inflated (should be 64 bytes long)
*
* If `false`, the originalKey is inflated using sha512
*/
inflatedKey?: boolean;
}

export const decryptAesCbcHmac256 = (params: DecryptAesCbcHmac256Params): Buffer => {
const { cipherData, originalKey, inflatedKey } = params;

let combinedKey: Buffer;
if (inflatedKey) {
if (originalKey.length !== 64) {
throw new Error(`crypto key must be 64 bytes long but is ${originalKey.length} bytes long`);
}
combinedKey = originalKey;
} else {
combinedKey = sha512(originalKey);
}

const cipheringKey = combinedKey.slice(0, 32);
const macKey = combinedKey.slice(32);

Expand All @@ -31,6 +54,7 @@ export const decrypt = async (encryptedAsBase64: string, symmetricKeyGetter: Sym
const { encryptedData, derivationMethodBytes } = deserializeEncryptedData(decodedBase64, buffer);

let symmetricKey: Buffer | undefined;

switch (symmetricKeyGetter.type) {
case 'alreadyComputed':
symmetricKey = symmetricKeyGetter.symmetricKey;
Expand All @@ -49,7 +73,11 @@ export const decrypt = async (encryptedAsBase64: string, symmetricKeyGetter: Sym
}
}

return decryptCipherData(encryptedData.cipherData, symmetricKey);
return decryptAesCbcHmac256({
cipherData: encryptedData.cipherData,
originalKey: symmetricKey,
inflatedKey: encryptedData.cipherConfig.cipherMode === 'cbchmac64',
});
};

export const decryptTransaction = async <TransactionContent>(
Expand Down Expand Up @@ -95,9 +123,9 @@ export const getDerivateUsingParametersFromEncryptedData = async (
32,
cipheringMethod.keyDerivation.hashMethod
);
case 'noderivation':
return Promise.resolve(Buffer.from(masterPassword, 'base64'));
default:
throw new Error(
`Impossible to compute derivate with derivation method '${cipheringMethod.keyDerivation.algo}'`
);
throw new Error('Impossible to compute derivate with derivation method');
}
};
2 changes: 1 addition & 1 deletion src/modules/crypto/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { serializeEncryptedData } from './encryptedDataSerialization';
import { hmacSha256, sha512 } from './hash';
import { EncryptedData } from './types';

export const encryptAES = (originalKey: Buffer, content: Buffer): string => {
export const encryptAesCbcHmac256 = (originalKey: Buffer, content: Buffer): string => {
const combinedKey = sha512(originalKey);
const cipheringKey = combinedKey.slice(0, 32);
const macKey = combinedKey.slice(32);
Expand Down
2 changes: 1 addition & 1 deletion src/modules/crypto/encryptedDataDeserialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const deserializeSymmetricCipherConfig = (

const mode = extractNextEncryptedDataStringComponent(encryptedDataString.substring(cursor));
cursor += mode.cursorAfter;
if (mode.component !== 'cbchmac') {
if (mode.component !== 'cbchmac' && mode.component !== 'cbchmac64') {
throw new Error(`Unrecognized cipher mode: ${mode.component}`);
}

Expand Down
Loading

0 comments on commit dea9710

Please sign in to comment.