зеркало из
1
0
Форкнуть 0
opensource-management-portal/lib/encryption.ts

509 строки
19 KiB
TypeScript

//
// Copyright (c) Microsoft.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//
// This is a Node.js implementation of client-side table entity encryption,
// compatible with the official Azure storage .NET library. Note that the
// original .NET library had a major logic bug in the ordering of parameters
// prior to encryption which persists to today and that logic is the same
// here as a result.
import crypto from 'crypto';
import jose from 'node-jose';
import { IKeyVaultSecretResolver } from './keyVaultResolver';
export interface IEncryptionOptions {
keyEncryptionKeyId?: string;
keyResolver?: IKeyVaultSecretResolver;
encryptionResolver?: object;
encryptedPropertyNames?: Set<string>;
binaryProperties?: 'buffer' | 'base64';
keyEncryptionKeys?: object;
tableDehydrator?: (instance: any) => any;
tableRehydrator?: (partitionKey: string, rowKey: string, obj?: any, callback?: any) => any;
}
// ----------------------------------------------------------------------------
// Azure Storage .NET client library - entity encryption keys:
//
// Key: _ClientEncryptionMetadata1
// Type: JSON stringified object
// Purpose: Contains information about the client encryption agent used to
// encrypt the entity. Contains a uniquely generated content
// encryption key for the specific row of data.
// Constant: tableEncryptionKeyDetails
//
// Key: _ClientEncryptionMetadata2
// Type: Binary buffer
// Purpose: Encrypted JSON stringified object containing the list of encrypted
// fields in the entity.
// Constant: tableEncryptionPropertyDetails
// ----------------------------------------------------------------------------
const tableEncryptionPropertyDetails = '_ClientEncryptionMetadata2';
const tableEncryptionKeyDetails = '_ClientEncryptionMetadata1';
// ----------------------------------------------------------------------------
// Azure Storage encryption agent values: as implemented today, the encryption
// agent for .NET is of version 1.0; initialization vectors are 16-bytes,
// CEKs are 32-bytes, etc. The agent includes the AES algorithm used for
// content keys, but the algorithm is the .NET framework-recognized value and
// not the OpenSSL defined constant. We maintain a map therefore to map
// between the two, but only those which are currently supported by the .NET
// Azure Storage library.
// ----------------------------------------------------------------------------
const azureStorageEncryptionAgentProtocol = '1.0';
const azureStorageKeyWrappingAlgorithm = 'A256KW';
const azureStorageContentEncryptionIVBytes = 16;
const azureStorageContentEncryptionKeyBytes = 32;
const azureStorageEncryptionAgentEncryptionAlgorithm = 'AES_CBC_256'; /* .NET value */
const mapDotNetFrameworkToOpenSslAlgorithm = new Map([
[azureStorageEncryptionAgentEncryptionAlgorithm, 'aes-256-cbc'],
]);
function openSslFromNetFrameworkAlgorithm(algorithm) {
const openSslAlgorithm = mapDotNetFrameworkToOpenSslAlgorithm.get(algorithm);
if (openSslAlgorithm === undefined) {
throw new Error(
`The OpenSSL algorithm constant for the .NET Framework value "${algorithm}" is not defined or tested.`
);
}
return openSslAlgorithm;
}
// ----------------------------------------------------------------------------
// Hash, encrypt, decrypt, wrap, unwrap and key generation routines
// ----------------------------------------------------------------------------
function getSha256Hash(buffer) {
return crypto.createHash('sha256').update(buffer).digest();
}
function encryptValue(contentEncryptionKey, iv, value) {
const cipher = crypto.createCipheriv(
openSslFromNetFrameworkAlgorithm(azureStorageEncryptionAgentEncryptionAlgorithm),
contentEncryptionKey,
iv
);
return Buffer.concat([cipher.update(value), cipher.final()]);
}
function decryptValue(algorithm, contentEncryptionKey, iv, encryptedValue) {
const decipher = crypto.createDecipheriv(algorithm, contentEncryptionKey, iv);
return Buffer.concat([decipher.update(encryptedValue), decipher.final()]);
}
function generate32bitKey(callback) {
crypto.randomBytes(azureStorageContentEncryptionKeyBytes, callback);
}
type ContentEncryptionPair = {
contentEncryptionIV: Buffer;
contentEncryptionKey: any;
};
function generateContentEncryptionKey(): Promise<ContentEncryptionPair> {
return new Promise((resolve, reject) => {
crypto.randomBytes(azureStorageContentEncryptionIVBytes, (cryptoError, contentEncryptionIV) => {
if (cryptoError) {
return reject(cryptoError);
}
generate32bitKey((createKeyError, contentEncryptionKey) => {
if (createKeyError) {
return reject(createKeyError);
}
return resolve({ contentEncryptionIV, contentEncryptionKey });
});
});
});
}
async function wrapContentKey(keyWrappingAlgorithm, keyEncryptionKey, contentEncryptionKey) {
const result = await jose.JWA.encrypt(keyWrappingAlgorithm, keyEncryptionKey, contentEncryptionKey);
return result.data;
}
async function unwrapContentKey(keyWrappingAlgorithm, keyEncryptionKey, wrappedContentKeyEncryptedKey) {
const contentEncryptionKey = await jose.JWA.decrypt(
keyWrappingAlgorithm,
keyEncryptionKey,
wrappedContentKeyEncryptedKey
);
return contentEncryptionKey;
}
// ----------------------------------------------------------------------------
// Azure encryption metadata object
// ----------------------------------------------------------------------------
function createEncryptionData(keyId, wrappedContentEncryptionKey, contentEncryptionIV, keyWrappingAlgorithm) {
const encryptionData = {
/* PascalCase object per the .NET library */
WrappedContentKey: {
KeyId: keyId,
EncryptedKey: base64StringFromBuffer(wrappedContentEncryptionKey),
Algorithm: keyWrappingAlgorithm,
},
EncryptionAgent: {
Protocol: azureStorageEncryptionAgentProtocol,
EncryptionAlgorithm: azureStorageEncryptionAgentEncryptionAlgorithm,
},
ContentEncryptionIV: base64StringFromBuffer(contentEncryptionIV),
KeyWrappingMetadata: {},
};
return encryptionData;
}
function validateEncryptionData(encryptionData) {
if (!encryptionData || !encryptionData.EncryptionAgent) {
throw new Error('No encryption data or encryption data agent.');
}
const agent = encryptionData.EncryptionAgent;
if (!agent.Protocol) {
throw new Error('Encryption agent protocol version must be present in the encryption data properties.');
}
if (agent.Protocol !== azureStorageEncryptionAgentProtocol) {
throw new Error(
`Encryption agent value "${agent.EncryptionAgent}" is not recognized or tested with this library.`
);
}
if (!agent.EncryptionAlgorithm) {
throw new Error('Encryption algorithm type must be present in the encryption data properties.');
}
if (!mapDotNetFrameworkToOpenSslAlgorithm.get(agent.EncryptionAlgorithm)) {
throw new Error(
`Encryption agent value "${agent.EncryptionAgent}" is not recognized or tested with this library.`
);
}
}
async function resolveKeyEncryptionKeyFromOptions(encryptionOptions: IEncryptionOptions, keyId: string) {
if (!encryptionOptions) {
throw new Error('Encryption options must be specified.');
}
if (!keyId) {
throw new Error('No key encryption key ID provided.');
}
if (
(!encryptionOptions.keyEncryptionKeys || typeof encryptionOptions.keyEncryptionKeys !== 'object') &&
(!encryptionOptions.keyResolver || typeof encryptionOptions.keyResolver !== 'function')
) {
throw new Error(
'Encryption options must provide either a "keyResolver" function or "keyEncryptionKeys" object.'
);
}
const resolver =
encryptionOptions.keyResolver ||
async function (keyId: string) {
const key = encryptionOptions.keyEncryptionKeys[keyId];
return key;
};
const key = await resolver(keyId);
if (!key) {
throw new Error(`We were not able to retrieve a key with identifier "${keyId}".`);
}
return bufferFromBase64String(key);
}
// ----------------------------------------------------------------------------
// Compute Truncated Column Hash:
// Each encrypted entity (row) has its own content encryption key, init vector,
// and then each column is encrypted using an IV that comes from key table
// properties, the row identity and the column name.
// ----------------------------------------------------------------------------
function computeTruncatedColumnHash(contentEncryptionIV, partitionKey, rowKey, columnName) {
// IMPORTANT:
// The .NET storage library (the reference implementation for Azure client-side
// storage) has a likely bug in the ordering and concatenation of parameters
// to generate the truncated column hash; it uses string.Join(partitionKey, rowKey, column)
// instead of String.Concat. The likely original intention of the author seems
// to be a concat in the order of partition key, row key, and then column, but
// instead the resulting string is actually row key, partition key, column,
// because string.Join treats the first parameter (the partition key in this
// case) as the separator for joining an array of values. This code uses array
// join to identically reproduce the .NET behavior here so that the two
// implementations remain compatible.
const columnIdentity = Buffer.from([rowKey, columnName].join(partitionKey), 'utf8');
const combined = Buffer.concat([contentEncryptionIV, columnIdentity]);
const hash = getSha256Hash(combined);
return hash.slice(0, azureStorageContentEncryptionIVBytes);
}
// ----------------------------------------------------------------------------
// Buffer/string functions
// ----------------------------------------------------------------------------
function base64StringFromBuffer(val) {
return Buffer.isBuffer(val) ? val.toString('base64') : val;
}
function bufferFromBase64String(val) {
return Buffer.isBuffer(val) ? val : Buffer.from(val, 'base64');
}
function translateBuffersToBase64(properties) {
for (const key in properties) {
if (Buffer.isBuffer(properties[key])) {
properties[key] = base64StringFromBuffer(properties[key]);
}
}
return properties;
}
// ----------------------------------------------------------------------------
// The default encryption resolver implementation: given a list of properties
// to encrypt, return true when that property is being processed.
// ----------------------------------------------------------------------------
function createDefaultEncryptionResolver(propertiesToEncrypt) {
const encryptedKeySet = new Set(propertiesToEncrypt);
// Default resolver does not use partition/row, but user could
return (partition: string, row: string, name: string) => {
return encryptedKeySet.has(name);
};
}
function encryptProperty(contentEncryptionKey, contentEncryptionIV, partitionKey, rowKey, property, value) {
const columnIV = computeTruncatedColumnHash(contentEncryptionIV, partitionKey, rowKey, property);
// Store the encrypted properties as binary values on the service instead of
// base 64 encoded strings because strings are stored as a sequence of WCHARs
// thereby further reducing the allowed size by half. During retrieve, it is
// handled by the response parsers correctly even when the service does not
// return the type for JSON no-metadata.
return encryptValue(contentEncryptionKey, columnIV, value);
}
function decryptProperty(
aesAlgorithm,
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
propertyName,
encryptedValue
) {
const columnIV = computeTruncatedColumnHash(contentEncryptionIV, partitionKey, rowKey, propertyName);
return decryptValue(aesAlgorithm, contentEncryptionKey, columnIV, bufferFromBase64String(encryptedValue));
}
async function encryptProperties(
encryptionResolver,
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
unencryptedProperties
) {
const encryptedProperties = {};
const encryptedPropertiesList = [];
if (!unencryptedProperties) {
throw new Error('The entity properties are not set.');
}
const propertyNames = Object.getOwnPropertyNames(unencryptedProperties);
try {
for (const property of propertyNames) {
const value = unencryptedProperties[property];
if (property === tableEncryptionKeyDetails || property === tableEncryptionPropertyDetails) {
throw new Error(
'A table encryption property is present in the entity properties to consider for encryption. The property must be removed.'
);
}
if (property === 'PartitionKey' || property === 'RowKey') {
encryptedProperties[property] = value;
continue;
}
if (property === 'Timestamp') {
continue;
}
if (encryptionResolver(partitionKey, rowKey, property) !== true) {
encryptedProperties[property] = value;
continue;
}
if (value === undefined || value === null) {
throw new Error(
`Null or undefined properties cannot be encrypted. Property in question: ${property}`
);
}
const type = typeof value;
if (type !== 'string') {
throw new Error(`${type} properties cannot be encrypted; property in question: ${property}`);
}
const encryptedValue = encryptProperty(
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
property,
value
);
encryptedPropertiesList.push(property);
encryptedProperties[property] = encryptedValue;
}
} catch (asyncError) {
throw asyncError;
}
return { encryptedProperties, encryptedPropertiesList };
}
function decryptProperties(
allEntityProperties,
encryptedPropertyNames,
partitionKey,
rowKey,
contentEncryptionKey,
encryptionData,
contentEncryptionIV
) {
validateEncryptionData(encryptionData);
const aesAlgorithm = openSslFromNetFrameworkAlgorithm(encryptionData.EncryptionAgent.EncryptionAlgorithm);
const decryptedProperties = {};
for (const key in allEntityProperties) {
if (key === tableEncryptionKeyDetails || key === tableEncryptionPropertyDetails) {
continue;
}
if (!encryptedPropertyNames.has(key)) {
decryptedProperties[key] = allEntityProperties[key];
continue;
}
const value = decryptProperty(
aesAlgorithm,
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
key,
allEntityProperties[key]
);
decryptedProperties[key] = value.toString('utf8');
}
return decryptedProperties;
}
export async function encryptEntityAsync(
partitionKey: string,
rowKey: string,
properties: object,
encryptionOptions: IEncryptionOptions
) {
if (!partitionKey || !rowKey || !properties) {
throw new Error('Must provide a partition key, row key and properties for the entity.');
}
const returnBinaryProperties = encryptionOptions.binaryProperties || 'buffer';
if (returnBinaryProperties !== 'base64' && returnBinaryProperties !== 'buffer') {
throw new Error('The binary properties value is not valid. Please provide "buffer" or "base64".');
}
const keyEncryptionKeyId = encryptionOptions.keyEncryptionKeyId;
const keyEncryptionKey = await resolveKeyEncryptionKeyFromOptions(encryptionOptions, keyEncryptionKeyId);
let encryptionResolver = encryptionOptions.encryptionResolver;
if (!encryptionResolver) {
const propertiesToEncrypt = encryptionOptions.encryptedPropertyNames;
if (!propertiesToEncrypt) {
throw new Error(
'Encryption options must contain either a list of properties to encrypt or an encryption resolver.'
);
}
encryptionResolver = createDefaultEncryptionResolver(propertiesToEncrypt);
}
const { contentEncryptionIV, contentEncryptionKey } = await generateContentEncryptionKey();
const keyWrappingAlgorithm = azureStorageKeyWrappingAlgorithm;
const wrappedContentEncryptionKey = await wrapContentKey(
keyWrappingAlgorithm,
keyEncryptionKey,
contentEncryptionKey
);
const { encryptedProperties, encryptedPropertiesList } = await encryptProperties(
encryptionResolver,
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
properties
);
if (encryptedPropertiesList.length === 0) {
return encryptedProperties;
}
const metadataSerialized = JSON.stringify(encryptedPropertiesList);
encryptedProperties[tableEncryptionPropertyDetails] = encryptProperty(
contentEncryptionKey,
contentEncryptionIV,
partitionKey,
rowKey,
tableEncryptionPropertyDetails,
metadataSerialized
);
encryptedProperties[tableEncryptionKeyDetails] = JSON.stringify(
createEncryptionData(
keyEncryptionKeyId,
jose.util.asBuffer(wrappedContentEncryptionKey),
contentEncryptionIV,
keyWrappingAlgorithm
)
);
if (returnBinaryProperties === 'base64') {
translateBuffersToBase64(encryptedProperties);
}
return encryptedProperties;
}
export async function decryptEntityAsync(partitionKey, rowKey, properties, encryptionOptions) {
if (!partitionKey || !rowKey || !properties) {
throw new Error('A partition key, row key and properties must be provided.');
}
const returnBinaryProperties = encryptionOptions.binaryProperties || 'buffer';
if (returnBinaryProperties !== 'base64' && returnBinaryProperties !== 'buffer') {
throw new Error('The binary properties value is not valid. Please provide "buffer" or "base64".');
}
const detailsValue = properties[tableEncryptionKeyDetails];
if (detailsValue === undefined) {
return properties;
}
let tableEncryptionKey = null;
try {
tableEncryptionKey = JSON.parse(detailsValue);
} catch (parseError) {
throw parseError;
}
const iv = bufferFromBase64String(tableEncryptionKey.ContentEncryptionIV);
const wrappedContentKey = tableEncryptionKey.WrappedContentKey;
if (wrappedContentKey.Algorithm !== azureStorageKeyWrappingAlgorithm) {
throw new Error(
`The key wrapping algorithm "${wrappedContentKey.Algorithm}" is not tested or supported in this library.`
);
}
const keyWrappingAlgorithm = wrappedContentKey.Algorithm;
const wrappedContentKeyIdentifier = wrappedContentKey.KeyId;
const wrappedContentKeyEncryptedKey = bufferFromBase64String(wrappedContentKey.EncryptedKey);
const aesAlgorithm = openSslFromNetFrameworkAlgorithm(
tableEncryptionKey.EncryptionAgent.EncryptionAlgorithm
);
const kvk = await resolveKeyEncryptionKeyFromOptions(encryptionOptions, wrappedContentKeyIdentifier);
const keyEncryptionKeyValue = bufferFromBase64String(kvk);
const contentEncryptionKey = await unwrapContentKey(
keyWrappingAlgorithm,
keyEncryptionKeyValue,
wrappedContentKeyEncryptedKey
);
const metadataIV = computeTruncatedColumnHash(iv, partitionKey, rowKey, tableEncryptionPropertyDetails);
const tableEncryptionDetails = bufferFromBase64String(properties[tableEncryptionPropertyDetails]);
try {
const decryptedPropertiesSet = decryptValue(
aesAlgorithm,
contentEncryptionKey,
metadataIV,
tableEncryptionDetails
);
const listOfEncryptedProperties = JSON.parse(decryptedPropertiesSet.toString('utf8'));
const decrypted = decryptProperties(
properties,
new Set(listOfEncryptedProperties),
partitionKey,
rowKey,
contentEncryptionKey,
tableEncryptionKey,
iv
);
if (returnBinaryProperties === 'base64') {
translateBuffersToBase64(decrypted);
}
return decrypted;
} catch (error) {
throw error;
}
}