From c10ac9d69799f31af067075c1195a61967c6144c Mon Sep 17 00:00:00 2001
From: retsl <retsl@lets.re>
Date: Sun, 3 Nov 2024 12:13:38 +0100
Subject: [PATCH] implement export

---
 background.js     | 315 +++++++++++++++++++++++++++++++++++++++-------
 content_script.js |  13 +-
 manifest.json     |   3 +-
 page_popup.html   |   1 +
 page_popup.js     |   3 +
 5 files changed, 284 insertions(+), 51 deletions(-)

diff --git a/background.js b/background.js
index b0c92f3..10b48f7 100644
--- a/background.js
+++ b/background.js
@@ -1,3 +1,5 @@
+var g_entries = new Array();
+var g_passwords = new Array();
 var pwlog = void 0,
 	pwerror = void 0;
 function isStringEmpty(e) {
@@ -1416,10 +1418,22 @@ function connectToBackgroundNativeAppAndSetUpListeners() {
 						g_tabMonitor.frameIsReadyToReceiveMessages(a, n);
 				}
 		});
+	function logNativeMessage(direction, message) {
+		const timestamp = new Date().toISOString();
+		const logEntry = {
+			timestamp,
+			direction,
+			command: cmd2string(message.cmd),
+			details: JSON.parse(JSON.stringify(message)), // Deep clone to handle circular references
+		};
+		console.log(JSON.stringify(logEntry));
+	}
+	var originalPostMessage;
 	try {
 		(g_nativeAppPort = chrome.runtime.connectNative(
 			"com.apple.passwordmanager",
 		)).onMessage.addListener(async function (e) {
+			// logNativeMessage("←", e);
 			switch (
 				(JSON.stringify(e),
 				chrome.storage.local.remove(["lastRetryTimestamp"]),
@@ -1494,35 +1508,16 @@ function connectToBackgroundNativeAppAndSetUpListeners() {
 						case QueryStatus.Success: {
 							performance.now();
 							var l = entriesFromLoginNames4URLData(d);
-							l.sort((e, t) =>
-								e.USR.localeCompare(t.USR, void 0, {
-									numeric: !0,
-									sensitivity: "base",
-								}),
-							),
-								l.forEach((e) => {
-									if ("Passwords not saved" === e.USR)
-										t = RememberIC.DoNotRemember;
-									else if (
-										(s.push(e.USR),
-										a.push(e.CDate || e.ModDate),
-										n.push(e.sites[0]),
-										"Not Included" === e.PWD)
-									)
-										t = RememberIC.RememberLoginAndPassword;
-									else t = RememberIC.UnknownPage;
+							// for each entry, store what we need for the
+							// CmdGetPassword4LoginName call in a global array
+							l.forEach((entry) => {
+								const sortedSites = Array.isArray(entry.sites)
+									? entry.sites.sort()
+									: [entry.sites];
+								g_entries.push({
+									site: sortedSites[0],
+									username: entry.USR,
 								});
-							const e = !!d.RequiresUserAuthenticationToFill;
-							sendMessageToPopupAndCompletionList({
-								from: "background",
-								subject: "users",
-								arrLoginNames: s,
-								arrDates: a,
-								arrHLDs: n,
-								tabId: o,
-								frameId: i,
-								theRememberICSelection: t,
-								requiresUserAuthenticationToFill: e,
 							});
 							break;
 						}
@@ -1543,6 +1538,7 @@ function connectToBackgroundNativeAppAndSetUpListeners() {
 					}
 					break;
 				case 5:
+					// Instead of AutoFilling, add the entry (now with password) to g_passwords
 					var d;
 					(r = e.payload), (c = g_secretSession.parseSMSG(r.SMSG));
 					switch ((d = JSON.parse(c)).STATUS) {
@@ -1551,14 +1547,17 @@ function connectToBackgroundNativeAppAndSetUpListeners() {
 								s = entriesFromLoginNames4URLData(d).find((e) =>
 									e.sites.includes(t),
 								);
-							s &&
-								sendUsernameAndPasswordToFrameForFilling(
-									e.tabId,
-									e.frameId,
-									t,
-									s.USR,
-									s.PWD,
-								);
+							var newPw = {
+								sites: s.sites,
+								username: s.USR,
+								password: s.PWD,
+								notes: s.NOTES,
+								name: s.HLD,
+								createdDate: s.CDate,
+								modifiedDate: s.ModDate,
+							};
+							console.log("hld " + s.HLD);
+							g_passwords.push(newPw);
 							break;
 						}
 						case QueryStatus.NoResults:
@@ -1628,7 +1627,12 @@ function connectToBackgroundNativeAppAndSetUpListeners() {
 								resetTheSession(ContextState.NativeSupportNotInstalled);
 					}, 1e3);
 			}),
-			g_nativeAppPort.postMessage({ cmd: 14 });
+			(originalPostMessage = g_nativeAppPort.postMessage.bind(g_nativeAppPort));
+		g_nativeAppPort.postMessage = function (message) {
+			// logNativeMessage("→", message);
+			return originalPostMessage(message);
+		};
+		g_nativeAppPort.postMessage({ cmd: 14 });
 	} catch (e) {
 		resetTheSession(ContextState.NativeSupportNotInstalled);
 	}
@@ -1734,14 +1738,14 @@ async function getUsernamesForMainFrameOfActiveTab() {
 		0,
 	);
 }
-function GetLoginNames4URL(e, t, s) {
-	let a = JSON.stringify({ ACT: actGhostSearch, URL: e }),
+function GetLoginNames4URL(url, tabId, frameId) {
+	let a = JSON.stringify({ ACT: actGhostSearch, URL: url }),
 		n = g_secretSession.createSMSG(a),
 		r = {
 			cmd: 4,
-			url: e,
-			tabId: t,
-			frameId: s,
+			url: url,
+			tabId: tabId,
+			frameId: frameId,
 			payload: JSON.stringify({ QID: "CmdGetLoginNames4URL", SMSG: n }),
 		};
 	(g_timeStartedFetchingCredentials = performance.now()),
@@ -1960,6 +1964,9 @@ function setUpPortToPopup(e) {
 				case "SetUpTOTP":
 					setUpTOTP(t.theURL, t.theTOTPURI);
 					break;
+				case "export":
+					export_pws();
+					break;
 				case "openURLInSafari":
 					openURLInSafari(t.url);
 			}
@@ -2014,3 +2021,227 @@ chrome.storage.local.get("isDark", function (e) {
 					? setUpPortToPopup(e)
 					: e.name;
 	});
+
+// Triggered by the user clicking the export button in the UI
+// That's only ever done after the challenge was completed
+async function export_pws() {
+	if (g_theState !== ContextState.SessionKeySet) {
+		console.error("Not in valid session state for export");
+		return;
+	}
+
+	// Create a promise that will resolve when we get credentials
+	const getCredentialsPromise = new Promise((resolve) => {
+		// Create a new listener that will intercept the credentials response
+		const messageListener = async function (response) {
+			// Check if this is a credentials response (cmd 4)
+			if (response.cmd === 4) {
+				try {
+					const smsg = response.payload;
+					const decrypted = g_secretSession.parseSMSG(smsg.SMSG);
+					const data = JSON.parse(decrypted);
+
+					if (data.STATUS === QueryStatus.Success) {
+						const entries = entriesFromLoginNames4URLData(data);
+						// Remove the temporary listener
+						g_nativeAppPort.onMessage.removeListener(messageListener);
+						resolve(entries);
+					} else {
+						g_nativeAppPort.onMessage.removeListener(messageListener);
+						resolve([]); // No entries found
+					}
+				} catch (error) {
+					console.error("Error processing credentials:", error);
+					g_nativeAppPort.onMessage.removeListener(messageListener);
+					resolve([]);
+				}
+			}
+		};
+
+		// Add the temporary listener
+		g_nativeAppPort.onMessage.addListener(messageListener);
+
+		// Request all credentials
+		const payload = JSON.stringify({ ACT: actGhostSearch, URL: "" });
+		const encryptedPayload = g_secretSession.createSMSG(payload);
+		const message = {
+			cmd: 4,
+			url: "",
+			tabId: 323205610,
+			frameId: 0,
+			payload: JSON.stringify({
+				QID: "CmdGetLoginNames4URL",
+				SMSG: encryptedPayload,
+			}),
+		};
+
+		// Send the request
+		g_nativeAppPort.postMessage(message);
+
+		// Timeout safeguard
+		setTimeout(() => {
+			g_nativeAppPort.onMessage.removeListener(messageListener);
+			resolve([]); // Resolve with empty array if timeout
+		}, 5000);
+	});
+
+	try {
+		// Wait until credentials are added to global hashmap
+		await getCredentialsPromise;
+
+		// Wait for all password requests to complete
+		// Implementing a loop to check if the length of g_passwords has changed
+		var lastLength = g_entries.length;
+		while (true) {
+			await new Promise((r) => setTimeout(r, 2000)); // Wait
+			if (g_entries.length === lastLength) break; // Exit loop if there was no change
+			lastLength = g_entries.size;
+		}
+
+		// Request passwords for all entries
+		const passwordPromises = [];
+		for (const entry of g_entries) {
+			// Only request password if we don't already have it
+			if (!entry.password) {
+				const payload = JSON.stringify({
+					ACT: actSearch,
+					URL: entry.site, // Use first site as URL
+					USR: entry.username,
+				});
+				const encryptedPayload = g_secretSession.createSMSG(payload);
+				const message = {
+					cmd: 5,
+					url: entry.site,
+					tabId: 323205610,
+					frameId: 0,
+					payload: JSON.stringify({
+						QID: "CmdGetPassword4LoginName",
+						SMSG: encryptedPayload,
+					}),
+				};
+
+				// Create promise for this password request
+				const passwordPromise = new Promise((resolve) => {
+					const messageListener = (response) => {
+						if (response.cmd === 5) {
+							g_nativeAppPort.onMessage.removeListener(messageListener);
+							resolve();
+						}
+					};
+					g_nativeAppPort.onMessage.addListener(messageListener);
+					g_nativeAppPort.postMessage(message);
+				});
+
+				passwordPromises.push(passwordPromise);
+			}
+		}
+
+		await Promise.all(passwordPromises);
+		// Wait for all password requests to complete
+		// Implementing a loop to check if the length of g_passwords has changed
+		var lastLength = g_passwords.length;
+		while (true) {
+			await new Promise((r) => setTimeout(r, 2000)); // Wait
+			if (g_passwords.length === lastLength) break; // Exit loop if there was no change
+			lastLength = g_passwords.length;
+		}
+		console.log("Done waiting!");
+		console.log("Total entries: " + g_entries.length);
+		console.log("Entries we received paswords for: " + g_passwords.length);
+
+		var cleaned_passwords = new Array();
+		for (const entry of g_passwords) {
+			if (entry.username === "Passwords not saved") {
+				continue;
+			}
+			// Ensure entry.sites is an array and handle empty cases
+			const sites = Array.isArray(entry.sites)
+				? entry.sites
+				: [entry.sites || []];
+			// Prepend https:// to each site URL if it doesn't already have a protocol
+			// Otherwise, Bitwarden adds http:// during import
+			uri_array = sites.map((site) => {
+				if (!site.startsWith("http://") && !site.startsWith("https://")) {
+					return "https://" + site;
+				}
+				return site;
+			});
+			entry.uri_list = uri_array.length > 0 ? uri_array.join(",") : "";
+			// try to keep unique names
+			if (cleaned_passwords.some((existing) => existing.name === entry.name)) {
+				entry.name = sites[0];
+			}
+			if (cleaned_passwords.some((existing) => existing.name === entry.name)) {
+				entry.name = sites[0] + "-" + entry.username;
+			}
+			var i = 1;
+			while (
+				cleaned_passwords.some((existing) => existing.name === entry.name)
+			) {
+				entry.name = sites[0] + "-" + entry.username + "-" + i;
+				i++;
+			}
+			cleaned_passwords.push(entry);
+		}
+
+		// Create CSV content
+		const csvHeader =
+			"folder,favorite,type,name,notes,fields,reprompt,login_uri,login_username,login_password,login_totp\n";
+		const csvRows = [];
+		for (const entry of cleaned_passwords) {
+			const row = [
+				"", // folder
+				"", // favorite
+				"login", // type
+				entry.name, // name
+				entry.notes, // notes
+				"", // fields
+				"", // reprompt
+				entry.uri_list, // login_uri
+				entry.username || "", // login_username
+				entry.password || "", // login_password
+				"", // login_totp
+			].map((field) => {
+				// Escape quotes and wrap in quotes if contains comma or newline
+				if (
+					field.includes(",") ||
+					field.includes("\n") ||
+					field.includes('"')
+				) {
+					return `"${field.replace(/"/g, '""')}"`;
+				}
+				return field;
+			});
+
+			csvRows.push(row.join(","));
+		}
+
+		const csvContent = csvHeader + csvRows.join("\n");
+
+		// Create a data URL from the CSV content
+		const byteCharacters = new TextEncoder().encode(csvContent);
+		const base64Content = btoa(String.fromCharCode(...byteCharacters)); // Convert byte array to base64
+		const dataUrl = `data:text/csv;base64,${base64Content}`;
+
+		// Generate filename with current date
+		const date = new Date().toISOString().split("T")[0]; // Format: YYYY-MM-DD
+		const filename = `passwords_export_${date}.csv`;
+
+		// Use Chrome's download API to save the file
+		chrome.downloads.download(
+			{
+				url: dataUrl,
+				filename: filename,
+				saveAs: true, // Shows the save dialog
+			},
+			(downloadId) => {
+				if (chrome.runtime.lastError) {
+					console.error("Download failed:", chrome.runtime.lastError);
+				}
+			},
+		);
+	} catch (error) {
+		console.error("Error during export:", JSON.stringify(error));
+		console.error("Error during export:", error);
+	}
+}
diff --git a/content_script.js b/content_script.js
index 82aa86d..fc0e7cb 100644
--- a/content_script.js
+++ b/content_script.js
@@ -6649,8 +6649,7 @@ function innermostActiveElement() {
 			(e._isVisible = -visibilityCacheGeneration),
 			"none" !== t.display &&
 				"visible" === t.visibility &&
-				!(r.offsetWidth < 2) &&
-				!(r.offsetHeight < 2) &&
+				!(r.offsetWidth < 2) && !(r.offsetHeight < 2) &&
 				((e._isVisible = visibilityCacheGeneration), !0)
 		);
 	}),
@@ -7925,8 +7924,7 @@ function innermostActiveElement() {
 		_isEditablePlainTextField: function (e) {
 			return (
 				!!isInputElement(e) &&
-				!e.disabled &&
-				!e.readOnly &&
+				!e.disabled && !e.readOnly &&
 				(!e.type || "text" === e.type)
 			);
 		},
@@ -9934,8 +9932,7 @@ function innermostActiveElement() {
 			for (var n = e.elements, a = n.length, i = 0; i < a; ++i) {
 				var o = n[i];
 				o.isVisible() &&
-					null != o.value &&
-					o.value.length &&
+					null != o.value && o.value.length &&
 					(this._isTextField(o) ? r.push(o) : this._isTextArea(o) && t.push(o));
 			}
 		},
@@ -21260,8 +21257,8 @@ function shouldOfferCompletionListForField(e, r, t) {
 						!t.PasswordElementUniqueID ||
 						t.PasswordElementUniqueID !== r.ControlUniqueID
 					) ||
-					!!r.ControlIsLabeledUsernameField ||
-					e === g_usernameControls.ForCompletionList ||
+						!!r.ControlIsLabeledUsernameField ||
+							e === g_usernameControls.ForCompletionList ||
 					e === g_passwordControls.ForCompletionList))
 	);
 }
diff --git a/manifest.json b/manifest.json
index 1831d27..a8e718a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -25,7 +25,8 @@
         "webNavigation",
         "storage",
         "contextMenus",
-        "scripting"
+        "scripting",
+        "downloads"
     ],
     "host_permissions": [
         "*://*/*"
diff --git a/page_popup.html b/page_popup.html
index b64d34c..efccdb6 100644
--- a/page_popup.html
+++ b/page_popup.html
@@ -37,6 +37,7 @@
             <ul class="credential-list" id="credentialList"></ul>
         </div>
         <a id="dismiss" class="dismiss" href="#"><img src="images/close.svg"></a>
+        <a id="export" class="export" href="#"><img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNoYXJlIj48cGF0aCBkPSJNNCAxMnY4YTIgMiAwIDAgMCAyIDJoMTJhMiAyIDAgMCAwIDItMnYtOCIvPjxwb2x5bGluZSBwb2ludHM9IjE2IDYgMTIgMiA4IDYiLz48bGluZSB4MT0iMTIiIHgyPSIxMiIgeTE9IjIiIHkyPSIxNSIvPjwvc3ZnPg=="></a>
         <!--p id="DebugOutput"></p-->
     </body>
     <script src="utils.js"></script>
diff --git a/page_popup.js b/page_popup.js
index dc624fe..9f54426 100644
--- a/page_popup.js
+++ b/page_popup.js
@@ -825,6 +825,9 @@ document.documentElement.addEventListener("keydown", function (e) {
 	(document.getElementById("dismiss").onclick = function () {
 		window.close();
 	}),
+	(document.getElementById("export").onclick = function () {
+		sendMessageToBackgroundPage({ subject: "export" });
+	}),
 	(window.onfocus = () => {
 		sendMessageToBackgroundPage({ subject: "getInitialPopupState" });
 	}),
-- 
2.47.0

