Some initial helpers.js namespaceing

This commit is contained in:
Matt Corallo 2014-05-17 00:54:12 -04:00
parent 07a23f0759
commit 05101b69b0
5 changed files with 154 additions and 137 deletions

View File

@ -14,14 +14,19 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// functions exposed for replacement and direct calling in test code
var crypto_tests = {};
var textsecure = textsecure || {};
window.crypto = (function() {
textsecure.crypto = new function() {
// functions exposed for replacement and direct calling in test code
var testing_only = {};
/******************************
*** Random constants/utils ***
******************************/
// We consider messages lost after a week and might throw away keys at that point
var MESSAGE_LOST_THRESHOLD_MS = 1000*60*60*24*7;
crypto.getRandomBytes = function(size) {
var getRandomBytes = function(size) {
//TODO: Better random (https://www.grc.com/r&d/js.htm?)
try {
var buffer = new ArrayBuffer(size);
@ -33,13 +38,23 @@ window.crypto = (function() {
throw err;
}
}
this.getRandomBytes = getRandomBytes;
function intToArrayBuffer(nInt) {
var res = new ArrayBuffer(16);
var thing = new Uint8Array(res);
thing[0] = (nInt >> 24) & 0xff;
thing[1] = (nInt >> 16) & 0xff;
thing[2] = (nInt >> 8 ) & 0xff;
thing[3] = (nInt >> 0 ) & 0xff;
return res;
}
function HmacSHA256(key, input) {
return window.crypto.subtle.sign({name: "HMAC", hash: "SHA-256"}, key, input);
}
crypto_tests.privToPub = function(privKey, isIdentity) {
testing_only.privToPub = function(privKey, isIdentity) {
if (privKey.byteLength != 32)
throw new Error("Invalid private key");
@ -75,13 +90,16 @@ window.crypto = (function() {
}
}
var privToPub = function(privKey, isIdentity) { return crypto_tests.privToPub(privKey, isIdentity); }
var privToPub = function(privKey, isIdentity) { return testing_only.privToPub(privKey, isIdentity); }
crypto_tests.createNewKeyPair = function(isIdentity) {
return privToPub(crypto.getRandomBytes(32), isIdentity);
testing_only.createNewKeyPair = function(isIdentity) {
return privToPub(getRandomBytes(32), isIdentity);
}
var createNewKeyPair = function(isIdentity) { return crypto_tests.createNewKeyPair(isIdentity); }
var createNewKeyPair = function(isIdentity) { return testing_only.createNewKeyPair(isIdentity); }
/***************************
*** Key/session storage ***
***************************/
var crypto_storage = {};
crypto_storage.getNewPubKeySTORINGPrivKey = function(keyName, isIdentity) {
@ -208,9 +226,7 @@ window.crypto = (function() {
/*****************************
*** Internal Crypto stuff ***
*****************************/
//TODO: Think about replacing CryptoJS stuff with optional NaCL-based implementations
// Probably means all of the low-level crypto stuff here needs pulled out into its own file
crypto_tests.ECDHE = function(pubKey, privKey) {
testing_only.ECDHE = function(pubKey, privKey) {
if (privKey === undefined || privKey.byteLength != 32)
throw new Error("Invalid private key");
@ -231,9 +247,9 @@ window.crypto = (function() {
}
});
}
var ECDHE = function(pubKey, privKey) { return crypto_tests.ECDHE(pubKey, privKey); }
var ECDHE = function(pubKey, privKey) { return testing_only.ECDHE(pubKey, privKey); }
crypto_tests.HKDF = function(input, salt, info) {
testing_only.HKDF = function(input, salt, info) {
// Specific implementation of RFC 5869 that only returns exactly 64 bytes
return HmacSHA256(salt, input).then(function(PRK) {
var infoBuffer = new ArrayBuffer(info.byteLength + 1 + 32);
@ -260,7 +276,7 @@ window.crypto = (function() {
info = toArrayBuffer(info); // TODO: maybe convert calls?
return crypto_tests.HKDF(input, salt, info);
return testing_only.HKDF(input, salt, info);
}
var calculateMACWithVersionByte = function(data, key, version) {
@ -363,7 +379,7 @@ window.crypto = (function() {
// Lock down current receive ratchet
// TODO: Some kind of delete chainKey['key']
// Delete current sending ratchet
delete session[getString(ratchet.ephemeralKeyPair.pubKey)];
delete session[getString(session.currentRatchet.ephemeralKeyPair.pubKey)];
// Delete current root key and our ephemeral key pair
delete session.currentRatchet['rootKey'];
delete session.currentRatchet['ephemeralKeyPair'];
@ -540,7 +556,7 @@ window.crypto = (function() {
*** Public crypto API ***
*************************/
// Decrypts message into a raw string
crypto.decryptWebsocketMessage = function(message) {
this.decryptWebsocketMessage = function(message) {
var signaling_key = storage.getEncrypted("signaling_key"); //TODO: in crypto_storage
var aes_key = toArrayBuffer(signaling_key.substring(0, 32));
var mac_key = toArrayBuffer(signaling_key.substring(32, 32 + 20));
@ -559,7 +575,7 @@ window.crypto = (function() {
});
};
crypto.decryptAttachment = function(encryptedBin, keys) {
this.decryptAttachment = function(encryptedBin, keys) {
var aes_key = keys.slice(0, 32);
var mac_key = keys.slice(32, 64);
@ -573,7 +589,7 @@ window.crypto = (function() {
});
};
crypto.handleIncomingPushMessageProto = function(proto) {
this.handleIncomingPushMessageProto = function(proto) {
switch(proto.type) {
case 0: //TYPE_MESSAGE_PLAINTEXT
return Promise.resolve({message: decodePushMessageContentProtobuf(getString(proto.message)), pushMessage:proto});
@ -596,7 +612,7 @@ window.crypto = (function() {
}
// return Promise(encoded [PreKey]WhisperMessage)
crypto.encryptMessageFor = function(deviceObject, pushMessageContent) {
this.encryptMessageFor = function(deviceObject, pushMessageContent) {
var session = crypto_storage.getOpenSession(deviceObject.encodedNumber);
var doEncryptPushMessageContent = function() {
@ -663,7 +679,7 @@ window.crypto = (function() {
}
var GENERATE_KEYS_KEYS_GENERATED = 100;
crypto.generateKeys = function() {
this.generateKeys = function() {
var identityKey = crypto_storage.getStoredPubKey("identityKey");
var identityKeyCalculated = function(pubKey) {
identityKey = pubKey;
@ -704,4 +720,6 @@ window.crypto = (function() {
else
return identityKeyCalculated(identityKey);
}
})();
this.testing_only = testing_only;
}();

View File

@ -95,16 +95,6 @@ function base64EncArr (aBytes) {
/*********************************
*** Type conversion utilities ***
*********************************/
function intToArrayBuffer(nInt) {
var res = new ArrayBuffer(16);
var thing = new Uint8Array(res);
thing[0] = (nInt >> 24) & 0xff;
thing[1] = (nInt >> 16) & 0xff;
thing[2] = (nInt >> 8 ) & 0xff;
thing[3] = (nInt >> 0 ) & 0xff;
return res;
}
// Strings/arrays
//TODO: Throw all this shit in favor of consistent types
var StaticByteBufferProto = new dcodeIO.ByteBuffer().__proto__;
@ -261,43 +251,95 @@ function objectContainsKeys(object) {
/************************************************
*** Utilities to store data in local storage ***
************************************************/
var storage = {};
var storage = new function() {
/*****************************
*** Base Storage Routines ***
*****************************/
this.putEncrypted = function(key, value) {
//TODO
if (value === undefined)
throw new Error("Tried to store undefined");
localStorage.setItem("e" + key, jsonThing(value));
}
storage.putEncrypted = function(key, value) {
this.getEncrypted = function(key, defaultValue) {
//TODO
if (value === undefined)
throw new Error("Tried to store undefined");
localStorage.setItem("e" + key, jsonThing(value));
}
var value = localStorage.getItem("e" + key);
if (value === null)
return defaultValue;
return JSON.parse(value);
}
storage.getEncrypted = function(key, defaultValue) {
//TODO
var value = localStorage.getItem("e" + key);
if (value === null)
return defaultValue;
return JSON.parse(value);
}
this.removeEncrypted = function(key) {
localStorage.removeItem("e" + key);
}
storage.removeEncrypted = function(key) {
localStorage.removeItem("e" + key);
}
this.putUnencrypted = function(key, value) {
if (value === undefined)
throw new Error("Tried to store undefined");
localStorage.setItem("u" + key, jsonThing(value));
}
storage.putUnencrypted = function(key, value) {
if (value === undefined)
throw new Error("Tried to store undefined");
localStorage.setItem("u" + key, jsonThing(value));
}
this.getUnencrypted = function(key, defaultValue) {
var value = localStorage.getItem("u" + key);
if (value === null)
return defaultValue;
return JSON.parse(value);
}
storage.getUnencrypted = function(key, defaultValue) {
var value = localStorage.getItem("u" + key);
if (value === null)
return defaultValue;
return JSON.parse(value);
}
this.removeUnencrypted = function(key) {
localStorage.removeItem("u" + key);
}
storage.removeUnencrypted = function(key) {
localStorage.removeItem("u" + key);
}
/**********************
*** Device Storage ***
**********************/
this.devices = new function() {
this.getDeviceObject = function(encodedNumber) {
return storage.getEncrypted("deviceObject" + getEncodedNumber(encodedNumber));
}
this.getDeviceIdListFromNumber = function(number) {
return storage.getEncrypted("deviceIdList" + getNumberFromString(number), []);
}
this.addDeviceIdForNumber = function(number, deviceId) {
var deviceIdList = this.getDeviceIdListFromNumber(getNumberFromString(number));
for (var i = 0; i < deviceIdList.length; i++) {
if (deviceIdList[i] == deviceId)
return;
}
deviceIdList[deviceIdList.length] = deviceId;
storage.putEncrypted("deviceIdList" + getNumberFromString(number), deviceIdList);
}
// throws "Identity key mismatch"
this.saveDeviceObject = function(deviceObject) {
var existing = this.getDeviceObject(deviceObject.encodedNumber);
if (existing === undefined)
existing = {encodedNumber: getEncodedNumber(deviceObject.encodedNumber)};
for (key in deviceObject) {
if (key == "encodedNumber")
continue;
if (key == "identityKey" && deviceObject.identityKey != deviceObject.identityKey)
throw new Error("Identity key mismatch");
existing[key] = deviceObject[key];
}
storage.putEncrypted("deviceObject" + getEncodedNumber(deviceObject.encodedNumber), existing);
this.addDeviceIdForNumber(deviceObject.encodedNumber, getDeviceId(deviceObject.encodedNumber));
}
this.getDeviceObjectListFromNumber = function(number) {
var deviceObjectList = [];
var deviceIdList = this.getDeviceIdListFromNumber(number);
for (var i = 0; i < deviceIdList.length; i++)
deviceObjectList[deviceObjectList.length] = this.getDeviceObject(getNumberFromString(number) + "." + deviceIdList[i]);
return deviceObjectList;
}
};
};
function registrationDone() {
storage.putUnencrypted("registration_done", "");
@ -328,49 +370,6 @@ function storeMessage(messageObject) {
chrome.runtime.sendMessage(conversation[conversation.length - 1]);
}
function getDeviceObject(encodedNumber) {
return storage.getEncrypted("deviceObject" + getEncodedNumber(encodedNumber));
}
function getDeviceIdListFromNumber(number) {
return storage.getEncrypted("deviceIdList" + getNumberFromString(number), []);
}
function addDeviceIdForNumber(number, deviceId) {
var deviceIdList = getDeviceIdListFromNumber(getNumberFromString(number));
for (var i = 0; i < deviceIdList.length; i++) {
if (deviceIdList[i] == deviceId)
return;
}
deviceIdList[deviceIdList.length] = deviceId;
storage.putEncrypted("deviceIdList" + getNumberFromString(number), deviceIdList);
}
// throws "Identity key mismatch"
function saveDeviceObject(deviceObject) {
var existing = getDeviceObject(deviceObject.encodedNumber);
if (existing === undefined)
existing = {encodedNumber: getEncodedNumber(deviceObject.encodedNumber)};
for (key in deviceObject) {
if (key == "encodedNumber")
continue;
if (key == "identityKey" && deviceObject.identityKey != deviceObject.identityKey)
throw new Error("Identity key mismatch");
existing[key] = deviceObject[key];
}
storage.putEncrypted("deviceObject" + getEncodedNumber(deviceObject.encodedNumber), existing);
addDeviceIdForNumber(deviceObject.encodedNumber, getDeviceId(deviceObject.encodedNumber));
}
function getDeviceObjectListFromNumber(number) {
var deviceObjectList = [];
var deviceIdList = getDeviceIdListFromNumber(number);
for (var i = 0; i < deviceIdList.length; i++)
deviceObjectList[deviceObjectList.length] = getDeviceObject(getNumberFromString(number) + "." + deviceIdList[i]);
return deviceObjectList;
}
/**********************
*** NaCL Interface ***
@ -456,16 +455,16 @@ function subscribeToPush(message_callback) {
if (message.type == 3) {
console.log("Got pong message");
} else if (message.type === undefined && message.id !== undefined) {
crypto.decryptWebsocketMessage(message.message).then(function(plaintext) {
textsecure.crypto.decryptWebsocketMessage(message.message).then(function(plaintext) {
var proto = decodeIncomingPushMessageProtobuf(getString(plaintext));
// After this point, a) decoding errors are not the server's fault, and
// b) we should handle them gracefully and tell the user they received an invalid message
console.log("Successfully decoded message with id: " + message.id);
socket.send(JSON.stringify({type: 1, id: message.id}));
return crypto.handleIncomingPushMessageProto(proto).then(function(decrypted) {
return textsecure.crypto.handleIncomingPushMessageProto(proto).then(function(decrypted) {
var handleAttachment = function(attachment) {
return API.getAttachment(attachment.id).then(function(encryptedBin) {
return crypto.decryptAttachment(encryptedBin, toArrayBuffer(attachment.key)).then(function(decryptedBin) {
return textsecure.crypto.decryptAttachment(encryptedBin, toArrayBuffer(attachment.key)).then(function(decryptedBin) {
attachment.decrypted = decryptedBin;
});
});
@ -492,7 +491,7 @@ function subscribeToPush(message_callback) {
function getKeysForNumber(number) {
return API.getKeysForNumber(number).then(function(response) {
for (var i = 0; i < response.length; i++) {
saveDeviceObject({
storage.devices.saveDeviceObject({
encodedNumber: number + "." + response[i].deviceId,
identityKey: response[i].identityKey,
publicKey: response[i].publicKey,
@ -512,7 +511,7 @@ function sendMessageToDevices(number, deviceObjectList, message, success_callbac
var promises = [];
var addEncryptionFor = function(i) {
return crypto.encryptMessageFor(deviceObjectList[i], message).then(function(encryptedMsg) {
return textsecure.crypto.encryptMessageFor(deviceObjectList[i], message).then(function(encryptedMsg) {
jsonData[i] = {
type: encryptedMsg.type,
destination: deviceObjectList[i].encodedNumber,
@ -574,11 +573,11 @@ function sendMessageToNumbers(numbers, message, callback) {
for (var i = 0; i < numbers.length; i++) {
var number = numbers[i];
var devicesForNumber = getDeviceObjectListFromNumber(number);
var devicesForNumber = storage.devices.getDeviceObjectListFromNumber(number);
if (devicesForNumber.length == 0) {
getKeysForNumber(number).then(function(identity_key) {
devicesForNumber = getDeviceObjectListFromNumber(number);
devicesForNumber = storage.devices.getDeviceObjectListFromNumber(number);
if (devicesForNumber.length == 0)
registerError(number, "Failed to retreive new device keys for number " + number, null);
else
@ -592,7 +591,7 @@ function sendMessageToNumbers(numbers, message, callback) {
}
function requestIdentityPrivKeyFromMasterDevice(number, identityKey) {
sendMessageToDevices([getDeviceObject(getNumberFromString(number)) + ".1"],
sendMessageToDevices([storage.devices.getDeviceObject(getNumberFromString(number)) + ".1"],
{message: "Identity Key request"}, function() {}, function() {});//TODO
}

View File

@ -39,10 +39,10 @@ $('#number').on('change', function() {//TODO
});
var single_device = false;
var signaling_key = window.crypto.getRandomBytes(32 + 20);
var password = btoa(getString(window.crypto.getRandomBytes(16)));
var signaling_key = textsecure.crypto.getRandomBytes(32 + 20);
var password = btoa(getString(textsecure.crypto.getRandomBytes(16)));
password = password.substring(0, password.length - 2);
var registrationId = new Uint16Array(window.crypto.getRandomBytes(2))[0];
var registrationId = new Uint16Array(textsecure.crypto.getRandomBytes(2))[0];
registrationId = registrationId & 0x3fff;
$('#init-go-single-client').click(function() {
@ -89,7 +89,7 @@ $('#init-go').click(function() {
var register_keys_func = function() {
$('#verify2done').html('done');
crypto.generateKeys().then(function(keys) {
textsecure.crypto.generateKeys().then(function(keys) {
$('#verify3done').html('done');
API.registerKeys(keys,
function(response) {

View File

@ -122,7 +122,7 @@ registerOnLoadFunction(function() {
var server_message = {type: 0, // unencrypted
source: "+19999999999", timestamp: 42, message: text_message.encode() };
return crypto.handleIncomingPushMessageProto(server_message).then(function(message) {
return textsecure.crypto.handleIncomingPushMessageProto(server_message).then(function(message) {
return (message.message.body == text_message.body &&
message.message.attachments.length == text_message.attachments.length &&
text_message.attachments.length == 0);
@ -130,7 +130,7 @@ registerOnLoadFunction(function() {
}, 'Unencrypted PushMessageProto "decrypt"', true);
TEST(function() {
return crypto.generateKeys().then(function() {
return textsecure.crypto.generateKeys().then(function() {
if (storage.getEncrypted("25519KeyidentityKey") === undefined)
return false;
if (storage.getEncrypted("25519KeypreKey16777215") === undefined)
@ -152,7 +152,7 @@ registerOnLoadFunction(function() {
var bob_pub = hexToArrayBuffer("05de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f");
var shared_sec = hexToArrayBuffer("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
return crypto_tests.privToPub(alice_priv, true).then(function(aliceKeyPair) {
return textsecure.crypto.testing_only.privToPub(alice_priv, true).then(function(aliceKeyPair) {
var target = new Uint8Array(alice_priv.slice(0));
target[0] &= 248;
target[31] &= 127;
@ -160,7 +160,7 @@ registerOnLoadFunction(function() {
if (String.fromCharCode.apply(null, new Uint8Array(aliceKeyPair.privKey)) != String.fromCharCode.apply(null, target))
return false;
return crypto_tests.privToPub(bob_priv, true).then(function(bobKeyPair) {
return textsecure.crypto.testing_only.privToPub(bob_priv, true).then(function(bobKeyPair) {
var target = new Uint8Array(bob_priv.slice(0));
target[0] &= 248;
target[31] &= 127;
@ -174,11 +174,11 @@ registerOnLoadFunction(function() {
if (String.fromCharCode.apply(null, new Uint8Array(bobKeyPair.pubKey)) != String.fromCharCode.apply(null, new Uint8Array(bob_pub)))
return false;
return crypto_tests.ECDHE(bobKeyPair.pubKey, aliceKeyPair.privKey).then(function(ss) {
return textsecure.crypto.testing_only.ECDHE(bobKeyPair.pubKey, aliceKeyPair.privKey).then(function(ss) {
if (String.fromCharCode.apply(null, new Uint16Array(ss)) != String.fromCharCode.apply(null, new Uint16Array(shared_sec)))
return false;
return crypto_tests.ECDHE(aliceKeyPair.pubKey, bobKeyPair.privKey).then(function(ss) {
return textsecure.crypto.testing_only.ECDHE(aliceKeyPair.pubKey, bobKeyPair.privKey).then(function(ss) {
if (String.fromCharCode.apply(null, new Uint16Array(ss)) != String.fromCharCode.apply(null, new Uint16Array(shared_sec)))
return false;
else
@ -204,7 +204,7 @@ registerOnLoadFunction(function() {
for (var i = 0; i < 10; i++)
info[i] = 240 + i;
return crypto_tests.HKDF(IKM, salt, info).then(function(OKM){
return textsecure.crypto.testing_only.HKDF(IKM, salt, info).then(function(OKM){
var T1 = hexToArrayBuffer("3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf");
var T2 = hexToArrayBuffer("34007208d5b887185865");
return (getString(OKM[0]) == getString(T1) && getString(OKM[1]).substring(0, 10) == getString(T2));
@ -300,28 +300,28 @@ registerOnLoadFunction(function() {
];
var axolotlTestVectors = function(v, remoteDevice) {
var origCreateNewKeyPair = crypto_tests.createNewKeyPair;
var origCreateNewKeyPair = textsecure.crypto.testing_only.createNewKeyPair;
var doStep;
var stepDone;
stepDone = function(res) {
if (!res || privKeyQueue.length != 0) {
crypto_tests.createNewKeyPair = origCreateNewKeyPair;
textsecure.crypto.testing_only.createNewKeyPair = origCreateNewKeyPair;
return false;
} else if (step == v.length) {
crypto_tests.createNewKeyPair = origCreateNewKeyPair;
textsecure.crypto.testing_only.createNewKeyPair = origCreateNewKeyPair;
return true;
} else
return doStep().then(stepDone);
}
var privKeyQueue = [];
crypto_tests.createNewKeyPair = function(isIdentity) {
textsecure.crypto.testing_only.createNewKeyPair = function(isIdentity) {
if (privKeyQueue.length == 0 || isIdentity)
throw new Error('Out of private keys');
else {
var privKey = privKeyQueue.shift();
return crypto_tests.privToPub(privKey, false).then(function(keyPair) {
return textsecure.crypto.testing_only.privToPub(privKey, false).then(function(keyPair) {
var a = btoa(getString(keyPair.privKey)); var b = btoa(getString(privKey));
if (getString(keyPair.privKey) != getString(privKey))
throw new Error('Failed to rederive private key!');
@ -345,15 +345,15 @@ registerOnLoadFunction(function() {
message.type = data.type;
message.source = remoteDevice.encodedNumber;
message.message = data.message;
return crypto.handleIncomingPushMessageProto(decodeIncomingPushMessageProtobuf(getString(message.encode()))).then(function(res) {
return textsecure.crypto.handleIncomingPushMessageProto(decodeIncomingPushMessageProtobuf(getString(message.encode()))).then(function(res) {
return res.message.body == data.expectedSmsText;
});
}
if (data.ourIdentityKey !== undefined)
return crypto_tests.privToPub(data.ourIdentityKey, true).then(function(keyPair) {
return textsecure.crypto.testing_only.privToPub(data.ourIdentityKey, true).then(function(keyPair) {
storage.putEncrypted("25519KeyidentityKey", keyPair);
return crypto_tests.privToPub(data.ourPreKey, false).then(function(keyPair) {
return textsecure.crypto.testing_only.privToPub(data.ourPreKey, false).then(function(keyPair) {
storage.putEncrypted("25519KeypreKey" + data.preKeyId, keyPair);
return postLocalKeySetup();
});
@ -374,7 +374,7 @@ registerOnLoadFunction(function() {
var message = new PushMessageContentProtobuf();
message.body = data.smsText;
return crypto.encryptMessageFor(remoteDevice, message).then(function(res) {
return textsecure.crypto.encryptMessageFor(remoteDevice, message).then(function(res) {
//XXX: This should be all we do: stepDone(getString(data.expectedCiphertext) == getString(res.body));
if (res.type == 1) { //XXX: This should be used for everything...
var expectedString = getString(data.expectedCiphertext);
@ -395,7 +395,7 @@ registerOnLoadFunction(function() {
privKeyQueue.push(data.ourEphemeralKey);
if (data.ourIdentityKey !== undefined)
return crypto_tests.privToPub(data.ourIdentityKey, true).then(function(keyPair) {
return textsecure.crypto.testing_only.privToPub(data.ourIdentityKey, true).then(function(keyPair) {
storage.putEncrypted("25519KeyidentityKey", keyPair);
return postLocalKeySetup();
});

View File

@ -42,9 +42,9 @@
<script type="text/javascript" src="js/webcrypto.js"></script>
<script type="text/javascript" src="js/crypto.js"></script>
<script type="text/javascript" src="js/helpers.js"></script>
<!-- TODO: Tests for api stuff -->
<script type="text/javascript" src="js/api.js"></script>
<script type="text/javascript" src="js/fake_api.js"></script>
<!-- TODO: Tests for api stuff -->
<!--<script type="text/javascript" src="js/fake_api.js"></script>-->
<script type="text/javascript" src="js/test.js"></script>
</body>
</html>