Skip to content

Commit

Permalink
Support Confidential SSO login
Browse files Browse the repository at this point in the history
  • Loading branch information
Mikescops committed May 17, 2024
1 parent 52b8d53 commit 3707dfe
Show file tree
Hide file tree
Showing 22 changed files with 1,110 additions and 15 deletions.
14 changes: 10 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
"lint": "eslint src",
"format": "prettier --write src && eslint --fix src",
"start": "node dist/index.cjs",
"pkg:linux": "pkg . -t node18-linux-x64 -o bundle/dcli-linux -C GZip --public --public-packages tslib,thirty-two --no-bytecode",
"pkg:macos": "pkg . -t node18-macos-x64 -o bundle/dcli-macos -C GZip --public --public-packages tslib,thirty-two --no-bytecode",
"pkg:macos-arm": "pkg . -t node18-macos-arm64 -o bundle/dcli-macos-arm -C GZip --public --public-packages tslib,thirty-two --no-bytecode",
"pkg:win": "pkg . -t node18-win-x64 -o bundle/dcli-win.exe -C GZip --public --public-packages tslib,thirty-two --no-bytecode",
"pkg:linux": "pkg . -t node18-linux-x64 -o bundle/dcli-linux -C GZip --public --public-packages tslib,thirty-two,node-hkdf-sync,vows --no-bytecode",
"pkg:macos": "pkg . -t node18-macos-x64 -o bundle/dcli-macos -C GZip --public --public-packages tslib,thirty-two,node-hkdf-sync,vows --no-bytecode",
"pkg:macos-arm": "pkg . -t node18-macos-arm64 -o bundle/dcli-macos-arm -C GZip --public --public-packages tslib,thirty-two,node-hkdf-sync,vows --no-bytecode",
"pkg:win": "pkg . -t node18-win-x64 -o bundle/dcli-win.exe -C GZip --public --public-packages tslib,thirty-two,node-hkdf-sync,vows --no-bytecode",
"pkg": "yarn run build && yarn run pkg:linux && yarn run pkg:macos && yarn run pkg:win",
"version:bump": "ts-node src/bumpVersion.ts",
"prepare": "husky",
Expand All @@ -40,6 +40,7 @@
"contributors": [],
"license": "Apache-2.0",
"nativeDependencies": {
"@dashlane/nsm-attestation": "*",
"better-sqlite3": "*",
"@json2csv/plainjs": "*",
"@json2csv/transforms": "*",
Expand All @@ -54,6 +55,7 @@
"@types/better-sqlite3": "^7.6.10",
"@types/chai": "^4.3.16",
"@types/inquirer": "^9.0.7",
"@types/libsodium-wrappers": "^0",
"@types/mocha": "^10.0.6",
"@types/node": "^18.19.33",
"@typescript-eslint/eslint-plugin": "^7.8.0",
Expand All @@ -71,17 +73,21 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@dashlane/nsm-attestation": "^1.0.1",
"@json2csv/plainjs": "^7.0.6",
"@json2csv/transforms": "^7.0.6",
"@napi-rs/clipboard": "^1.1.2",
"@napi-rs/keyring": "^1.1.6",
"@node-rs/argon2": "^1.8.3",
"ajv": "^8.13.0",
"ajv-formats": "^3.0.1",
"better-sqlite3": "^10.0.0",
"commander": "^12.0.0",
"got": "^14.2.1",
"inquirer": "^9.2.21",
"inquirer-search-list": "^1.2.6",
"jsonpath-plus": "^9.0.0",
"libsodium-wrappers": "^0.7.13",
"node-mac-auth": "^1.0.0",
"otplib": "^12.0.1",
"playwright-core": "^1.44.0",
Expand Down
5 changes: 5 additions & 0 deletions src/modules/auth/confidential-sso/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class SAMLResponseNotFound extends Error {
constructor() {
super('SAML Response not found');
}
}
61 changes: 61 additions & 0 deletions src/modules/auth/confidential-sso/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { chromium } from 'playwright-core';
import { ConfirmLogin2Request, RequestLogin2Request } from './types';
import { SAMLResponseNotFound } from './errors';
import { apiConnect } from '../../tunnel-api-connect';
import { performSSOVerification } from '../../../endpoints/performSSOVerification';

interface ConfidentialSSOParams {
requestedLogin: string;
}

export const doConfidentialSSOVerification = async ({ requestedLogin }: ConfidentialSSOParams) => {
const api = await apiConnect({ isProduction: true, enclavePcrList: [] });
const requestLoginResponse = await api.sendSecureContent<RequestLogin2Request>({
...api,
path: 'authentication/RequestLogin2',
payload: { login: requestedLogin },
});

const { idpAuthorizeUrl, spCallbackUrl, teamUuid, domainName } = requestLoginResponse;

const browser = await chromium.launch({ headless: false, channel: 'chrome' });
const context = await browser.newContext();
const page = await context.newPage();

await page.goto(idpAuthorizeUrl);

let samlResponseData;
const samlResponsePromise = new Promise((resolve) => {
page.on('request', (req) => {
const reqURL = req.url();
if (reqURL === spCallbackUrl) {
samlResponseData = req.postData();
if (browser) {
void browser.close();
}
resolve(undefined);
}
});
});

await samlResponsePromise;

const samlResponse = new URLSearchParams(samlResponseData).get('SAMLResponse');

if (!samlResponse) {
throw new SAMLResponseNotFound();
}

const confirmLoginResponse = await api.sendSecureContent<ConfirmLogin2Request>({
...api,
path: 'authentication/ConfirmLogin2',
payload: { teamUuid, domainName, samlResponse },
});

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

return { ...ssoVerificationResult, ssoSpKey: confirmLoginResponse.userServiceProviderKey };
};
37 changes: 37 additions & 0 deletions src/modules/auth/confidential-sso/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface RequestLogin2Data {
login: string;
}

export interface RequestLogin2Output {
domainName: string;
idpAuthorizeUrl: string;
spCallbackUrl: string;
teamUuid: string;
validatedDomains: string[];
}

export interface RequestLogin2Request {
path: 'authentication/RequestLogin2';
input: RequestLogin2Data;
output: RequestLogin2Output;
}

export interface ConfirmLogin2Data {
teamUuid: string;
domainName: string;
samlResponse: string;
}

export interface ConfirmLogin2Output {
ssoToken: string;
userServiceProviderKey: string;
exists: boolean;
currentAuthenticationMethods: string[];
expectedAuthenticationMethods: string[];
}

export interface ConfirmLogin2Request {
path: 'authentication/ConfirmLogin2';
input: ConfirmLogin2Data;
output: ConfirmLogin2Output;
}
15 changes: 9 additions & 6 deletions src/modules/auth/registerDevice.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import winston from 'winston';
import { doSSOVerification } from './sso';
import { doConfidentialSSOVerification } from './confidential-sso';
import {
completeDeviceRegistration,
performDashlaneAuthenticatorVerification,
Expand Down Expand Up @@ -63,13 +64,15 @@ export const registerDevice = async (params: RegisterDevice) => {
}));
} else if (selectedVerificationMethod.type === 'sso') {
if (selectedVerificationMethod.ssoInfo.isNitroProvider) {
throw new Error('Confidential SSO is currently not supported');
({ authTicket, ssoSpKey } = await doConfidentialSSOVerification({
requestedLogin: login,
}));
} else {
({ authTicket, ssoSpKey } = await doSSOVerification({
requestedLogin: login,
serviceProviderURL: selectedVerificationMethod.ssoInfo.serviceProviderUrl,
}));
}

({ authTicket, ssoSpKey } = await doSSOVerification({
requestedLogin: login,
serviceProviderURL: selectedVerificationMethod.ssoInfo.serviceProviderUrl,
}));
} else {
throw new Error('Auth verification method not supported: ' + selectedVerificationMethod.type);
}
Expand Down
47 changes: 47 additions & 0 deletions src/modules/tunnel-api-connect/apiconnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as sodium from 'libsodium-wrappers';
import { clientHello, terminateHello, SendSecureContentParams, sendSecureContent } from './steps';
import { ApiConnectParams, ApiConnect, ApiData, ApiRequestsDefault } from './types';
import { makeClientKeyPair, makeOrRefreshSession } from './utils';

/** Type predicates
* https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates
*
* From Partial<ApiData> to ApiData
*/
const hasFullApiData = (data: Partial<ApiData>): data is ApiData => {
if (data.clientHello && data.terminateHello) {
return true;
}
return false;
};

/** Return an object that can be used to send secure content through the tunnel
*/
export const apiConnect = async (apiParametersIn: ApiConnectParams): Promise<ApiConnect> => {
await sodium.ready;

const apiParameters = {
...apiParametersIn,
...{ clientKeyPair: apiParametersIn.clientKeyPair ?? makeClientKeyPair() },
};

const apiData: Partial<ApiData> = {};
const api: ApiConnect = {
apiData,
apiParameters,
clientHello: () => clientHello(apiParameters),
terminateHello: ({ attestation }: { attestation: Buffer }, apiData: Partial<ApiData>) =>
terminateHello({ ...apiParameters, attestation }, apiData),
makeOrRefreshSession,
sendSecureContent: async <R extends ApiRequestsDefault>(
params: Pick<SendSecureContentParams<R>, 'path' | 'payload'>
) => {
await api.makeOrRefreshSession({ api, apiData });
if (!hasFullApiData(apiData)) {
throw new Error('ShouldNotHappen');
}
return sendSecureContent({ ...apiParameters, ...apiData.terminateHello, ...params }, apiData);
},
};
return api;
};
30 changes: 30 additions & 0 deletions src/modules/tunnel-api-connect/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export class HTTPError extends Error {
constructor(
readonly statusCode: number,
readonly message: string
) {
super(`HTTP error: ${statusCode}`);
}
}

export class ApiError extends Error {
constructor(
readonly status: string,
readonly code: string,
readonly message: string
) {
super(`Api error: ${code}`);
}
}

export class SecureTunnelNotInitialized extends Error {
constructor() {
super('Secure tunnel not initialized');
}
}

export class SendSecureContentDataDecryptionError extends Error {
constructor() {
super('Send secure content data decryption error');
}
}
1 change: 1 addition & 0 deletions src/modules/tunnel-api-connect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './apiconnect';
32 changes: 32 additions & 0 deletions src/modules/tunnel-api-connect/steps/clientHello.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import sodium from 'libsodium-wrappers';
import type { ClientHelloParsedResponse, ClientHelloRequest, ClientHelloResponse } from './types';
import { clientHelloResponseSchema } from './schemas';
import type { ApiConnectInternalParams } from '../types';
import { TypeCheck, TypeCheckError } from '../../typecheck';
import { requestAppApi } from '../../../requestApi';

export const clientHelloRequestSchemaValidator = new TypeCheck<ClientHelloResponse>(clientHelloResponseSchema);

export const clientHello = async (params: ApiConnectInternalParams): Promise<ClientHelloParsedResponse> => {
const { clientKeyPair } = params;

const payload = {
clientPublicKey: sodium.to_hex(clientKeyPair.publicKey),
} satisfies ClientHelloRequest;

const response = await requestAppApi<ClientHelloResponse>({
path: `tunnel/ClientHello`,
payload,
isNitroEncryptionService: true,
});

const validated = clientHelloRequestSchemaValidator.validate(response);
if (validated instanceof TypeCheckError) {
throw validated;
}

return {
attestation: Buffer.from(validated.attestation, 'hex'),
tunnelUuid: validated.tunnelUuid,
};
};
4 changes: 4 additions & 0 deletions src/modules/tunnel-api-connect/steps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './clientHello';
export * from './sendSecureContent';
export * from './terminateHello';
export * from './types';
59 changes: 59 additions & 0 deletions src/modules/tunnel-api-connect/steps/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { JSONSchema4 } from 'json-schema';

/**
* https://docs.aws.amazon.com/enclaves/latest/user/verify-root.html
* Attestation document specification
* - user_data = bytes .size (0..1024)
* To accommodate base64 encoding 1024 * 1.3 ~= 1332
*/
export const attestationUserDataSchema: JSONSchema4 = {
type: 'object',
description: 'User data from verifyAttestation',
properties: {
publicKey: {
type: 'string',
base64: true,
maxLength: 1500,
minLength: 4,
},
header: {
type: 'string',
base64: true,
maxLength: 1500,
minLength: 4,
},
},
required: ['publicKey', 'header'],
additionalProperties: false,
};

export const clientHelloResponseSchema: JSONSchema4 = {
type: 'object',
properties: {
attestation: {
type: 'string',
pattern: '^[A-Fa-f0-9]+$',
description: 'NSM enclave attestation in hexadecimal format',
},
tunnelUuid: {
type: 'string',
description: 'The UUID of the tunnel used for the cryptographic session',
},
},
required: ['attestation', 'tunnelUuid'],
additionalProperties: false,
};

export const secureContentBodyDataSchema: JSONSchema4 = {
type: 'object',
description: 'Send secure content data',
properties: {
encryptedData: {
type: 'string',
// TODO: Extends AJV with an `encoding` keyword to support base64 | hex
pattern: '^[A-Fa-f0-9]+$',
},
},
required: ['encryptedData'],
additionalProperties: false,
};
Loading

0 comments on commit 3707dfe

Please sign in to comment.