typo-steganography
Version:
Hide secret information in typographical errors
1,581 lines (1,226 loc) • 38.1 kB
JavaScript
/* ----------------------------------------------------------------------------
* typo v0.4.12
*
* Hide secret information in typographical errors
*
* Author: Manish Jethani (manish.jethani@gmail.com)
* Date: April 9, 2015
*
* See 'typo --help'
*
* PGP: 57F8 9653 7461 1F9C EEF9 578B FBDC 955C E6B7 4303
*
* Bitcoin: 1NxChtv1R6q6STF9rq1BZsZ4jUKDh5MsQg
*
* http://manishjethani.com/
*
* Copyright (c) 2015 Manish Jethani
*
* Permission to use, copy, modify, and/or distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
* SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
* IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
* ------------------------------------------------------------------------- */
var crypto = require('crypto');
var fs = require('fs');
var os = require('os');
var path = require('path');
var readline = require('readline');
var stream = require('stream');
var _name = 'typo';
var _version = '0.4.12';
var QWERTY = !'<%= package %>' && '<%= keyboard %>';
var WORDS = !'<%= package %>' && '<%= dictionary %>'.split('\n');
var HELP_TEXT = !'<%= package %>' && '<%= help %>';
var LICENSE_TEXT = !'<%= package %>' && '<%= license %>';
var dictionary = {};
var rules = {};
var rulesetOrder = [];
var wordCharacter = /[A-Za-z'-]/;
var wordPattern = /^'?[A-Za-z]+-?[A-Za-z]+'?[A-Za-z]'?$/;
var say = function () {};
var colors = {
'black': { open: 30, close: 39 },
'red': { open: 31, close: 39 },
'green': { open: 32, close: 39 },
'yellow': { open: 33, close: 39 },
'blue': { open: 34, close: 39 },
'magenta': { open: 35, close: 39 },
'cyan': { open: 36, close: 39 },
'white': { open: 37, close: 39 },
'gray': { open: 90, close: 39 },
'grey': { open: 90, close: 39 },
};
var backgroundColors = {
'black': { open: 40, close: 49 },
'red': { open: 41, close: 49 },
'green': { open: 42, close: 49 },
'yellow': { open: 43, close: 49 },
'blue': { open: 44, close: 49 },
'magenta': { open: 45, close: 49 },
'cyan': { open: 46, close: 49 },
'white': { open: 47, close: 49 },
};
var textStyles = {
'bold': { open: 1, close: 22 },
'dim': { open: 2, close: 22 },
'italic': { open: 3, close: 23 },
'underline': { open: 4, close: 24 },
'inverse': { open: 7, close: 27 },
'hidden': { open: 8, close: 28 },
'strikethrough': { open: 9, close: 29 },
};
function sayImpl(prefix) {
if (!prefix) {
return console.error;
} else if (typeof prefix === 'function') {
return function () {
console.error.apply(console, [ prefix() ].concat(
sliceArguments(arguments)));
};
} else {
return function () {
console.error.apply(console, [ prefix ].concat(
sliceArguments(arguments)));
};
}
}
function sliceArguments(begin, end) {
return Array.prototype.slice.call(sliceArguments.caller.arguments,
begin, end);
}
function async(func) {
var args = sliceArguments(1);
process.nextTick(function () {
func.apply(null, args);
});
}
function chain(list, errorCallback, doneCallback) {
// This function lets you chain function calls so the output of one is the
// the input to the next. If any of them throws an error, it goes to the
// error callback. Once the list has been exhausted, the final result goes to
// the done callback.
var params = sliceArguments(3);
var func = list.shift();
if (func) {
params.push(function (error) {
if (error) {
errorCallback(error);
} else {
chain.apply(null, [ list, errorCallback, doneCallback ]
.concat(sliceArguments(1)));
}
});
} else {
func = doneCallback;
}
async(function () {
try {
func.apply(null, params);
} catch (error) {
errorCallback(error);
}
});
}
function die() {
if (arguments.length > 0) {
console.error.apply(console, arguments);
}
process.exit(1);
}
function dieOnExit() {
process.exitCode = 1;
}
function logError(error) {
if (error) {
console.error(error.toString());
}
}
function parseArgs(args) {
// This is another cool function. It parses command line arguments of two
// kinds: '--long-name[=<value>]' and '-n [<value>]'
//
// If the value is omitted, it's assumed to be a boolean true.
//
// You can pass in default values and a mapping of short names to long names
// as the first and second arguments respectively.
var rest = sliceArguments(1);
var defaultOptions = typeof rest[0] === 'object' && rest.shift() || {};
var shortcuts = typeof rest[0] === 'object' && rest.shift() || {};
var expect = null;
var stop = false;
var obj = Object.create(defaultOptions);
obj = Object.defineProperty(obj, '...', { value: [] });
obj = Object.defineProperty(obj, '!?', { value: [] });
// Preprocessing.
args = args.reduce(function (newArgs, arg) {
if (!stop) {
if (arg === '--') {
stop = true;
// Split '-xyz' into '-x', '-y', '-z'.
} else if (arg.length > 2 && arg[0] === '-' && arg[1] !== '-') {
arg = arg.slice(1).split('').map(function (v) { return '-' + v });
}
}
return newArgs.concat(arg);
},
[]);
stop = false;
return args.reduce(function (obj, arg, index) {
var single = !stop && arg[0] === '-' && arg[1] !== '-';
if (!(single && !(arg = shortcuts[arg]))) {
if (!stop && arg.slice(0, 2) === '--') {
if (arg.length > 2) {
var eq = arg.indexOf('=');
if (eq === -1) {
eq = arg.length;
}
var name = arg.slice(2, eq);
if (!single && !defaultOptions.hasOwnProperty(name)) {
obj['!?'].push(arg.slice(0, eq));
return obj;
}
if (single && eq === arg.length - 1) {
obj[expect = name] = '';
return obj;
}
obj[name] = typeof defaultOptions[name] === 'boolean'
&& eq === arg.length
|| arg.slice(eq + 1);
} else {
stop = true;
}
} else if (expect) {
obj[expect] = arg;
} else if (rest.length > 0) {
obj[rest.shift()] = arg;
} else {
obj['...'].push(arg);
}
} else if (single) {
obj['!?'].push(args[index]);
}
expect = null;
return obj;
},
obj);
}
function prettyBuffer(buffer) {
return (buffer.toString('hex').toUpperCase().match(/.{2}/g) || []).join(' ');
}
function hash(message, algorithm) {
return crypto.Hash(algorithm || 'sha256').update(message).digest();
}
function stringDistance(s, t) {
var a = new Array(t.length + 1);
for (var x = 0; x < a.length; x++) {
a[x] = x;
}
for (var j = 1; j <= s.length; j++) {
var p = a[0]++;
for (var k = 1; k <= t.length; k++) {
var o = a[k];
if (s[j - 1] === t[k - 1]) {
a[k] = p;
} else {
a[k] = Math.min(a[k - 1] + 1, a[k] + 1, p + 1);
}
p = o;
}
}
return a[t.length];
}
function shuffle(array) {
for (var m = array.length - 1; m >= 0; m--) {
var x = 0 | Math.random() * (m + 1);
if (x !== m) {
var p = array[x];
array[x] = array[m];
array[m] = p;
}
}
return array;
}
function sortBy(array, prop) {
return array.sort(function (a, b) {
return -(a[prop] < b[prop]) || +(a[prop] > b[prop]);
});
}
function findCloseMatches(string, candidateList, distanceThreshold) {
if (arguments.length < 3) {
distanceThreshold = 1;
}
var matches = candidateList.map(function (candidate) {
// Split candidate into individual components. e.g. 'output-file' becomes a
// list containing 'output', 'file', and 'output-file'.
var candidateWords = candidate.split('-');
if (candidateWords.length > 1) {
candidateWords.push(candidate);
}
var distance = candidateWords.reduce(function (distance, word) {
// Take the lowest distance.
return Math.min(distance, stringDistance(string, word));
},
Infinity);
return {
candidate: candidate,
distance: distance
};
}).filter(function (match) {
return match.distance <= distanceThreshold;
});
sortBy(matches, 'distance');
return matches.map(function (match) { return match.candidate });
}
function typeMatch(one, other, type, exempt) {
// Check that every property of the given type in one object is also of the
// same type in the other object.
return Object.keys(one).every(function (key) {
return typeof one[key] !== type
|| typeof other[key] === type
|| exempt && exempt.indexOf(key) !== -1;
});
}
function trigrams(word) {
// Return three-letter sequences for the word.
// Example: 'hello' ...
var seq = [
// '^he', 'lo$'
'^' + word.slice(0, 2),
word.slice(word.length - 2) + '$'
];
// 'hel', 'ell', 'llo'
for (var i = 0; i < word.length - 2; i++) {
seq.push(word.slice(i, i + 3));
}
return seq;
}
function parseTabularData(data) {
if (data == null) {
return null;
}
var lines = data.toString().split('\n');
var records = lines.filter(function (line) {
return line.match(/^[^#]/);
});
return records.map(function (record) {
return record.split('\t');
});
}
function stringToBuffer(string, format) {
var buffer = null;
switch (format) {
case 'hex':
string = '0'.slice(0, string.length % 2) + string;
case 'base64':
buffer = new Buffer(string, format);
break;
default:
buffer = new Buffer(string);
}
return buffer;
}
function bufferToString(buffer, format) {
var string = null;
switch (format) {
case 'hex':
case 'base64':
string = buffer.toString(format);
break;
default:
string = buffer.toString();
}
return string;
}
function slurp(callback) {
var input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', function () {
var chunk = process.stdin.read();
if (chunk !== null) {
input += chunk;
}
});
process.stdin.on('end', function () {
callback(null, input);
});
}
function slurpFile(filename, callback) {
fs.readFile(filename, { encoding: 'utf8' }, callback);
}
function slurpFileSync(filename) {
return fs.readFileSync(filename, { encoding: 'utf8' });
}
function dumpFile(filename, stream, transformer) {
if (!stream) {
stream = process.stdout;
}
if (transformer) {
transformer.pipe(stream);
stream = transformer;
}
fs.createReadStream(filename, { encoding: 'utf8' }).pipe(stream);
}
function readInput(filename, callback) {
if (filename) {
say('Reading input from file ' + filename);
slurpFile(filename, callback);
} else {
say('Reading input from stdin');
slurp(callback);
}
}
function writeOutput(string, filename) {
if (string != null) {
if (filename) {
say('Writing output to file ' + filename);
fs.writeFileSync(filename, string);
} else {
say('Writing output to stdout');
process.stdout.write(string);
}
}
}
function prompt(label, quiet, callback) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error('No TTY.');
}
if (arguments.length > 0) {
callback = arguments[arguments.length - 1];
if (typeof callback !== 'function') {
callback = null;
}
}
if (typeof quiet !== 'boolean') {
quiet = false;
}
if (typeof label === 'string') {
process.stdout.write(label);
}
var rl = readline.createInterface({
input: process.stdin,
// The quiet argument is for things like passwords. It turns off standard
// output so nothing is displayed.
output: !quiet && process.stdout || null,
terminal: true
});
rl.on('line', function (line) {
rl.close();
if (quiet) {
process.stdout.write(os.EOL);
}
if (callback) {
callback(null, line);
}
});
}
function deriveKey(password, salt, length) {
return crypto.pbkdf2Sync(password, salt, 0x100000, length, 'sha256');
}
function encrypt(buffer, password, salt, authenticated) {
var keyLength = 48;
var algorithm = 'aes-256-ctr';
if (authenticated) {
keyLength = 44;
algorithm = 'aes-256-gcm';
}
var key = deriveKey(password, salt, keyLength);
var cipher = crypto.createCipheriv(algorithm, key.slice(0, 32),
key.slice(32));
var encrypted = Buffer.concat([ cipher.update(buffer), cipher.final() ]);
if (authenticated) {
// Attach 16-byte authentication tag.
encrypted = Buffer.concat([ encrypted, cipher.getAuthTag() ]);
}
return encrypted;
}
function decrypt(buffer, password, salt, authenticated) {
var keyLength = 48;
var algorithm = 'aes-256-ctr';
if (authenticated) {
keyLength = 44;
algorithm = 'aes-256-gcm';
}
var key = deriveKey(password, salt, keyLength);
var decipher = crypto.createDecipheriv(algorithm, key.slice(0, 32),
key.slice(32));
if (authenticated) {
decipher.setAuthTag(buffer.slice(-16));
buffer = buffer.slice(0, -16);
}
return Buffer.concat([ decipher.update(buffer), decipher.final() ]);
}
function wordValue(word) {
// The value of a word is the lower half of the first octet of its SHA-256
// digest.
//
// e.g. 'colour' is 6 (hash: 'd6838c35...')
return hash(word, 'sha256')[0] & 0xF;
}
function isColorName(name) {
return Object.keys(colors).indexOf(name) !== -1;
}
function isTextStyleName(name) {
return Object.keys(textStyles).indexOf(name) !== -1;
}
function colorize(text, color, table) {
if (!table) {
table = colors;
}
var open = '';
var close = '';
var obj = table[color];
if (obj) {
open = obj.open;
close = obj.close;
}
return '\u001b[' + open + 'm' + text + '\u001b[' + close + 'm';
}
function textStylize(text, textStyle) {
return colorize(text, textStyle, textStyles);
}
function stylize(text, style) {
var props = style && style.match(/([^\s]+)/g) || [];
var colorProps = props.filter(isColorName);
var textStyleProps = props.filter(isTextStyleName);
// Foreground and background colors respectively.
text = colorize(text, colorProps[0]);
text = colorize(text, colorProps[1], backgroundColors);
// Text styles.
text = textStyleProps.reduce(textStylize, text);
return text;
}
function printVersion() {
console.log(_name + ' v' + _version);
}
function printHelp() {
if (HELP_TEXT) {
process.stdout.write(HELP_TEXT);
process.stdout.write(os.EOL);
return;
}
dumpFile(path.join(__dirname, 'default.help'));
}
function printLicense() {
if (LICENSE_TEXT) {
process.stdout.write(LICENSE_TEXT);
process.stdout.write(os.EOL);
return;
}
dumpFile(path.join(__dirname, 'LICENSE'));
}
function printSource() {
dumpFile(__filename);
}
function printUsage() {
var seeHelp = os.EOL + os.EOL + "See '" + _name + " --help'."
+ os.EOL + os.EOL;
var breakAt = '\n\n';
if (HELP_TEXT) {
process.stderr.write(HELP_TEXT.slice(0, HELP_TEXT.indexOf(breakAt)));
process.stderr.write(seeHelp);
return;
}
var cut = false;
var x = new stream.Transform({ decodeStrings: false });
x._transform = function (chunk, encoding, callback) {
if (!cut) {
var br = chunk.indexOf(breakAt);
if (br !== -1) {
cut = true;
this.push(chunk.slice(0, br));
} else {
this.push(chunk);
}
callback();
}
};
x._flush = function (callback) {
this.push(seeHelp);
callback();
};
dumpFile(path.join(__dirname, 'default.help'), process.stderr, x);
}
function printCloseMatches(string, candidateList) {
var closeMatches = findCloseMatches(string, candidateList, 2);
if (closeMatches.length > 1) {
console.error('Did you mean one of these?');
} else if (closeMatches.length === 1) {
console.error('Did you mean this?');
}
closeMatches.forEach(function (v) {
console.error('\t' + v);
});
}
function loadDictionary(filename) {
say('Loading dictionary' + (filename ? ' file ' + filename : ''));
var defaultFilename = path.join(__dirname, 'dictionary');
var defaultWords = WORDS;
var words = filename ? slurpFileSync(filename).split('\n')
: defaultWords || slurpFileSync(defaultFilename).split('\n');
words.forEach(function (word) {
trigrams(word).forEach(function (v) {
dictionary[v] = dictionary[v] + 1 || 1;
});
});
}
function loadKeyboard(filename) {
say('Loading keyboard ' + (filename ? 'file ' + filename : 'QWERTY'));
var defaultFilename = path.join(__dirname, 'QWERTY.keyboard');
var defaultLayout = QWERTY;
var keyboard = [];
var layout = filename ? slurpFileSync(filename)
: defaultLayout || slurpFileSync(defaultFilename);
layout.split('\n').forEach(function (row) {
var keys = row.split('');
if (keys.length > 0) {
keyboard.push(keys);
}
});
var ruleset = [];
var addRule = function (pattern, substitution, weight) {
ruleset = ruleset.concat(createRule(pattern, substitution, weight));
};
for (var i = 0; i < keyboard.length; i++) {
for (var j = 0; j < keyboard[i].length; j++) {
var c = (keyboard[i][j] || '').toLowerCase();
if (c.match(/[a-z]/)) {
for (var k = j - 1; k <= j + 1; k += 2) {
var x = (keyboard[i][k] || '').toLowerCase();
if (x.match(/[a-z]/)) {
var p = '([^' + c + x + '][^' + c + x + '])' + c
+ '([^' + c + x + '][^' + c + x + '])';
// Insertions (aka "fat fingers")
addRule(p, '$1' + c + x + '$2', 4);
addRule(p, '$1' + x + c + '$2');
// Substitutions (wrong key)
addRule(p, '$1' + x + '$2');
}
}
// Transpositions
addRule('([^' + c + '])' + c + '([a-z])(?!\\2)', '$1$2' + c, 4);
// Shift typos (e.g. "THe")
addRule('^([A-Z])' + c, '$1' + c.toUpperCase());
}
}
}
rules['keyboard'] = ruleset;
rulesetOrder.push('keyboard');
}
function createRule(pattern, substitution, weight) {
if (arguments.length < 3) {
return { re: new RegExp(pattern), sub: substitution };
}
if (isNaN(weight = +weight)) {
weight = 1;
}
var array = [];
for (var i = 0; i < weight; i++) {
array.push(createRule(pattern, substitution));
}
return array;
}
function loadRulesetFile(filename, alias) {
var data = slurpFileSync(filename);
var records = parseTabularData(data);
var ruleset = records.reduce(function (ruleset, fields) {
return ruleset.concat(createRule.apply(null, fields.slice(0, 2)));
},
[]);
rules[alias || filename] = ruleset;
return ruleset;
}
function loadRules(name) {
if (rules.hasOwnProperty(name)) {
return rules[name];
}
say('Loading ruleset ' + name);
return loadRulesetFile(path.join(__dirname, name + '.rules'), name);
}
function rulesetAvailable(name) {
try {
fs.accessSync(path.join(__dirname, name + '.rules'));
return true;
} catch (error) {
return false;
}
}
function loadRulesets(spec, filename) {
if (filename) {
rulesetOrder = 'custom'.split(' ');
say('Loading ruleset file ' + filename);
loadRulesetFile(filename, 'custom');
} else {
if (spec != null) {
rulesetOrder = spec.match(/([^ ,]+)/g) || [];
} else {
if (rulesetAvailable('misspelling')) {
rulesetOrder.push('misspelling');
}
if (rulesetAvailable('grammatical')) {
rulesetOrder.push('grammatical');
}
}
rulesetOrder.forEach(loadRules);
}
}
function shuffleRules(name) {
shuffle(rules[name] || []);
}
function mapOptions(options, names, values) {
names.forEach(function (n) {
var v = values.shift();
if (v !== undefined && !options.hasOwnProperty(n)) {
options[n] = v;
}
});
}
function readPassword(password, callback) {
if (password === true) {
prompt('Password: ', true, callback);
} else {
async(callback, null, typeof password === 'string' ? password : null);
}
}
function checkPlausibility(typo) {
// Check if the typo is 'plausible' (note: quotes).
var n = trigrams(typo.toLowerCase()).reduce(function (a, v) {
return a + !!dictionary[v];
},
0);
// If every three-letter sequence in the word occurs at least once in the
// dictionary, we consider it 'plausible'.
return n / typo.length >= 1;
}
function generateTypos(word) {
if (!word.match(wordPattern)) {
return [];
}
var collection = [];
// Bookkeeping.
var book = {};
rulesetOrder.forEach(function (name) {
if (!rules.hasOwnProperty(name)) {
// Ruleset is not available.
return;
}
rules[name].forEach(function (rule) {
var mutation = word.replace(rule.re, rule.sub);
if (mutation === word
// Include every mutation no more than once.
|| book.hasOwnProperty(mutation)
// For QWERTY typos, include the typo only if it passes the
// 'plausibility' test.
|| (name === 'keyboard' && !checkPlausibility(mutation))
) {
return;
}
collection.push(mutation);
book[mutation] = true;
});
});
return collection;
}
function processWord(word, buffer, offset) {
// Take the low 4 bits.
var nibble = 0xF & buffer[offset];
generateTypos(word).some(function (candidate) {
if (wordValue(candidate) === nibble) {
// This typo works.
word = candidate;
return true;
}
});
return word;
}
function extractTypos(original, modified) {
// Compare the two texts to get all the typos.
var typos = [];
var offset = 0;
var i = -1;
var j = -1;
var k = -1;
var c = null;
for (i = 0; i < modified.length; i++) {
if (modified[i] !== original[i + offset]) {
// We've hit a typo!
var word = '';
// Add every character until the beginning of the word.
for (j = i - 1; j >= 0; j--) {
c = modified[j];
if (!c.match(wordCharacter)) {
break;
}
word = c + word;
}
// Now add every character until the end of the word.
for (j = i; j < modified.length; j++) {
c = modified[j];
if (!c.match(wordCharacter)) {
break;
}
word += c;
}
// This is the piece of information we're looking for.
typos.push(word);
// Stay in sync with the original text.
for (k = i + offset; k < original.length; k++) {
if (!original[k].match(wordCharacter)) {
break;
}
}
i = j;
offset = k - j;
}
}
return typos;
}
function extractTyposFromMarkup(markup) {
var typos = [];
// Look for our custom markup, which is of the form '{[s/typo/correction/]}'
var index = markup.indexOf('{[s/');
while (index !== -1) {
var x1 = markup.indexOf('/', index + 4) + 1;
var x2 = markup.indexOf('/', x1);
// Extract typo and save it.
typos.push(markup.slice(index + 4, x1 - 1));
// Also do the substitution on the input text.
markup = markup.slice(0, index) + markup.slice(x1, x2)
+ markup.slice(x2 + 3);
index = markup.indexOf('{[s/');
}
// By the end of the loop we have both the list of typos and the original
// text.
return {
typos: typos,
original: markup
};
}
function encryptSecret(secret, format, password, covertext, nosalt,
authenticated) {
var salt = hash(!nosalt && covertext || '');
var extraSalt = !nosalt && crypto.randomBytes(2) || new Buffer(0);
var encrypted = encrypt(stringToBuffer(secret, format),
password || '',
Buffer.concat([ salt, extraSalt ]),
authenticated);
return Buffer.concat([ extraSalt, encrypted ]);
}
function decryptPayload(payload, format, password, covertext, nosalt,
authenticated) {
var ciphertextBegin = !nosalt ? 2 : 0;
var salt = hash(!nosalt && covertext || '');
var extraSalt = payload.slice(0, ciphertextBegin);
var decrypted = decrypt(payload.slice(ciphertextBegin),
password || '',
Buffer.concat([ salt, extraSalt ]),
authenticated);
return bufferToString(decrypted, format);
}
function encode(message, secret, password, options) {
var result = null;
if (password) {
say('Password:', new Array(2 + Math.floor(Math.random() * 15)).join('*'));
}
say('Encrypting ...');
var buffer = encryptSecret(secret, options.format, password, message,
options.deterministic || options.nosalt,
options.authenticated);
say('Encrypted secret:', prettyBuffer(buffer));
say('Buffer size: ' + buffer.length);
var random = null;
var odd = false;
if (!options.deterministic) {
try {
random = crypto.randomBytes(2);
// One in two times add an extra meaningless typo just to throw 'em off.
// By default we always have an even number of typos. This helps.
odd = random[1] >= 128;
} catch (error) {
}
}
// This is the ratio of the total number of typos to the message length. It's
// the rate at which typos should be introduced. We want to make sure the
// typos are spread out more or less evenly.
var density = (buffer.length * 2 + odd) / message.length;
say('Density: ' + (density * 1000).toFixed(4) + ' per thousand');
// This is how much we try to squeeze the information into the message.
var multiplier = 1.0;
do {
say('Trying with multiplier ' + multiplier.toFixed(4));
var workingBuffer = new Buffer(buffer);
var word = '';
var count = 0;
var targetDensity = density * multiplier;
result = '';
for (var i = 0; i < message.length; i++) {
var c = message[i];
if (c.match(wordCharacter)) {
word += c;
} else {
if (word) {
// Here we're dividing count by two and rounding down. The offset
// into the buffer is half of the number of typos already introduced,
// because each typo carries only 4 bits of information.
var offset = count >>> 1;
var newWord = null;
if (offset < buffer.length) {
// Adjust the bar for letting in the next typo based on the current
// rate.
var bar = count / i / targetDensity || 0;
if (bar < 1.0) {
newWord = processWord(word, workingBuffer, offset);
} else {
newWord = word;
}
} else if (odd) {
// Throw in the extra typo.
newWord = processWord(word, random, 0);
} else {
newWord = word;
}
var replacement = newWord;
if (newWord !== word) {
if (options.markup) {
replacement = '{[s/' + newWord + '/' + word + '/]}';
// Once you're satisfied with the result, open in Vim and do:
// %s/{\[s\/\([^\/]\+\)\/[^\/]\+\/\]}/\1/g
} else if (options.highlight) {
replacement = stylize(replacement, options.highlight);
}
if (offset < buffer.length) {
// Bring the next 4 bits into position.
workingBuffer[offset] >>>= 4;
} else {
odd = false;
}
if (++count >>> 1 >= buffer.length && !odd) {
// Optimization: We have enough typos now, let's add the rest of
// the text and get out of this loop.
result += replacement;
result += message.slice(i);
break;
}
}
result += replacement;
word = '';
}
result += c;
}
}
say('Score: ' + count + ' / ' + buffer.length * 2);
// Try again if required with a higher density.
} while (count >>> 1 < buffer.length && (multiplier *= 1.1) <= 10.0);
if (count >>> 1 < buffer.length) {
// This is the main problem. The input text simply isn't big enough for the
// secret. For example, you can't encode 'Hello, world!' in 'A quick brown
// fox jumped over the lazy dog.'
throw new Error('Not enough text.');
}
return result;
}
function decode(message, password, options) {
var original = null;
var typos = null;
say('Extracting typos');
if (options.original != null) {
// If we have the original text, we're only interested in extracting the
// typos.
typos = extractTypos(original = options.original, message);
} else {
// If the original text hasn't been provided, then we assume the input text
// contains substitution markup, and we try to extract both the list of
// typos and the original text out of it.
var obj = extractTyposFromMarkup(message);
original = obj.original;
typos = obj.typos;
}
if (typos.length % 2 === 1) {
// Ignore any odd typo at the end.
typos.pop();
}
var buffer = new Buffer(typos.length / 2);
say('Buffer size: ' + buffer.length);
for (var i = 0; i < typos.length; i++) {
var d = wordValue(typos[i]);
// Read the encrypted secret 4 bits at a time. The even ones are the low 4
// bits, the odd ones are the high 4 bits.
if (i % 2 === 0) {
buffer[i >>> 1] = d;
} else {
buffer[i >>> 1] |= d << 4;
}
}
say('Encrypted secret:', prettyBuffer(buffer));
if (password) {
say('Password:', new Array(2 + Math.floor(Math.random() * 15)).join('*'));
}
say('Decrypting ...');
// Finally, decrypt the buffer to get the original secret.
return decryptPayload(buffer, options.format, password, original,
options.nosalt,
options.authenticated);
}
function query(q, options) {
say('Generating typos');
var data = generateTypos(q || '').map(function (typo) {
var value = wordValue(typo);
var grams = trigrams(typo.toLowerCase());
var hits = grams.reduce(function (a, v) {
return a + (dictionary[v] || 0);
},
0);
var score = hits / grams.length;
return { typo: typo, value: value, score: score };
});
// Sort by score.
sortBy(data, 'score').reverse();
return data.map(function (record) {
return [
stylize(record.typo, options.highlight),
record.value.toString(16).toUpperCase(),
record.score.toFixed(4),
].join('\t');
}).join(os.EOL);
}
function run() {
if (process.argv.length <= 2) {
// No arguments.
dieOnExit();
printUsage();
return;
}
var defaultOptions = {
'version': false,
'help': false,
'license': false,
'view-source': false,
'highlight': null,
'verbose': false,
'secret': null,
'decode': false,
'file': null,
'output-file': null,
'original-file': null,
'format': null,
'password': false,
'authenticated': false,
'nosalt': false,
'markup': false,
'deterministic': false,
'rulesets': null,
'ruleset-file': null,
'keyboard-file': null,
'dictionary-file': null,
'query': null,
};
var shortcuts = {
'-V': '--version',
'-h': '--help',
'-?': '--help',
'-v': '--verbose',
'-d': '--decode',
'-f': '--file=',
'-o': '--output-file=',
'-g': '--original-file=',
'-P': '--password',
'-a': '--authenticated',
'-q': '--query=',
};
var options = parseArgs(process.argv.slice(2), defaultOptions, shortcuts);
var seeHelp = os.EOL + os.EOL + "See '" + _name + " --help'."
+ os.EOL;
if (options['!?'].length > 0) {
var unknown = options['!?'][0];
console.error("Unknown option '" + unknown + "'." + seeHelp);
if (unknown.slice(0, 2) === '--') {
// Find and display close matches using Levenshtein distance.
printCloseMatches(unknown.slice(2), Object.keys(defaultOptions));
}
die();
}
if ((options.help || options.version || options.license
|| options['view-source'])
&& Object.keys(options).length > 1) {
// '--help', '--version', and '--license' do not take any arguments.
dieOnExit();
printUsage();
return;
}
if (options.help) {
printHelp();
return;
}
if (options.version) {
printVersion();
return;
}
if (options.license) {
printLicense();
return;
}
if (options['view-source']) {
printSource();
return;
}
var optKeys = Object.keys(options);
// There are three 'modes' broadly: encode (default), decode, and query.
var decodeMode = options.decode;
var queryMode = options.hasOwnProperty('query');
var encodeMode = !decodeMode && !queryMode;
var validOpts = null;
// Valid options for each mode.
if (encodeMode) {
validOpts = 'highlight verbose secret file output-file format password'
+ ' authenticated nosalt markup deterministic rulesets ruleset-file'
+ ' keyboard-file dictionary-file';
} else if (decodeMode) {
validOpts = 'highlight verbose decode file original-file format password'
+ ' authenticated nosalt markup';
} else if (queryMode) {
validOpts = 'highlight verbose query rulesets ruleset-file'
+ ' keyboard-file dictionary-file';
}
validOpts = validOpts && validOpts.split(' ') || [];
if (encodeMode + decodeMode + queryMode !== 1
|| !optKeys.every(function (k) { return validOpts.indexOf(k) !== -1 })) {
dieOnExit();
printUsage();
return;
}
// If any boolean options have non-boolean (string) values, print usage and
// exit.
if (!typeMatch(defaultOptions, options, 'boolean', [ 'password' ])) {
dieOnExit();
printUsage();
return;
}
// Positional arguments.
mapOptions(options, encodeMode ? [ 'secret', 'file' ] : [ 'file' ],
options['...']);
if (encodeMode && typeof options.secret !== 'string') {
dieOnExit();
printUsage();
return;
}
optKeys.forEach(function (name) {
if ((name === 'file' || name.slice(-5) === '-file')
&& options[name] === '') {
die('Filename cannot be blank.' + seeHelp);
}
});
if (decodeMode && !options['original-file'] && !options.markup) {
die("Required '--original-file' or '--markup' argument." + seeHelp);
}
if (options.format != null && options.format !== 'hex'
&& options.format !== 'base64') {
die("Format must be 'hex' or 'base64'." + seeHelp);
}
if (options.verbose) {
say = sayImpl(function () {
return '[' + process.uptime().toFixed(2) + ']';
});
}
var isTerminal = function () {
return !options['output-file'] && process.stdout.isTTY;
};
say('Hi!');
chain([
function (callback) {
readPassword(options.password, callback);
},
function (password, callback) {
if (encodeMode || decodeMode) {
readInput(options.file, function (error, message) {
callback(error, password, message);
});
} else {
callback(null, password, null);
}
},
function (password, message, callback) {
if (decodeMode && !options.markup) {
// Read the original file.
say('Reading original file ' + options['original-file']);
slurpFile(options['original-file'], function (error, original) {
callback(error, password, message, original);
});
} else {
callback(null, password, message, null);
}
},
function (password, message, original, callback) {
if (encodeMode || queryMode) {
loadDictionary(options['dictionary-file']);
loadKeyboard(options['keyboard-file']);
loadRulesets(options.rulesets, options['ruleset-file']);
if (encodeMode && !options.deterministic) {
say('Shuffling rules');
rulesetOrder.forEach(shuffleRules);
}
}
if (encodeMode) {
say('Secret: ' + options.secret);
say('Encoding');
var encodeOptions = Object.create(options);
if (!isTerminal()) {
encodeOptions.highlight = null;
}
var output = encode(message, options.secret, password,
encodeOptions);
if (!output) {
throw '';
}
callback(null, output);
} else if (decodeMode) {
say('Decoding');
var secret = decode(message, password,
Object.create(options, { original: { value: original } })
);
say('Secret: ' + secret);
// Note: secret can be an empty string! It's an error only if it's
// null or undefined.
if (secret == null) {
// Throw an empty string to exit quietly with a nonzero exit code.
throw '';
}
callback(null, secret);
} else if (queryMode) {
say('Query: ' + options.query);
callback(null, query(options.query, options));
}
}
],
function (error) {
logError(error);
say('Sorry, we failed');
die();
},
function (finalResult) {
say('Almost done!');
if (!encodeMode || isTerminal()) {
if (finalResult) {
console.log(finalResult);
}
} else {
writeOutput(finalResult, options['output-file']);
}
say('Goodbye');
}
);
}
function main() {
run();
}
if (require.main === module) {
main();
}
exports.run = run;
// vim: et ts=2 sw=2