diff --git a/lib/api/apiUtils/object/sourceChecksum.js b/lib/api/apiUtils/object/sourceChecksum.js new file mode 100644 index 0000000000..92698a3dae --- /dev/null +++ b/lib/api/apiUtils/object/sourceChecksum.js @@ -0,0 +1,64 @@ +const { PassThrough } = require('stream'); +const async = require('async'); +const { jsutil } = require('arsenal'); + +const { data } = require('../../../data/wrapper'); + +/** + * Sequentially GET the ordered source `dataLocator` parts into a single + * readable stream, reading them in order through `data.get`. Returns the + * PassThrough immediately; consumers should pipe it onward and observe + * `error` for any read failure along the way. + * + * @param {Array} dataLocator - ordered source parts + * @param {object} log - request logger + * @return {PassThrough} + */ +function buildSourcePartsStream(dataLocator, log) { + const passthrough = new PassThrough(); + const wrapErr = (err, part) => + Object.assign(err, { + copyPart: { key: part.key, dataStoreName: part.dataStoreName, dataStoreType: part.dataStoreType }, + }); + async.eachSeries( + dataLocator, + (part, cb) => { + const done = jsutil.once(cb); + if (part.dataStoreType === 'azure') { + // Azure's data.get writes part bytes into the provided writable + // instead of returning a Readable. Pipe a per-part PassThrough + // into the master passthrough and use its 'end' as the completion + // signal — same pattern arsenal's data.copyObject uses. + const perPart = new PassThrough(); + perPart.once('error', err => done(wrapErr(err, part))); + perPart.once('end', () => done()); + perPart.pipe(passthrough, { end: false }); + return data.get(part, perPart, log, err => { + if (err) { + perPart.destroy(err); + done(wrapErr(err, part)); + } + }); + } + return data.get(part, null, log, (err, partStream) => { + if (err) { + return done(wrapErr(err, part)); + } + partStream.once('error', err => done(wrapErr(err, part))); + partStream.once('end', () => done()); + partStream.pipe(passthrough, { end: false }); + return undefined; + }); + }, + err => { + if (err) { + passthrough.destroy(err); + } else { + passthrough.end(); + } + }, + ); + return passthrough; +} + +module.exports = { buildSourcePartsStream }; diff --git a/lib/api/apiUtils/object/sourceMasterKey.js b/lib/api/apiUtils/object/sourceMasterKey.js new file mode 100644 index 0000000000..3598f1c602 --- /dev/null +++ b/lib/api/apiUtils/object/sourceMasterKey.js @@ -0,0 +1,126 @@ +const async = require('async'); +const crypto = require('crypto'); +const { jsutil } = require('arsenal'); + +const kms = require('../../../kms/wrapper'); +const { buildSourcePartsStream } = require('./sourceChecksum'); + +// `:` +const partETagPattern = /^\d+:([0-9a-f]{32})$/; + +/** + * Find the smallest encrypted part of the data that has an MD5. A part is an + * MPU part, or the whole object if it was not uploaded in parts. + * + * An MPU part written by UploadPartCopy can span several locations, and each + * of them carries the MD5 of the whole part in its dataStoreETag. So a part is + * the run of consecutive locations that share a dataStoreETag. + * + * An empty part decrypts to the same MD5 under any key, so it is picked only if + * every other part is empty too. + * + * @param {Array} dataLocator - source data locations + * @returns {object|null} the part's locations and MD5, or null if no + * encrypted part has an MD5 + */ +function _smallestPart(dataLocator) { + const parts = []; + dataLocator.forEach(location => { + const part = parts[parts.length - 1]; + if (part && location.dataStoreETag === part.dataStoreETag) { + part.locations.push(location); + part.size += Number(location.size); + } else { + parts.push({ + dataStoreETag: location.dataStoreETag, + locations: [location], + size: Number(location.size), + }); + } + }); + const rank = part => (part.size === 0 ? Infinity : part.size); + return parts.reduce((smallest, part) => { + const match = partETagPattern.exec(part.dataStoreETag); + if (!match || !part.locations.every(location => location.cipheredDataKey)) { + return smallest; + } + if (smallest && rank(smallest) <= rank(part)) { + return smallest; + } + return { locations: part.locations, md5: match[1], size: part.size }; + }, null); +} + +/** + * Compute the MD5 of the locations' data, decrypted with masterKeyId. + * + * @param {Array} locations - data locations, in order + * @param {string} masterKeyId - master key to decrypt the data with + * @param {object} log - request logger + * @param {function} cb - cb(err, md5): md5 is the hex digest + * @returns {undefined} + */ +function _md5WithKey(locations, masterKeyId, log, cb) { + const done = jsutil.once(cb); + const hash = crypto.createHash('md5'); + const stream = buildSourcePartsStream( + locations.map(location => ({ ...location, masterKeyId })), + log, + ); + stream.on('data', chunk => hash.update(chunk)); + stream.once('error', done); + stream.once('end', () => done(null, hash.digest('hex'))); +} + +/** + * Find which master key decrypts the source data correctly: read one part, + * decrypted with each candidate key in turn, and compare its MD5 with the + * MD5 recorded for the part. + * + * @param {Array} dataLocator - source data locations + * @param {Array} candidateKeyIds - master key IDs, in the order to try + * @param {object} log - request logger + * @param {function} cb - cb(err, masterKeyId, reason): masterKeyId is the + * first candidate that decrypts the data, or null with the reason when none + * does or the data has no MD5 to check; err is the error of the read with the + * first candidate, returned when no other candidate decrypts the data + * @returns {undefined} + */ +function findSourceMasterKey(dataLocator, candidateKeyIds, log, cb) { + const part = _smallestPart(dataLocator); + if (!part) { + return process.nextTick(cb, null, null, 'no MD5 recorded for the encrypted data'); + } + const keyIds = candidateKeyIds.filter( + (keyId, i) => keyId && !candidateKeyIds.slice(0, i).some(other => kms.isSameMasterKey(other, keyId, log)), + ); + let firstReadError = null; + return async.detectSeries( + keyIds, + (keyId, next) => + _md5WithKey(part.locations, keyId, log, (err, md5) => { + if (err) { + log.debug('cannot read the source data with a candidate master key', { + masterKeyId: keyId, + error: err, + }); + if (keyId === keyIds[0]) { + firstReadError = err; + } + return next(null, false); + } + return next(null, md5 === part.md5); + }), + (err, keyId) => { + if (keyId) { + return cb(null, keyId); + } + if (firstReadError) { + return cb(firstReadError); + } + return cb(null, null, 'no candidate master key decrypts the data'); + }, + ); +} + +module.exports = { findSourceMasterKey, _smallestPart }; diff --git a/lib/api/objectCopy.js b/lib/api/objectCopy.js index 12395a6eaa..25a7295e91 100644 --- a/lib/api/objectCopy.js +++ b/lib/api/objectCopy.js @@ -24,6 +24,7 @@ const { getObjectSSEConfiguration } = require('./apiUtils/bucket/bucketEncryptio const { setExpirationHeaders } = require('./apiUtils/object/expirationHeaders'); const { verifyColdObjectAvailable } = require('./apiUtils/object/coldStorage'); const { setSSEHeaders } = require('./apiUtils/object/sseHeaders'); +const { findSourceMasterKey } = require('./apiUtils/object/sourceMasterKey'); const { updateEncryption } = require('./apiUtils/bucket/updateEncryption'); const { initializeInternalLogRequestQueue, queueInternalLogRequest } = require('../utilities/serverAccessLogger'); @@ -79,6 +80,33 @@ function _mustReencryptData(dataLocator, serverSideEncryption, log) { ); } +/** + * Tell whether the copy replaces its own source, without keeping the source as + * another version: if the copy rewrites the data, the source's data is deleted. + * + * @param {boolean} sourceIsDestination - source key == destination key + * @param {BucketInfo} destBucketMD - destination bucket metadata + * @returns {boolean} true if the copy replaces its source + */ +function _overwritesSource(sourceIsDestination, destBucketMD) { + const vcfg = destBucketMD.getVersioningConfiguration(); + const isVersionedObj = vcfg && vcfg.Status === 'Enabled'; + return sourceIsDestination && !isVersionedObj; +} + +/** + * Tell whether the copy replaces its own source in the same location, without + * keeping the source as another version. + * + * @param {boolean} sourceIsDestination - source key == destination key + * @param {object} storeMetadataParams - destination metadata params + * @param {BucketInfo} destBucketMD - destination bucket metadata + * @returns {boolean} true if the copy replaces its source in place + */ +function _isInPlaceCopy(sourceIsDestination, storeMetadataParams, destBucketMD) { + return _overwritesSource(sourceIsDestination, destBucketMD) && storeMetadataParams.locationMatch; +} + /** * Preps metadata to be saved (based on copy or replace request header) * @param {object} request - request @@ -310,6 +338,9 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion }; const websiteRedirectHeader = request.headers['x-amz-website-redirect-location']; const responseHeaders = {}; + // Master key recorded on the source locations, before this copy replaces it + // with the master key in the object metadata + let recordedMasterKeyId; if ( request.headers['x-amz-storage-class'] && @@ -473,6 +504,11 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion : [{ key: sourceObjMD.location }]; } + // Also read when the metadata says the object is not + // encrypted: a self-copy without SSE removes the + // encryption from the metadata but keeps the data + // encrypted. + recordedMasterKeyId = dataLocator.find(location => location.masterKeyId)?.masterKeyId; if (sourceObjMD['x-amz-server-side-encryption']) { for (let i = 0; i < dataLocator.length; i++) { dataLocator[i].masterKeyId = sourceObjMD['x-amz-server-side-encryption-aws-kms-key-id']; @@ -531,6 +567,86 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion ), ); }, + function checkSourceMasterKey( + storeMetadataParams, + dataLocator, + sourceBucketMD, + destBucketMD, + destObjMD, + sourceLocationConstraintName, + backendInfoDest, + serverSideEncryption, + next, + ) { + const forward = () => + next( + null, + storeMetadataParams, + dataLocator, + sourceBucketMD, + destBucketMD, + destObjMD, + sourceLocationConstraintName, + backendInfoDest, + serverSideEncryption, + ); + // The master key in the object metadata may not be the one the + // data is encrypted with: until CLDSRV-910, a self-copy to another + // master key changed only the metadata and left the data encrypted + // under the old one. When this copy rewrites such data, it decrypts + // it with the wrong key and deletes the original. Before rewriting, + // decrypt one part of the data with the master key in the metadata, + // then the one recorded on the locations, then the destination's, + // and use the first whose result matches the part's MD5; fail the + // copy if none does. + // Each key tried costs a read of the part and a KMS call, so only + // objects that can be in this state are checked. A self-copy that + // does not rewrite the data is the only write that records a master + // key on the locations, so objects without one are skipped. + if ( + !recordedMasterKeyId || + !_overwritesSource(sourceIsDestination, destBucketMD) || + !dataLocator.some(location => location.cipheredDataKey) || + (storeMetadataParams.locationMatch && !_mustReencryptData(dataLocator, serverSideEncryption, log)) + ) { + return forward(); + } + const objectMasterKeyId = dataLocator[0].masterKeyId; + const destMasterKeyId = + serverSideEncryption?.configuredMasterKeyId || serverSideEncryption?.masterKeyId; + return findSourceMasterKey( + dataLocator, + [objectMasterKeyId, recordedMasterKeyId, destMasterKeyId], + log, + (err, masterKeyId, reason) => { + const logFields = { bucketName: destBucketName, objectKey: destObjectKey, objectMasterKeyId }; + if (!err && !masterKeyId) { + log.error('refusing to rewrite the data of a self-copy', { + ...logFields, + candidateMasterKeyIds: [recordedMasterKeyId, destMasterKeyId], + reason, + }); + } + const error = err || (!masterKeyId && errors.InternalError); + if (error) { + // eslint-disable-next-line no-param-reassign + request.sourceServerAccessLog && (request.sourceServerAccessLog.error = error); + return next(error, destBucketMD); + } + if (masterKeyId !== objectMasterKeyId) { + log.info('source data is not encrypted with the master key in its metadata', { + ...logFields, + masterKeyId, + }); + for (let i = 0; i < dataLocator.length; i++) { + // eslint-disable-next-line no-param-reassign + dataLocator[i].masterKeyId = masterKeyId; + } + } + return forward(); + }, + ); + }, function goGetData( storeMetadataParams, dataLocator, @@ -550,9 +666,7 @@ function objectCopy(authInfo, request, sourceBucket, sourceObject, sourceVersion // still send along serverSideEncryption info so algo // and masterKeyId stored properly in metadata if ( - sourceIsDestination && - storeMetadataParams.locationMatch && - !isVersionedObj && + _isInPlaceCopy(sourceIsDestination, storeMetadataParams, destBucketMD) && !_mustReencryptData(dataLocator, serverSideEncryption, log) ) { return next(null, storeMetadataParams, dataLocator, destObjMD, serverSideEncryption, destBucketMD); diff --git a/tests/unit/api/apiUtils/object/sourceMasterKey.js b/tests/unit/api/apiUtils/object/sourceMasterKey.js new file mode 100644 index 0000000000..f491d71390 --- /dev/null +++ b/tests/unit/api/apiUtils/object/sourceMasterKey.js @@ -0,0 +1,45 @@ +const assert = require('assert'); + +const { _smallestPart } = require('../../../../../lib/api/apiUtils/object/sourceMasterKey'); + +const md5A = 'a'.repeat(32); +const md5B = 'b'.repeat(32); +const md5C = 'c'.repeat(32); +const md5Empty = 'd41d8cd98f00b204e9800998ecf8427e'; +const encrypted = (key, size, dataStoreETag) => ({ key, size, dataStoreETag, cipheredDataKey: 'ZGF0YWtleQ==' }); + +describe('_smallestPart', () => { + it('should pick the smallest part, with every location of a part copied from several locations', () => { + const dataLocator = [ + encrypted(1, 10, `1:${md5A}`), + encrypted(2, 4, `2:${md5B}`), + encrypted(3, 2, `2:${md5B}`), + encrypted(4, 8, `3:${md5C}`), + ]; + assert.deepStrictEqual(_smallestPart(dataLocator), { + locations: [dataLocator[1], dataLocator[2]], + md5: md5B, + size: 6, + }); + }); + + it('should skip parts with an unencrypted location or no MD5', () => { + const dataLocator = [ + { key: 1, size: 1, dataStoreETag: `1:${md5A}` }, + encrypted(2, 2, undefined), + encrypted(3, 3, `${md5B}-2`), + encrypted(4, 9, `4:${md5C}`), + ]; + assert.deepStrictEqual(_smallestPart(dataLocator).locations, [dataLocator[3]]); + }); + + it('should pick a part with data over a smaller empty part', () => { + const dataLocator = [encrypted(1, 10, `1:${md5A}`), encrypted(2, 0, `2:${md5Empty}`)]; + assert.deepStrictEqual(_smallestPart(dataLocator).locations, [dataLocator[0]]); + }); + + it('should pick an empty part when every part is empty', () => { + const dataLocator = [encrypted(1, 0, `1:${md5Empty}`)]; + assert.deepStrictEqual(_smallestPart(dataLocator).locations, [dataLocator[0]]); + }); +}); diff --git a/tests/unit/api/objectCopy.js b/tests/unit/api/objectCopy.js index b4a6447255..d2aa1917cb 100644 --- a/tests/unit/api/objectCopy.js +++ b/tests/unit/api/objectCopy.js @@ -1,12 +1,18 @@ const assert = require('assert'); const async = require('async'); +const crypto = require('crypto'); const { storage, versioning } = require('arsenal'); const sinon = require('sinon'); +const { parseString } = require('xml2js'); const { bucketPut } = require('../../../lib/api/bucketPut'); const bucketPutVersioning = require('../../../lib/api/bucketPutVersioning'); const bucketPutPolicy = require('../../../lib/api/bucketPutPolicy'); +const completeMultipartUpload = require('../../../lib/api/completeMultipartUpload'); +const initiateMultipartUpload = require('../../../lib/api/initiateMultipartUpload'); const objectPut = require('../../../lib/api/objectPut'); +const objectPutPart = require('../../../lib/api/objectPutPart'); +const objectPutCopyPart = require('../../../lib/api/objectPutCopyPart'); const objectCopy = require('../../../lib/api/objectCopy'); const objectGet = require('../../../lib/api/objectGet'); const kms = require('../../../lib/kms/wrapper'); @@ -1352,9 +1358,10 @@ describe('objectCopy self-copy with SSE', () => { 'x-amz-meta-updated': 'true', }; - // Asserts the object data was rewritten: new locations, all encrypted, - // the old locations gone from the data store, and the original bytes read back. - function assertDataRewritten(locationsBefore, done) { + // Asserts the object data was rewritten: new locations, encrypted except + // when the copy writes the data unencrypted (`encrypted` is false), the old + // locations gone from the data store, and the original bytes read back. + function assertDataRewritten(locationsBefore, done, { expectedBody = plaintext, encrypted = true } = {}) { return getLocations((err, locationsAfter) => { assert.ifError(err); assert.notDeepStrictEqual( @@ -1362,13 +1369,17 @@ describe('objectCopy self-copy with SSE', () => { locationsBefore.map(loc => loc.key), ); locationsAfter.forEach(loc => { - assert(loc.cipheredDataKey, 'new data location must be encrypted'); - assert.notDeepStrictEqual(ds[loc.key].value, plaintext); + if (encrypted) { + assert(loc.cipheredDataKey, 'new data location must be encrypted'); + assert.notDeepStrictEqual(ds[loc.key].value, plaintext); + } else { + assert.strictEqual(loc.cipheredDataKey, undefined); + } }); locationsBefore.forEach(loc => assert.strictEqual(ds[loc.key], undefined)); return getBody((err, body) => { assert.ifError(err); - assert.deepStrictEqual(body, plaintext); + assert.deepStrictEqual(body, expectedBody); done(); }); }); @@ -1388,6 +1399,107 @@ describe('objectCopy self-copy with SSE', () => { }); } + function updateObjectMD(update, cb) { + return metadata.getObjectMD(destBucketName, objectKey, {}, log, (err, md) => { + if (err) { + return cb(err); + } + update(md); + return metadata.putObjectMD(destBucketName, objectKey, md, {}, log, err => cb(err, md.location)); + }); + } + + // The in-memory metadata returns the stored object itself: copy it to + // compare before and after. + function getObjectMD(cb) { + return metadata.getObjectMD(destBucketName, objectKey, {}, log, (err, md) => + cb(err, md && JSON.parse(JSON.stringify(md))), + ); + } + + function recordKeyOnLocations(md, keyArn) { + md.location.forEach(location => { + /* eslint-disable no-param-reassign */ + location.masterKeyId = keyArn; + location.algorithm = 'aws:kms'; + /* eslint-enable no-param-reassign */ + }); + } + + // Puts the object encrypted under dataMasterKey, then sets + // metadataMasterKey as the master key in the object metadata and records + // recordedMasterKey on every location, as a metadata-only self-copy does. + // A plain put records no key on the locations. With metadataMasterKey + // other than dataMasterKey, the metadata does not name the key of the + // data. Self-copies made before CLDSRV-910 left objects in this state: + // they changed the master key without rewriting the data. The code no + // longer produces it. + function putObjectWithKeys({ dataMasterKey, metadataMasterKey, recordedMasterKey }, cb) { + return putObject(kmsHeaders(dataMasterKey), err => { + if (err) { + return cb(err); + } + return updateObjectMD(md => { + // eslint-disable-next-line no-param-reassign + md['x-amz-server-side-encryption-aws-kms-key-id'] = metadataMasterKey; + recordKeyOnLocations(md, recordedMasterKey); + }, cb); + }); + } + + function createKeys(count, cb) { + return async.timesSeries(count, (n, next) => createKey(next), cb); + } + + function initiateMpu(key, headers, cb) { + const request = new DummyRequest({ + bucketName: destBucketName, + namespace, + objectKey: key, + headers: { host: `${destBucketName}.s3.amazonaws.com`, ...headers }, + url: `/${key}?uploads`, + }); + return initiateMultipartUpload(authInfo, request, log, (err, xml) => { + if (err) { + return cb(err); + } + return parseString(xml, (err, json) => cb(err, json && json.InitiateMultipartUploadResult.UploadId[0])); + }); + } + + function partRequest(key, uploadId, partNumber, body) { + return new DummyRequest( + { + bucketName: destBucketName, + namespace, + objectKey: key, + headers: { host: `${destBucketName}.s3.amazonaws.com` }, + url: `/${key}?partNumber=${partNumber}&uploadId=${uploadId}`, + query: { partNumber, uploadId }, + }, + body, + ); + } + + function completeMpu(key, uploadId, partETags, cb) { + const parts = partETags + .map((eTag, i) => `${i + 1}"${eTag}"`) + .join(''); + const request = { + bucketName: destBucketName, + namespace, + objectKey: key, + parsedHost: 's3.amazonaws.com', + url: `/${key}?uploadId=${uploadId}`, + headers: { host: `${destBucketName}.s3.amazonaws.com` }, + query: { uploadId }, + post: `${parts}`, + }; + return completeMultipartUpload(authInfo, request, log, err => cb(err)); + } + + const md5 = body => crypto.createHash('md5').update(body).digest('hex'); + beforeEach(done => { cleanup(); bucketPut(authInfo, putDestBucketRequest, log, done); @@ -1564,6 +1676,366 @@ describe('objectCopy self-copy with SSE', () => { }, ); }); + + describe('which master key decrypts the data', () => { + // The test first sets up an object that a self-copy must refuse. Runs + // the self-copy, and asserts it failed and changed neither the metadata + // nor the data store. + function selfCopyLeavesObjectUnchanged(headers, cb) { + return getObjectMD((err, mdBefore) => { + if (err) { + return cb(err); + } + const dataStoreSize = ds.length; + return selfCopy(headers, copyErr => { + assert(copyErr, 'the copy must fail'); + assert.strictEqual(ds.length, dataStoreSize); + mdBefore.location.forEach(loc => assert(ds[loc.key], 'original data must stay in the data store')); + return getObjectMD((err, mdAfter) => { + assert.ifError(err); + assert.deepStrictEqual(mdAfter, mdBefore); + cb(null, copyErr); + }); + }); + }); + } + + it('should copy only the metadata when the copy asks for the key the data is encrypted under', done => { + async.waterfall( + [ + next => createKeys(2, next), + // The data is encrypted under dataMasterKey, but the + // metadata names metadataMasterKey. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey, recordedMasterKey: dataMasterKey }, + (err, locationsBefore) => next(err, dataMasterKey, locationsBefore), + ), + // The copy asks for dataMasterKey, which repairs the object. + (dataMasterKey, locationsBefore, next) => + selfCopy(kmsHeaders(dataMasterKey), err => next(err, dataMasterKey, locationsBefore)), + (dataMasterKey, locationsBefore, next) => + getObjectMD((err, md) => next(err, dataMasterKey, md, locationsBefore)), + ], + (err, dataMasterKey, md, locationsBefore) => { + assert.ifError(err); + // The metadata names dataMasterKey again, and the data was + // not rewritten. + assert.strictEqual(md['x-amz-server-side-encryption-aws-kms-key-id'], dataMasterKey); + assertDataKept(locationsBefore, done); + }, + ); + }); + + it('should decrypt the data with the key its locations record when re-encrypting it', done => { + async.waterfall( + [ + next => createKeys(3, next), + // The data is encrypted under dataMasterKey, which its + // locations record, but the metadata names + // metadataMasterKey. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey, destMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey, recordedMasterKey: dataMasterKey }, + (err, locationsBefore) => next(err, destMasterKey, locationsBefore), + ), + // The copy asks for a third key, so the data is rewritten. + (destMasterKey, locationsBefore, next) => + selfCopy(kmsHeaders(destMasterKey), err => next(err, locationsBefore)), + ], + (err, locationsBefore) => { + assert.ifError(err); + // The original bytes read back: the copy decrypted the data + // with dataMasterKey, not metadataMasterKey. + assertDataRewritten(locationsBefore, done); + }, + ); + }); + + it('should keep the data when a copy asks for its master key after a copy removed the encryption', done => { + async.waterfall( + [ + next => createKeys(2, next), + // The data is encrypted under dataMasterKey, but the + // metadata names metadataMasterKey. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey], next) => + putObjectWithKeys({ dataMasterKey, metadataMasterKey, recordedMasterKey: dataMasterKey }, err => + next(err, dataMasterKey), + ), + // A copy without SSE removes the encryption from the + // metadata, but the data stays encrypted. + (dataMasterKey, next) => selfCopy(replaceMetadataHeaders, err => next(err, dataMasterKey)), + (dataMasterKey, next) => + getLocations((err, locationsBefore) => next(err, dataMasterKey, locationsBefore)), + // The copy asks for dataMasterKey: the data is already + // under it, so only the metadata changes. + (dataMasterKey, locationsBefore, next) => + selfCopy(kmsHeaders(dataMasterKey), err => next(err, locationsBefore)), + ], + (err, locationsBefore) => { + assert.ifError(err); + assertDataKept(locationsBefore, done); + }, + ); + }); + + it('should refuse the copy when no key decrypts the data', done => { + async.waterfall( + [ + next => createKeys(3, next), + // The data is encrypted under dataMasterKey, but neither + // the metadata nor the locations name it. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey, destMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey, recordedMasterKey: metadataMasterKey }, + err => next(err, destMasterKey), + ), + // The copy tries metadataMasterKey and destMasterKey, and + // neither decrypts the data. + (destMasterKey, next) => selfCopyLeavesObjectUnchanged(kmsHeaders(destMasterKey), next), + ], + (err, copyErr) => { + assert.ifError(err); + assert(copyErr.is.InternalError, `expected InternalError, got ${copyErr}`); + done(); + }, + ); + }); + + it('should refuse the copy when the encrypted data has no recorded MD5', done => { + async.waterfall( + [ + next => createKeys(2, next), + // The locations record the key, so the copy checks which + // key decrypts the data before rewriting it. + ([dataMasterKey, destMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey: dataMasterKey, recordedMasterKey: dataMasterKey }, + err => next(err, destMasterKey), + ), + // Without an MD5 to compare with, the check cannot pass. + (destMasterKey, next) => + updateObjectMD( + // eslint-disable-next-line no-param-reassign + md => md.location.forEach(location => delete location.dataStoreETag), + err => next(err, destMasterKey), + ), + (destMasterKey, next) => selfCopyLeavesObjectUnchanged(kmsHeaders(destMasterKey), next), + ], + (err, copyErr) => { + assert.ifError(err); + assert(copyErr.is.InternalError, `expected InternalError, got ${copyErr}`); + done(); + }, + ); + }); + + it('should return the KMS error of the metadata master key when no other key decrypts the data', done => { + const kmsError = new Error('KMS unavailable'); + async.waterfall( + [ + next => createKeys(2, next), + ([dataMasterKey, destMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey: dataMasterKey, recordedMasterKey: dataMasterKey }, + err => next(err, dataMasterKey, destMasterKey), + ), + // The KMS fails for dataMasterKey, the key the metadata + // and the locations name, and destMasterKey does not + // decrypt the data. + (dataMasterKey, destMasterKey, next) => { + const createDecipherBundle = kms.createDecipherBundle; + sinon + .stub(kms, 'createDecipherBundle') + .callsFake((sse, offset, log, cb) => + kms.isSameMasterKey(sse.masterKeyId, dataMasterKey, log) + ? cb(kmsError) + : createDecipherBundle.call(kms, sse, offset, log, cb), + ); + return selfCopyLeavesObjectUnchanged(kmsHeaders(destMasterKey), next); + }, + ], + (err, copyErr) => { + assert.ifError(err); + assert.strictEqual(copyErr, kmsError); + done(); + }, + ); + }); + + it('should rewrite the data without reading it first when its locations record no key', done => { + async.waterfall( + [ + next => createKeys(2, next), + // A plain put records no key on the locations. + ([dataMasterKey, destMasterKey], next) => + putObject(kmsHeaders(dataMasterKey), err => next(err, destMasterKey)), + (destMasterKey, next) => + getLocations((err, locationsBefore) => next(err, destMasterKey, locationsBefore)), + (destMasterKey, locationsBefore, next) => { + const dataGetSpy = sinon.spy(data, 'get'); + return selfCopy(kmsHeaders(destMasterKey), err => + next(err, dataGetSpy.callCount, locationsBefore), + ); + }, + ], + (err, dataReads, locationsBefore) => { + assert.ifError(err); + // Only the read of the rewrite: no check before it. + assert.strictEqual(dataReads, 1); + assertDataRewritten(locationsBefore, done); + }, + ); + }); + + it('should encrypt plaintext data recorded as encrypted', done => { + async.waterfall( + [ + next => createKey(next), + (keyArn, next) => putObject({}, err => next(err, keyArn)), + // Self-copies made before CLDSRV-910 left objects in this + // state: one that asked for encryption marked the + // plaintext data as encrypted without rewriting it, and a + // later one recorded the key on the locations. + (keyArn, next) => + updateObjectMD( + md => { + /* eslint-disable no-param-reassign */ + md['x-amz-server-side-encryption'] = 'aws:kms'; + md['x-amz-server-side-encryption-aws-kms-key-id'] = keyArn; + /* eslint-enable no-param-reassign */ + recordKeyOnLocations(md, keyArn); + }, + (err, locationsBefore) => next(err, keyArn, locationsBefore), + ), + (keyArn, locationsBefore, next) => selfCopy(kmsHeaders(keyArn), err => next(err, locationsBefore)), + ], + (err, locationsBefore) => { + assert.ifError(err); + assertDataRewritten(locationsBefore, done); + }, + ); + }); + + it('should check every location of a part copied from several locations', done => { + const sourceKey = 'mpusource'; + const partBodies = [Buffer.alloc(constants.minimumAllowedPartSize, 'a'), Buffer.from('last part')]; + const body = Buffer.concat(partBodies); + let keys; + async.waterfall( + [ + next => createKeys(2, next), + // A source of two parts, copied as one part: the object + // has one part over two locations, and both carry the MD5 + // of the whole part. + (created, next) => { + keys = created; + return initiateMpu(sourceKey, kmsHeaders(keys[0]), next); + }, + (uploadId, next) => + async.timesSeries( + partBodies.length, + (i, partDone) => + objectPutPart( + authInfo, + partRequest(sourceKey, uploadId, i + 1, partBodies[i]), + undefined, + log, + err => partDone(err), + ), + err => next(err, uploadId), + ), + (uploadId, next) => completeMpu(sourceKey, uploadId, partBodies.map(md5), next), + next => initiateMpu(objectKey, kmsHeaders(keys[0]), next), + (uploadId, next) => + objectPutCopyPart( + authInfo, + partRequest(objectKey, uploadId, 1), + destBucketName, + sourceKey, + undefined, + log, + err => next(err, uploadId), + ), + (uploadId, next) => completeMpu(objectKey, uploadId, [md5(body)], next), + // A metadata-only self-copy records the key on the + // locations, so the next copy checks it. + next => selfCopy({ ...kmsHeaders(keys[0]), ...replaceMetadataHeaders }, next), + next => getLocations(next), + (locationsBefore, next) => { + assert.strictEqual(locationsBefore.length, 2); + assert.strictEqual(locationsBefore[0].dataStoreETag, locationsBefore[1].dataStoreETag); + assert(locationsBefore[0].masterKeyId); + // The check finds the key only if it reads both + // locations to compute the MD5 of the part. + return selfCopy(kmsHeaders(keys[1]), err => next(err, locationsBefore)); + }, + ], + (err, locationsBefore) => { + assert.ifError(err); + assertDataRewritten(locationsBefore, done, { expectedBody: body }); + }, + ); + }); + + const otherLocationHeaders = { + 'x-amz-metadata-directive': 'REPLACE', + 'x-amz-meta-scal-location-constraint': 'us-east-2', + }; + + it('should decrypt the data with the key its locations record when the copy changes location', done => { + async.waterfall( + [ + next => createKeys(2, next), + // The data is encrypted under dataMasterKey, which its + // locations record, but the metadata names + // metadataMasterKey. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey], next) => + putObjectWithKeys( + { dataMasterKey, metadataMasterKey, recordedMasterKey: dataMasterKey }, + (err, locationsBefore) => next(err, metadataMasterKey, locationsBefore), + ), + // The copy keeps metadataMasterKey but moves the object to + // another location, so the data is rewritten. + (metadataMasterKey, locationsBefore, next) => + selfCopy({ ...kmsHeaders(metadataMasterKey), ...otherLocationHeaders }, err => + next(err, locationsBefore), + ), + ], + (err, locationsBefore) => { + assert.ifError(err); + assertDataRewritten(locationsBefore, done); + }, + ); + }); + + it('should decrypt with the key its locations record when an unencrypted copy changes location', done => { + async.waterfall( + [ + next => createKeys(2, next), + // The data is encrypted under dataMasterKey, which its + // locations record, but the metadata names + // metadataMasterKey. + // Self-copies made before CLDSRV-910 left objects in this state. + ([dataMasterKey, metadataMasterKey], next) => + putObjectWithKeys({ dataMasterKey, metadataMasterKey, recordedMasterKey: dataMasterKey }, next), + // The copy has no SSE headers and the bucket no default + // encryption: the data is rewritten unencrypted to + // another location. + (locationsBefore, next) => selfCopy(otherLocationHeaders, err => next(err, locationsBefore)), + ], + (err, locationsBefore) => { + assert.ifError(err); + assertDataRewritten(locationsBefore, done, { encrypted: false }); + }, + ); + }); + }); }); describe('_mustReencryptData', () => {