Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/portable-native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
25 changes: 15 additions & 10 deletions docs/process-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -354,19 +356,22 @@ 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
only to the adopted Main account. A read failure does not delete an item. The game
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
Expand Down
2 changes: 1 addition & 1 deletion forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
57 changes: 45 additions & 12 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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") {
Expand Down
1 change: 1 addition & 0 deletions scripts/package-ignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
) {
Expand Down
121 changes: 121 additions & 0 deletions scripts/windows-credential-probe.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
}
}
19 changes: 18 additions & 1 deletion src/main/core/atomic-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,27 @@ export async function writeAll(handle: ByteSink, data: Uint8Array): Promise<void
* does not expose. This is correct against process and OS crashes, which is the
* failure mode we face; it is not a claim about sudden power loss.
*/
export function directorySyncIsUnsupported(
platform: NodeJS.Platform,
error: unknown,
): boolean {
return platform === "win32"
&& error instanceof Error
&& "code" in error
&& error.code === "EPERM";
}

async function syncDirectory(dir: string): Promise<void> {
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();
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading