Init
This commit is contained in:
+106
@@ -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/
|
||||
+302
@@ -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 <host>`
|
||||
|
||||
Probe service reachability and leaf certificate fingerprint; compare against any
|
||||
stored pin.
|
||||
|
||||
```sh
|
||||
portalkit-cli status 10.0.0.10:5654
|
||||
```
|
||||
|
||||
#### `pair <host> [--pin <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 <host> <command>`
|
||||
|
||||
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 <host>`
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<CMIOExtensionProperty> {
|
||||
return [.deviceTransportType, .deviceModel]
|
||||
}
|
||||
|
||||
func deviceProperties(forProperties properties: Set<CMIOExtensionProperty>) 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<CMIOExtensionProperty> {
|
||||
return [.streamActiveFormatIndex, .streamFrameDuration]
|
||||
}
|
||||
|
||||
func streamProperties(forProperties properties: Set<CMIOExtensionProperty>) 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<CMIOExtensionProperty> {
|
||||
return [
|
||||
.streamActiveFormatIndex,
|
||||
.streamFrameDuration,
|
||||
.streamSinkBufferQueueSize,
|
||||
.streamSinkBuffersRequiredForStartup
|
||||
]
|
||||
}
|
||||
|
||||
func streamProperties(forProperties properties: Set<CMIOExtensionProperty>) 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<UInt32>.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<CSHeader>.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<CSHeader>.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<CSHeader>.size...]))
|
||||
|
||||
// 3. Check Team Identifier
|
||||
let CS_OPS_TEAMID: UInt32 = 14
|
||||
var teamHeader = CSHeader()
|
||||
let teamHeaderRet = csops(pid, CS_OPS_TEAMID, &teamHeader, MemoryLayout<CSHeader>.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<CSHeader>.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<CSHeader>.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<CMIOExtensionProperty> {
|
||||
return [.providerManufacturer]
|
||||
}
|
||||
|
||||
func providerProperties(forProperties properties: Set<CMIOExtensionProperty>) throws -> CMIOExtensionProviderProperties {
|
||||
let providerProperties = CMIOExtensionProviderProperties(dictionary: [:])
|
||||
if properties.contains(.providerManufacturer) {
|
||||
providerProperties.manufacturer = "PortalCam"
|
||||
}
|
||||
return providerProperties
|
||||
}
|
||||
|
||||
func setProviderProperties(_ providerProperties: CMIOExtensionProviderProperties) throws {
|
||||
// Settable properties
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CMIOExtension</key>
|
||||
<dict>
|
||||
<key>CMIOExtensionMachServiceName</key>
|
||||
<string>$(TeamIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)..<sc]
|
||||
decoder.decode(nal: Data(nal))
|
||||
pending.removeSubrange(0..<sc)
|
||||
}
|
||||
if pending.count > 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<H264Decoder>.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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
28396015305543F400E4C494 /* CamExtension */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
2839601F305543F400E4C494 /* Exceptions for "CamExtension" folder in "CamExtension" target */,
|
||||
);
|
||||
path = CamExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
28396003305543C000E4C494 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
28396002305543C000E4C494 /* PortalCam.app */,
|
||||
28396014305543F400E4C494 /* com.kovtash.portalcam.camera-extension.systemextension */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.developer.system-extension.install</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.system-extension.types</key>
|
||||
<array>
|
||||
<string>com.apple.system_extension.camera-device</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<PortalReceiverModel, Double>) -> Binding<Double> {
|
||||
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<Double>,
|
||||
range: ClosedRange<Double> = 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..<segmentCount {
|
||||
setWidth(width, forSegment: i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> 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<Void, Never>?
|
||||
private let fixedCropMinInterval: TimeInterval = 0.1
|
||||
private var stateEventsTask: Task<Void, Never>?
|
||||
|
||||
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[..<pct])
|
||||
}
|
||||
guard let colon = trimmed.lastIndex(of: ":"),
|
||||
trimmed[trimmed.index(after: colon)...].allSatisfy(\.isNumber) else {
|
||||
return trimmed
|
||||
}
|
||||
return String(trimmed[..<colon])
|
||||
}
|
||||
|
||||
func panelDidAppear() {
|
||||
isPanelVisible = true
|
||||
if phase == .main {
|
||||
browser.start()
|
||||
acquireMedia(.uiPreview)
|
||||
startCameraStateEvents()
|
||||
}
|
||||
}
|
||||
|
||||
func panelDidDisappear() {
|
||||
isPanelVisible = false
|
||||
if phase == .main {
|
||||
browser.stop()
|
||||
}
|
||||
media.release(.uiPreview)
|
||||
image = nil
|
||||
// Keep SSE alive while paired so detached preview / virtual cam stay in sync.
|
||||
}
|
||||
|
||||
private static func normalizedHost(_ value: String) -> String {
|
||||
var h = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let pct = h.firstIndex(of: "%") {
|
||||
h = String(h[..<pct])
|
||||
}
|
||||
if h.hasPrefix("[") && h.hasSuffix("]") {
|
||||
h = String(h.dropFirst().dropLast())
|
||||
}
|
||||
return h.lowercased()
|
||||
}
|
||||
|
||||
func beginPairing() {
|
||||
applyManualHost(manualHost)
|
||||
let bare = hostWithoutPort
|
||||
guard !bare.isEmpty else {
|
||||
status = "Select or enter a Portal address"
|
||||
return
|
||||
}
|
||||
host = "\(bare):\(PortalEndpoints.port)"
|
||||
isBusy = true
|
||||
status = "Requesting pairing…"
|
||||
pin = ""
|
||||
activePairingSession = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let session = try await client.initiatePairing()
|
||||
self.activePairingSession = session
|
||||
self.phase = .enterPin
|
||||
self.status = "Enter the PIN shown on Portal TV"
|
||||
self.browser.stop()
|
||||
} catch {
|
||||
self.status = "Connect failed: \(error.localizedDescription)"
|
||||
}
|
||||
self.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
func cancelPairing() {
|
||||
activePairingSession = nil
|
||||
pin = ""
|
||||
phase = .setup
|
||||
status = "Select or enter a Portal"
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func submitPin() {
|
||||
let cleanPin = pin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !cleanPin.isEmpty else {
|
||||
status = "Enter the PIN"
|
||||
return
|
||||
}
|
||||
guard let session = activePairingSession else {
|
||||
status = "Session expired — tap Connect again"
|
||||
phase = .setup
|
||||
browser.start()
|
||||
return
|
||||
}
|
||||
|
||||
isBusy = true
|
||||
status = "Verifying…"
|
||||
|
||||
Task {
|
||||
do {
|
||||
let result = try await client.completePairing(session: session, pin: cleanPin)
|
||||
UserDefaults.standard.set(self.host, forKey: "portalHost")
|
||||
UserDefaults(suiteName: PortalAuth.suite)?.set(self.host, forKey: "portalHost")
|
||||
self.activePairingSession = nil
|
||||
self.pin = ""
|
||||
self.unauthorizedHandled = false
|
||||
self.phase = .main
|
||||
self.status = "Paired (\(result.pinnedCertSha256Hex.prefix(8))…)"
|
||||
self.acquireMedia(.uiPreview)
|
||||
self.startCameraStateEvents()
|
||||
} catch {
|
||||
self.status = "Pairing failed: \(error.localizedDescription)"
|
||||
}
|
||||
self.isBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
func unpair() {
|
||||
stopCameraStateEvents()
|
||||
media.releaseAll()
|
||||
ExtensionSinkWriter.shared.stopSink()
|
||||
isVirtualCamActive = false
|
||||
PortalAuth.clear()
|
||||
activePairingSession = nil
|
||||
pin = ""
|
||||
image = nil
|
||||
isConnected = false
|
||||
unauthorizedHandled = false
|
||||
phase = .setup
|
||||
status = "Unpaired — select or enter a Portal"
|
||||
browser.start()
|
||||
}
|
||||
|
||||
func selectMode(_ mode: PortalCameraMode) {
|
||||
guard mode != selectedMode else { return }
|
||||
// Keep the segment on the clicked mode while the request is in flight;
|
||||
// otherwise updateNSView snaps it back to the previous selectedMode.
|
||||
selectedMode = mode
|
||||
runCameraControl { try await self.client.setMode(mode.rawValue) }
|
||||
}
|
||||
|
||||
/// Slider edits for Fixed crop — local UI updates immediately, network at ≤10 Hz.
|
||||
func setFixedCropParameter(
|
||||
_ keyPath: ReferenceWritableKeyPath<PortalReceiverModel, Double>,
|
||||
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())
|
||||
}
|
||||
@@ -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<CMIODeviceID>.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<CFString>.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<CMIOStreamID>.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<UInt32>.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<CMSimpleQueue>?
|
||||
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<ExtensionSinkWriter>.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<UInt32>.size)
|
||||
var used: UInt32 = 0
|
||||
let status = CMIOObjectGetPropertyData(dev, &addr, 0, nil, size, &used, &isRunning)
|
||||
return status == noErr && isRunning != 0
|
||||
}
|
||||
}
|
||||
@@ -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)..<data.startIndex.advanced(by: len))
|
||||
data.removeSubrange(data.startIndex..<data.startIndex.advanced(by: len))
|
||||
decode(packet)
|
||||
}
|
||||
// Cap buffer growth if sync is lost for a long stretch.
|
||||
if data.count > 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..<sc]
|
||||
decoder.decode(nal: Data(nal))
|
||||
pending.removeSubrange(0..<sc)
|
||||
}
|
||||
if pending.count > 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<H264Decoder>.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
|
||||
}
|
||||
}
|
||||
@@ -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<NWBrowser.Result>) {
|
||||
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[..<pct])
|
||||
}
|
||||
|
||||
private func serviceFallback(id: String, name: String, result: NWBrowser.Result) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.system-extension.install</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>$(TeamIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.kovtash.portalcam</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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<PortalMediaConsumer>()
|
||||
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<PortalMediaConsumer> {
|
||||
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?()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UInt64>`. (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<Int>) -> 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: RangeExpression>(_ 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(..<mid))
|
||||
}
|
||||
|
||||
/// Index of the digit at the middle of this integer.
|
||||
///
|
||||
/// - Returns: The index of the digit that is least significant in `self.high`.
|
||||
internal var middleIndex: Int {
|
||||
return (count + 1) / 2
|
||||
}
|
||||
|
||||
/// The low-order half of this BigUInt.
|
||||
///
|
||||
/// - Returns: `self[0 ..< middleIndex]`
|
||||
/// - Requires: count > 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Unit: FixedWidthInteger, Words: RandomAccessCollection>: 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<Unit: FixedWidthInteger>(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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UInt8>.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<UInt8>(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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?<T: BinaryFloatingPoint>(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<T: BinaryFloatingPoint>(_ 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?<T: BinaryFloatingPoint>(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<T: BinaryFloatingPoint>(_ 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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?<T: BinaryInteger>(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<T: BinaryInteger>(_ source: T) {
|
||||
precondition(source >= (0 as T), "BigUInt cannot represent negative values")
|
||||
self.init(exactly: source)!
|
||||
}
|
||||
|
||||
public init<T: BinaryInteger>(truncatingIfNeeded source: T) {
|
||||
self.init(words: source.words)
|
||||
}
|
||||
|
||||
public init<T: BinaryInteger>(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<T>(_ 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?<T>(exactly source: T) where T : BinaryInteger {
|
||||
self.init(source)
|
||||
}
|
||||
|
||||
public init<T>(clamping source: T) where T : BinaryInteger {
|
||||
self.init(source)
|
||||
}
|
||||
|
||||
public init<T>(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <a,b> * <c,d> = <ac, ac + bd - (a-b)(c-d), bd> (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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<RNG: RandomNumberGenerator>(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<RNG: RandomNumberGenerator>(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<RNG: RandomNumberGenerator>(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)
|
||||
}
|
||||
}
|
||||
@@ -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 >>=<Other: BinaryInteger>(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 <<=<Other: BinaryInteger>(lhs: inout BigUInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs >>= (0 - rhs)
|
||||
return
|
||||
}
|
||||
lhs.shiftLeft(by: Word(exactly: rhs)!)
|
||||
}
|
||||
|
||||
public static func >><Other: BinaryInteger>(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 <<<Other: BinaryInteger>(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 <<<Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
|
||||
guard rhs >= (0 as Other) else { return lhs >> (0 - rhs) }
|
||||
return lhs.shiftedLeft(by: Word(rhs))
|
||||
}
|
||||
|
||||
public static func <<=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs >>= (0 - rhs)
|
||||
}
|
||||
else {
|
||||
lhs.shiftLeft(by: Word(rhs))
|
||||
}
|
||||
}
|
||||
|
||||
public static func >><Other: BinaryInteger>(lhs: BigInt, rhs: Other) -> BigInt {
|
||||
guard rhs >= (0 as Other) else { return lhs << (0 - rhs) }
|
||||
return lhs.shiftedRight(by: Word(rhs))
|
||||
}
|
||||
|
||||
public static func >>=<Other: BinaryInteger>(lhs: inout BigInt, rhs: Other) {
|
||||
if rhs < (0 as Other) {
|
||||
lhs <<= (0 - rhs)
|
||||
}
|
||||
else {
|
||||
lhs.shiftRight(by: Word(rhs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?<S: StringProtocol>(_ 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?<S: StringProtocol>(_ 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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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: Sequence>(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<Int>
|
||||
|
||||
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<S: Sequence>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<PortalCameraState, Error> {
|
||||
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<PortalCameraState, Error>.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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, Error>) -> 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<Void, Error>) 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<Void, Error>) {
|
||||
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..<newline)
|
||||
buffer.removeSubrange(buffer.startIndex...newline)
|
||||
if lineData.last == UInt8(ascii: "\r") {
|
||||
lineData.removeLast()
|
||||
}
|
||||
let line = String(data: lineData, encoding: .utf8) ?? ""
|
||||
handleSSELine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSSELine(_ line: String) {
|
||||
if line.hasPrefix(":") {
|
||||
return
|
||||
}
|
||||
if line.hasPrefix("data:") {
|
||||
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
|
||||
pendingDataLines.append(String(payload))
|
||||
return
|
||||
}
|
||||
if line.isEmpty {
|
||||
guard !pendingDataLines.isEmpty else { return }
|
||||
let json = pendingDataLines.joined(separator: "\n")
|
||||
pendingDataLines.removeAll(keepingCapacity: true)
|
||||
guard let raw = json.data(using: .utf8),
|
||||
let state = try? JSONDecoder().decode(PortalCameraState.self, from: raw) else {
|
||||
return
|
||||
}
|
||||
lock.lock()
|
||||
let emit = onState
|
||||
lock.unlock()
|
||||
emit?(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// PortalSrpClient.swift
|
||||
// PortalKit
|
||||
//
|
||||
// SRP-6a (RFC 5054 2048-bit) client with cryptographic TLS channel binding.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
public enum SrpError: LocalizedError, Sendable {
|
||||
case invalidParameter(String)
|
||||
case verificationFailed(String)
|
||||
case serverRejected(String)
|
||||
case tlsCertificateMissing
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidParameter(let m): return "SRP Parameter Error: \(m)"
|
||||
case .verificationFailed(let m): return "SRP Verification Failed: \(m)"
|
||||
case .serverRejected(let m): return "Portal Error: \(m)"
|
||||
case .tlsCertificateMissing: return "TLS server certificate could not be retrieved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class PortalSrpClient: @unchecked Sendable {
|
||||
public static let group = SrpGroup.rfc5054_2048
|
||||
public static let N = group.N
|
||||
public static let g = group.g
|
||||
public static let k = group.k
|
||||
|
||||
private let a: BigUInt
|
||||
public let A: BigUInt
|
||||
|
||||
public private(set) var K: Data?
|
||||
public private(set) var M1: Data?
|
||||
public private(set) var tlsCertHash: Data?
|
||||
|
||||
/// Initializes client with either a randomly generated ephemeral private key `a`
|
||||
/// or a predetermined `a` (primarily for test vectors and deterministic testing).
|
||||
public init(a: BigUInt? = nil) {
|
||||
if let customA = a {
|
||||
self.a = customA
|
||||
self.A = Self.g.power(customA, modulus: Self.N)
|
||||
} else {
|
||||
var aBytes = [UInt8](repeating: 0, count: 32)
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, 32, &aBytes)
|
||||
let randVal = BigUInt(Data(aBytes))
|
||||
let privA = (randVal % (Self.N - 2)) + 1
|
||||
self.a = privA
|
||||
self.A = Self.g.power(privA, modulus: Self.N)
|
||||
}
|
||||
}
|
||||
|
||||
public var pubAHex: String {
|
||||
Self.bytesToHex(A.toPadded256Data())
|
||||
}
|
||||
|
||||
public var sessionKey: Data? {
|
||||
K
|
||||
}
|
||||
|
||||
public var clientM1: Data? {
|
||||
M1
|
||||
}
|
||||
|
||||
/// Compute M1 using server parameters, user PIN, and captured TLS certificate SHA-256 hash.
|
||||
@discardableResult
|
||||
public func computeM1(saltHex: String, pubBHex: String, pin: String, tlsCertSha256: Data) throws -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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..<nextIndex])
|
||||
guard let byte = UInt8(byteStr, radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public static func constantTimeEquals(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= (a[i] ^ b[i])
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
}
|
||||
|
||||
extension BigUInt {
|
||||
public func toPaddedData(byteCount: Int) -> 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
public struct PortalKitVersion {
|
||||
public static let version = "1.0.0"
|
||||
}
|
||||
@@ -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 <command> [options]
|
||||
|
||||
COMMANDS:
|
||||
pair <host> [--pin <pin>]
|
||||
Initiates SRP-6a pairing over ephemeral TLS, prompts for (or uses) PIN,
|
||||
verifies server M2 proof, and pins the TLS certificate.
|
||||
|
||||
status <host>
|
||||
Checks Portal TV service status, inspects the presented TLS leaf certificate,
|
||||
and verifies certificate pinning state.
|
||||
|
||||
control <host> <command>
|
||||
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 <DefaultAuto|Desk|Meeting|Fixed>
|
||||
fixed?x=<0-1>&y=<0-1>&scale=<0.1-1>
|
||||
desk?tightness=<0-1>
|
||||
|
||||
test-mitm <host>
|
||||
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 <pin> 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 <host> parameter.\nUsage: portalkit-cli pair <host> [--pin <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 <host> parameter.\nUsage: portalkit-cli status <host>")
|
||||
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 <host> parameter.\nUsage: portalkit-cli control <host> <command>")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let commandArgs = Array(args.dropFirst())
|
||||
guard !commandArgs.isEmpty else {
|
||||
print("Error: Missing <command> parameter.\nUsage: portalkit-cli control <host> <command>")
|
||||
print("Examples:\n control <host> mode Desk\n control <host> 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 <host> parameter.\nUsage: portalkit-cli test-mitm <host>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import XCTest
|
||||
@testable import PortalKit
|
||||
|
||||
final class SmokeTests: XCTestCase {
|
||||
func testVersion() {
|
||||
XCTAssertEqual(PortalKitVersion.version, "1.0.0")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://<portal-ip>: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://<portal-ip>: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
|
||||
```
|
||||
@@ -0,0 +1,32 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.portaltv.capability">
|
||||
<uses-sdk android:minSdkVersion="28" android:targetSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_CONTROL" />
|
||||
<uses-permission android:name="com.facebook.portal.permission.SMART_CAMERA_METADATA" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.READ_PORTAL_PACKAGE_POLICY" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.GLOBAL_OWNER_SELECTED" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.APP_FOUNDATION_SERVICE_LAUNCH" />
|
||||
<uses-permission android:name="com.facebook.aloha.permission.BIND_USER_SERVICE" />
|
||||
<application android:theme="@style/AppTheme" android:label="Portal Capability Test" android:largeHeap="true">
|
||||
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_CONTROL_SERVICE" android:value="" />
|
||||
<meta-data android:name="com.facebook.portal.smartcamera.fbpermission.ACCESS_SMART_CAMERA_METADATA_SERVICE" android:value="" />
|
||||
<activity android:name=".MainActivity" android:screenOrientation="landscape" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service android:name=".PortalStreamingService" android:exported="false" android:stopWithTask="false" />
|
||||
<receiver android:name=".PortalBootReceiver" android:enabled="true" android:exported="true">
|
||||
<intent-filter><action android:name="android.intent.action.BOOT_COMPLETED" /></intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -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 <token> |
|
||||
|==================================================|
|
||||
```
|
||||
|
||||
### 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 <token>` header obtained via the SRP pairing handshake:
|
||||
|
||||
- `https://<portal-ip>:5654/` — info page
|
||||
- `https://<portal-ip>:5654/stream.ts` — **MPEG-TS: H.264 + AAC, synced (recommended)**
|
||||
- `https://<portal-ip>:5654/video.h264` — raw H.264 Annex B, 720p30 @ ~2.5 Mbps
|
||||
- `https://<portal-ip>:5654/audio.aac` — AAC ADTS, 48 kHz mono @ 64 kbps
|
||||
|
||||
Camera control (Bearer auth). Success bodies are always current state
|
||||
`{"mode":"<Name>","config":{…}}`; failures are
|
||||
`{"error":"<code>","message":"<text>"}` 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 <token>" https://<portal-ip>:5654/stream.ts | ffplay -
|
||||
```
|
||||
|
||||
Watch directly with ffplay or mpv (accepting pinned cert or with bearer token):
|
||||
|
||||
```sh
|
||||
curl -k -H "Authorization: Bearer <token>" https://<portal-ip>:5654/video.h264 | \
|
||||
ffplay -framerate 30 -probesize 500k -analyzeduration 500ms -fflags nobuffer -flags low_delay -f h264 -
|
||||
```
|
||||
-f h264 http://<portal-ip>:5654/video.h264
|
||||
ffplay -nodisp -probesize 50k -analyzeduration 200ms -f aac http://<portal-ip>: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://<portal-ip>:5654/video.h264 \
|
||||
-use_wallclock_as_timestamps 1 -f aac -i http://<portal-ip>: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://<portal-ip>:5654/video.h264 -pix_fmt yuv420p -f v4l2 /dev/video2
|
||||
ffmpeg -f aac -i http://<portal-ip>: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_<name>` + 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<RectF>, 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<String> topics)` | topic names | `MetadataBundle` snapshot |
|
||||
| 6 | `subscribeFrameMetadata(IStreamingMetadataReceiver, List<String> 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.
|
||||
Executable
+60
@@ -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"
|
||||
Executable
+14
@@ -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 =="
|
||||
@@ -0,0 +1 @@
|
||||
<resources><style name="AppTheme" parent="android:style/Theme.Material.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:colorAccent">#80cbc4</item></style></resources>
|
||||
Executable
+43
@@ -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 "$@"
|
||||
@@ -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<Boolean> =
|
||||
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> = _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<CropConfig> = _crop.asStateFlow()
|
||||
|
||||
val appliedCrop: Flow<RectF?> =
|
||||
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) })
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
+389
@@ -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<CameraState> = _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<ControlSnapshot> = _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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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_<name>").
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<String,Button> 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<java.util.concurrent.BlockingQueue<VChunk>> 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<byte[]> 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<off+len;i++){ c^=(d[i]&0xFF)<<24; for(int b=0;b<8;b++) c=(c&0x80000000)!=0?(c<<1)^0x04C11DB7:c<<1; } return c; }
|
||||
byte[] patPacket(){ byte[] s={0x00,(byte)0xB0,0x0D,0x00,0x01,(byte)0xC1,0x00,0x00,0x00,0x01,(byte)0xF0,0x00}; return tablePacket(0,s); }
|
||||
byte[] pmtPacket(){ byte[] s={0x02,(byte)0xB0,0x17,0x00,0x01,(byte)0xC1,0x00,0x00,(byte)0xE1,0x01,(byte)0xF0,0x00,0x1B,(byte)0xE1,0x01,(byte)0xF0,0x00,0x0F,(byte)0xE1,0x02,(byte)0xF0,0x00}; return tablePacket(0x1000,s); }
|
||||
byte[] tablePacket(int pid,byte[] sec){ int crc=crcMpeg(sec,0,sec.length); byte[] full=new byte[sec.length+5]; full[0]=0; System.arraycopy(sec,0,full,1,sec.length); int n=sec.length+1; full[n++]=(byte)(crc>>24); full[n++]=(byte)(crc>>16); full[n++]=(byte)(crc>>8); full[n]=(byte)crc; java.util.List<byte[]> 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<Math.min(d.length,64);i++){ if(d[i]==0&&d[i+1]==0&&d[i+2]==1&&(d[i+3]&0x1F)==7) return true; if(d[i]==0&&d[i+1]==0&&d[i+2]==0&&d[i+3]==1&&(d[i+4]&0x1F)==7) return true; } return false; }
|
||||
void tsBroadcast(byte[] d){ synchronized(tsClients){ java.util.Iterator<java.util.concurrent.BlockingQueue<byte[]>> 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<byte[]> packetize(int pid,boolean isVideo,byte[] pes,long pcr90k){ java.util.List<byte[]> out=new java.util.ArrayList<>(); int off=0; boolean first=true; while(off<pes.length){ byte[] p=new byte[188]; p[0]=0x47; p[1]=(byte)((first?0x40:0)|((pid>>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<room; int afc=(pcr||stuff)?3:1; int cc=pid==0x101?ccV++&15:pid==0x102?ccA++&15:pid==0?ccPAT++&15:ccPMT++&15; p[3]=(byte)((afc<<4)|cc); int pos=4; if(afc==3){ int afLen=183-take; p[pos++]= (byte)afLen; p[pos++]=(byte)(pcr?0x10:0); if(pcr){ long b=pcr90k; p[pos++]=(byte)(b>>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="<html><body style='background:#111;color:#eee;font-family:monospace'><h3>Portal webcam</h3><b><a style='color:#8af' href='/stream.ts'>/stream.ts</a> (MPEG-TS: H.264 720p30 + AAC 48kHz mono, synced)</b><br>video: <a style='color:#8af' href='/video.h264'>/video.h264</a> (raw H.264 Annex B)<br>audio: <a style='color:#8af' href='/audio.aac'>/audio.aac</a> (raw AAC ADTS)<br><br>ffplay http://"+deviceIp()+":5654/stream.ts<br>mpv http://"+deviceIp()+":5654/stream.ts<br>vlc http://"+deviceIp()+":5654/stream.ts<br></body></html>"; 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<VChunk> 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<byte[]> 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<byte[]> 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<String> 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<String> 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<String,Button> 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<len)frameBuf=new byte[len]; bb.get(frameBuf,0,len); im.close(); android.graphics.Bitmap bmp=BitmapFactory.decodeByteArray(frameBuf,0,len); runOnUiThread(()->{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<VChunk> tsV=new java.util.concurrent.ArrayBlockingQueue<>(120);
|
||||
final java.util.concurrent.BlockingQueue<VChunk> tsA=new java.util.concurrent.ArrayBlockingQueue<>(256);
|
||||
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> tsClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
|
||||
int ccV,ccA,ccPAT,ccPMT; long vBase=-1,aBase=-1;
|
||||
final java.util.Set<java.util.concurrent.BlockingQueue<VChunk>> vClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<VChunk>>());
|
||||
final java.util.Set<java.util.concurrent.BlockingQueue<byte[]>> aClients=java.util.Collections.synchronizedSet(new java.util.HashSet<java.util.concurrent.BlockingQueue<byte[]>>());
|
||||
final java.util.concurrent.BlockingQueue<byte[]> 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<String> t=new ArrayList<String>(); 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();}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<State>(replay = 1, extraBufferCapacity = 16)
|
||||
/** Hot stream of camera state for SSE / coroutines. Replay=1 → new collectors get latest. */
|
||||
val states: SharedFlow<State> = _states.asSharedFlow()
|
||||
|
||||
private val listeners = CopyOnWriteArrayList<StateListener>()
|
||||
|
||||
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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, String>): 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<String, String>()
|
||||
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<String, String>) {
|
||||
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<PortalSmartCamera.State>(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<LinkedBlockingDeque<P>>()
|
||||
@Volatile private var config: ByteArray? = null
|
||||
fun add(k: Boolean) = LinkedBlockingDeque<P>(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<P>) = qs.contains(q)
|
||||
fun remove(q: LinkedBlockingDeque<P>) { 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<out Principal>?): Array<String>? = null
|
||||
override fun chooseClientAlias(keyType: Array<out String>?, issuers: Array<out Principal>?, socket: Socket?): String? = null
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<out Principal>?): Array<String> = arrayOf(ALIAS)
|
||||
override fun chooseServerAlias(keyType: String?, issuers: Array<out Principal>?, socket: Socket?): String = ALIAS
|
||||
override fun chooseEngineServerAlias(keyType: String?, issuers: Array<out Principal>?, engine: SSLEngine?): String = ALIAS
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? = 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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+171
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
val salts = mutableSetOf<String>()
|
||||
val pubBs = mutableSetOf<BigInteger>()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -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<Int, String> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalArgumentException>("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<IllegalArgumentException>("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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 <reified T : Throwable> 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<TestCase>()
|
||||
|
||||
fun test(name: String, block: () -> Unit) {
|
||||
cases += TestCase(name, block)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user