Skip to content

KM-17448 Integrate Kape PlatformSDK - #350

Open
kp-diego-trevisan wants to merge 53 commits into
masterfrom
kape-platform-sdk-integration
Open

KM-17448 Integrate Kape PlatformSDK#350
kp-diego-trevisan wants to merge 53 commits into
masterfrom
kape-platform-sdk-integration

Conversation

@kp-diego-trevisan

@kp-diego-trevisan kp-diego-trevisan commented Jun 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Consolidate PIA's VPN protocol stacks (OpenVPN, WireGuard) into a single PlatformSDK-Tunnel Network Extension powered by Kape's Rust-based VPN engine, gated behind the usePlatformSDKVPN feature flag.

Rollout state: the usePlatformSDKVPN flag is temporarily hard-forced true in FeatureFlagHolder on both iOS and tvOS (a // TODO: [PlatformSDK] override), so CSI-driven gating is bypassed while the engine is under active development. Removing that override restores CSI control.


1. SDK Dependency Pipeline

  • scripts/pull-kape-platform-sdk.sh + scripts/kape-platform-sdk.version — pulls a pinned, vendored Kape Platform SDK from Cloudsmith into LocalPackages/KapePlatformSDK/ (gitignored)
  • ci_scripts/ci_post_clone.sh + CI workflows — CI runs the pull with caching before SPM resolves
  • PIAVPN/Package.swift — declares deps on KapeVPN-PacketTunnel and KapeVPN-OpenVPN
  • PIALibrary/Package.swift — depends on TunnelKitPackage (the Kape TunnelKit fork, ../KapePlatformSDK/TunnelKitPackage) for OpenVPN config types
  • README.md — documents the required local setup: obtaining a Cloudsmith token and running the pull before building

2. Architecture Decision Records

  • ADRs/0008-integrate-kape-platform-sdk-vpn-engine.md — full design rationale: one shared engine replacing three divergent protocol stacks, gated behind a CSI-controlled feature flag
  • ADRs/0007-ios-coordinator-navigation-pattern.md — coordinator navigation pattern ADR

3. New PIAVPN Local Package (Extension-Side Adapters)

LocalPackages/PIAVPN/ — thin adapters bridging PIA's model to the Kape SDK inside the extension:

Adapter Responsibility
PIAPacketTunnelProvider Main NEPacketTunnelProvider entry point; wires SDK session/connection controllers
PIAEndpointRepository Resolves endpoints from shared state; autonomously fetches and caches server list; ranks by latency
PIAWireguardAuthenticator WireGuard key exchange + TLS pinning to bundled PIA root CA
PIATunnelLogger Bridges SDK logging to os.Logger

4. App ↔ Extension IPC via Bidirectional Shared State

PIALibrary/.../KapePlatformSDK/SharedState/:

  • PIATunnelSharedState — persists state as pia_platformsdk_state.json in the shared app group (on tvOS under Library/Caches); every write posts a Darwin notification so the other side observes changes rather than polling
  • App → extension: connection inputs (selected server/DIP, protocol, custom DNS, MTU, OpenVPN/WireGuard settings, token, latencies). The extension autonomously fetches the server list when its cached copy is stale (works for on-demand reconnects with no app running); the servers cache is written by both sides
  • Extension → app (write-back): the extension reports the resolved connection (ActualConnection / activeConnection), a live tunnelStatus, live data usage (PIADataUsage — bytes sent/received, so the connection-stats UI reflects real tunnel throughput), and the connection timestamp (surfaced via VPNProfile.connectionDate); the app folds tunnelStatus into VPNStatus.resolve(system:tunnel:) to drive the "Connecting" UI even while NEVPNStatus stays .connected
  • Provider message: PIAPacketTunnelRequest.switchLocation (via sendProviderMessage()) switches region on a live tunnel in place, without tearing down the extension process
  • ServersPinger writes latency rankings so the extension can fan out fastest-first (online non-DIP servers) for the Automatic region

5. App-Side Tunnel Profile

PIALibrary/.../KapePlatformSDK/TunnelProfile/:

  • KapePlatformSDKTunnelProfile — the NetworkExtensionProfile that configures the PlatformSDK tunnel
  • KapePlatformSDKVPNType — centralized protocol type seam (PIA/PIAWG/PIAAutomatic, plus a non-connectable IKEv2 case for recognising/migrating legacy installs)
  • ActualConnection — resolved protocol/server/transport reported back to the app
  • PIAPacketTunnelRequest — IPC request model (switchLocation)
  • OpenVPNProvider+Compat — compat bridge to legacy TunnelKit types

6. New PlatformSDK-Tunnel Extension

  • Single shared Network Extension source (PlatformSDK-Tunnel/), built by two platform targets — PlatformSDK-Tunnel-iOS and PlatformSDK-Tunnel-tvOS — replacing the per-protocol extensions
  • Deleted PIA tvOS Tunnel/
  • Renamed/reorganized entitlements and scheme accordingly

7. Core Library (PIALibrary) Changes

  • VPNDaemon — suppresses app-side reconnect/fallback-timer/disconnect-error handling when the flag is on (the SDK owns reconnection)
  • DefaultVPNProvider / VPNProvider — adds changeServer API; surfaces "Connecting" UI from extension write-back
  • FeatureFlags — adds usePlatformSDKVPN CSI-controlled flag (ios_platform_sdk_vpn)
  • Server — model updates for DIP servers and resolved endpoint data
  • AppConstants, Client+Configuration, NetworkExtensionProfile — new constants and protocol changes

8. App Layer (iOS)

  • Bootstrapper — migrates legacy protocol to automatic, runs cleanupLegacyVPNProfilesIfNeeded
  • AppPreferences / AppConfiguration — new preferences keys
  • DashboardViewController / ConnectionTile / RegionTile — UI for new "Connecting" state
  • ProtocolSettingsViewController / RegionsViewController — updated for KapePlatformSDKVPNType and DIP
  • PIAConnectionLiveActivityManager / PIAWidgetAttributes — live activity updates

9. tvOS Support

  • BootstraperFactory — flag setup, restores connected state on relaunch
  • ProtocolSelectionView/ViewModel/UseCase — brand new protocol selection UI (was unavailable)
  • PIA-RSA-4096.pem — root CA for TLS pinning
  • Tests: ProtocolSelectionViewModelTests, AvailableSettingsViewModelTests

10. Mac Catalyst (KM-16341)

  • AdaptiveSplitViewController — iPad/Catalyst sidebar
  • AppDelegate, PIALibrary/Package.swift, UserInterface, CAGradientLayer+Image — Catalyst platform support and adjustments

11. Subscriptions & Pricing

  • Subscription info loading (KM-15998) — PIAAccountClient / EndpointManager / RequestBuilder / PIAWebServices updates
  • Localized prices — display subscription prices in the user's locale (PurchasePlan, SubscriptionOptionViewModelMapper)

12. Build Config, Entitlements & Misc

  • New/updated entitlements for app group and keychain sharing
  • Development.xcconfig / Staging.xcconfig / Production.xcconfig — extension bundle IDs
  • Package.resolved — updated dependency graph
  • New localized string, asset catalog updates

@kpkb-1f8e9813897fe9831983e89f7143

kpkb-1f8e9813897fe9831983e89f7143 Bot commented Jun 15, 2026

Copy link
Copy Markdown

KB review — 68 findings on this PR

🟠 36 HIGH · 🟡 26 MEDIUM · ⚪ 5 LOW · ℹ️ 1 INFO

Severity Status Kind Finding
🟠 HIGH added BUG_HUNT IKEv2 connect() calls startVPNTunnel() immediately after stopVPNTunnel() without waiting for disconnect
🟠 HIGH added BUG_HUNT OpenVPN and WireGuard connect() calls startTunnel() immediately after stopVPNTunnel() without waiting for disconnect
🟠 HIGH added SECURITY_REVIEW PlatformSDK WireGuard key exchange sends VPN token to server with no TLS certificate validation
🟠 HIGH added SECURITY_REVIEW VPN credentials stored as plaintext JSON in App Group container (PlatformSDK tunnel path)
🟠 HIGH added SECURITY_REVIEW PlatformSDK shared state stores VPN credentials as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK VPN credentials stored as plaintext in App Group container file
🟠 HIGH added SECURITY_REVIEW VPN credentials (OpenVPN password + WireGuard token) stored in plaintext App Group JSON file (PlatformSDK path)
🟠 HIGH added SECURITY_REVIEW TrustAllCertsDelegate bypasses TLS validation during WireGuard key exchange, exposing VPN token to on-path attacker
🟠 HIGH added SECURITY_REVIEW PlatformSDK credentials stored in plaintext App Group JSON file; stale file survives forced logout
🟠 HIGH added SECURITY_REVIEW VPN credentials stored as plaintext JSON in App Group container file
🟠 HIGH added SECURITY_REVIEW WireGuard key-exchange bypasses all TLS certificate validation, exposing VPN token to MITM
🟠 HIGH added SECURITY_REVIEW PlatformSDK tunnel stores VPN credentials as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW VPN credentials stored as plaintext in app group container JSON file (PlatformSDK path)
🟠 HIGH added SECURITY_REVIEW VPN credentials (OpenVPN username/password, WireGuard token) stored in plaintext App Group container JSON file
🟠 HIGH added SECURITY_REVIEW VPN credentials (OpenVPN password, WireGuard token) written as plaintext to App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK stores VPN credentials as plaintext JSON in App Group container; forced-logout path omits credential wipe
🟠 HIGH added SECURITY_REVIEW VPN credentials written in plaintext to app group container JSON file (PlatformSDK tunnel path)
🟠 HIGH added SECURITY_REVIEW OpenVPN and WireGuard VPN credentials stored as plaintext JSON in App Group container
🟠 HIGH added SECURITY_REVIEW PlatformSDK tunnel writes VPN credentials as plaintext into App Group container JSON file
🟠 HIGH added SECURITY_REVIEW WireGuard key exchange sends VPN token to server with no TLS certificate validation
🟠 HIGH added SECURITY_REVIEW PlatformSDK profile writes VPN credentials to plaintext JSON in App Group container and does not clear them on logout
🟠 HIGH added SECURITY_REVIEW PlatformSDK VPN credentials stored as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK OpenVPN password and WireGuard token written as plaintext to App Group container JSON file
🟠 HIGH added SECURITY_REVIEW VPN credentials (WireGuard token + OpenVPN username/password) stored as plaintext in app group JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK tunnel credentials stored in plaintext App Group container JSON, not cleared on forced logout
🟠 HIGH added SECURITY_REVIEW PlatformSDK integration stores OpenVPN and WireGuard credentials as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK shared state stores OpenVPN and WireGuard credentials in plaintext App Group file; not cleared on logout
🟠 HIGH added SECURITY_REVIEW PlatformSDK credentials written to App Group container filesystem, not wiped on logout
🟠 HIGH added SECURITY_REVIEW VPN credentials written as plaintext JSON into unprotected App Group container file on every connect
🟠 HIGH added SECURITY_REVIEW VPN credentials stored as cleartext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK WireGuard key exchange bypasses TLS validation, exposing VPN token to on-path attacker
🟠 HIGH added SECURITY_REVIEW KapePlatformSDK stores VPN credentials as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW PlatformSDK tunnel profile stores VPN credentials as plaintext in App Group container JSON file
🟠 HIGH added SECURITY_REVIEW TLS certificate validation bypassed for WireGuard key exchange, exposing VPN token to on-path attacker
🟠 HIGH added SECURITY_REVIEW PlatformSDK tunnel stores VPN credentials in plain App Group container JSON, bypassing Keychain; credentials not cleared on logout
🟠 HIGH added SECURITY_REVIEW PlatformSDK WireGuard key exchange disables TLS certificate validation, exposing VPN token to MITM
🟡 MEDIUM added BUG_HUNT MenuViewController.setupPlanHeader() progressively destroys allItems array on repeated account-refresh notifications
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK archive integrity check skippable on developer workstations; GitHub Actions cache bypasses it entirely
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script: checksum verification skippable on developer workstations and CI cache-hit path
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK integrity verification skippable on developer workstations; GitHub Actions cache-restore path also bypasses checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script bypasses checksum verification on developer workstations and GitHub Actions cache hits
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK integrity check skipped on developer workstations when registry metadata omits checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script skips integrity verification on developer workstations and CI cache hits
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK supply-chain integrity check skipped on GitHub Actions cache hits and developer workstations
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK integrity verification skippable on developer workstations and GitHub Actions cache hits
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script silently skips integrity verification when registry metadata lacks a checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK supply chain: checksum verification skipped on developer workstations and GitHub Actions cache hits
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script silently skips integrity check on developer workstations when registry metadata omits a checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK archive integrity verification skipped when registry omits checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK supply-chain integrity check skippable on developer workstations and bypassed by CI cache-restore
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK integrity verification bypassed on GitHub Actions cache hits
🟡 MEDIUM added SECURITY_REVIEW OpenVPN credentials stored as plaintext JSON in App Group container file
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script skips binary checksum verification when registry metadata omits the checksum field
🟡 MEDIUM added SECURITY_REVIEW OpenVPN credentials stored as plaintext in App Group container JSON file
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK binary pull script skips SHA-256 integrity check when registry metadata omits checksum
🟡 MEDIUM added SECURITY_REVIEW PlatformSDK tunnel writes OpenVPN credentials as plaintext JSON in App Group container, bypassing Keychain
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK pull script skips SHA256 integrity check when Cloudsmith registry metadata omits checksum
🟡 MEDIUM added SECURITY_REVIEW KapePlatformSDK archive integrity verification skipped on developer workstations and on every GitHub Actions cache hit
🟡 MEDIUM added SECURITY_REVIEW VPN reconnects after explicit user disconnect due to reverted timer safeguards
🟡 MEDIUM changed SECURITY_REVIEW WireGuard VPN token stored as plaintext in NETunnelProvider configuration
🟡 MEDIUM changed SECURITY_REVIEW PlatformSDK OpenVPN credentials written as plaintext JSON to App Group container file, bypassing Keychain
🟡 MEDIUM changed SECURITY_REVIEW New PR CI workflows use external GitHub Actions pinned to mutable version tags with access to org credentials
⚪ LOW added SECURITY_REVIEW PinnedCertificateDelegate does not call SecTrustSetAnchorCertificatesOnly, allowing system-trusted CAs to satisfy the WireGuard key-exchange TLS check
⚪ LOW added SECURITY_REVIEW WireGuard key-exchange TLS pinning allows system-trusted CAs because SecTrustSetAnchorCertificatesOnly is not called
⚪ LOW added SECURITY_REVIEW New PR CI workflows use external GitHub Actions pinned to mutable version tags
⚪ LOW added SECURITY_REVIEW WireGuard key-exchange TLS pinning allows system-trusted CAs alongside pinned PIA CA
⚪ LOW changed INFRASTRUCTURE Gemfile.lock excluded from version control, leaving CI Ruby gem versions unpinned
ℹ️ INFO changed UNKNOWN

kb · head 81a0929 · full PR view

@kp-diego-trevisan
kp-diego-trevisan force-pushed the kape-platform-sdk-integration branch 8 times, most recently from a357eec to 922d176 Compare June 24, 2026 09:21
@kp-diego-trevisan
kp-diego-trevisan force-pushed the kape-platform-sdk-integration branch 2 times, most recently from 3f28eba to 50a6ffa Compare July 3, 2026 12:42
Comment thread scripts/pull-kape-platform-sdk.sh
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 2m 15s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (eae48119) against origin/master. Five commits landed since my last review: a rebase onto master (fe2f9024), data-usage IPC (e99bb0c8), automatic-protocol pecking order (ba29e8f5), fallback-timer suppression (57b33eeb), and connectionDate (eae48119).

  • Gather context (new commits, key files)
  • Re-verify prior must-fix items (forced flag, plaintext credentials)
  • Review new/changed code on current HEAD
  • Post updated review

🔴 Still outstanding — must-fix before production

Both release blockers are unchanged on this HEAD (neither of the new commits touched them).

1. Forced feature-flag override (FeatureFlagHolder.swift:50-52). The subscript still hard-returns true for .usePlatformSDKVPN unconditionally, on every configuration including Release. Since the subscript is the single read path, CSI has no way to turn the PlatformSDK tunnel off — there is no remote kill switch if a bad SDK build ships. The // TODO: [PlatformSDK] comment correctly marks it temporary, which is fine while the engine is under active development, but at minimum fence it so it can't reach a Release binary:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes the state — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials in selectedDipServer — to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings.

Mitigations still worth crediting: the file is cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and lives in the sandboxed group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 New commits — reviewed, look correct

  • connectionDate (KapePlatformSDKTunnelProfile.swift:43-46) reads native.connection.connectedDate off the NETunnelProviderManager. Correct — connectedDate is nil unless .connected, matching the optional return.
  • Fallback-timer suppression (VPNDaemon.swift:200-203) now gates the PIA-level fallbackTimer on !featureFlags[.usePlatformSDKVPN], consistent with the existing reconnect/disconnect-error suppression so the SDK's KapePathReconnector/KapeSessionController owns reconnection and the double-reconnect trap is avoided. Good.
  • Data-usage IPC (requestDataUsage.dataUsage provider message → PIADataUsage). Clean separation, and the received→downloaded / sent→uploaded mapping is right. Two small notes:
    • PIADataUsage must stay key-compatible with the SDK's PacketTunnelDataUsage (bytesReceived/bytesSent) since the extension encodes the SDK type directly and the app decodes the PIA copy — the doc-comment calls this out, but it's a silent-breakage seam if the SDK ever renames those keys. A round-trip test would lock it in.
    • If sendProviderMessage's completion never fires (extension unresponsive), the callback is never invoked. Same pattern as the other NE calls here, so not new — just noting the shared assumption.

🟡 Smaller items

  • Pecking-order endpoint diversity (PIAEndpointRepository+PeckingOrder.swift:41-69). Each step does endpoints.prefix(step.attempts) over a flatMap across all (latency-sorted) servers. If a single server contributes multiple endpoints (e.g. several OpenVPN addresses), a step's attempts slots can all land on the same server rather than spreading across the fastest N distinct servers. Probably fine given inputs today, but the comment says "distinct endpoints" — worth confirming that matches intent, or dedupe by server within a step.
  • Censorship order is effectively a no-op first step. censorshipPeckingOrder's leading .wireGuard(amnezia: true) step always contributes 0 because amneziaObfuscation returns nil ("not available yet"). Fine as a placeholder, but confirm the censorship order is not yet wired to a real caller so this doesn't silently degrade to non-amnezia in a region that needs it.

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package. The "cache bypasses it entirely" findings no longer hold.

🧪 Test coverage

Still worth adding, per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks): PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + the new pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression (fallback timer not scheduled when the flag is on).

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic (exercise the new pecking order); in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters updating in the UI; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state; Live Activity updates.


Summary: the new commits (data-usage IPC, pecking order, fallback-timer suppression, connectionDate) are sound. The two release blockers — the forced flag override and plaintext-credential storage — remain open on this HEAD and should be resolved (or the flag fenced out of Release) before shipping to production.

· branch kape-platform-sdk-integration ·

Comment thread scripts/pull-kape-platform-sdk.sh
Comment thread .github/workflows/ios_pull_request.yml
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 1m 29s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (ef641fd9) against origin/master. One commit landed since my last review: ef641fd9 "Fix tvOS bundle ID and update VPN integration docs" — a docs + build-config change (ADR renumbering, PIAVPN/README.md, and one tvOS bundle-id fix in project.pbxproj). It does not touch either open release blocker.

  • Gather context (new commit since last review)
  • Re-verify the two release blockers (forced flag, plaintext credentials)
  • Review new/changed code on current HEAD
  • Post updated review

🟢 New commit — reviewed, looks correct

  • tvOS tunnel bundle-id fix (project.pbxproj:2960). The tvOS PlatformSDK-Tunnel Development config now sets PRODUCT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER) instead of the hard-coded com.privateinternetaccess.ios.PIA-VPN. That stray iOS bundle id under SDKROOT = appletvos was a real bug — deriving from the xcconfig variable is the right fix and matches how the other extension configs resolve their ids per environment.
  • ADR renumbering. The integration ADR is now ADRs/0008-integrate-kape-platform-sdk-vpn-engine.md and the surrounding ADRs were renumbered to keep the index contiguous. This resolves my earlier "ADR filename vs. PR body" note — worth a quick pass over the PR description so its 0006/0007 references point at the final 0008 filename.
  • PIAVPN/README.md doc updates — no functional impact.

🔴 Still outstanding — must-fix before production

Both release blockers are unchanged on this HEAD (the new commit didn't touch either).

1. Forced feature-flag override (FeatureFlagHolder.swift:50-52). The subscript still hard-returns true for .usePlatformSDKVPN unconditionally, on every configuration including Release:

if flag == .usePlatformSDKVPN {
    return true
}

Since the subscript is the single read path, CSI has no way to turn the PlatformSDK tunnel off — there is no remote kill switch if a bad SDK build ships. The // TODO: [PlatformSDK] comment correctly marks it temporary, which is fine while the engine is under active development, but at minimum fence it so it can't reach a Release binary:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes the state — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials in selectedDipServer — to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings.

Mitigations still worth crediting: the file is cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and lives in the sandboxed group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package. The "cache bypasses it entirely" findings no longer hold.

🟡 Smaller items (unchanged, still open)

  • Main-thread file I/O in the shared-state observer (VPNDaemon.platformSDKTunnelStatusDidChange, and the resolve path) synchronously reads + decodes the full snapshot (now embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Still worth adding, per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks): PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression.

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state (re-verify the corrected tvOS extension bundle id installs/activates cleanly); Live Activity updates.


Summary: the new commit is a clean docs + tvOS bundle-id fix — the bundle-id change is correct and the ADR renumbering closes my earlier filename note. The two release blockers — the forced flag override and plaintext-credential storage — remain open on this HEAD and should be resolved (or the flag fenced out of Release) before shipping to production.

· branch kape-platform-sdk-integration ·

Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/Daemons/VPNDaemon.swift
Comment thread .github/workflows/ios_pull_request.yml
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 1m 11s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (d2c4b95c) against origin/master. One commit landed since my last review: d2c4b95c "Fix Mac Catalyst archive: exclude iOS-only PlatformSDK tunnel via platformFilter" — a one-line project.pbxproj build-config change. It does not touch either open release blocker.

  • Gather context (new commit since last review)
  • Review the new commit (d2c4b95c — Mac Catalyst archive fix)
  • Re-verify the two release blockers (forced flag, plaintext credentials)
  • Post updated review

🟢 New commit — reviewed, looks correct

  • Mac Catalyst archive fix (project.pbxproj). The change adds platformFilter = ios to two places for the PlatformSDK-Tunnel-iOS appex: the Embed App Extensions PBXBuildFile and the target's PBXTargetDependency. This scopes the iOS-only Network Extension so it is excluded from the Mac Catalyst destination, which is the standard fix for the "extension isn't built for the archived platform" archive failure on Catalyst. Both the embed reference and the dependency are filtered, so the target won't be pulled in transitively either — that's the correct, complete pairing (filtering only one of them typically still fails the archive). No functional impact on the iOS/tvOS builds.

🔴 Still outstanding — must-fix before production

Both release blockers are unchanged on this HEAD (the new commit didn't touch either).

1. Forced feature-flag override (FeatureFlagHolder.swift:50-52). The subscript still hard-returns true for .usePlatformSDKVPN unconditionally, on every configuration including Release:

if flag == .usePlatformSDKVPN {
    return true
}

Since the subscript is the single read path, CSI has no way to turn the PlatformSDK tunnel off — there is no remote kill switch if a bad SDK build ships. The // TODO: [PlatformSDK] comment correctly marks it temporary, which is fine while the engine is under active development, but at minimum fence it so it can't reach a Release binary:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes the state — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials in selectedDipServer — to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings.

Mitigations still worth crediting: the file is cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and lives in the sandboxed group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package. The "cache bypasses it entirely" findings no longer hold.

🟡 Smaller items (unchanged, still open)

  • Main-thread file I/O in the shared-state observer (VPNDaemon.platformSDKTunnelStatusDidChange, and the resolve path) synchronously reads + decodes the full snapshot (now embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Still worth adding, per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks): PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression.

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build (confirm the newly-filtered iOS tunnel extension is correctly excluded and the Catalyst build archives cleanly).


Summary: the new commit is a correct, minimal Mac Catalyst archive fix (platformFilter = ios on both the embed and the target dependency for the iOS-only tunnel). The two release blockers — the forced flag override and plaintext-credential storage — remain open on this HEAD and should be resolved (or the flag fenced out of Release) before shipping to production.

· branch kape-platform-sdk-integration ·

}

private enum CodingKeys: String, CodingKey {
case `protocol`, serverId, resolvedTransport, updatedAt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials stored as plaintext JSON in App Group container (PlatformSDK tunnel path)

The new KapePlatformSDKTunnelProfile path stores full VPN credentials — OpenVPN username and password (vpnTokenUsername/vpnTokenPassword) and the WireGuard authentication token (vpnToken or dipUsername) — as plaintext fields inside PIATunnelSharedState, a JSON file (pia_platformsdk_state.json) written atomically to the App Group container at every VPN connect: ```swift // KapePlatformSDKTunnelProfile.doSave() — runs at every connect PIATunnelSharedState.write(.init( openVPN: o…

View finding in KB

kb · F-1XFZ6TVJKDY0F9R7

}

let cipher = sharedDefaults.string(forKey: AppConstants.UserDefaultsKeys.OpenVPN.cipher) ?? AppConstants.OpenVPNCrypto.default.rawValue
let auth = sharedDefaults.string(forKey: AppConstants.UserDefaultsKeys.OpenVPN.auth) ?? AppConstants.OpenVPNCrypto.defaultAuth

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK shared state stores VPN credentials as plaintext in App Group container JSON file

KapePlatformSDKTunnelProfile.writeSharedState() serialises both OpenVPN credentials (username, password) and the WireGuard authentication token (token) as plain JSON strings into PIATunnelSharedState.State, which is then written to pia_platformsdk_state.json in the App Group container: ```swift // KapePlatformSDKTunnelProfile+OpenVPN.swift return PIATunnelSharedState.OpenVPNSettings( caCertificate: caCertificate, username: username, // vpnTokenUsername — plaintext VPN cred…

View finding in KB

kb · F-1YVSXRM49TCCV5CS

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK VPN credentials stored as plaintext in App Group container file

KapePlatformSDKTunnelProfile.writeSharedState() writes VPN credentials — OpenVPN username and password (vpnTokenUsername / vpnTokenPassword) and WireGuard token (vpnToken) — as plain UTF-8 strings into PIATunnelSharedState.State, which is serialised as a JSON file (pia_platformsdk_state.json) in the App Group container. This file is written on every connect and every in-place server switch via switchLocation. App Group container files are included in **unencrypted local (iTunes/Fi…

View finding in KB

kb · F-2KQH614QJ8ZFT02S


/// Returns `true` if the feature flag is set.
public subscript(_ flag: FeatureFlag) -> Bool {
// TODO: [PlatformSDK] Temporary — force the PlatformSDK tunnel on regardless

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials (OpenVPN password + WireGuard token) stored in plaintext App Group JSON file (PlatformSDK path)

KapePlatformSDKTunnelProfile.doSave() serializes the full OpenVPN username, OpenVPN password, and WireGuard token into PIATunnelSharedState.State, which is persisted as a JSON file (pia_platformsdk_state.json) in the App Group container directory. Unlike the legacy OpenVPN (PIATunnelProfile) and IKEv2 paths — which store only an opaque Keychain passwordReference in NEVPNProtocol and never write the credential value to a file — this new path writes the actual VPN credential strings t…

View finding in KB

kb · F-3KJB6A1X12FG7HV8

Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/Daemons/VPNDaemon.swift Outdated

- name: Run iOS unit tests
uses: nick-fields/retry@v4
with:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — New PR CI workflows use external GitHub Actions pinned to mutable version tags

The newly added .github/workflows/ios_pull_request.yml and .github/workflows/tvos_pull_request.yml use external GitHub Actions pinned to mutable major-version tags rather than immutable commit SHAs: - maxim-lobanov/setup-xcode@v1 — third-party, not GitHub-maintained - nick-fields/retry@v4 — third-party, not GitHub-maintained - ruby/setup-ruby@v1 — Ruby organization - actions/cache@v4, actions/checkout@v6, actions/upload-artifact@v6 — GitHub-maintained but not SHA-pinned Per the…

View finding in KB

kb · F-WK64N10YSZ6K7VVN

/// session (disconnected, or not running through the PlatformSDK tunnel). Individual fields are
/// `nil` when that dimension did not resolve (e.g. the protocol under "Automatic", or a server id
/// the app can't match). Callers fall back to the user's selection per field.
public struct ActualConnection {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idea: Should we explicitly mark data types as Sendable? I know structs that can automatically be sendable are automatically marked as sendable, but marking them explicitly would help us move to swift 6 eventually.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The blocker here is that ActualConnection has a Server property, which is a class, so neither are actually Sendable. We can work on making Server sendable (I predict it's a big work) or drop Server in ActualConnection and store only the serverID instead (but the consumers will have to do some filtering).

Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/VPN/ActualConnection.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/VPN/DefaultVPNProvider.swift Outdated

@kp-mario-nachbaur kp-mario-nachbaur left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good. I left some comments with open questions, feel free to ignore them.

Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/VPN/PIATunnelProfile.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/AppConstants.swift Outdated
Comment thread LocalPackages/PIALibrary/Sources/PIALibrary/Daemons/VPNDaemon.swift Outdated
- VPNDaemon: revert the scheduleFallbackTimerIfNeeded refactor to match
  master (hotfix revert); drop the .disconnected no-internet retry loop,
  keeping only the PlatformSDK connect-time fallback-timer suppression
- KapePlatformSDKTunnelProfile+OpenVPN: replace NSError throws with a
  typed throws(OpenVPNSettingsError) Swift error enum
- ActualConnection: type vpnType as KapePlatformSDKVPNType instead of a
  raw String; bridge to the String preference at the ConnectionTile edge
- PIATunnelProfile: log the OpenVPN.ProviderConfiguration cast failure in
  requestLog/requestDataUsage; return .vpnProfileUnavailable from the
  latter for consistency with its other failure path
- AppConstants: make UserDefaultsKeys/OpenVPN/WireGuard caseless enum
  namespaces instead of instantiable structs
- DefaultVPNProvider: remove the redundant #if os(iOS) || os(tvOS) guard
  in seedInitialVPNStatus
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 1m 57s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (923c4dd1) against origin/master. One commit landed since my last review: 923c4dd1 "PlatformSDK: address PR review feedback on VPN integration" — it applies the changes discussed in the recent review threads (typed errors, KapePlatformSDKVPNType, VPNDaemon mirroring master, AppConstants namespaces, extra logging).

  • Gather context (new commit since last review)
  • Review the new commit (923c4dd1 — PR review feedback)
  • Re-verify the two release blockers (forced flag, plaintext credentials)
  • Post updated review

🟢 New commit — reviewed, looks correct

The commit cleanly addresses the open reviewer threads from @kp-mario-nachbaur:

  • Typed OpenVPN error (KapePlatformSDKTunnelProfile+OpenVPN.swift). The NSError(domain: "PIAVPNError", …) throws are replaced with a typed throws(OpenVPNSettingsError) Swift enum (.dedicatedIPUnavailable / .vpnCredentialsUnavailable, both with errorDescriptions). This is the type-safety improvement Mario asked for and it reads well.

  • ActualConnection.vpnType is now KapePlatformSDKVPNType? instead of a raw String?, with the bridge to the String preference pushed to the ConnectionTile edge — matches the "can this be an enum" request and the follow-up that both sides should use the existing type.

  • AppConstants UserDefaultsKeys / OpenVPN / WireGuard are now caseless enum namespaces rather than instantiable structs — non-instantiable, as requested.

  • PIATunnelProfile now logs the OpenVPN.ProviderConfiguration cast failure in requestLog/requestDataUsage and returns .vpnProfileUnavailable from the latter for consistency — good, resolves the "add logs / no-configuration error" note.

  • DefaultVPNProvider drops the redundant #if os(iOS) || os(tvOS) guard in seedInitialVPNStatus — correct, those are the only two platforms.

  • VPNDaemon reverts the scheduleFallbackTimerIfNeeded extraction and the .disconnected no-internet retry loop back to the master (hotfix) shape, keeping only the connect-time PlatformSDK suppression (fallbackTimer == nil && !featureFlags[.usePlatformSDKVPN] at :203, plus the guard !featureFlags[.usePlatformSDKVPN] at :307). This matches what Mario asked ("mirror master") and the diff is internally consistent — no dangling references to the removed helper remain.

    One thing to flag for the record: mirroring master also reverts the SEC-M-3 manual-disconnect guard — the .disconnected handler is back to if !isReconnecting { … } (was if !isReconnecting || Client.configuration.disconnectedManually). That's the deliberate "match master" decision, and since the PlatformSDK path suppresses the fallback timer entirely the reconnect-after-manual-disconnect scenario shouldn't arise on the active path — but if the legacy path is ever exercised again, confirm master's own handling of manual disconnect mid-reconnect is what you want.

🔴 Still outstanding — must-fix before production

Neither release blocker was touched by this commit.

1. Forced feature-flag override (FeatureFlagHolder.swift:47-52). The subscript still hard-returns true for .usePlatformSDKVPN unconditionally, on every configuration including Release:

if flag == .usePlatformSDKVPN {
    return true
}

Since the subscript is the single read path, CSI has no remote kill switch if a bad SDK build ships. The // TODO: [PlatformSDK] comment correctly marks it temporary (fine while the engine is under active development), but at minimum fence it so it can't reach a Release binary:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes the state — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials in selectedDipServer — to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings.

Mitigations still worth crediting: the file is cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and lives in the sandboxed group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package. The "cache bypasses it entirely" findings no longer hold.

🟡 Smaller items (unchanged, still open)

  • Main-thread file I/O in the shared-state observer (VPNDaemon.platformSDKTunnelStatusDidChange, and the resolve path) synchronously reads + decodes the full snapshot (embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Still worth adding, per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks): PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression (fallback timer not scheduled when the flag is on).

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build.


Summary: the new commit is a clean, well-targeted response to the review threads — typed error, enum-typed ActualConnection, caseless AppConstants namespaces, added logging, and a VPNDaemon revert that faithfully mirrors master. No new issues introduced. The two release blockers — the forced flag override and plaintext-credential storage — remain open on this HEAD and should be resolved (or the flag fenced out of Release) before shipping to production.

· branch kape-platform-sdk-integration ·

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 2m 50s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (923c4dd1) against origin/master. One commit landed since my last review: 923c4dd1 "PlatformSDK: address PR review feedback on VPN integration" — a targeted response to the human-reviewer comments from @kp-mario-nachbaur. It does not touch either open release blocker.

  • Gather context (new commit 923c4dd1)
  • Review the changes (VPNDaemon revert, typed throws, ActualConnection enum, AppConstants namespaces, DefaultVPNProvider, PIATunnelProfile logs)
  • Re-verify the two release blockers (forced flag, plaintext credentials)
  • Post updated review

🟢 New commit — reviewed, all changes look correct

Each item maps cleanly to a reviewer comment and is implemented well:

  • VPNDaemon revert to match master (VPNDaemon.swift). Addresses @kp-mario-nachbaur's "double-check what we have in master … I think this was reverted when we did the hotfix" and Diego's reply that it "survived some conflict resolution." The commit drops the scheduleFallbackTimerIfNeeded refactor and the .disconnected no-internet retry loop, restoring master's inline fallback-timer and if !isReconnecting { invalidateTimer(); reset() } shape — while keeping the two PlatformSDK-specific suppressions (connect-time fallback-timer guard at :203 and the .disconnected guard at :307, both gated on !usePlatformSDKVPN). Since the flag is forced on, the whole PIA-level reconnect loop is inert for PlatformSDK users, so the KB SEC-M-3 reconnect-after-manual-disconnect path can't fire through this timer. The disconnectedManually service-quality block is preserved and still referenced. Coherent, no dangling symbols.
  • Typed throws(OpenVPNSettingsError) (KapePlatformSDKTunnelProfile+OpenVPN.swift:26-53). Replaces the two NSError(domain: "PIAVPNError", …) throws with a LocalizedError enum (dedicatedIPUnavailable / vpnCredentialsUnavailable). Exactly what Mario asked for ("prefer a Swift error / typed throw"). The one caller (writeSharedState … throws at KapePlatformSDKTunnelProfile.swift:192) is untyped, so the concrete error widens to any Error cleanly — no call-site breakage.
  • ActualConnection.vpnType: KapePlatformSDKVPNType? (ActualConnection.swift:31, DefaultVPNProvider.swift:79-84). Replaces the raw String? with the existing enum ("Can this be an enum instead of a string?"). DefaultVPNProvider now returns the enum case directly, and ConnectionTile.swift:113 bridges at the edge via connection?.vpnType?.rawValue ?? Client.preferences.vpnType. The .rawValues ("PIA"/"PIAWG") match the persisted preference strings, and .automatic → nil still falls back to the user selection. Correct.
  • AppConstants caseless-enum namespaces (AppConstants.swift:144/158). UserDefaultsKeys / OpenVPN / WireGuard are now enum rather than instantiable struct — matches Mario's nitpick and prevents accidental instantiation of pure namespaces.
  • PIATunnelProfile cast-failure logging (PIATunnelProfile.swift:214/236). Adds log.error on the OpenVPN.ProviderConfiguration cast-fail in both requestLog/requestDataUsage, and requestDataUsage now returns .vpnProfileUnavailable for consistency with its other failure path. Addresses the "add logs / no-configuration error" suggestion.
  • DefaultVPNProvider.seedInitialVPNStatus (:174) drops the redundant #if os(iOS) || os(tvOS) guard (iOS + tvOS are the only supported OSes; Catalyst is os(iOS)), per Mario's question. No behavioural change.

One residual note on the enum-conversion thread: the ActualConnection Sendable question Diego raised is correctly deferred — ActualConnection still holds a Server (a class), so it can't be Sendable without either making Server sendable or storing just serverId. Reasonable to leave for a follow-up.

🔴 Still outstanding — must-fix before production

Both release blockers are unchanged on this HEAD (the new commit didn't touch either). These remain acceptable while the engine is under active development given the // TODO: [PlatformSDK] markers, but must be resolved before shipping.

1. Forced feature-flag override (FeatureFlagHolder.swift:49-52). The subscript still hard-returns true for .usePlatformSDKVPN unconditionally, on every configuration including Release:

if flag == .usePlatformSDKVPN {
    return true
}

Since the subscript is the single read path, CSI has no remote kill switch if a bad SDK build ships. At minimum, fence it out of Release:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes the state — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials in selectedDipServer — to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings.

Mitigations worth crediting: the file is cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and lives in the sandboxed group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package. The "cache bypasses it entirely" findings no longer hold.

🟡 Smaller items (unchanged, still open)

  • Main-thread file I/O in the shared-state observer (VPNDaemon.platformSDKTunnelStatusDidChange, and the seedInitialVPNStatus/resolve path at :285) synchronously reads + decodes the full snapshot (embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Still worth adding, per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks): PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression. A small addition worth making now: an openVPNSettings(for:) test asserting the new typed OpenVPNSettingsError cases (DIP-without-address and missing-credentials).

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build. Given the VPNDaemon revert, re-verify legacy-protocol (non-PlatformSDK) reconnect behavior matches master should the flag ever be turned off.


Summary: the new commit is a clean, correct response to the human-review feedback — typed error, enum-typed ActualConnection, caseless-enum namespaces, added logging, and a VPNDaemon revert that re-aligns with master while preserving the PlatformSDK suppressions. No new issues. The two release blockers — the forced flag override and plaintext-credential storage — remain open on this HEAD and should be resolved (or the flag fenced out of Release) before shipping to production.

· branch kape-platform-sdk-integration ·

@claude

claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 5m 38s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (211e8a97) against origin/master. One commit landed since my last review: 211e8a97 "Merge branch 'master' into kape-platform-sdk-integration" — a merge of master, which brings in KM-17461 (obtainVPNPermission + Server.vpnPermissionPlaceholder), the StoreKit/subscription refactor, localized prices, and Keychain changes.

  • Gather context (new commit since last review)
  • Check the merge for conflict-resolution damage
  • Review the new master↔PlatformSDK interaction (VPN-permission placeholder path)
  • Re-verify the two release blockers (forced flag, plaintext credentials)
  • Post updated review

🟢 The merge itself looks clean

git diff 923c4dd1 211e8a97 touches only master-side files plus the three VPN files master itself changed (DefaultVPNProvider, PIATunnelProfile, VPNProvider) — no PlatformSDK/PIAVPN/PlatformSDK-Tunnel file was altered by the merge, so no PlatformSDK work was lost to conflict resolution. Master's additions land cleanly:

  • VPNProvider.obtainVPNPermission(_:) + DefaultVPNProvider.install(force:allowServerPlaceholder:_:)
  • Server.vpnPermissionPlaceholder (.invalid TLD, computed not static let — nice touch given Server is a mutable class)
  • PIATunnelProfile's empty-serverAddress → hostname fallback

🟠 New this push — the placeholder path now runs through the PlatformSDK profile

Because resolvedActiveProfile() (DefaultVPNProvider.swift:453) routes every connection through KapePlatformSDKTunnelProfile while the flag is on, master's brand-new permission-grant flow now goes obtainVPNPermissioninstall(force:true, allowServerPlaceholder:true)KapePlatformSDKTunnelProfile.doSavewriteSharedState. Neither side was written with the other in mind, and two things fall out of it:

1. writeSharedState can hard-fail the OS permission grant. doSave (KapePlatformSDKTunnelProfile.swift:86-91) treats the shared-state write as fatal, and openVPNSettings(for:) (+OpenVPN.swift:61-67) throws .vpnCredentialsUnavailable when the VPN token isn't available — so saveToPreferences is never reached, VPNPermissionViewController shows the "permission required" alert, and the user is left retrying. The legacy profiles deliberately treat a missing token as optional and still save (PIATunnelProfile.swift:308, IKEv2Profile.swift:221 — both if let accountVpnUsername = …). Granting the OS VPN permission needs no credentials at all: generatedProtocol sets serverAddress = "" and reads nothing from them.

In practice the window is narrow — login guards vpnToken != nil (DefaultAccountProvider.handleLoginResult:243) and signup awaits webServices.token(...) before success, and the permission screen is only presented from handleAuthenticationSuccess — so a token is normally present. But this is a fail-closed failure on the one flow master just hardened specifically to survive missing data, and it hands the user an unrecoverable-looking alert. Suggest skipping the shared-state write when the configuration is the placeholder (or tolerating absent credentials there), so the permission grant can never be blocked by connection data. Fix this →

2. Placeholder identity leaks into the shared state. connectableServer(for:) (:363) can't resolve the placeholder — an empty/stale server list is the premise of the placeholder path — so bestServer ?? servers.first { !$0.offline } ?? server returns the placeholder itself. If the user has a preferredServer (the returning-user case the placeholder doc calls out: "when logout wiped the cache"), then isAutomaticSelection == false (:199) and selectedLocationId is persisted as "vpn-permission-placeholder" (Server.identifier = first hostname component, Server.swift:213).

Consequences are bounded — State.selectedServer(in:) degrades to fastest-available rather than returning nil, and on-demand is forced off for the placeholder so nothing dials it — but PIAEndpointRepository.generateConfigurations (:16-21) treats a non-nil selectedLocationId as a concrete selection, so Automatic silently loses its multi-server fan-out until the next connect() rewrites the state. Same fix as above (don't write shared state for a placeholder) closes both.

🟡 Also new to me this pass — writeSharedState drops the extension's write-back fields

writeSharedState (KapePlatformSDKTunnelProfile.swift:203-214) builds a fresh State.init(...), threading servers / serversFetchedAt / latencyByServerId forward from existing — but not activeConnection or tunnelStatus, which default to nil. So every connect / in-place switch / profile save silently clears the extension→app write-back. Every other mutator in PIATunnelSharedState (updateServers, updateLatencies, updateActiveConnection, updateTunnelStatus) is deliberately read-modify-write to avoid exactly this.

On the live-switch path it's harmless-to-desirable: the app clears, platformSDKTunnelStatusDidChange early-returns on nil, and the extension immediately re-reports .connecting. The exposure is any save not followed by a tunnel status change — e.g. install(force:)/obtainVPNPermission landing while a tunnel is live: activeConnection goes nil, so ConnectionTile.setConnectionValues (:113) and RegionTile fall back to Client.preferences.vpnType and show "Automatic" instead of the resolved protocol/region until the next status change. The bigger cost is the maintenance trap — the doc-comment already flags the wholesale-write hazard for the server cache, and every future State field has to be manually threaded here or it gets wiped. Making it read-modify-write (mutate only the app→tunnel fields) is both shorter and immune to that. Fix this →

🔴 Still outstanding — must-fix before production

Both release blockers are unchanged (the merge didn't touch either).

1. Forced feature-flag override (FeatureFlagHolder.swift:47-52). The subscript still hard-returns true for .usePlatformSDKVPN on every configuration including Release, so CSI has no remote kill switch. Note this is also what makes the two placeholder findings above reachable at all. At minimum fence it out of Release:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:78-84). write(_:) still serializes OpenVPNSettings.username/password, WireGuardSettings.token and the DIP credentials in selectedDipServer to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain — the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings. Mitigations still worth crediting: cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and sandboxed to the group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding no longer applies to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package.

🟡 Smaller items (unchanged, still open)

  • Main-thread file I/O in the shared-state observerVPNDaemon.platformSDKTunnelStatusDidChange (:88) and the resolve at :285 synchronously read + decode the full snapshot (embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks), still worth adding: PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression; openVPNSettings(for:)'s typed OpenVPNSettingsError cases. New, given the merge: master added VPNPermissionPlaceholderTests for the legacy path — a companion test that obtainVPNPermission succeeds with the PlatformSDK profile active (and doesn't persist the placeholder id into shared state) would lock down both findings above.

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; in-place region switch on a live tunnel (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; kill switch on/off; legacy-install migration + logout state clearing; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build. Newly merged and highest-priority to exercise: fresh signup and fresh login → VPN permission screen with the PlatformSDK flag on (grant succeeds; then confirm the first connect resolves a real region and that Automatic still fans out), plus login-after-logout with a previously-set preferred region.


Summary: the merge is clean and lost no PlatformSDK work, but it introduces a genuine new seam — master's VPN-permission placeholder now flows through KapePlatformSDKTunnelProfile, where a throwing writeSharedState can block the permission grant and the placeholder's identifier can be persisted as a concrete region selection. Separately, writeSharedState silently drops the extension's activeConnection/tunnelStatus write-back where every sibling mutator preserves it. The two release blockers — the forced flag override and plaintext-credential storage — remain open.
· branch kape-platform-sdk-integration

let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://\(host):\(config.authPort)/addKey?pubkey=\(encodedPubkey)&pt=\(encodedToken)")
else {
logger.error("Failed to build key-exchange URL for \(host):\(config.authPort)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK WireGuard key exchange sends VPN token to server with no TLS certificate validation

In PIAWireguardAuthenticator.authenticate(config:), the WireGuard key-exchange HTTPS request is made through a URLSession backed by TrustAllCertsDelegate, which accepts any TLS certificate presented by the server: swift let delegate = TrustAllCertsDelegate() let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) TrustAllCertsDelegate.urlSession(_:didReceive:completionHandler:) unconditionally calls `completionHandler(.useCredential, URLCreden…

View finding in KB

kb · F-0NT3A776DA24F1QB

}

private enum CodingKeys: String, CodingKey {
case `protocol`, serverId, resolvedTransport, updatedAt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials stored as plaintext JSON in App Group container (PlatformSDK tunnel path)

The new KapePlatformSDKTunnelProfile path stores full VPN credentials — OpenVPN username and password (vpnTokenUsername/vpnTokenPassword) and the WireGuard authentication token (vpnToken or dipUsername) — as plaintext fields inside PIATunnelSharedState, a JSON file (pia_platformsdk_state.json) written atomically to the App Group container at every VPN connect: ```swift // KapePlatformSDKTunnelProfile.doSave() — runs at every connect PIATunnelSharedState.write(.init( openVPN: o…

View finding in KB

kb · F-1XFZ6TVJKDY0F9R7

}
username = dipUsername
password = dipIp
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK shared state stores VPN credentials as plaintext in App Group container JSON file

KapePlatformSDKTunnelProfile.writeSharedState() serialises both OpenVPN credentials (username, password) and the WireGuard authentication token (token) as plain JSON strings into PIATunnelSharedState.State, which is then written to pia_platformsdk_state.json in the App Group container: ```swift // KapePlatformSDKTunnelProfile+OpenVPN.swift return PIATunnelSharedState.OpenVPNSettings( caCertificate: caCertificate, username: username, // vpnTokenUsername — plaintext VPN cred…

View finding in KB

kb · F-1YVSXRM49TCCV5CS

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK VPN credentials stored as plaintext in App Group container file

KapePlatformSDKTunnelProfile.writeSharedState() writes VPN credentials — OpenVPN username and password (vpnTokenUsername / vpnTokenPassword) and WireGuard token (vpnToken) — as plain UTF-8 strings into PIATunnelSharedState.State, which is serialised as a JSON file (pia_platformsdk_state.json) in the App Group container. This file is written on every connect and every in-place server switch via switchLocation. App Group container files are included in **unencrypted local (iTunes/Fi…

View finding in KB

kb · F-2KQH614QJ8ZFT02S


/// Returns `true` if the feature flag is set.
public subscript(_ flag: FeatureFlag) -> Bool {
// TODO: [PlatformSDK] Temporary — force the PlatformSDK tunnel on regardless

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials (OpenVPN password + WireGuard token) stored in plaintext App Group JSON file (PlatformSDK path)

KapePlatformSDKTunnelProfile.doSave() serializes the full OpenVPN username, OpenVPN password, and WireGuard token into PIATunnelSharedState.State, which is persisted as a JSON file (pia_platformsdk_state.json) in the App Group container directory. Unlike the legacy OpenVPN (PIATunnelProfile) and IKEv2 paths — which store only an opaque Keychain passwordReference in NEVPNProtocol and never write the credential value to a file — this new path writes the actual VPN credential strings t…

View finding in KB

kb · F-3KJB6A1X12FG7HV8

key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: spm-${{ runner.os }}-

- name: Set up Ruby

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · SECURITY_REVIEW — New PR CI workflows use external GitHub Actions pinned to mutable version tags with access to org credentials

The new ios_pull_request.yml and tvos_pull_request.yml CI workflows use three external GitHub Actions pinned to mutable version tags instead of full commit SHAs: - maxim-lobanov/setup-xcode@v1 - nick-fields/retry@v4 - ruby/setup-ruby@v1 Per the repository advisory guidance, only actions from the xvpn, xvpn-meta, and xvpn-sec GitHub organisations are permitted to use mutable labels; all other external actions remain untrusted. None of these three actions come from those organisat…

View finding in KB

kb · SEC-M-4

case invalidURL
case serverError(String)
case missingAnchorCertificate
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — PinnedCertificateDelegate does not call SecTrustSetAnchorCertificatesOnly, allowing system-trusted CAs to satisfy the WireGuard key-exchange TLS check

PIAWireguardAuthenticator.PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:) sets the PIA root CA as a trust anchor with SecTrustSetAnchorCertificates(trust, [anchorCertificate] as CFArray) but does not call SecTrustSetAnchorCertificatesOnly(trust, true). Without the exclusivity flag, Apple's trust evaluation treats the custom anchor as additive: the system's built-in CA store remains valid alongside the PIA root CA. A certificate signed by any system-trusted CA that pre…

View finding in KB

kb · F-5XSNY1058469A8SY

logger.error("Pinning failed: no server trust on key-exchange challenge")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — WireGuard key-exchange TLS pinning allows system-trusted CAs because SecTrustSetAnchorCertificatesOnly is not called

PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:) pins the WireGuard key-exchange TLS connection against the bundled PIA root CA by calling SecTrustSetAnchorCertificates(trust, [anchorCertificate] as CFArray). However, it does not follow this with SecTrustSetAnchorCertificatesOnly(trust, true). Without the exclusivity flag, Apple's documentation states that the system's built-in anchor certificates remain eligible for validation in addition to the explicitly supplied a…

View finding in KB

kb · F-J0TH0QY0N6TS0XSA


- name: Run iOS unit tests
uses: nick-fields/retry@v4
with:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — New PR CI workflows use external GitHub Actions pinned to mutable version tags

The newly added .github/workflows/ios_pull_request.yml and .github/workflows/tvos_pull_request.yml use external GitHub Actions pinned to mutable major-version tags rather than immutable commit SHAs: - maxim-lobanov/setup-xcode@v1 — third-party, not GitHub-maintained - nick-fields/retry@v4 — third-party, not GitHub-maintained - ruby/setup-ruby@v1 — Ruby organization - actions/cache@v4, actions/checkout@v6, actions/upload-artifact@v6 — GitHub-maintained but not SHA-pinned Per the…

View finding in KB

kb · F-WK64N10YSZ6K7VVN

authenticated.serverPublicKey = response.server_key
authenticated.clientPrivateKey = privateKeyBase64
authenticated.internalIp = response.peer_ip
authenticated.dnsServers = dnsServers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — WireGuard key-exchange TLS pinning allows system-trusted CAs alongside pinned PIA CA

In PIAWireguardAuthenticator.PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:), the certificate pinning implementation calls SecTrustSetAnchorCertificates(trust, [anchorCertificate]) but does NOT call SecTrustSetAnchorCertificatesOnly(trust, true) afterwards. Per Apple's documentation, omitting SecTrustSetAnchorCertificatesOnly means the system also trusts all built-in system anchor certificates in addition to the explicitly set PIA CA. A certificate chain that termin…

View finding in KB

kb · SEC-L-4

# Conflicts:
#	LocalPackages/PIALibrary/Sources/PIALibrary/VPN/NetworkExtensionProfile.swift
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 4m 10s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (d9020d8d) against origin/master. One commit landed since my last review: d9020d8d "Merge branch 'master' into kape-platform-sdk-integration", which brings in KM-17445 (don't sign up an already-purchased account), the Reachability restoration in ConnectivityDaemon, VPNDaemon.abortReconnectCycleIfNeeded(), and — most relevant here — a new shared NetworkExtensionProfile.afterTeardown(of:perform:) helper.

  • Gather context (new commit since last review)
  • Check the merge for conflict-resolution damage
  • Review the new master↔PlatformSDK interaction (afterTeardown, abortReconnectCycleIfNeeded)
  • Re-verify the open blockers/findings
  • Post updated review

🟢 The merge itself is clean

git diff 211e8a97..HEAD touches only master-side files plus VPNDaemon, NetworkExtensionProfile, IKEv2Profile, PIATunnelProfile, PIAWGTunnelProfile, DefaultVPNProvider — all files master itself changed. No PlatformSDK/PIAVPN/PlatformSDK-Tunnel file was altered, so no PlatformSDK work was lost to conflict resolution. Spot-checks all pass:

  • PIALibrary/Package.swift — the ../KapePlatformSDK/TunnelKitPackage path dep survived; master's new Reachability.swift dep was added alongside it.
  • NetworkObserver.swift deletion is complete — no dangling references anywhere in the tree.
  • VPNDaemon kept both PlatformSDK suppressions (fallbackTimer == nil && !featureFlags[.usePlatformSDKVPN] at :205, guard !featureFlags[...] at :309), the shared-state observer registration (:62-64), and the tunnelStatus fold-in at :287.
  • abortReconnectCycleIfNeeded() composes correctly with the flag: it early-returns on guard isReconnecting || fallbackTimer != nil, and under the flag neither is ever set, so it's a no-op on the PlatformSDK path. Correct by construction rather than by accident.

🟠 New this push — the PlatformSDK profile misses master's afterTeardown hardening

Master added NetworkExtensionProfile.afterTeardown(of:perform:) (NetworkExtensionProfile.swift:153-197) and routed all three legacy profiles through it, replacing their hand-rolled "wait for .disconnected then start" observers. Its doc-comment states the problem precisely: NE silently drops a start issued while the connection is .disconnecting, and no further status change follows to trigger a retry.

KapePlatformSDKTunnelProfile conforms to NetworkExtensionProfile, so it inherits afterTeardown for free — but it still uses its own waitForDisconnectedThenStart (:233-262), which is missing all three of the guards master just added, and structurally has a wider race window than the code master replaced:

1. The status decision is stale, and the observer is installed too late. connect() captures currentStatus at :135, then runs doSave — which does saveToPreferences and loadFromPreferences, two async IPC round-trips — and only inside that completion branches on the pre-doSave status and installs the observer (:165-168). If the teardown finishes during those round-trips (both are ~100-300ms; so is a tunnel teardown), .disconnecting → .disconnected fires before anything is listening. The observer then waits for a notification that will never come: startTunnel is never issued and callback? is never invoked, so the connect completion never runs. Master's helper is immune — it re-checks the status at the moment of the start, re-checks again right after installing the observer ("the teardown may have completed while the observer was being installed"), and arms a 5s watchdog so "a wedged teardown must not swallow the start for good." Neither the re-check nor the watchdog exists here, and under the forced flag VPNDaemon's fallback timer is suppressed, so nothing recovers it — the tunnel stays down until the user taps connect again.

2. Waits for exactly .disconnected, not != .disconnecting. If the connection lands in .invalid instead (profile removed mid-wait), that's another terminal state with no further change coming — same silent hang. Master's guard connection.status != .disconnecting handles it.

3. No disconnectedManually guard on the deferred start. Master explicitly abandons it: "Never bring the tunnel back up against that intent." Credit where due — disconnect() here removes waitObserver first (:265-268), and since profile(forVPNType:) returns the registered singleton it's the same instance, so the app's own disconnect path does cancel the pending start. But that's a narrower net than checking the flag at fire time (this push also added two new places that set disconnectedManually-driven aborts), and it relies on every give-up routing through this profile's disconnect.

4. waitObserver is shared mutable instance state. It's written from the doSave completion (delivered on an NE-internal queue, not documented as main), read/cleared in disconnect() on the caller's queue, and cleared again from the main-queue observer block — an unsynchronized race on a class property. Master's helper deliberately uses only locals (observer / hasRun) confined to main.

All four collapse into one small change: delete waitForDisconnectedThenStart and waitObserver, and inside the doSave completion do exactly what PIATunnelProfile/PIAWGTunnelProfile now do:

let session = vpn.connection as? NETunnelProviderSession
try self.afterTeardown(of: vpn.connection) { try session?.startTunnel(options: nil) }

That also lets the pre-doSave currentStatus switch drop its .disconnecting case entirely (the live-switch branch at :144-157 stays as-is). Fix this →

🟠 Still open from my last review (unchanged by the merge)

  • writeSharedState can hard-fail the OS permission grant. doSave (:86-91) still treats the shared-state write as fatal, and openVPNSettings(for:) still throws .vpnCredentialsUnavailable (+OpenVPN.swift:66) when the VPN token isn't available — so master's obtainVPNPermissioninstall(force:allowServerPlaceholder:true) path can never reach saveToPreferences, and the user gets the "permission required" alert with no way forward. Granting the OS permission needs no credentials at all. Legacy profiles deliberately treat a missing token as optional and still save.
  • Placeholder identity leaks into shared state. connectableServer(for:) can't resolve .vpnPermissionPlaceholder, so with a preferredServer set, selectedLocationId is persisted as "vpn-permission-placeholder" and PIAEndpointRepository.generateConfigurations reads that as a concrete selection — Automatic silently loses its fan-out until the next connect(). Skipping the shared-state write for the placeholder closes both.
  • writeSharedState drops the extension's write-back fields. :203-214 still builds a fresh State.init(...) threading servers/serversFetchedAt/latencyByServerId forward but not activeConnection/tunnelStatus, unlike every sibling mutator in PIATunnelSharedState, which is read-modify-write. Harmless on the live-switch path; on a save not followed by a status change the UI falls back to "Automatic". Mostly a maintenance trap — every future State field must be manually threaded here or it gets wiped.

🔴 Release blockers — both unchanged

1. Forced feature-flag override (FeatureFlagHolder.swift:47-52). Still hard-returns true for .usePlatformSDKVPN on every configuration including Release, so CSI has no remote kill switch. Note this is also what makes the placeholder findings above reachable at all. At minimum fence it out of Release:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still serializes OpenVPNSettings.username/password, WireGuardSettings.token and the DIP credentials in selectedDipServer to pia_platformsdk_state.json with options: .atomic and no NSFileProtection class, bypassing the Keychain — the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings. Mitigations still worth crediting: cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and sandboxed to the group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding does not apply to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package.

🟡 Smaller items

  • Main-thread file I/O in the shared-state observerplatformSDKTunnelStatusDidChange (:91) and the resolve at :287 synchronously read + decode the full snapshot (embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • Inherited via the merge, not this PR: ConnectivityDaemon now does try! Reachability(hostname: "8.8.8.8") — a force-try at daemon init plus a hardcoded external host in a VPN app. Master's call, but worth a separate look.

🧪 Test coverage

Per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks), still worth adding: PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression; openVPNSettings(for:)'s typed OpenVPNSettingsError cases; obtainVPNPermission succeeding with the PlatformSDK profile active. New, given this merge: a test that connect() from a .disconnecting state still issues startTunnel even when the teardown completes during doSave would lock down the afterTeardown finding above.

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; reconnect immediately after a disconnect / while still .disconnecting — the newly-merged afterTeardown window (change protocol or region on a live tunnel, and toggle the kill switch off then reconnect, which is the case master's doc-comment calls out as the one where nothing brings the tunnel back); in-place region switch (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; legacy-install migration + logout state clearing; fresh signup/login → VPN permission screen with the flag on; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build.


Summary: the merge is clean and lost no PlatformSDK work, and abortReconnectCycleIfNeeded composes correctly with the flag. But master arrived with a shared afterTeardown fix for NE silently dropping a start issued during teardown, and adopted it in all three legacy profiles — while KapePlatformSDKTunnelProfile, the profile that is actually active, keeps a bespoke wait that lacks the re-check, the watchdog, the manual-disconnect guard, and the thread confinement. Adopting the inherited helper is a small change that closes all four. The two release blockers and the three KM-17461 placeholder/write-back items from my last review remain open.
· branch kape-platform-sdk-integration

@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown

Claude finished @kp-diego-trevisan's task in 2m 7s —— View job


Claude PR Review — KapePlatformSDK integration (re-review on latest push)

Re-reviewed the current HEAD (81a0929d) against origin/master. One commit landed since my last review: 81a0929d "Merge branch 'master' into kape-platform-sdk-integration", pulling in master's 96ef6205 (Declare swift-log dependency in PIAAccount).

  • Gather context (new commit since last review)
  • Check the merge for conflict-resolution damage
  • Review the new/changed code
  • Re-verify the open blockers/findings
  • Post updated review

🟢 The merge is trivial and clean

git diff d9020d8d..HEAD is one file, 4 insertions / 2 deletionsLocalPackages/PIAAccount/Package.swift only, adding master's explicit swift-log dependency:

.package(url: "https://github.com/apple/swift-log", exact: "1.13.1")

.product(name: "Logging", package: "swift-log")

Checks that matter here:

  • Version pin is consistent. PIALibrary/Package.swift:29 already pins the identical exact: "1.13.1", and Package.resolved:166-172 is already at 1.13.1 (2aed77ae) — so no Package.resolved churn and no exact-vs-exact resolution conflict between the two local packages. The only residual risk is the vendored Kape TunnelKitPackage transitively pinning a different exact swift-log; that can't be checked from this tree (LocalPackages/KapePlatformSDK/ is gitignored), but SPM resolution in the PR workflows will surface it if so — worth a glance at the iOS/tvOS runs once they finish (all three were still in_progress at review time).
  • No PlatformSDK work was touched. The merge modified zero files under KapePlatformSDK/, PIAVPN/, or PlatformSDK-Tunnel/, so nothing was lost to conflict resolution. PIALibrary/Package.swift's mobile-ios-openvpn../KapePlatformSDK/TunnelKitPackage swap (and all four package: "TunnelKitPackage" product references) is intact.

Nothing new to review beyond that — so the rest of this comment is a status roll-up of what's still open. No new issues introduced.

🔴 Release blockers — both unchanged

1. Forced feature-flag override (FeatureFlagHolder.swift:47-52). Verified still present verbatim on this HEAD: the subscript hard-returns true for .usePlatformSDKVPN before consulting the CSI-populated set, on every configuration including Release. Since the subscript is the single read path, there is no remote kill switch if a bad SDK build ships — and it's also what makes the placeholder findings below reachable at all. Fine while the engine is under active development (the // TODO: [PlatformSDK] marks it), but at minimum fence it out of Release:

if flag == .usePlatformSDKVPN {
    #if DEBUG || DEVELOPMENT || STAGING
    return true
    #endif
}

Fix this →

2. VPN credentials persisted as plaintext JSON (PIATunnelSharedState.swift:66-84). write(_:) still encodes the whole State — including OpenVPNSettings.username/password, WireGuardSettings.token, and the DIP credentials carried in selectedDipServer — and writes it with options: .atomic and no NSFileProtection class, bypassing the Keychain. This is the one real issue behind the ~20 duplicate "plaintext credentials" HIGH KB findings. Mitigations still worth crediting: cleared on logout (DefaultAccountProvider.logout → PIATunnelSharedState.delete()) and sandboxed to the group container. Recommended: move the secret fields to a Keychain item with a shared kSecAttrAccessGroup (…AfterFirstUnlockThisDeviceOnly) — Keychain reads are reliable from a Network Extension, unlike the UserDefaults case that motivated the file — keeping only non-sensitive fields in the JSON. As a floor, set NSFileProtectionCompleteUntilFirstUserAuthentication on the write and document the residual risk. Fix this →

🟠 Still open from my last two reviews (all re-verified on this HEAD)

  • KapePlatformSDKTunnelProfile still doesn't use master's afterTeardown hardening. afterTeardown(of:perform:) exists at NetworkExtensionProfile.swift:154 and all three legacy profiles adopt it (PIATunnelProfile.swift:95, PIAWGTunnelProfile.swift:162, IKEv2Profile.swift:95) — but the active profile still routes .disconnecting through its own waitForDisconnectedThenStart (:168:233-262) with the waitObserver property (:29). That bespoke path lacks the status re-check at start time, the 5s watchdog, the disconnectedManually guard, and the main-queue confinement master added, and it decides on a currentStatus captured before doSave's two async IPC round-trips (:135) — so a teardown completing during doSave means the observer never fires, startTunnel is never issued, and the callback never runs. Under the forced flag the app-side fallback timer is suppressed, so nothing recovers it. Swapping in the inherited helper closes all four. Fix this →
  • writeSharedState can hard-fail the OS permission grant. doSave (:86-91) still treats the shared-state write as fatal (callback?(error); return before saveToPreferences), and openVPNSettings(for:) still throws .vpnCredentialsUnavailable when no VPN token is available — so master's obtainVPNPermissioninstall(force:allowServerPlaceholder:true) path can dead-end on the "permission required" alert. Granting the OS VPN permission needs no credentials at all; the legacy profiles deliberately treat a missing token as optional and still save.
  • Placeholder identity can leak into shared state. connectableServer(for:) can't resolve .vpnPermissionPlaceholder, so with a preferredServer set, writeSharedState (:203-214) persists selectedLocationId: "vpn-permission-placeholder", which PIAEndpointRepository.generateConfigurations reads as a concrete selection — Automatic silently loses its fan-out until the next connect(). Skipping the shared-state write for the placeholder closes this and the item above.
  • writeSharedState drops the extension's write-back fields. Confirmed at :203-214: the fresh State.init threads servers/serversFetchedAt/latencyByServerId forward from existing, but not activeConnection/tunnelStatus, so both default to nil on every save — unlike every sibling mutator in PIATunnelSharedState, which is read-modify-write. Harmless on the live-switch path; on a save not followed by a status change the tiles fall back to "Automatic". Mostly a maintenance trap: every future State field has to be manually threaded here or it gets wiped. Fix this →

🟢 Confirmed still resolved (worth resolving the KB threads)

  • TLS-bypass HIGH cluster + LOW pinning findings. PIAWireguardAuthenticator pins to the bundled PIA CA, requires the leaf CN to equal the per-server certDn, calls SecTrustSetAnchorCertificatesOnly(trust, true), and fails closed. The KB's TrustAllCertsDelegate finding does not apply to this tree.
  • Supply-chain checksum MEDIUM cluster. scripts/pull-kape-platform-sdk.sh is fail-closed (committed pin in scripts/kape-platform-sdk.checksum, dies with no checksum, re-verifies cached archives), and CI caches the archive keyed on the checksum — not the unpacked package.

🟡 Smaller items (unchanged)

  • Main-thread file I/O in the shared-state observerVPNDaemon.platformSDKTunnelStatusDidChange and the resolve path synchronously read + decode the full snapshot (embedding the entire servers array) on each Darwin notification. Consider reading off-main, or splitting the small write-back fields from the large servers cache.
  • serverId(forConnectedHost:) matches on IP membership and takes the first hit — a note documenting the "addresses are unique per server" assumption would help.
  • Pecking-order endpoint diversity — a step's attempts slots can land on the same server if it contributes multiple endpoints; confirm that matches the "distinct endpoints" intent or dedupe by server within a step.
  • INF-L-1 (Gemfile.lock gitignored) — unpinned CI gem versions, worth addressing independently of this PR.

🧪 Test coverage

Per CLAUDE.md (PIALibraryTests / PIA VPNTests with mocks), still worth adding: PIAWireguardAuthenticator pinning (reject wrong CN / non-anchored leaf); PIAEndpointRepository fastest-first ranking + selectedServer(in:) fallback chain + pecking-order batch composition; PIADataUsage ⇄ SDK PacketTunnelDataUsage key-compat round-trip; VPNDaemon flag-gated suppression; openVPNSettings(for:)'s typed OpenVPNSettingsError cases; obtainVPNPermission succeeding with the PlatformSDK profile active; and a connect()-from-.disconnecting test that still issues startTunnel when the teardown completes during doSave.

On-device flows to verify (physical device required for NE)

Connect/disconnect on OpenVPN, WireGuard, Automatic; reconnect immediately after a disconnect / while still .disconnecting (change protocol or region on a live tunnel; toggle kill switch off then reconnect); in-place region switch (switchLocation); DIP connect; data-usage counters; wifi↔cellular roaming; on-demand reconnect with the app killed; legacy-install migration + logout state clearing; fresh signup/login → VPN permission screen with the flag on; tvOS relaunch restoring connected state; Live Activity updates; Mac Catalyst archive + build. Given this merge specifically: a clean PIAAccount build/resolve (and, since master's swift-log change touches account logging, a quick login/signup smoke test) is the only new surface.


Summary: this push is a one-line dependency merge from master (PIAAccountswift-log 1.13.1), consistent with the existing pin and Package.resolved, and it touched no PlatformSDK code — nothing lost, no new issues. The two release blockers (forced flag override, plaintext-credential storage) and the four open items from my previous two reviews (afterTeardown adoption, permission-grant hard-fail, placeholder leaking into shared state, write-back fields dropped) all remain open on this HEAD.
· branch kape-platform-sdk-integration

let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "https://\(host):\(config.authPort)/addKey?pubkey=\(encodedPubkey)&pt=\(encodedToken)")
else {
logger.error("Failed to build key-exchange URL for \(host):\(config.authPort)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK WireGuard key exchange sends VPN token to server with no TLS certificate validation

In PIAWireguardAuthenticator.authenticate(config:), the WireGuard key-exchange HTTPS request is made through a URLSession backed by TrustAllCertsDelegate, which accepts any TLS certificate presented by the server: swift let delegate = TrustAllCertsDelegate() let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) TrustAllCertsDelegate.urlSession(_:didReceive:completionHandler:) unconditionally calls `completionHandler(.useCredential, URLCreden…

View finding in KB

kb · F-0NT3A776DA24F1QB

}

private enum CodingKeys: String, CodingKey {
case `protocol`, serverId, resolvedTransport, updatedAt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials stored as plaintext JSON in App Group container (PlatformSDK tunnel path)

The new KapePlatformSDKTunnelProfile path stores full VPN credentials — OpenVPN username and password (vpnTokenUsername/vpnTokenPassword) and the WireGuard authentication token (vpnToken or dipUsername) — as plaintext fields inside PIATunnelSharedState, a JSON file (pia_platformsdk_state.json) written atomically to the App Group container at every VPN connect: ```swift // KapePlatformSDKTunnelProfile.doSave() — runs at every connect PIATunnelSharedState.write(.init( openVPN: o…

View finding in KB

kb · F-1XFZ6TVJKDY0F9R7

}
username = dipUsername
password = dipIp
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK shared state stores VPN credentials as plaintext in App Group container JSON file

KapePlatformSDKTunnelProfile.writeSharedState() serialises both OpenVPN credentials (username, password) and the WireGuard authentication token (token) as plain JSON strings into PIATunnelSharedState.State, which is then written to pia_platformsdk_state.json in the App Group container: ```swift // KapePlatformSDKTunnelProfile+OpenVPN.swift return PIATunnelSharedState.OpenVPNSettings( caCertificate: caCertificate, username: username, // vpnTokenUsername — plaintext VPN cred…

View finding in KB

kb · F-1YVSXRM49TCCV5CS

}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — PlatformSDK VPN credentials stored as plaintext in App Group container file

KapePlatformSDKTunnelProfile.writeSharedState() writes VPN credentials — OpenVPN username and password (vpnTokenUsername / vpnTokenPassword) and WireGuard token (vpnToken) — as plain UTF-8 strings into PIATunnelSharedState.State, which is serialised as a JSON file (pia_platformsdk_state.json) in the App Group container. This file is written on every connect and every in-place server switch via switchLocation. App Group container files are included in **unencrypted local (iTunes/Fi…

View finding in KB

kb · F-2KQH614QJ8ZFT02S


/// Returns `true` if the feature flag is set.
public subscript(_ flag: FeatureFlag) -> Bool {
// TODO: [PlatformSDK] Temporary — force the PlatformSDK tunnel on regardless

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 HIGH · SECURITY_REVIEW — VPN credentials (OpenVPN password + WireGuard token) stored in plaintext App Group JSON file (PlatformSDK path)

KapePlatformSDKTunnelProfile.doSave() serializes the full OpenVPN username, OpenVPN password, and WireGuard token into PIATunnelSharedState.State, which is persisted as a JSON file (pia_platformsdk_state.json) in the App Group container directory. Unlike the legacy OpenVPN (PIATunnelProfile) and IKEv2 paths — which store only an opaque Keychain passwordReference in NEVPNProtocol and never write the credential value to a file — this new path writes the actual VPN credential strings t…

View finding in KB

kb · F-3KJB6A1X12FG7HV8

key: spm-${{ runner.os }}-${{ hashFiles('**/Package.resolved') }}
restore-keys: spm-${{ runner.os }}-

- name: Set up Ruby

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM · SECURITY_REVIEW — New PR CI workflows use external GitHub Actions pinned to mutable version tags with access to org credentials

The new ios_pull_request.yml and tvos_pull_request.yml CI workflows use three external GitHub Actions pinned to mutable version tags instead of full commit SHAs: - maxim-lobanov/setup-xcode@v1 - nick-fields/retry@v4 - ruby/setup-ruby@v1 Per the repository advisory guidance, only actions from the xvpn, xvpn-meta, and xvpn-sec GitHub organisations are permitted to use mutable labels; all other external actions remain untrusted. None of these three actions come from those organisat…

View finding in KB

kb · SEC-M-4

case invalidURL
case serverError(String)
case missingAnchorCertificate
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — PinnedCertificateDelegate does not call SecTrustSetAnchorCertificatesOnly, allowing system-trusted CAs to satisfy the WireGuard key-exchange TLS check

PIAWireguardAuthenticator.PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:) sets the PIA root CA as a trust anchor with SecTrustSetAnchorCertificates(trust, [anchorCertificate] as CFArray) but does not call SecTrustSetAnchorCertificatesOnly(trust, true). Without the exclusivity flag, Apple's trust evaluation treats the custom anchor as additive: the system's built-in CA store remains valid alongside the PIA root CA. A certificate signed by any system-trusted CA that pre…

View finding in KB

kb · F-5XSNY1058469A8SY

logger.error("Pinning failed: no server trust on key-exchange challenge")
completionHandler(.cancelAuthenticationChallenge, nil)
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — WireGuard key-exchange TLS pinning allows system-trusted CAs because SecTrustSetAnchorCertificatesOnly is not called

PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:) pins the WireGuard key-exchange TLS connection against the bundled PIA root CA by calling SecTrustSetAnchorCertificates(trust, [anchorCertificate] as CFArray). However, it does not follow this with SecTrustSetAnchorCertificatesOnly(trust, true). Without the exclusivity flag, Apple's documentation states that the system's built-in anchor certificates remain eligible for validation in addition to the explicitly supplied a…

View finding in KB

kb · F-J0TH0QY0N6TS0XSA


- name: Run iOS unit tests
uses: nick-fields/retry@v4
with:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — New PR CI workflows use external GitHub Actions pinned to mutable version tags

The newly added .github/workflows/ios_pull_request.yml and .github/workflows/tvos_pull_request.yml use external GitHub Actions pinned to mutable major-version tags rather than immutable commit SHAs: - maxim-lobanov/setup-xcode@v1 — third-party, not GitHub-maintained - nick-fields/retry@v4 — third-party, not GitHub-maintained - ruby/setup-ruby@v1 — Ruby organization - actions/cache@v4, actions/checkout@v6, actions/upload-artifact@v6 — GitHub-maintained but not SHA-pinned Per the…

View finding in KB

kb · F-WK64N10YSZ6K7VVN

authenticated.serverPublicKey = response.server_key
authenticated.clientPrivateKey = privateKeyBase64
authenticated.internalIp = response.peer_ip
authenticated.dnsServers = dnsServers

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · SECURITY_REVIEW — WireGuard key-exchange TLS pinning allows system-trusted CAs alongside pinned PIA CA

In PIAWireguardAuthenticator.PinnedCertificateDelegate.urlSession(_:didReceive:completionHandler:), the certificate pinning implementation calls SecTrustSetAnchorCertificates(trust, [anchorCertificate]) but does NOT call SecTrustSetAnchorCertificatesOnly(trust, true) afterwards. Per Apple's documentation, omitting SecTrustSetAnchorCertificatesOnly means the system also trusts all built-in system anchor certificates in addition to the explicitly set PIA CA. A certificate chain that termin…

View finding in KB

kb · SEC-L-4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants