WebCrypto zero-trust lottery

comment

Choosing a prize winner usually involves using a simple list of participants and the True Random Number Generator. That unfortunately makes the generator a trusted third-party.

Browsers that implement the WebCrypto API can be used to conduct a truly zero-trust, secure lottery in less than 200 lines of modern JavaScript code.

The implementation below uses several ES6 features:

Both Firefox 40 and Chrome 45 support all these features but Chrome needs to have Experimental JavaScript flag enabled at chrome://flags/#enable-javascript-harmony. Microsoft Edge can also be used but needs to have Experimental JavaScript features enabled at about:flags.

Views

The function below is used in conjunction with template strings to interleave template text with values provided inside placeholders. The placeholder values are automatically sanitized.

function html(parts, ...values) {
    var replacements = {
        '"': '"',
        '\'': ''',
        '<': '&lt;',
        '>': '&gt;',
        '&': '&amp;'
    };
    var replaceEntities = text =>
        text.replace(/["'<>&]/g, match => replacements[match]);

    var div = document.createElement('div');
    div.innerHTML = parts.map((part, index) =>
        part + replaceEntities(String(values[index] || ''))
    ).join('');
    return div.firstElementChild;
}

For example the input field below contains an unsafe HTML string:

The code below properly escapes the input string while leaving the template tags:

var node = html`<div>you <b>typed</b> ${input.value}.</div>`;
output.appendChild(node);

This template is then used to construct a dialog. The dialog is a small form used to present information or obtain an answer to the query.

function showDialog(type, title, value) {
    var info = document.getElementById('info');
    var row = html`<form class="${type || ''}">
        <label><span>${title}</span>
        <input value="${value}" class="value code" required/></label>
        <input type="submit" value="OK"/>
    </form>`;
    info.appendChild(row);
    var input = row.querySelector('input');
    input.select();
    input.focus();

    var ok = row.querySelector('input[type="submit"]');

    return new Promise(resolve =>
        row.addEventListener('submit', function submit(e) {
            e.preventDefault();
            row.removeEventListener('click', submit);
            ok.parentElement.removeChild(ok);
            input.readOnly = true;
            resolve(input.value);
        }));
}

var showInfo = showDialog.bind(null, 'infor');
var showError = showDialog.bind(null, 'error');
var showPrompt = showDialog.bind(null, '');

The answer is returned through a Promise. The code below asks a question and displays it in a dialog box:

showPrompt('Test').then(alert);

Rejection sampling

To generate cryptographically secure random values the crypto.getRandomValues API is used.

The function fills a one byte array with random number and takes the remainder after divison (modulo) by the maximum number. Unfortunately this operation does not produce uniform distribution — some numbers would be selected more often than others.

press Execute
from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  .select(() => crypto.getRandomValues(new Uint8Array(1))[0] % 10)
  .limit(iterations(100000))
  .into(graphs.bar(uintgraph));

As the number of trials increases it becomes apparent that 6, 7, 8 and 9 would be less frequently selected than the rest. Even though the random bytes from getRandomValues are uniformly distributed between 0 to 255 the modulo operation skews the distribution of 0 to 9.

The graphic below illustrates how all values from the random byte are mapped to a number from 0 to 9. Because the last row (numbers from 250 and above) contains only six elements instead of ten the results are more likely to contain numbers from 0 to 5 than these above 5.

0102030402302402500111213141231241251121222324223224225223132333432332432533414243444234244254451525354523524525556162636462362466717273747237247781828384823824889192939492392499Uint8Array[0]modulo 10

To generate uniformly distributed values from 0 (inclusive) to max (exclusive) a rejection sampling method is used.

In this method values that would cause the distribution skew (in this example 250 and above) are completely rejected and a new random number is drawn.

function getRandomInt(max) {
    console.assert(max <= 256, "Max must not be greater than 256.");

    var value = crypto.getRandomValues(new Uint8Array(1))[0];

    if (value >= Math.floor(256 / max) * max) {
        return getRandomInt(max);
    }

    return value % max;
}
press Execute
from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  .select(() => getRandomInt(10))
  .limit(iterations(100000))
  .into(graphs.bar(getrandomintgraph));

Now the distribution is normal and all numbers are equally likely to be drawn.

Math.random can also be used to generate uniformly distributed random numbers. This method is faster but not cryptographically secure.

press Execute
from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  .select(() => Math.floor(Math.random() * 10))
  .limit(iterations(100000))
  .into(graphs.bar(mathgraph));

It is important to use Math.floor instead of Math.round to keep the numbers uniformly distributed.

The example below uses round. That makes 0 and 9 twice as unlikely to be drawn out as other numbers.

press Execute
from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  .select(() => Math.round(Math.random() * 9))
  .limit(iterations(100000))
  .into(graphs.bar(mathroundgraph));

Handling bytes

To manage arrays of bytes a simple wrapper around Uint8Array is used. This wrapper adds comparison and equality test methods as well as printing bytes into hex-encoded human readable form.

function Bytes(bytes) {
    this.bytes = new Uint8Array(bytes);
}

Bytes.prototype = {
    get length() {
        return this.bytes.length;
    },
    compareTo: function(other) {
        return this.toString().localeCompare(other.toString());
    },
    equals: function(other) {
        return this.toString() === other.toString();
    },
    toString: function() {
        return Array.from(this.bytes)
            .map(byte => byte.toString(16))
            .map(byte => byte.length < 2 ? '0' + byte : byte)
            .join('');
    },
    digest: function() {
        return crypto.subtle.digest({
            name: 'SHA-256'
        }, this.bytes).then(digest => new Bytes(digest));
    }
};

Bytes.parse = function(text) {
    var bytes = text.match(/.{2}/g);
    if (!bytes) {
        throw new Error('Invalid format: ' + text);
    }
    return new Bytes(bytes.map(byte => {
        var number = parseInt(byte, 16);
        if (isNaN(number)) {
          throw new Error('Invalid byte: ' + byte);
        }
        return number;
    }));
};

Bytes.random = function(count) {
    return new Bytes(crypto.getRandomValues(new Uint8Array(count)));
};

function getRandomBytes(min, max) {
    return Bytes.random(min + getRandomInt(max - min));
}

The digest function returns a Promise object that will resolve to a hash of bytes.

User interaction

UI object handles interaction with user and data conversion. All functions on this object return Promises.

var ui = {
    getPlayersCount: () =>
        showPrompt('How many players?', 2).then(Number),
    sendOwnDigest: showInfo.bind(null,
        'Send your digest to everyone:'),
    pasteOtherDigest: () => showPrompt(
        'Paste here other participants\' digest (order does not matter):').then(Bytes.parse),
    cannotParseDigest: showError.bind(null,
        'Cannot parse digest. Ignoring.'),
    sendOwnSecret: showInfo.bind(null,
        'Send your secret to everyone:'),
    pasteOtherSecret: () => showPrompt(
        'Paste here other participants\' secret:').then(Bytes.parse),
    noDigestForSecret: showError.bind(null,
        'Secret does not match any digest. Ignoring.'),
    duplicateSecret: showError.bind(null,
        'Secret has been already added. Ignoring.'),
    cannotParseSecret: showError.bind(null,
        'Cannot parse secret. Ignoring.'),
    winnerDigest: showPrompt.bind(null, 'Winner holds this digest:'),
    unknownError: showError.bind(null, 'Something went wrong!')
};

Lottery

Main algorthm is implemented as an asynchronous function using generators. Each yield statement consumes a Promise value and suspends the execution until the promise is settled.

The algorithm operates in two phases:

  1. generate a secret and gather digests of secrets from all other parties,
  2. all parties reveal their secrets and the winner is selected.

To protect against brute-force digest attacks a random padding is used, similarily to the Bitcoin Multilottery example.

function* drawWinner(ui, getRandomBytes, padding) {
    var howMany = yield ui.getPlayersCount();
    var ownSecret = getRandomBytes(padding, padding + howMany);
    var ownDigest = yield ownSecret.digest();
    yield ui.sendOwnDigest(ownDigest);
    var digests = [ ownDigest ];
    do {
        try {
            digests.push(yield ui.pasteOtherDigest());
        } catch (e) {
            yield ui.cannotParseDigest(e);
        }
    } while (digests.length < howMany);
    yield ui.sendOwnSecret(ownSecret);
    var secrets = [ ownSecret ];
    do {
        try {
            const secret = yield ui.pasteOtherSecret();
            const digest = yield secret.digest();

            if (!digests.some(i => i.equals(digest))) {
                yield ui.noDigestForSecret(secret);
            } else if (secrets.some(i => i.equals(secret))) {
                yield ui.duplicateSecret(secret);
            } else {
                secrets.push(secret);
            }
        } catch (e) {
            yield ui.cannotParseSecret(e);
        }
    } while (secrets.length < digests.length);
    var winner = secrets
        .map(secret => secret.length - padding)
        .reduce((a, b) => a + b, 0) % howMany;
    digests.sort((a, b) => a.compareTo(b));
    return digests[winner];
}

async(drawWinner)(ui, getRandomBytes, 32)
    .then(ui.winnerDigest, ui.unknownError);

Currently the function uses generators and the async polyfill but in the future it can be replaced with an ES7 async function.

This entire script in one page can also be accessed on GitHub.

Comments

cancel

Revisions

  1. Initial version.