From 264a5a2327a5d1f3dc46c665856862755eb22545 Mon Sep 17 00:00:00 2001 From: GrantWasil Date: Thu, 30 Jul 2026 16:23:07 -0600 Subject: [PATCH 1/2] fix macOS helper extension installation --- README.md | 10 +- backend/install_extension/config.lua | 36 ++- backend/install_extension/hmac.lua | 17 +- backend/install_extension/init.lua | 332 ++++++++++++++++++++++----- backend/install_extension/macos.lua | 42 ++++ helpers/install-to-steam.test.ts | 49 ++++ helpers/install-to-steam.ts | 149 +++++++++--- package.json | 1 + 8 files changed, 529 insertions(+), 107 deletions(-) create mode 100644 backend/install_extension/macos.lua create mode 100644 helpers/install-to-steam.test.ts diff --git a/README.md b/README.md index 278563c..23fa342 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Wondering if your favorite Chrome extension works with Extendium? Check our offi 1. Ensure you have **Millennium** installed on your Steam client. 2. Download the **[latest release](https://github.com/BossSloth/Extendium/releases/latest)** from GitHub or install it from the **[Steambrew](https://steambrew.app/plugin?id=788ed8554492)** website. 3. (Manual only) Place the plugin files into your Millennium plugins directory (typically a `plugins` folder inside your Steam installation). -4. **Restart** your Steam client. +4. **Restart** your Steam client using your normal Millennium launch method (on macOS, typically `Steam Millennium.app`). 5. Open the Millennium plugin menu and **enable** the Extendium plugin. 6. Click **Save Changes** at the top of the menu and **restart Steam** one more time. @@ -85,6 +85,7 @@ If everything goes fully wrong (e.g. an extension breaks the client and you can' Don't worry, almost nothing is saved in these files, only extension settings and some browser state. If you want to be sure, back them up first. - **Windows**: `%localappdata%\Steam\htmlcache\Default` +- **macOS**: `~/Library/Application Support/Steam/config/htmlcache/Default` - **Linux**: `~/.steam/steam/config/htmlcache/Default` After deleting these files, boot up Steam. @@ -123,9 +124,14 @@ Follow the [Manual Extension Installation](#manual-extension-installation) steps - **Windows (Millennium < 3.0)**: `/plugins/extendium/fake-header-extension` - **Windows (Millennium >= 3.0)**: `/millennium/plugins/extendium/fake-header-extension` +- **macOS**: `~/Library/Application Support/Millennium/plugins/extendium/fake-header-extension` - **Linux**: `~/.local/share/millennium/plugins/extendium/fake-header-extension` -After loading it, restart Steam to make sure Extendium picks it up. +After loading it, restart Steam using your normal Millennium launch method to make sure Extendium picks it up. + +If you previously selected **Ignore and don't show again**, close Steam, delete +`install-state.json` from the `extendium` plugin directory listed above, and +relaunch Steam to let Extendium retry the helper installation. ## Known Limitations diff --git a/backend/install_extension/config.lua b/backend/install_extension/config.lua index 102dd06..7a19bdf 100644 --- a/backend/install_extension/config.lua +++ b/backend/install_extension/config.lua @@ -12,6 +12,25 @@ function M.is_windows() return jit.os == "Windows" end +function M.is_macos() + return jit.os == "OSX" +end + +function M.is_linux() + return jit.os == "Linux" +end + +function M.get_platform_name() + if M.is_windows() then + return "Windows" + elseif M.is_macos() then + return "macOS" + elseif M.is_linux() then + return "Linux" + end + return "Unsupported (" .. tostring(jit.os) .. ")" +end + function M.get_plugin_dir() local backend_path = utils.get_backend_path() if not backend_path then @@ -32,17 +51,23 @@ function M.get_steam_config_dir() local steam_path = millennium.steam_path() logger:info("[install] Steam path: " .. tostring(steam_path)) - local is_win = M.is_windows() - logger:info("[install] Platform: " .. (is_win and "Windows" or "Linux")) + logger:info("[install] Platform: " .. M.get_platform_name()) - if is_win then + if M.is_windows() then -- Windows: %LOCALAPPDATA%\Steam\htmlcache\Default local appdata = utils.getenv("LOCALAPPDATA") if appdata then return fs.join(appdata, "Steam", "htmlcache", "Default") end return nil - else + elseif M.is_macos() then + -- macOS: ~/Library/Application Support/Steam/config/htmlcache/Default + local home = utils.getenv("HOME") + if home then + return fs.join(home, "Library", "Application Support", "Steam", "config", "htmlcache", "Default") + end + return nil + elseif M.is_linux() then -- Linux: ~/.local/share/Steam/config/htmlcache/Default -- Or symlink: ~/.steam/steam/config/htmlcache/Default local home = utils.getenv("HOME") @@ -51,6 +76,9 @@ function M.get_steam_config_dir() end return nil end + + logger:error("[install] Unsupported platform: " .. tostring(jit.os)) + return nil end -- Generic JSON file reader with logging diff --git a/backend/install_extension/hmac.lua b/backend/install_extension/hmac.lua index e5d76fd..ec0aa2f 100644 --- a/backend/install_extension/hmac.lua +++ b/backend/install_extension/hmac.lua @@ -5,18 +5,18 @@ local json_helpers = require("install_extension.json_helpers") local sha2 = require("install_extension.sha2") ---@class HmacContext ----@field sid string +---@field device_id string ---@field seed_hex string ---@field seed_bytes string local HmacContext = {} HmacContext.__index = HmacContext ----@param sid string +---@param device_id string ---@param seed_hex string ---@return HmacContext -function HmacContext.new(sid, seed_hex) +function HmacContext.new(device_id, seed_hex) local self = setmetatable({}, HmacContext) - self.sid = sid or "" + self.device_id = device_id or "" self.seed_hex = seed_hex or "" -- Empty for Steam CEF self.seed_bytes = sha2.hex_to_bin(seed_hex or "") return self @@ -31,7 +31,7 @@ function HmacContext:compute_mac(json_path, value) local json_value = json_helpers.encode_sorted(cleaned) json_value = json_helpers.escape_for_hmac(json_value) - local message = self.sid .. json_path .. json_value + local message = self.device_id .. json_path .. json_value logger:info("[install] Computing MAC for path: " .. json_path) logger:info("[install] Message length: " .. #message) logger:info("[install] JSON preview: " .. json_value:sub(1, 100) .. "...") @@ -48,9 +48,10 @@ end ---@param macs table ---@return string|nil function HmacContext:compute_super_mac(macs) - -- Use sorted JSON for consistent key ordering - local macs_json = json_helpers.encode_sorted(macs) - local message = self.sid .. macs_json + -- Chromium removes empty descendants before serializing tracked values. + local cleaned_macs = json_helpers.remove_empty_children(macs) or {} + local macs_json = json_helpers.encode_sorted(cleaned_macs) + local message = self.device_id .. macs_json logger:info("[install] Computing super MAC...") logger:info("[install] MACs JSON: " .. macs_json:sub(1, 100) .. "...") diff --git a/backend/install_extension/init.lua b/backend/install_extension/init.lua index 66ad4e4..5e5d5a9 100644 --- a/backend/install_extension/init.lua +++ b/backend/install_extension/init.lua @@ -10,21 +10,54 @@ local config = require("install_extension.config") local install_utils = require("install_extension.utils") local extension_builder = require("install_extension.extension_builder") local HmacContext = require("install_extension.hmac") +local json_helpers = require("install_extension.json_helpers") local windows = require("install_extension.windows") +local macos = require("install_extension.macos") local M = {} --- Helper to load prefs file with fallback to empty table +-- Load an existing preferences file. Callers decide whether a missing file may +-- be created; malformed existing files are never silently replaced. local function load_prefs_file(path, label) if not fs.exists(path) then - logger:warn("[install] " .. label .. " not found, will create new") - return {} + return nil, label .. " not found" end local data = config.read_json_file(path, label) if data then logger:info("[install] Loaded existing " .. label) + return data, nil end - return data or {} + return nil, "Failed to read " .. label +end + +local function initialize_hmac_context() + if config.is_windows() then + logger:info("[install] Initializing HMAC context for Windows...") + local sid = windows.get_windows_sid() + if sid and sid ~= "" then + -- Steam CEF uses an empty seed (like Edge/Brave). + logger:info("[install] HMAC context initialized") + return HmacContext.new(sid, ""), nil + end + logger:warn("[install] Could not get Windows SID, HMAC will be skipped") + return nil, nil + elseif config.is_macos() then + logger:info("[install] Initializing HMAC context for macOS...") + local device_id = macos.get_macos_device_id() + if device_id and device_id ~= "" then + -- Steam CEF uses an empty seed; macOS uses IOPlatformUUID as the + -- device-specific part of the authenticated preference message. + logger:info("[install] HMAC context initialized") + return HmacContext.new(device_id, ""), nil + end + logger:error("[install] Could not get the macOS preference device ID") + return nil, "Failed to get the macOS device identifier required to sign Chromium preferences" + elseif config.is_linux() then + logger:info("[install] Linux detected, HMAC not required") + return nil, nil + end + + return nil, "Unsupported platform: " .. tostring(jit.os) end -- Set up extension in a prefs object @@ -36,9 +69,18 @@ local function setup_extension_prefs(prefs, extension_id, extension_settings, hm if hmac_ctx then install_utils.ensure_nested(prefs, "protection", "macs", "extensions", "settings") - install_utils.ensure_nested(prefs, "protection", "ui") + install_utils.ensure_nested(prefs, "protection", "macs", "extensions", "ui") if ext_mac then prefs.protection.macs.extensions.settings[extension_id] = ext_mac end - if dev_mode_mac then prefs.protection.ui.developer_mode = dev_mode_mac end + if dev_mode_mac then prefs.protection.macs.extensions.ui.developer_mode = dev_mode_mac end + + -- Remove the location used by older Extendium builds. Chromium only + -- reads tracked hashes from protection.macs. + if prefs.protection.ui then + prefs.protection.ui.developer_mode = nil + if next(prefs.protection.ui) == nil then + prefs.protection.ui = nil + end + end if compute_super then local super_mac = hmac_ctx:compute_super_mac(prefs.protection.macs) @@ -54,10 +96,17 @@ local function kill_steam_webhelper() if config.is_windows() then local msg = "Steam has been closed to complete the Extendium plugin installation. Please restart Steam." sys_utils.exec('start "" powershell -WindowStyle Hidden -Command "taskkill /F /IM steamwebhelper.exe; taskkill /F /IM steam.exe; Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(\'' .. msg .. '\', \'Steam restart required - extendium\', [System.Windows.Forms.MessageBoxButtons]::OK, [System.Windows.Forms.MessageBoxIcon]::Warning)"') - else + elseif config.is_macos() then + -- Run detached because utils.exec uses a blocking pipe. The bracketed + -- helper pattern cannot match this shell command itself. + sys_utils.exec([[( /bin/sleep 1; /usr/bin/pkill -KILL -f '/[S]team Helper.app/Contents/MacOS/Steam Helper'; /usr/bin/pkill -KILL -x steam_osx; /usr/bin/osascript -e 'display dialog "Steam has been closed to complete the Extendium plugin installation. Relaunch it using your normal Millennium launcher." with title "Steam restart required - Extendium" buttons {"OK"} default button "OK" with icon caution' ) /dev/null 2>&1 &]]) + elseif config.is_linux() then sys_utils.exec('sleep 1 && pkill -KILL -f steam') local msg = "Steam has been closed to complete the Extendium plugin installation.\\nPlease restart Steam." sys_utils.exec('zenity --info --title "Steam restart required - extendium" --text "' .. msg .. '" &') + else + logger:error("[install] Cannot restart Steam on unsupported platform") + return end logger:info("[install] Steam webhelper killed") end @@ -117,21 +166,11 @@ function M.install() local extension_settings = extension_builder.build_extension_settings(extension_dir, manifest) logger:info("[install] Built extension settings") - -- Initialize HMAC context (Windows only) - ---@type HmacContext|nil - local hmac_ctx = nil - if config.is_windows() then - logger:info("[install] Initializing HMAC context for Windows...") - local sid = windows.get_windows_sid() - if sid and sid ~= "" then - -- Steam CEF uses empty seed (like Edge/Brave) - hmac_ctx = HmacContext.new(sid, "") - logger:info("[install] HMAC context initialized") - else - logger:warn("[install] Could not get Windows SID, HMAC will be skipped") - end - else - logger:info("[install] Linux detected, HMAC not required") + -- Initialize the platform-specific HMAC context for Chromium's tracked + -- preferences. macOS fails closed if its device ID is unavailable. + local hmac_ctx, hmac_err = initialize_hmac_context() + if hmac_err then + return json.encode({ success = false, error = hmac_err }) end -- ======================================================================== @@ -142,10 +181,33 @@ function M.install() local prefs_path = fs.join(config_dir, "Preferences") local secure_prefs_path = fs.join(config_dir, "Secure Preferences") - local prefs = load_prefs_file(prefs_path, "Preferences") - local secure_prefs = fs.exists(secure_prefs_path) and load_prefs_file(secure_prefs_path, "Secure Preferences") or nil + local prefs = {} + if fs.exists(prefs_path) then + local prefs_err + prefs, prefs_err = load_prefs_file(prefs_path, "Preferences") + if not prefs then + return json.encode({ success = false, error = prefs_err }) + end + else + logger:warn("[install] Preferences not found, will create new") + end - -- Compute HMACs if needed (Windows only) - do this BEFORE modifying prefs + local secure_prefs = nil + if fs.exists(secure_prefs_path) then + local secure_prefs_err + secure_prefs, secure_prefs_err = load_prefs_file(secure_prefs_path, "Secure Preferences") + if not secure_prefs then + return json.encode({ success = false, error = secure_prefs_err }) + end + elseif config.is_macos() then + logger:error("[install] Secure Preferences is required on macOS") + return json.encode({ + success = false, + error = "Secure Preferences not found. Start Steam once before installing Extendium." + }) + end + + -- Compute HMACs if needed - do this BEFORE modifying prefs local ext_mac, dev_mode_mac if hmac_ctx then logger:info("[install] Computing HMAC signatures...") @@ -207,10 +269,114 @@ function M.install() }) end -local function check_extension_in_prefs(prefs, extension_id) - return prefs.extensions ~= nil and - prefs.extensions.settings ~= nil and - prefs.extensions.settings[extension_id] ~= nil +local function get_extension_settings(prefs, extension_id) + if not prefs or not prefs.extensions or not prefs.extensions.settings then + return nil + end + return prefs.extensions.settings[extension_id] +end + +local function extension_path_exists(extension_settings) + return extension_settings + and type(extension_settings.path) == "string" + and extension_settings.path ~= "" + and fs.exists(extension_settings.path) +end + +local function validate_tracked_extension(prefs, extension_id, hmac_ctx) + local extension_settings = get_extension_settings(prefs, extension_id) + if not extension_settings or not hmac_ctx then + return false + end + + local protection = prefs.protection + local macs = protection and protection.macs + local extension_macs = macs and macs.extensions + local settings_macs = extension_macs and extension_macs.settings + local ui_macs = extension_macs and extension_macs.ui + + local stored_ext_mac = settings_macs and settings_macs[extension_id] + local expected_ext_mac = hmac_ctx:compute_mac( + "extensions.settings." .. extension_id, + extension_settings + ) + if not stored_ext_mac or stored_ext_mac ~= expected_ext_mac then + logger:warn("[check_status] Extension preference MAC is missing or invalid") + return false + end + + if not prefs.extensions.ui or prefs.extensions.ui.developer_mode ~= true then + logger:warn("[check_status] Chromium developer mode is not enabled") + return false + end + + local stored_dev_mode_mac = ui_macs and ui_macs.developer_mode + local expected_dev_mode_mac = hmac_ctx:compute_mac("extensions.ui.developer_mode", true) + if not stored_dev_mode_mac or stored_dev_mode_mac ~= expected_dev_mode_mac then + logger:warn("[check_status] Developer-mode preference MAC is missing or invalid") + return false + end + + local expected_super_mac = macs and hmac_ctx:compute_super_mac(macs) + if not protection or not protection.super_mac or protection.super_mac ~= expected_super_mac then + logger:warn("[check_status] Chromium preference super MAC is missing or invalid") + return false + end + + return true +end + +local function remove_extension_from_prefs(prefs, extension_id, hmac_ctx, recompute_super) + local extension_settings = get_extension_settings(prefs, extension_id) + if not extension_settings then + return false + end + + prefs.extensions.settings[extension_id] = nil + + local protection = prefs.protection + local macs = protection and protection.macs + local extension_macs = macs and macs.extensions + local settings_macs = extension_macs and extension_macs.settings + if settings_macs then + settings_macs[extension_id] = nil + end + + if protection and protection.ui then + protection.ui.developer_mode = nil + if next(protection.ui) == nil then + protection.ui = nil + end + end + + if macs then + protection.macs = json_helpers.remove_empty_children(macs) or {} + end + + if recompute_super then + if not hmac_ctx then + return false + end + + if not protection then + prefs.protection = { macs = {} } + protection = prefs.protection + elseif not protection.macs then + protection.macs = {} + end + + if prefs.extensions + and prefs.extensions.ui + and prefs.extensions.ui.developer_mode == true then + install_utils.ensure_nested(protection, "macs", "extensions", "ui") + protection.macs.extensions.ui.developer_mode = + hmac_ctx:compute_mac("extensions.ui.developer_mode", true) + end + + protection.super_mac = hmac_ctx:compute_super_mac(protection.macs) + end + + return true end function M.check_status() @@ -226,61 +392,103 @@ function M.check_status() return json.encode({ installed = false, error = "Steam config directory not found" }) end - local installed = false - local needs_cleanup = false - local prefs_path = fs.join(config_dir, "Preferences") local secure_prefs_path = fs.join(config_dir, "Secure Preferences") - local prefs = load_prefs_file(prefs_path, "Preferences") - local secure_prefs = fs.exists(secure_prefs_path) and load_prefs_file(secure_prefs_path, "Secure Preferences") or nil + local prefs = nil + if fs.exists(prefs_path) then + prefs = select(1, load_prefs_file(prefs_path, "Preferences")) + end - if prefs and check_extension_in_prefs(prefs, keys.extensionId) then - logger:info("[check_status] Extension found in Preferences") + local secure_prefs = nil + if fs.exists(secure_prefs_path) then + secure_prefs = select(1, load_prefs_file(secure_prefs_path, "Secure Preferences")) + end - local ext_path = prefs.extensions.settings[keys.extensionId].path - if ext_path and not fs.exists(ext_path) then - logger:warn("[check_status] Extension path does not exist: " .. ext_path) - needs_cleanup = true - else - installed = true - end + local hmac_ctx, hmac_err = initialize_hmac_context() + if hmac_err then + return json.encode({ installed = false, error = hmac_err }) end - if secure_prefs and check_extension_in_prefs(secure_prefs, keys.extensionId) then - logger:info("[check_status] Extension found in Secure Preferences") + local installed = false + local needs_cleanup = false + local prefs_settings = get_extension_settings(prefs, keys.extensionId) + local secure_settings = get_extension_settings(secure_prefs, keys.extensionId) + + if config.is_macos() then + -- Secure Preferences is authoritative for tracked extensions on macOS. + -- A Preferences-only entry must not report a successful installation. + if secure_settings then + logger:info("[check_status] Extension found in Secure Preferences") + if not extension_path_exists(secure_settings) then + logger:warn("[check_status] Secure Preferences contains an invalid extension path") + needs_cleanup = true + elseif not validate_tracked_extension(secure_prefs, keys.extensionId, hmac_ctx) then + needs_cleanup = true + else + installed = true + end + end + else + if prefs_settings then + logger:info("[check_status] Extension found in Preferences") + if extension_path_exists(prefs_settings) then + installed = true + else + logger:warn("[check_status] Preferences contains an invalid extension path") + needs_cleanup = true + end + end - local ext_path = secure_prefs.extensions.settings[keys.extensionId].path - if ext_path and not fs.exists(ext_path) then - logger:warn("[check_status] Extension path does not exist in Secure Preferences: " .. ext_path) - needs_cleanup = true - else - installed = true + if secure_settings then + logger:info("[check_status] Extension found in Secure Preferences") + if extension_path_exists(secure_settings) then + installed = true + else + logger:warn("[check_status] Secure Preferences contains an invalid extension path") + needs_cleanup = true + end end end if needs_cleanup then logger:info("[check_status] Cleaning up invalid extension entries...") - if prefs and check_extension_in_prefs(prefs, keys.extensionId) then - prefs.extensions.settings[keys.extensionId] = nil - local prefs_json = json.encode(prefs) - local success, write_err = sys_utils.write_file(prefs_path, prefs_json) - if success then - logger:info("[check_status] Removed invalid extension from Preferences") - else - logger:error("[check_status] Failed to clean Preferences: " .. (write_err or "unknown")) + -- Clean the authoritative tracked file first so a secondary write + -- failure cannot leave a stale secure entry. + if secure_settings then + if (config.is_windows() or config.is_macos()) and not hmac_ctx then + return json.encode({ + installed = false, + error = "Cannot safely clean Secure Preferences without an HMAC context" + }) end - end - if secure_prefs and check_extension_in_prefs(secure_prefs, keys.extensionId) then - secure_prefs.extensions.settings[keys.extensionId] = nil + remove_extension_from_prefs( + secure_prefs, + keys.extensionId, + hmac_ctx, + config.is_windows() or config.is_macos() + ) local secure_prefs_json = json.encode(secure_prefs) local success, write_err = sys_utils.write_file(secure_prefs_path, secure_prefs_json) if success then logger:info("[check_status] Removed invalid extension from Secure Preferences") else logger:error("[check_status] Failed to clean Secure Preferences: " .. (write_err or "unknown")) + return json.encode({ installed = false, error = "Failed to clean Secure Preferences" }) + end + end + + if prefs_settings then + remove_extension_from_prefs(prefs, keys.extensionId, hmac_ctx, false) + local prefs_json = json.encode(prefs) + local success, write_err = sys_utils.write_file(prefs_path, prefs_json) + if success then + logger:info("[check_status] Removed invalid extension from Preferences") + else + logger:error("[check_status] Failed to clean Preferences: " .. (write_err or "unknown")) + return json.encode({ installed = false, error = "Failed to clean Preferences" }) end end diff --git a/backend/install_extension/macos.lua b/backend/install_extension/macos.lua new file mode 100644 index 0000000..56fbd0b --- /dev/null +++ b/backend/install_extension/macos.lua @@ -0,0 +1,42 @@ +-- macOS-specific utilities + +local sys_utils = require("utils") +local logger = require("logger") +local config = require("install_extension.config") + +local M = {} + +function M.get_macos_device_id() + if not config.is_macos() then + return "" + end + + logger:info("[install] Getting macOS Chromium preference device ID...") + + -- Chromium uses IOPlatformUUID as the device-specific component when + -- authenticating tracked preferences on macOS. + local output, status = sys_utils.exec("/usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice") + if not output then + logger:error("[install] Failed to read IOPlatformUUID: " .. tostring(status)) + return "" + end + + local device_id = output:match('"IOPlatformUUID"%s*=%s*"([^"]+)"') + local uuid_pattern = "^" + .. string.rep("%x", 8) .. "%-" + .. string.rep("%x", 4) .. "%-" + .. string.rep("%x", 4) .. "%-" + .. string.rep("%x", 4) .. "%-" + .. string.rep("%x", 12) + .. "$" + if not device_id or not device_id:match(uuid_pattern) then + logger:error("[install] IOPlatformUUID was missing or malformed") + return "" + end + + -- Do not log the persistent machine identifier. + logger:info("[install] macOS Chromium preference device ID found") + return device_id +end + +return M diff --git a/helpers/install-to-steam.test.ts b/helpers/install-to-steam.test.ts new file mode 100644 index 0000000..72ce394 --- /dev/null +++ b/helpers/install-to-steam.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test'; + +import { + computeMac, + computeSuperMac, + stringifyForHmac, +} from './install-to-steam'; + +const DEVICE_ID = '00000000-0000-0000-0000-000000000000'; +const EMPTY_SEED = Buffer.alloc(0); + +describe('Chromium tracked-preference HMACs', () => { + test('matches the macOS developer-mode golden vector', () => { + expect( + computeMac( + EMPTY_SEED, + DEVICE_ID, + 'extensions.ui.developer_mode', + true, + ), + ).toBe('2B2D34E187226094146FC0EB125FAF64089DDCA8911747FB65602EAF965672A9'); + }); + + test('sorts keys, escapes less-than signs, and removes empty children', () => { + const first = { + z: { empty: {}, value: '' }, + a: true, + }; + const second = { + a: true, + z: { value: '', empty: {} }, + }; + + expect(stringifyForHmac(first)).toBe( + '{"a":true,"z":{"value":"\\u003Call_urls>"}}', + ); + expect(stringifyForHmac(second)).toBe(stringifyForHmac(first)); + }); + + test('matches a canonical super-MAC golden vector', () => { + expect( + computeSuperMac(EMPTY_SEED, DEVICE_ID, { + z: { value: 'two' }, + empty: {}, + a: { value: 'one' }, + }), + ).toBe('F95D60E865DD3E8619F51900DA0831D498E5B2A19F67F0129B5D80275EA981D2'); + }); +}); diff --git a/helpers/install-to-steam.ts b/helpers/install-to-steam.ts index 11c315f..c9ed39c 100644 --- a/helpers/install-to-steam.ts +++ b/helpers/install-to-steam.ts @@ -10,7 +10,7 @@ * - manifest.json must contain the "key" field with the public key */ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { createHash, createHmac } from 'crypto'; import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'; import { homedir, platform } from 'os'; @@ -26,6 +26,9 @@ function getSteamConfigDir(): string { if (platform() === 'win32') { return join(homedir(), 'AppData/Local/Steam/htmlcache/Default'); } + if (platform() === 'darwin') { + return join(homedir(), 'Library/Application Support/Steam/config/htmlcache/Default'); + } return join(homedir(), '.local/share/Steam/config/htmlcache/Default'); } @@ -56,7 +59,11 @@ interface ChromePreferences { }; protection?: { macs?: { - extensions?: { settings?: Record; [key: string]: unknown }; + extensions?: { + settings?: Record; + ui?: Record; + [key: string]: unknown; + }; [key: string]: unknown; }; ui?: Record; @@ -113,7 +120,7 @@ function getWindowsFileTime(): string { } // ============================================================================ -// HMAC Signature Generation (Windows only) +// HMAC Signature Generation (Windows and macOS) // ============================================================================ function extractSeedFromPak(pakPath: string): string | null { @@ -171,6 +178,22 @@ function getWindowsSid(): string { return ''; } +function getMacDeviceId(): string { + if (platform() !== 'darwin') return ''; + try { + const output = execFileSync( + '/usr/sbin/ioreg', + ['-rd1', '-c', 'IOPlatformExpertDevice'], + { encoding: 'utf-8' }, + ); + const deviceId = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/)?.[1] ?? ''; + if (/^[\dA-Fa-f]{8}(?:-[\dA-Fa-f]{4}){3}-[\dA-Fa-f]{12}$/.test(deviceId)) { + return deviceId; + } + } catch { /* ignore */ } + return ''; +} + function removeEmptyChildren(obj: unknown): unknown { if (obj === null || obj === undefined) return obj; if (Array.isArray(obj)) { @@ -192,30 +215,54 @@ function removeEmptyChildren(obj: unknown): unknown { return obj; } -function computeMac(seed: Buffer, sid: string, jsonPath: string, value: unknown): string { +export function canonicalizeForHmac(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeForHmac); + } + if (typeof value === 'object' && value !== null) { + const canonical: Record = {}; + for (const key of Object.keys(value).sort()) { + canonical[key] = canonicalizeForHmac((value as Record)[key]); + } + return canonical; + } + return value; +} + +export function stringifyForHmac(value: unknown): string { const cleaned = removeEmptyChildren(value); - const jsonValue = JSON.stringify(cleaned).replace(/ = []; + + // Parse every existing file before writing either one. This avoids replacing + // malformed browser state or leaving a partial installation. for (const [filePath, isSecure] of [[preferencesFile, false], [securePreferencesFile, true]] as const) { if (!existsSync(filePath)) { - if (isSecure) continue; + if (isSecure) { + if (isMacOS) { + console.error('āŒ Secure Preferences not found. Start Steam once before installing the extension.'); + process.exit(1); + } + continue; + } + preferenceTargets.push({ filePath, isSecure, prefs: {} }); + continue; } - console.log(`\nšŸ“ Updating ${isSecure ? 'Secure Preferences' : 'Preferences'}...`); - - let prefs: ChromePreferences = {}; try { - prefs = JSON.parse(readFileSync(filePath, 'utf-8')); + const prefs = JSON.parse(readFileSync(filePath, 'utf-8')) as ChromePreferences; + preferenceTargets.push({ filePath, isSecure, prefs }); } catch { - if (isSecure) continue; + console.error(`āŒ ${isSecure ? 'Secure Preferences' : 'Preferences'} could not be read; no files were changed.`); + process.exit(1); } + } + + for (const { filePath, isSecure, prefs } of preferenceTargets) { + console.log(`\nšŸ“ Updating ${isSecure ? 'Secure Preferences' : 'Preferences'}...`); // Set up extension structure if (!prefs.extensions) prefs.extensions = {}; @@ -331,16 +402,21 @@ function main() { if (!prefs.protection.macs) prefs.protection.macs = {}; if (!prefs.protection.macs.extensions) prefs.protection.macs.extensions = {}; if (!prefs.protection.macs.extensions.settings) prefs.protection.macs.extensions.settings = {}; - if (!prefs.protection.ui) prefs.protection.ui = {}; + if (!prefs.protection.macs.extensions.ui) prefs.protection.macs.extensions.ui = {}; - const extMac = computeMac(hmacContext.seed, hmacContext.sid, `extensions.settings.${extensionId}`, extensionSettings); + const extMac = computeMac(hmacContext.seed, hmacContext.deviceId, `extensions.settings.${extensionId}`, extensionSettings); prefs.protection.macs.extensions.settings[extensionId] = extMac; - const devModeMac = computeMac(hmacContext.seed, hmacContext.sid, 'extensions.ui.developer_mode', true); - prefs.protection.ui.developer_mode = devModeMac; + const devModeMac = computeMac(hmacContext.seed, hmacContext.deviceId, 'extensions.ui.developer_mode', true); + prefs.protection.macs.extensions.ui.developer_mode = devModeMac; + + if (prefs.protection.ui) { + delete prefs.protection.ui.developer_mode; + if (Object.keys(prefs.protection.ui).length === 0) delete prefs.protection.ui; + } if (isSecure) { - const superMac = computeSuperMac(hmacContext.seed, hmacContext.sid, prefs.protection.macs); + const superMac = computeSuperMac(hmacContext.seed, hmacContext.deviceId, prefs.protection.macs); prefs.protection.super_mac = superMac; console.log(`āœ… Super MAC: ${superMac.substring(0, 16)}...`); } @@ -352,15 +428,26 @@ function main() { if (isWindows) { execSync('taskkill /F /IM steamwebhelper.exe') + } else if (isMacOS) { + try { + execFileSync( + '/usr/bin/pkill', + ['-KILL', '-f', '/Steam Helper.app/Contents/MacOS/Steam Helper'], + ); + } catch { /* Steam helpers may already be closed */ } + try { + execFileSync('/usr/bin/pkill', ['-KILL', '-x', 'steam_osx']); + } catch { /* Steam may already be closed */ } } else { execSync('pkill -f steamwebhelper') } console.log('\n✨ Installation complete!'); console.log('\nšŸ“Œ Next steps:'); - console.log(' 1. Close Steam completely'); - console.log(' 2. Restart Steam'); - console.log(' 3. The extension will be loaded automatically'); + console.log(' 1. Restart Steam'); + console.log(' 2. The extension will be loaded automatically'); } -main(); +if (import.meta.main) { + main(); +} diff --git a/package.json b/package.json index 156f45b..23895e9 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "watch": "nodemon --watch public/extendium.scss --exec \"npm run sass && npm run dev\"", "build": "npm run sass && millennium-ttc --build prod", "lint": "eslint", + "test": "bun test", "gen:compatibility": "bun run ./helpers/generate-compatibility-map.ts \"./Extendium 2.0 extension compatibility list - Compatibility list.csv\"" }, "devDependencies": { From 7d609e285fe324ea340233b6cff53dcd89da3457 Mon Sep 17 00:00:00 2001 From: GrantWasil Date: Thu, 30 Jul 2026 17:01:00 -0600 Subject: [PATCH 2/2] fix Chromium HMAC slash canonicalization --- backend/install_extension/json_helpers.lua | 6 ++++++ helpers/install-to-steam.test.ts | 23 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/backend/install_extension/json_helpers.lua b/backend/install_extension/json_helpers.lua index d823a66..59a25a4 100644 --- a/backend/install_extension/json_helpers.lua +++ b/backend/install_extension/json_helpers.lua @@ -2,6 +2,12 @@ local json = require("json") +-- Chromium's canonical JSON does not escape forward slashes, while +-- Millennium's bundled cjson does by default. Each plugin backend has its own +-- Lua VM, so configure this VM to hash paths and URLs exactly as Chromium +-- writes them. +json.encode_escape_forward_slash(false) + local M = {} -- Encode JSON with sorted keys (critical for HMAC - must match JS JSON.stringify order) diff --git a/helpers/install-to-steam.test.ts b/helpers/install-to-steam.test.ts index 72ce394..437f94e 100644 --- a/helpers/install-to-steam.test.ts +++ b/helpers/install-to-steam.test.ts @@ -24,19 +24,40 @@ describe('Chromium tracked-preference HMACs', () => { test('sorts keys, escapes less-than signs, and removes empty children', () => { const first = { z: { empty: {}, value: '' }, + path: '/Users/test/extension', a: true, }; const second = { a: true, + path: '/Users/test/extension', z: { value: '', empty: {} }, }; expect(stringifyForHmac(first)).toBe( - '{"a":true,"z":{"value":"\\u003Call_urls>"}}', + '{"a":true,"path":"/Users/test/extension","z":{"value":"\\u003Call_urls>"}}', ); expect(stringifyForHmac(second)).toBe(stringifyForHmac(first)); }); + test('matches Chromium when URL and path values contain forward slashes', () => { + const value = { + matches: ['https://store.steampowered.com/'], + path: '/Users/test/extension', + }; + + expect(stringifyForHmac(value)).toBe( + '{"matches":["https://store.steampowered.com/"],"path":"/Users/test/extension"}', + ); + expect( + computeMac( + EMPTY_SEED, + DEVICE_ID, + 'extensions.settings.test', + value, + ), + ).toBe('52104A704C3604F6D23DFDFCD8A9F7997B39911771793ABC792E00E92D663BA9'); + }); + test('matches a canonical super-MAC golden vector', () => { expect( computeSuperMac(EMPTY_SEED, DEVICE_ID, {