From 8065d5f584ab9c87d28b6aa99bb9837a90cb2d7f Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 18 Dec 2020 00:47:51 +0800 Subject: [PATCH] Add otpauth migration support (#560) * add otpauth migration support * bugfix for word array to byte array --- src/components/Import/QrImport.vue | 10 ++- src/import.ts | 132 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/src/components/Import/QrImport.vue b/src/components/Import/QrImport.vue index 562b3018a..7baa32c79 100644 --- a/src/components/Import/QrImport.vue +++ b/src/components/Import/QrImport.vue @@ -127,7 +127,10 @@ async function getOtpUrlFromQrFile(file: File): Promise { ); if (jsQrCode && jsQrCode.data) { - if (jsQrCode.data.indexOf("otpauth://") !== 0) { + if ( + jsQrCode.data.indexOf("otpauth://") !== 0 && + jsQrCode.data.indexOf("otpauth-migration://") !== 0 + ) { return resolve(null); } return resolve(jsQrCode.data); @@ -137,7 +140,10 @@ async function getOtpUrlFromQrFile(file: File): Promise { }; image.src = imageUrl; } else { - if (text.result.indexOf("otpauth://") !== 0) { + if ( + text.result.indexOf("otpauth://") !== 0 && + text.result.indexOf("otpauth-migration://") !== 0 + ) { return resolve(null); } return resolve(text.result); diff --git a/src/import.ts b/src/import.ts index 2613429e0..495301b7f 100644 --- a/src/import.ts +++ b/src/import.ts @@ -91,11 +91,143 @@ export function decryptBackupData( return decryptedbackupData; } +function byteArray2Base32(bytes: number[]) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + const len = bytes.length; + let result = ""; + let high = 0, + low = 0, + sh = 0; + for (let i = 0; i < len; i += 5) { + high = 0xf8 & bytes[i]; + result += chars.charAt(high >> 3); + low = 0x07 & bytes[i]; + sh = 2; + + if (i + 1 < len) { + high = 0xc0 & bytes[i + 1]; + result += chars.charAt((low << 2) + (high >> 6)); + result += chars.charAt((0x3e & bytes[i + 1]) >> 1); + low = bytes[i + 1] & 0x01; + sh = 4; + } + + if (i + 2 < len) { + high = 0xf0 & bytes[i + 2]; + result += chars.charAt((low << 4) + (high >> 4)); + low = 0x0f & bytes[i + 2]; + sh = 1; + } + + if (i + 3 < len) { + high = 0x80 & bytes[i + 3]; + result += chars.charAt((low << 1) + (high >> 7)); + result += chars.charAt((0x7c & bytes[i + 3]) >> 2); + low = 0x03 & bytes[i + 3]; + sh = 3; + } + + if (i + 4 < len) { + high = 0xe0 & bytes[i + 4]; + result += chars.charAt((low << 3) + (high >> 5)); + result += chars.charAt(0x1f & bytes[i + 4]); + low = 0; + sh = 0; + } + } + + if (low != 0) { + result += chars.charAt(low << sh); + } + + const padlen = 8 - (result.length % 8); + return result + (padlen < 8 ? Array(padlen + 1).join("=") : ""); +} + +function wordArrayToByteArray(wordArray: CryptoJS.lib.WordArray) { + const byteArray: number[] = []; + for (let i = 0; i < wordArray.words.length; ++i) { + const word = wordArray.words[i]; + for (let j = 3; j >= 0; --j) { + byteArray.push((word >> (8 * j)) & 0xff); + } + } + byteArray.length = wordArray.sigBytes; + return byteArray; +} + +function byteArray2String(bytes: number[]) { + return String.fromCharCode.apply(null, bytes); +} + +function subBytesArray(bytes: number[], start: number, length: number) { + const subBytes: number[] = []; + for (let i = 0; i < length; i++) { + subBytes.push(bytes[start + i]); + } + return subBytes; +} + +function getOTPAuthPerLineFromOPTAuthMigration(migrationUri: string) { + if (!migrationUri.startsWith("otpauth-migration:")) { + return []; + } + + const base64Data = decodeURIComponent(migrationUri.split("data=")[1]); + const wordArrayData = CryptoJS.enc.Base64.parse(base64Data); + const byteData = wordArrayToByteArray(wordArrayData); + const lines: string[] = []; + let offset = 0; + while (offset < byteData.length) { + if (byteData[offset] !== 10) { + break; + } + const lineLength = byteData[offset + 1]; + const secretStart = offset + 4; + const secretLength = byteData[offset + 3]; + const secretBytes = subBytesArray(byteData, secretStart, secretLength); + const secret = byteArray2Base32(secretBytes); + const accountStart = secretStart + secretLength + 2; + const accountLength = byteData[secretStart + secretLength + 1]; + const accountBytes = subBytesArray(byteData, accountStart, accountLength); + const account = byteArray2String(accountBytes); + const isserStart = accountStart + accountLength + 2; + const isserLength = byteData[accountStart + accountLength + 1]; + const issuerBytes = subBytesArray(byteData, isserStart, isserLength); + const issuer = byteArray2String(issuerBytes); + const algorithm = ["SHA1", "SHA1", "SHA256", "SHA512", "MD5"][ + byteData[isserStart + isserLength + 1] + ]; + const digits = [6, 6, 8][byteData[isserStart + isserLength + 3]]; + const type = ["totp", "hotp", "totp"][ + byteData[isserStart + isserLength + 5] + ]; + let line = `otpauth://${type}/${account}?secret=${secret}&issuer=${issuer}&algorithm=${algorithm}&digits=${digits}`; + if (type === "hotp") { + let counter = 1; + if (isserStart + isserLength + 7 <= lineLength) { + counter = byteData[isserStart + isserLength + 7]; + } + line += `&counter=${counter}`; + } + lines.push(line); + offset += lineLength + 2; + } + return lines; +} + export async function getEntryDataFromOTPAuthPerLine(importCode: string) { const lines = importCode.split("\n"); const exportData: { [hash: string]: OTPStorage } = {}; for (let item of lines) { item = item.trim(); + if (item.startsWith("otpauth-migration:")) { + const migrationData = getOTPAuthPerLineFromOPTAuthMigration(item); + for (const line of migrationData) { + lines.push(line); + } + continue; + } if (!item.startsWith("otpauth:")) { continue; }