Skip to content
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

Secrets encryption from CLI #141

Merged
merged 6 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/commands/api-mesh/__tests__/create.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ governing permissions and limitations under the License.
*/

const mockConsoleCLIInstance = {};
const crypto = require('crypto');

jest.mock('axios');
jest.mock('@adobe/aio-lib-ims');
Expand Down Expand Up @@ -44,6 +45,7 @@ const {
createAPIMeshCredentials,
subscribeCredentialToMeshService,
getTenantFeatures,
getPublicEncryptionKey,
} = require('../../../lib/devConsole');

const selectedOrg = { id: '1234', code: 'CODE1234@AdobeOrg', name: 'ORG01', type: 'entp' };
Expand Down Expand Up @@ -74,6 +76,7 @@ jest.mock('chalk', () => ({
red: jest.fn(text => text), // Return the input text without any color formatting
bold: jest.fn(text => text),
}));
jest.mock('crypto');

let logSpy = null;
let errorLogSpy = null;
Expand All @@ -83,6 +86,15 @@ let platformSpy = null;
const mockIgnoreCacheFlag = Promise.resolve(true);
const mockAutoApproveAction = Promise.resolve(false);

// Mock randomBytes for aesKey and iv
const mockAesKey = Buffer.from('mockAesKey');
const mockIv = Buffer.from('mockIv');
const mockEncryptedAesKey = Buffer.from('mockEncryptedAesKey');
const mockCipher = {
update: jest.fn().mockReturnValueOnce('mockEncryptedData'),
final: jest.fn().mockReturnValueOnce(''),
};

describe('create command tests', () => {
beforeEach(() => {
initSdk.mockResolvedValue({
Expand Down Expand Up @@ -127,6 +139,7 @@ describe('create command tests', () => {
showCloudflareURL: false,
});

getPublicEncryptionKey.mockResolvedValue('dummy_public_key');
global.requestId = 'dummy_request_id';

logSpy = jest.spyOn(CreateCommand.prototype, 'log');
Expand Down Expand Up @@ -1945,6 +1958,10 @@ describe('create command tests', () => {
},
});

crypto.randomBytes.mockReturnValueOnce(mockAesKey).mockReturnValueOnce(mockIv);
crypto.createCipheriv.mockReturnValueOnce(mockCipher);
crypto.publicEncrypt.mockReturnValueOnce(mockEncryptedAesKey);

const runResult = await CreateCommand.run();
expect(runResult).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -2015,6 +2032,10 @@ describe('create command tests', () => {
},
});

crypto.randomBytes.mockReturnValueOnce(mockAesKey).mockReturnValueOnce(mockIv);
crypto.createCipheriv.mockReturnValueOnce(mockCipher);
crypto.publicEncrypt.mockReturnValueOnce(mockEncryptedAesKey);

const runResult = await CreateCommand.run();
expect(runResult).toMatchInlineSnapshot(`
{
Expand Down Expand Up @@ -2052,6 +2073,10 @@ describe('create command tests', () => {
},
});

crypto.randomBytes.mockReturnValueOnce(mockAesKey).mockReturnValueOnce(mockIv);
crypto.createCipheriv.mockReturnValueOnce(mockCipher);
crypto.publicEncrypt.mockReturnValueOnce(mockEncryptedAesKey);

const runResult = await CreateCommand.run();
expect(runResult).toMatchInlineSnapshot(`
{
Expand All @@ -2077,4 +2102,31 @@ describe('create command tests', () => {
}
`);
});

test('should return error if secrets file is valid but public key for encryption is empty', async () => {
parseSpy.mockResolvedValueOnce({
args: { file: 'src/commands/__fixtures__/sample_secrets_mesh.json' },
flags: {
ignoreCache: mockIgnoreCacheFlag,
autoConfirmAction: Promise.resolve(true),
secrets: 'src/commands/__fixtures__/secrets_valid.yaml',
},
});
getPublicEncryptionKey.mockResolvedValue('');

crypto.randomBytes.mockReturnValueOnce(mockAesKey).mockReturnValueOnce(mockIv);
crypto.createCipheriv.mockReturnValueOnce(mockCipher);

const runResult = CreateCommand.run();
await expect(runResult).rejects.toEqual(
new Error('Unable to import secrets. Please check the file and try again.'),
);
expect(logSpy.mock.calls).toMatchInlineSnapshot(`
[
[
"Unable to encrypt secerts. Invalid Public Key.",
],
]
`);
});
});
8 changes: 6 additions & 2 deletions src/commands/api-mesh/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ const {
validateAndInterpolateMesh,
interpolateSecrets,
validateSecretsFile,
encryptSecrets,
} = require('../../utils');
const { createMesh, getTenantFeatures } = require('../../lib/devConsole');
const { createMesh, getTenantFeatures, getPublicEncryptionKey } = require('../../lib/devConsole');
const { buildEdgeMeshUrl, buildMeshUrl } = require('../../urlBuilder');

class CreateCommand extends Command {
Expand Down Expand Up @@ -112,7 +113,10 @@ class CreateCommand extends Command {
if (secretsFilePath) {
try {
await validateSecretsFile(secretsFilePath);
data.secrets = await interpolateSecrets(secretsFilePath, this);
const secretsData = await interpolateSecrets(secretsFilePath, this);
const publicKey = await getPublicEncryptionKey(imsOrgCode);
const encryptedSecrets = await encryptSecrets(publicKey, secretsData);
data.secrets = encryptedSecrets;
} catch (err) {
this.log(err.message);
this.error('Unable to import secrets. Please check the file and try again.');
Expand Down
24 changes: 17 additions & 7 deletions src/commands/api-mesh/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ const {
getFilesInMeshConfig,
interpolateSecrets,
validateSecretsFile,
encryptSecrets,
} = require('../../utils');
const { getMeshId, updateMesh } = require('../../lib/devConsole');
const { getMeshId, updateMesh, getPublicEncryptionKey } = require('../../lib/devConsole');

class UpdateCommand extends Command {
static args = [{ name: 'file' }];
Expand Down Expand Up @@ -54,11 +55,17 @@ class UpdateCommand extends Command {
const envFilePath = await flags.env;
const secretsFilePath = await flags.secrets;

const { imsOrgId, projectId, workspaceId, orgName, projectName, workspaceName } = await initSdk(
{
ignoreCache,
},
);
const {
imsOrgId,
imsOrgCode,
projectId,
workspaceId,
orgName,
projectName,
workspaceName,
} = await initSdk({
ignoreCache,
});

//Input the mesh data from the input file
let inputMeshData = await readFileContents(args.file, this, 'mesh');
Expand Down Expand Up @@ -112,7 +119,10 @@ class UpdateCommand extends Command {
if (secretsFilePath) {
try {
await validateSecretsFile(secretsFilePath);
data.secrets = await interpolateSecrets(secretsFilePath, this);
const secretsData = await interpolateSecrets(secretsFilePath, this);
const publicKey = await getPublicEncryptionKey(imsOrgCode);
const encryptedSecrets = await encryptSecrets(publicKey, secretsData);
data.secrets = encryptedSecrets;
} catch (err) {
this.log(err.message);
this.error('Unable to import secrets. Please check the file and try again.');
Expand Down
48 changes: 48 additions & 0 deletions src/lib/devConsole.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const fs = require('fs');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const contentDisposition = require('content-disposition');
const chalk = require('chalk');

const { DEV_CONSOLE_TRANSPORTER_API_KEY, SMS_BASE_URL } = CONSTANTS;

Expand Down Expand Up @@ -968,6 +969,52 @@ const getMeshDeployments = async (organizationCode, projectId, workspaceId, mesh
}
};

/**
* Gets the public key to encrypt secrets.
*
* This request bypasses the Dev Console and is sent directly to the Schema Management Service.
* As a result, we provide the publicKey used for secrets encryption.
* The near-term goal is to stop using Dev Console as a proxy for all routes.
* @param organizationCode
* @returns string
*/
const getPublicEncryptionKey = async organizationCode => {
const { accessToken, apiKey } = await getDevConsoleConfig();
const config = {
method: 'get',
url: `${SMS_BASE_URL}/organizations/${organizationCode}/getPublicKey?API_KEY=${apiKey}`,
headers: {
'Authorization': `Bearer ${accessToken}`,
'x-request-id': global.requestId,
},
};
logger.info(
'Initiating GET %s',
`${SMS_BASE_URL}/organizations/${organizationCode}/getPublicKey?API_KEY=${apiKey}`,
);
try {
const response = await axios(config);

logger.info('Response from GET %s', response.status);
if (response.status == 200) {
let publicKey = '';
logger.info(`Public key for encryption: ${objToString(response, ['data'])}`);
if (response.data.publicKey) {
publicKey = response.data.publicKey.replace(/\\n/g, '\n'); //correcting public key format
}
return publicKey;
} else {
let errorMessage = `Failed to load encryption keys. Please contact support.`;
logger.error(`${errorMessage}. Received ${response.status}, expected 200`);
throw new Error(chalk.red(errorMessage));
}
} catch (error) {
let errorMessage = `Something went wrong while encrypting secrets. Please try again.`;
logger.error(errorMessage);
throw new Error(chalk.red(errorMessage));
}
};

module.exports = {
getApiKeyCredential,
describeMesh,
Expand All @@ -984,4 +1031,5 @@ module.exports = {
getMeshArtifact,
getTenantFeatures,
getMeshDeployments,
getPublicEncryptionKey,
};
41 changes: 41 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const YAML = require('yaml');
const parseEnv = require('envsub/js/envsub-parser');
const os = require('os');
const chalk = require('chalk');
const crypto = require('crypto');

/**
* @returns returns the root directory of the project
Expand Down Expand Up @@ -492,6 +493,45 @@ function getSecretsYamlParseError(error) {
}
}

/**
* Performs hybrid encryption of secrets(AES + RSA)
*
* @param publicKey Public key for (AES + RSA) encryption
* @param secrets Secrets Data that needs encryption
*/
async function encryptSecrets(publicKey, secrets) {
if (!publicKey || typeof publicKey !== 'string' || !publicKey.trim()) {
throw new Error(chalk.red('Unable to encrypt secerts. Invalid Public Key.'));
}
try {
// Generate a random AES key and IV
const aesKey = crypto.randomBytes(32); // 256-bit key for AES-256
const iv = crypto.randomBytes(16); // Initialization vector
// Encrypt the secrets using AES-256-CBC
const cipher = crypto.createCipheriv('aes-256-cbc', aesKey, iv);
let encryptedData = cipher.update(secrets, 'utf8', 'base64');
encryptedData += cipher.final('base64');
// Encrypt the AES key using RSA with OAEP padding
const encryptedAesKey = crypto.publicEncrypt(
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
},
aesKey,
);
// Package the encrypted AES key, IV, and encrypted data
const encryptedPackage = {
iv: iv.toString('base64'),
key: encryptedAesKey.toString('base64'),
data: encryptedData,
};
return JSON.stringify(encryptedPackage);
} catch (error) {
logger.error('Unable to encrypt secrets. Please try again. :', error.message);
throw new Error(`Unable to encrypt secerts. ${error.message}`);
}
}

module.exports = {
ignoreCacheFlag,
autoConfirmActionFlag,
Expand All @@ -509,4 +549,5 @@ module.exports = {
secretsFlag,
interpolateSecrets,
validateSecretsFile,
encryptSecrets,
};
Loading