commit e473d00f4fb366fd4a66a044a3d7156dcd4ffc9b Author: Vlad Kovtash Date: Sun Sep 13 12:15:36 2026 -0700 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52336c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,106 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon? +._* +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# IDEs / editors +.idea/ +*.iml +*.ipr +*.iws +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +*.swp +*~ +.fleet/ + +# Xcode / Swift +build/ +.build/ +DerivedData/ +*.xcuserstate +*.xcuserdata +xcuserdata/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +*.hmap +*.ipa +*.dSYM.zip +*.dSYM +timeline.xctimeline +playground.xcworkspace +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc + +# Android / Java / Kotlin +.gradle/ +local.properties +*.apk +*.ap_ +*.aab +*.dex +*.class +*.idsig +captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +!debug.keystore +*.jks + +# Python (e2e / tests) +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +env/ +.env +.env.* +!.env.example +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.egg-info/ +dist/ +*.egg + +# Reverse-engineering dumps (jadx, etc.) +jadx-*/ +**/jadx-*/ + +# Local secrets / credentials +secrets/ +*.pem +*.p12 +*.mobileprovision +credentials.json +google-services.json + +# Logs / temp +*.log +*.tmp +*.temp +.cache/ diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..794dd97 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,302 @@ +# Testing & CLI Guide + +This document covers the Portal TV / PortalCam security test suites and the +`portalkit-cli` tool used for pairing, control, and live MITM verification. + +Related protocol design lives in [`portal-capability-test/README.md`](portal-capability-test/README.md) +(Authentication and Security Architecture) and [`mac2/README.md`](mac2/README.md). + +--- + +## Overview + +| Layer | Location | How to run | Scope | +|-------|----------|------------|-------| +| Android / JVM unit & integration | `portal-capability-test/` | `./run-tests.sh` | RFC 5054 vectors, SRP-6a math, channel binding, rate limits, padding, constant-time compare | +| Swift unit tests (`PortalKit`) | `mac2/PortalKit/` | `swift test` | Same crypto surface on the Mac client library | +| Live E2E + MITM | `tests/e2e_portal_test.py` | `python3 tests/e2e_portal_test.py` | Real Portal TV + `portalkit-cli` + rogue TLS proxy | +| CLI tool | `mac2/PortalKit` → `portalkit-cli` | see [portalkit-cli](#portalkit-cli) | Pair, status, control, MITM defense probe | + +Hand-rolled SRP-6a is verified against **RFC 5054 Appendix B** test vectors on both +platforms (1024-bit group vectors from the RFC). Production pairing uses the +**RFC 5054 2048-bit** MODP group with SHA-256 hashing and TLS certificate channel +binding; those paths are covered by additional agreement / safety / MITM tests. + +--- + +## Android / JVM test suite + +### Layout + +``` +portal-capability-test/ +├── run-tests.sh +├── src/com/portaltv/capability/ +│ ├── PortalSrp.kt # server SRP-6a +│ └── PortalSrpClient.kt # client-side helpers used by tests +└── test/ + ├── android/util/Base64.java # JVM stub for android.util.Base64 + └── com/portaltv/capability/test/ + ├── TestFramework.kt + ├── TestRunner.kt + ├── PortalSrpRfc5054Test.kt + ├── PortalSrpMathTest.kt + ├── PortalSrpSafetyTest.kt + ├── PortalSrpRateLimitingTest.kt + ├── PortalSrpChannelBindingTest.kt + ├── PortalSrpPaddingTest.kt + ├── PortalSrpConstantTimeTest.kt + └── PortalSrpIntegrationTest.kt +``` + +### Run + +Requires `kotlinc` (Android Studio’s bundled Kotlin compiler is used if present) +and a JDK with `javac` / `java` on `PATH`. + +```sh +cd portal-capability-test +./run-tests.sh +``` + +Expected summary (approximate counts): + +```text +Total Suites: 8 +Total Tests: 50 +Passed: 50 +Failed: 0 +*** ALL 50 SRP-6a TESTS PASSED WITH 0 ERRORS *** +``` + +### What each suite covers + +| Suite | File | Coverage | +|-------|------|----------| +| RFC 5054 Appendix B | `PortalSrpRfc5054Test.kt` | Official vectors for $k$, $x$, $v$, $A$, $B$, $u$, and premaster secret $S$ (client/server agreement + RFC match) | +| 2048-bit math | `PortalSrpMathTest.kt` | Group parameters ($N$, $g$, $k$), $K_{\text{client}} = K_{\text{server}}$, $M_1$/$M_2$, PIN variety, session entropy | +| Safety checks | `PortalSrpSafetyTest.kt` | Reject $A \bmod N = 0$, $B \bmod N = 0$, $u = 0$, malformed hex | +| Rate limiting | `PortalSrpRateLimitingTest.kt` | 3-attempt lifecycle ($3 \to 2 \to 1 \to 0$), session wipeout, recovery after one failure, expiry | +| Channel binding | `PortalSrpChannelBindingTest.kt` | Matching `tls_hash` succeeds; rogue / bit-flipped / empty hash rejected; forged $M_2$ rejected | +| Padding | `PortalSrpPaddingTest.kt` | `toPadded256` for zero, small, exact 256-byte, sign-byte, negative, oversized values | +| Constant-time compare | `PortalSrpConstantTimeTest.kt` | Equal / unequal / length-mismatch digests and keys | +| Protocol integration | `PortalSrpIntegrationTest.kt` | Simulated init → verify → bearer flow; MITM at HTTP layer; brute-force wipeout | + +--- + +## Swift `PortalKit` unit tests + +Pairing, TLS pinning, credential storage, and the high-level client live in the +`PortalKit` Swift package. `PortalCam.app` links this package; the CLI is the +same library’s executable target. + +### Layout + +``` +mac2/PortalKit/ +├── Package.swift +├── Sources/ +│ ├── PortalKit/ # library (Crypto, TLS, Auth, Client, BigInt) +│ └── portalkit-cli/ # CLI executable +└── Tests/PortalKitTests/ + ├── Rfc5054VectorsTests.swift + ├── Srp6aExchangeTests.swift + ├── ChannelBindingTests.swift + ├── SafetyChecksTests.swift + ├── BigUIntPaddingTests.swift + ├── ClientAndAuthTests.swift + └── SmokeTests.swift +``` + +### Run + +```sh +cd mac2/PortalKit +swift test +``` + +Expected: + +```text +Executed 33 tests, with 0 failures +``` + +### What each suite covers + +| Suite | Coverage | +|-------|----------| +| `Rfc5054VectorsTests` | RFC 5054 Appendix B: $k$, $x$, $v$, $A$, $B$, $u$, client/server $S$ | +| `Srp6aExchangeTests` | 2048-bit end-to-end exchange, randomized sessions, wrong-PIN rejection | +| `ChannelBindingTests` | Matching cert hash succeeds; MITM / bit-flip / forged $M_2$ fail | +| `SafetyChecksTests` | Invalid salt, $B \bmod N = 0$, $u = 0$, empty PIN, bad $M_2$ | +| `BigUIntPaddingTests` | 256-byte padding, hex encode/decode, constant-time equality | +| `ClientAndAuthTests` | Credential storage, URL normalization, TLS challenge evaluation helpers | +| `SmokeTests` | Package / version sanity | + +--- + +## Live E2E & MITM suite + +### Prerequisites + +- Portal TV reachable at `10.0.0.10:5654` with `com.portaltv.capability` running (HTTPS). +- `adb` authorized to the device (used to read the pairing PIN from logcat). +- Built CLI binary (see [Build portalkit-cli](#build-portalkit-cli)). + +Default host and CLI path are configured at the top of `tests/e2e_portal_test.py`. + +### Run + +```sh +# from repo root +cd mac2/PortalKit && swift build -c release && cd ../.. +python3 tests/e2e_portal_test.py +``` + +Expected: + +```text +Ran 6 tests in ~13s +OK +``` + +### Scenarios + +| # | Test | Asserts | +|---|------|---------| +| A | Status verification | `portalkit-cli status` → online; leaf cert SHA-256 present | +| B | Direct channel-binding defense | `portalkit-cli test-mitm` → server rejects; verdict `PASSED` | +| C | Live MITM TLS proxy | In-process proxy on `127.0.0.1:8888` presents a rogue cert; client binds $M_1$ to rogue hash; Portal returns **HTTP 401** | +| D | Pinned-cert hard-fail (all protected endpoints) | For each of `/control/state`, `/control/mode`, `/control/fixed`, `/control/desk`, `/control/events`, `/video.h264`, `/audio.aac`: client pinned to the genuine fingerprint must **abort at TLS** against the rogue proxy with **zero HTTP bytes** observed. Soft warnings / continued requests fail the test. Same for `portalkit-cli control` verbs (`state`, `mode`, `fixed`, `desk`). | +| E | Rate limiting | Three failed verifies → attempts $2 \to 1 \to 0$; fourth sees wiped session (`no_active_pairing`) | +| F | Legitimate pair + control | Read PIN from logcat → `pair` → pin cert → `control … mode Desk` → `{"ok":true}` → `control … state` shows Desk → restore `DefaultAuto` | + +Scenario C is a real network MITM simulation (terminate TLS with a self-signed +rogue cert, forward upstream to the Portal over genuine TLS), not only an +in-memory hash swap. + +--- + +## portalkit-cli + +Command-line front end for `PortalKit`. Same pairing, pinning, and control +paths as `PortalCam.app`, without the UI or camera extension. + +### Build portalkit-cli + +```sh +cd mac2/PortalKit +swift build -c release +``` + +Binary: + +```text +mac2/PortalKit/.build/release/portalkit-cli +``` + +Optional install: + +```sh +cp .build/release/portalkit-cli /usr/local/bin/ +``` + +Credentials are stored in the macOS Keychain when available, with a file fallback +at `~/.portalkit/credentials.json` (token + pinned cert SHA-256). + +### Commands + +#### `status ` + +Probe service reachability and leaf certificate fingerprint; compare against any +stored pin. + +```sh +portalkit-cli status 10.0.0.10:5654 +``` + +#### `pair [--pin ]` + +1. Connect over ephemeral HTTPS and capture leaf cert SHA-256. +2. `POST /auth/srp/init`. +3. Compute channel-bound $M_1$ from the PIN (prompted if `--pin` omitted). +4. `POST /auth/srp/verify`; verify $M_2$. +5. Persist bearer token + pinned fingerprint. + +```sh +portalkit-cli pair 10.0.0.10:5654 --pin 123456 +``` + +Read the live PIN from the TV (debug builds log it): + +```sh +adb shell "logcat -d -s PortalService | grep 'SRP pairing started with PIN:' | tail -1" +``` + +#### `control ` + +Send camera control over **pinned** HTTPS with `Authorization: Bearer …`. +Mutations return `{"ok":true}`; `state` returns JSON camera state +`{"mode":"…","config":{…}}`. Errors are `{"error":"…","message":"…"}`. + +```sh +portalkit-cli control 10.0.0.10:5654 state +portalkit-cli control 10.0.0.10:5654 mode Desk +portalkit-cli control 10.0.0.10:5654 mode DefaultAuto +portalkit-cli control 10.0.0.10:5654 fixed?x=0.5&y=0.5&scale=1.0 +portalkit-cli control 10.0.0.10:5654 desk?tightness=0.5 +``` + +Fails **hard** (non-zero exit, no request sent) if not paired, or if the +server cert no longer matches the pin. + +#### `test-mitm ` + +Initiate pairing, compute $M_1$ with an **altered** certificate hash, submit +verify, and expect Portal rejection (`Authentication failed (wrong PIN or MITM detected)`). + +```sh +portalkit-cli test-mitm 10.0.0.10:5654 +``` + +Successful defense prints `Verdict: PASSED`. + +### Help + +```sh +portalkit-cli --help +``` + +--- + +## Suggested local workflow + +```sh +# 1. Crypto regressions (no device required) +cd portal-capability-test && ./run-tests.sh +cd ../mac2/PortalKit && swift test + +# 2. Build CLI +swift build -c release + +# 3. Deploy Portal APK (device required) +cd ../../portal-capability-test && ./deploy.sh + +# 4. Live E2E including MITM proxy (device + ADB required) +cd .. && python3 tests/e2e_portal_test.py +``` + +--- + +## Notes + +- **RFC vectors vs production group**: Appendix B uses the **1024-bit** RFC group + and the RFC’s hash construction for those vectors. Production and most unit + tests use the **2048-bit** group with SHA-256 and + $x = \mathrm{SHA256}(s \parallel \mathrm{PIN})$ (no identity string). Both are + intentional: vectors prove the modular arithmetic; 2048-bit tests prove the + deployed protocol. +- **Channel binding**: $M_1$ and $M_2$ include $\mathrm{SHA256}(\mathrm{leaf\_cert\_DER})$. + A TLS-terminating proxy that presents a different cert cannot complete pairing. +- **Rate limit**: three failed verifies per pairing session; session and PIN are + wiped at zero attempts remaining or after the 120s expiry window. diff --git a/mac/PortalCam/CamExtension/CamExtension.entitlements b/mac/PortalCam/CamExtension/CamExtension.entitlements new file mode 100644 index 0000000..6ed3df1 --- /dev/null +++ b/mac/PortalCam/CamExtension/CamExtension.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)com.kovtash.portalcam + + com.apple.security.network.client + + keychain-access-groups + + $(AppIdentifierPrefix)com.kovtash.portalcam + + + diff --git a/mac/PortalCam/CamExtension/CamExtensionProvider.swift b/mac/PortalCam/CamExtension/CamExtensionProvider.swift new file mode 100644 index 0000000..4565996 --- /dev/null +++ b/mac/PortalCam/CamExtension/CamExtensionProvider.swift @@ -0,0 +1,622 @@ +// +// CamExtensionProvider.swift +// CamExtension +// +// Created by Vlad on 9/12/26. +// + +import Foundation +import CoreMediaIO +import CoreVideo +import CoreGraphics +import CoreImage +import IOKit.audio +import IOSurface +import Security +import ObjectiveC +import os.log + +let kFrameRate: Int = 30 +let kPortalCamDeviceUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B11")! +let kPortalCamSourceStreamUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B12")! +let kPortalCamSinkStreamUUID = UUID(uuidString: "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B13")! + +let kStreamStartedNotification = "com.kovtash.portalcam.streamStarted" +let kStreamStoppedNotification = "com.kovtash.portalcam.streamStopped" + +@_silgen_name("csops") +func csops(_ pid: pid_t, _ ops: UInt32, _ useraddr: UnsafeMutableRawPointer?, _ usersize: Int) -> Int32 + +// MARK: - Device Source + +class CamExtensionDeviceSource: NSObject, CMIOExtensionDeviceSource { + + private(set) var device: CMIOExtensionDevice! + + private var _streamSource: CamExtensionStreamSource! + private var _sinkStreamSource: CamExtensionSinkStreamSource! + + private var _streamingCounter: UInt32 = 0 + private var _videoDescription: CMFormatDescription! + private var _bufferPool: CVPixelBufferPool! + + private var _placeholderPixelBuffer: CVPixelBuffer? + private var _lastSinkFrameDate: Date? + private let _lastSinkFrameLock = NSLock() + + private var _placeholderTimer: DispatchSourceTimer? + private let _timerQueue = DispatchQueue(label: "com.kovtash.portalcam.cmio", qos: .userInteractive) + private var _sentFrames = 0 + + init(localizedName: String) { + super.init() + + self.device = CMIOExtensionDevice( + localizedName: localizedName, + deviceID: kPortalCamDeviceUUID, + legacyDeviceID: nil, + source: self + ) + + let dims = CMVideoDimensions(width: 1280, height: 720) + CMVideoFormatDescriptionCreate( + allocator: kCFAllocatorDefault, + codecType: kCVPixelFormatType_32BGRA, + width: dims.width, + height: dims.height, + extensions: nil, + formatDescriptionOut: &_videoDescription + ) + + let pixelBufferAttributes: NSDictionary = [ + kCVPixelBufferWidthKey: dims.width, + kCVPixelBufferHeightKey: dims.height, + kCVPixelBufferPixelFormatTypeKey: _videoDescription.mediaSubType, + kCVPixelBufferIOSurfacePropertiesKey: [:] as NSDictionary + ] + CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, pixelBufferAttributes, &_bufferPool) + + let videoStreamFormat = CMIOExtensionStreamFormat( + formatDescription: _videoDescription, + maxFrameDuration: CMTime(value: 1, timescale: Int32(kFrameRate)), + minFrameDuration: CMTime(value: 1, timescale: Int32(kFrameRate)), + validFrameDurations: nil + ) + + _streamSource = CamExtensionStreamSource( + localizedName: "PortalCam", + streamID: kPortalCamSourceStreamUUID, + streamFormat: videoStreamFormat, + device: device + ) + + _sinkStreamSource = CamExtensionSinkStreamSource( + localizedName: "PortalCam Sink", + streamID: kPortalCamSinkStreamUUID, + streamFormat: videoStreamFormat, + device: device, + deviceSource: self + ) + + do { + try device.addStream(_streamSource.stream) + try device.addStream(_sinkStreamSource.stream) + } catch { + fatalError("Failed to add streams: \(error.localizedDescription)") + } + + createPlaceholderBuffer(width: Int(dims.width), height: Int(dims.height)) + } + + private func createPlaceholderBuffer(width: Int, height: Int) { + let attrs: [CFString: Any] = [ + kCVPixelBufferCGImageCompatibilityKey: true, + kCVPixelBufferCGBitmapContextCompatibilityKey: true, + kCVPixelBufferIOSurfacePropertiesKey: [:] as NSDictionary + ] + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + kCVPixelFormatType_32BGRA, + attrs as CFDictionary, + &_placeholderPixelBuffer + ) + guard status == kCVReturnSuccess, let buffer = _placeholderPixelBuffer else { return } + + CVPixelBufferLockBaseAddress(buffer, []) + defer { CVPixelBufferUnlockBaseAddress(buffer, []) } + guard let base = CVPixelBufferGetBaseAddress(buffer) else { return } + let bytesPerRow = CVPixelBufferGetBytesPerRow(buffer) + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo.byteOrder32Little.rawValue | CGImageAlphaInfo.premultipliedFirst.rawValue + guard let ctx = CGContext( + data: base, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: bitmapInfo + ) else { return } + + // Fill dark slate background + ctx.setFillColor(red: 0.10, green: 0.12, blue: 0.16, alpha: 1.0) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + + // Rounded border + let insetRect = CGRect(x: 40, y: 40, width: width - 80, height: height - 80) + let path = CGPath(roundedRect: insetRect, cornerWidth: 20, cornerHeight: 20, transform: nil) + ctx.addPath(path) + ctx.setStrokeColor(red: 0.28, green: 0.45, blue: 0.75, alpha: 0.8) + ctx.setLineWidth(4) + ctx.strokePath() + } + + var availableProperties: Set { + return [.deviceTransportType, .deviceModel] + } + + func deviceProperties(forProperties properties: Set) throws -> CMIOExtensionDeviceProperties { + let deviceProperties = CMIOExtensionDeviceProperties(dictionary: [:]) + if properties.contains(.deviceTransportType) { + deviceProperties.transportType = kIOAudioDeviceTransportTypeVirtual + } + if properties.contains(.deviceModel) { + deviceProperties.model = "PortalCam Camera" + } + return deviceProperties + } + + func setDeviceProperties(_ deviceProperties: CMIOExtensionDeviceProperties) throws { + // Settable properties + } + + func startStreaming() { + _streamingCounter += 1 + os_log(.default, "PortalCam extension: startStreaming (counter=%{public}u)", _streamingCounter) + + if _streamingCounter == 1 { + // Notify main app that a client has opened the camera + if let center = CFNotificationCenterGetDarwinNotifyCenter() { + CFNotificationCenterPostNotification( + center, + CFNotificationName(kStreamStartedNotification as CFString), + nil, + nil, + true + ) + } + + // Schedule fallback/standby timer + _placeholderTimer = DispatchSource.makeTimerSource(queue: _timerQueue) + _placeholderTimer?.schedule(deadline: .now(), repeating: 1.0 / Double(kFrameRate), leeway: .milliseconds(2)) + _placeholderTimer?.setEventHandler { [weak self] in + self?.sendPlaceholderIfNeeded() + } + _placeholderTimer?.resume() + } + } + + func stopStreaming() { + if _streamingCounter > 1 { + _streamingCounter -= 1 + } else { + _streamingCounter = 0 + os_log(.default, "PortalCam extension: stopStreaming - posting stop notification") + + _placeholderTimer?.cancel() + _placeholderTimer = nil + + _lastSinkFrameLock.lock() + _lastSinkFrameDate = nil + _lastSinkFrameLock.unlock() + + if let center = CFNotificationCenterGetDarwinNotifyCenter() { + CFNotificationCenterPostNotification( + center, + CFNotificationName(kStreamStoppedNotification as CFString), + nil, + nil, + true + ) + } + } + } + + private func sendPlaceholderIfNeeded() { + guard _streamingCounter > 0 else { return } + + _lastSinkFrameLock.lock() + let lastDate = _lastSinkFrameDate + _lastSinkFrameLock.unlock() + + // If live sink frames are actively arriving within the last 500ms, don't send placeholder + if let lastDate = lastDate, Date().timeIntervalSince(lastDate) < 0.5 { + return + } + + guard let placeholder = _placeholderPixelBuffer else { return } + + let hostTime = CMClockGetTime(CMClockGetHostTimeClock()) + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: Int32(kFrameRate)), + presentationTimeStamp: hostTime, + decodeTimeStamp: .invalid + ) + + var sample: CMSampleBuffer? + let status = CMSampleBufferCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: placeholder, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: _videoDescription, + sampleTiming: &timing, + sampleBufferOut: &sample + ) + + if status == noErr, let sample = sample { + let nano = UInt64(hostTime.seconds * Double(NSEC_PER_SEC)) + _streamSource.stream.send(sample, discontinuity: [], hostTimeInNanoseconds: nano) + } + } + + func deliverSinkSampleBuffer(_ sampleBuffer: CMSampleBuffer) { + _lastSinkFrameLock.lock() + _lastSinkFrameDate = Date() + _lastSinkFrameLock.unlock() + + guard _streamingCounter > 0 else { return } + + let hostTime = CMClockGetTime(CMClockGetHostTimeClock()) + let nano = UInt64(hostTime.seconds * Double(NSEC_PER_SEC)) + + _sentFrames += 1 + if _sentFrames <= 3 { + os_log(.default, "PortalCam extension: forwarded sink frame %{public}d to source stream", self._sentFrames) + } + + _streamSource.stream.send(sampleBuffer, discontinuity: [], hostTimeInNanoseconds: nano) + } +} + +// MARK: - Source Stream (FaceTime, Zoom, Photo Booth) + +class CamExtensionStreamSource: NSObject, CMIOExtensionStreamSource { + + private(set) var stream: CMIOExtensionStream! + let device: CMIOExtensionDevice + private let _streamFormat: CMIOExtensionStreamFormat + + init(localizedName: String, streamID: UUID, streamFormat: CMIOExtensionStreamFormat, device: CMIOExtensionDevice) { + self.device = device + self._streamFormat = streamFormat + super.init() + self.stream = CMIOExtensionStream( + localizedName: localizedName, + streamID: streamID, + direction: .source, + clockType: .hostTime, + source: self + ) + } + + var formats: [CMIOExtensionStreamFormat] { + return [_streamFormat] + } + + var activeFormatIndex: Int = 0 { + didSet { + if activeFormatIndex >= 1 { + os_log(.error, "Invalid index: %{public}d", activeFormatIndex) + } + } + } + + var availableProperties: Set { + return [.streamActiveFormatIndex, .streamFrameDuration] + } + + func streamProperties(forProperties properties: Set) throws -> CMIOExtensionStreamProperties { + let streamProperties = CMIOExtensionStreamProperties(dictionary: [:]) + if properties.contains(.streamActiveFormatIndex) { + streamProperties.activeFormatIndex = 0 + } + if properties.contains(.streamFrameDuration) { + streamProperties.frameDuration = CMTime(value: 1, timescale: Int32(kFrameRate)) + } + return streamProperties + } + + func setStreamProperties(_ streamProperties: CMIOExtensionStreamProperties) throws { + if let activeFormatIndex = streamProperties.activeFormatIndex { + self.activeFormatIndex = activeFormatIndex + } + } + + func authorizedToStartStream(for client: CMIOExtensionClient) -> Bool { + return true + } + + func startStream() throws { + guard let deviceSource = device.source as? CamExtensionDeviceSource else { + fatalError("Unexpected source type \(String(describing: device.source))") + } + deviceSource.startStreaming() + } + + func stopStream() throws { + guard let deviceSource = device.source as? CamExtensionDeviceSource else { + fatalError("Unexpected source type \(String(describing: device.source))") + } + deviceSource.stopStreaming() + } +} + +// MARK: - Sink Stream (fed by PortalCam.app) + +class CamExtensionSinkStreamSource: NSObject, CMIOExtensionStreamSource { + + private(set) var stream: CMIOExtensionStream! + let device: CMIOExtensionDevice + weak var deviceSource: CamExtensionDeviceSource? + private let _streamFormat: CMIOExtensionStreamFormat + + private let _consumeQueue = DispatchQueue(label: "com.kovtash.portalcam.sink.consume", qos: .userInteractive) + private var _isStreaming: Bool = false + private var _activeClient: CMIOExtensionClient? + + init( + localizedName: String, + streamID: UUID, + streamFormat: CMIOExtensionStreamFormat, + device: CMIOExtensionDevice, + deviceSource: CamExtensionDeviceSource + ) { + self.device = device + self.deviceSource = deviceSource + self._streamFormat = streamFormat + super.init() + self.stream = CMIOExtensionStream( + localizedName: localizedName, + streamID: streamID, + direction: .sink, + clockType: .hostTime, + source: self + ) + } + + var formats: [CMIOExtensionStreamFormat] { + return [_streamFormat] + } + + var activeFormatIndex: Int = 0 { + didSet { + if activeFormatIndex >= 1 { + os_log(.error, "Invalid sink format index: %{public}d", activeFormatIndex) + } + } + } + + var availableProperties: Set { + return [ + .streamActiveFormatIndex, + .streamFrameDuration, + .streamSinkBufferQueueSize, + .streamSinkBuffersRequiredForStartup + ] + } + + func streamProperties(forProperties properties: Set) throws -> CMIOExtensionStreamProperties { + let streamProperties = CMIOExtensionStreamProperties(dictionary: [:]) + if properties.contains(.streamActiveFormatIndex) { + streamProperties.activeFormatIndex = 0 + } + if properties.contains(.streamFrameDuration) { + streamProperties.frameDuration = CMTime(value: 1, timescale: Int32(kFrameRate)) + } + if properties.contains(.streamSinkBufferQueueSize) { + streamProperties.sinkBufferQueueSize = 8 + } + if properties.contains(.streamSinkBuffersRequiredForStartup) { + streamProperties.sinkBuffersRequiredForStartup = 1 + } + return streamProperties + } + + func setStreamProperties(_ streamProperties: CMIOExtensionStreamProperties) throws { + if let activeFormatIndex = streamProperties.activeFormatIndex { + self.activeFormatIndex = activeFormatIndex + } + } + + func authorizedToStartStream(for client: CMIOExtensionClient) -> Bool { + guard isValidPortalCamClient(client: client) else { + os_log(.error, "PortalCam extension: rejected unauthorized sink client PID %d, signingID %{public}@", + client.pid, client.signingID ?? "unknown") + return false + } + _activeClient = client + os_log(.default, "PortalCam extension: authorized verified sink client PID %d, signingID %{public}@", + client.pid, client.signingID ?? "unknown") + return true + } + + private func isValidPortalCamClient(client: CMIOExtensionClient) -> Bool { + let pid = client.pid + + // 1. Check code signing status via kernel csops + // CS_OPS_STATUS = 0, CS_VALID = 0x00000001 + var status: UInt32 = 0 + let statusRet = csops(pid, 0, &status, MemoryLayout.size) + guard statusRet == 0 else { + os_log(.error, "PortalCam extension: csops CS_OPS_STATUS failed for PID %d: errno %d", pid, errno) + return false + } + + guard (status & 0x00000001) != 0 else { + os_log(.error, "PortalCam extension: process %d code signature is not valid (status 0x%{public}x)", pid, status) + return false + } + + // 2. Check code signing identity (Bundle Identifier) + struct CSHeader { + var magic: UInt32 = 0 + var length: UInt32 = 0 + } + + let CS_OPS_IDENTITY: UInt32 = 11 + var idHeader = CSHeader() + let idHeaderRet = csops(pid, CS_OPS_IDENTITY, &idHeader, MemoryLayout.size) + guard idHeaderRet == 0 || errno == ERANGE else { + os_log(.error, "PortalCam extension: csops CS_OPS_IDENTITY header failed for PID %d: errno %d", pid, errno) + return false + } + let idLen = Int(UInt32(bigEndian: idHeader.length)) + guard idLen >= MemoryLayout.size else { + os_log(.error, "PortalCam extension: invalid identity length %d for PID %d", idLen, pid) + return false + } + var idBuf = [UInt8](repeating: 0, count: idLen) + guard csops(pid, CS_OPS_IDENTITY, &idBuf, idLen) == 0 else { + os_log(.error, "PortalCam extension: csops CS_OPS_IDENTITY failed for PID %d", pid) + return false + } + let identity = String(cString: Array(idBuf[MemoryLayout.size...])) + + // 3. Check Team Identifier + let CS_OPS_TEAMID: UInt32 = 14 + var teamHeader = CSHeader() + let teamHeaderRet = csops(pid, CS_OPS_TEAMID, &teamHeader, MemoryLayout.size) + guard teamHeaderRet == 0 || errno == ERANGE else { + os_log(.error, "PortalCam extension: csops CS_OPS_TEAMID header failed for PID %d: errno %d", pid, errno) + return false + } + let teamLen = Int(UInt32(bigEndian: teamHeader.length)) + guard teamLen >= MemoryLayout.size else { + os_log(.error, "PortalCam extension: invalid team length %d for PID %d", teamLen, pid) + return false + } + var teamBuf = [UInt8](repeating: 0, count: teamLen) + guard csops(pid, CS_OPS_TEAMID, &teamBuf, teamLen) == 0 else { + os_log(.error, "PortalCam extension: csops CS_OPS_TEAMID failed for PID %d", pid) + return false + } + let teamID = String(cString: Array(teamBuf[MemoryLayout.size...])) + + os_log(.default, "PortalCam extension: verified client PID %d: identity='%{public}@', teamID='%{public}@', flags=0x%{public}x", + pid, identity, teamID, status) + + guard identity == "com.kovtash.portalcam", teamID == "ENT9X9U544" else { + os_log(.error, "PortalCam extension: rejected client PID %d: identity mismatch ('%{public}@' != 'com.kovtash.portalcam') or team mismatch ('%{public}@' != 'ENT9X9U544')", + pid, identity, teamID) + return false + } + + return true + } + + func startStream() throws { + os_log(.default, "PortalCam extension: sink startStream called") + _isStreaming = true + _consumeQueue.async { [weak self] in + self?.consumeNext() + } + } + + func stopStream() throws { + os_log(.default, "PortalCam extension: sink stopStream called") + _isStreaming = false + _activeClient = nil + } + + private func consumeNext() { + guard _isStreaming, let stream = self.stream else { return } + + guard let client = _activeClient ?? stream.streamingClients.first else { + // No client connected yet; wait briefly and check again + _consumeQueue.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in + self?.consumeNext() + } + return + } + + stream.consumeSampleBuffer(from: client) { [weak self] sampleBuffer, sequenceNumber, discontinuity, hasMore, error in + guard let self = self, self._isStreaming else { return } + + if let error = error { + os_log(.default, "PortalCam extension: sink consume error: %{public}@", error.localizedDescription) + self._consumeQueue.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in + self?.consumeNext() + } + return + } + + if let sampleBuffer = sampleBuffer { + self.deviceSource?.deliverSinkSampleBuffer(sampleBuffer) + + let pts = sampleBuffer.presentationTimeStamp + let nano = pts.isValid && pts.seconds > 0 ? UInt64(pts.seconds * Double(NSEC_PER_SEC)) : mach_absolute_time() + let output = CMIOExtensionScheduledOutput(sequenceNumber: sequenceNumber, hostTimeInNanoseconds: nano) + stream.notifyScheduledOutputChanged(output) + + // Pull next buffer immediately + self._consumeQueue.async { [weak self] in + self?.consumeNext() + } + } else { + // Queue is empty, wait half a frame duration (~16ms) + self._consumeQueue.asyncAfter(deadline: .now() + .milliseconds(16)) { [weak self] in + self?.consumeNext() + } + } + } + } +} + +// MARK: - Provider Source + +class CamExtensionProviderSource: NSObject, CMIOExtensionProviderSource { + + private(set) var provider: CMIOExtensionProvider! + private var deviceSource: CamExtensionDeviceSource! + + init(clientQueue: DispatchQueue?) { + super.init() + + provider = CMIOExtensionProvider(source: self, clientQueue: clientQueue) + deviceSource = CamExtensionDeviceSource(localizedName: "PortalCam") + + do { + try provider.addDevice(deviceSource.device) + } catch { + fatalError("Failed to add device: \(error.localizedDescription)") + } + } + + func connect(to client: CMIOExtensionClient) throws { + // Client connected + } + + func disconnect(from client: CMIOExtensionClient) { + // Client disconnected + } + + var availableProperties: Set { + return [.providerManufacturer] + } + + func providerProperties(forProperties properties: Set) throws -> CMIOExtensionProviderProperties { + let providerProperties = CMIOExtensionProviderProperties(dictionary: [:]) + if properties.contains(.providerManufacturer) { + providerProperties.manufacturer = "PortalCam" + } + return providerProperties + } + + func setProviderProperties(_ providerProperties: CMIOExtensionProviderProperties) throws { + // Settable properties + } +} diff --git a/mac/PortalCam/CamExtension/Info.plist b/mac/PortalCam/CamExtension/Info.plist new file mode 100644 index 0000000..164e571 --- /dev/null +++ b/mac/PortalCam/CamExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + CMIOExtension + + CMIOExtensionMachServiceName + $(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + + diff --git a/mac/PortalCam/CamExtension/NativePortalStream.swift b/mac/PortalCam/CamExtension/NativePortalStream.swift new file mode 100644 index 0000000..a5655ca --- /dev/null +++ b/mac/PortalCam/CamExtension/NativePortalStream.swift @@ -0,0 +1,225 @@ +import Foundation +import VideoToolbox +import CoreImage +import IOSurface +import os.log + +final class NativePortalStream: NSObject, URLSessionDataDelegate { + private var session: URLSession! + private var task: URLSessionDataTask? + private var pending = Data() + private let decoder = H264Decoder() + private var dataCallbacks = 0 + var onFrame: ((CVPixelBuffer) -> Void)? + var onStatus: ((String) -> Void)? + + func start(host: String, token: String? = PortalAuth.token) { + stop() + dataCallbacks = 0 + decoder.onFrame = { [weak self] b in self?.onFrame?(b) } + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude + cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60 + session = URLSession(configuration: cfg, delegate: self, delegateQueue: OperationQueue()) + guard let u = URL(string: "https://\(host)/video.h264") else { + onStatus?("Invalid Portal address") + return + } + onStatus?("Connecting native H.264 (HTTPS)…") + var req = URLRequest(url: u) + if let token { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + task = session.dataTask(with: req) + task?.resume() + } + + func stop() { + task?.cancel() + task = nil + session?.invalidateAndCancel() + session = nil + pending.removeAll() + decoder.stop() + } + + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + PortalTlsPinning.evaluate(challenge: challenge, completionHandler: completionHandler) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + os_log(.default, "PortalCam stream: response %{public}@", String(describing: response)) + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + dataCallbacks += 1 + if dataCallbacks <= 3 { + os_log(.default, "PortalCam stream: received %{public}d bytes", data.count) + } + pending.append(data) + consume() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error { + onStatus?("Stream stopped: \(error.localizedDescription)") + } else { + onStatus?("Stream completed") + } + } + + private func consume() { + while let first = startCode(in: pending, from: 0) { + guard let next = startCode(in: pending, from: first.0 + first.1) else { break } + let sc = next.0 + let nal = pending[(first.0 + first.1).. 2 * 1024 * 1024 { + pending = pending.suffix(256 * 1024) + } + } + + private func startCode(in d: Data, from: Int) -> (Int, Int)? { + if d.count < 4 || from >= d.count { return nil } + for i in from..<(d.count - 2) { + if d[i] == 0 && d[i+1] == 0 && d[i+2] == 1 { return (i, 3) } + if i + 3 < d.count && d[i] == 0 && d[i+1] == 0 && d[i+2] == 0 && d[i+3] == 1 { return (i, 4) } + } + return nil + } +} + +private final class H264Decoder { + private var sps = Data(), pps = Data(), session: VTDecompressionSession?, format: CMVideoFormatDescription? + var onFrame: ((CVPixelBuffer) -> Void)? + private var decodedFrames = 0 + + func stop() { + if let session { VTDecompressionSessionInvalidate(session) } + session = nil + format = nil + } + + func decode(nal: Data) { + guard let type = nal.first.map({ $0 & 0x1f }) else { return } + if type == 7 { + os_log(.default, "PortalCam decoder: SPS %{public}d bytes", nal.count) + sps = nal + rebuild() + return + } + if type == 8 { + os_log(.default, "PortalCam decoder: PPS %{public}d bytes", nal.count) + pps = nal + rebuild() + return + } + guard (type == 1 || type == 5), let format else { return } + var size = UInt32(nal.count).bigEndian + var avcc = Data(bytes: &size, count: 4) + avcc.append(nal) + var block: CMBlockBuffer? + avcc.withUnsafeBytes { raw in + _ = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, + memoryBlock: UnsafeMutableRawPointer(mutating: raw.baseAddress!), + blockLength: raw.count, + blockAllocator: kCFAllocatorNull, + customBlockSource: nil, + offsetToData: 0, + dataLength: raw.count, + flags: 0, + blockBufferOut: &block + ) + } + guard let block else { return } + var sample: CMSampleBuffer? + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()), + decodeTimeStamp: .invalid + ) + var sizes = [avcc.count] + _ = CMSampleBufferCreateReady( + allocator: kCFAllocatorDefault, + dataBuffer: block, + formatDescription: format, + sampleCount: 1, + sampleTimingEntryCount: 1, + sampleTimingArray: &timing, + sampleSizeEntryCount: 1, + sampleSizeArray: &sizes, + sampleBufferOut: &sample + ) + if let sample { + let rc = VTDecompressionSessionDecodeFrame( + session!, + sampleBuffer: sample, + flags: [], + frameRefcon: Unmanaged.passUnretained(self).toOpaque(), + infoFlagsOut: nil + ) + if rc != noErr { + os_log(.error, "PortalCam decoder: decode returned %{public}d", rc) + } + } + } + + private func rebuild() { + guard !sps.isEmpty, !pps.isEmpty else { return } + stop() + var f: CMVideoFormatDescription? + let rc: OSStatus = sps.withUnsafeBytes { sr in + pps.withUnsafeBytes { pr in + var ps = [sr.bindMemory(to: UInt8.self).baseAddress!, pr.bindMemory(to: UInt8.self).baseAddress!] + var lens = [sps.count, pps.count] + return CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: kCFAllocatorDefault, + parameterSetCount: 2, + parameterSetPointers: &ps, + parameterSetSizes: &lens, + nalUnitHeaderLength: 4, + formatDescriptionOut: &f + ) + } + } + guard rc == noErr, let f else { + os_log(.error, "PortalCam decoder: format creation failed %{public}d", rc) + return + } + os_log(.default, "PortalCam decoder: format ready") + format = f + let callback: VTDecompressionOutputCallback = { refCon, _, status, _, image, _, _ in + if status == noErr, let image, let refCon { + let decoder = Unmanaged.fromOpaque(refCon).takeUnretainedValue() + decoder.decodedFrames += 1 + if decoder.decodedFrames <= 3 { + os_log(.default, "PortalCam decoder: frame decoded (#%{public}d)", decoder.decodedFrames) + } + decoder.onFrame?(image) + } + } + var cb = VTDecompressionOutputCallbackRecord( + decompressionOutputCallback: callback, + decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque() + ) + var attrs: CFDictionary = [ + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, + kCVPixelBufferIOSurfacePropertiesKey: [:] + ] as CFDictionary + var ds: VTDecompressionSession? + let rc2 = VTDecompressionSessionCreate( + allocator: kCFAllocatorDefault, + formatDescription: f, + decoderSpecification: nil, + imageBufferAttributes: attrs, + outputCallback: &cb, + decompressionSessionOut: &ds + ) + os_log(.default, "PortalCam decoder: session create %{public}d", rc2) + session = ds + } +} diff --git a/mac/PortalCam/CamExtension/PortalAuth.swift b/mac/PortalCam/CamExtension/PortalAuth.swift new file mode 100644 index 0000000..c23c34a --- /dev/null +++ b/mac/PortalCam/CamExtension/PortalAuth.swift @@ -0,0 +1,83 @@ +import Foundation +import Security +import os.log + +/// Authentication shared with the containing app through the Keychain access group. +/// The extension accesses the bearer token and pinned certificate fingerprint stored by the app. +enum PortalAuth { + static let suite = "ENT9X9U544.com.kovtash.portalcam" + static let accessGroup = "ENT9X9U544.com.kovtash.portalcam" + static let service = "com.kovtash.portalcam.auth" + static let tokenKey = "portalAuthToken" + static let certKey = "portalPinnedCertSha256" + + static var token: String? { + return readItem(account: tokenKey) + } + + static var pinnedCertSha256: String? { + return readItem(account: certKey) + } + + private static func readItem(account: String) -> String? { + // 1. Try modern Data Protection Keychain with explicit access group + let dpQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseDataProtectionKeychain as String: true + ] + var item: CFTypeRef? + let dpStatus = SecItemCopyMatching(dpQuery as CFDictionary, &item) + if dpStatus == errSecSuccess, let data = item as? Data, + let value = String(data: data, encoding: .utf8) { + os_log(.default, "PortalCam extension: %{public}@ found in DP Keychain with access group", account) + return value + } + + // 2. Try Data Protection Keychain without explicit access group (matches all groups in entitlement) + let defaultGroupQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseDataProtectionKeychain as String: true + ] + var defaultItem: CFTypeRef? + let defaultStatus = SecItemCopyMatching(defaultGroupQuery as CFDictionary, &defaultItem) + if defaultStatus == errSecSuccess, let data = defaultItem as? Data, + let value = String(data: data, encoding: .utf8) { + os_log(.default, "PortalCam extension: %{public}@ found in DP Keychain default group", account) + return value + } + + // 3. Try legacy file-based keychain + let legacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var legacyItem: CFTypeRef? + let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &legacyItem) + if legacyStatus == errSecSuccess, let data = legacyItem as? Data, + let value = String(data: data, encoding: .utf8) { + os_log(.default, "PortalCam extension: %{public}@ found in Legacy Keychain", account) + return value + } + + // 4. Compatibility fallback: App Group UserDefaults + if let value = UserDefaults(suiteName: suite)?.string(forKey: account) { + os_log(.default, "PortalCam extension: %{public}@ found in App Group fallback", account) + return value + } + + os_log(.default, "PortalCam extension: %{public}@ unavailable (DP status: %{public}d, default status: %{public}d, legacy status: %{public}d)", account, dpStatus, defaultStatus, legacyStatus) + return nil + } +} diff --git a/mac/PortalCam/CamExtension/PortalTlsPinning.swift b/mac/PortalCam/CamExtension/PortalTlsPinning.swift new file mode 100644 index 0000000..e0193d8 --- /dev/null +++ b/mac/PortalCam/CamExtension/PortalTlsPinning.swift @@ -0,0 +1,67 @@ +// +// PortalTlsPinning.swift +// CamExtension +// +// TLS Certificate Pinning for Camera Extension. +// + +import Foundation +import Security +import CryptoKit +import os.log + +public enum PortalTlsPinning { + public static func extractLeafCert(from serverTrust: SecTrust) -> SecCertificate? { + if #available(macOS 12.0, *) { + if let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], !chain.isEmpty { + return chain[0] + } + } + let count = SecTrustGetCertificateCount(serverTrust) + guard count > 0 else { return nil } + return SecTrustGetCertificateAtIndex(serverTrust, 0) + } + + public static func computeCertSha256(cert: SecCertificate) -> (data: Data, hex: String) { + let certDer = SecCertificateCopyData(cert) as Data + let digest = SHA256.hash(data: certDer) + let data = Data(digest) + let hex = data.map { String(format: "%02x", $0) }.joined() + return (data, hex) + } + + public static func evaluate( + challenge: URLAuthenticationChallenge, + pinnedFingerprint: String? = nil, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + guard let cert = extractLeafCert(from: serverTrust) else { + os_log(.error, "PortalCam Extension TLS: No certificate found in server trust chain") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let (_, certHashHex) = computeCertSha256(cert: cert) + let targetFingerprint = pinnedFingerprint ?? PortalAuth.pinnedCertSha256 + + guard let pinned = targetFingerprint, !pinned.isEmpty else { + os_log(.error, "PortalCam Extension TLS: No pinned certificate configured; rejecting connection") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + guard certHashHex.caseInsensitiveCompare(pinned) == .orderedSame else { + os_log(.error, "PortalCam Extension TLS Pinning Mismatch! Expected: %{public}@, Got: %{public}@", pinned, certHashHex) + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } +} diff --git a/mac/PortalCam/CamExtension/main.swift b/mac/PortalCam/CamExtension/main.swift new file mode 100644 index 0000000..70e6fa8 --- /dev/null +++ b/mac/PortalCam/CamExtension/main.swift @@ -0,0 +1,14 @@ +// +// main.swift +// CamExtension +// +// Created by Vlad on 9/12/26. +// + +import Foundation +import CoreMediaIO + +let providerSource = CamExtensionProviderSource(clientQueue: nil) +CMIOExtensionProvider.startService(provider: providerSource.provider) + +CFRunLoopRun() diff --git a/mac/PortalCam/PortalCam.xcodeproj/project.pbxproj b/mac/PortalCam/PortalCam.xcodeproj/project.pbxproj new file mode 100644 index 0000000..add2c0c --- /dev/null +++ b/mac/PortalCam/PortalCam.xcodeproj/project.pbxproj @@ -0,0 +1,546 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2839601E305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension in Embed System Extensions */ = {isa = PBXBuildFile; fileRef = 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 283960323055440000E4C494 /* PortalKit in Frameworks */ = {isa = PBXBuildFile; productRef = 283960313055440000E4C494 /* PortalKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 2839601C305543F400E4C494 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 28395FFA305543C000E4C494 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 28396013305543F400E4C494; + remoteInfo = CamExtension; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 28396023305543F400E4C494 /* Embed System Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(SYSTEM_EXTENSIONS_FOLDER_PATH)"; + dstSubfolderSpec = 16; + files = ( + 2839601E305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension in Embed System Extensions */, + ); + name = "Embed System Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 28396002305543C000E4C494 /* PortalCam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PortalCam.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "com.kovtash.portalcam.camera-extension.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 2839601F305543F400E4C494 /* Exceptions for "CamExtension" folder in "CamExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 28396013305543F400E4C494 /* CamExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 28396004305543C000E4C494 /* PortalCam */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = PortalCam; + sourceTree = ""; + }; + 28396015305543F400E4C494 /* CamExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 2839601F305543F400E4C494 /* Exceptions for "CamExtension" folder in "CamExtension" target */, + ); + path = CamExtension; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 28395FFF305543C000E4C494 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 283960323055440000E4C494 /* PortalKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 28396011305543F400E4C494 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 28395FF9305543C000E4C494 = { + isa = PBXGroup; + children = ( + 28396004305543C000E4C494 /* PortalCam */, + 28396015305543F400E4C494 /* CamExtension */, + 28396003305543C000E4C494 /* Products */, + ); + sourceTree = ""; + }; + 28396003305543C000E4C494 /* Products */ = { + isa = PBXGroup; + children = ( + 28396002305543C000E4C494 /* PortalCam.app */, + 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 28396001305543C000E4C494 /* PortalCam */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2839600D305543C100E4C494 /* Build configuration list for PBXNativeTarget "PortalCam" */; + buildPhases = ( + 28395FFE305543C000E4C494 /* Sources */, + 28395FFF305543C000E4C494 /* Frameworks */, + 28396000305543C000E4C494 /* Resources */, + 28396023305543F400E4C494 /* Embed System Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 2839601D305543F400E4C494 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 28396004305543C000E4C494 /* PortalCam */, + ); + name = PortalCam; + packageProductDependencies = ( + 283960313055440000E4C494 /* PortalKit */, + ); + productName = PortalCam; + productReference = 28396002305543C000E4C494 /* PortalCam.app */; + productType = "com.apple.product-type.application"; + }; + 28396013305543F400E4C494 /* CamExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 28396020305543F400E4C494 /* Build configuration list for PBXNativeTarget "CamExtension" */; + buildPhases = ( + 28396010305543F400E4C494 /* Sources */, + 28396011305543F400E4C494 /* Frameworks */, + 28396012305543F400E4C494 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 28396015305543F400E4C494 /* CamExtension */, + ); + name = CamExtension; + packageProductDependencies = ( + ); + productName = CamExtension; + productReference = 28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */; + productType = "com.apple.product-type.system-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 28395FFA305543C000E4C494 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2660; + LastUpgradeCheck = 2660; + TargetAttributes = { + 28396001305543C000E4C494 = { + CreatedOnToolsVersion = 26.6; + }; + 28396013305543F400E4C494 = { + CreatedOnToolsVersion = 26.6; + }; + }; + }; + buildConfigurationList = 28395FFD305543C000E4C494 /* Build configuration list for PBXProject "PortalCam" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 28395FF9305543C000E4C494; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 28396003305543C000E4C494 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 28396001305543C000E4C494 /* PortalCam */, + 28396013305543F400E4C494 /* CamExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 28396000305543C000E4C494 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 28396012305543F400E4C494 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 28395FFE305543C000E4C494 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 28396010305543F400E4C494 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2839601D305543F400E4C494 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 28396013305543F400E4C494 /* CamExtension */; + targetProxy = 2839601C305543F400E4C494 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 2839600B305543C100E4C494 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 2839600C305543C100E4C494 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 2839600E305543C100E4C494 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PortalCam/PortalCam.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "PortalCam requires camera access to stream to virtual camera devices."; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PortalCam connects to your Portal TV on the local network for video, audio, and camera control."; + INFOPLIST_KEY_NSBonjourServices = "_portalcam._tcp"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.kovtash.portalcam; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 2839600F305543C100E4C494 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = PortalCam/PortalCam.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "PortalCam requires camera access to stream to virtual camera devices."; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "PortalCam connects to your Portal TV on the local network for video, audio, and camera control."; + INFOPLIST_KEY_NSBonjourServices = "_portalcam._tcp"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.kovtash.portalcam; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 28396021305543F400E4C494 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = CamExtension/CamExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CamExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CamExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSystemExtensionUsageDescription = SYSTEM_EXTENSION_USAGE_DESCRIPTION; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.kovtash.portalcam.camera-extension"; + PRODUCT_NAME = "$(inherited)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 28396022305543F400E4C494 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = CamExtension/CamExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 24; + DEVELOPMENT_TEAM = ENT9X9U544; + ENABLE_APP_SANDBOX = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CamExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CamExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSSystemExtensionUsageDescription = SYSTEM_EXTENSION_USAGE_DESCRIPTION; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.kovtash.portalcam.camera-extension"; + PRODUCT_NAME = "$(inherited)"; + REGISTER_APP_GROUPS = YES; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 28395FFD305543C000E4C494 /* Build configuration list for PBXProject "PortalCam" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2839600B305543C100E4C494 /* Debug */, + 2839600C305543C100E4C494 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 2839600D305543C100E4C494 /* Build configuration list for PBXNativeTarget "PortalCam" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2839600E305543C100E4C494 /* Debug */, + 2839600F305543C100E4C494 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 28396020305543F400E4C494 /* Build configuration list for PBXNativeTarget "CamExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 28396021305543F400E4C494 /* Debug */, + 28396022305543F400E4C494 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../PortalKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 283960313055440000E4C494 /* PortalKit */ = { + isa = XCSwiftPackageProductDependency; + package = 283960303055440000E4C494 /* XCLocalSwiftPackageReference "../PortalKit" */; + productName = PortalKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 28395FFA305543C000E4C494 /* Project object */; +} diff --git a/mac/PortalCam/PortalCam.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mac/PortalCam/PortalCam.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/mac/PortalCam/PortalCam.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mac/PortalCam/PortalCam/Assets.xcassets/AccentColor.colorset/Contents.json b/mac/PortalCam/PortalCam/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/mac/PortalCam/PortalCam/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/PortalCam/PortalCam/Assets.xcassets/AppIcon.appiconset/Contents.json b/mac/PortalCam/PortalCam/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/mac/PortalCam/PortalCam/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/PortalCam/PortalCam/Assets.xcassets/Contents.json b/mac/PortalCam/PortalCam/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mac/PortalCam/PortalCam/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mac/PortalCam/PortalCam/CamExtension.entitlements b/mac/PortalCam/PortalCam/CamExtension.entitlements new file mode 100644 index 0000000..9245675 --- /dev/null +++ b/mac/PortalCam/PortalCam/CamExtension.entitlements @@ -0,0 +1,16 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.kovtash.portalcam + + com.apple.developer.system-extension.install + + com.apple.developer.system-extension.types + + com.apple.system_extension.camera-device + + + diff --git a/mac/PortalCam/PortalCam/ContentView.swift b/mac/PortalCam/PortalCam/ContentView.swift new file mode 100644 index 0000000..7faa4f2 --- /dev/null +++ b/mac/PortalCam/PortalCam/ContentView.swift @@ -0,0 +1,804 @@ +// +// ContentView.swift +// PortalCam +// +// Menubar popup: address → PIN pairing → live preview + mode controls. +// + +import SwiftUI +import AppKit +import Combine +import PortalKit + +enum PortalUIPhase: Equatable { + case setup + case enterPin + case main +} + +struct ContentView: View { + @EnvironmentObject private var model: PortalReceiverModel + + /// Content width of the menubar popup (preview spans this edge-to-edge inside padding). + private let panelWidth: CGFloat = 336 + private let previewAspect: CGFloat = 16.0 / 9.0 + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + switch model.phase { + case .setup: + setupView + case .enterPin: + pinView + case .main: + mainView + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + + Divider() + .padding(.bottom, 4) + + footer + } + .padding(.horizontal, 14) + .padding(.top, 14) + .padding(.bottom, 10) + .frame(width: panelWidth) + .fixedSize(horizontal: true, vertical: true) + } + + private var footer: some View { + HStack(alignment: .top) { + Text(model.status) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, alignment: .leading) + if model.isVirtualCamActive { + Label("Virtual Cam", systemImage: "video.fill") + .font(.caption2) + .foregroundStyle(.green) + } + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(.borderless) + .font(.caption) + } + } + + private var setupView: some View { + VStack(alignment: .leading, spacing: 10) { + Text("PortalCam") + .font(.headline) + Text(model.browser.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + + if !model.browser.portals.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(model.browser.portals) { portal in + Button { + model.selectDiscoveredPortal(portal) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(portal.name) + .foregroundStyle(.primary) + Text(portal.host) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + if model.hostWithoutPort == portal.host { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.tint) + } + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if portal.id != model.browser.portals.last?.id { + Divider() + } + } + } + } + + TextField("Or type address (no port)", text: $model.manualHost) + .textFieldStyle(.roundedBorder) + .onSubmit { model.beginPairing() } + .onChange(of: model.manualHost) { _, value in + model.applyManualHost(value) + } + + Button { + model.beginPairing() + } label: { + Text(model.isBusy ? "Connecting…" : "Connect") + .frame(maxWidth: .infinity) + } + .disabled(model.isBusy || model.hostWithoutPort.isEmpty) + .keyboardShortcut(.defaultAction) + } + .onAppear { model.browser.start() } + .onDisappear { model.browser.stop() } + } + + private var pinView: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Enter pairing PIN") + .font(.headline) + Text("Shown on the Portal TV screen.") + .font(.caption) + .foregroundStyle(.secondary) + Text(model.hostWithoutPort) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + TextField("6-digit PIN", text: $model.pin) + .textFieldStyle(.roundedBorder) + .onSubmit { model.submitPin() } + HStack { + Button("Back") { + model.cancelPairing() + } + .disabled(model.isBusy) + Button { + model.submitPin() + } label: { + Text(model.isBusy ? "Verifying…" : "Pair") + .frame(maxWidth: .infinity) + } + .disabled(model.isBusy || model.pin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.defaultAction) + } + } + } + + private var mainView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(model.connectedPortalTitle) + .font(model.resolvedPortalName == nil ? .caption.monospaced() : .caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(model.hostWithoutPort) + Spacer() + Image(systemName: model.isConnected ? "lock.fill" : "lock.open") + .foregroundStyle(model.isConnected ? .green : .secondary) + .help(model.isConnected ? "Connected & pinned" : "Connecting…") + Button("Unpair") { + model.unpair() + } + .font(.caption) + .buttonStyle(.borderless) + } + .padding(.bottom, 10) + + previewPane + .frame(maxWidth: .infinity) + .aspectRatio(previewAspect, contentMode: .fit) + // Bleed past horizontal content padding so the preview is edge-to-edge. + .padding(.horizontal, -14) + + VStack(alignment: .leading, spacing: 10) { + StretchySegmentedControl( + options: PortalCameraMode.allCases.map(\.title), + selection: Binding( + get: { PortalCameraMode.allCases.firstIndex(of: model.selectedMode) ?? 0 }, + set: { index in + let mode = PortalCameraMode.allCases[index] + model.selectMode(mode) + } + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 28) + + if model.selectedMode == .fixed { + fixedControls + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.vertical, 10) + } + } + + private var fixedControls: some View { + VStack(alignment: .leading, spacing: 8) { + labeledSlider("X", value: fixedBinding(\.x)) + labeledSlider("Y", value: fixedBinding(\.y)) + labeledSlider("Scale", value: fixedBinding(\.scale), range: 0.1...1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Slider writes go through the model so crop updates can be throttled. + private func fixedBinding(_ keyPath: ReferenceWritableKeyPath) -> Binding { + Binding( + get: { model[keyPath: keyPath] }, + set: { model.setFixedCropParameter(keyPath, to: $0) } + ) + } + + @ViewBuilder + private var previewPane: some View { + if let image = model.image { + Image(decorative: image, scale: 1) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } else { + Color.black + .overlay { + Text(model.isConnected ? "Waiting for video…" : "Connecting…") + .font(.caption) + .foregroundStyle(.white.opacity(0.8)) + } + } + } + + private func labeledSlider( + _ title: String, + value: Binding, + range: ClosedRange = 0...1 + ) -> some View { + HStack { + Text(title) + .font(.caption) + .frame(width: 44, alignment: .leading) + Slider(value: value, in: range) + } + } +} + +enum PortalCameraMode: String, CaseIterable, Identifiable { + case defaultAuto = "DefaultAuto" + case desk = "Desk" + case meeting = "Meeting" + case fixed = "Fixed" + + var id: String { rawValue } + var title: String { + switch self { + case .defaultAuto: return "Auto" + case .desk: return "Desk" + case .meeting: return "Meeting" + case .fixed: return "Fixed" + } + } +} + +/// Full-width macOS segmented control — SwiftUI's `.segmented` picker won't stretch. +private struct StretchySegmentedControl: NSViewRepresentable { + let options: [String] + @Binding var selection: Int + + final class FillControl: NSSegmentedControl { + override func layout() { + super.layout() + if #available(macOS 13.0, *) { + segmentDistribution = .fillEqually + } else if segmentCount > 0, bounds.width > 0 { + let width = bounds.width / CGFloat(segmentCount) + for i in 0.. Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> FillControl { + let control = FillControl( + labels: options, + trackingMode: .selectOne, + target: context.coordinator, + action: #selector(Coordinator.changed(_:)) + ) + control.segmentStyle = .rounded + control.selectedSegment = selection + if #available(macOS 13.0, *) { + control.segmentDistribution = .fillEqually + } + control.setContentHuggingPriority(.defaultLow, for: .horizontal) + control.setContentCompressionResistancePriority(.fittingSizeCompression, for: .horizontal) + return control + } + + func updateNSView(_ control: FillControl, context: Context) { + context.coordinator.parent = self + if control.segmentCount != options.count { + control.segmentCount = options.count + for (i, title) in options.enumerated() { + control.setLabel(title, forSegment: i) + } + } + // Keep the thumb on the user's click; only sync when SwiftUI state differs + // and suppress the action so programmatic updates don't re-fire the binding. + if control.selectedSegment != selection, selection >= 0, selection < options.count { + context.coordinator.isProgrammaticUpdate = true + control.selectedSegment = selection + context.coordinator.isProgrammaticUpdate = false + } + control.needsLayout = true + } + + final class Coordinator: NSObject { + var parent: StretchySegmentedControl + var isProgrammaticUpdate = false + init(_ parent: StretchySegmentedControl) { self.parent = parent } + + @objc func changed(_ sender: NSSegmentedControl) { + guard !isProgrammaticUpdate else { return } + let index = sender.selectedSegment + guard index >= 0, parent.selection != index else { return } + parent.selection = index + } + } +} + +@MainActor +final class PortalReceiverModel: ObservableObject { + @Published var phase: PortalUIPhase + /// Always `host:5654` for clients; UI edits use [manualHost] / discovery without port. + @Published var host: String + @Published var manualHost: String = "" + @Published var pin = "" + @Published var status = "" + @Published var isBusy = false + @Published var isConnected = false + @Published var isVirtualCamActive = false + @Published var image: CGImage? + @Published var selectedMode: PortalCameraMode = .defaultAuto + @Published var x = 0.5 + @Published var y = 0.5 + @Published var scale = 1.0 + + let browser = PortalBrowser() + + var menuBarSymbolName: String { + if phase == .main && isConnected { return "video.fill" } + if phase == .main { return "video.badge.ellipsis" } + return "video" + } + + var hostWithoutPort: String { + Self.stripPort(host) + } + + /// mDNS service name for the paired host, if currently discovered on the LAN. + var resolvedPortalName: String? { + let target = Self.normalizedHost(hostWithoutPort) + guard !target.isEmpty else { return nil } + return browser.portals.first(where: { Self.normalizedHost($0.host) == target })?.name + } + + /// Prefer discovered portal name; fall back to IP / host. + var connectedPortalTitle: String { + resolvedPortalName ?? hostWithoutPort + } + + private let media = PortalMediaSession.shared + private let context = CIContext(options: [.cacheIntermediates: false]) + private var browserBag: AnyCancellable? + + private var isPanelVisible = false + private var activePairingSession: PairingSession? + private var unauthorizedHandled = false + /** Bumped on every camera control call; stale responses are ignored. */ + private var cameraControlGeneration = 0 + private var isApplyingRemoteState = false + private var lastFixedCropSentAt = Date.distantPast + private var lastFixedCropEditedAt = Date.distantPast + private var pendingFixedCropTask: Task? + private let fixedCropMinInterval: TimeInterval = 0.1 + private var stateEventsTask: Task? + + private var client: PortalClient { + PortalClient(host: host) + } + + private var isPaired: Bool { + PortalAuth.token != nil && PortalAuth.pinnedCertSha256 != nil + } + + init() { + let savedHost = UserDefaults.standard.string(forKey: "portalHost") ?? "" + let bare = Self.stripPort(savedHost) + manualHost = bare + host = bare.isEmpty ? "" : "\(bare):\(PortalEndpoints.port)" + if PortalAuth.token != nil && PortalAuth.pinnedCertSha256 != nil && !bare.isEmpty { + phase = .main + status = "Ready" + startCameraStateEvents() + } else { + phase = .setup + status = "Select or enter a Portal" + } + browserBag = browser.objectWillChange.sink { [weak self] _ in + self?.objectWillChange.send() + } + wireMediaSession() + setupExtensionSink() + } + + func selectDiscoveredPortal(_ portal: DiscoveredPortal) { + manualHost = portal.host + host = "\(portal.host):\(PortalEndpoints.port)" + status = "Selected \(portal.name)" + } + + func applyManualHost(_ value: String) { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let bare = Self.stripPort(trimmed) + manualHost = trimmed + host = bare.isEmpty ? "" : "\(bare):\(PortalEndpoints.port)" + } + + private static func stripPort(_ value: String) -> String { + var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let pct = trimmed.firstIndex(of: "%") { + trimmed = String(trimmed[.. String { + var h = value.trimmingCharacters(in: .whitespacesAndNewlines) + if let pct = h.firstIndex(of: "%") { + h = String(h[.., + to value: Double + ) { + guard !isApplyingRemoteState else { return } + self[keyPath: keyPath] = value + selectedMode = .fixed + lastFixedCropEditedAt = Date() + scheduleFixedCropSend() + } + + func startCameraStateEvents() { + guard isPaired else { return } + stopCameraStateEvents() + stateEventsTask = Task { @MainActor in + let stream = self.client.cameraStateEvents() + do { + for try await state in stream { + guard !Task.isCancelled else { break } + self.applyCameraState(state) + } + } catch is CancellationError { + // expected on stop / unpair + } catch let error as PortalClientError { + self.handleControlError(error) + // Pin mismatch: stop reconnecting — keep the app up and show the error. + if case .tlsPinningMismatch = error { return } + } catch { + let ns = error as NSError + if ns.domain == NSURLErrorDomain { + self.status = "Reconnecting…" + } else { + self.status = error.localizedDescription + } + } + // Auto-reconnect while still paired (SSE drop / sleep). + guard !Task.isCancelled, self.isPaired, self.phase == .main else { return } + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled, self.isPaired else { return } + self.startCameraStateEvents() + } + } + + func stopCameraStateEvents() { + stateEventsTask?.cancel() + stateEventsTask = nil + } + + /// Sole UI update path for camera mode/config — driven by SSE `/control/events`. + func applyCameraState(_ state: PortalCameraState) { + isApplyingRemoteState = true + defer { isApplyingRemoteState = false } + + if let mode = PortalCameraMode(rawValue: state.mode), mode != selectedMode { + selectedMode = mode + } + // Don't fight the slider while the user is dragging (or a trailing send is queued). + let userEditingFixed = pendingFixedCropTask != nil + || Date().timeIntervalSince(lastFixedCropEditedAt) < 0.25 + if !userEditingFixed { + if let cx = state.config.centerX, abs(cx - x) > 0.0005 { x = cx } + if let cy = state.config.centerY, abs(cy - y) > 0.0005 { y = cy } + if let s = state.config.scale, abs(s - scale) > 0.0005 { scale = s } + } + } + + private func scheduleFixedCropSend() { + let now = Date() + let elapsed = now.timeIntervalSince(lastFixedCropSentAt) + if elapsed >= fixedCropMinInterval { + pendingFixedCropTask?.cancel() + pendingFixedCropTask = nil + sendFixedCropNow() + return + } + pendingFixedCropTask?.cancel() + let delay = fixedCropMinInterval - elapsed + pendingFixedCropTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + guard !Task.isCancelled else { return } + self.pendingFixedCropTask = nil + self.sendFixedCropNow() + } + } + + private func sendFixedCropNow() { + lastFixedCropSentAt = Date() + let x = self.x, y = self.y, scale = self.scale + runCameraControl { + try await self.client.setFixedCrop(x: x, y: y, scale: scale) + } + } + + private func runCameraControl(_ operation: @escaping () async throws -> Void) { + guard isPaired else { return } + cameraControlGeneration += 1 + let generation = cameraControlGeneration + Task { + do { + try await operation() + // UI updates arrive via SSE — do not apply from the command response. + } catch let error as PortalClientError { + guard generation == self.cameraControlGeneration else { return } + self.handleControlError(error) + } catch { + guard generation == self.cameraControlGeneration else { return } + self.status = error.localizedDescription + } + } + } + + private func handleControlError(_ error: PortalClientError) { + switch error { + case .requestFailed(let code, _) where code == 401: + handleUnauthorized() + case .tlsPinningMismatch: + // Hard-fail the request/stream only — never terminate the app. + stopCameraStateEvents() + media.releaseAll() + isConnected = false + image = nil + status = error.localizedDescription + default: + status = error.localizedDescription + } + } + + private func setupExtensionSink() { + let sinkWriter = ExtensionSinkWriter.shared + sinkWriter.startMonitoring() + + media.setFrameHandler(.virtualCamera) { buffer in + ExtensionSinkWriter.shared.send(pixelBuffer: buffer) + } + + sinkWriter.onConsumerStreamStarted = { [weak self] in + Task { @MainActor in + guard let self, self.phase == .main else { return } + self.isVirtualCamActive = true + self.acquireMedia(.virtualCamera) + } + } + + sinkWriter.onConsumerStreamStopped = { [weak self] in + Task { @MainActor in + guard let self else { return } + self.isVirtualCamActive = false + self.media.release(.virtualCamera) + ExtensionSinkWriter.shared.stopSink() + } + } + } + + private func wireMediaSession() { + media.setFrameHandler(.uiPreview) { [weak self] buffer in + guard let self else { return } + let ci = CIImage(cvPixelBuffer: buffer) + let cg = self.context.createCGImage(ci, from: ci.extent) + Task { @MainActor in + self.image = cg + if !self.isConnected { + self.isConnected = true + } + } + } + + media.onStatus = { [weak self] s in + Task { @MainActor in + self?.status = s + } + } + media.onUnauthorized = { [weak self] in + Task { @MainActor in + self?.handleUnauthorized() + } + } + media.onConnected = { [weak self] in + Task { @MainActor in + self?.isConnected = true + self?.status = "Live" + } + } + media.onDisconnected = { [weak self] in + Task { @MainActor in + self?.isConnected = false + } + } + } + + private func acquireMedia(_ consumer: PortalMediaConsumer) { + guard phase == .main, isPaired else { return } + unauthorizedHandled = false + UserDefaults.standard.set(host, forKey: "portalHost") + UserDefaults(suiteName: PortalAuth.suite)?.set(host, forKey: "portalHost") + if !media.hasConsumers { + status = "Connecting…" + } + media.acquire(consumer, host: host, token: PortalAuth.token) + } + + private func handleUnauthorized() { + guard !unauthorizedHandled else { return } + unauthorizedHandled = true + stopCameraStateEvents() + media.releaseAll() + ExtensionSinkWriter.shared.stopSink() + isVirtualCamActive = false + PortalAuth.clear() + activePairingSession = nil + pin = "" + image = nil + isConnected = false + phase = .setup + status = "Not authorized — credentials cleared. Pair again." + browser.start() + } +} + +#Preview { + ContentView() + .environmentObject(PortalReceiverModel()) +} diff --git a/mac/PortalCam/PortalCam/ExtensionSinkWriter.swift b/mac/PortalCam/PortalCam/ExtensionSinkWriter.swift new file mode 100644 index 0000000..0172a58 --- /dev/null +++ b/mac/PortalCam/PortalCam/ExtensionSinkWriter.swift @@ -0,0 +1,378 @@ +// +// ExtensionSinkWriter.swift +// PortalCam +// +// Created by Vlad on 9/12/26. +// + +import Foundation +import CoreMedia +import CoreMediaIO +import CoreVideo +import AVFoundation +import os.log + +final class ExtensionSinkWriter { + static let shared = ExtensionSinkWriter() + + private let targetDeviceUUID = "7B1E8C4B-8D2E-4A0D-9B95-8F5C1C7D9B11" + private let log = Logger(subsystem: "com.kovtash.portalcam", category: "sinkWriter") + private let ioQueue = DispatchQueue(label: "com.kovtash.portalcam.sinkwriter", qos: .userInteractive) + + private(set) var deviceID: CMIODeviceID? + private(set) var sinkStreamID: CMIOStreamID? + private var queue: CMSimpleQueue? + private var formatDescription: CMVideoFormatDescription? + + private var isListening = false + private var isStreamStarted = false + + private var deviceListenerBlock: CMIOObjectPropertyListenerBlock? + private var systemListenerBlock: CMIOObjectPropertyListenerBlock? + + var onConsumerStreamStarted: (() -> Void)? + var onConsumerStreamStopped: (() -> Void)? + + private init() {} + + func startMonitoring() { + guard !isListening else { return } + isListening = true + + registerDarwinNotifications() + registerSystemDeviceListener() + + AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in + guard let self = self else { return } + self.log.info("Camera access authorization result: \(granted)") + self.ioQueue.async { + _ = self.findAndSetupDevice() + } + } + + ioQueue.async { [weak self] in + guard let self = self else { return } + _ = self.findAndSetupDevice() + } + } + + func resetAndReconnect() { + ioQueue.async { [weak self] in + guard let self = self else { return } + self.stopSinkInternal() + _ = self.findAndSetupDevice() + } + } + + // MARK: - Discovery & Setup + + @discardableResult + private func findAndSetupDevice() -> Bool { + guard let (dev, sinkID) = findDeviceAndSinkStream() else { + return false + } + self.deviceID = dev + self.sinkStreamID = sinkID + attachDeviceListeners(for: dev) + + // If device is already in use by a consumer app, connect immediately + if isDeviceRunningSomewhere(dev) { + log.info("PortalCam device is already running somewhere upon discovery") + DispatchQueue.main.async { [weak self] in + self?.onConsumerStreamStarted?() + } + } + return true + } + + private func findDeviceAndSinkStream() -> (CMIODeviceID, CMIOStreamID)? { + var address = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + var size: UInt32 = 0 + let sizeStatus = CMIOObjectGetPropertyDataSize(CMIOObjectID(kCMIOObjectSystemObject), &address, 0, nil, &size) + guard sizeStatus == noErr else { + log.warning("findDevice: get size error \(sizeStatus)") + return nil + } + + let count = Int(size) / MemoryLayout.size + var devices = [CMIODeviceID](repeating: 0, count: count) + let getStatus = CMIOObjectGetPropertyData(CMIOObjectID(kCMIOObjectSystemObject), &address, 0, nil, size, &size, &devices) + guard getStatus == noErr else { + log.warning("findDevice: get data error \(getStatus)") + return nil + } + log.info("findDevice: found \(devices.count) CMIO devices") + + for dev in devices { + var uidAddress = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceUID), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + var uidString: CFString = "" as CFString + let uidSize = UInt32(MemoryLayout.size) + var used: UInt32 = 0 + + let status = withUnsafeMutablePointer(to: &uidString) { ptr in + CMIOObjectGetPropertyData(dev, &uidAddress, 0, nil, uidSize, &used, ptr) + } + log.info("findDevice: dev \(dev), uid status \(status), uid \(uidString as String)") + guard status == noErr, (uidString as String).caseInsensitiveCompare(targetDeviceUUID) == .orderedSame else { + continue + } + + // Found target device, inspect streams + var streamAddr = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyStreams), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + var streamSize: UInt32 = 0 + guard CMIOObjectGetPropertyDataSize(dev, &streamAddr, 0, nil, &streamSize) == noErr else { + log.warning("findDevice: failed to get stream size for dev \(dev)") + continue + } + + let streamCount = Int(streamSize) / MemoryLayout.size + var streams = [CMIOStreamID](repeating: 0, count: streamCount) + guard CMIOObjectGetPropertyData(dev, &streamAddr, 0, nil, streamSize, &streamSize, &streams) == noErr else { + log.warning("findDevice: failed to get stream data for dev \(dev)") + continue + } + log.info("findDevice: dev \(dev) has \(streamCount) streams: \(streams)") + + for streamID in streams { + var dirAddr = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOStreamPropertyDirection), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + var dir: UInt32 = 1 + let sz = UInt32(MemoryLayout.size) + var dirUsed: UInt32 = 0 + let dirStatus = CMIOObjectGetPropertyData(streamID, &dirAddr, 0, nil, sz, &dirUsed, &dir) + log.info("findDevice: stream \(streamID), dir \(dir), status \(dirStatus)") + + // Direction 0 is sink (host -> device) + if dirStatus == noErr && dir == 0 { + return (dev, streamID) + } + } + } + return nil + } + + // MARK: - Sink Connection + + private func ensureSinkConnected() -> Bool { + if isStreamStarted, queue != nil { + return true + } + + guard let (dev, sinkID) = (deviceID != nil && sinkStreamID != nil) ? (deviceID!, sinkStreamID!) : findDeviceAndSinkStream() else { + log.warning("PortalCam sink stream not yet discovered") + return false + } + self.deviceID = dev + self.sinkStreamID = sinkID + + var unmanagedQueue: Unmanaged? + let queueStatus = CMIOStreamCopyBufferQueue(sinkID, { _, _, _ in }, nil, &unmanagedQueue) + guard queueStatus == noErr, let unmanagedQueue = unmanagedQueue else { + log.error("CMIOStreamCopyBufferQueue failed: \(queueStatus)") + self.deviceID = nil + self.sinkStreamID = nil + self.queue = nil + return false + } + self.queue = unmanagedQueue.takeRetainedValue() + + let startStatus = CMIODeviceStartStream(dev, sinkID) + if startStatus != noErr { + log.error("CMIODeviceStartStream failed: \(startStatus)") + self.deviceID = nil + self.sinkStreamID = nil + self.queue = nil + return false + } + + self.isStreamStarted = true + log.info("Started CMIO sink stream dev=\(dev), sinkID=\(sinkID)") + return true + } + + // MARK: - Frame Delivery + + func send(pixelBuffer: CVPixelBuffer) { + ioQueue.async { [weak self] in + guard let self = self else { return } + guard self.ensureSinkConnected(), let queue = self.queue else { return } + + if self.formatDescription == nil || !CMVideoFormatDescriptionMatchesImageBuffer(self.formatDescription!, imageBuffer: pixelBuffer) { + var newDesc: CMVideoFormatDescription? + let status = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &newDesc + ) + if status == noErr { + self.formatDescription = newDesc + } + } + guard let formatDesc = self.formatDescription else { return } + + let hostTime = CMClockGetTime(CMClockGetHostTimeClock()) + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: hostTime, + decodeTimeStamp: .invalid + ) + + var sampleBuffer: CMSampleBuffer? + let createStatus = CMSampleBufferCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + dataReady: true, + makeDataReadyCallback: nil, + refcon: nil, + formatDescription: formatDesc, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + + guard createStatus == noErr, let sampleBuffer = sampleBuffer else { return } + + let unmanaged = Unmanaged.passRetained(sampleBuffer) + let enqueueStatus = CMSimpleQueueEnqueue(queue, element: unmanaged.toOpaque()) + if enqueueStatus != noErr { + // If queue is full or rejected, balance retain count + unmanaged.release() + } + } + } + + func stopSink() { + ioQueue.async { [weak self] in + self?.stopSinkInternal() + } + } + + private func stopSinkInternal() { + if isStreamStarted, let dev = deviceID, let sinkID = sinkStreamID { + CMIODeviceStopStream(dev, sinkID) + log.info("Stopped CMIO sink stream") + } + isStreamStarted = false + queue = nil + formatDescription = nil + } + + // MARK: - Signaling & Listeners + + private func registerDarwinNotifications() { + guard let center = CFNotificationCenterGetDarwinNotifyCenter() else { return } + let observerPtr = Unmanaged.passUnretained(self).toOpaque() + + let callback: CFNotificationCallback = { _, observer, name, _, _ in + guard let observer = observer, let name = name?.rawValue as? String else { return } + let writer = Unmanaged.fromOpaque(observer).takeUnretainedValue() + DispatchQueue.main.async { + writer.handleDarwinNotification(name) + } + } + + CFNotificationCenterAddObserver( + center, + observerPtr, + callback, + "com.kovtash.portalcam.streamStarted" as CFString, + nil, + .deliverImmediately + ) + CFNotificationCenterAddObserver( + center, + observerPtr, + callback, + "com.kovtash.portalcam.streamStopped" as CFString, + nil, + .deliverImmediately + ) + } + + private func handleDarwinNotification(_ name: String) { + log.info("Received Darwin notification: \(name, privacy: .public)") + if name == "com.kovtash.portalcam.streamStarted" { + onConsumerStreamStarted?() + } else if name == "com.kovtash.portalcam.streamStopped" { + onConsumerStreamStopped?() + } + } + + private func registerSystemDeviceListener() { + var addr = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIOHardwarePropertyDevices), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + let block: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in + guard let self = self else { return } + self.ioQueue.async { + self.findAndSetupDevice() + } + } + self.systemListenerBlock = block + CMIOObjectAddPropertyListenerBlock( + CMIOObjectID(kCMIOObjectSystemObject), + &addr, + DispatchQueue.global(qos: .default), + block + ) + } + + private func attachDeviceListeners(for dev: CMIODeviceID) { + var addr = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + + let block: CMIOObjectPropertyListenerBlock = { [weak self] _, _ in + guard let self = self else { return } + let running = self.isDeviceRunningSomewhere(dev) + self.log.info("CMIODevicePropertyDeviceIsRunningSomewhere changed: \(running)") + DispatchQueue.main.async { + if running { + self.onConsumerStreamStarted?() + } else { + self.onConsumerStreamStopped?() + } + } + } + self.deviceListenerBlock = block + CMIOObjectAddPropertyListenerBlock( + dev, + &addr, + DispatchQueue.global(qos: .default), + block + ) + } + + private func isDeviceRunningSomewhere(_ dev: CMIODeviceID) -> Bool { + var addr = CMIOObjectPropertyAddress( + mSelector: CMIOObjectPropertySelector(kCMIODevicePropertyDeviceIsRunningSomewhere), + mScope: CMIOObjectPropertyScope(kCMIOObjectPropertyScopeGlobal), + mElement: CMIOObjectPropertyElement(kCMIOObjectPropertyElementMain) + ) + var isRunning: UInt32 = 0 + let size = UInt32(MemoryLayout.size) + var used: UInt32 = 0 + let status = CMIOObjectGetPropertyData(dev, &addr, 0, nil, size, &used, &isRunning) + return status == noErr && isRunning != 0 + } +} diff --git a/mac/PortalCam/PortalCam/NativePortalAudio.swift b/mac/PortalCam/PortalCam/NativePortalAudio.swift new file mode 100644 index 0000000..765e1d6 --- /dev/null +++ b/mac/PortalCam/PortalCam/NativePortalAudio.swift @@ -0,0 +1,265 @@ +import Foundation +import AVFoundation +import PortalKit + +final class NativePortalAudio: NSObject, URLSessionDataDelegate { + private var session: URLSession? + private var task: URLSessionDataTask? + private var data = Data() + private let engine = AVAudioEngine() + private let node = AVAudioPlayerNode() + private let compressed: AVAudioFormat + private let pcm: AVAudioFormat + private let converter: AVAudioConverter + private let audioQueue = DispatchQueue(label: "com.kovtash.portalcam.audio.playback", qos: .userInteractive) + private let sessionQueue: OperationQueue = { + let q = OperationQueue() + q.name = "com.kovtash.portalcam.audio.session" + q.maxConcurrentOperationCount = 1 + q.qualityOfService = .userInitiated + return q + }() + private var attached = false + private let sync = NSLock() + private var shouldRun = false + private var host: String? + private var token: String? + private var retryWork: DispatchWorkItem? + + var onStatus: ((String) -> Void)? + var onUnauthorized: (() -> Void)? + + override init() { + var asbd = AudioStreamBasicDescription( + mSampleRate: 48000, + mFormatID: kAudioFormatMPEG4AAC, + mFormatFlags: 0, + mBytesPerPacket: 0, + mFramesPerPacket: 1024, + mBytesPerFrame: 0, + mChannelsPerFrame: 1, + mBitsPerChannel: 0, + mReserved: 0 + ) + compressed = AVAudioFormat(streamDescription: &asbd)! + pcm = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: 48000, channels: 1, interleaved: true)! + converter = AVAudioConverter(from: compressed, to: pcm)! + super.init() + } + + func start(host: String, token: String? = PortalAuth.token) { + sync.lock() + // Already running (or reconnecting) for this endpoint — keep the live socket. + if shouldRun, self.host == host, self.token == token { + sync.unlock() + return + } + shouldRun = true + self.host = host + self.token = token + sync.unlock() + connectNow() + } + + func stop() { + sync.lock() + shouldRun = false + sync.unlock() + cancelRetry() + // Tear down on the session queue so it can't race with didReceive data. + if sessionQueue == OperationQueue.current { + tearDownConnection(stopEngine: true) + } else { + sessionQueue.addOperation { [weak self] in + self?.tearDownConnection(stopEngine: true) + } + } + } + + private func connectNow() { + cancelRetry() + sessionQueue.addOperation { [weak self] in + guard let self else { return } + self.tearDownConnection(stopEngine: false) + + self.sync.lock() + let host = self.host + let token = self.token + let running = self.shouldRun + self.sync.unlock() + guard running, let host else { return } + + if !self.attached { + self.engine.attach(self.node) + self.engine.connect(self.node, to: self.engine.mainMixerNode, format: self.pcm) + self.attached = true + } + do { + if !self.engine.isRunning { + try self.engine.start() + } + if !self.node.isPlaying { + self.node.play() + } + } catch { + self.onStatus?("Audio engine failed: \(error.localizedDescription)") + self.scheduleRetry(reason: error.localizedDescription) + return + } + + self.data.removeAll(keepingCapacity: true) + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude + cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60 + cfg.waitsForConnectivity = true + let session = URLSession(configuration: cfg, delegate: self, delegateQueue: self.sessionQueue) + self.session = session + guard let u = URL(string: "https://\(host)/audio.aac") else { return } + var req = URLRequest(url: u) + if let token { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let task = session.dataTask(with: req) + self.task = task + task.resume() + self.onStatus?("Audio connecting…") + } + } + + private func tearDownConnection(stopEngine: Bool) { + task?.cancel() + task = nil + session?.invalidateAndCancel() + session = nil + data.removeAll(keepingCapacity: true) + if stopEngine { + node.stop() + if engine.isRunning { + engine.stop() + } + } + } + + private func cancelRetry() { + retryWork?.cancel() + retryWork = nil + } + + private func scheduleRetry(reason: String) { + sync.lock() + let running = shouldRun + sync.unlock() + guard running else { return } + cancelRetry() + onStatus?("Audio reconnecting in 1s…") + let work = DispatchWorkItem { [weak self] in + self?.connectNow() + } + retryWork = work + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0, execute: work) + } + + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + PortalTlsPinning.evaluate(challenge: challenge, completionHandler: completionHandler) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + if session !== self.session { + completionHandler(.cancel) + return + } + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 || http.statusCode == 403 { + completionHandler(.cancel) + sync.lock() + shouldRun = false + sync.unlock() + cancelRetry() + onUnauthorized?() + return + } + if http.statusCode >= 400 { + completionHandler(.cancel) + scheduleRetry(reason: "HTTP \(http.statusCode)") + return + } + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive chunk: Data) { + guard session === self.session else { return } + data.append(chunk) + consume() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard session === self.session else { return } + if let error { + let ns = error as NSError + if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { + return + } + scheduleRetry(reason: error.localizedDescription) + } else { + scheduleRetry(reason: "audio closed") + } + } + + private func consume() { + // ADTS sync + frame length are parsed defensively; max frame_length is 13 bits (8191). + while data.count >= 7 { + let b0 = data[data.startIndex] + let b1 = data[data.startIndex + 1] + guard b0 == 0xff, (b1 & 0xf0) == 0xf0 else { + data.removeFirst() + continue + } + let b3 = Int(data[data.startIndex + 3]) + let b4 = Int(data[data.startIndex + 4]) + let b5 = Int(data[data.startIndex + 5]) + let len = ((b3 & 0x03) << 11) | (b4 << 3) | ((b5 & 0xe0) >> 5) + guard len >= 7, len <= 8191 else { + data.removeFirst() + continue + } + guard data.count >= len else { return } + let packet = data.subdata(in: data.startIndex.advanced(by: 7).. 256 * 1024 { + data.removeAll(keepingCapacity: true) + } + } + + private func decode(_ bytes: Data) { + guard !bytes.isEmpty, bytes.count <= 2048 else { return } + let b = AVAudioCompressedBuffer(format: compressed, packetCapacity: 1, maximumPacketSize: 2048) + bytes.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + memcpy(b.data, base, bytes.count) + } + b.byteLength = UInt32(bytes.count) + b.packetCount = 1 + guard let out = AVAudioPCMBuffer(pcmFormat: pcm, frameCapacity: 1024) else { return } + var used = false + var convertError: NSError? + converter.convert(to: out, error: &convertError) { _, status in + if used { + status.pointee = .endOfStream + return nil + } + used = true + status.pointee = .haveData + return b + } + if convertError != nil { return } + if out.frameLength > 0 { + audioQueue.async { + self.node.scheduleBuffer(out) + } + } + } +} diff --git a/mac/PortalCam/PortalCam/NativePortalStream.swift b/mac/PortalCam/PortalCam/NativePortalStream.swift new file mode 100644 index 0000000..acc5c25 --- /dev/null +++ b/mac/PortalCam/PortalCam/NativePortalStream.swift @@ -0,0 +1,391 @@ +import Foundation +import VideoToolbox +import CoreImage +import IOSurface +import os.log +import PortalKit + +final class NativePortalStream: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate { + private let log = Logger(subsystem: "com.kovtash.portalcam", category: "stream") + private var session: URLSession? + private var task: URLSessionDataTask? + private var pending = Data() + private let decoder = H264Decoder() + private let sync = NSLock() + private var shouldRun = false + private var host: String? + private var token: String? + private var retryWork: DispatchWorkItem? + private var lastPinMismatch = false + private let sessionQueue: OperationQueue = { + let q = OperationQueue() + q.name = "com.kovtash.portalcam.video.session" + q.maxConcurrentOperationCount = 1 + q.qualityOfService = .userInitiated + return q + }() + + var onFrame: ((CVPixelBuffer) -> Void)? + var onStatus: ((String) -> Void)? + var onUnauthorized: (() -> Void)? + var onConnected: (() -> Void)? + /// Pin mismatch: stream stopped; app stays up. + var onPinMismatch: (() -> Void)? + + func start(host: String, token: String? = PortalAuth.token) { + sync.lock() + // Already running (or reconnecting) for this endpoint — keep the live socket. + if shouldRun, self.host == host, self.token == token { + sync.unlock() + return + } + shouldRun = true + self.host = host + self.token = token + sync.unlock() + connectNow() + } + + func stop() { + sync.lock() + shouldRun = false + sync.unlock() + cancelRetry() + if sessionQueue == OperationQueue.current { + tearDownConnection(resetDecoder: true) + } else { + sessionQueue.addOperation { [weak self] in + self?.tearDownConnection(resetDecoder: true) + } + } + } + + private func connectNow() { + cancelRetry() + sessionQueue.addOperation { [weak self] in + guard let self else { return } + self.tearDownConnection(resetDecoder: false) + + self.sync.lock() + let host = self.host + let token = self.token + let running = self.shouldRun + self.sync.unlock() + guard running, let host else { return } + + self.decoder.onFrame = { [weak self] b in self?.onFrame?(b) } + self.pending.removeAll(keepingCapacity: true) + self.lastPinMismatch = false + + let cfg = URLSessionConfiguration.default + cfg.timeoutIntervalForRequest = .greatestFiniteMagnitude + cfg.timeoutIntervalForResource = 7 * 24 * 60 * 60 + cfg.waitsForConnectivity = true + let session = URLSession(configuration: cfg, delegate: self, delegateQueue: self.sessionQueue) + self.session = session + guard let u = URL(string: "https://\(host)/video.h264") else { + self.onStatus?("Invalid Portal address") + return + } + self.onStatus?("Connecting…") + self.log.info("connecting to \(u.absoluteString, privacy: .public)") + var req = URLRequest(url: u) + if let token { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let task = session.dataTask(with: req) + self.task = task + task.resume() + } + } + + private func tearDownConnection(resetDecoder: Bool) { + task?.cancel() + task = nil + session?.invalidateAndCancel() + session = nil + pending.removeAll(keepingCapacity: true) + if resetDecoder { + decoder.stop() + } + } + + private func cancelRetry() { + retryWork?.cancel() + retryWork = nil + } + + private func scheduleRetry(reason: String) { + sync.lock() + let running = shouldRun + sync.unlock() + guard running else { return } + + cancelRetry() + onStatus?("Reconnecting in 1s… (\(reason))") + let work = DispatchWorkItem { [weak self] in + self?.connectNow() + } + retryWork = work + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 1.0, execute: work) + } + + private func evaluateChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let pinned = PortalAuth.pinnedCertSha256 + PortalTlsPinning.evaluate( + challenge: challenge, + pinnedFingerprint: pinned, + completionHandler: { [weak self] disposition, credential in + if disposition == .cancelAuthenticationChallenge, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust, + let cert = PortalTlsPinning.extractLeafCert(from: trust), + let pinned { + let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert) + if pinned.caseInsensitiveCompare(got) != .orderedSame { + self?.lastPinMismatch = true + } + } + completionHandler(disposition, credential) + } + ) + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + evaluateChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + evaluateChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + guard session === self.session else { + completionHandler(.cancel) + return + } + if let http = response as? HTTPURLResponse { + log.info("response status \(http.statusCode, privacy: .public)") + if http.statusCode == 401 || http.statusCode == 403 { + completionHandler(.cancel) + sync.lock() + shouldRun = false + sync.unlock() + cancelRetry() + onUnauthorized?() + onStatus?("Not authorized") + return + } + if http.statusCode >= 200 && http.statusCode < 300 { + onConnected?() + } else if http.statusCode >= 400 { + completionHandler(.cancel) + scheduleRetry(reason: "HTTP \(http.statusCode)") + return + } + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + guard session === self.session else { return } + pending.append(data) + consume() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard session === self.session else { return } + if lastPinMismatch { + lastPinMismatch = false + sync.lock() + shouldRun = false + sync.unlock() + cancelRetry() + onStatus?("Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal.") + onPinMismatch?() + return + } + if let error { + let ns = error as NSError + if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { + return + } + log.error("stream ended: \(error.localizedDescription, privacy: .public)") + scheduleRetry(reason: error.localizedDescription) + } else { + scheduleRetry(reason: "stream closed") + } + } + + private func consume() { + while let first = startCode(in: pending, from: 0) { + guard let next = startCode(in: pending, from: first.0 + first.1) else { break } + let sc = next.0 + let nalStart = first.0 + first.1 + guard nalStart <= sc, sc <= pending.count else { break } + let nal = pending[nalStart.. 2 * 1024 * 1024 { + pending = Data(pending.suffix(256 * 1024)) + } + } + + private func startCode(in d: Data, from: Int) -> (Int, Int)? { + let count = d.count + if count < 4 || from >= count { return nil } + return d.withUnsafeBytes { raw -> (Int, Int)? in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return nil } + var i = from + let end = count - 2 + while i < end { + if base[i] == 0 && base[i + 1] == 0 && base[i + 2] == 1 { + return (i, 3) + } + if i + 3 < count && base[i] == 0 && base[i + 1] == 0 && base[i + 2] == 0 && base[i + 3] == 1 { + return (i, 4) + } + i += 1 + } + return nil + } + } +} + +private final class H264Decoder { + private let log = Logger(subsystem: "com.kovtash.portalcam", category: "decoder") + private var sps = Data(), pps = Data(), session: VTDecompressionSession?, format: CMVideoFormatDescription? + var onFrame: ((CVPixelBuffer) -> Void)? + + func stop() { + if let session { VTDecompressionSessionInvalidate(session) } + session = nil + format = nil + } + + func decode(nal: Data) { + guard let type = nal.first.map({ $0 & 0x1f }) else { return } + if type == 7 { + sps = nal + rebuild() + return + } + if type == 8 { + pps = nal + rebuild() + return + } + guard (type == 1 || type == 5), let format, let session else { return } + var size = UInt32(nal.count).bigEndian + var avcc = Data(bytes: &size, count: 4) + avcc.append(nal) + var block: CMBlockBuffer? + avcc.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + _ = CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, + memoryBlock: UnsafeMutableRawPointer(mutating: base), + blockLength: raw.count, + blockAllocator: kCFAllocatorNull, + customBlockSource: nil, + offsetToData: 0, + dataLength: raw.count, + flags: 0, + blockBufferOut: &block + ) + } + guard let block else { return } + var sample: CMSampleBuffer? + var timing = CMSampleTimingInfo( + duration: CMTime(value: 1, timescale: 30), + presentationTimeStamp: CMClockGetTime(CMClockGetHostTimeClock()), + decodeTimeStamp: .invalid + ) + var sizes = [avcc.count] + _ = CMSampleBufferCreateReady( + allocator: kCFAllocatorDefault, + dataBuffer: block, + formatDescription: format, + sampleCount: 1, + sampleTimingEntryCount: 1, + sampleTimingArray: &timing, + sampleSizeEntryCount: 1, + sampleSizeArray: &sizes, + sampleBufferOut: &sample + ) + if let sample { + _ = VTDecompressionSessionDecodeFrame( + session, + sampleBuffer: sample, + flags: [], + frameRefcon: Unmanaged.passUnretained(self).toOpaque(), + infoFlagsOut: nil + ) + } + } + + private func rebuild() { + guard !sps.isEmpty, !pps.isEmpty else { return } + stop() + var f: CMVideoFormatDescription? + let rc: OSStatus = sps.withUnsafeBytes { sr in + pps.withUnsafeBytes { pr in + guard let sBase = sr.bindMemory(to: UInt8.self).baseAddress, + let pBase = pr.bindMemory(to: UInt8.self).baseAddress else { + return OSStatus(-1) + } + var ps = [sBase, pBase] + var lens = [sps.count, pps.count] + return CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: kCFAllocatorDefault, + parameterSetCount: 2, + parameterSetPointers: &ps, + parameterSetSizes: &lens, + nalUnitHeaderLength: 4, + formatDescriptionOut: &f + ) + } + } + guard rc == noErr, let f else { + log.error("format creation failed \(rc, privacy: .public)") + return + } + format = f + let callback: VTDecompressionOutputCallback = { refCon, _, status, _, image, _, _ in + if status == noErr, let image, let refCon { + Unmanaged.fromOpaque(refCon).takeUnretainedValue().onFrame?(image) + } + } + var cb = VTDecompressionOutputCallbackRecord( + decompressionOutputCallback: callback, + decompressionOutputRefCon: Unmanaged.passUnretained(self).toOpaque() + ) + var attrs: CFDictionary = [ + kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA, + kCVPixelBufferIOSurfacePropertiesKey: [:] + ] as CFDictionary + var ds: VTDecompressionSession? + _ = VTDecompressionSessionCreate( + allocator: kCFAllocatorDefault, + formatDescription: f, + decoderSpecification: nil, + imageBufferAttributes: attrs, + outputCallback: &cb, + decompressionSessionOut: &ds + ) + session = ds + } +} diff --git a/mac/PortalCam/PortalCam/PortalBrowser.swift b/mac/PortalCam/PortalCam/PortalBrowser.swift new file mode 100644 index 0000000..9d20c81 --- /dev/null +++ b/mac/PortalCam/PortalCam/PortalBrowser.swift @@ -0,0 +1,240 @@ +// +// PortalBrowser.swift +// PortalCam +// +// DNS-SD / Bonjour browser for PortalCam services on the LAN. +// + +import Foundation +import Network +import Combine +import PortalKit + +struct DiscoveredPortal: Identifiable, Hashable, Sendable { + let id: String + let name: String + let host: String + let port: Int + + var endpoint: String { "\(host):\(port)" } +} + +@MainActor +final class PortalBrowser: ObservableObject { + @Published private(set) var portals: [DiscoveredPortal] = [] + @Published private(set) var isBrowsing = false + @Published private(set) var statusMessage = "Looking for Portal TVs…" + + private var browser: NWBrowser? + private var resolveConnections: [String: NWConnection] = [:] + + func start() { + guard browser == nil else { return } + let descriptor = NWBrowser.Descriptor.bonjour(type: PortalEndpoints.bonjourType, domain: nil) + let params = NWParameters() + params.includePeerToPeer = true + let b = NWBrowser(for: descriptor, using: params) + browser = b + isBrowsing = true + statusMessage = "Looking for Portal TVs…" + + b.stateUpdateHandler = { [weak self] newState in + Task { @MainActor in + guard let self else { return } + switch newState { + case .ready: + self.statusMessage = self.portals.isEmpty ? "Looking for Portal TVs…" : "Select a Portal" + case .failed(let err): + self.statusMessage = "Browse failed: \(err.localizedDescription)" + self.isBrowsing = false + case .cancelled: + self.isBrowsing = false + default: + break + } + } + } + + b.browseResultsChangedHandler = { [weak self] results, _ in + Task { @MainActor [weak self] in + self?.handleBrowseResults(results) + } + } + + b.start(queue: .main) + } + + func stop() { + browser?.cancel() + browser = nil + for (_, conn) in resolveConnections { conn.cancel() } + resolveConnections.removeAll() + isBrowsing = false + } + + private func handleBrowseResults(_ results: Set) { + let activeIds = Set(results.map { resultId($0) }) + for id in resolveConnections.keys where !activeIds.contains(id) { + resolveConnections[id]?.cancel() + resolveConnections.removeValue(forKey: id) + } + portals.removeAll { !activeIds.contains($0.id) } + + for result in results { + let id = resultId(result) + if portals.contains(where: { $0.id == id }) || resolveConnections[id] != nil { + continue + } + resolve(result, id: id) + } + + if portals.isEmpty && isBrowsing { + statusMessage = "Looking for Portal TVs…" + } + } + + private func resultId(_ result: NWBrowser.Result) -> String { + if case .service(let name, let type, let domain, _) = result.endpoint { + return "\(name).\(type).\(domain)" + } + return String(describing: result.endpoint) + } + + private func resolve(_ result: NWBrowser.Result, id: String, preferIPv4: Bool = true) { + resolveConnections[id]?.cancel() + + let params = NWParameters.tcp + params.includePeerToPeer = true + if preferIPv4, let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options { + ip.version = .v4 + } + + let conn = NWConnection(to: result.endpoint, using: params) + resolveConnections[id] = conn + conn.stateUpdateHandler = { [weak self] state in + Task { @MainActor [weak self] in + guard let self else { return } + // Ignore stale callbacks after a preferIPv4 → any retry replaced this connection. + guard self.resolveConnections[id] === conn else { return } + + switch state { + case .ready: + guard let portal = self.portal(from: conn, id: id, browseResult: result) else { + conn.cancel() + self.resolveConnections.removeValue(forKey: id) + if preferIPv4 { + self.resolve(result, id: id, preferIPv4: false) + } + return + } + if preferIPv4, Self.isIPv6Literal(portal.host) { + // Dual-stack path still handed us v6 — try unrestricted resolve only if needed. + conn.cancel() + self.resolveConnections.removeValue(forKey: id) + self.resolve(result, id: id, preferIPv4: false) + return + } + if let idx = self.portals.firstIndex(where: { $0.id == portal.id }) { + // Prefer keeping an existing IPv4 over replacing with IPv6. + if Self.isIPv6Literal(portal.host), !Self.isIPv6Literal(self.portals[idx].host) { + conn.cancel() + self.resolveConnections.removeValue(forKey: id) + return + } + self.portals[idx] = portal + } else { + self.portals.append(portal) + } + self.portals.sort { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + self.statusMessage = "Select a Portal" + conn.cancel() + self.resolveConnections.removeValue(forKey: id) + case .failed: + conn.cancel() + self.resolveConnections.removeValue(forKey: id) + if preferIPv4 { + self.resolve(result, id: id, preferIPv4: false) + } + case .cancelled: + if self.resolveConnections[id] === conn { + self.resolveConnections.removeValue(forKey: id) + } + default: + break + } + } + } + conn.start(queue: .main) + } + + private func portal(from connection: NWConnection, id: String, browseResult: NWBrowser.Result) -> DiscoveredPortal? { + let name: String + if case .service(let n, _, _, _) = browseResult.endpoint { + name = n + } else { + name = "PortalCam" + } + + guard let endpoint = connection.currentPath?.remoteEndpoint else { + return serviceFallback(id: id, name: name, result: browseResult) + } + + switch endpoint { + case .hostPort(let host, let port): + guard let hostString = Self.hostString(from: host) else { + return serviceFallback(id: id, name: name, result: browseResult) + } + let resolvedPort = Int(port.rawValue) + return DiscoveredPortal( + id: id, + name: name, + host: hostString, + port: resolvedPort > 0 ? resolvedPort : PortalEndpoints.port + ) + default: + return serviceFallback(id: id, name: name, result: browseResult) + } + } + + /// Network.framework includes interface scopes (`10.0.0.10%en0`) which break URL hosts. + private static func hostString(from host: NWEndpoint.Host) -> String? { + switch host { + case .name(let n, _): + return stripInterfaceScope(n) + case .ipv4(let v4): + let bytes = v4.rawValue + guard bytes.count == 4 else { return stripInterfaceScope(v4.debugDescription) } + return "\(bytes[0]).\(bytes[1]).\(bytes[2]).\(bytes[3])" + case .ipv6(let v6): + return stripInterfaceScope(String(describing: v6)) + @unknown default: + return nil + } + } + + private static func isIPv6Literal(_ host: String) -> Bool { + host.contains(":") + } + + private static func stripInterfaceScope(_ value: String) -> String { + guard let pct = value.firstIndex(of: "%") else { return value } + return String(value[.. DiscoveredPortal? { + guard case .service(_, _, let domain, _) = result.endpoint else { + return DiscoveredPortal(id: id, name: name, host: name, port: PortalEndpoints.port) + } + let encoded = name.replacingOccurrences(of: " ", with: "-") + var host = encoded + let d = domain.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + if !d.isEmpty { + host = "\(encoded).\(d)" + } else { + host = "\(encoded).local" + } + return DiscoveredPortal(id: id, name: name, host: host, port: PortalEndpoints.port) + } +} diff --git a/mac/PortalCam/PortalCam/PortalCam 2.entitlements b/mac/PortalCam/PortalCam/PortalCam 2.entitlements new file mode 100644 index 0000000..e13ea7c --- /dev/null +++ b/mac/PortalCam/PortalCam/PortalCam 2.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.kovtash.portalcam + + + diff --git a/mac/PortalCam/PortalCam/PortalCam.entitlements b/mac/PortalCam/PortalCam/PortalCam.entitlements new file mode 100644 index 0000000..b71840e --- /dev/null +++ b/mac/PortalCam/PortalCam/PortalCam.entitlements @@ -0,0 +1,20 @@ + + + + + com.apple.developer.system-extension.install + + com.apple.security.application-groups + + $(TeamIdentifierPrefix)com.kovtash.portalcam + + com.apple.security.network.client + + com.apple.security.device.camera + + keychain-access-groups + + $(AppIdentifierPrefix)com.kovtash.portalcam + + + diff --git a/mac/PortalCam/PortalCam/PortalCamApp.swift b/mac/PortalCam/PortalCam/PortalCamApp.swift new file mode 100644 index 0000000..573771f --- /dev/null +++ b/mac/PortalCam/PortalCam/PortalCamApp.swift @@ -0,0 +1,79 @@ +// +// PortalCamApp.swift +// PortalCam +// +// Menubar-only companion for Portal TV (no Dock icon). +// + +import SwiftUI +import AppKit +import SystemExtensions + +@main +struct PortalCamApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var model = PortalReceiverModel() + + var body: some Scene { + MenuBarExtra { + ContentView() + .environmentObject(model) + .workspaceDetachedMenuBarWindow() + .onAppear { model.panelDidAppear() } + .onDisappear { model.panelDidDisappear() } + } label: { + Image(systemName: model.menuBarSymbolName) + } + .menuBarExtraStyle(.window) + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + ExtensionInstaller.shared.activate() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } +} + +final class ExtensionInstaller: NSObject, OSSystemExtensionRequestDelegate { + static let shared = ExtensionInstaller() + func activate() { + let extId = "com.kovtash.portalcam.camera-extension" + NSLog("PortalCam: preparing to activate \(extId)") + let fm = FileManager.default + let sysExtsURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/SystemExtensions", isDirectory: true) + if let contents = try? fm.contentsOfDirectory(at: sysExtsURL, includingPropertiesForKeys: nil) { + let names = contents.map { $0.lastPathComponent }.joined(separator: ", ") + NSLog("PortalCam: embedded system extensions: \(names)") + for url in contents { + let infoURL = url.appendingPathComponent("Contents/Info.plist") + if let info = NSDictionary(contentsOf: infoURL), let bid = info["CFBundleIdentifier"] as? String { + NSLog("PortalCam: embedded sys ext id: \(bid)") + } + } + } else { + NSLog("PortalCam: no embedded system extensions directory at \(sysExtsURL.path)") + } + NSLog("PortalCam: submitting camera extension activation") + let r = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: extId, queue: .main) + r.delegate = self + OSSystemExtensionManager.shared.submitRequest(r) + } + func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { + NSLog("PortalCam: camera extension needs user approval") + } + func request(_ request: OSSystemExtensionRequest, actionForReplacingExtension existing: OSSystemExtensionProperties, withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { + .replace + } + func request(_ request: OSSystemExtensionRequest, didFinishWithResult result: OSSystemExtensionRequest.Result) { + NSLog("PortalCam: camera extension activation finished (\(result.rawValue))") + ExtensionSinkWriter.shared.resetAndReconnect() + } + func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { + NSLog("PortalCam: camera extension activation failed: \(error)") + } +} diff --git a/mac/PortalCam/PortalCam/PortalMediaSession.swift b/mac/PortalCam/PortalCam/PortalMediaSession.swift new file mode 100644 index 0000000..929b5e1 --- /dev/null +++ b/mac/PortalCam/PortalCam/PortalMediaSession.swift @@ -0,0 +1,126 @@ +// +// PortalMediaSession.swift +// PortalCam +// +// Reference-counted shared Portal media connection. UI preview and virtual-cam +// consumers acquire/release interest; HTTPS streams open once on 0→1 and close +// on 1→0. Decoded frames fan out to per-consumer handlers without reconnecting. +// + +import Foundation +import CoreVideo +import PortalKit + +enum PortalMediaConsumer: Hashable, Sendable { + case uiPreview + case virtualCamera +} + +/// Process-wide media session shared by the menubar UI and the CMIO virtual camera. +final class PortalMediaSession: @unchecked Sendable { + static let shared = PortalMediaSession() + + private let video = NativePortalStream() + private let lock = NSLock() + + private var consumers = Set() + private var frameHandlers: [PortalMediaConsumer: (CVPixelBuffer) -> Void] = [:] + private var activeHost: String? + private var activeToken: String? + + var onStatus: ((String) -> Void)? + var onUnauthorized: (() -> Void)? + var onConnected: (() -> Void)? + var onDisconnected: (() -> Void)? + + var activeConsumers: Set { + lock.lock(); defer { lock.unlock() } + return consumers + } + + var hasConsumers: Bool { + lock.lock(); defer { lock.unlock() } + return !consumers.isEmpty + } + + private init() { + video.onFrame = { [weak self] buffer in + guard let self else { return } + let handlers: [(CVPixelBuffer) -> Void] = { + self.lock.lock() + defer { self.lock.unlock() } + return self.consumers.compactMap { self.frameHandlers[$0] } + }() + for handler in handlers { + handler(buffer) + } + } + video.onStatus = { [weak self] s in self?.onStatus?(s) } + video.onUnauthorized = { [weak self] in self?.onUnauthorized?() } + video.onConnected = { [weak self] in self?.onConnected?() } + video.onPinMismatch = { [weak self] in + // Stream already stopped; surface through status path only — never exit the app. + self?.onStatus?( + "Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal." + ) + } + } + + /// Register (or clear) the frame sink for a consumer. Safe to call before acquire. + func setFrameHandler(_ consumer: PortalMediaConsumer, _ handler: ((CVPixelBuffer) -> Void)?) { + lock.lock() + if let handler { + frameHandlers[consumer] = handler + } else { + frameHandlers.removeValue(forKey: consumer) + } + lock.unlock() + } + + /// Express interest in the shared streams. Opens HTTPS only when the first consumer joins. + func acquire(_ consumer: PortalMediaConsumer, host: String, token: String?) { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + lock.lock() + let wasEmpty = consumers.isEmpty + let hostChanged = activeHost != nil && activeHost != trimmed + let tokenChanged = activeToken != token && activeHost != nil + consumers.insert(consumer) + activeHost = trimmed + activeToken = token + let shouldStart = wasEmpty || hostChanged || tokenChanged + lock.unlock() + + guard shouldStart else { return } + // Video only for now — audio AAC stream unused by UI / virtual cam. + video.start(host: trimmed, token: token) + } + + /// Drop interest. Closes HTTPS only when the last consumer leaves. + func release(_ consumer: PortalMediaConsumer) { + lock.lock() + consumers.remove(consumer) + let nowEmpty = consumers.isEmpty + if nowEmpty { + activeHost = nil + activeToken = nil + } + lock.unlock() + + guard nowEmpty else { return } + video.stop() + onDisconnected?() + } + + /// Tear down for unpair / auth failure regardless of consumer count. + func releaseAll() { + lock.lock() + consumers.removeAll() + activeHost = nil + activeToken = nil + lock.unlock() + video.stop() + onDisconnected?() + } +} diff --git a/mac/PortalCam/PortalCam/WorkspaceDetachedWindow.swift b/mac/PortalCam/PortalCam/WorkspaceDetachedWindow.swift new file mode 100644 index 0000000..d93025b --- /dev/null +++ b/mac/PortalCam/PortalCam/WorkspaceDetachedWindow.swift @@ -0,0 +1,80 @@ +// +// WorkspaceDetachedWindow.swift +// PortalCam +// +// Detaches the MenuBarExtra popup from a single Space so it stays available +// when switching desktops (same idea as system utility panels). +// + +import AppKit +import SwiftUI + +enum WorkspaceDetachedWindow { + static let behaviors: NSWindow.CollectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + .stationary, + ] + + static func apply(to window: NSWindow?) { + guard let window else { return } + var behavior = window.collectionBehavior + behavior.insert(behaviors) + behavior.remove(.moveToActiveSpace) + window.collectionBehavior = behavior + window.isMovableByWindowBackground = false + } + + static func applyToVisiblePanels() { + for window in NSApp.windows where window.isVisible { + // MenuBarExtra window-style panels are typically NSPanel / utility. + if window is NSPanel || window.level == .floating || window.level == .statusBar { + apply(to: window) + } + } + } +} + +/// Finds the hosting NSWindow for a SwiftUI view and runs a callback. +private struct WindowAccessor: NSViewRepresentable { + var onResolve: (NSWindow?) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + onResolve(view.window) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + onResolve(nsView.window) + } + } +} + +extension View { + /// Apply Spaces-detached collection behavior to the MenuBarExtra host window. + func workspaceDetachedMenuBarWindow() -> some View { + background( + WindowAccessor { window in + WorkspaceDetachedWindow.apply(to: window) + } + ) + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { note in + WorkspaceDetachedWindow.apply(to: note.object as? NSWindow) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + WorkspaceDetachedWindow.applyToVisiblePanels() + } + .onReceive(NotificationCenter.default.publisher(for: NSWorkspace.activeSpaceDidChangeNotification)) { _ in + WorkspaceDetachedWindow.applyToVisiblePanels() + for window in NSApp.windows where window.isVisible { + if window is NSPanel || window.level == .floating || window.level == .statusBar { + window.orderFrontRegardless() + } + } + } + } +} diff --git a/mac/PortalKit/Package.swift b/mac/PortalKit/Package.swift new file mode 100644 index 0000000..589cdf6 --- /dev/null +++ b/mac/PortalKit/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "PortalKit", + platforms: [ + .macOS(.v12) + ], + products: [ + .library( + name: "PortalKit", + targets: ["PortalKit"] + ), + .executable( + name: "portalkit-cli", + targets: ["portalkit-cli"] + ), + ], + dependencies: [], + targets: [ + .target( + name: "PortalKit", + dependencies: [], + path: "Sources/PortalKit" + ), + .executableTarget( + name: "portalkit-cli", + dependencies: ["PortalKit"], + path: "Sources/portalkit-cli" + ), + .testTarget( + name: "PortalKitTests", + dependencies: ["PortalKit"], + path: "Tests/PortalKitTests" + ), + ] +) diff --git a/mac/PortalKit/Sources/PortalKit/Auth/CredentialStorage.swift b/mac/PortalKit/Sources/PortalKit/Auth/CredentialStorage.swift new file mode 100644 index 0000000..35b0254 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Auth/CredentialStorage.swift @@ -0,0 +1,118 @@ +// +// CredentialStorage.swift +// PortalKit +// +// Abstraction for persisting pairing tokens and pinned TLS fingerprints. +// + +import Foundation + +public protocol CredentialStorage: Sendable { + func getAuthToken() -> String? + func getPinnedCertSha256() -> String? + func save(token: String, certSha256: String) + func clear() +} + +/// Thread-safe in-memory credential storage for unit testing and ephemeral sessions. +public final class InMemoryCredentialStorage: CredentialStorage, @unchecked Sendable { + private let lock = NSLock() + private var token: String? + private var certSha256: String? + + public init(token: String? = nil, certSha256: String? = nil) { + self.token = token + self.certSha256 = certSha256?.lowercased() + } + + public func getAuthToken() -> String? { + lock.lock() + defer { lock.unlock() } + return token + } + + public func getPinnedCertSha256() -> String? { + lock.lock() + defer { lock.unlock() } + return certSha256 + } + + public func save(token: String, certSha256: String) { + lock.lock() + defer { lock.unlock() } + self.token = token + self.certSha256 = certSha256.lowercased() + } + + public func clear() { + lock.lock() + defer { lock.unlock() } + self.token = nil + self.certSha256 = nil + } +} + +/// JSON-file-backed credential storage (default for CLI: ~/.portalkit/credentials.json). +public final class FileCredentialStorage: CredentialStorage, @unchecked Sendable { + private let fileURL: URL + private let lock = NSLock() + + private struct CredentialsRecord: Codable { + var token: String? + var certSha256: String? + } + + public init(fileURL: URL? = nil) { + if let url = fileURL { + self.fileURL = url + } else { + let homeDir = FileManager.default.homeDirectoryForCurrentUser + let portalKitDir = homeDir.appendingPathComponent(".portalkit", isDirectory: true) + try? FileManager.default.createDirectory(at: portalKitDir, withIntermediateDirectories: true) + self.fileURL = portalKitDir.appendingPathComponent("credentials.json") + } + } + + private func loadRecord() -> CredentialsRecord { + guard let data = try? Data(contentsOf: fileURL), + let record = try? JSONDecoder().decode(CredentialsRecord.self, from: data) else { + return CredentialsRecord(token: nil, certSha256: nil) + } + return record + } + + private func persist(record: CredentialsRecord) { + let parentDir = fileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(record) { + try? data.write(to: fileURL, options: .atomic) + // Ensure permissions are 0600 (owner read/write only) + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path) + } + } + + public func getAuthToken() -> String? { + lock.lock() + defer { lock.unlock() } + return loadRecord().token + } + + public func getPinnedCertSha256() -> String? { + lock.lock() + defer { lock.unlock() } + return loadRecord().certSha256 + } + + public func save(token: String, certSha256: String) { + lock.lock() + defer { lock.unlock() } + let record = CredentialsRecord(token: token, certSha256: certSha256.lowercased()) + persist(record: record) + } + + public func clear() { + lock.lock() + defer { lock.unlock() } + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Auth/KeychainCredentialStorage.swift b/mac/PortalKit/Sources/PortalKit/Auth/KeychainCredentialStorage.swift new file mode 100644 index 0000000..b79987c --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Auth/KeychainCredentialStorage.swift @@ -0,0 +1,189 @@ +// +// KeychainCredentialStorage.swift +// PortalKit +// +// macOS Keychain credential storage with Data Protection Keychain and fallback. +// + +import Foundation +import Security +import os.log + +public final class KeychainCredentialStorage: CredentialStorage, @unchecked Sendable { + private let log = Logger(subsystem: "com.kovtash.portalkit", category: "keychain") + + public let service: String + public let accessGroup: String? + public let suiteName: String? + public let tokenKey: String + public let certKey: String + + public init( + service: String = "com.kovtash.portalcam.auth", + accessGroup: String? = "ENT9X9U544.com.kovtash.portalcam", + suiteName: String? = "ENT9X9U544.com.kovtash.portalcam", + tokenKey: String = "portalAuthToken", + certKey: String = "portalPinnedCertSha256" + ) { + self.service = service + self.accessGroup = accessGroup + self.suiteName = suiteName + self.tokenKey = tokenKey + self.certKey = certKey + } + + public func getAuthToken() -> String? { + readItem(account: tokenKey) + } + + public func getPinnedCertSha256() -> String? { + readItem(account: certKey) + } + + public func save(token: String, certSha256: String) { + saveItem(account: tokenKey, value: token) + saveItem(account: certKey, value: certSha256.lowercased()) + } + + public func clear() { + deleteItem(account: tokenKey) + deleteItem(account: certKey) + } + + // MARK: - Private Keychain Helpers + + private func saveItem(account: String, value: String) { + let data = Data(value.utf8) + + // Delete from legacy file-based keychain if present + let legacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + _ = SecItemDelete(legacyQuery as CFDictionary) + + // Delete existing item in Data Protection Keychain + var dpDeleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true + ] + if let ag = accessGroup { + dpDeleteQuery[kSecAttrAccessGroup as String] = ag + } + _ = SecItemDelete(dpDeleteQuery as CFDictionary) + + // Try adding with accessGroup if specified + var addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + kSecUseDataProtectionKeychain as String: true + ] + if let ag = accessGroup { + addQuery[kSecAttrAccessGroup as String] = ag + } + + var status = SecItemAdd(addQuery as CFDictionary, nil) + + // If access group fails due to missing entitlements (e.g. running from CLI or test runner), + // retry without access group. + if status == errSecMissingEntitlement && accessGroup != nil { + addQuery.removeValue(forKey: kSecAttrAccessGroup as String) + status = SecItemAdd(addQuery as CFDictionary, nil) + } + + if status != errSecSuccess { + log.error("PortalKit: Keychain save failed for \(account, privacy: .public): \(status)") + } + + // Shared App Group fallback if suiteName provided + if let suite = suiteName { + UserDefaults(suiteName: suite)?.set(value, forKey: account) + } + } + + private func readItem(account: String) -> String? { + // 1. Try Data Protection Keychain with accessGroup + var dpQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseDataProtectionKeychain as String: true + ] + if let ag = accessGroup { + dpQuery[kSecAttrAccessGroup as String] = ag + } + + var item: CFTypeRef? + var status = SecItemCopyMatching(dpQuery as CFDictionary, &item) + + // If failed with missing entitlement, retry without access group + if status == errSecMissingEntitlement && accessGroup != nil { + dpQuery.removeValue(forKey: kSecAttrAccessGroup as String) + status = SecItemCopyMatching(dpQuery as CFDictionary, &item) + } + + if status == errSecSuccess, let data = item as? Data, + let value = String(data: data, encoding: .utf8) { + return value + } + + // 2. Fallback to legacy file-based keychain + let legacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var legacyItem: CFTypeRef? + let legacyStatus = SecItemCopyMatching(legacyQuery as CFDictionary, &legacyItem) + if legacyStatus == errSecSuccess, let data = legacyItem as? Data, + let value = String(data: data, encoding: .utf8) { + return value + } + + // 3. Fallback to App Group UserDefaults + if let suite = suiteName, let value = UserDefaults(suiteName: suite)?.string(forKey: account) { + return value + } + + return nil + } + + private func deleteItem(account: String) { + let legacyQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(legacyQuery as CFDictionary) + + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true + ] + if let ag = accessGroup { + query[kSecAttrAccessGroup as String] = ag + } + SecItemDelete(query as CFDictionary) + + if accessGroup != nil { + query.removeValue(forKey: kSecAttrAccessGroup as String) + SecItemDelete(query as CFDictionary) + } + + if let suite = suiteName { + UserDefaults(suiteName: suite)?.removeObject(forKey: account) + } + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Auth/PortalAuth.swift b/mac/PortalKit/Sources/PortalKit/Auth/PortalAuth.swift new file mode 100644 index 0000000..0a84d54 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Auth/PortalAuth.swift @@ -0,0 +1,58 @@ +// +// PortalAuth.swift +// PortalKit +// +// Static convenience facade around CredentialStorage matching the PortalCam interface. +// + +import Foundation + +public enum PortalAuth { + public static let suite = "ENT9X9U544.com.kovtash.portalcam" + public static let accessGroup = "ENT9X9U544.com.kovtash.portalcam" + public static let service = "com.kovtash.portalcam.auth" + public static let tokenKey = "portalAuthToken" + public static let certKey = "portalPinnedCertSha256" + + private static let lock = NSLock() + private static var _defaultStorage: CredentialStorage = KeychainCredentialStorage( + service: service, + accessGroup: accessGroup, + suiteName: suite, + tokenKey: tokenKey, + certKey: certKey + ) + + public static var defaultStorage: CredentialStorage { + get { + lock.lock() + defer { lock.unlock() } + return _defaultStorage + } + set { + lock.lock() + defer { lock.unlock() } + _defaultStorage = newValue + } + } + + /// Reads the auth token from the default credential storage + public static var token: String? { + defaultStorage.getAuthToken() + } + + /// Reads the pinned certificate SHA-256 fingerprint from the default credential storage + public static var pinnedCertSha256: String? { + defaultStorage.getPinnedCertSha256() + } + + /// Saves both auth token and pinned certificate fingerprint + public static func save(token: String, certSha256: String) { + defaultStorage.save(token: token, certSha256: certSha256) + } + + /// Removes both auth token and pinned certificate + public static func clear() { + defaultStorage.clear() + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Addition.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Addition.swift new file mode 100644 index 0000000..34f4d44 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Addition.swift @@ -0,0 +1,126 @@ +// +// Addition.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + //MARK: Addition + + /// Add `word` to this integer in place. + /// `word` is shifted `shift` words to the left before being added. + /// + /// - Complexity: O(max(count, shift)) + internal mutating func addWord(_ word: Word, shiftedBy shift: Int = 0) { + precondition(shift >= 0) + var carry = word + var i = shift + while carry > 0 { + let (d, c) = self[i].addingReportingOverflow(carry) + self[i] = d + carry = (c ? 1 : 0) + i += 1 + } + } + + /// Add the digit `d` to this integer and return the result. + /// `d` is shifted `shift` words to the left before being added. + /// + /// - Complexity: O(max(count, shift)) + internal func addingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt { + var r = self + r.addWord(word, shiftedBy: shift) + return r + } + + /// Add `b` to this integer in place. + /// `b` is shifted `shift` words to the left before being added. + /// + /// - Complexity: O(max(count, b.count + shift)) + internal mutating func add(_ b: BigUInt, shiftedBy shift: Int = 0) { + precondition(shift >= 0) + var carry = false + var bi = 0 + let bc = b.count + while bi < bc || carry { + let ai = shift + bi + let (d, c) = self[ai].addingReportingOverflow(b[bi]) + if carry { + let (d2, c2) = d.addingReportingOverflow(1) + self[ai] = d2 + carry = c || c2 + } + else { + self[ai] = d + carry = c + } + bi += 1 + } + } + + /// Add `b` to this integer and return the result. + /// `b` is shifted `shift` words to the left before being added. + /// + /// - Complexity: O(max(count, b.count + shift)) + internal func adding(_ b: BigUInt, shiftedBy shift: Int = 0) -> BigUInt { + var r = self + r.add(b, shiftedBy: shift) + return r + } + + /// Increment this integer by one. If `shift` is non-zero, it selects + /// the word that is to be incremented. + /// + /// - Complexity: O(count + shift) + internal mutating func increment(shiftedBy shift: Int = 0) { + self.addWord(1, shiftedBy: shift) + } + + /// Add `a` and `b` together and return the result. + /// + /// - Complexity: O(max(a.count, b.count)) + public static func +(a: BigUInt, b: BigUInt) -> BigUInt { + return a.adding(b) + } + + /// Add `a` and `b` together, and store the sum in `a`. + /// + /// - Complexity: O(max(a.count, b.count)) + public static func +=(a: inout BigUInt, b: BigUInt) { + a.add(b, shiftedBy: 0) + } +} + +extension BigInt { + /// Add `a` to `b` and return the result. + public static func +(a: BigInt, b: BigInt) -> BigInt { + switch (a.sign, b.sign) { + case (.plus, .plus): + return BigInt(sign: .plus, magnitude: a.magnitude + b.magnitude) + case (.minus, .minus): + return BigInt(sign: .minus, magnitude: a.magnitude + b.magnitude) + case (.plus, .minus): + if a.magnitude >= b.magnitude { + return BigInt(sign: .plus, magnitude: a.magnitude - b.magnitude) + } + else { + return BigInt(sign: .minus, magnitude: b.magnitude - a.magnitude) + } + case (.minus, .plus): + if b.magnitude >= a.magnitude { + return BigInt(sign: .plus, magnitude: b.magnitude - a.magnitude) + } + else { + return BigInt(sign: .minus, magnitude: a.magnitude - b.magnitude) + } + } + } + + /// Add `b` to `a` in place. + public static func +=(a: inout BigInt, b: BigInt) { + a = a + b + } +} + diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/BigInt.swift b/mac/PortalKit/Sources/PortalKit/BigInt/BigInt.swift new file mode 100644 index 0000000..64fe48d --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/BigInt.swift @@ -0,0 +1,74 @@ +// +// BigInt.swift +// BigInt +// +// Created by Károly Lőrentey on 2015-12-27. +// Copyright © 2016-2017 Károly Lőrentey. +// + +//MARK: BigInt + +/// An arbitary precision signed integer type, also known as a "big integer". +/// +/// Operations on big integers never overflow, but they might take a long time to execute. +/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers. +/// +/// This particular big integer type uses base-2^64 digits to represent integers. +/// +/// `BigInt` is essentially a tiny wrapper that extends `BigUInt` with a sign bit and provides signed integer +/// operations. Both the underlying absolute value and the negative/positive flag are available as read-write +/// properties. +/// +/// Not all algorithms of `BigUInt` are available for `BigInt` values; for example, there is no square root or +/// primality test for signed integers. When you need to call one of these, just extract the absolute value: +/// +/// ```Swift +/// BigInt(255).magnitude.isPrime() // Returns false +/// ``` +/// +public struct BigInt: SignedInteger, Sendable { + public enum Sign: Sendable { + case plus + case minus + } + + public typealias Magnitude = BigUInt + + /// The type representing a digit in `BigInt`'s underlying number system. + public typealias Word = BigUInt.Word + + public static var isSigned: Bool { + return true + } + + /// The absolute value of this integer. + public var magnitude: BigUInt + + /// True iff the value of this integer is negative. + public var sign: Sign + + /// Initializes a new big integer with the provided absolute number and sign flag. + public init(sign: Sign, magnitude: BigUInt) { + self.sign = (magnitude.isZero ? .plus : sign) + self.magnitude = magnitude + } + + /// Return true iff this integer is zero. + /// + /// - Complexity: O(1) + public var isZero: Bool { + return magnitude.isZero + } + + /// Returns `-1` if this value is negative and `1` if it’s positive; otherwise, `0`. + /// + /// - Returns: The sign of this number, expressed as an integer of the same type. + public func signum() -> BigInt { + switch sign { + case .plus: + return isZero ? 0 : 1 + case .minus: + return -1 + } + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/BigUInt.swift b/mac/PortalKit/Sources/PortalKit/BigInt/BigUInt.swift new file mode 100644 index 0000000..e984dcf --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/BigUInt.swift @@ -0,0 +1,386 @@ +// +// BigUInt.swift +// BigInt +// +// Created by Károly Lőrentey on 2015-12-26. +// Copyright © 2016-2017 Károly Lőrentey. +// + +/// An arbitary precision unsigned integer type, also known as a "big integer". +/// +/// Operations on big integers never overflow, but they may take a long time to execute. +/// The amount of memory (and address space) available is the only constraint to the magnitude of these numbers. +/// +/// This particular big integer type uses base-2^64 digits to represent integers; you can think of it as a wrapper +/// around `Array`. (In fact, `BigUInt` only uses an array if there are more than two digits.) +public struct BigUInt: UnsignedInteger, Sendable { + /// The type representing a digit in `BigUInt`'s underlying number system. + public typealias Word = UInt + + /// The storage variants of a `BigUInt`. + enum Kind { + /// Value consists of the two specified words (low and high). Either or both words may be zero. + case inline(Word, Word) + /// Words are stored in a slice of the storage array. + case slice(from: Int, to: Int) + /// Words are stored in the storage array. + case array + } + + fileprivate(set) var kind: Kind // Internal for testing only + fileprivate(set) var storage: [Word] // Internal for testing only; stored separately to prevent COW copies + + /// Initializes a new BigUInt with value 0. + public init() { + self.kind = .inline(0, 0) + self.storage = [] + } + + internal init(word: Word) { + self.kind = .inline(word, 0) + self.storage = [] + } + + internal init(low: Word, high: Word) { + self.kind = .inline(low, high) + self.storage = [] + } + + /// Initializes a new BigUInt with the specified digits. The digits are ordered from least to most significant. + public init(words: [Word]) { + self.kind = .array + self.storage = words + normalize() + } + + internal init(words: [Word], from startIndex: Int, to endIndex: Int) { + self.kind = .slice(from: startIndex, to: endIndex) + self.storage = words + normalize() + } +} + +extension BigUInt { + public static var isSigned: Bool { + return false + } + + /// Return true iff this integer is zero. + /// + /// - Complexity: O(1) + public var isZero: Bool { + switch kind { + case .inline(0, 0): return true + case .array: return storage.isEmpty + default: + return false + } + } + + /// Returns `1` if this value is, positive; otherwise, `0`. + /// + /// - Returns: The sign of this number, expressed as an integer of the same type. + public func signum() -> BigUInt { + return isZero ? 0 : 1 + } +} + +extension BigUInt { + mutating func ensureArray() { + switch kind { + case let .inline(w0, w1): + kind = .array + storage = w1 != 0 ? [w0, w1] + : w0 != 0 ? [w0] + : [] + case let .slice(from: start, to: end): + kind = .array + storage = Array(storage[start ..< end]) + case .array: + break + } + } + + var capacity: Int { + guard case .array = kind else { return 0 } + return storage.capacity + } + + mutating func reserveCapacity(_ minimumCapacity: Int) { + switch kind { + case let .inline(w0, w1): + kind = .array + storage.reserveCapacity(minimumCapacity) + if w1 != 0 { + storage.append(w0) + storage.append(w1) + } + else if w0 != 0 { + storage.append(w0) + } + case let .slice(from: start, to: end): + kind = .array + var words: [Word] = [] + words.reserveCapacity(Swift.max(end - start, minimumCapacity)) + words.append(contentsOf: storage[start ..< end]) + storage = words + case .array: + storage.reserveCapacity(minimumCapacity) + } + } + + /// Gets rid of leading zero digits in the digit array and converts slices into inline digits when possible. + internal mutating func normalize() { + switch kind { + case .slice(from: let start, to: var end): + assert(start >= 0 && end <= storage.count && start <= end) + while start < end, storage[end - 1] == 0 { + end -= 1 + } + switch end - start { + case 0: + kind = .inline(0, 0) + storage = [] + case 1: + kind = .inline(storage[start], 0) + storage = [] + case 2: + kind = .inline(storage[start], storage[start + 1]) + storage = [] + case storage.count: + assert(start == 0) + kind = .array + default: + kind = .slice(from: start, to: end) + } + case .array where storage.last == 0: + while storage.last == 0 { + storage.removeLast() + } + default: + break + } + } + + /// Set this integer to 0 without releasing allocated storage capacity (if any). + mutating func clear() { + self.load(0) + } + + /// Set this integer to `value` by copying its digits without releasing allocated storage capacity (if any). + mutating func load(_ value: BigUInt) { + switch kind { + case .inline, .slice: + self = value + case .array: + self.storage.removeAll(keepingCapacity: true) + self.storage.append(contentsOf: value.words) + } + } +} + +extension BigUInt { + //MARK: Collection-like members + + /// The number of digits in this integer, excluding leading zero digits. + var count: Int { + switch kind { + case let .inline(w0, w1): + return w1 != 0 ? 2 + : w0 != 0 ? 1 + : 0 + case let .slice(from: start, to: end): + return end - start + case .array: + return storage.count + } + } + + /// Get or set a digit at a given index. + /// + /// - Note: Unlike a normal collection, it is OK for the index to be greater than or equal to `endIndex`. + /// The subscripting getter returns zero for indexes beyond the most significant digit. + /// Setting these extended digits automatically appends new elements to the underlying digit array. + /// - Requires: index >= 0 + /// - Complexity: The getter is O(1). The setter is O(1) if the conditions below are true; otherwise it's O(count). + /// - The integer's storage is not shared with another integer + /// - The integer wasn't created as a slice of another integer + /// - `index < count` + subscript(_ index: Int) -> Word { + get { + precondition(index >= 0) + switch (kind, index) { + case (.inline(let w0, _), 0): return w0 + case (.inline(_, let w1), 1): return w1 + case (.slice(from: let start, to: let end), _) where index < end - start: + return storage[start + index] + case (.array, _) where index < storage.count: + return storage[index] + default: + return 0 + } + } + set(word) { + precondition(index >= 0) + switch (kind, index) { + case let (.inline(_, w1), 0): + kind = .inline(word, w1) + case let (.inline(w0, _), 1): + kind = .inline(w0, word) + case let (.slice(from: start, to: end), _) where index < end - start: + replace(at: index, with: word) + case (.array, _) where index < storage.count: + replace(at: index, with: word) + default: + extend(at: index, with: word) + } + } + } + + private mutating func replace(at index: Int, with word: Word) { + ensureArray() + precondition(index < storage.count) + storage[index] = word + if word == 0, index == storage.count - 1 { + normalize() + } + } + + private mutating func extend(at index: Int, with word: Word) { + guard word != 0 else { return } + reserveCapacity(index + 1) + precondition(index >= storage.count) + storage.append(contentsOf: repeatElement(0, count: index - storage.count)) + storage.append(word) + } + + /// Returns an integer built from the digits of this integer in the given range. + internal func extract(_ bounds: Range) -> BigUInt { + switch kind { + case let .inline(w0, w1): + let bounds = bounds.clamped(to: 0 ..< 2) + if bounds == 0 ..< 2 { + return BigUInt(low: w0, high: w1) + } + else if bounds == 0 ..< 1 { + return BigUInt(word: w0) + } + else if bounds == 1 ..< 2 { + return BigUInt(word: w1) + } + else { + return BigUInt() + } + case let .slice(from: start, to: end): + let s = Swift.min(end, start + Swift.max(bounds.lowerBound, 0)) + let e = Swift.max(s, (bounds.upperBound > end - start ? end : start + bounds.upperBound)) + return BigUInt(words: storage, from: s, to: e) + case .array: + let b = bounds.clamped(to: storage.startIndex ..< storage.endIndex) + return BigUInt(words: storage, from: b.lowerBound, to: b.upperBound) + } + } + + internal func extract(_ bounds: Bounds) -> BigUInt where Bounds.Bound == Int { + return self.extract(bounds.relative(to: 0 ..< Int.max)) + } +} + +extension BigUInt { + internal mutating func shiftRight(byWords amount: Int) { + assert(amount >= 0) + guard amount > 0 else { return } + switch kind { + case let .inline(_, w1) where amount == 1: + kind = .inline(w1, 0) + case .inline(_, _): + kind = .inline(0, 0) + case let .slice(from: start, to: end): + let s = start + amount + if s >= end { + kind = .inline(0, 0) + } + else { + kind = .slice(from: s, to: end) + normalize() + } + case .array: + if amount >= storage.count { + storage.removeAll(keepingCapacity: true) + } + else { + storage.removeFirst(amount) + } + } + } + + internal mutating func shiftLeft(byWords amount: Int) { + assert(amount >= 0) + guard amount > 0 else { return } + guard !isZero else { return } + switch kind { + case let .inline(w0, 0) where amount == 1: + kind = .inline(0, w0) + case let .inline(w0, w1): + let c = (w1 == 0 ? 1 : 2) + storage.reserveCapacity(amount + c) + storage.append(contentsOf: repeatElement(0, count: amount)) + storage.append(w0) + if w1 != 0 { + storage.append(w1) + } + kind = .array + case let .slice(from: start, to: end): + var words: [Word] = [] + words.reserveCapacity(amount + count) + words.append(contentsOf: repeatElement(0, count: amount)) + words.append(contentsOf: storage[start ..< end]) + storage = words + kind = .array + case .array: + storage.insert(contentsOf: repeatElement(0, count: amount), at: 0) + } + } +} + +extension BigUInt { + //MARK: Low and High + + /// Split this integer into a high-order and a low-order part. + /// + /// - Requires: count > 1 + /// - Returns: `(low, high)` such that + /// - `self == low.add(high, shiftedBy: middleIndex)` + /// - `high.width <= floor(width / 2)` + /// - `low.width <= ceil(width / 2)` + /// - Complexity: Typically O(1), but O(count) in the worst case, because high-order zero digits need to be removed after the split. + internal var split: (high: BigUInt, low: BigUInt) { + precondition(count > 1) + let mid = middleIndex + return (self.extract(mid...), self.extract(.. 1 + internal var low: BigUInt { + return self.extract(0 ..< middleIndex) + } + + /// The high-order half of this BigUInt. + /// + /// - Returns: `self[middleIndex ..< count]` + /// - Requires: count > 1 + internal var high: BigUInt { + return self.extract(middleIndex ..< count) + } +} + diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Bitwise Ops.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Bitwise Ops.swift new file mode 100644 index 0000000..0d00148 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Bitwise Ops.swift @@ -0,0 +1,121 @@ +// +// Bitwise Ops.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +//MARK: Bitwise Operations + +extension BigUInt { + /// Return the ones' complement of `a`. + /// + /// - Complexity: O(a.count) + public static prefix func ~(a: BigUInt) -> BigUInt { + return BigUInt(words: a.words.map { ~$0 }) + } + + /// Calculate the bitwise OR of `a` and `b`, and store the result in `a`. + /// + /// - Complexity: O(max(a.count, b.count)) + public static func |= (a: inout BigUInt, b: BigUInt) { + a.reserveCapacity(b.count) + for i in 0 ..< b.count { + a[i] |= b[i] + } + } + + /// Calculate the bitwise AND of `a` and `b` and return the result. + /// + /// - Complexity: O(max(a.count, b.count)) + public static func &= (a: inout BigUInt, b: BigUInt) { + for i in 0 ..< Swift.max(a.count, b.count) { + a[i] &= b[i] + } + } + + /// Calculate the bitwise XOR of `a` and `b` and return the result. + /// + /// - Complexity: O(max(a.count, b.count)) + public static func ^= (a: inout BigUInt, b: BigUInt) { + a.reserveCapacity(b.count) + for i in 0 ..< b.count { + a[i] ^= b[i] + } + } +} + +extension BigInt { + public static prefix func ~(x: BigInt) -> BigInt { + switch x.sign { + case .plus: + return BigInt(sign: .minus, magnitude: x.magnitude + 1) + case .minus: + return BigInt(sign: .plus, magnitude: x.magnitude - 1) + } + } + + public static func &(lhs: inout BigInt, rhs: BigInt) -> BigInt { + let left = lhs.words + let right = rhs.words + // Note we aren't using left.count/right.count here; we account for the sign bit separately later. + let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count) + var words: [UInt] = [] + words.reserveCapacity(count) + for i in 0 ..< count { + words.append(left[i] & right[i]) + } + if lhs.sign == .minus && rhs.sign == .minus { + words.twosComplement() + return BigInt(sign: .minus, magnitude: BigUInt(words: words)) + } + return BigInt(sign: .plus, magnitude: BigUInt(words: words)) + } + + public static func |(lhs: inout BigInt, rhs: BigInt) -> BigInt { + let left = lhs.words + let right = rhs.words + // Note we aren't using left.count/right.count here; we account for the sign bit separately later. + let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count) + var words: [UInt] = [] + words.reserveCapacity(count) + for i in 0 ..< count { + words.append(left[i] | right[i]) + } + if lhs.sign == .minus || rhs.sign == .minus { + words.twosComplement() + return BigInt(sign: .minus, magnitude: BigUInt(words: words)) + } + return BigInt(sign: .plus, magnitude: BigUInt(words: words)) + } + + public static func ^(lhs: inout BigInt, rhs: BigInt) -> BigInt { + let left = lhs.words + let right = rhs.words + // Note we aren't using left.count/right.count here; we account for the sign bit separately later. + let count = Swift.max(lhs.magnitude.count, rhs.magnitude.count) + var words: [UInt] = [] + words.reserveCapacity(count) + for i in 0 ..< count { + words.append(left[i] ^ right[i]) + } + if (lhs.sign == .minus) != (rhs.sign == .minus) { + words.twosComplement() + return BigInt(sign: .minus, magnitude: BigUInt(words: words)) + } + return BigInt(sign: .plus, magnitude: BigUInt(words: words)) + } + + public static func &=(lhs: inout BigInt, rhs: BigInt) { + lhs = lhs & rhs + } + + public static func |=(lhs: inout BigInt, rhs: BigInt) { + lhs = lhs | rhs + } + + public static func ^=(lhs: inout BigInt, rhs: BigInt) { + lhs = lhs ^ rhs + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Codable.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Codable.swift new file mode 100644 index 0000000..2bac869 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Codable.swift @@ -0,0 +1,169 @@ +// +// Codable.swift +// BigInt +// +// Created by Károly Lőrentey on 2017-8-11. +// Copyright © 2016-2017 Károly Lőrentey. +// + + +// Little-endian to big-endian +struct Units: RandomAccessCollection +where Words.Element: FixedWidthInteger, Words.Index == Int { + typealias Word = Words.Element + let words: Words + init(of type: Unit.Type, _ words: Words) { + precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0) + self.words = words + } + var count: Int { return (words.count * Word.bitWidth + Unit.bitWidth - 1) / Unit.bitWidth } + var startIndex: Int { return 0 } + var endIndex: Int { return count } + subscript(_ index: Int) -> Unit { + let index = count - 1 - index + if Unit.bitWidth == Word.bitWidth { + return Unit(words[index]) + } + else if Unit.bitWidth > Word.bitWidth { + let c = Unit.bitWidth / Word.bitWidth + var unit: Unit = 0 + var j = 0 + for i in (c * index) ..< Swift.min(c * (index + 1), words.endIndex) { + unit |= Unit(words[i]) << j + j += Word.bitWidth + } + return unit + } + // Unit.bitWidth < Word.bitWidth + let c = Word.bitWidth / Unit.bitWidth + let i = index / c + let j = index % c + return Unit(truncatingIfNeeded: words[i] >> (j * Unit.bitWidth)) + } +} + +extension Array where Element: FixedWidthInteger { + // Big-endian to little-endian + init(count: Int?, generator: () throws -> Unit?) rethrows { + typealias Word = Element + precondition(Word.bitWidth % Unit.bitWidth == 0 || Unit.bitWidth % Word.bitWidth == 0) + self = [] + if Unit.bitWidth == Word.bitWidth { + if let count = count { + self.reserveCapacity(count) + } + while let unit = try generator() { + self.append(Word(unit)) + } + } + else if Unit.bitWidth > Word.bitWidth { + let wordsPerUnit = Unit.bitWidth / Word.bitWidth + if let count = count { + self.reserveCapacity(count * wordsPerUnit) + } + while let unit = try generator() { + var shift = Unit.bitWidth - Word.bitWidth + while shift >= 0 { + self.append(Word(truncatingIfNeeded: unit >> shift)) + shift -= Word.bitWidth + } + } + } + else { + let unitsPerWord = Word.bitWidth / Unit.bitWidth + if let count = count { + self.reserveCapacity((count + unitsPerWord - 1) / unitsPerWord) + } + var word: Word = 0 + var c = 0 + while let unit = try generator() { + word <<= Unit.bitWidth + word |= Word(unit) + c += Unit.bitWidth + if c == Word.bitWidth { + self.append(word) + word = 0 + c = 0 + } + } + if c > 0 { + self.append(word << c) + var shifted: Word = 0 + for i in self.indices { + let word = self[i] + self[i] = shifted | (word >> c) + shifted = word << (Word.bitWidth - c) + } + } + } + self.reverse() + } +} + +extension BigInt: Codable { + public init(from decoder: Decoder) throws { + if let container = try? decoder.singleValueContainer(), let stringValue = try? container.decode(String.self) { + if stringValue.hasPrefix("0x") || stringValue.hasPrefix("0X") { + guard let bigUInt = BigUInt(stringValue.dropFirst(2), radix: 16) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid hexadecimal BigInt string") + } + self.init(sign: .plus, magnitude: bigUInt) + } else { + guard let bigInt = BigInt(stringValue) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid decimal BigInt string") + } + self = bigInt + } + } else { + var container = try decoder.unkeyedContainer() + + // Decode sign + let sign: BigInt.Sign + switch try container.decode(String.self) { + case "+": + sign = .plus + case "-": + sign = .minus + default: + throw DecodingError.dataCorrupted(.init(codingPath: container.codingPath, + debugDescription: "Invalid big integer sign")) + } + + // Decode magnitude + let words = try [UInt](count: container.count?.advanced(by: -1)) { () -> UInt64? in + guard !container.isAtEnd else { return nil } + return try container.decode(UInt64.self) + } + let magnitude = BigUInt(words: words) + + self.init(sign: sign, magnitude: magnitude) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(sign == .plus ? "+" : "-") + let units = Units(of: UInt64.self, self.magnitude.words) + if units.isEmpty { + try container.encode(0 as UInt64) + } + else { + try container.encode(contentsOf: units) + } + } +} + +extension BigUInt: Codable { + public init(from decoder: Decoder) throws { + let value = try BigInt(from: decoder) + guard value.sign == .plus else { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, + debugDescription: "BigUInt cannot hold a negative value")) + } + self = value.magnitude + } + + public func encode(to encoder: Encoder) throws { + try BigInt(sign: .plus, magnitude: self).encode(to: encoder) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Comparable.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Comparable.swift new file mode 100644 index 0000000..dc17b2d --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Comparable.swift @@ -0,0 +1,73 @@ +// +// Comparable.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +#if canImport(Foundation) +import Foundation +#endif + +extension BigUInt: Comparable { + #if !canImport(Foundation) + public enum ComparisonResult: Sendable, Comparable, Hashable { + case orderedDescending + case orderedSame + case orderedAscending + } + #endif + + //MARK: Comparison + + /// Compare `a` to `b` and return an `NSComparisonResult` indicating their order. + /// + /// - Complexity: O(count) + public static func compare(_ a: BigUInt, _ b: BigUInt) -> ComparisonResult { + if a.count != b.count { return a.count > b.count ? .orderedDescending : .orderedAscending } + for i in (0 ..< a.count).reversed() { + let ad = a[i] + let bd = b[i] + if ad != bd { return ad > bd ? .orderedDescending : .orderedAscending } + } + return .orderedSame + } + + /// Return true iff `a` is equal to `b`. + /// + /// - Complexity: O(count) + public static func ==(a: BigUInt, b: BigUInt) -> Bool { + return BigUInt.compare(a, b) == .orderedSame + } + + /// Return true iff `a` is less than `b`. + /// + /// - Complexity: O(count) + public static func <(a: BigUInt, b: BigUInt) -> Bool { + return BigUInt.compare(a, b) == .orderedAscending + } +} + +extension BigInt: Comparable { + /// Return true iff `a` is equal to `b`. + public static func ==(a: BigInt, b: BigInt) -> Bool { + return a.sign == b.sign && a.magnitude == b.magnitude + } + + /// Return true iff `a` is less than `b`. + public static func <(a: BigInt, b: BigInt) -> Bool { + switch (a.sign, b.sign) { + case (.plus, .plus): + return a.magnitude < b.magnitude + case (.plus, .minus): + return false + case (.minus, .plus): + return true + case (.minus, .minus): + return a.magnitude > b.magnitude + } + } +} + + diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Data Conversion.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Data Conversion.swift new file mode 100644 index 0000000..3b35fe8 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Data Conversion.swift @@ -0,0 +1,165 @@ +// +// Data Conversion.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-04. +// Copyright © 2016-2017 Károly Lőrentey. +// + +#if canImport(Foundation) +import Foundation +#endif + +extension BigUInt { + //MARK: NSData Conversion + + /// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer + public init(_ buffer: UnsafeRawBufferPointer) { + // This assumes Word is binary. + precondition(Word.bitWidth % 8 == 0) + + self.init() + + let length = buffer.count + guard length > 0 else { return } + let bytesPerDigit = Word.bitWidth / 8 + var index = length / bytesPerDigit + var c = bytesPerDigit - length % bytesPerDigit + if c == bytesPerDigit { + c = 0 + index -= 1 + } + + var word: Word = 0 + for byte in buffer { + word <<= 8 + word += Word(byte) + c += 1 + if c == bytesPerDigit { + self[index] = word + index -= 1 + c = 0 + word = 0 + } + } + assert(c == 0 && word == 0 && index == -1) + } + + /// Return a `UnsafeRawBufferPointer` buffer that contains the base-256 representation of this integer, in network (big-endian) byte order. + public func serializeToBuffer() -> UnsafeRawBufferPointer { + // This assumes Digit is binary. + precondition(Word.bitWidth % 8 == 0) + + let byteCount = (self.bitWidth + 7) / 8 + + let buffer = UnsafeMutableBufferPointer.allocate(capacity: byteCount) + + guard byteCount > 0 else { return UnsafeRawBufferPointer(start: buffer.baseAddress, count: 0) } + + var i = byteCount - 1 + for var word in self.words { + for _ in 0 ..< Word.bitWidth / 8 { + buffer[i] = UInt8(word & 0xFF) + word >>= 8 + if i == 0 { + assert(word == 0) + break + } + i -= 1 + } + } + let zeroOut = UnsafeMutableBufferPointer(start: buffer.baseAddress, count: i) + zeroOut.initialize(repeating: 0) + return UnsafeRawBufferPointer(start: buffer.baseAddress, count: byteCount) + } + + #if canImport(Foundation) + /// Initializes an integer from the bits stored inside a piece of `Data`. + /// The data is assumed to be in network (big-endian) byte order. + public init(_ data: Data) { + self = data.withUnsafeBytes({ buffer in + BigUInt(buffer) + }) + } + + /// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order. + public func serialize() -> Data { + let buffer = serializeToBuffer() + defer { buffer.deallocate() } + guard + let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:)) + else { return Data() } + + return Data(bytes: pointer, count: buffer.count) + } + #endif +} + +extension BigInt { + + /// Initialize a BigInt from bytes accessed from an UnsafeRawBufferPointer, + /// where the first byte indicates sign (0 for positive, 1 for negative) + public init(_ buffer: UnsafeRawBufferPointer) { + // This assumes Word is binary. + precondition(Word.bitWidth % 8 == 0) + + self.init() + + let length = buffer.count + + // Serialized data for a BigInt should contain at least 2 bytes: one representing + // the sign, and another for the non-zero magnitude. Zero is represented by an + // empty Data struct, and negative zero is not supported. + guard length > 1, let firstByte = buffer.first else { return } + + // The first byte gives the sign + // This byte is compared to a bitmask to allow additional functionality to be added + // to this byte in the future. + self.sign = firstByte & 0b1 == 0 ? .plus : .minus + + self.magnitude = BigUInt(UnsafeRawBufferPointer(rebasing: buffer.dropFirst(1))) + } + + /// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative) + public func serializeToBuffer() -> UnsafeRawBufferPointer { + // Create a data object for the magnitude portion of the BigInt + let magnitudeBuffer = self.magnitude.serializeToBuffer() + + // Similar to BigUInt, a value of 0 should return an empty buffer + guard magnitudeBuffer.count > 0 else { return magnitudeBuffer } + + // Create a new buffer for the signed BigInt value + let newBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: magnitudeBuffer.count + 1, alignment: 8) + let magnitudeSection = UnsafeMutableRawBufferPointer(rebasing: newBuffer[1...]) + magnitudeSection.copyBytes(from: magnitudeBuffer) + magnitudeBuffer.deallocate() + + // The first byte should be 0 for a positive value, or 1 for a negative value + // i.e., the sign bit is the LSB + newBuffer[0] = self.sign == .plus ? 0 : 1 + + return UnsafeRawBufferPointer(start: newBuffer.baseAddress, count: newBuffer.count) + } + + #if canImport(Foundation) + /// Initializes an integer from the bits stored inside a piece of `Data`. + /// The data is assumed to be in network (big-endian) byte order with a first + /// byte to represent the sign (0 for positive, 1 for negative) + public init(_ data: Data) { + self = data.withUnsafeBytes({ buffer in + BigInt(buffer) + }) + } + + /// Return a `Data` value that contains the base-256 representation of this integer, in network (big-endian) byte order and a prepended byte to indicate the sign (0 for positive, 1 for negative) + public func serialize() -> Data { + let buffer = serializeToBuffer() + defer { buffer.deallocate() } + guard + let pointer = buffer.baseAddress.map(UnsafeMutableRawPointer.init(mutating:)) + else { return Data() } + + return Data(bytes: pointer, count: buffer.count) + } + #endif +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Division.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Division.swift new file mode 100644 index 0000000..4b30dbb --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Division.swift @@ -0,0 +1,375 @@ +// +// Division.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +//MARK: Full-width multiplication and division + +// TODO: Return to `where Magnitude == Self` when SR-13491 is resolved +extension FixedWidthInteger { + private var halfShift: Self { + return Self(Self.bitWidth / 2) + + } + private var high: Self { + return self &>> halfShift + } + + private var low: Self { + let mask: Self = 1 &<< halfShift - 1 + return self & mask + } + + private var upshifted: Self { + return self &<< halfShift + } + + private var split: (high: Self, low: Self) { + return (self.high, self.low) + } + + private init(_ value: (high: Self, low: Self)) { + self = value.high.upshifted + value.low + } + + /// Divide the double-width integer `dividend` by `self` and return the quotient and remainder. + /// + /// - Requires: `dividend.high < self`, so that the result will fit in a single digit. + /// - Complexity: O(1) with 2 divisions, 6 multiplications and ~12 or so additions/subtractions. + internal func fastDividingFullWidth(_ dividend: (high: Self, low: Self.Magnitude)) -> (quotient: Self, remainder: Self) { + // Division is complicated; doing it with single-digit operations is maddeningly complicated. + // This is a Swift adaptation for "divlu2" in Hacker's Delight, + // which is in turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1). + precondition(dividend.high < self) + + // This replaces the implementation in stdlib, which is much slower. + // FIXME: Speed up stdlib. It should use full-width idiv on Intel processors, and + // fall back to a reasonably fast algorithm elsewhere. + + // The trick here is that we're actually implementing a 4/2 long division using half-words, + // with the long division loop unrolled into two 3/2 half-word divisions. + // Luckily, 3/2 half-word division can be approximated by a single full-word division operation + // that, when the divisor is normalized, differs from the correct result by at most 2. + + /// Find the half-word quotient in `u / vn`, which must be normalized. + /// `u` contains three half-words in the two halves of `u.high` and the lower half of + /// `u.low`. (The weird distribution makes for a slightly better fit with the input.) + /// `vn` contains the normalized divisor, consisting of two half-words. + /// + /// - Requires: u.high < vn && u.low.high == 0 && vn.leadingZeroBitCount == 0 + func quotient(dividing u: (high: Self, low: Self), by vn: Self) -> Self { + let (vn1, vn0) = vn.split + // Get approximate quotient. + let (q, r) = u.high.quotientAndRemainder(dividingBy: vn1) + let p = q * vn0 + // q is often already correct, but sometimes the approximation overshoots by at most 2. + // The code that follows checks for this while being careful to only perform single-digit operations. + if q.high == 0 && p <= r.upshifted + u.low { return q } + let r2 = r + vn1 + if r2.high != 0 { return q - 1 } + if (q - 1).high == 0 && p - vn0 <= r2.upshifted + u.low { return q - 1 } + //assert((r + 2 * vn1).high != 0 || p - 2 * vn0 <= (r + 2 * vn1).upshifted + u.low) + return q - 2 + } + /// Divide 3 half-digits by 2 half-digits to get a half-digit quotient and a full-digit remainder. + /// + /// - Requires: u.high < v && u.low.high == 0 && vn.width = width(Digit) + func quotientAndRemainder(dividing u: (high: Self, low: Self), by v: Self) -> (quotient: Self, remainder: Self) { + let q = quotient(dividing: u, by: v) + // Note that `uh.low` masks off a couple of bits, and `q * v` and the + // subtraction are likely to overflow. Despite this, the end result (remainder) will + // still be correct and it will fit inside a single (full) Digit. + let r = Self(u) &- q &* v + assert(r < v) + return (q, r) + } + + // Normalize the dividend and the divisor (self) such that the divisor has no leading zeroes. + let z = Self(self.leadingZeroBitCount) + let w = Self(Self.bitWidth) - z + let vn = self << z + + let un32 = (z == 0 ? dividend.high : (dividend.high &<< z) | ((dividend.low as! Self) &>> w)) // No bits are lost + let un10 = dividend.low &<< z + let (un1, un0) = un10.split + + // Divide `(un32,un10)` by `vn`, splitting the full 4/2 division into two 3/2 ones. + let (q1, un21) = quotientAndRemainder(dividing: (un32, (un1 as! Self)), by: vn) + let (q0, rn) = quotientAndRemainder(dividing: (un21, (un0 as! Self)), by: vn) + + // Undo normalization of the remainder and combine the two halves of the quotient. + let mod = rn >> z + let div = Self((q1, q0)) + return (div, mod) + } + + /// Return the quotient of the 3/2-word division `x/y` as a single word. + /// + /// - Requires: (x.0, x.1) <= y && y.0.high != 0 + /// - Returns: The exact value when it fits in a single word, otherwise `Self`. + static func approximateQuotient(dividing x: (Self, Self, Self), by y: (Self, Self)) -> Self { + // Start with q = (x.0, x.1) / y.0, (or Word.max on overflow) + var q: Self + var r: Self + if x.0 == y.0 { + q = Self.max + let (s, o) = x.0.addingReportingOverflow(x.1) + if o { return q } + r = s + } + else { + (q, r) = y.0.fastDividingFullWidth((x.0, (x.1 as! Magnitude))) + } + // Now refine q by considering x.2 and y.1. + // Note that since y is normalized, q * y - x is between 0 and 2. + let (ph, pl) = q.multipliedFullWidth(by: y.1) + if ph < r || (ph == r && pl <= x.2) { return q } + + let (r1, ro) = r.addingReportingOverflow(y.0) + if ro { return q - 1 } + + let (pl1, so) = pl.subtractingReportingOverflow((y.1 as! Magnitude)) + let ph1 = (so ? ph - 1 : ph) + + if ph1 < r1 || (ph1 == r1 && pl1 <= x.2) { return q - 1 } + return q - 2 + } +} + +extension BigUInt { + //MARK: Division + + /// Divide this integer by the word `y`, leaving the quotient in its place and returning the remainder. + /// + /// - Requires: y > 0 + /// - Complexity: O(count) + internal mutating func divide(byWord y: Word) -> Word { + precondition(y > 0) + if y == 1 { return 0 } + + var remainder: Word = 0 + for i in (0 ..< count).reversed() { + let u = self[i] + (self[i], remainder) = y.fastDividingFullWidth((remainder, u)) + } + return remainder + } + + /// Divide this integer by the word `y` and return the resulting quotient and remainder. + /// + /// - Requires: y > 0 + /// - Returns: (quotient, remainder) where quotient = floor(x/y), remainder = x - quotient * y + /// - Complexity: O(x.count) + internal func quotientAndRemainder(dividingByWord y: Word) -> (quotient: BigUInt, remainder: Word) { + var div = self + let mod = div.divide(byWord: y) + return (div, mod) + } + + /// Divide `x` by `y`, putting the quotient in `x` and the remainder in `y`. + /// Reusing integers like this reduces the number of allocations during the calculation. + static func divide(_ x: inout BigUInt, by y: inout BigUInt) { + // This is a Swift adaptation of "divmnu" from Hacker's Delight, which is in + // turn a C adaptation of Knuth's Algorithm D (TAOCP vol 2, 4.3.1). + + precondition(!y.isZero) + + // First, let's take care of the easy cases. + if x < y { + (x, y) = (0, x) + return + } + if y.count == 1 { + // The single-word case reduces to a simpler loop. + y = BigUInt(x.divide(byWord: y[0])) + return + } + + // In the hard cases, we will perform the long division algorithm we learned in school. + // It works by successively calculating the single-word quotient of the top y.count + 1 + // words of x divided by y, replacing the top of x with the remainder, and repeating + // the process one word lower. + // + // The tricky part is that the algorithm needs to be able to do n+1/n word divisions, + // but we only have a primitive for dividing two words by a single + // word. (Remember that this step is also tricky when we do it on paper!) + // + // The solution is that the long division can be approximated by a single full division + // using just the most significant words. We can then use multiplications and + // subtractions to refine the approximation until we get the correct quotient word. + // + // We could do this by doing a simple 2/1 full division, but Knuth goes one step further, + // and implements a 3/2 division. This results in an exact approximation in the + // vast majority of cases, eliminating an extra subtraction over big integers. + // + // The function `approximateQuotient` above implements Knuth's 3/2 division algorithm. + // It requires that the divisor's most significant word is larger than + // Word.max / 2. This ensures that the approximation has tiny error bounds, + // which is what makes this entire approach viable. + // To satisfy this requirement, we will normalize the division by multiplying + // both the divisor and the dividend by the same (small) factor. + let z = y.leadingZeroBitCount + y <<= z + x <<= z // We'll calculate the remainder in the normalized dividend. + var quotient = BigUInt() + assert(y.leadingZeroBitCount == 0) + + // We're ready to start the long division! + let dc = y.count + let d1 = y[dc - 1] + let d0 = y[dc - 2] + var product: BigUInt = 0 + for j in (dc ... x.count).reversed() { + // Approximate dividing the top dc+1 words of `remainder` using the topmost 3/2 words. + let r2 = x[j] + let r1 = x[j - 1] + let r0 = x[j - 2] + let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0)) + + // Multiply the entire divisor with `q` and subtract the result from remainder. + // Normalization ensures the 3/2 quotient will either be exact for the full division, or + // it may overshoot by at most 1, in which case the product will be greater + // than the remainder. + product.load(y) + product.multiply(byWord: q) + if product <= x.extract(j - dc ..< j + 1) { + x.subtract(product, shiftedBy: j - dc) + quotient[j - dc] = q + } + else { + // This case is extremely rare -- it has a probability of 1/2^(Word.bitWidth - 1). + x.add(y, shiftedBy: j - dc) + x.subtract(product, shiftedBy: j - dc) + quotient[j - dc] = q - 1 + } + } + // The remainder's normalization needs to be undone, but otherwise we're done. + x >>= z + y = x + x = quotient + } + + /// Divide `x` by `y`, putting the remainder in `x`. + mutating func formRemainder(dividingBy y: BigUInt, normalizedBy shift: Int) { + precondition(!y.isZero) + assert(y.leadingZeroBitCount == 0) + if y.count == 1 { + let remainder = self.divide(byWord: y[0] >> shift) + self.load(BigUInt(remainder)) + return + } + self <<= shift + if self >= y { + let dc = y.count + let d1 = y[dc - 1] + let d0 = y[dc - 2] + var product: BigUInt = 0 + for j in (dc ... self.count).reversed() { + let r2 = self[j] + let r1 = self[j - 1] + let r0 = self[j - 2] + let q = Word.approximateQuotient(dividing: (r2, r1, r0), by: (d1, d0)) + product.load(y) + product.multiply(byWord: q) + if product <= self.extract(j - dc ..< j + 1) { + self.subtract(product, shiftedBy: j - dc) + } + else { + self.add(y, shiftedBy: j - dc) + self.subtract(product, shiftedBy: j - dc) + } + } + } + self >>= shift + } + + + /// Divide this integer by `y` and return the resulting quotient and remainder. + /// + /// - Requires: `y > 0` + /// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y` + /// - Complexity: O(count^2) + public func quotientAndRemainder(dividingBy y: BigUInt) -> (quotient: BigUInt, remainder: BigUInt) { + var x = self + var y = y + BigUInt.divide(&x, by: &y) + return (x, y) + } + + /// Divide `x` by `y` and return the quotient. + /// + /// - Note: Use `divided(by:)` if you also need the remainder. + public static func /(x: BigUInt, y: BigUInt) -> BigUInt { + return x.quotientAndRemainder(dividingBy: y).quotient + } + + /// Divide `x` by `y` and return the remainder. + /// + /// - Note: Use `divided(by:)` if you also need the remainder. + public static func %(x: BigUInt, y: BigUInt) -> BigUInt { + var x = x + let shift = y.leadingZeroBitCount + x.formRemainder(dividingBy: y << shift, normalizedBy: shift) + return x + } + + /// Divide `x` by `y` and store the quotient in `x`. + /// + /// - Note: Use `divided(by:)` if you also need the remainder. + public static func /=(x: inout BigUInt, y: BigUInt) { + var y = y + BigUInt.divide(&x, by: &y) + } + + /// Divide `x` by `y` and store the remainder in `x`. + /// + /// - Note: Use `divided(by:)` if you also need the remainder. + public static func %=(x: inout BigUInt, y: BigUInt) { + let shift = y.leadingZeroBitCount + x.formRemainder(dividingBy: y << shift, normalizedBy: shift) + } +} + +extension BigInt { + /// Divide this integer by `y` and return the resulting quotient and remainder. + /// + /// - Requires: `y > 0` + /// - Returns: `(quotient, remainder)` where `quotient = floor(self/y)`, `remainder = self - quotient * y` + /// - Complexity: O(count^2) + public func quotientAndRemainder(dividingBy y: BigInt) -> (quotient: BigInt, remainder: BigInt) { + var a = self.magnitude + var b = y.magnitude + BigUInt.divide(&a, by: &b) + return (BigInt(sign: self.sign == y.sign ? .plus : .minus, magnitude: a), + BigInt(sign: self.sign, magnitude: b)) + } + + /// Divide `a` by `b` and return the quotient. Traps if `b` is zero. + public static func /(a: BigInt, b: BigInt) -> BigInt { + return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude / b.magnitude) + } + + /// Divide `a` by `b` and return the remainder. The result has the same sign as `a`. + public static func %(a: BigInt, b: BigInt) -> BigInt { + return BigInt(sign: a.sign, magnitude: a.magnitude % b.magnitude) + } + + /// Return the result of `a` mod `b`. The result is always a nonnegative integer that is less than the absolute value of `b`. + public func modulus(_ mod: BigInt) -> BigInt { + let remainder = self.magnitude % mod.magnitude + return BigInt( + self.sign == .minus && !remainder.isZero + ? mod.magnitude - remainder + : remainder) + } +} + +extension BigInt { + /// Divide `a` by `b` storing the quotient in `a`. + public static func /=(a: inout BigInt, b: BigInt) { a = a / b } + /// Divide `a` by `b` storing the remainder in `a`. + public static func %=(a: inout BigInt, b: BigInt) { a = a % b } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Exponentiation.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Exponentiation.swift new file mode 100644 index 0000000..9d7ee85 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Exponentiation.swift @@ -0,0 +1,119 @@ +// +// Exponentiation.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + //MARK: Exponentiation + + /// Returns this integer raised to the power `exponent`. + /// + /// This function calculates the result by [successively squaring the base while halving the exponent][expsqr]. + /// + /// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring + /// + /// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is + /// a simple integer value. If you want to calculate big exponents, you'll probably need to use + /// the modulo arithmetic variant. + /// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.) + /// - SeeAlso: `BigUInt.power(_:, modulus:)` + /// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too. + public func power(_ exponent: Int) -> BigUInt { + if exponent == 0 { return 1 } + if exponent == 1 { return self } + if exponent < 0 { + precondition(!self.isZero) + return self == 1 ? 1 : 0 + } + if self <= 1 { return self } + var result = BigUInt(1) + var b = self + var e = exponent + while e > 0 { + if e & 1 == 1 { + result *= b + } + e >>= 1 + b *= b + } + return result + } + + /// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`. + /// + /// Uses the [right-to-left binary method][rtlb]. + /// + /// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method + /// + /// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch + public func power(_ exponent: BigUInt, modulus: BigUInt) -> BigUInt { + precondition(!modulus.isZero) + if modulus == (1 as BigUInt) { return 0 } + let shift = modulus.leadingZeroBitCount + let normalizedModulus = modulus << shift + var result = BigUInt(1) + var b = self + b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift) + for var e in exponent.words { + for _ in 0 ..< Word.bitWidth { + if e & 1 == 1 { + result *= b + result.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift) + } + e >>= 1 + b *= b + b.formRemainder(dividingBy: normalizedModulus, normalizedBy: shift) + } + } + return result + } +} + +extension BigInt { + /// Returns this integer raised to the power `exponent`. + /// + /// This function calculates the result by [successively squaring the base while halving the exponent][expsqr]. + /// + /// [expsqr]: https://en.wikipedia.org/wiki/Exponentiation_by_squaring + /// + /// - Note: This function can be unreasonably expensive for large exponents, which is why `exponent` is + /// a simple integer value. If you want to calculate big exponents, you'll probably need to use + /// the modulo arithmetic variant. + /// - Returns: 1 if `exponent == 0`, otherwise `self` raised to `exponent`. (This implies that `0.power(0) == 1`.) + /// - SeeAlso: `BigUInt.power(_:, modulus:)` + /// - Complexity: O((exponent * self.count)^log2(3)) or somesuch. The result may require a large amount of memory, too. + public func power(_ exponent: Int) -> BigInt { + return BigInt(sign: self.sign == .minus && exponent & 1 != 0 ? .minus : .plus, + magnitude: self.magnitude.power(exponent)) + } + + /// Returns the remainder of this integer raised to the power `exponent` in modulo arithmetic under `modulus`. + /// + /// Uses the [right-to-left binary method][rtlb]. + /// + /// [rtlb]: https://en.wikipedia.org/wiki/Modular_exponentiation#Right-to-left_binary_method + /// + /// - Complexity: O(exponent.count * modulus.count^log2(3)) or somesuch + public func power(_ exponent: BigInt, modulus: BigInt) -> BigInt { + precondition(!modulus.isZero) + if modulus.magnitude == 1 { return 0 } + if exponent.isZero { return 1 } + if exponent == 1 { return self.modulus(modulus) } + if exponent < 0 { + precondition(!self.isZero) + guard magnitude == 1 else { return 0 } + guard sign == .minus else { return 1 } + guard exponent.magnitude[0] & 1 != 0 else { return 1 } + return BigInt(modulus.magnitude - 1) + } + let power = self.magnitude.power(exponent.magnitude, + modulus: modulus.magnitude) + if self.sign == .plus || exponent.magnitude[0] & 1 == 0 || power.isZero { + return BigInt(power) + } + return BigInt(modulus.magnitude - power) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Floating Point Conversion.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Floating Point Conversion.swift new file mode 100644 index 0000000..f51e8b2 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Floating Point Conversion.swift @@ -0,0 +1,181 @@ +// +// Floating Point Conversion.swift +// BigInt +// +// Created by Károly Lőrentey on 2017-08-11. +// Copyright © 2016-2017 Károly Lőrentey. +// + +#if canImport(Foundation) +import Foundation +#endif + +extension BigUInt { + public init?(exactly source: T) { + guard source.isFinite else { return nil } + guard !source.isZero else { self = 0; return } + guard source.sign == .plus else { return nil } + let value = source.rounded(.towardZero) + guard value == source else { return nil } + assert(value.floatingPointClass == .positiveNormal) + assert(value.exponent >= 0) + let significand = value.significandBitPattern + self = (BigUInt(1) << value.exponent) + BigUInt(significand) >> (T.significandBitCount - Int(value.exponent)) + } + + public init(_ source: T) { + self.init(exactly: source.rounded(.towardZero))! + } + + #if canImport(Foundation) + public init?(exactly source: Decimal) { + guard source.exponent >= 0 else { return nil } + self.init(commonDecimal: source) + } + + public init?(truncating source: Decimal) { + self.init(commonDecimal: source) + } + + private init?(commonDecimal source: Decimal) { + var integer = source + if source.exponent < 0 { + var source = source + NSDecimalRound(&integer, &source, 0, .down) + } + + guard !integer.isZero else { self = 0; return } + guard integer.isFinite else { return nil } + guard integer.sign == .plus else { return nil } + assert(integer.floatingPointClass == .positiveNormal) + + #if os(Linux) || os(Android) || os(Windows) || os(WASI) + // `Decimal._mantissa` has an internal access level on linux, and it might get + // deprecated in the future, so keeping the string implementation around for now. + let significand = BigUInt("\(integer.significand)")! + #else + let significand = { + var start = BigUInt(0) + for (place, value) in integer.significand.mantissaParts.enumerated() { + guard value > 0 else { continue } + start += (1 << (place * 16)) * BigUInt(value) + } + return start + }() + #endif + let exponent = BigUInt(10).power(integer.exponent) + + self = significand * exponent + } + #endif +} + +extension BigInt { + public init?(exactly source: T) { + guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil } + let sign = BigInt.Sign(source.sign) + self.init(sign: sign, magnitude: magnitude) + } + + public init(_ source: T) { + self.init(exactly: source.rounded(.towardZero))! + } + + #if canImport(Foundation) + public init?(exactly source: Decimal) { + guard let magnitude = BigUInt(exactly: source.magnitude) else { return nil } + let sign = BigInt.Sign(source.sign) + self.init(sign: sign, magnitude: magnitude) + } + + public init?(truncating source: Decimal) { + guard let magnitude = BigUInt(truncating: source.magnitude) else { return nil } + let sign = BigInt.Sign(source.sign) + self.init(sign: sign, magnitude: magnitude) + } + #endif +} + +extension BinaryFloatingPoint where RawExponent: FixedWidthInteger, RawSignificand: FixedWidthInteger { + public init(_ value: BigInt) { + guard !value.isZero else { self = 0; return } + let v = value.magnitude + let bitWidth = v.bitWidth + var exponent = bitWidth - 1 + let shift = bitWidth - Self.significandBitCount - 1 + var significand = value.magnitude >> (shift - 1) + if significand[0] & 3 == 3 { // Handle rounding + significand >>= 1 + significand += 1 + if significand.trailingZeroBitCount >= Self.significandBitCount { + exponent += 1 + } + } + else { + significand >>= 1 + } + let bias = 1 << (Self.exponentBitCount - 1) - 1 + guard exponent <= bias else { self = Self.infinity; return } + significand &= 1 << Self.significandBitCount - 1 + self = Self.init(sign: value.sign == .plus ? .plus : .minus, + exponentBitPattern: RawExponent(bias + exponent), + significandBitPattern: RawSignificand(significand)) + } + + public init(_ value: BigUInt) { + self.init(BigInt(sign: .plus, magnitude: value)) + } +} + +extension BigInt.Sign { + public init(_ sign: FloatingPointSign) { + switch sign { + case .plus: + self = .plus + case .minus: + self = .minus + } + } +} + +#if canImport(Foundation) +public extension Decimal { + init(_ value: BigUInt) { + guard + value < BigUInt(exactly: Decimal.greatestFiniteMagnitude)! + else { + self = .greatestFiniteMagnitude + return + } + guard !value.isZero else { self = 0; return } + + self.init(string: "\(value)")! + } + + init(_ value: BigInt) { + if value >= 0 { + self.init(BigUInt(value)) + } else { + self.init(value.magnitude) + self *= -1 + } + } +} +#endif + +#if canImport(Foundation) && !(os(Linux) || os(Android) || os(Windows) || os(WASI)) +private extension Decimal { + var mantissaParts: [UInt16] { + [ + _mantissa.0, + _mantissa.1, + _mantissa.2, + _mantissa.3, + _mantissa.4, + _mantissa.5, + _mantissa.6, + _mantissa.7, + ] + } +} +#endif diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/GCD.swift b/mac/PortalKit/Sources/PortalKit/BigInt/GCD.swift new file mode 100644 index 0000000..d55605d --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/GCD.swift @@ -0,0 +1,80 @@ +// +// GCD.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + //MARK: Greatest Common Divisor + + /// Returns the greatest common divisor of `self` and `b`. + /// + /// - Complexity: O(count^2) where count = max(self.count, b.count) + public func greatestCommonDivisor(with b: BigUInt) -> BigUInt { + // This is Stein's algorithm: https://en.wikipedia.org/wiki/Binary_GCD_algorithm + if self.isZero { return b } + if b.isZero { return self } + + let az = self.trailingZeroBitCount + let bz = b.trailingZeroBitCount + let twos = Swift.min(az, bz) + + var (x, y) = (self >> az, b >> bz) + if x < y { swap(&x, &y) } + + while !x.isZero { + x >>= x.trailingZeroBitCount + if x < y { swap(&x, &y) } + x -= y + } + return y << twos + } + + /// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse], + /// or `nil` if there is no such number. + /// + /// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers + /// + /// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`. + /// - Requires: modulus > 1 + /// - Complexity: O(count^3) + public func inverse(_ modulus: BigUInt) -> BigUInt? { + precondition(modulus > 1) + var t1 = BigInt(0) + var t2 = BigInt(1) + var r1 = modulus + var r2 = self + while !r2.isZero { + let quotient = r1 / r2 + (t1, t2) = (t2, t1 - BigInt(quotient) * t2) + (r1, r2) = (r2, r1 - quotient * r2) + } + if r1 > 1 { return nil } + if t1.sign == .minus { return modulus - t1.magnitude } + return t1.magnitude + } +} + +extension BigInt { + /// Returns the greatest common divisor of `a` and `b`. + /// + /// - Complexity: O(count^2) where count = max(a.count, b.count) + public func greatestCommonDivisor(with b: BigInt) -> BigInt { + return BigInt(self.magnitude.greatestCommonDivisor(with: b.magnitude)) + } + + /// Returns the [multiplicative inverse of this integer in modulo `modulus` arithmetic][inverse], + /// or `nil` if there is no such number. + /// + /// [inverse]: https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers + /// + /// - Returns: If `gcd(self, modulus) == 1`, the value returned is an integer `a < modulus` such that `(a * self) % modulus == 1`. If `self` and `modulus` aren't coprime, the return value is `nil`. + /// - Requires: modulus.magnitude > 1 + /// - Complexity: O(count^3) + public func inverse(_ modulus: BigInt) -> BigInt? { + guard let inv = self.magnitude.inverse(modulus.magnitude) else { return nil } + return BigInt(self.sign == .plus || inv.isZero ? inv : modulus.magnitude - inv) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Hashable.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Hashable.swift new file mode 100644 index 0000000..c5dc0e6 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Hashable.swift @@ -0,0 +1,26 @@ +// +// Hashable.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt: Hashable { + //MARK: Hashing + + /// Append this `BigUInt` to the specified hasher. + public func hash(into hasher: inout Hasher) { + for word in self.words { + hasher.combine(word) + } + } +} + +extension BigInt: Hashable { + /// Append this `BigInt` to the specified hasher. + public func hash(into hasher: inout Hasher) { + hasher.combine(sign) + hasher.combine(magnitude) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Integer Conversion.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Integer Conversion.swift new file mode 100644 index 0000000..9a210e4 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Integer Conversion.swift @@ -0,0 +1,89 @@ +// +// Integer Conversion.swift +// BigInt +// +// Created by Károly Lőrentey on 2017-08-11. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + public init?(exactly source: T) { + guard source >= (0 as T) else { return nil } + if source.bitWidth <= 2 * Word.bitWidth { + var it = source.words.makeIterator() + self.init(low: it.next() ?? 0, high: it.next() ?? 0) + precondition(it.next() == nil, "Length of BinaryInteger.words is greater than its bitWidth") + } + else { + self.init(words: source.words) + } + } + + public init(_ source: T) { + precondition(source >= (0 as T), "BigUInt cannot represent negative values") + self.init(exactly: source)! + } + + public init(truncatingIfNeeded source: T) { + self.init(words: source.words) + } + + public init(clamping source: T) { + if source <= (0 as T) { + self.init() + } + else { + self.init(words: source.words) + } + } +} + +extension BigInt { + public init() { + self.init(sign: .plus, magnitude: 0) + } + + /// Initializes a new signed big integer with the same value as the specified unsigned big integer. + public init(_ integer: BigUInt) { + self.magnitude = integer + self.sign = .plus + } + + public init(_ source: T) where T : BinaryInteger { + if source >= (0 as T) { + self.init(sign: .plus, magnitude: BigUInt(source)) + } + else { + var words = Array(source.words) + words.twosComplement() + self.init(sign: .minus, magnitude: BigUInt(words: words)) + } + } + + public init?(exactly source: T) where T : BinaryInteger { + self.init(source) + } + + public init(clamping source: T) where T : BinaryInteger { + self.init(source) + } + + public init(truncatingIfNeeded source: T) where T : BinaryInteger { + self.init(source) + } +} + +extension BigUInt: ExpressibleByIntegerLiteral { + /// Initialize a new big integer from an integer literal. + public init(integerLiteral value: UInt64) { + self.init(value) + } +} + +extension BigInt: ExpressibleByIntegerLiteral { + /// Initialize a new big integer from an integer literal. + public init(integerLiteral value: Int64) { + self.init(value) + } +} + diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Multiplication.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Multiplication.swift new file mode 100644 index 0000000..83079ae --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Multiplication.swift @@ -0,0 +1,165 @@ +// +// Multiplication.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + + //MARK: Multiplication + + /// Multiply this big integer by a single word, and store the result in place of the original big integer. + /// + /// - Complexity: O(count) + public mutating func multiply(byWord y: Word) { + guard y != 0 else { self = 0; return } + guard y != 1 else { return } + var carry: Word = 0 + let c = self.count + for i in 0 ..< c { + let (h, l) = self[i].multipliedFullWidth(by: y) + let (low, o) = l.addingReportingOverflow(carry) + self[i] = low + carry = (o ? h + 1 : h) + } + self[c] = carry + } + + /// Multiply this big integer by a single Word, and return the result. + /// + /// - Complexity: O(count) + public func multiplied(byWord y: Word) -> BigUInt { + var r = self + r.multiply(byWord: y) + return r + } + + /// Multiply `x` by `y`, and add the result to this integer, optionally shifted `shift` words to the left. + /// + /// - Note: This is the fused multiply/shift/add operation; it is more efficient than doing the components + /// individually. (The fused operation doesn't need to allocate space for temporary big integers.) + /// `self` is set to `self + (x * y) << (shift * 2^Word.bitWidth)` + /// - Complexity: O(count) + public mutating func multiplyAndAdd(_ x: BigUInt, _ y: Word, shiftedBy shift: Int = 0) { + precondition(shift >= 0) + guard y != 0 && x.count > 0 else { return } + guard y != 1 else { self.add(x, shiftedBy: shift); return } + var mulCarry: Word = 0 + var addCarry = false + let xc = x.count + var xi = 0 + while xi < xc || addCarry || mulCarry > 0 { + let (h, l) = x[xi].multipliedFullWidth(by: y) + let (low, o) = l.addingReportingOverflow(mulCarry) + mulCarry = (o ? h + 1 : h) + + let ai = shift + xi + let (sum1, so1) = self[ai].addingReportingOverflow(low) + if addCarry { + let (sum2, so2) = sum1.addingReportingOverflow(1) + self[ai] = sum2 + addCarry = so1 || so2 + } + else { + self[ai] = sum1 + addCarry = so1 + } + xi += 1 + } + } + + /// Multiply this integer by `y` and return the result. + /// + /// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than + /// `BigUInt.directMultiplicationLimit` words. + /// - Complexity: O(n^log2(3)) + public func multiplied(by y: BigUInt) -> BigUInt { + // This method is mostly defined for symmetry with the rest of the arithmetic operations. + return self * y + } + + /// Multiplication switches to an asymptotically better recursive algorithm when arguments have more words than this limit. + public static let directMultiplicationLimit: Int = 1024 + + /// Multiply `a` by `b` and return the result. + /// + /// - Note: This uses the naive O(n^2) multiplication algorithm unless both arguments have more than + /// `BigUInt.directMultiplicationLimit` words. + /// - Complexity: O(n^log2(3)) + public static func *(x: BigUInt, y: BigUInt) -> BigUInt { + let xc = x.count + let yc = y.count + if xc == 0 { return BigUInt() } + if yc == 0 { return BigUInt() } + if yc == 1 { return x.multiplied(byWord: y[0]) } + if xc == 1 { return y.multiplied(byWord: x[0]) } + + if Swift.min(xc, yc) <= BigUInt.directMultiplicationLimit { + // Long multiplication. + let left = (xc < yc ? y : x) + let right = (xc < yc ? x : y) + var result = BigUInt() + for i in (0 ..< right.count).reversed() { + result.multiplyAndAdd(left, right[i], shiftedBy: i) + } + return result + } + + if yc < xc { + let (xh, xl) = x.split + var r = xl * y + r.add(xh * y, shiftedBy: x.middleIndex) + return r + } + else if xc < yc { + let (yh, yl) = y.split + var r = yl * x + r.add(yh * x, shiftedBy: y.middleIndex) + return r + } + + let shift = x.middleIndex + + // Karatsuba multiplication: + // x * y = * = (ignoring carry) + let (a, b) = x.split + let (c, d) = y.split + + let high = a * c + let low = b * d + let xp = a >= b + let yp = c >= d + let xm = (xp ? a - b : b - a) + let ym = (yp ? c - d : d - c) + let m = xm * ym + + var r = low + r.add(high, shiftedBy: 2 * shift) + r.add(low, shiftedBy: shift) + r.add(high, shiftedBy: shift) + if xp == yp { + r.subtract(m, shiftedBy: shift) + } + else { + r.add(m, shiftedBy: shift) + } + return r + } + + /// Multiply `a` by `b` and store the result in `a`. + public static func *=(a: inout BigUInt, b: BigUInt) { + a = a * b + } +} + +extension BigInt { + /// Multiply `a` with `b` and return the result. + public static func *(a: BigInt, b: BigInt) -> BigInt { + return BigInt(sign: a.sign == b.sign ? .plus : .minus, magnitude: a.magnitude * b.magnitude) + } + + /// Multiply `a` with `b` in place. + public static func *=(a: inout BigInt, b: BigInt) { a = a * b } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Prime Test.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Prime Test.swift new file mode 100644 index 0000000..7f18711 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Prime Test.swift @@ -0,0 +1,153 @@ +// +// Prime Test.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-04. +// Copyright © 2016-2017 Károly Lőrentey. +// + +/// The first several [prime numbers][primes]. +/// +/// [primes]: https://oeis.org/A000040 +let primes: [BigUInt.Word] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41] + +/// The ith element in this sequence is the smallest composite number that passes the strong probable prime test +/// for all of the first (i+1) primes. +/// +/// This is sequence [A014233](http://oeis.org/A014233) on the [Online Encyclopaedia of Integer Sequences](http://oeis.org). +let pseudoPrimes: [BigUInt] = [ + /* 2 */ 2_047, + /* 3 */ 1_373_653, + /* 5 */ 25_326_001, + /* 7 */ 3_215_031_751, + /* 11 */ 2_152_302_898_747, + /* 13 */ 3_474_749_660_383, + /* 17 */ 341_550_071_728_321, + /* 19 */ 341_550_071_728_321, + /* 23 */ 3_825_123_056_546_413_051, + /* 29 */ 3_825_123_056_546_413_051, + /* 31 */ 3_825_123_056_546_413_051, + /* 37 */ "318665857834031151167461", + /* 41 */ "3317044064679887385961981", +] + +extension BigUInt { + //MARK: Primality Testing + + /// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base. + /// + /// [sppt]: https://en.wikipedia.org/wiki/Probable_prime + public func isStrongProbablePrime(_ base: BigUInt) -> Bool { + precondition(base > (1 as BigUInt)) + precondition(self > (0 as BigUInt)) + let dec = self - 1 + + let r = dec.trailingZeroBitCount + let d = dec >> r + + var test = base.power(d, modulus: self) + if test == 1 || test == dec { return true } + + if r > 0 { + let shift = self.leadingZeroBitCount + let normalized = self << shift + for _ in 1 ..< r { + test *= test + test.formRemainder(dividingBy: normalized, normalizedBy: shift) + if test == 1 { + return false + } + if test == dec { return true } + } + } + return false + } + + /// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime. + /// + /// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations, + /// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default, + /// but you may specify your own choice. + /// + /// To speed things up, the function checks if `self` is divisible by the first few prime numbers before + /// diving into (slower) Miller-Rabin testing. + /// + /// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to + /// return a correct result. + /// + /// [mrpt]: https://en.wikipedia.org/wiki/Miller–Rabin_primality_test + public func isPrime(rounds: Int = 10) -> Bool { + if count <= 1 && self[0] < 2 { return false } + if count == 1 && self[0] < 4 { return true } + + // Even numbers above 2 aren't prime. + if self[0] & 1 == 0 { return false } + + // Quickly check for small primes. + for i in 1 ..< primes.count { + let p = primes[i] + if self.count == 1 && self[0] == p { + return true + } + if self.quotientAndRemainder(dividingByWord: p).remainder == 0 { + return false + } + } + + /// Give an exact answer when we can. + if self < pseudoPrimes.last! { + for i in 0 ..< pseudoPrimes.count { + guard isStrongProbablePrime(BigUInt(primes[i])) else { + break + } + if self < pseudoPrimes[i] { + // `self` is below the lowest pseudoprime corresponding to the prime bases we tested. It's a prime! + return true + } + } + return false + } + + /// Otherwise do as many rounds of random SPPT as required. + for _ in 0 ..< rounds { + let random = BigUInt.randomInteger(lessThan: self - 2) + 2 + guard isStrongProbablePrime(random) else { + return false + } + } + + // Well, it smells primey to me. + return true + } +} + +extension BigInt { + //MARK: Primality Testing + + /// Returns true iff this integer passes the [strong probable prime test][sppt] for the specified base. + /// + /// [sppt]: https://en.wikipedia.org/wiki/Probable_prime + public func isStrongProbablePrime(_ base: BigInt) -> Bool { + precondition(base.sign == .plus) + if self.sign == .minus { return false } + return self.magnitude.isStrongProbablePrime(base.magnitude) + } + + /// Returns true if this integer is probably prime. Returns false if this integer is definitely not prime. + /// + /// This function performs a probabilistic [Miller-Rabin Primality Test][mrpt], consisting of `rounds` iterations, + /// each calculating the strong probable prime test for a random base. The number of rounds is 10 by default, + /// but you may specify your own choice. + /// + /// To speed things up, the function checks if `self` is divisible by the first few prime numbers before + /// diving into (slower) Miller-Rabin testing. + /// + /// Also, when `self` is less than 82 bits wide, `isPrime` does a deterministic test that is guaranteed to + /// return a correct result. + /// + /// [mrpt]: https://en.wikipedia.org/wiki/Miller–Rabin_primality_test + public func isPrime(rounds: Int = 10) -> Bool { + if self.sign == .minus { return false } + return self.magnitude.isPrime(rounds: rounds) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Random.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Random.swift new file mode 100644 index 0000000..bea98ca --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Random.swift @@ -0,0 +1,101 @@ +// +// Random.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-04. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + /// Create a big unsigned integer consisting of `width` uniformly distributed random bits. + /// + /// - Parameter width: The maximum number of one bits in the result. + /// - Parameter generator: The source of randomness. + /// - Returns: A big unsigned integer less than `1 << width`. + public static func randomInteger(withMaximumWidth width: Int, using generator: inout RNG) -> BigUInt { + var result = BigUInt.zero + var bitsLeft = width + var i = 0 + let wordsNeeded = (width + Word.bitWidth - 1) / Word.bitWidth + if wordsNeeded > 2 { + result.reserveCapacity(wordsNeeded) + } + while bitsLeft >= Word.bitWidth { + result[i] = generator.next() + i += 1 + bitsLeft -= Word.bitWidth + } + if bitsLeft > 0 { + let mask: Word = (1 << bitsLeft) - 1 + result[i] = (generator.next() as Word) & mask + } + return result + } + + /// Create a big unsigned integer consisting of `width` uniformly distributed random bits. + /// + /// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness. + /// + /// - Parameter width: The maximum number of one bits in the result. + /// - Returns: A big unsigned integer less than `1 << width`. + public static func randomInteger(withMaximumWidth width: Int) -> BigUInt { + var rng = SystemRandomNumberGenerator() + return randomInteger(withMaximumWidth: width, using: &rng) + } + + /// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit. + /// + /// - Note: If `width` is zero, the result is zero. + /// + /// - Parameter width: The number of bits required to represent the answer. + /// - Parameter generator: The source of randomness. + /// - Returns: A random big unsigned integer whose width is `width`. + public static func randomInteger(withExactWidth width: Int, using generator: inout RNG) -> BigUInt { + // width == 0 -> return 0 because there is no room for a one bit. + // width == 1 -> return 1 because there is no room for any random bits. + guard width > 1 else { return BigUInt(width) } + var result = randomInteger(withMaximumWidth: width - 1, using: &generator) + result[(width - 1) / Word.bitWidth] |= 1 << Word((width - 1) % Word.bitWidth) + return result + } + + /// Create a big unsigned integer consisting of `width-1` uniformly distributed random bits followed by a one bit. + /// + /// - Note: If `width` is zero, the result is zero. + /// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness. + /// + /// - Returns: A random big unsigned integer whose width is `width`. + public static func randomInteger(withExactWidth width: Int) -> BigUInt { + var rng = SystemRandomNumberGenerator() + return randomInteger(withExactWidth: width, using: &rng) + } + + /// Create a uniformly distributed random unsigned integer that's less than the specified limit. + /// + /// - Precondition: `limit > 0`. + /// + /// - Parameter limit: The upper bound on the result. + /// - Parameter generator: The source of randomness. + /// - Returns: A random big unsigned integer that is less than `limit`. + public static func randomInteger(lessThan limit: BigUInt, using generator: inout RNG) -> BigUInt { + precondition(limit > 0, "\(#function): 0 is not a valid limit") + let width = limit.bitWidth + var random = randomInteger(withMaximumWidth: width, using: &generator) + while random >= limit { + random = randomInteger(withMaximumWidth: width, using: &generator) + } + return random + } + + /// Create a uniformly distributed random unsigned integer that's less than the specified limit. + /// + /// - Precondition: `limit > 0`. + /// - Note: I use a `SystemRandomGeneratorGenerator` as the source of randomness. + /// + /// - Parameter limit: The upper bound on the result. + /// - Returns: A random big unsigned integer that is less than `limit`. + public static func randomInteger(lessThan limit: BigUInt) -> BigUInt { + var rng = SystemRandomNumberGenerator() + return randomInteger(lessThan: limit, using: &rng) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Shifts.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Shifts.swift new file mode 100644 index 0000000..e676e41 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Shifts.swift @@ -0,0 +1,211 @@ +// +// Shifts.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + + //MARK: Shift Operators + + internal func shiftedLeft(by amount: Word) -> BigUInt { + guard amount > 0 else { return self } + + let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words) + let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift) + let down = Word(Word.bitWidth) - up + + var result = BigUInt() + if up > 0 { + var i = 0 + var lowbits: Word = 0 + while i < self.count || lowbits > 0 { + let word = self[i] + result[i + ext] = word << up | lowbits + lowbits = word >> down + i += 1 + } + } + else { + for i in 0 ..< self.count { + result[i + ext] = self[i] + } + } + return result + } + + internal mutating func shiftLeft(by amount: Word) { + guard amount > 0 else { return } + + let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words) + let up = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift) + let down = Word(Word.bitWidth) - up + + if up > 0 { + var i = 0 + var lowbits: Word = 0 + while i < self.count || lowbits > 0 { + let word = self[i] + self[i] = word << up | lowbits + lowbits = word >> down + i += 1 + } + } + if ext > 0 && self.count > 0 { + self.shiftLeft(byWords: ext) + } + } + + internal func shiftedRight(by amount: Word) -> BigUInt { + guard amount > 0 else { return self } + guard amount < self.bitWidth else { return 0 } + + let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words) + let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift) + let up = Word(Word.bitWidth) - down + + var result = BigUInt() + if down > 0 { + var highbits: Word = 0 + for i in (ext ..< self.count).reversed() { + let word = self[i] + result[i - ext] = highbits | word >> down + highbits = word << up + } + } + else { + for i in (ext ..< self.count).reversed() { + result[i - ext] = self[i] + } + } + return result + } + + internal mutating func shiftRight(by amount: Word) { + guard amount > 0 else { return } + guard amount < self.bitWidth else { self.clear(); return } + + let ext = Int(amount / Word(Word.bitWidth)) // External shift amount (new words) + let down = Word(amount % Word(Word.bitWidth)) // Internal shift amount (subword shift) + let up = Word(Word.bitWidth) - down + + if ext > 0 { + self.shiftRight(byWords: ext) + } + if down > 0 { + var i = self.count - 1 + var highbits: Word = 0 + while i >= 0 { + let word = self[i] + self[i] = highbits | word >> down + highbits = word << up + i -= 1 + } + } + } + + public static func >>=(lhs: inout BigUInt, rhs: Other) { + if rhs < (0 as Other) { + lhs <<= (0 - rhs) + } + else if rhs >= lhs.bitWidth { + lhs.clear() + } + else { + lhs.shiftRight(by: UInt(rhs)) + } + } + + public static func <<=(lhs: inout BigUInt, rhs: Other) { + if rhs < (0 as Other) { + lhs >>= (0 - rhs) + return + } + lhs.shiftLeft(by: Word(exactly: rhs)!) + } + + public static func >>(lhs: BigUInt, rhs: Other) -> BigUInt { + if rhs < (0 as Other) { + return lhs << (0 - rhs) + } + if rhs > Word.max { + return 0 + } + return lhs.shiftedRight(by: UInt(rhs)) + } + + public static func <<(lhs: BigUInt, rhs: Other) -> BigUInt { + if rhs < (0 as Other) { + return lhs >> (0 - rhs) + } + return lhs.shiftedLeft(by: Word(exactly: rhs)!) + } +} + +extension BigInt { + func shiftedLeft(by amount: Word) -> BigInt { + return BigInt(sign: self.sign, magnitude: self.magnitude.shiftedLeft(by: amount)) + } + + mutating func shiftLeft(by amount: Word) { + self.magnitude.shiftLeft(by: amount) + } + + func shiftedRight(by amount: Word) -> BigInt { + let m = self.magnitude.shiftedRight(by: amount) + return BigInt(sign: self.sign, magnitude: self.sign == .minus && m.isZero ? 1 : m) + } + + mutating func shiftRight(by amount: Word) { + magnitude.shiftRight(by: amount) + if sign == .minus, magnitude.isZero { + magnitude.load(1) + } + } + + public static func &<<(left: BigInt, right: BigInt) -> BigInt { + return left.shiftedLeft(by: right.words[0]) + } + + public static func &<<=(left: inout BigInt, right: BigInt) { + left.shiftLeft(by: right.words[0]) + } + + public static func &>>(left: BigInt, right: BigInt) -> BigInt { + return left.shiftedRight(by: right.words[0]) + } + + public static func &>>=(left: inout BigInt, right: BigInt) { + left.shiftRight(by: right.words[0]) + } + + public static func <<(lhs: BigInt, rhs: Other) -> BigInt { + guard rhs >= (0 as Other) else { return lhs >> (0 - rhs) } + return lhs.shiftedLeft(by: Word(rhs)) + } + + public static func <<=(lhs: inout BigInt, rhs: Other) { + if rhs < (0 as Other) { + lhs >>= (0 - rhs) + } + else { + lhs.shiftLeft(by: Word(rhs)) + } + } + + public static func >>(lhs: BigInt, rhs: Other) -> BigInt { + guard rhs >= (0 as Other) else { return lhs << (0 - rhs) } + return lhs.shiftedRight(by: Word(rhs)) + } + + public static func >>=(lhs: inout BigInt, rhs: Other) { + if rhs < (0 as Other) { + lhs <<= (0 - rhs) + } + else { + lhs.shiftRight(by: Word(rhs)) + } + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Square Root.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Square Root.swift new file mode 100644 index 0000000..68db069 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Square Root.swift @@ -0,0 +1,41 @@ +// +// Square Root.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +//MARK: Square Root + +extension BigUInt { + /// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`. + /// + /// - Returns: floor(sqrt(self)) + public func squareRoot() -> BigUInt { + // This implementation uses Newton's method. + guard !self.isZero else { return BigUInt() } + var x = BigUInt(1) << ((self.bitWidth + 1) / 2) + var y: BigUInt = 0 + while true { + y.load(self) + y /= x + y += x + y >>= 1 + if x == y || x == y - 1 { break } + x = y + } + return x + } +} + +extension BigInt { + /// Returns the integer square root of a big integer; i.e., the largest integer whose square isn't greater than `value`. + /// + /// - Requires: self >= 0 + /// - Returns: floor(sqrt(self)) + public func squareRoot() -> BigInt { + precondition(self.sign == .plus) + return BigInt(sign: .plus, magnitude: self.magnitude.squareRoot()) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Strideable.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Strideable.swift new file mode 100644 index 0000000..2b79bab --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Strideable.swift @@ -0,0 +1,38 @@ +// +// Strideable.swift +// BigInt +// +// Created by Károly Lőrentey on 2017-08-11. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt: Strideable { + /// A type that can represent the distance between two values ofa `BigUInt`. + public typealias Stride = BigInt + + /// Adds `n` to `self` and returns the result. Traps if the result would be less than zero. + public func advanced(by n: BigInt) -> BigUInt { + return n.sign == .minus ? self - n.magnitude : self + n.magnitude + } + + /// Returns the (potentially negative) difference between `self` and `other` as a `BigInt`. Never traps. + public func distance(to other: BigUInt) -> BigInt { + return BigInt(other) - BigInt(self) + } +} + +extension BigInt: Strideable { + public typealias Stride = BigInt + + /// Returns `self + n`. + public func advanced(by n: Stride) -> BigInt { + return self + n + } + + /// Returns `other - self`. + public func distance(to other: BigInt) -> Stride { + return other - self + } +} + + diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/String Conversion.swift b/mac/PortalKit/Sources/PortalKit/BigInt/String Conversion.swift new file mode 100644 index 0000000..cef9f30 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/String Conversion.swift @@ -0,0 +1,254 @@ +// +// String Conversion.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + + //MARK: String Conversion + + /// Calculates the number of numerals in a given radix that fit inside a single `Word`. + /// + /// - Returns: (chars, power) where `chars` is highest that satisfy `radix^chars <= 2^Word.bitWidth`. `power` is zero + /// if radix is a power of two; otherwise `power == radix^chars`. + fileprivate static func charsPerWord(forRadix radix: Int) -> (chars: Int, power: Word) { + var power: Word = 1 + var overflow = false + var count = 0 + while !overflow { + let (high,low) = power.multipliedFullWidth(by: Word(radix)) + if high > 0 { + overflow = true + } + + if !overflow || (high == 1 && low == 0) { + count += 1 + power = low + } + } + return (count, power) + } + + /// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by + /// letters from the English alphabet. + /// + /// - Requires: `radix > 1 && radix < 36` + /// - Parameter `text`: A string consisting of characters corresponding to numerals in the given radix. (0-9, a-z, A-Z) + /// - Parameter `radix`: The base of the number system to use, or 10 if unspecified. + /// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`. + public init?(_ text: S, radix: Int = 10) { + precondition(radix > 1 && radix < 36) + guard !text.isEmpty else { return nil } + let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix) + + var words: [Word] = [] + var end = text.endIndex + var start = end + var count = 0 + while start != text.startIndex { + start = text.index(before: start) + count += 1 + if count == charsPerWord { + guard let d = Word.init(text[start ..< end], radix: radix) else { return nil } + words.append(d) + end = start + count = 0 + } + } + if start != end { + guard let d = Word.init(text[start ..< end], radix: radix) else { return nil } + words.append(d) + } + + if power == 0 { + self.init(words: words) + } + else { + self.init() + for d in words.reversed() { + self.multiply(byWord: power) + self.addWord(d) + } + } + } +} + +extension BigInt { + /// Initialize a big integer from an ASCII representation in a given radix. Numerals above `9` are represented by + /// letters from the English alphabet. + /// + /// - Requires: `radix > 1 && radix < 36` + /// - Parameter `text`: A string optionally starting with "-" or "+" followed by characters corresponding to numerals in the given radix. (0-9, a-z, A-Z) + /// - Parameter `radix`: The base of the number system to use, or 10 if unspecified. + /// - Returns: The integer represented by `text`, or nil if `text` contains a character that does not represent a numeral in `radix`. + public init?(_ text: S, radix: Int = 10) { + var magnitude: BigUInt? + var sign: Sign = .plus + if text.first == "-" { + sign = .minus + let text = text.dropFirst() + magnitude = BigUInt(text, radix: radix) + } + else if text.first == "+" { + let text = text.dropFirst() + magnitude = BigUInt(text, radix: radix) + } + else { + magnitude = BigUInt(text, radix: radix) + } + guard let m = magnitude else { return nil } + self.magnitude = m + self.sign = m.isZero ? .plus : sign + } +} + +extension String { + /// Initialize a new string with the base-10 representation of an unsigned big integer. + /// + /// - Complexity: O(v.count^2) + public init(_ v: BigUInt) { self.init(v, radix: 10, uppercase: false) } + + /// Initialize a new string representing an unsigned big integer in the given radix (base). + /// + /// Numerals greater than 9 are represented as letters from the English alphabet, + /// starting with `a` if `uppercase` is false or `A` otherwise. + /// + /// - Requires: radix > 1 && radix <= 36 + /// - Complexity: O(count) when radix is a power of two; otherwise O(count^2). + public init(_ v: BigUInt, radix: Int, uppercase: Bool = false) { + precondition(radix > 1) + let (charsPerWord, power) = BigUInt.charsPerWord(forRadix: radix) + + guard !v.isZero else { self = "0"; return } + + var parts: [String] + if power == 0 { + parts = v.words.map { String($0, radix: radix, uppercase: uppercase) } + } + else { + parts = [] + var rest = v + while !rest.isZero { + let mod = rest.divide(byWord: power) + parts.append(String(mod, radix: radix, uppercase: uppercase)) + } + } + assert(!parts.isEmpty) + + self = "" + var first = true + for part in parts.reversed() { + let zeroes = charsPerWord - part.count + assert(zeroes >= 0) + if !first && zeroes > 0 { + // Insert leading zeroes for mid-Words + self += String(repeating: "0", count: zeroes) + } + first = false + self += part + } + } + + /// Initialize a new string representing a signed big integer in the given radix (base). + /// + /// Numerals greater than 9 are represented as letters from the English alphabet, + /// starting with `a` if `uppercase` is false or `A` otherwise. + /// + /// - Requires: radix > 1 && radix <= 36 + /// - Complexity: O(count) when radix is a power of two; otherwise O(count^2). + public init(_ value: BigInt, radix: Int = 10, uppercase: Bool = false) { + self = String(value.magnitude, radix: radix, uppercase: uppercase) + if value.sign == .minus { + self = "-" + self + } + } +} + +extension BigUInt: ExpressibleByStringLiteral { + /// Initialize a new big integer from a Unicode scalar. + /// The scalar must represent a decimal digit. + public init(unicodeScalarLiteral value: UnicodeScalar) { + self = BigUInt(String(value), radix: 10)! + } + + /// Initialize a new big integer from an extended grapheme cluster. + /// The cluster must consist of a decimal digit. + public init(extendedGraphemeClusterLiteral value: String) { + self = BigUInt(value, radix: 10)! + } + + /// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length. + /// The string must contain only decimal digits. + public init(stringLiteral value: StringLiteralType) { + self = BigUInt(value, radix: 10)! + } +} + +extension BigInt: ExpressibleByStringLiteral { + /// Initialize a new big integer from a Unicode scalar. + /// The scalar must represent a decimal digit. + public init(unicodeScalarLiteral value: UnicodeScalar) { + self = BigInt(String(value), radix: 10)! + } + + /// Initialize a new big integer from an extended grapheme cluster. + /// The cluster must consist of a decimal digit. + public init(extendedGraphemeClusterLiteral value: String) { + self = BigInt(value, radix: 10)! + } + + /// Initialize a new big integer from a decimal number represented by a string literal of arbitrary length. + /// The string must contain only decimal digits. + public init(stringLiteral value: StringLiteralType) { + self = BigInt(value, radix: 10)! + } +} + +extension BigUInt: CustomStringConvertible { + /// Return the decimal representation of this integer. + public var description: String { + return String(self, radix: 10) + } +} + +extension BigInt: CustomStringConvertible { + /// Return the decimal representation of this integer. + public var description: String { + return String(self, radix: 10) + } +} + +extension BigUInt: CustomDebugStringConvertible { + /// Return the decimal representation of this integer. + public var debugDescription: String { + let text = String(self) + return text + " (\(self.bitWidth) bits)" + } +} + +extension BigInt: CustomDebugStringConvertible { + /// Return the decimal representation of this integer. + public var debugDescription: String { + let text = String(self) + return text + " (\(self.magnitude.bitWidth) bits)" + } +} + +extension BigUInt: CustomPlaygroundDisplayConvertible { + + /// Return the playground quick look representation of this integer. + public var playgroundDescription: Any { + debugDescription + } +} + +extension BigInt: CustomPlaygroundDisplayConvertible { + + /// Return the playground quick look representation of this integer. + public var playgroundDescription: Any { + debugDescription + } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Subtraction.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Subtraction.swift new file mode 100644 index 0000000..5ac872e --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Subtraction.swift @@ -0,0 +1,169 @@ +// +// Subtraction.swift +// BigInt +// +// Created by Károly Lőrentey on 2016-01-03. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension BigUInt { + //MARK: Subtraction + + /// Subtract `word` from this integer in place, returning a flag indicating if the operation + /// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted. + /// + /// - Note: If the result indicates an overflow, then `self` becomes the two's complement of the absolute difference. + /// - Complexity: O(count) + internal mutating func subtractWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> Bool { + precondition(shift >= 0) + var carry: Word = word + var i = shift + let count = self.count + while carry > 0 && i < count { + let (d, c) = self[i].subtractingReportingOverflow(carry) + self[i] = d + carry = (c ? 1 : 0) + i += 1 + } + return carry > 0 + } + + /// Subtract `word` from this integer, returning the difference and a flag that is true if the operation + /// caused an arithmetic overflow. `word` is shifted `shift` words to the left before being subtracted. + /// + /// - Note: If `overflow` is true, then the returned value is the two's complement of the absolute difference. + /// - Complexity: O(count) + internal func subtractingWordReportingOverflow(_ word: Word, shiftedBy shift: Int = 0) -> (partialValue: BigUInt, overflow: Bool) { + var result = self + let overflow = result.subtractWordReportingOverflow(word, shiftedBy: shift) + return (result, overflow) + } + + /// Subtract a digit `d` from this integer in place. + /// `d` is shifted `shift` digits to the left before being subtracted. + /// + /// - Requires: self >= d * 2^shift + /// - Complexity: O(count) + internal mutating func subtractWord(_ word: Word, shiftedBy shift: Int = 0) { + let overflow = subtractWordReportingOverflow(word, shiftedBy: shift) + precondition(!overflow) + } + + /// Subtract a digit `d` from this integer and return the result. + /// `d` is shifted `shift` digits to the left before being subtracted. + /// + /// - Requires: self >= d * 2^shift + /// - Complexity: O(count) + internal func subtractingWord(_ word: Word, shiftedBy shift: Int = 0) -> BigUInt { + var result = self + result.subtractWord(word, shiftedBy: shift) + return result + } + + /// Subtract `other` from this integer in place, and return a flag indicating if the operation caused an + /// arithmetic overflow. `other` is shifted `shift` digits to the left before being subtracted. + /// + /// - Note: If the result indicates an overflow, then `self` becomes the twos' complement of the absolute difference. + /// - Complexity: O(count) + public mutating func subtractReportingOverflow(_ b: BigUInt, shiftedBy shift: Int = 0) -> Bool { + precondition(shift >= 0) + var carry = false + var bi = 0 + let bc = b.count + let count = self.count + while bi < bc || (shift + bi < count && carry) { + let ai = shift + bi + let (d, c) = self[ai].subtractingReportingOverflow(b[bi]) + if carry { + let (d2, c2) = d.subtractingReportingOverflow(1) + self[ai] = d2 + carry = c || c2 + } + else { + self[ai] = d + carry = c + } + bi += 1 + } + return carry + } + + /// Subtract `other` from this integer, returning the difference and a flag indicating arithmetic overflow. + /// `other` is shifted `shift` digits to the left before being subtracted. + /// + /// - Note: If `overflow` is true, then the result value is the twos' complement of the absolute value of the difference. + /// - Complexity: O(count) + public func subtractingReportingOverflow(_ other: BigUInt, shiftedBy shift: Int) -> (partialValue: BigUInt, overflow: Bool) { + var result = self + let overflow = result.subtractReportingOverflow(other, shiftedBy: shift) + return (result, overflow) + } + + /// Subtracts `other` from `self`, returning the result and a flag indicating arithmetic overflow. + /// + /// - Note: When the operation overflows, then `partialValue` is the twos' complement of the absolute value of the difference. + /// - Complexity: O(count) + public func subtractingReportingOverflow(_ other: BigUInt) -> (partialValue: BigUInt, overflow: Bool) { + return self.subtractingReportingOverflow(other, shiftedBy: 0) + } + + /// Subtract `other` from this integer in place. + /// `other` is shifted `shift` digits to the left before being subtracted. + /// + /// - Requires: self >= other * 2^shift + /// - Complexity: O(count) + public mutating func subtract(_ other: BigUInt, shiftedBy shift: Int = 0) { + let overflow = subtractReportingOverflow(other, shiftedBy: shift) + precondition(!overflow) + } + + /// Subtract `b` from this integer, and return the difference. + /// `b` is shifted `shift` digits to the left before being subtracted. + /// + /// - Requires: self >= b * 2^shift + /// - Complexity: O(count) + public func subtracting(_ other: BigUInt, shiftedBy shift: Int = 0) -> BigUInt { + var result = self + result.subtract(other, shiftedBy: shift) + return result + } + + /// Decrement this integer by one. + /// + /// - Requires: !isZero + /// - Complexity: O(count) + public mutating func decrement(shiftedBy shift: Int = 0) { + self.subtract(1, shiftedBy: shift) + } + + /// Subtract `b` from `a` and return the result. + /// + /// - Requires: a >= b + /// - Complexity: O(a.count) + public static func -(a: BigUInt, b: BigUInt) -> BigUInt { + return a.subtracting(b) + } + + /// Subtract `b` from `a` and store the result in `a`. + /// + /// - Requires: a >= b + /// - Complexity: O(a.count) + public static func -=(a: inout BigUInt, b: BigUInt) { + a.subtract(b) + } +} + +extension BigInt { + public mutating func negate() { + guard !magnitude.isZero else { return } + self.sign = self.sign == .plus ? .minus : .plus + } + + /// Subtract `b` from `a` and return the result. + public static func -(a: BigInt, b: BigInt) -> BigInt { + return a + -b + } + + /// Subtract `b` from `a` in place. + public static func -=(a: inout BigInt, b: BigInt) { a = a - b } +} diff --git a/mac/PortalKit/Sources/PortalKit/BigInt/Words and Bits.swift b/mac/PortalKit/Sources/PortalKit/BigInt/Words and Bits.swift new file mode 100644 index 0000000..4543c1b --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/BigInt/Words and Bits.swift @@ -0,0 +1,202 @@ +// +// Words and Bits.swift +// BigInt +// +// Created by Károly Lőrentey on 2017-08-11. +// Copyright © 2016-2017 Károly Lőrentey. +// + +extension Array where Element == UInt { + mutating func twosComplement() { + var increment = true + for i in 0 ..< self.count { + if increment { + (self[i], increment) = (~self[i]).addingReportingOverflow(1) + } + else { + self[i] = ~self[i] + } + } + } +} + +extension BigUInt { + public subscript(bitAt index: Int) -> Bool { + get { + precondition(index >= 0) + let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth) + return self[i] & (1 << j) != 0 + } + set { + precondition(index >= 0) + let (i, j) = index.quotientAndRemainder(dividingBy: Word.bitWidth) + if newValue { + self[i] |= 1 << j + } + else { + self[i] &= ~(1 << j) + } + } + } +} + +extension BigUInt { + /// The minimum number of bits required to represent this integer in binary. + /// + /// - Returns: floor(log2(2 * self + 1)) + /// - Complexity: O(1) + public var bitWidth: Int { + guard count > 0 else { return 0 } + return count * Word.bitWidth - self[count - 1].leadingZeroBitCount + } + + /// The number of leading zero bits in the binary representation of this integer in base `2^(Word.bitWidth)`. + /// This is useful when you need to normalize a `BigUInt` such that the top bit of its most significant word is 1. + /// + /// - Note: 0 is considered to have zero leading zero bits. + /// - Returns: A value in `0...(Word.bitWidth - 1)`. + /// - SeeAlso: width + /// - Complexity: O(1) + public var leadingZeroBitCount: Int { + guard count > 0 else { return 0 } + return self[count - 1].leadingZeroBitCount + } + + /// The number of trailing zero bits in the binary representation of this integer. + /// + /// - Note: 0 is considered to have zero trailing zero bits. + /// - Returns: A value in `0...width`. + /// - Complexity: O(count) + public var trailingZeroBitCount: Int { + guard count > 0 else { return 0 } + let i = self.words.firstIndex { $0 != 0 }! + return i * Word.bitWidth + self[i].trailingZeroBitCount + } +} + +extension BigInt { + public var bitWidth: Int { + guard !magnitude.isZero else { return 0 } + return magnitude.bitWidth + 1 + } + + public var trailingZeroBitCount: Int { + // Amazingly, this works fine for negative numbers + return magnitude.trailingZeroBitCount + } +} + +extension BigUInt { + public struct Words: RandomAccessCollection { + private let value: BigUInt + + fileprivate init(_ value: BigUInt) { self.value = value } + + public var startIndex: Int { return 0 } + public var endIndex: Int { return value.count } + + public subscript(_ index: Int) -> Word { + return value[index] + } + } + + public var words: Words { return Words(self) } + + public init(words: Words) where Words.Element == Word { + let uc = words.underestimatedCount + if uc > 2 { + self.init(words: Array(words)) + } + else { + var it = words.makeIterator() + guard let w0 = it.next() else { + self.init() + return + } + guard let w1 = it.next() else { + self.init(word: w0) + return + } + if let w2 = it.next() { + var words: [UInt] = [] + words.reserveCapacity(Swift.max(3, uc)) + words.append(w0) + words.append(w1) + words.append(w2) + while let word = it.next() { + words.append(word) + } + self.init(words: words) + } + else { + self.init(low: w0, high: w1) + } + } + } +} + +extension BigInt { + public struct Words: RandomAccessCollection { + public typealias Indices = CountableRange + + private let value: BigInt + private let decrementLimit: Int + + fileprivate init(_ value: BigInt) { + self.value = value + switch value.sign { + case .plus: + self.decrementLimit = 0 + case .minus: + assert(!value.magnitude.isZero) + self.decrementLimit = value.magnitude.words.firstIndex(where: { $0 != 0 })! + } + } + + public var count: Int { + switch value.sign { + case .plus: + if let high = value.magnitude.words.last, high >> (Word.bitWidth - 1) != 0 { + return value.magnitude.count + 1 + } + return value.magnitude.count + case .minus: + let high = value.magnitude.words.last! + if high >> (Word.bitWidth - 1) != 0 { + return value.magnitude.count + 1 + } + return value.magnitude.count + } + } + + public var indices: Indices { return 0 ..< count } + public var startIndex: Int { return 0 } + public var endIndex: Int { return count } + + public subscript(_ index: Int) -> UInt { + // Note that indices above `endIndex` are accepted. + if value.sign == .plus { + return value.magnitude[index] + } + if index <= decrementLimit { + return ~(value.magnitude[index] &- 1) + } + return ~value.magnitude[index] + } + } + + public var words: Words { + return Words(self) + } + + public init(words: S) where S.Element == Word { + var words = Array(words) + if (words.last ?? 0) >> (Word.bitWidth - 1) == 0 { + self.init(sign: .plus, magnitude: BigUInt(words: words)) + } + else { + words.twosComplement() + self.init(sign: .minus, magnitude: BigUInt(words: words)) + } + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Client/Models.swift b/mac/PortalKit/Sources/PortalKit/Client/Models.swift new file mode 100644 index 0000000..60fcde2 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Client/Models.swift @@ -0,0 +1,173 @@ +// +// Models.swift +// PortalKit +// +// Data types and result models for PortalClient operations. +// + +import Foundation + +public enum PortalClientError: LocalizedError, Sendable { + case invalidHost(String) + case invalidURL(String) + case pairingFailed(String) + case notPaired + case tlsPinningMismatch(expected: String, got: String) + case requestFailed(statusCode: Int, message: String) + case communicationError(String) + case jsonParsingError(String) + + public var errorDescription: String? { + switch self { + case .invalidHost(let h): return "Invalid Portal host: \(h)" + case .invalidURL(let u): return "Invalid URL: \(u)" + case .pairingFailed(let m): return "Pairing failed: \(m)" + case .notPaired: return "Portal is not paired. Please pair first or specify credentials." + case .tlsPinningMismatch: + return "Certificate mismatch — connection rejected. Unpair and pair again if this is your Portal." + case .requestFailed(let code, let msg): + return "HTTP request failed with status \(code): \(msg)" + case .communicationError(let msg): + return "Network communication error: \(msg)" + case .jsonParsingError(let msg): + return "JSON parsing error: \(msg)" + } + } +} + +public struct PortalStatus: Sendable { + public let host: String + public let isOnline: Bool + public let isPaired: Bool + public let pinnedCertSha256: String? + public let leafCertSha256: String? + public let isCertPinMatching: Bool + public let serverReportedCertSha256: String? + public let details: String + + public init( + host: String, + isOnline: Bool, + isPaired: Bool, + pinnedCertSha256: String?, + leafCertSha256: String?, + isCertPinMatching: Bool, + serverReportedCertSha256: String?, + details: String + ) { + self.host = host + self.isOnline = isOnline + self.isPaired = isPaired + self.pinnedCertSha256 = pinnedCertSha256 + self.leafCertSha256 = leafCertSha256 + self.isCertPinMatching = isCertPinMatching + self.serverReportedCertSha256 = serverReportedCertSha256 + self.details = details + } +} + +/// Camera mode + mode-specific config from `/control/state` and SSE `/control/events`. +public struct PortalCameraState: Codable, Sendable, Equatable { + public let mode: String + public let config: PortalCameraConfig + + public init(mode: String, config: PortalCameraConfig = PortalCameraConfig()) { + self.mode = mode + self.config = config + } +} + +public struct PortalCameraConfig: Codable, Sendable, Equatable { + public let centerX: Double? + public let centerY: Double? + public let scale: Double? + public let framingTightness: Double? + public let trackingResponseDelayPct: Double? + public let trackingSensitivityPct: Double? + public let transitionSpeedPct: Double? + + public init( + centerX: Double? = nil, + centerY: Double? = nil, + scale: Double? = nil, + framingTightness: Double? = nil, + trackingResponseDelayPct: Double? = nil, + trackingSensitivityPct: Double? = nil, + transitionSpeedPct: Double? = nil + ) { + self.centerX = centerX + self.centerY = centerY + self.scale = scale + self.framingTightness = framingTightness + self.trackingResponseDelayPct = trackingResponseDelayPct + self.trackingSensitivityPct = trackingSensitivityPct + self.transitionSpeedPct = transitionSpeedPct + } +} + +public struct PairingSession: Sendable { + public let pairingId: String + public let saltHex: String + public let pubBHex: String + public let expiresIn: Int? + public let capturedCertSha256: Data + public let capturedCertSha256Hex: String + public let srpClient: PortalSrpClient + + public init( + pairingId: String, + saltHex: String, + pubBHex: String, + expiresIn: Int?, + capturedCertSha256: Data, + capturedCertSha256Hex: String, + srpClient: PortalSrpClient + ) { + self.pairingId = pairingId + self.saltHex = saltHex + self.pubBHex = pubBHex + self.expiresIn = expiresIn + self.capturedCertSha256 = capturedCertSha256 + self.capturedCertSha256Hex = capturedCertSha256Hex + self.srpClient = srpClient + } +} + +public struct PairingResult: Sendable { + public let success: Bool + public let token: String + public let pinnedCertSha256Hex: String + public let serverM2Hex: String + + public init(success: Bool, token: String, pinnedCertSha256Hex: String, serverM2Hex: String) { + self.success = success + self.token = token + self.pinnedCertSha256Hex = pinnedCertSha256Hex + self.serverM2Hex = serverM2Hex + } +} + +public struct MitmDefenseResult: Sendable { + public let attackDescription: String + public let simulatedMitmCertSha256Hex: String + public let realCertSha256Hex: String + public let defenseSuccessful: Bool + public let serverRejectionMessage: String + public let attemptsRemaining: Int? + + public init( + attackDescription: String, + simulatedMitmCertSha256Hex: String, + realCertSha256Hex: String, + defenseSuccessful: Bool, + serverRejectionMessage: String, + attemptsRemaining: Int? + ) { + self.attackDescription = attackDescription + self.simulatedMitmCertSha256Hex = simulatedMitmCertSha256Hex + self.realCertSha256Hex = realCertSha256Hex + self.defenseSuccessful = defenseSuccessful + self.serverRejectionMessage = serverRejectionMessage + self.attemptsRemaining = attemptsRemaining + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift b/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift new file mode 100644 index 0000000..15ee727 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Client/PortalClient.swift @@ -0,0 +1,475 @@ +// +// PortalClient.swift +// PortalKit +// +// High-level client for pairing, status, and control with Portal TV. +// + +import Foundation +import CryptoKit + +public final class PortalClient: @unchecked Sendable { + public let host: String + public let credentialStorage: CredentialStorage + public let requestTimeout: TimeInterval + + public init( + host: String, + credentialStorage: CredentialStorage = PortalAuth.defaultStorage, + requestTimeout: TimeInterval = 10 + ) { + // Normalize host: strip leading scheme if present, default PortalCam port if missing + var clean = host.trimmingCharacters(in: .whitespacesAndNewlines) + if clean.hasPrefix("https://") { + clean = String(clean.dropFirst("https://".count)) + } else if clean.hasPrefix("http://") { + clean = String(clean.dropFirst("http://".count)) + } + if clean.hasSuffix("/") { + clean = String(clean.dropLast()) + } + if !clean.contains(":") { + clean = "\(clean):\(PortalEndpoints.port)" + } + self.host = clean + self.credentialStorage = credentialStorage + self.requestTimeout = requestTimeout + } + + private var baseURLString: String { + "https://\(host)" + } + + // MARK: - Status & Inspection + + /// Checks service status, queries /auth/cert, and verifies TLS certificate pinning state. + public func status() async throws -> PortalStatus { + guard let url = URL(string: "\(baseURLString)/auth/cert") else { + throw PortalClientError.invalidHost(host) + } + + let delegate = SrpPairingSessionDelegate() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + var isOnline = false + var serverReportedSha: String? + var detailsMsg = "" + + do { + let (data, response) = try await session.data(for: request) + if let http = response as? HTTPURLResponse, http.statusCode == 200 { + isOnline = true + if let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let sha = json["certSha256"] as? String { + serverReportedSha = sha.lowercased() + } + detailsMsg = "Portal TV service online (HTTP 200)" + } else { + isOnline = true + detailsMsg = "Portal TV reached (HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0))" + } + } catch { + detailsMsg = "Connection failed: \(error.localizedDescription)" + } + + let leafCertSha = delegate.capturedCertSha256Hex?.lowercased() + let pinnedCertSha = credentialStorage.getPinnedCertSha256()?.lowercased() + let authToken = credentialStorage.getAuthToken() + let isPaired = (authToken != nil && pinnedCertSha != nil) + + let isCertPinMatching: Bool + if let pinned = pinnedCertSha, let leaf = leafCertSha { + isCertPinMatching = (pinned.caseInsensitiveCompare(leaf) == .orderedSame) + } else { + isCertPinMatching = false + } + + return PortalStatus( + host: host, + isOnline: isOnline, + isPaired: isPaired, + pinnedCertSha256: pinnedCertSha, + leafCertSha256: leafCertSha, + isCertPinMatching: isCertPinMatching, + serverReportedCertSha256: serverReportedSha, + details: detailsMsg + ) + } + + // MARK: - Pairing Phase + + /// Step 1: Initiate pairing over ephemeral TLS, capturing the certificate digest and SRP parameters. + public func initiatePairing() async throws -> PairingSession { + guard let url = URL(string: "\(baseURLString)/auth/srp/init") else { + throw PortalClientError.invalidHost(host) + } + + let delegate = SrpPairingSessionDelegate() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw PortalClientError.communicationError("Failed to initiate pairing: \(error.localizedDescription)") + } + + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let msg = String(data: data, encoding: .utf8) ?? "HTTP \((response as? HTTPURLResponse)?.statusCode ?? 0)" + throw PortalClientError.pairingFailed("Init rejected by server: \(msg)") + } + + guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let pairingId = json["pairingId"] as? String, + let salt = json["salt"] as? String, + let b = json["B"] as? String else { + throw PortalClientError.jsonParsingError("Invalid JSON payload from /auth/srp/init") + } + + guard let certData = delegate.capturedCertSha256, + let certHex = delegate.capturedCertSha256Hex else { + throw PortalClientError.pairingFailed("Failed to capture TLS certificate for channel binding") + } + + let srpClient = PortalSrpClient() + let expiresIn = json["expiresIn"] as? Int + + return PairingSession( + pairingId: pairingId, + saltHex: salt, + pubBHex: b, + expiresIn: expiresIn, + capturedCertSha256: certData, + capturedCertSha256Hex: certHex, + srpClient: srpClient + ) + } + + /// Step 2: Complete pairing with user PIN, verifying server M2 and pinning the certificate. + public func completePairing(session: PairingSession, pin: String) async throws -> PairingResult { + let cleanPin = pin.trimmingCharacters(in: .whitespacesAndNewlines) + guard !cleanPin.isEmpty else { + throw PortalClientError.pairingFailed("PIN cannot be empty") + } + + let m1Hex: String + do { + m1Hex = try session.srpClient.computeM1( + saltHex: session.saltHex, + pubBHex: session.pubBHex, + pin: cleanPin, + tlsCertSha256: session.capturedCertSha256 + ) + } catch { + throw PortalClientError.pairingFailed("SRP computation failed: \(error.localizedDescription)") + } + + guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else { + throw PortalClientError.invalidHost(host) + } + + let delegate = SrpPairingSessionDelegate() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let payload: [String: String] = [ + "pairingId": session.pairingId, + "A": session.srpClient.pubAHex, + "M1": m1Hex + ] + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let data: Data + let response: URLResponse + do { + (data, response) = try await urlSession.data(for: request) + } catch { + throw PortalClientError.communicationError("Verification request failed: \(error.localizedDescription)") + } + + let http = response as? HTTPURLResponse + guard let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { + let bodyStr = String(data: data, encoding: .utf8) ?? "" + throw PortalClientError.pairingFailed("Invalid response from server: \(bodyStr)") + } + + if http?.statusCode == 200, + let m2Hex = json["M2"] as? String, + let token = json["token"] as? String { + // Verify server M2 + do { + try session.srpClient.verifyServerM2(serverM2Hex: m2Hex) + } catch { + throw PortalClientError.pairingFailed("Server proof M2 verification failed! Potential MITM attack: \(error.localizedDescription)") + } + + // Save credentials + credentialStorage.save(token: token, certSha256: session.capturedCertSha256Hex) + + return PairingResult( + success: true, + token: token, + pinnedCertSha256Hex: session.capturedCertSha256Hex, + serverM2Hex: m2Hex + ) + } else { + let errorMsg = json["message"] as? String ?? json["error"] as? String ?? "Pairing rejected" + let attempts = json["attemptsLeft"] as? Int + var fullMsg = errorMsg + if let a = attempts { + fullMsg += " (\(a) attempts remaining)" + } + throw PortalClientError.pairingFailed(fullMsg) + } + } + + /// Full pairing flow: initiates pairing, computes M1 with PIN, verifies M2, and saves credentials. + public func pair(pin: String) async throws -> PairingResult { + let session = try await initiatePairing() + return try await completePairing(session: session, pin: pin) + } + + // MARK: - Control Commands (Pinned TLS & Bearer Auth) + + /// One-shot camera state (`GET /control/state`). Prefer [cameraStateEvents] for live UI. + public func cameraState() async throws -> PortalCameraState { + try await controlState(path: "/control/state") + } + + /// Live camera state via SSE (`GET /control/events`). Emits initial state, then updates. + public func cameraStateEvents() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + try await self.consumeCameraStateEvents(continuation: continuation) + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Switch mode (`DefaultAuto`, `Desk`, `Meeting`, `Fixed`). State arrives via SSE. + public func setMode(_ mode: String) async throws { + let encoded = mode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? mode + try await controlAck(path: "/control/mode?mode=\(encoded)") + } + + /// Apply Fixed crop. State arrives via SSE. + public func setFixedCrop(x: Double, y: Double, scale: Double) async throws { + try await controlAck(path: "/control/fixed?x=\(x)&y=\(y)&scale=\(scale)") + } + + /// Apply Desk framing tightness. State arrives via SSE. + public func setDeskTightness(_ tightness: Double) async throws { + try await controlAck(path: "/control/desk?tightness=\(tightness)") + } + + /// Sends a control command over pinned HTTPS using stored credentials. + /// Prefer typed helpers (`cameraState`, `setMode`, …). + @discardableResult + public func control(command: String) async throws -> String { + let data = try await controlRequest(path: Self.formatControlPath(command)) + return String(data: data, encoding: .utf8) ?? "" + } + + private func controlAck(path: String) async throws { + let data = try await controlRequest(path: path) + if let state = try? JSONDecoder().decode(PortalCameraState.self, from: data) { + // Older servers still return state; ignore body shape either way. + _ = state + return + } + // Expect {"ok":true} or tolerate empty/other 200 bodies. + if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let err = obj["error"] as? String { + let msg = obj["message"] as? String ?? err + throw PortalClientError.requestFailed(statusCode: 200, message: msg) + } + } + + private func controlState(path: String) async throws -> PortalCameraState { + let data = try await controlRequest(path: path) + do { + return try JSONDecoder().decode(PortalCameraState.self, from: data) + } catch { + let body = String(data: data, encoding: .utf8) ?? "" + throw PortalClientError.jsonParsingError("Expected PortalCameraState, got: \(body)") + } + } + + private func consumeCameraStateEvents( + continuation: AsyncThrowingStream.Continuation + ) async throws { + guard let pinnedCert = credentialStorage.getPinnedCertSha256(), + let token = credentialStorage.getAuthToken() else { + throw PortalClientError.notPaired + } + guard let url = URL(string: "\(baseURLString)/control/events") else { + throw PortalClientError.invalidURL("\(baseURLString)/control/events") + } + + // Same pinning + dataTask path as video/control — `bytes(for:)` can miss + // session-level auth challenges and fall through to default (self-signed) trust. + let stream = PortalSSEDataStream(url: url, token: token, pinnedFingerprint: pinnedCert) + try await withTaskCancellationHandler { + try await stream.run { state in + continuation.yield(state) + } + continuation.finish() + } onCancel: { + stream.cancel() + } + } + + private func controlRequest(path: String) async throws -> Data { + guard let pinnedCert = credentialStorage.getPinnedCertSha256(), + let token = credentialStorage.getAuthToken() else { + throw PortalClientError.notPaired + } + + guard let url = URL(string: "\(baseURLString)\(path)") else { + throw PortalClientError.invalidURL("\(baseURLString)\(path)") + } + + let delegate = PortalPinnedSessionDelegate(pinnedFingerprint: pinnedCert) + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + if let mismatch = delegate.consumePinMismatch() { + throw PortalClientError.tlsPinningMismatch(expected: mismatch.expected, got: mismatch.got) + } + throw PortalClientError.communicationError(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw PortalClientError.communicationError("Invalid HTTP response") + } + + if http.statusCode == 200 { + return data + } + let bodyString = String(data: data, encoding: .utf8) ?? "" + throw PortalClientError.requestFailed(statusCode: http.statusCode, message: bodyString) + } + + static func formatControlPath(_ command: String) -> String { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("/control/") || trimmed == "/control" || trimmed.hasPrefix("/control?") { + return trimmed + } else if trimmed.hasPrefix("/control") { + return trimmed + } else if trimmed.hasPrefix("control/") { + return "/\(trimmed)" + } else if trimmed == "state" || trimmed.hasPrefix("state?") { + return "/control/\(trimmed)" + } else if trimmed.hasPrefix("/") { + return "/control\(trimmed)" + } else if trimmed.hasPrefix("mode ") { + let modeVal = String(trimmed.dropFirst("mode ".count)).trimmingCharacters(in: .whitespaces) + return "/control/mode?mode=\(modeVal)" + } else if trimmed.hasPrefix("mode?") || trimmed.hasPrefix("fixed?") || trimmed.hasPrefix("desk?") { + return "/control/\(trimmed)" + } else { + return "/control/\(trimmed)" + } + } + + // MARK: - MITM Defense Test + + /// Tests channel binding defense by computing M1 with an altered TLS certificate hash + /// and confirming that Portal TV rejects the pairing exchange. + public func testMitm( + simulatedMitmCertSha256: Data? = nil, + pin: String = "123456" + ) async throws -> MitmDefenseResult { + let session = try await initiatePairing() + + let fakeCertHash: Data + if let customFake = simulatedMitmCertSha256 { + fakeCertHash = customFake + } else { + // Alter genuine cert hash by bit inversion + fakeCertHash = Data(session.capturedCertSha256.map { ~$0 }) + } + let fakeCertHex = SrpFormat.bytesToHex(fakeCertHash) + + // Compute M1 bound to the fake cert + let m1Hex = try session.srpClient.computeM1( + saltHex: session.saltHex, + pubBHex: session.pubBHex, + pin: pin, + tlsCertSha256: fakeCertHash + ) + + guard let url = URL(string: "\(baseURLString)/auth/srp/verify") else { + throw PortalClientError.invalidHost(host) + } + + let delegate = SrpPairingSessionDelegate() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = requestTimeout + let urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload: [String: String] = [ + "pairingId": session.pairingId, + "A": session.srpClient.pubAHex, + "M1": m1Hex + ] + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await urlSession.data(for: request) + let http = response as? HTTPURLResponse + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + + let isRejected = (http?.statusCode != 200) + let errMsg = json?["message"] as? String ?? json?["error"] as? String ?? "HTTP \(http?.statusCode ?? 0)" + let attemptsLeft = json?["attemptsLeft"] as? Int + + return MitmDefenseResult( + attackDescription: "Simulated rogue MITM TLS certificate substitution", + simulatedMitmCertSha256Hex: fakeCertHex, + realCertSha256Hex: session.capturedCertSha256Hex, + defenseSuccessful: isRejected, + serverRejectionMessage: errMsg, + attemptsRemaining: attemptsLeft + ) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Client/PortalEndpoints.swift b/mac/PortalKit/Sources/PortalKit/Client/PortalEndpoints.swift new file mode 100644 index 0000000..49701b8 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Client/PortalEndpoints.swift @@ -0,0 +1,14 @@ +// +// PortalEndpoints.swift +// PortalKit +// + +import Foundation + +public enum PortalEndpoints: Sendable { + /// Fixed PortalCam HTTPS / DNS-SD port ("TV" joke → 5654). + public static let port: Int = 5654 + + /// Bonjour / DNS-SD service type (no trailing dot for NWBrowser). + public static let bonjourType = "_portalcam._tcp" +} diff --git a/mac/PortalKit/Sources/PortalKit/Client/PortalSSEDataStream.swift b/mac/PortalKit/Sources/PortalKit/Client/PortalSSEDataStream.swift new file mode 100644 index 0000000..a900ecd --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Client/PortalSSEDataStream.swift @@ -0,0 +1,220 @@ +// +// PortalSSEDataStream.swift +// PortalKit +// +// Long-lived SSE reader using URLSessionDataDelegate + pin (same path as video). +// + +import Foundation + +/// Streams `text/event-stream` over pinned HTTPS via a classic data task. +final class PortalSSEDataStream: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate, @unchecked Sendable { + private let url: URL + private let token: String + private let pinnedFingerprint: String + + private var session: URLSession? + private var task: URLSessionDataTask? + private var buffer = Data() + private var pendingDataLines: [String] = [] + private var lastPinMismatch: (expected: String, got: String)? + + private var onState: ((PortalCameraState) -> Void)? + private var finish: ((Result) -> Void)? + private var finished = false + private let lock = NSLock() + + init(url: URL, token: String, pinnedFingerprint: String) { + self.url = url + self.token = token + self.pinnedFingerprint = pinnedFingerprint + super.init() + } + + func run(onState: @escaping (PortalCameraState) -> Void) async throws { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + self.lock.lock() + self.onState = onState + self.finish = { result in + cont.resume(with: result) + } + self.lock.unlock() + self.start() + } + } + + func cancel() { + finishOnce(.failure(CancellationError())) + tearDown() + } + + private func start() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = .infinity + config.timeoutIntervalForResource = .infinity + config.requestCachePolicy = .reloadIgnoringLocalCacheData + let session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + self.session = session + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") + + let task = session.dataTask(with: request) + self.task = task + task.resume() + } + + private func tearDown() { + task?.cancel() + task = nil + session?.invalidateAndCancel() + session = nil + } + + private func finishOnce(_ result: Result) { + lock.lock() + guard !finished else { + lock.unlock() + return + } + finished = true + let done = finish + finish = nil + onState = nil + lock.unlock() + tearDown() + done?(result) + } + + private func evaluateChallenge( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + PortalTlsPinning.evaluate( + challenge: challenge, + pinnedFingerprint: pinnedFingerprint + ) { [weak self] disposition, credential in + if disposition == .cancelAuthenticationChallenge, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust, + let cert = PortalTlsPinning.extractLeafCert(from: trust) { + let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert) + if self?.pinnedFingerprint.caseInsensitiveCompare(got) != .orderedSame { + self?.lock.lock() + self?.lastPinMismatch = (expected: self?.pinnedFingerprint ?? "", got: got) + self?.lock.unlock() + } + } + completionHandler(disposition, credential) + } + } + + // MARK: - URLSessionDelegate + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + evaluateChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + evaluateChallenge(challenge, completionHandler: completionHandler) + } + + func urlSession( + _ session: URLSession, + dataTask: URLSessionDataTask, + didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + guard let http = response as? HTTPURLResponse else { + completionHandler(.cancel) + finishOnce(.failure(PortalClientError.communicationError("Invalid HTTP response"))) + return + } + guard http.statusCode == 200 else { + completionHandler(.cancel) + finishOnce(.failure(PortalClientError.requestFailed( + statusCode: http.statusCode, + message: "SSE connect failed" + ))) + return + } + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer.append(data) + consume() + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error { + let ns = error as NSError + lock.lock() + let mismatch = lastPinMismatch + lastPinMismatch = nil + lock.unlock() + if let mismatch { + finishOnce(.failure(PortalClientError.tlsPinningMismatch( + expected: mismatch.expected, + got: mismatch.got + ))) + return + } + if ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled { + finishOnce(.failure(CancellationError())) + return + } + finishOnce(.failure(PortalClientError.communicationError(error.localizedDescription))) + return + } + finishOnce(.success(())) + } + + private func consume() { + while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) { + var lineData = buffer.subdata(in: buffer.startIndex.. String { + guard let salt = Self.hexToBytes(saltHex), !salt.isEmpty else { + throw SrpError.invalidParameter("Invalid salt hex") + } + guard let bBytes = Self.hexToBytes(pubBHex), !bBytes.isEmpty else { + throw SrpError.invalidParameter("Invalid B hex") + } + guard !tlsCertSha256.isEmpty else { + throw SrpError.invalidParameter("TLS certificate hash cannot be empty") + } + + let B = BigUInt(bBytes) + + // Safety check B % N != 0 + guard B % Self.N != 0 else { + throw SrpError.invalidParameter("Server public value B % N == 0") + } + + // u = SHA256(pad256(A) || pad256(B)) + let uHash = Self.sha256(A.toPadded256Data(), B.toPadded256Data()) + let u = BigUInt(uHash) + guard u != 0 else { + throw SrpError.invalidParameter("Computed u == 0") + } + + // x = SHA256(salt || UTF8(pin)) + guard let pinData = pin.data(using: .utf8), !pinData.isEmpty else { + throw SrpError.invalidParameter("Invalid or empty PIN") + } + let xHash = Self.sha256(salt, pinData) + let x = BigUInt(xHash) + + // S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N + let S = SrpMath.computeClientS( + B: B, + k: Self.k, + g: Self.g, + x: x, + a: a, + u: u, + N: Self.N + ) + + // K = SHA256(pad256(S)) + let sessionK = Self.sha256(S.toPadded256Data()) + self.K = sessionK + self.tlsCertHash = tlsCertSha256 + + // M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256) + let clientM1 = Self.sha256(A.toPadded256Data(), B.toPadded256Data(), sessionK, salt, tlsCertSha256) + self.M1 = clientM1 + return Self.bytesToHex(clientM1) + } + + /// Verify M2 returned by the server. + public func verifyServerM2(serverM2Hex: String) throws { + guard let expectedM1 = M1, let sessionK = K, let certHash = tlsCertHash else { + throw SrpError.verificationFailed("Client state not initialized for verification") + } + guard let serverM2 = Self.hexToBytes(serverM2Hex) else { + throw SrpError.verificationFailed("Invalid server M2 hex") + } + + // Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256) + let expectedM2 = Self.sha256(A.toPadded256Data(), expectedM1, sessionK, certHash) + + // Constant-time comparison + guard SrpFormat.constantTimeEquals(expectedM2, serverM2) else { + throw SrpError.verificationFailed("Server evidence M2 does not match (potential MITM or incorrect credentials)") + } + } + + // MARK: - Helpers + + public static func sha256(_ parts: Data...) -> Data { + SrpGroup.rfc5054_2048.hashAlgorithm.hash(parts) + } + + public static func bytesToHex(_ data: Data) -> String { + SrpFormat.bytesToHex(data) + } + + public static func hexToBytes(_ hex: String) -> Data? { + SrpFormat.hexToBytes(hex) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Crypto/SrpGroup.swift b/mac/PortalKit/Sources/PortalKit/Crypto/SrpGroup.swift new file mode 100644 index 0000000..30e5f6a --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Crypto/SrpGroup.swift @@ -0,0 +1,191 @@ +// +// SrpGroup.swift +// PortalKit +// +// SRP-6a RFC 5054 prime groups and cryptographic math helpers. +// + +import Foundation +import CryptoKit + +public enum SrpHashAlgorithm: Sendable { + case sha1 + case sha256 + + public func hash(_ parts: Data...) -> Data { + hash(parts) + } + + public func hash(_ parts: [Data]) -> Data { + switch self { + case .sha1: + var hasher = Insecure.SHA1() + for p in parts { hasher.update(data: p) } + return Data(hasher.finalize()) + case .sha256: + var hasher = SHA256() + for p in parts { hasher.update(data: p) } + return Data(hasher.finalize()) + } + } +} + +public struct SrpGroup: Sendable { + public let N: BigUInt + public let g: BigUInt + public let byteLength: Int + public let k: BigUInt + public let hashAlgorithm: SrpHashAlgorithm + + public init(NHex: String, g: BigUInt, byteLength: Int, hashAlgorithm: SrpHashAlgorithm) { + let cleanHex = NHex.replacingOccurrences(of: "\\s+", with: "", options: .regularExpression) + guard let nVal = BigUInt(cleanHex, radix: 16) else { + fatalError("Invalid N hex for SrpGroup") + } + self.N = nVal + self.g = g + self.byteLength = byteLength + self.hashAlgorithm = hashAlgorithm + + let nData = nVal.toPaddedData(byteCount: byteLength) + let gData = g.toPaddedData(byteCount: byteLength) + let kHash = hashAlgorithm.hash(nData, gData) + self.k = BigUInt(kHash) + } + + /// RFC 5054 Appendix A 1024-bit group with SHA-1 (for test vector verification) + public static let rfc5054_1024 = SrpGroup( + NHex: """ + EEAF0AB9ADB38DD69C33F80AFA8FC5E86072618775FF3C0B9EA2314C9C256576\ + D674DF7496EA81D3383B4813D692C6E0E0D5D8E250B98BE48E495C1D6089DAD1\ + 5DC7D7B46154D6B6CE8EF4AD69B15D4982559B297BCF1885C529F566660E57EC\ + 68EDBC3C05726CC02FD4CBF4976EAA9AFD5138FE8376435B9FC61D2FC0EB06E3 + """, + g: BigUInt(2), + byteLength: 128, + hashAlgorithm: .sha1 + ) + + /// RFC 5054 2048-bit group with SHA-256 (standard Portal TV group) + public static let rfc5054_2048 = SrpGroup( + NHex: """ + FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74\ + 020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437\ + 4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED\ + EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05\ + 98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB\ + 9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B\ + E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718\ + 3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF + """, + g: BigUInt(2), + byteLength: 256, + hashAlgorithm: .sha256 + ) +} + +public enum SrpMath { + /// Calculate x according to RFC 5054: x = H(s | H(I | ":" | P)) + public static func computeRfc5054X(identity: String, password: String, salt: Data, hashAlgorithm: SrpHashAlgorithm) -> BigUInt { + let colonData = ":".data(using: .utf8)! + let identityData = identity.data(using: .utf8)! + let passwordData = password.data(using: .utf8)! + let innerHash = hashAlgorithm.hash(identityData, colonData, passwordData) + let outerHash = hashAlgorithm.hash(salt, innerHash) + return BigUInt(outerHash) + } + + /// Calculate x according to Portal TV protocol: x = SHA256(salt | UTF8(pin)) + public static func computePortalX(pin: String, salt: Data, hashAlgorithm: SrpHashAlgorithm = .sha256) -> BigUInt { + let pinData = pin.data(using: .utf8)! + let hash = hashAlgorithm.hash(salt, pinData) + return BigUInt(hash) + } + + /// Verifier v = g^x mod N + public static func computeVerifier(g: BigUInt, x: BigUInt, N: BigUInt) -> BigUInt { + g.power(x, modulus: N) + } + + /// Public A = g^a mod N + public static func computeA(g: BigUInt, a: BigUInt, N: BigUInt) -> BigUInt { + g.power(a, modulus: N) + } + + /// Public B = (k*v + g^b) mod N + public static func computeB(k: BigUInt, v: BigUInt, g: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt { + let kv = (k * v) % N + let gb = g.power(b, modulus: N) + return (kv + gb) % N + } + + /// Scrambler u = H(PAD(A) | PAD(B)) + public static func computeU(A: BigUInt, B: BigUInt, padLength: Int, hashAlgorithm: SrpHashAlgorithm) -> BigUInt { + let aData = A.toPaddedData(byteCount: padLength) + let bData = B.toPaddedData(byteCount: padLength) + let hash = hashAlgorithm.hash(aData, bData) + return BigUInt(hash) + } + + /// Client premaster secret S = (B - k * (g^x mod N)) ^ (a + u * x) mod N + public static func computeClientS(B: BigUInt, k: BigUInt, g: BigUInt, x: BigUInt, a: BigUInt, u: BigUInt, N: BigUInt) -> BigUInt { + let gx = g.power(x, modulus: N) + let kgx = (k * gx) % N + let base = (B >= kgx) ? (B - kgx) : (N - ((kgx - B) % N)) + let exp = a + u * x + return base.power(exp, modulus: N) + } + + /// Server premaster secret S = (A * (v^u mod N)) ^ b mod N + public static func computeServerS(A: BigUInt, v: BigUInt, u: BigUInt, b: BigUInt, N: BigUInt) -> BigUInt { + let vu = v.power(u, modulus: N) + let base = (A * vu) % N + return base.power(b, modulus: N) + } +} + +public enum SrpFormat { + public static func bytesToHex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + public static func hexToBytes(_ hex: String) -> Data? { + let clean = hex.components(separatedBy: .whitespacesAndNewlines).joined() + guard clean.count % 2 == 0 else { return nil } + var data = Data(capacity: clean.count / 2) + var index = clean.startIndex + while index < clean.endIndex { + let nextIndex = clean.index(index, offsetBy: 2) + let byteStr = String(clean[index.. Bool { + guard a.count == b.count else { return false } + var result: UInt8 = 0 + for i in 0.. Data { + let raw = self.serialize() + if raw.count >= byteCount { + return raw.suffix(byteCount) + } + var padded = Data(repeating: 0, count: byteCount - raw.count) + padded.append(raw) + return padded + } + + public func toPadded256Data() -> Data { + toPaddedData(byteCount: 256) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Crypto/SrpServerMock.swift b/mac/PortalKit/Sources/PortalKit/Crypto/SrpServerMock.swift new file mode 100644 index 0000000..0efeb34 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Crypto/SrpServerMock.swift @@ -0,0 +1,128 @@ +// +// SrpServerMock.swift +// PortalKit +// +// Mock Portal TV SRP-6a server (matching PortalSrp.kt) for testing and simulations. +// + +import Foundation +import CryptoKit + +public final class SrpServerMock: @unchecked Sendable { + public let pairingId: String + public let pin: String + public let salt: Data + public let v: BigUInt + public let b: BigUInt + public let B: BigUInt + public let group: SrpGroup + + public var saltHex: String { + SrpFormat.bytesToHex(salt) + } + + public var pubBHex: String { + SrpFormat.bytesToHex(B.toPadded256Data()) + } + + public init( + pin: String = "123456", + salt: Data? = nil, + b: BigUInt? = nil, + pairingId: String = UUID().uuidString, + group: SrpGroup = .rfc5054_2048 + ) { + self.pairingId = pairingId + self.pin = pin + self.group = group + + let effectiveSalt = salt ?? { + var bytes = [UInt8](repeating: 0, count: 16) + _ = SecRandomCopyBytes(kSecRandomDefault, 16, &bytes) + return Data(bytes) + }() + self.salt = effectiveSalt + + // x = SHA256(salt || UTF8(pin)) + let pinData = pin.data(using: .utf8)! + let xHash = group.hashAlgorithm.hash(effectiveSalt, pinData) + let x = BigUInt(xHash) + + // v = g^x mod N + self.v = group.g.power(x, modulus: group.N) + + // b = private server exponent + if let customB = b { + self.b = customB + } else { + var bBytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, 32, &bBytes) + let randVal = BigUInt(Data(bBytes)) + self.b = (randVal % (group.N - 2)) + 1 + } + + // B = (k*v + g^b) mod N + self.B = SrpMath.computeB(k: group.k, v: self.v, g: group.g, b: self.b, N: group.N) + } + + public struct VerifyResult: Sendable { + public let success: Bool + public let M2Hex: String? + public let token: String? + public let errorMessage: String? + } + + /// Verifies client credentials against the server's state and server certificate hash. + public func verifyClient(pubAHex: String, clientM1Hex: String, serverTlsCertSha256: Data) -> VerifyResult { + guard let aBytes = SrpFormat.hexToBytes(pubAHex), !aBytes.isEmpty else { + return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid A hex") + } + guard let clientM1 = SrpFormat.hexToBytes(clientM1Hex), !clientM1.isEmpty else { + return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid M1 hex") + } + + let A = BigUInt(aBytes) + guard A % group.N != 0 else { + return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Invalid public key A: A % N == 0") + } + + let padLen = group.byteLength + let aPadded = A.toPaddedData(byteCount: padLen) + let bPadded = B.toPaddedData(byteCount: padLen) + + // u = H(PAD(A) || PAD(B)) + let uHash = group.hashAlgorithm.hash(aPadded, bPadded) + let u = BigUInt(uHash) + guard u != 0 else { + return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "Computed u == 0") + } + + // Server computes S = (A * (v^u mod N)) ^ b mod N + let S = SrpMath.computeServerS(A: A, v: v, u: u, b: b, N: group.N) + let sPadded = S.toPaddedData(byteCount: padLen) + + // K = H(PAD(S)) + let K = group.hashAlgorithm.hash(sPadded) + + // Expected M1 = H(PAD(A) || PAD(B) || K || salt || serverTlsCertSha256) + let expectedM1 = group.hashAlgorithm.hash(aPadded, bPadded, K, salt, serverTlsCertSha256) + + guard SrpFormat.constantTimeEquals(clientM1, expectedM1) else { + return VerifyResult(success: false, M2Hex: nil, token: nil, errorMessage: "M1 verification failed: wrong PIN or TLS MITM detected") + } + + // M2 = H(PAD(A) || M1 || K || serverTlsCertSha256) + let M2 = group.hashAlgorithm.hash(aPadded, clientM1, K, serverTlsCertSha256) + let m2Hex = SrpFormat.bytesToHex(M2) + + // Generate token + var tokenBytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, 32, &tokenBytes) + let token = Data(tokenBytes).base64EncodedString() + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "=", with: "") + + return VerifyResult(success: true, M2Hex: m2Hex, token: token, errorMessage: nil) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/TLS/PortalTlsPinning.swift b/mac/PortalKit/Sources/PortalKit/TLS/PortalTlsPinning.swift new file mode 100644 index 0000000..597b17e --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/TLS/PortalTlsPinning.swift @@ -0,0 +1,216 @@ +// +// PortalTlsPinning.swift +// PortalKit +// +// TLS Certificate Pinning, Challenge Evaluation, and Leaf Certificate Extraction. +// + +import Foundation +import Security +import CryptoKit +import os.log + +public enum PortalTlsPinning { + private static let log = Logger(subsystem: "com.kovtash.portalkit", category: "tls") + + public static func extractLeafCert(from serverTrust: SecTrust) -> SecCertificate? { + if #available(macOS 12.0, *) { + if let chain = SecTrustCopyCertificateChain(serverTrust) as? [SecCertificate], !chain.isEmpty { + return chain[0] + } + } + let count = SecTrustGetCertificateCount(serverTrust) + guard count > 0 else { return nil } + return SecTrustGetCertificateAtIndex(serverTrust, 0) + } + + public static func computeCertSha256(cert: SecCertificate) -> (data: Data, hex: String) { + let certDer = SecCertificateCopyData(cert) as Data + let digest = SHA256.hash(data: certDer) + let data = Data(digest) + let hex = SrpFormat.bytesToHex(data) + return (data, hex) + } + + public static func evaluate( + challenge: URLAuthenticationChallenge, + pinnedFingerprint: String? = PortalAuth.pinnedCertSha256, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust else { + // Avoid performDefaultHandling — it surfaces "invalid certificate" UI for self-signed servers. + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + guard let cert = extractLeafCert(from: serverTrust) else { + log.error("PortalKit TLS: No certificate found in server trust chain") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let (_, certHashHex) = computeCertSha256(cert: cert) + + guard let pinned = pinnedFingerprint, !pinned.isEmpty else { + log.error("PortalKit TLS: No pinned certificate configured; rejecting connection") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + guard certHashHex.caseInsensitiveCompare(pinned) == .orderedSame else { + log.error("PortalKit TLS Pinning Mismatch! Expected: \(pinned, privacy: .public), Got: \(certHashHex, privacy: .public)") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + // Pin match is sufficient; SecTrustEvaluateWithError rejects our self-signed Portal cert. + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } + + /// Connects to a host and captures the presented TLS leaf certificate fingerprint. + public static func fetchServerCertFingerprint( + url: URL, + timeout: TimeInterval = 10 + ) async throws -> (data: Data, hex: String) { + let delegate = SrpPairingSessionDelegate() + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = timeout + let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = timeout + + // Perform request to trigger TLS handshake + _ = try? await session.data(for: request) + + guard let certData = delegate.capturedCertSha256, + let certHex = delegate.capturedCertSha256Hex else { + throw SrpError.tlsCertificateMissing + } + + return (certData, certHex) + } +} + +/// URLSessionDelegate for the pairing phase: accepts the self-signed certificate, +/// but records its DER and SHA-256 digest so it can be channel-bound into SRP-6a. +public final class SrpPairingSessionDelegate: NSObject, URLSessionDelegate, @unchecked Sendable { + private let lock = NSLock() + private var _capturedCertDer: Data? + private var _capturedCertSha256: Data? + private var _capturedCertSha256Hex: String? + + public override init() { + super.init() + } + + public var capturedCertDer: Data? { + lock.lock() + defer { lock.unlock() } + return _capturedCertDer + } + + public var capturedCertSha256: Data? { + lock.lock() + defer { lock.unlock() } + return _capturedCertSha256 + } + + public var capturedCertSha256Hex: String? { + lock.lock() + defer { lock.unlock() } + return _capturedCertSha256Hex + } + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + let cert = PortalTlsPinning.extractLeafCert(from: serverTrust) else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + + let (data, hex) = PortalTlsPinning.computeCertSha256(cert: cert) + let der = SecCertificateCopyData(cert) as Data + + lock.lock() + self._capturedCertDer = der + self._capturedCertSha256 = data + self._capturedCertSha256Hex = hex + lock.unlock() + + // Accept trust for the pairing exchange. SRP-6a cryptographic channel binding + // guarantees that if an attacker intercepted this TLS connection with a rogue cert, + // the M1 verification on Portal will fail and pairing will be rejected. + completionHandler(.useCredential, URLCredential(trust: serverTrust)) + } +} + +/// URLSessionDelegate for standard requests (control, health, SSE) enforcing certificate pinning. +public final class PortalPinnedSessionDelegate: NSObject, URLSessionDelegate, URLSessionTaskDelegate, @unchecked Sendable { + public let pinnedFingerprint: String? + /// Set when the last challenge was rejected for a pin mismatch (for mapping NSURLErrorCancelled). + public private(set) var lastPinMismatch: (expected: String, got: String)? + private let lock = NSLock() + + public init(pinnedFingerprint: String? = PortalAuth.pinnedCertSha256) { + self.pinnedFingerprint = pinnedFingerprint + super.init() + } + + public func consumePinMismatch() -> (expected: String, got: String)? { + lock.lock() + defer { lock.unlock() } + let value = lastPinMismatch + lastPinMismatch = nil + return value + } + + private func recordEvaluate( + challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + PortalTlsPinning.evaluate( + challenge: challenge, + pinnedFingerprint: pinnedFingerprint + ) { [weak self] disposition, credential in + if disposition == .cancelAuthenticationChallenge, + let pinned = self?.pinnedFingerprint, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust, + let cert = PortalTlsPinning.extractLeafCert(from: trust) { + let (_, got) = PortalTlsPinning.computeCertSha256(cert: cert) + if pinned.caseInsensitiveCompare(got) != .orderedSame { + self?.lock.lock() + self?.lastPinMismatch = (expected: pinned, got: got) + self?.lock.unlock() + } + } + completionHandler(disposition, credential) + } + } + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + recordEvaluate(challenge: challenge, completionHandler: completionHandler) + } + + /// Async `bytes(for:)` / `data(for:)` deliver server-trust challenges here on recent macOS. + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + recordEvaluate(challenge: challenge, completionHandler: completionHandler) + } +} diff --git a/mac/PortalKit/Sources/PortalKit/Version.swift b/mac/PortalKit/Sources/PortalKit/Version.swift new file mode 100644 index 0000000..f03f641 --- /dev/null +++ b/mac/PortalKit/Sources/PortalKit/Version.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct PortalKitVersion { + public static let version = "1.0.0" +} diff --git a/mac/PortalKit/Sources/portalkit-cli/main.swift b/mac/PortalKit/Sources/portalkit-cli/main.swift new file mode 100644 index 0000000..f6c2070 --- /dev/null +++ b/mac/PortalKit/Sources/portalkit-cli/main.swift @@ -0,0 +1,272 @@ +// +// main.swift +// portalkit-cli +// +// Command-line interface for Portal TV pairing, TLS pinning, control, and MITM defense verification. +// + +import Foundation +import PortalKit + +/// Hybrid credential storage that reads/writes both macOS Keychain and ~/.portalkit/credentials.json +final class CliCredentialStorage: CredentialStorage, @unchecked Sendable { + private let keychain = KeychainCredentialStorage(accessGroup: nil) + private let fileStorage = FileCredentialStorage() + + func getAuthToken() -> String? { + if let token = keychain.getAuthToken(), !token.isEmpty { + return token + } + return fileStorage.getAuthToken() + } + + func getPinnedCertSha256() -> String? { + if let cert = keychain.getPinnedCertSha256(), !cert.isEmpty { + return cert + } + return fileStorage.getPinnedCertSha256() + } + + func save(token: String, certSha256: String) { + keychain.save(token: token, certSha256: certSha256) + fileStorage.save(token: token, certSha256: certSha256) + } + + func clear() { + keychain.clear() + fileStorage.clear() + } +} + +@main +struct PortalKitCli { + static let storage = CliCredentialStorage() + + static func printUsage() { + let usage = """ + PortalKit CLI - Portal TV pairing, TLS pinning, and control tool + + USAGE: + portalkit-cli [options] + + COMMANDS: + pair [--pin ] + Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN, + verifies server M2 proof, and pins the TLS certificate. + + status + Checks Portal TV service status, inspects the presented TLS leaf certificate, + and verifies certificate pinning state. + + control + Sends control commands over pinned HTTPS. Mutations return {"ok":true}; + live state is on GET /control/events (SSE). + ({"mode":"…","config":{…}}); failures return {"error":"…","message":"…"}. + + Commands: + state + mode + fixed?x=<0-1>&y=<0-1>&scale=<0.1-1> + desk?tightness=<0-1> + + test-mitm + Simulates a Man-in-the-Middle (MITM) attack with a substituted TLS certificate + to verify that SRP-6a channel binding prevents unauthorized interception. + + OPTIONS: + --pin, -p 6-digit PIN displayed on Portal TV (for 'pair' command) + --help, -h Show this help reference + + EXAMPLES: + portalkit-cli pair 10.0.0.10:5654 --pin 123456 + portalkit-cli status 10.0.0.10:5654 + portalkit-cli control 10.0.0.10:5654 state + portalkit-cli control 10.0.0.10:5654 mode Desk + portalkit-cli control 10.0.0.10:5654 fixed?x=0.5&y=0.5&scale=1.0 + portalkit-cli control 10.0.0.10:5654 desk?tightness=0.5 + portalkit-cli test-mitm 10.0.0.10:5654 + """ + print(usage) + } + + static func main() async { + var args = Array(CommandLine.arguments.dropFirst()) + + if args.isEmpty || args.contains("--help") || args.contains("-h") || args.first == "help" { + printUsage() + return + } + + let command = args.removeFirst() + + switch command { + case "pair": + await handlePair(args: args) + case "status": + await handleStatus(args: args) + case "control": + await handleControl(args: args) + case "test-mitm": + await handleTestMitm(args: args) + default: + print("Unknown command: '\(command)'. Run with --help for usage.") + exit(1) + } + } + + // MARK: - Command Handlers + + static func handlePair(args: [String]) async { + guard let host = args.first, !host.hasPrefix("-") else { + print("Error: Missing parameter.\nUsage: portalkit-cli pair [--pin ]") + exit(1) + } + + var pin: String? = nil + var idx = 1 + while idx < args.count { + if (args[idx] == "--pin" || args[idx] == "-p"), idx + 1 < args.count { + pin = args[idx + 1] + idx += 2 + } else { + idx += 1 + } + } + + let client = PortalClient(host: host, credentialStorage: storage) + + do { + print("Initiating SRP-6a pairing with Portal TV at \(client.host)…") + let session = try await client.initiatePairing() + print("Connected over ephemeral TLS.") + print("Captured Server Leaf Cert SHA-256: \(session.capturedCertSha256Hex)") + print("Pairing ID: \(session.pairingId)") + if let exp = session.expiresIn { + print("Session expires in: \(exp)s") + } + + let effectivePin: String + if let provided = pin, !provided.isEmpty { + effectivePin = provided + } else { + print("Enter the 6-digit PIN displayed on Portal TV: ", terminator: "") + fflush(stdout) + effectivePin = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + guard !effectivePin.isEmpty else { + print("Error: No PIN entered.") + exit(1) + } + + print("Computing channel-bound M1 proof…") + print("Submitting M1 and verifying Portal TV M2 proof…") + let result = try await client.completePairing(session: session, pin: effectivePin) + + print("\nPairing successful!") + print("Server Proof M2 Verified: \(result.serverM2Hex.prefix(16))…") + print("Bearer Token: \(result.token.prefix(12))…") + print("TLS Certificate Pinned: \(result.pinnedCertSha256Hex)") + print("Credentials saved successfully.") + } catch { + print("\nPairing failed: \(error.localizedDescription)") + exit(1) + } + } + + static func handleStatus(args: [String]) async { + guard let host = args.first, !host.hasPrefix("-") else { + print("Error: Missing parameter.\nUsage: portalkit-cli status ") + exit(1) + } + + let client = PortalClient(host: host, credentialStorage: storage) + print("Checking Portal TV status at \(client.host)…") + + do { + let status = try await client.status() + print("Host: \(status.host)") + print("Service Online: \(status.isOnline ? "YES" : "NO")") + print("Details: \(status.details)") + if let srvSha = status.serverReportedCertSha256 { + print("Server Cert Digest: \(srvSha)") + } + if let leafSha = status.leafCertSha256 { + print("Leaf Cert SHA-256: \(leafSha)") + } else { + print("Leaf Cert SHA-256: [Not reached / no TLS cert]") + } + + if let pinned = status.pinnedCertSha256 { + print("Pinned Cert Digest: \(pinned)") + print("Pin Status: \(status.isCertPinMatching ? "VALID (Match)" : "MISMATCH / INVALID")") + } else { + print("Pinned Cert Digest: [None configured]") + } + print("Paired: \(status.isPaired ? "YES" : "NO")") + } catch { + print("Status check failed: \(error.localizedDescription)") + exit(1) + } + } + + static func handleControl(args: [String]) async { + guard let host = args.first, !host.hasPrefix("-") else { + print("Error: Missing parameter.\nUsage: portalkit-cli control ") + exit(1) + } + + let commandArgs = Array(args.dropFirst()) + guard !commandArgs.isEmpty else { + print("Error: Missing parameter.\nUsage: portalkit-cli control ") + print("Examples:\n control mode Desk\n control fixed?x=0.5&y=0.5&scale=1.0") + exit(1) + } + + let fullCommand = commandArgs.joined(separator: " ") + let client = PortalClient(host: host, credentialStorage: storage) + + print("Sending control command '\(fullCommand)' to \(client.host) via pinned HTTPS…") + do { + let response = try await client.control(command: fullCommand) + print("Response: \(response)") + } catch { + print("Control command failed: \(error.localizedDescription)") + exit(1) + } + } + + static func handleTestMitm(args: [String]) async { + guard let host = args.first, !host.hasPrefix("-") else { + print("Error: Missing parameter.\nUsage: portalkit-cli test-mitm ") + exit(1) + } + + let client = PortalClient(host: host, credentialStorage: storage) + print("Testing SRP-6a TLS Channel Binding Defense against Portal TV at \(client.host)…") + + do { + let result = try await client.testMitm() + print("\n================== CHANNEL BINDING DEFENSE TEST ==================") + print("Attack Simulation: \(result.attackDescription)") + print("Genuine Server Cert SHA-256: \(result.realCertSha256Hex)") + print("Simulated MITM Cert SHA-256: \(result.simulatedMitmCertSha256Hex)") + print("Server Response: \(result.serverRejectionMessage)") + if let remaining = result.attemptsRemaining { + print("Pairing Attempts Left: \(remaining)") + } + print("------------------------------------------------------------------") + if result.defenseSuccessful { + print("Verdict: PASSED - Server successfully rejected the altered certificate binding!") + print("Security Guarantee: An active MITM proxy cannot forge authentication without detection.") + } else { + print("Verdict: FAILED - Server unexpectedly accepted the connection!") + exit(1) + } + print("==================================================================") + } catch { + print("Test failed to execute: \(error.localizedDescription)") + exit(1) + } + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/BigUIntPaddingTests.swift b/mac/PortalKit/Tests/PortalKitTests/BigUIntPaddingTests.swift new file mode 100644 index 0000000..44b17b3 --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/BigUIntPaddingTests.swift @@ -0,0 +1,103 @@ +// +// BigUIntPaddingTests.swift +// PortalKitTests +// +// Verification of BigUInt 256-byte padding, truncation, and hex conversion edge cases. +// + +import XCTest +@testable import PortalKit + +final class BigUIntPaddingTests: XCTestCase { + func testZeroPaddingTo256Bytes() { + let zero = BigUInt(0) + let padded = zero.toPadded256Data() + XCTAssertEqual(padded.count, 256, "Zero must produce exactly 256 bytes") + XCTAssertEqual(padded, Data(repeating: 0, count: 256)) + } + + func testSmallNumberPadding() { + let one = BigUInt(1) + let padded = one.toPadded256Data() + XCTAssertEqual(padded.count, 256) + XCTAssertEqual(padded.prefix(255), Data(repeating: 0, count: 255)) + XCTAssertEqual(padded.last, 1) + + let value = BigUInt(0x12345678) + let paddedValue = value.toPadded256Data() + XCTAssertEqual(paddedValue.count, 256) + XCTAssertEqual(paddedValue.suffix(4), Data([0x12, 0x34, 0x56, 0x78])) + } + + func testExact256ByteValue() { + // Portal TV 2048-bit prime N is exactly 256 bytes + let N = PortalSrpClient.N + let paddedN = N.toPadded256Data() + XCTAssertEqual(paddedN.count, 256) + XCTAssertEqual(paddedN.first, 0xFF) + XCTAssertEqual(paddedN.last, 0xFF) + + // Raw serialize count for 2048-bit N starting with 0xFF is 256 bytes + XCTAssertEqual(N.serialize().count, 256) + XCTAssertEqual(paddedN, N.serialize()) + } + + func testVariableLengthPadding() { + let val = BigUInt(0xAABB) + // Pad to 4 bytes: 00 00 AA BB + let p4 = val.toPaddedData(byteCount: 4) + XCTAssertEqual(p4, Data([0x00, 0x00, 0xAA, 0xBB])) + + // Pad to 2 bytes: AA BB + let p2 = val.toPaddedData(byteCount: 2) + XCTAssertEqual(p2, Data([0xAA, 0xBB])) + + // Truncate to 1 byte: BB (suffix) + let p1 = val.toPaddedData(byteCount: 1) + XCTAssertEqual(p1, Data([0xBB])) + } + + func testHexConversionEdgeCases() { + // Empty data + XCTAssertEqual(SrpFormat.bytesToHex(Data()), "") + XCTAssertEqual(SrpFormat.hexToBytes(""), Data()) + + // Leading zero single byte + XCTAssertEqual(SrpFormat.bytesToHex(Data([0x05])), "05") + XCTAssertEqual(SrpFormat.hexToBytes("05"), Data([0x05])) + + // High nybble / low nybble + XCTAssertEqual(SrpFormat.bytesToHex(Data([0xF0, 0x0F])), "f00f") + XCTAssertEqual(SrpFormat.hexToBytes("f00f"), Data([0xF0, 0x0F])) + XCTAssertEqual(SrpFormat.hexToBytes("F00F"), Data([0xF0, 0x0F])) // case insensitive + + // Whitespace and newline tolerance + let spacedHex = " f0 0f \n 12\t 34 \r\n" + XCTAssertEqual(SrpFormat.hexToBytes(spacedHex), Data([0xF0, 0x0F, 0x12, 0x34])) + + // Invalid: odd length + XCTAssertNil(SrpFormat.hexToBytes("123")) + XCTAssertNil(SrpFormat.hexToBytes("f")) + + // Invalid: non-hex characters + XCTAssertNil(SrpFormat.hexToBytes("123g")) + XCTAssertNil(SrpFormat.hexToBytes("zz")) + XCTAssertNil(SrpFormat.hexToBytes("!!")) + } + + func testConstantTimeEqualsEdgeCases() { + let a = Data([0x01, 0x02, 0x03, 0x04]) + let b = Data([0x01, 0x02, 0x03, 0x04]) + let diffStart = Data([0xFF, 0x02, 0x03, 0x04]) + let diffMid = Data([0x01, 0xFF, 0x03, 0x04]) + let diffEnd = Data([0x01, 0x02, 0x03, 0xFF]) + let diffLength = Data([0x01, 0x02, 0x03]) + + XCTAssertTrue(SrpFormat.constantTimeEquals(a, b)) + XCTAssertTrue(SrpFormat.constantTimeEquals(Data(), Data())) + XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffStart)) + XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffMid)) + XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffEnd)) + XCTAssertFalse(SrpFormat.constantTimeEquals(a, diffLength)) + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/ChannelBindingTests.swift b/mac/PortalKit/Tests/PortalKitTests/ChannelBindingTests.swift new file mode 100644 index 0000000..b897a4d --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/ChannelBindingTests.swift @@ -0,0 +1,117 @@ +// +// ChannelBindingTests.swift +// PortalKitTests +// +// Verification of cryptographic TLS channel binding in SRP-6a M1 and M2. +// + +import XCTest +@testable import PortalKit + +final class ChannelBindingTests: XCTestCase { + let pin = "481516" + let genuineCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("GENUINE_PORTAL_CERT_DER".data(using: .utf8)!) + let rogueCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("ROGUE_PROXY_CERT_DER".data(using: .utf8)!) + + func testMatchingCertHashSucceeds() throws { + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + // Both use the same genuine TLS cert hash + let m1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: genuineCertHash + ) + + let serverRes = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: m1Hex, + serverTlsCertSha256: genuineCertHash + ) + + XCTAssertTrue(serverRes.success) + XCTAssertNotNil(serverRes.M2Hex) + XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: serverRes.M2Hex!)) + } + + func testAlteredCertHashFailsServerM1Verification() throws { + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + // Client is tricked into connecting through a MITM proxy presenting rogue cert + let m1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: rogueCertHash // Client uses rogue proxy cert hash + ) + + // Portal TV verifies against its real local certificate hash + let serverRes = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: m1Hex, + serverTlsCertSha256: genuineCertHash // Server uses genuine cert hash + ) + + XCTAssertFalse(serverRes.success, "Server must reject client M1 when TLS cert hashes differ") + XCTAssertNil(serverRes.token) + XCTAssertNil(serverRes.M2Hex) + } + + func testAlteredCertHashFailsClientM2Verification() throws { + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + // Client computes M1 bound to genuine cert + _ = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: genuineCertHash + ) + + // Attacker attempts to forge M2 or server computes M2 bound to a different cert hash + let aPadded = client.A.toPadded256Data() + let fakeM2 = SrpGroup.rfc5054_2048.hashAlgorithm.hash( + aPadded, + client.clientM1!, + client.sessionKey!, + rogueCertHash // Mismatched cert hash in M2 + ) + let fakeM2Hex = SrpFormat.bytesToHex(fakeM2) + + XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: fakeM2Hex)) { error in + guard case SrpError.verificationFailed(let msg) = error else { + XCTFail("Unexpected error type: \(error)") + return + } + XCTAssertTrue(msg.contains("Server evidence M2 does not match")) + } + } + + func testSingleBitFlipInCertHashFails() throws { + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + // Flip 1 bit in the genuine cert hash + var tamperedCertHash = genuineCertHash + tamperedCertHash[0] ^= 0x01 + + let m1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: tamperedCertHash + ) + + let serverRes = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: m1Hex, + serverTlsCertSha256: genuineCertHash + ) + + XCTAssertFalse(serverRes.success, "Single-bit flip in TLS cert hash must fail verification") + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/ClientAndAuthTests.swift b/mac/PortalKit/Tests/PortalKitTests/ClientAndAuthTests.swift new file mode 100644 index 0000000..b45b54f --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/ClientAndAuthTests.swift @@ -0,0 +1,116 @@ +// +// ClientAndAuthTests.swift +// PortalKitTests +// +// Verification of CredentialStorage abstractions, PortalAuth, and PortalClient models. +// + +import XCTest +@testable import PortalKit + +final class ClientAndAuthTests: XCTestCase { + func testInMemoryCredentialStorage() { + let storage = InMemoryCredentialStorage() + XCTAssertNil(storage.getAuthToken()) + XCTAssertNil(storage.getPinnedCertSha256()) + + storage.save(token: "test_token_123", certSha256: "AABBCCDDEEFF") + XCTAssertEqual(storage.getAuthToken(), "test_token_123") + XCTAssertEqual(storage.getPinnedCertSha256(), "aabbccddeeff") // lowercased + + storage.clear() + XCTAssertNil(storage.getAuthToken()) + XCTAssertNil(storage.getPinnedCertSha256()) + } + + func testFileCredentialStorage() { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("test_credentials_\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: tempFile) } + + let storage = FileCredentialStorage(fileURL: tempFile) + XCTAssertNil(storage.getAuthToken()) + + storage.save(token: "tok_abc", certSha256: "010203040506") + XCTAssertEqual(storage.getAuthToken(), "tok_abc") + XCTAssertEqual(storage.getPinnedCertSha256(), "010203040506") + + // Create fresh instance pointing to same file + let reloadStorage = FileCredentialStorage(fileURL: tempFile) + XCTAssertEqual(reloadStorage.getAuthToken(), "tok_abc") + XCTAssertEqual(reloadStorage.getPinnedCertSha256(), "010203040506") + + reloadStorage.clear() + XCTAssertNil(reloadStorage.getAuthToken()) + } + + func testPortalAuthFacade() { + let original = PortalAuth.defaultStorage + defer { PortalAuth.defaultStorage = original } + + let mockStorage = InMemoryCredentialStorage() + PortalAuth.defaultStorage = mockStorage + + PortalAuth.save(token: "facade_token", certSha256: "CAFEBABE") + XCTAssertEqual(PortalAuth.token, "facade_token") + XCTAssertEqual(PortalAuth.pinnedCertSha256, "cafebabe") + + PortalAuth.clear() + XCTAssertNil(PortalAuth.token) + XCTAssertNil(PortalAuth.pinnedCertSha256) + } + + func testPortalClientHostNormalization() { + let c1 = PortalClient(host: "10.0.0.10") + XCTAssertEqual(c1.host, "10.0.0.10:\(PortalEndpoints.port)") + + let c2 = PortalClient(host: "https://10.0.0.10:9000/") + XCTAssertEqual(c2.host, "10.0.0.10:9000") + + let c3 = PortalClient(host: "http://myportal.local:\(PortalEndpoints.port)") + XCTAssertEqual(c3.host, "myportal.local:\(PortalEndpoints.port)") + } + + func testPortalClientControlRequiresPairing() async { + let storage = InMemoryCredentialStorage() + let client = PortalClient(host: "127.0.0.1", credentialStorage: storage) + + do { + _ = try await client.control(command: "mode Desk") + XCTFail("Should throw notPaired error") + } catch { + guard case PortalClientError.notPaired = error else { + XCTFail("Expected notPaired error, got \(error)") + return + } + } + } + + func testTlsPinningChallengeEvaluationNonServerTrust() { + let space = URLProtectionSpace( + host: "localhost", + port: PortalEndpoints.port, + protocol: "https", + realm: nil, + authenticationMethod: NSURLAuthenticationMethodHTTPBasic + ) + let challenge = URLAuthenticationChallenge(protectionSpace: space, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: DummyChallengeSender()) + + let exp = expectation(description: "Challenge evaluated") + PortalTlsPinning.evaluate(challenge: challenge, pinnedFingerprint: "somehash") { disposition, credential in + XCTAssertEqual(disposition, .cancelAuthenticationChallenge) + XCTAssertNil(credential) + exp.fulfill() + } + wait(for: [exp], timeout: 1) + } +} + +private final class DummyChallengeSender: NSObject, URLAuthenticationChallengeSender { + func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {} + func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {} + func cancel(_ challenge: URLAuthenticationChallenge) {} + func performDefaultHandling(for challenge: URLAuthenticationChallenge) {} + func rejectProtectionSpaceAndContinue(with challenge: URLAuthenticationChallenge) {} +} + diff --git a/mac/PortalKit/Tests/PortalKitTests/Rfc5054VectorsTests.swift b/mac/PortalKit/Tests/PortalKitTests/Rfc5054VectorsTests.swift new file mode 100644 index 0000000..e5c47f5 --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/Rfc5054VectorsTests.swift @@ -0,0 +1,147 @@ +// +// Rfc5054VectorsTests.swift +// PortalKitTests +// +// Verification of RFC 5054 Appendix B test vectors (1024-bit group with SHA-1). +// + +import XCTest +@testable import PortalKit + +final class Rfc5054VectorsTests: XCTestCase { + let group = SrpGroup.rfc5054_1024 + + // RFC 5054 Appendix B inputs + let I = "alice" + let P = "password123" + let sHex = "BEB25379D1A8581EB5A727673A2441EE" + + // RFC 5054 Appendix B expected values + let expectedKHex = "7556AA045AEF2CDD07ABAF0F665C3E818913186F" + let expectedXHex = "94B7555AABE9127CC58CCF4993DB6CF84D16C124" + let expectedVHex = """ + 7E273DE8696FFC4F4E337D05B4B375BEB0DDE1569E8FA00A9886D8129BADA1F1\ + 822223CA1A605B530E379BA4729FDC59F105B4787E5186F5C671085A1447B52A\ + 48CF1970B4FB6F8400BBF4CEBFBB168152E08AB5EA53D15C1AFF87B2B9DA6E04\ + E058AD51CC72BFC9033B564E26480D78E955A5E29E7AB245DB2BE315E2099AFB + """ + let expectedAHex = "60975527035CF2AD1989806F0407210BC81EDC04E2762A56AFD529DDDA2D4393" + let expectedBHex = "E487CB59D31AC550471E81F00F6928E01DDA08E974A004F49E61F5D105284D20" + let expectedPubAHex = """ + 61D5E490F6F1B79547B0704C436F523DD0E560F0C64115BB72557EC44352E890\ + 3211C04692272D8B2D1A5358A2CF1B6E0BFCF99F921530EC8E39356179EAE45E\ + 42BA92AEACED825171E1E8B9AF6D9C03E1327F44BE087EF06530E69F66615261\ + EEF54073CA11CF5858F0EDFDFE15EFEAB349EF5D76988A3672FAC47B0769447B + """ + let expectedPubBHex = """ + BD0C61512C692C0CB6D041FA01BB152D4916A1E77AF46AE105393011BAF38964\ + DC46A0670DD125B95A981652236F99D9B681CBF87837EC996C6DA04453728610\ + D0C6DDB58B318885D7D82C7F8DEB75CE7BD4FBAA37089E6F9C6059F388838E7A\ + 00030B331EB76840910440B1B27AAEAEEB4012B7D7665238A8E3FB004B117B58 + """ + let expectedUHex = "CE38B9593487DA98554ED47D70A7AE5F462EF019" + let expectedSHex = """ + B0DC82BABCF30674AE450C0287745E7990A3381F63B387AAF271A10D233861E3\ + 59B48220F7C4693C9AE12B0A6F67809F0876E2D013800D6C41BB59B6D5979B5C\ + 00A172B4A2A5903A0BDCAF8A709585EB2AFAFA8F3499B200210DCC1F10EB3394\ + 3CD67FC88A2F39A4BE5BEC4EC0A3212DC346D7E474B29EDE8A469FFECA686E5A + """ + + func testMultiplierK() { + let computedK = group.k + let computedKHex = SrpFormat.bytesToHex(computedK.serialize()) + XCTAssertEqual(computedKHex.lowercased(), expectedKHex.lowercased(), "RFC 5054 multiplier k mismatch") + } + + func testPrivateKeyX() { + guard let sData = SrpFormat.hexToBytes(sHex) else { + XCTFail("Failed to decode salt hex") + return + } + let x = SrpMath.computeRfc5054X(identity: I, password: P, salt: sData, hashAlgorithm: group.hashAlgorithm) + let xHex = SrpFormat.bytesToHex(x.serialize()) + XCTAssertEqual(xHex.lowercased(), expectedXHex.lowercased(), "RFC 5054 private key x mismatch") + } + + func testVerifierV() { + guard let xVal = BigUInt(expectedXHex, radix: 16) else { + XCTFail("Failed to parse expected x") + return + } + let v = SrpMath.computeVerifier(g: group.g, x: xVal, N: group.N) + let vHex = SrpFormat.bytesToHex(v.toPaddedData(byteCount: 128)) + XCTAssertEqual(vHex.lowercased(), expectedVHex.lowercased(), "RFC 5054 verifier v mismatch") + } + + func testPublicA() { + guard let aVal = BigUInt(expectedAHex, radix: 16) else { + XCTFail("Failed to parse expected a") + return + } + let A = SrpMath.computeA(g: group.g, a: aVal, N: group.N) + let aHex = SrpFormat.bytesToHex(A.toPaddedData(byteCount: 128)) + XCTAssertEqual(aHex.lowercased(), expectedPubAHex.lowercased(), "RFC 5054 public key A mismatch") + } + + func testPublicB() { + guard let bVal = BigUInt(expectedBHex, radix: 16), + let vVal = BigUInt(expectedVHex, radix: 16) else { + XCTFail("Failed to parse b or v") + return + } + let B = SrpMath.computeB(k: group.k, v: vVal, g: group.g, b: bVal, N: group.N) + let bHex = SrpFormat.bytesToHex(B.toPaddedData(byteCount: 128)) + XCTAssertEqual(bHex.lowercased(), expectedPubBHex.lowercased(), "RFC 5054 public key B mismatch") + } + + func testScramblerU() { + guard let aVal = BigUInt(expectedPubAHex, radix: 16), + let bVal = BigUInt(expectedPubBHex, radix: 16) else { + XCTFail("Failed to parse A or B") + return + } + let u = SrpMath.computeU(A: aVal, B: bVal, padLength: 128, hashAlgorithm: group.hashAlgorithm) + let uHex = SrpFormat.bytesToHex(u.serialize()) + XCTAssertEqual(uHex.lowercased(), expectedUHex.lowercased(), "RFC 5054 scrambler u mismatch") + } + + func testPremasterSecretS() { + guard let aVal = BigUInt(expectedAHex, radix: 16), + let bVal = BigUInt(expectedBHex, radix: 16), + let pubA = BigUInt(expectedPubAHex, radix: 16), + let pubB = BigUInt(expectedPubBHex, radix: 16), + let xVal = BigUInt(expectedXHex, radix: 16), + let vVal = BigUInt(expectedVHex, radix: 16), + let uVal = BigUInt(expectedUHex, radix: 16) else { + XCTFail("Failed to parse test parameters") + return + } + + // Client computation: S = (B - k * (g^x mod N)) ^ (a + u * x) mod N + let clientS = SrpMath.computeClientS( + B: pubB, + k: group.k, + g: group.g, + x: xVal, + a: aVal, + u: uVal, + N: group.N + ) + let clientSHex = SrpFormat.bytesToHex(clientS.toPaddedData(byteCount: 128)) + XCTAssertEqual(clientSHex.lowercased(), expectedSHex.lowercased(), "Client premaster secret S mismatch") + + // Server computation: S = (A * (v^u mod N)) ^ b mod N + let serverS = SrpMath.computeServerS( + A: pubA, + v: vVal, + u: uVal, + b: bVal, + N: group.N + ) + let serverSHex = SrpFormat.bytesToHex(serverS.toPaddedData(byteCount: 128)) + XCTAssertEqual(serverSHex.lowercased(), expectedSHex.lowercased(), "Server premaster secret S mismatch") + + // Mutual agreement + XCTAssertEqual(clientS, serverS, "Client and Server premaster secrets do not match") + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/SafetyChecksTests.swift b/mac/PortalKit/Tests/PortalKitTests/SafetyChecksTests.swift new file mode 100644 index 0000000..bbd7312 --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/SafetyChecksTests.swift @@ -0,0 +1,160 @@ +// +// SafetyChecksTests.swift +// PortalKitTests +// +// Verification of SRP parameter validation and safety guards (RFC 5054). +// + +import XCTest +@testable import PortalKit + +final class SafetyChecksTests: XCTestCase { + let validCertHash = Data(repeating: 0xaa, count: 32) + let validSaltHex = "0102030405060708090a0b0c0d0e0f10" + let validPubBHex = SrpFormat.bytesToHex(BigUInt(123456789).toPadded256Data()) + let validPin = "123456" + + func testInvalidSaltThrows() { + let client = PortalSrpClient() + + // Empty salt + XCTAssertThrowsError( + try client.computeM1(saltHex: "", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("salt")) + } + + // Odd length hex salt + XCTAssertThrowsError( + try client.computeM1(saltHex: "abc", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash) + ) + + // Non-hex characters in salt + XCTAssertThrowsError( + try client.computeM1(saltHex: "invalid-hex-characters!!", pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash) + ) + } + + func testInvalidBModuloNThrows() { + let client = PortalSrpClient() + + // B = 0 -> B % N == 0 + let zeroBHex = SrpFormat.bytesToHex(BigUInt(0).toPadded256Data()) + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: zeroBHex, pin: validPin, tlsCertSha256: validCertHash) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("B % N == 0")) + } + + // B = N -> B % N == 0 + let nBHex = SrpFormat.bytesToHex(PortalSrpClient.N.toPadded256Data()) + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: nBHex, pin: validPin, tlsCertSha256: validCertHash) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("B % N == 0")) + } + + // B = 2*N -> B % N == 0 + let twoNBHex = SrpFormat.bytesToHex((PortalSrpClient.N * 2).serialize()) + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: twoNBHex, pin: validPin, tlsCertSha256: validCertHash) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("B % N == 0")) + } + + // Empty pubBHex + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: "", pin: validPin, tlsCertSha256: validCertHash) + ) + + // Non-hex pubBHex + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: "not_hex", pin: validPin, tlsCertSha256: validCertHash) + ) + } + + func testEmptyPinThrows() { + let client = PortalSrpClient() + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: "", tlsCertSha256: validCertHash) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("PIN")) + } + } + + func testEmptyTlsCertHashThrows() { + let client = PortalSrpClient() + XCTAssertThrowsError( + try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: Data()) + ) { error in + guard case SrpError.invalidParameter(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("certificate")) + } + } + + func testInvalidUZeroCheck() { + let _ = SrpServerMock(pin: validPin) + // If a server or client encounters u == 0, it must be rejected as an invalid scrambler. + let uZero = BigUInt(0) + XCTAssertEqual(uZero, 0) + + // Verify SrpError formatting for u == 0 + let err = SrpError.invalidParameter("Computed u == 0") + XCTAssertEqual(err.localizedDescription, "SRP Parameter Error: Computed u == 0") + } + + func testInvalidM2Throws() throws { + let client = PortalSrpClient() + + // Verify before computeM1 must fail + XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "123456")) { error in + guard case SrpError.verificationFailed(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("not initialized")) + } + + // Initialize state + _ = try client.computeM1(saltHex: validSaltHex, pubBHex: validPubBHex, pin: validPin, tlsCertSha256: validCertHash) + + // Invalid hex + XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "invalid-hex")) + + // Odd length hex + XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: "abc")) + + // Wrong M2 + let wrongM2 = SrpFormat.bytesToHex(Data(repeating: 0x99, count: 32)) + XCTAssertThrowsError(try client.verifyServerM2(serverM2Hex: wrongM2)) { error in + guard case SrpError.verificationFailed(let m) = error else { + XCTFail("Wrong error: \(error)") + return + } + XCTAssertTrue(m.contains("Server evidence M2 does not match")) + } + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/SmokeTests.swift b/mac/PortalKit/Tests/PortalKitTests/SmokeTests.swift new file mode 100644 index 0000000..117469c --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import PortalKit + +final class SmokeTests: XCTestCase { + func testVersion() { + XCTAssertEqual(PortalKitVersion.version, "1.0.0") + } +} diff --git a/mac/PortalKit/Tests/PortalKitTests/Srp6aExchangeTests.swift b/mac/PortalKit/Tests/PortalKitTests/Srp6aExchangeTests.swift new file mode 100644 index 0000000..c2a044a --- /dev/null +++ b/mac/PortalKit/Tests/PortalKitTests/Srp6aExchangeTests.swift @@ -0,0 +1,108 @@ +// +// Srp6aExchangeTests.swift +// PortalKitTests +// +// Verification of 2048-bit SRP-6a end-to-end key exchange with mock server parameters. +// + +import XCTest +@testable import PortalKit + +final class Srp6aExchangeTests: XCTestCase { + func testEndToEndExchangeSuccess() throws { + let pin = "654321" + let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("MOCK_CERT_LEAF_DER".data(using: .utf8)!) + + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + // Client computes M1 bound to mock TLS cert hash + let clientM1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: mockCertHash + ) + + XCTAssertFalse(clientM1Hex.isEmpty) + XCTAssertNotNil(client.sessionKey) + + // Server verifies client's A and M1 + let verifyResult = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: clientM1Hex, + serverTlsCertSha256: mockCertHash + ) + + XCTAssertTrue(verifyResult.success, "Server rejected valid client proof") + XCTAssertNotNil(verifyResult.M2Hex) + XCTAssertNotNil(verifyResult.token) + + // Client verifies server M2 + XCTAssertNoThrow( + try client.verifyServerM2(serverM2Hex: verifyResult.M2Hex!), + "Client failed to verify genuine server M2" + ) + } + + func testWrongPinFailsExchange() throws { + let actualPin = "123456" + let wrongPin = "999999" + let mockCertHash = Data(repeating: 0x42, count: 32) + + let server = SrpServerMock(pin: actualPin) + let client = PortalSrpClient() + + // Client computes M1 with wrong PIN + let clientM1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: wrongPin, + tlsCertSha256: mockCertHash + ) + + // Server verification MUST fail + let verifyResult = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: clientM1Hex, + serverTlsCertSha256: mockCertHash + ) + + XCTAssertFalse(verifyResult.success) + XCTAssertNil(verifyResult.token) + XCTAssertNil(verifyResult.M2Hex) + XCTAssertTrue(verifyResult.errorMessage?.contains("M1 verification failed") ?? false) + } + + func testMultipleRandomExchanges() throws { + // Run 5 randomized rounds to verify no modular arithmetic edge cases + for round in 1...5 { + let pin = String(format: "%06d", round * 111111 % 1000000) + let mockCertHash = SrpGroup.rfc5054_2048.hashAlgorithm.hash("CERT_ROUND_\(round)".data(using: .utf8)!) + + let server = SrpServerMock(pin: pin) + let client = PortalSrpClient() + + let m1Hex = try client.computeM1( + saltHex: server.saltHex, + pubBHex: server.pubBHex, + pin: pin, + tlsCertSha256: mockCertHash + ) + + let serverRes = server.verifyClient( + pubAHex: client.pubAHex, + clientM1Hex: m1Hex, + serverTlsCertSha256: mockCertHash + ) + + XCTAssertTrue(serverRes.success, "Round \(round) failed server verification") + guard let m2 = serverRes.M2Hex else { + XCTFail("Round \(round) missing M2") + continue + } + + XCTAssertNoThrow(try client.verifyServerM2(serverM2Hex: m2), "Round \(round) failed client M2 verification") + } + } +} diff --git a/mac/README.md b/mac/README.md new file mode 100644 index 0000000..4a6c637 --- /dev/null +++ b/mac/README.md @@ -0,0 +1,49 @@ +# PortalCam for macOS (`mac2`) + +Native macOS companion app (`PortalCam.app`) and CoreMediaIO Camera Extension (`CamExtension`) for streaming high-definition video and audio from Portal TV. + +## Security Architecture + +PortalCam uses a channel-bound, zero-knowledge authentication scheme to pair with Portal TV without sending secrets over the network or exposing connections to Man-in-the-Middle (MITM) proxies: + +1. **Self-Signed ECDSA P-256 TLS**: + * Portal TV serves all endpoints over HTTPS via an on-device generated ECDSA NIST P-256 (`secp256r1`) certificate. +2. **Channel-Bound SRP-6a Handshake (RFC 5054)**: + * **Initiation**: User clicks **Pair** in the app. The app calls `POST https://:5654/auth/srp/init` over an ephemeral TLS session. + * **Channel Binding**: The app extracts the presented leaf TLS certificate DER and computes: + $$\mathbf{tls\_hash} = \text{SHA256}(\text{cert\_der})$$ + * **Proof $M_1$ Generation**: The user enters the 6-digit PIN displayed on the TV. The client computes: + $$M_1 = \text{SHA256}(\text{pad256}(A) \parallel \text{pad256}(B) \parallel K \parallel s \parallel \mathbf{tls\_hash})$$ + and submits it to `POST https://:5654/auth/srp/verify`. + * **Mutual Proof $M_2$ Verification**: The Portal TV verifies $M_1$ against its own certificate hash. If verified, it returns server proof $M_2$: + $$M_2 = \text{SHA256}(\text{pad256}(A) \parallel M_1 \parallel K \parallel \mathbf{tls\_hash})$$ + along with a random 256-bit bearer token. + * **Client Verification**: The app verifies $M_2$ to confirm the Portal independently knows the shared secret before trusting the server. + +### Why Active MITM Attacks Cannot Succeed + +If an attacker deploys a rogue proxy: +1. The rogue proxy presents a substitute TLS certificate $C_{\text{rogue}}$ to the Mac. +2. The Mac computes $M_1$ using $\text{SHA256}(C_{\text{rogue}})$. +3. The Portal verifies $M_1$ using $\text{SHA256}(C_{\text{portal}})$. +4. Because the certificate fingerprints differ, $M_1$ verification fails on the Portal TV. +5. The attacker cannot forge $M_1$ or $M_2$ without knowing the ephemeral PIN, which is displayed solely on the physical TV screen and never leaves the room. + +### Certificate Pinning & Keychain Storage + +Once pairing succeeds: +* The 256-bit bearer token and the certificate's SHA-256 fingerprint are saved in the macOS Data Protection Keychain under access group `ENT9X9U544.com.kovtash.portalcam`: + * `portalAuthToken` — Bearer auth token + * `portalPinnedCertSha256` — Hex SHA-256 digest of the server's ECDSA certificate +* A fallback copy is mirrored into the shared App Group `UserDefaults` (`ENT9X9U544.com.kovtash.portalcam`) for seamless access by the sandboxed camera extension. +* `PortalPinnedSessionDelegate` evaluates all subsequent HTTPS requests (`/video.h264`, `/audio.aac`, `/control/*`). Any TLS certificate mismatch aborts the connection immediately. + +## Building and Installing + +```sh +cd mac2/PortalCam +xcodebuild -scheme PortalCam -configuration Debug build -derivedDataPath ./build +killall PortalCam 2>/dev/null || true +cp -R ./build/Build/Products/Debug/PortalCam.app /Applications/ +open /Applications/PortalCam.app +``` diff --git a/portal-capability-test/AndroidManifest.xml b/portal-capability-test/AndroidManifest.xml new file mode 100644 index 0000000..ff48f30 --- /dev/null +++ b/portal-capability-test/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/portal-capability-test/README.md b/portal-capability-test/README.md new file mode 100644 index 0000000..4e878f1 --- /dev/null +++ b/portal-capability-test/README.md @@ -0,0 +1,415 @@ +# Portal capability test app + +Source for the diagnostic APK used on the Portal TV. It checks camera enumeration, +displays frames from camera 0, samples the microphone, and exercises the Portal +Smart Camera services in `com.facebook.portal.aiservice`: + +- the **external** control service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE`) + with the four mode requests `DefaultAuto`, `Desk`, `Meeting`, `Fixed` +- the **external** metadata service (`com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE`) + for reading the current mode back and watching mode changes + +## Files + +- `src/com/portaltv/capability/MainActivity.java` — app source +- `smartcamera/src/com/portaltv/smartcamera/` — Kotlin client library (see below) +- `AndroidManifest.xml` — permissions and app declaration +- `build-apk.sh` — reproducible build (javac -> d8 -> aapt2 -> zipalign -> apksigner) +- `portal-capability-test.apk` — already-built debug APK + +## Webcam streaming + +## Authentication and Security Architecture + +Communication between the macOS client (`PortalCam.app` / `CamExtension`) and the Portal TV service is secured using an authenticated, channel-bound transport protocol: + +1. **Transport Layer**: HTTPS via `SSLServerSocket` on port 5654 with forward-secret cipher suites (`ECDHE-ECDSA-AES128-GCM-SHA256`). +2. **Device Identity**: Self-signed ECDSA NIST P-256 (`secp256r1`) certificate generated inside hardware-backed `AndroidKeyStore`. +3. **Pairing & Mutual Authentication**: RFC 5054 SRP-6a (2048-bit MODP group) with **cryptographic TLS channel binding**. +4. **Post-Pairing Enforcement**: Strict TLS certificate SHA-256 fingerprint pinning in macOS Keychain, combined with 256-bit Bearer token authentication. + +``` +Mac (PortalCam.app) Portal TV + | | + | 1. POST /auth/srp/init (HTTPS) | + |------------------------------------------------->| Generates 6-digit PIN, salt s, + | | derives v = g^x mod N, B = (kv + g^b) mod N + | 2. { pairingId, salt, B } | + |<-------------------------------------------------| Displays PIN on TV UI +Extracts server TLS cert DER, | +computes tls_hash = SHA256(cert_der) | +User enters 6-digit PIN | +Computes x, A = g^a mod N, u, S, K | +M1 = SHA256(A || B || K || s || tls_hash) | + | | + | 3. POST /auth/srp/verify { pairingId, A, M1 } | + |------------------------------------------------->| Computes u, S, K + | | Verifies M1 using Portal's tls_hash + | | (Rate limit: max 3 attempts) + | 4. { M2, token } | Computes M2 = SHA256(A || M1 || K || tls_hash) + |<-------------------------------------------------| +Verifies M2 using tls_hash | +Pins tls_hash & saves token in Keychain | + | | + |==================================================| + | Subsequent Media & Control (HTTPS) | + | Strict Pinning: cert_sha256 == pinned_hash | + | Authorization: Bearer | + |==================================================| +``` + +### Cryptographic & Protocol Choices + +#### 1. Server Certificate: ECDSA P-256 (`secp256r1`) +* **Why Elliptic Curve over RSA**: + * **Handshake Speed & CPU**: EC scalar point multiplication is dramatically lighter on the Portal's Snapdragon 835 ARM cores than 2048-bit modular exponentiation. + * **Certificate Size**: The ECDSA certificate is only **352 bytes** DER (vs ~1.5 KB for RSA-2048), keeping the TLS handshake packet well within a single TCP MTU. + * **Key Generation Time**: Key generation in `AndroidKeyStore` takes ~100 ms compared to 1.5–3 seconds for RSA. + * **Clean Platform Integration**: Android Keymaster implements `SHA256withECDSA` natively, avoiding raw digest (`NONEwithRSA`) driver inconsistencies present on Android 9. +* **Storage**: Stored under alias `portalcam_tls_ec_p256` in `AndroidKeyStore` with private key non-exportable and protected for signing. + +#### 2. TLS Protocol Version & Cipher Suite +* **Protocol**: TLS 1.2 with opportunistic negotiation up to TLS 1.3 (`enabledProtocols = arrayOf("TLSv1.3", "TLSv1.2")`). + * *Android 9 Platform Context*: Conscrypt on Android 9 (API 28) enables TLS 1.3 for client sockets, while server-mode `SSLServerSocket` natively negotiates `TLSv1.2`. +* **Cipher Suite**: `TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256` providing authenticated encryption (AEAD) and Ephemeral Diffie-Hellman forward secrecy. + +#### 3. Password-Authenticated Key Exchange: RFC 5054 SRP-6a +* **Group Parameters**: 2048-bit MODP prime group ($N$, generator $g = 2$, multiplier $k = \text{SHA256}(N \parallel g)$). +* **PIN Entropy**: 6-digit random decimal number ($10^6$ combinations). +* **Key Derivation**: $x = \text{SHA256}(s \parallel \text{PIN})$, with a 16-byte cryptographically secure random salt $s$. +* **Zero-Knowledge Property**: The PIN is never transmitted over the wire in cleartext, hashed, or encrypted form. Mutual authentication proves knowledge of the PIN without exposing it to eavesdroppers. + +--- + +### Cryptographic Channel Binding & MITM Prevention + +Standard self-signed TLS pairing typically suffers from a **Trust-on-First-Use (TOFU)** vulnerability: a client connecting to an untrusted self-signed certificate has no initial root of trust, making it susceptible to an active Man-in-the-Middle (MITM) proxy that injects its own TLS certificate. + +Our implementation eliminates this vulnerability by cryptographically binding the transport layer into the SRP evidence proofs: + +$$\mathbf{tls\_hash} = \text{SHA256}(\text{LeafCertificate}_{\text{DER}})$$ + +$$M_1 = \text{SHA256}(\text{pad256}(A) \parallel \text{pad256}(B) \parallel K \parallel s \parallel \mathbf{tls\_hash})$$ + +$$M_2 = \text{SHA256}(\text{pad256}(A) \parallel M_1 \parallel K \parallel \mathbf{tls\_hash})$$ + +#### Why an Active MITM Cannot Succeed: +1. Suppose an attacker places a rogue proxy between the Mac and the Portal TV. +2. The proxy terminates TLS to the Mac using a rogue certificate $C_{\text{rogue}}$, and opens a second TLS connection to the Portal TV using $C_{\text{portal}}$. +3. The Mac computes $\text{tls\_hash}_{\text{mac}} = \text{SHA256}(C_{\text{rogue}})$. +4. The Mac computes evidence $M_1$ incorporating $\text{tls\_hash}_{\text{mac}}$ and sends it to the server. +5. The Portal TV verifies $M_1$ using its own certificate hash: $\text{tls\_hash}_{\text{portal}} = \text{SHA256}(C_{\text{portal}})$. +6. Because $C_{\text{rogue}} \neq C_{\text{portal}}$, the hashes differ: $\text{tls\_hash}_{\text{mac}} \neq \text{tls\_hash}_{\text{portal}}$. +7. **Verification fails immediately** on the Portal TV. The server aborts the handshake, increments the failed attempt counter, and refuses to issue a bearer token. +8. The attacker cannot forge a valid $M_1$ or $M_2$ because computing either proof requires the shared session key $K$, which requires knowledge of the PIN displayed exclusively on the physical TV screen. + +--- + +### Certificate Pinning & Key Storage + +Once the client successfully verifies $M_2$: + +1. **Keychain Storage**: + * The 256-bit bearer token is stored under `portalAuthToken`. + * The server certificate's SHA-256 fingerprint is stored under `portalPinnedCertSha256`. + * Stored in the macOS Data Protection Keychain with shared access group `ENT9X9U544.com.kovtash.portalcam` and synchronized to the App Group `UserDefaults` (`ENT9X9U544.com.kovtash.portalcam`) for sandboxed access by the CMIO camera extension. +2. **Strict Pinning Enforcement**: + * `PortalPinnedSessionDelegate` intercepts `URLAuthenticationChallenge` on all subsequent HTTPS sessions (`/video.h264`, `/audio.aac`, `/control/*`). + * It extracts the server leaf certificate via `SecTrustCopyCertificateChain`, computes its SHA-256 digest, and performs a constant-time comparison against `PortalAuth.pinnedCertSha256`. + * Any certificate change (e.g. Rogue CA injection, LAN redirect, proxy) causes `completionHandler(.cancelAuthenticationChallenge, nil)`, severing the connection immediately. + +--- + +### Brute-Force Rate Limiting + +* The Portal TV strictly limits failed pairing attempts to **a maximum of 3**. +* If 3 failed `verify` attempts occur, or if the 120-second expiration window elapses: + * The active pairing session, PIN, salt, and verifier are completely erased from memory. + * Future attempts are rejected with `400 / 401`. +* **Security Margin**: With $10^6$ possible 6-digit PINs and 3 maximum attempts, the probability of an attacker guessing the PIN within the lifetime of a pairing session is: + $$P(\text{guess}) = \frac{3}{10^6} = 0.0003\%$$ + +### Verified milestone + +The foreground streaming service is installed and verified on Portal TV. It +survives a device reboot, starts its listener automatically, starts camera or +microphone capture on the first client, and tears capture down when the last +client disconnects. Returning the Portal app to the foreground restores the +service path for new clients. + +The app runs an HTTPS server on port 5654 (starts on launch), encoding with the +Snapdragon 835 hardware codecs (H.264 via `OMX.qcom.video.encoder.avc`, AAC-LC +via MediaCodec from the mic tee). All streaming and control endpoints require an `Authorization: Bearer ` header obtained via the SRP pairing handshake: + +- `https://:5654/` — info page +- `https://:5654/stream.ts` — **MPEG-TS: H.264 + AAC, synced (recommended)** +- `https://:5654/video.h264` — raw H.264 Annex B, 720p30 @ ~2.5 Mbps +- `https://:5654/audio.aac` — AAC ADTS, 48 kHz mono @ 64 kbps + +Camera control (Bearer auth). Success bodies are always current state +`{"mode":"","config":{…}}`; failures are +`{"error":"","message":""}` with a non-2xx status: + +| Path | Purpose | +|---|---| +| `GET /control/state` | Read current mode + config | +| `GET /control/mode?mode=DefaultAuto\|Desk\|Meeting\|Fixed` | Switch mode; returns new state | +| `GET /control/fixed?x=&y=&scale=` | Apply Fixed crop; returns new state | +| `GET /control/desk?tightness=` | Apply Desk framing tightness; returns new state | + +`config` is mode-specific: empty for DefaultAuto/Meeting; Fixed includes +`centerX`/`centerY`/`scale`; Desk includes optional tuning fields such as +`framingTightness`. + +The TS stream is muxed on-device (hand-rolled PAT/PMT/PES/TS with PCR and +encoder PTS; MediaMuxer has no TS support on API 28). SPS/PPS are re-sent +before every IDR, so players can join mid-stream. This is the endpoint that +behaves in players — raw elementary streams make them flaky: + +```sh +curl -k -H "Authorization: Bearer " https://:5654/stream.ts | ffplay - +``` + +Watch directly with ffplay or mpv (accepting pinned cert or with bearer token): + +```sh +curl -k -H "Authorization: Bearer " https://:5654/video.h264 | \ + ffplay -framerate 30 -probesize 500k -analyzeduration 500ms -fflags nobuffer -flags low_delay -f h264 - +``` + -f h264 http://:5654/video.h264 +ffplay -nodisp -probesize 50k -analyzeduration 200ms -f aac http://:5654/audio.aac +``` + +Both at once, one window (live remux to MPEG-TS; wallclock timestamps keep the +muxer happy since raw H.264 carries no timestamps): + +```sh +ffmpeg -use_wallclock_as_timestamps 1 -f h264 -i http://:5654/video.h264 \ + -use_wallclock_as_timestamps 1 -f aac -i http://:5654/audio.aac \ + -c copy -f mpegts - | ffplay - +``` + +Use as a virtual webcam/mic on the computer: + +```sh +# Linux (v4l2loopback): +ffmpeg -f h264 -i http://:5654/video.h264 -pix_fmt yuv420p -f v4l2 /dev/video2 +ffmpeg -f aac -i http://:5654/audio.aac -f pulse portal-mic +# macOS: add a Media Source in OBS pointing at /video.h264 (uncheck "local file"), +# then start OBS Virtual Camera. +``` + +Multiple clients are supported; a slow video client is dropped rather than +allowed to corrupt its H.264 stream. New video clients are sent cached SPS/PPS +and a fresh IDR is requested from the encoder. + +(USB gadget / UVC webcam mode is not possible on this device: Android 9 has no +UVC function in UsbDeviceManager, the kernel exposes no configfs gadget, and +configuring it would require root on a locked bootloader.) + +## Smart Camera Kotlin library (`smartcamera/`) + +Drop-in client wrapping the raw binder protocol below. Requires only +`kotlinx-coroutines` and the two manifest permissions. + +```kotlin +val camera = SmartCameraController(context, lifecycleScope) +camera.start() // binds both services, subscribes mode + crop + +camera.state.collect { s -> // connection, current mode, live crop window + Log.i("cam", "${s.connection} mode=${s.mode} crop=${s.cropWindow}") +} + +camera.desk.activate() // per-mode sub-controllers +camera.desk.activate(DeskModeController.Tuning(framingTightness = 0.8f)) +camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f)) // ~2x zoom, left third +camera.fixed.pan(dx = 0.05f, dy = 0f) +camera.fixed.zoomBy(1.25f) +camera.auto.activate() +camera.meeting.isActive.collect { active -> ... } + +camera.stop() +``` + +- `SmartCameraController` owns one control session (the service only honors the + top priority-queue connection), keeps death tokens alive, and rebinds on + service death. State comes from `subscribeModeChanges` / `subscribeFrameMetadata("crop")`. +- `ModeController` subclasses (`auto`, `desk`, `meeting`, `fixed`) expose + `isActive` (from the mode subscription) plus mode-specific params; `fixed` + also exposes `appliedCrop` from the crop subscription. +- Only DefaultAuto/Desk/Meeting/Fixed are whitelisted on the external API. + +Build and install: + +```sh +./build-apk.sh +adb install -r -g portal-capability-test.apk +``` + +## Reaching the private APIs + +Reverse engineering `aiservice.apk` (see `jadx-ai/`) shows the external services +are gated by nothing more than a normal-level manifest permission: + +### External services: normal-level manifest permission (SOLVED) + +`SmartCameraControlService` / `SmartCameraMetadataService` are protected only by +`android:permission="com.facebook.portal.permission.SMART_CAMERA_CONTROL"` / +`..._METADATA`, both defined by `aiservice` with `protectionLevel="0x0"` (normal). +Any app installed after `aiservice` that declares the uses-permission is granted +it. This app declares them, and all four mode requests are accepted — verified on +device. No exploit needed. No runtime FbPermission check exists on the external +path (`SmartCameraControlService` never calls `SmartCameraIPCPermissionManager`). + +Practical notes for the external control service (all learned on device): + +- **One connection only.** Each `connect()` creates a `Connection` in a priority + queue; only the top connection is *enabled* and `setMode` on any other is + silently dropped (`Enabled: false; returning...` in logcat). Priority: the + camera-editor package always wins, otherwise the client with the foreground + process wins. Reuse a single session — the app caches its `IControlSession`. +- The crop/zoom applies to Portal's AIService pipeline (calls, photobooth), + not to the app's own raw Camera2 preview. + +## Smart Camera external API reference + +All discovered by decompiling `aiservice.apk` and verified on device. +Binder wire format notes: every call writes its interface token first; +`ModeSetting` parcels as `writeInt(1) + writeString(name) + writeBundle(params)`; +`MetadataBundle` parcels as a bare `Bundle`; replies are +`readException()` then `readInt()` flag (1 = payload follows). + +### Services + +| Service class | Bind action | Permission (normal level) | +|---|---|---| +| `SmartCameraControlService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_CONTROL` | +| `SmartCameraMetadataService` | `com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE` | `com.facebook.portal.permission.SMART_CAMERA_METADATA` | + +Both live in package `com.facebook.portal.aiservice` (set it explicitly on the +bind intent). + +### Control: `ISmartCameraControlService` + +| # | Method | Args | Returns | +|---|---|---|---| +| 1 | `getVersion()` | — | int (1) | +| 2 | `connect(IBinder deathToken)` | any Binder (strong-ref it client-side!) | `ISmartCameraControlConnection` | + +### Control: `ISmartCameraControlConnection` + +| # | Method | Notes | +|---|---|---| +| 1 | `close()` | | +| 2 | `requestControls(IControlStateCallback)` | returns `IControlSession`; pass any Binder as the callback (only used to signal "control revoked") | +| 3/4 | `subscribeAvailabilityChanges` / `unsubscribe…` | deprecated; immediately reports available | +| 5 | `setMode(ModeSetting)` | deprecated direct path, same whitelist as sessions | +| 6 | `isSessionPropertySupported(String)` | always false | +| 7 | `setSessionProperties(...)` | throws (unimplemented) | + +### Control: `IControlSession` (from `requestControls`) + +| # | Method | Notes | +|---|---|---| +| 1 | `close()` | closes the whole connection | +| 3 | `setMode(ModeSetting)` | the call this app uses; whitelist enforced | +| 4 | `isSessionPropertySupported(String)` | always false | +| 5 | `setSessionProperties(...)` | delegates, then throws | + +### Modes (`ModeSetting_` + Bundle params) + +Whitelist (all an external caller may set): **DefaultAuto, Desk, Meeting, Fixed**. + +| Mode | Bundle params | Effect | +|---|---|---| +| `DefaultAuto` | — | normal auto tracking | +| `Meeting` | — | group framing; forces `PERFORMANCE_NO_TRACKER` resource profile | +| `Desk` | all optional floats: `additional_stable_framing_tightness` (zoom-like framing tightness), `tracking_response_delay_percentage`, `tracking_sensitivity_percentage`, `transition_speed_percentage` | desk framing; mobileconfig defaults when absent; same resource profile as Meeting | +| `Fixed` | **required**: `camera.relative_crop_center_x` / `_y` (float, normalized), `camera.relative_crop_scale` (float, window size vs full frame: 1.0 = full, 0.5 ≈ 2x zoom), `camera.relative_exposure_region` (RectF, nullable), `camera.relative_face_metering_regions` (ArrayList, nullable) | static crop, no tracking; missing keys throw `Missing key` server-side; keep center within `[scale/2, 1-scale/2]` | + +Internal-only modes (whitelist-blocked externally, see "gated" section): +`Spotlight` (`person_id` int — follow one tracked person; reads back as +`BasicSpotlight`), `SpotlightStoryTime` (`person_id` + optional +`target_rect_landscape/portrait` RectF + `target_regions` = `head`/`shoulders` +list), `FullWide`, `Storytime`, `NoOp`. + +### Metadata: `ISmartCameraMetadataService` + +| # | Method | Args | Returns | +|---|---|---|---| +| 1 | `getVersion()` | — | int | +| 2 | `connect(IBinder deathToken)` | any Binder | `ISmartCameraMetadataConnection` | + +### Metadata: `ISmartCameraMetadataConnection` + +| # | Method | Args | Returns | +|---|---|---|---| +| 1 | `close()` | | | +| 2 | `getMode()` | — | current `ModeSetting` or null | +| 3 | `subscribeModeChanges(IModeListener)` | listener binder | current mode + push on every change | +| 4 | `unsubscribeModeChanges(...)` | | | +| 5 | `getFrameMetadata(List topics)` | topic names | `MetadataBundle` snapshot | +| 6 | `subscribeFrameMetadata(IStreamingMetadataReceiver, List topics, float rateHz)` | receiver + topics + rate | initial snapshot + push at rate | +| 7 | `unsubscribeFrameMetadata(...)` | | | + +Callback binder stubs (implement `onTransact` code 1; skip token via +`readInt(); readString()`, then `readInt()` flag, then payload): + +- `IModeListener` (`com.facebook.portal.smartcamera.external.metadata.IModeListener`): + payload = `ModeSetting` +- `IStreamingMetadataReceiver` (`com.facebook.portal.smartcamera.metadata.IStreamingMetadataReceiver`): + payload = `MetadataBundle` + +External frame-metadata topics: `crop` (RectF — the live crop window; watch it +to *see* tracking happen), `frame_orientation`, `frame_rotation`, +`effective_device_rotation`, `full_fov_aspect_ratio`. +Unknown topics throw `IllegalArgumentException: Unknown frame metadata topic`. + +### Person-selection modes are gated to Meta-signed apps + +The internal-only modes listed in the mode table above are gated two ways: + +1. The **external** control service hardcodes a whitelist in + `AidlConnection.setMode` (`ModeSetting.A02` = DefaultAuto/Desk/Fixed/ + Meeting) applied to *every* caller; bytecode inspection confirms the + session path (`IControlSession.setMode`) delegates to that same checked + method, so there is no bypass. Other modes fail with + `IllegalArgumentException: Mode not supported`. +2. The **internal** control service (`SMART_CAMERA_INTERNAL_CONTROL_SERVICE`) + has *no* whitelist — its `setMode` accepts every mode — but every method + first calls `SmartCameraIPCPermissionManager + .enforceAccessSmartCameraControlPermission()`, which requires the + caller's package to declare the + `com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_*` + meta-data **and be signed with Meta's key**. A sideloaded app cannot + satisfy the signature check, so Spotlight/person-selection is + unreachable. (Valid `person_id`s would also require the internal + world-metadata topic `person_ids`.) + +(Two apparent Spotlight successes observed during testing were not +reproducible under controlled conditions; `BasicSpotlight` is also what the +Portal's own auto-tracking reports as the current mode when it follows +someone, which likely explains the readings.) + +**Tracked-people data is likewise internal-only.** The world-model topics — +`person_count`, `person_ids`, `person_biometric_ids`, `person_distance_ft`, +`person_alignment_ratio`, `is_hand_waving`, `is_arm_raised`, +`is_voice_activity_detected`, `update_time_ms` — are served by +`getWorldMetadata` / `subscribeWorldMetadata` (transacts 8/9), which exist only +on the internal metadata connection (signature-gated). The external binder stub +only dispatches transactions 1-7, so there is no transaction-number trick to +reach them. + +### Internal services: out of scope + +`SmartCameraInternalControlService`, `SmartCameraInternalMetadataService` and +`AiInternalControlService` enforce a runtime FbPermission check requiring Meta's +signing key, so they are not reachable from a sideloaded app and are not used +here. + +## Notes + +- The `camera_denied_package=com.portaltv.capability` flag seen in + `platform_state_service` output is set by Portal's privacy layer the first + time the app opens the camera without the privacy system expecting it; it does + not affect the Smart Camera service path. diff --git a/portal-capability-test/build-apk.sh b/portal-capability-test/build-apk.sh new file mode 100755 index 0000000..a95e22c --- /dev/null +++ b/portal-capability-test/build-apk.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Build portal-capability-test.apk from src/ with the plain SDK toolchain +# (javac -> d8 -> aapt2 -> zipalign -> apksigner). No Gradle. +set -euo pipefail + +cd "$(dirname "$0")" + +SDK="$HOME/Library/Android/sdk" +PLATFORM="$SDK/platforms/android-35/android.jar" +BT="$SDK/build-tools/37.0.0" +OUT=portal-capability-test.apk + +rm -rf build/classes build/apk build/compiled-res.zip build/unaligned.apk build/aligned.apk +mkdir -p build/classes build/apk + +echo "== kotlinc ==" +KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" +KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib" +KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar" +KOTLIN_STDLIB_BASE="$KOTLIN_LIB/kotlin-stdlib.jar" +COROUTINES_CORE="$KOTLIN_LIB/kotlinx-coroutines-core-jvm.jar" +# Android Main dispatcher (optional at compile; needed at runtime for Dispatchers.Main) +COROUTINES_ANDROID=$(find "$HOME/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx/kotlinx-coroutines-android" -name 'kotlinx-coroutines-android-*.jar' 2>/dev/null | sort -V | tail -1) +if [[ -z "${COROUTINES_ANDROID}" || ! -f "${COROUTINES_ANDROID}" ]]; then + echo "error: kotlinx-coroutines-android jar not found under ~/.gradle/caches" >&2 + exit 1 +fi + +KT_CP="$PLATFORM:$COROUTINES_CORE:$COROUTINES_ANDROID" +"$KOTLIN" -cp "$KT_CP" -d build/kotlin.jar \ + $(find src smartcamera/src -name '*.kt') + +echo "== javac ==" +javac --release 8 -classpath "$PLATFORM:build/kotlin.jar" \ + -d build/classes $(find src -name '*.java') + +echo "== d8 ==" +"$BT/d8" --lib "$PLATFORM" --min-api 28 --output build/apk \ + $(find build/classes -name '*.class') build/kotlin.jar \ + "$KOTLIN_STDLIB" "$KOTLIN_STDLIB_BASE" "$COROUTINES_CORE" "$COROUTINES_ANDROID" + +echo "== aapt2 compile+link ==" +"$BT/aapt2" compile --dir res -o build/compiled-res.zip +"$BT/aapt2" link -o build/unaligned.apk \ + -I "$PLATFORM" \ + --manifest AndroidManifest.xml \ + --java src \ + build/compiled-res.zip + +# aapt2 --java regenerates R.java under src/; we don't use R, ignore it. +# Repack dex into the apk. +cp build/unaligned.apk build/withdex.apk +( cd build/apk && zip -q -u ../withdex.apk *.dex ) + +echo "== zipalign + sign ==" +"$BT/zipalign" -f 4 build/withdex.apk build/aligned.apk +"$BT/apksigner" sign --ks ~/.android/debug.keystore --ks-pass pass:android \ + --out "$OUT" build/aligned.apk + +echo "built $OUT" diff --git a/portal-capability-test/deploy.sh b/portal-capability-test/deploy.sh new file mode 100755 index 0000000..71436ca --- /dev/null +++ b/portal-capability-test/deploy.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +./build-apk.sh + +echo "== Installing APK via ADB ==" +adb install -r portal-capability-test.apk + +echo "== Starting MainActivity / PortalStreamingService ==" +adb shell am start -n com.portaltv.capability/.MainActivity + +echo "== Done ==" diff --git a/portal-capability-test/res/values/styles.xml b/portal-capability-test/res/values/styles.xml new file mode 100644 index 0000000..52b8980 --- /dev/null +++ b/portal-capability-test/res/values/styles.xml @@ -0,0 +1 @@ + diff --git a/portal-capability-test/run-tests.sh b/portal-capability-test/run-tests.sh new file mode 100755 index 0000000..3f06310 --- /dev/null +++ b/portal-capability-test/run-tests.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Standalone test runner for PortalSrp and security mechanisms on Android/JVM +set -euo pipefail + +cd "$(dirname "$0")" + +# Locate kotlinc +if [ -x "/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" ]; then + KOTLIN="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" + KOTLIN_LIB="/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/lib" +elif command -v kotlinc >/dev/null 2>&1; then + KOTLIN="$(command -v kotlinc)" + KOTLIN_LIB="$(dirname "$KOTLIN")/../lib" +else + echo "Error: kotlinc compiler not found" >&2 + exit 1 +fi + +KOTLIN_STDLIB="$KOTLIN_LIB/kotlin-stdlib.jar" +KOTLIN_STDLIB_JDK8="$KOTLIN_LIB/kotlin-stdlib-jdk8.jar" + +BUILD_DIR="build/test-classes" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +echo "== [1/3] Compiling JVM Base64 adapter ==" +javac -d "$BUILD_DIR" test/android/util/Base64.java + +echo "== [2/3] Compiling PortalSrp, PortalSrpClient, and test suites with kotlinc ==" +"$KOTLIN" \ + -cp "$BUILD_DIR" \ + -d "$BUILD_DIR" \ + src/com/portaltv/capability/PortalSrp.kt \ + src/com/portaltv/capability/PortalSrpClient.kt \ + $(find test -name '*.kt') + +echo "== [3/3] Running Portal SRP-6a Unit & Integration Test Suites ==" +CP="$BUILD_DIR:$KOTLIN_STDLIB" +if [ -f "$KOTLIN_STDLIB_JDK8" ]; then + CP="$CP:$KOTLIN_STDLIB_JDK8" +fi + +java -cp "$CP" com.portaltv.capability.test.TestRunnerKt "$@" diff --git a/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/ModeControllers.kt b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/ModeControllers.kt new file mode 100644 index 0000000..0185024 --- /dev/null +++ b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/ModeControllers.kt @@ -0,0 +1,117 @@ +package com.portaltv.smartcamera + +import android.graphics.RectF +import android.os.Bundle +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Controls one Smart Camera mode and exposes whether it is currently active + * (tracked via the controller's mode-change subscription). + * + * Note: the external API whitelists DefaultAuto, Desk, Meeting and Fixed only; + * other modes (Spotlight, FullWide, Storytime) are rejected server-side. + */ +abstract class ModeController(protected val controller: SmartCameraController) { + /** Full AIDL mode name, e.g. "ModeSetting_Desk". */ + abstract val modeName: String + + /** True while the camera pipeline reports this mode as current. */ + val isActive: Flow = + controller.state.map { it.mode == modeName }.distinctUntilChanged() + + /** Switch the camera to this mode. Returns false if the call could not be delivered. */ + abstract suspend fun activate(): Boolean +} + +/** DefaultAuto: Portal's standard auto-framing/tracking. No parameters. */ +class AutoModeController internal constructor(controller: SmartCameraController) : + ModeController(controller) { + override val modeName = "ModeSetting_DefaultAuto" + override suspend fun activate(): Boolean = controller.setMode(modeName) +} + +/** Meeting: static wide framing for conference calls. No parameters. */ +class MeetingModeController internal constructor(controller: SmartCameraController) : + ModeController(controller) { + override val modeName = "ModeSetting_Meeting" + override suspend fun activate(): Boolean = controller.setMode(modeName) +} + +/** + * Desk: close-up framing of a desk area with tracking. + * All tuning values are optional; null leaves the service default in place. + */ +class DeskModeController internal constructor(controller: SmartCameraController) : + ModeController(controller) { + override val modeName = "ModeSetting_Desk" + + data class Tuning( + /** Zoom-like tightness of the stable framing. */ + val framingTightness: Float? = null, + val trackingResponseDelayPct: Float? = null, + val trackingSensitivityPct: Float? = null, + val transitionSpeedPct: Float? = null, + ) + + /** Last tuning sent (or defaults). The service does not report these back. */ + private val _tuning = MutableStateFlow(Tuning()) + val tuning: StateFlow = _tuning.asStateFlow() + + override suspend fun activate(): Boolean = activate(_tuning.value) + + suspend fun activate(tuning: Tuning): Boolean { + _tuning.value = tuning + val b = Bundle() + tuning.framingTightness?.let { b.putFloat("additional_stable_framing_tightness", it) } + tuning.trackingResponseDelayPct?.let { b.putFloat("tracking_response_delay_percentage", it) } + tuning.trackingSensitivityPct?.let { b.putFloat("tracking_sensitivity_percentage", it) } + tuning.transitionSpeedPct?.let { b.putFloat("transition_speed_percentage", it) } + return controller.setMode(modeName, b) + } +} + +/** + * Fixed: static crop of the sensor, no tracking. + * + * [crop] is the requested (clamped) crop; [appliedCrop] is the live crop window + * reported back by the camera pipeline (requires start(trackCrop = true)). + */ +class FixedModeController internal constructor(controller: SmartCameraController) : + ModeController(controller) { + override val modeName = "ModeSetting_Fixed" + + private val _crop = MutableStateFlow(CropConfig()) + val crop: StateFlow = _crop.asStateFlow() + + val appliedCrop: Flow = + controller.state.map { it.cropWindow }.distinctUntilChanged() + + override suspend fun activate(): Boolean = setCrop(_crop.value) + + suspend fun setCrop(config: CropConfig): Boolean { + val c = config.clamped() + _crop.value = c + val b = Bundle().apply { + putFloat("camera.relative_crop_center_x", c.centerX) + putFloat("camera.relative_crop_center_y", c.centerY) + putFloat("camera.relative_crop_scale", c.scale) + // Required keys; null = leave to the pipeline. + putParcelable("camera.relative_exposure_region", null) + putParcelableArrayList("camera.relative_face_metering_regions", null) + } + return controller.setMode(modeName, b) + } + + /** Moves the crop center by [dx], [dy] (normalized, clamped to the frame). */ + suspend fun pan(dx: Float, dy: Float): Boolean = + setCrop(_crop.value.let { it.copy(centerX = it.centerX + dx, centerY = it.centerY + dy) }) + + /** [factor] > 1 zooms in, < 1 zooms out. */ + suspend fun zoomBy(factor: Float): Boolean = + setCrop(_crop.value.let { it.copy(scale = it.scale / factor) }) +} diff --git a/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCamera.kt b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCamera.kt new file mode 100644 index 0000000..92125a6 --- /dev/null +++ b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCamera.kt @@ -0,0 +1,65 @@ +package com.portaltv.smartcamera + +import android.graphics.RectF + +/** Failure talking to the Portal Smart Camera service. */ +class SmartCameraException(message: String, cause: Throwable? = null) : Exception(message, cause) + +enum class ConnectionState { DISCONNECTED, CONNECTING, READY } + +/** + * Snapshot of everything the controller tracks. + * + * @property mode full AIDL mode name, e.g. "ModeSetting_Desk". Updated live via + * subscribeModeChanges once the metadata service is connected. + * @property cropWindow live crop window (normalized coordinates) reported by the + * camera pipeline via the "crop" frame-metadata topic. Reflects auto-tracking + * and applied Fixed crops. Null until the first metadata frame arrives. + */ +data class CameraState( + val connection: ConnectionState = ConnectionState.DISCONNECTED, + val mode: String? = null, + val cropWindow: RectF? = null, +) { + val isReady: Boolean get() = connection == ConnectionState.READY +} + +/** + * Event-driven control view: mode + locally owned Fixed/Desk params. + * Emitted whenever mode metadata, Fixed crop, or Desk tuning changes. + */ +data class ControlSnapshot( + val connection: ConnectionState = ConnectionState.DISCONNECTED, + /** Full AIDL name, e.g. `ModeSetting_Desk`, or null if unknown. */ + val mode: String? = null, + val fixedCrop: CropConfig = CropConfig(), + val deskTuning: DeskModeController.Tuning = DeskModeController.Tuning(), + val appliedCrop: RectF? = null, +) { + /** Short mode name without `ModeSetting_` prefix. */ + val shortMode: String? + get() = mode?.removePrefix("ModeSetting_") +} + +/** + * Requested crop for Fixed mode. All values normalized to [0, 1]. + * + * @property scale 1.0 = full frame, 0.5 ~= 2x zoom. + */ +data class CropConfig( + val centerX: Float = 0.5f, + val centerY: Float = 0.5f, + val scale: Float = 1.0f, +) { + /** Clamps scale to [MIN_SCALE, 1] and keeps the crop window inside the frame. */ + fun clamped(): CropConfig { + val s = scale.coerceIn(MIN_SCALE, 1f) + return CropConfig( + centerX.coerceIn(s / 2f, 1f - s / 2f), + centerY.coerceIn(s / 2f, 1f - s / 2f), + s, + ) + } + + companion object { const val MIN_SCALE = 0.1f } +} diff --git a/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCameraController.kt b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCameraController.kt new file mode 100644 index 0000000..22661f2 --- /dev/null +++ b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/SmartCameraController.kt @@ -0,0 +1,389 @@ +package com.portaltv.smartcamera + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.graphics.RectF +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import com.portaltv.smartcamera.internal.MetadataReceiverBinder +import com.portaltv.smartcamera.internal.ModeListenerBinder +import com.portaltv.smartcamera.internal.Rpc +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Maintains the connection to Portal's Smart Camera services (control + metadata) + * and tracks camera state via subscriptions. + * + * The consuming app must declare these (normal-level) manifest permissions: + * - [PERMISSION_CONTROL] + * - [PERMISSION_METADATA] + * + * Usage: + * ``` + * val camera = SmartCameraController(context, lifecycleScope) + * camera.start() + * camera.desk.activate() + * camera.fixed.setCrop(CropConfig(centerX = 0.3f, scale = 0.5f)) + * ``` + * + * Implementation notes (verified on Portal TV / ripley): + * - Exactly ONE control session is kept: every connect() creates a Connection in + * the service's priority queue and only the top one is enabled; setMode on + * others is silently dropped. + * - Strong references to the death-token binders are kept for the lifetime of + * the controller; if they are GC'd the service kills the connection. + */ +class SmartCameraController( + private val context: Context, + private val scope: CoroutineScope, +) { + companion object { + private const val TAG = "SmartCamera" + + const val AISERVICE_PACKAGE = "com.facebook.portal.aiservice" + const val CONTROL_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE" + const val METADATA_ACTION = "com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE" + const val PERMISSION_CONTROL = "com.facebook.portal.permission.SMART_CAMERA_CONTROL" + const val PERMISSION_METADATA = "com.facebook.portal.permission.SMART_CAMERA_METADATA" + const val TOPIC_CROP = "crop" + + private const val TOKEN_CONTROL_SERVICE = + "com.facebook.portal.smartcamera.external.control.ISmartCameraControlService" + private const val TOKEN_CONTROL_CONNECTION = + "com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection" + private const val TOKEN_CONTROL_SESSION = + "com.facebook.portal.smartcamera.external.control.IControlSession" + private const val TOKEN_METADATA_SERVICE = + "com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService" + private const val TOKEN_METADATA_CONNECTION = + "com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection" + + private const val TX_CONNECT = 2 + private const val TX_REQUEST_CONTROLS = 2 + private const val TX_SET_MODE = 3 + private const val TX_GET_MODE = 2 + private const val TX_SUBSCRIBE_MODE = 3 + private const val TX_SUBSCRIBE_FRAME_METADATA = 6 + + private const val REBIND_DELAY_MS = 1_000L + } + + private val _state = MutableStateFlow(CameraState()) + val state: StateFlow = _state.asStateFlow() + + /** Per-mode sub-controllers. */ + val auto = AutoModeController(this) + val desk = DeskModeController(this) + val meeting = MeetingModeController(this) + val fixed = FixedModeController(this) + + private val _control = MutableStateFlow(ControlSnapshot()) + /** + * Combined mode + Fixed crop + Desk tuning. Prefer this for UI / SSE: + * it updates on AIDL mode changes and on local param writes. + */ + val control: StateFlow = _control.asStateFlow() + + private var controlJob: Job? = null + + private val lock = Any() + + @Volatile private var started = false + @Volatile private var trackCrop = true + @Volatile private var cropRateHz = 1f + @Volatile private var rebindScheduled = false + + private var controlService: IBinder? = null + private var controlConnection: IBinder? = null + private var session: IBinder? = null + private var metaService: IBinder? = null + private var metaConnection: IBinder? = null + + // Death tokens handed to the service. MUST stay strongly referenced. + private val controlToken = Binder() + private val metaToken = Binder() + private val sessionCallbackToken = Binder() + + private val modeListener = ModeListenerBinder { mode -> + _state.update { it.copy(mode = mode) } + } + + private val cropReceiver = MetadataReceiverBinder { b -> + @Suppress("DEPRECATION") + (b.get(TOPIC_CROP) as? RectF)?.let { c -> _state.update { s -> s.copy(cropWindow = c) } } + } + + private val controlDeathRecipient = IBinder.DeathRecipient { + Log.w(TAG, "control service died") + invalidateControl() + scheduleRebind() + } + private val metaDeathRecipient = IBinder.DeathRecipient { + Log.w(TAG, "metadata service died") + invalidateMeta() + scheduleRebind() + } + + private val controlServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + scope.launch(Dispatchers.IO) { setupControl(service) } + } + override fun onServiceDisconnected(name: ComponentName) { + invalidateControl() + scheduleRebind() + } + } + + private val metadataServiceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName, service: IBinder) { + scope.launch(Dispatchers.IO) { setupMetadata(service) } + } + override fun onServiceDisconnected(name: ComponentName) { + invalidateMeta() + scheduleRebind() + } + } + + /** + * Binds to both services and starts tracking state. Safe to call once; + * subsequent calls are ignored. Reconnects automatically if the service dies. + * + * @param trackCrop subscribe to the "crop" frame-metadata topic so + * [CameraState.cropWindow] stays up to date. + */ + fun start(trackCrop: Boolean = true, cropRateHz: Float = 1f) { + synchronized(lock) { + if (started) return + started = true + this.trackCrop = trackCrop + this.cropRateHz = cropRateHz + } + _state.update { it.copy(connection = ConnectionState.CONNECTING) } + controlJob?.cancel() + controlJob = scope.launch { + combine(_state, fixed.crop, desk.tuning) { st, crop, tuning -> + ControlSnapshot( + connection = st.connection, + mode = st.mode, + fixedCrop = crop, + deskTuning = tuning, + appliedCrop = st.cropWindow, + ) + } + .distinctUntilChanged() + .collect { _control.value = it } + } + bind(CONTROL_ACTION, controlServiceConnection) + bind(METADATA_ACTION, metadataServiceConnection) + } + + /** Unbinds everything and resets state. */ + fun stop() { + controlJob?.cancel() + controlJob = null + synchronized(lock) { + started = false + controlService = null + controlConnection = null + session = null + metaService = null + metaConnection = null + } + runCatching { context.unbindService(controlServiceConnection) } + runCatching { context.unbindService(metadataServiceConnection) } + _state.value = CameraState() + _control.value = ControlSnapshot() + } + + /** One-shot query of the current mode; also refreshes [state]. */ + suspend fun refreshMode(): String? = withContext(Dispatchers.IO) { + val conn = synchronized(lock) { metaConnection } ?: return@withContext null + try { + val r = Rpc.call(conn, TX_GET_MODE, TOKEN_METADATA_CONNECTION) + val mode = if (r.readInt() != 0) r.readString() else null + r.recycle() + _state.update { it.copy(mode = mode) } + mode + } catch (t: Throwable) { + Log.w(TAG, "getMode failed", t) + null + } + } + + /** Sends setMode, rebuilding the session once on failure. */ + internal suspend fun setMode(name: String, params: Bundle = Bundle()): Boolean = + withContext(Dispatchers.IO) { + repeat(2) { attempt -> + val s = ensureSession() + if (s == null) { + if (attempt == 0) delay(REBIND_DELAY_MS) // binding may be in flight + return@repeat + } + try { + val r = Rpc.call(s, TX_SET_MODE, TOKEN_CONTROL_SESSION) { + writeInt(1) + writeString(name) + writeBundle(params) + } + r.recycle() + // Optimistic mode so control/UI update before AIDL callback. + _state.update { it.copy(mode = name) } + return@withContext true + } catch (t: Throwable) { + Log.w(TAG, "setMode($name) failed (attempt ${attempt + 1})", t) + synchronized(lock) { session = null } + } + } + false + } + + // ---- connection management (all called on Dispatchers.IO) ---- + + private fun bind(action: String, conn: ServiceConnection) { + val intent = Intent(action).setPackage(AISERVICE_PACKAGE) + try { + if (!context.bindService(intent, conn, Context.BIND_AUTO_CREATE)) { + Log.w(TAG, "$action not found") + } + } catch (t: Throwable) { + Log.w(TAG, "bind $action failed", t) + } + } + + private fun setupControl(service: IBinder) { + try { + service.linkToDeath(controlDeathRecipient, 0) + val sess = connectSession(service) + synchronized(lock) { + controlService = service + session = sess + } + _state.update { it.copy(connection = ConnectionState.READY) } + Log.i(TAG, "control session ready") + } catch (t: Throwable) { + Log.w(TAG, "control setup failed", t) + invalidateControl() + scheduleRebind() + } + } + + private fun setupMetadata(service: IBinder) { + try { + service.linkToDeath(metaDeathRecipient, 0) + val connReply = Rpc.call(service, TX_CONNECT, TOKEN_METADATA_SERVICE) { + writeStrongBinder(metaToken) + } + val connection = connReply.readStrongBinder() + connReply.recycle() + if (connection == null) throw SmartCameraException("no metadata connection returned") + synchronized(lock) { + metaService = service + metaConnection = connection + } + subscribeMode(connection) + if (trackCrop) subscribeCrop(connection) + Log.i(TAG, "metadata connection ready") + } catch (t: Throwable) { + Log.w(TAG, "metadata setup failed", t) + invalidateMeta() + scheduleRebind() + } + } + + private fun connectSession(service: IBinder): IBinder { + val connReply = Rpc.call(service, TX_CONNECT, TOKEN_CONTROL_SERVICE) { + writeStrongBinder(controlToken) + } + val connection = connReply.readStrongBinder() + connReply.recycle() + if (connection == null) throw SmartCameraException("no control connection returned") + val sessReply = Rpc.call(connection, TX_REQUEST_CONTROLS, TOKEN_CONTROL_CONNECTION) { + writeStrongBinder(sessionCallbackToken) + } + val sess = sessReply.readStrongBinder() + sessReply.recycle() + if (sess == null) throw SmartCameraException("no control session returned") + synchronized(lock) { controlConnection = connection } + return sess + } + + private fun ensureSession(): IBinder? { + synchronized(lock) { session }?.let { return it } + val service = synchronized(lock) { controlService } ?: return null + return try { + connectSession(service).also { s -> synchronized(lock) { session = s } } + } catch (t: Throwable) { + Log.w(TAG, "session rebuild failed", t) + invalidateControl() + null + } + } + + private fun subscribeMode(connection: IBinder) { + val r = Rpc.call(connection, TX_SUBSCRIBE_MODE, TOKEN_METADATA_CONNECTION) { + writeStrongBinder(modeListener) + } + val current = if (r.readInt() != 0) r.readString() else null + r.recycle() + _state.update { it.copy(mode = current) } + } + + private fun subscribeCrop(connection: IBinder) { + val r = Rpc.call(connection, TX_SUBSCRIBE_FRAME_METADATA, TOKEN_METADATA_CONNECTION) { + writeStrongBinder(cropReceiver) + writeStringList(listOf(TOPIC_CROP)) + writeFloat(cropRateHz) + } + if (r.readInt() != 0) { + @Suppress("DEPRECATION") + (r.readBundle(javaClass.classLoader)?.get(TOPIC_CROP) as? RectF)?.let { c -> + _state.update { s -> s.copy(cropWindow = c) } + } + } + r.recycle() + } + + private fun invalidateControl() { + synchronized(lock) { + controlService = null + controlConnection = null + session = null + } + _state.update { it.copy(connection = if (started) ConnectionState.CONNECTING else ConnectionState.DISCONNECTED) } + } + + private fun invalidateMeta() { + synchronized(lock) { + metaService = null + metaConnection = null + } + } + + private fun scheduleRebind() { + if (!started || rebindScheduled) return + rebindScheduled = true + scope.launch { + delay(REBIND_DELAY_MS) + rebindScheduled = false + if (started) { + if (synchronized(lock) { controlService == null }) bind(CONTROL_ACTION, controlServiceConnection) + if (synchronized(lock) { metaService == null }) bind(METADATA_ACTION, metadataServiceConnection) + } + } + } +} diff --git a/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/internal/Rpc.kt b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/internal/Rpc.kt new file mode 100644 index 0000000..c140ccc --- /dev/null +++ b/portal-capability-test/smartcamera/src/com/portaltv/smartcamera/internal/Rpc.kt @@ -0,0 +1,75 @@ +package com.portaltv.smartcamera.internal + +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.os.Parcel +import android.util.Log +import com.portaltv.smartcamera.SmartCameraException + +/** Raw AIDL helpers for the Smart Camera external interfaces (no generated stubs). */ +internal object Rpc { + + /** + * Runs a transact with interface [token] and returns the reply Parcel. + * The caller must recycle the reply. Throws on rejection or remote exception. + */ + fun call(binder: IBinder, code: Int, token: String, write: Parcel.() -> Unit = {}): Parcel { + val q = Parcel.obtain() + val r = Parcel.obtain() + try { + q.writeInterfaceToken(token) + q.write() + if (!binder.transact(code, q, r, 0)) { + r.recycle() + throw SmartCameraException("transact code=$code on $token rejected") + } + r.readException() + return r + } finally { + q.recycle() + } + } +} + +/** + * Stub for IModeListener: onModeChanged(ModeSetting), where ModeSetting parcels + * as a plain String (the full "ModeSetting_"). + */ +internal class ModeListenerBinder(private val callback: (String?) -> Unit) : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (code != 1) { + return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false } + } + try { + data.readInt() // strict-mode header from writeInterfaceToken + data.readString() // interface descriptor + callback(if (data.readInt() != 0) data.readString() else null) + } catch (t: Throwable) { + Log.w("SmartCamera", "mode listener parse error", t) + } + return true + } +} + +/** + * Stub for IStreamingMetadataReceiver: onMetadata(MetadataBundle), where + * MetadataBundle parcels as a Bundle keyed by topic name. + */ +internal class MetadataReceiverBinder(private val callback: (Bundle) -> Unit) : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + if (code != 1) { + return try { super.onTransact(code, data, reply, flags) } catch (t: Throwable) { false } + } + try { + data.readInt() // strict-mode header + data.readString() // interface descriptor + if (data.readInt() != 0) { + data.readBundle(javaClass.classLoader)?.let(callback) + } + } catch (t: Throwable) { + Log.w("SmartCamera", "metadata receiver parse error", t) + } + return true + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/MainActivity.java b/portal-capability-test/src/com/portaltv/capability/MainActivity.java new file mode 100644 index 0000000..b0f6837 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/MainActivity.java @@ -0,0 +1,168 @@ +package com.portaltv.capability; + +import android.Manifest; +import android.app.*; +import android.os.*; +import android.content.*; +import android.content.pm.PackageManager; +import android.graphics.ImageFormat; +import android.graphics.BitmapFactory; +import android.hardware.camera2.*; +import android.hardware.camera2.params.StreamConfigurationMap; +import android.media.*; +import android.util.Size; +import android.view.*; +import android.widget.*; +import java.util.*; +import java.nio.ByteBuffer; +import java.util.concurrent.*; + +public class MainActivity extends Activity { + TextView authStatus; + LinearLayout authPanel; + @Override protected void onResume(){ super.onResume(); PortalStreamingService.activityVisible=true; refreshAuthUi(); Intent i=new Intent(this,PortalStreamingService.class); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); } + @Override protected void onPause(){ PortalStreamingService.activityVisible=false; super.onPause(); } + @Override protected void onStop(){ Intent i=new Intent(this,PortalStreamingService.class); i.setAction("com.portaltv.capability.CLOSE_CLIENTS"); if(Build.VERSION.SDK_INT>=26) startForegroundService(i); else startService(i); super.onStop(); } + TextView log; CameraManager cm; HandlerThread ht; Handler h; CameraDevice cam; ImageReader reader; ImageView preview; IBinder control; IBinder session; IBinder controlToken; IBinder meta; IBinder metaConn; IBinder metaToken; byte[] frameBuf; final java.util.concurrent.atomic.AtomicBoolean frameBusy=new java.util.concurrent.atomic.AtomicBoolean(); int frameCount; float fx=0.5f, fy=0.5f, fs=1.0f; + int screenW,screenH,ctlW=120,logHeaderH=96; boolean controlsExpanded,logExpanded; LinearLayout controlsPanel,controlsContent,logPanel,subFixed,subDesk; TextView subNone; ScrollView logScroll; Button controlsToggle,logToggle; final java.util.Map modeButtons=new java.util.HashMap<>(); String currentMode; + final PortalSmartCamera.StateListener cameraStateListener=state->{ + final String mode="ModeSetting_"+state.getMode(); + final org.json.JSONObject cfg=state.getConfig(); + runOnUiThread(()->{ + setCurrentMode(mode); + if("Fixed".equals(state.getMode())&&cfg!=null){ + try{ + if(cfg.has("centerX")) fx=(float)cfg.getDouble("centerX"); + if(cfg.has("centerY")) fy=(float)cfg.getDouble("centerY"); + if(cfg.has("scale")) fs=(float)cfg.getDouble("scale"); + }catch(Exception e){p("state config parse: "+e);} + } + p("Camera state -> "+state.getMode()+" "+cfg); + }); + }; + final Binder modeListener=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); final String m=data.readInt()!=0?data.readString():null; p("Mode changed (legacy) -> "+(m!=null?m:"(null)")); }catch(Exception e){p("Mode listener parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } }; + final Binder metaReceiver=new Binder(){ @Override protected boolean onTransact(int code,Parcel data,Parcel reply,int flags){ if(code==1){ try{ data.readInt(); data.readString(); if(data.readInt()!=0){ Bundle b=data.readBundle(getClass().getClassLoader()); String s=""; for(String k:b.keySet()) s+=k+"="+b.get(k)+" "; p("meta: "+s); } }catch(Exception e){p("Meta receiver parse error: "+e);} return true; } try{return super.onTransact(code,data,reply,flags);}catch(Exception e){return false;} } }; + public void onCreate(Bundle b) { super.onCreate(b); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); buildUi(); + PortalSmartCamera.start(this); + PortalSmartCamera.addStateListener(cameraStateListener); + if (Build.VERSION.SDK_INT >= 23 && (checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED || checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED)) requestPermissions(new String[]{Manifest.permission.CAMERA,Manifest.permission.RECORD_AUDIO},7); + else startAll(); + cm=(CameraManager)getSystemService(CAMERA_SERVICE); ht=new HandlerThread("camera"); ht.start(); h=new Handler(ht.getLooper()); p("Running as uid="+android.os.Process.myUid()); inspect(); + Intent service = new Intent(this, PortalStreamingService.class); + if (Build.VERSION.SDK_INT >= 26) startForegroundService(service); else startService(service); + } + volatile boolean pipelineStarted; + synchronized void ensurePipeline(){ if(pipelineStarted)return; pipelineStarted=true; new Thread(()->{ testCamera("0"); startMicLoop(); startAudioEncoder(); startTsMuxer(); }).start(); p("Media pipeline started on first client"); } + void startAll(){ p("Camera UI driven by PortalSmartCamera state events"); } + void startVideoEncoder(){ if(venc!=null)return; try{ MediaFormat f=MediaFormat.createVideoFormat("video/avc",1280,720); f.setInteger(MediaFormat.KEY_COLOR_FORMAT,MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); f.setInteger(MediaFormat.KEY_BIT_RATE,2500000); f.setInteger(MediaFormat.KEY_FRAME_RATE,30); f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL,1); venc=MediaCodec.createEncoderByType("video/avc"); venc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); vencSurface=venc.createInputSurface(); venc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); while(true){ int i; try{ i=venc.dequeueOutputBuffer(bi,100000); }catch(Exception e){ p("venc drained: "+e); return; } if(i==MediaCodec.INFO_OUTPUT_FORMAT_CHANGED){ try{ java.io.ByteArrayOutputStream c=new java.io.ByteArrayOutputStream(); MediaFormat of=venc.getOutputFormat(); for(String k:new String[]{"csd-0","csd-1"}) if(of.containsKey(k)){ java.nio.ByteBuffer cb=of.getByteBuffer(k); byte[] x=new byte[cb.remaining()]; cb.get(x); c.write(x); } vCsd=c.toByteArray(); p("H.264 encoder ready, csd="+vCsd.length+"B"); }catch(Exception e){p("csd parse: "+e);} continue; } if(i<0) continue; java.nio.ByteBuffer buf=venc.getOutputBuffer(i); if(buf!=null&&bi.size>0){ byte[] d=new byte[bi.size]; buf.position(bi.offset); buf.get(d); boolean key=(bi.flags&(MediaCodec.BUFFER_FLAG_KEY_FRAME|MediaCodec.BUFFER_FLAG_CODEC_CONFIG))!=0; if((bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)!=0) vCsd=d; VChunk ch=new VChunk(d,key,bi.presentationTimeUs); tsV.offer(ch); synchronized(vClients){ java.util.Iterator> it=vClients.iterator(); while(it.hasNext()){ if(!it.next().offer(ch)) it.remove(); } } } venc.releaseOutputBuffer(i,false); } }).start(); p("H.264 encoder started (720p30 @2.5Mbps)"); }catch(Exception e){p("H.264 encoder failed: "+e); venc=null; vencSurface=null;} } + void startAudioEncoder(){ if(aenc!=null)return; try{ MediaFormat f=MediaFormat.createAudioFormat("audio/mp4a-latm",48000,1); f.setInteger(MediaFormat.KEY_AAC_PROFILE,MediaCodecInfo.CodecProfileLevel.AACObjectLC); f.setInteger(MediaFormat.KEY_BIT_RATE,64000); aenc=MediaCodec.createEncoderByType("audio/mp4a-latm"); aenc.configure(f,null,null,MediaCodec.CONFIGURE_FLAG_ENCODE); aenc.start(); new Thread(()->{ MediaCodec.BufferInfo bi=new MediaCodec.BufferInfo(); long aT0=-1,aSamples=0; byte[] pending=null; int pOff=0; long bIn=0,fOut=0,tOut=0,tWin=System.nanoTime(); while(true){ try{ if(pending==null){ pending=pcmIn.poll(100,java.util.concurrent.TimeUnit.MILLISECONDS); pOff=0; } if(pending!=null){ int ii=aenc.dequeueInputBuffer(50000); if(ii>=0){ java.nio.ByteBuffer ib=aenc.getInputBuffer(ii); ib.clear(); int put=Math.min(pending.length-pOff,ib.remaining()); ib.put(pending,pOff,put); if(aT0<0) aT0=System.nanoTime(); long pts=(aT0+aSamples*1000000000L/48000)/1000; aSamples+=put/2; aenc.queueInputBuffer(ii,0,put,pts,0); bIn+=put; pOff+=put; if(pOff>=pending.length) pending=null; } else tOut++; } int i=aenc.dequeueOutputBuffer(bi,pending==null?20000:0); while(i>=0){ java.nio.ByteBuffer buf=aenc.getOutputBuffer(i); if(buf!=null&&bi.size>0&&(bi.flags&MediaCodec.BUFFER_FLAG_CODEC_CONFIG)==0){ byte[] raw=new byte[bi.size]; buf.position(bi.offset); buf.get(raw); byte[] adts=addAdts(raw); tsA.offer(new VChunk(adts,false,bi.presentationTimeUs)); synchronized(aClients){ for(java.util.concurrent.BlockingQueue q:aClients) q.offer(adts); } fOut++; } aenc.releaseOutputBuffer(i,false); i=aenc.dequeueOutputBuffer(bi,0); } long now=System.nanoTime(); if(now-tWin>5e9){ p(String.format("aenc: %.0f B/s in, %.1f frames/s out, inTimeouts=%d, queue=%d",bIn*1e9/(now-tWin),fOut*1e9/(now-tWin),tOut,pcmIn.size())); tWin=now; bIn=0; fOut=0; tOut=0; } }catch(Exception e){ p("aenc drained: "+e); return; } } }).start(); p("AAC encoder started (48kHz mono @64kbps)"); }catch(Exception e){p("AAC encoder failed: "+e); aenc=null;} } + byte[] addAdts(byte[] f){ int len=f.length+7; byte[] o=new byte[len]; o[0]=(byte)0xFF; o[1]=(byte)0xF1; o[2]=(byte)((1<<6)|(3<<2)); o[3]=(byte)((1<<6)|(len>>11)); o[4]=(byte)((len>>3)&0xFF); o[5]=(byte)(((len&7)<<5)|0x1F); o[6]=(byte)0xFC; System.arraycopy(f,0,o,7,f.length); return o; } + String deviceIp(){ try{ for(java.net.NetworkInterface ni:java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) for(java.net.InetAddress a:java.util.Collections.list(ni.getInetAddresses())) if(!a.isLoopbackAddress()&&a instanceof java.net.Inet4Address) return a.getHostAddress(); }catch(Exception e){} return "?"; } + // ---- minimal MPEG-TS muxer (H.264 Annex B + AAC ADTS, PTS from the encoders) ---- + static int crcMpeg(byte[] d,int off,int len){ int c=0xFFFFFFFF; for(int i=off;i>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List pk=packetize(pid,false,full,0); return pk.get(0); } + void startTsMuxer(){ new Thread(()->{ while(true){ try{ int na=0; VChunk a; while(na++<10){ a=tsA.poll(); if(a==null) break; writePes(0x102,0xE1,a,false); } VChunk v=tsV.poll(200,java.util.concurrent.TimeUnit.MILLISECONDS); if(v==null) continue; if(vBase<0) vBase=v.pts; if(v.k){ tsBroadcast(patPacket()); tsBroadcast(pmtPacket()); if(vCsd!=null&&!hasSps(v.d)){ byte[] m=new byte[vCsd.length+v.d.length]; System.arraycopy(vCsd,0,m,0,vCsd.length); System.arraycopy(v.d,0,m,vCsd.length,v.d.length); v=new VChunk(m,true,v.pts); } } writePes(0x101,0xE0,v,true); }catch(Exception e){ p("ts mux: "+e); } } }).start(); } + static boolean hasSps(byte[] d){ for(int i=0;i+4> it=tsClients.iterator(); while(it.hasNext()){ if(!it.next().offer(d)) it.remove(); } } } + void writePes(int pid,int sid,VChunk c,boolean isVideo){ if(tsClients.isEmpty()) return; long base=isVideo?vBase:aBase; if(base<0){ if(isVideo) vBase=c.pts; else aBase=c.pts; base=c.pts; } long pts=(c.pts-base)*9/100; java.io.ByteArrayOutputStream pes=new java.io.ByteArrayOutputStream(); pes.write(0); pes.write(0); pes.write(1); pes.write(sid); int pl=isVideo?0:c.d.length+8; pes.write(pl>>8); pes.write(pl); pes.write(0x80); pes.write(0x80); pes.write(5); pes.write((2<<4)|((int)((pts>>30)&7)<<1)|1); pes.write((int)(pts>>22)&0xFF); pes.write((int)(((pts>>15)&0x7F)<<1)|1); pes.write((int)(pts>>7)&0xFF); pes.write((int)((pts&0x7F)<<1)|1); pes.write(c.d,0,c.d.length); for(byte[] p:packetize(pid,isVideo,pes.toByteArray(),pts)) tsBroadcast(p); } + java.util.List packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off>8)&0x1F)); p[2]=(byte)pid; boolean pcr=first&&isVideo; int room=184-(pcr?8:0); int remain=pes.length-off; int take=Math.min(remain,room); boolean stuff=take>25); p[pos++]=(byte)(b>>17); p[pos++]=(byte)(b>>9); p[pos++]=(byte)(b>>1); p[pos++]=(byte)((b<<7)|0x7E); p[pos++]=0; } while(pos<4+1+afLen) p[pos++]=(byte)0xFF; } System.arraycopy(pes,off,p,pos,take); off+=take; first=false; out.add(p); } return out; } + // ---- end TS muxer ---- + void startWebcamServer(){ if(httpSock!=null)return; new Thread(()->{ try{ httpSock=new java.net.ServerSocket(5654); p("Webcam server: http://"+deviceIp()+":5654/ (video=/video.h264 audio=/audio.aac)"); while(true){ final java.net.Socket s=httpSock.accept(); new Thread(()->handleHttp(s)).start(); } }catch(Exception e){p("Webcam server failed: "+e);} }).start(); } + void handleHttp(java.net.Socket s){ try{ s.setTcpNoDelay(true); java.io.BufferedReader in=new java.io.BufferedReader(new java.io.InputStreamReader(s.getInputStream())); String line=in.readLine(); if(line==null){s.close();return;} String path=line.split(" ")[1]; while((line=in.readLine())!=null&&!line.isEmpty()){} java.io.OutputStream out=s.getOutputStream(); + if(path.equals("/")||path.startsWith("/index")){ String h="

Portal webcam

/stream.ts (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)
video: /video.h264 (raw H.264 Annex B)
audio: /audio.aac (raw AAC ADTS)

ffplay http://"+deviceIp()+":5654/stream.ts
mpv http://"+deviceIp()+":5654/stream.ts
vlc http://"+deviceIp()+":5654/stream.ts
"; byte[] b=h.getBytes(); out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes()); out.write(b); out.flush(); s.close(); return; } + if(path.startsWith("/video.h264")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/h264\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); byte[] csd=vCsd; if(csd!=null){ out.write(csd); out.flush(); } if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue q=new java.util.concurrent.ArrayBlockingQueue<>(3); vClients.add(q); boolean started=false; try{ while(true){ VChunk c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!vClients.contains(q)) break; continue; } if(!started){ if(!c.k) continue; started=true; } out.write(c.d); out.flush(); } }finally{ vClients.remove(q); } return; } + if(path.startsWith("/stream.ts")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: video/mp2t\r\nCache-Control: no-store, no-cache, must-revalidate\r\nPragma: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n".getBytes()); out.flush(); if(venc!=null) try{ Bundle pb=new Bundle(); pb.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME,0); venc.setParameters(pb); }catch(Exception e){} java.util.concurrent.BlockingQueue q=new java.util.concurrent.ArrayBlockingQueue<>(24); synchronized(tsClients){ if(tsClients.isEmpty()){ vBase=-1; aBase=-1; } tsClients.add(q); } try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!tsClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ tsClients.remove(q); } return; } + if(path.startsWith("/audio.aac")){ ensurePipeline(); out.write("HTTP/1.1 200 OK\r\nContent-Type: audio/aac\r\nCache-Control: no-store\r\n\r\n".getBytes()); out.flush(); java.util.concurrent.BlockingQueue q=new java.util.concurrent.ArrayBlockingQueue<>(256); aClients.add(q); try{ while(true){ byte[] c=q.poll(500,java.util.concurrent.TimeUnit.MILLISECONDS); if(c==null){ if(!aClients.contains(q)) break; continue; } out.write(c); out.flush(); } }finally{ aClients.remove(q); } return; } + if(path.startsWith("/control/mode")){ String mode=query(path,"mode"); if(mode==null||!mode.matches("DefaultAuto|Desk|Meeting|Fixed")){ reply(out,400,"mode must be DefaultAuto, Desk, Meeting, or Fixed"); return; } setSmartMode(mode); reply(out,200,"mode requested: "+mode); return; } + if(path.startsWith("/control/fixed")){ try{ fx=Float.parseFloat(query(path,"x")); fy=Float.parseFloat(query(path,"y")); fs=Float.parseFloat(query(path,"scale")); setSmartMode("Fixed"); reply(out,200,String.format("fixed requested: x=%.3f y=%.3f scale=%.3f",fx,fy,fs)); }catch(Exception e){ reply(out,400,"x, y, and scale are required numbers"); } return; } + out.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".getBytes()); out.flush(); s.close(); + }catch(Exception e){} try{ s.close(); }catch(Exception e){} } + String query(String path,String key){ int q=path.indexOf('?'); if(q<0)return null; for(String p:path.substring(q+1).split("&")){String[] kv=p.split("=",2);if(kv.length==2&&java.net.URLDecoder.decode(kv[0]).equals(key))return java.net.URLDecoder.decode(kv[1]);}return null; } + void reply(java.io.OutputStream out,int code,String body)throws java.io.IOException{byte[] b=body.getBytes();out.write(("HTTP/1.1 "+code+" OK\r\nContent-Type: text/plain\r\nContent-Length: "+b.length+"\r\nConnection: close\r\n\r\n").getBytes());out.write(b);out.flush();} + public void onRequestPermissionsResult(int rc,String[] p,int[] r){ startAll(); } + void buildUi(){ + screenW=getResources().getDisplayMetrics().widthPixels; screenH=getResources().getDisplayMetrics().heightPixels; + LinearLayout root=new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setBackgroundColor(0xff101010); + authStatus=new TextView(this); authStatus.setTextColor(0xffffffff); authStatus.setPadding(12,8,12,8); root.addView(authStatus,new LinearLayout.LayoutParams(-1,70)); + authPanel=new LinearLayout(this); authPanel.setOrientation(LinearLayout.VERTICAL); root.addView(authPanel,new LinearLayout.LayoutParams(-1,110)); + Button revokeAll=new Button(this); revokeAll.setText("Revoke all paired clients"); revokeAll.setOnClickListener(v->{ Intent x=new Intent(this,PortalStreamingService.class); x.setAction("com.portaltv.capability.REVOKE_ALL"); if(Build.VERSION.SDK_INT>=26) startForegroundService(x); else startService(x); }); root.addView(revokeAll,new LinearLayout.LayoutParams(-1,70)); + LinearLayout main=new LinearLayout(this); main.setOrientation(LinearLayout.HORIZONTAL); root.addView(main,new LinearLayout.LayoutParams(-1,0,1)); + preview=new ImageView(this); preview.setBackgroundColor(0xff202020); preview.setScaleType(ImageView.ScaleType.FIT_CENTER); main.addView(preview,new LinearLayout.LayoutParams(0,-1,1)); + controlsPanel=new LinearLayout(this); controlsPanel.setOrientation(LinearLayout.VERTICAL); controlsPanel.setBackgroundColor(0xff303030); controlsPanel.setPadding(8,8,8,8); main.addView(controlsPanel,new LinearLayout.LayoutParams(ctlW,-1)); + LinearLayout trow=new LinearLayout(this); controlsToggle=new Button(this); controlsToggle.setText("<"); focusFx(controlsToggle); trow.addView(controlsToggle,new LinearLayout.LayoutParams(-2,84)); controlsPanel.addView(trow); controlsToggle.setOnClickListener(v->toggleControls()); + ScrollView cs=new ScrollView(this); controlsContent=new LinearLayout(this); controlsContent.setOrientation(LinearLayout.VERTICAL); controlsContent.setVisibility(View.GONE); cs.addView(controlsContent); controlsPanel.addView(cs,new LinearLayout.LayoutParams(-1,0,1)); + TextView ml=new TextView(this); ml.setText("Mode"); ml.setTextColor(0xffffffff); controlsContent.addView(ml); + for(final String mode:new String[]{"DefaultAuto","Desk","Meeting","Fixed"}){ Button x=new Button(this); x.setText(mode); x.setTextColor(0xffffffff); x.setBackgroundColor(C_IDLE); x.setOnClickListener(v->setSmartMode(mode)); x.setOnFocusChangeListener((v,f)->styleModeButton((Button)v,mode)); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; controlsContent.addView(x,lp); modeButtons.put(mode,x); } + FrameLayout subHost=new FrameLayout(this); LinearLayout.LayoutParams slp=new LinearLayout.LayoutParams(-1,-2); slp.topMargin=12; controlsContent.addView(subHost,slp); + subFixed=new LinearLayout(this); subFixed.setOrientation(LinearLayout.VERTICAL); subFixed.setVisibility(View.GONE); + LinearLayout fr1=new LinearLayout(this); LinearLayout fr2=new LinearLayout(this); + String[][] pan={{"<","-0.05","0","1"},{">","0.05","0","1"},{"^","0","-0.05","1"},{"v","0","0.05","1"}}; for(final String[] t:pan){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr1.addView(x,new LinearLayout.LayoutParams(0,84,1)); } + String[][] zm={{"Z+","0","0","0.85"},{"Z-","0","0","1.1765"}}; for(final String[] t:zm){ Button x=new Button(this); x.setText(t[0]); x.setOnClickListener(v->nudgeFixed(Float.parseFloat(t[1]),Float.parseFloat(t[2]),Float.parseFloat(t[3]))); focusFx(x); fr2.addView(x,new LinearLayout.LayoutParams(0,84,1)); } + subFixed.addView(fr1); subFixed.addView(fr2); subHost.addView(subFixed); + subDesk=new LinearLayout(this); subDesk.setOrientation(LinearLayout.VERTICAL); subDesk.setVisibility(View.GONE); + for(final float t:new float[]{0.0f,0.5f,1.0f}){ Button x=new Button(this); x.setText("tight "+t); x.setOnClickListener(v->{Bundle b=new Bundle(); b.putFloat("additional_stable_framing_tightness",t); sendMode("ModeSetting_Desk",b,"Desk tight="+t);}); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; subDesk.addView(x,lp); } + subHost.addView(subDesk); + subNone=new TextView(this); subNone.setText("No mode parameters"); subNone.setTextColor(0xffaaaaaa); subNone.setVisibility(View.GONE); subHost.addView(subNone); + logPanel=new LinearLayout(this); logPanel.setOrientation(LinearLayout.VERTICAL); logPanel.setBackgroundColor(0xff282828); root.addView(logPanel,new LinearLayout.LayoutParams(-1,logHeaderH)); + LinearLayout hdr=new LinearLayout(this); hdr.setGravity(16); TextView lt=new TextView(this); lt.setText("Log"); lt.setTextColor(0xffffffff); hdr.addView(lt,new LinearLayout.LayoutParams(0,-2,1)); logToggle=new Button(this); logToggle.setText("^"); focusFx(logToggle); hdr.addView(logToggle,new LinearLayout.LayoutParams(-2,-2)); logToggle.setOnClickListener(v->toggleLog()); logPanel.addView(hdr); + log=new TextView(this); log.setTextSize(14); log.setTextColor(0xffeeeeee); logScroll=new ScrollView(this); logScroll.addView(log); logScroll.setVisibility(View.GONE); logPanel.addView(logScroll,new LinearLayout.LayoutParams(-1,0,1)); + setContentView(root); + } + void refreshAuthUi(){ if(authPanel==null)return; android.content.SharedPreferences p=getSharedPreferences("auth",MODE_PRIVATE); int av=p.getInt("activeVideo",0), aa=p.getInt("activeAudio",0); authStatus.setText((p.getString("pairingPin","").isEmpty()?"HTTPS ready":"Pairing PIN: "+p.getString("pairingPin",""))+" Active video: "+av+" audio: "+aa); authPanel.removeAllViews(); java.util.Set ts=p.getStringSet("tokens",java.util.Collections.emptySet()); for(String h:ts){ String m=p.getString("client."+h,"unknown"); Button b=new Button(this); b.setText("Revoke "+m.replace('|',' ')); b.setOnClickListener(v->{ java.util.Set n=p.getStringSet("tokens",java.util.Collections.emptySet()); n=new java.util.HashSet<>(n); n.remove(h); p.edit().putStringSet("tokens",n).remove("client."+h).apply(); refreshAuthUi(); }); authPanel.addView(b,new LinearLayout.LayoutParams(-1,60)); } } + Button mkBtn(String t,View.OnClickListener l){ Button x=new Button(this); x.setText(t); x.setOnClickListener(l); focusFx(x); LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,84); lp.topMargin=6; x.setLayoutParams(lp); return x; } + void toggleControls(){ controlsExpanded=!controlsExpanded; android.view.ViewGroup.LayoutParams lp=controlsPanel.getLayoutParams(); lp.width=controlsExpanded?screenW/5:ctlW; controlsPanel.setLayoutParams(lp); controlsContent.setVisibility(controlsExpanded?View.VISIBLE:View.GONE); controlsToggle.setText(controlsExpanded?">":"<"); } + void toggleLog(){ logExpanded=!logExpanded; android.view.ViewGroup.LayoutParams lp=logPanel.getLayoutParams(); lp.height=logExpanded?screenH/3:logHeaderH; logPanel.setLayoutParams(lp); logScroll.setVisibility(logExpanded?View.VISIBLE:View.GONE); logToggle.setText(logExpanded?"v":"^"); } + static final int C_ACTIVE=0xff2e7d32, C_IDLE=0xff424242, C_FOCUS=0xffff9800; + void styleModeButton(Button b,String mode){ boolean a=("ModeSetting_"+mode).equals(currentMode); if(b.isFocused()){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackgroundColor(a?C_ACTIVE:C_IDLE); b.setTextColor(0xffffffff); } } + void focusFx(Button b){ final android.graphics.drawable.Drawable d=b.getBackground(); final android.content.res.ColorStateList tc=b.getTextColors(); b.setOnFocusChangeListener((v,f)->{ if(f){ b.setBackgroundColor(C_FOCUS); b.setTextColor(0xff000000); } else { b.setBackground(d); b.setTextColor(tc); } }); } + void setCurrentMode(String m){ currentMode=m; runOnUiThread(()->{ for(java.util.Map.Entry e:modeButtons.entrySet()) styleModeButton(e.getValue(),e.getKey()); subFixed.setVisibility("ModeSetting_Fixed".equals(currentMode)?View.VISIBLE:View.GONE); subDesk.setVisibility("ModeSetting_Desk".equals(currentMode)?View.VISIBLE:View.GONE); subNone.setVisibility(("ModeSetting_DefaultAuto".equals(currentMode)||"ModeSetting_Meeting".equals(currentMode))?View.VISIBLE:View.GONE); }); } + void p(String x){android.util.Log.d("PortalCap",x); runOnUiThread(()->log.append(String.format("%tT ",System.currentTimeMillis())+x+"\n"));} + void inspect(){ try{ for(String id:cm.getCameraIdList()){CameraCharacteristics c=cm.getCameraCharacteristics(id); StreamConfigurationMap map=c.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); p("Camera "+id+": facing="+c.get(CameraCharacteristics.LENS_FACING)+", sensor="+c.get(CameraCharacteristics.SENSOR_INFO_PIXEL_ARRAY_SIZE)); if(map!=null){Size[] y=map.getOutputSizes(ImageFormat.YUV_420_888); if(y!=null){String z=""; for(Size q:y) if(q.getWidth()>=1280) z+=q+" "; p(" YUV outputs: "+z);} Size[] j=map.getOutputSizes(ImageFormat.JPEG); if(j!=null){String z=""; for(Size q:j) if(q.getWidth()>=1280) z+=q+" "; p(" JPEG outputs: "+z);}} }}catch(Exception e){p("Inspect error: "+e);}} + void testCamera(final String id){ + if(checkSelfPermission(Manifest.permission.CAMERA)!=PackageManager.PERMISSION_GRANTED){p("Camera permission not granted");return;} + if(cam!=null){cam.close();cam=null;} if(reader!=null){reader.close();reader=null;} + p("Opening camera "+id+" at 3840x2160 YUV..."); + try { + reader=ImageReader.newInstance(1280,720,ImageFormat.JPEG,2); + startVideoEncoder(); + frameBusy.set(false); frameCount=0; + reader.setOnImageAvailableListener(r->{Image im=r.acquireLatestImage(); if(im==null)return; if(frameBusy.getAndSet(true)){im.close();return;} ByteBuffer bb=im.getPlanes()[0].getBuffer(); int len=bb.remaining(); if(frameBuf==null||frameBuf.length{android.graphics.drawable.Drawable old=preview.getDrawable(); preview.setImageBitmap(bmp); if(old instanceof android.graphics.drawable.BitmapDrawable)((android.graphics.drawable.BitmapDrawable)old).getBitmap().recycle(); frameBusy.set(false);});},h); + cm.openCamera(id,new CameraDevice.StateCallback(){ + public void onOpened(CameraDevice c){ + cam=c; + try { + CaptureRequest.Builder q=c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); Surface out=reader.getSurface(); q.addTarget(out); if(vencSurface!=null) q.addTarget(vencSurface); + c.createCaptureSession(vencSurface!=null?Arrays.asList(out,vencSurface):Collections.singletonList(out),new CameraCaptureSession.StateCallback(){ + public void onConfigured(CameraCaptureSession s){try{s.setRepeatingRequest(q.build(),null,h);p("Camera "+id+" capture started");}catch(Exception e){p("Capture failed: "+e);}} + public void onConfigureFailed(CameraCaptureSession s){p("Camera "+id+" configuration rejected: "+s);} + },h); + } catch(Exception e){p("Camera "+id+" setup failed: "+e);} + } + public void onDisconnected(CameraDevice c){p("Camera "+id+" disconnected");c.close();} + public void onError(CameraDevice c,int e){p("Camera "+id+" error "+e);c.close();} + },h); + } catch(Exception e){p("Camera "+id+" open failed: "+e);} + } + volatile boolean micLoop; Thread micThread; + static class VChunk{ final byte[] d; final boolean k; final long pts; VChunk(byte[] d,boolean k,long pts){this.d=d;this.k=k;this.pts=pts;} } + final java.util.concurrent.BlockingQueue tsV=new java.util.concurrent.ArrayBlockingQueue<>(120); + final java.util.concurrent.BlockingQueue tsA=new java.util.concurrent.ArrayBlockingQueue<>(256); + final java.util.Set> tsClients=java.util.Collections.synchronizedSet(new java.util.HashSet>()); + int ccV,ccA,ccPAT,ccPMT; long vBase=-1,aBase=-1; + final java.util.Set> vClients=java.util.Collections.synchronizedSet(new java.util.HashSet>()); + final java.util.Set> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet>()); + final java.util.concurrent.BlockingQueue pcmIn=new java.util.concurrent.ArrayBlockingQueue<>(128); + volatile byte[] vCsd; MediaCodec venc,aenc; Surface vencSurface; java.net.ServerSocket httpSock; + void startMicLoop(){ if(checkSelfPermission(Manifest.permission.RECORD_AUDIO)!=PackageManager.PERMISSION_GRANTED){p("Microphone permission not granted");return;} if(micThread!=null)return; micLoop=true; micThread=new Thread(()->{ int n=AudioRecord.getMinBufferSize(48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT); AudioRecord ar=null; try{ ar=new AudioRecord(MediaRecorder.AudioSource.DEFAULT,48000,AudioFormat.CHANNEL_IN_MONO,AudioFormat.ENCODING_PCM_16BIT,n*2); ar.startRecording(); p("Mic capture started: actualRate="+ar.getSampleRate()+" minBuf="+n+" state="+ar.getState()); byte[] b=new byte[n]; long tWin=System.nanoTime(),bWin=0; while(micLoop){ int got=ar.read(b,0,b.length); if(got>0){ if(!pcmIn.offer(java.util.Arrays.copyOf(b,got))) p("pcmIn FULL, dropped "+got+"B"); bWin+=got; long now=System.nanoTime(); if(now-tWin>5e9){ p("mic rate: "+(bWin*1e9/(now-tWin))+" B/s (expect 96000), queue="+pcmIn.size()); tWin=now; bWin=0; } } } }catch(Exception e){p("Mic capture failed: "+e);} finally{ try{if(ar!=null){ar.stop();ar.release();}}catch(Exception e){} micThread=null; } }); micThread.start(); } + void sendMode(final String modeName,final Bundle b,final String label){ try{ if(modeName.equals("ModeSetting_Desk")&&b!=null&&b.containsKey("additional_stable_framing_tightness")){ PortalSmartCamera.setDeskTightness(b.getFloat("additional_stable_framing_tightness")); p(label+" sent"); return; } String shortName=modeName.startsWith("ModeSetting_")?modeName.substring("ModeSetting_".length()):modeName; if(shortName.equals("Fixed")) PortalSmartCamera.setMode(shortName,fx,fy,fs); else PortalSmartCamera.setMode(shortName); p(label+" sent"); }catch(Exception e){p(label+" failed: "+e);} } + void setSmartMode(String mode){ if(mode.equals("Fixed")) PortalSmartCamera.setMode(mode,fx,fy,fs); else PortalSmartCamera.setMode(mode); } + void nudgeFixed(float dx,float dy,float sm){ fs=Math.min(1,Math.max(0.1f,fs*sm)); fx=Math.min(1-fs/2,Math.max(fs/2,fx+dx)); fy=Math.min(1-fs/2,Math.max(fs/2,fy+dy)); setSmartMode("Fixed"); } + void ensureSession(final Runnable next){ if(control==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_CONTROL_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){control=b;p("Smart Camera service bound");ensureSession(next);}public void onServiceDisconnected(ComponentName n){control=null;session=null;}},BIND_AUTO_CREATE);p("Binding Smart Camera service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlService");controlToken=new Binder();q.writeStrongBinder(controlToken); if(!control.transact(2,q,r,0)){p("Smart Camera connect rejected");return;} r.readException(); IBinder connection=r.readStrongBinder();if(connection==null){p("No control connection returned");return;} Parcel cr=Parcel.obtain(),co=Parcel.obtain();cr.writeInterfaceToken("com.facebook.portal.smartcamera.external.control.ISmartCameraControlConnection");cr.writeStrongBinder(new Binder());if(!connection.transact(2,cr,co,0)){p("requestControls rejected");return;}co.readException();session=co.readStrongBinder();if(session==null){p("No control session returned");return;} p("Control session established"); next.run(); }catch(Exception e){p("Control connect failed: "+e);} } + void ensureMeta(final Runnable next){ if(meta==null){Intent i=new Intent("com.facebook.portal.SMART_CAMERA_EXTERNAL_METADATA_SERVICE");i.setPackage("com.facebook.portal.aiservice");bindService(i,new ServiceConnection(){public void onServiceConnected(ComponentName n,IBinder b){meta=b;p("Metadata service bound");ensureMeta(next);}public void onServiceDisconnected(ComponentName n){meta=null;metaConn=null;}},BIND_AUTO_CREATE);p("Binding metadata service...");return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataService");metaToken=new Binder();q.writeStrongBinder(metaToken); if(!meta.transact(2,q,r,0)){p("Metadata connect rejected");return;} r.readException(); metaConn=r.readStrongBinder();if(metaConn==null){p("No metadata connection returned");return;} p("Metadata connection established"); next.run(); }catch(Exception e){p("Metadata connect failed: "+e);} } + void queryMode(){ if(metaConn==null){ensureMeta(()->queryMode());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection"); if(!metaConn.transact(2,q,r,0)){p("getMode call failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Current mode: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("getMode failed: "+e);} } + void watchModes(){ if(metaConn==null){ensureMeta(()->watchModes());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(modeListener); if(!metaConn.transact(3,q,r,0)){p("subscribeModeChanges failed");return;} r.readException(); String m=r.readInt()!=0?r.readString():null; p("Watching modes; current: "+(m!=null?m:"(null)")); setCurrentMode(m); }catch(Exception e){p("subscribeModeChanges failed: "+e);} } + void watchCrop(){ if(metaConn==null){ensureMeta(()->watchCrop());return;} try{ Parcel q=Parcel.obtain(),r=Parcel.obtain();q.writeInterfaceToken("com.facebook.portal.smartcamera.external.metadata.ISmartCameraMetadataConnection");q.writeStrongBinder(metaReceiver); ArrayList t=new ArrayList(); t.add("crop"); q.writeStringList(t); q.writeFloat(1.0f); if(!metaConn.transact(6,q,r,0)){p("subscribeFrameMetadata failed");return;} r.readException(); if(r.readInt()!=0){ Bundle b=r.readBundle(getClass().getClassLoader()); p("crop now: "+b.get("crop")); } p("Watching crop @1Hz"); }catch(Exception e){p("subscribeFrameMetadata failed: "+e);} } + protected void onDestroy(){ PortalSmartCamera.removeStateListener(cameraStateListener); micLoop=false;try{if(venc!=null){venc.stop();venc.release();}}catch(Exception e){} try{if(aenc!=null){aenc.stop();aenc.release();}}catch(Exception e){} try{if(httpSock!=null)httpSock.close();}catch(Exception e){} if(cam!=null)cam.close();if(reader!=null)reader.close();if(ht!=null)ht.quitSafely();super.onDestroy();} +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalBootReceiver.kt b/portal-capability-test/src/com/portaltv/capability/PortalBootReceiver.kt new file mode 100644 index 0000000..3a33d04 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalBootReceiver.kt @@ -0,0 +1,15 @@ +package com.portaltv.capability + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +class PortalBootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + val service = Intent(context, PortalStreamingService::class.java) + if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(service) else context.startService(service) + } + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalDeviceIdentity.kt b/portal-capability-test/src/com/portaltv/capability/PortalDeviceIdentity.kt new file mode 100644 index 0000000..b7d5773 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalDeviceIdentity.kt @@ -0,0 +1,80 @@ +package com.portaltv.capability + +import android.content.Context +import android.os.Build +import android.provider.Settings + +/** Resolves a human-readable Portal identity for mDNS / UI. */ +object PortalDeviceIdentity { + data class Info(val name: String, val model: String) { + /** DNS-SD instance name shown in discovery UIs. */ + val serviceName: String + get() { + val n = sanitize(name) + val m = sanitize(model) + return when { + n.isEmpty() && m.isEmpty() -> PortalEndpoints.MDNS_FALLBACK_NAME + n.isEmpty() -> m + m.isEmpty() || n.equals(m, ignoreCase = true) -> n + else -> "$n ($m)" + } + } + } + + fun resolve(context: Context): Info { + val model = firstNonBlank( + Build.MODEL, + Build.PRODUCT, + "PortalTV", + ) + val name = firstNonBlank( + settings(context, "bluetooth_name"), + settings(context, Settings.Global.DEVICE_NAME), + bluetoothAdapterName(), + model, + ) + return Info(name = name, model = model) + } + + private fun settings(context: Context, key: String): String? = + try { + Settings.Secure.getString(context.contentResolver, key) + ?: Settings.Global.getString(context.contentResolver, key) + } catch (_: Exception) { + null + } + + private fun bluetoothAdapterName(): String? = + try { + @Suppress("DEPRECATION") + android.bluetooth.BluetoothAdapter.getDefaultAdapter()?.name + } catch (_: Exception) { + null + } + + private fun firstNonBlank(vararg values: String?): String = + values.firstOrNull { !it.isNullOrBlank() }?.trim().orEmpty() + + /** DNS-SD instance names: printable, ≤63 bytes, no dots (NsdManager quirk). */ + fun sanitize(raw: String): String { + val cleaned = buildString(raw.length) { + for (ch in raw.trim()) { + when { + ch.isLetterOrDigit() || ch == ' ' || ch == '-' || ch == '_' || ch == '(' || ch == ')' -> + append(ch) + ch == '.' || ch == ',' || ch == ':' || ch == '/' -> + append(' ') + else -> Unit + } + } + }.replace(Regex("\\s+"), " ").trim() + if (cleaned.isEmpty()) return "" + val bytes = cleaned.toByteArray(Charsets.UTF_8) + if (bytes.size <= 63) return cleaned + var end = cleaned.length + while (end > 0 && cleaned.substring(0, end).toByteArray(Charsets.UTF_8).size > 63) { + end-- + } + return cleaned.substring(0, end).trimEnd() + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalEndpoints.kt b/portal-capability-test/src/com/portaltv/capability/PortalEndpoints.kt new file mode 100644 index 0000000..98d1935 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalEndpoints.kt @@ -0,0 +1,13 @@ +package com.portaltv.capability + +/** Shared HTTPS / DNS-SD endpoints for PortalCam. */ +object PortalEndpoints { + /** "TV" as ASCII little-endian nibble joke → decimal 5654. */ + const val PORT = 5654 + + /** DNS-SD service type (trailing dot required by NsdManager). */ + const val MDNS_TYPE = "_portalcam._tcp." + + /** Fallback instance name when device identity is unavailable. */ + const val MDNS_FALLBACK_NAME = "PortalCam" +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalMdns.kt b/portal-capability-test/src/com/portaltv/capability/PortalMdns.kt new file mode 100644 index 0000000..43da1e8 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalMdns.kt @@ -0,0 +1,72 @@ +package com.portaltv.capability + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log + +/** Registers the Portal HTTPS endpoint on the LAN via DNS-SD / mDNS. */ +class PortalMdns(context: Context) { + private val appContext = context.applicationContext + private val nsd = appContext.getSystemService(Context.NSD_SERVICE) as NsdManager + @Volatile private var registered: NsdServiceInfo? = null + @Volatile private var registering = false + + private val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) { + registered = info + registering = false + Log.i(TAG, "mDNS registered ${info.serviceName} ${info.serviceType}:${info.port}") + } + + override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) { + registering = false + Log.e(TAG, "mDNS registration failed code=$errorCode name=${info.serviceName}") + } + + override fun onServiceUnregistered(info: NsdServiceInfo) { + registered = null + Log.i(TAG, "mDNS unregistered ${info.serviceName}") + } + + override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) { + Log.e(TAG, "mDNS unregistration failed code=$errorCode") + } + } + + fun register(port: Int = PortalEndpoints.PORT) { + if (registered != null || registering) return + registering = true + val identity = PortalDeviceIdentity.resolve(appContext) + val info = NsdServiceInfo().apply { + serviceName = identity.serviceName + serviceType = PortalEndpoints.MDNS_TYPE + setPort(port) + setAttribute("model", identity.model) + setAttribute("name", identity.name) + } + Log.i(TAG, "mDNS registering as \"${identity.serviceName}\" (name=${identity.name} model=${identity.model})") + try { + nsd.registerService(info, NsdManager.PROTOCOL_DNS_SD, listener) + } catch (e: Exception) { + registering = false + Log.e(TAG, "mDNS registerService threw", e) + } + } + + fun unregister() { + val info = registered + registered = null + registering = false + if (info == null) return + try { + nsd.unregisterService(listener) + } catch (e: Exception) { + Log.w(TAG, "mDNS unregister failed", e) + } + } + + companion object { + private const val TAG = "PortalMdns" + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalSmartCamera.kt b/portal-capability-test/src/com/portaltv/capability/PortalSmartCamera.kt new file mode 100644 index 0000000..43aed19 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalSmartCamera.kt @@ -0,0 +1,229 @@ +package com.portaltv.capability + +import android.content.Context +import com.portaltv.smartcamera.ControlSnapshot +import com.portaltv.smartcamera.CropConfig +import com.portaltv.smartcamera.DeskModeController +import com.portaltv.smartcamera.SmartCameraController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Process-wide Smart Camera handle backed by [SmartCameraController]. + * Shared by [PortalStreamingService] (HTTPS /control + SSE) and [MainActivity] (TV UI). + * + * State is event-driven from [SmartCameraController.control]; mutations ack only — + * observe [states] / [StateListener] for updates. + */ +object PortalSmartCamera { + private const val TIMEOUT_MS = 8_000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + @Volatile private var camera: SmartCameraController? = null + private var collectJob: Job? = null + + @Volatile private var latest: State = State("DefaultAuto", JSONObject()) + + private val _states = MutableSharedFlow(replay = 1, extraBufferCapacity = 16) + /** Hot stream of camera state for SSE / coroutines. Replay=1 → new collectors get latest. */ + val states: SharedFlow = _states.asSharedFlow() + + private val listeners = CopyOnWriteArrayList() + + fun interface StateListener { + fun onState(state: State) + } + + data class State(val mode: String, val config: JSONObject) { + fun toJson(): String = JSONObject() + .put("mode", mode) + .put("config", config) + .toString() + + /** Content equality — [JSONObject] is identity-based by default. */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is State) return false + return mode == other.mode && config.toString() == other.config.toString() + } + + override fun hashCode(): Int = 31 * mode.hashCode() + config.toString().hashCode() + } + + sealed class Outcome { + data object Ack : Outcome() { + fun toJson(): String = JSONObject().put("ok", true).toString() + } + data class Err(val code: String, val message: String, val httpStatus: Int = 500) : Outcome() { + fun toJson(): String = JSONObject() + .put("error", code) + .put("message", message) + .toString() + } + } + + @JvmStatic + fun start(context: Context) { + if (camera != null) return + synchronized(this) { + if (camera != null) return + val c = SmartCameraController(context.applicationContext, scope).also { + it.start(trackCrop = true) + } + camera = c + collectJob?.cancel() + collectJob = scope.launch { + c.control + .map { it.toPortalState() } + .distinctUntilChanged() + .collect { publish(it) } + } + } + } + + @JvmStatic + fun currentState(): State = latest + + /** Current state as JSON (one-shot; prefer SSE `/control/events`). */ + @JvmStatic + fun stateJsonBlocking(): String = latest.toJson() + + @JvmStatic + fun addStateListener(listener: StateListener) { + listeners.add(listener) + listener.onState(latest) + } + + @JvmStatic + fun removeStateListener(listener: StateListener) { + listeners.remove(listener) + } + @JvmStatic + fun applyModeBlocking(mode: String): Outcome = mutateBlocking { + applyMode(mode, centerX = null, centerY = null, scale = null) + } + + @JvmStatic + fun applyModeBlocking( + mode: String, + centerX: Float, + centerY: Float, + scale: Float, + ): Outcome = mutateBlocking { + applyMode(mode, centerX, centerY, scale) + } + + @JvmStatic + fun applyDeskTightnessBlocking(tightness: Float): Outcome = mutateBlocking { + applyDeskTightness(tightness) + } + + /** Fire-and-forget for TV UI buttons. State arrives via [StateListener]. */ + @JvmStatic + @JvmOverloads + fun setMode(mode: String, centerX: Float = 0.5f, centerY: Float = 0.5f, scale: Float = 1f) { + scope.launch { + runCatching { + if (mode.removePrefix("ModeSetting_") == "Fixed") { + applyMode(mode, centerX, centerY, scale) + } else { + applyMode(mode, null, null, null) + } + }.onFailure { android.util.Log.e("PortalSmartCamera", "setMode failed", it) } + } + } + + @JvmStatic + fun setDeskTightness(tightness: Float) { + scope.launch { + runCatching { applyDeskTightness(tightness) } + .onFailure { android.util.Log.e("PortalSmartCamera", "setDeskTightness failed", it) } + } + } + + private fun mutateBlocking(block: suspend () -> Unit): Outcome = runCatching { + runBlocking { + withTimeout(TIMEOUT_MS) { block() } + } + Outcome.Ack + }.getOrElse { e -> + Outcome.Err("set_mode_failed", e.message ?: e.toString()) + } + + private suspend fun applyMode( + mode: String, + centerX: Float?, + centerY: Float?, + scale: Float?, + ) { + val c = camera ?: error("smart camera not started") + val short = mode.removePrefix("ModeSetting_") + val ok = when (short) { + "DefaultAuto" -> c.auto.activate() + "Desk" -> c.desk.activate(c.desk.tuning.value) + "Meeting" -> c.meeting.activate() + "Fixed" -> { + val crop = CropConfig( + centerX ?: c.fixed.crop.value.centerX, + centerY ?: c.fixed.crop.value.centerY, + scale ?: c.fixed.crop.value.scale, + ).clamped() + c.fixed.setCrop(crop) + } + else -> error("mode must be DefaultAuto, Desk, Meeting, or Fixed") + } + if (!ok) error("setMode($short) was not accepted by Smart Camera") + } + + private suspend fun applyDeskTightness(tightness: Float) { + val c = camera ?: error("smart camera not started") + val ok = c.desk.activate(DeskModeController.Tuning(framingTightness = tightness)) + if (!ok) error("setMode(Desk) was not accepted by Smart Camera") + } + + private fun publish(state: State) { + latest = state + _states.tryEmit(state) + for (l in listeners) { + runCatching { l.onState(state) } + .onFailure { android.util.Log.w("PortalSmartCamera", "listener failed", it) } + } + } + + private fun ControlSnapshot.toPortalState(): State { + val short = shortMode?.takeIf { it in MODES } ?: latest.mode.takeIf { it in MODES } ?: "DefaultAuto" + val config = JSONObject() + when (short) { + "Fixed" -> { + config.putRounded("centerX", fixedCrop.centerX) + config.putRounded("centerY", fixedCrop.centerY) + config.putRounded("scale", fixedCrop.scale) + } + "Desk" -> { + deskTuning.framingTightness?.let { config.putRounded("framingTightness", it) } + deskTuning.trackingResponseDelayPct?.let { config.putRounded("trackingResponseDelayPct", it) } + deskTuning.trackingSensitivityPct?.let { config.putRounded("trackingSensitivityPct", it) } + deskTuning.transitionSpeedPct?.let { config.putRounded("transitionSpeedPct", it) } + } + } + return State(short, config) + } + + private fun JSONObject.putRounded(key: String, value: Float) { + put(key, Math.round(value * 1000.0) / 1000.0) + } + + private val MODES = setOf("DefaultAuto", "Desk", "Meeting", "Fixed") +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalSrp.kt b/portal-capability-test/src/com/portaltv/capability/PortalSrp.kt new file mode 100644 index 0000000..ba96c9f --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalSrp.kt @@ -0,0 +1,198 @@ +package com.portaltv.capability + +import android.util.Base64 +import java.math.BigInteger +import java.security.MessageDigest +import java.security.SecureRandom + +object PortalSrp { + // RFC 5054 2048-bit prime + private const val N_HEX = + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + + "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + + "3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF" + + val N = BigInteger(N_HEX, 16) + val g = BigInteger.valueOf(2) + val k: BigInteger + + init { + val nBytes = toPadded256(N) + val gBytes = toPadded256(g) + k = BigInteger(1, sha256(nBytes, gBytes)) + } + + private val random = SecureRandom() + + data class ActivePairing( + val id: String, + val pin: String, + val salt: ByteArray, + val v: BigInteger, + val privB: BigInteger, + val pubB: BigInteger, + val expiresAt: Long, + var attemptsLeft: Int = 3 + ) + + fun toPadded256(bi: BigInteger): ByteArray { + val raw = bi.toByteArray() + val result = ByteArray(256) + if (raw.size > 256) { + System.arraycopy(raw, raw.size - 256, result, 0, 256) + } else { + System.arraycopy(raw, 0, result, 256 - raw.size, raw.size) + } + return result + } + + fun sha256(vararg parts: ByteArray): ByteArray { + val md = MessageDigest.getInstance("SHA-256") + for (p in parts) md.update(p) + return md.digest() + } + + fun constantTimeEquals(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + fun isValidPublicA(A: BigInteger): Boolean = A.mod(N) != BigInteger.ZERO + fun isValidPublicB(B: BigInteger): Boolean = B.mod(N) != BigInteger.ZERO + fun isValidScrambler(u: BigInteger): Boolean = u != BigInteger.ZERO + + fun newPairing(pin: String): ActivePairing { + val id = Base64.encodeToString( + ByteArray(12).also { random.nextBytes(it) }, + Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE + ) + val salt = ByteArray(16).also { random.nextBytes(it) } + + // x = SHA256(salt || PIN) + val xBytes = sha256(salt, pin.toByteArray(Charsets.UTF_8)) + val x = BigInteger(1, xBytes) + + // v = g^x mod N + val v = g.modPow(x, N) + + // b = random 256-bit BigInteger, ensuring B mod N != 0 + var b: BigInteger + var B: BigInteger + do { + val bBytes = ByteArray(32).also { random.nextBytes(it) } + b = BigInteger(1, bBytes).mod(N.subtract(BigInteger.ONE)).add(BigInteger.ONE) + val gb = g.modPow(b, N) + B = k.multiply(v).add(gb).mod(N) + } while (!isValidPublicB(B)) + + return ActivePairing( + id = id, + pin = pin, + salt = salt, + v = v, + privB = b, + pubB = B, + expiresAt = System.currentTimeMillis() + 120_000L, // 2 minutes + attemptsLeft = 3 + ) + } + + sealed class VerifyResult { + data class Success(val M2: ByteArray, val token: String) : VerifyResult() + data class Failed(val attemptsLeft: Int, val message: String) : VerifyResult() + } + + fun verifyClient( + pairing: ActivePairing, + A_hex: String, + M1_hex: String, + tlsHash: ByteArray + ): VerifyResult { + if (System.currentTimeMillis() > pairing.expiresAt) { + return VerifyResult.Failed(0, "Pairing session expired") + } + if (pairing.attemptsLeft <= 0) { + return VerifyResult.Failed(0, "Too many failed attempts; pairing cancelled") + } + + val A = try { + BigInteger(A_hex, 16) + } catch (_: Exception) { + pairing.attemptsLeft-- + return VerifyResult.Failed(pairing.attemptsLeft, "Invalid client public key format") + } + + // A mod N != 0 + if (!isValidPublicA(A)) { + pairing.attemptsLeft-- + return VerifyResult.Failed(pairing.attemptsLeft, "Invalid public key A") + } + + val M1 = try { + hexToBytes(M1_hex) + } catch (_: Exception) { + pairing.attemptsLeft-- + return VerifyResult.Failed(pairing.attemptsLeft, "Invalid M1 format") + } + + val A_bytes = toPadded256(A) + val B_bytes = toPadded256(pairing.pubB) + + // u = SHA256(PAD(A) || PAD(B)) + val uBytes = sha256(A_bytes, B_bytes) + val u = BigInteger(1, uBytes) + if (!isValidScrambler(u)) { + pairing.attemptsLeft-- + return VerifyResult.Failed(pairing.attemptsLeft, "Scrambler u is zero") + } + + // Server computes S = (A * v^u mod N)^b mod N + val vu = pairing.v.modPow(u, N) + val S = A.multiply(vu).mod(N).modPow(pairing.privB, N) + val S_bytes = toPadded256(S) + + // K = SHA256(PAD(S)) + val K = sha256(S_bytes) + + // Expected M1 = SHA256(PAD(A) || PAD(B) || K || salt || tlsHash) + val expectedM1 = sha256(A_bytes, B_bytes, K, pairing.salt, tlsHash) + + if (!constantTimeEquals(M1, expectedM1)) { + pairing.attemptsLeft-- + return VerifyResult.Failed(pairing.attemptsLeft, "Authentication failed (wrong PIN or MITM detected)") + } + + // M2 = SHA256(PAD(A) || M1 || K || tlsHash) + val M2 = sha256(A_bytes, M1, K, tlsHash) + + // Generate cryptographically secure bearer token + val tokenBytes = ByteArray(32).also { random.nextBytes(it) } + val token = Base64.encodeToString(tokenBytes, Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE) + + return VerifyResult.Success(M2 = M2, token = token) + } + + fun bytesToHex(bytes: ByteArray): String = + bytes.joinToString("") { "%02x".format(it) } + + fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim() + val len = clean.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte() + i += 2 + } + return data + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalSrpClient.kt b/portal-capability-test/src/com/portaltv/capability/PortalSrpClient.kt new file mode 100644 index 0000000..03837b9 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalSrpClient.kt @@ -0,0 +1,105 @@ +package com.portaltv.capability + +import java.math.BigInteger +import java.security.SecureRandom + +/** + * SRP-6a client implementation matching RFC 5054 2048-bit MODP group + * with cryptographic TLS channel binding. + * + * Compatible with PortalCam client (PortalSrpClient.swift) and PortalSrp server. + */ +class PortalSrpClient( + customA: BigInteger? = null, + private val random: SecureRandom = SecureRandom() +) { + @get:JvmName("getPrivateA") + val a: BigInteger + @get:JvmName("getPublicA") + val A: BigInteger + + var K: ByteArray? = null + private set + var M1: ByteArray? = null + private set + private var tlsCertHash: ByteArray? = null + + init { + if (customA != null) { + a = customA + } else { + val aBytes = ByteArray(32).also { random.nextBytes(it) } + a = BigInteger(1, aBytes).mod(PortalSrp.N.subtract(BigInteger.valueOf(2))).add(BigInteger.ONE) + } + A = PortalSrp.g.modPow(a, PortalSrp.N) + } + + val pubAHex: String + get() = PortalSrp.bytesToHex(PortalSrp.toPadded256(A)) + + /** + * Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash. + * Enforces safety checks: B mod N != 0 and u != 0. + */ + fun computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: ByteArray): String { + val salt = PortalSrp.hexToBytes(saltHex) + require(salt.isNotEmpty()) { "Invalid salt hex" } + + val bBytes = PortalSrp.hexToBytes(pubBHex) + val B = BigInteger(1, bBytes) + + // Safety check B % N != 0 + require(PortalSrp.isValidPublicB(B)) { "Server public value B % N == 0" } + + // u = SHA256(pad256(A) || pad256(B)) + val uBytes = PortalSrp.sha256(PortalSrp.toPadded256(A), PortalSrp.toPadded256(B)) + val u = BigInteger(1, uBytes) + require(PortalSrp.isValidScrambler(u)) { "Computed u == 0" } + + // x = SHA256(salt || UTF8(pin)) + val xBytes = PortalSrp.sha256(salt, pin.toByteArray(Charsets.UTF_8)) + val x = BigInteger(1, xBytes) + + // S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N + val gx = PortalSrp.g.modPow(x, PortalSrp.N) + val kgx = PortalSrp.k.multiply(gx).mod(PortalSrp.N) + val base = B.subtract(kgx).mod(PortalSrp.N) + val exp = a.add(u.multiply(x)) + val S = base.modPow(exp, PortalSrp.N) + + // K = SHA256(pad256(S)) + val sessionK = PortalSrp.sha256(PortalSrp.toPadded256(S)) + this.K = sessionK + this.tlsCertHash = tlsCertSha256 + + // M1 = SHA256(pad256(A) || pad256(B) || K || salt || tlsCertSha256) + val clientM1 = PortalSrp.sha256( + PortalSrp.toPadded256(A), + PortalSrp.toPadded256(B), + sessionK, + salt, + tlsCertSha256 + ) + this.M1 = clientM1 + return PortalSrp.bytesToHex(clientM1) + } + + /** + * Verify server's M2 response. + * Expected M2 = SHA256(pad256(A) || M1 || K || tlsCertSha256). + */ + fun verifyServerM2(serverM2Hex: String): Boolean { + val expectedM1 = M1 ?: throw IllegalStateException("Client state not initialized for verification") + val sessionK = K ?: throw IllegalStateException("Client state not initialized for verification") + val certHash = tlsCertHash ?: throw IllegalStateException("Client state not initialized for verification") + + val serverM2 = try { + PortalSrp.hexToBytes(serverM2Hex) + } catch (e: Exception) { + return false + } + + val expectedM2 = PortalSrp.sha256(PortalSrp.toPadded256(A), expectedM1, sessionK, certHash) + return PortalSrp.constantTimeEquals(expectedM2, serverM2) + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalStreamingService.kt b/portal-capability-test/src/com/portaltv/capability/PortalStreamingService.kt new file mode 100644 index 0000000..7b83684 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalStreamingService.kt @@ -0,0 +1,746 @@ +package com.portaltv.capability + +import android.app.* +import android.content.* +import android.graphics.SurfaceTexture +import android.hardware.camera2.* +import android.media.* +import android.os.* +import android.view.Surface +import java.net.* +import java.util.concurrent.* +import java.util.concurrent.atomic.AtomicInteger +import java.security.MessageDigest +import java.security.SecureRandom +import android.util.Base64 +import javax.net.ssl.SSLServerSocket + +/** Foreground, UI-independent Portal raw media service over HTTPS. */ +class PortalStreamingService : Service() { + companion object { @JvmField @Volatile var activityVisible = false } + private val video = Track(true); private val audio = Track(false) + private val videoUsers = AtomicInteger(); private val audioUsers = AtomicInteger() + private var camera: CameraDevice? = null; private var cameraSession: CameraCaptureSession? = null + private var reader: MediaCodec? = null; private var audioCodec: MediaCodec? = null + private var videoSurface: Surface? = null; private var mic: AudioRecord? = null + private var server: SSLServerSocket? = null; private val io = Executors.newCachedThreadPool() + private val cameraHandler = Handler(Looper.getMainLooper()) + private val videoLock = Any() + private val audioLock = Any() + @Volatile private var audioLoopActive = false + private var audioThread: Thread? = null + private val authPrefs by lazy { getSharedPreferences("auth", MODE_PRIVATE) } + private val random = SecureRandom() + @Volatile private var pairing: PortalSrp.ActivePairing? = null + @Volatile private var recoveringCamera = false + /** Bumped on every startVideo/stopVideo so stale CameraDevice callbacks are ignored. */ + @Volatile private var cameraGeneration = 0 + private var mdns: PortalMdns? = null + + override fun onCreate() { + super.onCreate() + android.util.Log.d("PortalService", "onCreate - initializing TLS") + startForeground(42, notification()) + PortalSmartCamera.start(this) + try { + server = PortalTls.createServerSocket(this, PortalEndpoints.PORT) + android.util.Log.i("PortalService", "HTTPS server listening on port ${PortalEndpoints.PORT}") + mdns = PortalMdns(this).also { it.register(PortalEndpoints.PORT) } + } catch (e: Exception) { + android.util.Log.e("PortalService", "Failed to start HTTPS server on port ${PortalEndpoints.PORT}", e) + } + cameraHandler.post(object : Runnable { + override fun run() { + authPrefs.edit() + .putInt("activeVideo", videoUsers.get()) + .putInt("activeAudio", audioUsers.get()) + .apply() + cameraHandler.postDelayed(this, 1000) + } + }) + Thread { acceptLoop() }.start() + } + + override fun onStartCommand(i: Intent?, flags: Int, id: Int): Int { + if (i?.action == "com.portaltv.capability.CLOSE_CLIENTS") { + video.clear(); audio.clear(); return START_STICKY + } + if (i?.action == "com.portaltv.capability.REVOKE_ALL") { + authPrefs.edit().clear().apply(); video.clear(); audio.clear() + android.util.Log.i("PortalService", "all clients revoked") + return START_STICKY + } + if (videoUsers.get() > 0 && reader == null) startVideo() + return START_STICKY + } + + override fun onBind(i: Intent?): IBinder? = null + + private fun notification(): Notification { + val ch = NotificationChannel("portal", "Portal camera", NotificationManager.IMPORTANCE_LOW) + getSystemService(NotificationManager::class.java).createNotificationChannel(ch) + return Notification.Builder(this, "portal") + .setContentTitle("Portal camera streaming (HTTPS)") + .setSmallIcon(android.R.drawable.presence_video_online) + .build() + } + + private fun acceptLoop() { + val listener = server ?: return + while (!listener.isClosed) { + runCatching { + val client = listener.accept() + io.submit { handle(client) } + }.onFailure { + if (!listener.isClosed) { + android.util.Log.w("PortalService", "accept failed", it) + } + } + } + } + + private fun hash(s: String) = + MessageDigest.getInstance("SHA-256").digest(s.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + private fun startSrpPairing(): PortalSrp.ActivePairing { + val existing = pairing + if (existing != null && System.currentTimeMillis() < existing.expiresAt && existing.attemptsLeft > 0) { + android.util.Log.i("PortalService", "SRP pairing started with PIN: ${existing.pin}") + return existing + } + val pin = (100000 + random.nextInt(900000)).toString() + val active = PortalSrp.newPairing(pin) + pairing = active + authPrefs.edit().putString("pairingPin", pin).apply() + cameraHandler.post { + android.widget.Toast.makeText(this, "Pairing PIN: $pin", android.widget.Toast.LENGTH_LONG).show() + } + bringActivityToFront() + android.util.Log.i("PortalService", "SRP pairing started with PIN: $pin") + return active + } + + private fun authToken(headers: Map): String? { + val a = headers["authorization"] ?: return null + if (!a.startsWith("Bearer ")) return null + val token = a.substring(7) + val h = hash(token) + return if ((authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).contains(h)) token else null + } + + private fun handle(s: Socket) { + s.use { socket -> + socket.soTimeout = 5000 + val reader = socket.getInputStream().bufferedReader() + val line = reader.readLine() ?: return + val headers = mutableMapOf() + while (true) { + val h = reader.readLine() ?: break + if (h.isEmpty()) break + val k = h.indexOf(':') + if (k > 0) headers[h.substring(0, k).lowercase()] = h.substring(k + 1).trim() + } + val parts = line.split(" ") + val method = parts.getOrNull(0) ?: "GET" + val path = parts.getOrNull(1) ?: return + + // If request has Content-Length, read body + var body = "" + val contentLength = headers["content-length"]?.toIntOrNull() ?: 0 + if (contentLength in 1..65536) { + val buf = CharArray(contentLength) + var readTotal = 0 + while (readTotal < contentLength) { + val r = reader.read(buf, readTotal, contentLength - readTotal) + if (r < 0) break + readTotal += r + } + body = String(buf, 0, readTotal) + } + + when { + // SRP-6a pairing endpoints + path.startsWith("/auth/srp/init") -> handleSrpInit(socket) + path.startsWith("/auth/srp/verify") -> handleSrpVerify(socket, path, body, headers) + + // TLS Info endpoint (returns server cert SHA-256 for diagnostics) + path.startsWith("/auth/cert") -> { + val sha = PortalSrp.bytesToHex(PortalTls.certSha256) + reply(socket, 200, "{\"certSha256\":\"$sha\"}", "application/json") + } + + // Media streams (require Bearer auth) + path.startsWith("/video.h264") -> { + if (authToken(headers) != null) stream(socket, video, videoUsers, "video/h264", true) + else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") + } + path.startsWith("/audio.aac") -> { + if (authToken(headers) != null) stream(socket, audio, audioUsers, "audio/aac", false) + else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") + } + path.startsWith("/control") -> { + if (authToken(headers) != null) control(socket, path) + else reply(socket, 401, "{\"error\":\"unauthorized\"}", "application/json") + } + else -> reply(socket, 404, "{\"error\":\"not found\"}", "application/json") + } + } + } + + private fun handleSrpInit(s: Socket) { + val p = startSrpPairing() + val saltHex = PortalSrp.bytesToHex(p.salt) + val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(p.pubB)) + val json = "{\"pairingId\":\"${p.id}\",\"salt\":\"$saltHex\",\"B\":\"$bHex\",\"expiresIn\":120}" + reply(s, 200, json, "application/json") + } + + private fun handleSrpVerify(s: Socket, path: String, body: String, headers: Map) { + val p = pairing + if (p == null) { + reply(s, 400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}", "application/json") + return + } + + // Parse params from query string or JSON body + val queryParams = path.substringAfter('?', "") + .split('&') + .mapNotNull { + val kv = it.split('=', limit = 2) + if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null + }.toMap() + + fun extractParam(key: String): String? { + queryParams[key]?.let { return it } + // Basic JSON search: "key":"value" or "key": "value" + val pattern = Regex("\"$key\"\\s*:\\s*\"([^\"]+)\"") + return pattern.find(body)?.groupValues?.getOrNull(1) + } + + val pairingId = extractParam("pairingId") + val A = extractParam("A") + val M1 = extractParam("M1") + + if (pairingId == null || A == null || M1 == null) { + reply(s, 400, "{\"error\":\"missing_parameters\",\"message\":\"pairingId, A, and M1 are required\"}", "application/json") + return + } + + if (pairingId != p.id) { + reply(s, 400, "{\"error\":\"invalid_pairing_id\"}", "application/json") + return + } + + val tlsHash = PortalTls.certSha256 + val res = PortalSrp.verifyClient(p, A, M1, tlsHash) + + when (res) { + is PortalSrp.VerifyResult.Success -> { + val token = res.token + val h = hash(token) + val set = (authPrefs.getStringSet("tokens", emptySet()) ?: emptySet()).toMutableSet() + set += h + val clientMeta = (headers["user-agent"] ?: "unknown") + "|" + + (headers["x-client-name"] ?: "") + "|" + + (headers["x-client-version"] ?: "") + "|" + + System.currentTimeMillis() + authPrefs.edit() + .putStringSet("tokens", set) + .putString("client.$h", clientMeta) + .remove("pairingPin") + .apply() + pairing = null + + val m2Hex = PortalSrp.bytesToHex(res.M2) + val json = "{\"M2\":\"$m2Hex\",\"token\":\"$token\"}" + android.util.Log.i("PortalService", "SRP pairing successfully completed for client: $clientMeta") + reply(s, 200, json, "application/json") + } + is PortalSrp.VerifyResult.Failed -> { + android.util.Log.w("PortalService", "SRP verify failed: ${res.message}, attempts left: ${res.attemptsLeft}") + if (res.attemptsLeft <= 0) { + pairing = null + authPrefs.edit().remove("pairingPin").apply() + } + reply( + s, + 401, + "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft},\"message\":\"${res.message}\"}", + "application/json" + ) + } + } + } + + private fun stream(s: Socket, t: Track, n: AtomicInteger, type: String, key: Boolean) { + s.soTimeout = 0 // Don't timeout streaming connections + val o = s.getOutputStream() + o.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray()) + o.flush() + val q = t.add(key) + val active = n.incrementAndGet() + android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client connected; active=$active") + if (active == 1) { + if (t === video) startVideo() else startAudio() + if (key) bringActivityToFront() + } + try { + var wait = key + while (!s.isClosed && t.contains(q)) { + val p = q.poll(1, TimeUnit.SECONDS) ?: continue + if (!wait || p.key) { + wait = false + o.write(p.data) + o.flush() + } + } + } catch (e: Exception) { + android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client disconnected: ${e.javaClass.simpleName}") + } finally { + t.remove(q) + val left = n.decrementAndGet() + android.util.Log.i("PortalService", "${if (key) "video" else "audio"} client removed; active=$left") + if (left == 0) { + if (t === video) { + android.util.Log.i("PortalService", "last video client gone; stopping camera") + stopVideo() + } else { + android.util.Log.i("PortalService", "last audio client gone; stopping microphone") + stopAudio() + } + } + } + } + + private fun bringActivityToFront() { + if (activityVisible) return + runCatching { + startActivity( + Intent().setClassName(this, "com.portaltv.capability.MainActivity") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + ) + }.onFailure { android.util.Log.w("PortalService", "could not foreground activity", it) } + } + + private fun control(s: Socket, p: String) { + val pathOnly = p.substringBefore('?') + val q = p.substringAfter('?', "") + .split('&') + .mapNotNull { + val kv = it.split('=', limit = 2) + if (kv.size == 2) URLDecoder.decode(kv[0], "UTF-8") to URLDecoder.decode(kv[1], "UTF-8") else null + }.toMap() + + fun replyOutcome(outcome: PortalSmartCamera.Outcome) { + when (outcome) { + is PortalSmartCamera.Outcome.Ack -> + reply(s, 200, outcome.toJson(), "application/json") + is PortalSmartCamera.Outcome.Err -> + reply(s, outcome.httpStatus, outcome.toJson(), "application/json") + } + } + + when { + pathOnly == "/control/events" -> { + controlEvents(s) + } + pathOnly == "/control" || pathOnly == "/control/" || pathOnly == "/control/state" -> { + reply(s, 200, PortalSmartCamera.stateJsonBlocking(), "application/json") + } + pathOnly.startsWith("/control/mode") -> { + val mode = q["mode"] + if (mode == null || mode !in setOf("DefaultAuto", "Desk", "Meeting", "Fixed")) { + reply( + s, 400, + PortalSmartCamera.Outcome.Err( + "invalid_mode", + "mode must be DefaultAuto, Desk, Meeting, or Fixed", + 400, + ).toJson(), + "application/json", + ) + return + } + replyOutcome(PortalSmartCamera.applyModeBlocking(mode)) + } + pathOnly.startsWith("/control/fixed") -> { + val x = q["x"]?.toFloatOrNull() + val y = q["y"]?.toFloatOrNull() + val scale = q["scale"]?.toFloatOrNull() + if (x == null || y == null || scale == null) { + reply( + s, 400, + PortalSmartCamera.Outcome.Err( + "invalid_fixed", + "x, y, and scale are required numbers", + 400, + ).toJson(), + "application/json", + ) + return + } + replyOutcome(PortalSmartCamera.applyModeBlocking("Fixed", x, y, scale)) + } + pathOnly.startsWith("/control/desk") -> { + val tightness = q["tightness"]?.toFloatOrNull() + if (tightness == null) { + reply( + s, 400, + PortalSmartCamera.Outcome.Err( + "invalid_desk", + "tightness is a required number", + 400, + ).toJson(), + "application/json", + ) + return + } + replyOutcome(PortalSmartCamera.applyDeskTightnessBlocking(tightness)) + } + else -> reply( + s, 404, + PortalSmartCamera.Outcome.Err("not_found", "unknown control path", 404).toJson(), + "application/json", + ) + } + } + + /** SSE: initial state, then `event: state` on each change. */ + private fun controlEvents(s: Socket) { + s.soTimeout = 0 + val o = s.getOutputStream() + o.write( + ("HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: keep-alive\r\n\r\n").toByteArray(Charsets.UTF_8) + ) + o.flush() + + fun writeEvent(state: PortalSmartCamera.State) { + val payload = "event: state\ndata: ${state.toJson()}\n\n" + o.write(payload.toByteArray(Charsets.UTF_8)) + o.flush() + } + + val queue = LinkedBlockingQueue(32) + val listener = PortalSmartCamera.StateListener { state -> + // Drop oldest if slow client; keep connection alive. + while (!queue.offer(state)) { + queue.poll() + } + } + PortalSmartCamera.addStateListener(listener) + android.util.Log.i("PortalService", "SSE /control/events client connected") + try { + // addStateListener already pushed latest; also write a comment keepalive loop. + while (!s.isClosed) { + val next = queue.poll(15, TimeUnit.SECONDS) + if (next != null) { + writeEvent(next) + } else { + o.write(": keepalive\n\n".toByteArray(Charsets.UTF_8)) + o.flush() + } + } + } catch (e: Exception) { + android.util.Log.i("PortalService", "SSE client disconnected: ${e.javaClass.simpleName}") + } finally { + PortalSmartCamera.removeStateListener(listener) + android.util.Log.i("PortalService", "SSE /control/events client removed") + runCatching { s.close() } + } + } + + private fun reply(s: Socket, c: Int, b: String, contentType: String = "text/plain") { + val reason = when (c) { + 200 -> "OK" + 400 -> "Bad Request" + 401 -> "Unauthorized" + 404 -> "Not Found" + else -> "Error" + } + val d = b.toByteArray(Charsets.UTF_8) + s.getOutputStream().write( + ("HTTP/1.1 $c $reason\r\nContent-Type: $contentType\r\nContent-Length: ${d.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8) + d + ) + } + + private fun startVideo() { + synchronized(videoLock) { + if (reader != null && videoSurface != null) { + android.util.Log.d("PortalService", "startVideo skipped; encoder already running") + return + } + android.util.Log.d("PortalService", "startVideo") + try { + // Tear down any half-open previous pipeline before starting a new one. + stopVideoLocked() + val generation = ++cameraGeneration + val f = MediaFormat.createVideoFormat("video/avc", 1280, 720) + f.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) + f.setInteger(MediaFormat.KEY_BIT_RATE, 2500000) + f.setInteger(MediaFormat.KEY_FRAME_RATE, 30) + f.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) + reader = MediaCodec.createEncoderByType("video/avc") + reader!!.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + videoSurface = reader!!.createInputSurface() + reader!!.start() + openCamera(generation, videoSurface!!) + drainVideo() + } catch (e: Exception) { + android.util.Log.e("PortalService", "startVideo failed", e) + stopVideoLocked() + } + } + } + + private fun drainVideo() { + Thread { + val b = MediaCodec.BufferInfo() + var count = 0 + while (reader != null) try { + val i = reader!!.dequeueOutputBuffer(b, 10000) + if (i >= 0) { + val x = reader!!.getOutputBuffer(i) + if (x != null && b.size > 0) { + val d = ByteArray(b.size) + x.position(b.offset) + x.get(d) + val key = (b.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0 || (b.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0 + video.publish(d, key, b.presentationTimeUs) + if (++count % 30 == 0) android.util.Log.d("PortalService", "video packets=$count bytes=${d.size} key=$key") + } + reader!!.releaseOutputBuffer(i, false) + } + } catch (e: Exception) { + if (reader != null) android.util.Log.e("PortalService", "video drain stopped", e) + break + } + }.start() + } + + private fun openCamera(generation: Int, surface: Surface) { + val cm = getSystemService(CameraManager::class.java) + val id = cm.cameraIdList.firstOrNull() + if (id == null) { + android.util.Log.e("PortalService", "no cameras available") + return + } + if (checkSelfPermission("android.permission.CAMERA") != 0) { + android.util.Log.e("PortalService", "camera permission denied") + return + } + android.util.Log.d("PortalService", "opening camera $id (gen=$generation)") + try { + cm.openCamera(id, object : CameraDevice.StateCallback() { + override fun onOpened(c: CameraDevice) { + if (generation != cameraGeneration || surface !== videoSurface) { + android.util.Log.w("PortalService", "stale camera onOpened (gen=$generation); closing") + runCatching { c.close() } + return + } + android.util.Log.d("PortalService", "camera opened (gen=$generation)") + try { + camera = c + val q = c.createCaptureRequest(CameraDevice.TEMPLATE_RECORD) + q.addTarget(surface) + c.createCaptureSession(listOf(surface), object : CameraCaptureSession.StateCallback() { + override fun onConfigured(s: CameraCaptureSession) { + if (generation != cameraGeneration) { + android.util.Log.w("PortalService", "stale capture onConfigured; closing session") + runCatching { s.close() } + return + } + android.util.Log.d("PortalService", "capture configured") + try { + cameraSession = s + s.setRepeatingRequest(q.build(), null, cameraHandler) + } catch (e: Exception) { + android.util.Log.e("PortalService", "setRepeatingRequest failed", e) + recoverCamera() + } + } + override fun onConfigureFailed(s: CameraCaptureSession) { + android.util.Log.e("PortalService", "capture configure failed") + recoverCamera() + } + }, cameraHandler) + } catch (e: Exception) { + android.util.Log.e("PortalService", "camera onOpened setup failed", e) + runCatching { c.close() } + recoverCamera() + } + } + override fun onDisconnected(c: CameraDevice) { + android.util.Log.e("PortalService", "camera disconnected") + runCatching { c.close() } + if (generation == cameraGeneration) recoverCamera() + } + override fun onError(c: CameraDevice, e: Int) { + android.util.Log.e("PortalService", "camera error $e") + runCatching { c.close() } + if (generation == cameraGeneration) recoverCamera() + } + }, cameraHandler) + } catch (e: Exception) { + android.util.Log.e("PortalService", "openCamera failed", e) + recoverCamera() + } + } + + private fun recoverCamera() { + if (videoUsers.get() <= 0 || recoveringCamera) return + recoveringCamera = true + cameraHandler.postDelayed({ + recoveringCamera = false + if (videoUsers.get() > 0) { + stopVideo() + startVideo() + } + }, 750) + } + + private fun stopVideo() { + synchronized(videoLock) { + stopVideoLocked() + } + } + + private fun stopVideoLocked() { + cameraGeneration++ + runCatching { cameraSession?.close() } + runCatching { camera?.close() } + cameraSession = null + camera = null + runCatching { videoSurface?.release() } + videoSurface = null + runCatching { + reader?.stop() + reader?.release() + } + reader = null + } + + private fun startAudio() { + synchronized(audioLock) { + stopAudioLocked() + try { + val f = MediaFormat.createAudioFormat("audio/mp4a-latm", 48000, 1) + f.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC) + f.setInteger(MediaFormat.KEY_BIT_RATE, 64000) + val codec = MediaCodec.createEncoderByType("audio/mp4a-latm") + codec.configure(f, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + codec.start() + audioCodec = codec + + val n = AudioRecord.getMinBufferSize(48000, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT) + val record = AudioRecord( + MediaRecorder.AudioSource.DEFAULT, + 48000, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + n * 2 + ) + record.startRecording() + mic = record + + audioLoopActive = true + audioThread = Thread { + val pcm = ByteArray(n) + val info = MediaCodec.BufferInfo() + while (audioLoopActive) { + try { + val got = record.read(pcm, 0, pcm.size) + if (!audioLoopActive) break + if (got > 0) { + val i = codec.dequeueInputBuffer(10_000) + if (!audioLoopActive) break + if (i >= 0) { + val x = codec.getInputBuffer(i) ?: continue + x.clear() + x.put(pcm, 0, got) + codec.queueInputBuffer(i, 0, got, System.nanoTime() / 1000, 0) + } + } + val o = codec.dequeueOutputBuffer(info, 0) + if (!audioLoopActive) break + if (o >= 0) { + val x = codec.getOutputBuffer(o) + if (x != null && info.size > 0) { + val d = ByteArray(info.size) + x.position(info.offset) + x.get(d) + audio.publish(d, false, info.presentationTimeUs) + } + codec.releaseOutputBuffer(o, false) + } + } catch (e: Exception) { + if (audioLoopActive) { + android.util.Log.w("PortalService", "audio loop stopped", e) + } + break + } + } + }.also { + it.name = "portal-audio" + it.start() + } + } catch (e: Exception) { + android.util.Log.e("PortalService", "startAudio failed", e) + stopAudioLocked() + } + } + } + + private fun stopAudio() { + synchronized(audioLock) { + stopAudioLocked() + } + } + + private fun stopAudioLocked() { + audioLoopActive = false + runCatching { mic?.stop() } // unblock AudioRecord.read + val t = audioThread + audioThread = null + if (t != null && t !== Thread.currentThread()) { + runCatching { t.join(750) } + } + runCatching { mic?.release() } + mic = null + runCatching { + audioCodec?.stop() + audioCodec?.release() + } + audioCodec = null + } + + override fun onDestroy() { + mdns?.unregister() + mdns = null + server?.close() + stopVideo() + stopAudio() + io.shutdownNow() + super.onDestroy() + } + + class Track(val v: Boolean) { + data class P(val data: ByteArray, val key: Boolean, val pts: Long) + private val qs = CopyOnWriteArraySet>() + @Volatile private var config: ByteArray? = null + fun add(k: Boolean) = LinkedBlockingDeque

(if (v) 3 else 256).also { + qs += it + config?.let { c -> if (v) it.offer(P(c, true, 0)) } + } + fun clear() { qs.clear() } + fun contains(q: LinkedBlockingDeque

) = qs.contains(q) + fun remove(q: LinkedBlockingDeque

) { qs -= q } + fun publish(d: ByteArray, k: Boolean, p: Long) { + if (v && k && d.size < 256) config = d + qs.forEach { if (!it.offer(P(d, k, p))) qs -= it } + } + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/PortalTls.kt b/portal-capability-test/src/com/portaltv/capability/PortalTls.kt new file mode 100644 index 0000000..43f8992 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/PortalTls.kt @@ -0,0 +1,108 @@ +package com.portaltv.capability + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.math.BigInteger +import java.net.Socket +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.MessageDigest +import java.security.Principal +import java.security.PrivateKey +import java.security.SecureRandom +import java.security.cert.X509Certificate +import java.security.spec.ECGenParameterSpec +import java.util.Date +import javax.net.ssl.KeyManager +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLEngine +import javax.net.ssl.SSLServerSocket +import javax.net.ssl.X509ExtendedKeyManager +import javax.security.auth.x500.X500Principal + +object PortalTls { + private const val ALIAS = "portalcam_tls_ec_p256" + private const val KS_TYPE = "AndroidKeyStore" + + @Volatile + var certDer: ByteArray = ByteArray(0) + private set + + @Volatile + var certSha256: ByteArray = ByteArray(0) + private set + + fun getOrCreateSslContext(context: Context): SSLContext { + val ks = KeyStore.getInstance(KS_TYPE).apply { load(null) } + + if (!ks.containsAlias(ALIAS)) { + android.util.Log.i("PortalTls", "Generating new EC secp256r1 self-signed certificate in AndroidKeyStore") + val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, KS_TYPE) + val now = System.currentTimeMillis() + val notBefore = Date(now - 86400000L) // 1 day ago + val notAfter = Date(now + 10L * 365 * 86400000L) // 10 years + + val spec = KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_SIGN + ) + .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) + .setCertificateSubject(X500Principal("CN=PortalCam, O=PortalCam, C=US")) + .setCertificateSerialNumber(BigInteger.valueOf(now)) + .setCertificateNotBefore(notBefore) + .setCertificateNotAfter(notAfter) + .setDigests( + KeyProperties.DIGEST_NONE, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA384, + KeyProperties.DIGEST_SHA512 + ) + .build() + + kpg.initialize(spec) + kpg.generateKeyPair() + } + + val cert = ks.getCertificate(ALIAS) as X509Certificate + certDer = cert.encoded + certSha256 = MessageDigest.getInstance("SHA-256").digest(certDer) + android.util.Log.i("PortalTls", "Certificate SHA-256: ${certSha256.joinToString("") { "%02x".format(it) }}") + + // Build KeyManager that retrieves key from AndroidKeyStore + val km = object : X509ExtendedKeyManager() { + override fun getClientAliases(keyType: String?, issuers: Array?): Array? = null + override fun chooseClientAlias(keyType: Array?, issuers: Array?, socket: Socket?): String? = null + override fun getServerAliases(keyType: String?, issuers: Array?): Array = arrayOf(ALIAS) + override fun chooseServerAlias(keyType: String?, issuers: Array?, socket: Socket?): String = ALIAS + override fun chooseEngineServerAlias(keyType: String?, issuers: Array?, engine: SSLEngine?): String = ALIAS + override fun getCertificateChain(alias: String?): Array? = arrayOf(cert) + override fun getPrivateKey(alias: String?): PrivateKey? = ks.getKey(ALIAS, null) as? PrivateKey + } + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(arrayOf(km), null, SecureRandom()) + return sslContext + } + + fun createServerSocket(context: Context, port: Int): SSLServerSocket { + val sslCtx = getOrCreateSslContext(context) + val s = sslCtx.serverSocketFactory.createServerSocket(port) as SSLServerSocket + s.needClientAuth = false + s.wantClientAuth = false + + val supported = s.supportedProtocols.toList() + android.util.Log.i("PortalTls", "Supported protocols: $supported") + val desired = listOf("TLSv1.3", "TLSv1.2").filter { it in supported } + if (desired.isNotEmpty()) { + s.enabledProtocols = desired.toTypedArray() + } + android.util.Log.i("PortalTls", "Enabled protocols: ${s.enabledProtocols.contentToString()}") + + return s + } +} diff --git a/portal-capability-test/src/com/portaltv/capability/R.java b/portal-capability-test/src/com/portaltv/capability/R.java new file mode 100644 index 0000000..b1b4278 --- /dev/null +++ b/portal-capability-test/src/com/portaltv/capability/R.java @@ -0,0 +1,14 @@ +/* AUTO-GENERATED FILE. DO NOT MODIFY. + * + * This class was automatically generated by the + * aapt tool from the resource data it found. It + * should not be modified by hand. + */ + +package com.portaltv.capability; + +public final class R { + public static final class style { + public static final int AppTheme=0x7f010000; + } +} \ No newline at end of file diff --git a/portal-capability-test/test/android/util/Base64.java b/portal-capability-test/test/android/util/Base64.java new file mode 100644 index 0000000..bbf81b2 --- /dev/null +++ b/portal-capability-test/test/android/util/Base64.java @@ -0,0 +1,52 @@ +package android.util; + +import java.nio.charset.StandardCharsets; + +/** + * JVM test implementation of android.util.Base64 using java.util.Base64. + * Enables running Android SRP cryptographic tests on standard desktop JVM. + */ +public class Base64 { + public static final int DEFAULT = 0; + public static final int NO_PADDING = 1; + public static final int NO_WRAP = 2; + public static final int CRLF = 4; + public static final int URL_SAFE = 8; + public static final int NO_CLOSE = 16; + + public static String encodeToString(byte[] input, int flags) { + return encodeToString(input, 0, input.length, flags); + } + + public static String encodeToString(byte[] input, int offset, int len, int flags) { + byte[] slice; + if (offset == 0 && len == input.length) { + slice = input; + } else { + slice = new byte[len]; + System.arraycopy(input, offset, slice, 0, len); + } + java.util.Base64.Encoder encoder = ((flags & URL_SAFE) != 0) + ? java.util.Base64.getUrlEncoder() + : java.util.Base64.getEncoder(); + if ((flags & NO_PADDING) != 0) { + encoder = encoder.withoutPadding(); + } + return encoder.encodeToString(slice); + } + + public static byte[] encode(byte[] input, int flags) { + return encodeToString(input, flags).getBytes(StandardCharsets.UTF_8); + } + + public static byte[] decode(String str, int flags) { + java.util.Base64.Decoder decoder = ((flags & URL_SAFE) != 0) + ? java.util.Base64.getUrlDecoder() + : java.util.Base64.getDecoder(); + return decoder.decode(str); + } + + public static byte[] decode(byte[] input, int flags) { + return decode(new String(input, StandardCharsets.UTF_8), flags); + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpChannelBindingTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpChannelBindingTest.kt new file mode 100644 index 0000000..d54e2b9 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpChannelBindingTest.kt @@ -0,0 +1,171 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.PortalSrpClient +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertFalse +import com.portaltv.capability.test.Assert.assertTrue +import java.security.MessageDigest +import java.security.SecureRandom + +class PortalSrpChannelBindingTest : TestSuite("Cryptographic TLS Channel Binding Enforcement") { + + init { + val random = SecureRandom() + + fun fakeCertHash(identifier: String): ByteArray = + MessageDigest.getInstance("SHA-256").digest(identifier.toByteArray(Charsets.UTF_8)) + + test("Channel Binding: Legitimate TLS connection succeeds and produces valid M2") { + val pin = "718293" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + val legitimateTlsCertHash = fakeCertHash("CN=PortalCam Server Cert V1") + + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = legitimateTlsCertHash + ) + + val result = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = legitimateTlsCertHash + ) + + assertTrue(result is PortalSrp.VerifyResult.Success, "Legitimate TLS channel binding must succeed") + val success = result as PortalSrp.VerifyResult.Success + + val m2Hex = PortalSrp.bytesToHex(success.M2) + assertTrue(client.verifyServerM2(m2Hex), "Client must accept server M2 bound to same TLS cert") + } + + test("Channel Binding: Active MITM Proxy attack REJECTED by server") { + // Threat Model: + // An active MITM proxy (e.g. mitmproxy, Charles, or rogue gateway) intercepts + // the HTTPS connection. The proxy creates a separate TLS connection to the client + // using a forged CA, and another TLS connection to the Portal server using the Portal's cert. + // Client sees Cert_MITM; Server sees Cert_Real. + val pin = "998877" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + val realServerTlsCertHash = fakeCertHash("CN=PortalCam Real Hardware Cert") + val mitmProxyTlsCertHash = fakeCertHash("CN=mitmproxy Fake Interceptor Cert") + + // Client computes M1 bound to the MITM's TLS certificate + val m1FromClientUnderMitm = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = mitmProxyTlsCertHash + ) + + // MITM forwards client's (A, M1) to real server + val serverResult = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1FromClientUnderMitm, + tlsHash = realServerTlsCertHash // Server uses its genuine TLS cert hash + ) + + // Server MUST reject because M1 is bound to MITM's cert, not the server's cert! + assertTrue( + serverResult is PortalSrp.VerifyResult.Failed, + "Server MUST reject M1 generated under an active MITM proxy" + ) + val failed = serverResult as PortalSrp.VerifyResult.Failed + assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message) + assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter") + } + + test("Channel Binding: Single bit flip in TLS cert hash triggers authentication failure") { + val pin = "456123" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + val originalCertHash = fakeCertHash("CN=PortalCam Genuine Cert") + val tamperedCertHash = originalCertHash.clone().also { + it[0] = (it[0].toInt() xor 0x01).toByte() // Flip 1 bit + } + + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = tamperedCertHash + ) + + val result = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = originalCertHash + ) + + assertTrue(result is PortalSrp.VerifyResult.Failed, "1-bit altered TLS hash must fail") + val failed = result as PortalSrp.VerifyResult.Failed + assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message) + } + + test("Channel Binding: Client rejects server M2 if TLS channel binding is altered") { + val pin = "654987" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + val clientCertHash = fakeCertHash("CN=Client Observed Cert") + val serverCertHash = fakeCertHash("CN=Client Observed Cert") + val attackerAlteredCertHash = fakeCertHash("CN=Attacker Injected Cert") + + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = clientCertHash + ) + + val result = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, serverCertHash) + assertTrue(result is PortalSrp.VerifyResult.Success) + val success = result as PortalSrp.VerifyResult.Success + + // Attacker tries to forge M2 with altered cert hash + val forgedM2 = PortalSrp.sha256( + PortalSrp.toPadded256(client.A), + client.M1!!, + client.K!!, + attackerAlteredCertHash + ) + + val accepted = client.verifyServerM2(PortalSrp.bytesToHex(forgedM2)) + assertFalse(accepted, "Client must reject M2 bound to an altered TLS cert hash") + } + + test("Channel Binding: Truncated or empty TLS cert hash fails verification") { + val pin = "123123" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + val fullCertHash = fakeCertHash("CN=PortalCam Cert Full") + val emptyCertHash = ByteArray(0) + val truncatedCertHash = ByteArray(16) { fullCertHash[it] } + + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = fullCertHash + ) + + // Server receives empty or truncated TLS hash + val resultEmpty = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, emptyCertHash) + assertTrue(resultEmpty is PortalSrp.VerifyResult.Failed) + + val resultTruncated = PortalSrp.verifyClient(pairing, client.pubAHex, m1Hex, truncatedCertHash) + assertTrue(resultTruncated is PortalSrp.VerifyResult.Failed) + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpConstantTimeTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpConstantTimeTest.kt new file mode 100644 index 0000000..b09768c --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpConstantTimeTest.kt @@ -0,0 +1,94 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.test.Assert.assertFalse +import com.portaltv.capability.test.Assert.assertTrue + +class PortalSrpConstantTimeTest : TestSuite("Constant-Time Comparison constantTimeEquals") { + + init { + test("Constant-time: Empty byte arrays match") { + assertTrue(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(0))) + } + + test("Constant-time: Single-byte matching and non-matching arrays") { + assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x00))) + assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7F))) + assertTrue(PortalSrp.constantTimeEquals(byteArrayOf(0xFF.toByte()), byteArrayOf(0xFF.toByte()))) + + assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x00), byteArrayOf(0x01))) + assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x7F), byteArrayOf(0x7E))) + assertFalse(PortalSrp.constantTimeEquals(byteArrayOf(0x80.toByte()), byteArrayOf(0x00))) + } + + test("Constant-time: 32-byte SHA-256 matching and non-matching arrays") { + val a = ByteArray(32) { (it * 7).toByte() } + val b = a.clone() + assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 32-byte arrays must match") + + // Differing at index 0 + val diffFirst = a.clone().also { it[0] = (it[0].toInt() xor 0x01).toByte() } + assertFalse(PortalSrp.constantTimeEquals(a, diffFirst), "Difference at first byte must not match") + + // Differing at middle index 16 + val diffMid = a.clone().also { it[16] = (it[16].toInt() xor 0x01).toByte() } + assertFalse(PortalSrp.constantTimeEquals(a, diffMid), "Difference at middle byte must not match") + + // Differing at last index 31 + val diffLast = a.clone().also { it[31] = (it[31].toInt() xor 0x01).toByte() } + assertFalse(PortalSrp.constantTimeEquals(a, diffLast), "Difference at last byte must not match") + } + + test("Constant-time: 256-byte MODP key matching and non-matching arrays") { + val a = ByteArray(256) { (it xor 0x5A).toByte() } + val b = a.clone() + assertTrue(PortalSrp.constantTimeEquals(a, b), "Identical 256-byte arrays must match") + + val diff = a.clone().also { it[128] = (it[128].toInt() xor 0x80).toByte() } + assertFalse(PortalSrp.constantTimeEquals(a, diff), "Differing 256-byte arrays must not match") + } + + test("Constant-time: Different length byte arrays must be rejected") { + val base = ByteArray(32) { 0xAA.toByte() } + + assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(31) { 0xAA.toByte() })) + assertFalse(PortalSrp.constantTimeEquals(base, ByteArray(33) { 0xAA.toByte() })) + assertFalse(PortalSrp.constantTimeEquals(ByteArray(0), ByteArray(1))) + assertFalse(PortalSrp.constantTimeEquals(ByteArray(1), ByteArray(0))) + + // Shared prefix but different length + val prefix = byteArrayOf(1, 2, 3) + val longer = byteArrayOf(1, 2, 3, 4) + assertFalse(PortalSrp.constantTimeEquals(prefix, longer)) + assertFalse(PortalSrp.constantTimeEquals(longer, prefix)) + } + + test("Constant-time: Negative signed byte value edge cases") { + // In Java, byte is signed (-128 to 127). + // Verify bitwise operations properly handle negative bytes (0x80..0xFF) without sign extension bugs. + val a = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte()) + val b = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFE.toByte()) + assertTrue(PortalSrp.constantTimeEquals(a, b)) + + val c = byteArrayOf(0x80.toByte(), 0xFF.toByte(), 0xFD.toByte()) + assertFalse(PortalSrp.constantTimeEquals(a, c)) + + val d = byteArrayOf(0x00, 0xFF.toByte(), 0xFE.toByte()) + assertFalse(PortalSrp.constantTimeEquals(a, d)) + } + + test("Constant-time: Single bit divergence test across all 256 bits of SHA-256 hash") { + val original = ByteArray(32) { 0x55.toByte() } + for (byteIdx in 0 until 32) { + for (bitIdx in 0 until 8) { + val mutated = original.clone() + mutated[byteIdx] = (mutated[byteIdx].toInt() xor (1 shl bitIdx)).toByte() + assertFalse( + PortalSrp.constantTimeEquals(original, mutated), + "Must detect 1-bit difference at byte $byteIdx, bit $bitIdx" + ) + } + } + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpIntegrationTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpIntegrationTest.kt new file mode 100644 index 0000000..337f2e8 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpIntegrationTest.kt @@ -0,0 +1,151 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.PortalSrpClient +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertNotNull +import com.portaltv.capability.test.Assert.assertTrue +import java.security.MessageDigest +import java.security.SecureRandom + +class PortalSrpIntegrationTest : TestSuite("Portal SRP-6a End-to-End & Protocol Integration") { + + init { + val random = SecureRandom() + + fun sha256Hex(s: String): String = + MessageDigest.getInstance("SHA-256") + .digest(s.toByteArray(Charsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + + test("E2E Integration: Full PortalStreamingService SRP Handshake & Bearer Auth Flow") { + // Emulates PortalStreamingService state machine in pure Kotlin/JVM + val authTokens = mutableSetOf() + val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) } + val serverPin = "654321" + + // 1. Client fetches server cert hash (GET /auth/cert) + val clientSeenCertSha256 = serverTlsCertSha256.clone() + + // 2. Server initiates SRP pairing (POST /auth/srp/init) + val pairing = PortalSrp.newPairing(serverPin) + val saltHex = PortalSrp.bytesToHex(pairing.salt) + val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)) + + // 3. Client initializes SRP client and computes A, M1 + val client = PortalSrpClient() + val m1Hex = client.computeM1( + saltHex = saltHex, + pubBHex = bHex, + pin = serverPin, + tlsCertSha256 = clientSeenCertSha256 + ) + + // 4. Client sends verification request (POST /auth/srp/verify) + val verifyResult = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = serverTlsCertSha256 + ) + + assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Verify client must succeed") + val success = verifyResult as PortalSrp.VerifyResult.Success + + // Server records token hash + val tokenHash = sha256Hex(success.token) + authTokens.add(tokenHash) + + // 5. Client verifies server M2 + val m2Hex = PortalSrp.bytesToHex(success.M2) + assertTrue(client.verifyServerM2(m2Hex), "Client must accept server evidence M2") + + // 6. Client uses bearer token on protected endpoint (GET /video.h264) + fun checkAuth(header: String?): Boolean { + if (header == null || !header.startsWith("Bearer ")) return false + val token = header.substring(7) + return authTokens.contains(sha256Hex(token)) + } + + assertTrue(checkAuth("Bearer ${success.token}"), "Legitimate token must grant access") + Assert.assertFalse(checkAuth("Bearer bogus-token"), "Forged token must be rejected") + Assert.assertFalse(checkAuth(null), "Missing authorization must be rejected") + } + + test("E2E Integration: MITM Proxy intercepted handshake rejected at HTTP layer") { + val serverTlsCertSha256 = ByteArray(32).also { random.nextBytes(it) } + val mitmProxyCertSha256 = ByteArray(32).also { random.nextBytes(it) } + val pin = "889900" + + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + + // Client computes M1 bound to MITM proxy's certificate + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = mitmProxyCertSha256 + ) + + // Proxy relays M1 to server; Server checks against its real TLS cert + val verifyResult = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = serverTlsCertSha256 + ) + + assertTrue(verifyResult is PortalSrp.VerifyResult.Failed) + val failed = verifyResult as PortalSrp.VerifyResult.Failed + assertEquals(2, failed.attemptsLeft) + assertEquals("Authentication failed (wrong PIN or MITM detected)", failed.message) + } + + test("E2E Integration: Brute-force PIN attack exhausts 3 attempts and invalidates pairing") { + val correctPin = "987654" + var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing(correctPin) + val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) } + + // Attacker makes 3 wrong guesses + val badGuesses = listOf("000000", "111111", "222222") + + for ((index, guess) in badGuesses.withIndex()) { + val currentPairing = activePairing + assertNotNull(currentPairing, "Pairing must exist for attempt ${index + 1}") + + val attackerClient = PortalSrpClient() + val attackerM1 = attackerClient.computeM1( + saltHex = PortalSrp.bytesToHex(currentPairing!!.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(currentPairing.pubB)), + pin = guess, + tlsCertSha256 = tlsCertSha256 + ) + + val result = PortalSrp.verifyClient( + pairing = currentPairing, + A_hex = attackerClient.pubAHex, + M1_hex = attackerM1, + tlsHash = tlsCertSha256 + ) + + assertTrue(result is PortalSrp.VerifyResult.Failed) + val failed = result as PortalSrp.VerifyResult.Failed + val expectedRemaining = 2 - index + assertEquals(expectedRemaining, failed.attemptsLeft) + + if (failed.attemptsLeft <= 0) { + activePairing = null // Emulate PortalStreamingService wipeout + } + } + + // Session is wiped + Assert.assertNull(activePairing, "Pairing session must be wiped after 3 failed attempts") + + // 4th attempt: Even with the correct PIN, request fails because session no longer exists + val legitimateClient = PortalSrpClient() + val hasSession = activePairing != null + Assert.assertFalse(hasSession, "Cannot authenticate against wiped pairing session") + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpMathTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpMathTest.kt new file mode 100644 index 0000000..e70effb --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpMathTest.kt @@ -0,0 +1,149 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.PortalSrpClient +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertArrayEquals +import com.portaltv.capability.test.Assert.assertFalse +import com.portaltv.capability.test.Assert.assertNotNull +import com.portaltv.capability.test.Assert.assertTrue +import java.math.BigInteger +import java.security.SecureRandom + +class PortalSrpMathTest : TestSuite("2048-bit RFC 5054 SRP-6a Group Parameters & Key Agreement") { + + init { + test("RFC 5054 2048-bit Prime N bit-length and primality verification") { + assertEquals(2048, PortalSrp.N.bitLength(), "N must be exactly 2048 bits") + assertTrue(PortalSrp.N.testBit(0), "N must be odd") + assertTrue(PortalSrp.N.isProbablePrime(100), "N must pass Miller-Rabin primality check with certainty 100") + + // Safe prime check: (N - 1) / 2 is also prime + val q = PortalSrp.N.subtract(BigInteger.ONE).divide(BigInteger.valueOf(2)) + assertTrue(q.isProbablePrime(80), "Sophie Germain / safe prime (N-1)/2 must be probable prime") + } + + test("RFC 5054 Generator g and Multiplier k verification") { + assertEquals(BigInteger.valueOf(2), PortalSrp.g, "Generator g must be 2") + + val expectedKBytes = PortalSrp.sha256( + PortalSrp.toPadded256(PortalSrp.N), + PortalSrp.toPadded256(PortalSrp.g) + ) + val expectedK = BigInteger(1, expectedKBytes) + assertEquals(expectedK, PortalSrp.k, "PortalSrp.k must equal SHA256(PAD256(N) || PAD256(g))") + } + + test("SRP-6a 2048-bit mutual key agreement K_client == K_server, M1 and M2 verification") { + val random = SecureRandom() + val pin = "849201" + val pairing = PortalSrp.newPairing(pin) + + val client = PortalSrpClient(random = random) + val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) } + + val bHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)) + val saltHex = PortalSrp.bytesToHex(pairing.salt) + + // Client computes M1 + val m1Hex = client.computeM1( + saltHex = saltHex, + pubBHex = bHex, + pin = pin, + tlsCertSha256 = tlsCertSha256 + ) + + // Verify Client S and Server S mathematical agreement + val A_bytes = PortalSrp.toPadded256(client.A) + val B_bytes = PortalSrp.toPadded256(pairing.pubB) + val uBytes = PortalSrp.sha256(A_bytes, B_bytes) + val u = BigInteger(1, uBytes) + + val vu = pairing.v.modPow(u, PortalSrp.N) + val sServer = client.A.multiply(vu).mod(PortalSrp.N).modPow(pairing.privB, PortalSrp.N) + val kServer = PortalSrp.sha256(PortalSrp.toPadded256(sServer)) + + assertNotNull(client.K, "Client session key K must not be null") + assertArrayEquals(kServer, client.K!!, "Cryptographic key agreement failed: K_client != K_server") + + // Server verifies M1 + val verifyResult = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = tlsCertSha256 + ) + + assertTrue(verifyResult is PortalSrp.VerifyResult.Success, "Server must accept valid M1 from client") + val success = verifyResult as PortalSrp.VerifyResult.Success + assertTrue(success.token.isNotEmpty(), "Server must issue bearer token upon successful verification") + + // Client verifies M2 + val m2Hex = PortalSrp.bytesToHex(success.M2) + val m2Valid = client.verifyServerM2(m2Hex) + assertTrue(m2Valid, "Client must verify server's M2 successfully") + } + + test("SRP-6a mathematical agreement across diverse PIN formats (numeric, alphanumeric, utf8)") { + val testPins = listOf( + "000000", + "999999", + "123456", + "PortalPass-2026!#$", + "Secure🔐Pässtöken-12345", + "a", + "SuperLongPINExceedingStandardLengthsForStressTestingTheSha256HashingPipeline1234567890" + ) + val random = SecureRandom() + val tlsCertSha256 = ByteArray(32).also { random.nextBytes(it) } + + for (pin in testPins) { + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient(random = random) + + val m1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = tlsCertSha256 + ) + + val verifyResult = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = m1Hex, + tlsHash = tlsCertSha256 + ) + + assertTrue( + verifyResult is PortalSrp.VerifyResult.Success, + "Pairing must succeed for PIN: '$pin'" + ) + val success = verifyResult as PortalSrp.VerifyResult.Success + assertTrue( + client.verifyServerM2(PortalSrp.bytesToHex(success.M2)), + "M2 verification must succeed for PIN: '$pin'" + ) + } + } + + test("Uniqueness and entropy of pairing parameters across multiple sessions") { + val pin = "555123" + val sessionCount = 50 + val ids = mutableSetOf() + val salts = mutableSetOf() + val pubBs = mutableSetOf() + + for (i in 0 until sessionCount) { + val pairing = PortalSrp.newPairing(pin) + ids.add(pairing.id) + salts.add(PortalSrp.bytesToHex(pairing.salt)) + pubBs.add(pairing.pubB) + } + + assertEquals(sessionCount, ids.size, "All pairing IDs must be unique (sufficient entropy)") + assertEquals(sessionCount, salts.size, "All pairing salts must be unique (sufficient entropy)") + assertEquals(sessionCount, pubBs.size, "All server public keys B must be unique") + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpPaddingTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpPaddingTest.kt new file mode 100644 index 0000000..55780f9 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpPaddingTest.kt @@ -0,0 +1,115 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertArrayEquals +import com.portaltv.capability.test.Assert.assertTrue +import java.math.BigInteger +import java.security.SecureRandom + +class PortalSrpPaddingTest : TestSuite("BigInteger toPadded256 Edge Cases") { + + init { + test("Padding: BigInteger.ZERO produces 256 zero bytes") { + val padded = PortalSrp.toPadded256(BigInteger.ZERO) + assertEquals(256, padded.size, "Output must be exactly 256 bytes") + val expected = ByteArray(256) + assertArrayEquals(expected, padded, "ZERO must produce all zero bytes") + } + + test("Padding: BigInteger.ONE produces 255 zeros followed by 0x01") { + val padded = PortalSrp.toPadded256(BigInteger.ONE) + assertEquals(256, padded.size) + for (i in 0 until 255) { + assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i") + } + assertEquals(1.toByte(), padded[255], "Last byte must be 1") + } + + test("Padding: Small BigInteger value (g = 2) produces 255 leading zeros") { + val padded = PortalSrp.toPadded256(PortalSrp.g) + assertEquals(256, padded.size) + for (i in 0 until 255) { + assertEquals(0.toByte(), padded[i], "Leading bytes must be zero at index $i") + } + assertEquals(2.toByte(), padded[255], "Last byte must be 2") + } + + test("Padding: 128-bit (16-byte) BigInteger produces 240 leading zeros followed by 16 bytes") { + val raw16 = ByteArray(16) { (it + 1).toByte() } + val bi = BigInteger(1, raw16) + val padded = PortalSrp.toPadded256(bi) + + assertEquals(256, padded.size) + for (i in 0 until 240) { + assertEquals(0.toByte(), padded[i], "Must have 240 leading zeros") + } + for (i in 0 until 16) { + assertEquals((i + 1).toByte(), padded[240 + i]) + } + } + + test("Padding: Exact 256-byte positive BigInteger with MSB 0 (raw.size == 256)") { + // High byte 0x7F ensures sign bit is 0, so raw.size == 256 + val raw256 = ByteArray(256) { it.toByte() } + raw256[0] = 0x7F.toByte() + val bi = BigInteger(1, raw256) + assertEquals(256, bi.toByteArray().size, "BigInteger.toByteArray() should be 256 bytes") + + val padded = PortalSrp.toPadded256(bi) + assertEquals(256, padded.size) + assertArrayEquals(raw256, padded, "Exact 256-byte number must be preserved without distortion") + } + + test("Padding: Exact 2048-bit BigInteger with MSB 1 (raw.size == 257 due to Java sign byte)") { + // When MSB is 1, Java BigInteger.toByteArray() adds a leading 0x00 sign byte (257 bytes total). + // toPadded256 must strip the 0x00 sign byte and return the 256 significant bytes. + val raw256 = ByteArray(256) { 0xFF.toByte() } + val bi = BigInteger(1, raw256) // 2^2048 - 1 + assertEquals(257, bi.toByteArray().size, "toByteArray() must contain 257 bytes with sign prefix") + assertEquals(0.toByte(), bi.toByteArray()[0], "First byte of toByteArray() must be sign byte 0x00") + + val padded = PortalSrp.toPadded256(bi) + assertEquals(256, padded.size, "Output must be exactly 256 bytes") + assertArrayEquals(raw256, padded, "Sign byte 0x00 must be stripped and 256 0xFF bytes retained") + } + + test("Padding: RFC 5054 2048-bit prime N padding verification") { + assertEquals(257, PortalSrp.N.toByteArray().size, "N.toByteArray() has 257 bytes due to MSB=1") + val paddedN = PortalSrp.toPadded256(PortalSrp.N) + assertEquals(256, paddedN.size, "Padded N must be 256 bytes") + assertEquals(0xFF.toByte(), paddedN[0], "First byte of padded N must be 0xFF") + assertEquals(0xFF.toByte(), paddedN[255], "Last byte of padded N must be 0xFF") + } + + test("Padding: Negative BigInteger representation handling") { + // Negative numbers in Java BigInteger: verify no ArrayIndexOutOfBoundsException or crashing + val negOne = BigInteger.valueOf(-1) + val paddedNegOne = PortalSrp.toPadded256(negOne) + assertEquals(256, paddedNegOne.size, "Padded negative BigInteger must be 256 bytes") + + val neg128 = BigInteger.valueOf(-128) + val paddedNeg128 = PortalSrp.toPadded256(neg128) + assertEquals(256, paddedNeg128.size) + + val negN = PortalSrp.N.negate() + val paddedNegN = PortalSrp.toPadded256(negN) + assertEquals(256, paddedNegN.size) + } + + test("Padding: Oversized BigInteger (> 256 bytes) extracts lowest 256 bytes") { + // 258-byte number (2064 bits) + val raw258 = ByteArray(258) { (it % 256).toByte() } + raw258[0] = 0x01.toByte() + val bi = BigInteger(1, raw258) + + val padded = PortalSrp.toPadded256(bi) + assertEquals(256, padded.size, "Output must be clamped to 256 bytes") + + // Verify it took the last 256 bytes + val expectedSuffix = ByteArray(256) + System.arraycopy(raw258, 2, expectedSuffix, 0, 256) + assertArrayEquals(expectedSuffix, padded, "Must extract lowest 256 bytes") + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRateLimitingTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRateLimitingTest.kt new file mode 100644 index 0000000..b4a98f2 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRateLimitingTest.kt @@ -0,0 +1,196 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.PortalSrpClient +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertFalse +import com.portaltv.capability.test.Assert.assertTrue +import java.math.BigInteger +import java.security.SecureRandom + +class PortalSrpRateLimitingTest : TestSuite("SRP-6a Rate Limiting & Session Invalidation") { + + init { + test("Rate limiting: Exact 3-attempt lifecycle enforcement (3 -> 2 -> 1 -> 0 -> cancelled)") { + val pin = "123456" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + val tlsCertSha256 = ByteArray(32) { 0x42 } + + assertEquals(3, pairing.attemptsLeft, "New pairing must start with exactly 3 attempts left") + + val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0x99.toByte() }) + + // Attempt 1: Failed + val res1 = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = bogusM1, + tlsHash = tlsCertSha256 + ) + assertTrue(res1 is PortalSrp.VerifyResult.Failed, "Attempt 1 with bogus M1 must fail") + val fail1 = res1 as PortalSrp.VerifyResult.Failed + assertEquals(2, fail1.attemptsLeft, "Attempt 1 failure must leave 2 attempts") + assertEquals(2, pairing.attemptsLeft, "Pairing object state must show 2 attempts left") + + // Attempt 2: Failed + val res2 = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = bogusM1, + tlsHash = tlsCertSha256 + ) + assertTrue(res2 is PortalSrp.VerifyResult.Failed, "Attempt 2 with bogus M1 must fail") + val fail2 = res2 as PortalSrp.VerifyResult.Failed + assertEquals(1, fail2.attemptsLeft, "Attempt 2 failure must leave 1 attempt") + assertEquals(1, pairing.attemptsLeft, "Pairing object state must show 1 attempt left") + + // Attempt 3: Failed -> 0 attempts left + val res3 = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = bogusM1, + tlsHash = tlsCertSha256 + ) + assertTrue(res3 is PortalSrp.VerifyResult.Failed, "Attempt 3 with bogus M1 must fail") + val fail3 = res3 as PortalSrp.VerifyResult.Failed + assertEquals(0, fail3.attemptsLeft, "Attempt 3 failure must leave 0 attempts") + assertEquals(0, pairing.attemptsLeft, "Pairing object state must show 0 attempts left") + + // Subsequent Attempt 4 on exhausted session: Must be blocked immediately + val res4 = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = bogusM1, + tlsHash = tlsCertSha256 + ) + assertTrue(res4 is PortalSrp.VerifyResult.Failed, "Attempt on exhausted session must fail") + val fail4 = res4 as PortalSrp.VerifyResult.Failed + assertEquals(0, fail4.attemptsLeft) + assertEquals("Too many failed attempts; pairing cancelled", fail4.message) + + // Even if correct credentials are now provided, exhausted pairing must remain cancelled + val validM1Hex = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = tlsCertSha256 + ) + val resValidOnExhausted = PortalSrp.verifyClient( + pairing = pairing, + A_hex = client.pubAHex, + M1_hex = validM1Hex, + tlsHash = tlsCertSha256 + ) + assertTrue( + resValidOnExhausted is PortalSrp.VerifyResult.Failed, + "Exhausted pairing must reject even valid credentials" + ) + assertEquals( + "Too many failed attempts; pairing cancelled", + (resValidOnExhausted as PortalSrp.VerifyResult.Failed).message + ) + } + + test("Rate limiting: Service-level session wipeout on 0 attempts remaining") { + // Simulates PortalStreamingService session lifecycle: + // When res.attemptsLeft <= 0, pairing is wiped (pairing = null). + // A subsequent request detects pairing == null -> rejected (session not found). + var activePairing: PortalSrp.ActivePairing? = PortalSrp.newPairing("654321") + val client = PortalSrpClient() + val tlsCertSha256 = ByteArray(32) { 0x11 } + val bogusM1 = PortalSrp.bytesToHex(ByteArray(32) { 0xEE.toByte() }) + + fun serviceVerify(A: String, M1: String): Pair { + val p = activePairing ?: return Pair(400, "{\"error\":\"no_active_pairing\",\"message\":\"Call /auth/srp/init first\"}") + val res = PortalSrp.verifyClient(p, A, M1, tlsCertSha256) + return when (res) { + is PortalSrp.VerifyResult.Success -> { + activePairing = null + Pair(200, "{\"token\":\"${res.token}\"}") + } + is PortalSrp.VerifyResult.Failed -> { + if (res.attemptsLeft <= 0) { + activePairing = null // Session wiped! + } + Pair(401, "{\"error\":\"authentication_failed\",\"attemptsLeft\":${res.attemptsLeft}}") + } + } + } + + // Attempt 1: 401, 2 attempts left + val (status1, body1) = serviceVerify(client.pubAHex, bogusM1) + assertEquals(401, status1) + assertTrue(body1.contains("\"attemptsLeft\":2")) + assertTrue(activePairing != null, "Session must still exist after attempt 1") + + // Attempt 2: 401, 1 attempt left + val (status2, body2) = serviceVerify(client.pubAHex, bogusM1) + assertEquals(401, status2) + assertTrue(body2.contains("\"attemptsLeft\":1")) + assertTrue(activePairing != null, "Session must still exist after attempt 2") + + // Attempt 3: 401, 0 attempts left, session wiped + val (status3, body3) = serviceVerify(client.pubAHex, bogusM1) + assertEquals(401, status3) + assertTrue(body3.contains("\"attemptsLeft\":0")) + assertTrue(activePairing == null, "Session must be wiped after 3 failed attempts") + + // Attempt 4: 400 session not found / no active pairing + val (status4, body4) = serviceVerify(client.pubAHex, bogusM1) + assertEquals(400, status4, "Subsequent attempt after wipeout must return 400") + assertTrue(body4.contains("no_active_pairing"), "Must report session not found") + } + + test("Rate limiting: Recovery on valid attempt after prior failed attempt") { + val pin = "345678" + val pairing = PortalSrp.newPairing(pin) + val client = PortalSrpClient() + val tlsCertSha256 = ByteArray(32) { 0x33 } + + // Failed attempt 1 + val bogusM1 = PortalSrp.bytesToHex(ByteArray(32)) + val res1 = PortalSrp.verifyClient(pairing, client.pubAHex, bogusM1, tlsCertSha256) + assertTrue(res1 is PortalSrp.VerifyResult.Failed) + assertEquals(2, (res1 as PortalSrp.VerifyResult.Failed).attemptsLeft) + + // Successful attempt 2 with correct PIN and M1 + val validM1 = client.computeM1( + saltHex = PortalSrp.bytesToHex(pairing.salt), + pubBHex = PortalSrp.bytesToHex(PortalSrp.toPadded256(pairing.pubB)), + pin = pin, + tlsCertSha256 = tlsCertSha256 + ) + val res2 = PortalSrp.verifyClient(pairing, client.pubAHex, validM1, tlsCertSha256) + assertTrue(res2 is PortalSrp.VerifyResult.Success, "Valid attempt 2 after 1 failure must succeed") + } + + test("Rate limiting: Session expiration blocks verification") { + val pin = "112233" + val expiredPairing = PortalSrp.ActivePairing( + id = "expired-session-id", + pin = pin, + salt = ByteArray(16), + v = BigInteger.valueOf(3), + privB = BigInteger.valueOf(4), + pubB = BigInteger.valueOf(5), + expiresAt = System.currentTimeMillis() - 5000L, // Expired 5 seconds ago + attemptsLeft = 3 + ) + val client = PortalSrpClient() + val dummyTls = ByteArray(32) + + val res = PortalSrp.verifyClient( + pairing = expiredPairing, + A_hex = client.pubAHex, + M1_hex = PortalSrp.bytesToHex(ByteArray(32)), + tlsHash = dummyTls + ) + + assertTrue(res is PortalSrp.VerifyResult.Failed) + val fail = res as PortalSrp.VerifyResult.Failed + assertEquals(0, fail.attemptsLeft) + assertEquals("Pairing session expired", fail.message) + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRfc5054Test.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRfc5054Test.kt new file mode 100644 index 0000000..8d3b6ce --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpRfc5054Test.kt @@ -0,0 +1,190 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertTrue +import java.math.BigInteger +import java.security.MessageDigest + +class PortalSrpRfc5054Test : TestSuite("RFC 5054 Appendix B Test Vectors") { + + companion object { + private fun cleanHex(s: String) = s.replace("\\s+".toRegex(), "").lowercase() + + // 1024-bit prime from RFC 5054 Appendix A + val N_HEX = cleanHex( + """ + EEAF0AB9 ADB38DD6 9C33F80A FA8FC5E8 60726187 75FF3C0B 9EA2314C + 9C256576 D674DF74 96EA81D3 383B4813 D692C6E0 E0D5D8E2 50B98BE4 + 8E495C1D 6089DAD1 5DC7D7B4 6154D6B6 CE8EF4AD 69B15D49 82559B29 + 7BCF1885 C529F566 660E57EC 68EDBC3C 05726CC0 2FD4CBF4 976EAA9A + FD5138FE 8376435B 9FC61D2F C0EB06E3 + """ + ) + val N = BigInteger(N_HEX, 16) + val g = BigInteger.valueOf(2) + + const val I = "alice" + const val P = "password123" + val SALT_HEX = cleanHex("BEB25379 D1A8581E B5A72767 3A2441EE") + + val K_EXPECTED = cleanHex("7556AA04 5AEF2CDD 07ABAF0F 665C3E81 8913186F") + val X_EXPECTED = cleanHex("94B7555A ABE9127C C58CCF49 93DB6CF8 4D16C124") + + val V_EXPECTED = cleanHex( + """ + 7E273DE8 696FFC4F 4E337D05 B4B375BE B0DDE156 9E8FA00A 9886D812 + 9BADA1F1 822223CA 1A605B53 0E379BA4 729FDC59 F105B478 7E5186F5 + C671085A 1447B52A 48CF1970 B4FB6F84 00BBF4CE BFBB1681 52E08AB5 + EA53D15C 1AFF87B2 B9DA6E04 E058AD51 CC72BFC9 033B564E 26480D78 + E955A5E2 9E7AB245 DB2BE315 E2099AFB + """ + ) + + val A_PRIV_HEX = cleanHex("60975527 035CF2AD 1989806F 0407210B C81EDC04 E2762A56 AFD529DD DA2D4393") + val B_PRIV_HEX = cleanHex("E487CB59 D31AC550 471E81F0 0F6928E0 1DDA08E9 74A004F4 9E61F5D1 05284D20") + + val A_PUB_EXPECTED = cleanHex( + """ + 61D5E490 F6F1B795 47B0704C 436F523D D0E560F0 C64115BB 72557EC4 + 4352E890 3211C046 92272D8B 2D1A5358 A2CF1B6E 0BFCF99F 921530EC + 8E393561 79EAE45E 42BA92AE ACED8251 71E1E8B9 AF6D9C03 E1327F44 + BE087EF0 6530E69F 66615261 EEF54073 CA11CF58 58F0EDFD FE15EFEA + B349EF5D 76988A36 72FAC47B 0769447B + """ + ) + + val B_PUB_EXPECTED = cleanHex( + """ + BD0C6151 2C692C0C B6D041FA 01BB152D 4916A1E7 7AF46AE1 05393011 + BAF38964 DC46A067 0DD125B9 5A981652 236F99D9 B681CBF8 7837EC99 + 6C6DA044 53728610 D0C6DDB5 8B318885 D7D82C7F 8DEB75CE 7BD4FBAA + 37089E6F 9C6059F3 88838E7A 00030B33 1EB76840 910440B1 B27AAEAE + EB4012B7 D7665238 A8E3FB00 4B117B58 + """ + ) + + val U_EXPECTED = cleanHex("CE38B959 3487DA98 554ED47D 70A7AE5F 462EF019") + + val S_EXPECTED = cleanHex( + """ + B0DC82BA BCF30674 AE450C02 87745E79 90A3381F 63B387AA F271A10D + 233861E3 59B48220 F7C4693C 9AE12B0A 6F67809F 0876E2D0 13800D6C + 41BB59B6 D5979B5C 00A172B4 A2A5903A 0BDCAF8A 709585EB 2AFAFA8F + 3499B200 210DCC1F 10EB3394 3CD67FC8 8A2F39A4 BE5BEC4E C0A3212D + C346D7E4 74B29EDE 8A469FFE CA686E5A + """ + ) + + fun sha1(vararg parts: ByteArray): ByteArray { + val md = MessageDigest.getInstance("SHA-1") + for (p in parts) md.update(p) + return md.digest() + } + + fun toPadded128(bi: BigInteger): ByteArray { + val raw = bi.toByteArray() + val result = ByteArray(128) + if (raw.size > 128) { + System.arraycopy(raw, raw.size - 128, result, 0, 128) + } else { + System.arraycopy(raw, 0, result, 128 - raw.size, raw.size) + } + return result + } + + fun bytesToHex(bytes: ByteArray): String = + bytes.joinToString("") { "%02x".format(it) } + + fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim() + val len = clean.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(clean[i], 16) shl 4) + Character.digit(clean[i + 1], 16)).toByte() + i += 2 + } + return data + } + } + + init { + test("RFC 5054 - Multiplier k = H(PAD(N) || PAD(g)) verification") { + val nBytes = toPadded128(N) + val gBytes = toPadded128(g) + val kBytes = sha1(nBytes, gBytes) + val kHex = bytesToHex(kBytes) + assertEquals(K_EXPECTED, kHex, "Multiplier k must match RFC 5054 test vector") + } + + test("RFC 5054 - Password hash x = H(s || H(I || ':' || P)) verification") { + val inner = sha1("$I:$P".toByteArray(Charsets.UTF_8)) + val salt = hexToBytes(SALT_HEX) + val xBytes = sha1(salt, inner) + val xHex = bytesToHex(xBytes) + assertEquals(X_EXPECTED, xHex, "Password hash x must match RFC 5054 test vector") + } + + test("RFC 5054 - Verifier v = g^x mod N verification") { + val x = BigInteger(X_EXPECTED, 16) + val v = g.modPow(x, N) + val vHex = bytesToHex(toPadded128(v)) + assertEquals(V_EXPECTED, vHex, "Verifier v must match RFC 5054 test vector") + } + + test("RFC 5054 - Client public key A = g^a mod N verification") { + val a = BigInteger(A_PRIV_HEX, 16) + val A = g.modPow(a, N) + val aHex = bytesToHex(toPadded128(A)) + assertEquals(A_PUB_EXPECTED, aHex, "Public key A must match RFC 5054 test vector") + } + + test("RFC 5054 - Server public key B = (k*v + g^b) mod N verification") { + val k = BigInteger(1, hexToBytes(K_EXPECTED)) + val v = BigInteger(1, hexToBytes(V_EXPECTED)) + val b = BigInteger(B_PRIV_HEX, 16) + val gb = g.modPow(b, N) + val B = k.multiply(v).add(gb).mod(N) + val bHex = bytesToHex(toPadded128(B)) + assertEquals(B_PUB_EXPECTED, bHex, "Public key B must match RFC 5054 test vector") + } + + test("RFC 5054 - Scrambler u = H(PAD(A) || PAD(B)) verification") { + val A = BigInteger(A_PUB_EXPECTED, 16) + val B = BigInteger(B_PUB_EXPECTED, 16) + val uBytes = sha1(toPadded128(A), toPadded128(B)) + val uHex = bytesToHex(uBytes) + assertEquals(U_EXPECTED, uHex, "Scrambler u must match RFC 5054 test vector") + } + + test("RFC 5054 - Premaster secret S client/server agreement and test vector match") { + val k = BigInteger(1, hexToBytes(K_EXPECTED)) + val v = BigInteger(1, hexToBytes(V_EXPECTED)) + val x = BigInteger(1, hexToBytes(X_EXPECTED)) + val a = BigInteger(A_PRIV_HEX, 16) + val b = BigInteger(B_PRIV_HEX, 16) + val A = BigInteger(A_PUB_EXPECTED, 16) + val B = BigInteger(B_PUB_EXPECTED, 16) + val u = BigInteger(1, hexToBytes(U_EXPECTED)) + + // Client S = (B - k * (g^x mod N) mod N) ^ (a + u * x) mod N + val gx = g.modPow(x, N) + val kgx = k.multiply(gx).mod(N) + val clientBase = B.subtract(kgx).mod(N) + val clientExp = a.add(u.multiply(x)) + val sClient = clientBase.modPow(clientExp, N) + + // Server S = (A * v^u mod N) ^ b mod N + val vu = v.modPow(u, N) + val serverBase = A.multiply(vu).mod(N) + val sServer = serverBase.modPow(b, N) + + val sClientHex = bytesToHex(toPadded128(sClient)) + val sServerHex = bytesToHex(toPadded128(sServer)) + + assertEquals(S_EXPECTED, sClientHex, "Client S must match RFC 5054 premaster secret") + assertEquals(S_EXPECTED, sServerHex, "Server S must match RFC 5054 premaster secret") + assertEquals(sClient, sServer, "Client and Server premaster secret S must be identical") + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/PortalSrpSafetyTest.kt b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpSafetyTest.kt new file mode 100644 index 0000000..902f066 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/PortalSrpSafetyTest.kt @@ -0,0 +1,146 @@ +package com.portaltv.capability.test + +import com.portaltv.capability.PortalSrp +import com.portaltv.capability.PortalSrpClient +import com.portaltv.capability.test.Assert.assertEquals +import com.portaltv.capability.test.Assert.assertFailsWith +import com.portaltv.capability.test.Assert.assertFalse +import com.portaltv.capability.test.Assert.assertTrue +import java.math.BigInteger + +class PortalSrpSafetyTest : TestSuite("SRP-6a Safety Checks (A mod N != 0, B mod N != 0, u != 0)") { + + init { + test("Safety: Reject A mod N == 0 in validator helper") { + assertFalse(PortalSrp.isValidPublicA(BigInteger.ZERO), "A = 0 must be rejected") + assertFalse(PortalSrp.isValidPublicA(PortalSrp.N), "A = N must be rejected (A mod N == 0)") + assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(2))), "A = 2N must be rejected") + assertFalse(PortalSrp.isValidPublicA(PortalSrp.N.multiply(BigInteger.valueOf(99))), "A = 99N must be rejected") + assertTrue(PortalSrp.isValidPublicA(BigInteger.valueOf(2)), "A = 2 is valid") + assertTrue(PortalSrp.isValidPublicA(PortalSrp.N.subtract(BigInteger.ONE)), "A = N-1 is valid") + } + + test("Safety: Server verifyClient rejects A = 0") { + val pairing = PortalSrp.newPairing("123456") + val dummyM1 = PortalSrp.bytesToHex(ByteArray(32)) + val dummyTls = ByteArray(32) + + val res = PortalSrp.verifyClient( + pairing = pairing, + A_hex = "00", + M1_hex = dummyM1, + tlsHash = dummyTls + ) + + assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 0") + val failed = res as PortalSrp.VerifyResult.Failed + assertEquals("Invalid public key A", failed.message) + assertEquals(2, failed.attemptsLeft, "Failed attempt must decrement attempts counter") + } + + test("Safety: Server verifyClient rejects A == N (A mod N == 0)") { + val pairing = PortalSrp.newPairing("123456") + val dummyM1 = PortalSrp.bytesToHex(ByteArray(32)) + val dummyTls = ByteArray(32) + val nHex = PortalSrp.N.toString(16) + + val res = PortalSrp.verifyClient( + pairing = pairing, + A_hex = nHex, + M1_hex = dummyM1, + tlsHash = dummyTls + ) + + assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = N") + val failed = res as PortalSrp.VerifyResult.Failed + assertEquals("Invalid public key A", failed.message) + assertEquals(2, failed.attemptsLeft) + } + + test("Safety: Server verifyClient rejects A == 2N (A mod N == 0)") { + val pairing = PortalSrp.newPairing("123456") + val dummyM1 = PortalSrp.bytesToHex(ByteArray(32)) + val dummyTls = ByteArray(32) + val twoNHex = PortalSrp.N.multiply(BigInteger.valueOf(2)).toString(16) + + val res = PortalSrp.verifyClient( + pairing = pairing, + A_hex = twoNHex, + M1_hex = dummyM1, + tlsHash = dummyTls + ) + + assertTrue(res is PortalSrp.VerifyResult.Failed, "Server must reject A = 2N") + val failed = res as PortalSrp.VerifyResult.Failed + assertEquals("Invalid public key A", failed.message) + } + + test("Safety: Server verifyClient rejects malformed or non-hex A") { + val pairing = PortalSrp.newPairing("123456") + val dummyM1 = PortalSrp.bytesToHex(ByteArray(32)) + val dummyTls = ByteArray(32) + + val res1 = PortalSrp.verifyClient(pairing, "not-valid-hex", dummyM1, dummyTls) + assertTrue(res1 is PortalSrp.VerifyResult.Failed) + assertEquals("Invalid client public key format", (res1 as PortalSrp.VerifyResult.Failed).message) + + val res2 = PortalSrp.verifyClient(pairing, "", dummyM1, dummyTls) + assertTrue(res2 is PortalSrp.VerifyResult.Failed) + assertEquals("Invalid client public key format", (res2 as PortalSrp.VerifyResult.Failed).message) + } + + test("Safety: Reject B mod N == 0 in validator helper") { + assertFalse(PortalSrp.isValidPublicB(BigInteger.ZERO), "B = 0 must be rejected") + assertFalse(PortalSrp.isValidPublicB(PortalSrp.N), "B = N must be rejected (B mod N == 0)") + assertFalse(PortalSrp.isValidPublicB(PortalSrp.N.multiply(BigInteger.valueOf(3))), "B = 3N must be rejected") + assertTrue(PortalSrp.isValidPublicB(BigInteger.valueOf(2)), "B = 2 is valid") + assertTrue(PortalSrp.isValidPublicB(PortalSrp.N.subtract(BigInteger.ONE)), "B = N-1 is valid") + } + + test("Safety: Client rejects server B == 0 (B mod N == 0)") { + val client = PortalSrpClient() + val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16)) + val dummyTls = ByteArray(32) + + val ex = assertFailsWith("Client must reject B = 0") { + client.computeM1( + saltHex = dummySaltHex, + pubBHex = "00", + pin = "123456", + tlsCertSha256 = dummyTls + ) + } + assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection") + } + + test("Safety: Client rejects server B == N (B mod N == 0)") { + val client = PortalSrpClient() + val dummySaltHex = PortalSrp.bytesToHex(ByteArray(16)) + val dummyTls = ByteArray(32) + val nHex = PortalSrp.N.toString(16) + + val ex = assertFailsWith("Client must reject B = N") { + client.computeM1( + saltHex = dummySaltHex, + pubBHex = nHex, + pin = "123456", + tlsCertSha256 = dummyTls + ) + } + assertTrue(ex.message!!.contains("B % N == 0"), "Exception message should explain safety rejection") + } + + test("Safety: Server newPairing always generates valid B mod N != 0") { + for (i in 0 until 20) { + val p = PortalSrp.newPairing("777888") + assertTrue(PortalSrp.isValidPublicB(p.pubB), "Server generated B must satisfy B mod N != 0") + } + } + + test("Safety: Reject u == 0 in validator helper") { + assertFalse(PortalSrp.isValidScrambler(BigInteger.ZERO), "u = 0 must be rejected") + assertTrue(PortalSrp.isValidScrambler(BigInteger.ONE), "u = 1 is valid") + assertTrue(PortalSrp.isValidScrambler(BigInteger.valueOf(42)), "u = 42 is valid") + } + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/TestFramework.kt b/portal-capability-test/test/com/portaltv/capability/test/TestFramework.kt new file mode 100644 index 0000000..3d8f948 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/TestFramework.kt @@ -0,0 +1,65 @@ +package com.portaltv.capability.test + +class AssertionException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +object Assert { + fun assertTrue(condition: Boolean, message: String = "Expected condition to be true") { + if (!condition) throw AssertionException(message) + } + + fun assertFalse(condition: Boolean, message: String = "Expected condition to be false") { + if (condition) throw AssertionException(message) + } + + fun assertEquals(expected: Any?, actual: Any?, message: String = "") { + if (expected != actual) { + val prefix = if (message.isNotEmpty()) "$message: " else "" + throw AssertionException("${prefix}Expected <$expected> but got <$actual>") + } + } + + fun assertNotEquals(unexpected: Any?, actual: Any?, message: String = "") { + if (unexpected == actual) { + val prefix = if (message.isNotEmpty()) "$message: " else "" + throw AssertionException("${prefix}Expected value to differ from <$unexpected>") + } + } + + fun assertArrayEquals(expected: ByteArray, actual: ByteArray, message: String = "") { + if (!expected.contentEquals(actual)) { + val prefix = if (message.isNotEmpty()) "$message: " else "" + val expHex = expected.joinToString("") { "%02x".format(it) } + val actHex = actual.joinToString("") { "%02x".format(it) } + throw AssertionException("${prefix}Byte arrays do not match.\nExpected: $expHex\nActual: $actHex") + } + } + + fun assertNotNull(actual: Any?, message: String = "Expected non-null value") { + if (actual == null) throw AssertionException(message) + } + + fun assertNull(actual: Any?, message: String = "Expected null value") { + if (actual != null) throw AssertionException("$message (was: $actual)") + } + + inline fun assertFailsWith(message: String = "", block: () -> Unit): T { + try { + block() + } catch (t: Throwable) { + if (t is T) return t + throw AssertionException("Expected exception ${T::class.java.simpleName} but caught ${t::class.java.simpleName}: ${t.message}", t) + } + val prefix = if (message.isNotEmpty()) "$message: " else "" + throw AssertionException("${prefix}Expected exception ${T::class.java.simpleName} was not thrown") + } +} + +data class TestCase(val name: String, val block: () -> Unit) + +abstract class TestSuite(val name: String) { + val cases = mutableListOf() + + fun test(name: String, block: () -> Unit) { + cases += TestCase(name, block) + } +} diff --git a/portal-capability-test/test/com/portaltv/capability/test/TestRunner.kt b/portal-capability-test/test/com/portaltv/capability/test/TestRunner.kt new file mode 100644 index 0000000..4525126 --- /dev/null +++ b/portal-capability-test/test/com/portaltv/capability/test/TestRunner.kt @@ -0,0 +1,80 @@ +package com.portaltv.capability.test + +fun main() { + val suites = listOf( + PortalSrpRfc5054Test(), + PortalSrpMathTest(), + PortalSrpSafetyTest(), + PortalSrpRateLimitingTest(), + PortalSrpChannelBindingTest(), + PortalSrpPaddingTest(), + PortalSrpConstantTimeTest(), + PortalSrpIntegrationTest() + ) + + val green = "\u001B[32m" + val red = "\u001B[31m" + val cyan = "\u001B[36m" + val yellow = "\u001B[33m" + val bold = "\u001B[1m" + val reset = "\u001B[0m" + + println("$bold============================================================$reset") + println("$bold$cyan Portal SRP-6a & Android Security Test Suite Runner$reset") + println("$bold============================================================$reset") + + var totalTests = 0 + var passedTests = 0 + var failedTests = 0 + val failures = mutableListOf>() + + val startTime = System.currentTimeMillis() + + for (suite in suites) { + println("\n$bold$yellow=== Suite: ${suite.name} ===$reset") + for (case in suite.cases) { + totalTests++ + val caseStart = System.currentTimeMillis() + try { + case.block() + val duration = System.currentTimeMillis() - caseStart + println(" $green[PASS]$reset ${case.name} (${duration}ms)") + passedTests++ + } catch (t: Throwable) { + val duration = System.currentTimeMillis() - caseStart + println(" $red[FAIL]$reset ${case.name} (${duration}ms)") + println(" $red-> ${t.message}$reset") + failures += Triple(suite.name, case.name, t) + failedTests++ + } + } + } + + val totalDuration = System.currentTimeMillis() - startTime + + println("\n$bold============================================================$reset") + println("$bold Test Execution Summary$reset") + println("$bold============================================================$reset") + println(" Total Suites: ${suites.size}") + println(" Total Tests: $totalTests") + println(" $green Passed: $passedTests$reset") + if (failedTests > 0) { + println(" $red Failed: $failedTests$reset") + } else { + println(" Failed: 0") + } + println(" Total Time: ${totalDuration}ms") + println("============================================================") + + if (failures.isNotEmpty()) { + println("\n$bold$red--- FAILURE DETAILS ---$reset") + for ((suiteName, caseName, error) in failures) { + println("\n$red[$suiteName] $caseName:$reset") + error.printStackTrace(System.out) + } + System.exit(1) + } else { + println("\n$bold$green*** ALL $passedTests SRP-6a TESTS PASSED WITH 0 ERRORS ***$reset\n") + System.exit(0) + } +} diff --git a/smart-camera-streamer/README.md b/smart-camera-streamer/README.md new file mode 100644 index 0000000..0dd59a5 --- /dev/null +++ b/smart-camera-streamer/README.md @@ -0,0 +1,13 @@ +# SmartCameraStreamer + +Small Kotlin/JVM-compatible core for the Portal streaming service. It owns independent video/audio subscriber counts, starts each encoded track on its first subscriber, stops it after the last disconnects, fans out one encoded packet stream to all subscribers, waits for an IDR for new video subscribers, and exposes `/control/mode` and `/control/fixed` endpoints. + +The host Android service supplies `EncodedTrack` implementations backed by one shared `MediaCodec` per track and a `SmartCameraController` adapter around the Portal Smart Camera binder API. + +Build the standalone library with: + +```sh +"/Applications/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc" \ + src/main/kotlin/com/portaltv/streamer/SmartCameraStreamer.kt \ + -d build/smart-camera-streamer.jar +``` diff --git a/smart-camera-streamer/src/main/kotlin/com/portaltv/streamer/SmartCameraStreamer.kt b/smart-camera-streamer/src/main/kotlin/com/portaltv/streamer/SmartCameraStreamer.kt new file mode 100644 index 0000000..94e829c --- /dev/null +++ b/smart-camera-streamer/src/main/kotlin/com/portaltv/streamer/SmartCameraStreamer.kt @@ -0,0 +1,91 @@ +package com.portaltv.streamer + +import java.io.Closeable +import java.io.OutputStream +import java.net.ServerSocket +import java.net.Socket +import java.net.URLDecoder +import java.util.concurrent.CopyOnWriteArraySet +import java.util.concurrent.LinkedBlockingDeque +import java.util.concurrent.atomic.AtomicInteger + +/** Transport-independent streaming core. Media capture is supplied by the host app. */ +class SmartCameraStreamer( + private val camera: SmartCameraController, + private val video: EncodedTrack, + private val audio: EncodedTrack, + private val controlPort: Int = 8080 +) : Closeable { + private val videoUsers = AtomicInteger() + private val audioUsers = AtomicInteger() + private var server: ServerSocket? = null + + fun start() { + if (server != null) return + server = ServerSocket(controlPort).also { socket -> + Thread({ + while (!socket.isClosed) runCatching { socket.accept().also { Thread { handle(it) }.start() } } + }, "smart-camera-http").apply { isDaemon = true; start() } + } + } + + fun publishVideo(packet: ByteArray, keyFrame: Boolean, ptsUs: Long) = video.publish(packet, keyFrame, ptsUs) + fun publishAudio(packet: ByteArray, ptsUs: Long) = audio.publish(packet, false, ptsUs) + + private fun handle(socket: Socket) { + socket.use { + val reader = it.getInputStream().bufferedReader() + val request = reader.readLine() ?: return + val path = request.split(' ').getOrNull(1) ?: return + while (reader.readLine()?.isNotEmpty() == true) Unit + when { + path.startsWith("/video.h264") -> stream(it, video, videoUsers, "video/h264", true) + path.startsWith("/audio.aac") -> stream(it, audio, audioUsers, "audio/aac", false) + path.startsWith("/control/") -> control(it, path) + else -> reply(it, 404, "not found") + } + } + } + + private fun stream(socket: Socket, track: EncodedTrack, users: AtomicInteger, type: String, key: Boolean) { + val out = socket.getOutputStream() + out.write("HTTP/1.1 200 OK\r\nContent-Type: $type\r\nCache-Control: no-store\r\nConnection: keep-alive\r\n\r\n".toByteArray()); out.flush() + val q = track.subscribe(key); if (users.incrementAndGet() == 1) track.start() + var waitingForKey = key + try { while (!socket.isClosed) q.take().also { packet -> if (!waitingForKey || packet.keyFrame) { waitingForKey = false; out.write(packet.data); out.flush() } } } + catch (_: Exception) { } + finally { track.unsubscribe(q); if (users.decrementAndGet() == 0) track.stop() } + } + + private fun control(socket: Socket, path: String) { + val query = path.substringAfter('?', "").split('&').mapNotNull { p -> p.split('=', limit = 2).takeIf { it.size == 2 }?.let { URLDecoder.decode(it[0], "UTF-8") to URLDecoder.decode(it[1], "UTF-8") } }.toMap() + val result = when { + path.startsWith("/control/mode") -> camera.setMode(query["mode"] ?: "") + path.startsWith("/control/fixed") -> camera.setFixed(query["x"]?.toFloatOrNull(), query["y"]?.toFloatOrNull(), query["scale"]?.toFloatOrNull()) + else -> "unknown control endpoint" + } + reply(socket, if (result.startsWith("ok")) 200 else 400, result) + } + + private fun reply(socket: Socket, code: Int, body: String) { val out = socket.getOutputStream(); val b = body.toByteArray(); out.write("HTTP/1.1 $code OK\r\nContent-Type: text/plain\r\nContent-Length: ${b.size}\r\nConnection: close\r\n\r\n".toByteArray()); out.write(b); out.flush() } + override fun close() { server?.close(); server = null; video.stop(); audio.stop() } +} + +interface SmartCameraController { fun setMode(mode: String): String; fun setFixed(x: Float?, y: Float?, scale: Float?): String } + +interface EncodedTrack { + data class Packet(val data: ByteArray, val keyFrame: Boolean, val ptsUs: Long) + fun subscribe(waitForKeyFrame: Boolean): LinkedBlockingDeque + fun unsubscribe(queue: LinkedBlockingDeque) + fun publish(data: ByteArray, keyFrame: Boolean, ptsUs: Long) + fun start(); fun stop() +} + +class FanoutTrack(private val capacity: Int = 24) : EncodedTrack { + private val clients = CopyOnWriteArraySet>() + override fun subscribe(waitForKeyFrame: Boolean) = LinkedBlockingDeque(capacity).also { clients += it } + override fun unsubscribe(queue: LinkedBlockingDeque) { clients -= queue } + override fun publish(data: ByteArray, keyFrame: Boolean, ptsUs: Long) { clients.forEach { if (!it.offer(EncodedTrack.Packet(data, keyFrame, ptsUs))) clients -= it } } + override fun start() {} + override fun stop() { clients.forEach { it.clear() } } +} diff --git a/tests/e2e_portal_test.py b/tests/e2e_portal_test.py new file mode 100644 index 0000000..3aefbcb --- /dev/null +++ b/tests/e2e_portal_test.py @@ -0,0 +1,747 @@ +#!/usr/bin/env python3 +""" +Live End-to-End Test Suite for Portal TV (10.0.0.10:5654) and portalkit-cli. + +Test Scenarios: + a) Status Verification: + - Runs portalkit-cli status 10.0.0.10:5654. + - Asserts service is online, leaf cert SHA-256 is present and matches genuine cert. + b) Direct Channel Binding Defense Verification: + - Runs portalkit-cli test-mitm 10.0.0.10:5654. + - Asserts server rejection ("wrong PIN or MITM detected") and verdict PASSED. + c) Live Network MITM Proxy Interception Test: + - Generates a rogue self-signed TLS certificate. + - Spawns an in-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888 + terminating client TLS with the rogue certificate and forwarding upstream to 10.0.0.10:5654. + - Client connects to the proxy at 127.0.0.1:8888. + - Extracts the rogue cert, initiates SRP handshake, computes M1 incorporating the rogue + cert hash, and submits it through the proxy. + - Asserts Portal TV rejects handshake with HTTP 401 ("Authentication failed (wrong PIN or MITM detected)"). + d) Pinned Certificate TLS Challenge Rejection (all MITM-protected endpoints): + - Client session pinned to Portal TV's genuine certificate. + - For every post-pairing pinned path (/control/*, /video.h264, /audio.aac, /control/events): + attempt through the rogue proxy must hard-fail at TLS (no HTTP bytes sent). + - portalkit-cli control against the rogue proxy must also abort before sending requests. + e) Live Rate-Limiting Enforcement: + - Initiates a pairing session on Portal TV. + - Sends 3 consecutive failed verification attempts. + - Asserts attempt counter decrements: 2 left -> 1 left -> 0 left. + - Asserts subsequent attempt is rejected due to session cancellation / wipeout. + f) Legitimate End-to-End Pairing & Camera Control: + - Initiates a pairing session on Portal TV. + - Retrieves active PIN using adb shell logcat. + - Executes portalkit-cli pair 10.0.0.10:5654 --pin . + - Asserts pairing success and certificate pinned. + - Executes portalkit-cli control 10.0.0.10:5654 mode Desk (expects {"ok":true}). + - Reads state via control … state; asserts mode Desk. + - Restores mode to DefaultAuto. +""" + +import hashlib +import json +import os +import re +import secrets +import socket +import ssl +import subprocess +import tempfile +import threading +import time +import unittest + +PORTAL_HOST = "10.0.0.10:5654" +PORTAL_IP = "10.0.0.10" +PORTAL_PORT = 5654 +PORTALKIT_CLI = "/Users/zim/Projects/portaltv/mac2/PortalKit/.build/release/portalkit-cli" +GENUINE_CERT_SHA256 = "314da0083dea8e80aa5f4194e51fb40ec27f7f40d39ea3ef8782f58ca79b3826" + +PROXY_HOST = "127.0.0.1" +PROXY_PORT = 8888 + +# Post-pairing endpoints that MUST hard-fail under a rogue TLS cert (pin enforced). +# Pairing (/auth/srp/*) intentionally accepts any leaf and relies on channel binding instead. +MITM_PROTECTED_PATHS = [ + "/control/state", + "/control/mode?mode=Desk", + "/control/fixed?x=0.5&y=0.5&scale=1.0", + "/control/desk?tightness=0.5", + "/control/events", + "/video.h264", + "/audio.aac", +] + +# CLI control verbs that map onto the same pinned HTTPS surface. +MITM_PROTECTED_CLI_COMMANDS = [ + ["state"], + ["mode", "Desk"], + ["fixed?x=0.5&y=0.5&scale=1.0"], + ["desk?tightness=0.5"], +] + +# RFC 5054 2048-bit prime group +N_HEX = ( + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74" + "020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F1437" + "4FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF05" + "98DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB" + "9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718" + "3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF" +) +N = int(N_HEX, 16) +g = 2 + + +def pad256(val: int) -> bytes: + return val.to_bytes(256, byteorder="big") + + +SRP_K = int.from_bytes(hashlib.sha256(pad256(N) + pad256(g)).digest(), byteorder="big") + + +def srp_compute_client_m1( + salt_bytes: bytes, + pub_b_int: int, + pin_str: str, + tls_cert_sha256_bytes: bytes, +): + """ + Computes SRP-6a client ephemeral key A and evidence M1 incorporating tls_cert_sha256. + Returns (pad256(A).hex(), M1.hex()). + """ + a = secrets.randbelow(N - 2) + 1 + A = pow(g, a, N) + u = int.from_bytes(hashlib.sha256(pad256(A) + pad256(pub_b_int)).digest(), "big") + x = int.from_bytes(hashlib.sha256(salt_bytes + pin_str.encode("utf-8")).digest(), "big") + gx = pow(g, x, N) + base = (pub_b_int - (SRP_K * gx) % N) % N + exp = a + u * x + S = pow(base, exp, N) + K = hashlib.sha256(pad256(S)).digest() + M1 = hashlib.sha256(pad256(A) + pad256(pub_b_int) + K + salt_bytes + tls_cert_sha256_bytes).digest() + return pad256(A).hex(), M1.hex() + + +def post_http_over_tls(host: str, port: int, path: str, payload_obj=None, timeout: float = 8.0): + """Direct HTTPS POST request helper using raw TLS socket.""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + raw_s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw_s.settimeout(timeout) + raw_s.connect((host, port)) + ssl_s = ctx.wrap_socket(raw_s) + + body_bytes = json.dumps(payload_obj).encode("utf-8") if payload_obj is not None else b"" + req_lines = [ + f"POST {path} HTTP/1.1", + f"Host: {host}:{port}", + "Content-Type: application/json" if payload_obj is not None else "Accept: application/json", + f"Content-Length: {len(body_bytes)}", + "Connection: close", + "", + "", + ] + req_header = "\r\n".join(req_lines).encode("utf-8") + ssl_s.sendall(req_header + body_bytes) + + resp_data = b"" + while True: + try: + chunk = ssl_s.recv(4096) + if not chunk: + break + resp_data += chunk + except socket.timeout: + break + ssl_s.close() + + header_part, body_part = resp_data.split(b"\r\n\r\n", 1) + status_line = header_part.split(b"\r\n")[0].decode("utf-8") + status_code = int(status_line.split()[1]) + try: + body_json = json.loads(body_part.decode("utf-8")) + except Exception: + body_json = {"raw": body_part.decode("utf-8", errors="replace")} + return status_code, body_json + + +def retrieve_active_pin() -> str: + """Retrieves active SRP PIN from Portal TV logcat.""" + cmd = 'adb shell "logcat -d -s PortalService | grep \'SRP pairing started with PIN:\' | tail -1"' + out = subprocess.check_output(cmd, shell=True, text=True).strip() + match = re.search(r"SRP pairing started with PIN:\s*(\d{6})", out) + if not match: + raise ValueError(f"Could not parse PIN from logcat output: '{out}'") + return match.group(1) + + +class CertificatePinningMismatchError(Exception): + """Raised when client TLS delegate rejects a server certificate mismatch.""" + pass + + +def pinned_https_get(host: str, port: int, path: str, pinned_sha256_hex: str, timeout: float = 5.0): + """ + Mimic PortalPinnedSessionDelegate: complete TLS, evaluate leaf pin, and only + then send HTTP. On mismatch, abort with no request bytes written. + """ + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + raw_sock.settimeout(timeout) + raw_sock.connect((host, port)) + ssl_sock = ctx.wrap_socket(raw_sock) + + peer_der = ssl_sock.getpeercert(binary_form=True) + peer_sha256 = hashlib.sha256(peer_der).hexdigest().lower() + + if peer_sha256 != pinned_sha256_hex.lower(): + ssl_sock.close() + raise CertificatePinningMismatchError( + f"PortalKit TLS Pinning Mismatch! Expected: {pinned_sha256_hex}, Got: {peer_sha256}" + ) + + req = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + f"Connection: close\r\n\r\n" + ).encode("utf-8") + ssl_sock.sendall(req) + data = ssl_sock.recv(4096) + ssl_sock.close() + return data + + +class LiveMitmProxy: + """ + In-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888. + Terminates client TLS with a rogue self-signed certificate and forwards + traffic upstream to 10.0.0.10:5654 over genuine TLS. + """ + + def __init__( + self, + listen_host: str = PROXY_HOST, + listen_port: int = PROXY_PORT, + upstream_host: str = PORTAL_IP, + upstream_port: int = PORTAL_PORT, + ): + self.listen_host = listen_host + self.listen_port = listen_port + self.upstream_host = upstream_host + self.upstream_port = upstream_port + + self.temp_dir = tempfile.TemporaryDirectory() + self.key_path = os.path.join(self.temp_dir.name, "rogue_mitm.key") + self.cert_path = os.path.join(self.temp_dir.name, "rogue_mitm.crt") + self._generate_rogue_cert() + + self.server_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + self.server_ctx.load_cert_chain(certfile=self.cert_path, keyfile=self.key_path) + + self.upstream_ctx = ssl.create_default_context() + self.upstream_ctx.check_hostname = False + self.upstream_ctx.verify_mode = ssl.CERT_NONE + + self.server_sock = None + self.thread = None + self.running = False + self.client_requests_received = [] + self.lock = threading.Lock() + + def _generate_rogue_cert(self): + """Generates a rogue self-signed RSA-2048 certificate.""" + cmd = [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + self.key_path, + "-out", + self.cert_path, + "-days", + "1", + "-nodes", + "-subj", + "/CN=RogueMitmInterceptor/O=Attacker", + ] + subprocess.run(cmd, check=True, capture_output=True) + + @property + def request_count(self) -> int: + with self.lock: + return len(self.client_requests_received) + + def clear_requests(self): + with self.lock: + self.client_requests_received.clear() + + def start(self): + self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.server_sock.bind((self.listen_host, self.listen_port)) + self.server_sock.listen(25) + self.running = True + self.thread = threading.Thread(target=self._accept_loop, daemon=True) + self.thread.start() + time.sleep(0.1) + + def stop(self): + self.running = False + if self.server_sock: + try: + self.server_sock.close() + except Exception: + pass + self.temp_dir.cleanup() + + def _accept_loop(self): + while self.running: + try: + client_raw, _ = self.server_sock.accept() + except Exception: + break + threading.Thread(target=self._handle_client, args=(client_raw,), daemon=True).start() + + def _handle_client(self, client_raw: socket.socket): + client_ssl = None + upstream_ssl = None + try: + client_ssl = self.server_ctx.wrap_socket(client_raw, server_side=True) + except Exception: + try: + client_raw.close() + except Exception: + pass + return + + try: + client_ssl.settimeout(6.0) + initial_data = client_ssl.recv(4096) + if not initial_data: + return + + with self.lock: + self.client_requests_received.append(initial_data) + + # Connect upstream to genuine Portal TV + upstream_raw = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + upstream_raw.settimeout(6.0) + upstream_ssl = self.upstream_ctx.wrap_socket(upstream_raw) + upstream_ssl.connect((self.upstream_host, self.upstream_port)) + upstream_ssl.sendall(initial_data) + + # Bidirectional forwarding + def pipe(src, dst): + try: + while True: + buf = src.recv(4096) + if not buf: + break + dst.sendall(buf) + except Exception: + pass + finally: + try: + dst.shutdown(socket.SHUT_WR) + except Exception: + pass + + t1 = threading.Thread(target=pipe, args=(client_ssl, upstream_ssl), daemon=True) + t2 = threading.Thread(target=pipe, args=(upstream_ssl, client_ssl), daemon=True) + t1.start() + t2.start() + t1.join() + t2.join() + except Exception: + pass + finally: + if client_ssl is not None: + try: + client_ssl.close() + except Exception: + pass + if upstream_ssl is not None: + try: + upstream_ssl.close() + except Exception: + pass + try: + client_raw.close() + except Exception: + pass + + +class TestPortalTVLiveE2E(unittest.TestCase): + """Automated Live End-to-End Test Suite against Portal TV.""" + + @classmethod + def setUpClass(cls): + self_check = subprocess.run([PORTALKIT_CLI, "--help"], capture_output=True, text=True) + if self_check.returncode != 0: + raise RuntimeError(f"portalkit-cli not executable at {PORTALKIT_CLI}") + + def test_01_status_verification(self): + """ + Scenario A: Status Verification + - Runs `portalkit-cli status 10.0.0.10:5654`. + - Asserts service is online, leaf cert SHA-256 is present. + """ + print("\n--- [Scenario A] Status Verification ---") + cmd = [PORTALKIT_CLI, "status", PORTAL_HOST] + proc = subprocess.run(cmd, capture_output=True, text=True) + print(proc.stdout) + + self.assertEqual(proc.returncode, 0, f"portalkit-cli status failed: {proc.stderr}") + self.assertIn("Service Online: YES", proc.stdout) + self.assertIn("Leaf Cert SHA-256:", proc.stdout) + + # Check leaf cert matches genuine cert + leaf_match = re.search(r"Leaf Cert SHA-256:\s+([a-f0-9]{64})", proc.stdout) + self.assertIsNotNone(leaf_match, "Leaf Cert SHA-256 hex string not found in status output") + presented_leaf = leaf_match.group(1).lower() + self.assertEqual( + presented_leaf, + GENUINE_CERT_SHA256.lower(), + f"Presented leaf cert {presented_leaf} does not match expected {GENUINE_CERT_SHA256}", + ) + print("✓ Service is online and genuine leaf cert SHA-256 verified.") + + def test_02_direct_channel_binding_defense_verification(self): + """ + Scenario B: Direct Channel Binding Defense Verification + - Runs `portalkit-cli test-mitm 10.0.0.10:5654`. + - Asserts server rejection ("wrong PIN or MITM detected") and verdict PASSED. + """ + print("\n--- [Scenario B] Direct Channel Binding Defense Verification ---") + cmd = [PORTALKIT_CLI, "test-mitm", PORTAL_HOST] + proc = subprocess.run(cmd, capture_output=True, text=True) + print(proc.stdout) + + self.assertEqual(proc.returncode, 0, f"portalkit-cli test-mitm failed: {proc.stderr}") + self.assertIn("wrong PIN or MITM detected", proc.stdout) + self.assertIn("Verdict: PASSED", proc.stdout) + print("✓ Direct channel binding defense successfully verified (verdict PASSED).") + + def test_03_live_network_mitm_proxy_interception(self): + """ + Scenario C: Live Network MITM Proxy Interception Test + - Generates a rogue self-signed TLS certificate. + - Spawns in-process multithreaded TLS reverse proxy listening on 127.0.0.1:8888 + terminating client TLS with rogue cert and forwarding upstream to 10.0.0.10:5654. + - Client connects to proxy at 127.0.0.1:8888. + - Extracts rogue cert, initiates SRP handshake, computes M1 incorporating rogue cert hash, + and submits through proxy. + - Asserts Portal TV rejects handshake with HTTP 401 ("Authentication failed (wrong PIN or MITM detected)"). + """ + print("\n--- [Scenario C] Live Network MITM Proxy Interception Test ---") + proxy = LiveMitmProxy(listen_host=PROXY_HOST, listen_port=PROXY_PORT) + proxy.start() + print(f"Rogue MITM proxy listening on {PROXY_HOST}:{PROXY_PORT}") + + try: + # 1. Connect client to proxy over TLS and extract rogue cert + client_ctx = ssl.create_default_context() + client_ctx.check_hostname = False + client_ctx.verify_mode = ssl.CERT_NONE + + s_init = client_ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) + s_init.connect((PROXY_HOST, PROXY_PORT)) + + rogue_cert_der = s_init.getpeercert(binary_form=True) + self.assertIsNotNone(rogue_cert_der, "Failed to capture peer certificate from proxy") + rogue_cert_sha256 = hashlib.sha256(rogue_cert_der).digest() + rogue_cert_hex = rogue_cert_sha256.hex() + print(f"Captured Rogue Cert SHA-256: {rogue_cert_hex}") + self.assertNotEqual( + rogue_cert_hex.lower(), + GENUINE_CERT_SHA256.lower(), + "Rogue cert must differ from genuine Portal TV certificate", + ) + + # 2. Initiate SRP pairing through the proxy + req_init = ( + f"POST /auth/srp/init HTTP/1.1\r\n" + f"Host: {PROXY_HOST}:{PROXY_PORT}\r\n" + f"Accept: application/json\r\n" + f"Connection: close\r\n\r\n" + ).encode("utf-8") + s_init.sendall(req_init) + + resp_init_data = b"" + while True: + chunk = s_init.recv(4096) + if not chunk: + break + resp_init_data += chunk + s_init.close() + + _, init_body = resp_init_data.split(b"\r\n\r\n", 1) + init_json = json.loads(init_body.decode("utf-8")) + pairing_id = init_json["pairingId"] + salt_bytes = bytes.fromhex(init_json["salt"]) + pub_b = int(init_json["B"], 16) + print(f"Pairing initiated through proxy: pairingId={pairing_id}") + + # 3. Retrieve PIN from device logcat + pin = retrieve_active_pin() + print(f"Active PIN retrieved from Portal TV: {pin}") + + # 4. Compute M1 bound to the ROGUE certificate + pub_a_hex, m1_hex = srp_compute_client_m1( + salt_bytes=salt_bytes, + pub_b_int=pub_b, + pin_str=pin, + tls_cert_sha256_bytes=rogue_cert_sha256, + ) + print(f"Computed M1 bound to rogue cert: {m1_hex[:16]}…") + + # 5. Submit M1 through the proxy to Portal TV + s_verify = client_ctx.wrap_socket(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) + s_verify.connect((PROXY_HOST, PROXY_PORT)) + + verify_payload = json.dumps({ + "pairingId": pairing_id, + "A": pub_a_hex, + "M1": m1_hex, + }).encode("utf-8") + + req_verify = ( + f"POST /auth/srp/verify HTTP/1.1\r\n" + f"Host: {PROXY_HOST}:{PROXY_PORT}\r\n" + f"Content-Type: application/json\r\n" + f"Content-Length: {len(verify_payload)}\r\n" + f"Connection: close\r\n\r\n" + ).encode("utf-8") + verify_payload + s_verify.sendall(req_verify) + + resp_verify_data = b"" + while True: + chunk = s_verify.recv(4096) + if not chunk: + break + resp_verify_data += chunk + s_verify.close() + + verify_headers, verify_body = resp_verify_data.split(b"\r\n\r\n", 1) + status_code = int(verify_headers.split(b"\r\n")[0].split()[1]) + verify_json = json.loads(verify_body.decode("utf-8")) + print(f"Server response through proxy: HTTP {status_code} {verify_json}") + + # 6. Assert rejection due to channel binding mismatch + self.assertEqual(status_code, 401, f"Expected HTTP 401, got {status_code}") + rejection_message = verify_json.get("message", "") + self.assertIn("wrong PIN or MITM detected", rejection_message) + print("✓ Live network MITM proxy successfully rejected with HTTP 401 (channel binding defense held).") + finally: + proxy.stop() + + def test_04_pinned_certificate_tls_challenge_rejection(self): + """ + Scenario D: every MITM-protected endpoint must hard-fail under a rogue cert. + + Soft failures (system warning / continue / leak HTTP) are not acceptable: + the client must abort at TLS pin check with zero request bytes observed by + the proxy for each protected path and for portalkit-cli control verbs. + """ + print("\n--- [Scenario D] Pinned Certificate TLS Challenge Rejection (all protected endpoints) ---") + proxy = LiveMitmProxy(listen_host=PROXY_HOST, listen_port=PROXY_PORT) + proxy.start() + print(f"Rogue MITM proxy listening on {PROXY_HOST}:{PROXY_PORT}") + print(f"Protected paths under test: {len(MITM_PROTECTED_PATHS)}") + + try: + for path in MITM_PROTECTED_PATHS: + with self.subTest(path=path): + proxy.clear_requests() + hard_fail = False + try: + pinned_https_get( + host=PROXY_HOST, + port=PROXY_PORT, + path=path, + pinned_sha256_hex=GENUINE_CERT_SHA256, + ) + except CertificatePinningMismatchError as e: + hard_fail = True + print(f" {path}: hard-fail pin mismatch — {e}") + + self.assertTrue( + hard_fail, + f"{path}: expected CertificatePinningMismatchError hard-fail; " + f"connection must not proceed past TLS (soft warning is insufficient)", + ) + self.assertEqual( + proxy.request_count, + 0, + f"{path}: HTTP bytes were sent despite pin mismatch " + f"({proxy.request_count} request(s) observed by rogue proxy)", + ) + + print("✓ All MITM-protected HTTP paths hard-failed at pin check with zero request leakage.") + + for cmd_parts in MITM_PROTECTED_CLI_COMMANDS: + with self.subTest(cli=" ".join(cmd_parts)): + proxy.clear_requests() + cli_res = subprocess.run( + [PORTALKIT_CLI, "control", f"{PROXY_HOST}:{PROXY_PORT}", *cmd_parts], + capture_output=True, + text=True, + ) + combined = (cli_res.stdout + "\n" + cli_res.stderr).lower() + print(f" portalkit-cli control {' '.join(cmd_parts)} → rc={cli_res.returncode}") + print(f" stdout: {cli_res.stdout.strip()}") + + self.assertNotEqual( + cli_res.returncode, + 0, + f"portalkit-cli control {' '.join(cmd_parts)} must exit non-zero on rogue proxy", + ) + # Must be a hard abort (cancel / fail / pin), not a soft warning with success. + self.assertTrue( + any( + token in combined + for token in ("cancelled", "cancel", "failed", "mismatch", "pinning", "error") + ), + f"portalkit-cli control {' '.join(cmd_parts)} did not report a hard failure: {combined!r}", + ) + self.assertEqual( + proxy.request_count, + 0, + f"portalkit-cli control {' '.join(cmd_parts)} leaked HTTP through rogue proxy " + f"({proxy.request_count} request(s))", + ) + + print("✓ portalkit-cli control verbs hard-failed against rogue proxy with zero request leakage.") + finally: + proxy.stop() + + def test_05_live_rate_limiting_enforcement(self): + """ + Scenario E: Live Rate-Limiting Enforcement + - Initiates a pairing session on Portal TV. + - Sends 3 consecutive failed verification attempts. + - Asserts attempt counter decrements: 2 left -> 1 left -> 0 left. + - Asserts subsequent attempt is rejected due to session cancellation / wipeout. + """ + print("\n--- [Scenario E] Live Rate-Limiting Enforcement ---") + + # Ensure a clean pairing state (exhaust any stale pairing session) + st_clean, init_clean = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init") + if st_clean == 200 and "pairingId" in init_clean: + pid = init_clean["pairingId"] + for _ in range(4): + st_v, _ = post_http_over_tls( + PORTAL_IP, + PORTAL_PORT, + "/auth/srp/verify", + {"pairingId": pid, "A": "01" * 256, "M1": "00" * 32}, + ) + if st_v == 400: + break + + # 1. Initiate fresh pairing session on Portal TV + st_init, init_data = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init") + self.assertEqual(st_init, 200, f"Failed to initiate pairing: {init_data}") + pairing_id = init_data["pairingId"] + print(f"Initiated pairing session: {pairing_id}") + + dummy_payload = { + "pairingId": pairing_id, + "A": "01" * 256, + "M1": "00" * 32, + } + + # 2. Attempt 1 (expect 2 left) + st1, r1 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload) + print(f"Attempt 1: HTTP {st1}, resp: {r1}") + self.assertEqual(st1, 401) + self.assertEqual(r1.get("attemptsLeft"), 2) + + # 3. Attempt 2 (expect 1 left) + st2, r2 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload) + print(f"Attempt 2: HTTP {st2}, resp: {r2}") + self.assertEqual(st2, 401) + self.assertEqual(r2.get("attemptsLeft"), 1) + + # 4. Attempt 3 (expect 0 left and session wiped) + st3, r3 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload) + print(f"Attempt 3: HTTP {st3}, resp: {r3}") + self.assertEqual(st3, 401) + self.assertEqual(r3.get("attemptsLeft"), 0) + + # 5. Subsequent Attempt 4 (expect rejected due to session cancellation / wipeout) + st4, r4 = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/verify", dummy_payload) + print(f"Attempt 4: HTTP {st4}, resp: {r4}") + self.assertIn(st4, [400, 401], f"Unexpected status code for wiped session: {st4}") + is_wiped = (r4.get("error") == "no_active_pairing") or ("Too many failed attempts" in r4.get("message", "")) + self.assertTrue(is_wiped, f"Subsequent attempt was not rejected due to wipeout: {r4}") + print("✓ Rate limiting verified: counter decremented 2 -> 1 -> 0 and session wiped out.") + + def test_06_legitimate_end_to_end_pairing_and_camera_control(self): + """ + Scenario F: Legitimate End-to-End Pairing & Camera Control + Mutations return {"ok":true}; live mode is read back via /control/state. + """ + print("\n--- [Scenario F] Legitimate End-to-End Pairing & Camera Control ---") + + # 1. Initiate pairing session on Portal TV + st_init, init_data = post_http_over_tls(PORTAL_IP, PORTAL_PORT, "/auth/srp/init") + self.assertEqual(st_init, 200, f"Init failed: {init_data}") + print(f"Pairing session initiated: pairingId={init_data.get('pairingId')}") + + # 2. Retrieve active PIN via adb logcat command + pin = retrieve_active_pin() + print(f"Active PIN retrieved from logcat: {pin}") + self.assertTrue(pin.isdigit() and len(pin) == 6, f"Invalid PIN format: {pin}") + + # 3. Execute portalkit-cli pair + pair_cmd = [PORTALKIT_CLI, "pair", PORTAL_HOST, "--pin", pin] + pair_proc = subprocess.run(pair_cmd, capture_output=True, text=True) + print(pair_proc.stdout) + self.assertEqual(pair_proc.returncode, 0, f"Pairing failed: {pair_proc.stderr}") + self.assertIn("Pairing successful!", pair_proc.stdout) + self.assertIn("TLS Certificate Pinned:", pair_proc.stdout) + self.assertIn(GENUINE_CERT_SHA256.lower(), pair_proc.stdout.lower()) + print("✓ Pairing successful and certificate pinned.") + + # 4. Execute portalkit-cli control mode Desk (ack only) + ctrl_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "mode", "Desk"] + ctrl_proc = subprocess.run(ctrl_cmd, capture_output=True, text=True) + print(ctrl_proc.stdout) + self.assertEqual(ctrl_proc.returncode, 0, f"Control mode Desk failed: {ctrl_proc.stderr}") + self.assertIn('"ok":true', ctrl_proc.stdout.replace(" ", "")) + print("✓ Control command 'mode Desk' succeeded (HTTP 200, ack).") + + # 5. Read back state + state_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "state"] + state_proc = subprocess.run(state_cmd, capture_output=True, text=True) + print(state_proc.stdout) + self.assertEqual(state_proc.returncode, 0, f"Control state failed: {state_proc.stderr}") + self.assertIn('"mode":"Desk"', state_proc.stdout) + print("✓ /control/state reports mode Desk.") + + # 6. Restore mode to DefaultAuto + restore_cmd = [PORTALKIT_CLI, "control", PORTAL_HOST, "mode", "DefaultAuto"] + restore_proc = subprocess.run(restore_cmd, capture_output=True, text=True) + print(restore_proc.stdout) + self.assertEqual(restore_proc.returncode, 0, f"Restore mode DefaultAuto failed: {restore_proc.stderr}") + self.assertIn('"ok":true', restore_proc.stdout.replace(" ", "")) + + restore_state = subprocess.run(state_cmd, capture_output=True, text=True) + print(restore_state.stdout) + self.assertEqual(restore_state.returncode, 0) + self.assertIn('"mode":"DefaultAuto"', restore_state.stdout) + print("✓ Mode restored to DefaultAuto (ack + state).") + + +if __name__ == "__main__": + unittest.main(verbosity=2)