9 Commits

21 changed files with 560 additions and 523 deletions

View File

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

View File

@@ -1,15 +1,17 @@
# TOTPFIT # TOTPFIT
### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 with Google Authenticator migration Support
### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 with Google Authenticator migration support
![alt text](docs/assets/image2.png) ![alt text](docs/assets/image2.png)
### Features: ### 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 - 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: ### Guides:
[How to add 2FA TOTP records (keys) on app](/docs/guides/how-to-add-totps/README.md) [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( AppSideService(
BaseSideService( BaseSideService({
{ onInit() {},
onInit(){ onRequest(request, response) {
if (request.method === "totps") {
}, response(null, settings.settingsStorage.getItem("TOTPs"));
onRequest(request, response){ }
if(request.method === 'totps'){ },
response(null, settings.settingsStorage.getItem('TOTPs')) onSettingsChange() {},
} }),
}, );
onSettingsChange(){ }
}
)
)

2
app.js
View File

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

View File

@@ -1,56 +1,50 @@
{ {
"configVersion": "v3", "configVersion": "v3",
"app": { "app": {
"appId": 25087, "appId": 25087,
"appName": "totpfit", "appName": "totpfit",
"appType": "app", "appType": "app",
"version": { "version": {
"code": 1, "code": 1,
"name": "1.3.0" "name": "1.3.0"
},
"icon": "icon.png",
"vender": "zepp",
"description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4"
}, },
"icon": "icon.png", "permissions": ["data:os.device.info", "device:os.local_storage"],
"vender": "zepp", "runtime": {
"description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4" "apiVersion": {
}, "compatible": "3.0.0",
"permissions": [ "target": "3.0.0",
"data:os.device.info", "minVersion": "3.0"
"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"
} }
}, },
"platforms": [ "targets": {
{ "default": {
"st": "s", "module": {
"dw": 390 "page": {
"pages": ["page/index", "page/tip"]
},
"app-side": {
"path": "app-side/index"
},
"setting": {
"path": "setting/index"
}
},
"platforms": [
{
"st": "s",
"dw": 390
}
]
} }
] },
} "i18n": {
}, "en-US": {
"i18n": { "appName": "TOTPFit"
"en-US": { }
"appName": "TOTPFit" },
} "defaultLanguage": "en-US"
}, }
"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) ![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) ![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) ![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) ### 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: For example, this QR-Code will represent next URI string:
![Google lens scan from Google Authenticator](image-5.png) ![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) ![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) ![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.

View File

@@ -1,9 +1,9 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "commonjs", "module": "commonjs",
"target": "es6", "target": "es6",
"checkJs": true "checkJs": true
}, },
"exclude": ["node_modules", "**/node_modules/*"], "exclude": ["node_modules", "**/node_modules/*"],
"files": ["node_modules/@zeppos/device-types/dist/index.d.ts"] "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) { export function parseInput(input) {
const normalizedInput = input.replace(/\s/g, ""); const normalizedInput = input.replace(/\s/g, "");
const normalizedHexInput = normalizedInput.replace(/0x/g, "").toLowerCase(); const normalizedHexInput = normalizedInput.replace(/0x/g, "").toLowerCase();
if (isHex(normalizedHexInput)) { if (isHex(normalizedHexInput)) {
return Buffer.from(normalizedHexInput, "hex"); return Buffer.from(normalizedHexInput, "hex");
} else { } else {
return Buffer.from(normalizedInput, "base64"); return Buffer.from(normalizedInput, "base64");
} }
} }
export function isHex(string) { export function isHex(string) {
let result = true; let result = true;
for (const char of string) { for (const char of string) {
if (!((char >= "a" && char <= "f") || (char >= "0" && char <= "9"))) { if (!((char >= "a" && char <= "f") || (char >= "0" && char <= "9"))) {
result = false; result = false;
}
} }
} return result;
return result;
} }
export function bufferLeToBeHex(buffer) { export function bufferLeToBeHex(buffer) {
let output = ""; let output = "";
for (const v of buffer) { for (const v of buffer) {
const hex = v.toString(16); const hex = v.toString(16);
if (hex.length === 1) { if (hex.length === 1) {
output = "0" + hex + output; output = "0" + hex + output;
} else { } else {
output = hex + output; 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"; import { decodeVarint } from "./varintUtils";
export class BufferReader { export class BufferReader {
constructor(buffer) { constructor(buffer) {
this.buffer = buffer; this.buffer = buffer;
this.offset = 0; 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() { readVarInt() {
return this.buffer.length - this.offset; const result = decodeVarint(this.buffer, this.offset);
} this.offset += result.length;
checkByte(length) { return result.value;
const bytesAvailable = this.leftBytes();
if (length > bytesAvailable) {
throw new Error(
"Not enough bytes left. Requested: " +
length +
" left: " +
bytesAvailable
);
} }
}
checkpoint() { readBuffer(length) {
this.savedOffset = this.offset; this.checkByte(length);
} const result = this.buffer.slice(this.offset, this.offset + length);
this.offset += length;
resetToCheckpoint() { return result;
this.offset = this.savedOffset; }
}
// 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 = { export const TYPES = {
VARINT: 0, VARINT: 0,
FIXED64: 1, FIXED64: 1,
LENDELIM: 2, LENDELIM: 2,
FIXED32: 5 FIXED32: 5,
}; };
export function decodeProto(buffer) { export function decodeProto(buffer) {
const reader = new BufferReader(buffer); const reader = new BufferReader(buffer);
const parts = []; const parts = [];
reader.trySkipGrpcHeader(); reader.trySkipGrpcHeader();
try { try {
while (reader.leftBytes() > 0) { while (reader.leftBytes() > 0) {
reader.checkpoint(); reader.checkpoint();
const byteRange = [reader.offset]; const byteRange = [reader.offset];
const indexType = parseInt(reader.readVarInt().toString()); const indexType = parseInt(reader.readVarInt().toString());
const type = indexType & 0b111; const type = indexType & 0b111;
const index = indexType >> 3; const index = indexType >> 3;
let value; let value;
if (type === TYPES.VARINT) { if (type === TYPES.VARINT) {
value = reader.readVarInt().toString(); value = reader.readVarInt().toString();
} else if (type === TYPES.LENDELIM) { } else if (type === TYPES.LENDELIM) {
const length = parseInt(reader.readVarInt().toString()); const length = parseInt(reader.readVarInt().toString());
value = reader.readBuffer(length); value = reader.readBuffer(length);
} else if (type === TYPES.FIXED32) { } else if (type === TYPES.FIXED32) {
value = reader.readBuffer(4); value = reader.readBuffer(4);
} else if (type === TYPES.FIXED64) { } else if (type === TYPES.FIXED64) {
value = reader.readBuffer(8); value = reader.readBuffer(8);
} else { } else {
throw new Error("Unknown type: " + type); throw new Error("Unknown type: " + type);
} }
byteRange.push(reader.offset); byteRange.push(reader.offset);
parts.push({ parts.push({
byteRange, byteRange,
index, index,
type, type,
value value,
}); });
}
} catch (err) {
reader.resetToCheckpoint();
console.log(`ERR: ${err}`);
} }
} catch (err) {
reader.resetToCheckpoint();
console.log(err);
}
return { return {
parts, parts,
leftOver: reader.readBuffer(reader.leftBytes()) leftOver: reader.readBuffer(reader.leftBytes()),
}; };
} }
export function typeToString(type, subType) { export function typeToString(type, subType) {
switch (type) { switch (type) {
case TYPES.VARINT: case TYPES.VARINT:
return "varint"; return "varint";
case TYPES.LENDELIM: case TYPES.LENDELIM:
return subType || "len_delim"; return subType || "len_delim";
case TYPES.FIXED32: case TYPES.FIXED32:
return "fixed32"; return "fixed32";
case TYPES.FIXED64: case TYPES.FIXED64:
return "fixed64"; return "fixed64";
default: default:
return "unknown"; return "unknown";
} }
} }

View File

@@ -1,23 +1,23 @@
export function decodeVarint(buffer, offset) { export function decodeVarint(buffer, offset) {
let res = 0; let res = 0;
let shift = 0; let shift = 0;
let byte = 0; let byte = 0;
do { do {
if (offset >= buffer.length) { if (offset >= buffer.length) {
throw new RangeError("Index out of bound decoding varint"); throw new RangeError("Index out of bound decoding varint");
} }
byte = buffer[offset++]; byte = buffer[offset++];
const multiplier = 2 ** shift; const multiplier = 2 ** shift;
const thisByteValue = (byte & 0x7f) * multiplier; const thisByteValue = (byte & 0x7f) * multiplier;
shift += 7; shift += 7;
res = res + thisByteValue; res = res + thisByteValue;
} while (byte >= 0x80); } while (byte >= 0x80);
return { return {
value: res, value: res,
length: shift / 7 length: shift / 7,
}; };
} }

View File

@@ -1,53 +1,63 @@
import { decode } from "./base32decoder.js"; import { decode } from "./base32decoder.js";
import jsSHA from "jssha"; import jsSHA from "jssha";
"use bigint" ("use bigint");
/** /**
* get HOTP based on counter * get HOTP based on counter
* @param {BigInt} counter BigInt counter of HOTP * @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 {number} [digits=6] number of digits in OTP token
* @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation) * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation)
* @returns HOTP string * @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 //Stage 1: Prepare data
const rawDataCounter = new DataView(new ArrayBuffer(8)) const rawDataCounter = new DataView(new ArrayBuffer(8));
rawDataCounter.setUint32(4, counter) rawDataCounter.setUint32(4, counter);
const bCounter = new Uint8Array(rawDataCounter.buffer) const bCounter = new Uint8Array(rawDataCounter.buffer);
const bSecret = new Uint8Array(decode(secret).match(/.{1,2}/g).map(chunk => parseInt(chunk, 16))); //confirmed const bSecret = new Uint8Array(
decode(secret)
.match(/.{1,2}/g)
.map((chunk) => parseInt(chunk, 16)),
); //confirmed
//Stage 2: Hash data //Stage 2: Hash data
const jssha = new jsSHA(hashType, 'UINT8ARRAY') const jssha = new jsSHA(hashType, "UINT8ARRAY");
jssha.setHMACKey(bSecret, 'UINT8ARRAY') jssha.setHMACKey(bSecret, "UINT8ARRAY");
jssha.update(bCounter) jssha.update(bCounter);
const hmacResult = jssha.getHMAC('UINT8ARRAY') //confirmed const hmacResult = jssha.getHMAC("UINT8ARRAY"); //confirmed
//Stage 3: Dynamic truncate //Stage 3: Dynamic truncate
const offsetB = hmacResult[19] & 0xf; const offsetB = hmacResult[19] & 0xf;
const P = hmacResult.slice(offsetB, offsetB + 4) const P = hmacResult.slice(offsetB, offsetB + 4);
P[0] = P[0] & 0x7f; P[0] = P[0] & 0x7f;
//Stage 4: Format string //Stage 4: Format string
let res = (new DataView(P.buffer).getInt32(0) % Math.pow(10, digits)).toString() let res = (
while(res.length < digits) new DataView(P.buffer).getInt32(0) % Math.pow(10, digits)
res = '0' + res; ).toString();
while (res.length < digits) res = "0" + res;
return res; return res;
} }
/** /**
* get OTP based on current time * 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} [digits=6] digits in OTP
* @param {number} [time=Date.now()] time for counter (default unix time epoch) * @param {number} [time=Date.now()] time for counter (default unix time epoch)
* @param {number} [fetchTime=30] period of token in seconds * @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) * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation)
* @returns TOTP string * @returns TOTP string
*/ */
export function getTOTP(secret, digits = 6, time = Date.now(), fetchTime = 30, timeOffset = 0, hashType = 'SHA-1') export function getTOTP(
{ secret,
const unixTime = Math.round((time / 1000 + timeOffset) / fetchTime) digits = 6,
return getHOTP(BigInt(unixTime), secret, digits) 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) { export function decode(base32) {
for ( for (
var base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567", var base32chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",
bits = "", bits = "",
hex = "", hex = "",
i = 0; i = 0;
i < base32.length; i < base32.length;
i++ i++
) { ) {
var val = base32chars.indexOf(base32.charAt(i).toUpperCase()); var val = base32chars.indexOf(base32.charAt(i).toUpperCase());
bits += leftpad(val.toString(2), 5, "0"); bits += leftpad(val.toString(2), 5, "0");
} }
for (i = 0; i + 4 <= bits.length; i += 4) { for (i = 0; i + 4 <= bits.length; i += 4) {
var chunk = bits.substr(i, 4); var chunk = bits.substr(i, 4);
hex += parseInt(chunk, 2).toString(16); hex += parseInt(chunk, 2).toString(16);
} }
return hex; return hex;
} }
function leftpad(str, len, pad) { function leftpad(str, len, pad) {
return ( return (
len + 1 >= str.length && len + 1 >= str.length &&
(str = new Array(len + 1 - str.length).join(pad) + str), (str = new Array(len + 1 - str.length).join(pad) + str),
str str
); );
} }
export function encode(bytes) { export function encode(bytes) {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
let bits = 0; let bits = 0;
let value = 0; let value = 0;
let output = ''; let output = "";
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i]; value = (value << 8) | bytes[i];
bits += 8; bits += 8;
while (bits >= 5) { while (bits >= 5) {
output += alphabet[(value >>> (bits - 5)) & 0x1F]; output += alphabet[(value >>> (bits - 5)) & 0x1f];
bits -= 5; bits -= 5;
} }
} }
if (bits > 0) { if (bits > 0) {
output += alphabet[(value << (5 - bits)) & 0x1F]; output += alphabet[(value << (5 - bits)) & 0x1f];
} }
const paddingLength = (8 - (output.length % 8)) % 8; const paddingLength = (8 - (output.length % 8)) % 8;
output += '='.repeat(paddingLength); output += "=".repeat(paddingLength);
return output; return output;
} }
export function base64decode(base64) { export function base64decode(base64) {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let result = []; let result = [];
let i = 0, j = 0; let i = 0,
j = 0;
let b1, b2, b3, b4; let b1, b2, b3, b4;
while (i < base64.length) { while (i < base64.length) {
@@ -77,4 +79,4 @@ export function base64decode(base64) {
} }
return result.slice(0, j); return result.slice(0, j);
} }

View File

@@ -1,60 +1,63 @@
import { getHOTP } from "./OTPGenerator.js" import { getHOTP } from "./OTPGenerator.js";
/** /**
* TOTP instance * TOTP instance
*/ */
export class TOTP { export class TOTP {
/** /**
* *
* @param {string} secret base32 encoded string * @param {string} secret base32 encoded string
* @param {string} issuer issuer of TOTP * @param {string} issuer issuer of TOTP
* @param {string} client client of TOTP * @param {string} client client of TOTP
* @param {number} [digits=6] number of digits in OTP token * @param {number} [digits=6] number of digits in OTP token
* @param {number} [fetchTime=30] period of token in seconds * @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) * @param {string} [hashType='SHA-1'] type of hash (more in jsSHA documentation)
*/ */
constructor(secret, constructor(
secret,
issuer, issuer,
client, client,
digits = 6, digits = 6,
fetchTime = 30, fetchTime = 30,
timeOffset = 0, timeOffset = 0,
hashType = 'SHA-1') { hashType = "SHA-1",
this.secret = secret ) {
this.issuer = issuer this.secret = secret;
this.client = client this.issuer = issuer;
this.digits = digits this.client = client;
this.fetchTime = fetchTime this.digits = digits;
this.timeOffset = timeOffset this.fetchTime = fetchTime;
this.hashType = hashType this.timeOffset = timeOffset;
this.hashType = hashType;
} }
static copy(totp){ static copy(totp) {
return new TOTP( return new TOTP(
secret = totp.secret, (secret = totp.secret),
issuer = totp.TOTPissuer, (issuer = totp.TOTPissuer),
client = totp.client, (client = totp.client),
digits = totp.digits, (digits = totp.digits),
fetchTime = totp.fetchTime, (fetchTime = totp.fetchTime),
timeOffset = totp.timeOffset, (timeOffset = totp.timeOffset),
hashType = totp.hashType (hashType = totp.hashType),
) );
} }
/** /**
* *
* @param {number} time time for counter (default unix time epoch) * @param {number} time time for counter (default unix time epoch)
* @returns OTP instance * @returns OTP instance
*/ */
getOTP(time = Date.now()) { getOTP(time = Date.now()) {
const unixTime = (time / 1000 + this.timeOffset) / this.fetchTime const unixTime = (time / 1000 + this.timeOffset) / this.fetchTime;
const otp = getHOTP(Math.floor(unixTime), this.secret, this.digits) const otp = getHOTP(Math.floor(unixTime), this.secret, this.digits, this.hashType);
const expireTime = time + const expireTime =
time +
(this.fetchTime - (this.fetchTime -
(time / 1000 + this.timeOffset) % ((time / 1000 + this.timeOffset) % this.fetchTime)) *
this.fetchTime) * 1000 1000;
const createdTime = time - (((time / 1000 + this.timeOffset) % const createdTime =
this.fetchTime) * 1000) time - ((time / 1000 + this.timeOffset) % this.fetchTime) * 1000;
return new OTP(otp, createdTime, expireTime) return new OTP(otp, createdTime, expireTime);
} }
} }
@@ -63,14 +66,14 @@ export class TOTP {
*/ */
export class OTP { export class OTP {
/** /**
* *
* @param {string} otp OTP string * @param {string} otp OTP string
* @param {number} createdTime time in unix epoch created OTP * @param {number} createdTime time in unix epoch created OTP
* @param {number} expireTime time in unix epoch to expire OTP * @param {number} expireTime time in unix epoch to expire OTP
*/ */
constructor(otp, createdTime, expireTime) { constructor(otp, createdTime, expireTime) {
this.otp = otp this.otp = otp;
this.createdTime = createdTime this.createdTime = createdTime;
this.expireTime = expireTime this.expireTime = expireTime;
} }
} }

View File

@@ -1,15 +1,15 @@
{ {
"name": "totpfit", "name": "totpfit",
"version": "1.3.0", "version": "1.3.0",
"description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4", "description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4",
"main": "app.js", "main": "app.js",
"author": "Lisoveliy", "author": "Lisoveliy",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@zeppos/device-types": "^3.0.0" "@zeppos/device-types": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"@zeppos/zml": "^0.0.27", "@zeppos/zml": "^0.0.27",
"jssha": "^3.3.1" "jssha": "^3.3.1"
} }
} }

View File

@@ -13,23 +13,22 @@ Page(
this.getTOTPData() this.getTOTPData()
.then((x) => { .then((x) => {
app._options.globalData.TOTPS = JSON.parse(x) ?? []; app._options.globalData.TOTPS = JSON.parse(x) ?? [];
let localStorage = new LocalStorage(); let localStorage = new LocalStorage();
localStorage.setItem( localStorage.setItem(
"TOTPs", "TOTPs",
JSON.stringify(app._options.globalData.TOTPS) JSON.stringify(app._options.globalData.TOTPS),
); );
this.initPage(); this.initPage();
}) })
.catch((x) => { .catch((x) => {
console.log(`Init failed: ${x}`); console.log(`ERR: Init failed - ${x}`);
try{ try {
let localStorage = new LocalStorage(); let localStorage = new LocalStorage();
app._options.globalData.TOTPS = JSON.parse( app._options.globalData.TOTPS = JSON.parse(
localStorage.getItem("TOTPs", []) localStorage.getItem("TOTPs", []),
); );
} } catch {
catch{
app._options.globalData.TOTPS = []; app._options.globalData.TOTPS = [];
} }
this.initPage(); this.initPage();
@@ -56,5 +55,5 @@ Page(
method: "totps", method: "totps",
}); });
}, },
}) }),
); );

View File

@@ -30,7 +30,7 @@ function renderTOTPs(buffer) {
expireBar: RenderExpireBar( expireBar: RenderExpireBar(
i, i,
otpData.createdTime, otpData.createdTime,
buffer[i].fetchTime buffer[i].fetchTime,
), ),
}; };
setInterval(() => { setInterval(() => {
@@ -38,7 +38,7 @@ function renderTOTPs(buffer) {
(Date.now() - otpData.createdTime) / (Date.now() - otpData.createdTime) /
1000 / 1000 /
buffer[i].fetchTime - buffer[i].fetchTime -
1 1,
); );
renderData[i].expireBar.setProperty(prop.MORE, { renderData[i].expireBar.setProperty(prop.MORE, {

View File

@@ -77,7 +77,7 @@ export function RenderOTPValue(position, otpValue) {
export function RenderExpireBar(position, createdTime, fetchTime) { export function RenderExpireBar(position, createdTime, fetchTime) {
const yPos = getYPos(position); const yPos = getYPos(position);
const expireDif = Math.abs( const expireDif = Math.abs(
(Date.now() - createdTime) / 1000 / fetchTime - 1 (Date.now() - createdTime) / 1000 / fetchTime - 1,
); );
return createWidget(widget.ARC, { return createWidget(widget.ARC, {
x: buttonWidth - 50, x: buttonWidth - 50,

View File

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

34
setting/consts.js Normal file
View File

@@ -0,0 +1,34 @@
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",
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,50 +1,43 @@
import { getTOTPByLink } from "./utils/queryParser.js"; import { getTOTPByLink } from "./utils/queryParser.js";
import { colors, content } from "./consts.js";
let _props = null; let _props = null;
const colors = {
bg: "#101010",
linkBg: "#ffffffc0",
secondaryBg: "#282828",
text: "#fafafa",
alert: "#ad3c23",
notify: "#555555",
bigText: "#fafafa"
};
AppSettingsPage({ AppSettingsPage({
build(props) { build(props) {
_props = props; _props = props;
const storage = JSON.parse( const storage = JSON.parse(
props.settingsStorage.getItem("TOTPs") ?? "[]" props.settingsStorage.getItem("TOTPs") ?? "[]",
); );
const totpEntrys = GetTOTPList(storage); const totpEntrys = GetTOTPList(storage);
const addTOTPsHint = storage.length < 1 ? const addTOTPsHint =
Text({ storage.length < 1
paragraph: true, ? Text(
align: "center", {
style: { paragraph: true,
paddingTop: "10px", align: "center",
marginBottom: "10px", style: {
color: colors.text, paddingTop: "10px",
fontSize: 16, marginBottom: "10px",
verticalAlign: "middle", 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; },
content.addTotpsHint,
)
: null;
const createButton = TextInput({ const createButton = TextInput({
placeholder: "otpauth(-migration)://", placeholder: content.createButton.placeHolder,
label: "Add new TOTP record", label: content.createButton.label,
onChange: (changes) => { onChange: (changes) => {
let link = getTOTPByLink(changes); let link = getTOTPByLink(changes);
if (link == null) { if (link == null) {
console.log("link is invalid"); console.log("ERR: link is invalid");
return; return;
} }
if (Array.isArray(link)) if (Array.isArray(link)) storage.push(...link);
storage.push(...link);
else storage.push(link); else storage.push(link);
updateStorage(storage); updateStorage(storage);
@@ -61,7 +54,7 @@ AppSettingsPage({
position: storage.length < 1 ? "absolute" : null, //TODO: Сделать что-то с этим кошмаром position: storage.length < 1 ? "absolute" : null, //TODO: Сделать что-то с этим кошмаром
bottom: storage.length < 1 ? "0px" : null, bottom: storage.length < 1 ? "0px" : null,
left: storage.length < 1 ? "0px" : null, left: storage.length < 1 ? "0px" : null,
right: storage.length < 1 ? "0px" : null right: storage.length < 1 ? "0px" : null,
}, },
}); });
@@ -79,58 +72,79 @@ AppSettingsPage({
textAlign: "center", textAlign: "center",
}, },
}, },
storage.length < 1 ? addTOTPsHint : Text( storage.length < 1
{ ? addTOTPsHint
align: "center", : Text(
paragraph: true, {
style: { align: "center",
marginBottom: "10px", paragraph: true,
color: colors.bigText, style: {
fontSize: 23, marginBottom: "10px",
fontWeight: "500", color: colors.bigText,
verticalAlign: "middle", fontSize: 23,
fontWeight: "500",
verticalAlign: "middle",
},
}, },
}, "TOTP records:",
"TOTP records:" ),
)
), ),
...totpEntrys, ...totpEntrys,
createButton, createButton,
View({ View(
style: { {
display: "flex", style: {
justifyContent: "center" 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)") Link(
{
source: content.instructionLink.source,
},
content.instructionLink.label,
),
), ),
] ],
); );
return body; return body;
}, },
}); });
/**
* Get DOM elements of TOTP records
* @param {*} storage Array of TOTP objects
* @returns Array of DOM elements
*/
function GetTOTPList(storage) { function GetTOTPList(storage) {
let totpEntrys = []; let totpEntrys = [];
let counter = 0; let counter = 0;
storage.forEach((element) => { storage.forEach((element) => {
const elementId = counter; const elementId = counter;
const textInput = TextInput({ const totpLabelText = Text(
placeholder: "otpauth(-migration)://", {
label: "Change TOTP link", align: "center",
style: {
color: colors.text,
fontSize: "18px",
fontWeight: "500",
},
paragraph: true,
},
content.totpLabelText.eval(element.issuer, element.client),
);
const changeButton = TextInput({
placeholder: content.changeButton.placeHolder,
label: content.changeButton.label,
onChange: (changes) => { onChange: (changes) => {
try { try {
let link = getTOTPByLink(changes); let link = getTOTPByLink(changes);
if (Array.isArray(link)) if (Array.isArray(link)) return;
return;
storage[elementId] = link; storage[elementId] = link;
updateStorage(storage); updateStorage(storage);
} catch (err) { } catch (err) {
console.log(err); console.log(`ERR: ${err}`);
} }
}, },
labelStyle: { labelStyle: {
@@ -146,22 +160,20 @@ function GetTOTPList(storage) {
borderRadius: "5px", borderRadius: "5px",
}, },
}); });
const textBig = Text( const totpDescText = Text(
{ {
align: "center",
style: { style: {
color: colors.text, color: colors.text,
fontSize: "18px", fontSize: "14px",
fontWeight: "500"
}, },
paragraph: true, align: "center",
}, },
`${element.issuer}: ${element.client}` content.totpDescText.eval(element.hashType, element.digits, element.fetchTime, element.timeOffset),
); );
const delButton = Button({ const deleteButton = Button({
onClick: () => { onClick: () => {
storage = storage.filter( storage = storage.filter(
(x) => storage.indexOf(x) != elementId (x) => storage.indexOf(x) != elementId,
); );
updateStorage(storage); updateStorage(storage);
}, },
@@ -172,31 +184,20 @@ function GetTOTPList(storage) {
height: "fit-content", height: "fit-content",
margin: "10px", margin: "10px",
}, },
label: "Delete", label: content.deleteButton.label,
}); });
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( const view = View(
{ {
style: { style: {
textAlign: "center", textAlign: "center",
backgroundColor: colors.secondaryBg, backgroundColor: colors.secondaryBg,
//border: "2px solid white",
borderRadius: "5px", borderRadius: "5px",
margin: "10px", margin: "10px",
}, },
}, },
[ [
textBig, totpLabelText,
text, totpDescText,
View( View(
{ {
style: { style: {
@@ -204,17 +205,21 @@ function GetTOTPList(storage) {
gridTemplateColumns: "1fr 100px", gridTemplateColumns: "1fr 100px",
}, },
}, },
[textInput, delButton] [changeButton, deleteButton],
), ),
] ],
); );
totpEntrys.push({ text: text, view: view }); totpEntrys.push({ text: totpDescText, view: view });
counter++; counter++;
}); });
return totpEntrys.map((x) => x.view); return totpEntrys.map((x) => x.view);
} }
/**
* Update
* @param {*} storage Array of TOTP objects
*/
function updateStorage(storage) { function updateStorage(storage) {
_props.settingsStorage.setItem("TOTPs", JSON.stringify(storage)); _props.settingsStorage.setItem("TOTPs", JSON.stringify(storage));
} }

View File

@@ -6,109 +6,100 @@ const otpauthScheme = "otpauth:/";
const googleMigrationScheme = "otpauth-migration:/"; const googleMigrationScheme = "otpauth-migration:/";
export function getTOTPByLink(link) { export function getTOTPByLink(link) {
if (link.includes(otpauthScheme)) if (link.includes(otpauthScheme)) return getByOtpauthScheme(link);
return getByOtpauthScheme(link) if (link.includes(googleMigrationScheme))
if (link.includes(googleMigrationScheme)) return getByGoogleMigrationScheme(link);
return getByGoogleMigrationScheme(link)
return null; return null;
} }
function getHashType(algorithm) { function getHashType(algorithm) {
if (algorithm == "SHA1") return "SHA-1"; if (algorithm == "SHA1") return "SHA-1";
if (algorithm == "SHA256") return "SHA-256"; if (algorithm == "SHA256") return "SHA-256";
if (algorithm == "SHA512") return "SHA-512"; if (algorithm == "SHA512") return "SHA-512";
else return "SHA-1"; else return "SHA-1";
} }
function getByOtpauthScheme(link) { function getByOtpauthScheme(link) {
try { try {
let args = link.split("/", otpauthScheme.length); let args = link.split("/", otpauthScheme.length);
let type = args[2]; //Returns 'hotp' or 'totp' let type = args[2]; //Returns 'hotp' or 'totp'
let issuer = args[3].split(":")[0]?.split("?")[0]; //Returns issuer let issuer = args[3].split(":")[0]?.split("?")[0]; //Returns issuer
let client = let client =
args[3].split(":")[1]?.split("?")[0] ?? args[3].split(":")[1]?.split("?")[0] ??
args[3].split(":")[0]?.split("?")[0]; //Returns client args[3].split(":")[0]?.split("?")[0]; //Returns client
let secret = args[3].split("secret=")[1]?.split("&")[0]; //Returns secret let secret = args[3].split("secret=")[1]?.split("&")[0]; //Returns secret
let period = args[3].split("period=")[1]?.split("&")[0]; //Returns period let period = args[3].split("period=")[1]?.split("&")[0]; //Returns period
let digits = args[3].split("digit=")[1]?.split("&")[0]; //Returns digits let digits = args[3].split("digit=")[1]?.split("&")[0]; //Returns digits
let algorithm = args[3].split("algorithm=")[1]?.split("&")[0]; //Returns algorithm let algorithm = args[3].split("algorithm=")[1]?.split("&")[0]; //Returns algorithm
let offset = args[3].split("offset=")[1]?.split("&")[0] ?? 0; //Returns offset let offset = args[3].split("offset=")[1]?.split("&")[0] ?? 0; //Returns offset
if (type.toLowerCase() != "totp") if (type.toLowerCase() != "totp")
throw new Error("Type is not valid, requires 'TOTP'"); throw new Error("Type is not valid, requires 'TOTP'");
if (secret === undefined) throw new Error("Secret not defined"); if (secret === undefined) throw new Error("Secret not defined");
if (issuer == client) { if (issuer == client) {
issuer = args[3].split("issuer=")[1]?.split("&")[0]; issuer = args[3].split("issuer=")[1]?.split("&")[0];
} }
issuer = decodeURIComponent(issuer); issuer = decodeURIComponent(issuer);
client = decodeURIComponent(client); client = decodeURIComponent(client);
return new TOTP( return new TOTP(
secret, secret,
issuer, issuer,
client, client,
Number(digits), Number(digits),
Number(period), Number(period),
Number(offset), Number(offset),
getHashType(algorithm) getHashType(algorithm),
); );
} catch (err) { } catch (err) {
console.log(err) console.log(`ERR: ${err}`);
return null; return null;
} }
} }
function getByGoogleMigrationScheme(link) { 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 data = link.split("data=")[1]; //Returns base64 encoded data let protoTotps = [];
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));
}
});
proto.parts.forEach(part => { let totps = [];
if (part.type == TYPES.LENDELIM) { protoTotps.forEach((x) => {
protoTotps.push(decodeProto(part.value)); 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 totps = []; let name = bytesToString(x.parts.filter((x) => x.index == 2)[0].value);
protoTotps.forEach(x => { let issuer = bytesToString(
let type = x.parts.filter(x => x.index == 6)[0]; //find type of OTP x.parts.filter((x) => x.index == 3)[0].value,
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); totps.push(new TOTP(secret, issuer, name, 6, 30, 0, "SHA-1"));
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;
return totps;
} }
function bytesToString(bytes) { function bytesToString(bytes) {
let str = ''; let str = "";
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]); str += String.fromCharCode(bytes[i]);
} }
return str; return str;
} }