BangleApps/apps/authentiwatch/interface.html

632 lines
20 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=0"/>
<link rel="stylesheet" href="../../css/spectre.min.css">
<style type="text/css">
body{font-family:sans-serif}
body div{display:none}
body.select tr>:first-child,body.export tr>:nth-child(3),body.export tr>:nth-child(4){display:none}
body.select div.select,body.export div.export{display:block}
body.select div.export,body.export div.select{display:none}
body.select div#tokens,body.editing div#edit,body.scanning div#scan,body.showqr div#showqr,body.export div#tokens{display:block}
#tokens th,#tokens td{padding:5px}
#tokens tr:nth-child(odd){background-color:#ccc}
#tokens tr:nth-child(even){background-color:#eee}
#qr-canvas{margin:auto;width:calc(100%-20px);max-width:400px}
#advbtn,#scan,#tokenqr table{text-align:center}
#edittoken tbody#adv{display:none}
#edittoken.showadv tbody#adv{display:table-row-group}
#advbtn button:before,#advbtn button:after{content:"\25bc"}
#edittoken.showadv #advbtn button:before,#edittoken.showadv #advbtn button:after{content:"\25b2"}
button{height:3em}
table button{width:100%}
form.totp tr.hotp,form.hotp tr.totp{display:none}
</style>
<!-- https://rawgit.com/sitepoint-editors/jsqrcode/master/src/qr_packed.js -->
<script src="qr_packed.js"></script>
<!-- https://davidshimjs.github.io/qrcodejs/ -->
<script src="../../core/lib/qrcode.min.js"></script>
<script type="text/javascript">
/* Start of all TOTP URLs */
const otpAuthUrl = 'otpauth://';
/* Start of all OTP migration URLs */
const otpMigrUrl = 'otpauth-migration://offline?data=';
/* Hash algorithms */
const otpAlgos = ['SHA1','SHA256','SHA512'];
const tokentypes = ['TOTP (Timed)', 'HOTP (Counter)'];
/* Settings */
var settings = {tokens:[], misc:{}};
var tokens = settings.tokens;
/* Remove any non-base-32 characters from the given string and collapses
* whitespace to a single space. Optionally removes all whitespace from
* the string.
*/
function base32clean(val, nows) {
var ret = val.replaceAll(/\s+/g, ' ');
ret = ret.replaceAll(/0/g, 'O');
ret = ret.replaceAll(/1/g, 'I');
ret = ret.replaceAll(/8/g, 'B');
ret = ret.replaceAll(/[^A-Za-z2-7 ]/g, '');
if (nows) {
ret = ret.replaceAll(/\s+/g, '');
}
return ret;
}
function b32encode(str) {
let buf = 0, bitcount = 0, ret = '';
while (str.length > 0) {
buf <<= 8;
buf |= str.charCodeAt(0);
bitcount += 8;
str = str.substr(1);
while (bitcount >= 5) {
ret += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'[(buf >> (bitcount - 5)) & 31];
bitcount -= 5;
}
}
return ret;
}
function b32decode(seedstr) {
// RFC4648
var i, buf = 0, bitcount = 0, ret = '';
for (i in seedstr) {
var c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'.indexOf(seedstr.charAt(i).toUpperCase(), 0);
if (c != -1) {
buf <<= 5;
buf |= c;
bitcount += 5;
if (bitcount >= 8) {
ret += String.fromCharCode(buf >> (bitcount - 8));
buf &= (0xFF >> (16 - bitcount));
bitcount -= 8;
}
}
}
return ret;
}
function makeLabel(token) {
let lbl = token['label'];
if (lbl == '') {
lbl = (token['issuer'] == '') ? token['account'] : token['issuer'] + ' (' + token['account'] + ')';
}
token['label'] = lbl.substr(0, 10);
}
/* Save changes to a token to the global tokens[] array.
* id is the index into the global tokens[].
* forget is a flag indicating if the token should be forgotten.
*/
function saveEdit(id, forget) {
if (forget) {
if (confirm('Forget token?')) {
tokens.splice(id, 1);
updateTokens();
}
} else {
let fe = document.forms['edittoken'].elements;
let d = parseInt(fe['digits'].value);
let p = parseInt(fe['period'].value);
let c = parseInt(fe['count'].value);
switch (fe['type'].value) {
case tokentypes[1]: p = (c > 0) ? -c : 0; break;
default : p = (p > 0) ? p : 30; break;
}
tokens[id] = {
'algorithm':fe['algorithm'].value,
'digits':((d > 0) ? d : 6),
'period':p,
'issuer':fe['issuer'].value,
'account':fe['account'].value,
'secret':base32clean(fe['secret'].value, false),
'label':fe['label'].value
};
updateTokens();
}
}
function showQr(url) {
tokenqr.clear();
tokenqr.makeCode(url);
qrPreviousClass = document.body.className;
document.body.className = 'showqr';
}
/* Generate and display a QR-code representing the current token.
*/
function showTokenQr() {
var fe = document.forms['edittoken'].elements;
var url = new String(otpAuthUrl);
switch (fe['type'].value) {
case tokentypes[1]: url += 'hotp/'; break;
default : url += 'totp/'; break;
}
if (fe['account'].value.length > 0) {
url += encodeURIComponent(fe['account'].value);
} else {
url += encodeURIComponent(fe['label'].value);
}
url += '?';
if (fe['issuer'].value.length > 0) {
url += 'issuer=' + encodeURIComponent(fe['issuer'].value);
url += '&';
}
url += 'secret=' + base32clean(fe['secret'].value, true);
switch (fe['type'].value) {
case tokentypes[1]:
if (parseInt(fe['count'].value) != 0) {
url += '&counter=' + fe['count'].value;
}
break;
default:
if (parseInt(fe['period'].value) != 30) {
url += '&period=' + fe['period'].value;
}
break;
}
if (parseInt(fe['digits'].value) != 6) {
url += '&digits=' + fe['digits'].value;
}
if (fe['algorithm'].value != 'SHA1') {
url += '&algorithm=' + fe['algorithm'].value;
}
showQr(url);
}
function onTypeChanged() {
var f = document.forms['edittoken'];
var fe = f.elements;
if (fe['type'].value == tokentypes[0]) { f.classList.add('totp'); f.classList.remove('hotp'); }
if (fe['type'].value == tokentypes[1]) { f.classList.add('hotp'); f.classList.remove('totp'); }
}
/* Generate a form for editing the specified token.
* id is the index into the global tokens[].
*/
function editToken(id) {
if (document.body.className == 'export') return;
var p;
const selectMarkup = function(name, ary, cur, onchg) {
var ret = '<select name="' + name + '"' + ((typeof onchg == 'string') ? ' onchange="' + onchg + '"' : '') + '>';
for (let i = 0; i < ary.length; i++) {
ret += '<option' + ((ary[i] == cur) ? ' selected=selected' : '') + '>' + ary[i] + '</option>';
}
return ret + '</select>';
};
scanning=false;
var markup = '<form id="edittoken"><input type="hidden" name="tokenid" value="' + id + '"><table>';
markup += '<tr><td>Name:</td><td><input name="label" type="text" value="' + tokens[id].label + '"></td></tr>';
markup += '<tr><td>Secret:</td><td><input name="secret" type="text" value="' + tokens[id].secret + '"></td></tr>';
markup += '<tbody id="adv">';
markup += '<tr><td>Account:</td><td><input name="account" type="text" value="' + tokens[id].account + '"></td></tr>';
markup += '<tr><td>Issuer:</td><td><input name="issuer" type="text" value="' + tokens[id].issuer + '"></td></tr>';
p = parseInt(tokens[id].period);
markup += '<tr><td>Type:</td><td>';
markup += selectMarkup('type', tokentypes, (tokens[id].period > 0) ? tokentypes[0] : tokentypes[1], 'onTypeChanged()');
markup += '</td></tr>';
markup += '<tr class="totp"><td>Period:</td><td><input name="period" type="text" value="' + ((p > 0) ? p : 30) + '"></td></tr>';
markup += '<tr class="hotp"><td>Count:</td><td><input name="count" type="text" value="' + ((p >= 0) ? 0 : -p) + '"></td></tr>';
markup += '<tr><td>Digits:</td><td>';
markup += selectMarkup('digits', ['6','7','8','9','10'], tokens[id].digits);
markup += '</td></tr>';
markup += '<tr><td>Hash:</td><td>';
markup += selectMarkup('algorithm', otpAlgos, tokens[id].algorithm);
markup += '</td></tr>';
markup += '</tbody><tr><td id="advbtn" colspan="2">';
markup += '<button type="button" onclick="document.getElementById(\'edittoken\').classList.toggle(\'showadv\')">Advanced</button>';
markup += '</td></tr></table></form>';
markup += '<button type="button" onclick="updateTokens()">Cancel Edit</button>';
markup += '<button type="button" onclick="saveEdit(' + id + ', false)">Save Changes</button>';
if (tokens[id].isnew) {
markup += '<button type="button" onclick="startScan(handleTokenQr,cancelTokenQr)">Scan QR</button>';
} else {
markup += '<button type="button" onclick="showTokenQr()">Show QR</button>';
markup += '<button type="button" onclick="saveEdit(' + id + ', true)">Forget Token</button>';
}
document.getElementById('edit').innerHTML = markup;
document.body.className = 'editing';
onTypeChanged();
}
/* Create a new blank token and open the editor for it.
*/
function addToken() {
tokens[tokens.length] = {'algorithm':'SHA1','digits':6,'period':30,'issuer':'','account':'','secret':'','label':'','isnew':true};
editToken(tokens.length - 1);
}
/* Convert a number to a proto3 varint.
*/
function int2proto3varint(val) {
var ret = '';
do {
let c = val & 0x7F;
val >>>= 7;
if (val > 0) {
c |= 0x80;
}
ret += String.fromCharCode(c);
} while (val > 0);
return ret;
}
/* Convert a string to a proto3 field.
*/
function str2proto3(field_number, str) {
return int2proto3varint((field_number << 3) + 2) + int2proto3varint(str.length) + str;
}
/* Convert a number to a proto3 field.
*/
function int2proto3(field_number, val) {
return int2proto3varint(field_number << 3) + int2proto3varint(val);
}
/* Convert the specified token to its proto3 representation.
*/
function token2proto3(id) {
var secret = str2proto3(1, b32decode(tokens[id].secret));
var name = str2proto3(2, (tokens[id].account == '') ? tokens[id].label : tokens[id].account);
var issuer = (tokens[id].issuer == '') ? '' : str2proto3(3, tokens[id].issuer);
var algorithm = int2proto3(4, (tokens[id].algorithm == 'SHA512') ? 3 : ((tokens[id].algorithm == 'SHA256') ? 2 : 1));
var digits = int2proto3(5, (tokens[id].digits == 8) ? 2 : 1);
var type = int2proto3(6, (tokens[id].period <= 0) ? 1 : 2);
var counter = (tokens[id].period <= 0) ? int2proto3(7, -tokens[id].period) : '';
return str2proto3(1, secret + name + issuer + algorithm + digits + type + counter);
}
/* Move the specified token up or down in the global tokens[].
* id is the index in the global tokens[] of the token to move.
* dir is the direction to move: -1=up, 1=down.
*/
function moveToken(id, dir) {
tokens.splice(id + dir, 0, tokens.splice(id, 1)[0]);
updateTokens();
}
/* Update the display listing all the tokens.
*/
function updateTokens() {
const tokenSelect = function(id) {
return '<input name="exp_' + id + '" type="checkbox" onclick="exportTokens(false, \'' + id + '\')">';
};
const tokenButton = function(fn, id, label, dir) {
return '<button type="button" onclick="' + fn + '(' + id + (dir ? ',' + dir : '') + ')">' + label + '</button>';
};
var markup = '<table><tr><th>';
markup += tokenSelect('all');
markup += '</th><th>Token</th><th colspan="2">Order</th></tr>';
/* any tokens marked new are cancelled new additions and must be removed */
for (let i = 0; i < tokens.length; i++) {
if (tokens[i].isnew) {
tokens.splice(i--, 1);
}
}
for (let i = 0; i < tokens.length; i++) {
markup += '<tr><td>';
markup += tokenSelect(i);
markup += '</td><td>';
markup += tokenButton('editToken', i, tokens[i].label);
markup += '</td><td>';
if (i < (tokens.length - 1)) {
markup += tokenButton('moveToken', i, '&#x25bc;', 1);
}
markup += '</td><td>';
if (i > 0) {
markup += tokenButton('moveToken', i, '&#x25b2;', -1);
}
markup += '</td></tr>';
}
markup += '</table>';
markup += '<div class="select">';
markup += '<button type="button" onclick="addToken()">Add Token</button>';
markup += '<button type="button" onclick="saveTokens()">Save to watch</button>';
markup += '<button type="button" onclick="startScan(handleImportQr,cancelImportQr)">Import</button>';
markup += '<button type="button" onclick="document.body.className=\'export\'">Export</button>';
markup += '</div><div class="export">';
markup += '<button type="button" onclick="document.body.className=\'select\'">Cancel</button>';
markup += '<button type="button" onclick="exportTokens(true, null)">Show QR</button>';
markup += '</div>';
document.getElementById('tokens').innerHTML = markup;
document.body.className = 'select';
}
function handleTokenQr(res) {
if (res) {
if (res.startsWith(otpAuthUrl)) {
res = decodeURIComponent(res);
var paramsidx = res.indexOf('?');
var params = res.substr(paramsidx+1).split('&');
var t = {
'algorithm':'SHA1',
'digits':'6',
'counter':'0',
'period':'30',
'secret':'',
'issuer':'',
'label':''
};
var otpok = true;
for (let pi in params) {
var param = params[pi].split('=');
if (param[0] in t) {
t[param[0]] = param[1];
} else {
otpok = false;
}
}
t['account'] = res.substring(res.lastIndexOf('/', paramsidx)+1, paramsidx);
if ((t['account'] == '') || (t['secret'] == '')) {
otpok = false;
}
if (otpok) {
scanning = false;
editToken(parseInt(document.forms['edittoken'].elements['tokenid'].value));
makeLabel(t);
var fe = document.forms['edittoken'].elements;
if (res.startsWith(otpAuthUrl + 'hotp/')) {
t['period'] = '30';
fe['type'].value = tokentypes[1];
} else {
t['counter'] = '0';
fe['type'].value = tokentypes[0];
}
fe['algorithm'].value = t['algorithm'];
fe['digits' ].value = t['digits' ];
fe['count' ].value = t['counter' ];
fe['period' ].value = t['period' ];
fe['secret' ].value = t['secret' ];
fe['issuer' ].value = t['issuer' ];
fe['account' ].value = t['account' ];
fe['label' ].value = t['label' ];
onTypeChanged();
}
}
}
}
function cancelTokenQr() {
scanning = false;
editToken(parseInt(document.forms['edittoken'].elements['tokenid'].value));
}
class proto3decoder {
constructor(str) {
this.buf = [];
for (let i in str) {
this.buf = this.buf.concat(str.charCodeAt(i));
}
}
getVarint() {
let c, ret = 0
do {
c = this.buf.shift();
ret = (ret << 7) | (c & 0x7F);
} while ((c & 0x80) != 0);
return ret;
}
getString(length) {
let ret = '';
for (let i = 0; i < length; ++i) {
ret += String.fromCharCode(this.buf.shift());
}
return ret;
}
parse() {
let ret = null;
if (this.buf.length > 0) {
let field_data = null;
let field_type = this.getVarint();
let field_number = field_type >>> 3;
let wire_type = field_type & 7;
switch (wire_type) {
case 0: field_data = this.getVarint(); break;
case 2: field_data = this.getString(this.getVarint()); break;
}
ret = {number:field_number,data:field_data};
}
return ret;
}
}
function handleImportQr(res) {
if (res) {
if (res.startsWith(otpMigrUrl)) {
scanning = false;
let data = new proto3decoder(atob(decodeURIComponent(res.substr(otpMigrUrl.length))));
while (data.buf.length > 0) {
let field = data.parse();
if (field?.number == 1) {
let newtoken = {'algorithm':'SHA1','digits':6,'period':30,'issuer':'','account':'','secret':'','label':''};
let p3token = new proto3decoder(field.data);
while (p3token.buf.length > 0) {
let buf = p3token.parse();
switch (buf?.number) {
case 1: newtoken.secret = b32encode(buf.data); break;
case 2: newtoken.account = buf.data; break;
case 3: newtoken.issuer = buf.data; break;
case 4: newtoken.algorithm = otpAlgos[buf.data - 1]; break;
case 5: newtoken.digits = (['6','8'])[buf.data - 1]; break;
case 7: newtoken.period = -buf.data; break;
}
}
makeLabel(newtoken);
tokens[tokens.length] = newtoken;
}
}
updateTokens();
}
}
}
function cancelImportQr() {
scanning = false;
document.body.className = 'select';
}
/* Original QR-code reader: https://www.sitepoint.com/create-qr-code-reader-mobile-website/ */
qrcode.callback = res => {
if (res) {
scanCallback(res);
if (scanning) {
scanBack();
}
}
};
function startScan(handler,cancel) {
scanCallback = handler;
scanBack = cancel;
document.body.className = 'scanning';
navigator.mediaDevices
.getUserMedia({video:{facingMode:'environment'}})
.then(function(stream){
scanning=true;
video.setAttribute('playsinline',true);
video.srcObject = stream;
video.play();
scanTick();
doScan();
});
}
function scanTick() {
canvasElement.height = video.videoHeight;
canvasElement.width = video.videoWidth;
canvas.drawImage(video,0,0,canvasElement.width,canvasElement.height);
if (scanning) {
requestAnimationFrame(scanTick);
} else {
video.srcObject.getTracks().forEach(track => {
track.stop();
});
}
}
function doScan() {
try {
qrcode.decode();
} catch (e) {
setTimeout(doScan,300);
}
}
/* Load settings JSON file from the watch.
*/
function loadTokens() {
Util.showModal('Loading...');
Puck.eval(`require('Storage').readJSON(${JSON.stringify('authentiwatch.json')})`,data=>{
Util.hideModal();
if (data.data ) settings.tokens = data.data ; /* v0.02 settings */
if (data.tokens) settings.tokens = data.tokens; /* v0.03+ settings */
if (data.misc ) settings.misc = data.misc ; /* v0.03+ settings */
tokens = settings.tokens;
updateTokens();
});
}
/* Save settings as a JSON file on the watch.
*/
function saveTokens() {
Util.showModal('Saving...');
let newsettings={tokens:tokens,misc:settings.misc};
Puck.write(`\x10require('Storage').writeJSON(${JSON.stringify('authentiwatch.json')},${JSON.stringify(newsettings)})\n`,()=>{
Util.hideModal();
});
}
/* Handle token export.
* showqr is true if the QR code should be shown, if false the checkboxes need updating
* id is the name of the clicked checkbox, or null if the export button was pressed
*/
function exportTokens(showqr, id) {
let allchecked = true, allclear = true;
let cball;
let exp = '';
for (let cb of document.querySelectorAll('input[type=checkbox]')) {
let cbid = cb.name.substring(4);
if (cbid == 'all') {
cball = cb;
} else {
if (id == 'all') {
cb.checked = cball.checked;
} else {
if (cb.checked) {
if (showqr) {
exp += token2proto3(parseInt(cbid));
}
allclear = false;
} else {
allchecked = false;
}
}
}
}
if (id != 'all') {
if (allclear) {
cball.indeterminate = false;
cball.checked = false;
} else if (allchecked) {
cball.indeterminate = false;
cball.checked = true;
} else {
cball.indeterminate = true;
}
}
if (showqr) {
if (exp != '') {
/* add version, batch_size, batch_index, but no batch_id */
exp += int2proto3(2, 1) + int2proto3(3, 1) + int2proto3(4, 0);
let url = otpMigrUrl + encodeURIComponent(btoa(exp));
showQr(url);
}
}
}
function onInit() {
loadTokens();
updateTokens();
}
function qrBack() {
document.body.className = qrPreviousClass;
}
</script>
</head>
<body class="select">
<h1>Authentiwatch</h1>
<div id="tokens">
<p>No watch comms.</p>
</div>
<div id="scan">
<table>
<tr><td><canvas id="qr-canvas"></canvas></td></tr>
<tr><td><button type="button" onclick="scanBack()">Cancel</button></td></tr>
</table>
</div>
<div id="edit">
</div>
<div id="showqr">
<table><tr><td id="qrcode"></td></tr><tr><td>
<button type="button" onclick="qrBack()">Back</button>
</td></tr></table>
</div>
</div>
<script type="text/javascript">
const video=document.createElement('video');
const canvasElement=document.getElementById('qr-canvas');
const canvas=canvasElement.getContext('2d');
let scanning=false;
const tokenqr=new QRCode(document.getElementById('qrcode'), {width:354,height:354});
</script>
<script src="../../core/lib/interface.js"></script>
</body>
</html>