diff --git a/index.d.ts b/index.d.ts index 84a81b1eb..2b9759753 100644 --- a/index.d.ts +++ b/index.d.ts @@ -816,6 +816,24 @@ export type AutoFilter = string | { to: string | { row: number; column: number }; }; +export interface WorksheetProtection { + objects: boolean; + scenarios: boolean; + selectLockedCells: boolean; + selectUnlockedCells: boolean; + formatCells: boolean; + formatColumns: boolean; + formatRows: boolean; + insertColumns: boolean; + insertRows: boolean; + insertHyperlinks: boolean; + deleteColumns: boolean; + deleteRows: boolean; + sort: boolean; + autoFilter: boolean; + pivotTables: boolean; +} + export interface Image { extension: 'jpeg' | 'png' | 'gif'; base64?: string; @@ -1129,6 +1147,12 @@ export interface Worksheet { commit(): void; model: WorksheetModel; + + /** + * Worksheet protection + */ + protect(password: string, options: Partial); + unprotect(); } export interface WorksheetProperties { diff --git a/lib/doc/worksheet.js b/lib/doc/worksheet.js index 7a78ba57c..a3678b5d8 100644 --- a/lib/doc/worksheet.js +++ b/lib/doc/worksheet.js @@ -8,6 +8,8 @@ const Enums = require('./enums'); const Image = require('./image'); const Table= require('./table'); const DataValidations = require('./data-validations'); +const Encryptor = require('../utils/encryptor'); +const PromiseLib = require('../utils/promise'); // Worksheet requirements // Operate as sheet inside workbook or standalone @@ -109,8 +111,12 @@ class Worksheet { // for images, etc this._media = []; + // worksheet protection + this.sheetProtection = null; + // for tables this.tables = {}; + } get workbook() { @@ -579,6 +585,30 @@ class Worksheet { return image && image.imageId; } + // ========================================================================= + // Worksheet Protection + protect(password, options){ + return new PromiseLib.Promise(resolve => { + this.sheetProtection = { + sheet : true, + }; + if (password) { + this.sheetProtection.algorithmName = 'SHA-512'; + this.sheetProtection.saltValue = Encryptor.randomBytes(16).toString('base64'); + this.sheetProtection.spinCount = 100000; + this.sheetProtection.hashValue = Encryptor.convertPasswordToHash(password, 'SHA512', this.sheetProtection.saltValue, this.sheetProtection.spinCount); + } + if(options){ + this.sheetProtection = Object.assign(this.sheetProtection, options); + } + resolve(); + }); + } + + unprotect(){ + this.sheetProtection = null; + } + // ========================================================================= // Tables addTable(model) { @@ -629,6 +659,7 @@ class Worksheet { views: this.views, autoFilter: this.autoFilter, media: this._media.map(medium => medium.model), + sheetProtection: this.sheetProtection, tables: Object.values(this.tables).map(table => table.model), }; @@ -686,6 +717,7 @@ class Worksheet { this.views = value.views; this.autoFilter = value.autoFilter; this._media = value.media.map(medium => new Image(this, medium)); + this.sheetProtection = value.sheetProtection; this.tables = value.tables.reduce( (tables, table) => { const t = new Table; diff --git a/lib/stream/xlsx/worksheet-writer.js b/lib/stream/xlsx/worksheet-writer.js index 62087738f..6c415308d 100644 --- a/lib/stream/xlsx/worksheet-writer.js +++ b/lib/stream/xlsx/worksheet-writer.js @@ -27,6 +27,7 @@ const ColXform = require('../../xlsx/xform/sheet/col-xform'); const RowXform = require('../../xlsx/xform/sheet/row-xform'); const HyperlinkXform = require('../../xlsx/xform/sheet/hyperlink-xform'); const SheetViewXform = require('../../xlsx/xform/sheet/sheet-view-xform'); +const SheetProtectionXform = require('../../xlsx/xform/sheet/sheet-protection-xform'); const PageMarginsXform = require('../../xlsx/xform/sheet/page-margins-xform'); const PageSetupXform = require('../../xlsx/xform/sheet/page-setup-xform'); const AutoFilterXform = require('../../xlsx/xform/sheet/auto-filter-xform'); @@ -41,6 +42,7 @@ const xform = { row: new RowXform(), hyperlinks: new ListXform({ tag: 'hyperlinks', length: false, childXform: new HyperlinkXform() }), sheetViews: new ListXform({ tag: 'sheetViews', length: false, childXform: new SheetViewXform() }), + sheetProtection: new SheetProtectionXform(), pageMargins: new PageMarginsXform(), pageSeteup: new PageSetupXform(), autoFilter: new AutoFilterXform(), @@ -210,6 +212,7 @@ WorksheetWriter.prototype = { this._writeHyperlinks(); this._writeDataValidations(); + this._writeSheetProtection(); this._writePageMargins(); this._writePageSetup(); this._writeBackground(); @@ -518,6 +521,9 @@ WorksheetWriter.prototype = { _writeDataValidations() { this.stream.write(xform.dataValidations.toXml(this.dataValidations.model)); }, + _writeSheetProtection() { + this.stream.write(xform.sheetProtection.toXml(this.sheetProtection)); + }, _writePageMargins() { this.stream.write(xform.pageMargins.toXml(this.pageSetup.margins)); }, diff --git a/lib/utils/encryptor.js b/lib/utils/encryptor.js new file mode 100644 index 000000000..b87da598d --- /dev/null +++ b/lib/utils/encryptor.js @@ -0,0 +1,56 @@ +'use strict'; + +const crypto = require('crypto'); + +const Encryptor = { + /** + * Calculate a hash of the concatenated buffers with the given algorithm. + * @param {string} algorithm - The hash algorithm. + * @returns {Buffer} The hash + */ + hash(algorithm, ...buffers) { + const hashes = crypto.getHashes(); + if (hashes.indexOf(algorithm) < 0) { + throw new Error(`Hash algorithm '${ algorithm }' not supported!`); + } + const hash = crypto.createHash(algorithm); + hash.update(Buffer.concat(buffers)); + return hash.digest(); + }, + /** + * Convert a password into an encryption key + * @param {string} password - The password + * @param {string} hashAlgorithm - The hash algoritm + * @param {string} saltValue - The salt value + * @param {number} spinCount - The spin count + * @param {number} keyBits - The length of the key in bits + * @param {Buffer} blockKey - The block key + * @returns {Buffer} The encryption key + */ + convertPasswordToHash(password, hashAlgorithm, saltValue, spinCount) { + hashAlgorithm = hashAlgorithm.toLowerCase(); + // Password must be in unicode buffer + const passwordBuffer = Buffer.from(password, 'utf16le'); + // Generate the initial hash + let key = this.hash( + hashAlgorithm, + Buffer.from(saltValue, 'base64'), + passwordBuffer + ); + // Now regenerate until spin count + for (let i = 0; i < spinCount; i++) { + const iterator = Buffer.alloc(4); + iterator.writeUInt32LE(i, 0); + key = this.hash(hashAlgorithm, key, iterator); + } + return key.toString('base64'); + }, + /** + * Generates cryptographically strong pseudo-random data. + * @param size The size argument is a number indicating the number of bytes to generate. + */ + randomBytes(size) { + return crypto.randomBytes(size); + }, +}; +module.exports = Encryptor; diff --git a/lib/xlsx/xform/sheet/sheet-protection-xform.js b/lib/xlsx/xform/sheet/sheet-protection-xform.js new file mode 100644 index 000000000..2a887f398 --- /dev/null +++ b/lib/xlsx/xform/sheet/sheet-protection-xform.js @@ -0,0 +1,89 @@ +const _ = require('../../../utils/under-dash'); +const BaseXform = require('../base-xform'); + +function booleanToXml(model, value) { + return model ? value : undefined; +} + +function xmlToBoolean(value, equals) { + return value === equals ? true : undefined; +} + +class SheetProtectionXform extends BaseXform { + get tag() { + return 'sheetProtection'; + } + + render(xmlStream, model) { + if (model) { + const attributes = { + sheet: booleanToXml(model.sheet, '1'), + selectLockedCells: model.selectLockedCells === false ? '1' : undefined, + selectUnlockedCells: model.selectUnlockedCells === false ? '1' : undefined, + formatCells: booleanToXml(model.formatCells, '0'), + formatColumns: booleanToXml(model.formatColumns, '0'), + formatRows: booleanToXml(model.formatRows, '0'), + insertColumns: booleanToXml(model.insertColumns, '0'), + insertRows: booleanToXml(model.insertRows, '0'), + insertHyperlinks: booleanToXml(model.insertHyperlinks, '0'), + deleteColumns: booleanToXml(model.deleteColumns, '0'), + deleteRows: booleanToXml(model.deleteRows, '0'), + sort: booleanToXml(model.sort, '0'), + autoFilter: booleanToXml(model.autoFilter, '0'), + pivotTables: booleanToXml(model.pivotTables, '0'), + }; + if(model.sheet){ + attributes.algorithmName = model.algorithmName; + attributes.hashValue = model.hashValue; + attributes.saltValue = model.saltValue; + attributes.spinCount = model.spinCount; + attributes.objects = booleanToXml(model.objects === false, '1'); + attributes.scenarios = booleanToXml(model.scenarios === false, '1'); + } + if (_.some(attributes, value => value !== undefined)) { + xmlStream.leafNode(this.tag, attributes); + } + } + } + + parseOpen(node) { + switch (node.name) { + case this.tag: + this.model = { + sheet: xmlToBoolean(node.attributes.sheet, '1'), + objects: (node.attributes.objects === '1') ? false : undefined, + scenarios: (node.attributes.scenarios === '1') ? false : undefined, + selectLockedCells: (node.attributes.selectLockedCells === '1') ? false : undefined, + selectUnlockedCells: (node.attributes.selectUnlockedCells === '1') ? false : undefined, + formatCells: xmlToBoolean(node.attributes.formatCells, '0'), + formatColumns: xmlToBoolean(node.attributes.formatColumns, '0'), + formatRows: xmlToBoolean(node.attributes.formatRows, '0'), + insertColumns: xmlToBoolean(node.attributes.insertColumns, '0'), + insertRows: xmlToBoolean(node.attributes.insertRows, '0'), + insertHyperlinks: xmlToBoolean(node.attributes.insertHyperlinks, '0'), + deleteColumns: xmlToBoolean(node.attributes.deleteColumns, '0'), + deleteRows: xmlToBoolean(node.attributes.deleteRows, '0'), + sort: xmlToBoolean(node.attributes.sort, '0'), + autoFilter: xmlToBoolean(node.attributes.autoFilter, '0'), + pivotTables: xmlToBoolean(node.attributes.pivotTables, '0'), + }; + if(node.attributes.algorithmName){ + this.model.algorithmName = node.attributes.algorithmName; + this.model.hashValue = node.attributes.hashValue; + this.model.saltValue = node.attributes.saltValue; + this.model.spinCount = parseInt(node.attributes.spinCount, 10); + } + return true; + default: + return false; + } + } + + parseText() {} + + parseClose() { + return false; + } +} + +module.exports = SheetProtectionXform; diff --git a/lib/xlsx/xform/sheet/worksheet-xform.js b/lib/xlsx/xform/sheet/worksheet-xform.js index f074e3bde..244b94f9c 100644 --- a/lib/xlsx/xform/sheet/worksheet-xform.js +++ b/lib/xlsx/xform/sheet/worksheet-xform.js @@ -18,6 +18,7 @@ const DataValidationsXform = require('./data-validations-xform'); const SheetPropertiesXform = require('./sheet-properties-xform'); const SheetFormatPropertiesXform = require('./sheet-format-properties-xform'); const SheetViewXform = require('./sheet-view-xform'); +const SheetProtectionXform = require('./sheet-protection-xform'); const PageMarginsXform = require('./page-margins-xform'); const PageSetupXform = require('./page-setup-xform'); const PrintOptionsXform = require('./print-options-xform'); @@ -57,6 +58,7 @@ class WorkSheetXform extends BaseXform { printOptions: new PrintOptionsXform(), picture: new PictureXform(), drawing: new DrawingXform(), + sheetProtection: new SheetProtectionXform(), tableParts: new ListXform({tag: 'tableParts', count: true, childXform: new TablePartXform()}), }; } @@ -217,6 +219,7 @@ class WorkSheetXform extends BaseXform { horizontalCentered: model.horizontalCentered, verticalCentered: model.verticalCentered, }; + const sheetProtectionModel = model.sheetProtection; this.map.sheetPr.render(xmlStream, sheetPropertiesModel); this.map.dimension.render(xmlStream, model.dimensions); @@ -224,6 +227,7 @@ class WorkSheetXform extends BaseXform { this.map.sheetFormatPr.render(xmlStream, sheetFormatPropertiesModel); this.map.cols.render(xmlStream, model.cols); this.map.sheetData.render(xmlStream, model.rows); + this.map.sheetProtection.render(xmlStream, sheetProtectionModel); // Note: must be after sheetData and before autoFilter this.map.autoFilter.render(xmlStream, model.autoFilter); this.map.mergeCells.render(xmlStream, model.mergeCells); this.map.dataValidations.render(xmlStream, model.dataValidations); @@ -317,6 +321,10 @@ class WorkSheetXform extends BaseXform { if (this.map.autoFilter.model) { this.model.autoFilter = this.map.autoFilter.model; } + if (this.map.sheetProtection.model) { + this.model.sheetProtection = this.map.sheetProtection.model; + } + return false; } diff --git a/spec/unit/utils/encryptor.spec.js b/spec/unit/utils/encryptor.spec.js new file mode 100644 index 000000000..958c7bf46 --- /dev/null +++ b/spec/unit/utils/encryptor.spec.js @@ -0,0 +1,14 @@ +const { expect } = require('chai'); + +const Encryptor = require('../../../lib/utils/encryptor'); + +describe('Encryptor', () => { + it('Generates SHA-512 hash for given password, salt value and spin count', () => { + const password = '123'; + const saltValue = '6tC6yotbNa8JaMaDvbUgxw=='; + const spinCount = 100000; + const hash = Encryptor.convertPasswordToHash(password, 'SHA512', saltValue, spinCount); + expect(hash).to.equal('RHtx1KpAYT7nBzGCTInkHrbf2wTZxP3BT4Eo8PBHPTM4KfKArJTluFvizDvo6GnBCOO6JJu7qwKvMqnKHs7dcw=='); + }); + +}); diff --git a/spec/unit/xlsx/xform/sheet/sheet-protection-xform.spec.js b/spec/unit/xlsx/xform/sheet/sheet-protection-xform.spec.js new file mode 100644 index 000000000..1905e81de --- /dev/null +++ b/spec/unit/xlsx/xform/sheet/sheet-protection-xform.spec.js @@ -0,0 +1,92 @@ +'use strict'; + +const SheetProtectionXform = require('../../../../../lib/xlsx/xform/sheet/sheet-protection-xform'); +const testXformHelper = require('../test-xform-helper'); + +const expectations = [ + { + title: 'Unprotected (Empty)', + create() { + return new SheetProtectionXform(); + }, + preparedModel: {}, + xml: '', + parsedModel: {}, + tests: ['render', 'renderIn'], + }, + { + title: 'Protected (Default)', + create() { + return new SheetProtectionXform(); + }, + preparedModel: { + algorithmName: 'SHA-512', + hashValue: 'RHtx1KpAYT7nBzGCTInkHrbf2wTZxP3BT4Eo8PBHPTM4KfKArJTluFvizDvo6GnBCOO6JJu7qwKvMqnKHs7dcw==', + saltValue: '6tC6yotbNa8JaMaDvbUgxw==', + spinCount: 100000, + sheet: true, + objects: false, + scenarios: false, + }, + xml: + '', + parsedModel: { + algorithmName: 'SHA-512', + hashValue: 'RHtx1KpAYT7nBzGCTInkHrbf2wTZxP3BT4Eo8PBHPTM4KfKArJTluFvizDvo6GnBCOO6JJu7qwKvMqnKHs7dcw==', + saltValue: '6tC6yotbNa8JaMaDvbUgxw==', + spinCount: 100000, + sheet: true, + objects: false, + scenarios: false, + }, + tests: ['render', 'renderIn', 'parse'], + }, + { + title: 'Unprotected (All false)', + create() { + return new SheetProtectionXform(); + }, + preparedModel: { + selectLockedCells: false, + selectUnlockedCells: false, + }, + xml: + '', + parsedModel: { + selectLockedCells: false, + selectUnlockedCells: false, + }, + tests: ['render', 'renderIn', 'parse'], + }, + { + title: 'Protected (All false)', + create() { + return new SheetProtectionXform(); + }, + preparedModel: { + algorithmName: 'SHA-512', + hashValue: 'RHtx1KpAYT7nBzGCTInkHrbf2wTZxP3BT4Eo8PBHPTM4KfKArJTluFvizDvo6GnBCOO6JJu7qwKvMqnKHs7dcw==', + saltValue: '6tC6yotbNa8JaMaDvbUgxw==', + spinCount: 100000, + sheet: true, + selectLockedCells: false, + selectUnlockedCells: false, + }, + xml: + '', + parsedModel: { + algorithmName: 'SHA-512', + hashValue: 'RHtx1KpAYT7nBzGCTInkHrbf2wTZxP3BT4Eo8PBHPTM4KfKArJTluFvizDvo6GnBCOO6JJu7qwKvMqnKHs7dcw==', + saltValue: '6tC6yotbNa8JaMaDvbUgxw==', + spinCount: 100000, + sheet: true, + selectLockedCells: false, + selectUnlockedCells: false, + }, + tests: ['render', 'renderIn', 'parse'], + }, +]; + +describe('SheetProtectionXform', () => { + testXformHelper(expectations); +}); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy