diff --git a/README.md b/README.md index 97dd8f2..40b4efb 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ -# TOTPFIT +# TOTPFIT + ### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 with Google Authenticator migration support ![alt text](docs/assets/image2.png) ### Features: -- Supports of ```otpauth://``` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset" + +- Supports of `otpauth://` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset" - Addition/Edition/Deletion of TOTPs from mobile app -- Support of Google Authenticator migration links formated: ```otpauth-migration://offline?data=...``` (At this stage with only 6 digits and only 30 seconds period) +- Support of Google Authenticator migration links formated: `otpauth-migration://offline?data=...` (At this stage with only 6 digits and only 30 seconds period) ### Guides: [How to add 2FA TOTP records (keys) on app](/docs/guides/how-to-add-totps/README.md) -#### This repo has mirror for issues on [GitHub](https://github.com/Lisoveliy/totpfit) \ No newline at end of file +#### This repo has mirror for issues on [GitHub](https://github.com/Lisoveliy/totpfit) diff --git a/app-side/index.js b/app-side/index.js index b1cc4bf..17f4896 100644 --- a/app-side/index.js +++ b/app-side/index.js @@ -1,17 +1,13 @@ -import { BaseSideService } from "@zeppos/zml/base-side" +import { BaseSideService } from "@zeppos/zml/base-side"; AppSideService( - BaseSideService( - { - onInit(){ - - }, - onRequest(request, response){ - if(request.method === 'totps'){ - response(null, settings.settingsStorage.getItem('TOTPs')) - } - }, - onSettingsChange(){ } - } - ) -) \ No newline at end of file + BaseSideService({ + onInit() {}, + onRequest(request, response) { + if (request.method === "totps") { + response(null, settings.settingsStorage.getItem("TOTPs")); + } + }, + onSettingsChange() {}, + }), +); diff --git a/app.js b/app.js index 49d65b6..188d3b2 100644 --- a/app.js +++ b/app.js @@ -7,5 +7,5 @@ App( }, onCreate() {}, onDestroy() {}, - }) + }), ); diff --git a/app.json b/app.json index 3d73ff5..3d015e4 100644 --- a/app.json +++ b/app.json @@ -1,56 +1,50 @@ { - "configVersion": "v3", - "app": { - "appId": 25087, - "appName": "totpfit", - "appType": "app", - "version": { - "code": 1, - "name": "1.3.1" + "configVersion": "v3", + "app": { + "appId": 25087, + "appName": "totpfit", + "appType": "app", + "version": { + "code": 1, + "name": "1.3.1" + }, + "icon": "icon.png", + "vender": "zepp", + "description": "Another 2FAuthenticator based on TOTP for Zepp OS" }, - "icon": "icon.png", - "vender": "zepp", - "description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4" - }, - "permissions": [ - "data:os.device.info", - "device:os.local_storage" - ], - "runtime": { - "apiVersion": { - "compatible": "3.0.0", - "target": "3.0.0", - "minVersion": "3.0" - } - }, - "targets": { - "default": { - "module": { - "page": { - "pages": [ - "page/index", - "page/tip" - ] - }, - "app-side": { - "path": "app-side/index" - }, - "setting": { - "path": "setting/index" + "permissions": ["data:os.device.info", "device:os.local_storage"], + "runtime": { + "apiVersion": { + "compatible": "3.0.0", + "target": "3.0.0", + "minVersion": "3.0" } - }, - "platforms": [ - { - "st": "s", - "dw": 390 + }, + "targets": { + "default": { + "module": { + "page": { + "pages": ["page/index", "page/tip"] + }, + "app-side": { + "path": "app-side/index" + }, + "setting": { + "path": "setting/index" + } + }, + "platforms": [ + { + "st": "s", + "dw": 390 + } + ] } - ] - } - }, - "i18n": { - "en-US": { - "appName": "TOTPFit" - } - }, - "defaultLanguage": "en-US" -} \ No newline at end of file + }, + "i18n": { + "en-US": { + "appName": "TOTPFit" + } + }, + "defaultLanguage": "en-US" +} diff --git a/docs/guides/how-to-add-totps/README.md b/docs/guides/how-to-add-totps/README.md index 9311a59..f78b736 100644 --- a/docs/guides/how-to-add-totps/README.md +++ b/docs/guides/how-to-add-totps/README.md @@ -6,7 +6,7 @@ To add 2FA TOTP records using 2FA TOTP QR-Codes, you must scan QR-Code of servic ![QR Code with URI](image.png) -Copy this URI string and paste it to app using button *"Add new TOTP record"*: +Copy this URI string and paste it to app using button _"Add new TOTP record"_: ![Add new TOTP record popup](image-2.png) @@ -18,7 +18,7 @@ You can edit your otpauth:// records using button "Change TOTP link". Your previ ### If you use google migrations (otpauth-migration:// links) -To add 2FA TOTP recods using migration from Google Authenticator app, you must go to menu, select "Transfer accounts" -> "Export accounts" +To add 2FA TOTP recods using migration from Google Authenticator app, you must go to menu, select "Transfer accounts" -> "Export accounts" Select codes then screenshot QR code and scan (decode) it to a URI. Use any app providing scan from image, ex: "Search screen" function (Google Lens) on Google Assistant. @@ -26,7 +26,7 @@ For example, this QR-Code will represent next URI string: ![Google lens scan from Google Authenticator](image-5.png) -After scaning copy this URI string and paste it to app using button *"Add new TOTP record"*: +After scaning copy this URI string and paste it to app using button _"Add new TOTP record"_: ![Add new TOTP record using otpauth-migration](image-6.png) @@ -34,4 +34,4 @@ Then press OK, all selected records from Google Authenticator will appear on pag ![Added records from otpauth-migration](image-7.png) -You can edit your records using button "Change TOTP link". Your previous record will be replaced with a new otpauth:// link from text field (otpauth-migration:// will not work), and previous link will not be shown on field. \ No newline at end of file +You can edit your records using button "Change TOTP link". Your previous record will be replaced with a new otpauth:// link from text field (otpauth-migration:// will not work), and previous link will not be shown on field. diff --git a/jsconfig.json b/jsconfig.json index 1bd80d8..c8e1fa6 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,9 +1,9 @@ { - "compilerOptions": { - "module": "commonjs", - "target": "es6", - "checkJs": true - }, - "exclude": ["node_modules", "**/node_modules/*"], - "files": ["node_modules/@zeppos/device-types/dist/index.d.ts"] + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "checkJs": true + }, + "exclude": ["node_modules", "**/node_modules/*"], + "files": ["node_modules/@zeppos/device-types/dist/index.d.ts"] } diff --git a/lib/protobuf-decoder/hexUtils.js b/lib/protobuf-decoder/hexUtils.js index 6f6ff52..636c027 100644 --- a/lib/protobuf-decoder/hexUtils.js +++ b/lib/protobuf-decoder/hexUtils.js @@ -1,36 +1,37 @@ -import { Buffer } from 'buffer' +import { Buffer } from "buffer"; export function parseInput(input) { - const normalizedInput = input.replace(/\s/g, ""); - const normalizedHexInput = normalizedInput.replace(/0x/g, "").toLowerCase(); - if (isHex(normalizedHexInput)) { - return Buffer.from(normalizedHexInput, "hex"); - } else { - return Buffer.from(normalizedInput, "base64"); - } + const normalizedInput = input.replace(/\s/g, ""); + const normalizedHexInput = normalizedInput.replace(/0x/g, "").toLowerCase(); + if (isHex(normalizedHexInput)) { + return Buffer.from(normalizedHexInput, "hex"); + } else { + return Buffer.from(normalizedInput, "base64"); + } } export function isHex(string) { - let result = true; - for (const char of string) { - if (!((char >= "a" && char <= "f") || (char >= "0" && char <= "9"))) { - result = false; + let result = true; + for (const char of string) { + if (!((char >= "a" && char <= "f") || (char >= "0" && char <= "9"))) { + result = false; + } } - } - return result; + return result; } export function bufferLeToBeHex(buffer) { - let output = ""; - for (const v of buffer) { - const hex = v.toString(16); - if (hex.length === 1) { - output = "0" + hex + output; - } else { - output = hex + output; + let output = ""; + for (const v of buffer) { + const hex = v.toString(16); + if (hex.length === 1) { + output = "0" + hex + output; + } else { + output = hex + output; + } } - } - return output; + return output; } -export const bufferToPrettyHex = b => [...b].map(c => c.toString(16).padStart(2, '0')).join(' '); +export const bufferToPrettyHex = (b) => + [...b].map((c) => c.toString(16).padStart(2, "0")).join(" "); diff --git a/lib/protobuf-decoder/protobufDecoder.js b/lib/protobuf-decoder/protobufDecoder.js index f99a573..e8ad50e 100644 --- a/lib/protobuf-decoder/protobufDecoder.js +++ b/lib/protobuf-decoder/protobufDecoder.js @@ -1,132 +1,132 @@ import { decodeVarint } from "./varintUtils"; export class BufferReader { - constructor(buffer) { - this.buffer = buffer; - this.offset = 0; - } - - readVarInt() { - const result = decodeVarint(this.buffer, this.offset); - this.offset += result.length; - - return result.value; - } - - readBuffer(length) { - this.checkByte(length); - const result = this.buffer.slice(this.offset, this.offset + length); - this.offset += length; - - return result; - } - - // gRPC has some additional header - remove it - trySkipGrpcHeader() { - const backupOffset = this.offset; - - if (this.buffer[this.offset] === 0 && this.leftBytes() >= 5) { - this.offset++; - const length = this.buffer.readInt32BE(this.offset); - this.offset += 4; - - if (length > this.leftBytes()) { - // Something is wrong, revert - this.offset = backupOffset; - } + constructor(buffer) { + this.buffer = buffer; + this.offset = 0; } - } - leftBytes() { - return this.buffer.length - this.offset; - } + readVarInt() { + const result = decodeVarint(this.buffer, this.offset); + this.offset += result.length; - checkByte(length) { - const bytesAvailable = this.leftBytes(); - if (length > bytesAvailable) { - throw new Error( - "Not enough bytes left. Requested: " + - length + - " left: " + - bytesAvailable - ); + return result.value; } - } - checkpoint() { - this.savedOffset = this.offset; - } + readBuffer(length) { + this.checkByte(length); + const result = this.buffer.slice(this.offset, this.offset + length); + this.offset += length; - resetToCheckpoint() { - this.offset = this.savedOffset; - } + return result; + } + + // gRPC has some additional header - remove it + trySkipGrpcHeader() { + const backupOffset = this.offset; + + if (this.buffer[this.offset] === 0 && this.leftBytes() >= 5) { + this.offset++; + const length = this.buffer.readInt32BE(this.offset); + this.offset += 4; + + if (length > this.leftBytes()) { + // Something is wrong, revert + this.offset = backupOffset; + } + } + } + + leftBytes() { + return this.buffer.length - this.offset; + } + + checkByte(length) { + const bytesAvailable = this.leftBytes(); + if (length > bytesAvailable) { + throw new Error( + "Not enough bytes left. Requested: " + + length + + " left: " + + bytesAvailable, + ); + } + } + + checkpoint() { + this.savedOffset = this.offset; + } + + resetToCheckpoint() { + this.offset = this.savedOffset; + } } export const TYPES = { - VARINT: 0, - FIXED64: 1, - LENDELIM: 2, - FIXED32: 5 + VARINT: 0, + FIXED64: 1, + LENDELIM: 2, + FIXED32: 5, }; export function decodeProto(buffer) { - const reader = new BufferReader(buffer); - const parts = []; - reader.trySkipGrpcHeader(); + const reader = new BufferReader(buffer); + const parts = []; + reader.trySkipGrpcHeader(); - try { - while (reader.leftBytes() > 0) { - reader.checkpoint(); + try { + while (reader.leftBytes() > 0) { + reader.checkpoint(); - const byteRange = [reader.offset]; - const indexType = parseInt(reader.readVarInt().toString()); - const type = indexType & 0b111; - const index = indexType >> 3; + const byteRange = [reader.offset]; + const indexType = parseInt(reader.readVarInt().toString()); + const type = indexType & 0b111; + const index = indexType >> 3; - let value; - if (type === TYPES.VARINT) { - value = reader.readVarInt().toString(); - } else if (type === TYPES.LENDELIM) { - const length = parseInt(reader.readVarInt().toString()); - value = reader.readBuffer(length); - } else if (type === TYPES.FIXED32) { - value = reader.readBuffer(4); - } else if (type === TYPES.FIXED64) { - value = reader.readBuffer(8); - } else { - throw new Error("Unknown type: " + type); - } - byteRange.push(reader.offset); + let value; + if (type === TYPES.VARINT) { + value = reader.readVarInt().toString(); + } else if (type === TYPES.LENDELIM) { + const length = parseInt(reader.readVarInt().toString()); + value = reader.readBuffer(length); + } else if (type === TYPES.FIXED32) { + value = reader.readBuffer(4); + } else if (type === TYPES.FIXED64) { + value = reader.readBuffer(8); + } else { + throw new Error("Unknown type: " + type); + } + byteRange.push(reader.offset); - parts.push({ - byteRange, - index, - type, - value - }); + parts.push({ + byteRange, + index, + type, + value, + }); + } + } catch (err) { + reader.resetToCheckpoint(); + console.log(err); } - } catch (err) { - reader.resetToCheckpoint(); - console.log(err); - } - return { - parts, - leftOver: reader.readBuffer(reader.leftBytes()) - }; + return { + parts, + leftOver: reader.readBuffer(reader.leftBytes()), + }; } export function typeToString(type, subType) { - switch (type) { - case TYPES.VARINT: - return "varint"; - case TYPES.LENDELIM: - return subType || "len_delim"; - case TYPES.FIXED32: - return "fixed32"; - case TYPES.FIXED64: - return "fixed64"; - default: - return "unknown"; - } + switch (type) { + case TYPES.VARINT: + return "varint"; + case TYPES.LENDELIM: + return subType || "len_delim"; + case TYPES.FIXED32: + return "fixed32"; + case TYPES.FIXED64: + return "fixed64"; + default: + return "unknown"; + } } diff --git a/lib/protobuf-decoder/varintUtils.js b/lib/protobuf-decoder/varintUtils.js index b3a8a98..3d540e0 100644 --- a/lib/protobuf-decoder/varintUtils.js +++ b/lib/protobuf-decoder/varintUtils.js @@ -1,23 +1,23 @@ export function decodeVarint(buffer, offset) { - let res = 0; - let shift = 0; - let byte = 0; + let res = 0; + let shift = 0; + let byte = 0; - do { - if (offset >= buffer.length) { - throw new RangeError("Index out of bound decoding varint"); - } + do { + if (offset >= buffer.length) { + throw new RangeError("Index out of bound decoding varint"); + } - byte = buffer[offset++]; + byte = buffer[offset++]; - const multiplier = 2 ** shift; - const thisByteValue = (byte & 0x7f) * multiplier; - shift += 7; - res = res + thisByteValue; - } while (byte >= 0x80); + const multiplier = 2 ** shift; + const thisByteValue = (byte & 0x7f) * multiplier; + shift += 7; + res = res + thisByteValue; + } while (byte >= 0x80); - return { - value: res, - length: shift / 7 - }; + return { + value: res, + length: shift / 7, + }; } diff --git a/lib/totp-quickjs/OTPGenerator.js b/lib/totp-quickjs/OTPGenerator.js index cbf9a29..7e161d0 100644 --- a/lib/totp-quickjs/OTPGenerator.js +++ b/lib/totp-quickjs/OTPGenerator.js @@ -1,53 +1,59 @@ import { decode } from "./base32decoder.js"; import jsSHA from "jssha"; -"use bigint" +("use bigint"); /** * get HOTP based on counter * @param {BigInt} counter BigInt counter of HOTP - * @param {string} secret base32 encoded string + * @param {string} secret base32 encoded string * @param {number} [digits=6] number of digits in OTP token * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation) * @returns HOTP string */ -export function getHOTP(counter, secret, digits = 6, hashType = 'SHA-1'){ - +export function getHOTP(counter, secret, digits = 6, hashType = "SHA-1") { //Stage 1: Prepare data - const rawDataCounter = new DataView(new ArrayBuffer(8)) - rawDataCounter.setUint32(4, counter) - - const bCounter = new Uint8Array(rawDataCounter.buffer) - const bSecret = new Uint8Array(decode(secret).match(/.{1,2}/g).map(chunk => parseInt(chunk, 16))); //confirmed - + const rawDataCounter = new DataView(new ArrayBuffer(8)); + rawDataCounter.setUint32(4, counter); + const bCounter = new Uint8Array(rawDataCounter.buffer); + const bSecret = new Uint8Array( + decode(secret) + .match(/.{1,2}/g) + .map((chunk) => parseInt(chunk, 16)), + ); //confirmed //Stage 2: Hash data - const jssha = new jsSHA(hashType, 'UINT8ARRAY') - jssha.setHMACKey(bSecret, 'UINT8ARRAY') - jssha.update(bCounter) - const hmacResult = jssha.getHMAC('UINT8ARRAY') //confirmed - + const jssha = new jsSHA(hashType, "UINT8ARRAY"); + jssha.setHMACKey(bSecret, "UINT8ARRAY"); + jssha.update(bCounter); + const hmacResult = jssha.getHMAC("UINT8ARRAY"); //confirmed //Stage 3: Dynamic truncate const offsetB = hmacResult[19] & 0xf; - const P = hmacResult.slice(offsetB, offsetB + 4) + const P = hmacResult.slice(offsetB, offsetB + 4); P[0] = P[0] & 0x7f; - //Stage 4: Format string - let res = (new DataView(P.buffer).getInt32(0) % Math.pow(10, digits)).toString() - while(res.length < digits) - res = '0' + res; + let res = ( + new DataView(P.buffer).getInt32(0) % Math.pow(10, digits) + ).toString(); + while (res.length < digits) res = "0" + res; return res; } /** * get OTP based on current time - * @param {string} secret base32 encoded string + * @param {string} secret base32 encoded string * @param {number} [digits=6] digits in OTP * @param {number} [time=Date.now()] time for counter (default unix time epoch) * @param {number} [fetchTime=30] period of token in seconds - * @param {number} [timeOffset=0] time offset for token in seconds + * @param {number} [timeOffset=0] time offset for token in seconds * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation) * @returns TOTP string */ -export function getTOTP(secret, digits = 6, time = Date.now(), fetchTime = 30, timeOffset = 0, hashType = 'SHA-1') -{ - const unixTime = Math.round((time / 1000 + timeOffset) / fetchTime) - return getHOTP(BigInt(unixTime), secret, digits) -} \ No newline at end of file +export function getTOTP( + secret, + digits = 6, + time = Date.now(), + fetchTime = 30, + timeOffset = 0, + hashType = "SHA-1", +) { + const unixTime = Math.round((time / 1000 + timeOffset) / fetchTime); + return getHOTP(BigInt(unixTime), secret, digits); +} diff --git a/lib/totp-quickjs/base32decoder.js b/lib/totp-quickjs/base32decoder.js index 784e2b1..705dcf9 100644 --- a/lib/totp-quickjs/base32decoder.js +++ b/lib/totp-quickjs/base32decoder.js @@ -1,60 +1,62 @@ export function decode(base32) { - for ( - var base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", - bits = "", - hex = "", - i = 0; - i < base32.length; - i++ - ) { - var val = base32chars.indexOf(base32.charAt(i).toUpperCase()); - bits += leftpad(val.toString(2), 5, "0"); - } - for (i = 0; i + 4 <= bits.length; i += 4) { - var chunk = bits.substr(i, 4); - hex += parseInt(chunk, 2).toString(16); - } - return hex; + for ( + var base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", + bits = "", + hex = "", + i = 0; + i < base32.length; + i++ + ) { + var val = base32chars.indexOf(base32.charAt(i).toUpperCase()); + bits += leftpad(val.toString(2), 5, "0"); + } + for (i = 0; i + 4 <= bits.length; i += 4) { + var chunk = bits.substr(i, 4); + hex += parseInt(chunk, 2).toString(16); + } + return hex; } function leftpad(str, len, pad) { - return ( - len + 1 >= str.length && - (str = new Array(len + 1 - str.length).join(pad) + str), - str - ); + return ( + len + 1 >= str.length && + (str = new Array(len + 1 - str.length).join(pad) + str), + str + ); } export function encode(bytes) { - const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let bits = 0; let value = 0; - let output = ''; + let output = ""; for (let i = 0; i < bytes.length; i++) { value = (value << 8) | bytes[i]; bits += 8; while (bits >= 5) { - output += alphabet[(value >>> (bits - 5)) & 0x1F]; + output += alphabet[(value >>> (bits - 5)) & 0x1f]; bits -= 5; } } if (bits > 0) { - output += alphabet[(value << (5 - bits)) & 0x1F]; + output += alphabet[(value << (5 - bits)) & 0x1f]; } const paddingLength = (8 - (output.length % 8)) % 8; - output += '='.repeat(paddingLength); + output += "=".repeat(paddingLength); return output; } export function base64decode(base64) { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let result = []; - let i = 0, j = 0; + let i = 0, + j = 0; let b1, b2, b3, b4; while (i < base64.length) { @@ -77,4 +79,4 @@ export function base64decode(base64) { } return result.slice(0, j); -} \ No newline at end of file +} diff --git a/lib/totp-quickjs/index.js b/lib/totp-quickjs/index.js index f2453b8..d77715f 100644 --- a/lib/totp-quickjs/index.js +++ b/lib/totp-quickjs/index.js @@ -1,60 +1,63 @@ -import { getHOTP } from "./OTPGenerator.js" +import { getHOTP } from "./OTPGenerator.js"; /** * TOTP instance */ export class TOTP { /** - * - * @param {string} secret base32 encoded string + * + * @param {string} secret base32 encoded string * @param {string} issuer issuer of TOTP * @param {string} client client of TOTP * @param {number} [digits=6] number of digits in OTP token * @param {number} [fetchTime=30] period of token in seconds - * @param {number} [timeOffset=0] time offset for token in seconds + * @param {number} [timeOffset=0] time offset for token in seconds * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation) */ - constructor(secret, + constructor( + secret, issuer, client, digits = 6, fetchTime = 30, timeOffset = 0, - hashType = 'SHA-1') { - this.secret = secret - this.issuer = issuer - this.client = client - this.digits = digits - this.fetchTime = fetchTime - this.timeOffset = timeOffset - this.hashType = hashType + hashType = "SHA-1", + ) { + this.secret = secret; + this.issuer = issuer; + this.client = client; + this.digits = digits; + this.fetchTime = fetchTime; + this.timeOffset = timeOffset; + this.hashType = hashType; } - static copy(totp){ + static copy(totp) { return new TOTP( - secret = totp.secret, - issuer = totp.TOTPissuer, - client = totp.client, - digits = totp.digits, - fetchTime = totp.fetchTime, - timeOffset = totp.timeOffset, - hashType = totp.hashType - ) + (secret = totp.secret), + (issuer = totp.TOTPissuer), + (client = totp.client), + (digits = totp.digits), + (fetchTime = totp.fetchTime), + (timeOffset = totp.timeOffset), + (hashType = totp.hashType), + ); } /** - * + * * @param {number} time time for counter (default unix time epoch) * @returns OTP instance */ getOTP(time = Date.now()) { - const unixTime = (time / 1000 + this.timeOffset) / this.fetchTime - const otp = getHOTP(Math.floor(unixTime), this.secret, this.digits) - const expireTime = time + + const unixTime = (time / 1000 + this.timeOffset) / this.fetchTime; + const otp = getHOTP(Math.floor(unixTime), this.secret, this.digits); + const expireTime = + time + (this.fetchTime - - (time / 1000 + this.timeOffset) % - this.fetchTime) * 1000 - const createdTime = time - (((time / 1000 + this.timeOffset) % - this.fetchTime) * 1000) - - return new OTP(otp, createdTime, expireTime) + ((time / 1000 + this.timeOffset) % this.fetchTime)) * + 1000; + const createdTime = + time - ((time / 1000 + this.timeOffset) % this.fetchTime) * 1000; + + return new OTP(otp, createdTime, expireTime); } } @@ -63,14 +66,14 @@ export class TOTP { */ export class OTP { /** - * + * * @param {string} otp OTP string - * @param {number} createdTime time in unix epoch created OTP - * @param {number} expireTime time in unix epoch to expire OTP + * @param {number} createdTime time in unix epoch created OTP + * @param {number} expireTime time in unix epoch to expire OTP */ constructor(otp, createdTime, expireTime) { - this.otp = otp - this.createdTime = createdTime - this.expireTime = expireTime + this.otp = otp; + this.createdTime = createdTime; + this.expireTime = expireTime; } -} \ No newline at end of file +} diff --git a/package.json b/package.json index ab7d926..b46fdb4 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "totpfit", - "version": "1.3.1", - "description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4", - "main": "app.js", - "author": "Lisoveliy", - "license": "MIT", - "devDependencies": { - "@zeppos/device-types": "^3.0.0" - }, - "dependencies": { - "@zeppos/zml": "^0.0.27", - "jssha": "^3.3.1" - } + "name": "totpfit", + "version": "1.3.1", + "description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4", + "main": "app.js", + "author": "Lisoveliy", + "license": "MIT", + "devDependencies": { + "@zeppos/device-types": "^3.0.0", + "prettier": "3.6.2" + }, + "dependencies": { + "@zeppos/zml": "^0.0.27", + "jssha": "^3.3.1" + } } diff --git a/page/index.js b/page/index.js index 2f72254..ec20543 100644 --- a/page/index.js +++ b/page/index.js @@ -2,34 +2,37 @@ import { RenderAddButton } from "./render/totpRenderer"; import { initLoop } from "./render/index/renderer"; import { BasePage } from "@zeppos/zml/base-page"; import { LocalStorage } from "@zos/storage"; +import { setPageBrightTime } from "@zos/display"; const app = getApp(); +const brightTimeMs = 300000; + let waitForFetch = true; Page( BasePage({ onInit() { + setPageBrightTime({ brightTime: brightTimeMs }); this.getTOTPData() .then((x) => { app._options.globalData.TOTPS = JSON.parse(x) ?? []; - + let localStorage = new LocalStorage(); localStorage.setItem( "TOTPs", - JSON.stringify(app._options.globalData.TOTPS) + JSON.stringify(app._options.globalData.TOTPS), ); this.initPage(); }) .catch((x) => { console.log(`Init failed: ${x}`); - try{ + try { let localStorage = new LocalStorage(); app._options.globalData.TOTPS = JSON.parse( - localStorage.getItem("TOTPs", []) + localStorage.getItem("TOTPs", []), ); - } - catch{ + } catch { app._options.globalData.TOTPS = []; } this.initPage(); @@ -56,5 +59,5 @@ Page( method: "totps", }); }, - }) + }), ); diff --git a/page/render/index/renderer.js b/page/render/index/renderer.js index 7301b3b..cbd08b1 100644 --- a/page/render/index/renderer.js +++ b/page/render/index/renderer.js @@ -22,15 +22,16 @@ function renderContainers(buffer) { } const renderData = []; + function renderTOTPs(buffer) { for (let i = 0; i < buffer.length; i++) { let otpData = TOTP.copy(buffer[i]).getOTP(); renderData[i] = { - OTP: RenderOTPValue(i, otpData.otp), + OTP: RenderOTPValue(i, formatOTP(otpData.otp)), expireBar: RenderExpireBar( i, otpData.createdTime, - buffer[i].fetchTime + buffer[i].fetchTime, ), }; setInterval(() => { @@ -38,7 +39,7 @@ function renderTOTPs(buffer) { (Date.now() - otpData.createdTime) / 1000 / buffer[i].fetchTime - - 1 + 1, ); renderData[i].expireBar.setProperty(prop.MORE, { @@ -49,9 +50,15 @@ function renderTOTPs(buffer) { if (otpData.expireTime < Date.now()) { otpData = TOTP.copy(buffer[i]).getOTP(); renderData[i].OTP.setProperty(prop.MORE, { - text: otpData.otp, + text: formatOTP(otpData.otp), }); } }, 50); } } + +function formatOTP(otp) { + if (otp.length === 6) return `${otp.substring(0, 3)} ${otp.substring(3)}`; + + return otp; +} diff --git a/page/render/totpRenderer.js b/page/render/totpRenderer.js index 714d6ed..996622b 100644 --- a/page/render/totpRenderer.js +++ b/page/render/totpRenderer.js @@ -77,7 +77,7 @@ export function RenderOTPValue(position, otpValue) { export function RenderExpireBar(position, createdTime, fetchTime) { const yPos = getYPos(position); const expireDif = Math.abs( - (Date.now() - createdTime) / 1000 / fetchTime - 1 + (Date.now() - createdTime) / 1000 / fetchTime - 1, ); return createWidget(widget.ARC, { x: buttonWidth - 50, diff --git a/page/tip.js b/page/tip.js index 94a42e0..7a20cc8 100644 --- a/page/tip.js +++ b/page/tip.js @@ -27,5 +27,5 @@ Page( text: "To add TOTP record open\n settings on Zepp app", }); }, - }) + }), ); diff --git a/setting/index.js b/setting/index.js index fb0641b..d4b5d72 100644 --- a/setting/index.js +++ b/setting/index.js @@ -1,6 +1,11 @@ import { getTOTPByLink } from "./utils/queryParser.js"; +import { createTOTPCard } from "./ui/card.js"; let _props = null; +let editingIndex = -1; +let tempIssuer = ""; +let tempClient = ""; +let errorMessage = ""; const colors = { bg: "#101010", @@ -9,45 +14,114 @@ const colors = { text: "#fafafa", alert: "#ad3c23", notify: "#555555", - bigText: "#fafafa" + bigText: "#fafafa", }; +function updateStorage(storage) { + _props.settingsStorage.setItem("TOTPs", JSON.stringify(storage)); +} + +function GetTOTPList(storage) { + return storage.map((element, index) => { + return createTOTPCard({ + element, + index, + storage, + isEditing: editingIndex === index, + tempIssuer, + tempClient, + onIssuerChange: (val) => { + tempIssuer = val; + }, + onClientChange: (val) => { + tempClient = val; + }, + onRename: () => { + editingIndex = index; + tempIssuer = element.issuer; + tempClient = element.client; + updateStorage(storage); + }, + onSave: () => { + storage[index].issuer = tempIssuer; + storage[index].client = tempClient; + editingIndex = -1; + updateStorage(storage); + }, + onDelete: () => { + storage.splice(index, 1); + updateStorage(storage); + }, + onMoveUp: () => { + if (index > 0) { + [storage[index], storage[index - 1]] = [ + storage[index - 1], + storage[index], + ]; + updateStorage(storage); + } + }, + onMoveDown: () => { + if (index < storage.length - 1) { + [storage[index], storage[index + 1]] = [ + storage[index + 1], + storage[index], + ]; + updateStorage(storage); + } + }, + }); + }); +} + AppSettingsPage({ build(props) { _props = props; const storage = JSON.parse( - props.settingsStorage.getItem("TOTPs") ?? "[]" + props.settingsStorage.getItem("TOTPs") ?? "[]", ); const totpEntrys = GetTOTPList(storage); - const addTOTPsHint = storage.length < 1 ? - Text({ - paragraph: true, - align: "center", - style: { - paddingTop: "10px", - marginBottom: "10px", - color: colors.text, - fontSize: 16, - verticalAlign: "middle", - }, - }, - "For add a 2FA TOTP record you must have otpauth:// link or otpauth-migration:// link from Google Authenticator Migration QR-Code" - ) : null; + const addTOTPsHint = + storage.length < 1 + ? Text( + { + paragraph: true, + align: "center", + style: { + paddingTop: "10px", + marginBottom: "10px", + color: colors.text, + fontSize: 16, + verticalAlign: "middle", + }, + }, + "For add a 2FA TOTP record you must have otpauth:// link or otpauth-migration:// link from Google Authenticator Migration QR-Code", + ) + : null; + const createButton = TextInput({ placeholder: "otpauth(-migration)://", label: "Add new TOTP record", onChange: (changes) => { - let link = getTOTPByLink(changes); - if (link == null) { - console.log("link is invalid"); - return; + try { + errorMessage = ""; + let link = getTOTPByLink(changes); + if (link == null) { + throw new Error( + "Unsupported link type. Please use an otpauth:// or otpauth-migration:// link.", + ); + } + + if (Array.isArray(link)) { + storage.push(...link); + } else { + storage.push(link); + } + updateStorage(storage); + } catch (e) { + errorMessage = e.message; + updateStorage(storage); } - - if (Array.isArray(link)) - storage.push(...link); - else storage.push(link); - - updateStorage(storage); }, labelStyle: { backgroundColor: colors.notify, @@ -58,18 +132,42 @@ AppSettingsPage({ fontSize: "20px", color: colors.text, borderRadius: "5px", - position: storage.length < 1 ? "absolute" : null, //TODO: Сделать что-то с этим кошмаром - bottom: storage.length < 1 ? "0px" : null, - left: storage.length < 1 ? "0px" : null, - right: storage.length < 1 ? "0px" : null + width: "100%", + height: "45px", }, }); - var body = Section( + const errorText = errorMessage + ? Text( + { + style: { + color: colors.alert, + textAlign: "center", + margin: "5px", + }, + }, + errorMessage, + ) + : null; //TODO: Check for work + + const bottomContainer = View( + { + style: { + padding: "5px 0px", + backgroundColor: colors.bg, + }, + }, + [errorText, createButton].filter(Boolean), + ); + + const pageContainer = View( { style: { backgroundColor: colors.bg, - minHeight: "100vh", + height: "100vh", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", }, }, [ @@ -79,142 +177,58 @@ AppSettingsPage({ textAlign: "center", }, }, - storage.length < 1 ? addTOTPsHint : Text( - { - align: "center", - paragraph: true, - style: { - marginBottom: "10px", - color: colors.bigText, - fontSize: 23, - fontWeight: "500", - verticalAlign: "middle", - }, - }, - "TOTP records:" - ) + [ + storage.length < 1 + ? addTOTPsHint + : Text( + { + align: "center", + paragraph: true, + style: { + marginBottom: "10px", + color: colors.bigText, + fontSize: 23, + fontWeight: "500", + verticalAlign: "middle", + }, + }, + "TOTP records:", + ), + ], ), - ...totpEntrys, - createButton, - View({ - style: { - display: "flex", - justifyContent: "center" - } - }, - Link({ - source: "https://github.com/Lisoveliy/totpfit/blob/main/docs/guides/how-to-add-totps/README.md" - }, - "Instruction | Report issue (GitHub)") - ), - ] - ); - return body; - }, -}); -function GetTOTPList(storage) { - let totpEntrys = []; - let counter = 0; - storage.forEach((element) => { - const elementId = counter; - const textInput = TextInput({ - placeholder: "otpauth(-migration)://", - label: "Change TOTP link", - onChange: (changes) => { - try { - let link = getTOTPByLink(changes); - if (Array.isArray(link)) - return; - - storage[elementId] = link; - updateStorage(storage); - } catch (err) { - console.log(err); - } - }, - labelStyle: { - backgroundColor: colors.notify, - textAlign: "center", - display: "flex", - alignItems: "center", - justifyContent: "center", - margin: "10px", - flexGrow: 1, - fontSize: "20px", - color: colors.text, - borderRadius: "5px", - }, - }); - const textBig = Text( - { - align: "center", - style: { - color: colors.text, - fontSize: "18px", - fontWeight: "500" - }, - paragraph: true, - }, - `${element.issuer}: ${element.client}` - ); - const delButton = Button({ - onClick: () => { - storage = storage.filter( - (x) => storage.indexOf(x) != elementId - ); - updateStorage(storage); - }, - style: { - backgroundColor: colors.alert, - fontSize: "18px", - color: colors.text, - height: "fit-content", - margin: "10px", - }, - label: "Delete", - }); - const text = Text( - { - style: { - color: colors.text, - fontSize: "14px", - }, - align: "center", - }, - `${element.hashType} | ${element.digits} digits | ${element.fetchTime} seconds | ${element.timeOffset} sec offset` - ); - const view = View( - { - style: { - textAlign: "center", - backgroundColor: colors.secondaryBg, - //border: "2px solid white", - borderRadius: "5px", - margin: "10px", - }, - }, - [ - textBig, - text, View( { style: { - display: "grid", - gridTemplateColumns: "1fr 100px", + flexGrow: 1, + overflow: "scroll", }, }, - [textInput, delButton] + [ + ...totpEntrys, + View( + { + style: { + display: "flex", + justifyContent: "center", + marginTop: "20px", + marginBottom: "20px", + }, + }, + Link( + { + source: "https://github.com/Lisoveliy/totpfit/blob/main/docs/guides/how-to-add-totps/README.md", + }, + "Instruction | Report issue (GitHub)", + ), + ), + ], ), - ] + + bottomContainer, + ], ); - totpEntrys.push({ text: text, view: view }); - counter++; - }); - return totpEntrys.map((x) => x.view); -} - -function updateStorage(storage) { - _props.settingsStorage.setItem("TOTPs", JSON.stringify(storage)); -} + return pageContainer; + }, +}); diff --git a/setting/ui/card.js b/setting/ui/card.js new file mode 100644 index 0000000..7c04771 --- /dev/null +++ b/setting/ui/card.js @@ -0,0 +1,140 @@ +export function createTOTPCard({ + element, + index, + storage, + isEditing, + tempIssuer, + tempClient, + onRename, + onSave, + onDelete, + onMoveUp, + onMoveDown, + onIssuerChange, + onClientChange, +}) { + const colors = { + secondaryBg: "#282828", + text: "#fafafa", + alert: "#ad3c23", + notify: "#555555", + }; + + const infoView = View( + { + style: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + }, + }, + isEditing + ? [ + TextInput({ + label: "Issuer", + value: tempIssuer, + onChange: onIssuerChange, + }), + TextInput({ + label: "Client", + value: tempClient, + onChange: onClientChange, + }), + ] + : [ + Text( + { style: { color: colors.text, marginBottom: "2px" } }, + `Issuer: ${element.issuer}`, + ), + Text( + { style: { color: colors.text } }, + `Client: ${element.client}`, + ), + ], + ); + + const buttonsView = View( + { style: { display: "flex", flexDirection: "row" } }, + isEditing + ? [ + Button({ + label: "Save", + style: { + margin: "5px", + backgroundColor: "#28a745", + color: colors.text, + }, + onClick: onSave, + }), + ] + : [ + Button({ + label: "Rename", + style: { + margin: "5px", + backgroundColor: colors.notify, + color: colors.text, + }, + onClick: onRename, + }), + Button({ + label: "Delete", + style: { + margin: "5px", + backgroundColor: colors.alert, + color: colors.text, + }, + onClick: onDelete, + }), + ], + ); + + const reorderView = View( + { style: { display: "flex", flexDirection: "column" } }, + [ + Button({ + label: "↑", + disabled: index === 0, + style: { width: "50px", margin: "2px" }, + onClick: onMoveUp, + }), + Button({ + label: "↓", + disabled: index === storage.length - 1, + style: { width: "50px", margin: "2px" }, + onClick: onMoveDown, + }), + ], + ); + + const mainContent = View({ style: { flexGrow: 1, padding: "5px" } }, [ + infoView, + Text( + { + style: { + color: colors.text, + fontSize: "14px", + marginTop: "5px", + }, + }, + `${element.hashType} | ${element.digits} digits | ${element.fetchTime} seconds | ${element.timeOffset} sec offset`, + ), + buttonsView, + ]); + + return View( + { + style: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + backgroundColor: colors.secondaryBg, + borderRadius: "5px", + margin: "10px", + padding: "5px", + }, + }, + [mainContent, reorderView], + ); +} diff --git a/setting/utils/queryParser.js b/setting/utils/queryParser.js index ae94456..95486fe 100644 --- a/setting/utils/queryParser.js +++ b/setting/utils/queryParser.js @@ -2,113 +2,168 @@ import { decodeProto, TYPES } from "../../lib/protobuf-decoder/protobufDecoder"; import { TOTP } from "../../lib/totp-quickjs"; import { base64decode, encode } from "../../lib/totp-quickjs/base32decoder"; -const otpauthScheme = "otpauth:/"; -const googleMigrationScheme = "otpauth-migration:/"; +const otpauthScheme = "otpauth://"; +const googleMigrationScheme = "otpauth-migration://"; -export function getTOTPByLink(link) { - if (link.includes(otpauthScheme)) - return getByOtpauthScheme(link) - if (link.includes(googleMigrationScheme)) - return getByGoogleMigrationScheme(link) +function _parseSingleMigrationEntry(part) { + const totpProto = decodeProto(part.value); + const otpData = {}; - return null; -} + const protoPartHandlers = { + 1: (p) => { + otpData.secret = encode(p.value); + }, + 2: (p) => { + otpData.name = bytesToString(p.value); + }, + 3: (p) => { + otpData.issuer = bytesToString(p.value); + }, + 4: (p) => { + otpData.algorithm = p.value; + }, + 5: (p) => { + otpData.digits = p.value; + }, + 6: (p) => { + otpData.type = p.value; + }, + }; -function getHashType(algorithm) { - if (algorithm == "SHA1") return "SHA-1"; - if (algorithm == "SHA256") return "SHA-256"; - if (algorithm == "SHA512") return "SHA-512"; - else return "SHA-1"; -} + totpProto.parts.forEach((p) => { + const handler = protoPartHandlers[p.index]; + if (handler) { + handler(p); + } + }); -function getByOtpauthScheme(link) { - try { - let args = link.split("/", otpauthScheme.length); - let type = args[2]; //Returns 'hotp' or 'totp' - let issuer = args[3].split(":")[0]?.split("?")[0]; //Returns issuer - let client = - args[3].split(":")[1]?.split("?")[0] ?? - args[3].split(":")[0]?.split("?")[0]; //Returns client - let secret = args[3].split("secret=")[1]?.split("&")[0]; //Returns secret - let period = args[3].split("period=")[1]?.split("&")[0]; //Returns period - let digits = args[3].split("digit=")[1]?.split("&")[0]; //Returns digits - let algorithm = args[3].split("algorithm=")[1]?.split("&")[0]; //Returns algorithm - let offset = args[3].split("offset=")[1]?.split("&")[0] ?? 0; //Returns offset + if (otpData.type !== "2") { + return null; + } - if (type.toLowerCase() != "totp") - throw new Error("Type is not valid, requires 'TOTP'"); + const digitsMap = { 1: 6, 2: 8 }; + const algoMap = { 1: "SHA-1", 2: "SHA-256", 3: "SHA-512" }; - if (secret === undefined) throw new Error("Secret not defined"); + const finalDigits = digitsMap[otpData.digits] || 6; + const finalAlgo = algoMap[otpData.algorithm] || "SHA-1"; + const finalIssuer = otpData.issuer || otpData.name; + const finalName = otpData.name; - if (issuer == client) { - issuer = args[3].split("issuer=")[1]?.split("&")[0]; - } + if (!otpData.secret || !finalName) { + throw new Error("Skipping record with missing secret or name."); + } - issuer = decodeURIComponent(issuer); - client = decodeURIComponent(client); - - return new TOTP( - secret, - issuer, - client, - Number(digits), - Number(period), - Number(offset), - getHashType(algorithm) - ); - } catch (err) { - console.log(err) - return null; - } + return new TOTP( + otpData.secret, + finalIssuer, + finalName, + finalDigits, + 30, + 0, + finalAlgo, + ); } function getByGoogleMigrationScheme(link) { + try { + const data = link.split("data=")[1]; + if (!data) return null; - let data = link.split("data=")[1]; //Returns base64 encoded data - data = decodeURIComponent(data); - let decode = base64decode(data); - let proto = decodeProto(decode); + const decodedData = decodeURIComponent(data); + const buffer = base64decode(decodedData); + const proto = decodeProto(buffer); - let protoTotps = []; + const totps = []; + const otpParameters = proto.parts.filter( + (p) => p.index === 1 && p.type === TYPES.LENDELIM, + ); - proto.parts.forEach(part => { - if (part.type == TYPES.LENDELIM) { - protoTotps.push(decodeProto(part.value)); - } - }); + otpParameters.forEach((part) => { + const totp = _parseSingleMigrationEntry(part); + if (totp) { + totps.push(totp); + } + }); - let totps = []; - protoTotps.forEach(x => { - let type = x.parts.filter(x => x.index == 6)[0]; //find type of OTP - if (type.value !== '2') { - console.log("ERR: it's a not TOTP record") - return; - } - let secret = x.parts.filter(x => x.index == 1)[0].value; - secret = encode(secret); + return totps.length > 0 ? totps : null; + } catch (err) { + console.log("Failed to parse Google Migration scheme:", err); + throw new Error( + "Invalid otpauth-migration:// link. Failed to parse migration data.", + ); + } +} - let name = bytesToString(x.parts.filter(x => x.index == 2)[0].value); - let issuer = bytesToString(x.parts.filter(x => x.index == 3)[0].value); +export function getTOTPByLink(link) { + if (link.startsWith(googleMigrationScheme)) { + return getByGoogleMigrationScheme(link); + } + if (link.startsWith(otpauthScheme)) { + return getByOtpauthScheme(link); + } - totps.push(new TOTP( - secret, - issuer, - name, - 6, - 30, - 0, - "SHA-1" - )); - }); + return null; +} - return totps; +function getHashType(algorithm) { + if (algorithm == "SHA1") return "SHA-1"; + if (algorithm == "SHA256") return "SHA-256"; + if (algorithm == "SHA512") return "SHA-512"; + else return "SHA-1"; +} +function getByOtpauthScheme(link) { + try { + let args = link.split("?"); + let path = args[0]; + let params = args[1]; + + let pathParts = path.split("/"); + let type = pathParts[2]; //hotp or totp + let label = decodeURIComponent(pathParts[3]); + + let issuerFromLabel = label.includes(":") ? label.split(":")[0] : null; + let client = label.includes(":") ? label.split(":")[1].trim() : label; + + let secret = params.match(/secret=([^&]*)/)?.[1]; + let issuerFromParams = params.match(/issuer=([^&]*)/)?.[1]; + + let issuer = issuerFromParams + ? decodeURIComponent(issuerFromParams) + : issuerFromLabel; + if (!issuer) issuer = client; + + let period = params.match(/period=([^&]*)/)?.[1]; + let digits = params.match(/digits=([^&]*)/)?.[1]; + let algorithm = params.match(/algorithm=([^&]*)/)?.[1]; + let offset = params.match(/offset=([^&]*)/)?.[1] ?? 0; + + if (type.toLowerCase() != "totp") + throw new Error("Type is not valid, requires 'TOTP'"); + + if (!secret) throw new Error("Secret not defined"); + + return new TOTP( + secret, + issuer, + client, + Number(digits) || 6, + Number(period) || 30, + Number(offset), + getHashType(algorithm), + ); + } catch (err) { + console.log("Failed to parse otpauth scheme:", err); + throw new Error( + `Invalid otpauth:// link. Please check the link and try again. ERR: ${err}`, + ); + } } function bytesToString(bytes) { - let str = ''; - for (let i = 0; i < bytes.length; i++) { - str += String.fromCharCode(bytes[i]); - } - return str; -} \ No newline at end of file + let str = ""; + for (let i = 0; i < bytes.length; i++) { + str += String.fromCharCode(bytes[i]); + } + return str; +}