GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
ChromeProfileManager.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.Diagnostics;
20using System.IO;
21using System.Linq;
22using System.Threading;
23
24//using Microsoft.Data.Sqlite;
25using Newtonsoft.Json;
26using Newtonsoft.Json.Linq;
27using static GenerallyPositive.Enums;
28using static Org.BouncyCastle.Asn1.Cmp.Challenge;
29
31{
32 internal class ChromeProfileManager
33 {
44 private static readonly Random random = new Random();
45
46 internal static string CreateTempUserProfile(
47 string downloadDirectory,
48 bool promptForDownload,
49 bool openPDFExternally,
50 bool loadImages,
51 bool useOttoMagic,
52 Dictionary<string, object> additionalPrefs = null)
53 {
54 try
55 {
56 if (!Directory.Exists(downloadDirectory))
57 Directory.CreateDirectory(downloadDirectory);
58
59 // Create brand new empty profile root
60 string tempProfileDir =
61 Path.Combine(GPAL.TempProfileDirectory, $"BrowserProfile_{Guid.NewGuid():N}");
62
63 string defaultDir = Path.Combine(tempProfileDir, "Default");
64
65 Directory.CreateDirectory(defaultDir);
66
67 Base64Helper base64Helper = new Base64Helper();
68 base64Helper.WithInput(GPAL.GPALSettings.Preferences).SaveTo(Path.Combine(defaultDir, "Preferences"));
69 base64Helper.WithInput(GPAL.GPALSettings.SecurePreferences).SaveTo(Path.Combine(defaultDir, "Secure Preferences"));
70 base64Helper.WithInput(GPAL.GPALSettings.LocalState).SaveTo(Path.Combine(tempProfileDir, "Local State"));
71
72 return tempProfileDir;
73 }
74 catch (Exception ex)
75 {
76 GPAL.PublishSimpleEvent(
77 Enums.GPALEventType.EXCEPTION,
78 $"Failed creating temp Chrome profile folder",
79 null,
80 Enums.GPALObjectType.None,
81 ex);
82
83 return null;
84 }
85 }
86
87
88 // reddit consent banner - test to see if injecting this works
89 // "Your request has been blocked by network security. Please try to login with your Reddit account."
90 // https://www.reddit.com/r/firefox/comments/1mqmsv5/your_request_has_been_blocked_by_network_security/
91
92 // the preferences GPAL sets on a supplied profile, by their chrome names, so apply and restore cannot
93 // drift apart.
94 // session.restore_on_startup does NOT belong here and must not be added. chromium keeps an HMAC for it in
95 // Secure Preferences, along with the homepage and the search provider, so that malware cannot hijack what
96 // the browser opens with. writing it from outside breaks that mac, and chromium answers by resetting the
97 // preference and taking a good part of the rest of the file with it. what actually stops a session being
98 // restored is profile.exit_type below: the restore was crash recovery, not the startup setting
99 private static readonly string[] downloadPreferenceKeys =
100 {
101 "download.prompt_for_download",
102 "download.default_directory",
103 "plugins.always_open_pdf_externally",
104 "browser.show_hub_popup_on_download_start",
105 };
106
107
115 private static void SetByPath(JObject root, string path, JToken value)
116 {
117 string[] parts = path.Split('.');
118 JObject node = root;
119
120 for (int idx = 0; idx < parts.Length - 1; idx++)
121 {
122 if (false == (node[parts[idx]] is JObject child))
123 {
124 child = new JObject();
125 node[parts[idx]] = child;
126 }
127
128 node = child;
129 }
130
131 node[parts[parts.Length - 1]] = value;
132 }
139 private static string PreferencesFileFor(string profileDirectory)
140 {
141 string preferencesPath = null;
142
143 if (false == string.IsNullOrEmpty(profileDirectory))
144 {
145 string inDefault = Path.Combine(profileDirectory, "Default", "Preferences");
146 string inProfile = Path.Combine(profileDirectory, "Preferences");
147
148 preferencesPath = File.Exists(inDefault) ? inDefault : File.Exists(inProfile) ? inProfile : null;
149 }
150
151 return preferencesPath;
152 }
153
165 internal static Dictionary<string, JToken> ApplyDownloadPreferences(string profileDirectory, string downloadDirectory, bool promptForDownload, bool openPDFExternally)
166 {
167 Dictionary<string, JToken> previous = null;
168 string preferencesPath = PreferencesFileFor(profileDirectory);
169
170 if (null == preferencesPath)
171 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No chrome Preferences file under [{profileDirectory}], so prompt for download and open pdf externally cannot be set for this profile.", null, GPALObjectType.None);
172 else
173 try
174 {
175 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
176
177 previous = new Dictionary<string, JToken>();
178 foreach (string key in downloadPreferenceKeys)
179 previous[key] = preferences.SelectToken(key);
180
181 SetByPath(preferences, "download.prompt_for_download", promptForDownload);
182 SetByPath(preferences, "plugins.always_open_pdf_externally", openPDFExternally);
183
184 // the flyout edge opens when a download starts. it takes focus and covers the top right of the
185 // page, which is somewhere a workflow may be about to click
186 SetByPath(preferences, "browser.show_hub_popup_on_download_start", false);
187
188 // not a setting and not restored: it is how the browser records its own last shutdown, and a
189 // killed browser leaves it Crashed. chromium reopens the previous session after a crash no
190 // matter what session.restore_on_startup says, so every run GPAL kills teaches the next run to
191 // come up holding the last one's tabs. saying it exited normally is what breaks that loop
192 SetByPath(preferences, "profile.exit_type", "Normal");
193
194 if (false == string.IsNullOrEmpty(downloadDirectory))
195 SetByPath(preferences, "download.default_directory", downloadDirectory);
196
197 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences)); // not JToken.ToString(Formatting), whose overload binds against whichever Newtonsoft the host app loaded
198
199 // which file, what it said, what it says now. a profile is the one thing here nobody can see
200 // from the outside, so when a browser goes on prompting after being told not to, this is the
201 // difference between knowing and guessing
202 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] prompt for download [{previous["download.prompt_for_download"]?.ToString() ?? "unset"}] -> [{promptForDownload}], open pdf externally [{previous["plugins.always_open_pdf_externally"]?.ToString() ?? "unset"}] -> [{openPDFExternally}], download directory [{previous["download.default_directory"]?.ToString() ?? "unset"}] -> [{downloadDirectory ?? "left alone"}]", null, GPALObjectType.None);
203 }
204 catch (Exception ex)
205 {
206 previous = null;
207 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not write download preferences into [{preferencesPath}]", null, GPALObjectType.None, ex);
208 }
209
210 return previous;
211 }
212
219 internal static void RestoreDownloadPreferences(string profileDirectory, Dictionary<string, JToken> previous)
220 {
221 string preferencesPath = null == previous ? null : PreferencesFileFor(profileDirectory);
222
223 if (null == preferencesPath)
224 return;
225
226 try
227 {
228 JObject preferences = JObject.Parse(File.ReadAllText(preferencesPath));
229
230 foreach (string key in downloadPreferenceKeys)
231 if (null == previous[key])
232 preferences.SelectToken(key)?.Parent?.Remove(); // it was not there before, so it goes back to not being there
233 else
234 SetByPath(preferences, key, previous[key]);
235
236 File.WriteAllText(preferencesPath, JsonConvert.SerializeObject(preferences)); // not JToken.ToString(Formatting), whose overload binds against whichever Newtonsoft the host app loaded
237
238 // the other half of what Apply said. a browser writes this file itself as it shuts down, so which
239 // of the two wrote last decides what the next run reads, and that only shows up in the timing
240 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Profile [{preferencesPath}] put back, prompt for download [{previous["download.prompt_for_download"]?.ToString() ?? "unset"}], download directory [{previous["download.default_directory"]?.ToString() ?? "unset"}]", null, GPALObjectType.None);
241 }
242 catch (Exception ex)
243 {
244 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Could not put the download preferences back in [{preferencesPath}]", null, GPALObjectType.None, ex);
245 }
246 }
247
248 internal static void RemoveTempUserProfile(string profileDirectory)
249 {
250 Exception innerEx = null;
251 if (!Directory.Exists(profileDirectory))
252 return;
253
254 for (int i = 0; i < 10; i++)
255 {
256 try
257 {
258 Directory.Delete(profileDirectory, true);
259 GPAL.PublishSimpleEvent(Enums.GPALEventType.INFO, $"Deleted temporary user profile at [{profileDirectory}]");
260 return;
261 }
262 catch (Exception ex)
263 {
264 innerEx = ex;
265 System.Threading.Thread.Sleep(500);
266 GPAL.PublishSimpleEvent(Enums.GPALEventType.EXCEPTION, $"Failed to delete temporary user profile at [{profileDirectory}][{innerEx.Message}]", null, GPALObjectType.None, innerEx);
267 }
268 }
269 GPAL.PublishSimpleEvent(Enums.GPALEventType.ERROR, $"GIVING UP: Failed to delete temporary user profile after 10 tries at [{profileDirectory}][{innerEx.Message}]", null, GPALObjectType.None, innerEx);
270 }
271
272 // ------------------------------------------------------------
273 // Spoofing methods = none seem to work
274 // ------------------------------------------------------------
275 /*
276 private static readonly Random _rnd = new Random();
277
278 [Obsolete]
279 public static void AddFakeGoogleAccount(string profileDir)
280 {
281 var defaultDir = Path.Combine(profileDir, "Default");
282
283 // 1. Login Data (Google account entry)
284 CreateLoginDataFromScratch(Path.Combine(defaultDir, "Login Data"));
285
286 // 2. Auth cookies that match the gaia_id above
287 var gaiaId = "123456789012345678901"; // 21-digit fake GAIA ID
288 var email = "john.doe." + _rnd.Next(1000, 9999) + "@gmail.com";
289
290 var authCookies = new[]
291 {
292 (".google.com", "NID", GenerateNidCookie(gaiaId)),
293 (".google.com", "SID", RandomHex(64)),
294 (".google.com", "HSID", RandomHex(32)),
295 (".google.com", "SSID", RandomHex(32)),
296 (".google.com", "__Secure-1PSID", RandomHex(96)),
297 (".google.com", "__Secure-3PSID", RandomHex(96)),
298 };
299
300 foreach (var (domain, name, value) in authCookies)
301 InjectCookie(profileDir, domain, name, value, secure: true, httpOnly: true);
302 }
303
304 // ------------------------------------------------------------
305 // 1. Create Login Data DB + fake Google account
306 // ------------------------------------------------------------
307 private static void CreateLoginDataFromScratch(string dbPath)
308 {
309 // Chrome creates this file the first time it needs it – we do the same
310 if (File.Exists(dbPath)) File.Delete(dbPath);
311
312 SQLitePCL.Batteries.Init();
313
314 using var conn = new SqliteConnection($"Data Source={dbPath};");
315 conn.Open();
316
317 // Exact schema Chrome 137+ uses (only the columns we need)
318 var create = @"
319 CREATE TABLE logins (
320 origin_url TEXT NOT NULL,
321 action_url TEXT,
322 username_value TEXT,
323 password_value BLOB,
324 date_created INTEGER NOT NULL,
325 blacklisted_by_user INTEGER NOT NULL,
326 signon_realm TEXT NOT NULL,
327 gaia_id TEXT,
328 display_name TEXT,
329 avatar_url TEXT
330 );
331 CREATE INDEX IF NOT EXISTS logins_signon ON logins(signon_realm);
332 ";
333 using (var cmd = new SqliteCommand(create, conn)) cmd.ExecuteNonQuery();
334
335 // Fake Google account – Chrome ignores the encrypted password blob
336 var ts = DateTimeOffset.UtcNow.AddDays(-_rnd.Next(5, 60)).ToUnixTimeSeconds() * 1_000_000L;
337 var sql = @"
338 INSERT INTO logins
339 (origin_url, action_url, username_value, password_value, date_created,
340 blacklisted_by_user, signon_realm, gaia_id, display_name, avatar_url)
341 VALUES
342 (@origin,@action,@user,@pwd,@ts,0,@realm,@gaia,@name,@avatar);";
343
344 using var ins = new SqliteCommand(sql, conn);
345 ins.Parameters.AddWithValue("@origin", "https://accounts.google.com/");
346 ins.Parameters.AddWithValue("@action", "");
347 ins.Parameters.AddWithValue("@user", ""); // empty – Chrome shows email from gaia_id
348 ins.Parameters.AddWithValue("@pwd", new byte[32]); // dummy encrypted blob
349 ins.Parameters.AddWithValue("@ts", ts);
350 ins.Parameters.AddWithValue("@realm", "https://accounts.google.com");
351 ins.Parameters.AddWithValue("@gaia", "123456789012345678901"); // 21-digit GAIA ID
352 ins.Parameters.AddWithValue("@name", "John Doe");
353 ins.Parameters.AddWithValue("@avatar", "https://lh3.googleusercontent.com/a-/fake");
354 ins.ExecuteNonQuery();
355
356 conn.Close();
357 }
358
359 // ------------------------------------------------------------
360 // 2. Helper: inject a single cookie (re-uses your existing DB)
361 // ------------------------------------------------------------
362 private static void InjectCookie(string profileDir, string domain, string name,
363 string value, bool secure, bool httpOnly)
364 {
365 var dbPath = Path.Combine(profileDir, "Default", "Cookies");
366 var connStr = $"Data Source={dbPath};Pooling=false;";
367
368 SQLitePCL.Batteries.Init();
369
370 using var conn = new SqliteConnection(connStr);
371 conn.Open();
372
373 var sql = @"
374 INSERT OR REPLACE INTO cookies
375 (creation_utc, host_key, name, value, path, expires_utc,
376 is_secure, is_httponly, last_access_utc, has_expires, is_persistent,
377 priority, samesite, source_scheme, source_port)
378 VALUES
379 (@creation,@host,@name,@value,'/',@expires,
380 @secure,@httponly,@access,1,1,
381 1,-1,2,-1);";
382
383 using var cmd = new SqliteCommand(sql, conn);
384 var now = DateTimeOffset.UtcNow;
385 cmd.Parameters.AddWithValue("@creation", now.ToUnixTimeSeconds() * 1_000_000L);
386 cmd.Parameters.AddWithValue("@host", domain);
387 cmd.Parameters.AddWithValue("@name", name);
388 cmd.Parameters.AddWithValue("@value", value);
389 cmd.Parameters.AddWithValue("@expires", now.AddYears(2).ToUnixTimeSeconds() * 1_000_000L);
390 cmd.Parameters.AddWithValue("@secure", secure ? 1 : 0);
391 cmd.Parameters.AddWithValue("@httponly", httpOnly ? 1 : 0);
392 cmd.Parameters.AddWithValue("@access", now.ToUnixTimeSeconds() * 1_000_000L);
393 cmd.ExecuteNonQuery();
394
395 conn.Close();
396 }
397
398 // ------------------------------------------------------------
399 // 3. NID cookie generator – Chrome validates the format
400 // ------------------------------------------------------------
401 private static string GenerateNidCookie(string gaiaId)
402 {
403 // Real NID format (simplified but enough for reCAPTCHA):
404 // 123=ABC...XYZ where ABC... is a base64-ish string
405 var rnd = RandomHex(120);
406 return $"215={rnd}";
407 }
408 public static void SeedLocalStorage(string profileDir)
409 {
410 var lsDir = Path.Combine(profileDir, "Default", "Local Storage", "leveldb");
411 Directory.CreateDirectory(lsDir);
412
413 var ldbPath = Path.Combine(lsDir, "000003.ldb");
414 var logPath = Path.Combine(lsDir, "000004.log");
415 var manifestPath = Path.Combine(lsDir, "MANIFEST-000001");
416 var currentPath = Path.Combine(lsDir, "CURRENT");
417
418 // 1. MANIFEST
419 File.WriteAllText(manifestPath,
420 "manifest-000001\n" +
421 "comparator: leveldb.BytewiseComparator\n" +
422 "log_number: 4\n" +
423 "next_file_number: 5\n" +
424 "last_sequence: 15\n" +
425 "compaction_pointers: {}\n" +
426 "deleted_files: {}\n" +
427 "new_files: {3: (\"000003.ldb\", 0, 4096, 0)}\n" +
428 "live_files: [\"000004.log\"]\n");
429
430 // 2. CURRENT
431 File.WriteAllText(currentPath, "MANIFEST-000001\n");
432
433 // 3. Empty log
434 File.WriteAllBytes(logPath, new byte[0]);
435
436 // 4. PRE-MADE .ldb (NO CRC, NO WRITER — JUST BYTES)
437 var ldbBytes = new byte[]
438 {
439 // === RECORD 1: https://www.youtube.com_0VISITOR_INFO1_LIVE ===
440 0x2C, 0x00, 0x00, 0x00, // key_len = 44
441 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x77, 0x77, 0x77, 0x2E, 0x79, 0x6F, 0x75, 0x74,
442 0x75, 0x62, 0x65, 0x2E, 0x63, 0x6F, 0x6D, 0x5F, 0x30, 0x56, 0x49, 0x53, 0x49, 0x54, 0x4F, 0x52,
443 0x5F, 0x49, 0x4E, 0x46, 0x4F, 0x31, 0x5F, 0x4C, 0x49, 0x56, 0x45, // "https://www.youtube.com_0VISITOR_INFO1_LIVE"
444 0x0B, 0x00, 0x00, 0x00, // value_len = 11
445 0x59, 0x38, 0x71, 0x75, 0x35, 0x76, 0x34, 0x78, 0x59, 0x32, 0x6B, // "Y8qu5v4xY2k"
446 0x8F, 0x3D, 0xC7, 0xA1, // CRC32
447
448 // === RECORD 2: https://www.youtube.com_0YSC ===
449 0x1F, 0x00, 0x00, 0x00, // key_len = 31
450 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x77, 0x77, 0x77, 0x2E, 0x79, 0x6F, 0x75, 0x74,
451 0x75, 0x62, 0x65, 0x2E, 0x63, 0x6F, 0x6D, 0x5F, 0x30, 0x59, 0x53, 0x43, // "https://www.youtube.com_0YSC"
452 0x0C, 0x00, 0x00, 0x00, // value_len = 12
453 0x64, 0x51, 0x77, 0x34, 0x77, 0x39, 0x57, 0x67, 0x58, 0x63, 0x51, // "dQw4w9WgXcQ"
454 0x1E, 0xB2, 0x9A, 0xC4, // CRC32
455
456 // === RECORD 3: https://www.google.com_0GAPS ===
457 0x23, 0x00, 0x00, 0x00, // key_len = 35
458 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x77, 0x77, 0x77, 0x2E, 0x67, 0x6F, 0x6F, 0x67,
459 0x6C, 0x65, 0x2E, 0x63, 0x6F, 0x6D, 0x5F, 0x30, 0x47, 0x41, 0x50, 0x53, // "https://www.google.com_0GAPS"
460 0x2F, 0x00, 0x00, 0x00, // value_len = 47
461 0x31, 0x3A, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E,
462 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x61, 0x62, 0x63, 0x64,
463 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, 0x70, // "1:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"
464 0xD7, 0xA8, 0xF1, 0x2C, // CRC32
465
466 // === RECORD 4: https://accounts.google.com_0LSID ===
467 0x2B, 0x00, 0x00, 0x00, // key_len = 43
468 0x68, 0x74, 0x74, 0x70, 0x73, 0x3A, 0x2F, 0x2F, 0x61, 0x63, 0x63, 0x6F, 0x75, 0x6E, 0x74, 0x73,
469 0x2E, 0x67, 0x6F, 0x6F, 0x67, 0x6C, 0x65, 0x2E, 0x63, 0x6F, 0x6D, 0x5F, 0x30, 0x4C, 0x53, 0x49,
470 0x44, // "https://accounts.google.com_0LSID"
471 0x05, 0x00, 0x00, 0x00, // value_len = 5
472 0x67, 0x6D, 0x61, 0x69, 0x6C, // "gmail"
473 0x9E, 0x4B, 0xC8, 0xD2 // CRC32
474 };
475
476 File.WriteAllBytes(ldbPath, ldbBytes);
477 }
478 // ------------------------------------------------------------
479 // CRC32 Helper (LevelDB uses this)
480 // ------------------------------------------------------------
481 private class Crc32
482 {
483 private uint[] table = new uint[256];
484 private uint value = 0xffffffff;
485
486 public Crc32()
487 {
488 uint poly = 0xedb88320;
489 for (uint i = 0; i < 256; i++)
490 {
491 uint c = i;
492 for (int k = 0; k < 8; k++)
493 c = (c & 1) > 0 ? poly ^ (c >> 1) : c >> 1;
494 table[i] = c;
495 }
496 }
497
498 public void Update(byte[] data)
499 {
500 foreach (var b in data)
501 value = table[(value ^ b) & 0xff] ^ (value >> 8);
502 }
503
504 public uint Value => value ^ 0xffffffff;
505 }
506
507 // ------------------------------------------------------------
508 // Helper: random hex string
509 // ------------------------------------------------------------
510 private static string RandomHex(int bytes)
511 {
512 var buf = new byte[bytes];
513 _rnd.NextBytes(buf);
514 return BitConverter.ToString(buf).Replace("-", "");
515 }
516 */
517 }
518}