Compare commits

...

4 Commits

Author SHA1 Message Date
Florent BEAUCHAMP
6d311d9b11 wip 2026-01-12 17:00:48 +00:00
Florent BEAUCHAMP
06547f0a6f add typing 2026-01-08 17:01:08 +00:00
Florent BEAUCHAMP
e1b7077dc1 skip test that can't work on encrypted remote 2026-01-08 12:37:01 +00:00
Florent BEAUCHAMP
790201ef8b feat(fs): add rolling encryption
This PR add the ability to update the encrryption key of a remote / backup repository

To acheive this goal, a few changes have been mades :

 * The files will be prepended with a the index of key (unencrypted)
 * The encrypted part of the file will also contains this index, to ensure it
is not toyed with, and authenticated by the encryption algorithm
 * The latest encryption key is still not stored in the BR
 * the previous keys are stored in encryptors/{index], each key encrypted by the
following one

This means that you can decrypt all the keys with only the latest one
Another consequence is that the oldest incremental backup can contains a mix of
files encrytped with different keys, and it's ok since each file

This can onoy be enabled on a a new BR, since the files must have a header indicating
the key used

Oleder key are not meant to be deleted, but if mandatory, the user can delete the keys
that are older than the most recent
2026-01-08 10:26:53 +00:00
10 changed files with 1045 additions and 107 deletions

View File

@@ -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() {

View File

@@ -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)
)
})
})
})*/
})

View 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
}
}
}

View 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)
})
})

View 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)
}
}

View 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)
}
}

View 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)
})
})

View 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)
}
}

View 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))
})
})

View File

@@ -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
)
}
)
})