14 Commits

Author SHA1 Message Date
259c2cdd2e feat: added sort for totps, added possibility to change issuer and client name 2025-08-08 15:34:34 +03:00
d45be3ee68 fix: provided hashType into getOTP 2025-08-07 16:56:07 +03:00
56d5edc37b fix, chore, feat: refactor of settings app, fix of Google Auth migration parse, added rename button(testing), added sort feature, added timeout to screen turning off, formated codes with space between 6-digits codes
Co-authored-by: DemiarUA <demiar97@gmail.com>
https://github.com/Lisoveliy/totpfit/pull/1
2025-08-07 16:53:54 +03:00
ea75f03fbe Merge branch 'main' of https://git.lisoveliy.su/lisoveliy/totpfit 2025-07-26 14:30:03 +03:00
7c33fcffc8 feat: updated icon 2025-07-26 14:30:01 +03:00
3e037da6da chore: typos fix 2025-07-24 03:43:44 +02:00
cfa6bf0c23 chore: typos fix 2025-07-24 03:43:23 +02:00
54526f0724 Обновить docs/guides/how-to-add-totps/README.md 2025-07-24 03:42:03 +02:00
1d61879baf Обновить docs/guides/how-to-add-totps/README.md 2025-07-24 03:39:29 +02:00
6a2ab542b9 feat: v1.3.0 final cut 2025-07-24 04:35:43 +03:00
3aaedec7f6 chore: updated docs 2025-07-24 04:24:53 +03:00
70841349b7 chore: updated docs 2025-07-24 04:24:00 +03:00
7eafac6916 Merge pull request 'v1.3 Fix of Google Authenticator migration support' (#9) from dev into main
Reviewed-on: http://git.lisoveliy.su/Lisoveliy/TOTPFit/pulls/9
2025-07-24 03:18:36 +02:00
08b11a3d09 chore: updated readme 2025-07-24 04:16:21 +03:00
24 changed files with 923 additions and 616 deletions

View File

@@ -1 +1 @@
tabWidth: 4
tabWidth: 4

View File

@@ -1,15 +1,17 @@
# TOTPFIT
### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 with Google Authenticator migration Support
# 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 "issuer", "algorithm", "digits", "period"
- Supports of `otpauth://` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset"
- Addition/Edition/Deletion of TOTPs from mobile app
- Support of google migration links formated: ```otpauth-migration://offline?data=...```
- 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)
#### This repo has mirror for issues on [GitHub](https://github.com/Lisoveliy/totpfit)

View File

@@ -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(){ }
}
)
)
BaseSideService({
onInit() {},
onRequest(request, response) {
if (request.method === "totps") {
response(null, settings.settingsStorage.getItem("TOTPs"));
}
},
onSettingsChange() {},
}),
);

2
app.js
View File

@@ -7,5 +7,5 @@ App(
},
onCreate() {},
onDestroy() {},
})
}),
);

View File

@@ -1,56 +1,50 @@
{
"configVersion": "v3",
"app": {
"appId": 25087,
"appName": "totpfit",
"appType": "app",
"version": {
"code": 1,
"name": "1.3"
"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"
}
},
"i18n": {
"en-US": {
"appName": "TOTPFit"
}
},
"defaultLanguage": "en-US"
}

View File

@@ -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)
@@ -14,24 +14,24 @@ Then press OK, record will appear on page
![Added record](image-4.png)
You can edit your otpauth:// records using button "Change TOTP link". Your previous record will be replaced with a new otpauth:// link entered on text field, and previous link will not be shown on field.
You can edit your otpauth:// records using button "Change TOTP link". Your previous record will be replaced with a new otpauth:// link from text field, and previous link will not be shown on field.
### 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 and scan (decode) it to a URI. Use any app providing scan from image, ex: "Search screen" function (Google Lens) on Google Assistant.
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.
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)
Then press OK, all selected records on Google Authenticator will appear on page
Then press OK, all selected records from Google Authenticator will appear on page
![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 entered on text field, and previous link will not be shown on field.
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}
export function getTOTP(
secret,
digits = 6,
time = Date.now(),
fetchTime = 30,
timeOffset = 0,
hashType = "SHA-1",
) {
const unixTime = Math.round((time / 1000n + timeOffset) / fetchTime);
return getHOTP(BigInt(unixTime), secret, digits, hashType);
}

View File

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

View File

@@ -1,60 +1,68 @@
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,
this.hashType,
);
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 +71,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;
}
}
}

BIN
new_icon_for_appstore.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,15 +1,16 @@
{
"name": "totpfit",
"version": "1.3",
"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"
}
}

View File

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

View File

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

View File

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

View File

@@ -27,5 +27,5 @@ Page(
text: "To add TOTP record open\n settings on Zepp app",
});
},
})
}),
);

40
setting/consts.js Normal file
View File

@@ -0,0 +1,40 @@
export const colors = {
bg: "#101010",
linkBg: "#ffffffc0",
secondaryBg: "#282828",
text: "#fafafa",
alert: "#ad3c23",
notify: "#555555",
bigText: "#fafafa",
};
export const content = {
addTotpsHint:
"For add a 2FA TOTP record you must have otpauth:// link or otpauth-migration:// link from Google Authenticator Migration QR-Code",
totpRecordsHint: "TOTP records:",
createButton: {
placeHolder: "otpauth(-migration)://",
label: "Add new TOTP record",
},
instructionLink: {
label: "Instruction | Report issue (GitHub)",
source: "https://github.com/Lisoveliy/totpfit/blob/main/docs/guides/how-to-add-totps/README.md",
},
changeButton: {
label: "Change TOTP link",
placeHolder: "otpauth(-migration)://",
},
deleteButton: {
label: "Delete",
},
totpLabelText: {
eval(issuer, client) {
return `${issuer}: ${client}`;
},
},
totpDescText: {
eval(hashType, digits, fetchTime, timeOffset) {
return `${hashType} | ${digits} digits | ${fetchTime} seconds | ${timeOffset} sec offset`;
},
},
};

View File

@@ -1,53 +1,118 @@
import { getTOTPByLink } from "./utils/queryParser.js";
import { createTOTPCard } from "./ui/card.js";
import { colors, content } from "./consts.js";
let _props = null;
let editingIndex = -1;
let tempIssuer = "";
let tempClient = "";
let errorMessage = "";
const colors = {
bg: "#101010",
linkBg: "#ffffffc0",
secondaryBg: "#282828",
text: "#fafafa",
alert: "#ad3c23",
notify: "#555555",
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",
},
},
content.addTotpsHint,
)
: null;
const createButton = TextInput({
placeholder: "otpauth(-migration)://",
label: "Add new TOTP record",
placeholder: content.createButton.placeHolder,
label: content.createButton.label,
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 +123,58 @@ 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
height: "45px",
},
});
var body = Section(
const errorText = errorMessage
? Text(
{
style: {
color: colors.alert,
textAlign: "center",
},
},
errorMessage,
)
: null; //TODO: Check for work
const bottomContainer = View(
{
style: {
backgroundColor: colors.bg,
minHeight: "100vh",
},
},
[
View(
{
style: {
display: "flex",
justifyContent: "center",
marginTop: "20px",
marginBottom: "20px",
},
},
Link(
{
source: content.instructionLink.source,
},
content.instructionLink.label,
),
),
errorText,
createButton,
].filter(Boolean),
);
const pageContainer = View(
{
style: {
backgroundColor: colors.bg,
height: "100vh",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
},
},
[
@@ -79,142 +184,43 @@ 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: {
marginTop: "10px",
marginBottom: "10px",
color: colors.bigText,
fontSize: 23,
fontWeight: "500",
verticalAlign: "middle",
},
},
content.totpRecordsHint,
),
],
),
...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",
height: "100%",
overflowX: "hidden",
overflowY: "auto",
backgroundColor: colors.bg,
},
},
[textInput, delButton]
[...totpEntrys],
),
]
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;
},
});

186
setting/ui/card.js Normal file
View File

@@ -0,0 +1,186 @@
import { colors, content } from "../consts";
export function createTOTPCard({
element,
index,
storage,
isEditing,
tempIssuer,
tempClient,
onRename,
onSave,
onDelete,
onMoveUp,
onMoveDown,
onIssuerChange,
onClientChange,
}) {
const infoView = View(
{
style: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
},
},
isEditing
? [
TextInput({
label: "Rename Issuer",
value: tempIssuer,
onChange: onIssuerChange,
labelStyle: {
backgroundColor: colors.notify,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "10px",
fontSize: "20px",
color: colors.text,
borderRadius: "5px",
height: "40px",
width: "200px"
},
subStyle: {
display: "none",
},
}),
TextInput({
label: "Rename client",
value: tempClient,
onChange: onClientChange,
labelStyle: {
backgroundColor: colors.notify,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "10px",
fontSize: "20px",
color: colors.text,
borderRadius: "5px",
height: "40px",
width: "200px"
},
subStyle: {
display: "none",
},
}),
]
: [
Text(
{
style: {
color: colors.text,
marginBottom: "2px",
fontWeight: "600",
},
},
`Issuer: ${element.issuer}`,
),
Text(
{ style: { color: colors.text, fontWeight: "600" } },
`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",
color: colors.text,
backgroundColor: colors.notify,
},
onClick: onMoveUp,
}),
Button({
label: "⬇",
disabled: index === storage.length - 1,
style: {
width: "50px",
margin: "2px",
color: colors.text,
backgroundColor: colors.notify,
},
onClick: onMoveDown,
}),
],
);
const mainContent = View({ style: { flexGrow: 1, padding: "5px" } }, [
infoView,
Text(
{
style: {
color: colors.text,
fontSize: "14px",
marginTop: "5px",
},
},
content.totpDescText.eval(
element.hashType,
element.digits,
element.fetchTime,
element.timeOffset,
),
),
buttonsView,
]);
return View(
{
style: {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: colors.secondaryBg,
borderRadius: "5px",
margin: "10px",
padding: "5px",
},
},
[mainContent, reorderView],
);
}

View File

@@ -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;
}
let str = "";
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}
return str;
}