diff --git a/README.md b/README.md index 25f7017..e958a1d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # TOTPFIT ### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 -Features: +![alt text](docs/assets/image2.png) + +### Features: - Supports of otpauth links with parameters "issuer", "algorithm", "digits", "period" -- Addition/Edition/Deletion of TOTPs from mobile settings app \ No newline at end of file +- Addition/Edition/Deletion of TOTPs from mobile settings app + +### Google Migration Support: +- Support of google migration links formated: ```otpauth-migration://offline?data=...``` (BETA) + +### Screenshots: + +![alt text](docs/assets/image2.png) + +![alt text](docs/assets/image.png) \ No newline at end of file diff --git a/app.json b/app.json index 5085576..12119f3 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "appType": "app", "version": { "code": 1, - "name": "1.1.1" + "name": "1.1.3" }, "icon": "icon.png", "vender": "zepp", diff --git a/docs/assets/image.png b/docs/assets/image.png new file mode 100644 index 0000000..69a46ed Binary files /dev/null and b/docs/assets/image.png differ diff --git a/docs/assets/image2.png b/docs/assets/image2.png new file mode 100644 index 0000000..0e82be5 Binary files /dev/null and b/docs/assets/image2.png differ diff --git a/lib/protobuf-decoder/hexUtils.js b/lib/protobuf-decoder/hexUtils.js new file mode 100644 index 0000000..6f6ff52 --- /dev/null +++ b/lib/protobuf-decoder/hexUtils.js @@ -0,0 +1,36 @@ +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"); + } +} + +export function isHex(string) { + let result = true; + for (const char of string) { + if (!((char >= "a" && char <= "f") || (char >= "0" && char <= "9"))) { + result = false; + } + } + 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; + } + } + return output; +} + +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 new file mode 100644 index 0000000..f99a573 --- /dev/null +++ b/lib/protobuf-decoder/protobufDecoder.js @@ -0,0 +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; + } + } + } + + 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 +}; + +export function decodeProto(buffer) { + const reader = new BufferReader(buffer); + const parts = []; + reader.trySkipGrpcHeader(); + + 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; + + 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 + }); + } + } catch (err) { + reader.resetToCheckpoint(); + console.log(err); + } + + 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"; + } +} diff --git a/lib/protobuf-decoder/varintUtils.js b/lib/protobuf-decoder/varintUtils.js new file mode 100644 index 0000000..6b9b6b4 --- /dev/null +++ b/lib/protobuf-decoder/varintUtils.js @@ -0,0 +1,23 @@ +export function decodeVarint(buffer, offset) { + let res = this.BigInt(0); + let shift = 0; + let byte = 0; + + do { + if (offset >= buffer.length) { + throw new RangeError("Index out of bound decoding varint"); + } + + byte = buffer[offset++]; + + const multiplier = this.BigInt(2) ** this.BigInt(shift); + const thisByteValue = this.BigInt(byte & 0x7f) * multiplier; + shift += 7; + res = res + thisByteValue; + } while (byte >= 0x80); + + return { + value: res, + length: shift / 7 + }; +} diff --git a/lib/totp-quickjs/base32decoder.js b/lib/totp-quickjs/base32decoder.js index b74d251..784e2b1 100644 --- a/lib/totp-quickjs/base32decoder.js +++ b/lib/totp-quickjs/base32decoder.js @@ -23,4 +23,58 @@ function leftpad(str, len, pad) { (str = new Array(len + 1 - str.length).join(pad) + str), str ); +} + +export function encode(bytes) { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let bits = 0; + let value = 0; + 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]; + bits -= 5; + } + } + + if (bits > 0) { + output += alphabet[(value << (5 - bits)) & 0x1F]; + } + + const paddingLength = (8 - (output.length % 8)) % 8; + output += '='.repeat(paddingLength); + + return output; +} + +export function base64decode(base64) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let result = []; + let i = 0, j = 0; + let b1, b2, b3, b4; + + while (i < base64.length) { + b1 = chars.indexOf(base64.charAt(i++)); + b2 = chars.indexOf(base64.charAt(i++)); + b3 = chars.indexOf(base64.charAt(i++)); + b4 = chars.indexOf(base64.charAt(i++)); + + if (b1 === -1 || b2 === -1) break; + + result[j++] = (b1 << 2) | (b2 >> 4); + + if (b3 !== -1) { + result[j++] = ((b2 & 15) << 4) | (b3 >> 2); + } + + if (b4 !== -1) { + result[j++] = ((b3 & 3) << 6) | b4; + } + } + + return result.slice(0, j); } \ No newline at end of file diff --git a/package.json b/package.json index 864b752..d9a0d69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "totpfit", - "version": "1.1.1", + "version": "1.1.3", "description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4", "main": "app.js", "author": "Lisoveliy", diff --git a/page/index.js b/page/index.js index 776ff07..c60a457 100644 --- a/page/index.js +++ b/page/index.js @@ -6,7 +6,6 @@ import { LocalStorage } from "@zos/storage"; const app = getApp(); let waitForFetch = true; -let localStorage = new LocalStorage(); Page( BasePage({ @@ -14,6 +13,8 @@ Page( this.getTOTPData() .then((x) => { app._options.globalData.TOTPS = JSON.parse(x) ?? []; + + let localStorage = new LocalStorage(); localStorage.setItem( "TOTPs", JSON.stringify(app._options.globalData.TOTPS) @@ -22,6 +23,7 @@ Page( }) .catch((x) => { console.log(`Init failed: ${x}`); + let localStorage = new LocalStorage(); app._options.globalData.TOTPS = JSON.parse( localStorage.getItem("TOTPs", null) ?? [] ); diff --git a/setting/index.js b/setting/index.js index d9276b2..03e27a1 100644 --- a/setting/index.js +++ b/setting/index.js @@ -5,7 +5,6 @@ let _props = null; AppSettingsPage({ build(props) { _props = props; - const storage = JSON.parse( props.settingsStorage.getItem("TOTPs") ?? "[]" ); @@ -14,12 +13,16 @@ AppSettingsPage({ placeholder: "otpauth://", label: "Add new OTP Link", onChange: (changes) => { - var link = getTOTPByLink(changes); + let link = getTOTPByLink(changes); if (link == null) { console.log("link is invalid"); return; } - storage.push(link); + + if(Array.isArray(link)) + storage.push(...link); + else storage.push(link); + updateStorage(storage); }, labelStyle: { @@ -81,7 +84,11 @@ function GetTOTPList(storage) { label: "Change OTP link", onChange: (changes) => { try { - storage[elementId] = getTOTPByLink(changes); + let link = getTOTPByLink(changes); + if(Array.isArray(link)) + return; + + storage[elementId] = link; updateStorage(storage); } catch (err) { console.log(err); diff --git a/setting/utils/queryParser.js b/setting/utils/queryParser.js index c999285..1a27e22 100644 --- a/setting/utils/queryParser.js +++ b/setting/utils/queryParser.js @@ -1,10 +1,29 @@ +import { decodeProto, TYPES } from "../../lib/protobuf-decoder/protobufDecoder"; import { TOTP } from "../../lib/totp-quickjs"; +import { base64decode, encode } from "../../lib/totp-quickjs/base32decoder"; -const otpScheme = "otpauth:/"; +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) + + return null; +} + +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("/", otpScheme.length); + 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 = @@ -20,8 +39,12 @@ export function getTOTPByLink(link) { if (secret === undefined) throw new Error("Secret not defined"); - issuer = issuer.replace("%20", " "); - client = client.replace("%20", " "); + if(issuer == client){ + issuer = args[3].split("issuer=")[1]?.split("&")[0]; + } + + issuer = decodeURIComponent(issuer); + client = decodeURIComponent(client); return new TOTP( secret, @@ -33,13 +56,58 @@ export function getTOTPByLink(link) { getHashType(algorithm) ); } catch (err) { + console.log(err) return null; } } -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 getByGoogleMigrationScheme(link){ + + let data = link.split("data=")[1]; //Returns base64 encoded data + data = decodeURIComponent(data); + let decode = base64decode(data); + let proto = decodeProto(decode); + + let protoTotps = []; + + proto.parts.forEach(part => { + if(part.type == TYPES.LENDELIM){ + protoTotps.push(decodeProto(part.value)); + } + }); + + 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); + + let name = bytesToString(x.parts.filter(x => x.index == 2)[0].value); + let issuer = bytesToString(x.parts.filter(x => x.index == 3)[0].value); + + totps.push(new TOTP( + secret, + issuer, + name, + 6, + 30, + 0, + "SHA-1" + )); + }); + + return totps; + } + +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