Added google migration support (BETA) v.1.2.0 #8

Merged
Lisoveliy merged 7 commits from dev into main 2025-03-17 13:32:50 +01:00
12 changed files with 351 additions and 18 deletions

View File

@ -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
- 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)

View File

@ -6,7 +6,7 @@
"appType": "app",
"version": {
"code": 1,
"name": "1.1.1"
"name": "1.1.3"
},
"icon": "icon.png",
"vender": "zepp",

BIN
docs/assets/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
docs/assets/image2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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(' ');

View File

@ -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";
}
}

View File

@ -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
};
}

View File

@ -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);
}

View File

@ -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",

View File

@ -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) ?? []
);

View File

@ -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);

View File

@ -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;
}