Compare commits

..

8 Commits

22 changed files with 217 additions and 138 deletions

View File

@ -1,17 +1,22 @@
# TOTPFIT # TOTPFIT
### Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4 with Google Authenticator migration support ### Another 2FAuthenticator based on TOTP for Zepp OS with Google Authenticator and Proton Authenticator support
![alt text](docs/assets/image2.png) ![alt text](docs/assets/image2.png)![alt text](docs/assets/image.jpg)
### Features: ### Features:
- Supports of `otpauth://` links with parameters "client", "issuer", "algorithm", "digits", "period", "offset" - Support 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=...`
- 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 **Proton Authenticator** export with parameters "client", "issuer", "algorithm", "digits", "period", "offset" **(BETA)**
- Addition/Sort/Edition/Deletion of TOTPs from mobile app
### 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 public issues on [GitHub](https://github.com/Lisoveliy/totpfit)
#### To contribute on this repo you must use my [Gitea](https://git.lisoveliy.su/Lisoveliy/totpfit)
Don't be shy to contact me for access!

View File

@ -6,7 +6,7 @@
"appType": "app", "appType": "app",
"version": { "version": {
"code": 1, "code": 1,
"name": "1.3.1" "name": "1.4.0"
}, },
"icon": "icon.png", "icon": "icon.png",
"vender": "zepp", "vender": "zepp",
@ -37,6 +37,11 @@
{ {
"st": "s", "st": "s",
"dw": 390 "dw": 390
},
{
"st": "r",
"dw": 390
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 21 KiB

BIN
docs/assets/image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -14,11 +14,9 @@ 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 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 records using migration from Google Authenticator app, you must go to menu, select "Transfer accounts" -> "Export accounts"
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. 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.
@ -34,4 +32,23 @@ Then press OK, all selected records from Google Authenticator will appear on pag
![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 from text field (otpauth-migration:// will not work), and previous link will not be shown on field. ### If you use Proton Authenticator
To add 2FA TOTP records from Proton Authenticator you must go to settings and press "Export":
![alt text](photo_2025-08-08_17-10-27.jpg)
After this save file and open it on text editor:
![alt text](photo_2025-08-08_17-10-35.jpg)
![alt text](photo_2025-08-08_17-10-41.jpg)
Copy all stuff from file at clipboard and import in application:
![alt text](photo_2025-08-08_17-10-38.jpg)
![alt text](<Снимок экрана_20250808_170854.png>)
Then press OK, records will appear on page:
![alt text](<Снимок экрана_20250808_170912.png>)

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View File

@ -1,7 +1,7 @@
{ {
"name": "totpfit", "name": "totpfit",
"version": "1.3.1", "version": "1.4.0",
"description": "Another 2FAuthenticator based on TOTP for Zepp Amazfit GTS 4", "description": "Another 2FAuthenticator based on TOTP for Zepp OS with Google Authenticator and Proton Authenticator support",
"main": "app.js", "main": "app.js",
"author": "Lisoveliy", "author": "Lisoveliy",
"license": "MIT", "license": "MIT",

View File

@ -10,7 +10,9 @@ export const colors = {
export const content = { export const content = {
addTotpsHint: addTotpsHint:
"For add a 2FA TOTP record you must have otpauth:// link or otpauth-migration:// link from Google Authenticator Migration QR-Code", "For add a 2FA TOTP record you must have otpauth:// link otpauth-migration://" +
"link from Google Authenticator Migration QR-Code or Proton authenticator Export JSON string." +
"If you have a questions - check instruction below!",
totpRecordsHint: "TOTP records:", totpRecordsHint: "TOTP records:",
createButton: { createButton: {
placeHolder: "otpauth(-migration)://", placeHolder: "otpauth(-migration)://",
@ -24,6 +26,14 @@ export const content = {
label: "Change TOTP link", label: "Change TOTP link",
placeHolder: "otpauth(-migration)://", placeHolder: "otpauth(-migration)://",
}, },
renameButtons: {
rename: "Rename",
renameIssuer: "Rename Issuer",
renameClient: "Rename Client",
},
saveButton: {
label: "Save",
},
deleteButton: { deleteButton: {
label: "Delete", label: "Delete",
}, },

View File

@ -31,7 +31,7 @@ function GetTOTPList(storage) {
editingIndex = index; editingIndex = index;
tempIssuer = element.issuer; tempIssuer = element.issuer;
tempClient = element.client; tempClient = element.client;
updateStorage(storage); _props.settingsStorage.setItem("requestUpdate", Math.random());
}, },
onSave: () => { onSave: () => {
storage[index].issuer = tempIssuer; storage[index].issuer = tempIssuer;
@ -61,6 +61,7 @@ function GetTOTPList(storage) {
updateStorage(storage); updateStorage(storage);
} }
}, },
isEditInProgress: editingIndex !== -1,
}); });
}); });
} }
@ -97,11 +98,6 @@ AppSettingsPage({
try { try {
errorMessage = ""; errorMessage = "";
let link = getTOTPByLink(changes); 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)) { if (Array.isArray(link)) {
storage.push(...link); storage.push(...link);
@ -119,7 +115,9 @@ AppSettingsPage({
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
margin: "10px", marginBottom: "10px",
marginLeft: "10px",
marginRight: "10px",
fontSize: "20px", fontSize: "20px",
color: colors.text, color: colors.text,
borderRadius: "5px", borderRadius: "5px",
@ -137,7 +135,7 @@ AppSettingsPage({
}, },
errorMessage, errorMessage,
) )
: null; //TODO: Check for work : null;
const bottomContainer = View( const bottomContainer = View(
{ {
@ -151,8 +149,8 @@ AppSettingsPage({
style: { style: {
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
marginTop: "20px", marginTop: "10px",
marginBottom: "20px", marginBottom: "10px",
}, },
}, },
Link( Link(

View File

@ -14,6 +14,7 @@ export function createTOTPCard({
onMoveDown, onMoveDown,
onIssuerChange, onIssuerChange,
onClientChange, onClientChange,
isEditInProgress,
}) { }) {
const infoView = View( const infoView = View(
{ {
@ -26,7 +27,7 @@ export function createTOTPCard({
isEditing isEditing
? [ ? [
TextInput({ TextInput({
label: "Rename Issuer", label: content.renameButtons.renameIssuer,
value: tempIssuer, value: tempIssuer,
onChange: onIssuerChange, onChange: onIssuerChange,
labelStyle: { labelStyle: {
@ -39,14 +40,14 @@ export function createTOTPCard({
color: colors.text, color: colors.text,
borderRadius: "5px", borderRadius: "5px",
height: "40px", height: "40px",
width: "200px" width: "200px",
}, },
subStyle: { subStyle: {
display: "none", display: "none",
}, },
}), }),
TextInput({ TextInput({
label: "Rename client", label: content.renameButtons.renameClient,
value: tempClient, value: tempClient,
onChange: onClientChange, onChange: onClientChange,
labelStyle: { labelStyle: {
@ -59,7 +60,7 @@ export function createTOTPCard({
color: colors.text, color: colors.text,
borderRadius: "5px", borderRadius: "5px",
height: "40px", height: "40px",
width: "200px" width: "200px",
}, },
subStyle: { subStyle: {
display: "none", display: "none",
@ -89,7 +90,7 @@ export function createTOTPCard({
isEditing isEditing
? [ ? [
Button({ Button({
label: "Save", label: content.saveButton.label,
style: { style: {
margin: "5px", margin: "5px",
backgroundColor: "#28a745", backgroundColor: "#28a745",
@ -100,7 +101,7 @@ export function createTOTPCard({
] ]
: [ : [
Button({ Button({
label: "Rename", label: content.renameButtons.rename,
style: { style: {
margin: "5px", margin: "5px",
backgroundColor: colors.notify, backgroundColor: colors.notify,
@ -108,15 +109,17 @@ export function createTOTPCard({
}, },
onClick: onRename, onClick: onRename,
}), }),
Button({ !isEditInProgress
label: "Delete", ? Button({
style: { label: content.deleteButton.label,
margin: "5px", style: {
backgroundColor: colors.alert, margin: "5px",
color: colors.text, backgroundColor: colors.alert,
}, color: colors.text,
onClick: onDelete, },
}), onClick: onDelete,
})
: null,
], ],
); );
@ -127,7 +130,6 @@ export function createTOTPCard({
label: "⬆", label: "⬆",
disabled: index === 0, disabled: index === 0,
style: { style: {
width: "50px",
margin: "2px", margin: "2px",
color: colors.text, color: colors.text,
backgroundColor: colors.notify, backgroundColor: colors.notify,
@ -138,7 +140,6 @@ export function createTOTPCard({
label: "⬇", label: "⬇",
disabled: index === storage.length - 1, disabled: index === storage.length - 1,
style: { style: {
width: "50px",
margin: "2px", margin: "2px",
color: colors.text, color: colors.text,
backgroundColor: colors.notify, backgroundColor: colors.notify,

View File

@ -0,0 +1,11 @@
export class ProtonBackupExport {
version;
entries = Array.of(ProtonTotpRecord);
}
export class ProtonTotpRecord {
content = {
uri,
entry_type,
};
}

View File

@ -1,11 +1,139 @@
import { decodeProto, TYPES } from "../../lib/protobuf-decoder/protobufDecoder"; import { decodeProto, TYPES } from "../../lib/protobuf-decoder/protobufDecoder";
import { TOTP } from "../../lib/totp-quickjs"; import { TOTP } from "../../lib/totp-quickjs";
import { base64decode, encode } from "../../lib/totp-quickjs/base32decoder"; import { base64decode, encode } from "../../lib/totp-quickjs/base32decoder";
import { ProtonBackupExport } from "./protonBackupExport";
const otpauthScheme = "otpauth://"; const otpauthScheme = "otpauth://";
const googleMigrationScheme = "otpauth-migration://"; const googleMigrationScheme = "otpauth-migration://";
export function getTOTPByLink(link) {
try {
//proton export
const json = JSON.parse(link);
console.log(json);
return getByProtonBackup(json);
} catch (e) {
if (link.startsWith(googleMigrationScheme)) {
//google migration export
return getByGoogleMigrationScheme(link);
}
if (link.startsWith(otpauthScheme)) {
//otpauth export
return getByOtpauthScheme(link);
}
throw new Error(
`Unsupported link type. Please use an otpauth:// or otpauth-migration:// link\n ERR: ${e}`,
);
}
}
function _parseSingleMigrationEntry(part) { 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 getByProtonBackup(protonjson) {
try {
if ("entries" in protonjson && protonjson.version == 1) {
//Is proton export?
console.log(1);
const protonBE = Object.assign(
new ProtonBackupExport(),
protonjson,
);
const res = protonBE.entries.map((x) => {
return getByOtpauthScheme(x.content.uri);
});
console.log(res);
return res;
} else throw new Error("use proton export backup with version: 1");
} catch (e) {
console.log(e);
throw new Error(`Unsupported JSON type: ${e}`);
}
}
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;
client = decodeURIComponent(client);
let secret = params.match(/secret=([^&]*)/)?.[1];
let issuerFromParams = params.match(/issuer=([^&]*)/)?.[1];
let issuer = issuerFromParams
? decodeURIComponent(issuerFromParams)
: decodeURIComponent(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 getByGoogleMigrationScheme(link) {
try {
const data = link.split("data=")[1];
if (!data) return null;
const decodedData = decodeURIComponent(data);
const buffer = base64decode(decodedData);
const proto = decodeProto(buffer);
const totps = [];
const otpParameters = proto.parts.filter(
(p) => p.index === 1 && p.type === TYPES.LENDELIM,
);
otpParameters.forEach((part) => {
const totp = parseSingleMigrationEntry(part);
if (totp) {
totps.push(totp);
}
});
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.",
);
}
}
function parseSingleMigrationEntry(part) {
const totpProto = decodeProto(part.value); const totpProto = decodeProto(part.value);
const otpData = {}; const otpData = {};
@ -64,102 +192,6 @@ function _parseSingleMigrationEntry(part) {
); );
} }
function getByGoogleMigrationScheme(link) {
try {
const data = link.split("data=")[1];
if (!data) return null;
const decodedData = decodeURIComponent(data);
const buffer = base64decode(decodedData);
const proto = decodeProto(buffer);
const totps = [];
const otpParameters = proto.parts.filter(
(p) => p.index === 1 && p.type === TYPES.LENDELIM,
);
otpParameters.forEach((part) => {
const totp = _parseSingleMigrationEntry(part);
if (totp) {
totps.push(totp);
}
});
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.",
);
}
}
export function getTOTPByLink(link) {
if (link.startsWith(googleMigrationScheme)) {
return getByGoogleMigrationScheme(link);
}
if (link.startsWith(otpauthScheme)) {
return getByOtpauthScheme(link);
}
return null;
}
function getHashType(algorithm) {
if (algorithm == "SHA1") return "SHA-1";
if (algorithm == "SHA256") return "SHA-256";
if (algorithm == "SHA512") return "SHA-512";
else return "SHA-1";
}
function getByOtpauthScheme(link) {
try {
let args = link.split("?");
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) { function bytesToString(bytes) {
let str = ""; let str = "";
for (let i = 0; i < bytes.length; i++) { for (let i = 0; i < bytes.length; i++) {