diff --git a/.github/workflows/portable-native-build.yml b/.github/workflows/portable-native-build.yml index 791720b2..d4e0a34a 100644 --- a/.github/workflows/portable-native-build.yml +++ b/.github/workflows/portable-native-build.yml @@ -9,6 +9,8 @@ on: - ".github/workflows/portable-native-build.yml" - "apps/**" - "scripts/build.mjs" + - "scripts/platform-storage-probe.ts" + - "scripts/windows-credential-probe.ts" - "src/**" - "tests/integration/gw-dat-dimensions.test.ts" - "package.json" @@ -52,6 +54,11 @@ jobs: - run: pnpm build - name: Run the portable source and renderer gate run: pnpm run check:portable + - name: Exercise Windows Credential Manager with synthetic values + if: runner.os == 'Windows' + run: pnpm test:windows-credentials + - name: Exercise atomic storage on the target filesystem + run: pnpm storage:probe - name: Exercise the target decoder executable run: >- node --import ./scripts/ts-hook.mjs --test diff --git a/docs/process-model.md b/docs/process-model.md index 4f7265d8..3a3be2b5 100644 --- a/docs/process-model.md +++ b/docs/process-model.md @@ -342,10 +342,12 @@ C++ compiler; macOS keeps its released Xcode recipe. The decoder never owns a credential or an Electron process. `host.node` remains Darwin-only. It contains the existing AppKit key-release -monitor and Apple Data Protection Keychain implementation. A Windows or Linux -build does not load or package this addon. Until that platform has a qualified -secure provider, persistent saved login fails closed and ordinary development -uses only the in-memory provider. +monitor and Apple Data Protection Keychain implementation. Windows packages a +separate `windows-host.node`: it obtains LocalAppData from the Windows known- +folder API and stores only the closed saved-login slots in Windows Credential +Manager. Linux does not load either addon. Until Linux has a qualified secure +provider, persistent saved login fails closed and ordinary development uses +only the in-memory provider. Forge applies the complete cross-platform fuse set to every packaged Electron executable. Embedded ASAR integrity is enabled on macOS and Windows. Electron @@ -354,10 +356,13 @@ Flatpak sandbox, and ASAR-only loading must form the installed package proof. ## Saved login -The Release and signed Development identities use separate Keychain authority. -Historical signed Preview builds have their own retained identity too. Each -identity can read only its own provisioned items; no new signed Preview is -published. +The Release and signed Development identities use separate secret namespaces. +Historical signed Preview builds have their own retained namespace too; no new +signed Preview is published. On macOS, code-signing entitlements enforce the +Keychain identity. On Windows, the native host binds the closed application +identity into each Credential Manager target. This prevents accidental +cross-channel reads but is not a boundary against another process running as +the same Windows user. Each account scope has one item for the ArenaNet user name and password and one item for the Steam access token and expiry. The existing fixed items belong @@ -365,8 +370,8 @@ only to the adopted Main account. A read failure does not delete an item. The ga can continue to its login screen when an item is unavailable. Unpackaged, ordinary local, and ad-hoc developer builds use volatile storage. -They do not claim a provisioned Keychain item. There is no file or -`safeStorage` fallback. +They do not claim a provisioned Keychain or Credential Manager item. There is +no file or `safeStorage` fallback. The game proxy does not send or accept browser cookies. The Steam sign-in window uses a separate in-memory session. It destroys that session after diff --git a/forge.config.ts b/forge.config.ts index 2820ffc5..1cdcadb5 100644 --- a/forge.config.ts +++ b/forge.config.ts @@ -83,7 +83,7 @@ const config: ForgeConfig = { // `.node` addon cannot be dlopen'd from it, and a helper cannot be spawned // from it. asar: { - unpack: "**/build/native/{host.node,gw-dat-decode,gw-dat-decode.exe}", + unpack: "**/build/native/{host.node,windows-host.node,gw-dat-decode,gw-dat-decode.exe}", }, name: channelConfig.productName, executableName: channelConfig.productName, diff --git a/package.json b/package.json index 5783681c..fdccc229 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev:signed": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts", "launcher:fixture": "pnpm build && node --import ./scripts/ts-hook.mjs scripts/launcher-fixture.ts", "storage:probe": "node --import ./scripts/ts-hook.mjs scripts/platform-storage-probe.ts", + "test:windows-credentials": "node --import ./scripts/ts-hook.mjs scripts/windows-credential-probe.ts", "test:signed-dev": "node --import ./scripts/ts-hook.mjs scripts/run-signed-dev.ts --test-keychain", "tools:dev": "pnpm --filter @gwonmac/tools-ui dev", "tools:test": "pnpm --filter @gwonmac/tools-ui test", diff --git a/scripts/build.mjs b/scripts/build.mjs index 52505d90..aa71f7de 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -69,8 +69,9 @@ const DECODER_SOURCES = [ /** * Native recipes selected by the build host. macOS keeps its exact released - * addon and decoder commands. Windows and Linux compile only the portable - * decoder until their credential implementations pass installed tests. + * addon and decoder commands. Windows compiles its Credential Manager addon + * and decoder; Linux compiles only the decoder until its secure provider is + * qualified on an installed package. * * @param {NodeJS.Platform} platform * @param {NodeJS.Architecture} architecture @@ -145,18 +146,50 @@ export function nativeBuildSteps(platform, architecture) { if (architecture !== "x64") { throw new Error(`unsupported Windows build architecture: ${architecture}`); } - return [[ - "cl.exe", + return [ [ - "/nologo", - "/std:c++20", - "/O2", - "/EHsc", - "/Isrc/native/gw-dat", - ...DECODER_SOURCES, - "/Fe:build/native/gw-dat-decode.exe", + "lib.exe", + [ + "/nologo", + "/def:node_modules/node-api-headers/def/node_api.def", + "/machine:x64", + "/out:build/native/node.lib", + ], ], - ]]; + [ + "cl.exe", + [ + "/nologo", + "/std:c++20", + "/O2", + "/EHsc", + "/LD", + "/DNAPI_VERSION=8", + "/Inode_modules/node-api-headers/include", + "/Fobuild\\native\\", + "/Fdbuild/native/windows-host.pdb", + "src/native/windows-host/host.cpp", + "Advapi32.lib", + "Shell32.lib", + "Ole32.lib", + "build/native/node.lib", + "/Fe:build/native/windows-host.node", + ], + ], + [ + "cl.exe", + [ + "/nologo", + "/std:c++20", + "/O2", + "/EHsc", + "/Isrc/native/gw-dat", + "/Fobuild\\native\\", + ...DECODER_SOURCES, + "/Fe:build/native/gw-dat-decode.exe", + ], + ], + ]; } if (platform === "linux") { diff --git a/scripts/package-ignore.ts b/scripts/package-ignore.ts index 6caf1efd..59e311e4 100644 --- a/scripts/package-ignore.ts +++ b/scripts/package-ignore.ts @@ -30,6 +30,7 @@ export function ignorePackageFile(file: string): boolean { if ( p === "/build/native" || p === "/build/native/host.node" + || p === "/build/native/windows-host.node" || p === "/build/native/gw-dat-decode" || p === "/build/native/gw-dat-decode.exe" ) { diff --git a/scripts/windows-credential-probe.ts b/scripts/windows-credential-probe.ts new file mode 100644 index 00000000..90b506fc --- /dev/null +++ b/scripts/windows-credential-probe.ts @@ -0,0 +1,121 @@ +/** + * Exercises the real Windows Credential Manager addon with synthetic values. + * It is deliberately restricted to a fresh GitHub-hosted runner: the addon's + * closed production namespaces make a local runtime probe unsafe for a player. + */ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import process from "node:process"; +import { multiSecretSlot, SINGLE_SECRET_SLOTS, type SecretSlot } from "../src/main/core/native-keychain.js"; +import { loadWindowsNativeHost, WindowsCredentialKeychain } from "../src/main/windows-native-host.js"; +import { DISTRIBUTION_CHANNELS } from "../src/shared/distribution-channel.js"; +import { parseProfileId } from "../src/shared/multiple-accounts.js"; + +if ( + process.platform !== "win32" + || process.env.GITHUB_ACTIONS !== "true" + || process.env.RUNNER_ENVIRONMENT !== "github-hosted" +) { + throw new Error( + "The Windows credential probe runs only on a fresh GitHub-hosted Windows runner", + ); +} + +const host = loadWindowsNativeHost({ + packaged: false, + appPath: process.cwd(), + resourcesPath: "unused", +}); +const profile = parseProfileId("917e78f3-2d6a-46ad-9142-51ba6c50ccf4"); +const peerProfile = parseProfileId("8d4644cf-9535-4307-8049-388351d40716"); +const isolationSlot: SecretSlot = "arenaNetCredentials"; +const profileIsolationSlot = multiSecretSlot(profile, "arenaNetCredentials"); +const peerProfileSlot = multiSecretSlot(peerProfile, "arenaNetCredentials"); +const slots: readonly SecretSlot[] = [ + ...SINGLE_SECRET_SLOTS, + profileIsolationSlot, + multiSecretSlot(profile, "steamSession"), + peerProfileSlot, + multiSecretSlot(peerProfile, "steamSession"), +]; +const touched: Array<{ + keychain: WindowsCredentialKeychain; + slot: SecretSlot; +}> = []; + +try { + assert.match(host.localAppData(), /^[A-Za-z]:\\/u); + + for (const channel of DISTRIBUTION_CHANNELS) { + const keychain = new WindowsCredentialKeychain(host, channel); + for (const slot of slots) { + assert.equal( + await keychain.load(slot), + null, + `refusing to overwrite an occupied ${channel}/${slot} credential`, + ); + } + } + + for (const channel of DISTRIBUTION_CHANNELS) { + const keychain = new WindowsCredentialKeychain(host, channel); + for (const slot of slots) { + const initial = randomBytes(48); + const replacement = randomBytes(64); + await keychain.save(slot, initial); + touched.push({ keychain, slot }); + assert.deepEqual(await keychain.load(slot), initial); + await keychain.save(slot, replacement); + assert.deepEqual(await keychain.load(slot), replacement); + initial.fill(0); + replacement.fill(0); + } + } + + const release = new WindowsCredentialKeychain(host, "release"); + const development = new WindowsCredentialKeychain(host, "development"); + await release.clear(isolationSlot); + assert.notEqual( + await development.load(isolationSlot), + null, + "clearing Release must not clear Development", + ); + await release.clear(profileIsolationSlot); + assert.notEqual( + await release.load(peerProfileSlot), + null, + "clearing one profile must not clear another", + ); + + globalThis.console.log(JSON.stringify({ + platform: process.platform, + localAppData: "resolved", + channels: DISTRIBUTION_CHANNELS.length, + slotsPerChannel: slots.length, + results: { + emptyNamespaceGuard: "passed", + roundTrip: "passed", + replacement: "passed", + channelIsolation: "passed", + profileIsolation: "passed", + }, + unproven: [ + "signed installer replacement", + "installed application identity", + "credential persistence across reboot", + ], + }, null, 2)); +} finally { + // Keep cleanup deterministic and surface the first Credential Manager error. + // Promise.allSettled hid failed deletions and made the later assertion look + // like a persistence bug instead of reporting the operation that failed. + for (const { keychain, slot } of touched) { + await keychain.clear(slot); + } + for (const channel of DISTRIBUTION_CHANNELS) { + const keychain = new WindowsCredentialKeychain(host, channel); + for (const slot of slots) { + assert.equal(await keychain.load(slot), null, `${channel}/${slot} was not cleared`); + } + } +} diff --git a/src/main/core/atomic-file.ts b/src/main/core/atomic-file.ts index a40603d1..32e2efc0 100644 --- a/src/main/core/atomic-file.ts +++ b/src/main/core/atomic-file.ts @@ -67,10 +67,27 @@ export async function writeAll(handle: ByteSink, data: Uint8Array): Promise { const handle = await open(dir, "r"); try { - await handle.sync(); + try { + await handle.sync(); + } catch (error) { + // Windows does not expose directory fsync through Node. The file itself + // was flushed before its atomic rename, which is the strongest portable + // guarantee available without adding another native storage host. + if (!directorySyncIsUnsupported(process.platform, error)) throw error; + } } finally { await handle.close(); } diff --git a/src/main/core/paths.ts b/src/main/core/paths.ts index 361fe230..c1d67a7e 100644 --- a/src/main/core/paths.ts +++ b/src/main/core/paths.ts @@ -81,6 +81,21 @@ export function colocatedStorageRoots(root: string): ApplicationStorageRoots { }; } +/** Resolve the first Windows release layout below the native LocalAppData root. */ +export function windowsStorageRoots( + localAppData: string, +): ApplicationStorageRoots { + const root = path.win32.join(localAppData, "Guild Wars Reforged"); + return { + config: path.win32.join(root, "config"), + data: path.win32.join(root, "data"), + cache: path.win32.join(root, "cache"), + state: path.win32.join(root, "state"), + logs: path.win32.join(root, "logs"), + sessions: path.win32.join(root, "data", "sessions"), + }; +} + export function gamePaths(storage: ApplicationStorageRoots): GamePaths { const game = path.join(storage.cache, "game"); const artifacts = path.join(game, "artifacts"); diff --git a/src/main/main.ts b/src/main/main.ts index e365e6de..1ffacdf9 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -11,6 +11,7 @@ import { app, autoUpdater, type BrowserWindow, + crashReporter, dialog, Notification, powerMonitor, @@ -77,7 +78,11 @@ import { wireLifecycle, } from "./lifecycle.js"; import { sweepOrphanDirectories } from "./core/atomic-file.js"; -import { documentDirectories } from "./core/paths.js"; +import { + colocatedStorageRoots, + documentDirectories, + windowsStorageRoots, +} from "./core/paths.js"; import { gamePaths } from "./paths.js"; import { DEVELOPER_ENHANCEMENT_PROGRAM, @@ -155,19 +160,48 @@ import { shortcutOwner, } from "./core/launcher-tools.js"; import { captureLauncherShortcut } from "./launcher-shortcut-capture.js"; +import { + loadWindowsNativeHost, + WindowsCredentialKeychain, +} from "./windows-native-host.js"; + +const nativeHostLayout = { + packaged: app.isPackaged, + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, +}; +const windowsNativeHost = process.platform === "win32" + ? loadWindowsNativeHost(nativeHostLayout) + : null; +const explicitUserData = app.commandLine.hasSwitch("user-data-dir"); -// The public app name changed after alpha profiles already existed. Keep that -// one profile as the canonical home so the rename cannot strand saved login, -// settings, diagnostics, or roughly 4 GB of verified game data. An explicit -// profile remains authoritative for tests and the deliberately scoped tools. -if (!app.commandLine.hasSwitch("user-data-dir")) { +// macOS keeps its released root exactly. Windows has no released population, +// so its first layout starts in native LocalAppData. Explicit fixture roots +// always win and remain fully disposable on every platform. +if (!explicitUserData && process.platform === "darwin") { app.setPath("userData", path.join(app.getPath("appData"), "Guild Wars")); } +const applicationStorageRoots = explicitUserData + ? colocatedStorageRoots(app.getPath("userData")) + : windowsNativeHost + ? windowsStorageRoots(windowsNativeHost.localAppData()) + : colocatedStorageRoots(app.getPath("userData")); +if (!explicitUserData && process.platform === "win32") { + app.setPath("userData", applicationStorageRoots.sessions); + app.setPath("sessionData", applicationStorageRoots.sessions); +} const primaryInstance = app.requestSingleInstanceLock(); if (!primaryInstance) { app.quit(); } else { + // Electron's Windows renderers initialize Crashpad before application code + // runs. Start their local handler before `ready` so a renderer never exits + // because no handler is connected. Reports remain on this device; this + // application does not configure a crash-report upload endpoint. + if (process.platform === "win32") { + crashReporter.start({ uploadToServer: false }); + } enableSandboxBeforeReady(); registerGwScheme(); wireLifecycle(); @@ -194,7 +228,7 @@ const HOST_VERSION = (() => { })(); const preferences = new PreferencesCoordinator( - () => gamePaths(), + () => gamePaths(applicationStorageRoots), () => logEvent({ k: "travelPreferences.corruptRecovered" }), publishSettings, ); @@ -377,7 +411,7 @@ async function hasReleasedSingleData(paths: ReturnType): Promi } async function ensureDirs(): Promise { - const paths = gamePaths(); + const paths = gamePaths(applicationStorageRoots); await mkdir(paths.game, { recursive: true }); await mkdir(paths.chunks, { recursive: true }); await mkdir(paths.diagnostics, { recursive: true }); @@ -500,11 +534,6 @@ if (primaryInstance) void app.whenReady().then(async () => { "Mat4m0/gwonmac · App icon artwork © ArenaNet LLC · QT Friz Quad © 1992 QualiType (SIL OFL 1.1) · Not affiliated with ArenaNet or NCSOFT.", website: EXTERNAL_URLS.github, }); - const nativeHostLayout = { - packaged: app.isPackaged, - appPath: app.getAppPath(), - resourcesPath: process.resourcesPath, - }; const darwinNativeHost = process.platform === "darwin" ? loadDarwinNativeHost(nativeHostLayout) : null; @@ -530,7 +559,7 @@ if (primaryInstance) void app.whenReady().then(async () => { }) : () => {}; app.once("will-quit", () => stopCommandKeyUps()); - const paths = gamePaths(); + const paths = gamePaths(applicationStorageRoots); const legacySingleData = await hasReleasedSingleData(paths); const workspaceExisted = await pathExists(paths.multiWorkspace); const loadedLauncherState = await loadOrCreateLauncherState( @@ -633,10 +662,19 @@ if (primaryInstance) void app.whenReady().then(async () => { } let keychain: NativeKeychain; if (persistentSecrets) { - if (darwinNativeHost === null) { + if (distributionChannel === null) { + throw new Error("persistent secret provider is unavailable"); + } + if (process.platform === "darwin" && darwinNativeHost !== null) { + keychain = darwinNativeHost; + } else if (process.platform === "win32" && windowsNativeHost !== null) { + keychain = new WindowsCredentialKeychain( + windowsNativeHost, + distributionChannel, + ); + } else { throw new Error("persistent secret provider is unavailable"); } - keychain = darwinNativeHost; } else { keychain = new VolatileNativeKeychain(); } diff --git a/src/main/paths.ts b/src/main/paths.ts index f74590b2..289420a3 100644 --- a/src/main/paths.ts +++ b/src/main/paths.ts @@ -16,12 +16,17 @@ import { unpackedPath, } from "./core/paths.js"; import type { GamePaths } from "./core/paths.js"; +import type { ApplicationStorageRoots } from "./core/paths.js"; export type { GamePaths } from "./core/paths.js"; /** The path table rooted at Electron's per-user data directory. */ -export function gamePaths(userData = app.getPath("userData")): GamePaths { - return resolveGamePaths(colocatedStorageRoots(userData)); +export function gamePaths( + storage: ApplicationStorageRoots = colocatedStorageRoots( + app.getPath("userData"), + ), +): GamePaths { + return resolveGamePaths(storage); } export function rendererRoot(): string { diff --git a/src/main/windows-native-host.ts b/src/main/windows-native-host.ts new file mode 100644 index 00000000..25bca885 --- /dev/null +++ b/src/main/windows-native-host.ts @@ -0,0 +1,73 @@ +/** + * Loads the Windows-only known-folder and Credential Manager boundary. + * The raw addon accepts only closed identities and slots; this module binds + * one distribution identity to the existing NativeKeychain contract. + */ +import { createRequire } from "node:module"; +import path from "node:path"; +import type { SecretSlot, NativeKeychain } from "./core/native-keychain.js"; +import type { BundleLayout } from "./core/paths.js"; +import { + DISTRIBUTION_CHANNEL_CONFIG, + type DistributionChannel, +} from "../shared/distribution-channel.js"; + +interface WindowsNativeHost { + localAppData(): string; + load(identity: string, slot: SecretSlot): Promise; + save(identity: string, slot: SecretSlot, value: Buffer): Promise; + clear(identity: string, slot: SecretSlot): Promise; +} + +export function windowsNativeHostPath(layout: BundleLayout): string { + const root = layout.packaged + ? path.win32.join(layout.resourcesPath, "app.asar.unpacked") + : layout.appPath; + return path.win32.join(root, "build/native/windows-host.node"); +} + +function isWindowsNativeHost(value: unknown): value is WindowsNativeHost { + if (typeof value !== "object" || value === null) return false; + const candidate = value as Partial>; + return ( + typeof candidate.localAppData === "function" + && typeof candidate.load === "function" + && typeof candidate.save === "function" + && typeof candidate.clear === "function" + ); +} + +export function loadWindowsNativeHost(layout: BundleLayout): WindowsNativeHost { + const loaded: unknown = createRequire(import.meta.url)( + windowsNativeHostPath(layout), + ); + if (!isWindowsNativeHost(loaded)) { + throw new TypeError("Windows native host module has an invalid shape"); + } + return loaded; +} + +export class WindowsCredentialKeychain implements NativeKeychain { + readonly #identity: string; + readonly #host: WindowsNativeHost; + + constructor( + host: WindowsNativeHost, + channel: DistributionChannel, + ) { + this.#host = host; + this.#identity = DISTRIBUTION_CHANNEL_CONFIG[channel].bundleId; + } + + load(slot: SecretSlot): Promise { + return this.#host.load(this.#identity, slot); + } + + save(slot: SecretSlot, value: Buffer): Promise { + return this.#host.save(this.#identity, slot, value); + } + + clear(slot: SecretSlot): Promise { + return this.#host.clear(this.#identity, slot); + } +} diff --git a/src/native/windows-host/host.cpp b/src/native/windows-host/host.cpp new file mode 100644 index 00000000..4bab5605 --- /dev/null +++ b/src/native/windows-host/host.cpp @@ -0,0 +1,319 @@ +/** + * Windows-only native boundary for LocalAppData and Credential Manager. + * Renderer code never loads this addon. Main supplies one closed application + * identity and one closed secret slot to every asynchronous credential call. + */ +#define NAPI_VERSION 8 + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr char kReleaseIdentity[] = "io.github.mat4m0.gwonmac"; +constexpr char kPreviewIdentity[] = "io.github.mat4m0.gwonmac.preview"; +constexpr char kDevelopmentIdentity[] = "io.github.mat4m0.gwonmac.dev"; +constexpr char kCredentialsSlot[] = "arenaNetCredentials"; +constexpr char kSteamSlot[] = "steamSession"; +constexpr char kMultiPrefix[] = "multi."; + +enum class Operation { kLoad, kSave, kClear }; +enum class Result { kSuccess, kNotFound, kTooLarge, kUnavailable }; + +struct Work { + napi_env env = nullptr; + napi_async_work asyncWork = nullptr; + napi_deferred deferred = nullptr; + Operation operation = Operation::kLoad; + std::wstring target; + Result result = Result::kUnavailable; + std::vector input; + std::vector output; +}; + +void Zero(std::vector &bytes) { + if (!bytes.empty()) SecureZeroMemory(bytes.data(), bytes.size()); + bytes.clear(); +} + +bool IsLowerHex(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'); +} + +bool IsUuidV4(const std::string &value) { + if (value.size() != 36 || value[8] != '-' || value[13] != '-' || + value[18] != '-' || value[23] != '-' || value[14] != '4' || + (value[19] != '8' && value[19] != '9' && value[19] != 'a' && + value[19] != 'b')) return false; + for (std::size_t index = 0; index < value.size(); ++index) { + if (index == 8 || index == 13 || index == 18 || index == 23) continue; + if (!IsLowerHex(value[index])) return false; + } + return true; +} + +bool ValidIdentity(const std::string &value) { + return value == kReleaseIdentity || value == kPreviewIdentity || + value == kDevelopmentIdentity; +} + +bool ValidSlot(const std::string &slot) { + if (slot == kCredentialsSlot || slot == kSteamSlot) return true; + const std::string prefix = kMultiPrefix; + if (slot.rfind(prefix, 0) != 0) return false; + const std::size_t separator = slot.find('.', prefix.size()); + if (separator == std::string::npos) return false; + const std::string profile = slot.substr(prefix.size(), separator - prefix.size()); + const std::string kind = slot.substr(separator + 1); + return IsUuidV4(profile) && + (kind == kCredentialsSlot || kind == kSteamSlot); +} + +bool ReadAscii(napi_env env, napi_value value, std::string *output, + std::size_t maximum) { + std::size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok || + length == 0 || length > maximum) return false; + std::vector bytes(length + 1, '\0'); + if (napi_get_value_string_utf8(env, value, bytes.data(), bytes.size(), + &length) != napi_ok) return false; + for (std::size_t index = 0; index < length; ++index) { + if (static_cast(bytes[index]) > 0x7f) return false; + } + output->assign(bytes.data(), length); + return true; +} + +std::wstring WidenAscii(const std::string &value) { + return std::wstring(value.begin(), value.end()); +} + +Result Load(Work &work) { + PCREDENTIALW credential = nullptr; + if (!CredReadW(work.target.c_str(), CRED_TYPE_GENERIC, 0, &credential)) { + return GetLastError() == ERROR_NOT_FOUND ? Result::kNotFound + : Result::kUnavailable; + } + try { + if (credential->CredentialBlobSize > 0) { + const auto *begin = credential->CredentialBlob; + work.output.assign(begin, begin + credential->CredentialBlobSize); + } + } catch (const std::exception &) { + CredFree(credential); + return Result::kUnavailable; + } + CredFree(credential); + return Result::kSuccess; +} + +Result Save(Work &work) { + if (work.input.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) { + return Result::kTooLarge; + } + CREDENTIALW credential{}; + credential.Type = CRED_TYPE_GENERIC; + credential.TargetName = work.target.data(); + credential.CredentialBlobSize = static_cast(work.input.size()); + credential.CredentialBlob = work.input.data(); + credential.Persist = CRED_PERSIST_LOCAL_MACHINE; + wchar_t label[] = L"Guild Wars Reforged"; + credential.UserName = label; + return CredWriteW(&credential, 0) ? Result::kSuccess : Result::kUnavailable; +} + +Result Clear(Work &work) { + if (CredDeleteW(work.target.c_str(), CRED_TYPE_GENERIC, 0)) { + return Result::kSuccess; + } + return GetLastError() == ERROR_NOT_FOUND ? Result::kSuccess + : Result::kUnavailable; +} + +void Execute(napi_env, void *data) { + auto &work = *static_cast(data); + try { + switch (work.operation) { + case Operation::kLoad: work.result = Load(work); break; + case Operation::kSave: work.result = Save(work); break; + case Operation::kClear: work.result = Clear(work); break; + } + } catch (const std::exception &) { + work.result = Result::kUnavailable; + } + Zero(work.input); +} + +const char *ErrorCode(Result result) { + return result == Result::kTooLarge ? "too_large" : "unavailable"; +} + +void Reject(Work &work) { + napi_value message; + napi_value error; + napi_value code; + if (napi_create_string_utf8(work.env, "Credential Manager unavailable", + NAPI_AUTO_LENGTH, &message) != napi_ok || + napi_create_error(work.env, nullptr, message, &error) != napi_ok || + napi_create_string_utf8(work.env, ErrorCode(work.result), + NAPI_AUTO_LENGTH, &code) != napi_ok || + napi_set_named_property(work.env, error, "code", code) != napi_ok) { + napi_get_undefined(work.env, &error); + } + napi_reject_deferred(work.env, work.deferred, error); +} + +void Complete(napi_env env, napi_status status, void *data) { + auto *work = static_cast(data); + if (status != napi_ok) work->result = Result::kUnavailable; + if (work->result == Result::kSuccess || + (work->operation == Operation::kLoad && + work->result == Result::kNotFound)) { + napi_value value; + napi_status valueStatus = napi_ok; + if (work->operation == Operation::kLoad && + work->result == Result::kSuccess) { + valueStatus = napi_create_buffer_copy(env, work->output.size(), + work->output.data(), nullptr, + &value); + } else if (work->operation == Operation::kLoad) { + valueStatus = napi_get_null(env, &value); + } else { + valueStatus = napi_get_undefined(env, &value); + } + if (valueStatus == napi_ok) napi_resolve_deferred(env, work->deferred, value); + else Reject(*work); + } else { + Reject(*work); + } + Zero(work->input); + Zero(work->output); + napi_delete_async_work(env, work->asyncWork); + delete work; +} + +napi_value Queue(napi_env env, napi_callback_info info, Operation operation) { + std::size_t argc = operation == Operation::kSave ? 3 : 2; + napi_value argv[3]; + if (napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr) != napi_ok || + argc != (operation == Operation::kSave ? 3U : 2U)) { + napi_throw_type_error(env, nullptr, "invalid Credential Manager arguments"); + return nullptr; + } + std::string identity; + std::string slot; + if (!ReadAscii(env, argv[0], &identity, 64) || !ValidIdentity(identity) || + !ReadAscii(env, argv[1], &slot, 96) || !ValidSlot(slot)) { + napi_throw_type_error(env, nullptr, "invalid Credential Manager slot"); + return nullptr; + } + auto *work = new (std::nothrow) Work(); + if (work == nullptr) { + napi_throw_error(env, nullptr, "Credential Manager unavailable"); + return nullptr; + } + work->env = env; + work->operation = operation; + work->target = WidenAscii(identity + "/saved-login/" + slot); + if (operation == Operation::kSave) { + bool isBuffer = false; + void *bytes = nullptr; + std::size_t length = 0; + if (napi_is_buffer(env, argv[2], &isBuffer) != napi_ok || !isBuffer || + napi_get_buffer_info(env, argv[2], &bytes, &length) != napi_ok) { + delete work; + napi_throw_type_error(env, nullptr, "credential value must be a Buffer"); + return nullptr; + } + try { + const auto *begin = static_cast(bytes); + if (length > 0) work->input.assign(begin, begin + length); + } catch (const std::exception &) { + delete work; + napi_throw_error(env, nullptr, "Credential Manager unavailable"); + return nullptr; + } + } + napi_value promise; + napi_value resourceName; + if (napi_create_promise(env, &work->deferred, &promise) != napi_ok || + napi_create_string_utf8(env, "gwonmac.windowsCredentials", + NAPI_AUTO_LENGTH, &resourceName) != napi_ok || + napi_create_async_work(env, nullptr, resourceName, Execute, Complete, + work, &work->asyncWork) != napi_ok || + napi_queue_async_work(env, work->asyncWork) != napi_ok) { + Zero(work->input); + if (work->asyncWork != nullptr) napi_delete_async_work(env, work->asyncWork); + delete work; + napi_throw_error(env, nullptr, "Credential Manager unavailable"); + return nullptr; + } + return promise; +} + +napi_value LoadCallback(napi_env env, napi_callback_info info) { + return Queue(env, info, Operation::kLoad); +} +napi_value SaveCallback(napi_env env, napi_callback_info info) { + return Queue(env, info, Operation::kSave); +} +napi_value ClearCallback(napi_env env, napi_callback_info info) { + return Queue(env, info, Operation::kClear); +} + +napi_value LocalAppDataCallback(napi_env env, napi_callback_info info) { + std::size_t argc = 0; + if (napi_get_cb_info(env, info, &argc, nullptr, nullptr, nullptr) != napi_ok || + argc != 0) { + napi_throw_type_error(env, nullptr, "LocalAppData takes no arguments"); + return nullptr; + } + PWSTR knownFolder = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_DEFAULT, + nullptr, &knownFolder)) || + knownFolder == nullptr) { + napi_throw_error(env, nullptr, "LocalAppData unavailable"); + return nullptr; + } + napi_value value; + const napi_status status = napi_create_string_utf16( + env, reinterpret_cast(knownFolder), NAPI_AUTO_LENGTH, + &value); + CoTaskMemFree(knownFolder); + if (status != napi_ok) { + napi_throw_error(env, nullptr, "LocalAppData unavailable"); + return nullptr; + } + return value; +} + +napi_value Init(napi_env env, napi_value exports) { + napi_property_descriptor properties[] = { + {"load", nullptr, LoadCallback, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"save", nullptr, SaveCallback, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"clear", nullptr, ClearCallback, nullptr, nullptr, nullptr, napi_default, + nullptr}, + {"localAppData", nullptr, LocalAppDataCallback, nullptr, nullptr, nullptr, + napi_default, nullptr}, + }; + if (napi_define_properties(env, exports, + sizeof(properties) / sizeof(properties[0]), + properties) != napi_ok) { + napi_throw_error(env, nullptr, "Windows native host initialization failed"); + } + return exports; +} + +} // namespace + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/tests/policy/forbidden-artifacts.test.ts b/tests/policy/forbidden-artifacts.test.ts index 0f5935cf..748e07ce 100644 --- a/tests/policy/forbidden-artifacts.test.ts +++ b/tests/policy/forbidden-artifacts.test.ts @@ -127,6 +127,8 @@ test("only public identifiers and explicit profile-id fixtures are UUID-shaped", "tests/unit/profile-storage.test.ts", "tests/unit/window-registry.test.ts", "tests/unit/window-coordinator.test.ts", + "scripts/windows-credential-probe.ts", + "tests/unit/windows-native-host.test.ts", ]); const hits = []; for (const file of tracked) { diff --git a/tests/policy/source-native-keychain.test.ts b/tests/policy/source-native-keychain.test.ts index 29d5d03c..646464dc 100644 --- a/tests/policy/source-native-keychain.test.ts +++ b/tests/policy/source-native-keychain.test.ts @@ -113,8 +113,15 @@ test("Windows and Linux build the decoder without the Darwin host", () => { const windows = nativeBuildSteps("win32", "x64"); const linux = nativeBuildSteps("linux", "x64"); - assert.deepEqual(windows.map(([command]) => command), ["cl.exe"]); - assert.ok(windows[0]?.[1].includes("/Fe:build/native/gw-dat-decode.exe")); + assert.deepEqual(windows.map(([command]) => command), [ + "lib.exe", + "cl.exe", + "cl.exe", + ]); + assert.ok(windows[0]?.[1].includes("/out:build/native/node.lib")); + assert.ok(windows[1]?.[1].includes("build/native/node.lib")); + assert.ok(windows[1]?.[1].includes("/Fe:build/native/windows-host.node")); + assert.ok(windows[2]?.[1].includes("/Fe:build/native/gw-dat-decode.exe")); assert.deepEqual(linux.map(([command]) => command), ["c++"]); assert.deepEqual(linux[0]?.[1].slice(-2), [ "-o", @@ -146,19 +153,21 @@ test("the first target ports refuse unsupported CPU architectures", () => { assert.throws(() => nativeBuildSteps("linux", "arm64")); }); -// Each package contains the decoder for its own platform. macOS also contains -// the native host. Every listed file is executable code that cannot run inside -// the archive, and the allowlist remains exact rather than opening a directory. +// Each package contains the decoder for its own platform. macOS and Windows +// also contain their separate native host. Every listed file is executable +// code that cannot run inside the archive, and the allowlist remains exact +// rather than opening a directory. test("Forge unpacks only the platform-native executables from ASAR", () => { const forge = read("forge.config.ts"); const packageIgnore = read("scripts/package-ignore.ts"); assert.match( forge, - /unpack: "\*\*\/build\/native\/\{host\.node,gw-dat-decode,gw-dat-decode\.exe\}"/u, + /unpack: "\*\*\/build\/native\/\{host\.node,windows-host\.node,gw-dat-decode,gw-dat-decode\.exe\}"/u, ); for (const kept of [ /p === "\/build\/native"/u, /p === "\/build\/native\/host\.node"/u, + /p === "\/build\/native\/windows-host\.node"/u, /p === "\/build\/native\/gw-dat-decode"/u, /p === "\/build\/native\/gw-dat-decode\.exe"/u, ]) { diff --git a/tests/policy/source-release-pipeline.test.ts b/tests/policy/source-release-pipeline.test.ts index fa323c99..1acd14ff 100644 --- a/tests/policy/source-release-pipeline.test.ts +++ b/tests/policy/source-release-pipeline.test.ts @@ -71,7 +71,14 @@ test("the public rename keeps the existing profile as its one data home", () => main, /app\.setPath\("userData", path\.join\(app\.getPath\("appData"\), "Guild Wars"\)\)/, ); - assert.match(main, /!app\.commandLine\.hasSwitch\("user-data-dir"\)/); + assert.match( + main, + /const explicitUserData = app\.commandLine\.hasSwitch\("user-data-dir"\)/, + ); + assert.match( + main, + /if \(!explicitUserData && process\.platform === "darwin"\)/, + ); }); test("package metadata identifies the GPL project and canonical repository", () => { diff --git a/tests/policy/source-saved-login-surface.test.ts b/tests/policy/source-saved-login-surface.test.ts index 695b8006..b57cb11a 100644 --- a/tests/policy/source-saved-login-surface.test.ts +++ b/tests/policy/source-saved-login-surface.test.ts @@ -75,7 +75,19 @@ test("only provisioned distribution channels enable persistent secrets", () => { assert.match(main, /capable: distribution\.automaticUpdates/); assert.match( main, - /if \(persistentSecrets\) \{[\s\S]{0,180}darwinNativeHost === null[\s\S]{0,180}persistent secret provider is unavailable[\s\S]{0,120}keychain = darwinNativeHost/, + /if \(persistentSecrets\) \{[\s\S]{0,160}distributionChannel === null[\s\S]{0,120}persistent secret provider is unavailable/, + ); + assert.match( + main, + /process\.platform === "darwin" && darwinNativeHost !== null[\s\S]{0,100}keychain = darwinNativeHost/, + ); + assert.match( + main, + /process\.platform === "win32" && windowsNativeHost !== null[\s\S]{0,160}keychain = new WindowsCredentialKeychain/, + ); + assert.match( + main, + /else \{\s*throw new Error\("persistent secret provider is unavailable"\)/, ); assert.match(main, /else \{\s*keychain = new VolatileNativeKeychain\(\)/); assert.doesNotMatch(shippedApplication, /use-mock-keychain/); diff --git a/tests/policy/source-windows-credentials.test.ts b/tests/policy/source-windows-credentials.test.ts new file mode 100644 index 00000000..5747cca0 --- /dev/null +++ b/tests/policy/source-windows-credentials.test.ts @@ -0,0 +1,47 @@ +/** Static authority checks for the Windows known-folder and secret boundary. */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; + +const root = process.cwd(); +const native = readFileSync( + path.join(root, "src/native/windows-host/host.cpp"), + "utf8", +); +const main = readFileSync(path.join(root, "src/main/main.ts"), "utf8"); + +test("Windows storage starts from the native LocalAppData known folder", () => { + assert.match(native, /SHGetKnownFolderPath\(FOLDERID_LocalAppData/u); + assert.doesNotMatch(native, /getenv|LOCALAPPDATA|APPDATA/u); + assert.match(main, /windowsStorageRoots\(windowsNativeHost\.localAppData\(\)\)/u); + assert.match(main, /explicitUserData\s*\? colocatedStorageRoots/u); +}); + +test("Windows starts the local Crashpad handler before renderer creation", () => { + assert.match(main, /process\.platform === "win32"[\s\S]*crashReporter\.start\(\{ uploadToServer: false \}\)/u); + assert.doesNotMatch(main, /crashReporter\.start\(\{[^}]*submitURL/u); +}); + +test("Credential Manager owns only closed application and profile slots", () => { + for (const value of [ + "io.github.mat4m0.gwonmac", + "io.github.mat4m0.gwonmac.preview", + "io.github.mat4m0.gwonmac.dev", + "arenaNetCredentials", + "steamSession", + ]) { + assert.ok(native.includes(`"${value}"`), `${value} is not native-owned`); + } + assert.match(native, /CredReadW/u); + assert.match(native, /CredWriteW/u); + assert.match(native, /CredDeleteW/u); + assert.match(native, /CRED_TYPE_GENERIC/u); + assert.match(native, /CRED_PERSIST_LOCAL_MACHINE/u); + assert.match(native, /CRED_MAX_CREDENTIAL_BLOB_SIZE/u); + assert.match(native, /SecureZeroMemory/u); + assert.match(native, /napi_create_async_work/u); + assert.match(native, /napi_queue_async_work/u); + assert.doesNotMatch(native, /system\s*\(|popen\s*\(|CreateProcess/u); + assert.doesNotMatch(main, /safeStorage|encryptString|decryptString/u); +}); diff --git a/tests/unit/atomic-file.test.ts b/tests/unit/atomic-file.test.ts index 4f3495f6..0690f0b1 100644 --- a/tests/unit/atomic-file.test.ts +++ b/tests/unit/atomic-file.test.ts @@ -19,6 +19,7 @@ import { sweepOrphanDirectories, sweepOrphans, AtomicExclusiveWriteError, + directorySyncIsUnsupported, writeAll, writeAtomic, writeAtomicExclusive, @@ -35,6 +36,23 @@ async function scratch(): Promise { return mkdtemp(join(tmpdir(), "gw-atomic-")); } +describe("atomic directory durability", () => { + it("accepts only Windows' unsupported directory fsync result", () => { + const unsupported = Object.assign(new Error("operation not permitted"), { + code: "EPERM", + }); + assert.equal(directorySyncIsUnsupported("win32", unsupported), true); + assert.equal(directorySyncIsUnsupported("linux", unsupported), false); + assert.equal( + directorySyncIsUnsupported( + "win32", + Object.assign(new Error("I/O error"), { code: "EIO" }), + ), + false, + ); + }); +}); + /** A sink that never consumes more than `limit` bytes per call, like a real short write. */ function shortWriteSink(limit: number): { write: ( diff --git a/tests/unit/paths.test.ts b/tests/unit/paths.test.ts index fd757520..b43c2804 100644 --- a/tests/unit/paths.test.ts +++ b/tests/unit/paths.test.ts @@ -10,6 +10,7 @@ import { multiProfilePaths, nativeExecutableName, unpackedPath, + windowsStorageRoots, } from "../../src/main/core/paths.ts"; import { parseProfileId } from "../../src/shared/multiple-accounts.ts"; @@ -210,4 +211,16 @@ describe("resolved profile paths", () => { "/roots/cache/game/enhancements", ]); }); + + it("pins the first Windows layout beneath native LocalAppData", () => { + assert.deepEqual(windowsStorageRoots("C:\\Users\\Player\\AppData\\Local"), { + config: "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\config", + data: "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\data", + cache: "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\cache", + state: "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\state", + logs: "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\logs", + sessions: + "C:\\Users\\Player\\AppData\\Local\\Guild Wars Reforged\\data\\sessions", + }); + }); }); diff --git a/tests/unit/windows-native-host.test.ts b/tests/unit/windows-native-host.test.ts new file mode 100644 index 00000000..83e592c2 --- /dev/null +++ b/tests/unit/windows-native-host.test.ts @@ -0,0 +1,64 @@ +/** Contract tests for the Windows Credential Manager binding owned by main. */ +import assert from "node:assert/strict"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { parseProfileId } from "../../src/shared/multiple-accounts.js"; +import { multiSecretSlot } from "../../src/main/core/native-keychain.js"; +import { + WindowsCredentialKeychain, + windowsNativeHostPath, +} from "../../src/main/windows-native-host.js"; + +describe("Windows native host boundary", () => { + it("resolves the unpacked addon in development and a package", () => { + assert.equal( + windowsNativeHostPath({ + packaged: false, + appPath: "C:\\checkout", + resourcesPath: "C:\\ignored", + }), + path.win32.join("C:\\checkout", "build/native/windows-host.node"), + ); + assert.equal( + windowsNativeHostPath({ + packaged: true, + appPath: "C:\\ignored", + resourcesPath: "C:\\App\\resources", + }), + path.win32.join( + "C:\\App\\resources", + "app.asar.unpacked/build/native/windows-host.node", + ), + ); + }); + + it("binds one distribution identity to every closed slot", async () => { + const calls: unknown[][] = []; + const host = { + localAppData: () => "unused", + load: async (...args: unknown[]) => { + calls.push(["load", ...args]); + return Buffer.from("saved"); + }, + save: async (...args: unknown[]) => { + calls.push(["save", ...args]); + }, + clear: async (...args: unknown[]) => { + calls.push(["clear", ...args]); + }, + }; + const keychain = new WindowsCredentialKeychain(host, "preview"); + const profile = parseProfileId("2d31e565-9fc8-4dde-9fd4-9d644f8283ae"); + const slot = multiSecretSlot(profile, "arenaNetCredentials"); + + assert.equal((await keychain.load(slot))?.toString(), "saved"); + await keychain.save(slot, Buffer.from("replacement")); + await keychain.clear(slot); + + assert.deepEqual(calls, [ + ["load", "io.github.mat4m0.gwonmac.preview", slot], + ["save", "io.github.mat4m0.gwonmac.preview", slot, Buffer.from("replacement")], + ["clear", "io.github.mat4m0.gwonmac.preview", slot], + ]); + }); +});