Compare commits
6 Commits
f8fa7a8507
...
e43cf044e0
Author | SHA1 | Date | |
---|---|---|---|
e43cf044e0 | |||
08894ee061 | |||
1e69a70ea0 | |||
86f382bef9 | |||
eaf6aefd5b | |||
52d053e024 |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 21 KiB |
BIN
docs/assets/image.jpg
Normal file
After Width: | Height: | Size: 115 KiB |
Before Width: | Height: | Size: 27 KiB |
@ -14,11 +14,9 @@ Then press OK, record will appear on page
|
||||
|
||||

|
||||
|
||||
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 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.
|
||||
|
||||
@ -34,4 +32,23 @@ Then press OK, all selected records from Google Authenticator will appear on pag
|
||||
|
||||

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

|
||||
|
||||
After this save file and open it on text editor:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Copy all stuff from file at clipboard and import in application:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Then press OK, records will appear on page:
|
||||
|
||||

|
||||
|
BIN
docs/guides/how-to-add-totps/photo_2025-08-08_17-10-27.jpg
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
docs/guides/how-to-add-totps/photo_2025-08-08_17-10-35.jpg
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
docs/guides/how-to-add-totps/photo_2025-08-08_17-10-38.jpg
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
docs/guides/how-to-add-totps/photo_2025-08-08_17-10-41.jpg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
docs/guides/how-to-add-totps/Снимок экрана_20250808_170854.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
docs/guides/how-to-add-totps/Снимок экрана_20250808_170912.png
Normal file
After Width: | Height: | Size: 37 KiB |
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 21 KiB |
@ -10,7 +10,9 @@ export const colors = {
|
||||
|
||||
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",
|
||||
"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:",
|
||||
createButton: {
|
||||
placeHolder: "otpauth(-migration)://",
|
||||
@ -24,6 +26,14 @@ export const content = {
|
||||
label: "Change TOTP link",
|
||||
placeHolder: "otpauth(-migration)://",
|
||||
},
|
||||
renameButtons: {
|
||||
rename: "Rename",
|
||||
renameIssuer: "Rename Issuer",
|
||||
renameClient: "Rename Client",
|
||||
},
|
||||
saveButton: {
|
||||
label: "Save",
|
||||
},
|
||||
deleteButton: {
|
||||
label: "Delete",
|
||||
},
|
||||
|
@ -31,7 +31,7 @@ function GetTOTPList(storage) {
|
||||
editingIndex = index;
|
||||
tempIssuer = element.issuer;
|
||||
tempClient = element.client;
|
||||
updateStorage(storage);
|
||||
_props.settingsStorage.setItem("requestUpdate", Math.random());
|
||||
},
|
||||
onSave: () => {
|
||||
storage[index].issuer = tempIssuer;
|
||||
@ -61,6 +61,7 @@ function GetTOTPList(storage) {
|
||||
updateStorage(storage);
|
||||
}
|
||||
},
|
||||
isEditInProgress: editingIndex !== -1,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -97,11 +98,6 @@ AppSettingsPage({
|
||||
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);
|
||||
@ -119,7 +115,9 @@ AppSettingsPage({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "10px",
|
||||
marginBottom: "10px",
|
||||
marginLeft: "10px",
|
||||
marginRight: "10px",
|
||||
fontSize: "20px",
|
||||
color: colors.text,
|
||||
borderRadius: "5px",
|
||||
@ -137,7 +135,7 @@ AppSettingsPage({
|
||||
},
|
||||
errorMessage,
|
||||
)
|
||||
: null; //TODO: Check for work
|
||||
: null;
|
||||
|
||||
const bottomContainer = View(
|
||||
{
|
||||
@ -151,8 +149,8 @@ AppSettingsPage({
|
||||
style: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
marginTop: "20px",
|
||||
marginBottom: "20px",
|
||||
marginTop: "10px",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
},
|
||||
Link(
|
||||
|
@ -14,6 +14,7 @@ export function createTOTPCard({
|
||||
onMoveDown,
|
||||
onIssuerChange,
|
||||
onClientChange,
|
||||
isEditInProgress,
|
||||
}) {
|
||||
const infoView = View(
|
||||
{
|
||||
@ -26,7 +27,7 @@ export function createTOTPCard({
|
||||
isEditing
|
||||
? [
|
||||
TextInput({
|
||||
label: "Rename Issuer",
|
||||
label: content.renameButtons.renameIssuer,
|
||||
value: tempIssuer,
|
||||
onChange: onIssuerChange,
|
||||
labelStyle: {
|
||||
@ -39,14 +40,14 @@ export function createTOTPCard({
|
||||
color: colors.text,
|
||||
borderRadius: "5px",
|
||||
height: "40px",
|
||||
width: "200px"
|
||||
width: "200px",
|
||||
},
|
||||
subStyle: {
|
||||
display: "none",
|
||||
},
|
||||
}),
|
||||
TextInput({
|
||||
label: "Rename client",
|
||||
label: content.renameButtons.renameClient,
|
||||
value: tempClient,
|
||||
onChange: onClientChange,
|
||||
labelStyle: {
|
||||
@ -59,7 +60,7 @@ export function createTOTPCard({
|
||||
color: colors.text,
|
||||
borderRadius: "5px",
|
||||
height: "40px",
|
||||
width: "200px"
|
||||
width: "200px",
|
||||
},
|
||||
subStyle: {
|
||||
display: "none",
|
||||
@ -89,7 +90,7 @@ export function createTOTPCard({
|
||||
isEditing
|
||||
? [
|
||||
Button({
|
||||
label: "Save",
|
||||
label: content.saveButton.label,
|
||||
style: {
|
||||
margin: "5px",
|
||||
backgroundColor: "#28a745",
|
||||
@ -100,7 +101,7 @@ export function createTOTPCard({
|
||||
]
|
||||
: [
|
||||
Button({
|
||||
label: "Rename",
|
||||
label: content.renameButtons.rename,
|
||||
style: {
|
||||
margin: "5px",
|
||||
backgroundColor: colors.notify,
|
||||
@ -108,15 +109,17 @@ export function createTOTPCard({
|
||||
},
|
||||
onClick: onRename,
|
||||
}),
|
||||
Button({
|
||||
label: "Delete",
|
||||
style: {
|
||||
margin: "5px",
|
||||
backgroundColor: colors.alert,
|
||||
color: colors.text,
|
||||
},
|
||||
onClick: onDelete,
|
||||
}),
|
||||
!isEditInProgress
|
||||
? Button({
|
||||
label: content.deleteButton.label,
|
||||
style: {
|
||||
margin: "5px",
|
||||
backgroundColor: colors.alert,
|
||||
color: colors.text,
|
||||
},
|
||||
onClick: onDelete,
|
||||
})
|
||||
: null,
|
||||
],
|
||||
);
|
||||
|
||||
@ -127,7 +130,6 @@ export function createTOTPCard({
|
||||
label: "⬆",
|
||||
disabled: index === 0,
|
||||
style: {
|
||||
width: "50px",
|
||||
margin: "2px",
|
||||
color: colors.text,
|
||||
backgroundColor: colors.notify,
|
||||
@ -138,7 +140,6 @@ export function createTOTPCard({
|
||||
label: "⬇",
|
||||
disabled: index === storage.length - 1,
|
||||
style: {
|
||||
width: "50px",
|
||||
margin: "2px",
|
||||
color: colors.text,
|
||||
backgroundColor: colors.notify,
|
||||
|
11
setting/utils/protonBackupExport.js
Normal file
@ -0,0 +1,11 @@
|
||||
export class ProtonBackupExport {
|
||||
version;
|
||||
entries = Array.of(ProtonTotpRecord);
|
||||
}
|
||||
|
||||
export class ProtonTotpRecord {
|
||||
content = {
|
||||
uri,
|
||||
entry_type,
|
||||
};
|
||||
}
|
@ -1,11 +1,139 @@
|
||||
import { decodeProto, TYPES } from "../../lib/protobuf-decoder/protobufDecoder";
|
||||
import { TOTP } from "../../lib/totp-quickjs";
|
||||
import { base64decode, encode } from "../../lib/totp-quickjs/base32decoder";
|
||||
import { ProtonBackupExport } from "./protonBackupExport";
|
||||
|
||||
const otpauthScheme = "otpauth://";
|
||||
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 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) {
|
||||
let str = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
|