From dd6a4c9e696b9b3a4988e44d94e6a416034d75a1 Mon Sep 17 00:00:00 2001 From: Theodosis Katsoulis Date: Thu, 5 Nov 2026 00:50:06 +0200 Subject: [PATCH] Sign Safari into Drive on top of the current extension. Safari still cannot finish the https callback itself, so the Mac app does. Dropbox and OneDrive stay hidden there. The vault-key rewrite from the old branch is gone, because dev already uses a different key model. --- .gitignore | 1 + manifests/manifest-safari.json | 76 ++++++++++++++++++++++++ package.json | 1 + scripts/build.sh | 10 ++-- src/argon.ts | 10 +++- src/background.ts | 92 ++++++++++++++++++++++++++++- src/browser.ts | 2 + src/components/Popup/BackupPage.vue | 21 ++++++- src/components/Popup/DrivePage.vue | 10 ++++ src/models/storage.ts | 33 +++++++++++ src/popup.ts | 75 ++++++++++++----------- 11 files changed, 285 insertions(+), 46 deletions(-) create mode 100644 manifests/manifest-safari.json diff --git a/.gitignore b/.gitignore index d2335a07e..9528693d0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ build chrome* firefox* edge* +safari* dist .vscode .atom-build.yml diff --git a/manifests/manifest-safari.json b/manifests/manifest-safari.json new file mode 100644 index 000000000..e4e650533 --- /dev/null +++ b/manifests/manifest-safari.json @@ -0,0 +1,76 @@ +{ + "manifest_version": 3, + "name": "__MSG_extName__", + "short_name": "__MSG_extShortName__", + "version": "8.0.2", + "default_locale": "en", + "description": "__MSG_extDesc__", + "icons": { + "16": "images/icon16.png", + "48": "images/icon48.png", + "128": "images/icon128.png" + }, + "action": { + "default_icon": { + "19": "images/icon19.png", + "38": "images/icon38.png" + }, + "default_title": "__MSG_extShortName__", + "default_popup": "view/popup.html" + }, + "commands": { + "_execute_action": {}, + "scan-qr": { + "description": "Scan a QR code" + }, + "autofill": { + "description": "Autofill the matched code" + } + }, + "options_ui": { + "page": "view/options.html", + "open_in_tab": false + }, + "storage": { + "managed_schema": "schema.json" + }, + "oauth2": { + "client_id": "292457304165-u8ve4j79gag5o231n5u2pdtdrbfdo1hh.apps.googleusercontent.com", + "scopes": [ + "https://www.googleapis.com/auth/drive.file" + ] + }, + "background": { + "service_worker": "dist/background.js" + }, + "sandbox": { + "pages": [ + "view/argon.html" + ] + }, + "permissions": [ + "activeTab", + "storage", + "identity", + "alarms", + "scripting" + ], + "host_permissions": [ + "https://www.google.com/", + "https://www.googleapis.com/*", + "https://accounts.google.com/*" + ], + "optional_permissions": [ + "clipboardWrite", + "contextMenus" + ], + "optional_host_permissions": [ + "https://*.dropboxapi.com/*", + "https://graph.microsoft.com/me/*", + "https://login.microsoftonline.com/common/oauth2/v2.0/token" + ], + "content_security_policy": { + "extension_pages": "script-src 'self'; font-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src https://www.google.com/ https://*.dropboxapi.com https://www.googleapis.com/ https://accounts.google.com/ https://oauth2.googleapis.com/ https://login.microsoftonline.com/common/oauth2/v2.0/token https://graph.microsoft.com/; default-src 'none'", + "sandbox": "sandbox allow-scripts; script-src 'self' 'unsafe-eval';" + } +} diff --git a/package.json b/package.json index 4970fa5be..f1cdd4cc2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "compile": "bash scripts/build.sh firefox", "dev:chrome": "npm run pretest && webpack --config ./webpack.watch.js", "chrome": "bash scripts/build.sh chrome", + "safari": "bash scripts/build.sh safari", "firefox": "bash scripts/build.sh firefox", "edge": "bash scripts/build.sh edge", "prod": "bash scripts/build.sh prod", diff --git a/scripts/build.sh b/scripts/build.sh index 3da00c0e2..80a324976 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -13,14 +13,14 @@ CREDREGEX='^.*".+".*".+".*".+".*".+".*".+".*$' STYLEFILES="./src/* ./src/**/* ./src/**/**/* ./src/**/**/**/* ./sass/*.scss" set -e -if [[ $PLATFORM != "chrome" ]] && [[ $PLATFORM != "firefox" ]] && [[ $PLATFORM != "edge" ]] && [[ $PLATFORM != "prod" ]] && [[ $PLATFORM != "test" ]]; then - echo "Invalid platform type. Supported platforms are 'chrome', 'firefox', 'test', and 'prod'" +if [[ $PLATFORM != "chrome" ]] && [[ $PLATFORM != "firefox" ]] && [[ $PLATFORM != "edge" ]] && [[ $PLATFORM != "safari" ]] && [[ $PLATFORM != "prod" ]] && [[ $PLATFORM != "test" ]]; then + echo "Invalid platform type. Supported platforms are 'chrome', 'firefox', 'safari', 'test', and 'prod'" exit 1 fi echo "Removing old build files..." rm -rf build dist -rm -rf firefox chrome edge release test +rm -rf firefox chrome edge safari release test echo "Checking style..." if ./node_modules/.bin/prettier --check $STYLEFILES 1> /dev/null ; then true @@ -39,7 +39,7 @@ if ! [[ $CREDS =~ $CREDREGEX ]] ; then fi fi -if ! [[ $REMOTE = *"https://github.com/Authenticator-Extension/Authenticator.git"* || $REMOTE = *"git@github.com:Authenticator-Extension/Authenticator.git"* || $CI ]] ; then +if [[ $PLATFORM != "safari" ]] && ! [[ $REMOTE = *"https://github.com/Authenticator-Extension/Authenticator.git"* || $REMOTE = *"git@github.com:Authenticator-Extension/Authenticator.git"* || $CI ]] ; then echo echo -e "\e[7m\033[33mNotice\033[0m" echo @@ -80,7 +80,7 @@ postCompile () { cp manifests/manifest-$1.json $1/manifest.json fi - if [[ $1 = "chrome" ]] || [[ $1 = "edge" ]]; then + if [[ $1 = "chrome" ]] || [[ $1 = "edge" ]] || [[ $1 = "safari" ]]; then cp manifests/schema-chrome.json $1/schema.json fi diff --git a/src/argon.ts b/src/argon.ts index ea27b3967..5693cabb2 100644 --- a/src/argon.ts +++ b/src/argon.ts @@ -1,10 +1,18 @@ import argon2 from "argon2-browser"; +function isExtensionOrigin(origin: string): boolean { + return ( + origin.indexOf("chrome-extension://") === 0 || + origin.indexOf("safari-web-extension://") === 0 || + origin.indexOf("moz-extension://") === 0 + ); +} + window.addEventListener("message", (event) => { const message = event.data; const source = event.source as Window; - if (!source) { + if (!source || !isExtensionOrigin(event.origin)) { return; } diff --git a/src/background.ts b/src/background.ts index bb59a2f9d..fb78749eb 100644 --- a/src/background.ts +++ b/src/background.ts @@ -11,7 +11,13 @@ import { import { CodeState } from "./models/otp"; import { getOTPAuthPerLineFromOPTAuthMigration } from "./models/migration"; -import { isChrome, isFirefox } from "./browser"; +import { + containingAppId, + driveRedirect, + isChrome, + isFirefox, + isSafari, +} from "./browser"; import { UserSettings } from "./models/settings"; let contentTab: chrome.tabs.Tab | undefined; @@ -19,6 +25,18 @@ let contentTab: chrome.tabs.Tab | undefined; chrome.runtime.onMessage.addListener(async (message, sender) => { await UserSettings.updateItems(); + if ( + message.action === "cachePassphrase" || + message.action === "lock" || + message.action === "dropbox" || + message.action === "drive" || + message.action === "onedrive" + ) { + if (sender.id !== chrome.runtime.id || sender.tab) { + return true; + } + } + if (message.action === "getCapture") { if (!sender.tab) { return; @@ -240,7 +258,79 @@ async function getTotp(text: string, silent = false) { } } +async function exchangeDriveCode(code: string, encodedRedirect: string) { + const response = await fetch( + "https://www.googleapis.com/oauth2/v4/token?client_id=" + + getCredentials().drive.client_id + + "&client_secret=" + + getCredentials().drive.client_secret + + "&code=" + + code + + "&redirect_uri=" + + encodedRedirect + + "&grant_type=authorization_code", + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + const res = await response.json(); + if (res.error) { + console.error(res.error_description); + return false; + } + UserSettings.items.driveToken = res.access_token; + UserSettings.items.driveRefreshToken = res.refresh_token; + await UserSettings.commitItems(); + return true; +} + +function signInDriveSafari() { + const encodedRedirect = encodeURIComponent(driveRedirect); + const authUrl = + "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&access_type=offline&client_id=" + + getCredentials().drive.client_id + + "&scope=https%3A//www.googleapis.com/auth/drive.file&prompt=consent&redirect_uri=" + + encodedRedirect; + chrome.runtime.sendNativeMessage( + containingAppId, + { action: "drive-auth", url: authUrl }, + async (response: { ok?: boolean; url?: string }) => { + if (!response || !response.ok || !response.url) { + return; + } + let callback: URL; + try { + callback = new URL(response.url); + } catch (error) { + console.error(error); + return; + } + if (callback.origin + callback.pathname !== driveRedirect) { + return; + } + const code = callback.searchParams.get("code"); + if (!code) { + return; + } + const exchanged = await exchangeDriveCode(code, encodedRedirect); + if (!exchanged) { + return; + } + chrome.runtime.sendMessage({ action: "drivetoken" }); + uploadBackup("drive"); + } + ); +} + function getBackupToken(service: string) { + if (isSafari && service === "drive") { + signInDriveSafari(); + return; + } if (isChrome && service === "drive") { chrome.identity.getAuthToken( { diff --git a/src/browser.ts b/src/browser.ts index c8b9fc7a2..ab1772c36 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -4,6 +4,8 @@ export const isEdge = navigator.userAgent.indexOf("Edg") >= 0; export const isChromium = navigator.userAgent.indexOf("Chrome") >= 0; export const isSafari = !isChromium && navigator.userAgent.indexOf("Safari") >= 0; +export const containingAppId = "com.yourCompany.Authenticator"; +export const driveRedirect = "https://authenticator.cc/oauth"; export const isChrome = navigator.userAgent.indexOf("Chrome") !== -1 && navigator.userAgent.indexOf("Edg") === -1; diff --git a/src/components/Popup/BackupPage.vue b/src/components/Popup/BackupPage.vue index 8e718b46b..358d8765d 100644 --- a/src/components/Popup/BackupPage.vue +++ b/src/components/Popup/BackupPage.vue @@ -57,14 +57,24 @@ {{ i18n.import_backup }}
-
+
{{ i18n.storage_sync_info }}

Google Drive - OneDrive - Dropbox + + OneDrive + + + Dropbox +
@@ -119,6 +129,11 @@ export default Vue.extend({ ); return; } else if (tab === "DrivePage") { + if (isSafari) { + this.$store.commit("style/showInfo"); + this.$store.commit("currentView/changeView", tab); + return; + } chrome.permissions.request( { origins: [ diff --git a/src/components/Popup/DrivePage.vue b/src/components/Popup/DrivePage.vue index 351a61bbf..0e74a05b5 100644 --- a/src/components/Popup/DrivePage.vue +++ b/src/components/Popup/DrivePage.vue @@ -100,6 +100,7 @@ export default Vue.extend({ xhr.send(); }); UserSettings.removeItem("driveToken"); + UserSettings.removeItem("driveRefreshToken"); this.$store.commit("backup/setToken", { service, value: false }); this.$store.commit("style/hideInfo"); }, @@ -130,6 +131,15 @@ export default Vue.extend({ if (this.backupToken) { this.email = await this.getUser(); } + chrome.runtime.onMessage.addListener((message) => { + if (message.action !== "drivetoken") { + return; + } + this.$store.commit("backup/setToken", { service, value: true }); + this.getUser().then((email: string) => { + this.email = email; + }); + }); }, }); diff --git a/src/models/storage.ts b/src/models/storage.ts index 4b3699fb2..ad92edba4 100644 --- a/src/models/storage.ts +++ b/src/models/storage.ts @@ -1,10 +1,43 @@ import { Encryption } from "./encryption"; import { OTPEntry, OTPType, OTPAlgorithm, CodeState } from "./otp"; +import { isSafari } from "../browser"; import { StorageLocation, UserSettings } from "./settings"; import { DataType } from "./otp"; export class BrowserStorage { + private static async migrateSyncToLocal(): Promise { + const flag = await chrome.storage.local.get("safariStorageMigrated"); + if (flag.safariStorageMigrated === true) { + return; + } + const sync = await chrome.storage.sync.get(); + const syncKeys = Object.keys(sync); + const local = await chrome.storage.local.get(); + const localKeys = Object.keys(local).filter( + (key) => key !== "safariStorageMigrated" + ); + if (syncKeys.length === 0 || localKeys.length > 0) { + await chrome.storage.local.set({ safariStorageMigrated: true }); + return; + } + await chrome.storage.local.set(sync); + const copied = await chrome.storage.local.get(syncKeys); + if (Object.keys(copied).length !== syncKeys.length) { + return; + } + await chrome.storage.sync.remove(syncKeys); + await chrome.storage.local.set({ safariStorageMigrated: true }); + } + private static async getStorageLocation(): Promise { await UserSettings.updateItems(); + if (isSafari) { + await this.migrateSyncToLocal(); + if (UserSettings.items.storageLocation !== StorageLocation.Local) { + UserSettings.items.storageLocation = StorageLocation.Local; + await UserSettings.commitItems(); + } + return StorageLocation.Local; + } const managedLocation = await ManagedStorage.get( "storageArea" ); diff --git a/src/popup.ts b/src/popup.ts index 173bc033e..44484cf5c 100644 --- a/src/popup.ts +++ b/src/popup.ts @@ -18,6 +18,7 @@ import { Notification } from "./store/Notification"; import { Qr } from "./store/Qr"; import { Advisor } from "./store/Advisor"; import { Dropbox, Drive, OneDrive } from "./models/backup"; +import { isSafari } from "./browser"; import { syncTimeWithGoogle } from "./syncTime"; import { StorageLocation, UserSettings } from "./models/settings"; @@ -241,46 +242,48 @@ async function runScheduledBackup(clientTime: number, instance: Vue) { ); } if (instance.$store.state.backup.driveToken) { - chrome.permissions.contains( - { - origins: [ - "https://www.googleapis.com/*", - "https://accounts.google.com/o/oauth2/revoke", - ], - }, - async (hasPermission) => { - if (hasPermission) { - try { - const drive = new Drive(); - const res = await drive.upload( - instance.$store.state.accounts.encryption.get( - instance.$store.state.accounts.defaultEncryption - ) + const uploadDrive = async (hasPermission: boolean) => { + if (hasPermission) { + try { + const drive = new Drive(); + const res = await drive.upload( + instance.$store.state.accounts.encryption.get( + instance.$store.state.accounts.defaultEncryption + ) + ); + if (res) { + UserSettings.items.lastRemindingBackupTime = clientTime; + UserSettings.commitItems(); + return; + } else if (UserSettings.items.driveRevoked === true) { + instance.$store.commit( + "notification/alert", + chrome.i18n.getMessage("token_revoked", ["Google Drive"]) ); - if (res) { - UserSettings.items.lastRemindingBackupTime = clientTime; - UserSettings.commitItems(); - return; - } else if (UserSettings.items.driveRevoked === true) { - instance.$store.commit( - "notification/alert", - chrome.i18n.getMessage("token_revoked", ["Google Drive"]) - ); - UserSettings.items.driveRevoked = undefined; - UserSettings.removeItem("driveRevoked"); - } - } catch (error) { - // ignore + UserSettings.items.driveRevoked = undefined; + UserSettings.removeItem("driveRevoked"); } + } catch (error) { + // ignore } - instance.$store.commit( - "notification/alert", - instance.i18n.remind_backup - ); - UserSettings.items.lastRemindingBackupTime = clientTime; - UserSettings.commitItems(); } - ); + instance.$store.commit("notification/alert", instance.i18n.remind_backup); + UserSettings.items.lastRemindingBackupTime = clientTime; + UserSettings.commitItems(); + }; + if (isSafari) { + uploadDrive(true); + } else { + chrome.permissions.contains( + { + origins: [ + "https://www.googleapis.com/*", + "https://accounts.google.com/o/oauth2/revoke", + ], + }, + uploadDrive + ); + } } if (instance.$store.state.backup.oneDriveToken) { chrome.permissions.contains(