From 4a19b44e05d7cd33b2833e08d6142e22f46bc980 Mon Sep 17 00:00:00 2001 From: RioPlay Date: Sun, 13 Sep 2026 07:23:10 -0500 Subject: [PATCH 1/3] Add typed OBS pairing preparation requests --- docs/desktop-roadmap.md | 27 +- docs/plans/active/obs-audio-implementation.md | 13 +- docs/plans/active/obs-native-enrollment.md | 157 +++++++- docs/workspaces.md | 12 +- tests/test_obs_control.py | 2 +- tests/test_obs_control_enrollment.py | 369 ++++++++++++++++++ utterleaf/obs_control.py | 143 ++++++- 7 files changed, 703 insertions(+), 20 deletions(-) create mode 100644 tests/test_obs_control_enrollment.py diff --git a/docs/desktop-roadmap.md b/docs/desktop-roadmap.md index 712e8cfa..80aee639 100644 --- a/docs/desktop-roadmap.md +++ b/docs/desktop-roadmap.md @@ -129,12 +129,17 @@ independent source/evidence review is clear. Pairing-file UI and the vendor adapter remain to be implemented before any app entry point is exposed. PR #34 merged at `9260e6b` after all five [exact-source desktop CI jobs](https://github.com/RioPlay/utterleaf/actions/runs/34754090586) -passed at `d44126a`. The `feat/obs-pairing-store` increment now implements the +passed at `d44126a`. The `feat/obs-pairing-store` increment, merged through +PR #35 at `3bd072d` from exact source head `c7f86f6`, now implements the selected CurrentUser DPAPI package, separate native/desktop stores, native export, explicit desktop import/replacement and separate forget behavior. The first -local regression passes 292 tests, including 48 pairing cases, plus the native -store state/fault and five cross-language checks, including an actual junction. -Independent source/test and final receipt review is clear. +local regression passes 292 tests, including 48 pairing cases, plus 22 native +verification commands, 57 source/artifact hashes, five interop cases and five +store state/fault groups, including an actual junction. Exact-head CI +[34755748029](https://github.com/RioPlay/utterleaf/actions/runs/34755748029) +passes all five desktop jobs; release publication was skipped. Independent +source/test and final receipt review is clear. Three known warnings remain +isolated to the test heap shim. The enrollment plan distinguishes these storage results from pending UI, live revocation, vendor and audio integration. Cross-user/logon, remote clients, forced PID reuse and kernel @@ -145,6 +150,20 @@ binary is included in desktop or Android releases. The [control dependency record](desktop-obs-control-resource.md) records the pinned library, reviewed full license and development-wheel provenance. +Continue on `feat/obs-enrollment-flow` with the typed desktop preparation adapter, +visible native pairing Tools flow and exclusive per-user owner, followed by +vendor dispatch and the desktop controller. Arm/PCM and live +recognition remain later gates. No app entry point, audio endpoint, plugin binary +publication or OBS capture integration exists yet. + +The typed desktop adapter now sends only the defined Utterleaf Issue/Prepare +requests after explicit invocation, verifies challenge binding and the OBS peer +before proof, and rejects concurrent operations. The focused bundle passes +**341 tests with no skips**, including **66** enrollment tests and an ephemeral +loopback exchange. Independent source review is clear; native identity is stubbed +in that new exchange fixture. This prepares a session ID and does not connect +the audio pipe or arm capture. Native owner/UI/vendor integration remains open. + Current integrated desktop regression: **1,432 passed, 13 skipped in 39.47 seconds**, including the reviewed OBS control, native identity, pipe and audio handshake. The combined focused OBS/pipe/privacy/configuration/boundary/packaging bundle diff --git a/docs/plans/active/obs-audio-implementation.md b/docs/plans/active/obs-audio-implementation.md index ff524396..4a080edc 100644 --- a/docs/plans/active/obs-audio-implementation.md +++ b/docs/plans/active/obs-audio-implementation.md @@ -11,6 +11,12 @@ in source; its physical microphone and release gates remain separate. ## Current increment +The [enrollment plan](obs-native-enrollment.md) now records the integrated private +pairing stores and the typed desktop Issue/Prepare adapter on +`feat/obs-enrollment-flow`. Its focused regression passes 341 tests with no skips, +including 66 enrollment cases. Native owner, Tools and vendor dispatch are still +pending; no app entry point or capture activation is exposed. + The bounded audio protocol, consent/session receiver and read-only loopback WebSocket control client are reviewed. The Windows TCP peer identity gate is now implemented and integrated, as recorded below. The separate original C module now @@ -176,8 +182,11 @@ not a hard real-time scheduling guarantee. `obs_control.py` requires a password-protected Hello, computes OBS's documented challenge response and waits for RPC 1 identification before requesting version information. The response must advertise the required read-only requests and a -5.x WebSocket version. Only `GetVersion` and `GetStreamStatus` are allowed; there -is no arbitrary request passthrough. The General/Outputs subscription mask is 65. +5.x WebSocket version. That initial component allowed only `GetVersion` and +`GetStreamStatus`. The later enrollment adapter also permits the exact Utterleaf +Issue/Prepare schemas on explicit invocation, gated by advertised +`CallVendorRequest` support. There is no arbitrary request passthrough. The +General/Outputs subscription mask is 65. Only stream lifecycle events are retained, with a 32-event limit; shutdown closes the control connection. Unrelated event payloads, including recording paths, are discarded. Events arriving after Identify but before Identified stay quarantined diff --git a/docs/plans/active/obs-native-enrollment.md b/docs/plans/active/obs-native-enrollment.md index 21f777ae..314f95d2 100644 --- a/docs/plans/active/obs-native-enrollment.md +++ b/docs/plans/active/obs-native-enrollment.md @@ -2,9 +2,19 @@ September 13, 2026. The proof/admission increment merged through PR #34 at `9260e6b`; [exact-source desktop CI](https://github.com/RioPlay/utterleaf/actions/runs/34754090586) -passed all five required jobs at `d44126a`. Native and desktop source/evidence -review is clear for that increment. Continue the selected stores below on -`feat/obs-pairing-store`. No live OBS entry point exists. +passed all five required jobs at `d44126a`. The private-store increment merged +through PR #35 at exact source head `c7f86f6202ff9cd6acc647443b9d0ce768b6359b`, +with merge `3bd072deb7f03952f42d5516ff3c5818e1ab53e8` at +`2026-09-13T12:03:22Z`. Exact-head CI +[34755748029](https://github.com/RioPlay/utterleaf/actions/runs/34755748029) +passed all five desktop jobs; release publication was skipped. No live OBS +entry point exists. + +On `feat/obs-enrollment-flow`, the typed desktop preparation adapter below now +passes **341** focused tests, including **66** enrollment cases. Independent +source and final test review are clear after the documented failure-path additions. +The native owner, Tools flow and vendor registration remain next implementation +work. The module is still inert and no installed release changes here. ## Goal and area @@ -164,6 +174,137 @@ tests do not verify the later stores, app pairing flow, vendor JSON adapter, atomic Arm or actual OBS/audio integration. Storage evidence is recorded below. Existing desktop/Android binaries remain unchanged. +### Enrollment workflow integration contract + +Continue on `feat/obs-enrollment-flow`. The area includes native pairing Tools, +one per-user live owner, vendor dispatch, and the desktop control/setup flow. +Keep the five existing Settings destinations; eventual OBS setup opens from +Speech & privacy in its own window. No connection, import, preparation or +restart may arm capture. Android and published desktop binaries are unchanged. + +The first desktop adapter adds `ObsControl.prepare_session(key, +additional_mix_mask=0) -> bytes`: an explicit call generates its own fresh, +nonzero 16-byte session and uses the actual caller PID. Accept only a nonzero +32-byte `bytes`/`bytearray` capability and an exact integer mask from 0 through +63 (not `bool`). One control connection permits one preparation attempt. +The existing version/status request entry remains read-only; a separate narrow +adapter sends only these fixed requests under vendor name `Utterleaf`: + +| Request | Exact request data | Exact successful response data | +| --- | --- | --- | +| `IssueAuthorization` | `clientPid`: integer 5–4294967295; `sessionId`: 32 lowercase hex characters, nonzero; `additionalMixMask`: integer 0–63 | `ok`: true; `protocolVersion`: integer 1; `challenge`: 120 lowercase hex characters | +| `PrepareSession` | `challenge`: 120 lowercase hex characters; `proof`: 64 lowercase hex characters | `ok`: true; `protocolVersion`: integer 1 | + +The failure response is `{ "ok": false }`. Reject extra fields, incorrect types, +noncanonical hex, versions and echoes. The outer `CallVendorRequest` response +must contain exactly the matching `vendorName`, matching `requestType` and an +object `responseData`, in addition to the existing matching OBS request ID/type +and success-status checks. This envelope follows the pinned +[OBS protocol](https://github.com/obsproject/obs-websocket/blob/1ef34bf48110c2a18184e50e41cd0b1a855e2147/docs/generated/protocol.md#callvendorrequest). +Require `CallVendorRequest` in the advertised capabilities only when preparing; +status-only connections remain compatible without the plugin. + +After receiving the challenge, revalidate the authenticated native peer +immediately before calling the existing proof function, with no intervening +network operation. That function validates every challenge field against the +actual PID, fresh session and selected mask. Never put the capability or pipe +Hello secret into JSON. Clear the adapter's owned mutable key on every exit; +Python/OpenSSL erasure remains best-effort. Do not retain or log request secrets. +Malformed, refused, mismatched, cancelled, timed-out or uncertain operations +close the connection with no retry. A nonblocking reentrant operation lock +serializes complete prepare/status/event/peer-lease operations; a second thread +is rejected and closes the connection. Close itself remains able to interrupt I/O. + +Return only the client-created session ID after confirmed prepare success. +The existing pipe client derives its fixed name from this ID and independently +checks the server against the retained control-process lease; do not accept a +response-selected pipe path or PID. Preparing does not connect the pipe, confirm +handshake readiness, or establish an idle/armed state. A failed response may +leave a native admission until its bounded server expiry; do not claim rollback. + +Acceptance: focused control, proof, transport, pairing/privacy/boundary checks; +synthetic loopback request/response interoperability; independent review of the +contract, diff and results. Verify invalid local inputs before any request, +challenge binding and post-challenge identity failure before any proof is sent, +strict replies, event ordering, cancellation, concurrent-operation rejection, +fresh session generation, one attempt and no automatic capture or logging. +Use scoped `PYTHONPATH` and `python -m pytest tests/test_obs_control_enrollment.py +tests/test_obs_control.py tests/test_obs_authorization.py tests/test_obs_websocket.py +tests/test_obs_audio_pipe.py tests/test_privacy.py tests/test_repo_boundaries.py +-o addopts='' -q` from this worktree. Native ownership, callback shutdown, +pairing Tools, bounded admission expiry, vendor dispatch, visible setup, atomic +Arm and PCM still require their own integrated verification. Stop changing this +adapter when its checks and review pass; continue that required native/UI work. + +Implementation verification on September 13: the exact nine-file focused pytest +bundle (control enrollment, control, proof, WebSocket, audio pipe, pairing store, +Windows pipe, privacy and repository boundaries) passes **341 tests, no skips, +in 6.20 seconds**. The 66 enrollment cases include an actual ephemeral loopback +WebSocket exchange and independent literal-domain HMAC validation. That server +uses a stub for native process identity; it is not a running OBS instance. +Existing native identity/pipe checks retain their separately recorded scope. +Five real two-thread cases exercise overlap with preparation, status, event +polling, lease retention and close; both success and post-proof failure observe +the adapter's mutable key cleared. Schema/echo/binding failures, cancellation, +terminal uncertainty, one attempt, fresh sessions and no payload logging pass. +Independent source/test review found no source defect, requested the latter +failure/concurrency checks and cleared their final implementation. No full package or native rebuild +is warranted by this Python-only adapter; no app/UI entry is enabled yet. + +#### Selected native callback lifetime and ownership + +The public C frontend API has no Tools-item removal function. The pinned +[vendor dispatcher](https://github.com/obsproject/obs-websocket/blob/1ef34bf48110c2a18184e50e41cd0b1a855e2147/src/WebSocketApi.cpp) +copies a callback before releasing its mutex and invoking it; unregistering a +request therefore does not establish callback quiescence. Keep the original C +implementation and solve lifetime explicitly rather than freeing callback data +after unregister alone. + +Before registering any callback, pin the module with +[`GetModuleHandleExW(PIN | FROM_ADDRESS)`](https://learn.microsoft.com/en-us/windows/win32/api/libloaderapi/nf-libloaderapi-getmodulehandleexw), +using an address of static module data. Failure refuses load. Claim a never-reset +first-load latch and allow only one generation per OBS process; after shutdown +the gate must never reopen. Windows keeps a pinned module resident until process +termination. Plugin reload/update consequently requires restarting OBS. + +All callbacks receive static module state, never a heap-runtime pointer. That +state holds a permanently initialized SRW lock, a phase and the runtime pointer. +Callback entry checks the accepting phase while holding the lock before accessing +runtime state. A file picker releases the lock without retaining borrowed runtime +state, then reacquires and checks the phase before any commit. Return data is +copied locally before calling OBS response APIs outside the gate. Closed Tools +callbacks return; closed vendor callbacks return only `{ "ok": false }` without +accessing runtime, frontend, stores or logging. Pinning protects our code/data, +not OBS APIs after their own teardown. + +At `OBS_FRONTEND_EVENT_EXIT`, close the gate and detach runtime under the static +lock. This drains callbacks currently using runtime and rejects callbacks copied +earlier but invoked later. Outside all state locks, unregister requests, revoke +and cancel, join the owned worker, destroy authorizer/store, release the exclusive +owner and clear/free runtime. Do not remove the frontend callback from inside +itself. The [frontend contract](https://github.com/obsproject/obs-studio/blob/ba2f32bdf791005443988a4955e963663e16b1ed/docs/sphinx/reference-frontend-api.rst) +makes EXIT the final opportunity to call frontend APIs. Module unload is an +idempotent native-resource fallback; if EXIT was missed, it must not call +frontend or websocket unregister APIs whose teardown order is not established. +No joins, dialogs or OBS/frontend calls occur under the static/authorizer lock. + +The live owner holds a verified noninheritable share-zero private file handle +at `Utterleaf/obs-plugin/owner-v1.lock` beneath LocalAppData until teardown. +Validate path/locality/DACL through the existing store boundary; do not duplicate +a weaker path-only check. A second OBS process cannot install an authorizer or +dispatch privileged requests. A crash releases the handle; the inert lock file +may remain. Creating/replacing/forgetting pairing suspends dispatch and uses the +same owner. Forget always revokes the live generation, including deletion failure; +report nondurable deletion failure distinctly. + +These are selected implementation constraints, not verified native behavior. +Acceptance must simulate a copied callback after close, overlapping close and +dispatch, failed pin, second load, missing EXIT, in-flight worker cancellation, +and a second actual Windows process competing for ownership. A joinable worker +must also expire a prepared admission without depending on another incoming +request. Finalize that bounded handshake/READY lifetime before exposing Prepare; +the full Arm/PCM state machine remains required. + ### Private pairing stores: contract The selected export/import and persistence boundary lives in original native @@ -322,7 +463,11 @@ refuses to follow reparse entries and reports cleanup failures. Normal native translation units compile with warnings as errors; standalone native static analysis also passes. The existing test-only heap shim retains its three documented local-import warnings. Independent production-source and -test and final receipt review is clear. These results do not +test and final receipt review is clear. The local evidence records 22 native +commands, 57 source/artifact hashes, five interop cases and five store +state/fault groups; three known warnings are isolated to the test heap shim. +Exact receipts are `.grok/obs-pairing-store/verification/test-receipt.json` and +`.grok/obs-pairing-store/ci-34755748029/receipt.json`. These results do not verify different-user DPAPI behavior, power-loss durability, consumer pairing UI, real OBS dispatch, durable live-authorizer revocation or audio. No components are linked into the inert module and no binary is published by this increment. @@ -348,5 +493,5 @@ Do not expand into Android, custom speech models, theming, OBS routing changes or release packaging. Finish each reviewed component, then continue atomic idle-to-arm/start coordination and actual primary-mix/separate-bus PCM under the full [OBS design](obs-audio-design.md). The proof/admission component is integrated; -implement the selected enrollment stores and user flow before exposing vendor -handling. +the private stores are integrated through PR #35. Complete the selected native +ownership and user flow before exposing vendor handling. diff --git a/docs/workspaces.md b/docs/workspaces.md index a5ffe2e3..1a4ecdcd 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -4,10 +4,20 @@ September 18, 2026: merged topic branches were deleted after their commits reached `main`. Remaining checkouts are the mixed recovery snapshot, one Android tree, one desktop OBS tree, and published/unpublished release receipts. +## Streams (one owner, one PR at a time) + +| Stream | Checkout | Next PR | +| --- | --- | --- | +| Android keyboard | `android-keyboard-hardening` on `main` | Signed alpha18 candidate. Phone/TalkBack/landscape stay Ernest. No new keyboard features in this slice. | +| Desktop OBS | `desktop-obs-bridge` | Land draft stack from the base: 36 enrollment → 37 arm → 38 PCM → 39 disarm → 40 controller → 41 provenance → 42 timelines. Rebase each onto current `main`/parent before undrafting. | +| Android CI split | same Android tree, later | Follow-up only: stop downloading speech models and running the full emulator suite on every keyboard PR. | + +Do not mix these in one branch. Desktop CI already skips Android-only paths. + | Branch | Purpose | Next work | | --- | --- | --- | | `main` | Current Android keyboard checkout (`android-keyboard-hardening` worktree) | [Alpha17 polish](plans/active/android-alpha17-polish.md) is merged; signed candidate and phone acceptance remain | -| `feat/recorded-file-timelines` | Desktop OBS stack tip (`desktop-obs-bridge` worktree); draft PRs 36–42 | Packaged-decoder release acceptance and leftover real timeline edge cases | +| `feat/obs-enrollment-flow` | Desktop OBS stack base (`desktop-obs-bridge` worktree); draft [PR #36](https://github.com/RioPlay/utterleaf/pull/36) | Rebase onto current `main`, then pairing UI/vendor requests. Later stack PRs 37–42 stay parked. | | `checkpoint/mixed-work-20260912` | Preserved mixed development snapshot; not a release or PR | Recovery/reference only; leave the original source environment intact | | `release/desktop-0.4.6rc2` | Published Windows x64 CPU prerelease | Preserve the immutable RC2 tag and release evidence | | `release/desktop-0.4.6rc1` | Unpublished RC1 candidate retained for audit | Reference only; tag and downloaded artifact unchanged | diff --git a/tests/test_obs_control.py b/tests/test_obs_control.py index e4eafc6c..b5949405 100644 --- a/tests/test_obs_control.py +++ b/tests/test_obs_control.py @@ -938,7 +938,7 @@ def test_request_deadline_closes_connection_without_retry( ticks = iter([100.0, 106.0]) monkeypatch.setattr(obs_control.time, "monotonic", lambda: next(ticks)) - with pytest.raises(ObsControlError, match="status request timed out"): + with pytest.raises(ObsControlError, match="control request timed out"): control.stream_status() assert len(transport.sent) == sent_before + 1 diff --git a/tests/test_obs_control_enrollment.py b/tests/test_obs_control_enrollment.py new file mode 100644 index 00000000..92018449 --- /dev/null +++ b/tests/test_obs_control_enrollment.py @@ -0,0 +1,369 @@ +"""Focused wire and lifecycle checks for the typed OBS enrollment exchange.""" +from __future__ import annotations + +import hmac +import json +import logging +import os +import struct +import threading +import pytest + +from utterleaf import obs_authorization, obs_control +from utterleaf.obs_control import ObsControlError, ObsControlCancelled +from utterleaf.obs_websocket import ObsWebSocketError + +from test_obs_control import ( + connected_stub, + connect_control, + hello, + identified, + install_stub, + install_native_identity_stub, + loopback_server, + status_reply, + stream_event, + version_reply, + wire, + PASSWORD, + EXPECTED_AUTH, + VERSION_DATA, +) + + +KEY = bytearray(range(1, 33)) + + +def _proof(challenge): + return hmac.digest(KEY, b"Utterleaf OBS prepare authorization v1\0" + bytes.fromhex(challenge), "sha256").hex() +def _challenge(issue): + return struct.pack("<4sBBBBI16s32s", b"ULAA", 1, 1, 0, + issue["additionalMixMask"], issue["clientPid"], + bytes.fromhex(issue["sessionId"]), b"n" * 32).hex() + + +def _vendor_response(transport): + request = json.loads(transport.sent[-1]) + request_id = request["d"]["requestId"] + data = {"requestType": "CallVendorRequest", "requestId": request_id, + "requestStatus": {"result": True, "code": 100}, + "responseData": {"vendorName": "Utterleaf", + "requestType": "IssueAuthorization", + "responseData": { + "ok": True, "protocolVersion": 1, + "challenge": _challenge(request["d"]["requestData"]["requestData"]), + }}} + return wire(7, data) + + +def _prepare_response(transport): + request = json.loads(transport.sent[-1]) + request_id = request["d"]["requestId"] + return wire(7, {"requestType": "CallVendorRequest", "requestId": request_id, + "requestStatus": {"result": True, "code": 100}, + "responseData": {"vendorName": "Utterleaf", + "requestType": "PrepareSession", + "responseData": {"ok": True, + "protocolVersion": 1}}}) + + +def _connected(monkeypatch, responses, *, cancelled=lambda: False): + version = dict(VERSION_DATA) + version["availableRequests"] = [*VERSION_DATA["availableRequests"], "CallVendorRequest"] + transport, _ = install_stub(monkeypatch, [hello(), identified(), version_reply(version), *responses]) + control = connect_control("127.0.0.1", 4455, "pw", cancelled=cancelled) + return control, transport + + +def test_prepare_session_uses_typed_two_step_exchange_and_fresh_session(monkeypatch): + control, transport = _connected(monkeypatch, [_vendor_response, _prepare_response]) + try: + result = control.prepare_session(KEY) + calls = [json.loads(item)["d"] for item in transport.sent[2:]] + assert result == bytes.fromhex(calls[0]["requestData"]["requestData"]["sessionId"]) + assert [item["requestType"] for item in calls] == ["CallVendorRequest", "CallVendorRequest"] + first = calls[0]["requestData"] + assert set(first) == {"vendorName", "requestType", "requestData"} + assert first["vendorName"] == "Utterleaf" + assert first["requestType"] == "IssueAuthorization" + issue = first["requestData"] + assert issue["clientPid"] == os.getpid() + assert isinstance(issue["sessionId"], str) + assert len(issue["sessionId"]) == 32 + assert issue["sessionId"] == issue["sessionId"].lower() + assert int(issue["sessionId"], 16) != 0 + assert issue["additionalMixMask"] == 0 + second = calls[1]["requestData"] + assert set(second) == {"vendorName", "requestType", "requestData"} + assert second["vendorName"] == "Utterleaf" + assert second["requestType"] == "PrepareSession" + assert set(second["requestData"]) == {"challenge", "proof"} + assert len(second["requestData"]["challenge"]) == 120 + assert len(second["requestData"]["proof"]) == 64 + assert second["requestData"]["proof"] == _proof(second["requestData"]["challenge"]) + assert KEY == bytearray(range(1, 33)) + finally: + control.close() + + +def test_prepare_session_rejects_malformed_vendor_response_without_proof(monkeypatch): + def malformed(transport): + request = json.loads(transport.sent[-1]) + return wire(7, {"requestType": "CallVendorRequest", "requestId": request["d"]["requestId"], + "requestStatus": {"result": True, "code": 100}, + "responseData": {"vendorName": "Other", "requestType": "IssueAuthorization", + "responseData": {"ok": True, "protocolVersion": 1, + "challenge": "ab" * 60}}}) + control, transport = _connected(monkeypatch, [malformed]) + with pytest.raises(ObsControlError): + control.prepare_session(bytes(KEY), additional_mix_mask=1) + assert len(transport.sent) == 3 + assert transport.close_calls >= 1 + + +@pytest.mark.parametrize("key,mask", [ + (b"x", 0), (bytes(32), 0), ("x" * 32, 0), (memoryview(KEY), 0), + (bytes(KEY), -1), (bytes(KEY), 64), (bytes(KEY), True), (bytes(KEY), 1.0), +]) +def test_prepare_session_rejects_bad_arguments_before_network(monkeypatch, key, mask): + control, transport = _connected(monkeypatch, []) + try: + with pytest.raises(ObsControlError): + control.prepare_session(key, additional_mix_mask=mask) + assert len(transport.sent) == 2 + assert control.closed + finally: + control.close() + + +def test_prepare_session_peer_failure_sends_no_proof(monkeypatch): + def fail_peer(transport): + transport.verify_peer_error = ObsControlError("peer changed") + return _vendor_response(transport) + control, transport = _connected(monkeypatch, [fail_peer]) + with pytest.raises(ObsControlError): + control.prepare_session(bytes(KEY)) + assert len(transport.sent) == 3 + assert transport.close_calls >= 1 + + +def test_prepare_session_rejects_second_use_on_same_connection(monkeypatch): + control, transport = _connected(monkeypatch, [_vendor_response, _prepare_response]) + try: + control.prepare_session(bytes(KEY)) + with pytest.raises(ObsControlError): + control.prepare_session(bytes(KEY)) + assert len(transport.sent) == 4 + finally: + control.close() + + +def test_status_only_server_remains_usable_until_preparation_is_requested(monkeypatch): + control, transport, _ = connected_stub(monkeypatch) + transport.responses.append(status_reply()) + assert not control.stream_status().active + with pytest.raises(ObsControlError, match="does not support"): + control.prepare_session(KEY) + assert [json.loads(item)["d"].get("requestType") for item in transport.sent] == [ + None, "GetVersion", "GetStreamStatus", + ] + assert control.closed + + +@pytest.mark.parametrize("stage", ["issue", "prepare"]) +@pytest.mark.parametrize("path,value", [ + (("requestId",), "stale"), + (("requestType",), "GetStreamStatus"), + (("requestStatus", "result"), 1), + (("requestStatus", "code"), True), + (("responseData", "vendorName"), "utterleaf"), + (("responseData", "requestType"), "OtherOperation"), + (("responseData", "extra"), "unexpected"), + (("responseData", "responseData"), []), + (("responseData", "responseData"), {"ok": False}), + (("responseData", "responseData", "ok"), 1), + (("responseData", "responseData", "protocolVersion"), True), + (("responseData", "responseData", "protocolVersion"), 2), + (("responseData", "responseData", "extra"), "unexpected"), +]) +def test_vendor_response_contract_is_strict(monkeypatch, stage, path, value): + def malformed(transport): + message = json.loads((_vendor_response if stage == "issue" else _prepare_response)(transport)) + target = message["d"] + for field in path[:-1]: + target = target[field] + target[path[-1]] = value + return json.dumps(message) + control, transport = _connected(monkeypatch, [malformed] if stage == "issue" else [_vendor_response, malformed]) + with pytest.raises(ObsControlError): + control.prepare_session(KEY) + assert control.closed + assert len(transport.sent) == (3 if stage == "issue" else 4) + + +@pytest.mark.parametrize("offset", [0, 4, 5, 6, 7, 8, 12]) +def test_changed_challenge_binding_never_gets_a_proof(monkeypatch, offset): + def changed(transport): + message = json.loads(_vendor_response(transport)) + response = message["d"]["responseData"]["responseData"] + challenge = bytearray.fromhex(response["challenge"]) + challenge[offset] ^= 1 + response["challenge"] = challenge.hex() + return json.dumps(message) + control, transport = _connected(monkeypatch, [changed]) + with pytest.raises(ObsControlError): + control.prepare_session(KEY, additional_mix_mask=3) + assert control.closed and len(transport.sent) == 3 + + +@pytest.mark.parametrize("challenge", [None, 120, "a" * 118, "A" * 120, "gg" * 60, " " * 120]) +def test_challenge_hex_is_canonical(monkeypatch, challenge): + def changed(transport): + message = json.loads(_vendor_response(transport)) + message["d"]["responseData"]["responseData"]["challenge"] = challenge + return json.dumps(message) + control, transport = _connected(monkeypatch, [changed]) + with pytest.raises(ObsControlError): + control.prepare_session(KEY) + assert control.closed and len(transport.sent) == 3 + + +@pytest.mark.parametrize("fails", [False, True]) +def test_post_challenge_peer_check_precedes_signing_and_owned_key_is_cleared(monkeypatch, fails): + control, transport = _connected(monkeypatch, [ + _vendor_response, ObsWebSocketError("private detail") if fails else _prepare_response, + ]) + original = obs_authorization.create_prepare_proof + observed = [] + def checked(key, *args, **kwargs): + assert transport.operations[-1] == "verify_peer" + assert len(transport.sent) == 3 + assert key is not KEY and key == KEY + observed.append(key) + return original(key, *args, **kwargs) + monkeypatch.setattr(obs_authorization, "create_prepare_proof", checked) + with control: + if fails: + with pytest.raises(ObsControlError): + control.prepare_session(KEY) + else: + control.prepare_session(KEY) + assert observed == [bytearray(32)] + assert KEY == bytearray(range(1, 33)) + + +@pytest.mark.parametrize("stage", ["issue", "prepare"]) +@pytest.mark.parametrize("failure_type", [TimeoutError, ObsWebSocketError]) +def test_request_failure_is_terminal_and_not_retried(monkeypatch, stage, failure_type): + failure = failure_type("private remote detail") + control, transport = _connected(monkeypatch, [failure] if stage == "issue" else [_vendor_response, failure]) + with pytest.raises(ObsControlError) as caught: + control.prepare_session(KEY) + assert "private remote detail" not in str(caught.value) + sent = len(transport.sent) + assert sent == (3 if stage == "issue" else 4) + with pytest.raises(ObsControlError): + control.prepare_session(KEY) + assert len(transport.sent) == sent and control.closed + + +def test_cancellation_after_challenge_sends_no_proof(monkeypatch): + cancelled = threading.Event() + def cancel(transport): + response = _vendor_response(transport) + cancelled.set() + return response + control, transport = _connected(monkeypatch, [cancel], cancelled=cancelled.is_set) + with pytest.raises(ObsControlCancelled): + control.prepare_session(KEY) + assert control.closed and len(transport.sent) == 3 + + +@pytest.mark.parametrize("second", ["stream_status", "poll_event", "retain_peer_process", "prepare_session", "close"]) +def test_overlapping_operations_close_without_crossing_responses(monkeypatch, second): + entered, release = threading.Event(), threading.Event() + failures = [] + def blocked(transport): + entered.set() + assert release.wait(2) + return _vendor_response(transport) + control, transport = _connected(monkeypatch, [blocked]) + def prepare(): + try: + control.prepare_session(KEY) + except ObsControlError as exc: + failures.append(exc) + worker = threading.Thread(target=prepare) + worker.start() + try: + assert entered.wait(2) + if second == "close": + control.close() + else: + with pytest.raises(ObsControlError, match="serialized"): + getattr(control, second)(*([KEY] if second == "prepare_session" else [])) + finally: + release.set() + worker.join(2) + control.close() + assert not worker.is_alive() and len(failures) == 1 + assert control.closed and len(transport.sent) == 3 + + +def test_fresh_session_each_connection_and_events_preserve_order(monkeypatch): + sessions = [] + for _ in range(2): + control, transport = _connected(monkeypatch, [ + stream_event(False, "OBS_WEBSOCKET_OUTPUT_STARTING"), _vendor_response, + stream_event(True, "OBS_WEBSOCKET_OUTPUT_STARTED"), _prepare_response, + ]) + with control: + sessions.append(control.prepare_session(KEY, additional_mix_mask=63)) + assert not control.poll_event().active + assert control.poll_event().active + assert json.loads(transport.sent[2])["d"]["requestData"]["requestData"]["additionalMixMask"] == 63 + assert len(set(sessions)) == 2 + + +def test_real_loopback_prepare_matches_independent_proof_without_logging(monkeypatch, caplog): + install_native_identity_stub(monkeypatch) + caplog.set_level(logging.DEBUG) + seen = [] + def handler(connection): + def receive(): + message = json.loads(connection.recv(timeout=2)) + seen.append(message) + return message["d"] + def reply(request, data): + connection.send(wire(7, { + "requestType": request["requestType"], "requestId": request["requestId"], + "requestStatus": {"result": True, "code": 100}, "responseData": data, + })) + connection.send(hello()) + assert receive()["authentication"] == EXPECTED_AUTH + connection.send(identified()) + request = receive() + reply(request, {**VERSION_DATA, "availableRequests": [*VERSION_DATA["availableRequests"], "CallVendorRequest"]}) + request = receive() + assert request["requestType"] == "CallVendorRequest" + vendor = request["requestData"] + assert vendor["vendorName"] == "Utterleaf" and vendor["requestType"] == "IssueAuthorization" + issue = vendor["requestData"] + assert issue["clientPid"] == os.getpid() and issue["additionalMixMask"] == 5 + challenge = _challenge(issue) + reply(request, {"vendorName": "Utterleaf", "requestType": "IssueAuthorization", + "responseData": {"ok": True, "protocolVersion": 1, "challenge": challenge}}) + request = receive() + assert request["requestType"] == "CallVendorRequest" + assert request["requestData"] == { + "vendorName": "Utterleaf", "requestType": "PrepareSession", + "requestData": {"challenge": challenge, "proof": _proof(challenge)}, + } + reply(request, {"vendorName": "Utterleaf", "requestType": "PrepareSession", + "responseData": {"ok": True, "protocolVersion": 1}}) + with loopback_server(handler) as port: + with connect_control("127.0.0.1", port, PASSWORD, cancelled=lambda: False) as control: + session = control.prepare_session(KEY, additional_mix_mask=5) + assert session.hex() == seen[2]["d"]["requestData"]["requestData"]["sessionId"] + assert len(seen) == 4 and KEY.hex() not in json.dumps(seen) + assert not [record for record in caplog.records if record.name.startswith(("utterleaf", "websockets"))] diff --git a/utterleaf/obs_control.py b/utterleaf/obs_control.py index 88e6c4d0..ccfce504 100644 --- a/utterleaf/obs_control.py +++ b/utterleaf/obs_control.py @@ -1,4 +1,4 @@ -"""Explicit, read-only OBS WebSocket control connection. +"""Explicit OBS status and narrowly scoped Utterleaf preparation requests. This channel answers OBS's password challenge and reads stream lifecycle state. It does not authenticate the audio pipe, arm a receiver, capture audio, or change @@ -11,14 +11,17 @@ import base64 from collections import deque from dataclasses import dataclass +from functools import wraps import hashlib import json import math +import os import secrets +import threading import time from typing import Callable -from utterleaf import obs_websocket +from utterleaf import obs_authorization, obs_websocket MAX_MESSAGE = 65_536 MAX_PENDING_EVENTS = 32 @@ -27,6 +30,7 @@ GENERAL_SUBSCRIPTION = 1 EVENT_SUBSCRIPTIONS = GENERAL_SUBSCRIPTION | OUTPUTS_SUBSCRIPTION _READ_REQUESTS = frozenset({"GetVersion", "GetStreamStatus"}) +_VENDOR_NAME = "Utterleaf" _OUTPUT_STATES = frozenset({ "OBS_WEBSOCKET_OUTPUT_STARTING", "OBS_WEBSOCKET_OUTPUT_STARTED", "OBS_WEBSOCKET_OUTPUT_STOPPING", "OBS_WEBSOCKET_OUTPUT_STOPPED", @@ -124,6 +128,48 @@ def _authentication(password: str, salt, challenge) -> str: return base64.b64encode(hashlib.sha256(secret + challenge.encode("ascii")).digest()).decode("ascii") +def _serialized(method): + """Fail closed on overlapping workers; close itself must still interrupt I/O.""" + @wraps(method) + def operation(self, *args, **kwargs): + if not self._operation_lock.acquire(blocking=False): + self.close() + raise ObsControlError("OBS control operations must be serialized") + try: + return method(self, *args, **kwargs) + finally: + self._operation_lock.release() + return operation + + +def _hex_bytes(value, length: int) -> bytes: + if (type(value) is not str or len(value) != length * 2 + or any(char not in "0123456789abcdef" for char in value)): + raise ObsControlError("Invalid Utterleaf OBS message") + return bytes.fromhex(value) + + +def _vendor_payload(data) -> None: + """Validate the only two permitted request shapes before any JSON send.""" + if (type(data) is not dict or set(data) != {"vendorName", "requestType", "requestData"} + or data["vendorName"] != _VENDOR_NAME or type(data["requestData"]) is not dict): + raise ObsControlError("Unsupported OBS control request") + payload = data["requestData"] + if data["requestType"] == "IssueAuthorization": + if (set(payload) != {"clientPid", "sessionId", "additionalMixMask"} + or type(payload["clientPid"]) is not int or not 5 <= payload["clientPid"] <= 0xFFFFFFFF + or type(payload["additionalMixMask"]) is not int or not 0 <= payload["additionalMixMask"] <= 63 + or not any(_hex_bytes(payload["sessionId"], 16))): + raise ObsControlError("Unsupported OBS control request") + elif data["requestType"] == "PrepareSession": + if set(payload) != {"challenge", "proof"}: + raise ObsControlError("Unsupported OBS control request") + _hex_bytes(payload["challenge"], obs_authorization.CHALLENGE_BYTES) + _hex_bytes(payload["proof"], obs_authorization.PROOF_BYTES) + else: + raise ObsControlError("Unsupported OBS control request") + + class ObsControl: """A single authenticated control connection, without audio privileges. @@ -140,6 +186,9 @@ def __init__(self, transport, cancelled: Callable[[], bool]): self._request_id = 0 self._request_prefix = secrets.token_hex(16) self._identified = False + self._vendor_available = False + self._preparation_attempted = False + self._operation_lock = threading.RLock() self.closed = False self.version: ObsVersion | None = None @@ -185,6 +234,7 @@ def _check(self) -> None: if self.closed: raise ObsControlError("OBS control connection is closed") + @_serialized def retain_peer_process(self): """Retain an independently owned authenticated audio-process lease. @@ -278,13 +328,26 @@ def _event(self, data: dict) -> None: self._events.append(StreamEvent(event["outputActive"], event["outputState"], self._revision)) def _request(self, name: str) -> dict: + if name not in _READ_REQUESTS: + self._failed(ObsControlError("Unsupported OBS control request")) + return self._exchange(name) + + @_serialized + def _exchange(self, name: str, request_data: dict | None = None) -> dict: try: self._check() - if not self._identified or name not in _READ_REQUESTS: + if not self._identified: + raise ObsControlError("Unsupported OBS control request") + if name == "CallVendorRequest" and self._vendor_available: + _vendor_payload(request_data) + elif name not in _READ_REQUESTS or request_data is not None: raise ObsControlError("Unsupported OBS control request") self._request_id += 1 request_id = f"{self._request_prefix}-{self._request_id}" - self._send(6, {"requestType": name, "requestId": request_id}) + request = {"requestType": name, "requestId": request_id} + if request_data is not None: + request["requestData"] = request_data + self._send(6, request) deadline = time.monotonic() + REQUEST_TIMEOUT while True: opcode, data = self._receive(deadline) @@ -297,7 +360,7 @@ def _request(self, name: str) -> dict: if (type(status) is not dict or status.get("result") is not True or type(status.get("code")) is not int or status["code"] != 100 or type(data.get("responseData")) is not dict): - raise ObsControlError("OBS could not provide the requested status") + raise ObsControlError("OBS could not complete the requested operation") return data["responseData"] except BaseException as exc: self._failed(exc) @@ -314,7 +377,73 @@ def _read_version(self) -> None: if not websocket_version.startswith("5."): raise ObsControlError("Unsupported OBS WebSocket version") self.version = ObsVersion(_version(data.get("obsVersion")), websocket_version) + self._vendor_available = "CallVendorRequest" in requests + + def _vendor_request(self, operation: str, payload: dict) -> dict: + data = self._exchange("CallVendorRequest", { + "vendorName": _VENDOR_NAME, "requestType": operation, "requestData": payload, + }) + if (set(data) != {"vendorName", "requestType", "responseData"} + or data["vendorName"] != _VENDOR_NAME or data["requestType"] != operation + or type(data["responseData"]) is not dict): + raise ObsControlError("Invalid Utterleaf OBS response") + response = data["responseData"] + expected = {"ok", "protocolVersion", "challenge"} if operation == "IssueAuthorization" else {"ok", "protocolVersion"} + if response == {"ok": False} and type(response["ok"]) is bool: + raise ObsControlError("OBS pairing request was refused") + if (set(response) != expected or response["ok"] is not True + or type(response["protocolVersion"]) is not int or response["protocolVersion"] != 1): + raise ObsControlError("Invalid Utterleaf OBS response") + return response + + @_serialized + def prepare_session(self, key: bytes | bytearray, *, additional_mix_mask: int = 0) -> bytes: + """Prepare one fresh session; this does not open the pipe or arm capture. + + The caller owns the capability. Our mutable copy is cleared on every + exit, but Python/OpenSSL cannot guarantee erasure of all secret copies. + A lost response may leave server admission pending until its expiry; + never retry this connection or treat failure as a confirmed rollback. + """ + owned_key = bytearray() + try: + self._check() + if (type(key) not in (bytes, bytearray) or len(key) != 32 or not any(key) + or type(additional_mix_mask) is not int or not 0 <= additional_mix_mask <= 63): + raise ObsControlError("Invalid local OBS pairing request") + if not self._identified or self.version is None or not self._vendor_available: + raise ObsControlError("OBS does not support Utterleaf pairing requests") + if self._preparation_attempted: + raise ObsControlError("This OBS connection has already attempted preparation") + client_pid = os.getpid() + session_id = secrets.token_bytes(16) + if (type(client_pid) is not int or not 5 <= client_pid <= 0xFFFFFFFF + or type(session_id) is not bytes or len(session_id) != 16 or not any(session_id)): + raise ObsControlError("Could not create a fresh OBS session") + owned_key = bytearray(key) + key = b"" + self._preparation_attempted = True + response = self._vendor_request("IssueAuthorization", { + "clientPid": client_pid, "sessionId": session_id.hex(), + "additionalMixMask": additional_mix_mask, + }) + challenge = _hex_bytes(response["challenge"], obs_authorization.CHALLENGE_BYTES) + self._check() + self._transport.verify_peer() + self._check() + proof = obs_authorization.create_prepare_proof( + owned_key, challenge, client_pid=client_pid, session_id=session_id, + additional_mix_mask=additional_mix_mask, + ) + self._vendor_request("PrepareSession", {"challenge": challenge.hex(), "proof": proof.hex()}) + self._check() + return session_id + except BaseException as exc: + self._failed(exc) + finally: + owned_key[:] = b"\0" * len(owned_key) + @_serialized def stream_status(self) -> StreamSnapshot: """Read status without arming. Pending events make idle snapshots stale. @@ -329,6 +458,7 @@ def stream_status(self) -> StreamSnapshot: except BaseException as exc: self._failed(exc) + @_serialized def poll_event(self, timeout: float = 0.1) -> StreamEvent | None: """Return one stream event, preserving arrival order; never auto-capture.""" if type(timeout) not in (float, int) or not 0 < timeout <= REQUEST_TIMEOUT: @@ -354,7 +484,7 @@ def _failed(self, exc: BaseException): if isinstance(exc, ObsControlError): raise exc from None if isinstance(exc, TimeoutError): - raise ObsControlError("OBS status request timed out") from None + raise ObsControlError("OBS control request timed out") from None if isinstance(exc, Exception): raise ObsControlError("OBS control connection ended unexpectedly") from None raise exc @@ -364,6 +494,7 @@ def close(self) -> None: return self.closed = True self._identified = False + self._vendor_available = False self._events.clear() self._transport.close() From 2ae947feab8eeb32843a491a69e09818af49387b Mon Sep 17 00:00:00 2001 From: RioPlay Date: Sun, 13 Sep 2026 08:06:12 -0500 Subject: [PATCH 2/3] Link native OBS pairing and guarded vendor lifecycle --- docs/desktop-roadmap.md | 38 +- docs/plans/active/obs-native-enrollment.md | 75 +- docs/plans/active/obs-plugin-build.md | 13 +- native/obs-plugin/README.md | 95 +- native/obs-plugin/dependencies.json | 13 + native/obs-plugin/src/bridge.c | 79 +- native/obs-plugin/src/bridge.def | 1 + native/obs-plugin/src/bridge.manifest | 9 + native/obs-plugin/src/bridge.rc | 3 + native/obs-plugin/src/pairing_store.c | 66 ++ native/obs-plugin/src/pairing_store.h | 11 +- native/obs-plugin/src/pairing_ui.c | 255 ++++++ native/obs-plugin/src/pairing_ui.h | 9 + native/obs-plugin/src/plugin_state.c | 546 ++++++++++++ native/obs-plugin/src/plugin_state.h | 79 ++ native/obs-plugin/src/vendor_dispatch.c | 144 +++ native/obs-plugin/src/vendor_dispatch.h | 14 + native/obs-plugin/tests/bridge_test.c | 127 +++ native/obs-plugin/tests/pairing_store_test.c | 131 ++- native/obs-plugin/tests/plugin_state_test.c | 819 ++++++++++++++++++ .../obs-plugin/tests/test_vendor_dispatch.py | 127 +++ native/obs-plugin/tests/vendor_dispatch.def | 7 + .../obs-plugin/tests/vendor_dispatch_shim.c | 51 ++ native/obs-plugin/tools/build.py | 70 +- native/obs-plugin/tools/smoke.py | 57 +- native/obs-plugin/tools/test_native.py | 70 +- 26 files changed, 2811 insertions(+), 98 deletions(-) create mode 100644 native/obs-plugin/src/bridge.manifest create mode 100644 native/obs-plugin/src/bridge.rc create mode 100644 native/obs-plugin/src/pairing_ui.c create mode 100644 native/obs-plugin/src/pairing_ui.h create mode 100644 native/obs-plugin/src/plugin_state.c create mode 100644 native/obs-plugin/src/plugin_state.h create mode 100644 native/obs-plugin/src/vendor_dispatch.c create mode 100644 native/obs-plugin/src/vendor_dispatch.h create mode 100644 native/obs-plugin/tests/bridge_test.c create mode 100644 native/obs-plugin/tests/plugin_state_test.c create mode 100644 native/obs-plugin/tests/test_vendor_dispatch.py create mode 100644 native/obs-plugin/tests/vendor_dispatch.def create mode 100644 native/obs-plugin/tests/vendor_dispatch_shim.c diff --git a/docs/desktop-roadmap.md b/docs/desktop-roadmap.md index 80aee639..9acb6d6f 100644 --- a/docs/desktop-roadmap.md +++ b/docs/desktop-roadmap.md @@ -107,11 +107,11 @@ identity; it is not historical loaded-image attestation. There is no app entry point. Actual OBS enrollment, the original server plugin's restrictive DACL/client authentication, atomic arming/start coordination, controller/UI, live recognition and streaming-load acceptance remain open. -The original C-only [development module](../native/obs-plugin/README.md) now -builds against 39 verified OBS 32.2.2 public resources. It opens, initializes and -unloads through the installed libobs runtime in an isolated fixture without -starting the OBS application or creating audio sources. This establishes only -the inert build/load prerequisite, merged through PR #32 at `e61c7dd`. +The original C-only [development module](../native/obs-plugin/README.md) first +established the inert build/load prerequisite through PR #32 at `e61c7dd`. +The current linked module builds against 41 pinned OBS/frontend/vendor public +resources and deliberately refuses headless initialization before opening its +pairing store. The isolated fixture starts no OBS application or audio sources. The separate [native session components](plans/active/obs-native-session.md) now pass fixed Hello/ACK and CNG-failure checks, 11 actual child-process transport tests, current-logon DACL/noninheritance checks and 64 create/destroy handle @@ -119,14 +119,14 @@ balance cycles. A focused desktop boundary/pipe regression passes 54 tests. Independent admission source/test/evidence review is clear; PR #33 merged at `006d482` after all five exact-source desktop CI jobs passed. The next [enrollment step](plans/active/obs-native-enrollment.md) accounts for the public -vendor API's lack of caller authentication context. These components are not -linked into the module; vendor authorization/enrollment, atomic arming and PCM capture remain -unimplemented. The enrollment branch now implements independent capability +vendor API's lack of caller authentication context. That earlier increment kept +the session components separate; the current linked increment is described below. +The enrollment branch implements independent capability proof and native admission ownership: 30 client tests, 10 native child-process cases and six state/fault groups pass, including expiry, replay, revocation and concurrent preparation. The focused desktop regression passes 177 tests; final -independent source/evidence review is clear. Pairing-file UI and the vendor -adapter remain to be implemented before any app entry point is exposed. +independent source/evidence review is clear. These historical component checks +precede the native pairing/vendor integration described below. PR #34 merged at `9260e6b` after all five [exact-source desktop CI jobs](https://github.com/RioPlay/utterleaf/actions/runs/34754090586) passed at `d44126a`. The `feat/obs-pairing-store` increment, merged through @@ -150,19 +150,25 @@ binary is included in desktop or Android releases. The [control dependency record](desktop-obs-control-resource.md) records the pinned library, reviewed full license and development-wheel provenance. -Continue on `feat/obs-enrollment-flow` with the typed desktop preparation adapter, -visible native pairing Tools flow and exclusive per-user owner, followed by -vendor dispatch and the desktop controller. Arm/PCM and live -recognition remain later gates. No app entry point, audio endpoint, plugin binary +Continue on `feat/obs-enrollment-flow` with frontend acceptance and the desktop +setup/controller after the linked pairing/vendor increment. Arm/PCM and live +recognition remain later gates. No desktop app entry point, audio endpoint, plugin binary publication or OBS capture integration exists yet. -The typed desktop adapter now sends only the defined Utterleaf Issue/Prepare +The linked native pairing Tools flow, exclusive per-user owner and strict +vendor Issue/Prepare dispatch are now present in the development module. +All 28 native driver commands pass, including lifecycle/dispatch races, actual +owner competition, shimmed bridge lifecycle and six real-libobs parser cases. +The linked DLL builds and headless refusal passes with unchanged fixed-store +metadata. Frontend UI interaction and real OBS acceptance remain unverified. +The typed desktop adapter sends only the defined Utterleaf Issue/Prepare requests after explicit invocation, verifies challenge binding and the OBS peer before proof, and rejects concurrent operations. The focused bundle passes **341 tests with no skips**, including **66** enrollment tests and an ephemeral loopback exchange. Independent source review is clear; native identity is stubbed in that new exchange fixture. This prepares a session ID and does not connect -the audio pipe or arm capture. Native owner/UI/vendor integration remains open. +the audio pipe or arm capture. Native owner/UI/vendor integration remains +unverified for real OBS/device acceptance. Current integrated desktop regression: **1,432 passed, 13 skipped in 39.47 seconds**, including the reviewed OBS control, native identity, pipe and audio handshake. diff --git a/docs/plans/active/obs-native-enrollment.md b/docs/plans/active/obs-native-enrollment.md index 314f95d2..9413cb72 100644 --- a/docs/plans/active/obs-native-enrollment.md +++ b/docs/plans/active/obs-native-enrollment.md @@ -13,8 +13,9 @@ entry point exists. On `feat/obs-enrollment-flow`, the typed desktop preparation adapter below now passes **341** focused tests, including **66** enrollment cases. Independent source and final test review are clear after the documented failure-path additions. -The native owner, Tools flow and vendor registration remain next implementation -work. The module is still inert and no installed release changes here. +The native owner, Tools flow and vendor registration are now linked bounded +components; frontend/device and real OBS acceptance remain unverified. No +installed release changes here. ## Goal and area @@ -60,7 +61,7 @@ from independent enrollment and request verification, never callback arrival. - Preserve actual pipe-client process checks before the first Hello read and the existing single-use and cancellation behavior. Check native completion before destroying owned buffers; no detached workers. -- Keep the module inert until its integration gates pass. Do not launch OBS, +- Keep capture disabled until its integration gates pass. Do not launch OBS, install the plugin, change profiles/routing/recording or acquire microphone audio during primitive verification. No new binary publication follows here. @@ -277,11 +278,12 @@ callbacks return; closed vendor callbacks return only `{ "ok": false }` without accessing runtime, frontend, stores or logging. Pinning protects our code/data, not OBS APIs after their own teardown. -At `OBS_FRONTEND_EVENT_EXIT`, close the gate and detach runtime under the static -lock. This drains callbacks currently using runtime and rejects callbacks copied -earlier but invoked later. Outside all state locks, unregister requests, revoke -and cancel, join the owned worker, destroy authorizer/store, release the exclusive -owner and clear/free runtime. Do not remove the frontend callback from inside +At `OBS_FRONTEND_EVENT_EXIT`, first disable vendor dispatch and close the runtime +gate under the static lock, signaling only stable cancellation objects. Unregister +requests before any joins. Then detach runtime, take its operation lock before +accessing the mutable authorizer, revoke/cancel/join the worker and drain retained +runtime leases before freeing the store, exclusive owner and runtime. This +rejects callbacks copied earlier but invoked later. Do not remove the frontend callback from inside itself. The [frontend contract](https://github.com/obsproject/obs-studio/blob/ba2f32bdf791005443988a4955e963663e16b1ed/docs/sphinx/reference-frontend-api.rst) makes EXIT the final opportunity to call frontend APIs. Module unload is an idempotent native-resource fallback; if EXIT was missed, it must not call @@ -297,14 +299,62 @@ may remain. Creating/replacing/forgetting pairing suspends dispatch and uses the same owner. Forget always revokes the live generation, including deletion failure; report nondurable deletion failure distinctly. -These are selected implementation constraints, not verified native behavior. -Acceptance must simulate a copied callback after close, overlapping close and +These constraints are implemented with the bounded fixture evidence below; +real OBS interaction remains unverified. Acceptance must simulate a copied callback after close, overlapping close and dispatch, failed pin, second load, missing EXIT, in-flight worker cancellation, and a second actual Windows process competing for ownership. A joinable worker must also expire a prepared admission without depending on another incoming request. Finalize that bounded handshake/READY lifetime before exposing Prepare; the full Arm/PCM state machine remains required. +### Linked native integration verification + +The native increment links `plugin_state`, `pairing_ui`, `vendor_dispatch`, stores, +authorization and admission into the original module. It adds a single native +Tools entry and a common-controls manifest. Both vendor endpoints remain disabled +until both registrations succeed. Failed rollback unregister retains its flag +for EXIT retry, while the disabled gate refuses copied callbacks. Unload only +disables native dispatch and closes state; it makes no frontend/websocket calls. + +Replacement commits the store before retiring the old generation. A precommit +failure preserves its exact authorizer, challenge and worker; postcommit store +uncertainty or failed authorizer activation leaves pairing unavailable. The UI +distinguishes these from a successful pairing whose export failed. Forget revokes +before attempting deletion and reports nondurable failure. Informational dialogs +use Close, while file selection/confirmations can cancel before mutation. +UI rendering, keyboard/accessibility interaction and real frontend load still +require acceptance. + +One joinable admission worker bounds authentication to 15 seconds and READY +retention to at most another 15 seconds, then cancels without further requests. +A completed worker handle and one canceled admission can remain until the next +state call or close safely reaps them. `admission_pending=false` describes the +worker, not zero retained handles or a closed pipe handle. No PCM or Arm exists. + +The September 13 driver at `.grok/obs-enrollment-flow/native-final/test-receipt.json` +passes all **28 compilation/test commands**, including real Windows owner-process +competition, controlled pin/reload/missing-EXIT/worker cases, close overlapping +an in-flight Issue call, and the bridge wrapper's registration/teardown failure +paths. Six parsed-request tests use actual libobs data APIs with a substituted +runtime boundary and no OBS startup. They cannot verify duplicate/null JSON +fields that libobs parsing discards. The receipt binds native/client sources, +compiler, logs/artifacts, pinned headers and the associated build inputs. +Three existing test-only heap-shim link warnings remain; production compiles +with warnings as errors. + +The linked build uses 41 verified public resources. Its DLL SHA-256 is +`b029401c1f5da71bf3c27c7dce084aa9c183d8ce8366f2ad1907c1447fe871cf`. +`build-a/smoke-receipt.json` verifies headless refusal before store startup, +returned libobs shutdown and unchanged metadata for the three fixed consumer +store nodes; no contents are read, consumer paths created or OBS application/audio +started. The exact build/test commands are in the native README. These are local +fixture results, not successful frontend initialization or live OBS acceptance. +Final independent source/test/documentation and receipt review is clear. The +reviewer rehashed build inputs/outputs, both OBS runtimes, invoked tools, smoke +inputs, native/client sources, dispatch inputs and all test artifacts/logs against +the current files. Exact-source CI for this linked increment remains pending; +the draft PR does not establish frontend/UI or live OBS acceptance. + ### Private pairing stores: contract The selected export/import and persistence boundary lives in original native @@ -469,8 +519,9 @@ state/fault groups; three known warnings are isolated to the test heap shim. Exact receipts are `.grok/obs-pairing-store/verification/test-receipt.json` and `.grok/obs-pairing-store/ci-34755748029/receipt.json`. These results do not verify different-user DPAPI behavior, power-loss durability, consumer pairing -UI, real OBS dispatch, durable live-authorizer revocation or audio. No components -are linked into the inert module and no binary is published by this increment. +UI, real OBS dispatch, durable live-authorizer revocation or audio. These +components are linked into the development module, but no binary is published +by this increment and no live OBS/audio acceptance follows. ### Full enrollment checks diff --git a/docs/plans/active/obs-plugin-build.md b/docs/plans/active/obs-plugin-build.md index 8a0ebf01..0a699bed 100644 --- a/docs/plans/active/obs-plugin-build.md +++ b/docs/plans/active/obs-plugin-build.md @@ -1,6 +1,7 @@ # Original OBS plugin build and native acceptance -Status: native prerequisite build/load verified; full bridge not implemented. +Status: historical inert build/load verified; linked pairing/runtime build and +headless-refusal checks pass. Real frontend and full audio bridge remain open. September 13, 2026. Follows the [Windows audio pipe](windows-obs-audio-pipe.md) and [OBS design](obs-audio-design.md). @@ -56,7 +57,7 @@ toolchain requirement as proof that an independently built C-only DLL is impossi ## Verified native prerequisite (September 13, 2026) -The original inert module now builds with the existing LLVM-MinGW installation +The earlier original inert module built with the existing LLVM-MinGW installation at `C:/Users/unknown/.local/llvm-mingw-20260616-ucrt-x86_64`, installed OBS `C:/Program Files/obs-studio/bin/64bit`, and the locked cache at `C:/Users/unknown/Projects/Mindict/.grok/obs-native-build/headers`. Two clean @@ -89,6 +90,14 @@ The pinned obs-websocket API header is C-compatible and routes vendor calls through libobs proc handlers; it does not require linking C++ or Qt. Consuming its static-inline public interface still requires exact provenance/license review. +The current [enrollment increment](obs-native-enrollment.md#linked-native-integration-verification) +supersedes that module: it links native pairing, frontend Tools, strict vendor +dispatch and bounded runtime/admission with 41 pinned public resources. Its +headless smoke deliberately expects initialization refusal, before opening +pairing state. The earlier hash and successful inert initialization above do not +describe this linked DLL. Current build/smoke/native receipts are recorded in +the enrollment plan and native README; real frontend/UI and PCM acceptance remain open. + Primary evidence: - [OBS 32.2.2 release](https://github.com/obsproject/obs-studio/releases/tag/32.2.2) diff --git a/native/obs-plugin/README.md b/native/obs-plugin/README.md index c80c1bc7..c0b4b259 100644 --- a/native/obs-plugin/README.md +++ b/native/obs-plugin/README.md @@ -1,35 +1,43 @@ -# Utterleaf OBS native bridge prerequisite +# Utterleaf OBS native bridge development This directory contains the original, C-only development module and tools that write build receipts into a separate output directory. The current increment -proves a narrow native prerequisite: an x64 DLL -can be compiled from the pinned OBS 32.2.2 public-header closure, its exports -and imports can be inspected, and the inert module can be opened, initialized, -unloaded and shut down through `libobs`. +proves the native build prerequisite and adds linked pairing, plugin-state, +frontend Tools and vendor-dispatch components. It defines an exclusive +per-user owner, bounded admission worker, pairing TaskDialog and strict +`IssueAuthorization`/`PrepareSession` vendor requests. The x64 DLL builds from +pinned public resources. The headless `libobs` fixture verifies that it refuses +initialization before opening pairing state. Actual frontend load, UI interaction +and real OBS acceptance remain unverified. The full goal remains an authenticated OBS audio bridge: a restrictive native server, process and pipe identity checks, explicit idle-to-arm control, actual primary streaming-mix and selected-bus PCM delivery, bounded conversion and storage, cancellation and gap handling, visible control, local recognition, and live OBS acceptance. Separate native Hello/ACK and retained-process pipe -admission components now have an explicit test build, described below. They are -not linked into the inert module: post-load vendor registration, stream-following, -PCM, controller, Arm and real OBS application behavior remain unimplemented. +admission components now have an explicit test build, described below, and the +bounded admission worker is linked into the module. Frontend/device behavior +and real OBS acceptance remain unverified; stream-following, PCM, controller, +Arm and live capture remain unimplemented. ## Inputs and legal boundary `dependencies.json` pins the exact OBS source revision -`ba2f32bdf791005443988a4955e963663e16b1ed` and 39 resources: 37 used public -headers plus `libobs/obsconfig.h.in` and `COPYING`. The build verifies every +`ba2f32bdf791005443988a4955e963663e16b1ed` for 40 public resources, including the +frontend header, configuration template and license. The obs-websocket API header +is pinned separately at `1ef34bf48110c2a18184e50e41cd0b1a855e2147`, for 41 total +resources. The build verifies every resource's URL, byte count and SHA-256 before compiling. It generates -`obsconfig.h`, derives a local import library from the installed `obs.dll`, and +`obsconfig.h`, derives local import libraries from installed `obs.dll` and +`obs-frontend-api.dll`, embeds the common-controls activation manifest, and emits source/input/generated-file receipts. The nine ISC header notices in -`OBS-HEADER-NOTICES.txt` reproduce each header's complete leading comment. +`OBS-HEADER-NOTICES.txt` and the obs-websocket notice reproduce each header's +complete leading comment. The build uses an existing LLVM-MinGW installation and the installed OBS -32.2.2 runtime. Repeated builds with the same installed inputs currently give -byte-identical DLLs, but this is not a hermetic toolchain or toolchain -reproducibility claim: compiler driver configuration, linker behavior, headers +32.2.2 runtime. The earlier inert prerequisite produced byte-identical repeated +builds; that result does not establish reproducibility of this linked increment. +This is not a hermetic toolchain: compiler driver configuration, linker behavior, headers outside the locked closure and static-runtime inputs are not fully pinned. MinGW/static-runtime redistribution licensing remains open. The original plugin source is GPL-2.0-or-later and is kept separate from the Apache desktop client; @@ -51,19 +59,22 @@ $output = "C:\Users\unknown\Projects\Mindict\.grok\obs-native-build\review-a" & $py native/obs-plugin/tools/smoke.py --build $output ``` -The smoke fixture uses a separate build-local OBS configuration, does not start -the OBS application, creates no sources, resets no audio, and does not touch a -microphone, stream, recording, credentials, or consumer profile. Its raw OBS +The smoke fixture uses a separate build-local OBS configuration. It verifies a +null frontend handle, successful `obs_open_module`, refusal by `obs_init_module`, +and returned shutdown. It compares only metadata of the three fixed pairing-store +nodes before and after; it never reads their contents or creates those paths. +It does not start the OBS application, create sources, reset audio, or access a +microphone, stream, recording, credentials, or consumer OBS profile. Its raw OBS log can contain machine and security information; keep it local and do not publish it. -The verified review run used `review-a` and `review-b`. Both DLLs had SHA-256 -`f3322eabd7a7dd1f7a3439670d08db89e54155c8e35e8013bae973842bdced84`. -The smoke receipt recorded OBS API version `537001986`, `obs_open_module=0`, -successful initialization and returned shutdown. The local libobs log also -records the module's unload callback. Scratch copies with a flipped -cached header byte and a changed plugin were rejected before compilation or -native load, respectively. +The linked September 13 build at `.grok/obs-enrollment-flow/build-a` has DLL SHA-256 +`b029401c1f5da71bf3c27c7dce084aa9c183d8ce8366f2ad1907c1447fe871cf`. +Its smoke receipt records OBS API version `537001986`, `obs_open_module=0`, +`obs_init_module=false`, returned shutdown and unchanged fixed-store metadata. +This checks headless refusal, not actual OBS frontend/vendor registration. +The [build plan](../../docs/plans/active/obs-plugin-build.md) retains the separate +historical inert prerequisite evidence and remaining release gates. ## Native admission tests @@ -74,17 +85,19 @@ checks the actual kernel pipe-client PID, liveness, creation time and user/logon identity before reading a Hello. The explicit DACL allows the current logon, and failure or cancellation consumes the pending session. -The obs-websocket vendor API does not expose its caller's WebSocket connection. +The linked obs-websocket vendor API does not expose its caller's WebSocket connection. An expected PID from vendor JSON is a credential holder's assertion, not reverse connection attribution or trusted Utterleaf executable enrollment. That -integration must be resolved before exposing arming. These primitives are not -linked into the current module or started by loading it. +integration must be resolved before exposing arming. The module's vendor +callbacks enforce strict fixed requests and hand off only to bounded +owner/admission state; they do not provide caller attribution. Run from the owning repository root with an existing Windows LLVM-MinGW toolchain: ```powershell -$output = "C:\Users\unknown\Projects\Mindict\.grok\obs-native-session\check-b" -& $py native/obs-plugin/tools/test_native.py --toolchain $toolchain --output $output +$build = $output +$testOutput = "C:\Users\unknown\Projects\Mindict\.grok\obs-enrollment-flow\native-final" +& $py native/obs-plugin/tools/test_native.py --toolchain $toolchain --output $testOutput --build $build --headers $headers ``` The driver performs fixed-vector and injected CNG-failure checks, actual token/ @@ -92,8 +105,8 @@ pipe-security checks and 11 disposable child-process transport tests. It also builds the independent capability authorizer, runs six state/fault groups and 10 authorization-to-admission child-process tests, and checks the shared CNG helper against an independently computed .NET/Python vector. It runs -without OBS or audio, scopes Python imports to this checkout and records local -source/tool/compiler/output hashes. Every translation unit uses warnings as +without the OBS application or audio, scopes Python imports to this checkout +and records local source/tool/compiler/output hashes. Every translation unit uses warnings as errors; the test-only macro-renamed heap shim link has three local-import warnings. The [native session plan](../../docs/plans/active/obs-native-session.md) records commands, results and remaining kernel-failure, cross-user/logon, @@ -110,8 +123,18 @@ that exports from native code, imports/reloads in Python and produces a native admission proof using the loaded key. All storage fixtures use disposable roots; they do not touch a consumer pairing, OBS profile or audio source. -The store component is not a pairing UI or vendor adapter. Its caller must still -serialize live authorizer ownership and implement visible pairing/revocation, -vendor handling, atomic Arm and audio integration. Neither successful storage -nor the proof establishes executable identity or Arm authority. The test DLLs +The store component is separate from the linked pairing Tools dialog and vendor +adapter. The caller still must complete frontend/device acceptance, durable live +revocation, atomic Arm and audio integration. Neither successful storage nor the +proof establishes executable identity or Arm authority. The test DLLs remain local verification artifacts and are never installed into OBS. + +The current driver also checks the runtime's pin/start/close state with controlled +substitutions and real Windows threads, including close overlapping dispatch, +precommit replacement failure and worker cancellation. Actual child processes +compete for the private store owner. With `--build` and `--headers`, it verifies +the recorded build/header inputs, runs a shimmed bridge registration/teardown +fixture and six parser cases through real libobs data APIs (without libobs startup). +All 28 compilation/test commands pass in `native-final/test-receipt.json`. +Omitting both optional arguments leaves those two frontend-boundary fixtures +explicitly unrun; it does not establish their acceptance. diff --git a/native/obs-plugin/dependencies.json b/native/obs-plugin/dependencies.json index 06c7c3e3..ecd6b060 100644 --- a/native/obs-plugin/dependencies.json +++ b/native/obs-plugin/dependencies.json @@ -1,6 +1,19 @@ { "obs_revision": "ba2f32bdf791005443988a4955e963663e16b1ed", + "obs_websocket_revision": "1ef34bf48110c2a18184e50e41cd0b1a855e2147", "resources": { + "frontend/api/obs-frontend-api.h": { + "url": "https://raw.githubusercontent.com/obsproject/obs-studio/ba2f32bdf791005443988a4955e963663e16b1ed/frontend/api/obs-frontend-api.h", + "sha256": "e6f332c0cfbb8ea3c5faf7861a170412626dd5633c3bc0de4c54e2e3921f758a", + "git_blob_sha1": "46713fbcbd9749f981854fdc6e9cf33ecfeb249c", + "bytes": 10383 + }, + "obs-websocket/obs-websocket-api.h": { + "url": "https://raw.githubusercontent.com/obsproject/obs-websocket/1ef34bf48110c2a18184e50e41cd0b1a855e2147/lib/obs-websocket-api.h", + "sha256": "c29e8e38ee66c36db79cae86217ab0f270aad39f97e82090809d087c281b7948", + "git_blob_sha1": "b7a6d5c071cf52bb78f76d7a060cf7d3c9fa2304", + "bytes": 7412 + }, "libobs/obs-module.h": { "url": "https://raw.githubusercontent.com/obsproject/obs-studio/ba2f32bdf791005443988a4955e963663e16b1ed/libobs/obs-module.h", "sha256": "e177a961500bab34b907b6bbf9261f0071fdaf97727a43095927365963a000c3", diff --git a/native/obs-plugin/src/bridge.c b/native/obs-plugin/src/bridge.c index b86e677f..34beda9e 100644 --- a/native/obs-plugin/src/bridge.c +++ b/native/obs-plugin/src/bridge.c @@ -1,20 +1,89 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include +#include +#include +#include +#include + +#include "plugin_state.h" +#include "pairing_ui.h" +#include "vendor_dispatch.h" OBS_DECLARE_MODULE() +/* OBS serializes module hooks and frontend EXIT on the frontend thread. + * Vendor callbacks can race these hooks; they carry no heap-owned state. */ +static obs_websocket_vendor vendor; +static bool issue_registered; +static bool prepare_registered; + +static void pairing_menu(void *private_data) +{ + (void)private_data; + if (ul_plugin_get_status().status != UL_PLUGIN_CLOSED) + ul_pairing_ui_show((HWND)obs_frontend_get_main_window_handle()); +} + +static void frontend_event(enum obs_frontend_event event, void *private_data) +{ + (void)private_data; + if (event != OBS_FRONTEND_EVENT_EXIT) + return; + ul_vendor_set_enabled(false); + ul_plugin_stop_accepting(); + if (prepare_registered) + obs_websocket_vendor_unregister_request(vendor, "PrepareSession"); + if (issue_registered) + obs_websocket_vendor_unregister_request(vendor, "IssueAuthorization"); + issue_registered = prepare_registered = false; + ul_plugin_close(); + /* The frontend owns its callback/menu destruction. Static data and module + * code stay pinned, so callbacks copied before close safely decline. */ +} + bool obs_module_load(void) { - /* This first build is intentionally tied to the reviewed runtime. */ - if (obs_get_version() != LIBOBS_API_VER || obs_current_module() == NULL) + if (obs_get_version() != LIBOBS_API_VER || obs_current_module() == NULL || + obs_frontend_get_main_window_handle() == NULL) + return false; + if (!ul_plugin_start()) return false; - blog(LOG_INFO, "[Utterleaf OBS bridge] inert module loaded"); + obs_frontend_add_event_callback(frontend_event, NULL); + obs_frontend_add_tools_menu_item("Utterleaf pairing...", pairing_menu, NULL); + blog(LOG_INFO, "[Utterleaf OBS bridge] pairing controls loaded; recording remains off"); return true; } +void obs_module_post_load(void) +{ + ul_plugin_snapshot state = ul_plugin_get_status(); + if (vendor != NULL) + return; + if (!state.owns_store || state.status == UL_PLUGIN_CLOSED) + return; + if (obs_websocket_get_api_version() != OBS_WEBSOCKET_API_VERSION) { + blog(LOG_WARNING, "[Utterleaf OBS bridge] compatible obs-websocket API unavailable"); + return; + } + vendor = obs_websocket_register_vendor("Utterleaf"); + if (vendor == NULL) + return; + issue_registered = obs_websocket_vendor_register_request(vendor, "IssueAuthorization", ul_vendor_issue, NULL); + if (issue_registered) + prepare_registered = obs_websocket_vendor_register_request(vendor, "PrepareSession", ul_vendor_prepare, NULL); + if (!prepare_registered && issue_registered) { + issue_registered = !obs_websocket_vendor_unregister_request(vendor, "IssueAuthorization"); + } + ul_vendor_set_enabled(issue_registered && prepare_registered); + if (!prepare_registered) + blog(LOG_WARNING, "[Utterleaf OBS bridge] request registration failed; connections disabled"); +} + void obs_module_unload(void) { - blog(LOG_INFO, "[Utterleaf OBS bridge] inert module unloaded"); + /* Native-only fallback: frontend/websocket teardown order is not assumed. */ + ul_vendor_set_enabled(false); + ul_plugin_close(); } const char *obs_module_name(void) @@ -24,7 +93,7 @@ const char *obs_module_name(void) const char *obs_module_description(void) { - return "Inert build and loader verification. No audio capture or network endpoint."; + return "Private Utterleaf pairing and authenticated session preparation. No audio capture yet."; } const char *obs_module_author(void) diff --git a/native/obs-plugin/src/bridge.def b/native/obs-plugin/src/bridge.def index 043a8b68..26e81ed3 100644 --- a/native/obs-plugin/src/bridge.def +++ b/native/obs-plugin/src/bridge.def @@ -1,6 +1,7 @@ LIBRARY utterleaf-obs-bridge EXPORTS obs_module_load + obs_module_post_load obs_module_unload obs_module_set_pointer obs_module_ver diff --git a/native/obs-plugin/src/bridge.manifest b/native/obs-plugin/src/bridge.manifest new file mode 100644 index 00000000..431ea97b --- /dev/null +++ b/native/obs-plugin/src/bridge.manifest @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/native/obs-plugin/src/bridge.rc b/native/obs-plugin/src/bridge.rc new file mode 100644 index 00000000..a594d2a6 --- /dev/null +++ b/native/obs-plugin/src/bridge.rc @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +2 RT_MANIFEST "bridge.manifest" diff --git a/native/obs-plugin/src/pairing_store.c b/native/obs-plugin/src/pairing_store.c index a900f29b..a8f1601b 100644 --- a/native/obs-plugin/src/pairing_store.c +++ b/native/obs-plugin/src/pairing_store.c @@ -46,9 +46,11 @@ struct ul_pairing_store { SRWLOCK lock; PSID user_sid; DWORD user_sid_size; + HANDLE owner; ul_handle_set directories; wchar_t directory[UL_PAIRING_MAX_PATH_CHARS]; wchar_t store_path[UL_PAIRING_MAX_PATH_CHARS]; + wchar_t owner_path[UL_PAIRING_MAX_PATH_CHARS]; }; static void wipe_free(void *memory, SIZE_T size) @@ -918,6 +920,7 @@ static ul_pairing_result store_open_root(const wchar_t *root, if (store == NULL) return UL_PAIRING_IO_ERROR; InitializeSRWLock(&store->lock); + store->owner = INVALID_HANDLE_VALUE; if (!bounded_token_user(&store->user_sid, &store->user_sid_size)) { result = UL_PAIRING_IO_ERROR; goto cleanup; @@ -939,6 +942,10 @@ static ul_pairing_result store_open_root(const wchar_t *root, result = UL_PAIRING_UNSUPPORTED; goto cleanup; } + if (!join_path(store->directory, L"owner-v1.lock", store->owner_path)) { + result = UL_PAIRING_UNSUPPORTED; + goto cleanup; + } *store_out = store; return UL_PAIRING_OK; @@ -999,12 +1006,71 @@ void ul_pairing_store_destroy(ul_pairing_store *store) { if (store == NULL) return; + if (store->owner != INVALID_HANDLE_VALUE) + CloseHandle(store->owner); handles_close(&store->directories); wipe_free(store->user_sid, store->user_sid_size); SecureZeroMemory(store, sizeof(*store)); HeapFree(GetProcessHeap(), 0, store); } +ul_pairing_result ul_pairing_store_claim_owner(ul_pairing_store *store) +{ + ul_private_security security; + HANDLE owner = INVALID_HANDLE_VALUE; + LARGE_INTEGER size; + ul_pairing_result result = UL_PAIRING_IO_ERROR; + DWORD error; + bool created = false; + + if (store == NULL) + return UL_PAIRING_INVALID_ARGUMENT; + SecureZeroMemory(&security, sizeof(security)); + SecureZeroMemory(&size, sizeof(size)); + AcquireSRWLockExclusive(&store->lock); + if (store->owner != INVALID_HANDLE_VALUE) { + result = UL_PAIRING_OK; + goto cleanup; + } + if (!private_security_init(store->user_sid, store->user_sid_size, + &security)) + goto cleanup; + SetLastError(ERROR_SUCCESS); + owner = CreateFileW(store->owner_path, + GENERIC_READ | GENERIC_WRITE | READ_CONTROL, 0, + &security.attributes, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + error = GetLastError(); + private_security_clear(&security); + if (owner == INVALID_HANDLE_VALUE) { + result = (error == ERROR_SHARING_VIOLATION || error == ERROR_LOCK_VIOLATION) + ? UL_PAIRING_IN_USE + : UL_PAIRING_IO_ERROR; + goto cleanup; + } + created = error != ERROR_ALREADY_EXISTS; + result = validate_disk_object(owner, store->owner_path, false, false); + if (result == UL_PAIRING_OK) + result = verify_private_security(owner, store->user_sid); + if (result == UL_PAIRING_OK && + (!GetFileSizeEx(owner, &size) || size.QuadPart != 0)) + result = UL_PAIRING_UNSAFE_SECURITY; + if (result != UL_PAIRING_OK) + goto cleanup; + store->owner = owner; + owner = INVALID_HANDLE_VALUE; + +cleanup: + if (owner != INVALID_HANDLE_VALUE) + CloseHandle(owner); + if (created && result != UL_PAIRING_OK) + DeleteFileW(store->owner_path); + private_security_clear(&security); + ReleaseSRWLockExclusive(&store->lock); + return result; +} + ul_pairing_result ul_pairing_store_load(ul_pairing_store *store, uint8_t key[UL_PAIRING_KEY_BYTES]) { diff --git a/native/obs-plugin/src/pairing_store.h b/native/obs-plugin/src/pairing_store.h index d7755866..dc5ff014 100644 --- a/native/obs-plugin/src/pairing_store.h +++ b/native/obs-plugin/src/pairing_store.h @@ -22,7 +22,8 @@ typedef enum ul_pairing_result { UL_PAIRING_CORRUPT = 7, UL_PAIRING_CRYPTO_ERROR = 8, UL_PAIRING_IO_ERROR = 9, - UL_PAIRING_POSTCOMMIT_INVALID = 10 + UL_PAIRING_POSTCOMMIT_INVALID = 10, + UL_PAIRING_IN_USE = 11 } ul_pairing_result; typedef struct ul_pairing_cancel { @@ -52,6 +53,14 @@ ul_pairing_store_open_test_root(const wchar_t *root, /* The caller must quiesce operations before destroying the store. */ void ul_pairing_store_destroy(ul_pairing_store *store); +/* + * Claim the sole live plugin owner for this user's store. The verified, + * noninheritable share-zero owner-v1.lock handle is retained until destroy. + * Existing store primitives remain usable without a claim for isolated codec + * and migration callers; live plugin state must require a successful claim. + */ +ul_pairing_result ul_pairing_store_claim_owner(ul_pairing_store *store); + /* Loads the authoritative role-1 capability. */ ul_pairing_result ul_pairing_store_load(ul_pairing_store *store, uint8_t key[UL_PAIRING_KEY_BYTES]); diff --git a/native/obs-plugin/src/pairing_ui.c b/native/obs-plugin/src/pairing_ui.c new file mode 100644 index 00000000..0b9bc374 --- /dev/null +++ b/native/obs-plugin/src/pairing_ui.c @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#define _WIN32_WINNT 0x0601 +#include "pairing_ui.h" +#include "plugin_state.h" + +#include +#include +#include + +#define UL_PAIR 101 +#define UL_EXPORT 102 +#define UL_REPLACE 103 +#define UL_FORGET 104 + +typedef HRESULT (WINAPI *ul_task_dialog_fn)(const TASKDIALOGCONFIG *, int *, int *, BOOL *); +static volatile LONG dialog_running; + +static HRESULT CALLBACK dialog_callback(HWND window, UINT notification, + WPARAM wparam, LPARAM lparam, LONG_PTR data) +{ + (void)wparam; + (void)lparam; + if (notification == TDN_TIMER && ul_plugin_get_status().status == UL_PLUGIN_CLOSED) + SendMessageW(window, TDM_CLICK_BUTTON, (WPARAM)data, 0); + return S_OK; +} + +static int show_dialog(ul_task_dialog_fn show, HWND owner, const wchar_t *heading, + const wchar_t *content, const TASKDIALOG_BUTTON *buttons, + unsigned count, int default_button) +{ + TASKDIALOGCONFIG config = {0}; + int selected = IDCANCEL; + if (ul_plugin_get_status().status == UL_PLUGIN_CLOSED) + return IDCANCEL; + config.cbSize = sizeof(config); + config.hwndParent = owner; + config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_POSITION_RELATIVE_TO_WINDOW | + TDF_SIZE_TO_CONTENT | TDF_CALLBACK_TIMER; + if (count != 0u) + config.dwFlags |= TDF_USE_COMMAND_LINKS; + config.dwCommonButtons = count == 0u ? TDCBF_CLOSE_BUTTON : TDCBF_CANCEL_BUTTON; + config.pszWindowTitle = L"Utterleaf - OBS pairing"; + config.pszMainInstruction = heading; + config.pszContent = content; + config.pszFooter = L"Pairing stays on this Windows account. Pairing never starts recording."; + config.cButtons = count; + config.pButtons = buttons; + config.nDefaultButton = count == 0u ? IDCLOSE : default_button; + config.pfCallback = dialog_callback; + config.lpCallbackData = count == 0u ? IDCLOSE : IDCANCEL; + if (FAILED(show(&config, &selected, NULL, NULL))) { + if (ul_plugin_get_status().status != UL_PLUGIN_CLOSED && IsWindow(owner)) + MessageBoxW(owner, L"The pairing dialog could not be displayed. Restart OBS and try again.", + L"Utterleaf pairing", MB_OK | MB_ICONERROR); + return IDCANCEL; + } + return selected; +} + +static HRESULT choose_destination(HWND owner, wchar_t **path) +{ + IFileSaveDialog *dialog = NULL; + IShellItem *item = NULL; + FILEOPENDIALOGOPTIONS options; + const COMDLG_FILTERSPEC filter = {L"Utterleaf OBS pairing", L"*.ulobs"}; + HRESULT result; + *path = NULL; + result = CoCreateInstance(&CLSID_FileSaveDialog, NULL, CLSCTX_INPROC_SERVER, + &IID_IFileSaveDialog, (void **)&dialog); + if (FAILED(result)) + return result; + result = dialog->lpVtbl->GetOptions(dialog, &options); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->SetOptions(dialog, (options & ~FOS_OVERWRITEPROMPT) | FOS_FORCEFILESYSTEM | + FOS_PATHMUSTEXIST | FOS_DONTADDTORECENT | + FOS_NOREADONLYRETURN); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->SetFileTypes(dialog, 1, &filter); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->SetDefaultExtension(dialog, L"ulobs"); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->SetFileName(dialog, L"Utterleaf-OBS-pairing.ulobs"); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->SetTitle(dialog, L"Save a new Utterleaf pairing file"); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->Show(dialog, owner); + if (SUCCEEDED(result)) + result = dialog->lpVtbl->GetResult(dialog, &item); + if (SUCCEEDED(result)) + result = item->lpVtbl->GetDisplayName(item, SIGDN_FILESYSPATH, path); + if (item != NULL) + item->lpVtbl->Release(item); + dialog->lpVtbl->Release(dialog); + return result; +} + +static const wchar_t *operation_error(ul_pairing_result result) +{ + switch (result) { + case UL_PAIRING_EXISTS: + return L"A file already exists at that location. Choose a new filename; existing files are never replaced."; + case UL_PAIRING_IN_USE: + return L"Another OBS process owns this pairing. Close that process, then restart this OBS instance."; + case UL_PAIRING_UNSAFE_SECURITY: + case UL_PAIRING_UNSUPPORTED: + return L"This location cannot safely store pairing. Use a local Windows drive with private permissions and no linked folders."; + case UL_PAIRING_CORRUPT: + return L"The saved pairing could not be verified. Forget this pairing before creating another."; + case UL_PAIRING_POSTCOMMIT_INVALID: + return L"The save may have completed, but its final verification failed. Pairing is unavailable; check the storage location before trying again."; + default: + return L"The pairing operation could not finish. Check the storage location and available disk space, then try again."; + } +} + +static void pairing_actions(ul_task_dialog_fn show, HWND owner) +{ + static const TASKDIALOG_BUTTON pair[] = {{UL_PAIR, L"Pair Utterleaf\nCreate a private file to import in Utterleaf."}}; + static const TASKDIALOG_BUTTON paired[] = { + {UL_EXPORT, L"Export pairing file\nCreate another file for the current pairing."}, + {UL_REPLACE, L"Replace pairing\nDisconnect existing clients and create a new pairing."}, + {UL_FORGET, L"Forget pairing\nRevoke this pairing, including copied pairing files."}, + }; + static const TASKDIALOG_BUTTON forget[] = {{UL_FORGET, L"Forget pairing\nRemove the saved pairing so you can start again."}}; + ul_plugin_snapshot before = ul_plugin_get_status(); + ul_pairing_result result; + wchar_t *path = NULL; + bool saved = false; + int action; + if (before.status == UL_PLUGIN_CLOSED) + return; + if (before.status == UL_PLUGIN_IN_USE) { + show_dialog(show, owner, L"Pairing is open in another OBS process", + operation_error(UL_PAIRING_IN_USE), NULL, 0u, IDCANCEL); + return; + } + if (before.status == UL_PLUGIN_UNPAIRED) + action = show_dialog(show, owner, L"Connect Utterleaf to OBS", + L"Create a pairing file, then explicitly import it in Utterleaf. Live transcription is still in development.", + pair, 1u, UL_PAIR); + else if (before.status == UL_PLUGIN_PAIRED) + action = show_dialog(show, owner, L"Utterleaf is paired", + before.admission_pending ? L"A connection is prepared. Replacing or forgetting pairing disconnects it." : + L"Manage your pairing without changing OBS audio, stream or recording settings.", + paired, 3u, UL_EXPORT); + else + action = show_dialog(show, owner, L"Pairing needs attention", operation_error(before.storage_result), + before.owns_store ? forget : NULL, before.owns_store ? 1u : 0u, IDCANCEL); + if (action == IDCANCEL || ul_plugin_get_status().status == UL_PLUGIN_CLOSED) + return; + if (action == UL_FORGET) { + if (show_dialog(show, owner, L"Forget this pairing?", + L"Utterleaf will disconnect. Existing pairing files will no longer authorize new connections. Models and transcripts are kept.", + forget, 1u, IDCANCEL) != UL_FORGET) + return; + result = ul_plugin_forget(); + if (result != UL_PAIRING_OK && result != UL_PAIRING_MISSING) { + show_dialog(show, owner, L"Disconnected, but pairing could not be removed", + L"Current connections are revoked. The saved pairing remains and may work again after OBS restarts. Resolve the storage problem and retry Forget pairing.", + NULL, 0u, IDCANCEL); + return; + } + show_dialog(show, owner, L"Pairing forgotten", L"Create a new pairing when you want to connect Utterleaf again.", NULL, 0u, IDCANCEL); + return; + } + if (action != UL_PAIR && action != UL_REPLACE && action != UL_EXPORT) + return; + HRESULT selected = choose_destination(owner, &path); + if (FAILED(selected) || path == NULL) { + if (selected != HRESULT_FROM_WIN32(ERROR_CANCELLED)) + show_dialog(show, owner, L"Could not choose a pairing file", L"No pairing changes were made. Try opening the file picker again.", NULL, 0u, IDCANCEL); + CoTaskMemFree(path); + return; + } + if (action == UL_REPLACE && show_dialog(show, owner, L"Replace this pairing?", + L"Existing clients will disconnect and old pairing files will stop working. Import the new file in Utterleaf to reconnect.", + &paired[1], 1u, IDCANCEL) != UL_REPLACE) { + CoTaskMemFree(path); + return; + } + result = action == UL_EXPORT ? ul_plugin_export(path) : ul_plugin_pair(path, action == UL_REPLACE, &saved); + CoTaskMemFree(path); + if (result == UL_PAIRING_CANCELLED) + return; + if (result == UL_PAIRING_OK) + show_dialog(show, owner, L"Pairing file saved", L"Explicitly import this file in Utterleaf. A copied file remains valid for this Windows account until you replace or forget pairing in OBS.", NULL, 0u, IDCANCEL); + else if (action == UL_EXPORT || saved) { + const wchar_t *detail = result == UL_PAIRING_POSTCOMMIT_INVALID ? + L"The pairing file may have been written, but its final verification failed. Your current pairing is still active. Choose a new filename and export again before importing it." : + L"Your current pairing is still active. The file was not exported. Choose Export pairing file and a new local filename to try again; existing files are never overwritten."; + show_dialog(show, owner, saved ? L"Pairing saved; export did not finish" : L"Pairing file could not be exported", detail, NULL, 0u, IDCANCEL); + } + else if (result == UL_PAIRING_POSTCOMMIT_INVALID) + show_dialog(show, owner, L"Pairing could not be verified", operation_error(result), NULL, 0u, IDCANCEL); + else if (ul_plugin_get_status().status == UL_PLUGIN_STORAGE_ERROR) + show_dialog(show, owner, L"Pairing is unavailable", + L"The saved pairing could not be activated or verified. An earlier pairing may already have been replaced. Open pairing controls again to resolve the saved pairing before reconnecting.", + NULL, 0u, IDCANCEL); + else + show_dialog(show, owner, action == UL_REPLACE ? L"Existing pairing kept" : L"No pairing was created", + action == UL_REPLACE ? + L"The replacement could not be saved. Your existing pairing and connections remain active. Check the storage location and available disk space, then try again." : + L"The new pairing could not be saved. Check the storage location and available disk space, then try again.", + NULL, 0u, IDCANCEL); +} + +void ul_pairing_ui_show(HWND owner) +{ + HMODULE self = NULL, common_controls = NULL; + HANDLE context = INVALID_HANDLE_VALUE; + ULONG_PTR cookie = 0; + bool activated = false; + HRESULT com = E_FAIL; + ACTCTXW activation = {0}; + ul_task_dialog_fn show = NULL; + if (owner == NULL || !IsWindow(owner) || + InterlockedCompareExchange(&dialog_running, 1, 0) != 0) + return; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCWSTR)(const void *)&dialog_running, &self)) + goto cleanup; + activation.cbSize = sizeof(activation); + activation.dwFlags = ACTCTX_FLAG_RESOURCE_NAME_VALID | ACTCTX_FLAG_HMODULE_VALID; + activation.hModule = self; + activation.lpResourceName = MAKEINTRESOURCEW(2); + context = CreateActCtxW(&activation); + if (context == INVALID_HANDLE_VALUE || !ActivateActCtx(context, &cookie)) + goto cleanup; + activated = true; + common_controls = LoadLibraryExW(L"comctl32.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (common_controls == NULL) + goto cleanup; + show = (ul_task_dialog_fn)(void *)GetProcAddress(common_controls, "TaskDialogIndirect"); + if (show == NULL) + goto cleanup; + com = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + if (SUCCEEDED(com)) + pairing_actions(show, owner); + else + show_dialog(show, owner, L"Pairing controls could not open", L"Restart OBS and try again. No pairing changes were made.", NULL, 0u, IDCANCEL); +cleanup: + if (SUCCEEDED(com)) + CoUninitialize(); + if (common_controls != NULL) + FreeLibrary(common_controls); + if (activated) + DeactivateActCtx(0, cookie); + if (context != INVALID_HANDLE_VALUE) + ReleaseActCtx(context); + if (show == NULL && ul_plugin_get_status().status != UL_PLUGIN_CLOSED && IsWindow(owner)) + MessageBoxW(owner, L"Windows could not open the pairing controls. Restart OBS and try again. No pairing changes were made.", + L"Utterleaf pairing", MB_OK | MB_ICONERROR); + InterlockedExchange(&dialog_running, 0); +} diff --git a/native/obs-plugin/src/pairing_ui.h b/native/obs-plugin/src/pairing_ui.h new file mode 100644 index 00000000..c5308e50 --- /dev/null +++ b/native/obs-plugin/src/pairing_ui.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef UTTERLEAF_OBS_PAIRING_UI_H +#define UTTERLEAF_OBS_PAIRING_UI_H +#include + +/* Called on the frontend thread, with its native top-level owner window. */ +void ul_pairing_ui_show(HWND owner); + +#endif diff --git a/native/obs-plugin/src/plugin_state.c b/native/obs-plugin/src/plugin_state.c new file mode 100644 index 00000000..19df1b3d --- /dev/null +++ b/native/obs-plugin/src/plugin_state.c @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "plugin_state.h" + +#include + +#include +#include +#include +#include + +#ifndef UL_PLUGIN_AUTH_TIMEOUT_MS +#define UL_PLUGIN_AUTH_TIMEOUT_MS 15000u +#endif + +#ifndef UL_PLUGIN_READY_TIMEOUT_MS +#define UL_PLUGIN_READY_TIMEOUT_MS 15000u +#endif + +typedef struct ul_plugin_runtime { + SRWLOCK operation_lock; + HANDLE leases_zero; + volatile LONG leases; + volatile LONG closing; + volatile LONG mutation_active; + ul_pairing_cancel mutation_cancel; + ul_pairing_store *store; + ul_authorizer *authorizer; + HANDLE worker; + HANDLE worker_cancel; + ul_admission *worker_admission; + volatile LONG worker_active; + ul_plugin_status status; + ul_pairing_result storage_result; + bool owns_store; +} ul_plugin_runtime; + +typedef struct ul_plugin_global { + SRWLOCK lock; + volatile LONG started; + ul_plugin_runtime *runtime; + ul_plugin_snapshot snapshot; +} ul_plugin_global; + +static ul_plugin_global plugin_global = { + SRWLOCK_INIT, + 0, + NULL, + {UL_PLUGIN_CLOSED, UL_PAIRING_CANCELLED, false, false}}; + +static void publish_runtime(ul_plugin_runtime *runtime) +{ + AcquireSRWLockExclusive(&plugin_global.lock); + if (plugin_global.runtime == runtime && + InterlockedCompareExchange(&plugin_global.started, 0, 0) == 1) { + plugin_global.snapshot.status = runtime->status; + plugin_global.snapshot.storage_result = runtime->storage_result; + plugin_global.snapshot.owns_store = runtime->owns_store; + plugin_global.snapshot.admission_pending = + InterlockedCompareExchange(&runtime->worker_active, 0, 0) != 0; + } + ReleaseSRWLockExclusive(&plugin_global.lock); +} + +static ul_plugin_runtime *runtime_acquire(void) +{ + ul_plugin_runtime *runtime = NULL; + + AcquireSRWLockShared(&plugin_global.lock); + if (InterlockedCompareExchange(&plugin_global.started, 0, 0) == 1 && + plugin_global.runtime != NULL && + InterlockedCompareExchange(&plugin_global.runtime->closing, 0, 0) == 0) { + runtime = plugin_global.runtime; + if (InterlockedIncrement(&runtime->leases) == 1) + ResetEvent(runtime->leases_zero); + } + ReleaseSRWLockShared(&plugin_global.lock); + return runtime; +} + +static void runtime_release(ul_plugin_runtime *runtime) +{ + if (InterlockedDecrement(&runtime->leases) == 0) + SetEvent(runtime->leases_zero); +} + +static void mutation_begin(ul_plugin_runtime *runtime) +{ + ul_pairing_cancel_init(&runtime->mutation_cancel); + InterlockedExchange(&runtime->mutation_active, 1); + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) + ul_pairing_cancel_request(&runtime->mutation_cancel); +} + +static void mutation_end(ul_plugin_runtime *runtime) +{ + InterlockedExchange(&runtime->mutation_active, 0); +} + +static DWORD WINAPI admission_worker(void *context) +{ + ul_plugin_runtime *runtime = (ul_plugin_runtime *)context; + ul_admission *admission = runtime->worker_admission; + HANDLE pipe = NULL; + int result; + + result = ul_admission_authenticate(admission, UL_PLUGIN_AUTH_TIMEOUT_MS); + if (result == UL_ADMISSION_AUTH_OK) { + pipe = ul_admission_pipe(admission); + (void)WaitForSingleObject(runtime->worker_cancel, + UL_PLUGIN_READY_TIMEOUT_MS); + } + ul_admission_cancel(admission); + if (pipe != NULL && pipe != INVALID_HANDLE_VALUE) + (void)DisconnectNamedPipe(pipe); + InterlockedExchange(&runtime->worker_active, 0); + publish_runtime(runtime); + return (DWORD)result; +} + +/* operation_lock is held. */ +static bool reap_worker(ul_plugin_runtime *runtime) +{ + DWORD wait_result; + + if (runtime->worker == NULL) + return true; + wait_result = WaitForSingleObject(runtime->worker, 0); + if (wait_result == WAIT_TIMEOUT) + return false; + if (wait_result != WAIT_OBJECT_0) + return false; + CloseHandle(runtime->worker); + runtime->worker = NULL; + runtime->worker_admission = NULL; + ul_authorizer_release(runtime->authorizer); + ResetEvent(runtime->worker_cancel); + return true; +} + +/* operation_lock is held; this permanently retires the current generation. */ +static void retire_authorizer(ul_plugin_runtime *runtime) +{ + if (runtime->authorizer == NULL) + return; + SetEvent(runtime->worker_cancel); + ul_authorizer_revoke(runtime->authorizer); + if (runtime->worker != NULL) { + (void)WaitForSingleObject(runtime->worker, INFINITE); + CloseHandle(runtime->worker); + runtime->worker = NULL; + runtime->worker_admission = NULL; + } + InterlockedExchange(&runtime->worker_active, 0); + ul_authorizer_destroy(runtime->authorizer); + runtime->authorizer = NULL; + ResetEvent(runtime->worker_cancel); +} + +bool ul_plugin_start(void) +{ + ul_plugin_runtime *runtime = NULL; + HMODULE module = NULL; + uint8_t key[UL_PAIRING_KEY_BYTES]; + ul_pairing_result result; + + SecureZeroMemory(key, sizeof(key)); + if (InterlockedCompareExchange(&plugin_global.started, 1, 0) != 0) + return false; + if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN | + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + (LPCWSTR)(const void *)&plugin_global, &module)) + goto permanent_failure; + (void)module; + runtime = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(*runtime)); + if (runtime == NULL) + goto permanent_failure; + InitializeSRWLock(&runtime->operation_lock); + runtime->leases_zero = CreateEventW(NULL, TRUE, TRUE, NULL); + runtime->worker_cancel = CreateEventW(NULL, TRUE, FALSE, NULL); + if (runtime->leases_zero == NULL || runtime->worker_cancel == NULL) + goto fail; + result = ul_pairing_store_open(&runtime->store); + if (result != UL_PAIRING_OK) { + runtime->status = UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = result; + goto ready; + } + result = ul_pairing_store_claim_owner(runtime->store); + if (result != UL_PAIRING_OK) { + runtime->status = result == UL_PAIRING_IN_USE ? UL_PLUGIN_IN_USE + : UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = result; + ul_pairing_store_destroy(runtime->store); + runtime->store = NULL; + goto ready; + } + runtime->owns_store = true; + result = ul_pairing_store_load(runtime->store, key); + if (result == UL_PAIRING_OK) { + runtime->authorizer = ul_authorizer_create(key); + if (runtime->authorizer == NULL) { + runtime->status = UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = UL_PAIRING_IO_ERROR; + } else { + runtime->status = UL_PLUGIN_PAIRED; + runtime->storage_result = UL_PAIRING_OK; + } + } else if (result == UL_PAIRING_MISSING) { + runtime->status = UL_PLUGIN_UNPAIRED; + runtime->storage_result = result; + } else { + runtime->status = UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = result; + } + +ready: + SecureZeroMemory(key, sizeof(key)); + AcquireSRWLockExclusive(&plugin_global.lock); + if (InterlockedCompareExchange(&plugin_global.started, 0, 0) != 1) { + ReleaseSRWLockExclusive(&plugin_global.lock); + AcquireSRWLockExclusive(&runtime->operation_lock); + retire_authorizer(runtime); + if (runtime->store != NULL) + ul_pairing_store_destroy(runtime->store); + ReleaseSRWLockExclusive(&runtime->operation_lock); + CloseHandle(runtime->worker_cancel); + CloseHandle(runtime->leases_zero); + SecureZeroMemory(runtime, sizeof(*runtime)); + HeapFree(GetProcessHeap(), 0, runtime); + return false; + } + plugin_global.runtime = runtime; + plugin_global.snapshot.status = runtime->status; + plugin_global.snapshot.storage_result = runtime->storage_result; + plugin_global.snapshot.owns_store = runtime->owns_store; + plugin_global.snapshot.admission_pending = false; + ReleaseSRWLockExclusive(&plugin_global.lock); + return true; + +fail: + SecureZeroMemory(key, sizeof(key)); + if (runtime->worker_cancel != NULL) + CloseHandle(runtime->worker_cancel); + if (runtime->leases_zero != NULL) + CloseHandle(runtime->leases_zero); + SecureZeroMemory(runtime, sizeof(*runtime)); + HeapFree(GetProcessHeap(), 0, runtime); +permanent_failure: + InterlockedExchange(&plugin_global.started, 2); + return false; +} + +void ul_plugin_stop_accepting(void) +{ + ul_plugin_runtime *runtime; + + AcquireSRWLockExclusive(&plugin_global.lock); + InterlockedExchange(&plugin_global.started, 2); + runtime = plugin_global.runtime; + plugin_global.snapshot.status = UL_PLUGIN_CLOSED; + plugin_global.snapshot.storage_result = UL_PAIRING_CANCELLED; + plugin_global.snapshot.owns_store = false; + plugin_global.snapshot.admission_pending = false; + if (runtime != NULL) + InterlockedExchange(&runtime->closing, 1); + ReleaseSRWLockExclusive(&plugin_global.lock); + if (runtime == NULL) + return; + if (InterlockedCompareExchange(&runtime->mutation_active, 0, 0) != 0) + ul_pairing_cancel_request(&runtime->mutation_cancel); + SetEvent(runtime->worker_cancel); +} + +void ul_plugin_close(void) +{ + ul_plugin_runtime *runtime; + + ul_plugin_stop_accepting(); + AcquireSRWLockExclusive(&plugin_global.lock); + runtime = plugin_global.runtime; + plugin_global.runtime = NULL; + ReleaseSRWLockExclusive(&plugin_global.lock); + if (runtime == NULL) + return; + + AcquireSRWLockExclusive(&runtime->operation_lock); + retire_authorizer(runtime); + ReleaseSRWLockExclusive(&runtime->operation_lock); + (void)WaitForSingleObject(runtime->leases_zero, INFINITE); + AcquireSRWLockExclusive(&runtime->operation_lock); + if (runtime->store != NULL) + ul_pairing_store_destroy(runtime->store); + runtime->store = NULL; + ReleaseSRWLockExclusive(&runtime->operation_lock); + CloseHandle(runtime->worker_cancel); + CloseHandle(runtime->leases_zero); + SecureZeroMemory(runtime, sizeof(*runtime)); + HeapFree(GetProcessHeap(), 0, runtime); +} + +ul_plugin_snapshot ul_plugin_get_status(void) +{ + ul_plugin_snapshot snapshot; + ul_plugin_runtime *runtime = runtime_acquire(); + + if (runtime != NULL) { + if (TryAcquireSRWLockExclusive(&runtime->operation_lock)) { + (void)reap_worker(runtime); + ReleaseSRWLockExclusive(&runtime->operation_lock); + } + runtime_release(runtime); + } + + AcquireSRWLockShared(&plugin_global.lock); + snapshot = plugin_global.snapshot; + ReleaseSRWLockShared(&plugin_global.lock); + return snapshot; +} + +ul_pairing_result ul_plugin_pair(const wchar_t *destination, bool replace, + bool *pairing_saved) +{ + ul_plugin_runtime *runtime; + uint8_t key[UL_PAIRING_KEY_BYTES]; + ul_pairing_result result; + + if (pairing_saved != NULL) + *pairing_saved = false; + if (pairing_saved == NULL || destination == NULL) + return UL_PAIRING_INVALID_ARGUMENT; + runtime = runtime_acquire(); + if (runtime == NULL) + return UL_PAIRING_CANCELLED; + SecureZeroMemory(key, sizeof(key)); + AcquireSRWLockExclusive(&runtime->operation_lock); + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) { + result = UL_PAIRING_CANCELLED; + goto cleanup; + } + if (!runtime->owns_store || runtime->store == NULL) { + result = runtime->status == UL_PLUGIN_IN_USE ? UL_PAIRING_IN_USE + : runtime->storage_result; + goto cleanup; + } + if (replace && runtime->status != UL_PLUGIN_PAIRED) { + result = runtime->status == UL_PLUGIN_UNPAIRED + ? UL_PAIRING_MISSING + : runtime->storage_result; + goto cleanup; + } + if (!replace && runtime->status != UL_PLUGIN_UNPAIRED) { + result = runtime->status == UL_PLUGIN_PAIRED + ? UL_PAIRING_EXISTS + : runtime->storage_result; + goto cleanup; + } + mutation_begin(runtime); + result = replace ? ul_pairing_store_replace(runtime->store, + &runtime->mutation_cancel, key) + : ul_pairing_store_create(runtime->store, + &runtime->mutation_cancel, key); + mutation_end(runtime); + if (result != UL_PAIRING_OK) { + if (result == UL_PAIRING_POSTCOMMIT_INVALID) { + retire_authorizer(runtime); + runtime->status = UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = result; + } else { + runtime->storage_result = result; + } + goto publish; + } + retire_authorizer(runtime); + runtime->authorizer = ul_authorizer_create(key); + SecureZeroMemory(key, sizeof(key)); + if (runtime->authorizer == NULL) { + runtime->status = UL_PLUGIN_STORAGE_ERROR; + runtime->storage_result = UL_PAIRING_IO_ERROR; + result = UL_PAIRING_IO_ERROR; + goto publish; + } + runtime->status = UL_PLUGIN_PAIRED; + runtime->storage_result = UL_PAIRING_OK; + *pairing_saved = true; + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) { + result = UL_PAIRING_CANCELLED; + goto publish; + } + mutation_begin(runtime); + result = ul_pairing_store_export(runtime->store, destination, + &runtime->mutation_cancel); + mutation_end(runtime); + runtime->storage_result = result; + +publish: + publish_runtime(runtime); +cleanup: + SecureZeroMemory(key, sizeof(key)); + ReleaseSRWLockExclusive(&runtime->operation_lock); + runtime_release(runtime); + return result; +} + +ul_pairing_result ul_plugin_export(const wchar_t *destination) +{ + ul_plugin_runtime *runtime; + ul_pairing_result result; + + if (destination == NULL) + return UL_PAIRING_INVALID_ARGUMENT; + runtime = runtime_acquire(); + if (runtime == NULL) + return UL_PAIRING_CANCELLED; + AcquireSRWLockExclusive(&runtime->operation_lock); + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) { + result = UL_PAIRING_CANCELLED; + } else if (!runtime->owns_store || runtime->store == NULL) { + result = runtime->status == UL_PLUGIN_IN_USE ? UL_PAIRING_IN_USE + : runtime->storage_result; + } else if (runtime->status != UL_PLUGIN_PAIRED) { + result = runtime->status == UL_PLUGIN_UNPAIRED + ? UL_PAIRING_MISSING + : runtime->storage_result; + } else { + mutation_begin(runtime); + result = ul_pairing_store_export(runtime->store, destination, + &runtime->mutation_cancel); + mutation_end(runtime); + runtime->storage_result = result; + publish_runtime(runtime); + } + ReleaseSRWLockExclusive(&runtime->operation_lock); + runtime_release(runtime); + return result; +} + +ul_pairing_result ul_plugin_forget(void) +{ + ul_plugin_runtime *runtime = runtime_acquire(); + ul_pairing_result result; + + if (runtime == NULL) + return UL_PAIRING_CANCELLED; + AcquireSRWLockExclusive(&runtime->operation_lock); + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) { + result = UL_PAIRING_CANCELLED; + goto cleanup; + } + if (!runtime->owns_store || runtime->store == NULL) { + result = runtime->status == UL_PLUGIN_IN_USE ? UL_PAIRING_IN_USE + : runtime->storage_result; + goto cleanup; + } + retire_authorizer(runtime); + mutation_begin(runtime); + result = ul_pairing_store_forget(runtime->store); + mutation_end(runtime); + if (result == UL_PAIRING_OK || result == UL_PAIRING_MISSING) { + runtime->status = UL_PLUGIN_UNPAIRED; + } else { + runtime->status = UL_PLUGIN_STORAGE_ERROR; + } + runtime->storage_result = result; + publish_runtime(runtime); + +cleanup: + ReleaseSRWLockExclusive(&runtime->operation_lock); + runtime_release(runtime); + return result; +} + +bool ul_plugin_issue( + DWORD client_pid, const uint8_t session[16], uint8_t additional_mix_mask, + uint8_t out_challenge[UL_AUTHORIZATION_CHALLENGE_BYTES]) +{ + ul_plugin_runtime *runtime; + bool success = false; + + if (out_challenge == NULL) + return false; + SecureZeroMemory(out_challenge, UL_AUTHORIZATION_CHALLENGE_BYTES); + runtime = runtime_acquire(); + if (runtime == NULL) + return false; + if (!TryAcquireSRWLockExclusive(&runtime->operation_lock)) { + runtime_release(runtime); + return false; + } + if (InterlockedCompareExchange(&runtime->closing, 0, 0) == 0 && + runtime->status == UL_PLUGIN_PAIRED && runtime->owns_store && + runtime->authorizer != NULL && reap_worker(runtime) && + runtime->worker == NULL) { + success = ul_authorizer_issue(runtime->authorizer, client_pid, session, + additional_mix_mask, out_challenge); + } + ReleaseSRWLockExclusive(&runtime->operation_lock); + runtime_release(runtime); + return success; +} + +bool ul_plugin_prepare(const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size) +{ + ul_plugin_runtime *runtime = runtime_acquire(); + ul_prepare_options options; + ul_admission *admission; + bool success = false; + + SecureZeroMemory(&options, sizeof(options)); + if (runtime == NULL) + return false; + if (!TryAcquireSRWLockExclusive(&runtime->operation_lock)) { + runtime_release(runtime); + return false; + } + if (InterlockedCompareExchange(&runtime->closing, 0, 0) != 0 || + runtime->status != UL_PLUGIN_PAIRED || !runtime->owns_store || + runtime->authorizer == NULL || !reap_worker(runtime) || + runtime->worker != NULL) + goto cleanup; + admission = ul_authorizer_prepare(runtime->authorizer, challenge, + challenge_size, proof, proof_size, + &options); + SecureZeroMemory(&options, sizeof(options)); + if (admission == NULL || + InterlockedCompareExchange(&runtime->closing, 0, 0) != 0) + goto cleanup; + ResetEvent(runtime->worker_cancel); + runtime->worker_admission = admission; + InterlockedExchange(&runtime->worker_active, 1); + runtime->worker = CreateThread(NULL, 0, admission_worker, runtime, 0, NULL); + if (runtime->worker == NULL) { + InterlockedExchange(&runtime->worker_active, 0); + runtime->worker_admission = NULL; + ul_authorizer_release(runtime->authorizer); + goto cleanup; + } + publish_runtime(runtime); + success = true; + +cleanup: + SecureZeroMemory(&options, sizeof(options)); + ReleaseSRWLockExclusive(&runtime->operation_lock); + runtime_release(runtime); + return success; +} diff --git a/native/obs-plugin/src/plugin_state.h b/native/obs-plugin/src/plugin_state.h new file mode 100644 index 00000000..35ed6b8c --- /dev/null +++ b/native/obs-plugin/src/plugin_state.h @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef UTTERLEAF_OBS_PLUGIN_STATE_H +#define UTTERLEAF_OBS_PLUGIN_STATE_H + +#include "authorization.h" +#include "pairing_store.h" + +#include + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef enum ul_plugin_status { + UL_PLUGIN_CLOSED = 0, + UL_PLUGIN_UNPAIRED = 1, + UL_PLUGIN_PAIRED = 2, + UL_PLUGIN_STORAGE_ERROR = 3, + UL_PLUGIN_IN_USE = 4 +} ul_plugin_status; + +typedef struct ul_plugin_snapshot { + ul_plugin_status status; + ul_pairing_result storage_result; + bool owns_store; + /* False means no active authenticate/READY worker. Reaping is deferred. */ + bool admission_pending; +} ul_plugin_snapshot; + +/* + * Permanently pins this DLL generation before callbacks may be registered. + * A process may start this component once; close is final and cannot be reset. + * True means the status API is usable, including unpaired/error/in-use states. + */ +bool ul_plugin_start(void); + +/* + * Permanently closes callback admission and signals stable cancellation state. + * This is nonblocking; call close after unregistering external callbacks. + */ +void ul_plugin_stop_accepting(void); + +/* Safe to repeat. New calls fail once close begins; in-flight calls are drained. */ +void ul_plugin_close(void); + +/* Returns a value copy that remains valid after close. */ +ul_plugin_snapshot ul_plugin_get_status(void); + +/* + * Persist a new role-1 key and export its role-2 package. pairing_saved is + * cleared on entry and set only after the new role-1 record is verified and + * its authorizer is installed. An export error can therefore be returned with + * pairing_saved true and a PAIRED snapshot. + */ +ul_pairing_result ul_plugin_pair(const wchar_t *destination, bool replace, + bool *pairing_saved); + +ul_pairing_result ul_plugin_export(const wchar_t *destination); + +/* Revokes live authorization before attempting durable deletion. */ +ul_pairing_result ul_plugin_forget(void); + +bool ul_plugin_issue( + DWORD client_pid, const uint8_t session[16], uint8_t additional_mix_mask, + uint8_t out_challenge[UL_AUTHORIZATION_CHALLENGE_BYTES]); + +/* Malformed byte inputs are forwarded so they consume an outstanding attempt. */ +bool ul_plugin_prepare(const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/obs-plugin/src/vendor_dispatch.c b/native/obs-plugin/src/vendor_dispatch.c new file mode 100644 index 00000000..ba74595f --- /dev/null +++ b/native/obs-plugin/src/vendor_dispatch.c @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "vendor_dispatch.h" +#include "authorization.h" +#include "plugin_state.h" + +#include +#include +#include + +static volatile LONG dispatch_enabled; + +void ul_vendor_set_enabled(bool enabled) +{ + InterlockedExchange(&dispatch_enabled, enabled ? 1 : 0); +} + +/* OBS owns JSON decoding. These checks describe the parsed public obs_data + * boundary, not access to the original JSON text or duplicate-key spelling. */ +static bool exact_fields(obs_data_t *request, const char *const *names, + const enum obs_data_type *types, size_t count) +{ + unsigned seen = 0u; + obs_data_item_t *item; + if (request == NULL) + return false; + item = obs_data_first(request); + while (item != NULL) { + const char *name = obs_data_item_get_name(item); + size_t index; + for (index = 0u; index < count; ++index) + if (name != NULL && strcmp(name, names[index]) == 0) + break; + if (index == count || (seen & (1u << index)) != 0u || + !obs_data_item_has_user_value(item) || + obs_data_item_gettype(item) != types[index] || + (types[index] == OBS_DATA_NUMBER && + obs_data_item_numtype(item) != OBS_DATA_NUM_INT)) { + obs_data_item_release(&item); + return false; + } + seen |= 1u << index; + obs_data_item_next(&item); + } + return seen == (1u << count) - 1u; +} + +static int hex_digit(char value) +{ + if (value >= '0' && value <= '9') + return value - '0'; + if (value >= 'a' && value <= 'f') + return value - 'a' + 10; + return -1; +} + +static bool decode_hex(const char *value, uint8_t *output, size_t length) +{ + if (value == NULL) + return false; + for (size_t i = 0u; i < length; ++i) { + int high = hex_digit(value[i * 2u]); + int low; + /* Stop on the first terminator; never read beyond a short C string. */ + if (high < 0) + return false; + low = hex_digit(value[i * 2u + 1u]); + if (low < 0) + return false; + output[i] = (uint8_t)((high << 4) | low); + } + return value[length * 2u] == '\0'; +} + +static void encode_hex(const uint8_t *value, size_t length, char *output) +{ + static const char digits[] = "0123456789abcdef"; + for (size_t i = 0u; i < length; ++i) { + output[i * 2u] = digits[value[i] >> 4]; + output[i * 2u + 1u] = digits[value[i] & 15u]; + } + output[length * 2u] = '\0'; +} + +void ul_vendor_issue(obs_data_t *request, obs_data_t *response, void *private_data) +{ + static const char *const names[] = {"clientPid", "sessionId", "additionalMixMask"}; + static const enum obs_data_type types[] = {OBS_DATA_NUMBER, OBS_DATA_STRING, OBS_DATA_NUMBER}; + uint8_t session[16] = {0}; + uint8_t challenge[UL_AUTHORIZATION_CHALLENGE_BYTES] = {0}; + char encoded[UL_AUTHORIZATION_CHALLENGE_BYTES * 2u + 1u]; + long long pid, mask; + unsigned nonzero = 0u; + (void)private_data; + if (response == NULL) + return; + obs_data_clear(response); + obs_data_set_bool(response, "ok", false); + if (InterlockedCompareExchange(&dispatch_enabled, 0, 0) == 0 || + ul_plugin_get_status().status == UL_PLUGIN_CLOSED) + return; + if (!exact_fields(request, names, types, 3u)) + return; + pid = obs_data_get_int(request, "clientPid"); + mask = obs_data_get_int(request, "additionalMixMask"); + if (pid < 5 || pid > UINT32_MAX || mask < 0 || mask > 63 || + !decode_hex(obs_data_get_string(request, "sessionId"), session, sizeof(session))) + return; + for (size_t i = 0u; i < sizeof(session); ++i) + nonzero |= session[i]; + if (nonzero == 0u || !ul_plugin_issue((DWORD)pid, session, (uint8_t)mask, challenge)) + return; + encode_hex(challenge, sizeof(challenge), encoded); + obs_data_set_int(response, "protocolVersion", 1); + obs_data_set_string(response, "challenge", encoded); + obs_data_set_bool(response, "ok", true); +} + +void ul_vendor_prepare(obs_data_t *request, obs_data_t *response, void *private_data) +{ + static const char *const names[] = {"challenge", "proof"}; + static const enum obs_data_type types[] = {OBS_DATA_STRING, OBS_DATA_STRING}; + uint8_t challenge[UL_AUTHORIZATION_CHALLENGE_BYTES] = {0}; + uint8_t proof[UL_AUTHORIZATION_PROOF_BYTES] = {0}; + bool valid, prepared; + (void)private_data; + if (response == NULL) + return; + obs_data_clear(response); + obs_data_set_bool(response, "ok", false); + if (InterlockedCompareExchange(&dispatch_enabled, 0, 0) == 0 || + ul_plugin_get_status().status == UL_PLUGIN_CLOSED) + return; + valid = exact_fields(request, names, types, 2u) && + decode_hex(obs_data_get_string(request, "challenge"), challenge, sizeof(challenge)) && + decode_hex(obs_data_get_string(request, "proof"), proof, sizeof(proof)); + /* Even a malformed attempt consumes the one outstanding challenge. */ + prepared = ul_plugin_prepare(valid ? challenge : NULL, valid ? sizeof(challenge) : 0u, + valid ? proof : NULL, valid ? sizeof(proof) : 0u); + SecureZeroMemory(proof, sizeof(proof)); + if (valid && prepared) { + obs_data_set_int(response, "protocolVersion", 1); + obs_data_set_bool(response, "ok", true); + } +} diff --git a/native/obs-plugin/src/vendor_dispatch.h b/native/obs-plugin/src/vendor_dispatch.h new file mode 100644 index 00000000..150d67f1 --- /dev/null +++ b/native/obs-plugin/src/vendor_dispatch.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#ifndef UTTERLEAF_OBS_VENDOR_DISPATCH_H +#define UTTERLEAF_OBS_VENDOR_DISPATCH_H + +#include + +/* Enable only after both endpoints are registered. Disable before teardown. */ +void ul_vendor_set_enabled(bool enabled); + +/* Static callbacks; private_data is unused and must never own runtime memory. */ +void ul_vendor_issue(obs_data_t *request, obs_data_t *response, void *private_data); +void ul_vendor_prepare(obs_data_t *request, obs_data_t *response, void *private_data); + +#endif diff --git a/native/obs-plugin/tests/bridge_test.c b/native/obs-plugin/tests/bridge_test.c new file mode 100644 index 00000000..8ab4787c --- /dev/null +++ b/native/obs-plugin/tests/bridge_test.c @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* Public wrapper substitutions: no OBS startup, store, UI or audio. */ +#include +#include +#include +#include +#include +#include +#include "../src/plugin_state.h" +#include "../src/pairing_ui.h" +#include "../src/vendor_dispatch.h" + +static unsigned start_calls, register_calls, unregister_calls, tools_calls, event_calls; +static bool has_window, allow_start, allow_issue, allow_prepare, allow_unregister, enabled; +static bool exiting; +static unsigned api_version; +static char order[32]; +static size_t order_size; +static ul_plugin_snapshot snapshot; + +static void record(char value) { assert(order_size + 1 < sizeof(order)); order[order_size++] = value; order[order_size] = 0; } +static uint32_t test_version(void) { return LIBOBS_API_VER; } +static obs_module_t *test_module(void) { return (obs_module_t *)(uintptr_t)1; } +static void *test_window(void) { return has_window ? (void *)(uintptr_t)1 : NULL; } +static unsigned test_api(void) { return api_version; } +static void test_event(obs_frontend_event_cb callback, void *data) +{ assert(callback != NULL && data == NULL); ++event_calls; } +static void test_menu(const char *name, obs_frontend_cb callback, void *data) +{ assert(strcmp(name, "Utterleaf pairing...") == 0 && callback != NULL && data == NULL); ++tools_calls; } +static obs_websocket_vendor test_vendor(const char *name) +{ assert(strcmp(name, "Utterleaf") == 0); ++register_calls; return (void *)(uintptr_t)2; } +static bool test_register(obs_websocket_vendor handle, const char *name, + obs_websocket_request_callback_function callback, void *data) +{ + assert(handle == (void *)(uintptr_t)2 && data == NULL && !enabled); + ++register_calls; + if (strcmp(name, "IssueAuthorization") == 0) { assert(callback == ul_vendor_issue); return allow_issue; } + assert(strcmp(name, "PrepareSession") == 0 && callback == ul_vendor_prepare); + return allow_prepare; +} +static bool test_unregister(obs_websocket_vendor handle, const char *name) +{ + assert(handle == (void *)(uintptr_t)2 && !enabled); + if (exiting) assert(snapshot.status == UL_PLUGIN_CLOSED); + ++unregister_calls; + record(strcmp(name, "PrepareSession") == 0 ? 'P' : 'I'); + return allow_unregister; +} +static void test_log(int level, const char *format, ...) { (void)level; (void)format; } +bool ul_plugin_start(void) { ++start_calls; return allow_start; } +ul_plugin_snapshot ul_plugin_get_status(void) { return snapshot; } +void ul_plugin_stop_accepting(void) { assert(!enabled); snapshot.status = UL_PLUGIN_CLOSED; record('S'); } +void ul_plugin_close(void) { assert(!enabled); snapshot.status = UL_PLUGIN_CLOSED; record('C'); } +void ul_vendor_set_enabled(bool value) { enabled = value; record(value ? 'E' : 'D'); } +void ul_pairing_ui_show(HWND owner) { assert(owner != NULL); } +void ul_vendor_issue(obs_data_t *request, obs_data_t *response, void *data) +{ (void)request; (void)response; (void)data; } +void ul_vendor_prepare(obs_data_t *request, obs_data_t *response, void *data) +{ (void)request; (void)response; (void)data; } + +#undef OBS_DECLARE_MODULE +#define OBS_DECLARE_MODULE() +#define obs_get_version test_version +#define obs_current_module test_module +#define obs_frontend_get_main_window_handle test_window +#define obs_frontend_add_event_callback test_event +#define obs_frontend_add_tools_menu_item test_menu +#define obs_websocket_get_api_version test_api +#define obs_websocket_register_vendor test_vendor +#define obs_websocket_vendor_register_request test_register +#define obs_websocket_vendor_unregister_request test_unregister +#define blog test_log +#include "../src/bridge.c" + +static void reset(void) +{ + start_calls = register_calls = unregister_calls = tools_calls = event_calls = 0; + has_window = allow_start = allow_issue = allow_prepare = allow_unregister = true; + enabled = exiting = false; + api_version = OBS_WEBSOCKET_API_VERSION; + snapshot = (ul_plugin_snapshot){UL_PLUGIN_UNPAIRED, UL_PAIRING_MISSING, true, false}; + vendor = NULL; + issue_registered = prepare_registered = false; + order_size = 0; + order[0] = 0; +} + +int main(void) +{ + reset(); has_window = false; + assert(!obs_module_load() && start_calls == 0 && tools_calls == 0 && event_calls == 0); + reset(); allow_start = false; + assert(!obs_module_load() && start_calls == 1 && tools_calls == 0 && event_calls == 0); + reset(); + assert(obs_module_load() && start_calls == 1 && tools_calls == 1 && event_calls == 1); + obs_module_post_load(); + assert(enabled && register_calls == 3 && issue_registered && prepare_registered); + obs_module_post_load(); assert(register_calls == 3); + order_size = 0; order[0] = 0; exiting = true; + frontend_event(OBS_FRONTEND_EVENT_EXIT, NULL); + assert(strcmp(order, "DSPIC") == 0 && unregister_calls == 2 && !enabled); + + reset(); allow_prepare = allow_unregister = false; + obs_module_post_load(); + assert(!enabled && issue_registered && !prepare_registered && unregister_calls == 1); + order_size = 0; order[0] = 0; exiting = true; + frontend_event(OBS_FRONTEND_EVENT_EXIT, NULL); + assert(strcmp(order, "DSIC") == 0 && unregister_calls == 2 && !enabled); + + reset(); allow_prepare = false; + obs_module_post_load(); + assert(!enabled && !issue_registered && !prepare_registered && unregister_calls == 1); + reset(); allow_issue = false; + obs_module_post_load(); assert(!enabled && register_calls == 2 && unregister_calls == 0); + reset(); api_version = 0; + obs_module_post_load(); assert(!enabled && register_calls == 0); + reset(); snapshot.owns_store = false; + obs_module_post_load(); assert(!enabled && register_calls == 0); + + reset(); obs_module_post_load(); + order_size = 0; order[0] = 0; + obs_module_unload(); + assert(strcmp(order, "DC") == 0 && !enabled && unregister_calls == 0); + assert(snapshot.status == UL_PLUGIN_CLOSED && event_calls == 0 && tools_calls == 0); + puts("bridge wrapper lifecycle tests passed"); + return 0; +} diff --git a/native/obs-plugin/tests/pairing_store_test.c b/native/obs-plugin/tests/pairing_store_test.c index 78a50241..6717c556 100644 --- a/native/obs-plugin/tests/pairing_store_test.c +++ b/native/obs-plugin/tests/pairing_store_test.c @@ -274,6 +274,9 @@ static int cleanup_root(const wchar_t *root) swprintf(path, MAX_PATH, L"%ls\\Utterleaf\\obs-plugin\\pairing-v1.dat", root); failures += delete_known_file(path); + swprintf(path, MAX_PATH, L"%ls\\Utterleaf\\obs-plugin\\owner-v1.lock", + root); + failures += delete_known_file(path); swprintf(path, MAX_PATH, L"%ls\\Utterleaf\\obs-plugin", root); leaf_attributes = GetFileAttributesW(path); if (leaf_attributes != INVALID_FILE_ATTRIBUTES && @@ -758,15 +761,141 @@ static int test_volume_and_reparse_refusal(void) return failures; } -int main(void) +static int owner_child(void) +{ + wchar_t root[MAX_PATH]; + ul_pairing_store *store = NULL; + ul_pairing_result result; + + SecureZeroMemory(root, sizeof(root)); + if (GetEnvironmentVariableW(L"UL_PAIRING_OWNER_TEST_ROOT", root, + MAX_PATH) == 0) + return 1; + result = ul_pairing_store_open_test_root(root, &store); + if (result != UL_PAIRING_OK || store == NULL) + return 1; + result = ul_pairing_store_claim_owner(store); + ul_pairing_store_destroy(store); + return result == UL_PAIRING_IN_USE ? 0 : 1; +} + +static int test_owner_claim(void) +{ + wchar_t root[MAX_PATH]; + wchar_t executable[MAX_PATH]; + wchar_t command[MAX_PATH + 32]; + STARTUPINFOW startup; + PROCESS_INFORMATION process; + ul_pairing_store *store = NULL; + ul_pairing_store *successor = NULL; + ul_pairing_store *invalid = NULL; + HANDLE owner_file = INVALID_HANDLE_VALUE; + BYTE marker = 1; + DWORD written = 0; + DWORD flags = HANDLE_FLAG_INHERIT; + DWORD exit_code = 1; + DWORD wait_result; + int failures = 0; + + SecureZeroMemory(root, sizeof(root)); + SecureZeroMemory(executable, sizeof(executable)); + SecureZeroMemory(command, sizeof(command)); + SecureZeroMemory(&startup, sizeof(startup)); + SecureZeroMemory(&process, sizeof(process)); + startup.cb = sizeof(startup); + failures += check(make_root(root), "create owner root"); + if (failures != 0) + return failures; + failures += check(ul_pairing_store_open_test_root(root, &store) == + UL_PAIRING_OK && + store != NULL, + "open owner store"); + if (store == NULL) { + failures += cleanup_root(root); + return failures; + } + failures += check(ul_pairing_store_claim_owner(store) == UL_PAIRING_OK, + "claim owner"); + failures += check(ul_pairing_store_claim_owner(store) == UL_PAIRING_OK, + "owner claim idempotent"); + failures += check(store->owner != INVALID_HANDLE_VALUE && + verify_private_security(store->owner, + store->user_sid) == + UL_PAIRING_OK, + "owner lock private ACL"); + failures += check(GetHandleInformation(store->owner, &flags) && + (flags & HANDLE_FLAG_INHERIT) == 0, + "owner lock handle noninheritable"); + failures += check(GetModuleFileNameW(NULL, executable, MAX_PATH) > 0, + "locate owner test executable"); + failures += check(SetEnvironmentVariableW(L"UL_PAIRING_OWNER_TEST_ROOT", + root), + "publish owner child root"); + if (_snwprintf(command, MAX_PATH + 32, L"\"%ls\" --owner-child", + executable) < 0) + failures += check(false, "build owner child command"); + if (failures == 0) + failures += check(CreateProcessW(executable, command, NULL, NULL, + FALSE, 0, NULL, NULL, &startup, + &process), + "start competing owner process"); + (void)SetEnvironmentVariableW(L"UL_PAIRING_OWNER_TEST_ROOT", NULL); + if (process.hProcess != NULL) { + wait_result = WaitForSingleObject(process.hProcess, 5000); + if (wait_result != WAIT_OBJECT_0) + ExitProcess(1); + failures += check(GetExitCodeProcess(process.hProcess, &exit_code) && + exit_code == 0, + "other process observes store in use"); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } + ul_pairing_store_destroy(store); + store = NULL; + failures += check(ul_pairing_store_open_test_root(root, &successor) == + UL_PAIRING_OK && + successor != NULL && + ul_pairing_store_claim_owner(successor) == + UL_PAIRING_OK, + "owner released at destroy"); + ul_pairing_store_destroy(successor); + successor = NULL; + swprintf(command, MAX_PATH + 32, + L"%ls\\Utterleaf\\obs-plugin\\owner-v1.lock", root); + owner_file = CreateFileW(command, GENERIC_WRITE, 0, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | + FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + failures += check(owner_file != INVALID_HANDLE_VALUE && + WriteFile(owner_file, &marker, sizeof(marker), + &written, NULL) && + written == sizeof(marker), + "make invalid nonempty owner file"); + if (owner_file != INVALID_HANDLE_VALUE) + CloseHandle(owner_file); + failures += check(ul_pairing_store_open_test_root(root, &invalid) == + UL_PAIRING_OK && + invalid != NULL && + ul_pairing_store_claim_owner(invalid) == + UL_PAIRING_UNSAFE_SECURITY, + "nonempty owner file refused"); + ul_pairing_store_destroy(invalid); + failures += cleanup_root(root); + return failures; +} + +int main(int argc, char **argv) { int failures = 0; + if (argc == 2 && strcmp(argv[1], "--owner-child") == 0) + return owner_child(); failures += test_round_trip_and_security(); failures += test_cancel_and_faults(); failures += test_corruption_and_refusal(); failures += test_codec_strictness(); failures += test_volume_and_reparse_refusal(); + failures += test_owner_claim(); if (failures == 0) puts("pairing store tests passed"); return failures == 0 ? 0 : 1; diff --git a/native/obs-plugin/tests/plugin_state_test.c b/native/obs-plugin/tests/plugin_state_test.c new file mode 100644 index 00000000..025ec2ad --- /dev/null +++ b/native/obs-plugin/tests/plugin_state_test.c @@ -0,0 +1,819 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include + +#include "../src/plugin_state.h" + +static BOOL WINAPI shim_GetModuleHandleExW(DWORD flags, LPCWSTR address, + HMODULE *module); +static ul_pairing_result +shim_ul_pairing_store_open(ul_pairing_store **store_out); +static void shim_ul_pairing_cancel_init(ul_pairing_cancel *cancel); +static void shim_ul_pairing_cancel_request(ul_pairing_cancel *cancel); +static void shim_ul_pairing_store_destroy(ul_pairing_store *store); +static ul_pairing_result +shim_ul_pairing_store_claim_owner(ul_pairing_store *store); +static ul_pairing_result shim_ul_pairing_store_load(ul_pairing_store *store, + uint8_t key[32]); +static ul_pairing_result shim_ul_pairing_store_create( + ul_pairing_store *store, const ul_pairing_cancel *cancel, uint8_t key[32]); +static ul_pairing_result shim_ul_pairing_store_replace( + ul_pairing_store *store, const ul_pairing_cancel *cancel, uint8_t key[32]); +static ul_pairing_result shim_ul_pairing_store_export( + ul_pairing_store *store, const wchar_t *destination, + const ul_pairing_cancel *cancel); +static ul_pairing_result +shim_ul_pairing_store_forget(ul_pairing_store *store); +static ul_authorizer *shim_ul_authorizer_create(const uint8_t key[32]); +static bool shim_ul_authorizer_issue(ul_authorizer *authorizer, + DWORD client_pid, + const uint8_t session[16], uint8_t mask, + uint8_t out_challenge[60]); +static ul_admission *shim_ul_authorizer_prepare( + ul_authorizer *authorizer, const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size, ul_prepare_options *options); +static void shim_ul_authorizer_revoke(ul_authorizer *authorizer); +static void shim_ul_authorizer_release(ul_authorizer *authorizer); +static void shim_ul_authorizer_destroy(ul_authorizer *authorizer); +static int shim_ul_admission_authenticate(ul_admission *admission, + DWORD timeout_ms); +static void shim_ul_admission_cancel(ul_admission *admission); +static HANDLE shim_ul_admission_pipe(const ul_admission *admission); + +#define GetModuleHandleExW shim_GetModuleHandleExW +#define ul_pairing_store_open shim_ul_pairing_store_open +#define ul_pairing_cancel_init shim_ul_pairing_cancel_init +#define ul_pairing_cancel_request shim_ul_pairing_cancel_request +#define ul_pairing_store_destroy shim_ul_pairing_store_destroy +#define ul_pairing_store_claim_owner shim_ul_pairing_store_claim_owner +#define ul_pairing_store_load shim_ul_pairing_store_load +#define ul_pairing_store_create shim_ul_pairing_store_create +#define ul_pairing_store_replace shim_ul_pairing_store_replace +#define ul_pairing_store_export shim_ul_pairing_store_export +#define ul_pairing_store_forget shim_ul_pairing_store_forget +#define ul_authorizer_create shim_ul_authorizer_create +#define ul_authorizer_issue shim_ul_authorizer_issue +#define ul_authorizer_prepare shim_ul_authorizer_prepare +#define ul_authorizer_revoke shim_ul_authorizer_revoke +#define ul_authorizer_release shim_ul_authorizer_release +#define ul_authorizer_destroy shim_ul_authorizer_destroy +#define ul_admission_authenticate shim_ul_admission_authenticate +#define ul_admission_cancel shim_ul_admission_cancel +#define ul_admission_pipe shim_ul_admission_pipe +#define UL_PLUGIN_AUTH_TIMEOUT_MS 40u +#define UL_PLUGIN_READY_TIMEOUT_MS 40u +#include "../src/plugin_state.c" +#undef GetModuleHandleExW +#undef ul_pairing_store_open +#undef ul_pairing_cancel_init +#undef ul_pairing_cancel_request +#undef ul_pairing_store_destroy +#undef ul_pairing_store_claim_owner +#undef ul_pairing_store_load +#undef ul_pairing_store_create +#undef ul_pairing_store_replace +#undef ul_pairing_store_export +#undef ul_pairing_store_forget +#undef ul_authorizer_create +#undef ul_authorizer_issue +#undef ul_authorizer_prepare +#undef ul_authorizer_revoke +#undef ul_authorizer_release +#undef ul_authorizer_destroy +#undef ul_admission_authenticate +#undef ul_admission_cancel +#undef ul_admission_pipe + +struct ul_pairing_store { + int unused; +}; + +struct ul_admission { + HANDLE cancelled; + int authenticate_result; +}; + +struct ul_authorizer { + bool outstanding; + bool revoked; + ul_admission *admission; +}; + +static bool fake_pin = true; +static ul_pairing_result fake_open_result = UL_PAIRING_OK; +static ul_pairing_result fake_claim_result = UL_PAIRING_OK; +static ul_pairing_result fake_load_result = UL_PAIRING_MISSING; +static ul_pairing_result fake_generate_result = UL_PAIRING_OK; +static ul_pairing_result fake_export_result = UL_PAIRING_OK; +static ul_pairing_result fake_forget_result = UL_PAIRING_OK; +static int fake_authenticate_result = UL_ADMISSION_AUTH_OK; +static bool fake_block_export; +static HANDLE fake_export_entered; +static bool fake_block_issue; +static HANDLE fake_issue_entered; +static HANDLE fake_issue_release; +static volatile LONG fake_release_count; +static volatile LONG fake_destroy_count; +static volatile LONG fake_store_destroy_count; + +static int check(bool condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message); + return 1; + } + return 0; +} + +static BOOL WINAPI shim_GetModuleHandleExW(DWORD flags, LPCWSTR address, + HMODULE *module) +{ + (void)flags; + (void)address; + if (!fake_pin) { + SetLastError(ERROR_MOD_NOT_FOUND); + return FALSE; + } + *module = GetModuleHandleW(NULL); + return TRUE; +} + +static ul_pairing_result +shim_ul_pairing_store_open(ul_pairing_store **store_out) +{ + *store_out = NULL; + if (fake_open_result != UL_PAIRING_OK) + return fake_open_result; + *store_out = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, + sizeof(**store_out)); + return *store_out == NULL ? UL_PAIRING_IO_ERROR : UL_PAIRING_OK; +} + +static void shim_ul_pairing_cancel_init(ul_pairing_cancel *cancel) +{ + InterlockedExchange(&cancel->requested, 0); +} + +static void shim_ul_pairing_cancel_request(ul_pairing_cancel *cancel) +{ + InterlockedExchange(&cancel->requested, 1); +} + +static void shim_ul_pairing_store_destroy(ul_pairing_store *store) +{ + HeapFree(GetProcessHeap(), 0, store); + InterlockedIncrement(&fake_store_destroy_count); +} + +static ul_pairing_result +shim_ul_pairing_store_claim_owner(ul_pairing_store *store) +{ + (void)store; + return fake_claim_result; +} + +static ul_pairing_result shim_ul_pairing_store_load(ul_pairing_store *store, + uint8_t key[32]) +{ + (void)store; + SecureZeroMemory(key, 32); + if (fake_load_result == UL_PAIRING_OK) + memset(key, 0x5a, 32); + return fake_load_result; +} + +static ul_pairing_result fake_generate(const ul_pairing_cancel *cancel, + uint8_t key[32]) +{ + SecureZeroMemory(key, 32); + if (cancel != NULL && + InterlockedCompareExchange((volatile LONG *)&cancel->requested, 0, 0)) + return UL_PAIRING_CANCELLED; + if (fake_generate_result == UL_PAIRING_OK) { + memset(key, 0xa5, 32); + fake_load_result = UL_PAIRING_OK; + } + return fake_generate_result; +} + +static ul_pairing_result shim_ul_pairing_store_create( + ul_pairing_store *store, const ul_pairing_cancel *cancel, uint8_t key[32]) +{ + (void)store; + return fake_generate(cancel, key); +} + +static ul_pairing_result shim_ul_pairing_store_replace( + ul_pairing_store *store, const ul_pairing_cancel *cancel, uint8_t key[32]) +{ + (void)store; + return fake_generate(cancel, key); +} + +static ul_pairing_result shim_ul_pairing_store_export( + ul_pairing_store *store, const wchar_t *destination, + const ul_pairing_cancel *cancel) +{ + (void)store; + (void)destination; + if (fake_block_export) { + SetEvent(fake_export_entered); + while (InterlockedCompareExchange((volatile LONG *)&cancel->requested, + 0, 0) == 0) + Sleep(1); + return UL_PAIRING_CANCELLED; + } + return fake_export_result; +} + +static ul_pairing_result +shim_ul_pairing_store_forget(ul_pairing_store *store) +{ + (void)store; + if (fake_forget_result == UL_PAIRING_OK) + fake_load_result = UL_PAIRING_MISSING; + return fake_forget_result; +} + +static ul_authorizer *shim_ul_authorizer_create(const uint8_t key[32]) +{ + ul_authorizer *authorizer; + + if (key == NULL || key[0] == 0) + return NULL; + authorizer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, + sizeof(*authorizer)); + return authorizer; +} + +static bool shim_ul_authorizer_issue(ul_authorizer *authorizer, + DWORD client_pid, + const uint8_t session[16], uint8_t mask, + uint8_t out_challenge[60]) +{ + if (fake_block_issue) { + SetEvent(fake_issue_entered); + if (WaitForSingleObject(fake_issue_release, 5000) != WAIT_OBJECT_0) + return false; + } + if (authorizer == NULL || authorizer->revoked || client_pid < 5 || + session == NULL || mask > 63) + return false; + memset(out_challenge, 0x33, 60); + authorizer->outstanding = true; + return true; +} + +static ul_admission *shim_ul_authorizer_prepare( + ul_authorizer *authorizer, const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size, ul_prepare_options *options) +{ + ul_admission *admission; + + SecureZeroMemory(options, sizeof(*options)); + if (authorizer == NULL || authorizer->revoked || !authorizer->outstanding) + return NULL; + authorizer->outstanding = false; + if (challenge == NULL || proof == NULL || challenge_size != 60 || + proof_size != 32 || challenge[0] != 0x33 || proof[0] != 0x44) + return NULL; + admission = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, + sizeof(*admission)); + if (admission == NULL) + return NULL; + admission->cancelled = CreateEventW(NULL, TRUE, FALSE, NULL); + if (admission->cancelled == NULL) { + HeapFree(GetProcessHeap(), 0, admission); + return NULL; + } + admission->authenticate_result = fake_authenticate_result; + authorizer->admission = admission; + options->client_pid = 100; + return admission; +} + +static void shim_ul_authorizer_revoke(ul_authorizer *authorizer) +{ + if (authorizer == NULL) + return; + authorizer->revoked = true; + if (authorizer->admission != NULL) + SetEvent(authorizer->admission->cancelled); +} + +static void shim_ul_authorizer_release(ul_authorizer *authorizer) +{ + if (authorizer == NULL || authorizer->admission == NULL) + return; + CloseHandle(authorizer->admission->cancelled); + HeapFree(GetProcessHeap(), 0, authorizer->admission); + authorizer->admission = NULL; + InterlockedIncrement(&fake_release_count); +} + +static void shim_ul_authorizer_destroy(ul_authorizer *authorizer) +{ + if (authorizer == NULL) + return; + shim_ul_authorizer_release(authorizer); + SecureZeroMemory(authorizer, sizeof(*authorizer)); + HeapFree(GetProcessHeap(), 0, authorizer); + InterlockedIncrement(&fake_destroy_count); +} + +static int shim_ul_admission_authenticate(ul_admission *admission, + DWORD timeout_ms) +{ + if (admission->authenticate_result == UL_ADMISSION_AUTH_OK) + return UL_ADMISSION_AUTH_OK; + if (WaitForSingleObject(admission->cancelled, timeout_ms) == WAIT_OBJECT_0) + return UL_ADMISSION_CANCELLED; + return admission->authenticate_result; +} + +static void shim_ul_admission_cancel(ul_admission *admission) +{ + SetEvent(admission->cancelled); +} + +static HANDLE shim_ul_admission_pipe(const ul_admission *admission) +{ + (void)admission; + return NULL; +} + +static DWORD WINAPI export_thread(void *unused) +{ + (void)unused; + return (DWORD)ul_plugin_export(L"C:\\transfer.ulpair"); +} + +typedef struct issue_thread_context { + uint8_t session[16]; + uint8_t challenge[60]; +} issue_thread_context; + +static DWORD WINAPI issue_thread(void *context) +{ + issue_thread_context *issue = (issue_thread_context *)context; + + return ul_plugin_issue(100, issue->session, 0, issue->challenge) ? 1u : 0u; +} + +static DWORD WINAPI close_thread(void *unused) +{ + (void)unused; + ul_plugin_close(); + return 0; +} + +static bool bytes_are_zero(const uint8_t *bytes, size_t size) +{ + size_t index; + + for (index = 0; index < size; ++index) { + if (bytes[index] != 0) + return false; + } + return true; +} + +static int scenario_pin_failure(void) +{ + ul_plugin_snapshot snapshot; + uint8_t challenge[60]; + uint8_t session[16] = {1}; + int failures = 0; + + fake_pin = false; + failures += check(!ul_plugin_start(), "pin failure rejected start"); + failures += check(!ul_plugin_start(), "pin failure permanently closes"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_CLOSED, + "pin failure remains closed"); + failures += check(!ul_plugin_issue(100, session, 0, challenge), + "late issue rejected"); + ul_plugin_close(); + return failures; +} + +static int scenario_reload_and_close(void) +{ + ul_plugin_snapshot snapshot; + bool saved = true; + int failures = 0; + + failures += check(ul_plugin_start(), "first start succeeds"); + failures += check(!ul_plugin_start(), "second start rejected"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_UNPAIRED && + snapshot.owns_store, + "unpaired owner snapshot"); + ul_plugin_stop_accepting(); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_CLOSED && + InterlockedCompareExchange(&fake_store_destroy_count, + 0, 0) == 0, + "stop accepting is nonblocking and retains cleanup"); + ul_plugin_stop_accepting(); + ul_plugin_close(); + failures += check(InterlockedCompareExchange(&fake_store_destroy_count, + 0, 0) == 1, + "close cleans runtime after staged stop"); + failures += check(!ul_plugin_start(), "restart after close rejected"); + failures += check(ul_plugin_pair(L"C:\\x", false, &saved) == + UL_PAIRING_CANCELLED && + !saved, + "late pair rejected and output cleared"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_CLOSED && + !snapshot.owns_store && !snapshot.admission_pending, + "closed snapshot stable"); + return failures; +} + +static int scenario_close_fallback(void) +{ + int failures = 0; + + failures += check(ul_plugin_start(), "fallback start"); + ul_plugin_close(); + failures += check(InterlockedCompareExchange(&fake_store_destroy_count, + 0, 0) == 1, + "close directly stops and cleans runtime"); + return failures; +} + +static int scenario_start_states(void) +{ + ul_plugin_snapshot snapshot; + int failures = 0; + + fake_claim_result = UL_PAIRING_IN_USE; + failures += check(ul_plugin_start(), "in-use start exposes status"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_IN_USE && + snapshot.storage_result == UL_PAIRING_IN_USE && + !snapshot.owns_store, + "in-use snapshot"); + ul_plugin_close(); + return failures; +} + +static int scenario_pair_phases(void) +{ + ul_plugin_snapshot snapshot; + uint8_t challenge[60]; + uint8_t proof[32] = {0x44}; + uint8_t session[16] = {1}; + bool saved = true; + int failures = 0; + + failures += check(ul_plugin_start(), "pair start"); + fake_generate_result = UL_PAIRING_IO_ERROR; + failures += check(ul_plugin_pair(L"C:\\x", false, &saved) == + UL_PAIRING_IO_ERROR && + !saved, + "precommit create failure not saved"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_UNPAIRED, + "precommit failure keeps old state"); + fake_generate_result = UL_PAIRING_OK; + fake_export_result = UL_PAIRING_IO_ERROR; + failures += check(ul_plugin_pair(L"C:\\x", false, &saved) == + UL_PAIRING_IO_ERROR && + saved, + "export failure reports durable save"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_PAIRED && + snapshot.storage_result == UL_PAIRING_IO_ERROR, + "export failure leaves paired status"); + fake_generate_result = UL_PAIRING_CRYPTO_ERROR; + saved = true; + failures += check(ul_plugin_issue(100, session, 0, challenge), + "issue remains available before failed replace"); + failures += check(ul_plugin_pair(L"C:\\y", true, &saved) == + UL_PAIRING_CRYPTO_ERROR && + !saved, + "precommit replace failure not saved"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_PAIRED, + "failed replace restores old authorizer"); + failures += check(ul_plugin_prepare(challenge, sizeof(challenge), proof, + sizeof(proof)), + "failed replace preserves current generation"); + fake_generate_result = UL_PAIRING_POSTCOMMIT_INVALID; + saved = true; + failures += check(ul_plugin_pair(L"C:\\z", true, &saved) == + UL_PAIRING_POSTCOMMIT_INVALID && + !saved, + "postcommit uncertainty is not reported saved"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_STORAGE_ERROR && + snapshot.storage_result == + UL_PAIRING_POSTCOMMIT_INVALID, + "postcommit uncertainty disables authorization"); + ul_plugin_close(); + return failures; +} + +static int scenario_prepare_and_forget(void) +{ + uint8_t challenge[60]; + uint8_t proof[32] = {0x44}; + uint8_t session[16] = {1}; + ul_plugin_snapshot snapshot; + int failures = 0; + + fake_load_result = UL_PAIRING_OK; + failures += check(ul_plugin_start(), "paired start"); + failures += check(ul_plugin_issue(100, session, 3, challenge), + "issue challenge"); + failures += check(!ul_plugin_prepare(NULL, 0, NULL, 0), + "malformed prepare rejected"); + failures += check(!ul_plugin_prepare(challenge, sizeof(challenge), proof, + sizeof(proof)), + "malformed prepare consumed challenge"); + failures += check(ul_plugin_issue(100, session, 3, challenge), + "fresh issue after consumption"); + failures += check(ul_plugin_prepare(challenge, sizeof(challenge), proof, + sizeof(proof)), + "prepare starts worker"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.admission_pending, "worker published pending"); + fake_forget_result = UL_PAIRING_IO_ERROR; + failures += check(ul_plugin_forget() == UL_PAIRING_IO_ERROR, + "forget reports durable deletion failure"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_STORAGE_ERROR && + !snapshot.admission_pending, + "failed forget remains revoked and idle"); + failures += check(!ul_plugin_issue(100, session, 3, challenge), + "failed forget cannot restore dispatch"); + fake_forget_result = UL_PAIRING_OK; + failures += check(ul_plugin_forget() == UL_PAIRING_OK, + "forget retry succeeds"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_UNPAIRED, + "forget retry publishes unpaired"); + failures += check(InterlockedCompareExchange(&fake_destroy_count, 0, 0) > 0, + "forget destroyed revoked generation"); + ul_plugin_close(); + return failures; +} + +static int scenario_worker_expiry(void) +{ + uint8_t challenge[60]; + uint8_t proof[32] = {0x44}; + uint8_t session[16] = {1}; + ul_plugin_snapshot snapshot; + ULONGLONG deadline; + int failures = 0; + + fake_load_result = UL_PAIRING_OK; + failures += check(ul_plugin_start(), "expiry start"); + failures += check(ul_plugin_issue(100, session, 0, challenge) && + ul_plugin_prepare(challenge, sizeof(challenge), proof, + sizeof(proof)), + "expiry prepare"); + deadline = GetTickCount64() + 2000; + do { + snapshot = ul_plugin_get_status(); + if (!snapshot.admission_pending) + break; + Sleep(2); + } while (GetTickCount64() < deadline); + failures += check(!snapshot.admission_pending, + "READY worker autonomously expires"); + deadline = GetTickCount64() + 2000; + while (InterlockedCompareExchange(&fake_release_count, 0, 0) == 0 && + GetTickCount64() < deadline) { + (void)ul_plugin_get_status(); + Sleep(1); + } + failures += check(InterlockedCompareExchange(&fake_release_count, 0, 0) == 1, + "status reaps completed worker once"); + ul_plugin_close(); + return failures; +} + +static int scenario_close_cancels_mutation(void) +{ + HANDLE thread; + DWORD wait_result; + DWORD exit_code = UL_PAIRING_IO_ERROR; + int failures = 0; + + fake_load_result = UL_PAIRING_OK; + fake_block_export = true; + fake_export_entered = CreateEventW(NULL, TRUE, FALSE, NULL); + failures += check(fake_export_entered != NULL && ul_plugin_start(), + "mutation start"); + if (failures != 0) + return failures; + thread = CreateThread(NULL, 0, export_thread, NULL, 0, NULL); + failures += check(thread != NULL, "create mutation thread"); + failures += check(WaitForSingleObject(fake_export_entered, 2000) == + WAIT_OBJECT_0, + "mutation entered store"); + ul_plugin_close(); + wait_result = WaitForSingleObject(thread, 2000); + failures += check(wait_result == WAIT_OBJECT_0, + "close drains cancelled mutation"); + if (wait_result != WAIT_OBJECT_0) + ExitProcess(1); + failures += check(GetExitCodeThread(thread, &exit_code) && + exit_code == UL_PAIRING_CANCELLED, + "drained mutation observed cancellation"); + CloseHandle(thread); + CloseHandle(fake_export_entered); + return failures; +} + +static int scenario_close_drains_issue(void) +{ + issue_thread_context issue = {{1}, {0}}; + uint8_t late_challenge[60]; + uint8_t late_session[16] = {2}; + ul_plugin_snapshot snapshot; + HANDLE dispatch = NULL; + HANDLE closing = NULL; + ULONGLONG deadline; + DWORD dispatch_exit = 0; + DWORD close_exit = 1; + DWORD wait_result; + int failures = 0; + + fake_load_result = UL_PAIRING_OK; + fake_block_issue = true; + fake_issue_entered = CreateEventW(NULL, TRUE, FALSE, NULL); + fake_issue_release = CreateEventW(NULL, TRUE, FALSE, NULL); + failures += check(fake_issue_entered != NULL && fake_issue_release != NULL && + ul_plugin_start(), + "dispatch drain start"); + if (failures != 0) { + if (fake_issue_entered != NULL) + CloseHandle(fake_issue_entered); + if (fake_issue_release != NULL) + CloseHandle(fake_issue_release); + return failures; + } + + dispatch = CreateThread(NULL, 0, issue_thread, &issue, 0, NULL); + failures += check(dispatch != NULL, "create blocked dispatch thread"); + if (dispatch == NULL) { + ul_plugin_close(); + CloseHandle(fake_issue_entered); + CloseHandle(fake_issue_release); + return failures; + } + wait_result = WaitForSingleObject(fake_issue_entered, 2000); + failures += check(wait_result == WAIT_OBJECT_0, + "public dispatch entered authorizer"); + if (wait_result != WAIT_OBJECT_0) { + SetEvent(fake_issue_release); + if (WaitForSingleObject(dispatch, 2000) != WAIT_OBJECT_0) + ExitProcess(1); + ul_plugin_close(); + CloseHandle(dispatch); + CloseHandle(fake_issue_entered); + CloseHandle(fake_issue_release); + return failures; + } + + closing = CreateThread(NULL, 0, close_thread, NULL, 0, NULL); + failures += check(closing != NULL, "create close thread"); + if (closing == NULL) { + SetEvent(fake_issue_release); + if (WaitForSingleObject(dispatch, 2000) != WAIT_OBJECT_0) + ExitProcess(1); + ul_plugin_close(); + CloseHandle(dispatch); + CloseHandle(fake_issue_entered); + CloseHandle(fake_issue_release); + return failures; + } + + deadline = GetTickCount64() + 2000; + do { + snapshot = ul_plugin_get_status(); + if (snapshot.status == UL_PLUGIN_CLOSED) + break; + Sleep(1); + } while (GetTickCount64() < deadline); + failures += check(snapshot.status == UL_PLUGIN_CLOSED, + "close shuts gate during in-flight dispatch"); + failures += check(WaitForSingleObject(closing, 50) == WAIT_TIMEOUT, + "close waits for in-flight dispatch"); + + memset(late_challenge, 0xa5, sizeof(late_challenge)); + failures += check(!ul_plugin_issue(101, late_session, 0, late_challenge), + "closed gate refuses late dispatch"); + failures += check(bytes_are_zero(late_challenge, sizeof(late_challenge)), + "late dispatch returns zero challenge"); + + SetEvent(fake_issue_release); + wait_result = WaitForSingleObject(dispatch, 2000); + failures += check(wait_result == WAIT_OBJECT_0, + "in-flight dispatch drains after release"); + if (wait_result != WAIT_OBJECT_0) + ExitProcess(1); + wait_result = WaitForSingleObject(closing, 2000); + failures += check(wait_result == WAIT_OBJECT_0, + "close completes after dispatch drain"); + if (wait_result != WAIT_OBJECT_0) + ExitProcess(1); + failures += check(GetExitCodeThread(dispatch, &dispatch_exit) && + dispatch_exit == 1, + "in-flight dispatch completes before destruction"); + failures += check(GetExitCodeThread(closing, &close_exit) && close_exit == 0, + "close thread exits successfully"); + snapshot = ul_plugin_get_status(); + failures += check(snapshot.status == UL_PLUGIN_CLOSED && + !snapshot.owns_store && !snapshot.admission_pending, + "drained runtime remains closed"); + failures += check(InterlockedCompareExchange(&fake_destroy_count, 0, 0) == 1, + "authorizer destroyed once after dispatch"); + failures += check( + InterlockedCompareExchange(&fake_store_destroy_count, 0, 0) == 1, + "store destroyed once after dispatch"); + + CloseHandle(dispatch); + CloseHandle(closing); + CloseHandle(fake_issue_entered); + CloseHandle(fake_issue_release); + return failures; +} + +static int run_child(const char *scenario) +{ + if (strcmp(scenario, "pin") == 0) + return scenario_pin_failure(); + if (strcmp(scenario, "reload") == 0) + return scenario_reload_and_close(); + if (strcmp(scenario, "fallback") == 0) + return scenario_close_fallback(); + if (strcmp(scenario, "states") == 0) + return scenario_start_states(); + if (strcmp(scenario, "pair") == 0) + return scenario_pair_phases(); + if (strcmp(scenario, "prepare") == 0) + return scenario_prepare_and_forget(); + if (strcmp(scenario, "expiry") == 0) + return scenario_worker_expiry(); + if (strcmp(scenario, "cancel") == 0) + return scenario_close_cancels_mutation(); + if (strcmp(scenario, "dispatch") == 0) + return scenario_close_drains_issue(); + return 1; +} + +static int spawn_scenario(const wchar_t *executable, const wchar_t *scenario) +{ + STARTUPINFOW startup; + PROCESS_INFORMATION process; + wchar_t command[32768]; + DWORD exit_code = 1; + + SecureZeroMemory(&startup, sizeof(startup)); + SecureZeroMemory(&process, sizeof(process)); + SecureZeroMemory(command, sizeof(command)); + startup.cb = sizeof(startup); + if (_snwprintf(command, 32768, L"\"%ls\" %ls", executable, scenario) < 0) + return 1; + if (!CreateProcessW(executable, command, NULL, NULL, FALSE, 0, NULL, NULL, + &startup, &process)) + return 1; + if (WaitForSingleObject(process.hProcess, 10000) != WAIT_OBJECT_0) + ExitProcess(1); + if (!GetExitCodeProcess(process.hProcess, &exit_code)) + exit_code = 1; + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return exit_code == 0 ? 0 : 1; +} + +int main(int argc, char **argv) +{ + static const wchar_t *scenarios[] = { + L"pin", L"reload", L"fallback", L"states", L"pair", L"prepare", + L"expiry", L"cancel", L"dispatch"}; + wchar_t executable[32768]; + size_t index; + int failures = 0; + + if (argc == 2) + return run_child(argv[1]) == 0 ? 0 : 1; + if (GetModuleFileNameW(NULL, executable, 32768) == 0) + return 1; + for (index = 0; index < sizeof(scenarios) / sizeof(scenarios[0]); ++index) + failures += spawn_scenario(executable, scenarios[index]); + if (failures == 0) + puts("plugin state tests passed"); + return failures == 0 ? 0 : 1; +} diff --git a/native/obs-plugin/tests/test_vendor_dispatch.py b/native/obs-plugin/tests/test_vendor_dispatch.py new file mode 100644 index 00000000..e10e7872 --- /dev/null +++ b/native/obs-plugin/tests/test_vendor_dispatch.py @@ -0,0 +1,127 @@ +"""Real libobs data decoding with a controlled runtime boundary; no OBS startup.""" +# SPDX-License-Identifier: GPL-2.0-or-later +from __future__ import annotations + +import ctypes +import json +import os +from pathlib import Path +import struct +import sys +import unittest + + +class VendorTests(unittest.TestCase): + def setUp(self): + VENDOR.ul_vendor_test_reset(True) + + def dispatch(self, payload, *, prepare=False): + request = OBS.obs_data_create_from_json(json.dumps(payload).encode()) + response = OBS.obs_data_create() + self.assertTrue(request and response) + try: + callback = VENDOR.ul_vendor_prepare if prepare else VENDOR.ul_vendor_issue + callback(request, response, None) + return json.loads(OBS.obs_data_get_json(response)) + finally: + OBS.obs_data_release(request) + OBS.obs_data_release(response) + + def test_issue_roundtrip_uses_exact_native_data(self): + for pid, mask in [(5, 0), (0xFFFFFFFF, 63)]: + with self.subTest(pid=pid, mask=mask): + response = self.dispatch({**ISSUE, "clientPid": pid, "additionalMixMask": mask}) + expected = struct.pack("<4sBBBBI16s32s", b"ULAA", 1, 1, 0, mask, pid, SESSION, b"\x11" * 32) + self.assertEqual(response, {"ok": True, "protocolVersion": 1, "challenge": expected.hex()}) + + def test_invalid_issue_never_reaches_runtime(self): + changes = [ + ("clientPid", True), ("clientPid", 4), ("clientPid", 0x100000000), + ("clientPid", 5.0), ("clientPid", "5"), ("clientPid", []), + ("additionalMixMask", False), ("additionalMixMask", -1), ("additionalMixMask", 64), + ("additionalMixMask", 1.5), ("additionalMixMask", "1"), + ("sessionId", ""), ("sessionId", "a"), ("sessionId", "a" * 31), + ("sessionId", "a" * 33), ("sessionId", "AA" * 16), ("sessionId", "gg" * 16), + ("sessionId", "00" * 16), ("sessionId", 123), ("sessionId", {}), + ("extra", 1), + ] + for field, value in changes: + with self.subTest(field=field, value=value): + VENDOR.ul_vendor_test_reset(True) + self.assertEqual(self.dispatch({**ISSUE, field: value}), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(0), 0) + for missing in ISSUE: + with self.subTest(missing=missing): + self.assertEqual(self.dispatch({k: v for k, v in ISSUE.items() if k != missing}), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(0), 0) + + def test_prepare_accepts_only_canonical_fixed_fields(self): + payload = {"challenge": "ab" * 60, "proof": "12" * 32} + self.assertEqual(self.dispatch(payload, prepare=True), {"ok": True, "protocolVersion": 1}) + self.assertEqual(VENDOR.ul_vendor_test_count(1), 1) + self.assertEqual(VENDOR.ul_vendor_test_count(2), 0) + for field in payload: + for invalid in [True, 7, [], {}, "", "a", "A" * len(payload[field]), payload[field] + "0"]: + with self.subTest(field=field, value=invalid): + VENDOR.ul_vendor_test_reset(True) + self.assertEqual(self.dispatch({**payload, field: invalid}, prepare=True), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(1), 1) + self.assertEqual(VENDOR.ul_vendor_test_count(2), 1) + for changed in [{}, {"challenge": payload["challenge"]}, {**payload, "extra": False}]: + with self.subTest(changed=changed): + VENDOR.ul_vendor_test_reset(True) + self.assertEqual(self.dispatch(changed, prepare=True), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(2), 1) + + def test_closed_runtime_returns_only_generic_refusal(self): + VENDOR.ul_vendor_test_reset(False) + self.assertEqual(self.dispatch(ISSUE), {"ok": False}) + self.assertEqual(self.dispatch({"challenge": "ab" * 60, "proof": "12" * 32}, prepare=True), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(0), 0) + self.assertEqual(VENDOR.ul_vendor_test_count(1), 0) + + def test_disabled_registration_gate_refuses_before_runtime(self): + VENDOR.ul_vendor_set_enabled(False) + self.assertEqual(self.dispatch(ISSUE), {"ok": False}) + self.assertEqual(self.dispatch({"challenge": "ab" * 60, "proof": "12" * 32}, prepare=True), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(0), 0) + self.assertEqual(VENDOR.ul_vendor_test_count(1), 0) + + def test_defaults_do_not_authorize_a_request(self): + request, response = OBS.obs_data_create(), OBS.obs_data_create() + try: + OBS.obs_data_set_default_int(request, b"clientPid", 5) + OBS.obs_data_set_default_int(request, b"additionalMixMask", 0) + OBS.obs_data_set_default_string(request, b"sessionId", SESSION.hex().encode()) + VENDOR.ul_vendor_issue(request, response, None) + self.assertEqual(json.loads(OBS.obs_data_get_json(response)), {"ok": False}) + self.assertEqual(VENDOR.ul_vendor_test_count(0), 0) + finally: + OBS.obs_data_release(request) + OBS.obs_data_release(response) + + +if __name__ == "__main__": + runtime, fixture = map(Path, sys.argv[1:3]) + sys.argv = sys.argv[:1] + SESSION = bytes(range(1, 17)) + ISSUE = {"clientPid": 5, "sessionId": SESSION.hex(), "additionalMixMask": 0} + with os.add_dll_directory(str(runtime.parent)): + OBS = ctypes.CDLL(str(runtime), winmode=0x1100) + VENDOR = ctypes.CDLL(str(fixture), winmode=0x1100) + for name, arguments, result in [ + ("obs_data_create", [], ctypes.c_void_p), + ("obs_data_create_from_json", [ctypes.c_char_p], ctypes.c_void_p), + ("obs_data_get_json", [ctypes.c_void_p], ctypes.c_char_p), + ("obs_data_release", [ctypes.c_void_p], None), + ("obs_data_set_default_int", [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_longlong], None), + ("obs_data_set_default_string", [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p], None), + ]: + getattr(OBS, name).argtypes, getattr(OBS, name).restype = arguments, result + for name in ["ul_vendor_issue", "ul_vendor_prepare"]: + getattr(VENDOR, name).argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p] + getattr(VENDOR, name).restype = None + VENDOR.ul_vendor_test_reset.argtypes, VENDOR.ul_vendor_test_reset.restype = [ctypes.c_bool], None + VENDOR.ul_vendor_set_enabled.argtypes, VENDOR.ul_vendor_set_enabled.restype = [ctypes.c_bool], None + VENDOR.ul_vendor_test_count.argtypes, VENDOR.ul_vendor_test_count.restype = [ctypes.c_uint], ctypes.c_uint + unittest.main() diff --git a/native/obs-plugin/tests/vendor_dispatch.def b/native/obs-plugin/tests/vendor_dispatch.def new file mode 100644 index 00000000..0e47090b --- /dev/null +++ b/native/obs-plugin/tests/vendor_dispatch.def @@ -0,0 +1,7 @@ +LIBRARY utterleaf-vendor-test +EXPORTS + ul_vendor_set_enabled + ul_vendor_issue + ul_vendor_prepare + ul_vendor_test_reset + ul_vendor_test_count diff --git a/native/obs-plugin/tests/vendor_dispatch_shim.c b/native/obs-plugin/tests/vendor_dispatch_shim.c new file mode 100644 index 00000000..113ca2ca --- /dev/null +++ b/native/obs-plugin/tests/vendor_dispatch_shim.c @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "../src/plugin_state.h" +#include "../src/vendor_dispatch.h" +#include + +static unsigned issue_count, prepare_count, malformed_count; +static bool enabled; + +ul_plugin_snapshot ul_plugin_get_status(void) +{ + ul_plugin_snapshot state = {0}; + state.status = enabled ? UL_PLUGIN_PAIRED : UL_PLUGIN_CLOSED; + return state; +} + +void ul_vendor_test_reset(bool accept) +{ + issue_count = prepare_count = malformed_count = 0u; + enabled = accept; + ul_vendor_set_enabled(true); +} + +unsigned ul_vendor_test_count(unsigned kind) +{ + return kind == 0u ? issue_count : kind == 1u ? prepare_count : malformed_count; +} + +bool ul_plugin_issue(DWORD pid, const uint8_t session[16], uint8_t mask, uint8_t challenge[60]) +{ + ++issue_count; + memset(challenge, 0, 60); + memcpy(challenge, "ULAA", 4); + challenge[4] = challenge[5] = 1; + challenge[7] = mask; + for (unsigned i = 0; i < 4; ++i) + challenge[8 + i] = (uint8_t)(pid >> (8 * i)); + memcpy(challenge + 12, session, 16); + memset(challenge + 28, 17, 32); + return enabled; +} + +bool ul_plugin_prepare(const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size) +{ + ++prepare_count; + if (challenge == NULL || proof == NULL || challenge_size != 60 || proof_size != 32) { + ++malformed_count; + return false; + } + return enabled; +} diff --git a/native/obs-plugin/tools/build.py b/native/obs-plugin/tools/build.py index 740d0190..f6de4833 100644 --- a/native/obs-plugin/tools/build.py +++ b/native/obs-plugin/tools/build.py @@ -13,6 +13,10 @@ import urllib.request ROOT = Path(__file__).resolve().parents[1] +SOURCES = ( + "bridge", "plugin_state", "pairing_ui", "vendor_dispatch", "pairing_store", + "authorization", "admission", "handshake", "crypto", +) ISC_HEADERS = ( "callback/calldata.h", "callback/proc.h", "callback/signal.h", "util/base.h", "util/bmem.h", "util/c99defs.h", "util/darray.h", @@ -34,12 +38,16 @@ def main() -> None: args = parser.parse_args() headers, output = args.headers.resolve(), args.output.resolve() output.mkdir(parents=True, exist_ok=True) + (output / "build-receipt.json").unlink(missing_ok=True) lock = json.loads((ROOT / "dependencies.json").read_text(encoding="utf-8")) revision = lock["obs_revision"] prefix = f"https://raw.githubusercontent.com/obsproject/obs-studio/{revision}/" + websocket_url = ("https://raw.githubusercontent.com/obsproject/obs-websocket/" + + lock["obs_websocket_revision"] + "/lib/obs-websocket-api.h") for relative, resource in lock["resources"].items(): path = (headers / relative).resolve() - if not path.is_relative_to(headers) or resource["url"] != prefix + relative: + expected_url = websocket_url if relative == "obs-websocket/obs-websocket-api.h" else prefix + relative + if not path.is_relative_to(headers) or resource["url"] != expected_url: raise ValueError("Unexpected public-header path or origin") if not path.is_file(): if not args.fetch: @@ -63,7 +71,14 @@ def main() -> None: tools = args.toolchain.resolve() / "bin" compiler = tools / "x86_64-w64-mingw32-clang.exe" obs = args.obs_bin.resolve() / "obs.dll" + frontend = args.obs_bin.resolve() / "obs-frontend-api.dll" runtime_hash = digest(obs) + frontend_hash = digest(frontend) + source_names = ["src/" + name + ".c" for name in SOURCES] + source_names += ["src/" + name + ".h" for name in SOURCES if name != "bridge"] + source_names += ["src/bridge.def", "src/bridge.rc", "src/bridge.manifest", + "dependencies.json", "COPYING", "tools/build.py", "tools/smoke.py"] + source_hashes = {name: digest(ROOT / name) for name in source_names} commands: list[list[str]] = [] def run(command: list[str]) -> str: @@ -78,13 +93,26 @@ def run(command: list[str]) -> str: run([str(tools / "gendef.exe"), "--no-include-current-dir", str(obs)]) run([str(tools / "llvm-dlltool.exe"), "-m", "i386:x86-64", "-d", str(output / "obs.def"), "-l", str(output / "libobs.dll.a")]) + run([str(tools / "gendef.exe"), "--no-include-current-dir", str(frontend)]) + run([str(tools / "llvm-dlltool.exe"), "-m", "i386:x86-64", "-d", + str(output / "obs-frontend-api.def"), "-l", str(output / "libobs-frontend.dll.a")]) + resource_object = output / "bridge-resource.o" + run([str(tools / "x86_64-w64-mingw32-windres.exe"), "-I", str(ROOT / "src"), + "-i", str(ROOT / "src/bridge.rc"), "-O", "coff", "-o", str(resource_object)]) plugin = output / "utterleaf-obs-bridge.dll" - run([str(compiler), "-std=c11", "-D_M_X64=100", "-Wall", "-Wextra", "-Werror", - "-O2", "-shared", f"-ffile-prefix-map={ROOT}=native/obs-plugin", - f"-I{headers / 'libobs'}", f"-I{output}", str(ROOT / "src/bridge.c"), - str(ROOT / "src/bridge.def"), str(output / "libobs.dll.a"), - "-Wl,--no-insert-timestamp,--exclude-all-symbols", "-MD", "-MF", - str(output / "bridge.d"), "-o", str(plugin)]) + objects, dependency_files = [], [] + for name in SOURCES: + obj, dep = output / (name + ".o"), output / (name + ".d") + run([str(compiler), "-std=c11", "-D_M_X64=100", "-Wall", "-Wextra", "-Werror", "-O2", + f"-ffile-prefix-map={ROOT}=native/obs-plugin", f"-I{headers / 'libobs'}", + f"-I{headers / 'frontend/api'}", f"-I{headers / 'obs-websocket'}", f"-I{output}", + "-MD", "-MF", str(dep), "-c", str(ROOT / ("src/" + name + ".c")), "-o", str(obj)]) + objects.append(obj) + dependency_files.append(dep) + run([str(compiler), "-shared", *map(str, objects), str(resource_object), + str(ROOT / "src/bridge.def"), str(output / "libobs.dll.a"), str(output / "libobs-frontend.dll.a"), + "-lbcrypt", "-lcrypt32", "-ladvapi32", "-lshell32", "-lole32", "-luuid", "-luser32", + "-Wl,--no-insert-timestamp,--exclude-all-symbols", "-o", str(plugin)]) inventory = run([str(tools / "llvm-readobj.exe"), "--file-headers", "--coff-imports", "--coff-exports", str(plugin)]) (output / "pe-inventory.txt").write_text(inventory, encoding="utf-8") @@ -97,11 +125,15 @@ def run(command: list[str]) -> str: imports = re.findall(r"Import \{\s*Name: ([^\n]+)", inventory) allowed = {"obs.dll", "kernel32.dll", "api-ms-win-crt-runtime-l1-1-0.dll", "api-ms-win-crt-private-l1-1-0.dll", "api-ms-win-crt-stdio-l1-1-0.dll", - "api-ms-win-crt-heap-l1-1-0.dll", "api-ms-win-crt-string-l1-1-0.dll"} + "api-ms-win-crt-heap-l1-1-0.dll", "api-ms-win-crt-string-l1-1-0.dll", + "obs-frontend-api.dll", "bcrypt.dll", "crypt32.dll", "advapi32.dll", + "shell32.dll", "ole32.dll", "user32.dll"} if {name.lower() for name in imports} != allowed or len(imports) != len(allowed): raise ValueError(f"Unreviewed runtime import: {imports}") - if digest(obs) != runtime_hash: + if digest(obs) != runtime_hash or digest(frontend) != frontend_hash: raise ValueError("OBS runtime changed during the build") + if any(digest(ROOT / name) != expected for name, expected in source_hashes.items()): + raise ValueError("Native source changed during the build") shutil.copyfile(headers / "COPYING", output / "COPYING-OBS.txt") shutil.copyfile(ROOT / "COPYING", output / "COPYING.txt") @@ -117,19 +149,27 @@ def run(command: list[str]) -> str: raise ValueError(f"Expected header notice is missing: {name}") notices.append(f"\n{name}\n{notice[1]}\n") (output / "OBS-HEADER-NOTICES.txt").write_text("".join(notices), encoding="utf-8") + websocket_header = (headers / "obs-websocket/obs-websocket-api.h").read_text(encoding="utf-8") + websocket_notice = re.match(r"\s*(/\*.*?\*/)", websocket_header, flags=re.S) + if websocket_notice is None or "GNU General Public License" not in websocket_notice[1]: + raise ValueError("Missing obs-websocket header license") + (output / "OBS-WEBSOCKET-NOTICE.txt").write_text(websocket_notice[1] + "\n\nGPL terms: see COPYING-OBS.txt.\n", encoding="utf-8") # This development receipt is not a complete binary redistribution review. shutil.copyfile(args.toolchain / "LICENSE.TXT", output / "LLVM-LICENSE.txt") generated = [plugin, output / "obsconfig.h", output / "obs.def", output / "libobs.dll.a", - output / "bridge.d", output / "pe-inventory.txt", output / "LLVM-LICENSE.txt", - output / "COPYING.txt", output / "COPYING-OBS.txt", output / "OBS-HEADER-NOTICES.txt"] + output / "obs-frontend-api.def", output / "libobs-frontend.dll.a", resource_object, + *objects, *dependency_files, output / "pe-inventory.txt", output / "LLVM-LICENSE.txt", + output / "COPYING.txt", output / "COPYING-OBS.txt", output / "OBS-HEADER-NOTICES.txt", + output / "OBS-WEBSOCKET-NOTICE.txt"] receipt = {"obs_revision": revision, "obs_runtime": {"path": str(obs), "sha256": runtime_hash}, + "obs_frontend_runtime": {"path": str(frontend), "sha256": frontend_hash}, + "obs_websocket_revision": lock["obs_websocket_revision"], "compiler": {"version": version.strip(), "sha256": digest(compiler)}, "invoked_tools": {name: digest(tools / name) for name in ( - compiler.name, "gendef.exe", "llvm-dlltool.exe", "llvm-readobj.exe")}, + compiler.name, "gendef.exe", "llvm-dlltool.exe", "llvm-readobj.exe", + "x86_64-w64-mingw32-windres.exe")}, "toolchain_scope": "Installed toolchain is not fully pinned: driver configuration, linker, headers and static runtime inputs remain unbound", - "source": {name: digest(ROOT / name) for name in ( - "src/bridge.c", "src/bridge.def", "dependencies.json", "COPYING", - "tools/build.py", "tools/smoke.py")}, + "source": source_hashes, "inputs": lock["resources"], "generated": {p.name: digest(p) for p in generated}, "exports": exports, "imports": imports, "commands": commands, "distribution": "development only; no OBS DLLs included; runtime/source distribution review remains open"} diff --git a/native/obs-plugin/tools/smoke.py b/native/obs-plugin/tools/smoke.py index 457c701d..e403fc77 100644 --- a/native/obs-plugin/tools/smoke.py +++ b/native/obs-plugin/tools/smoke.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Load the inert development module through libobs, without the OBS app or audio.""" +"""Verify headless module refusal without the OBS app, pairing writes or audio.""" # SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations @@ -9,6 +9,38 @@ import json import os from pathlib import Path +import uuid + + +def consumer_metadata() -> tuple[object, ...]: + """Inspect only fixed store nodes; never open their content or create them.""" + shell = ctypes.WinDLL("shell32.dll", winmode=0x800) + ole = ctypes.WinDLL("ole32.dll", winmode=0x800) + shell.SHGetKnownFolderPath.argtypes = [ctypes.c_void_p, ctypes.c_uint32, + ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)] + shell.SHGetKnownFolderPath.restype = ctypes.c_long + ole.CoTaskMemFree.argtypes = [ctypes.c_void_p] + ole.CoTaskMemFree.restype = None + folder_id = (ctypes.c_ubyte * 16).from_buffer_copy( + uuid.UUID("f1b32785-6fba-4fcf-9d55-7b8e7f157091").bytes_le) + value = ctypes.c_void_p() + result = shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(value)) + try: + if result < 0 or not value.value: + raise RuntimeError("Could not inspect the fixed pairing location") + root = Path(ctypes.wstring_at(value)) / "Utterleaf" / "obs-plugin" + finally: + ole.CoTaskMemFree(value) + states = [] + for path in (root, root / "pairing-v1.dat", root / "owner-v1.lock"): + try: + info = path.lstat() + except FileNotFoundError: + states.append(None) + else: + states.append((info.st_dev, info.st_ino, info.st_mode, info.st_size, + info.st_mtime_ns, info.st_ctime_ns, info.st_file_attributes)) + return tuple(states) def main() -> None: @@ -16,21 +48,32 @@ def main() -> None: parser.add_argument("--build", type=Path, required=True) args = parser.parse_args() build = args.build.resolve() + (build / "smoke-receipt.json").unlink(missing_ok=True) receipt_path = build / "build-receipt.json" receipt = json.loads(receipt_path.read_text(encoding="utf-8")) plugin = build / "utterleaf-obs-bridge.dll" runtime = Path(receipt["obs_runtime"]["path"]).resolve() + frontend_path = Path(receipt["obs_frontend_runtime"]["path"]).resolve() + if frontend_path.parent != runtime.parent: + raise ValueError("OBS runtime and frontend must share the reviewed directory") for path, expected in ((plugin, receipt["generated"][plugin.name]), (runtime, receipt["obs_runtime"]["sha256"]), + (frontend_path, receipt["obs_frontend_runtime"]["sha256"]), (Path(__file__), receipt["source"]["tools/smoke.py"])): if hashlib.sha256(path.read_bytes()).hexdigest() != expected: raise ValueError(f"The reviewed build input changed: {path.name}") config = build / "smoke-config" config.mkdir(exist_ok=True) + before = consumer_metadata() # Default DLL search excludes the current directory; only this explicitly # selected installed runtime supplies libobs's dependencies. with os.add_dll_directory(str(runtime.parent)): obs = ctypes.CDLL(str(runtime), winmode=0x1100) + frontend = ctypes.CDLL(str(frontend_path), winmode=0x1100) + frontend.obs_frontend_get_main_window_handle.argtypes = [] + frontend.obs_frontend_get_main_window_handle.restype = ctypes.c_void_p + if frontend.obs_frontend_get_main_window_handle(): + raise RuntimeError("The headless fixture unexpectedly has an OBS frontend") obs.obs_get_version.argtypes = [] obs.obs_get_version.restype = ctypes.c_uint32 obs.obs_startup.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_void_p] @@ -52,18 +95,22 @@ def main() -> None: str(build).encode("utf-8")) if result != 0 or not module.value: raise RuntimeError(f"obs_open_module failed: {result}") - if not obs.obs_init_module(module): - raise RuntimeError("obs_init_module failed") + if obs.obs_init_module(module): + raise RuntimeError("The plugin must refuse initialization without a frontend") finally: obs.obs_shutdown() + if consumer_metadata() != before: + raise RuntimeError("Fixed pairing-store metadata changed during headless verification") evidence = {"obs_api_version": version, "obs_open_module": result, - "obs_init_module": True, "obs_shutdown": "returned", + "obs_init_module": False, "headless_refusal": True, + "consumer_store_metadata_unchanged": True, "obs_shutdown": "returned", "fixture": {"audio_reset": False, "sources_created": 0, "obs_app_started": False}, "build_receipt_sha256": hashlib.sha256(receipt_path.read_bytes()).hexdigest(), "smoke_script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "plugin_sha256": receipt["generated"][plugin.name], - "obs_runtime_sha256": receipt["obs_runtime"]["sha256"]} + "obs_runtime_sha256": receipt["obs_runtime"]["sha256"], + "obs_frontend_runtime_sha256": receipt["obs_frontend_runtime"]["sha256"]} (build / "smoke-receipt.json").write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") print(json.dumps(evidence)) diff --git a/native/obs-plugin/tools/test_native.py b/native/obs-plugin/tools/test_native.py index adc2c5ca..ef01f0a4 100644 --- a/native/obs-plugin/tools/test_native.py +++ b/native/obs-plugin/tools/test_native.py @@ -1,4 +1,4 @@ -"""Build and exercise Windows pairing/admission without OBS or audio.""" +"""Exercise Windows pairing/runtime and optional libobs dispatch without audio.""" # SPDX-License-Identifier: GPL-2.0-or-later from __future__ import annotations @@ -22,7 +22,11 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--toolchain", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--build", type=Path, help="Reviewed development build for libobs dispatch checks") + parser.add_argument("--headers", type=Path, help="Pinned public-header cache used by --build") args = parser.parse_args() + if (args.build is None) != (args.headers is None): + parser.error("--build and --headers must be supplied together") if sys.platform != "win32": raise SystemExit("These native admission checks require Windows") output = args.output.resolve() @@ -42,6 +46,10 @@ def main() -> None: "src/crypto.c", "src/crypto.h", "src/authorization.c", "src/authorization.h", "src/pairing_store.c", "src/pairing_store.h", + "src/plugin_state.c", "src/plugin_state.h", "tests/plugin_state_test.c", + "src/vendor_dispatch.c", "src/vendor_dispatch.h", "tests/vendor_dispatch_shim.c", + "src/bridge.c", "tests/bridge_test.c", + "tests/vendor_dispatch.def", "tests/test_vendor_dispatch.py", "dependencies.json", "tests/handshake_test.c", "tests/handshake_failure_test.c", "tests/admission_identity_test.c", "tests/crypto_test.c", "tests/authorization_test.c", @@ -54,6 +62,34 @@ def main() -> None: "utterleaf/obs_authorization.py", "utterleaf/obs_pairing_store.py", "utterleaf/windows_pipe.py", )] client_hashes = {str(path.relative_to(REPO)): digest(path) for path in client_sources} + dispatch_inputs = {} + if args.build is not None: + build, headers = args.build.resolve(), args.headers.resolve() + build_receipt_path = build / "build-receipt.json" + build_receipt = json.loads(build_receipt_path.read_text(encoding="utf-8")) + dispatch_inputs[build_receipt_path] = digest(build_receipt_path) + # Bind dispatch to the same current source and verified public headers + # as the linked DLL; no independent unreviewed SDK path is accepted. + for name, expected in build_receipt["source"].items(): + path = (ROOT / name).resolve() + if not path.is_relative_to(ROOT) or digest(path) != expected: + raise ValueError("The reviewed development build source changed") + dispatch_inputs[path] = expected + lock = json.loads((ROOT / "dependencies.json").read_text(encoding="utf-8")) + if build_receipt["inputs"] != lock["resources"]: + raise ValueError("Build headers differ from the current dependency lock") + for name, resource in lock["resources"].items(): + path = (headers / name).resolve() + if (not path.is_relative_to(headers) or path.stat().st_size != resource["bytes"] + or digest(path) != resource["sha256"]): + raise ValueError("A pinned dispatch header changed") + dispatch_inputs[path] = resource["sha256"] + runtime = Path(build_receipt["obs_runtime"]["path"]).resolve() + dispatch_inputs[runtime] = build_receipt["obs_runtime"]["sha256"] + for name in ("obsconfig.h", "libobs.dll.a"): + dispatch_inputs[build / name] = build_receipt["generated"][name] + if any(digest(path) != expected for path, expected in dispatch_inputs.items()): + raise ValueError("A reviewed dispatch input changed") def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: command = [str(arg) for arg in arguments] @@ -78,6 +114,7 @@ def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: authorization_state = output / "authorization_test.exe" pairing_dll = output / "utterleaf-pairing-store-test.dll" pairing_state = output / "pairing_store_test.exe" + plugin_state = output / "plugin_state_test.exe" run("compiler", [compiler, "--version"]) run("handshake-build", [*flags, ROOT / "src/crypto.c", ROOT / "src/handshake.c", ROOT / "tests/handshake_test.c", "-lbcrypt", "-o", fixed]) @@ -88,6 +125,9 @@ def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: run("authorization-state-build", [*flags, ROOT / "tests/authorization_test.c", "-o", authorization_state]) run("authorization-state-test", [authorization_state]) + run("plugin-state-build", [*flags, "-D_M_X64=100", ROOT / "tests/plugin_state_test.c", + "-o", plugin_state]) + run("plugin-state-test", [plugin_state]) functions = ( "BCryptOpenAlgorithmProvider", "BCryptGetProperty", "BCryptCreateHash", "BCryptHashData", "BCryptFinishHash", "BCryptDestroyHash", @@ -129,17 +169,37 @@ def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: ROOT / "tests/pairing_store.def", *pairing_libraries, "-o", pairing_dll]) run("pairing-interop-test", [sys._base_executable, ROOT / "tests/test_pairing_interop.py", pairing_dll, authorization_dll]) + artifacts = [fixed, fault, identity, dll, crypto, authorization_state, authorization_dll, + pairing_state, pairing_dll, plugin_state] + if args.build is not None: + bridge_test = output / "bridge_test.exe" + run("bridge-wrapper-build", [*flags, "-D_M_X64=100", f"-I{headers / 'libobs'}", + f"-I{headers / 'frontend/api'}", f"-I{headers / 'obs-websocket'}", + f"-I{build}", ROOT / "tests/bridge_test.c", "-o", bridge_test]) + run("bridge-wrapper-test", [bridge_test]) + artifacts.append(bridge_test) + vendor_dll = output / "utterleaf-vendor-test.dll" + run("vendor-dispatch-build", [*flags, "-D_M_X64=100", "-shared", + f"-I{headers / 'libobs'}", f"-I{build}", + ROOT / "src/vendor_dispatch.c", ROOT / "tests/vendor_dispatch_shim.c", + ROOT / "tests/vendor_dispatch.def", build / "libobs.dll.a", + "-o", vendor_dll]) + run("vendor-dispatch-test", [sys._base_executable, ROOT / "tests/test_vendor_dispatch.py", + runtime, vendor_dll]) + artifacts.append(vendor_dll) if source_hashes != {str(path.relative_to(ROOT)): digest(path) for path in sources}: raise RuntimeError("Source changed during native verification") if client_hashes != {str(path.relative_to(REPO)): digest(path) for path in client_sources}: raise RuntimeError("Client source changed during native verification") + if any(digest(path) != expected for path, expected in dispatch_inputs.items()): + raise RuntimeError("Reviewed dispatch inputs changed during verification") receipt = { - "schema": 1, "scope": "private pairing/admission only; no OBS dispatch, arming or audio", + "schema": 2, "scope": "pairing/admission/runtime; optional parsed libobs dispatch; no OBS application, arming or audio", + "vendor_dispatch": "passed" if args.build is not None else "not run: supply --build and --headers", + "dispatch_inputs": {str(path): expected for path, expected in dispatch_inputs.items()}, "sources": source_hashes, "client_sources": client_hashes, "compiler_sha256": digest(compiler), - "artifacts": {path.name: digest(path) for path in - (fixed, fault, identity, dll, crypto, authorization_state, authorization_dll, - pairing_state, pairing_dll)}, + "artifacts": {path.name: digest(path) for path in artifacts}, "logs": {path.name: digest(path) for path in logs}, "commands": commands, } From 24fd3a0499f655e3cdd201e8c6dc46580ea50250 Mon Sep 17 00:00:00 2001 From: RioPlay Date: Sun, 13 Sep 2026 08:45:53 -0500 Subject: [PATCH 3/3] Add explicit Windows OBS pairing setup and dialog acceptance --- docs/desktop-roadmap.md | 18 +- docs/plans/active/obs-desktop-pairing-ui.md | 126 +++++ docs/plans/active/obs-native-enrollment.md | 15 +- native/obs-plugin/README.md | 15 +- native/obs-plugin/tests/pairing_ui_test.c | 590 ++++++++++++++++++++ native/obs-plugin/tools/test_native.py | 10 +- native/obs-plugin/tools/test_pairing_ui.py | 170 ++++++ tests/test_obs_pairing_owner.py | 235 ++++++++ tests/test_obs_pairing_ui.py | 374 +++++++++++++ utterleaf/obs_pairing_store.py | 30 + utterleaf/obs_pairing_ui.py | 350 ++++++++++++ utterleaf/settings_ui.py | 19 + 12 files changed, 1943 insertions(+), 9 deletions(-) create mode 100644 docs/plans/active/obs-desktop-pairing-ui.md create mode 100644 native/obs-plugin/tests/pairing_ui_test.c create mode 100644 native/obs-plugin/tools/test_pairing_ui.py create mode 100644 tests/test_obs_pairing_owner.py create mode 100644 tests/test_obs_pairing_ui.py create mode 100644 utterleaf/obs_pairing_ui.py diff --git a/docs/desktop-roadmap.md b/docs/desktop-roadmap.md index 9acb6d6f..d0d68120 100644 --- a/docs/desktop-roadmap.md +++ b/docs/desktop-roadmap.md @@ -104,7 +104,7 @@ audio delivery after TCP loss and wrong-process rejection with zero secret bytes received. This checks the originally attributed process and current path/file identity; it is not historical loaded-image attestation. -There is no app entry point. Actual OBS enrollment, the original server plugin's +There is no live-capture app entry point. Actual OBS enrollment, the original server plugin's restrictive DACL/client authentication, atomic arming/start coordination, controller/UI, live recognition and streaming-load acceptance remain open. The original C-only [development module](../native/obs-plugin/README.md) first @@ -151,8 +151,8 @@ binary is included in desktop or Android releases. The library, reviewed full license and development-wheel provenance. Continue on `feat/obs-enrollment-flow` with frontend acceptance and the desktop -setup/controller after the linked pairing/vendor increment. Arm/PCM and live -recognition remain later gates. No desktop app entry point, audio endpoint, plugin binary +connection/controller after the pairing setup increment. Arm/PCM and live +recognition remain later gates. No live-capture app entry point, audio endpoint, plugin binary publication or OBS capture integration exists yet. The linked native pairing Tools flow, exclusive per-user owner and strict @@ -170,6 +170,18 @@ in that new exchange fixture. This prepares a session ID and does not connect the audio pipe or arm capture. Native owner/UI/vendor integration remains unverified for real OBS/device acceptance. +The [desktop pairing dialog](plans/active/obs-desktop-pairing-ui.md) now opens from +Speech & privacy on Windows, with explicit import/replace/forget, a separate +per-user store owner and asynchronous cancellation/teardown. Saved status never +claims an OBS connection. The related bundle passes 204 tests; all 23 UI cases +pass after the final status-card adjustment. Native TaskDialog activation, +Escape, modal cleanup and zero-mutation checks also pass in an isolated fixture; +all 29 native driver commands pass with `--ui`. Normal/compact/enlarged desktop +renders were inspected. Independent final desktop setup review is clear, with +29 focused and 187 related tests rerun on the final source. Its own CI remains +pending; no existing release changes. Earlier linked checkpoint `15e1c77` passed +all five desktop CI jobs in [34758907354](https://github.com/RioPlay/utterleaf/actions/runs/34758907354). + Current integrated desktop regression: **1,432 passed, 13 skipped in 39.47 seconds**, including the reviewed OBS control, native identity, pipe and audio handshake. The combined focused OBS/pipe/privacy/configuration/boundary/packaging bundle diff --git a/docs/plans/active/obs-desktop-pairing-ui.md b/docs/plans/active/obs-desktop-pairing-ui.md new file mode 100644 index 00000000..0b74f1df --- /dev/null +++ b/docs/plans/active/obs-desktop-pairing-ui.md @@ -0,0 +1,126 @@ +# Desktop OBS pairing setup + +September 13, 2026. Continue `feat/obs-enrollment-flow` after the reviewed native +checkpoint `15e1c77` in draft PR #36. The full OBS audio goal remains in the +[enrollment](obs-native-enrollment.md) and [audio](obs-audio-design.md) plans. + +## Goal and area + +Let Windows users explicitly import, replace, inspect and forget their desktop +OBS pairing from Speech & privacy, in one separate compact dialog. Preserve the +five Settings destinations. Display a saved local pairing as saved, never as a +verified connection or live transcription readiness. The native plugin remains +an unpublished development component; the dialog must state that live OBS +transcription is still in development. + +Area: new `utterleaf/obs_pairing_ui.py`, the existing Settings entry/close path, +desktop store owner support, focused store/UI tests and corresponding docs. +Native TaskDialog acceptance runs independently against synthetic state, with +no consumer OBS instance or store. + +## Constraints and observable acceptance + +- Opening Settings does not open a pairing store. Only explicitly opening the + Windows pairing dialog may create its private directories/owner sentinel. + The dialog worker claims a protected noninheritable share-zero owner file in + the fixed desktop pairing directory, independent of configuration profiles. + No automatic repair, key generation, network, capture or model loading occurs. +- Keep storage/key work on one joined or retained non-daemon worker; Tk stays on + its owner thread. Hold the store/owner until worker teardown. Queue only safe + outcomes and booleans; wipe loaded capability buffers, never display/log/copy + keys or raw exception/native path data. Imports use the existing validated + store; no format reimplementation. +- File selection and confirmation precede mutation. Initial import and explicit + replacement are distinct; explain that verified import removes the selected + transfer file when possible. Report saved-but-transfer-remains separately. + Cancellation before commit preserves prior pairing and transfer; cancellation + after commit must report saved state. Postcommit uncertainty requires explicit + status reload and must not claim rollback. +- Forget confirms removal of this desktop copy only, explains that copies remain + authorized until OBS Forget/Replace, and preserves preferences, models and + transcripts. Failed deletion does not claim success. Corrupt/inaccessible + state stays an error; allow explicit refresh/forget, not silent replacement. +- One child dialog per Settings window. Escape, window close and parent close + signal cancellation and wait asynchronously for worker teardown. If close + overlaps a committed/uncertain mutation, keep its outcome visible for Close + acknowledgement. Do not destroy the parent with a live pairing worker. +- Use the existing theme, readable status/actions, responsive wrapping and + keyboard focus. Test normal/compact sizes and enlarged fonts with rendered + fixtures. Physical scaling/assistive-technology and actual OBS UI acceptance + remain separate. + +## Verification + +From the owning worktree, use the original `.venv/Scripts/python.exe` with +`PYTHONPATH` set to this worktree. Run focused pairing-owner/store/UI and Settings +tests first, then affected privacy/configuration/boundary checks. Use disposable +Windows roots and synthetic capabilities for persistence, second-process owner, +cancel/reset/forget and real-Tk integration; no consumer pairing or microphone. +Capture the dialog in normal/compact/error states and inspect the actual images. +Independent review reads source, diff, contract and test/render evidence. + +## Implemented source and evidence + +`ObsPairingDialog` now opens lazily from Speech & privacy on Windows and retains +one worker/store owner until close. Normal and compact dark layouts keep status +and cancellation together in the top card; a scrollable body and fixed footer +preserve access with longer messages and enlarged text. Unexpected operation +failures mark the saved state unknown and require explicit Refresh; only a typed +precommit cancellation claims that the old pairing was preserved. Parent close +waits for storage teardown and leaves committed outcomes visible for acknowledgement. + +The desktop store now exposes `claim_owner()` using the protected, noninheritable +share-zero `obs-pairing-owner-v1.lock`. Its six real Windows tests include actual +child-process contention/succession and noninheritance, plus unsafe ACL, nonempty +sentinel and reparse refusal. The larger owner/storage/privacy bundle passes 125 +tests. The integrated ten-module setup/settings/store/privacy/configuration/backup/ +boundary bundle passes **204 tests with no skips in 33.28 seconds**. After the +final unknown-state and status-card adjustment, all **23 UI tests** pass again +in 5.14 seconds. Tests include disposable actual DPAPI import, dialog reopen and +Forget while preserving unrelated files, worker cancellation on both sides of +commit, parent-close waiting, startup failure, lazy/singleton entry and Settings +reset/discard isolation. Test fixtures that render transient dialogs map their +parent before measuring geometry; hidden-window dimensions are not layout evidence. + +Exact local commands from the owning worktree: + +```powershell +$py = "C:\Users\unknown\Projects\Mindict\.venv\Scripts\python.exe" +$env:PYTHONPATH = (Get-Location).Path +& $py -m pytest tests/test_obs_pairing_ui.py tests/test_obs_pairing_owner.py tests/test_obs_pairing_store.py tests/test_settings_ui.py tests/test_settings.py tests/test_config.py tests/test_privacy.py tests/test_backup.py tests/test_backup_store.py tests/test_repo_boundaries.py -o addopts='' -q +& $py -m pytest tests/test_obs_pairing_ui.py -o addopts='' -q +& $py tests/capture_settings.py +& $py native/obs-plugin/tools/test_native.py --toolchain "C:\Users\unknown\.local\llvm-mingw-20260616-ucrt-x86_64" --output "C:\Users\unknown\Projects\Mindict\.grok\obs-enrollment-flow\setup-native-final" --build "C:\Users\unknown\Projects\Mindict\.grok\obs-enrollment-flow\build-a" --headers "C:\Users\unknown\Projects\Mindict\.grok\obs-native-build\headers" --ui +``` + +Settings capture passes. The actual desktop dialog captures at +`.grok/obs-enrollment-flow/desktop-ui-captures/` cover unpaired, paired, compact +uncertain state and enlarged text; root inspected normal/compact/enlarged images. +The revised error card keeps its explanation visible instead of placing it below +the setup instructions. Native dialog acceptance passes through real Windows +TaskDialog activation and targeted Escape, with three fixture captures and no +mutations or OBS startup; the native driver passes 29 top-level commands. Three +existing test-only heap-shim link warnings remain. Native and desktop fixture +checks do not establish actual OBS integration, physical scaling/input or assistive +technology. Final independent desktop source/test/render review is clear. The +reviewer reran 29 focused UI/owner tests and a 187-test related bundle against +the final source, and directly checked mapped modality, Escape and an ambiguous +post-mutation close result. The latter probes supplement the committed tests; +they do not establish physical assistive-technology acceptance. Separate native +fixture/driver review is also clear, including all three captures and current +source/artifact/nested-receipt hashes. The final combined local evidence is +`.grok/obs-enrollment-flow/desktop-setup-verification.json`. + +CI [34758907354](https://github.com/RioPlay/utterleaf/actions/runs/34758907354) passes +all five desktop jobs at `15e1c776821abf14abfe6bec7786c8c133d867f7`, with release +skipped. That is the earlier linked native checkpoint; it does not validate this +desktop setup follow-up; its own CI remains pending. + +## Non-goals and stop + +No Android edits, plugin installation/publication, OBS launch, connection/Arm, +audio pipe, PCM, live recognition, new Settings preference or global redesign in +this step. Those remain required subsequent components, not removed scope. +Stop changing this setup increment when the named checks and independent review +pass; preserve its evidence, then continue real frontend/connection/Arm/audio +integration rather than declaring live OBS complete. diff --git a/docs/plans/active/obs-native-enrollment.md b/docs/plans/active/obs-native-enrollment.md index 9413cb72..a33c9e4b 100644 --- a/docs/plans/active/obs-native-enrollment.md +++ b/docs/plans/active/obs-native-enrollment.md @@ -77,7 +77,9 @@ Desktop Settings imports this user-selected package into its own private store. Neither UI displays or copies the key. Removal of the transfer file is best-effort and visible; a copied package remains usable by its Windows user until plugin revocation. Forget pairing is separate from Reset defaults. The storage component -below implements persistence; the app pairing flow is not exposed yet. +below implements persistence; the desktop import/replace/forget flow is now +exposed in the development source through the separate +[pairing setup dialog](obs-desktop-pairing-ui.md). The capability proves possession, not executable identity. It does not protect against a compromised process under the same Windows user. DPAPI normally binds @@ -108,7 +110,8 @@ canonical challenge. The client must validate every fixed field and match its own PID, session ID and requested mix mask before signing. Its existing native OBS server identity and password-authentication checks remain prerequisites. Vendor JSON will carry only strict fixed-length public encodings, never a key -or the pipe Hello secret. The JSON adapter remains to be implemented. +or the pipe Hello secret. The typed client adapter and strict native dispatch +are implemented in the linked increment below; actual OBS acceptance remains open. A challenge lasts 15,000 milliseconds on the server's monotonic clock. A matching repeated issue request may retrieve it; a different request cannot replace it @@ -352,8 +355,12 @@ fixture results, not successful frontend initialization or live OBS acceptance. Final independent source/test/documentation and receipt review is clear. The reviewer rehashed build inputs/outputs, both OBS runtimes, invoked tools, smoke inputs, native/client sources, dispatch inputs and all test artifacts/logs against -the current files. Exact-source CI for this linked increment remains pending; -the draft PR does not establish frontend/UI or live OBS acceptance. +the current files. All five desktop CI jobs passed for the linked native checkpoint `15e1c77` in +[34758907354](https://github.com/RioPlay/utterleaf/actions/runs/34758907354). +The draft PR does not establish actual OBS frontend or live-audio acceptance. +The [desktop pairing setup follow-up](obs-desktop-pairing-ui.md) records its own +new source, owner/UI checks and isolated native TaskDialog acceptance; that later +work is not covered by the earlier CI run. ### Private pairing stores: contract diff --git a/native/obs-plugin/README.md b/native/obs-plugin/README.md index c0b4b259..d4031788 100644 --- a/native/obs-plugin/README.md +++ b/native/obs-plugin/README.md @@ -135,6 +135,19 @@ precommit replacement failure and worker cancellation. Actual child processes compete for the private store owner. With `--build` and `--headers`, it verifies the recorded build/header inputs, runs a shimmed bridge registration/teardown fixture and six parser cases through real libobs data APIs (without libobs startup). -All 28 compilation/test commands pass in `native-final/test-receipt.json`. +The linked checkpoint's 28 compilation/test commands pass in +`native-final/test-receipt.json`. Omitting both optional arguments leaves those two frontend-boundary fixtures explicitly unrun; it does not establish their acceptance. + +Add `--ui` on a Windows desktop to also run the isolated native TaskDialog +fixture. The setup follow-up passes all 29 top-level driver commands at +`.grok/obs-enrollment-flow/setup-native-final/test-receipt.json`; the UI command +contains its own five compile/link/run commands and nested receipt. Its real +common-controls manifest activation, modal owner, command links, Escape +cancellation with zero mutations, duplicate-open guard and cleanup pass for +unpaired, paired and storage-error states. The three window captures contain +only the fixture. Pass-through observation wrappers call the actual Windows +TaskDialog function; no OBS or store implementation is linked into that fixture. +This verifies native dialog behavior in isolation, not real OBS frontend load, +file-picker import/export interaction, physical input or accessibility support. diff --git a/native/obs-plugin/tests/pairing_ui_test.c b/native/obs-plugin/tests/pairing_ui_test.c new file mode 100644 index 00000000..33f5a93b --- /dev/null +++ b/native/obs-plugin/tests/pairing_ui_test.c @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#define _WIN32_WINNT 0x0601 +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "../src/pairing_ui.h" +#include "../src/plugin_state.h" + +#ifndef PW_RENDERFULLCONTENT +#define PW_RENDERFULLCONTENT 0x00000002 +#endif + +typedef HRESULT (CALLBACK *ul_dll_get_version_fn)(DLLVERSIONINFO *); +typedef HRESULT (WINAPI *ul_task_dialog_fn)(const TASKDIALOGCONFIG *, int *, + int *, BOOL *); + +typedef struct fixture_state { + HANDLE owner_ready; + HANDLE begin_dialog; + HANDLE dialog_done; + HANDLE finish; + HWND owner; + DWORD ui_thread_id; +} fixture_state; + +typedef struct dialog_search { + HWND dialog; + unsigned count; +} dialog_search; + +#pragma pack(push, 1) +typedef struct bitmap_file_header { + WORD type; + DWORD size; + WORD reserved1; + WORD reserved2; + DWORD pixel_offset; +} bitmap_file_header; +#pragma pack(pop) + +static ul_plugin_snapshot fixture_snapshot; +static volatile LONG mutation_calls; +static volatile LONG config_checks; +static volatile LONG config_errors; +static HMODULE observed_common_controls; +static ul_task_dialog_fn observed_task_dialog; +static const wchar_t *expected_heading; +static const wchar_t *expected_content; +static const wchar_t *expected_buttons[3]; +static unsigned expected_button_count; + +static int check(bool condition, const char *message) +{ + if (!condition) { + fprintf(stderr, "FAIL: %s\n", message); + return 1; + } + return 0; +} + +ul_plugin_snapshot ul_plugin_get_status(void) +{ + return fixture_snapshot; +} + +ul_pairing_result ul_plugin_pair(const wchar_t *destination, bool replace, + bool *pairing_saved) +{ + (void)destination; + (void)replace; + if (pairing_saved != NULL) + *pairing_saved = false; + InterlockedIncrement(&mutation_calls); + return UL_PAIRING_CANCELLED; +} + +ul_pairing_result ul_plugin_export(const wchar_t *destination) +{ + (void)destination; + InterlockedIncrement(&mutation_calls); + return UL_PAIRING_CANCELLED; +} + +ul_pairing_result ul_plugin_forget(void) +{ + InterlockedIncrement(&mutation_calls); + return UL_PAIRING_CANCELLED; +} + +bool ul_plugin_start(void) { return false; } +void ul_plugin_stop_accepting(void) {} +void ul_plugin_close(void) {} +bool ul_plugin_issue(DWORD client_pid, const uint8_t session[16], + uint8_t additional_mix_mask, uint8_t out_challenge[60]) +{ + (void)client_pid; + (void)session; + (void)additional_mix_mask; + (void)out_challenge; + return false; +} +bool ul_plugin_prepare(const uint8_t *challenge, size_t challenge_size, + const uint8_t *proof, size_t proof_size) +{ + (void)challenge; + (void)challenge_size; + (void)proof; + (void)proof_size; + return false; +} + +static HRESULT WINAPI fixture_task_dialog(const TASKDIALOGCONFIG *config, + int *button, int *radio, + BOOL *verification) +{ + unsigned index; + LONG errors = 0; + + if (config == NULL || config->hwndParent == NULL || + config->pszWindowTitle == NULL || + wcscmp(config->pszWindowTitle, L"Utterleaf - OBS pairing") != 0 || + config->pszMainInstruction == NULL || expected_heading == NULL || + wcscmp(config->pszMainInstruction, expected_heading) != 0 || + config->pszContent == NULL || expected_content == NULL || + wcscmp(config->pszContent, expected_content) != 0 || + config->pszFooter == NULL || + wcscmp(config->pszFooter, + L"Pairing stays on this Windows account. Pairing never starts recording.") != 0 || + config->cButtons != expected_button_count || + (config->dwFlags & (TDF_ALLOW_DIALOG_CANCELLATION | + TDF_POSITION_RELATIVE_TO_WINDOW | + TDF_SIZE_TO_CONTENT | TDF_CALLBACK_TIMER)) != + (TDF_ALLOW_DIALOG_CANCELLATION | + TDF_POSITION_RELATIVE_TO_WINDOW | + TDF_SIZE_TO_CONTENT | TDF_CALLBACK_TIMER)) + ++errors; + if (config != NULL) { + for (index = 0; index < expected_button_count; ++index) { + if (config->pButtons == NULL || expected_buttons[index] == NULL || + wcsstr(config->pButtons[index].pszButtonText, + expected_buttons[index]) == NULL) + ++errors; + } + } + InterlockedExchangeAdd(&config_errors, errors); + InterlockedIncrement(&config_checks); + if (observed_task_dialog == NULL) + return E_UNEXPECTED; + return observed_task_dialog(config, button, radio, verification); +} + +HMODULE WINAPI fixture_LoadLibraryExW(LPCWSTR name, HANDLE file, DWORD flags) +{ + HMODULE module = LoadLibraryExW(name, file, flags); + if (module != NULL && name != NULL && _wcsicmp(name, L"comctl32.dll") == 0) + observed_common_controls = module; + return module; +} + +FARPROC WINAPI fixture_GetProcAddress(HMODULE module, LPCSTR name) +{ + FARPROC address = GetProcAddress(module, name); + if (module == observed_common_controls && name != NULL && + strcmp(name, "TaskDialogIndirect") == 0 && address != NULL) { + observed_task_dialog = (ul_task_dialog_fn)(void *)address; + return (FARPROC)(void *)fixture_task_dialog; + } + return address; +} + +static LRESULT CALLBACK owner_proc(HWND window, UINT message, WPARAM wparam, + LPARAM lparam) +{ + if (message == WM_DESTROY) { + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(window, message, wparam, lparam); +} + +static DWORD WINAPI ui_thread(void *parameter) +{ + fixture_state *state = parameter; + WNDCLASSW klass = {0}; + unsigned index; + + state->ui_thread_id = GetCurrentThreadId(); + klass.lpfnWndProc = owner_proc; + klass.hInstance = GetModuleHandleW(NULL); + klass.lpszClassName = L"UtterleafPairingUiFixtureOwner"; + klass.hCursor = LoadCursorW(NULL, MAKEINTRESOURCEW(32512)); + klass.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); + if (!RegisterClassW(&klass) && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) + return 2; + state->owner = CreateWindowExW( + 0, klass.lpszClassName, L"Utterleaf pairing fixture owner", + WS_OVERLAPPEDWINDOW, 80, 80, 720, 440, NULL, NULL, klass.hInstance, + NULL); + if (state->owner == NULL) + return 3; + ShowWindow(state->owner, SW_SHOWNORMAL); + UpdateWindow(state->owner); + SetEvent(state->owner_ready); + + for (index = 0; index < 3; ++index) { + if (WaitForSingleObject(state->begin_dialog, 5000) != WAIT_OBJECT_0) + return 4; + ResetEvent(state->begin_dialog); + ul_pairing_ui_show(state->owner); + SetEvent(state->dialog_done); + } + if (WaitForSingleObject(state->finish, 5000) != WAIT_OBJECT_0) + return 5; + DestroyWindow(state->owner); + return 0; +} + +static BOOL CALLBACK find_dialog_callback(HWND window, LPARAM parameter) +{ + dialog_search *search = (dialog_search *)parameter; + wchar_t title[128]; + + if (GetWindowTextW(window, title, 128) > 0 && + wcscmp(title, L"Utterleaf - OBS pairing") == 0) { + search->dialog = window; + ++search->count; + } + return TRUE; +} + +static dialog_search find_dialog(DWORD thread_id) +{ + dialog_search search = {0}; + EnumThreadWindows(thread_id, find_dialog_callback, (LPARAM)&search); + return search; +} + +static HWND wait_for_dialog(DWORD thread_id, DWORD timeout_ms) +{ + ULONGLONG deadline = GetTickCount64() + timeout_ms; + dialog_search search; + + do { + search = find_dialog(thread_id); + if (search.dialog != NULL) + return search.dialog; + Sleep(10); + } while (GetTickCount64() < deadline); + return NULL; +} + +typedef struct text_search { + const wchar_t *needle; + bool found; +} text_search; + +static BOOL CALLBACK find_text_callback(HWND window, LPARAM parameter) +{ + text_search *search = (text_search *)parameter; + wchar_t text[1024]; + + if (GetWindowTextW(window, text, 1024) > 0 && + wcsstr(text, search->needle) != NULL) { + search->found = true; + return FALSE; + } + return TRUE; +} + +static bool dialog_has_text(HWND dialog, const wchar_t *needle) +{ + text_search search = {needle, false}; + + EnumChildWindows(dialog, find_text_callback, (LPARAM)&search); + return search.found; +} + +static bool common_controls_v6_owns(HWND dialog, DWORD *major, DWORD *minor) +{ + HMODULE module = observed_common_controls; + ul_dll_get_version_fn get_version; + DLLVERSIONINFO version = {0}; + wchar_t path[MAX_PATH]; + wchar_t *name; + + (void)dialog; + *major = 0; + *minor = 0; + if (module == NULL || GetModuleFileNameW(module, path, MAX_PATH) == 0) + return false; + name = wcsrchr(path, L'\\'); + name = name == NULL ? path : name + 1; + if (_wcsicmp(name, L"comctl32.dll") != 0 || + GetProcAddress(module, "TaskDialogIndirect") == NULL) + return false; + get_version = (ul_dll_get_version_fn)(void *)GetProcAddress( + module, "DllGetVersion"); + if (get_version == NULL) + return false; + version.cbSize = sizeof(version); + if (FAILED(get_version(&version))) + return false; + *major = version.dwMajorVersion; + *minor = version.dwMinorVersion; + return version.dwMajorVersion >= 6; +} + +static bool write_all(HANDLE file, const void *buffer, DWORD size) +{ + DWORD written = 0; + return WriteFile(file, buffer, size, &written, NULL) && written == size; +} + +static bool render_dialog(HWND dialog, const wchar_t *name, + unsigned long *distinct_pixels) +{ + RECT bounds; + BITMAPINFO info = {0}; + bitmap_file_header file_header = {0}; + HDC window_dc = NULL; + HDC memory_dc = NULL; + HBITMAP bitmap = NULL; + HGDIOBJ old_bitmap = NULL; + uint32_t *pixels = NULL; + HANDLE file = INVALID_HANDLE_VALUE; + wchar_t directory[32768]; + wchar_t path[32768]; + int width, height; + DWORD pixel_bytes; + bool printed = false; + bool ok = false; + size_t index, count; + uint32_t first; + + *distinct_pixels = 0; + if (!GetWindowRect(dialog, &bounds)) + return false; + width = bounds.right - bounds.left; + height = bounds.bottom - bounds.top; + if (width < 240 || height < 120) + return false; + info.bmiHeader.biSize = sizeof(info.bmiHeader); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + window_dc = GetDC(dialog); + if (window_dc == NULL) + goto cleanup; + memory_dc = CreateCompatibleDC(window_dc); + if (memory_dc == NULL) + goto cleanup; + bitmap = CreateDIBSection(window_dc, &info, DIB_RGB_COLORS, + (void **)&pixels, NULL, 0); + if (bitmap == NULL || pixels == NULL) + goto cleanup; + old_bitmap = SelectObject(memory_dc, bitmap); + if (old_bitmap == NULL || old_bitmap == HGDI_ERROR) + goto cleanup; + count = (size_t)width * (size_t)height; + for (index = 0; index < count; ++index) + pixels[index] = 0x00ff00ffu; + RedrawWindow(dialog, NULL, NULL, + RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN); + printed = PrintWindow(dialog, memory_dc, PW_RENDERFULLCONTENT) != FALSE; + if (!printed) + printed = BitBlt(memory_dc, 0, 0, width, height, window_dc, 0, 0, + SRCCOPY) != FALSE; + if (!printed) + goto cleanup; + first = pixels[0]; + for (index = 1; index < count; ++index) { + if (pixels[index] != first && pixels[index] != 0x00ff00ffu) + ++*distinct_pixels; + } + if (*distinct_pixels < 100) + goto cleanup; + + if (GetEnvironmentVariableW(L"UL_PAIRING_UI_CAPTURE_DIR", directory, + 32768) != 0) { + CreateDirectoryW(directory, NULL); + if (_snwprintf(path, 32768, L"%ls\\%ls.bmp", directory, name) < 0) + goto cleanup; + file = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) + goto cleanup; + pixel_bytes = (DWORD)(count * sizeof(*pixels)); + file_header.type = 0x4d42; + file_header.pixel_offset = sizeof(file_header) + sizeof(info.bmiHeader); + file_header.size = file_header.pixel_offset + pixel_bytes; + if (!write_all(file, &file_header, sizeof(file_header)) || + !write_all(file, &info.bmiHeader, sizeof(info.bmiHeader)) || + !write_all(file, pixels, pixel_bytes)) + goto cleanup; + } + ok = true; + +cleanup: + if (file != INVALID_HANDLE_VALUE) + CloseHandle(file); + if (old_bitmap != NULL && old_bitmap != HGDI_ERROR) + SelectObject(memory_dc, old_bitmap); + if (bitmap != NULL) + DeleteObject(bitmap); + if (memory_dc != NULL) + DeleteDC(memory_dc); + if (window_dc != NULL) + ReleaseDC(dialog, window_dc); + return ok; +} + +static DWORD WINAPI competing_dialog(void *parameter) +{ + ul_pairing_ui_show((HWND)parameter); + return 0; +} + +static int inspect_and_cancel(fixture_state *state, const wchar_t *capture_name, + const wchar_t *heading, const wchar_t *content, + const wchar_t *button1, const wchar_t *button2, + const wchar_t *button3, bool check_double_open) +{ + HWND dialog = wait_for_dialog(state->ui_thread_id, 4000); + wchar_t class_name[64]; + DWORD major = 0, minor = 0; + unsigned long rendered_pixels = 0; + dialog_search search; + HANDLE competitor = NULL; + DWORD competitor_id = 0; + int failures = 0; + + failures += check(dialog != NULL, "TaskDialog appeared"); + if (dialog == NULL) + return failures; + failures += check(GetClassNameW(dialog, class_name, 64) > 0 && + wcscmp(class_name, L"#32770") == 0, + "TaskDialog has the native dialog class"); + failures += check(GetWindow(dialog, GW_OWNER) == state->owner, + "TaskDialog has the fixture owner"); + failures += check(!IsWindowEnabled(state->owner), + "TaskDialog is modal to the fixture owner"); + failures += check(common_controls_v6_owns(dialog, &major, &minor), + "TaskDialog is owned by common-controls v6"); + failures += check(wcscmp(expected_heading, heading) == 0 && + wcsstr(expected_content, content) != NULL && + InterlockedCompareExchange(&config_errors, 0, 0) == 0 && + InterlockedCompareExchange(&config_checks, 0, 0) > 0, + "real TaskDialog received the exact heading, content, footer and flags"); + if (button1 != NULL) + failures += check(dialog_has_text(dialog, button1), "first action rendered"); + if (button2 != NULL) + failures += check(dialog_has_text(dialog, button2), "second action rendered"); + if (button3 != NULL) + failures += check(dialog_has_text(dialog, button3), "third action rendered"); + failures += check(render_dialog(dialog, capture_name, &rendered_pixels), + "TaskDialog produced nonuniform rendered pixels"); + + if (check_double_open) { + competitor = CreateThread(NULL, 0, competing_dialog, state->owner, 0, + &competitor_id); + failures += check(competitor != NULL, "created competing open thread"); + if (competitor != NULL) { + failures += check(WaitForSingleObject(competitor, 1000) == + WAIT_OBJECT_0, + "a second open returned while modal dialog ran"); + search = find_dialog(state->ui_thread_id); + failures += check(search.count == 1, + "double-open guard kept one TaskDialog"); + CloseHandle(competitor); + } + } + + failures += check(PostMessageW(dialog, WM_KEYDOWN, VK_ESCAPE, 0), + "posted Escape keydown to fixture dialog"); + failures += check(PostMessageW(dialog, WM_KEYUP, VK_ESCAPE, 0), + "posted Escape keyup to fixture dialog"); + if (WaitForSingleObject(state->dialog_done, 3000) != WAIT_OBJECT_0) { + failures += check(false, "Escape closed the TaskDialog"); + PostMessageW(dialog, WM_CLOSE, 0, 0); + } + failures += check(!IsWindow(dialog), "modal TaskDialog cleaned up"); + failures += check(IsWindowEnabled(state->owner), + "fixture owner re-enabled after cancellation"); + failures += check(InterlockedCompareExchange(&mutation_calls, 0, 0) == 0, + "keyboard cancellation made no pairing mutation"); + printf("%ls: native TaskDialog comctl32 %lu.%lu, %lu rendered pixels, Escape cancelled\n", + capture_name, (unsigned long)major, (unsigned long)minor, + rendered_pixels); + return failures; +} + +int main(void) +{ + fixture_state state = {0}; + HANDLE thread = NULL; + DWORD exit_code = 1; + int failures = 0; + + state.owner_ready = CreateEventW(NULL, TRUE, FALSE, NULL); + state.begin_dialog = CreateEventW(NULL, TRUE, FALSE, NULL); + state.dialog_done = CreateEventW(NULL, TRUE, FALSE, NULL); + state.finish = CreateEventW(NULL, TRUE, FALSE, NULL); + failures += check(state.owner_ready != NULL && state.begin_dialog != NULL && + state.dialog_done != NULL && state.finish != NULL, + "created fixture synchronization events"); + if (failures != 0) + return 1; + thread = CreateThread(NULL, 0, ui_thread, &state, 0, NULL); + failures += check(thread != NULL, "created fixture UI thread"); + failures += check(thread != NULL && + WaitForSingleObject(state.owner_ready, 3000) == + WAIT_OBJECT_0, + "fixture owner appeared"); + if (failures != 0) + goto cleanup; + + fixture_snapshot = (ul_plugin_snapshot){UL_PLUGIN_UNPAIRED, + UL_PAIRING_MISSING, true, false}; + expected_heading = L"Connect Utterleaf to OBS"; + expected_content = L"Create a pairing file, then explicitly import it in Utterleaf. Live transcription is still in development."; + expected_buttons[0] = L"Pair Utterleaf"; + expected_button_count = 1; + InterlockedExchange(&config_checks, 0); + InterlockedExchange(&config_errors, 0); + ResetEvent(state.dialog_done); + SetEvent(state.begin_dialog); + failures += inspect_and_cancel( + &state, L"unpaired", L"Connect Utterleaf to OBS", + L"Create a pairing file, then explicitly import it in Utterleaf.", + L"Pair Utterleaf", NULL, NULL, true); + + fixture_snapshot = (ul_plugin_snapshot){UL_PLUGIN_PAIRED, UL_PAIRING_OK, + true, true}; + expected_heading = L"Utterleaf is paired"; + expected_content = L"A connection is prepared. Replacing or forgetting pairing disconnects it."; + expected_buttons[0] = L"Export pairing file"; + expected_buttons[1] = L"Replace pairing"; + expected_buttons[2] = L"Forget pairing"; + expected_button_count = 3; + InterlockedExchange(&config_checks, 0); + InterlockedExchange(&config_errors, 0); + ResetEvent(state.dialog_done); + SetEvent(state.begin_dialog); + failures += inspect_and_cancel( + &state, L"paired", L"Utterleaf is paired", + L"A connection is prepared. Replacing or forgetting pairing disconnects it.", + L"Export pairing file", L"Replace pairing", L"Forget pairing", false); + + fixture_snapshot = (ul_plugin_snapshot){UL_PLUGIN_STORAGE_ERROR, + UL_PAIRING_CORRUPT, false, false}; + expected_heading = L"Pairing needs attention"; + expected_content = L"The saved pairing could not be verified. Forget this pairing before creating another."; + expected_buttons[0] = NULL; + expected_buttons[1] = NULL; + expected_buttons[2] = NULL; + expected_button_count = 0; + InterlockedExchange(&config_checks, 0); + InterlockedExchange(&config_errors, 0); + ResetEvent(state.dialog_done); + SetEvent(state.begin_dialog); + failures += inspect_and_cancel( + &state, L"storage-error", L"Pairing needs attention", + L"The saved pairing could not be verified.", NULL, NULL, NULL, false); + +cleanup: + SetEvent(state.finish); + if (thread != NULL) { + if (WaitForSingleObject(thread, 3000) != WAIT_OBJECT_0) + failures += check(false, "fixture UI thread exited"); + if (GetExitCodeThread(thread, &exit_code)) + failures += check(exit_code == 0, "fixture UI thread succeeded"); + CloseHandle(thread); + } + CloseHandle(state.owner_ready); + CloseHandle(state.begin_dialog); + CloseHandle(state.dialog_done); + CloseHandle(state.finish); + if (failures == 0) + puts("pairing UI acceptance fixture passed"); + return failures == 0 ? 0 : 1; +} diff --git a/native/obs-plugin/tools/test_native.py b/native/obs-plugin/tools/test_native.py index ef01f0a4..1a8fdb8e 100644 --- a/native/obs-plugin/tools/test_native.py +++ b/native/obs-plugin/tools/test_native.py @@ -24,6 +24,7 @@ def main() -> None: parser.add_argument("--output", type=Path, required=True) parser.add_argument("--build", type=Path, help="Reviewed development build for libobs dispatch checks") parser.add_argument("--headers", type=Path, help="Pinned public-header cache used by --build") + parser.add_argument("--ui", action="store_true", help="Also run the bounded native dialog fixture (needs a Windows desktop)") args = parser.parse_args() if (args.build is None) != (args.headers is None): parser.error("--build and --headers must be supplied together") @@ -49,6 +50,8 @@ def main() -> None: "src/plugin_state.c", "src/plugin_state.h", "tests/plugin_state_test.c", "src/vendor_dispatch.c", "src/vendor_dispatch.h", "tests/vendor_dispatch_shim.c", "src/bridge.c", "tests/bridge_test.c", + "src/pairing_ui.c", "src/pairing_ui.h", "src/bridge.rc", "src/bridge.manifest", + "tests/pairing_ui_test.c", "tools/test_pairing_ui.py", "tests/vendor_dispatch.def", "tests/test_vendor_dispatch.py", "dependencies.json", "tests/handshake_test.c", "tests/handshake_failure_test.c", "tests/admission_identity_test.c", @@ -187,6 +190,10 @@ def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: run("vendor-dispatch-test", [sys._base_executable, ROOT / "tests/test_vendor_dispatch.py", runtime, vendor_dll]) artifacts.append(vendor_dll) + if args.ui: + run("pairing-ui-acceptance", [sys._base_executable, ROOT / "tools/test_pairing_ui.py", + "--toolchain", args.toolchain, "--output", output / "pairing-ui"], timeout=60) + artifacts.append(output / "pairing-ui/pairing-ui-receipt.json") if source_hashes != {str(path.relative_to(ROOT)): digest(path) for path in sources}: raise RuntimeError("Source changed during native verification") if client_hashes != {str(path.relative_to(REPO)): digest(path) for path in client_sources}: @@ -196,10 +203,11 @@ def run(name: str, arguments: list[str | Path], timeout: int = 60) -> None: receipt = { "schema": 2, "scope": "pairing/admission/runtime; optional parsed libobs dispatch; no OBS application, arming or audio", "vendor_dispatch": "passed" if args.build is not None else "not run: supply --build and --headers", + "native_dialog": "passed" if args.ui else "not run: supply --ui on a Windows desktop", "dispatch_inputs": {str(path): expected for path, expected in dispatch_inputs.items()}, "sources": source_hashes, "client_sources": client_hashes, "compiler_sha256": digest(compiler), - "artifacts": {path.name: digest(path) for path in artifacts}, + "artifacts": {str(path.relative_to(output)): digest(path) for path in artifacts}, "logs": {path.name: digest(path) for path in logs}, "commands": commands, } diff --git a/native/obs-plugin/tools/test_pairing_ui.py b/native/obs-plugin/tools/test_pairing_ui.py new file mode 100644 index 00000000..b13c4b86 --- /dev/null +++ b/native/obs-plugin/tools/test_pairing_ui.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Build and run the isolated native pairing TaskDialog fixture on Windows.""" +# SPDX-License-Identifier: GPL-2.0-or-later + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--toolchain", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + receipt_path = output / "pairing-ui-receipt.json" + receipt_path.unlink(missing_ok=True) + captures = output / "captures" + captures.mkdir(exist_ok=True) + compiler = args.toolchain.resolve() / "bin/x86_64-w64-mingw32-clang.exe" + windres = args.toolchain.resolve() / "bin/x86_64-w64-mingw32-windres.exe" + resource = output / "pairing-ui-resource.o" + pairing_ui_object = output / "pairing-ui.o" + fixture_object = output / "pairing-ui-test.o" + executable = output / "pairing-ui-test.exe" + sources = [ + ROOT / "src/pairing_ui.c", + ROOT / "src/pairing_ui.h", + ROOT / "src/plugin_state.h", + ROOT / "src/authorization.h", + ROOT / "src/admission.h", + ROOT / "src/pairing_store.h", + ROOT / "src/bridge.rc", + ROOT / "src/bridge.manifest", + ROOT / "tests/pairing_ui_test.c", + Path(__file__).resolve(), + ] + initial_hashes = {str(path.relative_to(ROOT)): digest(path) for path in sources} + commands: list[list[str]] = [] + + def run(name: str, command: list[str | Path], timeout: int) -> str: + normalized = [str(part) for part in command] + commands.append(normalized) + result = subprocess.run( + normalized, + cwd=output, + env={ + **os.environ, + "PYTHONNOUSERSITE": "1", + "PYTHONDONTWRITEBYTECODE": "1", + "UL_PAIRING_UI_CAPTURE_DIR": str(captures), + }, + capture_output=True, + text=True, + timeout=timeout, + ) + log = result.stdout + result.stderr + (output / f"{name}.log").write_text(log, encoding="utf-8") + if result.returncode: + raise RuntimeError(f"{name} failed ({result.returncode}); see {name}.log") + print(f"{name}: passed", flush=True) + return log + + run( + "pairing-ui-resource-build", + [windres, "-I", ROOT / "src", "-i", ROOT / "src/bridge.rc", "-O", "coff", "-o", resource], + 30, + ) + run( + "pairing-ui-build", + [ + compiler, + "-std=c11", + "-D_M_X64=100", + "-Wall", + "-Wextra", + "-Werror", + "-DLoadLibraryExW=fixture_LoadLibraryExW", + "-DGetProcAddress=fixture_GetProcAddress", + ROOT / "src/pairing_ui.c", + "-c", + "-o", + pairing_ui_object, + ], + 30, + ) + run( + "pairing-ui-fixture-build", + [ + compiler, + "-std=c11", + "-D_M_X64=100", + "-Wall", + "-Wextra", + "-Werror", + ROOT / "tests/pairing_ui_test.c", + "-c", + "-o", + fixture_object, + ], + 30, + ) + run( + "pairing-ui-link", + [ + compiler, + pairing_ui_object, + fixture_object, + resource, + "-lcomctl32", + "-lole32", + "-luuid", + "-lshell32", + "-lgdi32", + "-luser32", + "-o", + executable, + ], + 30, + ) + log = run("pairing-ui-test", [executable], 20) + + expected_captures = [captures / name for name in ("unpaired.bmp", "paired.bmp", "storage-error.bmp")] + if any(not path.is_file() or path.stat().st_size < 1024 for path in expected_captures): + raise RuntimeError("The fixture did not produce all three bounded UI captures") + final_hashes = {str(path.relative_to(ROOT)): digest(path) for path in sources} + if final_hashes != initial_hashes: + raise RuntimeError("Pairing UI fixture sources changed during verification") + receipt = { + "schema": 1, + "scope": ( + "isolated native fixture: real common-controls TaskDialog activation, " + "rendering and targeted Escape cancellation; no OBS, consumer pairing, " + "configuration or audio" + ), + "limitations": "fixture UI only; no real OBS frontend or physical interaction acceptance", + "instrumentation": ( + "pass-through LoadLibraryExW/GetProcAddress wrappers record the real module and function; " + "the TaskDialogIndirect wrapper validates its config, then calls the recorded Windows function" + ), + "sources": initial_hashes, + "compiler_sha256": digest(compiler), + "windres_sha256": digest(windres), + "artifacts": { + path.name: digest(path) + for path in [resource, pairing_ui_object, fixture_object, executable, *expected_captures] + }, + "commands": commands, + "fixture_output": log.splitlines(), + } + receipt_path.write_text(json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + print(receipt_path) + + +if __name__ == "__main__": + main() diff --git a/tests/test_obs_pairing_owner.py b/tests/test_obs_pairing_owner.py new file mode 100644 index 00000000..6df37315 --- /dev/null +++ b/tests/test_obs_pairing_owner.py @@ -0,0 +1,235 @@ +"""Real Windows ownership checks for desktop OBS pairing setup.""" +from __future__ import annotations + +import ctypes as C +import os +from pathlib import Path +import struct +import subprocess +import sys +import time + +import pytest + +from utterleaf import obs_pairing_store as pairing + + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="Windows pairing owner lock") + +_FSCTL_SET_REPARSE_POINT = 0x000900A4 +_FSCTL_DELETE_REPARSE_POINT = 0x000900AC +_IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003 +_FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000 +_FILE_FLAG_BACKUP_SEMANTICS = 0x02000000 +_INVALID_HANDLE = C.c_void_p(-1).value + + +def _owner(store: pairing.ObsPairingStore) -> Path: + return Path(store._owner_path) # noqa: SLF001 - exact sentinel fixture + + +def _child_claim(root: str) -> int: + store = pairing.ObsPairingStore(_root=Path(root)) + try: + store.claim_owner() + except pairing.PairingStoreError: + store.close() + return 3 + store.close() + return 0 + + +def _child_linger(marker: str) -> int: + Path(marker).write_bytes(b"ready") + return 0 if sys.stdin.buffer.read(1) == b"x" else 4 + + +def _run_claim(root: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, __file__, "--claim", str(root)], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + +def _junction_api(): + kernel32 = C.WinDLL("kernel32", use_last_error=True) + kernel32.CreateFileW.argtypes = [C.c_wchar_p, C.c_uint32, C.c_uint32, + C.c_void_p, C.c_uint32, C.c_uint32, C.c_void_p] + kernel32.CreateFileW.restype = C.c_void_p + kernel32.DeviceIoControl.argtypes = [C.c_void_p, C.c_uint32, C.c_void_p, + C.c_uint32, C.c_void_p, C.c_uint32, + C.POINTER(C.c_uint32), C.c_void_p] + kernel32.DeviceIoControl.restype = C.c_int + kernel32.CloseHandle.argtypes = [C.c_void_p] + kernel32.CloseHandle.restype = C.c_int + return kernel32 + + +def _make_junction(target: Path, junction: Path) -> None: + target.mkdir() + junction.mkdir() + kernel32 = _junction_api() + handle = kernel32.CreateFileW( + str(junction), 0x40000000, 0, None, 3, + _FILE_FLAG_OPEN_REPARSE_POINT | _FILE_FLAG_BACKUP_SEMANTICS, None) + if handle in (None, _INVALID_HANDLE): + os.rmdir(junction) + raise RuntimeError("could not open junction fixture") + try: + substitute = ("\\??\\" + str(target)).encode("utf-16-le") + printed = str(target).encode("utf-16-le") + names = substitute + b"\0\0" + printed + b"\0\0" + header = struct.pack( + " None: + kernel32 = _junction_api() + handle = kernel32.CreateFileW( + str(junction), 0x40000000, 0, None, 3, + _FILE_FLAG_OPEN_REPARSE_POINT | _FILE_FLAG_BACKUP_SEMANTICS, None) + if handle in (None, _INVALID_HANDLE): + raise RuntimeError("could not reopen junction fixture") + try: + header = C.create_string_buffer( + struct.pack("= dialog.root.winfo_rootx() + assert button.winfo_rootx() + button.winfo_width() <= dialog.root.winfo_rootx() + dialog.root.winfo_width() + assert button.winfo_rooty() + button.winfo_height() <= dialog.root.winfo_rooty() + dialog.root.winfo_height() + dialog.canvas.yview_moveto(1) + dialog._reveal_focus(SimpleNamespace(widget=dialog.import_button)) + tk_root.update() + top = dialog.import_button.winfo_rooty() - dialog.canvas.winfo_rooty() + assert top >= 0 and top + dialog.import_button.winfo_height() <= dialog.canvas.winfo_height() + tk_root.withdraw() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Real Windows DPAPI store") +def test_real_disposable_import_reload_and_forget_through_ui(opened, tk_root, tmp_path, monkeypatch): + from utterleaf.obs_pairing_store import ObsPairingStore, _Native, encode_pairing_package, ROLE_TRANSFER + native = _Native() + transfer = tmp_path / "transfer.ulobs" + key = bytearray(b"s" * 32) + with native.opened(str(transfer), create=True, write=True) as handle: + native.write(handle, encode_pairing_package(key, ROLE_TRANSFER, _native=native)) + sentinel = tmp_path / "transcript.txt" + sentinel.write_text("keep") + factory = lambda: ObsPairingStore(_root=tmp_path) + dialog, _ = opened(factory=factory) + monkeypatch.setattr(ui.filedialog, "askopenfilename", lambda **_: str(transfer)) + dialog.import_pairing() + pump(tk_root, lambda: not dialog.busy) + assert dialog.paired and not transfer.exists() + dialog.close() + pump(tk_root, lambda: dialog.closed) + second, _ = opened(factory=factory) + assert second.paired + second.forget_pairing() + pump(tk_root, lambda: not second.busy) + assert second.paired is False and sentinel.read_text() == "keep" + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows Settings pairing entry") +def test_settings_entry_is_lazy_singleton_and_parent_close_waits(opened, tk_root, monkeypatch): + from utterleaf import settings_ui + from utterleaf.config import Config + monkeypatch.setattr(settings_ui, "startup_enabled", lambda: False) + monkeypatch.setattr(settings_ui, "dictionary_text", lambda: "") + parent = tk.Toplevel(tk_root) + app = settings_ui.SettingsWindow(parent, Config(), background=False) + store = Store() + actual_dialog = ui.ObsPairingDialog + dialogs = [] + def create(root): + dialog = actual_dialog(root, store_factory=lambda: store) + dialogs.append(dialog) + return dialog + monkeypatch.setattr(ui, "ObsPairingDialog", create) + try: + assert not store.claimed and app.obs_pairing_dialog is None + app.vars["hotkey"].set("f8") + before = app._snapshot() + app.show_obs_pairing() + app.show_obs_pairing() + assert len(dialogs) == 1 and app._snapshot() == before + dialog = dialogs[0] + pump(tk_root, lambda: not dialog.busy) + store.block = store.commit_before_release = True + dialog.import_pairing() + assert store.entered.wait(3) + app.close() + assert not app.closed and not dialog.closed + store.release.set() + pump(tk_root, lambda: dialog.stopped.is_set() and not dialog.busy) + assert not app.closed and not dialog.closed + dialog.close() + assert app.closed and store.closed + finally: + store.release.set() + if not app.closed: + app.vars["hotkey"].set(app.baseline["hotkey"]) + app.close() + if dialogs and not dialogs[0].closed: + pump(tk_root, lambda: dialogs[0].stopped.is_set()) + dialogs[0].close() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows Settings pairing entry") +def test_reset_and_discard_leave_pairing_untouched(opened, tk_root, monkeypatch): + from utterleaf import settings_ui + from utterleaf.config import Config + monkeypatch.setattr(settings_ui, "startup_enabled", lambda: False) + monkeypatch.setattr(settings_ui, "dictionary_text", lambda: "") + app = settings_ui.SettingsWindow(tk.Toplevel(tk_root), Config(hotkey="f8"), background=False) + dialog, store = opened(Store(paired=True)) + app.obs_pairing_dialog = dialog + try: + app.restore_defaults() + assert store.paired and not store.calls + dialog.close() + pump(tk_root, lambda: dialog.closed) + monkeypatch.setattr(settings_ui.messagebox, "askyesno", lambda *_, **__: False) + app.close() + assert not app.closed and store.paired and not store.calls + monkeypatch.setattr(settings_ui.messagebox, "askyesno", lambda *_, **__: True) + app.close() + assert app.closed and store.paired and not store.calls + finally: + if not app.closed: + app.closed = True + app.root.after_cancel(app.poll_id) + if app._page_reset is not None: + app.root.after_cancel(app._page_reset) + app.root.destroy() diff --git a/utterleaf/obs_pairing_store.py b/utterleaf/obs_pairing_store.py index 2ce50016..5c0ae228 100644 --- a/utterleaf/obs_pairing_store.py +++ b/utterleaf/obs_pairing_store.py @@ -444,6 +444,12 @@ def read(self, handle: int) -> bytes: offset += received.value return buffer.raw + def size(self, handle: int) -> int: + size = C.c_int64() + if not self.k.GetFileSizeEx(handle, C.byref(size)) or size.value < 0: + raise PairingStoreError("Could not inspect the pairing owner lock") + return size.value + def write(self, handle: int, payload: bytes) -> None: if type(payload) is not bytes or not 13 <= len(payload) <= MAX_PACKAGE_BYTES: raise PairingStoreError("Invalid pairing file size") @@ -485,6 +491,8 @@ def __init__(self, *, _root: Path | None = None, _native=None): self._root = _path(_root if _root is not None else self._native.known_folder()) self.directory = ntpath.join(self._root, "Utterleaf", "desktop") self.path = ntpath.join(self.directory, "obs-pairing-v1.dat") + self._owner_path = ntpath.join(self.directory, "obs-pairing-owner-v1.lock") + self._owner_claimed = False try: self._held.enter_context(self._native.parents(self._root)) for folder in (ntpath.join(self._root, "Utterleaf"), self.directory): @@ -516,8 +524,30 @@ def close(self) -> None: with _STORE_LOCK: if not self._closed: self._closed = True + self._owner_claimed = False self._held.close() + def claim_owner(self) -> None: + """Retain the one per-user desktop pairing setup owner until close.""" + with _STORE_LOCK: + self._ensure_open() + if self._owner_claimed: + return + owner = ExitStack() + try: + handle = owner.enter_context( + self._native.opened(self._owner_path, missing=True)) + if handle is None: + handle = owner.enter_context( + self._native.opened(self._owner_path, create=True)) + if self._native.size(handle) != 0: + raise PairingStoreError("The pairing owner lock is invalid") + self._held.callback(owner.close) + self._owner_claimed = True + except BaseException: + owner.close() + raise + def load(self) -> bytearray | None: with _STORE_LOCK: self._ensure_open() diff --git a/utterleaf/obs_pairing_ui.py b/utterleaf/obs_pairing_ui.py new file mode 100644 index 00000000..2d0a4c02 --- /dev/null +++ b/utterleaf/obs_pairing_ui.py @@ -0,0 +1,350 @@ +"""Explicit local OBS pairing management; no connection, capture or model imports.""" +from __future__ import annotations + +from dataclasses import dataclass +import queue +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, ttk + +from utterleaf import theme +from utterleaf.obs_pairing_store import ( + ObsPairingStore, PairingStoreCancelled, PairingStoreCommitError, +) + + +@dataclass(frozen=True) +class _Outcome: + action: str + kind: str + paired: bool | None + package_removed: bool = False + + +def _stored(store) -> bool: + key = store.load() + try: + return key is not None + finally: + if key is not None: + key[:] = b"\0" * len(key) + + +def _pairing_worker(factory, commands, events, cancel, stop, stopped): + """This thread receives no Tk objects and returns no secrets or exceptions.""" + try: + with factory() as store: + store.claim_owner() + paired = None + action, path, replace = "refresh", None, False + while not stop.is_set(): + try: + if action == "refresh": + paired = _stored(store) + outcome = _Outcome(action, "ready", paired) + elif cancel.is_set(): + raise PairingStoreCancelled("Cancelled before operation") + elif action == "import": + result = store.import_package(path, replace=replace, cancelled=cancel.is_set) + paired = True + outcome = _Outcome(action, "saved", paired, result.package_removed) + else: + store.forget() + paired = False + outcome = _Outcome(action, "forgotten", paired) + except PairingStoreCancelled: + outcome = _Outcome(action, "cancelled", paired) + except PairingStoreCommitError: + paired = None + outcome = _Outcome(action, "uncertain", paired) + except Exception: + # An unexpected failure may happen after a storage change. + # Only the typed precommit cancellation proves preservation. + paired = None + outcome = _Outcome(action, "error", paired) + events.put(outcome) + if stop.is_set(): + break + command = commands.get() + if command is None: + break + action, path, replace = command + except Exception: + events.put(_Outcome("open", "unavailable", None)) + finally: + stopped.set() + + +class ObsPairingDialog: + def __init__(self, parent, *, store_factory=ObsPairingStore): + self.parent, self.factory = parent, store_factory + self.closed = self.busy = self.closing = False + self.paired = None + self._on_closed = None + self._review_close_result = False + self.root = tk.Toplevel(parent) + theme.apply(self.root) + self.root.title("Utterleaf · OBS pairing") + self.root.geometry("620x480") + self.root.minsize(450, 460) + self.root.transient(parent) + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + self.labels = [] + viewport = ttk.Frame(self.root) + viewport.grid(row=0, column=0, sticky="nsew") + viewport.columnconfigure(0, weight=1) + viewport.rowconfigure(0, weight=1) + self.canvas = tk.Canvas(viewport, bg=theme.SURFACE_LOW, highlightthickness=0) + self.canvas.grid(row=0, column=0, sticky="nsew") + scroll = ttk.Scrollbar(viewport, orient="vertical", command=self.canvas.yview) + scroll.grid(row=0, column=1, sticky="ns") + self.canvas.configure(yscrollcommand=scroll.set) + content = ttk.Frame(self.canvas, style="Page.TFrame", padding=24) + self.content = content + self.content_id = self.canvas.create_window((0, 0), window=content, anchor="nw") + content.bind("", lambda _e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))) + self.canvas.bind("", self._resize) + self._label(content, "OBS PAIRING", "Eyebrow.TLabel") + self._label(content, "Keep your connection local", "Title.TLabel", pady=(8, 10)) + self._label(content, "Live OBS transcription is in development. This window manages your saved pairing only.", + "Subtitle.TLabel", pady=(0, 18)) + card = ttk.Frame(content, padding=16) + card.pack(fill="x") + self.state = tk.StringVar(self.root, "Checking saved pairing…") + self.state_label = ttk.Label(card, textvariable=self.state, style="Section.TLabel", wraplength=520) + self.state_label.pack(anchor="w", fill="x") + self.labels.append((self.state_label, 80)) + self.status = tk.StringVar(self.root, "Checking private local storage…") + self.status_label = ttk.Label(card, textvariable=self.status, style="Hint.TLabel", wraplength=520) + self.status_label.pack(anchor="w", fill="x", pady=(6, 12)) + self.labels.append((self.status_label, 80)) + self.import_button = ttk.Button(card, text="Import pairing file…", style="Primary.TButton", + command=self.import_pairing) + self.import_button.pack(anchor="w") + self._label(content, "In OBS, open Tools → Utterleaf pairing to create or export a pairing file. " + "Choose that file here on the same Windows account.", "Subtitle.TLabel", pady=(16, 10)) + footer = ttk.Frame(self.root, padding=(20, 12)) + footer.grid(row=1, column=0, sticky="ew") + self.refresh_button = ttk.Button(footer, text="Refresh", command=self.refresh) + self.refresh_button.pack(side="left") + self.forget_button = ttk.Button(footer, text="Forget…", command=self.forget_pairing) + self.forget_button.pack(side="left", padx=8) + self.close_button = ttk.Button(footer, text="Close", command=self.close) + self.close_button.pack(side="right") + self.cancel_button = ttk.Button(card, text="Cancel import", command=self.cancel_import) + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.bind("", lambda _e: self.close()) + self.root.bind("", self._resize) + self.root.bind("", self._mapped, add="+") + self.root.bind("", self._reveal_focus, add="+") + self.root.bind("", lambda e: self.canvas.yview_scroll(-1 if e.delta > 0 else 1, "units")) + self.root.bind("", lambda _e: self.canvas.yview_scroll(-1, "units")) + self.root.bind("", lambda _e: self.canvas.yview_scroll(1, "units")) + self.root.bind("", self._destroyed, add="+") + self._start() + self.poll_id = self.root.after(40, self._poll) + + def _mapped(self, event): + if event.widget is self.root and not self.closed: + self.root.grab_set() + self.close_button.focus_set() + + def _label(self, parent, text, style, *, pady=0, margin=48): + label = ttk.Label(parent, text=text, style=style, wraplength=560) + label.pack(anchor="w", fill="x", pady=pady) + self.labels.append((label, margin)) + return label + + def _resize(self, event): + if event.widget is self.canvas: + self.canvas.itemconfigure(self.content_id, width=event.width) + for label, margin in self.labels: + label.configure(wraplength=max(100, event.width - margin)) + + def _reveal_focus(self, event): + self._reveal(event.widget) + + def _reveal(self, widget): + if not str(widget).startswith(str(self.content)): + return + self.root.update_idletasks() + region = self.canvas.bbox("all") + height = max(1, region[3] - region[1]) if region else 1 + top = widget.winfo_rooty() - self.canvas.winfo_rooty() + bottom = top + widget.winfo_height() - self.canvas.winfo_height() + if top < 0: + self.canvas.yview_moveto(max(0, self.canvas.yview()[0] + top / height)) + elif bottom > 0: + self.canvas.yview_moveto(min(1, self.canvas.yview()[0] + bottom / height)) + + def _start(self): + self.commands, self.events = queue.Queue(maxsize=1), queue.Queue(maxsize=2) + self.cancel, self.stop, self.stopped = threading.Event(), threading.Event(), threading.Event() + self.busy = True + self._failed = False + self.worker = threading.Thread(target=_pairing_worker, + args=(self.factory, self.commands, self.events, self.cancel, self.stop, self.stopped), + name="obs-pairing-setup", daemon=False) + try: + self.worker.start() + except Exception: + self._failed = True + self.events.put(_Outcome("open", "unavailable", None)) + self.stopped.set() + self._controls() + + def _controls(self): + active = not self.busy and not self.closing and not self._failed and not self.stopped.is_set() + self.import_button.configure(text="Replace pairing file…" if self.paired else "Import pairing file…", + state="normal" if active and self.paired is not None else "disabled") + self.forget_button.configure(state="normal" if active and self.paired is not False else "disabled") + self.refresh_button.configure(text="Retry" if self.stopped.is_set() else "Refresh", + state="normal" if not self.busy and not self.closing else "disabled") + + def _submit(self, action, path=None, replace=False): + if self.busy or self.closing or self.stopped.is_set(): + return + self.busy = True + self.cancel.clear() + self.status.set({"refresh": "Checking saved pairing…", "import": "Importing and verifying pairing…", + "forget": "Removing this desktop pairing…"}[action]) + if action == "import": + self.cancel_button.pack(anchor="w", pady=(2, 0)) + self.cancel_button.configure(state="normal") + self._controls() + self.commands.put_nowait((action, path, replace)) + + def refresh(self): + if self.closed or self.busy or self.closing: + return + if self.stopped.is_set(): + self._join() + self.state.set("Checking saved pairing…") + self.status.set("Checking private local storage…") + self._start() + elif not self._failed: + self._submit("refresh") + + def import_pairing(self): + if self.busy or self.closing or self.paired is None or self.stopped.is_set(): + return + path = filedialog.askopenfilename(parent=self.root, title="Choose an OBS pairing file", + filetypes=[("Utterleaf OBS pairing", "*.ulobs")]) + if not path or self.closed or self.closing: + return + replace = bool(self.paired) + title = "Replace this desktop pairing?" if replace else "Import this pairing?" + if not messagebox.askyesno(title, + "Save the selected pairing in Utterleaf? After saving and verifying it, Utterleaf removes " + "the selected transfer file when possible.\n\n" + "This changes only this desktop copy. Other copied files remain valid until you replace " + "or forget pairing in OBS. No recording starts.", parent=self.root): + return + self._submit("import", path, replace) + + def forget_pairing(self): + if self.busy or self.closing or self.paired is False or self.stopped.is_set(): + return + if messagebox.askyesno("Forget this desktop pairing?", + "Remove Utterleaf’s saved pairing from this computer?\n\n" + "This does not revoke copied pairing files. Use Forget pairing in OBS to revoke them. " + "Your preferences, models and transcripts are kept.", parent=self.root): + self._submit("forget") + + def cancel_import(self): + self.cancel.set() + self.cancel_button.configure(state="disabled") + self.status.set("Cancelling before commit, if possible. Waiting for the verified result…") + + def _receive(self, outcome): + self.busy, self.paired = False, outcome.paired + if outcome.kind == "unavailable": + self._failed = True + self.cancel_button.pack_forget() + self.state.set("Pairing saved" if self.paired else "Not paired" if self.paired is False else "Pairing needs attention") + messages = { + "ready": "A saved pairing does not mean OBS is connected. Nothing here starts recording. Pairing is separate from Reset to defaults.", + "forgotten": "This desktop pairing was removed. Other copies remain valid until revoked in OBS.", + "cancelled": "Cancelled before commit. Your previous pairing and the transfer file were kept.", + "uncertain": "Pairing was written but could not be verified. The transfer file was kept. Refresh to inspect the saved state before continuing.", + "unavailable": "Private pairing storage could not open. Close other pairing windows, or check local storage access, then Retry.", + "error": "The operation could not finish. Check the selected file and local storage access. Refresh to inspect the saved state.", + "saved": "Pairing saved and verified. The selected transfer file was removed." if outcome.package_removed else + "Pairing saved and verified, but the selected transfer file remains. Keep it private; copies remain usable until revoked in OBS.", + } + message = messages[outcome.kind] + if outcome.kind == "saved" and self.cancel.is_set(): + message = "Pairing committed before cancellation. " + message + if self.closing and outcome.action in ("import", "forget") and outcome.kind != "cancelled": + self._review_close_result = True + message += " Close this window after reviewing the result." + self.status.set(message) + self._controls() + if outcome.kind != "ready" and self.root.winfo_viewable(): + self._reveal(self.status_label) + + def _poll(self): + if self.closed: + return + while True: + try: + self._receive(self.events.get_nowait()) + except queue.Empty: + break + if self.stopped.is_set(): + self._join() + self.busy = False + self._controls() + if self.closing and not self._review_close_result: + self._finish_close() + return + self.poll_id = self.root.after(40, self._poll) + + def close(self, *, on_closed=None): + if on_closed is not None: + self._on_closed = on_closed + if self.closed: + return + if self.stopped.is_set() and not self.busy: + self._finish_close() + return + self.closing = True + self.cancel.set() + self.stop.set() + try: + self.commands.put_nowait(None) + except queue.Full: + pass + self.status.set("Finishing the pairing operation and releasing private storage…") + self._controls() + + def _finish_close(self): + self._join() + callback = self._on_closed + self._on_closed = None + self.closed = True + self.root.after_cancel(self.poll_id) + self.root.destroy() + if callback is not None: + callback() + + def _join(self): + if self.worker.ident is not None: + self.worker.join() + + def _destroyed(self, event): + if event.widget is not self.root: + return + self.closed = True + if hasattr(self, "poll_id"): + try: + self.root.after_cancel(self.poll_id) + except tk.TclError: + pass + self.cancel.set() + self.stop.set() + try: + self.commands.put_nowait(None) + except queue.Full: + pass diff --git a/utterleaf/settings_ui.py b/utterleaf/settings_ui.py index 7bd7bda6..3a99d5b3 100644 --- a/utterleaf/settings_ui.py +++ b/utterleaf/settings_ui.py @@ -3,6 +3,7 @@ from __future__ import annotations import queue +import sys import threading import tkinter as tk from tkinter import filedialog, messagebox, ttk @@ -33,6 +34,7 @@ def __init__(self, root: tk.Tk, cfg: Config, *, background: bool = True): self._mascot_heading_labels = set() self._column_labels = set() self.appearance_guide = None + self.obs_pairing_dialog = None self.report = "" self.vars = {} self.fields = {} @@ -446,6 +448,19 @@ def _engine(self): self._check(p, "Allow missing model downloads", "allow_network", "Only model files are downloaded. Turn off to require an already installed model.") self._check(p, "Restore my clipboard after pasting", "restore_clipboard") + if sys.platform == "win32": + p = self._section(page, "OBS pairing", "Manage the private pairing saved for this Windows user. Live OBS transcription is still in development.") + ttk.Button(p, text="Manage OBS pairing…", command=self.show_obs_pairing).pack(anchor="w") + + def show_obs_pairing(self): + if sys.platform != "win32" or self.closed: + return + if self.obs_pairing_dialog is not None and not self.obs_pairing_dialog.closed: + self.obs_pairing_dialog.root.lift() + self.obs_pairing_dialog.close_button.focus_set() + return + from utterleaf.obs_pairing_ui import ObsPairingDialog + self.obs_pairing_dialog = ObsPairingDialog(self.root) def _selected_model(self): from dataclasses import replace @@ -933,6 +948,10 @@ def close(self): if self.saving: self.status.set("Finishing your save…") return + if self.obs_pairing_dialog is not None and not self.obs_pairing_dialog.closed: + self.status.set("Finishing OBS pairing before closing Settings…") + self.obs_pairing_dialog.close(on_closed=self.close) + return if self._reset_pending or self._snapshot() != self.baseline: if not messagebox.askyesno("Discard unsaved changes?", "Close without saving your changes?", parent=self.root): return