Fully end to end encrypted anonymous chat program. Server only stores public key lookup for users and the encrypted messages. No credentials are transfered to the server, but kept in local browser storage. This allows 100% safe chatting. https://safechat.ch
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

723 lines
26 KiB

/*! @file
10 years ago
9 years ago
@id $Id$
This is the main application as it is fully run in the user's browser.
@dot
digraph X {
start [URL="\ref start()"];
newuser [URL="\ref newuser()"];
login [URL="\ref login()"];
createkeypair [URL="\ref createkeypair()"];
chat [URL="\ref chat()"];
getpwd [URL="\ref getpwd()"];
setpw [URL="\ref setpw()"];
get [URL="\ref get()"];
sendmessage [URL="\ref sendmessage()"];
start -> newuser [label="if no keys exist"];
start -> login [label="if keys exist"];
newuser -> createkeypair [label="on submit"];
createkeypair -> "openpgp.generateKeyPair";
"openpgp.generateKeyPair" -> login [label="keys generated in local store"];
login -> chat [label="user is valid on server"];
chat -> getpwd [label="password not yet entered"];
getpwd -> setpw [label="on input"];
setpw -> chat [label="password is valid"];
chat -> chat [label="remain in chat"];
chat -> get [label="start timer"];
get -> get [label="restart timer"];
chat -> sendmessage [label="on submit"];
sendmessage -> chat [label="remain in chat"];
}
@enddot
*/
// 1 2 3 4 5 6 7 8
// 45678901234567890123456789012345678901234567890123456789012345678901234567890
var password = null; ///< password, only stored temporary, until reload
var username = null; ///< username, only used during registration
var filecontent = new Array(); ///< temporary storage for attachments
/// Show error messsage
/** Fades in an error message and logs to console.
@param data (optional) The error can be a string or any structure.
Strings are shown to the user, structures are logged only.
@param stay (optional) If not given as @c true, reloads page after 5s. */
10 years ago
function error(data, stay) {
$("#status").fadeOut("slow", function() {
$("#status").addClass("error")
$("#status").removeClass("notice")
$("#status").removeClass("success")
10 years ago
if (data) {
if (typeof data == 'string') {
$("#status").html(data);
10 years ago
console.log("error: "+data);
} else {
$("#status").html('unknown error: '+JSON.stringify(data));
10 years ago
console.log("error: "+JSON.stringify(data));
}
} else {
$("#status").html('error');
10 years ago
console.log("error");
}
$("#status").fadeIn("slow");
if (!stay) setTimeout(start, 5000);
});
}
/// Show notice messsage
/** Fades in an notice message and logs to console.
9 years ago
@param text (optional) The data is a string. */
10 years ago
function notice(text) {
$("#status").fadeOut("slow", function() {
$("#status").addClass("notice")
$("#status").removeClass("error")
$("#status").removeClass("success")
10 years ago
if (text) {
$("#status").html(text);
10 years ago
console.log("notice: "+text);
} else {
$("#status").html('');
console.log("notice");
}
$("#status").fadeIn("slow");
});
}
/// Show notice messsage
/** Fades in an success message and logs to console.
9 years ago
@param text (optional) The data is a string. */
10 years ago
function success(text) {
$("#status").fadeOut("slow", function() {
$("#status").addClass("success")
$("#status").removeClass("error")
$("#status").removeClass("notice")
10 years ago
if (text) {
$("#status").html(text);
10 years ago
console.log("success: "+text);
} else {
$("#status").html('');
console.log("success");
}
$("#status").fadeIn("slow");
});
}
/// Show status message in the main screen area
/** @param text Text is a message or some complex HTML from the server.
@param msg The success message text */
10 years ago
function status(text, msg) {
$("#main").fadeOut("slow", function() {
$("#main").html(text);
if (msg) success(msg);
else setTimeout("$('#status').fadeOut('slow')", 5000);
10 years ago
$("#main").fadeIn("slow", function() {
$("form input:first-child").focus();
})
10 years ago
});
}
var getLoopTimeout = null; ///< store get timeout to make sure only one is running
/// Set timeout for next get request
/** @param time timeout time in ms, defaults to 10000 */
function getLoop(time) {
if (!time) time = 10000;
getLoopStop();
getLoopTimeout = setTimeout(get, time);
}
/// Stop get loop if it is running
function getLoopStop() {
if (getLoopTimeout) clearTimeout(getLoopTimeout);
getLoopTimeout = null;
}
/// Alert user
9 years ago
/** Alert user, e.g. that a new message has arrived. */
function alert() {
navigator.vibrate =
navigator.vibrate || navigator.webkitVibrate || navigator.mozVibrate || navigator.msVibrate;
if (navigator.vibrate) {
// vibration API supported
navigator.vibrate(1000);
}
(new Audio("A-Tone-His_Self-1266414414.mp3")).play();
}
/// Toggle Menu Display
function togglemenu() {
$("#menu").toggle();
}
/// Download Profile Backup
function backup() {
getLoopStop();
status("<p>Starting backup download ...</p>", "");
var download = document.createElement('a');
download.href = 'data:attachment/text,'+encodeURI(JSON.stringify(localStorage));
download.target = '_blank';
function pad(n) {return n<10 ? '0'+n : n}
var now = new Date();
download.download =
pad(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+
"-"+userid()+"@"+window.location.hostname+".bak";
var clickEvent = new MouseEvent("click", {
"view": window,
"bubbles": true,
"cancelable": false
});
download.dispatchEvent(clickEvent);
togglemenu();
setTimeout(start, 2000);
}
/// Upload Profile Backup
function restore(evt) {
getLoopStop();
status("<p>Starting backup restore ...</p>", "");
if (!window.FileReader)
return error("your browser dows not support file upload", true);
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f;
var reader = new FileReader();
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true);
if (evt.target.readyState==0) return notice("waiting for data ...");
if (evt.target.readyState==1) return notice("loading data ...");
var parsed=JSON.parse(evt.target.result);
togglemenu();
localStorage.pubKey = parsed.pubKey;
localStorage.privKey = parsed.privKey;
setTimeout(start, 2000);
}
reader.readAsText(file);
}
}
/// Configure local groups
/** ... */
function groups() {
}
/// Check if user name is available
/** Calls checknewuser.php on server and displays an error, if the
user name is already in use. This function is used when creating a
new user. It immediately gives the user a feedback, whether the
chosen user name is available or not.
Called when user edits the user name fields.
Sets @ref username and checks @ref password - if both are well
defined, enables the submit button.
@param user User name to check. */
10 years ago
function checkuser(user) {
$("#register").submit(function(event) {
return false;
});
$.post("checknewuser.php", {user: user})
.done(function(res) {
username=JSON.parse(res);
if (!username||username.length<1) username=null;
$("#createuser").prop("disabled", !(username && password));
if (username) {
if (password) success("user is ready to be created");
else notice("user name is available, please set password");
} else notice("user name is not available");
}).fail(function(res) {
username=null;
$("#createuser").prop("disabled", !(username && password));
error("offline");
10 years ago
});
}
/// Check if password is set and matches the repeated password
/** Checks if both passwords are identical and valid and gives
feedback to the user.
Called when user edits the password fields.
Sets @ref username and checks @ref password - if both are well
defined, enables the submit button.
@param pwd The password.
@param pwd2 The repeated password. */
10 years ago
function checkpwd(pwd, pwd2) {
$("#register").submit(function(event) {
return false;
});
if (pwd==pwd2) password=pwd;
else password=null;
if (!password||password.length<1) password=null;
$("#createuser").prop("disabled", !(username && password));
if (password) {
if (username) success("user is ready to be created");
else notice("password matches, please chose a valid user name");
} else notice("passwords don't match");
}
/// Checks if the receiver of a message exists on server.
/** Calls checknewuser.php on server and enables the message submit
button if the receiver of the message exists on the server. */
10 years ago
function checkpartner(user) {
$("#chat").submit(function(event) {
return false;
});
$.post("checknewuser.php", {user: user})
.done(function(res) {
if (JSON.parse(res)) {
notice("receiver does not exist");
$("#send").prop("disabled", true);
return;
}
$("#send").prop("disabled", false);
success("receiver exists");
}).fail(function(res) {
error("offline", true);
10 years ago
$("#send").prop("disabled", true);
});
}
/// Create Local Public-/Private-Key Pair
/** Called if user has not yet his keys, just generates a new key pair. */
function createkeypair(user, pwd) {
10 years ago
status("generate keys");
openpgp.generateKeyPair({
numBits: 4096,
10 years ago
userId: user,
passphrase: pwd
}).then(function(keyPair) {
success("keys generated");
localStorage["pubKey"] = keyPair.publicKeyArmored;
localStorage["privKey"] = keyPair.privateKeyArmored;
10 years ago
login();
}).catch(function(e) {
console.log(e.stack);
error("generating key pairs failed");
10 years ago
});
}
/// Get Own Public Key
/** @return public key object */
10 years ago
function publicKey() {
if (typeof localStorage["pubKey"] == 'undefined') return null;
return openpgp.key.readArmored(localStorage["pubKey"]);
10 years ago
}
/// Get Own Private Key
/** @return private key object */
10 years ago
function privateKey() {
if (typeof localStorage["privKey"] == 'undefined') return null;
return openpgp.key.readArmored(localStorage["privKey"]);
10 years ago
}
/// Get Own User Name
/** Get user name as user id of first public key */
10 years ago
function userid() {
if (!publicKey() ||
publicKey().keys.length < 1 ||
publicKey().keys[0].getUserIds().length < 1) return null
return publicKey().keys[0].getUserIds()[0];
}
/// Clear Message Text And Attachments
/** Does not remove the receiver's name */
function clearmessage() {
filecontent = new Array();
$('#preview').empty();
$("#msg").val("");
notice("message cleared");
}
/// Display Image Attachments
function attachments(files, id) {
if (files) files.forEach(function(file) {
if (file.content.length<100000) {
var img = document.createElement('img');
img.src = file.content;
$(id).append(img);
}
});
}
/// Upload Attachment
/** Prepares attachment to be sent in a message. If the attachment is
an image, it resizes the image to 400px on the lager side.
By now, only images are supported.
Stores data in global variable @ref filecontent. */
function fileupload(evt) {
if (!window.FileReader)
return error("your browser dows not support file upload", true);
for (var i=0, f; f=evt.target.files[i]; ++i) {
var file = f;
var reader = new FileReader();
reader.onload = function(evt) {
if (evt.target.error) return error("error reading file", true);
if (evt.target.readyState==0) return notice("waiting for data ...");
if (evt.target.readyState==1) return notice("loading data ...");
if (!file.type.match('^image/'))
return error(file.name+": not an image", true);
var img = document.createElement("img");
img.onload = function() {
var MAX = 400;
var width = img.width;
var height = img.height;
if (width > MAX) {
height *= MAX / width;
width = MAX;
}
if (height > MAX) {
width *= MAX / height;
height = MAX;
}
var canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, width, height);
img.onload = function() {
filecontent.push({type: file.type, content: img.src});
$("#preview").append(img);
success('image of type '+file.type+' is ready to be sent');
}
img.src = canvas.toDataURL(file.type);
}
img.src=evt.target.result;
}
reader.readAsDataURL(file);
}
}
/// Sets Receiver's Name
/** Called when clicked on a receiver's name. Sets focus to the
message text field.
@param name The receiver's name. */
10 years ago
function setreceiver(name) {
$("#recv").val(name);
checkpartner(name);
$("#msg").focus();
}
var userMap = null;
/// Get a user's public key.
/** The first time, gets it from the server, later from the cache. */
function getPublicKey(user) {
var deferredObject = $.Deferred();
if (userMap == null) {
if (localStorage.userMap) {
userMap = JSON.parse(localStorage.userMap);
console.log("got userMap from localStorage");
} else {
userMap = new Array();
}
}
if (userMap[user]) {
console.log("user "+user+" is in cache");
deferredObject.resolve(userMap[user]);
} else {
$.post("pubkey.php", {user: user}) // get sender's key
.done(function(pk) {
console.log("got user "+user+" from server");
userMap[user] = pk;
localStorage.userMap = JSON.stringify(userMap);
deferredObject.resolve(pk);
}).fail(function(e) {
error("offline");
deferredObject.reject(e);
});
}
return deferredObject.promise();
}
var startmsg = 0; ///< number of last downloaded message
/// Poll For New Messages, Get And Show Them
/** The global variable @ref startmsg stores the id of the last
downloaded message. This function is called by timer in regulary
periods. It calls get on server and passes @ref startmsg. The
server returns all newer messages. They are then decrypted. If
decryption is successful, then the message is shown, including
attachments. If decryption fails, the message is sent to someone
else, so failure is simply ignored. Beeps a sound once, if new
messages have been displayed. */
10 years ago
function get() {
var beeped = false; // beep only once
$.post("get.php", {start: startmsg})
.done(function(res) { // new messages from server received
var msgs = JSON.parse(res);
if (msgs) {
msgs.forEach(function(e) { // one single message
if (startmsg<Number(e.id)) startmsg = Number(e.id);
getPublicKey(e.user) // get sender's key
.done(function(pk) {
var res=JSON.parse(pk);
var key=openpgp.key.readArmored(res);
if (!res||key.err) {
getLoop();
return error("key of receiver not found", true);
}
var message = openpgp.message.readArmored(e.msg);
var privkey = privateKey().keys[0];
if (privkey.decrypt(password)) // prepare own key
openpgp.decryptAndVerifyMessage(privkey, key.keys, message)
.then(function(msg) { // decryption succeded
// prepend message to list of messages
var message = JSON.parse(msg.text);
$("#msgs") // todo: check msg.signatures[0].valid
.prepend('<div id="id'+(e.id)+'" class="msg '+
(e.user==userid()?"me":"other")+
'"><div class="header">'+
'<span class="date">'+
(new Date(1000*Number(e.time))).toLocaleString()+
'</span><span class="sender">'+
'<a href="javascript:void(0)" '+
'onclick="setreceiver(this.innerHTML)">'+
e.user+
'</a>'+(message.receiver?" → "+message.receiver:"")+
'</span></div>'+
'<div class="text">'+
message.text+
'</div></div><div class="clear"/>');
// show attachments
attachments(message.files, '#id'+e.id+' .text');
// calculate and show emoticons
$('#id'+e.id).emoticonize();
// beep for the first new message
if (!beeped) alert()
beeped = true;
success();
})
.catch(function(e) {
// not for me
success();
});
}).fail(function(e) {
error("offline");
});
10 years ago
});
}
}).fail(function(e) {
error("offline")
});
getLoop(); // repeat every 10 seconds
10 years ago
}
/// Send Message To Server
/** User wants to send a message. Encrypt message with own private and
the receiver's public key, then send it to the server. */
10 years ago
function sendmessage(recv, txt) {
notice("1/3 preparing message ...");
$("#message").fadeOut("slow");
getPublicKey(recv) // get receiver's public key
.done(function(pk) {
var res=JSON.parse(pk);
var key=openpgp.key.readArmored(res);
if (!res||key.err) {
$("#message").fadeIn("slow");
error("receiver's key not found", true);
return;
}
var privkey = privateKey().keys[0];
privkey.decrypt(password); // get own private key ready
var message = JSON.stringify({receiver: recv, text: txt, files: filecontent});
notice("2/3 encrypting message ...");
openpgp.signAndEncryptMessage(key.keys.concat(publicKey().keys), privkey, message)
.then(function(msg) { // message is encrypted
notice("3/3 sending message ...");
$.post("send.php", {user: userid(), msg: msg})
.done(function(res) { // message has been sent to server
var st = JSON.parse(res);
if (st.success) {
$("#message").fadeIn("slow");
clearmessage();
success(st.txt);
} else {
$("#message").fadeIn("slow");
error(st.txt, true);
}
})
.fail(function() {
error("offline", true);
});
10 years ago
})
.catch(function(e) {
$("#message").fadeIn("slow");
error("encryption of message failed", true);
});
10 years ago
})
.fail(function(e) {
$("#message").fadeIn("slow");
error("offline", true);
10 years ago
});
$("#message").fadeIn("slow");
10 years ago
}
/// Check And Set Password
/** Check if given password matches to decrypt the private key. If so,
store it in global temporary variable @ref password and start the
chat. The password matches, when the private key can be decrypted.
@param pwd The password to check. */
10 years ago
function setpw(pwd) {
if (privateKey().keys[0].decrypt(pwd)) {
success("password matches");
$("#removeKey").hide();
10 years ago
password = pwd;
chat();
} else {
notice("password does not match");
10 years ago
}
}
/// Create Password Entry Field
/** Asks user for password. When user starts to enter it, it is
permanentely checked in setpw(). As soon as the password matches,
setpw() continues automatically. No submit is required by the
user. */
10 years ago
function getpwd() {
$("#removeKey").show();
10 years ago
status('<form>'+
' <input placeholder="please enter password for user '+userid()+
10 years ago
'" id="pwd" oninput="setpw(this.value)" type="password" />'+
10 years ago
'</form>');
}
function deleteUser() {
var uid = userid();
localStorage.pubKey = null;
localStorage.privKey = null;
error("user "+uid+" permanentely lost");
status("Deleted User: "+uid);
}
function removeKey() {
togglemenu();
$("#removeKey").hide();
status('<h2>Password Forgotten</h2>'+
'<div class="warning"><strong>Warning!</strong>'+
'<ul><li>You loose all messages.</li>'+
'<li>You loose your account name <em>«'+userid()+'»</em>.</li>'+
'<li>You should backup now, before you continue!</li></ul></div>'+
'<p>You can only remove your local data. '+
'You will have to create a new account with a new name on the server. '+
'This means, you loose all your messages and you loose your account '+
'name <em>«'+userid()+'»</em> forever. '+
'This chat program is secure, nobody can restore your password. '+
'Without password, you can\'t prove, that you are <em>«'+userid()+'»</em>.</p>'+
'<div class="buttongroup"><p class="toolbutton bad" onclick="deleteUser()">'+
'Yes, I really forgot my password.<br/>I want to loose my data to get a new account.</p>'+
'<p class="toolbutton good" onclick="start()">'+
'No, bring me back!.<br/>I\'ll try to remember my password.</p></div>', "");
}
/// Main Chat Window
/** Gets chat widgets from server and displays them. Starts timer for
get() which polls for new messages. */
10 years ago
function chat() {
$("#username").html(userid()+"@"+window.location.hostname);
10 years ago
if (!password) return getpwd();
$.ajax({url: "chat.html", success: function(res) {
status(res);
getLoop(2000);
}}).fail(function() {
error("offline")
});
10 years ago
}
/// Login User
/** This is not really a login, it is just some kind of validation.
The server does not care if a user is online or not, it is only
interesting to the client to make sure, everything is fine. User
is logged in the following way: User name and public key are sent
to the server. If the user name exists on the server and the
public key is the same, the user is considered logged in, his
credentials seem to be valid. If user does not yet exits on
server, it is created now. If user exists, but public key is
different, then this is a complete failure, something went
terribly wrong. */
10 years ago
function login() {
status("login ...");
$.post("login.php", {user: userid(),
pubkey: localStorage.pubKey},
function(res) {
var st = JSON.parse(res);
if (st.success) {
status("logged in ...", st.txt);
10 years ago
chat();
} else {
error(st.txt);
10 years ago
}
})
.fail(function(e) {
error("offline");
});
10 years ago
}
/// Get And Display Form To Create New User
/** Shows user creation form. On submit, a private key is generated in
createkeypair(), then login() creates the user. */
10 years ago
function newuser() {
status("new user ...");
$.ajax({url: "newuser.html", success: function(res) {
status(res);
}}).fail(function() {
error("offline");
});
10 years ago
}
/// Check if local storage is available
function checkLocalStorage() {
var test = 'test';
try {
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch(e) {
status("<p>No access to local storage. Please allow "+window.location.hostname
+" to access localhost, i.e. do not block cookies.<p>");
error("local storage not available");
}
return false;
}
/// Initial Function: Startup
/** Decide whether to login or to create a new user */
10 years ago
function start() {
$("#menu").hide();
if (checkLocalStorage())
try {
status("Starting up ...");
if (!userid()) {
newuser();
} else {
login();
}
} catch (m) {
console.log(m.stack);
error(m);
10 years ago
}
}
/// On Load, Call @ref start
$(window.onbeforeunload = function() {
return "Are you sure you want to navigate away?";
});
$(window.onunload = function() { // you probably don't want to leave now...
alert('You are trying to leave.');
return false;
});
/// Allow Running in Background on Android
document.addEventListener('deviceready', function () {
if (cordova && cordova.plugins.backgroundMode) {
cordova.plugins.backgroundMode.enable();
}
}, false);
/// Start Main Loop
$(start);