Skip to content

Instantly share code, notes, and snippets.

@Zytekaron
Last active November 11, 2022 14:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Zytekaron/beae99b3e9a69a7ad76cbeec54347d21 to your computer and use it in GitHub Desktop.
Save Zytekaron/beae99b3e9a69a7ad76cbeec54347d21 to your computer and use it in GitHub Desktop.
An encryption scheme implementation derived from Blockcrypt by Sun Knudsen
// This is an implementation of a plausible-deniability-enabled
// encryption scheme derived from blockcrypt by Sun Knudsen.
// All credits go to him for the idea behind this scheme.
//
// Primary differences:
// - AES-256-CTR instead of AES-256-GCM.
// I am not a security expert, so this may be a poor choice.
// I'm just more familiar with the use of CTR, so I did that.
// - Blocks and headers are shuffled prior to being exported.
// Hopefully, this improves privacy by further obscuring data.
// More details about this are in later comments on lines 104, 150.
// - Headers are not fixed-length. They're meant to be stored
// in an array in JSON or whatever other format the archive
// is exported in. It was easier to implement this way :shrug:
//
// blockcrypt repo: https://github.com/sunknudsen/blockcrypt
// blockcrypt video: https://www.youtube.com/watch?v=bary2XghKjw
const crypto = require('crypto');
// here's a quick overview of basic usage:
// users' secrets
const secrets = [
{
data: 'this is a message',
key: 'password'
},
{
data: 'this is another message',
key: 'key'
},
{
data: 'oh look, a third',
key: 'bonk'
}
];
// encrypting them:
const encrypted = encryptSecrets(secrets, 2);
console.log('encrypted:', encrypted);
// decrypting them:
for (const { key } of Object.values(secrets)) {
const decrypted = decryptSecrets(encrypted, key);
console.log('data for key', key + ':', decrypted);
}
// storing and loading the archive to a file:
(async () => {
const fs = require('fs');
const rl = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
const ask = q => new Promise(r => rl.question(q, r));
// store:
const encoded = JSON.stringify(encrypted);
fs.writeFileSync('archive.json', encoded);
// load:
const fileData = fs.readFileSync('archive.json');
const decoded = JSON.parse(fileData.toString());
// decrypt loop:
if (process.argv[2] == 'loop') { // node index.js loop
while (true) {
const key = await ask('key? ');
console.log(decryptSecrets(decoded, key));
}
}
process.exit(0);
})(); // only an iife if you want it to be! call it to try it out.
// and now for the implementation. let's begin!
function encryptSecrets(secrets, padBlocks = 1) {
if (secrets == null || !Array.isArray(secrets) || secrets.length < 1) {
throw new Error('bad input: secrets');
}
if (!Number.isInteger(padBlocks) || padBlocks < 0) {
throw new Error('bad input: padBlocks');
}
// if a key is duplicated, the set will be smaller.
const keys = secrets.map(s => s.key);
if (keys.length > new Set(keys).size) {
throw new Error('bad input: duplicate keys')
}
// fill data blocks with encrypted data and
// fill padding blocks with random noise.
const blocksOrdered = [];
for (const secret of secrets) {
blocksOrdered.push(encrypt(secret.data, secret.key));
}
for (let i = 0; i < padBlocks; i++) {
blocksOrdered.push(randomHex(16) + randomHex());
}
// custom step: shuffle blocks, including padding.
// by shuffling them, you can ensure no apparent gaps are revealed
// if two keys are revealed, and a third secret exists in between,
// and the existence of front or middle padding *may* also protect
// certain information about the secrets contained in the archive.
const blockOrders = Array(secrets.length + padBlocks)
.fill(0)
.map((_, i) => i);
// this shuffles the indices, which determines
// the order of the blocks in the output.
shuffle(blockOrders);
const blocks = Array(blocksOrdered.length);
let i = 0;
for (const j of blockOrders) {
// move each block to its new shuffled position.
blocks[i++] = blocksOrdered[j];
}
// prepare encrypted headers. iterate in order
// of the final block order, so the start
// position can be recorded along the way.
const headers = [];
let startPosition = 0;
for (const i in blocks) {
const { length } = blocks[i];
// generate the encrypted header. also generate a
// random encryption key if this header points to
// a padding block. this makes it impossible to
// determine what kind of data a header points to,
// either encrypted user data or random dummy data.
const key = secrets[blockOrders[i]]?.key // get initial index -> secret.
?? crypto.randomBytes(16).toString('hex'); // generate 128 bits of entropy.
const headerJSON = JSON.stringify({
start: startPosition,
length
});
// push back the start position by the size of this block.
startPosition += length;
headers.push(encrypt(headerJSON, key));
}
// shuffle the headers as well, in a different pattern
// than the blocks were shuffled. header order doesn't
// matter, since they still contain the same positional
// information, and this prevents revealing the existence
// of additional blocks between two other revealed blocks.
shuffle(headers);
// example: given that headers 2 and 4 are revealed, you
// cannot say with any certainty that headers 1 and 3 are
// also real headers, as those could be 2 padding headers
// shuffled into the list of real ones.
return {
// the header array should remain intact, since my
// implementation doesn't define header size or count.
// this will also make the decryption step easier.
headers,
// this is the output data chunk. you should
// combine blocks into one string so that no data
// is revealed about the number or size of blocks.
data: blocks.join(''),
};
}
function decryptSecrets({ headers, data }, key) {
// pretty easy to iterate through the headers, eh?
for (const i in headers) {
const header = headers[i];
const decrypted = decrypt(header, key);
try {
const { start, length } = JSON.parse(decrypted);
// this should never fail, unless input is modified by the user.
const encData = data.substring(start, start + length);
return {
header: +i,
data: decrypt(encData, key)
}
} catch (_) {
// no match on this header (decrypted data
// is not valid json); check the next one.
}
}
throw new Error('no header match found for provided key');
}
// random utils
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = secureRandomInt(0, i + 1);
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
function randomHex(size) {
if (size == null) {
// this is random padding. it could use some
// more thought, or maybe even user input.
size = secureRandomInt(1, 1024);
}
return crypto.randomBytes(size).toString('hex');
}
// from https://github.com/Zytekaron/jvar.js/blob/master/fn/conform.js
function conform(num, imin, imax, omin, omax) {
return omin + (omax - omin) * ((num - imin) / (imax - imin));
}
// from https://github.com/Zytekaron/jvar.js/blob/master/fn/constrain.js
function constrain(num, min, max) {
return num < min ? min : num > max ? max : num;
}
// from https://github.com/Zytekaron/jvar.js/blob/master/math/secureRandomInt.js
function secureRandomInt(min, max) {
const range = max - min;
const bits = Math.ceil(Math.log2(range));
const bytes = constrain(Math.ceil(bits / 8), 4, 2 ** 31 - 1);
const random = crypto
.randomBytes(bytes)
.readUInt32BE(0);
return Math.floor(conform(random, 0, 2 ** 32, min, max));
}
// encryption helpers
function sha256(...data) {
const sha = crypto.createHash('sha256');
for (const elem of data) {
sha.update(elem);
}
return sha.digest();
}
function encrypt(message, key) {
// hash the key to get data useful to aes.
const keyHash = sha256(key);
// generate a random initialization vector.
const iv = crypto.randomBytes(16);
// create an aes-256-ctr cipher with the key and iv.
const cipher = crypto.createCipheriv('aes-256-ctr', keyHash, iv);
// update the cipher with the plaintext data and store the encrypted data.
const enc = cipher.update(message, 'utf8');
// return the encrypted data.
return iv.toString('hex') + enc.toString('hex') + cipher.final().toString('hex');
}
function decrypt(message, key) {
// parse the initialization vector and encrypted data from the input.
const iv = Buffer.from(message.substring(0, 32), 'hex');
const raw = Buffer.from(message.substring(32), 'hex');
// hash the key to get data useful to aes.
const keyHash = sha256(key);
// create an aes-256-ctr decipher with the key and iv.
const cipher = crypto.createDecipheriv('aes-256-ctr', keyHash, iv);
// update the cipher with the encrypted data and store the plaintext data.
const dec = cipher.update(raw, 'utf8');
// return the decrypted data.
return dec.toString('utf8') + cipher.final('utf8');
}
@Zytekaron
Copy link
Author

Zytekaron commented Nov 10, 2022

I realized a massive plausible deniability issue with this implementation: there's an array of headers, one per secret. so basically, it tells you how many secrets there are. I'll revise this and post the fixed version some time later.

Edit: I've revised it, so now one header is generated for each block, including padding blocks. They're also shuffled, in a different order than the regular blocks (I don't think this helps at all, but it can't hurt), to prevent two discovered headers from revealing that more exist in between. This also gives another reason for a user to add more than one padding block when encrypting.

Encrypted data for padding headers looks the same as for regular headers, with the only difference being that they're encrypted using a unique random key instead of a user-provided one. This is enough to ensure that you can't differentiate between real and padding blocks, and thus an attacker can't count real blocks or otherwise find out about any real blocks besides ones which have already been discovered.

@Zytekaron
Copy link
Author

Quick notes:

With a lot of headers and a lot of data (multiple orders of magnitude of bytes in the data chunk), you might be able to roughly or fully reorder the headers by where they point from the beginning to the end of the data chunk, due to the start position increasing in string length when encoded into json. This shouldn't be an issue, since knowing the order of the headers (which are generated in the same order as the shuffled data blocks, not in the order of the initial secrets) still shouldn't grant you any information about which chunks are real and which are just padding. If I'm not correct here, let me know, because the headers may need to each be equal in length once encoded into json or whatever else to prevent header order reconstruction.

I didn't address generating plausible random padding sizes (I just picked a couple numbers for rng bounds), though I think it would make the most sense to leave that up to the user during the encryption process if they're serious about plausible deniability, to prevent math from being used to guess at what data is real or padding, or whether other data exists after you discover some (addressed here: sunknudsen/blockcrypt/issues/3).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment