Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/blue-emus-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@9c5s/node-tcnet": patch
---

fix: Windows BridgeのXTEA暗号文バイトリバースに対応
81 changes: 81 additions & 0 deletions src/tcnet.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Socket, createSocket, RemoteInfo } from "dgram";
import { EventEmitter } from "events";
import { execFile } from "child_process";
import { platform } from "os";
import * as nw from "./network";
import { MultiPacketAssembler } from "./multi-packet";
import { interfaceAddress, listNetworkAdapters, findIPv4Address, type NetworkAdapterInfo } from "./utils";
Expand Down Expand Up @@ -91,6 +93,7 @@ export class TCNetClient extends EventEmitter {
private _authState: AuthState = "none";
private sessionToken: number | null = null;
private authTimeoutId: NodeJS.Timeout | null = null;
private bridgeIsWindows: boolean | null = null;

/**
* TCNetClientを初期化する
Expand Down Expand Up @@ -470,6 +473,9 @@ export class TCNetClient extends EventEmitter {
}
} else if (this.connected) {
// 確定後: 従来通りserver更新
if (this.server?.address !== rinfo.address) {
this.bridgeIsWindows = null;
}
this.server = rinfo;
this.server.port = packet.nodeListenerPort;
}
Expand Down Expand Up @@ -797,6 +803,67 @@ export class TCNetClient extends EventEmitter {
return null;
}

/**
* BridgeのOSがWindowsであるかをpingのTTL値から検出する
*
* Bridge IPが自分のIPと一致する場合はos.platform()で判定する。
* リモートの場合はpingを1回実行しTTL値をパースする (TTL > 64 → Windows)。
* WindowsのデフォルトTTLは128、macOS/LinuxのデフォルトTTLは64であるため、
* 同一LAN (0-1ホップ) ではこの閾値で正確に判定できる。
* 結果はインスタンス変数にキャッシュし、セッション中1回だけ検出する。
* @returns Windowsならtrue、それ以外ならfalse
*/
private async detectBridgeIsWindows(): Promise<boolean> {
if (this.bridgeIsWindows !== null) return this.bridgeIsWindows;

const bridgeIp = this.server?.address;
if (!bridgeIp) {
// serverが未設定の場合はキャッシュせず、次回の呼び出しで再検出を許可する
return false;
}

// IPv4フォーマットバリデーション (execFileに渡す前の防御)
// 不正IPは確定的でないためキャッシュしない
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(bridgeIp)) {
return false;
}

const clientIp = this.getClientIp();
if (clientIp && bridgeIp === clientIp) {
this.bridgeIsWindows = platform() === "win32";
this.log?.debug(`Bridge OS detected (local): ${this.bridgeIsWindows ? "Windows" : "non-Windows"}`);
return this.bridgeIsWindows;
}

try {
const isWin = platform() === "win32";
const args = isWin ? ["-n", "1", "-w", "1000", bridgeIp] : ["-c", "1", "-W", "1", bridgeIp];
const output = await new Promise<string>((resolve, reject) => {
execFile("ping", args, { timeout: 3000 }, (err, stdout) => {
if (err) reject(err);
else resolve(stdout);
});
});
const match = output.match(/ttl[=:](\d+)/i);
if (match) {
const ttl = Number.parseInt(match[1], 10);
this.bridgeIsWindows = ttl > 64;
this.log?.debug(`Bridge OS detected (TTL=${ttl}): ${this.bridgeIsWindows ? "Windows" : "non-Windows"}`);
} else {
// TTL未検出は確定的でないためキャッシュせず、次回再検出を許可する
this.log?.debug("Bridge OS detection: TTL not found in ping output, assuming non-Windows");
return false;
}
} catch (err) {
// ping失敗は確定的でないためキャッシュせず、次回再検出を許可する
const msg = err instanceof Error ? err.message : String(err);
this.log?.debug(`Bridge OS detection: ping failed (${msg}), assuming non-Windows`);
return false;
}

return this.bridgeIsWindows;
}

/**
* AppDataパケットを生成する
* @param cmd - コマンド番号
Expand Down Expand Up @@ -831,6 +898,7 @@ export class TCNetClient extends EventEmitter {
private resetAuthSession(): void {
this._authState = "none";
this.sessionToken = null;
this.bridgeIsWindows = null;
if (this.authTimeoutId) {
clearTimeout(this.authTimeoutId);
this.authTimeoutId = null;
Expand Down Expand Up @@ -874,6 +942,19 @@ export class TCNetClient extends EventEmitter {
}

const ciphertext = Buffer.from(ct, "hex");
const tokenBeforePing = this.sessionToken;
// Windows BridgeはXTEA暗号文をバイトリバースして読み取るため、事前にリバースして送信する
// 初回呼び出し時のみpingが発生し最大3秒かかる (AUTH_RESPONSE_TIMEOUT=5秒内に収まる想定)
if (await this.detectBridgeIsWindows()) {
ciphertext.reverse();
}

// detectBridgeIsWindowsのawait中に状態が変わった場合のガード
if (!this.server || !this.broadcastSocket || this.sessionToken !== tokenBeforePing) {
this.resetAuthSession();
return;
}

const payload = generateAuthPayload(this.sessionToken, clientIp, ciphertext);
const auth = this.createAppDataPacket(2, this.sessionToken, payload);
await this.sendPacket(auth, this.broadcastSocket, TCNET_BROADCAST_PORT, this.config.broadcastAddress);
Expand Down
101 changes: 101 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,18 @@ class AuthSequenceTestClient extends TCNetClient {
public setSelectedAdapter(adapter: any): void {
(this as any)._selectedAdapter = adapter;
}
public callDetectBridgeIsWindows(): Promise<boolean> {
return (this as any).detectBridgeIsWindows();
}
public getBridgeIsWindows(): boolean | null {
return (this as any).bridgeIsWindows;
}
public setBridgeIsWindows(value: boolean | null): void {
(this as any).bridgeIsWindows = value;
}
public setServer(server: any): void {
(this as any).server = server;
}
}

describe("sendAuthSequence 状態リセット", () => {
Expand Down Expand Up @@ -703,3 +715,92 @@ describe("TCNetApplicationDataPacket.write() payload長検証", () => {
expect(() => packet.write()).toThrow("ApplicationData payload must be 12 bytes");
});
});

describe("sendAuthSequence XTEA暗号文バイトリバース", () => {
/**
* テスト用アダプタ情報を生成する
* @param ip - IPv4アドレス文字列
*/
function createAdapter(ip: string) {
return {
name: "test0",
addresses: [
{
address: ip,
netmask: "255.255.255.0",
family: "IPv4" as const,
mac: "00:00:00:00:00:00",
internal: false,
cidr: `${ip}/24`,
},
],
};
}

it("Windows Bridge判定時に暗号文がバイトリバースされて送信される", async () => {
const client = new AuthSequenceTestClient("8ee0dc051b1ddf8b");
client.setSessionToken(0xdeec6dfc);
client.setAuthState("pending");
client.setSelectedAdapter(createAdapter("192.168.0.10"));

const sentBuffers: Buffer[] = [];
client.setBroadcastSocket({
send: vi.fn((buf: Buffer, _port: number, _addr: string, cb: (err: Error | null) => void) => {
sentBuffers.push(Buffer.from(buf));
cb(null);
}),
});
(client as any).config.broadcastAddress = "255.255.255.255";

// BridgeをWindowsとして事前設定する
client.setBridgeIsWindows(true);

await (client as any).sendAuthSequence();

// 2番目のパケット (cmd=2) のpayloadを検査する
expect(sentBuffers.length).toBe(2);
const authBuf = sentBuffers[1];
// payloadはoffset 50から12バイト
const payload = authBuf.subarray(50, 62);
// auth[4:12]がリバースされた暗号文であることを確認
const reversedCiphertext = Buffer.from("8ee0dc051b1ddf8b", "hex").reverse();
expect(payload.subarray(4, 12)).toEqual(reversedCiphertext);
});

it("non-Windows Bridge判定時に暗号文がそのまま送信される", async () => {
const client = new AuthSequenceTestClient("8ee0dc051b1ddf8b");
client.setSessionToken(0xdeec6dfc);
client.setAuthState("pending");
client.setSelectedAdapter(createAdapter("192.168.0.10"));

const sentBuffers: Buffer[] = [];
client.setBroadcastSocket({
send: vi.fn((buf: Buffer, _port: number, _addr: string, cb: (err: Error | null) => void) => {
sentBuffers.push(Buffer.from(buf));
cb(null);
}),
});
(client as any).config.broadcastAddress = "255.255.255.255";

// Bridgeをnon-Windowsとして事前設定する
client.setBridgeIsWindows(false);

await (client as any).sendAuthSequence();

expect(sentBuffers.length).toBe(2);
const authBuf = sentBuffers[1];
const payload = authBuf.subarray(50, 62);
const originalCiphertext = Buffer.from("8ee0dc051b1ddf8b", "hex");
expect(payload.subarray(4, 12)).toEqual(originalCiphertext);
});

it("resetAuthSessionでbridgeIsWindowsキャッシュがクリアされる", () => {
const client = new AuthSequenceTestClient();
client.setBridgeIsWindows(true);
client.setAuthState("pending");

(client as any).resetAuthSession();

expect(client.getBridgeIsWindows()).toBeNull();
});
});
Loading
Loading