9 Commits

21 changed files with 560 additions and 523 deletions

View File

@@ -1,12 +1,14 @@
# 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)
### Features:
- Supports of ```otpauth://``` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset"
- Supports of `otpauth://` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset"
- Addition/Edition/Deletion of TOTPs from mobile app
- Support of Google Authenticator migration links formated: ```otpauth-migration://offline?data=...``` (At this stage with only 6 digits and only 30 seconds period)
- Support of Google Authenticator migration links formated: `otpauth-migration://offline?data=...` (At this stage with only 6 digits and only 30 seconds period)
### Guides:

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'))
BaseSideService({
onInit() {},
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() {},
onDestroy() {},
})
}),
);

View File

@@ -12,10 +12,7 @@
"vender": "zepp",
"description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4"
},
"permissions": [
"data:os.device.info",
"device:os.local_storage"
],
"permissions": ["data:os.device.info", "device:os.local_storage"],
"runtime": {
"apiVersion": {
"compatible": "3.0.0",
@@ -27,10 +24,7 @@
"default": {
"module": {
"page": {
"pages": [
"page/index",
"page/tip"
]
"pages": ["page/index", "page/tip"]
},
"app-side": {
"path": "app-side/index"

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"
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.

View File

@@ -1,4 +1,4 @@
import { Buffer } from 'buffer'
import { Buffer } from "buffer";
export function parseInput(input) {
const normalizedInput = input.replace(/\s/g, "");
@@ -33,4 +33,5 @@ export function bufferLeToBeHex(buffer) {
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

@@ -48,7 +48,7 @@ export class BufferReader {
"Not enough bytes left. Requested: " +
length +
" left: " +
bytesAvailable
bytesAvailable,
);
}
}
@@ -66,7 +66,7 @@ export const TYPES = {
VARINT: 0,
FIXED64: 1,
LENDELIM: 2,
FIXED32: 5
FIXED32: 5,
};
export function decodeProto(buffer) {
@@ -102,17 +102,17 @@ export function decodeProto(buffer) {
byteRange,
index,
type,
value
value,
});
}
} catch (err) {
reader.resetToCheckpoint();
console.log(err);
console.log(`ERR: ${err}`);
}
return {
parts,
leftOver: reader.readBuffer(reader.leftBytes())
leftOver: reader.readBuffer(reader.leftBytes()),
};
}

View File

@@ -18,6 +18,6 @@ export function decodeVarint(buffer, offset) {
return {
value: res,
length: shift / 7
length: shift / 7,
};
}

View File

@@ -1,6 +1,6 @@
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
@@ -9,30 +9,34 @@ import jsSHA from "jssha";
* @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 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 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;
}
@@ -46,8 +50,14 @@ export function getHOTP(counter, secret, digits = 6, hashType = 'SHA-1'){
* @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

@@ -26,35 +26,37 @@ function leftpad(str, len, pad) {
}
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) {

View File

@@ -1,4 +1,4 @@
import { getHOTP } from "./OTPGenerator.js"
import { getHOTP } from "./OTPGenerator.js";
/**
* TOTP instance
*/
@@ -13,31 +13,33 @@ export class TOTP {
* @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),
);
}
/**
*
@@ -45,16 +47,17 @@ export class TOTP {
* @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)
((time / 1000 + this.timeOffset) % this.fetchTime)) *
1000;
const createdTime =
time - ((time / 1000 + this.timeOffset) % this.fetchTime) * 1000;
return new OTP(otp, createdTime, expireTime)
return new OTP(otp, createdTime, expireTime);
}
}
@@ -69,8 +72,8 @@ export class 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;
}
}

View File

@@ -17,19 +17,18 @@ Page(
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{
console.log(`ERR: Init failed - ${x}`);
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 +55,5 @@ Page(
method: "totps",
});
},
})
}),
);

View File

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

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

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,26 +1,19 @@
import { getTOTPByLink } from "./utils/queryParser.js";
import { colors, content } from "./consts.js";
let _props = null;
const colors = {
bg: "#101010",
linkBg: "#ffffffc0",
secondaryBg: "#282828",
text: "#fafafa",
alert: "#ad3c23",
notify: "#555555",
bigText: "#fafafa"
};
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({
const addTOTPsHint =
storage.length < 1
? Text(
{
paragraph: true,
align: "center",
style: {
@@ -31,20 +24,20 @@ AppSettingsPage({
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({
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");
console.log("ERR: link is invalid");
return;
}
if (Array.isArray(link))
storage.push(...link);
if (Array.isArray(link)) storage.push(...link);
else storage.push(link);
updateStorage(storage);
@@ -61,7 +54,7 @@ AppSettingsPage({
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
right: storage.length < 1 ? "0px" : null,
},
});
@@ -79,7 +72,9 @@ AppSettingsPage({
textAlign: "center",
},
},
storage.length < 1 ? addTOTPsHint : Text(
storage.length < 1
? addTOTPsHint
: Text(
{
align: "center",
paragraph: true,
@@ -91,46 +86,65 @@ AppSettingsPage({
verticalAlign: "middle",
},
},
"TOTP records:"
)
"TOTP records:",
),
),
...totpEntrys,
createButton,
View({
View(
{
style: {
display: "flex",
justifyContent: "center"
}
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;
},
});
/**
* Get DOM elements of TOTP records
* @param {*} storage Array of TOTP objects
* @returns Array of DOM elements
*/
function GetTOTPList(storage) {
let totpEntrys = [];
let counter = 0;
storage.forEach((element) => {
const elementId = counter;
const textInput = TextInput({
placeholder: "otpauth(-migration)://",
label: "Change TOTP link",
const totpLabelText = Text(
{
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) => {
try {
let link = getTOTPByLink(changes);
if (Array.isArray(link))
return;
if (Array.isArray(link)) return;
storage[elementId] = link;
updateStorage(storage);
} catch (err) {
console.log(err);
console.log(`ERR: ${err}`);
}
},
labelStyle: {
@@ -146,22 +160,20 @@ function GetTOTPList(storage) {
borderRadius: "5px",
},
});
const textBig = Text(
const totpDescText = Text(
{
align: "center",
style: {
color: colors.text,
fontSize: "18px",
fontWeight: "500"
fontSize: "14px",
},
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: () => {
storage = storage.filter(
(x) => storage.indexOf(x) != elementId
(x) => storage.indexOf(x) != elementId,
);
updateStorage(storage);
},
@@ -172,31 +184,20 @@ function GetTOTPList(storage) {
height: "fit-content",
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(
{
style: {
textAlign: "center",
backgroundColor: colors.secondaryBg,
//border: "2px solid white",
borderRadius: "5px",
margin: "10px",
},
},
[
textBig,
text,
totpLabelText,
totpDescText,
View(
{
style: {
@@ -204,17 +205,21 @@ function GetTOTPList(storage) {
gridTemplateColumns: "1fr 100px",
},
},
[textInput, delButton]
[changeButton, deleteButton],
),
]
],
);
totpEntrys.push({ text: text, view: view });
totpEntrys.push({ text: totpDescText, view: view });
counter++;
});
return totpEntrys.map((x) => x.view);
}
/**
* Update
* @param {*} storage Array of TOTP objects
*/
function updateStorage(storage) {
_props.settingsStorage.setItem("TOTPs", JSON.stringify(storage));
}

View File

@@ -6,10 +6,9 @@ const otpauthScheme = "otpauth:/";
const googleMigrationScheme = "otpauth-migration:/";
export function getTOTPByLink(link) {
if (link.includes(otpauthScheme))
return getByOtpauthScheme(link)
if (link.includes(otpauthScheme)) return getByOtpauthScheme(link);
if (link.includes(googleMigrationScheme))
return getByGoogleMigrationScheme(link)
return getByGoogleMigrationScheme(link);
return null;
}
@@ -54,16 +53,15 @@ function getByOtpauthScheme(link) {
Number(digits),
Number(period),
Number(offset),
getHashType(algorithm)
getHashType(algorithm),
);
} catch (err) {
console.log(err)
console.log(`ERR: ${err}`);
return null;
}
}
function getByGoogleMigrationScheme(link) {
let data = link.split("data=")[1]; //Returns base64 encoded data
data = decodeURIComponent(data);
let decode = base64decode(data);
@@ -71,42 +69,35 @@ function getByGoogleMigrationScheme(link) {
let protoTotps = [];
proto.parts.forEach(part => {
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")
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;
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);
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"
));
totps.push(new TOTP(secret, issuer, name, 6, 30, 0, "SHA-1"));
});
return totps;
}
function bytesToString(bytes) {
let str = '';
let str = "";
for (let i = 0; i < bytes.length; i++) {
str += String.fromCharCode(bytes[i]);
}