Lafine Systems Design · 技術文書

RoamSwitch アーキテクチャ/セキュリティホワイトペーパー

RoamSwitch がどのような権限で動作し、その境界で何を行っているのかを説明する技術文書です。 宣伝的な表現は使わず、記載した内容はすべて、配布中のアプリのバイナリと実際の動作から確認できるようにしています。

v1.5 対象 RoamSwitch 1.9.9 (build 66) 要件 macOS 13.0+ / Apple Silicon 発行 2026-09-07 Team ID GV76B6G4YU

§1この文書について

RoamSwitch は、メニューバーに常駐する Mac 向けのアプリです。いま接続しているネットワークをどの程度 信頼しているかに応じて、macOS のファイアウォール、共有サービス、AirDrop、DNS を自動的に切り替えます。 あわせて、ARP スプーフィング、外部に開いたポート、USB ストレージ、ランサムウェアのような暗号化の動きを 監視しており、危険を検知した場合には、パケットフィルタ(pf)で通信を遮断する緊急隔離 (Air-Gap)まで行います。

つまり RoamSwitch は、root 権限のデーモンを常駐させ、その気になれば Mac の通信をすべて止められる、 ということになります。開発元は個人であり、「信頼してください」という言葉だけでこの権限を正当化することは できません。そこで本書では、その代わりに、設計の内容を検証できる形で説明していきます。

対象となる読者

  • 導入するかどうかを技術的に判断したいエンジニアの方
  • レビューや記事化の前に、内部の作りを確認しておきたいセキュリティ研究者・ジャーナリストの方
  • 自社導入や OEM 同梱を検討している、パートナー企業のセキュリティ担当の方

この文書が扱わないこと

検知ロジックのしきい値調整や誤検知の統計、画面の操作手順については触れません。本書で説明するのは、 権限、プロセスの境界、データの流れ、暗号の 4 つです。機能そのものの仕様は、同梱の MCP リソース roamswitch://docs/features と、アプリ内のヘルプに記載しています。

何をどこまで書くか(開示方針)

本書は、攻撃者がすでに配布バイナリを入手している、という前提で書いています。ここに登場するエンドポイント の URL、識別子、ファイルパス、XPC プロトコル、埋め込みの公開鍵は、いずれも出荷済みの RoamSwitch.app から stringscodesign -d、通信プロキシを使えば、 数分で取り出せるものです。ですから、これらを記載しても、攻撃者にとっての手がかりが増えることはありません。 増えるのは、レビューする側の理解だけです。

一方で、バイナリからは分からないサーバー側の実装 ── レート制限のしきい値、鍵、管理用のエンドポイント、 DB スキーマ、Firebase プロジェクトの構成 ── は記載しません。OEM・パートナー連携の設計も本書の範囲外とし、 別の内部文書で扱います。「クライアントを観察すれば分かる範囲」が、本書の開示の線引きです。

補足

本書の内容は、冒頭に記載したバージョンのソースに対応しています。以降のバージョンで挙動が変わった箇所に ついては、改版のうえ版番号と対象ビルドを更新します。記述とコードの食い違いに気づかれた場合は、 lafine.net/contact.html からお知らせください。

§2コンポーネントと信頼境界

RoamSwitch.app は、3 つの実行体で構成されています。このうち特権を持つのは 1 つだけで、 残りの 2 つはログインユーザーの権限で動作します。3 つとも Hardened Runtime を有効にし、Developer ID で 署名して公証を受けた状態で配布しています。

RoamSwitch のコンポーネントと信頼境界の図 MCP クライアント Claude Desktop / Code MCPServer ユーザー権限 読み取り専用 RoamSwitch.app ユーザー権限 メニューバー / 監視 / UI Helper root / LaunchDaemon SMAppService.daemon システムバイナリ pfctl / socketfilterfw … stdio (JSON-RPC) 読み取りのみ * XPC mach service コード署名で認可 Process 実行
* MCPServer はアプリ本体には接続せず、共有の設定ドメインと監視モジュールを直接読み取ります(§8)。
コンポーネント別の権限と役割
実行体権限できることできないこと
RoamSwitch
.app
ログインユーザー ネットワーク状態の監視、各種診断、UI 表示、ヘルパーへの XPC 呼び出し、AirDrop の defaults 変更、ClamAV(任意)の起動 ファイアウォール・pf・システムデーモンの直接操作(すべてヘルパー経由で行います)
RoamSwitch
Helper
root HelperProtocol に列挙された操作だけ(§3 の表)。ファイアウォール/ステルス、共有デーモンの load / unload、pf ルールセットの適用、DNS の変更、プロセスへのシグナル送出 それ以外。任意コマンドを実行するためのインターフェースはありません。ネットワーク送信の entitlement もありません
RoamSwitch
MCPServer
ログインユーザー 診断値の読み取りと整形、ローカルのナレッジベース検索。結果は stdio 経由でクライアントに返します 設定変更、ロックダウン切替、ポート隔離、デバイス取り出し。ソケットは開きません。ネットワーク送信もありません

Entitlements と署名

  • 3 つのターゲットは、いずれも ENABLE_HARDENED_RUNTIME = true です。
  • App Sandbox は無効にしています(com.apple.security.app-sandbox = false)。ヘルパーと MCPServer の entitlements は、空の辞書です。
  • 配布用のビルドには、Developer ID Application 署名、Apple の公証、staple を行っています(§10)。
設計上のトレードオフ

App Sandbox は使っていません。RoamSwitch は、IOKit からのハードウェア UUID の取得、CoreWLAN、 DiskArbitration、他プロセスの待ち受けソケットの列挙(lsof)、LaunchDaemon への XPC 接続、 システムバイナリの起動を必要とします。これらはいずれもサンドボックスの中では実現できないため、 有効にしていません。

その代わりに、次の 4 つで補っています。1 つ目は Hardened Runtime、2 つ目は Developer ID 署名と公証です。 3 つ目は、root で動く実行体をヘルパー 1 つに限定し、そのヘルパーが実行できる操作を固定して一覧化して いること(§3 の表)。4 つ目は、ヘルパーへの接続をコード署名で制限していること(§3)です。

§3特権ヘルパーの設計

登録のされ方

ヘルパーは、SMAppService.daemon(plistName:) を使って LaunchDaemon として登録します。 アプリに埋め込んだ plist(Contents/Library/LaunchDaemons/com.tetsuharu.RoamSwitch.Helper.plist) で宣言しているのは、LabelBundleProgramMachServices の 1 エントリ、 AssociatedBundleIdentifiers だけです。SMAppService の仕様上、アプリが /Applications に置かれていないと、そもそも登録できません。初回の登録時には、ユーザーが システム設定でヘルパーを手動で承認しないと有効になりません。

誰の接続を受けるか(ClientValidator

ヘルパーは、NSXPCListenershouldAcceptNewConnection で接続元を検査し、これを 通過したものにだけ HelperProtocol を渡します。検査には、PID ではなく audit_token を使っています。PID の使い回しや TOCTOU を避けるためです。

# Release ビルドで接続元に要求するコード署名要件
identifier "com.tetsuharu.RoamSwitch"
  and anchor apple generic
  and certificate leaf[subject.OU] = "GV76B6G4YU"

この要件を SecCodeCopyGuestWithAttributesSecStaticCodeCheckValidity で 照合し、通らなければ接続を切ります。チーム ID の固定を外しているのは DEBUG ビルドだけで、これは開発中の 都合によるものです。実際に配布されるのは、常に Release ビルドです。

境界の要点

ヘルパーの安全性は、このコード署名要件 1 つに懸かっています。要件を満たす相手(正規に署名された RoamSwitch.app)であれば、下の表にある操作をすべて呼び出せます。任意のコマンドを流し込む 口はありませんが、表に並んでいる操作そのものは、決して弱いものではありません。RoamSwitch.app 本体が乗っ取られた場合、これらの操作は攻撃者の手に渡ります。

ヘルパーができること(全リスト)

Shared/HelperProtocol.swift が定義している特権操作は、以下がすべてです。ここに載っていない 特権 API はありません。

HelperProtocol — root で実行される操作の全て
メソッド行うこと起動されるバイナリ / API
setBlockAll(_:) アプリケーションファイアウォールとステルスモードの ON / OFF /usr/libexec/ApplicationFirewall/socketfilterfw
--setblockall / --setstealthmode
getBlockAllStatus(...) 上記の現在値の読み取り socketfilterfw --getblockall
setSharingServicesEnabled(_:) SSH / SMB / 画面共有デーモンの unload / load。停止時に「元々起動していたもの」だけを記録し、復帰時にそれだけを戻します /bin/launchctl list / unload -w / load -w
(対象は ssh.plist / com.apple.smbd.plist / com.apple.screensharing.plist の 3 つに固定)
enableNetworkAirGap(...)
disableNetworkAirGap(...)
緊急時の全遮断(block drop all)の適用・解除。PFRulesetCoordinator を経由します(§4) /sbin/pfctl -f / -e / -sr
setGuardedDevServerPorts(_:) 指定した開発サーバーポートへの外部からの接続だけを pf で遮断します(localhost は素通し)。空配列を渡すと全解除します /sbin/pfctl(同じく Coordinator 経由)
setSecureDNSServers(_:)
restoreOriginalDNSServers(...)
getCurrentDNSServers(...)
アクティブなネットワークサービスの DNS を、マルウェア遮断 DNS(Quad9 9.9.9.9 / Cloudflare 1.1.1.2)へ切り替え、元の設定をバックアップして復元します /usr/sbin/networksetup
-listallnetworkservices / -getdnsservers / -setdnsservers
setLinkGuardSinkhole(_:) リンク保護(§5)。渡されたフィッシング/詐欺ドメインを /etc/hosts の区切り付き管理セクションに 0.0.0.0 で書き込み、DNS キャッシュをフラッシュします。空配列でセクションを削除。ドメインは正規化・重複排除し、IP や不正な文字列は捨て、上限 6 万件でキャップ。一時ファイル + アトミック置換で書きます /etc/hosts の書き換え(FileManager.replaceItemAt
/usr/bin/dscacheutil -flushcache
/usr/bin/killall -HUP mDNSResponder
lockGatewayARP(_:)
unlockGatewayARP(...)
getGatewayARPLockStatus(...)
予防的ゲートウェイ ARP/NDP 固定(§5)。渡された IP → MAC(IPv4 ゲートウェイ・IPv6 デフォルトルーター・同一リンク上の DNS)を permanent エントリとして近隣キャッシュに固定します。IP・MAC の形式を検証し、要求に無いピンは解除する reconcile 方式。ピン集合は gateway_arp_lock.json に永続化 /usr/sbin/arp -s / -d
/usr/sbin/ndp -s / -d
wireGuardImport(_:)
wireGuardForget(...)
VPN トンネル(§5)。WireGuard の .conf をヘルパーが 0600 で保存 / 削除します ファイル書き込みのみ
wireGuardUp(endpointIPv4:endpointIPv6:port:)
wireGuardDown(...)
wireGuardStatus(...)
トンネルの起動 / 停止 / 状態取得。エンドポイントのホスト名はアプリ側で解決して IP をヘルパーに渡します(キルスイッチ中はヘルパーの DNS が通らないため) Homebrew の wg-quick up/downwg showwireguard-tools。未導入時は無効)
terminateProcess(pid:forceKill:) プロセスの一時停止(SIGSTOP)または強制終了(SIGKILL)。ランサムウェア様プロセスの封じ込めに使います。pid > 1 のみ kill(2) システムコール(サブプロセスではありません)
getHelperVersion(...) ヘルパーのバージョン文字列を返します(アプリとの互換性確認に使います)
設計上のトレードオフ

terminateProcess は、pid > 1 でさえあれば、どのプロセスにも SIGKILL を送れます。setSecureDNSServers には、任意の DNS サーバー文字列を 渡せます。機能を成立させるうえで必要な広さではありますが、狭いとは言えません。手前にあるコード署名 チェック(ClientValidator)が唯一の関門です。この点を踏まえて判断してください。

ヘルパー内部の状態

  • HelperTool.shared は、接続をまたいで共有する 1 つのインスタンスにしています。以前は接続ごとに別インスタンスを渡していたため、緊急封じ込めが新しい接続を開くと、「どのサービスを元に戻せばよいか」という記録を取りこぼすレースが発生していました。
  • 共有サービスと DNS のバックアップは、直列キュー(stateQueue)の上でのみ書き換わります。

§4パケットフィルタ(pf)の扱い

pf を操作する機能は 4 つあります。緊急 Air-Gap、開発サーバーのポートガード、VPN トンネルの WireGuard キルスイッチ(§5、1.7.6 以降)、Tailscale キルスイッチ(§5、1.8.0 以降。選択したバックエンドの分だけ)です。いずれも PFRulesetCoordinator という 1 つの窓口を必ず経由し、自分で pfctl -f を実行することはありません。

なぜ窓口を 1 つにしたか

以前は、2 つの機能がそれぞれ独自に pfctl -f でルールを読み込んでおり、pf に 1 つしかない メインルールセットを奪い合っていました。ポートガードの狭いルール block ... port {…} が、 Air-Gap の block drop all よりあとに読み込まれると、画面には「隔離中」と表示されているのに Mac には到達できてしまう、という状態が発生していました。この不具合は、別のマシンから実際に攻撃を試して 発見し、1.4.3 で修正しています(経緯は docs/marketing/zenn/03_lan_side_attack_test.md に まとめています)。

いまの作り

  • 毎回、丸ごと組み直します。現在の状態から、必要なルールセット全体を作り直し、一括で適用します。差分での適用は行いません。
  • 直列キューは 1 本です。pf を変更する処理はすべて同じ DispatchQueue に乗るため、どの XPC 接続から来ても、ヘルパーの起動処理でも、フェイルセーフタイマーでも、順番に処理されます。
  • 優先順位は次のとおりです(上にあるものが優先されます)。
    1. 緊急 Air-Gap → set skip on lo0block drop all(ほかは考慮しません)
    2. VPN キルスイッチ → block drop all +「lo / トンネル インターフェース(utunN)/ 固定したエンドポイント IP への UDP ハンドシェイク / DHCP / ICMP」だけを pass quick
    3. 開発サーバーガード → block drop in quick proto tcp ... port { … }
    4. どれもない場合 → /etc/pf.conf を読み直し、pf を元の状態に戻します
  • 適用したあとに読み返します。pfctl -sr でルールを読み戻し、block drop all や各ポートのルールが実際に載っているかを確認します。pfctl -f が黙って無視された場合を「成功」とは扱いません。
  • 一時ファイルは UUID を含むパスに書き込み、適用が終わったら削除します(v1.4.5 で固定パスをやめ、推測しにくいパスに変更しました)。状態を保存するディレクトリは /Library/Application Support/RoamSwitch です。
API の挙動

enableNetworkAirGapsetGuardedDevServerPorts の XPC 応答 (Bool, String?) は、読み返しまで通過したかどうかを返します。呼び出し側 (ARPSpoofContainmentManager など)は、失敗した場合はやり直し、それでも駄目な場合は 「まだ通信は止まっていません。すぐに Wi-Fi を切ってください」という内容を、そのまま画面に表示します。

§5遮断のかかり方と、戻り方

遮断は、それぞれ別物です

遮断の種類
種類範囲発動条件loopback
緊急 Air-Gap 入る通信も出る通信も、すべて止めます ランサムウェアのような暗号化の動きを検知したとき。ARP スプーフィング検知時はロックダウン中のみ即時発動し、バランス/信頼済みネットワークでは通知のみ(ユーザーが手動で発動)。curl 等で取得した内容を Base64 デコードしてシェル/AppleScript に直接渡すなど「ClickFix」型の既知の不審コマンドパターンをシェル履歴で検知したとき(1.8.7〜、Pro・既定オフ)。ふだんの外出先保護では使いません set skip on lo0 で通します
VPN キルスイッチ トンネルとハンドシェイク・DHCP・ICMP 以外の全通信 VPN トンネル(§5、Pro・既定オフ)が有効で、未信頼ネットワークに接続したとき。トンネル確立まで(および切断時)継続 set skip on lo0 で通します
開発サーバーポートガード 指定ポートへの、外からの TCP 接続だけ ボタン 1 つでの手動隔離、または、見慣れない待ち受けポートを検知したときの自動遮断(Pro) localhost からは、今までどおりです
ふだんの未信頼ネットワーク保護 ファイアウォールとステルス、共有の停止(§6)。pf は使いません 登録していないネットワークに接続したとき
リンク保護 フィッシング/詐欺ドメインへの名前解決だけ(/etc/hosts0.0.0.0)。pf は使いません 脅威フィード掲載またはブランド ホモグラフ偽装に該当する接続先。既定でオン(Pro) 影響しません
予防的 ARP/NDP 固定 ゲートウェイ・IPv6 ルーター・同一リンク上 DNS の MAC のみ(近隣キャッシュ)。pf は使いません 未信頼ネットワークに接続したとき(Pro・既定オフ)。ネットワーク変更ごとに再固定 影響しません

Air-Gap を居座らせない仕組み

  • 最長でも 10 分で解除されます。ヘルパー自身も起動直後と 60 秒ごとのタイマーで releaseAirGapIfExpired() を実行しますが、それとは完全に独立したセーフティネットとして、KeepAlive を一切持たない専用の LaunchDaemon(AirGapFailsafe)が 3 分ごとに単体で起動し、同じ判定・解除を行います。ヘルパー自身がクラッシュして自己解除のタイマーごと失われても、このセーフティネットには影響しません(1.8.6〜)。
  • この専用デーモンはヘルパー本体の常駐設定には一切手を加えません。以前(1.8.5)はヘルパー自身の KeepAlive.PathState でこの復旧を実現していましたが、通常のアプリ終了操作と稀に干渉して意図せず再起動してしまう不具合があったため撤回し、現在の完全独立デーモン方式に置き換えました。
  • タイムスタンプの判定は、単調増加時間(ProcessInfo.systemUptime)と壁時計を組み合わせたハイブリッド方式です。通常は単調増加時間を優先するため NTP 補正や手動での時刻変更の影響を受けません。reboot 直後で単調増加時間が信用できない場合(保存値より現在値が小さい=再起動を検知)にのみ、壁時計にフォールバックします。
  • 再起動やデーモンの再生成のあとに、当て直します。ヘルパーは起動時に reapplyFromDisk() でディスク上の状態を読み込み、Air-Gap、ポートガード、システム既定の順に、自分で復元します(このときも 10 分ルールは適用されます)。
  • 解除に失敗した場合は、失敗として扱います。block drop all を実際に外せなかったときは、タイムスタンプを書き戻し、フェイルセーフタイマーと再試行が向かう先を残します。画面が「解除済み」と誤って表示することはありません。
  • ユーザーは、モーダルの解除ボタンからでも、単に Wi-Fi を切ることでも、いつでも操作の主導権を取り戻せます。

ポートガードの誤検知からの戻し方

未知ポートの自動遮断(Pro、Pro 有効化時に既定でオン)が、正規の LAN 受信アプリ (LocalSend や Syncthing など、ガードを有効にしたあとに起動したもの)を止めてしまうことがあります。 そのときは、通知バナーの「許可する」ボタン、または「外部公開ポート」画面の該当項目から解除できます。 一度許可した実行体は既知として記録され、以降は再び遮断されません (PortAnomalyGuard.allowPort(_:))。なお、Python や Node.js、Netcat などの汎用インタプリタは、 Living-off-the-land 攻撃を防止するためバイナリ単体ではなく「実行パス+ポート番号」で厳格に識別されます。 anchor apple を満たす macOS 標準のシステムデーモン(Handoff の rapportd など)は、 最初から監視対象外として安全に保護されます。

ランサムウェアおとり(カナリア)の自律復元と隔離中制御

カナリアファイル(おとり)が改ざん・リネームされて緊急 Air-Gap が発動した場合、安全を確認して隔離を解除すると、 破損・消失したカナリアファイルは自動的にアプリ内の正規テンプレートから再生成・ハッシュ修復され、 kqueue リアルタイム監視が途切れることなく即座に再開されます(攻撃者による改変データが新ベースラインとして誤認されることはありません)。 また、緊急隔離発動中(モーダル表示中)は定期整合性ポーリングからの多重発火および重複通知を自動抑止し、ユーザーの確認・対処を妨げない設計としています。

ダウンロードファイルの多層検知(静的シグネチャ + ClamAV) — 1.8.7 で静的シグネチャ層を追加

ダウンロードフォルダやデスクトップ、書類フォルダに新規配置されたファイルは、まず StaticSignatureScanner による軽量な静的シグネチャ照合を通過します(先頭 4MB を対象、AND/OR パターンの単純なバイト列一致のみで、正規表現やエントロピー計算は行いません)。EndpointSecurity entitlement を使わず、ClamAV が未導入の環境でも動作します。検知対象は、実際のマルウェア検体を持たずに正確さを保証できる範囲——業界標準の EICAR テスト署名と、公開の攻撃手法解説(PayloadsAllTheThings 等)に掲載されている定番のリバースシェル一行コマンド 5 種(bash/sh/dev/tcp/nc -e、Python pty.spawn、Perl Socket、PHP fsockopen)——に意図的に絞っています。特定マルウェアファミリー固有のバイト列を検体なしに「シグネチャ」として書くことはしません(検証できない偽の安心感を与えるだけのため)。パターン自体は XOR 0x5A で難読化して保持し、RoamSwitch 自身のバイナリが EICAR 文字列等を平文で含んで ClamAV に誤検知されるのを防いでいます。

静的シグネチャ層で「クリーン」と判定されたファイルのみ、続けて ClamAV のスキャン対象になります。ダウンロードフォルダやデスクトップ、書類フォルダに新規配置されたファイルは、.tmp 拡張子や com.apple.quarantine 属性の有無(ターミナル経由のコピー等)に関わらず即座に ClamAV スキャン対象となります。EICAR 等の業界標準テスト署名は「無害」として情報通知のみを行い、隔離・遮断はしません(実マルウェア検体のみ隔離対象)。 また、隔離先フォルダ(Quarantine)に過去に隔離された同名ファイルが既に存在する場合でも、タイムスタンプ付きのユニーク名で隔離フォルダへ確実に退避し、脅威が元の場所に残存する検疫漏れを完全に排除します。

新しい自動起動登録(LaunchAgent/LaunchDaemon)の監視 — 1.8.7

~/Library/LaunchAgents/Library/LaunchAgents/Library/LaunchDaemons への新規 .plist 設置を FSEvents でリアルタイム検知します(PersistenceMonitorGuard)。EndpointSecurity の ES_EVENT_TYPE_AUTH_CREATE を使わないため、書き込みそのものを止めることはできません。できるのは、書き込まれた直後(数秒以内)に気づき、内容を判定することです。

判定の要は「実行体自身の署名」ではなく「何を直接起動しようとしているか」です。2026 年に報告された実際の手口(Google Update を偽装した LaunchAgent の中に Base64 エンコードされた bash スクリプトを隠す、root の LaunchDaemon が起動のたびに Base64 エンコードされた AppleScript ペイロードを再実行する、等)は、いずれも悪意あるコードを スクリプト自体に持たせ、正当に署名された /bin/bash/usr/bin/osascript を素のまま呼び出します。実行体の署名だけを見る検証では、こうした手口をすり抜けてしまいます。そこで、新規登録された LaunchAgent/LaunchDaemon が生のスクリプトインタプリタ(bashshzshosascriptpython3perlrubyphp 等)を直接起動している場合は、そのインタプリタ自身の署名が正当であっても無条件で警告します。渡された引数はさらに StaticSignatureScanner の判定にもかけ、既知の不審パターンが含まれていれば通知に含めます。BundleProgram(RoamSwitch 自身の Helper など、アプリバンドル内の署名済みコンパイル済みバイナリを指す形式)で登録されたものは、通常どおり codesign --verify --strict による署名検証のみを行います。

ClickFix の検知と緊急遮断 — 1.8.7、既定オフ

「ClickFix」は、偽の CAPTCHA やエラー画面が「確認のため Terminal を開いてこのコマンドを貼り付けてください」と指示し、ユーザー自身にコマンドを実行させる社会工学的な攻撃手法です。2024〜2025 年にかけて検知数が 500% 以上増加し、2026 年時点で macOS における最も蔓延した攻撃経路の一つとされています。実行するのはユーザー自身の正規のシェル操作であるため、Gatekeeper の署名検証を完全にすり抜けます。

メニューバー →「マルウェア対策」→ ClickFix 対策(Pro・既定オフ)。~/.zsh_history~/.bash_history への追記を FSEvents で監視し(起動後に新規追加された行のみが対象で、既存の履歴はさかのぼりません)、StaticSignatureScanner と共有するリバースシェル一行コマンド、および Base64 デコードした内容をシェルまたは osascript に直接パイプする複合パターンのみを検知します。正規のインストーラーで極めて頻繁に使われる curl | bash 単体は、Homebrew・rustup・nvm 等の公式インストール手順と区別が付かないため、意図的に検知対象から外しています。検知した瞬間、既存の緊急 Air-Gap(本節)を発動します。コマンドはすでに実行済みのため事後対応にはなりますが、多段ペイロードの追加ダウンロードや、認証情報の外部送信が進行中であれば、それを遮断できる可能性があります。他の Air-Gap 発動要因と同様、最長 10 分で自律的に解除されます(本節冒頭)。

既知の限界。 検知はシェル履歴への書き込みが前提のため、Terminal を経由せず applescript:// URL スキームで Script Editor を直接呼び出し、ペイロードをディスクに一切書き込まないファイルレス型の亜種には対応できません。この種の亜種は、Apple 自身も一定の緩和策(未確認スクリプトへの承認ダイアログ)を導入していますが、それを回避する変種も報告されています。実機で macOS の統合ログ(log showlog stream)を用いてこの経路を検証しましたが、判定に使える固有のログシグナルは見つかりませんでした。対応できないことを対応済みと表示するより、この限界を明記することを選んでいます。

Dockerリスク検知ガード — 1.8.9、Pro・既定オフ

メニューバー →「マルウェア対策」→「Dockerの特権コンテナ・docker.sockマウントを検知」(DockerEventGuard)。このコードベースには docker events のような永続的なストリーミング接続を張る前例が無いため、PortAnomalyGuard と同じタイマーポーリング方式を採用しています。20 秒ごとに軽量な docker ps -q でコンテナ ID の集合を取得し、前回との差分(新規起動分)だけを docker inspect --format で詳細確認します。判定に使う書式文字列は Linux 版 roamswitch_core::health::LinuxHealthChecker::DOCKER_INSPECT_RISK_FORMAT と意図的に同一にしており、両プラットフォームで全く同じ条件(--privileged 起動、または /var/run/docker.sock のバインドマウント)を検知します。

検知しても自動的な遮断は行いません。特権コンテナや docker.sock マウントはコンテナ脱出につながり得るリスクの高い「設定」ではありますが、確認された侵害ではないためです(監視エージェントを意図的に privileged で動かす等の正当な用途もあります)。Docker を使わないユーザーが大半のため、Pro ライセンスでも既定は OFF のままです。EndpointSecurity entitlement は使用せず、docker CLI は Docker Desktop の既定インストール先・Homebrew・which の順で探索します。実機の Docker Desktop(29.7.2)で privileged コンテナ・docker.sock マウントコンテナ・通常コンテナの 3 パターンを検証し、誤検知・検知漏れが無いことを確認済みです。

機密情報・APIキー漏洩監査 — 1.8.4 で追加、1.8.9 でフォルダスキャンに対応

メニューバー →「マルウェア対策」→「機密情報・APIキー漏洩監査」。テキストを貼り付けると、その場で API キーやトークンの漏洩パターンを検知し、行番号・マスク済み文字列・種別ごとの推奨対応を表示します(SecretLeakAuditor、完全ローカル)。1.8.9 では「フォルダを選択してスキャン」を追加し、同じ検知エンジンでディレクトリ(ソースコードのチェックアウト先など)を再帰的に監査できるようになりました(auditDirectory(at:))。.gitnode_modulestargetvendordistbuild__pycache__venv は自動的に除外し、2MB を超えるファイルやバイナリと判定されたファイルもスキップします。処理はメインスレッドをブロックしないよう別スレッドで実行され、外部への送信は一切発生しません。

リンク保護(フィッシング接続の遮断) — 1.7.2 以降、1.8.0 で強化

メニューバー →「マルウェア対策」→「リンク保護」(Pro)。フィッシング/詐欺サイトへの接続を、ブラウザやアプリを問わず端末側で遮断します。強制ポイントは 2 段で、優先順に併用します。

  • ① コンテンツフィルタ システム拡張(RoamSwitchLinkFilter、1.8.0〜、承認後に優先)。NEFilterDataProvider の system extension です。Apple の申請は不要(コンテンツフィルタは 2016 年からセルフサービス、審査キューなし)。名前解決のに実際の外向き TCP フローを見ます。宛先の名前は OS 解決名、無ければ TLS ClientHello の SNI をパースして得るため、ブラウザーが自前で DoH/DoT 解決して生 IP に繋ぐケースも遮断できます。/etc/hosts は書き換えず、プロセス単位の帰属が取れます。QUIC(UDP/443)は SNI が読めないため block モードでは drop し、ブラウザーを HTTP/2(TCP)にフォールバックさせます。初回のみシステム設定での承認が要ります。
  • /etc/hosts シンクホール(フォールバック、拡張が未承認・拒否のとき)。特権ヘルパーが /etc/hosts の区切り付き管理セクションに対象ドメインを 0.0.0.0 で書き込み、dscacheutil -flushcachekillall -HUP mDNSResponder でキャッシュを流します。上限 6 万件。拡張が有効になるとこのセクションは撤去されます。
  • 3 モード。「オフ」=無効。「警告のみ」=該当接続を一時停止してユーザーに諮ります(下記の実 warn)。「明らかな詐欺サイトは自動でブロック(推奨)」=即遮断。1.7.2 以降の既定はブロック
  • warn(1.8.0〜、拡張のみ)。warn ヒット時、システム拡張は該当フロー(OS 解決名・SNI の両経路)を .pause()保留し、アプリが「許可/ブロック」通知を出します。ユーザーのタップが allowlist.txtextra.txt に届いた瞬間(拡張が小さな状態ファイルを 1.5 秒間隔でポーリング)、保留フローを resume(許可)または drop(ブロック)します。25 秒無応答なら fail-open(warn は助言的なので通す)。判断はホスト単位でキャッシュ(許可 5 分/ブロック 1 時間)し、同じページの他フローや再訪は即時・強制。block 通知は従来どおり「〜をブロックしました」+「今回は許可(5 分)」。
  • 遮断は明確なケースだけ。「脅威フィード掲載」または「ブランド名の Unicode ホモグラフ偽装」に該当するときだけブロックし、それ以外(高リスク TLD、サブドメイン偽装など)は警告に留めます。判定エンジンは Linux 版と共通で、URL をどこにも送りません。
  • 誤遮断からの戻し方。通知またはメニューからドメインを「許可」(5 分間 or 恒久)。許可リストはセクション再生成時に差し引かれます。
  • Pro ゲート。遮断の適用(applyMode())は Pro ライセンスが有効なときだけ行われます。非 Pro ではモード設定は保存されますが /etc/hosts は一切変更されません。ライセンスの有効化・失効はセッション中でも反映されます。
  • フィードと同梱シード。ブロック対象の一覧は署名付きの脅威フィード(§7、フィード専用鍵で検証)由来です。未取得でもアプリ同梱のシード(約 6 万件)で動作し、「自動更新」をオフにすると外部通信は発生しません。

予防的ゲートウェイ ARP/NDP 固定 — 1.7.5 以降

メニューバー →「ポート・デバイス監視」→「未信頼ネットワークでゲートウェイの ARP/NDP を固定(予防)」(Pro・既定オフ)。

  • 仕組み。未信頼ネットワークに接続した時点で、route / scutil --dns / arp -n / ndp -an から IPv4 ゲートウェイ・IPv6 デフォルトルーター・同一リンク上の DNS リゾルバの現在の MAC を収集し、ヘルパーが arp -s / ndp -spermanent エントリとして固定します(TOFU=最初に観測した MAC を信頼)。以降その IP に対する ARP/NDP スプーフィング応答は無視され、中間者攻撃を未然に防ぎます。
  • 範囲。固定するのは上記 3 種のエントリだけです。信頼済み(オープン)ネットワークでは固定しません。ネットワークが変わるたびに一度解除して再固定します。off-link のパブリック リゾルバ(8.8.8.8 等)は同一リンクに ARP エントリが無いため自動的に対象外になります。
  • arp -s は予防、Air-Gap は事後。この固定は「なりすましを成立させない」ための予防策で、ARP スプーフィング検知(ARPSpoofContainmentManager)と緊急 Air-Gap は「検知したら人間より速く切る」ための事後策です。両者は独立して動きます。
  • 永続化。ピン集合は /Library/Application Support/RoamSwitch/gateway_arp_lock.json に保存され、XPC 再接続やヘルパー再起動をまたいで解除できます。

VPN トンネル(WireGuard/Tailscale)とキルスイッチ — 1.7.6 以降、1.8.0 でバックエンド選択

メニューバー →「ポート・デバイス監視」→「VPN トンネル」(Pro・既定オフ)。L2(ARP/NDP)の完全性に依存しない、中間者攻撃対策の本命です。バックエンドは「WireGuard(設定ファイル)」または「Tailscale(Exit Node)」から選べます(サブメニュー →「バックエンド」)。RoamSwitch 自身は暗号処理を実装せず、既存の VPN/tailnet に相乗りします。選ばれた方だけを arm します。

  • (A) WireGuard バックエンド。Apple の Network Extension エンタイトルメントは使わず、Homebrew の wireguard-toolswg-quick + wireguard-go)を呼び出します。ユーザーが自分の WireGuard 設定(.conf)を読み込むと、ヘルパーが 0600 で保存します。キルスイッチは pf の block drop all +「lo / トンネル IF / 固定エンドポイント IP への UDP ハンドシェイク / DHCP / ICMP」だけの pass quickAllowedIPs がフルトンネルでない設定にはスプリット トンネル警告を出します。
  • (B) Tailscale バックエンド(1.8.0〜)。既に Tailscale を使っているユーザー向け。RoamSwitch は tailscale up/ログイン/導入は行わずtailscale status --json を読み tailscale set --exit-node=<ノード> を実行するだけです。CLI 版(brew install tailscale)推奨——App Store 版(GUI)は sandbox のためアプリ外から制御できず、その場合は Tailscale アプリで Exit Node を選び RoamSwitch は状態表示のみを担当します。Exit Node 必須(全通信をそこ経由に)。オフライン/到達不能なら自動解除。
  • 切断時のネットワーク再構成。standalone の macOS tailscaled は Exit Node 解除時に routing/DNS を正しく戻さない既知の症状があるため、RoamSwitch はアクティブなネットワークサービスを bounce します(「Wi-Fi 手動トグル」と同じ。IPv4 アドレスを持つサービスのみ、複数 NIC・static IP でも安全)。この間 5〜10 秒ほど通信が途切れ、その間 RoamSwitch は保護レベルを降格しません。
  • Tailscale キルスイッチは既定オフ・オプトイン。Exit Node 自体がトンネルを提供するので、pf block drop all(CGNAT/MagicDNS/STUN/DERP/DHCP/ICMP/DNS のみ許可)は上級者向けです。DNS を許可するぶん「漏れにくい」止まりです。
  • 自動適用/ライセンス失効。未信頼ネットワークでトンネル/Exit Node を起動、信頼済みで停止。Pro ライセンス失効で解除。

§6ふだんの未信頼ネットワーク保護

登録していないネットワークに接続したときの「保護レベル」の切り替えは、pf ではなく、OS 標準の設定を、 あとで元に戻せる形で変更しているだけです。

保護レベルごとの操作
操作実装権限復元方法
ファイアウォール + ステルスモード ON socketfilterfw --setblockall on / --setstealthmode on root(ヘルパー) 安全なネットワークに復帰したときに off
SSH / SMB / 画面共有の停止 launchctl unload -w root(ヘルパー) 停止時に起動していたものだけを記録し、復帰時に load -w(SSH は /usr/sbin/systemsetup -setremotelogin on も連携実行して確実に復元)
AirDrop を無効化 defaults write com.apple.sharingd DiscoverableMode ユーザー(アプリ本体) 直前の値を保存しておき、復帰時に書き戻す

いずれも、RoamSwitch が新しく追加した遮断のしくみではなく、OS の設定を切り替えているだけです。 アプリを削除すると、ネットワークに応じた切り替えが行われなくなるだけで、最後に適用された OS 設定は そのまま残ります。締まったまま放置されることはありませんが、安全側に倒したい場合は、アンインストールの 前に、信頼できるネットワーク上で「オープン」に戻しておくことをおすすめします。

§7データの扱いと Zero Telemetry

Mac の中に残るもの

ディスクに保存されるデータ
データ場所内容
ライセンストークンKeychain
com.tetsuharu.RoamSwitch.license
Ed25519 署名付きトークン。kSecAttrAccessibleAfterFirstUnlock
アプリ設定 / ガードの ON-OFFUserDefaults suite
com.tetsuharu.RoamSwitch
信頼ネットワークの登録、保護ポリシー、除外リストなど
pf 状態/Library/Application Support/RoamSwitch/Air-Gap のタイムスタンプ、ガード対象ポートの JSON、VPN キルスイッチ状態、適用中ルールの一時ファイル
リンク保護の脅威フィード~/Library/Application Support/RoamSwitch/threatfeed/feed.txtダウンロード済みのフィッシング/詐欺ドメイン一覧(未取得ならアプリ同梱のシード)。フィード版数は UserDefaults
リンク保護の管理セクション/etc/hosts# BEGIN RoamSwitch link guard# END で囲まれた区切り付きセクション。ブロック対象を 0.0.0.0 で無効化。モード「オフ」で削除されます(§5)
ARP/NDP 固定ピン/Library/Application Support/RoamSwitch/gateway_arp_lock.json予防ロック(§5)で permanent 化した IP → MAC の集合。固定解除時に削除
WireGuard 設定/Library/Application Support/RoamSwitch/(ヘルパー領域、0600ユーザーが読み込んだ .conf の内容。エンドポイントのホスト名は UserDefaults にも退避(アプリ側で解決するため)
デバイスフォールバック UUIDUserDefaultsIOKit が UUID を返さないときにのみ生成される乱数(§9)
ログos.Logger / NSLog統合ログ。外部への送信はありません

外に出る通信(全リスト)

診断結果、ポート情報、URL、ログを収集して送信するコードは、どこにもありません。解析 SDK も クラッシュレポーター SDK も入れていません。外部ライブラリは Sparkle(アップデート)だけです。 ネットワークに出るのは、次の 6 つですべてです(6 つ目はユーザーが VPN を設定した場合のみ)。

RoamSwitch が行う外部通信
通信宛先発生条件送信内容
ライセンス認証 / 解除 lafine.net
/api/v1/license/*
ユーザーがライセンスキーを入力したとき、または Pro を解除したときだけ ライセンスキー、デバイスハッシュ、ホスト名、アプリバージョン。個人情報は購入時に Stripe が扱い、アプリは扱いません
アップデート確認 lafine.net
/updates/appcast.xml
Sparkle が 24 時間ごと、および起動時に実施 HTTP リクエスト(標準的な UA・バージョン)。取得物は EdDSA 署名で検証します(§10)
リンク保護の脅威フィード lafine.net
/updates/v1/{manifest, feed/<版>.txt}
リンク保護(§5)が有効で「自動更新」がオンのとき、24 時間ごと(+起動時)。「自動更新」オフでこの経路は消えます GET のみ。クエリ文字列・Cookie・端末を識別する情報はありません。受信専用の署名付き静的ファイルで、マニフェストとフィード本体を Ed25519 で検証します。署名鍵はフィード専用で、アプリ更新の SUPublicEDKey とは別鍵です(漏洩時の影響を「誤ったブロックリスト」に閉じ込めるため)
ClamAV ウイルス定義更新 ClamAV 公式ミラー ユーザーが ClamAV を導入し、スキャン機能を使うときだけ。freshclam を起動します ClamAV 標準の定義取得。RoamSwitch 由来の情報は含みません
VPN トンネル(§5) ユーザーが設定した WireGuard エンドポイント VPN トンネル(Pro・既定オフ)を設定し、未信頼ネットワークに接続したときだけ。トンネル起動前にエンドポイントのホスト名を 1 回 DNS 解決します WireGuard のハンドシェイク(UDP)とトンネル内の通信。宛先はユーザー自身の VPN サーバーで、内容はユーザーの通信そのものです。RoamSwitch は識別子・診断結果を一切足しません
決済ページ Stripe Checkout ユーザーが購入ボタンを押したときだけ(ブラウザで開きます) —(ブラウザ側の遷移です)
「Zero Telemetry」の範囲

ここで言う「Zero Telemetry」は、利用状況や診断結果を収集して送信するテレメトリを行わない、という 意味です。通信を一切しない、という意味ではありません。上の表の 6 経路は、実際に存在します。ただし、 いずれもユーザーがきっかけをつくるものか、署名検証つきの受信専用の取得であり、Mac 上の診断結果、 ポート、URL、ファイルの中身が外に出ることはありません。

リンク保護の脅威フィード(3 行目)は「送らない」防御に「受け取るだけの更新はする」を足したものです。両者は別の話として切り分けており、Linux 版ホワイトペーパー §1.1 でも「Zero Telemetry」と「受信専用の更新」を分けて記述しています。「自動更新」をオフにすれば、リンク保護は同梱データ(約 6 万件のフィッシング/詐欺ドメイン)とオフラインのホモグラフ検知だけで動作し、この経路は発生しません。

VPN トンネル(5 行目)はユーザーが自分の WireGuard サーバーを設定した場合にのみ発生し、宛先も内容もユーザーの管理下にあります。RoamSwitch はトンネルを張る・キルスイッチを掛けるだけで、識別子や利用状況を足すことはありません。VPN を設定しなければこの経路は存在しません。

アプリ内の「リンク安全性診断」シートは、短縮 URL の飛び先を確認するために、対象の URL へ HEAD リクエストを送信します(プライベートアドレスやローカルアドレスへの追跡は、v1.4.5 の SSRF 対策で停止しています)。一方、MCP の audit_url_safety は、その場で完結するオフライン 解析であり、URL をどこにも送信しません(§8)。

実測(2026-08-29) — 判定 PASS

主張だけではありません。2026-08-29、稼働中の 1.4.7 を tcpdump + プロセス帰属 (nettop / lsof / 絞り込んだ pktap キャプチャ)+ LuLu で、約 2 時間、 セキュリティレベルを最大ロックダウンに固定し appcast チェックを強制発火させた状態で監査しました。 結果:RoamSwitch / RoamSwitchHelper / RoamSwitchMCPServer に帰属する外向きは、lafine.net への appcast チェック以外なし。MCP サーバーが開いたソケットは ローカルホストのみ。entitlements のダンプは空。

詳細と、誰でも自分のマシンで再現できるスクリプト: audit/RESULTS-2026-08-29.ja.mdroamswitch-support/audit/。 第三者の追試を歓迎します。

この実測は 1.4.7 時点のもので、リンク保護の脅威フィード(1.7.2、上表 3 行目)と VPN トンネル(1.7.6、5 行目)はまだ存在しません。 1.7.2 以降で監査すると lafine.net への受信専用の GET が 2 種類(appcast と脅威フィード)観測されます。 VPN トンネルを設定していれば、ユーザー自身の WireGuard サーバー宛の UDP も観測されます(宛先・内容ともユーザーの管理下)。いずれも「自動更新: オフ」「VPN 未設定」で消えます。

§8MCP サーバーのセキュリティモデル

RoamSwitchMCPServer は、RoamSwitch.app/Contents/MacOS/ に同梱している単体の コマンドラインツールです。Claude Desktop や Claude Code などの MCP クライアントが、これをサブプロセスと して起動し、stdio(改行区切りの JSON-RPC 2.0)でやり取りします。公式 SDK が本機の macOS SDK ではビルドできなかったため、Foundation の JSONSerialization の上に手で実装して います。

設計で縛っていること

  • 読み取り専用です。セキュリティレベルの変更、ポートの隔離、デバイスの取り出しといった API は、そもそも用意していません。v1 で作り忘れたわけではなく、意図的に外しています。外部のコード(ここでは LLM)に、セキュリティツールの防御状態を書き換えさせてしまうと、すべてのユーザーの信頼が崩れるためです。
  • ソケットを開きません。サービス登録もリッスンもしません。stdin から 1 行読み、stdout に 1 行返し、あとはクライアントがプロセスを終了させます。
  • 外に送信しません。診断はすべて Mac の中で完結します。
  • 設定は別のドメインから読み取ります。UserDefaults(suiteName: "com.tetsuharu.RoamSwitch") で、アプリ側のドメインを明示的に指定して読み取ります(自身のバンドル ID のドメインは空です)。読み取るだけで、書き込みは行いません。

公開しているツール

tools/list が返すツールと、返すデータ
ツール返すもの通信
get_security_report18 項目診断(FileVault / SIP / Gatekeeper / 自動更新 / XProtect / ファイアウォール / Wi-Fi 暗号化 / ARP / ゲートウェイ ARP 固定 / SSH 設定監査 / Sudo NOPASSWD 監査 / 公開ポート / ガード構成 等)のスコアと、項目別の改善アドバイスローカルのみ
get_exposed_ports待ち受け中の TCP ポート一覧。localhost を越えて公開されているものは、既知危険サービス DB と照合し、127.0.0.1:port への HTTP プローブ(ローカル閉域)で CORS / ヘッダを確認します127.0.0.1 へのプローブのみ
get_guard_statusPro の自動対応ガード(ポート異常 / ARP / USB / Bluetooth / Web・Mail ダウンロード / DNS 脅威保護)の ON / OFF、現在の保護レベル、信頼ネットワーク状態ローカルのみ
audit_url_safetyURL のフィッシング / ホモグラフ(Unicode 偽装)/ ブランドサブドメイン偽装 / 高リスク TLD / 平文 HTTP の判定。同期・完全オフラインです(analyzeURL。リダイレクト追跡は行いません)なし
get_app_help同梱ナレッジベースの全文検索(機能仕様 / 設定 / トラブルシュート / 通知メッセージ解説)なし

initialize の応答に含まれる instructions でも、「Cannot change security level, isolate ports, or eject devices」と明記し、クライアント側の LLM に能力の境界を伝えています。MCP リソース (roamswitch://docs/*)も、読み取り専用の Markdown ドキュメントです。

要点:ソースを公開

このサーバーと、そこから使う検知ロジック(ARP 監視・ポートスキャン・ポート診断・18 項目ヘルスチェック・ リンク安全性診断)のソースは、github.com/lafine1211/roamswitch-mcp で公開しています(MIT、 出荷コードのミラー、リリースごとにタグ)。「読み取り専用であること」「LLM に何を渡しているか」 「外部送信がないこと」をコードで直接確認できます。特権ヘルパー・pf 制御・実際に遮断する各ガード・ ライセンスは含みません(本体リポジトリのまま)。

テストも同梱しています(本体スイートのミラー+敵対的入力テスト+ミューテーションファジング、 swift test で実行、CI で検証)。ファジングで JSONSerialization の未対策の クラッシュ(深くネストした JSON オブジェクトでのスタックオーバーフロー)を 1 件発見し、 パーサ前段のネスト深度チェックで修正しました(SECURITY_TESTING.md に記録)。

§9ライセンス認証の暗号設計

トークン

  • Ed25519(Curve25519 署名)を使っています。公開鍵はアプリに埋め込んでいます(LicenseVerifier.embeddedPublicKeyBase64)。対応する秘密鍵は、ライセンスバックエンド(Firebase Functions の環境変数)にのみ存在し、リポジトリには含まれていません。
  • 署名の対象は正準 JSON です。JSONEncoder.sortedKeys.withoutEscapingSlashesLicensePayload(ライセンスキー、tier、デバイスハッシュ、発行時刻、有効期限、台数)をエンコードした、正確なバイト列に対して署名と検証を行います。
  • fail-closed で設計しています。埋め込み鍵が欠落・不正であったり、署名や正準 JSON の生成に失敗したりした場合は、「検証成功」にはならず、invalidSignature を返します。

デバイスバインド

device_hash = SHA-256( "RoamSwitch-LifetimeSalt-v1" : lowercase(IOPlatformUUID) )

生のハードウェア UUID は、サーバーに送信しません。IOKit が UUID を返さないまれなケースでは、 UserDefaults に保持する乱数 UUID にフォールバックします。検証時、トークン内の device_hash と現在のデバイスハッシュが一致しない場合は、deviceMismatch と なります。

オフラインで動く

要点:サーバーが無くても動く

起動時の validateSavedLicense() が行うのは、Keychain のトークンを読み込み、埋め込みの 公開鍵で手元で検証することだけです。ネットワークには接続しません。ライセンスサーバーが将来的に停止した 場合でも、すでに認証済みの Mac では、Pro 機能はそのまま使えます。サーバーと通信するのは、新規の アクティベーションと、明示的な解除のときだけです。解除のサーバーへの通知はベストエフォートで、失敗した 場合でも、手元の解除は必ず完了します。

既定は買い切り(Lifetime)で、is_lifetime でない場合にのみ expires_at を 確認します。台数は tier で表現し、個人 Pro が 2 台、Team が 5 台です。

§10配布とアップデート

署名・公証(scripts/release.sh

  1. xcodebuild archive を実行します(Release、手動署名、Developer ID Application)。
  2. -exportArchivemethod: developer-id としてエクスポートします。
  3. notarytool submit --wait のあと、.app に stapler staple します。
  4. spctl -a -t exec -vv で検証します。
  5. staple のあとに再度 zip し、Sparkle 用の配信物を作ります(公証チケットを同梱し、オフラインでも Gatekeeper の警告なしで動くようにするためです)。
  6. DMG を作成し、DMG も公証・staple したうえで、stapler validate で検証します。

アップデート(Sparkle 2.9.6)

Info.plist のアップデート設定
キー
SUFeedURLhttps://lafine.net/updates/appcast.xml
SUPublicEDKeyCNxzwijMzMCJzliId76Yl88S/9np6t/xg/zQ9YbYzHs=
SUEnableAutomaticCheckstrue
SUScheduledCheckInterval86400

配信物は、appcast に記載された EdDSA 署名を、アプリに埋め込んだ SUPublicEDKey で検証してから適用します。署名用の秘密鍵は、ビルド環境にしかありません。appcast は HTTPS で配信します。 差分(delta)アップデートも、同じように署名を検証します。

要点:appcast は公開前提

appcast.xml の URL が公開されていること自体は、弱点ではありません。中身は、バージョン 番号、リリースノート、ダウンロード URL、ファイルサイズ、各ビルドの EdDSA 署名だけで、秘密は含まれて いません。信頼のよりどころは、「通信の途中で appcast が本物かどうか」ではなく、アプリに焼き込まれた 公開鍵で配信物の署名を検証すること、にあります。appcast を完全に差し替えられる攻撃者(MITM、DNS 乗っ取り、Web ホストの侵害)であっても、署名鍵がないかぎり、不正なアップデートを通すことはできません。 Gatekeeper(Developer ID と公証)が、2 つ目の関門になります。

残るリスクは 2 つあります。1 つは、ホストの停止や壊れた appcast による更新の不達です(不正な インストールは起きず、単に更新されないだけです)。もう 1 つは、セキュリティ更新を意図的に握りつぶす freeze 攻撃です。Sparkle 2 系は、バージョンの順序を確認してダウングレードやリプレイを弾きますが、 freeze への完全な対策には、有効期限つきの専用アップデートサーバーが必要です。ここは今後の検討課題と しています。

§11脅威モデルと、やらないこと

RoamSwitch が相手にするもの

  • 同じ LAN にいる攻撃者や、乗っ取られた IoT 機器からの探査・攻撃。ステルス化、公開ポートの監査、外部からの隔離で対応します(実測検証: RESULTS-DEFENSE-2026-08-30 および包括的ペネトレーションテストレポート RESULTS-PENTEST-2026-08-31 にて、ARPスプーフィング・未知ポート・ランサムウェア暗号化・ダウンロード検疫の全シナリオに対する自律遮断を実証済み)。
  • 信頼していないネットワークでの露出。共有サービスと AirDrop を自動的に停止します。
  • 中間者攻撃(ARP/NDP スプーフィング)。1.7.6 以降は多層で対応します:(1)VPN トンネル + キルスイッチ(§5、本命。L2 の完全性に依存しない)、(2)未信頼ネットワークでのゲートウェイ ARP/NDP 予防固定(§5)、(3)スプーフィング検知と緊急 Air-Gap(事後)。いずれも Pro・既定オフ(検知は既定オン)。
  • 認証なしで 0.0.0.0 に晒された開発サーバーやデータベース(Redis、MongoDB、Elasticsearch など)、およびローカルAI/LLM推論サーバー(Ollama、LM Studio、vLLM、Gradio など)を見つけ、外部から遮断します。
  • ランサムウェアのような不正な暗号化の動きを早い段階で捉え、通信をすべて止めます(シグネチャには依存しません)。
  • 未承認のキーボードや改造USBケーブル(Rubber Ducky / O.MG Cable等)からの自動コマンド注入を水際で遮断するBadUSB物理キーボード承認ガード(USBKeyboardGuard、CGEventTap + IOKit)。
  • 許可していない USB ストレージの承認プロンプト(未知のドライブは即取り出しではなく読み取り専用で保留)と、接続されたストレージの自動 ClamAV スキャン(任意)。
  • クリップボード内の機密 API キー(OpenAI、Anthropic、GitHub、AWS 等)を完全ローカルで検知し、Web フォームや AI チャットへの誤貼り付けを未然に防止。
  • HuggingFace や Web からダウンロードされた危険な Pickle 形式 AI モデルファイル(任意のコード実行リスク)の検知・警告。
  • ダウンロードファイル内の業界標準 EICAR テスト署名と、公開されている定番リバースシェル一行コマンド(StaticSignatureScanner、1.8.7〜、ClamAV 未導入でも動作)。
  • 生のスクリプトインタプリタを直接呼び出す、新規の LaunchAgent/LaunchDaemon 自動起動登録(PersistenceMonitorGuard、1.8.7〜)。
  • 偽の警告画面がユーザー自身に Terminal でコマンドを実行させる「ClickFix」型攻撃を、既知の不審コマンドパターンのシェル履歴監視で検知し、緊急 Air-Gap を発動(ClickFixGuard、1.8.7〜、Pro・既定オフ)。
  • --privileged 起動や /var/run/docker.sock のバインドマウントなど、コンテナ脱出リスクのある Docker 設定を新規起動の瞬間に検知し通知(DockerEventGuard、1.8.9〜、Pro・既定オフ・通知のみ)。
  • 貼り付けたテキストだけでなく、フォルダ単位での再帰的スキャンにも対応した機密情報・APIキー漏洩監査(SecretLeakAuditor、1.8.9〜フォルダスキャン対応、完全ローカル)。

やらないと決めていること

  • アンチウイルスの代わりにはなりません。ClamAV と XProtect は補助として使いますが、RoamSwitch 単体は、汎用のマルウェア検出器ではありません。
  • EndpointSecurity entitlement は申請しない方針です。個人アカウント(Apple Developer Program Individual)での審査通過率が低いこと、そして真の pre-exec ブロック(ES_EVENT_TYPE_AUTH_EXEC 等)が必要な機能はこの方針が変わらない限りクローズドな non-goal として扱うことを、あらかじめ明記します。実行そのものを止められない代わりに、post-hoc(事後)の検知で実用上十分な範囲(静的シグネチャ検知、自動起動監視、ClickFix 対策)を entitlement なしで実装しています。
  • 保証ではありません。多層防御のうちの 1 層であって、「ランサムウェアを完全に防ぐ」ものではありません。マーケティングの文言も、この前提でレビューしています。
  • すでに乗っ取られた root やカーネルは守れません。攻撃者がすでに root を取得している場合、ヘルパーの pf ルールも外せてしまいます。
  • L2 の完全性そのものを回復することはしません。ARP/NDP 予防固定は「最初に観測した MAC を信頼する(TOFU)」方式で、接続前から攻撃者が居座っている場合は偽の MAC を固定し得ます。この前提を置きたくない場合の答えが VPN トンネル(§5)で、こちらは L2 が汚染されていても中身は暗号化されキルスイッチで平文が漏れません。企業ネットワークの DHCP スヌーピングや Dynamic ARP Inspection の代わりにはなりません。
  • VPN サーバーは提供しません。トンネル機能はユーザーが用意した WireGuard 設定を使うもので、RoamSwitch が VPN 事業者になることはありません。

RoamSwitch を入れることで増える攻撃面

導入で増える攻撃面と、その抑え方
攻撃面抑え方
root で動く LaunchDaemon と、その mach service(com.tetsuharu.RoamSwitch.Helper 操作面を HelperProtocol に固定しています(§3 の表)。任意コマンドを実行する口はありません。接続はコード署名要件で認可し、audit_token を使います。
RoamSwitch.app 本体が侵害された場合、ヘルパーの全操作が攻撃者に渡ります Hardened Runtime を有効にし、アプリ側に不要な権限を持たせません。ネットワーク送信は、上表の 6 経路(6 つ目はユーザーが設定した VPN のみ)に限定しています。今後、第三者レビューの対象にする予定です。
ヘルパーが起動するシステムバイナリのパス /sbin/pfctl などを絶対パスで直接指定し、PATH には依存しません。引数もハードコードしています(ポート番号・DNS 文字列・ARP の IP/MAC・VPN エンドポイント IP を除く。いずれも形式を検証します)。
リンク保護が /etc/hosts を書き換える(setLinkGuardSinkhole 書き込みは区切り付きの管理セクション内に限定し、セクション外の行はそのまま保持します。ドメインは正規化・検証し、IP や不正文字列は捨て、6 万件でキャップ、一時ファイル + アトミック置換で書きます。適用するのは「脅威フィード掲載」または「ブランド名のホモグラフ偽装」の明確なケースだけで、判定エンジンはローカル完結です。ブロックリスト自体は署名検証済みのフィード(フィード専用鍵)に由来します。
リンク保護の脅威フィード取得(受信専用の日次 GET クエリ・識別子なしの静的ファイル取得のみ。マニフェスト・フィード本体とも Ed25519 で検証し、検証に失敗した取得物は破棄します(無署名データにフォールバックしません)。「自動更新: オフ」でこの経路自体が消えます。
ARP/NDP 予防固定(lockGatewayARP 固定するのは「ゲートウェイ・IPv6 ルーター・同一リンク上 DNS」の近隣キャッシュ エントリだけ。渡された IP・MAC は形式を検証し、要求集合との reconcile 方式(勝手にエントリを増やさない)。未信頼ネットワークでのみ動作し、Pro・既定オフ。TOFU の限界は「やらないと決めていること」に明記。
VPN トンネル(wireGuardImport/Up/…、Homebrew wireguard-tools .conf はヘルパーが 0600 で保存。エンドポイント IP はアプリ側で解決してヘルパーに渡す(ヘルパーに任意ホスト名の名前解決をさせない)。トンネルの宛先・内容はユーザー管理下。wireguard-tools 未導入なら機能は無効。Pro・既定オフ。キルスイッチは PFRulesetCoordinator(§4)を経由。
MCP サーバーが LLM にシステム状態を渡すこと(confused deputy) 読み取り専用で、書き込み API は実装していません。URL 診断はオフラインです。設定ドメインは read-only で参照します。
アップデート経路の乗っ取り EdDSA 署名検証(SUPublicEDKey)と、公証チケットの同梱で対応します。appcast は HTTPS です。

付録 Aセルフチェック

本書に記載した内容は、配布物に対して次のコマンドで確認できます。

署名・公証

# Developer ID 署名とチーム ID
codesign -dvvv /Applications/RoamSwitch.app 2>&1 | grep -E 'Authority|TeamIdentifier|flags'

# 公証チケットが staple されているか
stapler validate /Applications/RoamSwitch.app
spctl -a -t exec -vvv /Applications/RoamSwitch.app

# 同梱ヘルパー / MCP サーバーの署名
codesign -dvvv /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchHelper
codesign -dvvv /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer

Entitlements(ネットワーク送信権限が無いこと)

codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchHelper
codesign -d --entitlements :- /Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer
# → 空の entitlements 辞書。app-sandbox / network client のキーはありません

通信の実測

# tcpdump を併走させ、通常利用で通信が出ないことを確認します
sudo tcpdump -i any -n 'host not 127.0.0.1' and 'not port 53'
# ライセンス認証・アップデート確認・ClamAV 更新以外のトラフィックが無いこと

より厳密に(プロセス帰属つきで自動化):roamswitch-support/audit/rs-zerotel-audit.shtcpdumpnettop/lsof/pktap + LuLu で外向き通信を実測し、 PASS/FAIL を判定します。実施例:RESULTS-2026-08-29(PASS)

防御機構・ペネトレーション実測検証(5つの防御境界)

# XPC 認可境界、pf Air-Gap 優先度、ポート露出検知、MCP Read-Only、ARP 監視を一括自動検証
git clone https://github.com/lafine1211/roamswitch-support
cd roamswitch-support/audit
./rs-defense-audit.sh all

macOS VMでの実機ペネトレーションテスト実測結果:RESULTS-DEFENSE-2026-08-30、および最新検証レポート RESULTS-PENTEST-2026-08-31(全項目 PASS / 脆弱性修正完了)

DNS 脅威保護(Quad9 悪質ドメイン・C2 遮断)の動作確認

# Quad9 公式の脅威ブロック検証用テストドメインを問い合わせ
nslookup test.dns9.quad9.net
# → ** server can't find test.dns9.quad9.net: NXDOMAIN となり名前解決が遮断されること

ダウンロード保護(ClamAV リアルタイム検知)の動作確認

# 無害なアンチウイルス標準テスト文字列(EICAR)を Downloads に作成
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > ~/Downloads/eicar_test.com
# → FSEvents 検知により ClamAV が即時スキャンし、「EICAR テスト署名を検出(無害)」という
#   情報通知が表示されること。EICAR は業界標準のテストファイルであり実際の脅威ではないため、
#   隔離も遮断もされず、ファイルはそのまま残ります(実マルウェア検体を置いた場合のみ隔離マネージャーへ退避)。

静的シグネチャ検知(EndpointSecurity 不要)の動作確認

## 公開の攻撃手法解説に載っている定番のリバースシェル一行コマンド(無害、実行はしない)
echo 'bash -i >& /dev/tcp/127.0.0.1/4444 0>&1' > ~/Downloads/rs_test.sh
## → ClamAV 未導入でも「危険なダウンロードファイルを隔離しました」の通知とともに
##   Quarantine フォルダへ即時隔離されること(ClamAV のシグネチャDBに依存しない)

新規自動起動監視(PersistenceMonitorGuard)の動作確認

# 生のスクリプトインタプリタを直接呼び出す無害な LaunchAgent を作成(実行はしない)
cat > ~/Library/LaunchAgents/com.example.selfcheck.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>com.example.selfcheck</string>
  <key>ProgramArguments</key><array><string>/bin/bash</string><string>-c</string><string>echo hi</string></array>
</dict></plist>
EOF
# → 数秒以内に「新しい自動起動登録を検知しました」の通知が出ること。確認後は削除:
rm ~/Library/LaunchAgents/com.example.selfcheck.plist

リンク保護(フィッシング接続の遮断)の動作確認

# 管理セクションが書かれているか(Pro + 既定モードなら BEGIN/END の 2 行)
sudo grep -c 'RoamSwitch link guard' /etc/hosts

# セクションの中身と sinkhole 件数
sudo sed -n '/BEGIN RoamSwitch link guard/,/END RoamSwitch link guard/p' /etc/hosts | head -4
sudo sed -n '/BEGIN RoamSwitch link guard/,/END RoamSwitch link guard/p' /etc/hosts | grep -c '^0\.0\.0\.0'

# 実際に落ちているか(セクション内の 1 件で確認。無害)
D=$(sudo sed -n '/BEGIN RoamSwitch/,/END RoamSwitch/p' /etc/hosts | awk '/^0\.0\.0\.0/{print $2; exit}')
dscacheutil -q host -a name "$D"   # → ip_address: 0.0.0.0(名前解決が遮断されている)

# メニュー →「リンク保護」→「オフ」で上記セクションが消えることも確認

脅威フィードの受信専用取得(署名検証つき)

# 公開フィードとマニフェスト(誰でも取得・検証可能)
curl -s https://lafine.net/updates/v1/manifest        # version/generated/threatfeed{...}
curl -sI https://lafine.net/updates/v1/manifest.sig   # → text/plain
# 送信内容はクエリ・Cookie・識別子なしの GET のみ。tcpdump 併走で確認できます

予防的 ARP/NDP 固定(§5、1.7.5+)の動作確認

# 予防ロックが有効な状態で未信頼ネットワークに接続 → permanent エントリを確認
arp -an | grep -i permanent          # ゲートウェイ等の IP が (permanent) で並ぶ
ndp -an | grep -i 'P '               # IPv6 側(P = permanent)
sudo cat "/Library/Application Support/RoamSwitch/gateway_arp_lock.json"  # 固定中の IP→MAC 集合
# メニューで固定を解除 → 上記が消えること

VPN トンネル + キルスイッチ(§5、1.7.6+)の動作確認

# wireguard-tools(Homebrew)が前提
brew list wireguard-tools >/dev/null && echo "wireguard-tools: OK"

# 未信頼ネットワークで VPN を有効化 → トンネル確立前は pf がキルスイッチ状態
sudo pfctl -sr | grep -E 'block drop all|pass .*(utun|udp)'   # block drop all + 限定的な pass quick

# トンネル確立後(wg-quick up 済み)
wg show                              # handshake / transfer が動いている
route -n get default | grep interface  # → utunN(既定ルートがトンネル)
# VPN を無効化 → キルスイッチ解除、pf が元に戻ること

特権ヘルパーの実体

# 登録されている LaunchDaemon
sudo launchctl print system/com.tetsuharu.RoamSwitch.Helper

# 現在ロードされている pf ルール(Air-Gap / ポートガードの実状)
sudo pfctl -sr

# ヘルパーの状態ディレクトリ
ls -la "/Library/Application Support/RoamSwitch/"

MCP サーバーの応答(オフライン確認)

BIN=/Applications/RoamSwitch.app/Contents/MacOS/RoamSwitchMCPServer
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \
              '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | "$BIN"
# serverInfo と 5 ツールの定義が返ります。ネットワーク接続は発生しません

MCP サーバーのソースとテスト

git clone https://github.com/lafine1211/roamswitch-mcp
cd roamswitch-mcp
swift build -c release        # 出荷バイナリと同じソース
swift test                    # 単体・敵対的入力・stdio・ミューテーションファジング
# SECURITY_TESTING.md にテスト内容と発見済みの問題

デバイス識別子

# トークンにバインドされる素の値(送信されるのは salted SHA-256 のみ)
ioreg -d2 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'
RoamSwitch アーキテクチャ/セキュリティホワイトペーパー v1.5 · 対象 1.9.9 (build 66) · Lafine Systems Design · 2026-09-07
誤り・不足の指摘は lafine.net/contact.html へお願いします。