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 895b605
Show file tree
Hide file tree
Showing 18 changed files with 998 additions and 9 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
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,
};
56 changes: 56 additions & 0 deletions src/modules/tunnel-api-connect/steps/sendSecureContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import sodium from 'libsodium-wrappers';
import { secureContentBodyDataSchema } from './schemas';
import type { SecureContentRequest, SecureContentResponse, SendSecureContentParams } from './types';
import { SecureTunnelNotInitialized, SendSecureContentDataDecryptionError } from '../errors';
import type { ApiConnectInternalParams, ApiData, ApiRequestsDefault } from '../types';
import { TypeCheck } from '../../typecheck';
import { requestAppApi } from '../../../requestApi';

const verifySendSecureBodySchemaValidator = new TypeCheck<SecureContentResponse>(secureContentBodyDataSchema);

export const encryptData = <P = any>(clientStateOut: sodium.StateAddress, payload: P) =>
sodium.crypto_secretstream_xchacha20poly1305_push(
clientStateOut,
sodium.from_string(JSON.stringify(payload)),
null,
sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE
);

export const sendSecureContent = async <R extends ApiRequestsDefault>(
params: ApiConnectInternalParams & SendSecureContentParams<R>,
apiData: Partial<ApiData>
): Promise<R['output']> => {
if (!apiData.clientHello) {
throw new SecureTunnelNotInitialized();
}

const { path, clientStateIn, clientStateOut, payload } = params;
const { tunnelUuid } = apiData.clientHello;

const encryptedData = encryptData(clientStateOut, payload);

const response = await requestAppApi<SecureContentResponse>({
path,
payload: {
encryptedData: sodium.to_hex(encryptedData),
tunnelUuid,
} satisfies SecureContentRequest,
isNitroEncryptionService: true,
});

const body = verifySendSecureBodySchemaValidator.validate(response);
if (body instanceof Error) {
throw body;
}

const decryptedResponse = sodium.crypto_secretstream_xchacha20poly1305_pull(
clientStateIn,
sodium.from_hex(body.encryptedData)
).message;

if (decryptedResponse === undefined) {
throw new SendSecureContentDataDecryptionError();
}

return JSON.parse(sodium.to_string(decryptedResponse)) as R['output'];
};
62 changes: 62 additions & 0 deletions src/modules/tunnel-api-connect/steps/terminateHello.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { verifyAttestation } from '@dashlane/nsm-attestation';
import sodium from 'libsodium-wrappers';
import { attestationUserDataSchema } from './schemas';
import { AttestationUserData, TerminateHelloParams, TerminateHelloRequest, TerminateHelloResponse } from './types';
import { ApiConnectInternalParams, ApiData } from '../types';
import { TypeCheck } from '../../typecheck';
import { requestAppApi } from '../../../requestApi';
import { SecureTunnelNotInitialized } from '../errors';

const verifyAttestationUserDataSchemaValidator = new TypeCheck<AttestationUserData>(attestationUserDataSchema);

export const terminateHello = async (
params: ApiConnectInternalParams & TerminateHelloParams,
apiData: Partial<ApiData>
): Promise<TerminateHelloResponse> => {
if (!apiData.clientHello) {
throw new SecureTunnelNotInitialized();
}

const { clientKeyPair, attestation, isProduction, enclavePcrList } = params;
const { tunnelUuid } = apiData.clientHello;

const { userData } = await verifyAttestation({
attestation,
useProductionCertificate: isProduction,
pcrs: enclavePcrList,
});

const userDataParsed = verifyAttestationUserDataSchemaValidator.parseAndValidate(userData.toString());

if (userDataParsed instanceof Error) {
throw userDataParsed;
}

const serverPublicKey = Buffer.from(userDataParsed.publicKey, 'base64');
const serverHeader = Buffer.from(userDataParsed.header, 'base64');

const sessionKeys = sodium.crypto_kx_client_session_keys(
clientKeyPair.publicKey,
clientKeyPair.privateKey,
serverPublicKey
);

const secretStream = sodium.crypto_secretstream_xchacha20poly1305_init_push(sessionKeys.sharedRx); // rx1
const clientStateOut = secretStream.state;
const clientHeader = secretStream.header;

const clientStateIn = sodium.crypto_secretstream_xchacha20poly1305_init_pull(serverHeader, sessionKeys.sharedTx);

const payload = {
clientHeader: sodium.to_hex(clientHeader),
tunnelUuid,
} satisfies TerminateHelloRequest;

await requestAppApi<TerminateHelloRequest>({
path: `tunnel/TerminateHello`,
payload,
isNitroEncryptionService: true,
});

return { clientStateIn, clientStateOut, sessionKeys, serverPublicKey, serverHeader };
};
Loading

0 comments on commit 895b605

Please sign in to comment.