const COUNTER_TRIANGLE_SIZE = 10; const TOKEN_EXTRA_HEIGHT = 16; var TOKEN_DIGITS_HEIGHT = 30; var TOKEN_HEIGHT = TOKEN_DIGITS_HEIGHT + TOKEN_EXTRA_HEIGHT; const PROGRESSBAR_HEIGHT = 3; const IDLE_REPEATS = 1; // when idle, the number of extra timed periods to show before hiding const SETTINGS = "authentiwatch.json"; // Hash functions const crypto = require("crypto"); const algos = { "SHA512":{sha:crypto.SHA512,retsz:64,blksz:128}, "SHA256":{sha:crypto.SHA256,retsz:32,blksz:64 }, "SHA1" :{sha:crypto.SHA1 ,retsz:20,blksz:64 }, }; const CALCULATING = /*LANG*/"Calculating"; const NO_TOKENS = /*LANG*/"No tokens"; const NOT_SUPPORTED = /*LANG*/"Not supported"; // sample settings: // {tokens:[{"algorithm":"SHA1","digits":6,"period":30,"issuer":"","account":"","secret":"Bbb","label":"Aaa"}],misc:{}} var settings = require("Storage").readJSON(SETTINGS, true) || {tokens:[], misc:{}}; if (settings.data ) tokens = settings.data ; /* v0.02 settings */ if (settings.tokens) tokens = settings.tokens; /* v0.03+ settings */ function b32decode(seedstr) { // RFC4648 Base16/32/64 Data Encodings let buf = 0, bitcount = 0, retstr = ""; for (let c of seedstr.toUpperCase()) { if (c == '0') c = 'O'; if (c == '1') c = 'I'; if (c == '8') c = 'B'; c = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".indexOf(c); if (c != -1) { buf <<= 5; buf |= c; bitcount += 5; if (bitcount >= 8) { retstr += String.fromCharCode(buf >> (bitcount - 8)); buf &= (0xFF >> (16 - bitcount)); bitcount -= 8; } } } let retbuf = new Uint8Array(retstr.length); for (let i in retstr) { retbuf[i] = retstr.charCodeAt(i); } return retbuf; } function hmac(key, message, algo) { let a = algos[algo.toUpperCase()]; // RFC2104 HMAC if (key.length > a.blksz) { key = a.sha(key); } let istr = new Uint8Array(a.blksz + message.length); let ostr = new Uint8Array(a.blksz + a.retsz); for (let i = 0; i < a.blksz; ++i) { let c = (i < key.length) ? key[i] : 0; istr[i] = c ^ 0x36; ostr[i] = c ^ 0x5C; } istr.set(message, a.blksz); ostr.set(a.sha(istr), a.blksz); let ret = a.sha(ostr); // RFC4226 HOTP (dynamic truncation) let v = new DataView(ret, ret[ret.length - 1] & 0x0F, 4); return v.getUint32(0) & 0x7FFFFFFF; } function formatOtp(otp, digits) { // add 0 padding let ret = "" + otp % Math.pow(10, digits); while (ret.length < digits) { ret = "0" + ret; } // add a space after every 3rd or 4th digit let re = (digits % 3 == 0 || (digits % 3 >= digits % 4 && digits % 4 != 0)) ? "" : "."; return ret.replace(new RegExp("(..." + re + ")", "g"), "$1 ").trim(); } function hotp(token) { let d = Date.now(); let tick, next; if (token.period > 0) { // RFC6238 - timed tick = Math.floor(Math.floor(d / 1000) / token.period); next = (tick + 1) * token.period * 1000; } else { // RFC4226 - counter tick = -token.period; next = d + 30000; } let msg = new Uint8Array(8); let v = new DataView(msg.buffer); v.setUint32(0, tick >> 16 >> 16); v.setUint32(4, tick & 0xFFFFFFFF); let ret; try { ret = hmac(b32decode(token.secret), msg, token.algorithm); ret = formatOtp(ret, token.digits); } catch(err) { ret = NOT_SUPPORTED; } return {hotp:ret, next:next}; } // Tokens are displayed in three states: // 1. Unselected (state.id<0) // 2. Selected, inactive (no code) (state.id>=0,state.hotp.hotp=="") // 3. Selected, active (code showing) (state.id>=0,state.hotp.hotp!="") var fontszCache = {}; var state = { listy:0, // list scroll position id:-1, // current token ID hotp:{hotp:"",next:0} }; function sizeFont(id, txt, w) { let sz = fontszCache[id]; if (!sz) { sz = TOKEN_DIGITS_HEIGHT; do { g.setFont("Vector", sz--); } while (g.stringWidth(txt) > w); fontszCache[id] = ++sz; } g.setFont("Vector", sz); } tokenY = id => id * TOKEN_HEIGHT + AR.y - state.listy; half = n => Math.floor(n / 2); function timerCalc() { let timerfn = exitApp; let timerdly = 10000; if (state.id >= 0 && state.hotp.hotp != "") { if (tokens[state.id].period > 0) { // timed HOTP if (state.hotp.next < Date.now()) { if (state.cnt > 0) { state.cnt--; state.hotp = hotp(tokens[state.id]); } else { state.hotp.hotp = ""; } timerdly = 1; timerfn = updateCurrentToken; } else { timerdly = 1000; timerfn = updateProgressBar; } } else { // counter HOTP if (state.cnt > 0) { state.cnt--; timerdly = 30000; } else { state.hotp.hotp = ""; timerdly = 1; } timerfn = updateCurrentToken; } } if (state.drawtimer) { clearTimeout(state.drawtimer); } state.drawtimer = setTimeout(timerfn, timerdly); } function updateCurrentToken() { drawToken(state.id); timerCalc(); } function updateProgressBar() { drawProgressBar(); timerCalc(); } function drawProgressBar() { let id = state.id; if (id >= 0 && tokens[id].period > 0) { let rem = Math.min(tokens[id].period, Math.floor((state.hotp.next - Date.now()) / 1000)); if (rem >= 0) { let y1 = tokenY(id); let y2 = y1 + TOKEN_HEIGHT - 1; if (y2 >= AR.y && y1 <= AR.y2) { // token visible y1 = y2 - PROGRESSBAR_HEIGHT; if (y1 <= AR.y2) { // progress bar visible y2 = Math.min(y2, AR.y2); let xr = Math.floor(AR.w * rem / tokens[id].period) + AR.x; g.setColor(g.theme.fgH) .setBgColor(g.theme.bgH) .fillRect(AR.x, y1, xr, y2) .clearRect(xr + 1, y1, AR.x2, y2); } } else { // token not visible state.id = -1; } } } } // id = token ID number (0...) function drawToken(id) { let x1 = AR.x; let y1 = tokenY(id); let x2 = AR.x2; let y2 = y1 + TOKEN_HEIGHT - 1; let lbl = (id >= 0 && id < tokens.length) ? tokens[id].label.substr(0, 10) : ""; let adj; g.setClipRect(x1, Math.max(y1, AR.y), x2, Math.min(y2, AR.y2)); if (id === state.id) { g.setColor(g.theme.fgH) .setBgColor(g.theme.bgH); } else { g.setColor(g.theme.fg) .setBgColor(g.theme.bg); } if (id == state.id && state.hotp.hotp != "") { // small label centered just below top line g.setFont("Vector", TOKEN_EXTRA_HEIGHT) .setFontAlign(0, -1, 0); adj = y1; } else { // large label centered in box sizeFont("l" + id, lbl, AR.w); g.setFontAlign(0, 0, 0); adj = half(y1 + y2); } g.clearRect(x1, y1, x2, y2) .drawString(lbl, half(x1 + x2), adj, false); if (id == state.id && state.hotp.hotp != "") { adj = 0; if (tokens[id].period <= 0) { // counter - draw triangle as swipe hint let yc = half(y1 + y2); adj = COUNTER_TRIANGLE_SIZE; g.fillPoly([AR.x, yc, AR.x + adj, yc - adj, AR.x + adj, yc + adj]); adj += 2; } // digits just below label x1 = half(x1 + adj + x2); y1 += TOKEN_EXTRA_HEIGHT; if (state.hotp.hotp == CALCULATING) { sizeFont("c", CALCULATING, AR.w - adj); g.drawString(CALCULATING, x1, y1, false) .flip(); state.hotp = hotp(tokens[id]); g.clearRect(AR.x + adj, y1, AR.x2, y2); } sizeFont("d" + id, state.hotp.hotp, AR.w - adj); g.drawString(state.hotp.hotp, x1, y1, false); if (tokens[id].period > 0) { drawProgressBar(); } } g.setClipRect(0, 0, g.getWidth() - 1, g.getHeight() - 1); } function changeId(id) { if (id != state.id) { state.hotp.hotp = CALCULATING; let pid = state.id; state.id = id; if (pid >= 0) { drawToken(pid); } if (id >= 0) { drawToken( id); } } } function onDrag(e) { state.cnt = IDLE_REPEATS; if (e.b != 0 && e.dy != 0) { let y = E.clip(state.listy - E.clip(e.dy, -AR.h, AR.h), 0, Math.max(0, tokens.length * TOKEN_HEIGHT - AR.h)); if (state.listy != y) { let id, dy = state.listy - y; state.listy = y; g.setClipRect(AR.x, AR.y, AR.x2, AR.y2) .scroll(0, dy); if (dy > 0) { id = Math.floor((state.listy + dy) / TOKEN_HEIGHT); y = tokenY(id + 1); do { drawToken(id); id--; y -= TOKEN_HEIGHT; } while (y > AR.y); } if (dy < 0) { id = Math.floor((state.listy + dy + AR.h) / TOKEN_HEIGHT); y = tokenY(id); while (y < AR.y2) { drawToken(id); id++; y += TOKEN_HEIGHT; } } } } if (e.b == 0) { timerCalc(); } } function onTouch(zone, e) { state.cnt = IDLE_REPEATS; if (e) { let id = Math.floor((state.listy + e.y - AR.y) / TOKEN_HEIGHT); if (id == state.id || tokens.length == 0 || id >= tokens.length) { id = -1; } if (state.id != id) { if (id >= 0) { // scroll token into view if necessary let dy = 0; let y = id * TOKEN_HEIGHT - state.listy; if (y < 0) { dy -= y; y = 0; } y += TOKEN_HEIGHT; if (y > AR.h) { dy -= (y - AR.h); } onDrag({b:1, dy:dy}); } changeId(id); } } timerCalc(); } function onSwipe(e) { state.cnt = IDLE_REPEATS; switch (e) { case 1: exitApp(); break; case -1: if (state.id >= 0 && tokens[state.id].period <= 0) { tokens[state.id].period--; require("Storage").writeJSON(SETTINGS, {tokens:tokens, misc:settings.misc}); state.hotp.hotp = CALCULATING; drawToken(state.id); } break; } timerCalc(); } function bangleBtn(e) { state.cnt = IDLE_REPEATS; if (tokens.length > 0) { let id = E.clip(state.id + e, 0, tokens.length - 1); onDrag({b:1, dy:state.listy - E.clip(id * TOKEN_HEIGHT - half(AR.h - TOKEN_HEIGHT), 0, Math.max(0, tokens.length * TOKEN_HEIGHT - AR.h))}); changeId(id); drawProgressBar(); } timerCalc(); } function exitApp() { if (state.drawtimer) { clearTimeout(state.drawtimer); } Bangle.showLauncher(); } Bangle.on('touch', onTouch); Bangle.on('drag' , onDrag ); Bangle.on('swipe', onSwipe); if (typeof BTN1 == 'number') { if (typeof BTN2 == 'number' && typeof BTN3 == 'number') { setWatch(()=>bangleBtn(-1), BTN1, {edge:"rising" , debounce:50, repeat:true}); setWatch(()=>exitApp() , BTN2, {edge:"falling", debounce:50}); setWatch(()=>bangleBtn( 1), BTN3, {edge:"rising" , debounce:50, repeat:true}); } else { setWatch(()=>exitApp() , BTN1, {edge:"falling", debounce:50}); } } Bangle.loadWidgets(); const AR = Bangle.appRect; // draw the initial display g.clear(); if (tokens.length > 0) { state.listy = AR.h; onDrag({b:1, dy:AR.h}); } else { g.setFont("Vector", TOKEN_DIGITS_HEIGHT) .setFontAlign(0, 0, 0) .drawString(NO_TOKENS, AR.x + half(AR.w), AR.y + half(AR.h), false); } timerCalc(); Bangle.drawWidgets();