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 requestsDefaultAuto,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 sourcesmartcamera/src/com/portaltv/smartcamera/— Kotlin client library (see below)AndroidManifest.xml— permissions and app declarationbuild-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:
- Transport Layer: HTTPS via
SSLServerSocketon port 5654 with forward-secret cipher suites (ECDHE-ECDSA-AES128-GCM-SHA256). - Device Identity: Self-signed ECDSA NIST P-256 (
secp256r1) certificate generated inside hardware-backedAndroidKeyStore. - Pairing & Mutual Authentication: RFC 5054 SRP-6a (2048-bit MODP group) with cryptographic TLS channel binding.
- 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
AndroidKeyStoretakes ~100 ms compared to 1.5–3 seconds for RSA. - Clean Platform Integration: Android Keymaster implements
SHA256withECDSAnatively, avoiding raw digest (NONEwithRSA) driver inconsistencies present on Android 9.
- Storage: Stored under alias
portalcam_tls_ec_p256inAndroidKeyStorewith 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
SSLServerSocketnatively negotiatesTLSv1.2.
- Android 9 Platform Context: Conscrypt on Android 9 (API 28) enables TLS 1.3 for client sockets, while server-mode
- Cipher Suite:
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256providing 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, generatorg = 2, multiplierk = \text{SHA256}(N \parallel g)). - PIN Entropy: 6-digit random decimal number (
10^6combinations). - Key Derivation:
x = \text{SHA256}(s \parallel \text{PIN}), with a 16-byte cryptographically secure random salts. - 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:
- Suppose an attacker places a rogue proxy between the Mac and the Portal TV.
- The proxy terminates TLS to the Mac using a rogue certificate
C_{\text{rogue}}, and opens a second TLS connection to the Portal TV usingC_{\text{portal}}. - The Mac computes
\text{tls\_hash}_{\text{mac}} = \text{SHA256}(C_{\text{rogue}}). - The Mac computes evidence
M_1incorporating\text{tls\_hash}_{\text{mac}}and sends it to the server. - The Portal TV verifies
M_1using its own certificate hash:\text{tls\_hash}_{\text{portal}} = \text{SHA256}(C_{\text{portal}}). - Because
C_{\text{rogue}} \neq C_{\text{portal}}, the hashes differ:\text{tls\_hash}_{\text{mac}} \neq \text{tls\_hash}_{\text{portal}}. - Verification fails immediately on the Portal TV. The server aborts the handshake, increments the failed attempt counter, and refuses to issue a bearer token.
- The attacker cannot forge a valid
M_1orM_2because computing either proof requires the shared session keyK, which requires knowledge of the PIN displayed exclusively on the physical TV screen.
Certificate Pinning & Key Storage
Once the client successfully verifies M_2:
- 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.portalcamand synchronized to the App GroupUserDefaults(ENT9X9U544.com.kovtash.portalcam) for sandboxed access by the CMIO camera extension.
- The 256-bit bearer token is stored under
- Strict Pinning Enforcement:
PortalPinnedSessionDelegateinterceptsURLAuthenticationChallengeon 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 againstPortalAuth.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
verifyattempts 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^6possible 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 pagehttps://<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 Mbpshttps://<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:
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):
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://:5654/video.h264 ffplay -nodisp -probesize 50k -analyzeduration 200ms -f aac http://:5654/audio.aac
Both at once, one window (live remux to MPEG-TS; wallclock timestamps keep the
muxer happy since raw H.264 carries no timestamps):
```sh
ffmpeg -use_wallclock_as_timestamps 1 -f h264 -i http://<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:
# 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.
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()
SmartCameraControllerowns one control session (the service only honors the top priority-queue connection), keeps death tokens alive, and rebinds on service death. State comes fromsubscribeModeChanges/subscribeFrameMetadata("crop").ModeControllersubclasses (auto,desk,meeting,fixed) exposeisActive(from the mode subscription) plus mode-specific params;fixedalso exposesappliedCropfrom the crop subscription.- Only DefaultAuto/Desk/Meeting/Fixed are whitelisted on the external API.
Build and install:
./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 aConnectionin a priority queue; only the top connection is enabled andsetModeon 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 itsIControlSession. - 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, 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 =ModeSettingIStreamingMetadataReceiver(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:
- 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 withIllegalArgumentException: Mode not supported. - The internal control service (
SMART_CAMERA_INTERNAL_CONTROL_SERVICE) has no whitelist — itssetModeaccepts every mode — but every method first callsSmartCameraIPCPermissionManager .enforceAccessSmartCameraControlPermission(), which requires the caller's package to declare thecom.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. (Validperson_ids would also require the internal world-metadata topicperson_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.capabilityflag seen inplatform_state_serviceoutput 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.