fix(ios-qa): boot token out of os_log entirely; IPv4 listener pinned to loopback at the socket

The StateServer's bootstrap announce logged the live boot token with
privacy: .public — and nothing consumed it: the daemon has read the token
from the 0600 app-container file since the devicectl copy flow landed. The
log line handed a credential to anything reading the unified log during the
launch window. It now announces port/build only.

The IPv4 listener bound the wildcard interface and relied on the
per-connection peer check alone; IPv4 has no CoreDevice tunnel path, so it
now binds 127.0.0.1 via requiredLocalEndpoint at the socket level. IPv6
keeps the wildcard bind for CoreDevice ULA peers by design.

Static pins cover both the template and the fixture app copy.

Ported from time-attack/gstack (GStack 2).

Co-authored-by: Sina Matian <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 12:37:45 -07:00
parent f31aff1bc6
commit a83292b1ed
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 86 additions and 10 deletions

View File

@ -97,10 +97,12 @@ public final class StateServer {
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
@ -149,7 +151,19 @@ public final class StateServer {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {

View File

@ -97,10 +97,12 @@ public final class StateServer {
try? bootToken.write(toFile: bootTokenPath, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: bootTokenPath)
// 2. Log the boot token EXACTLY ONCE so the daemon can scrape it.
// The daemon will rotate immediately; this log line is dead within
// seconds.
logger.notice("gstack-ios-qa-bootstrap token=\(self.bootToken, privacy: .public) port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 2. Announce bootstrap WITHOUT the token. The daemon reads the boot
// token from the 0600 file above (copyFileFromAppContainer); the
// os_log line that used to carry it had no consumer and handed a
// live credential to anything reading the unified log during the
// launch window. Port/build stay for diagnostics.
logger.notice("gstack-ios-qa-bootstrap port=\(self.port, privacy: .public) build=\(self.appBuildId, privacy: .public)")
// 3. Bind both IPv6 and IPv4 loopback. CoreDevice tunnel uses IPv6;
// local tooling may use IPv4. Never bind 0.0.0.0 or ::.
@ -149,7 +151,19 @@ public final class StateServer {
let params = NWParameters.tcp
params.allowLocalEndpointReuse = true
let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
// IPv4 has no CoreDevice tunnel path, so it binds strictly to
// loopback at the socket level; IPv6 keeps the wildcard bind and
// relies on the per-connection peer check below for tunnel peers.
let listener: NWListener
switch family {
case .ipv4:
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"),
port: NWEndpoint.Port(rawValue: port)!)
listener = try NWListener(using: params)
case .ipv6:
listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!)
}
listener.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .ready = state {

View File

@ -0,0 +1,48 @@
/**
* StateServer hardening pins (fork port wave 2, B3 + B5).
*
* B3: the boot token must never appear in an os_log statement. The daemon
* reads it from the 0600 app-container file (copyFileFromAppContainer in
* tunnel-bootstrap.ts); the old `token=\(self.bootToken, privacy: .public)`
* announce line had NO consumer and handed a live credential to anything
* reading the unified log during the launch window.
*
* B5: the IPv4 listener has no CoreDevice tunnel path, so it must bind to
* loopback at the socket level (requiredLocalEndpoint 127.0.0.1), not rely
* solely on the per-connection peer check. IPv6 keeps the wildcard bind for
* CoreDevice ULA peers by design.
*
* Pinned on BOTH the generated template and the fixture app copy so neither
* can drift back independently.
*/
import { describe, test, expect } from "bun:test";
import { readFileSync } from "fs";
import { join } from "path";
const ROOT = join(import.meta.dir, "..");
const COPIES = [
"ios-qa/templates/StateServer.swift.template",
"test/fixtures/ios-qa/FixtureApp/Sources/DebugBridgeCore/StateServer.swift",
];
describe.each(COPIES)("StateServer hardening — %s", (rel) => {
const src = readFileSync(join(ROOT, rel), "utf-8");
test("no os_log statement interpolates the boot token (B3)", () => {
const logLines = src.split("\n").filter((l) => /logger\.(notice|info|error|debug|log)/.test(l));
for (const line of logLines) {
expect(line).not.toContain("bootToken");
}
// The bootstrap announce survives (diagnostics), token-free.
expect(src).toContain('gstack-ios-qa-bootstrap port=');
expect(src).not.toContain("gstack-ios-qa-bootstrap token=");
});
test("IPv4 listener binds loopback at the socket level (B5)", () => {
expect(src).toContain("requiredLocalEndpoint");
expect(src).toContain('NWEndpoint.Host("127.0.0.1")');
// IPv6 wildcard + peer-check path must survive (CoreDevice tunnel peers).
expect(src).toMatch(/case \.ipv6:\s*\n\s*listener = try NWListener\(using: params, on:/);
});
});