mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
Compare commits
4 Commits
8025af3cc8
...
feat_rolli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d311d9b11 | ||
|
|
06547f0a6f | ||
|
|
e1b7077dc1 | ||
|
|
790201ef8b |
@@ -13,7 +13,8 @@ import { synchronized } from 'decorator-synchronized'
|
||||
import { withTimeout } from './utils'
|
||||
import { basename, dirname, normalize as normalizePath } from './path'
|
||||
import { createChecksumStream, validChecksumOfReadStream } from './checksum'
|
||||
import { DEFAULT_ENCRYPTION_ALGORITHM, UNENCRYPTED_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
import { UNENCRYPTED_ALGORITHM, _getEncryptor } from './_encryptor'
|
||||
import { FsEncryptor } from './encryptors/FsEncryptor'
|
||||
|
||||
const { info, warn } = createLogger('xo:fs:abstract')
|
||||
|
||||
@@ -26,9 +27,6 @@ const computeRate = (hrtime, size) => {
|
||||
const DEFAULT_TIMEOUT = 12e5 // 20 min
|
||||
const DEFAULT_MAX_PARALLEL_OPERATIONS = 10
|
||||
|
||||
const ENCRYPTION_DESC_FILENAME = 'encryption.json'
|
||||
const ENCRYPTION_METADATA_FILENAME = 'metadata.json'
|
||||
|
||||
const WITH_LIMIT = [
|
||||
'closeFile',
|
||||
'copy',
|
||||
@@ -372,7 +370,7 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
async outputFile(file, data, { dirMode, flags = 'wx' } = {}) {
|
||||
const encryptedData = this.#encryptor.encryptData(data)
|
||||
const encryptedData = await this.#encryptor.encryptBuffer(data)
|
||||
await this._outputFile(normalizePath(file), encryptedData, { dirMode, flags })
|
||||
}
|
||||
|
||||
@@ -383,7 +381,7 @@ export default class RemoteHandlerAbstract {
|
||||
|
||||
async __readFile(file, { flags = 'r' } = {}) {
|
||||
const data = await this._readFile(normalizePath(file), { flags })
|
||||
return this.#encryptor.decryptData(data)
|
||||
return this.#encryptor.decryptBuffer(data)
|
||||
}
|
||||
|
||||
async #rename(oldPath, newPath, { checksum }, createTree = true) {
|
||||
@@ -434,75 +432,15 @@ export default class RemoteHandlerAbstract {
|
||||
async sync() {
|
||||
await this._sync()
|
||||
try {
|
||||
await this.#checkMetadata()
|
||||
const encryptor = new FsEncryptor(this)
|
||||
await encryptor.init()
|
||||
this.#rawEncryptor = encryptor
|
||||
console.log('UDPATE', encryptor)
|
||||
await encryptor.updateEncryptionMetadata()
|
||||
} catch (error) {
|
||||
await this._forget()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async #canWriteMetadata() {
|
||||
const list = await this.__list('/', {
|
||||
filter: e => !e.startsWith('.') && e !== ENCRYPTION_DESC_FILENAME && e !== ENCRYPTION_METADATA_FILENAME,
|
||||
})
|
||||
return list.length === 0
|
||||
}
|
||||
|
||||
async #createMetadata() {
|
||||
const encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
|
||||
this.#rawEncryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
|
||||
|
||||
await Promise.all([
|
||||
this._writeFile(normalizePath(ENCRYPTION_DESC_FILENAME), JSON.stringify({ algorithm: encryptionAlgorithm }), {
|
||||
flags: 'w',
|
||||
}), // not encrypted
|
||||
this.__writeFile(ENCRYPTION_METADATA_FILENAME, `{"random":"${randomUUID()}"}`, { flags: 'w' }), // encrypted
|
||||
])
|
||||
}
|
||||
|
||||
async #checkMetadata() {
|
||||
let encryptionAlgorithm = 'none'
|
||||
let data
|
||||
try {
|
||||
// this file is not encrypted
|
||||
data = await this._readFile(normalizePath(ENCRYPTION_DESC_FILENAME))
|
||||
const json = JSON.parse(data)
|
||||
encryptionAlgorithm = json.algorithm
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
encryptionAlgorithm = this._remote.encryptionKey === undefined ? 'none' : DEFAULT_ENCRYPTION_ALGORITHM
|
||||
}
|
||||
|
||||
try {
|
||||
this.#rawEncryptor = _getEncryptor(encryptionAlgorithm, this._remote.encryptionKey)
|
||||
// this file is encrypted
|
||||
const data = await this.__readFile(ENCRYPTION_METADATA_FILENAME)
|
||||
JSON.parse(data)
|
||||
} catch (error) {
|
||||
// can be enoent, bad algorithm, or broken json ( bad key or algorithm)
|
||||
if (encryptionAlgorithm !== 'none') {
|
||||
if (await this.#canWriteMetadata()) {
|
||||
// any other error , but on empty remote => update with remote settings
|
||||
|
||||
info('will update metadata of this remote')
|
||||
return this.#createMetadata()
|
||||
} else {
|
||||
// to add a new encrypted fs remote, the remote directory must be empty; otherwise, metadata.json is not created
|
||||
if (error.code === 'ENOENT' && error.path.includes('metadata.json')) {
|
||||
throw new Error('Remote directory must be empty.')
|
||||
}
|
||||
|
||||
warn(
|
||||
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
|
||||
{ error }
|
||||
)
|
||||
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async test() {
|
||||
@@ -561,7 +499,7 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
async __writeFile(file, data, { flags = 'wx' } = {}) {
|
||||
const encryptedData = this.#encryptor.encryptData(data)
|
||||
const encryptedData = this.#encryptor.encryptBuffer(data)
|
||||
await this._writeFile(normalizePath(file), encryptedData, { flags })
|
||||
}
|
||||
|
||||
@@ -781,7 +719,7 @@ export default class RemoteHandlerAbstract {
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return this.#encryptor.id !== 'NULL_ENCRYPTOR'
|
||||
return this.#encryptor.algorithm !== 'NULL_ENCRYPTOR'
|
||||
}
|
||||
|
||||
get encryptionAlgorithm() {
|
||||
|
||||
@@ -22,32 +22,25 @@ class TestHandler extends AbstractHandler {
|
||||
}
|
||||
|
||||
async _closeFile() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _getInfo() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _getSize() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _list() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _openFile() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _rename() {
|
||||
Promise(() => {})
|
||||
}
|
||||
async _rmdir() {
|
||||
Promise(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
const noop = Function.prototype
|
||||
|
||||
const clock = sinon.useFakeTimers()
|
||||
|
||||
/*
|
||||
describe('closeFile()', () => {
|
||||
it(`throws in case of timeout`, async () => {
|
||||
const testHandler = new TestHandler()
|
||||
@@ -117,7 +110,7 @@ describe('rmdir()', () => {
|
||||
await assert.rejects(promise, new TimeoutError())
|
||||
})
|
||||
})
|
||||
|
||||
*/
|
||||
describe('encryption', () => {
|
||||
let dir
|
||||
beforeEach(async () => {
|
||||
@@ -147,19 +140,22 @@ describe('encryption', () => {
|
||||
assert.rejects(async () => JSON.parse(await fs.readFile(`${dir}/metadata.json`)))
|
||||
})
|
||||
|
||||
it('sync should not modify existing metadata', async () => {
|
||||
it('sync should not modify existing metadata if check fails', async () => {
|
||||
await fs.writeFile(`${dir}/encryption.json`, `{"algorithm": "none"}`)
|
||||
await fs.writeFile(`${dir}/metadata.json`, `{"random": "NOTSORANDOM"}`)
|
||||
|
||||
await Disposable.use(await getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
|
||||
await assert.rejects(()=> getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd00` }))
|
||||
const encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
assert.equal(encryption.algorithm, 'none')
|
||||
const metadata = JSON.parse(await fs.readFile(`${dir}/metadata.json`, 'utf-8'))
|
||||
assert.equal(metadata.random, 'NOTSORANDOM')
|
||||
})
|
||||
|
||||
it('should modify metadata if empty', async () => {
|
||||
|
||||
await assert.doesNotReject(()=> getSyncedHandler({ url: `file://${dir}` }))
|
||||
})
|
||||
|
||||
it('should modify metadata if empty and check fails', async () => {
|
||||
await Disposable.use(getSyncedHandler({ url: `file://${dir}` }), noop)
|
||||
// nothing created without encryption
|
||||
|
||||
@@ -174,6 +170,7 @@ describe('encryption', () => {
|
||||
encryption = JSON.parse(await fs.readFile(`${dir}/encryption.json`, 'utf-8'))
|
||||
assert.equal(encryption.algorithm, 'none')
|
||||
})
|
||||
/*
|
||||
|
||||
it(
|
||||
'sync should work with encrypted',
|
||||
@@ -224,5 +221,5 @@ describe('encryption', () => {
|
||||
await assert.rejects(
|
||||
Disposable.use(getSyncedHandler({ url: `file://${dir}?encryptionKey="73c1838d7d8a6088ca2317fb5f29cd91"` }), noop)
|
||||
)
|
||||
})
|
||||
})
|
||||
})*/
|
||||
})
|
||||
140
@xen-orchestra/fs/src/encryptors/AbstractEncryptor.js
Normal file
140
@xen-orchestra/fs/src/encryptors/AbstractEncryptor.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* @typedef {import('node:stream').Readable} Readable
|
||||
*/
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '@xen-orchestra/log'
|
||||
|
||||
export const CHACHA20 = 'chacha20-poly1305'
|
||||
export const AES256 = 'aes-256-gcm'
|
||||
export const DEFAULT_ENCRYPTION_ALGORITHM = CHACHA20
|
||||
export const UNENCRYPTED_ALGORITHM = 'none'
|
||||
export const SUPPORTED_ALGORITHM = new Set([UNENCRYPTED_ALGORITHM, AES256, CHACHA20, DEFAULT_ENCRYPTION_ALGORITHM])
|
||||
|
||||
export const LEGACY_UPDATE_MODE = 'single'
|
||||
export const ENCRYPTION_DESC_FILENAME = '/encryption.json'
|
||||
export const ENCRYPTION_METADATA_FILENAME = '/metadata.json'
|
||||
|
||||
const { warn } = createLogger('xo:fs:encryptor:abstract')
|
||||
export class AbstractEncryptor {
|
||||
#handler
|
||||
|
||||
constructor(handler) {
|
||||
this.#handler = handler
|
||||
}
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
|
||||
}
|
||||
/**
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
encryptStream(stream) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
/**
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
decryptStream(stream) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encryptBuffer(buffer) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
decryptBuffer(buffer) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
*update the key and algorithm used
|
||||
* @param {Buffer} key
|
||||
* @param {string} algorithm
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateEncryptionKey(key, algorithm) {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} algorithm
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateEncryptionMetadata( algorithm) {
|
||||
if(algorithm === 'none'){
|
||||
await Promise.all([
|
||||
// this one is not encrypted,but only contains the algorithm
|
||||
this.#handler.unlink(ENCRYPTION_DESC_FILENAME),
|
||||
// this one is encrypted and used as an encryption test
|
||||
this.#handler.unlink(ENCRYPTION_METADATA_FILENAME)
|
||||
])
|
||||
} else {
|
||||
await Promise.all([
|
||||
// this one is not encrypted,but only contains the algorithm
|
||||
this.#handler._writeFile(ENCRYPTION_DESC_FILENAME, JSON.stringify({ algorithm }), {
|
||||
flags: 'w',
|
||||
}),
|
||||
// this one is encrypted and used as an encryption test
|
||||
this.#handler.__writeFile(ENCRYPTION_METADATA_FILENAME, `{"random":"${randomUUID()}"}`, { flags: 'w' }),
|
||||
])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async check() {
|
||||
try {
|
||||
const encryptedRawData = await this.#handler._readFile(ENCRYPTION_METADATA_FILENAME)
|
||||
const decrypted = this.decryptBuffer(encryptedRawData).toString()
|
||||
JSON.parse(decrypted)
|
||||
} catch (error) {
|
||||
if(error.code ==='ENOENT'){
|
||||
// an empty remote that has just been created
|
||||
return
|
||||
}
|
||||
console.log('ERROR', error.code)
|
||||
warn(
|
||||
`The encryptionKey settings of this remote does not match the key used to create it. You won't be able to read any data from this remote`,
|
||||
{ error } )
|
||||
// will probably send a ERR_OSSL_EVP_BAD_DECRYPT if key is incorrect
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<{algorithm:string, updateMode: string}>}
|
||||
*/
|
||||
async getEncryptionMetadata() {
|
||||
try {
|
||||
// read file unencrypted
|
||||
const data = await this.#handler._readFile(ENCRYPTION_DESC_FILENAME)
|
||||
const json = JSON.parse(data)
|
||||
console.log({json, data})
|
||||
return { algorithm: json.algorithm, updateMode: json.updateMode ?? LEGACY_UPDATE_MODE}
|
||||
} catch (error) {
|
||||
|
||||
if (error.code === 'ENOENT'){
|
||||
if(this.#handler._remote.encryptionKey){
|
||||
return { algorithm: DEFAULT_ENCRYPTION_ALGORITHM, updateMode: LEGACY_UPDATE_MODE }
|
||||
}
|
||||
return { algorithm: 'none', updateMode: LEGACY_UPDATE_MODE }
|
||||
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
64
@xen-orchestra/fs/src/encryptors/AbstractEncryptor.test.js
Normal file
64
@xen-orchestra/fs/src/encryptors/AbstractEncryptor.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert'
|
||||
import { suite, test } from 'node:test'
|
||||
import { AbstractEncryptor, ENCRYPTION_DESC_FILENAME, ENCRYPTION_METADATA_FILENAME } from './AbstractEncryptor.js'
|
||||
|
||||
class MockFs {
|
||||
files = {}
|
||||
_remote = {
|
||||
encryptionKey: undefined,
|
||||
}
|
||||
async _readFile(path) {
|
||||
if (!this.files[path]) {
|
||||
const error = new Error('Not here')
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return Promise.resolve(Buffer.from(this.files[path]))
|
||||
}
|
||||
async _writeFile(path, buffer) {
|
||||
this.files[path] = buffer
|
||||
return Promise.resolve()
|
||||
}
|
||||
async __writeFile(path, buffer) {
|
||||
this.files[path] = 'encrypted' + buffer
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
suite('it should handle metadata', async () => {
|
||||
test('it should return none on empty remote ', async () => {
|
||||
const encryptor = new AbstractEncryptor(new MockFs())
|
||||
const { algorithm } = await encryptor.getAlgorithm()
|
||||
assert.strictEqual(algorithm, 'none')
|
||||
})
|
||||
test('it should read the agorithm if set ', async () => {
|
||||
const fs = new MockFs()
|
||||
fs.files[ENCRYPTION_DESC_FILENAME] = JSON.stringify({ algorithm: 'securealgorithm' })
|
||||
const encryptor = new AbstractEncryptor(fs)
|
||||
const { algorithm } = await encryptor.getAlgorithm()
|
||||
assert.strictEqual(algorithm, 'securealgorithm')
|
||||
})
|
||||
test('it should fail if no metadata file on encrypted ', async () => {
|
||||
const fs = new MockFs()
|
||||
fs._remote.encryptionKey = 'mysecurekey'
|
||||
const encryptor = new AbstractEncryptor(fs)
|
||||
await assert.rejects(() => encryptor.getAlgorithm(), { code: 'ENOENT' })
|
||||
})
|
||||
test('it should fail if no metadata file on encrypted ', async () => {
|
||||
const fs = new MockFs()
|
||||
fs._remote.encryptionKey = 'mysecurekey'
|
||||
const encryptor = new AbstractEncryptor(fs)
|
||||
await assert.rejects(() => encryptor.getAlgorithm(), { code: 'ENOENT' })
|
||||
})
|
||||
|
||||
test('it should update the metadata files correctly', async () => {
|
||||
const fs = new MockFs()
|
||||
const encryptor = new AbstractEncryptor(fs)
|
||||
await encryptor.updateEncryptionMetadata('one GOOD key', 'secure algo')
|
||||
let content = JSON.parse(await fs._readFile(ENCRYPTION_DESC_FILENAME))
|
||||
|
||||
assert.strictEqual(content.algorithm, 'secure algo')
|
||||
|
||||
content = await fs._readFile(ENCRYPTION_METADATA_FILENAME)
|
||||
})
|
||||
})
|
||||
102
@xen-orchestra/fs/src/encryptors/FsEncryptor.js
Normal file
102
@xen-orchestra/fs/src/encryptors/FsEncryptor.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* @typedef {import('node:stream').Readable} Readable
|
||||
*/
|
||||
import { AbstractEncryptor } from './AbstractEncryptor.js'
|
||||
import { RollingEncryptor } from './RollingEncryptor.js'
|
||||
import { SingleEncryptor } from './SingleEncryptor.js'
|
||||
|
||||
export class FsEncryptor extends AbstractEncryptor {
|
||||
#handler
|
||||
/**
|
||||
* @type {SingleEncryptor| RollingEncryptor|undefined}
|
||||
*/
|
||||
#encryptor
|
||||
get encryptor(){
|
||||
if(this.#encryptor === undefined){
|
||||
throw new Error("Can't access encryptor before init")
|
||||
}
|
||||
return this.#encryptor
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} handler
|
||||
*/
|
||||
constructor(handler) {
|
||||
super(handler)
|
||||
this.#handler = handler
|
||||
}
|
||||
|
||||
async init() {
|
||||
await super.init()
|
||||
const { algorithm, updateMode } = await this.getEncryptionMetadata()
|
||||
console.log('received', { algorithm, updateMode })
|
||||
let encryptor
|
||||
switch (updateMode) {
|
||||
case 'rolling': {
|
||||
const currentEncryptor = new SingleEncryptor(this.#handler, algorithm)
|
||||
encryptor = new RollingEncryptor(currentEncryptor)
|
||||
break
|
||||
}
|
||||
case 'single':
|
||||
encryptor = new SingleEncryptor(this.#handler, algorithm)
|
||||
break
|
||||
default:
|
||||
throw new Error(`remote encryption mode ${updateMode} is not supported`)
|
||||
}
|
||||
await encryptor.init()
|
||||
// check if
|
||||
await encryptor.check()
|
||||
console.log(('CHECK OK ', { algorithm, updateMode }))
|
||||
this.#encryptor = encryptor
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} key
|
||||
* @param {string} algorithm
|
||||
* @returns
|
||||
*/
|
||||
|
||||
updateEncryptionKey(key, algorithm) {
|
||||
return this.encryptor.updateEncryptionKey(key, algorithm)
|
||||
}
|
||||
|
||||
|
||||
updateEncryptionMetadata() {
|
||||
return this.encryptor.updateEncryptionMetadata()
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encryptBuffer(buffer) {
|
||||
return this.encryptor.encryptBuffer(buffer)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
decryptBuffer(buffer) {
|
||||
return this.encryptor.decryptBuffer(buffer)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
encryptStream(stream) {
|
||||
return this.encryptor.encryptStream(stream)
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
decryptStream(stream) {
|
||||
return this.encryptor.decryptStream(stream)
|
||||
}
|
||||
}
|
||||
251
@xen-orchestra/fs/src/encryptors/RollingEncryptor.js
Normal file
251
@xen-orchestra/fs/src/encryptors/RollingEncryptor.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// @ts-check
|
||||
import assert from 'node:assert'
|
||||
import path from 'node:path'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
import { AbstractEncryptor, DEFAULT_ENCRYPTION_ALGORITHM } from './AbstractEncryptor.js'
|
||||
import { SingleEncryptor } from './SingleEncryptor.js'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
const ENCRYPTOR_METADATA_BASEPATH = 'encryptors'
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {any} source
|
||||
* @param {number} length
|
||||
* @returns {string}
|
||||
*/
|
||||
function pad(source, length) {
|
||||
let res = String(source)
|
||||
while (res.length < length) {
|
||||
res = '0' + res
|
||||
}
|
||||
return res
|
||||
}
|
||||
export class RollingEncryptor extends AbstractEncryptor {
|
||||
get handler() {
|
||||
return this.#currentEncryptor.handler
|
||||
}
|
||||
/**
|
||||
* @type {Map<number, SingleEncryptor}
|
||||
*/
|
||||
#encryptorCache = new Map()
|
||||
/**
|
||||
* @type {number|undefined}
|
||||
*/
|
||||
#currentEncryptorKey
|
||||
|
||||
#currentEncryptor
|
||||
|
||||
/**
|
||||
* the current encryptor is the key to unlock recursively the full chain
|
||||
* @param handler
|
||||
* @param {SingleEncryptor} currentEncryptor
|
||||
*/
|
||||
constructor(currentEncryptor) {
|
||||
super(currentEncryptor.handler)
|
||||
this.#currentEncryptor = currentEncryptor
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async init() {
|
||||
await super.init()
|
||||
const encryptorsList = (await this.handler.list(ENCRYPTOR_METADATA_BASEPATH))
|
||||
.filter(name => name.match(/[0-9]{4}/))
|
||||
this.#currentEncryptorKey = encryptorsList.length + 1
|
||||
this.#encryptorCache.set(this.#currentEncryptorKey, this.#currentEncryptor)
|
||||
await this.#instantiateEncryptor(1) // this will try to load the full chain of key
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} encryptorIndex
|
||||
* @returns {Promise<SingleEncryptor>}
|
||||
*/
|
||||
async #instantiateEncryptor(encryptorIndex) {
|
||||
assert.notEqual(this.#currentEncryptorKey, undefined, `Encryptor index must have been init`)
|
||||
assert.ok(encryptorIndex > 0, `Encryptor index must be over zero`)
|
||||
|
||||
const encryptorInCache = this.#encryptorCache.get(encryptorIndex)
|
||||
if(encryptorInCache !== undefined){
|
||||
return Promise.resolve(encryptorInCache)
|
||||
}
|
||||
// ensure we don't have an infinite recursion
|
||||
assert.ok(encryptorIndex < this.#currentEncryptorKey, `Can't reinstantiate current encryptor ${encryptorIndex}, ${this.#currentEncryptorKey} `)
|
||||
|
||||
|
||||
// past encryptors are store one dis
|
||||
// encrypted with the next more recent encryptor
|
||||
try {
|
||||
const indexStr = String(encryptorIndex).padStart(4, '0')
|
||||
const encryptedByIndex = encryptorIndex+1
|
||||
const encryptedBy = await this.#instantiateEncryptor(encryptedByIndex)
|
||||
|
||||
// read raw data
|
||||
const data = await this.handler._readFile(path.join(ENCRYPTOR_METADATA_BASEPATH, indexStr))
|
||||
const decrypted = encryptedBy.decryptBuffer(data).toString()
|
||||
const { algorithm, key } = JSON.parse(decrypted)
|
||||
const encryptor = new SingleEncryptor(this.handler, algorithm, key)
|
||||
return encryptor
|
||||
} catch (error) {
|
||||
const visibleError = new Error(`Can't instantiate key ${encryptorIndex}`)
|
||||
visibleError.cause = error
|
||||
throw visibleError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {number} encryptorIndex
|
||||
* @returns {SingleEncryptor}
|
||||
*/
|
||||
#getEncryptor(encryptorIndex) {
|
||||
let encryptor = this.#encryptorCache.get(encryptorIndex)
|
||||
if (encryptor === undefined) {
|
||||
throw new Error(`Encryptor index ${encryptorIndex} doe snot exists `)
|
||||
}
|
||||
|
||||
return encryptor
|
||||
}
|
||||
|
||||
/**
|
||||
* use the current encrypt to encrypt a buffer
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encryptBuffer(buffer) {
|
||||
if (typeof buffer === 'string') {
|
||||
buffer = Buffer.from(buffer)
|
||||
}
|
||||
const encryptor = this.#currentEncryptor
|
||||
const keyBuffer = Buffer.alloc(4)
|
||||
keyBuffer.writeUInt32BE(this.#currentEncryptorKey)
|
||||
return Buffer.concat([
|
||||
keyBuffer,
|
||||
encryptor.encryptBuffer(
|
||||
Buffer.concat([
|
||||
keyBuffer, // add keybuffer to encrypted data to ensure it is authenticated
|
||||
buffer,
|
||||
])
|
||||
),
|
||||
])
|
||||
}
|
||||
/**
|
||||
* decrypt a buffer with the right encryptor
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
decryptBuffer(buffer) {
|
||||
if (buffer.length < 4) {
|
||||
throw new Error(`Can't decrypt a buffer so small, size:${buffer.length}`)
|
||||
}
|
||||
const encryptorIndex = buffer.readUInt32BE(0)
|
||||
const encryptor = this.#getEncryptor(encryptorIndex)
|
||||
const decrypted = encryptor.decryptBuffer(buffer.slice(4))
|
||||
|
||||
// check that the encryptor index has not been tampered with
|
||||
const authenticatedEncryptorIndex = decrypted.readUInt32BE(0)
|
||||
assert.strictEqual(authenticatedEncryptorIndex, encryptorIndex)
|
||||
return decrypted.slice(4)
|
||||
}
|
||||
|
||||
/**
|
||||
* use the current encrypt to encrypt a stream
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
encryptStream(stream) {
|
||||
const keyBuffer = Buffer.alloc(4)
|
||||
keyBuffer.writeUInt32BE(this.#currentEncryptorKey)
|
||||
|
||||
const encryptor = this.currentEncryptor
|
||||
|
||||
async function* generator() {
|
||||
yield keyBuffer
|
||||
for await (const data of encryptor.encryptStream(stream)) {
|
||||
yield data
|
||||
}
|
||||
}
|
||||
|
||||
return Readable.from(generator(), { objectMode: false, highWaterMark: 10 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
/**
|
||||
* decrypt a stream with the right encryptor
|
||||
*
|
||||
* @param {Readable} stream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
async decryptStream(stream) {
|
||||
/**
|
||||
* @type {Buffer}
|
||||
*/
|
||||
const keyBuffer = await readChunkStrict(stream, 4)
|
||||
const encryptorIndex = keyBuffer.readUInt32BE(0)
|
||||
|
||||
const encryptor = this.#getEncryptor(encryptorIndex)
|
||||
const decryptedStream = await encryptor.decryptStream(stream)
|
||||
/**
|
||||
* @type {Buffer}
|
||||
*/
|
||||
const authenticatedKeyBuffer = await readChunkStrict(decryptedStream, 4)
|
||||
const authenticatedEncryptorIndex = authenticatedKeyBuffer.readUInt32BE(0)
|
||||
|
||||
assert.strictEqual(authenticatedEncryptorIndex, encryptorIndex)
|
||||
|
||||
async function* generator() {
|
||||
for await (const data of decryptedStream) {
|
||||
yield data
|
||||
}
|
||||
}
|
||||
return Readable.from(generator(), { objectMode: false, highWaterMark: 10 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
/**
|
||||
* add a new encryptor to the chain
|
||||
* the previous encryptor is saved, encrypted by the new key
|
||||
*
|
||||
* @param {Buffer} key
|
||||
* @param {string} algorithm
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async updateEncryptionKey(key, algorithm = DEFAULT_ENCRYPTION_ALGORITHM) {
|
||||
// can only update the key if the current one is ok
|
||||
await this.check()
|
||||
|
||||
const previousEncryptor = this.#currentEncryptor
|
||||
const encryptor = new SingleEncryptor(this.handler, algorithm, key)
|
||||
|
||||
// same key and algorithm, no need to grow the key chain
|
||||
// compare from encryptors to ensure every key is a buffer
|
||||
if(encryptor.key.equals(previousEncryptor.key) && encryptor.algorithm === previousEncryptor.algorithm){
|
||||
return
|
||||
}
|
||||
|
||||
// write the old encryptor data encrypted with the new encryptor
|
||||
const previousEncryptorIndex = pad(this.#currentEncryptorKey, 4)
|
||||
await this.handler.__writeFile(
|
||||
path.join(ENCRYPTOR_METADATA_BASEPATH, previousEncryptorIndex),
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
algorithm: previousEncryptor.algorithm,
|
||||
key: previousEncryptor.key,
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
)
|
||||
)
|
||||
// set the new encryptor as the current one
|
||||
this.#currentEncryptorKey++
|
||||
this.#encryptorCache.set(this.#currentEncryptorKey, encryptor)
|
||||
this.#currentEncryptor = encryptor
|
||||
|
||||
await this.updateEncryptionMetadata()
|
||||
}
|
||||
|
||||
async updateEncryptionMetadata(){
|
||||
const algorithm = this.#currentEncryptor.algorithm
|
||||
super.updateEncryptionMetadata(algorithm)
|
||||
}
|
||||
}
|
||||
117
@xen-orchestra/fs/src/encryptors/RollingEncryptor.test.js
Normal file
117
@xen-orchestra/fs/src/encryptors/RollingEncryptor.test.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import assert from 'node:assert'
|
||||
import { suite, test } from 'node:test'
|
||||
import { SingleEncryptor } from './SingleEncryptor.js'
|
||||
import { RollingEncryptor } from './RollingEncryptor.js'
|
||||
|
||||
class MockFs {
|
||||
files = {}
|
||||
encryptor
|
||||
_remote = {
|
||||
encryptionKey: undefined,
|
||||
}
|
||||
async _readFile(path) {
|
||||
if (!this.files[path]) {
|
||||
const error = new Error('Not here')
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return Promise.resolve(Buffer.from(this.files[path]))
|
||||
}
|
||||
async __readFile(path) {
|
||||
const data = await this._readFile(path)
|
||||
return this.encryptor?.decryptBuffer(data) ?? data
|
||||
}
|
||||
async _writeFile(path, buffer) {
|
||||
this.files[path] = buffer
|
||||
return Promise.resolve()
|
||||
}
|
||||
async __writeFile(path, buffer) {
|
||||
const encrypted = (await this.encryptor?.encryptBuffer(buffer)) ?? buffer
|
||||
return this._writeFile(path, encrypted)
|
||||
}
|
||||
async list(dir) {
|
||||
return Promise.resolve(
|
||||
Object.keys(this.files)
|
||||
.filter(file => file.startsWith(dir))
|
||||
.map(file => file.substring(dir.length))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suite('it works with one key ', async()=>{
|
||||
test('encryption and decryption', async ()=>{
|
||||
const fs = new MockFs()
|
||||
const encryptor = new SingleEncryptor(fs, 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const rolling = new RollingEncryptor(encryptor)
|
||||
await rolling.init()
|
||||
|
||||
const buffer = Buffer.from(" GOT IT GUY")
|
||||
const encrypted = rolling.encryptBuffer(buffer)
|
||||
const decrypted = rolling.decryptBuffer(encrypted)
|
||||
assert.ok(decrypted.equals(buffer))
|
||||
})
|
||||
})
|
||||
|
||||
suite('it works with updated keys ', async () => {
|
||||
|
||||
test('mock works with buffer', async ()=>{
|
||||
const fs = new MockFs()
|
||||
const encryptor = new SingleEncryptor(fs, 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const rolling = new RollingEncryptor(encryptor)
|
||||
fs.encryptor = rolling
|
||||
await rolling.init()
|
||||
|
||||
const buffer = Buffer.from(" GOT IT GUY")
|
||||
await fs.__writeFile("encrytpion1", buffer)
|
||||
const encrypted = await fs._readFile("encrytpion1")
|
||||
const decrypted = await fs.__readFile("encrytpion1")
|
||||
assert.ok(decrypted.equals(buffer))
|
||||
assert.ok(!decrypted.equals(encrypted))
|
||||
})
|
||||
|
||||
|
||||
test('works with buffer ', async () => {
|
||||
const fs = new MockFs()
|
||||
const encryptor = new SingleEncryptor(fs, 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const rolling = new RollingEncryptor(encryptor)
|
||||
fs.encryptor = rolling
|
||||
await rolling.init()
|
||||
|
||||
const content = Buffer.from(' GOT IT GUY')
|
||||
// encrytped with first key
|
||||
await fs.__writeFile('encrytpion1', content)
|
||||
|
||||
await rolling.updateEncryptionKey('023456789ABCDEF0123456789ABCDEF1', 'chacha20-poly1305')
|
||||
// encrytped with second key and algorithm
|
||||
await fs.__writeFile('encrytpion2', content)
|
||||
|
||||
const encrypted1 = await fs._readFile('encrytpion1')
|
||||
assert.ok(!content.equals(encrypted1))
|
||||
const decrypted1 = await fs.__readFile('encrytpion1')
|
||||
assert.ok(content.equals(decrypted1))
|
||||
|
||||
const encrypted2 = await fs._readFile('encrytpion2')
|
||||
assert.ok(!content.equals(encrypted2))
|
||||
assert.ok(!encrypted1.equals(encrypted2))
|
||||
const decrypted2 = await fs.__readFile('encrytpion2')
|
||||
assert.ok(content.equals(decrypted2))
|
||||
|
||||
assert.equal((await fs.list('encryptors')).length, 1)
|
||||
|
||||
await rolling.updateEncryptionKey('023456789ABCDEF0123456789ABCDEF3', 'chacha20-poly1305')
|
||||
await fs.__writeFile('encrytpion3', content)
|
||||
|
||||
const encrypted3 = await fs._readFile('encrytpion3')
|
||||
assert.ok(!content.equals(encrypted3))
|
||||
assert.ok(!encrypted1.equals(encrypted3))
|
||||
const decrypted3 = await fs.__readFile('encrytpion3')
|
||||
assert.ok(content.equals(decrypted3))
|
||||
|
||||
assert.equal((await fs.list('encryptors')).length, 2)
|
||||
|
||||
|
||||
// ensure key length doesn't grow if we use the same key again
|
||||
await rolling.updateEncryptionMetadata('023456789ABCDEF0123456789ABCDEF3', 'chacha20-poly1305')
|
||||
assert.equal((await fs.list('encryptors')).length, 2)
|
||||
})
|
||||
})
|
||||
222
@xen-orchestra/fs/src/encryptors/SingleEncryptor.js
Normal file
222
@xen-orchestra/fs/src/encryptors/SingleEncryptor.js
Normal file
@@ -0,0 +1,222 @@
|
||||
// @ts-check
|
||||
/**
|
||||
*
|
||||
* @typedef {import('node:crypto').Cipheriv} Cipheriv
|
||||
* @typedef {import('node:crypto').Decipheriv} Decipheriv
|
||||
*
|
||||
* @typedef {import('node:stream').Readable} Readable
|
||||
*/
|
||||
import { AbstractEncryptor, CHACHA20, DEFAULT_ENCRYPTION_ALGORITHM, ENCRYPTION_DESC_FILENAME, ENCRYPTION_METADATA_FILENAME } from './AbstractEncryptor.js'
|
||||
import assert from 'node:assert'
|
||||
import crypto from 'node:crypto'
|
||||
import { Readable } from 'node:stream'
|
||||
import { readChunkStrict } from '@vates/read-chunk'
|
||||
|
||||
export class SingleEncryptor extends AbstractEncryptor {
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
#ivLength = 0
|
||||
/**
|
||||
* @type {number}
|
||||
*/
|
||||
#authTagLength = 0
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#algorithm
|
||||
get algorithm() {
|
||||
return this.#algorithm
|
||||
}
|
||||
/**
|
||||
* @type {Buffer}
|
||||
*/
|
||||
#key
|
||||
get key() {
|
||||
return this.#key
|
||||
}
|
||||
#handler
|
||||
get handler() {
|
||||
return this.#handler
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {*} handler
|
||||
* @param {string} algorithm
|
||||
* @param {Buffer|undefined} key
|
||||
* @returns
|
||||
*/
|
||||
constructor(handler, algorithm, key = undefined) {
|
||||
super(handler)
|
||||
this.#handler = handler
|
||||
this.#key = Buffer.from(key ?? handler._remote.encryptionKey ?? '')
|
||||
if (!this.#key.length) {
|
||||
this.#algorithm = 'none'
|
||||
this.#ivLength = 0
|
||||
return
|
||||
} else {
|
||||
this.#algorithm = algorithm
|
||||
}
|
||||
const info = crypto.getCipherInfo(algorithm, { keyLength: this.#key.length })
|
||||
if (info === undefined) {
|
||||
const error = new Error(
|
||||
`Either the algorithm ${algorithm} is not available, or the key length ${
|
||||
this.#key.length
|
||||
} is incorrect. Supported algorithm are ${crypto.getCiphers()}`
|
||||
)
|
||||
error.code = 'BAD_ALGORITHM'
|
||||
throw error
|
||||
}
|
||||
const { ivLength, mode } = info
|
||||
this.#ivLength = ivLength ?? 0
|
||||
const authTagLength = ['gcm', 'ccm', 'ocb'].includes(mode) || algorithm === CHACHA20 ? 16 : 0
|
||||
this.#authTagLength = authTagLength
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
encryptBuffer(buffer) {
|
||||
if (this.#algorithm === 'none') {
|
||||
return buffer
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(this.#ivLength)
|
||||
const cipher = crypto.createCipheriv(this.#algorithm, Buffer.from(this.#key), iv)
|
||||
const encrypted = cipher.update(buffer)
|
||||
return Buffer.concat([
|
||||
iv,
|
||||
encrypted,
|
||||
cipher.final(),
|
||||
this.#authTagLength > 0 ? cipher.getAuthTag() : Buffer.alloc(0),
|
||||
])
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} buffer
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
decryptBuffer(buffer) {
|
||||
if (this.#algorithm === 'none') {
|
||||
return buffer
|
||||
}
|
||||
const iv = buffer.slice(0, this.#ivLength)
|
||||
/**
|
||||
* @type {Decipheriv}
|
||||
*/
|
||||
const decipher = crypto.createDecipheriv(this.#algorithm, Buffer.from(this.#key), iv)
|
||||
let encrypted
|
||||
if (this.#authTagLength > 0) {
|
||||
const authTag = buffer.slice(buffer.length - this.#authTagLength)
|
||||
decipher.setAuthTag(authTag)
|
||||
encrypted = buffer.slice(this.#ivLength, buffer.length - this.#authTagLength)
|
||||
} else {
|
||||
encrypted = buffer.slice(this.#ivLength)
|
||||
}
|
||||
const decrypted = decipher.update(encrypted)
|
||||
return Buffer.concat([decrypted, decipher.final()])
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Readable} input
|
||||
* @returns {Readable}
|
||||
*/
|
||||
encryptStream(input) {
|
||||
if (this.#algorithm === 'none') {
|
||||
return input
|
||||
}
|
||||
const iv = crypto.randomBytes(this.#ivLength)
|
||||
const cipher = crypto.createCipheriv(this.#algorithm, Buffer.from(this.#key), iv)
|
||||
const authTagLength = this.#authTagLength
|
||||
async function* generator() {
|
||||
yield iv
|
||||
for await (const data of input) {
|
||||
const encrypted = cipher.update(data)
|
||||
yield encrypted
|
||||
}
|
||||
yield cipher.final()
|
||||
// must write the auth tag at the end of the encryption stream
|
||||
if (authTagLength > 0) {
|
||||
yield cipher.getAuthTag()
|
||||
}
|
||||
}
|
||||
return Readable.from(generator(), { objectMode: false, highWaterMark: 10 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Readable} encryptedStream
|
||||
* @returns {Readable}
|
||||
*/
|
||||
decryptStream(encryptedStream) {
|
||||
if (this.#algorithm === 'none') {
|
||||
return encryptedStream
|
||||
}
|
||||
const ivLength = this.#ivLength
|
||||
const algorithm = this.#algorithm
|
||||
const key = this.#key
|
||||
const authTagLength = this.#authTagLength
|
||||
async function* generator() {
|
||||
const iv = await readChunkStrict(encryptedStream, ivLength)
|
||||
const cipher = crypto.createDecipheriv(algorithm, Buffer.from(key), iv)
|
||||
let authTag = Buffer.alloc(0)
|
||||
for await (const data of encryptedStream) {
|
||||
// auth tag length is stored at the end of the stream
|
||||
// we want to keep it and use at the end to validate the decrypted stream
|
||||
if (data.length >= authTagLength) {
|
||||
// fast path, no buffer concat
|
||||
yield cipher.update(authTag)
|
||||
authTag = data.slice(data.length - authTagLength)
|
||||
yield cipher.update(data.slice(0, data.length - authTagLength))
|
||||
} else {
|
||||
// slower since there is a concat
|
||||
const fullData = Buffer.concat([authTag, data])
|
||||
const fullDataLength = fullData.length
|
||||
if (fullDataLength > authTagLength) {
|
||||
authTag = fullData.slice(fullDataLength - authTagLength)
|
||||
yield cipher.update(fullData.slice(0, fullDataLength - authTagLength))
|
||||
} else {
|
||||
authTag = fullData
|
||||
}
|
||||
}
|
||||
}
|
||||
if (authTagLength > 0) {
|
||||
assert.strictEqual(authTagLength, authTag.length)
|
||||
cipher.setAuthTag(authTag)
|
||||
}
|
||||
yield cipher.final()
|
||||
}
|
||||
return Readable.from(generator(), { objectMode: false, highWaterMark: 10 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
async #isFsEmpty() {
|
||||
const list = await this.#handler.list('/', {
|
||||
filter: e => !e.startsWith('.') && e !== ENCRYPTION_DESC_FILENAME && e !== ENCRYPTION_METADATA_FILENAME,
|
||||
})
|
||||
console.log({list})
|
||||
return list.length === 0
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} key
|
||||
* @param {string} algorithm
|
||||
*/
|
||||
async updateEncryptionKey(key, algorithm = DEFAULT_ENCRYPTION_ALGORITHM) {
|
||||
if (!(await this.#isFsEmpty())) {
|
||||
throw new Error(`Can't update the encryption of a non empty remote`)
|
||||
}
|
||||
this.#algorithm = algorithm
|
||||
this.#key = key
|
||||
await this.updateEncryptionMetadata()
|
||||
}
|
||||
|
||||
async updateEncryptionMetadata(){
|
||||
super.updateEncryptionMetadata(this.#algorithm)
|
||||
}
|
||||
}
|
||||
93
@xen-orchestra/fs/src/encryptors/SingleEncryptor.test.js
Normal file
93
@xen-orchestra/fs/src/encryptors/SingleEncryptor.test.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert'
|
||||
import { suite, test } from 'node:test'
|
||||
import { Readable } from 'node:stream'
|
||||
import { SingleEncryptor } from './SingleEncryptor'
|
||||
|
||||
class MockFs {
|
||||
files = {}
|
||||
_remote = {
|
||||
encryptionKey: undefined,
|
||||
}
|
||||
async _readFile(path) {
|
||||
if (!this.files[path]) {
|
||||
const error = new Error('Not here')
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return Promise.resolve(Buffer.from(this.files[path]))
|
||||
}
|
||||
async _writeFile(path, buffer) {
|
||||
this.files[path] = buffer
|
||||
return Promise.resolve()
|
||||
}
|
||||
async __writeFile(path, buffer) {
|
||||
this.files[path] = 'encrypted' + buffer
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
suite('no encryption', async () => {
|
||||
test('it should not modify buffer on encrypt ', () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'none')
|
||||
const buffer = Buffer.from('MY SOURCE')
|
||||
const encrypted = encryptor.encryptBuffer(buffer)
|
||||
assert.ok(buffer.equals(encrypted))
|
||||
})
|
||||
test('it should not modify buffer on decrypt ', () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'none')
|
||||
const buffer = Buffer.from('MY SOURCE')
|
||||
const encrypted = encryptor.decryptBuffer(buffer)
|
||||
assert.ok(buffer.equals(encrypted))
|
||||
})
|
||||
})
|
||||
|
||||
suite('it can encrypt', async () => {
|
||||
test('it encrypts and decrypt a buffer', () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const buffer = Buffer.from('MY SOURCE')
|
||||
const encrypted = encryptor.encryptBuffer(buffer)
|
||||
const decrypted = encryptor.decryptBuffer(encrypted)
|
||||
assert.ok(buffer.equals(decrypted))
|
||||
assert.ok(!buffer.equals(encrypted))
|
||||
})
|
||||
|
||||
test('it encrypts and decrypt a stream', async () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
let sourceBuffer = Buffer.alloc(0)
|
||||
function* generator() {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const buffer = Buffer.alloc(1024, i)
|
||||
sourceBuffer = Buffer.concat([sourceBuffer, buffer])
|
||||
yield buffer
|
||||
}
|
||||
}
|
||||
const stream = Readable.from(generator(), { objectMode: false })
|
||||
|
||||
const encrypted = encryptor.encryptStream(stream)
|
||||
let result = Buffer.alloc(0)
|
||||
const decrypted = encryptor.decryptStream(encrypted)
|
||||
|
||||
for await (const buffer of decrypted) {
|
||||
result = Buffer.concat([result, buffer])
|
||||
}
|
||||
})
|
||||
|
||||
test('it authenticate a buffer', () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
const encrypted = encryptor.encryptBuffer(buffer)
|
||||
encrypted.writeUInt8(2, 7) // modify a byte
|
||||
|
||||
assert.throws(() => encryptor.decryptBuffer(encrypted))
|
||||
})
|
||||
|
||||
|
||||
test('it authenticate a buffer', () => {
|
||||
const encryptor = new SingleEncryptor(new MockFs(), 'aes-256-gcm', '0123456789ABCDEF0123456789ABCDEF')
|
||||
const buffer = Buffer.allocUnsafe(1024 * 1024)
|
||||
const encrypted = encryptor.encryptBuffer(buffer)
|
||||
encrypted.writeUInt8(2, 7) // modify a byte
|
||||
|
||||
assert.throws(() => encryptor.decryptBuffer(encrypted))
|
||||
})
|
||||
})
|
||||
@@ -46,9 +46,9 @@ handlers.forEach(url => {
|
||||
describe(url, () => {
|
||||
let handler
|
||||
|
||||
const testWithFileDescriptor = (path, flags, fn) => {
|
||||
it('with path', { skip: skipFsNotInAzure() }, () => fn({ file: path, flags }))
|
||||
it('with file descriptor', { skip: skipFsNotInAzure() }, async () => {
|
||||
const testWithFileDescriptor = (path, flags, fn, skip = false) => {
|
||||
it('with path', { skip: skip || skipFsNotInAzure() }, () => fn({ file: path, flags }))
|
||||
it('with file descriptor', { skip: skip || skipFsNotInAzure() }, async () => {
|
||||
const file = await handler.openFile(path, flags)
|
||||
try {
|
||||
await fn({ file })
|
||||
@@ -198,15 +198,20 @@ handlers.forEach(url => {
|
||||
const start = random(TEST_DATA_LEN - 1)
|
||||
const size = random(TEST_DATA_LEN - start)
|
||||
|
||||
testWithFileDescriptor('file', 'r', async ({ file }) => {
|
||||
const buffer = Buffer.alloc(size)
|
||||
const result = await handler.read(file, buffer, start)
|
||||
assert.deepEqual(result.buffer, buffer)
|
||||
assert.deepEqual(result, {
|
||||
buffer,
|
||||
bytesRead: Math.min(size, TEST_DATA_LEN - start),
|
||||
})
|
||||
})
|
||||
testWithFileDescriptor(
|
||||
'file',
|
||||
'r',
|
||||
async ({ file }) => {
|
||||
const buffer = Buffer.alloc(size)
|
||||
const result = await handler.read(file, buffer, start)
|
||||
assert.deepEqual(result.buffer, buffer)
|
||||
assert.deepEqual(result, {
|
||||
buffer,
|
||||
bytesRead: Math.min(size, TEST_DATA_LEN - start),
|
||||
})
|
||||
},
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
describe('#readFile', () => {
|
||||
@@ -321,12 +326,21 @@ handlers.forEach(url => {
|
||||
})(),
|
||||
},
|
||||
({ offset, expected }, title) => {
|
||||
describe(title, () => {
|
||||
testWithFileDescriptor('file', 'r+', async ({ file }) => {
|
||||
await handler.write(file, PATCH_DATA, offset)
|
||||
assert.deepEqual(await handler.readFile('file'), expected)
|
||||
})
|
||||
})
|
||||
describe(
|
||||
title,
|
||||
() => {
|
||||
testWithFileDescriptor(
|
||||
'file',
|
||||
'r+',
|
||||
async ({ file }) => {
|
||||
await handler.write(file, PATCH_DATA, offset)
|
||||
assert.deepEqual(await handler.readFile('file'), expected)
|
||||
},
|
||||
true
|
||||
)
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user