-
Notifications
You must be signed in to change notification settings - Fork 12
[DO NOT MERGE] [RECINF-1032] FIPS support for BOYI #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
johnsontsegenesys
wants to merge
3
commits into
MyPureCloud:main
Choose a base branch
from
johnsontsegenesys:RECINF-1032-FIPS-support-for-BOYI
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
242 changes: 242 additions & 0 deletions
242
platform/integrations/byoi-integration-guide/byoiclient-encrypt-recording-cipherv2.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
|
|
||
| const platformClient = require('purecloud-platform-client-v2'); | ||
| const winston = require('winston'); | ||
| const fs = require('fs'); | ||
| const path = require('path') | ||
| const {spawn} = require('child_process'); | ||
|
|
||
| const clientId = process.env.GENESYS_CLOUD_CLIENT_ID; | ||
| const clientSecret = process.env.GENESYS_CLOUD_CLIENT_SECRET; | ||
|
|
||
| // Set the Genesys Cloud region | ||
| const client = platformClient.ApiClient.instance; | ||
| client.setEnvironment(platformClient.PureCloudRegionHosts.us_east_1); | ||
|
|
||
| // Create API instance | ||
| const recordingApi = new platformClient.RecordingApi(); | ||
|
|
||
| // Use winston for logging | ||
| const logger = winston.createLogger({ | ||
| level: 'info', | ||
| format: winston.format.combine( | ||
| winston.format.timestamp(), winston.format.prettyPrint() | ||
| ), | ||
| transports: [ | ||
| new winston.transports.File({ | ||
| filename: `./byoiclient-encrypt-recording.log`, | ||
| }), | ||
| // Output to console for error logs | ||
| new winston.transports.Console({ | ||
| level: 'error', | ||
| }) | ||
| ] | ||
| }); | ||
|
|
||
|
|
||
| /* Demonstrate the process to encrypt a recording: | ||
| * | ||
| * Login OAuth client with client ID and secret | ||
| * Fetch the most recent recording key | ||
| * Generate X509 certificate | ||
| * Encrypt the recording file using the certificate | ||
| * Catch and log any error | ||
| */ | ||
| // >> START byoi-generate-intermediate-key-cipherv2 | ||
| // Use OpenSSL 3.0.x to convert DER to RSA Public Key | ||
| async function convertDerToRSAPublicKey(opensslPath, derFileName, pemFileName) { | ||
| return new Promise((resolve, reject) => { | ||
| // openssl rsa -RSAPublicKey_in -in derFileName.der -inform DER -outform PEM -out pemFileName.pem | ||
| let parameters = [ | ||
| 'rsa', '-RSAPublicKey_in', | ||
| '-in', derFileName, | ||
| '-inform', 'DER', '-outform', 'PEM', | ||
| '-out', pemFileName | ||
| ]; | ||
|
|
||
| let opensslProcess = spawn(opensslPath, parameters); | ||
| opensslProcess.on('close', (code) => { | ||
| if (code == 0) { | ||
| resolve(true); | ||
| } else { | ||
| logger.error(`OpenSSL returned ${code} when converting public key`); | ||
| opensslProcess.stdout.pipe(process.stdout); | ||
| resolve(null); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Run an OpenSSL command and return a promise that resolves to true on success | ||
| function runOpenssl(opensslPath, parameters) { | ||
| return new Promise((resolve) => { | ||
| let opensslProcess = spawn(opensslPath, parameters); | ||
| opensslProcess.on('close', (code) => { | ||
| if (code == 0) { | ||
| resolve(true); | ||
| } else { | ||
| logger.error(`OpenSSL returned ${code} for command: openssl ${parameters[0]}`); | ||
| resolve(false); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Generate an X509 certificate embedding the Genesys public key, using OpenSSL (FIPS-validated module) with SHA-256 signing | ||
| async function generateX509Certificate(opensslPath, keyId, pemFileName, certFileName) { | ||
| let throwawayKeyFile = certFileName.replace('.cert.pem', '.throwaway.key'); | ||
| let csrFile = certFileName.replace('.cert.pem', '.csr'); | ||
|
|
||
| // Serial number must start with "00" and not contain dashes | ||
| let serialHex = '0x00' + keyId.replace(/-/g, ''); | ||
|
|
||
| // Step 1: Generate a throwaway 2048-bit RSA private key for certificate signing | ||
| let success = await runOpenssl(opensslPath, [ | ||
| 'genpkey', '-algorithm', 'RSA', | ||
| '-pkeyopt', 'rsa_keygen_bits:2048', | ||
| '-out', throwawayKeyFile | ||
| ]); | ||
| if (!success) return false; | ||
|
|
||
| // Step 2: Create a certificate signing request using the throwaway key | ||
| success = await runOpenssl(opensslPath, [ | ||
| 'req', '-new', | ||
| '-key', throwawayKeyFile, | ||
| '-subj', '/CN=recording-encryption', | ||
| '-out', csrFile | ||
| ]); | ||
| if (!success) return false; | ||
|
|
||
| // Step 3: Create self-signed cert with the Genesys public key forced in, signed with SHA-256 | ||
| success = await runOpenssl(opensslPath, [ | ||
| 'x509', '-req', | ||
| '-in', csrFile, | ||
| '-force_pubkey', pemFileName, | ||
| '-signkey', throwawayKeyFile, | ||
| '-set_serial', serialHex, | ||
| '-sha256', | ||
| '-days', '1', | ||
| '-out', certFileName | ||
| ]); | ||
|
|
||
| // Clean up throwaway intermediate files | ||
| try { fs.unlinkSync(throwawayKeyFile); } catch(e) { /* ignore */ } | ||
| try { fs.unlinkSync(csrFile); } catch(e) { /* ignore */ } | ||
|
|
||
| return success; | ||
| } | ||
| // >> END byoi-generate-intermediate-key-cipherv2 | ||
| // >> START byoi-perform-encryption-cipherv2 | ||
| // Invoke openssl command to encrypt the file using the certificate file (FIPS 140-3 compliant: AES-256-GCM + RSA-OAEP) | ||
| async function performEncryption(opensslPath, x509CertificateFilePath, originalFilePath, outputFilePath) { | ||
| return new Promise((resolve, reject) => { | ||
| // openssl cms -encrypt -aes-256-gcm -recip f36f8c85-8922-45fa-a3f1-d197d1176340.cert.pem -keyopt rsa_padding_mode:oaep -keyopt rsa_oaep_md:sha256 -in audio.opus -binary -outform DER -out audio.opus.bin | ||
| // The recipient certificate must be supplied with -recip and must appear BEFORE -keyopt. | ||
| // -keyopt applies to the preceding recipient, so passing the certificate as a trailing | ||
| // argument instead would make OpenSSL fail with "No key specified". | ||
| // rsa_oaep_md:sha256 is required as well as rsa_padding_mode:oaep. Without it OpenSSL omits the | ||
| // RSAESOAEPparams from the CMS envelope, which means SHA-1 by default (RFC 8017), and Genesys Cloud | ||
| // will not be able to decrypt the recording. Both flags require OpenSSL 3.0 or later. | ||
| let parameters = [ | ||
| 'cms', '-encrypt', | ||
| '-aes-256-gcm', | ||
| '-recip', x509CertificateFilePath, | ||
| '-keyopt', 'rsa_padding_mode:oaep', | ||
| '-keyopt', 'rsa_oaep_md:sha256', | ||
| '-in', originalFilePath, | ||
| '-binary', '-outform', 'DER', | ||
| '-out', outputFilePath | ||
| ]; | ||
|
|
||
| let opensslProcess = spawn(opensslPath, parameters); | ||
| opensslProcess.on('close', (code) => { | ||
| if (code == 0) { | ||
| resolve(true); | ||
| } else { | ||
| logger.error(`OpenSSL returned ${code} when performing encryption`); | ||
| opensslProcess.stdout.pipe(process.stdout); | ||
| resolve(false); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| // >> END byoi-perform-encryption-cipherv2 | ||
| // Encrypt recording file (e.g. recordingFile.opus => recordingFile.opus.bin) | ||
| async function encryptRecording(recordingAudioFile) { | ||
| try { | ||
|
|
||
| // Specify the path to OpenSSL executable. Please specify the absolute path if there are multiple OpenSSL versions. | ||
| // This example uses OpenSSL 3.0.7 to perform CMS encryption. Other equivalent CMS encryption tools should also work | ||
| // but these have not been tested. Run "openssl version" to get the version | ||
| const opensslPath = 'openssl'; | ||
|
|
||
| // Create folder for saving temp files | ||
| let workingDir = path.join(__dirname, 'temp'); | ||
|
|
||
| // Create the folders if they do not exist | ||
| if(!fs.existsSync(workingDir)) { | ||
| fs.mkdirSync(workingDir); | ||
| } | ||
| // >> START byoi-get-encryption-key-cipherv2 | ||
| // Login OAuth client | ||
| let response = await client.loginClientCredentialsGrant(clientId, clientSecret); | ||
| logger.verbose('Login successfully'); | ||
|
|
||
| // Fetch the most recent recording key by specifying page 1 with a page size of 1 | ||
| // Ensure that only a single key is returned | ||
| response = await recordingApi.getRecordingRecordingkeys({ | ||
| 'pageSize': 1, | ||
| 'pageNumber': 1 | ||
| }); | ||
| logger.verbose(response); | ||
| if(response.total !== 1) { | ||
| logger.error('Failed to retrieve public recording key'); | ||
| return false; | ||
| } | ||
|
|
||
| let keyId = response.entities[0].id; | ||
| let publicKey = response.entities[0].keydataSummary; | ||
| // >> END byoi-get-encryption-key-cipherv2 | ||
| // >> START byoi-encrypt-file-cipherv2 | ||
| // OpenSSL CMS encryption requires X509 certificate, so the public key needs to be converted to X509 certificate | ||
| // Save the public key to the binary DER as .der file | ||
| let derFileName = path.join(workingDir, keyId + '.der'); | ||
| let keyDer = Buffer.from(publicKey, 'base64'); | ||
| var stream = fs.createWriteStream(derFileName); | ||
| stream.write(keyDer); | ||
|
|
||
| // Convert DER to RSA Public Key and save it as .pem file | ||
| let pemFileName = path.join(workingDir, keyId + '.pem'); | ||
| await convertDerToRSAPublicKey(opensslPath, derFileName, pemFileName); | ||
|
|
||
| // Generate X509 certificate and save the certificate as .cert.pem file | ||
| let certFileName = path.join(workingDir, keyId + '.cert.pem'); | ||
| await generateX509Certificate(opensslPath, keyId, pemFileName, certFileName); | ||
| logger.verbose(`Generated X509 certificate file: ${certFileName}`); | ||
|
|
||
| // Perform CMS encryption using the certificate file | ||
| let fileToEncrypt = path.join(__dirname, recordingAudioFile); | ||
| let outputFile = fileToEncrypt + '.bin'; | ||
| let isFileEncrypted = await performEncryption(opensslPath, certFileName, fileToEncrypt, outputFile); | ||
| // >> END byoi-encrypt-file-cipherv2 | ||
| if(isFileEncrypted) { | ||
| logger.info(`File ${fileToEncrypt} has been encrypted into ${outputFile}`); | ||
| } | ||
| else { | ||
| logger.error(`Failed to encrypt file ${fileToEncrypt}`); | ||
| } | ||
| } | ||
| catch(err) { | ||
| // Directly logging an error in winston would result in empty string | ||
| if (err instanceof Error) { | ||
| logger.error(`${err.stack || err}`); | ||
| } | ||
| // Handle failure response | ||
| else { | ||
| logger.error(err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let recordingAudioFile = 'recordingAudio.opus'; | ||
|
|
||
| encryptRecording(recordingAudioFile); |
130 changes: 130 additions & 0 deletions
130
platform/integrations/byoi-integration-guide/byoiclient-upload-recordings-cipherv2.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| const platformClient = require('purecloud-platform-client-v2'); | ||
| const winston = require('winston'); | ||
|
|
||
| const fetch = require('node-fetch'); | ||
| const fs = require('fs'); | ||
| const md5 = require('md5-file'); | ||
| const archiver = require('archiver'); | ||
|
|
||
| const clientId = process.env.GENESYS_CLOUD_CLIENT_ID; | ||
| const clientSecret = process.env.GENESYS_CLOUD_CLIENT_SECRET; | ||
|
|
||
| // Set the Genesys Cloud region | ||
| const client = platformClient.ApiClient.instance; | ||
| client.setEnvironment(platformClient.PureCloudRegionHosts.us_east_1); | ||
|
|
||
| // Create API instance | ||
| const uploadsApi = new platformClient.UploadsApi(); | ||
|
|
||
| // Use winston for logging | ||
| const logger = winston.createLogger({ | ||
| level: 'info', | ||
| format: winston.format.combine( | ||
| winston.format.timestamp(), winston.format.prettyPrint() | ||
| ), | ||
| transports: [ | ||
| new winston.transports.File({ | ||
| filename: `./byoiclient-upload-recordings.log`, | ||
| }), | ||
| // Output to console for error logs | ||
| new winston.transports.Console({ | ||
| level: 'error', | ||
| }) | ||
| ] | ||
| }); | ||
|
|
||
|
|
||
| /* Demonstrate the process to upload recordings: | ||
| * | ||
| * Login OAuth client with client ID and secret | ||
| * Create zip with the recording and metadata file | ||
| * Load the zip file and create the MD5 hash | ||
| * Obtain a presigned URL | ||
| * Upload the file to the presigned URL | ||
| * Catch and log any error | ||
| */ | ||
|
|
||
| async function uploadRecordings(fileName) { | ||
| try { | ||
| let response = await client.loginClientCredentialsGrant(clientId, clientSecret); | ||
| logger.verbose('Login successfully'); | ||
|
|
||
| // >> START byoi-zip-file-cipherv2 | ||
| var output = fs.createWriteStream(fileName); | ||
| var archive = archiver('zip', { | ||
| store: true, | ||
| // uploaded zip file must be uncompressed, setting compression level to 0 | ||
| zlib: { level: 0 } | ||
| }); | ||
|
|
||
| archive.on('error', function(err) { | ||
| logger.error(err); | ||
| }); | ||
|
|
||
| archive.pipe(output); | ||
|
|
||
| // the recording and metadata file must be stored at the root level in the zip file | ||
| archive.file('./myfile.opus.bin', {name: 'myfile.opus.bin'}); | ||
| archive.file('./metadata.json', {name: 'metadata.json'}); | ||
|
|
||
| // wait for streams to complete | ||
| archive.finalize(); | ||
| // >> END byoi-zip-file-cipherv2 | ||
| // >> START byoi-get-presigned-url-cipherv2 | ||
| // Get base64-encoded 128-bit MD5 digest of the file content | ||
| let md5sum = await md5(fileName); | ||
| let md5Base64 = Buffer.from(md5sum, 'hex').toString('base64'); | ||
| logger.verbose(md5Base64); | ||
|
|
||
| // Get the presigned URL | ||
| // signedUrlTimeoutSeconds is optional for the number of seconds the presigned URL is valid for (1-604800). | ||
| // The default value is 600 seconds if it is not provided | ||
| response = await uploadsApi.postUploadsRecordings({ | ||
| 'fileName': fileName, | ||
| 'contentMd5': md5Base64, | ||
| 'signedUrlTimeoutSeconds': 600 | ||
| }); | ||
| logger.info(response); | ||
| // >> END byoi-get-presigned-url-cipherv2 | ||
| // Get the presigned URL from the response | ||
| let presignedUploadUrl = response.url; | ||
| // Save the headers in returned response for the following upload | ||
| let responseHeaders = response.headers; | ||
| // >> START byoi-upload-file-cipherv2 | ||
| // Upload the file to the presigned URL | ||
| const fileContent = fs.readFileSync(fileName); | ||
| logger.info(`Upload ${fileName} to the presigned URL`) | ||
| response = await fetch(presignedUploadUrl, { | ||
| method: 'PUT', | ||
| headers: responseHeaders, | ||
| body: fileContent | ||
| }); | ||
| let responseBody = await response.text(); | ||
| logger.info(responseBody); | ||
| return responseBody; | ||
| // >> END byoi-upload-file-cipherv2 | ||
| } | ||
| catch(err) { | ||
| // Directly logging an error in winston would result in empty string | ||
| if (err instanceof Error) { | ||
| logger.error(`${err.stack || err}`); | ||
| } | ||
| // Handle failure response | ||
| else { | ||
| logger.error(err); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // The recording file to be uploaded | ||
| /* Note: The recording file and metadata.json shall be at the top level of the zip file. | ||
| Zipping up the folder that contains the recording file and the metadata.json would cause a validation failure | ||
|
|
||
| recordingExample.zip | ||
| |--- audioExampleRecording.opus.bin | ||
| |--- metadata.json | ||
| */ | ||
|
|
||
| const fileName = 'recordingExample.zip'; | ||
|
|
||
| uploadRecordings(fileName); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is fixing a syntax problem with the existing example