diff --git a/docs/adr/ADR-043-stereo-camera-source.md b/docs/adr/ADR-043-stereo-camera-source.md index dfa710ed70..ff049749ae 100644 --- a/docs/adr/ADR-043-stereo-camera-source.md +++ b/docs/adr/ADR-043-stereo-camera-source.md @@ -143,3 +143,30 @@ camera switch** → **stored Deny** → delegating client → stored Allow → A (spec §7.1 is normative). Delegation means exactly "no runtime prompt and no stored per-app decision needed". `displayxr-cli camera trust` over a stored Deny clears it and says so (the user's newer explicit word); an installer's registration never touches the user's store. + +## Amendment 3 (2026-10-06, spec v3) — delegation hardening + +Delegation trusted a path, any path, and could not be turned off by the client that held it. +Three changes close that (spec §2a, §7.1, §7.1.1 are normative): + +- **A user-writable executable path needs a signer.** A path is only an identity where nobody + but an administrator can put a different file there. On Windows an entry whose executable is + not under `%ProgramFiles%`, `%ProgramFiles(x86)%` or `%SystemRoot%` is applied only when it + records a signer CN (`.signer`) and the executable carries a valid Authenticode + signature by exactly that signer (`WinVerifyTrust`, never touching the network). Otherwise the + entry is skipped as if absent — stored decision / prompt — with one WARN per executable per + run. Admin-protected paths keep path-only trust. POSIX stays path-only for now (no signature + check exists there yet; recorded as a known gap). +- **Per-user delegation is announced.** A user-level entry (`HKCU` / the user JSON) can be + written by anything running as the user. It is still honoured — it is how a developer or a + per-user install registers — but its first use per executable per service run raises a WARN + and a tray balloon. Installer (machine-level) entries stay silent. +- **A client can decline delegation.** `XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` on + `XrStereoCameraClientInfoDXR`: a browser running with its own prompt bypassed (automation, + auto-grant switches) must not let the runtime take that missing prompt as consent. The bit + only restricts, so the claim is not verified; the runtime then requires a stored decision or + its own prompt. + +A skipped delegation (declined or untrusted) also drops the foreground-rule exemption — the +client is an ordinary window-bearing app for that stream — but never the RAW refusal, which keys +on registration alone, so an untrusted entry can only ever narrow what a client gets. diff --git a/docs/guides/stereo-camera-consent.md b/docs/guides/stereo-camera-consent.md index 2edfd96402..478fe2489b 100644 --- a/docs/guides/stereo-camera-consent.md +++ b/docs/guides/stereo-camera-consent.md @@ -53,7 +53,11 @@ this is the same order in developer terms. 5. A stored **Deny** for your executable (the user answered Deny before, or ran `displayxr-cli camera deny`) → `CONSENT_REFUSED`, even if you are a registered delegating client. -6. You are a **registered delegating client** → allowed, no prompt, nothing stored. +6. You are a **registered delegating client** → allowed, no prompt, nothing stored. Skipped — + you go on to 7–9 like any other app — when your instance set + `XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` (spec v3, §4), or when your entry is not + trusted: your executable is in a **user-writable** location and the entry does not record a + signer that your valid Authenticode signature matches (§4, spec §7.1.1). 7. A stored **Allow** for your executable (the user answered before, or an admin pre-seeded it) → allowed. 8. The user chose **Allow once** earlier for this very process → allowed. @@ -86,7 +90,9 @@ If you are a window-bearing client (an ordinary app, or a present-owner), frames only while your process **owns a visible, non-minimised top-level window** (checked every 250 ms). Otherwise your stream is `SUSPENDED`: no new frames, and the frame you had pinned is cleared so nothing lingers. Camera-only and delegating clients are exempt — the browser's own -tab rule applies to its pages. +tab rule applies to its pages. (Delegating means the stream was allowed *by* delegation: a +registered client that declined delegation, or whose entry is not trusted, is an ordinary app +here too.) Every stream, of every client, is suspended while the **OS session is locked or switched away** (Windows lock / disconnect, macOS screen lock / fast user switch) and resumes afterwards. You @@ -104,14 +110,36 @@ should not ask a second time per page. Register the browser's **executable path* Linux `/etc/displayxr/camera-delegating.json`; macOS `/Library/Application Support/DisplayXR/camera-delegating.json` — both `{"delegating": ["/full/path/to/browser", …]}`. -- **Per user / development:** `displayxr-cli camera trust ` (or `--self`), which writes the - user-level list (`HKCU\…\CameraConsent\Delegating`, `~/.config/…/camera_consent.json`). - If the user had stored a Deny for that executable, `trust` clears it and says so; an - installer's registration never does — a user's Deny beats it. +- **Per user / development:** `displayxr-cli camera trust [--signer ""]` (or + `--self`), which writes the user-level list (`HKCU\…\CameraConsent\Delegating`, + `~/.config/…/camera_consent.json`). If the user had stored a Deny for that executable, `trust` + clears it and says so; an installer's registration never does — a user's Deny beats it. + The **first** time a user-level entry lets an executable in (per service run), the service + logs a WARN and Windows shows a tray balloon (*"3D camera: per-user trust used"*), so a + per-user registration is never silent. Machine-level (installer) entries are. Registration means **no runtime prompt and no stored decision needed** — nothing more. Sharing off, the OS camera switch and a stored Deny still refuse you, each with its own result (§2). +**User-writable install locations need a signer (spec v3, Windows).** If your executable is +*not* under `%ProgramFiles%`, `%ProgramFiles(x86)%` or `%SystemRoot%` — a per-user install in +`%LOCALAPPDATA%`, a dev tree — any process of the user could drop a different binary at that +path, so the path alone is not trusted. The entry must also record the **signer CN** of your +Authenticode certificate, as a sibling `REG_SZ` named `.signer` in the same +`Delegating` key, and your executable must carry a valid signature by exactly that signer +(`WinVerifyTrust`, no revocation / network fetch; CN compared ASCII-case-insensitively). No +signer recorded, unsigned, or signed by someone else → the entry is ignored for that start (one +WARN per run says which) and you are prompted like any app. `displayxr-cli camera signer ` +prints what the runtime sees (path class + signer CN). On macOS / Linux delegation is +path-only for now. + +**Declining delegation (spec v3).** When your browser runs with its own permission prompt +bypassed — automation, a test harness, an auto-grant switch — it must not let the runtime take +that missing prompt as consent. Chain `XrStereoCameraClientInfoDXR` with +`XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` (alongside `CONSUMER_ONLY`) for that +instance: the runtime then requires a stored decision or its own prompt (§2 steps 7–9). Try it +without a browser: `displayxr-cli camera probe --decline-delegation`. + Obligations that come with it: prompt the user yourself before opening a stream, show your own in-use indicator, stop the stream when your page stops capturing, and never expose raw calibration or the raw frame — the runtime enforces the last one (RAW is refused to delegating @@ -131,20 +159,22 @@ entries. Rules, all of which follow from how the runtime checks the entry: `/proc//exe`, macOS `proc_pidpath`) — never by anything the client sends. In a multi-process browser that is the process that calls `xrStartStereoCameraStreamDXR` (typically a video-capture utility process), whose image is usually the browser's main executable. -2. **Trust is by full path only** — no signature or hash check in this version. So register only a - path inside a directory that only an administrator / root can write (Windows `%ProgramFiles%`; - a root-owned prefix such as `/usr` or `/opt/` on Linux). An entry pointing into a - user-writable directory lets any unelevated process drop an executable there and inherit the - trust. If the user installs to such a location, register nothing; the runtime then asks once. - Note that macOS `/Applications` is writable by any admin-group user without elevation. +2. **Prefer an admin-protected path.** Under `%ProgramFiles%`, `%ProgramFiles(x86)%` or + `%SystemRoot%` the full path alone is trusted. **Anywhere else** (Windows) also write the + `.signer` `REG_SZ` with your Authenticode signer CN — without it the runtime + ignores the entry (spec §7.1.1). Do this even for a path the rule counts as protected if your + installer loosens that directory's ACL. On Linux / macOS trust is still by full path only, so + register only a root-owned prefix such as `/usr` or `/opt/`; if the user installs to a + user-writable location there, register nothing and the runtime asks once. Note that macOS + `/Applications` is writable by any admin-group user without elevation. 3. **Protect the entry itself.** `HKLM\Software` is admin-only by default; the POSIX system file must be root-owned and not group/world-writable (mode `0644`) — the runtime does not check its ownership. 4. **Windows specifics.** Write the **64-bit registry view** (a 32-bit installer such as NSIS needs `SetRegView 64`; otherwise the value lands under `WOW6432Node`, which the runtime never reads). The value **name** is free: use your product name, so it is unique to you and identifies the - entry to an admin. Keep the name under 256 characters and the path under 1024 bytes and in - ASCII — the current reader enumerates with the ANSI registry API and fixed buffers. + entry to an admin. Do not end it in `.signer` — that suffix names an entry's signer + companion, never an entry. Keep the signer CN under 256 characters. 5. **POSIX specifics.** The JSON file is shared by every installer: read it, add your path to `delegating` if absent, write it back atomically; never rewrite other entries. 6. **Uninstall removes only what you wrote** — your value (Windows) or your path (POSIX), and only @@ -152,11 +182,14 @@ entries. Rules, all of which follow from how the runtime checks the entry: the JSON file only if nothing else is left in it. 7. **No restart needed.** The service reads the entry at every stream start and calibration read, so a registration or removal takes effect on the next one. -8. **Know what you are asking for.** A delegating client is evaluated at §7.1 step 4, before the - Windows camera privacy switch (step 5) and before a stored decision (step 6): a user's earlier - runtime *Deny* for that executable no longer applies, and `displayxr-cli camera untrust` only - edits the user list, not yours. The user's remaining runtime controls are *"Share the 3D camera - with apps"* and *"Stop camera sharing"*. Register only software that really prompts per origin. +8. **Know what you are asking for.** A delegating client is evaluated at §7.1 step 6, *after* + the OS camera privacy switch (step 4) and a stored Deny (step 5), which keep refusing it, but + before a stored Allow or the prompt: the runtime never asks the user about that executable + again, and `displayxr-cli camera untrust` only edits the user list, not yours. The user's + remaining runtime controls are a Deny, *"Share the 3D camera with apps"* and *"Stop camera + sharing"*. Register only software that really prompts per origin — and make it set + `DECLINE_DELEGATION` whenever that prompt is bypassed. +9. **Uninstall removes the signer too** — the `.signer` companion with its entry. ## 5. What the user can do to you at any time @@ -187,6 +220,9 @@ displayxr-cli camera fake-lock off displayxr-cli camera stop-all # a running probe prints "ENDED … USER_STOPPED", exits 8 displayxr-cli camera sharing off # zero cameras; start -> DISABLED (exit 6) displayxr-cli camera consent # what is stored, and this CLI's own path +displayxr-cli camera signer --self # path class + Authenticode signer (exit 3: unsigned) +displayxr-cli camera trust --self --signer "Your Corp" # user-level entry with a signer +displayxr-cli camera probe --decline-delegation # as a browser in automation mode ``` `DXR_STEREO_CAMERA_DEV_ALLOW=1` on the service skips consent for everything (loud WARN) — use diff --git a/docs/specs/extensions/XR_DXR_stereo_camera.md b/docs/specs/extensions/XR_DXR_stereo_camera.md index fd72164a8b..eef9c8d3df 100644 --- a/docs/specs/extensions/XR_DXR_stereo_camera.md +++ b/docs/specs/extensions/XR_DXR_stereo_camera.md @@ -3,7 +3,7 @@ | Field | Value | |---|---| | **Extension Name** | `XR_DXR_stereo_camera` | -| **Spec Version** | 1 | +| **Spec Version** | 3 | | **Extension Type** | Instance extension, service path only (an in-process instance enumerates zero cameras) | | **Header** | [`src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h`](../../../src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h) (+ its `index.json` catalog note) | | **Status** | **R1 implemented** (runtime, hardware-free): header, plug-in slots, service camera manager, IPC, OpenXR entry points, sim_display fake, `displayxr-cli camera`, selftest check. **R2 implemented**: the service-side rectifier (`u_stereo_rectify`, CPU) — RECTIFIED frames + rectified calibration from any CALIBRATED source, golden-tested against OpenCV, and a distorted sim fake with ground truth. **Not yet:** consent / indicator / foreground rule (R3 — deny-by-default hooks in place), GPU transports (the rectifier has the seam), state-change events, the Leia provider (L1), the browser (B1). Provisional type values `1004999300–310` (relocated from `290–300` when `XR_DXR_weave` v14 took `290`; `XR_DXR_lift` holds `270–289`), pending Khronos registry | @@ -88,6 +88,14 @@ enables both is still a camera consumer. A hybrid runtime always routes a declar the service (an in-process instance has no cameras). Without the declaration the instance is classed as before (APP, or PRESENT_OWNER when it enables `XR_DXR_weave`). +**Declining delegation (spec v3).** A registered consent-delegating client (§7.1 row 6) that is +running with its own permission prompt bypassed — automation, a test harness, an auto-grant +switch such as a browser's `--use-fake-ui-for-media-stream` — sets +`XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` in the same `flags`. For that instance the +runtime skips row 6: the executable needs a stored decision or the runtime's own prompt, like any +other app (§7.1). The bit only ever restricts, so it needs no verification; it combines freely +with `CONSUMER_ONLY`. Unknown bits are ignored. + ## 3. Enumerating cameras ```c @@ -358,7 +366,7 @@ at a time (so two apps never race two prompts): | 3 | The peer executable could not be verified (`""`) | `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` | | 4 | The OS camera privacy switch denies this executable (Windows `CapabilityAccessManager\ConsentStore\webcam`: machine policy, user global, desktop-app class, per-app entry; no equivalent is readable on macOS / Linux) — **for every client, delegating ones included** | `XR_ERROR_PERMISSION_INSUFFICIENT` | | 5 | A stored per-app **Deny** (`HKCU\Software\DisplayXR\CameraConsent\Apps\` = 2, `camera_consent.json` `apps`) — it beats a delegating registration, an installer's included | `CONSENT_REFUSED` | -| 6 | The executable is a registered **consent-delegating** client (a browser that shows its own per-origin prompt + in-use indicator): machine list written by its installer (`HKLM\Software\DisplayXR\CameraConsent\Delegating`, `/etc/displayxr/camera-delegating.json`, `/Library/Application Support/DisplayXR/camera-delegating.json`) or the user list (`displayxr-cli camera trust `) | allowed, no prompt, no store entry; never RAW (§7.5) | +| 6 | The executable is a registered **consent-delegating** client (a browser that shows its own per-origin prompt + in-use indicator): machine list written by its installer (`HKLM\Software\DisplayXR\CameraConsent\Delegating`, `/etc/displayxr/camera-delegating.json`, `/Library/Application Support/DisplayXR/camera-delegating.json`) or the user list (`displayxr-cli camera trust `) — **unless** the client set `XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` (§2a), or the entry is not trusted (§7.1.1). A skipped row 6 falls through to rows 7–9 exactly as for an unregistered app | allowed, no prompt, no store entry; never RAW (§7.5) | | 7 | A stored per-app **Allow** | allowed | | 8 | "Allow once" already granted to this same process (executable + pid) | allowed | | 9 | The **tray prompt**: *" wants to use the 3D camera — Allow / Allow once / Deny"* (Windows: a top-most dialog from the tray thread; macOS: a floating panel from the menu-bar item — never a modal that would park the service's run loop). **Allow** and **Deny** are written to the store; **Allow once** is remembered for the process. The call blocks for up to 60 s; unanswered / dismissed, or no prompt available (`DXR_STEREO_CAMERA_PROMPT=0`, headless Linux service) | `CONSENT_REFUSED` — retryable, asked again next time | @@ -378,6 +386,47 @@ Deny keeps winning. `camera deny` on a delegating executable notes that the Deny This table is the one statement of the order; `u_camera_consent.h`, the developer guide and the roadmap refer to it. +#### 7.1.1 Trusting a delegating entry (spec v3) + +An entry names an executable by full path, and a path is only an identity where nobody but an +administrator can put a different file there. + +- **Admin-protected path** — on Windows the OS-derived executable path is under + `%ProgramFiles%`, `%ProgramFiles(x86)%` or `%SystemRoot%` (`SHGetKnownFolderPath` + `FOLDERID_ProgramFiles` / `_ProgramFilesX86` / `_Windows`; ASCII-case-insensitive, at a + separator boundary, no `..` component): the path alone is trusted, as before. +- **User-writable path** — anywhere else (a per-user install under `%LOCALAPPDATA%`, a dev + tree, Downloads): the entry must also record the **required signer**, and the executable must + carry a **valid Authenticode signature** whose leaf signer's subject CN equals it + (ASCII-case-insensitive, surrounding spaces ignored). Verification is `WinVerifyTrust` + (`WINTRUST_ACTION_GENERIC_VERIFY_V2`, no UI, `WTD_REVOKE_NONE`, `WTD_CACHE_ONLY_URL_RETRIEVAL` + — it never blocks a stream start on the network). The signer is stored as a sibling `REG_SZ` + in the same `Delegating` key, named `.signer` (`displayxr-cli camera trust + --signer ""` writes it; an installer registering a per-user install writes it next to + its entry). A `*.signer` value is never itself an entry. +- An entry that fails — no signer recorded, no valid signature, or another signer — is **not + applied**: row 6 is skipped exactly as if the executable were not registered (stored decision + / prompt), with one WARN per executable per service run naming the reason. The executable + stays *registered* for the RAW refusal (§7.5): an untrusted entry never widens anything. +- **POSIX:** delegation is path-only for now — no signer is recorded and no code-signature check + exists yet (macOS `SecStaticCode` / Linux are a follow-up). +- Known limit of the path rule: a few folders under `%SystemRoot%` (e.g. `Temp`, `Tasks`) and an + install directory whose ACL an installer loosened are writable all the same. The path rule is + the cheap gate for the common case; an installer that registers such a path should record a + signer anyway. + +**User-level entries are announced.** An entry in the *user* list (`HKCU`, the user JSON — +written by `displayxr-cli camera trust` or by anything else running as the user) is honoured, +but the first time it actually allows a given executable in a service run the service logs a +WARN and raises a notice (Windows: a tray balloon, *"3D camera: per-user trust used"*; macOS: +the WARN only, for now), once per executable per service run, so a silent self-registration +cannot go unnoticed. A machine-level (installer, `HKLM` / system JSON) entry is silent. When the +same path is in both lists the machine-level entry is the one evaluated. + +**Foreground rule.** Only a stream whose start was allowed *by* row 6 follows its client's own +visibility rule (§7.2); a registered client that declined delegation, or whose entry was not +trusted, is an ordinary window-bearing app there too. + A refused stream stays created and may be started again (e.g. after `displayxr-cli camera allow`). The policy is pure (two injected vtables) and unit-tested with fakes in `tests_camera_consent`. @@ -395,7 +444,7 @@ frame with a 250 ms cache: always visible — the OS lock below is the gate there). Otherwise the stream is `SUSPENDED` for that client: no new frames, pinned slot cleared so the last image does not linger. - **Delegating clients** follow their own visibility rule (a capturing tab keeps its indicator; - closing it stops the track). **`CAMERA_CONSUMER` and `DIAG`** have no window by contract and + closing it stops the track) — only when the stream's start was allowed by delegation (§7.1.1). **`CAMERA_CONSUMER` and `DIAG`** have no window by contract and are exempt — they were granted explicit consent instead. - **Session lock:** every stream is suspended while the OS session is locked or switched away (Windows `WM_WTSSESSION_CHANGE` lock / console or remote disconnect, plus `SM_REMOTESESSION` at @@ -452,7 +501,7 @@ The service queues, per connection (16 deep, oldest dropped), and the client dra | `XR_STEREO_CAMERA_FRAME_NOT_READY_DXR` (success) | no frame newer than the last acquired | | `XR_ERROR_VALIDATION_FAILURE` | struct out of contract (unknown camera, format/transport not in the supported bits, RECTIFIED on an uncalibrated camera) | | `XR_ERROR_PERMISSION_INSUFFICIENT` | the OS camera privacy switch denies the app (§7.1 row 4, delegating clients included); RAW asked by a browser / delegating client; a client class that may not use cameras (or `xrCreateSession` from a `CAMERA_CONSUMER`) | -| `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` | §7.1: the user / the stored decision refused, the prompt was unanswered or unavailable; retryable after consent. A browser maps it to `NotAllowedError` | +| `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` | §7.1: the user / the stored decision refused, the prompt was unanswered or unavailable; retryable after consent. A browser maps it to `NotAllowedError`. A registered delegating client gets it too when its delegation was skipped — it declined (`DECLINE_DELEGATION_BIT`, §2a) or its entry is untrusted (§7.1.1) — and no stored Allow / prompt answer allowed it | | `XR_ERROR_STEREO_CAMERA_DISABLED_DXR` | §7.4: sharing is off (user toggle or `DXR_STEREO_CAMERA=0`) | | `XR_ERROR_STEREO_CAMERA_BUSY_DXR` | the plug-in could not open the source and its retry is pending; retry later. A browser maps it to `NotReadableError` | | `XR_ERROR_STEREO_CAMERA_STREAM_ENDED_DXR` | start / acquire on a stream the service ended (§7.4); destroy it and create a new one | @@ -669,6 +718,7 @@ camera state change and per stream start/stop with the peer executable; INFO sta | Version | Change | |---|---| +| 3 | Delegation hardening: `XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR` (`0x2`) — a delegating client opts out of row 6 for one instance (§2a); a delegating entry for a user-writable executable path is applied only with a recorded signer its valid Authenticode signature matches (`.signer`, Windows; POSIX path-only) and a user-level entry's first use per executable per service run is announced (§7.1.1). Appended; nothing renumbered | | 2 (R3) | Consent and privacy as built (§7): `XrStereoCameraClientInfoDXR` + `XR_STEREO_CAMERA_CLIENT_CONSUMER_ONLY_BIT_DXR` (the `CAMERA_CONSUMER` class, §2a); distinct results `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` / `_DISABLED_DXR` / `_BUSY_DXR` / `_STREAM_ENDED_DXR`; `XrEventDataStereoCameraStreamEndedDXR`; the state-changed event is now delivered; `persistentId` keyed with a per-user secret; read-only consumer section on every platform; `DXR_STEREO_CAMERA_DEV_ALLOW` becomes a documented dev override, `DXR_STEREO_CAMERA_PROMPT=0` disables the prompt. Everything appended at `1004999311–316`; nothing renumbered | | 1 (R2) | No API change. RECTIFIED output and rectified calibration now come from the service's rectifier for any CALIBRATED, non-natively-rectified source; the browser is refused a camera it cannot rectify; sim_display `_DISTORT=1` fake; `camera probe` row alignment + `--rectified` | | 1 | R1 implements: enumerate, calibration (RAW; RECTIFIED for natively rectified sources), streams with start/stop, latest-wins acquire over a pinned 3-slot shared-memory ring with per-stream wake handles, stream stats, plug-in slots, sim_display fake, CLI, selftest. Deferred: state-change events (structs defined, not delivered), GPU transports, rectifier (R2), consent/indicator (R3). Design scope: Enumerate + state events, calibration (raw / rectified), streams with start/stop, latest-wins acquire over a pinned 3-slot shared-memory ring with per-stream wake handles, optional GPU transports, runtime-enforced consent / foreground / indicator, plug-in iface slots, sim_display fake, CLI. | diff --git a/src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h b/src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h index ce514d908e..c239336854 100644 --- a/src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h +++ b/src/external/openxr_includes/openxr/XR_DXR_stereo_camera.h @@ -58,7 +58,10 @@ * class), distinct refusal results (CONSENT_REFUSED / DISABLED / BUSY / * STREAM_ENDED), the state-changed and cameras-changed events are now * delivered, XrEventDataStereoCameraStreamEndedDXR, persistentId keyed with - * a per-user secret. Everything appended; nothing renumbered. + * a per-user secret. Everything appended; nothing renumbered. 3 = + * XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR (a consent-delegating + * client opts out of delegation for one instance); delegating entries for a + * user-writable executable path require a matching code signer (spec §7.1.1). */ #ifndef XR_DXR_STEREO_CAMERA_H #define XR_DXR_STEREO_CAMERA_H 1 @@ -71,7 +74,7 @@ extern "C" { #endif #define XR_DXR_stereo_camera 1 -#define XR_DXR_stereo_camera_SPEC_VERSION 2 +#define XR_DXR_stereo_camera_SPEC_VERSION 3 #define XR_DXR_STEREO_CAMERA_EXTENSION_NAME "XR_DXR_stereo_camera" // Reserved 1004999300..319 (relocated from 290..309 on the post-lift rebase: 290 was taken by XR_DXR_weave v14). Allocation registry: README.md in this directory. @@ -349,6 +352,10 @@ typedef XrFlags64 XrStereoCameraClientFlagsDXR; //! quota. A hybrid runtime routes such an instance to the service (an //! in-process instance has no cameras). static const XrStereoCameraClientFlagsDXR XR_STEREO_CAMERA_CLIENT_CONSUMER_ONLY_BIT_DXR = 0x00000001; +//! The client asks the runtime NOT to treat it as a consent-delegating client for this +//! instance (e.g. it runs with automation / auto-grant switches that bypass its own +//! permission prompt). The runtime then requires a stored decision or its own prompt. +static const XrStereoCameraClientFlagsDXR XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR = 0x00000002; /*! * Chain on XrInstanceCreateInfo (the extension must be in enabledExtensionNames). diff --git a/src/xrt/auxiliary/util/CMakeLists.txt b/src/xrt/auxiliary/util/CMakeLists.txt index 6a7837b56b..8ade7d4dcc 100644 --- a/src/xrt/auxiliary/util/CMakeLists.txt +++ b/src/xrt/auxiliary/util/CMakeLists.txt @@ -178,7 +178,9 @@ if(WIN32) # shell32 needed for SHGetFolderPathA in u_file_logging.c # advapi32 needed for ACL/security APIs (AppContainer write access) in u_file_logging.c # bcrypt: BCryptGenRandom seeds the stereo camera persistentId secret (u_camera_consent_store.c) - target_link_libraries(aux_util PRIVATE shell32 advapi32 bcrypt) + # wintrust + crypt32: Authenticode signer check for user-writable consent-delegating + # entries; ole32 (CoTaskMemFree) + uuid (FOLDERID_*): SHGetKnownFolderPath (u_camera_consent_store.c) + target_link_libraries(aux_util PRIVATE shell32 advapi32 bcrypt wintrust crypt32 ole32 uuid) endif() # WIL is lacking official support on MINGW. diff --git a/src/xrt/auxiliary/util/u_camera_consent.c b/src/xrt/auxiliary/util/u_camera_consent.c index e16b87ddc2..85f09abdc3 100644 --- a/src/xrt/auxiliary/util/u_camera_consent.c +++ b/src/xrt/auxiliary/util/u_camera_consent.c @@ -81,6 +81,89 @@ u_camera_consent_why_str(enum u_camera_consent_why why) } } +const char * +u_camera_consent_delegation_skip_str(enum u_camera_consent_delegation_skip skip) +{ + switch (skip) { + case U_CAMERA_CONSENT_DELEGATION_APPLIES: return ""; + case U_CAMERA_CONSENT_DELEGATION_DECLINED: return "the client declined delegation (DECLINE_DELEGATION)"; + case U_CAMERA_CONSENT_DELEGATION_NO_SIGNER: + return "user-writable path and the delegating entry records no signer"; + case U_CAMERA_CONSENT_DELEGATION_NOT_SIGNED: + return "user-writable path and the executable has no valid code signature"; + case U_CAMERA_CONSENT_DELEGATION_SIGNER_MISMATCH: + return "user-writable path and the executable is signed by someone other than the entry's signer"; + default: return "?"; + } +} + +static bool +get_delegation(const struct u_camera_consent_store_ops *st, + void *sc, + const char *exe, + struct u_camera_consent_delegation *out) +{ + memset(out, 0, sizeof(*out)); + if (st == NULL || st->get_delegation == NULL || !st->get_delegation(sc, exe, out)) { + memset(out, 0, sizeof(*out)); + return false; + } + out->signer[sizeof(out->signer) - 1] = '\0'; + return out->scope != U_CAMERA_CONSENT_DELEGATION_NONE; +} + +bool +u_camera_consent_is_registered_delegating(const struct u_camera_consent_store_ops *store, + void *store_ctx, + const char *exe) +{ + struct u_camera_consent_delegation d; + return exe_known(exe) && get_delegation(store, store_ctx, exe, &d); +} + +//! A small "seen this executable in this run" set; the oldest entry is overwritten. +static bool +seen_check_and_add(char (*set)[U_CAMERA_CONSENT_EXE_MAX], uint32_t *count, const char *exe) +{ + uint32_t n = *count < U_CAMERA_CONSENT_SEEN_MAX ? *count : U_CAMERA_CONSENT_SEEN_MAX; + for (uint32_t i = 0; i < n; i++) { + if (strncmp(set[i], exe, U_CAMERA_CONSENT_EXE_MAX - 1) == 0) { + return true; + } + } + snprintf(set[*count % U_CAMERA_CONSENT_SEEN_MAX], U_CAMERA_CONSENT_EXE_MAX, "%s", exe); + (*count)++; + return false; +} + +/*! + * Step 6's trust check for a registered entry (spec §7.1.1). Admin-protected + * path: the path is the identity. User-writable path: the entry must name a + * signer and the executable must be validly signed by exactly that signer. + */ +static enum u_camera_consent_delegation_skip +delegation_trust(struct u_camera_consent *c, const char *exe, const struct u_camera_consent_delegation *d) +{ + const struct u_camera_consent_env_ops *env = c->env; + // NULL = assume user-writable: fail closed. + bool user_writable = env == NULL || env->path_user_writable == NULL || env->path_user_writable(c->env_ctx, exe); + if (!user_writable) { + return U_CAMERA_CONSENT_DELEGATION_APPLIES; + } + if (d->signer[0] == '\0') { + return U_CAMERA_CONSENT_DELEGATION_NO_SIGNER; + } + char actual[U_CAMERA_CONSENT_SIGNER_MAX] = {0}; + if (env == NULL || env->exe_signer == NULL || !env->exe_signer(c->env_ctx, exe, actual, sizeof(actual))) { + return U_CAMERA_CONSENT_DELEGATION_NOT_SIGNED; + } + actual[sizeof(actual) - 1] = '\0'; + if (!u_camera_consent_signer_equal(actual, d->signer)) { + return U_CAMERA_CONSENT_DELEGATION_SIGNER_MISMATCH; + } + return U_CAMERA_CONSENT_DELEGATION_APPLIES; +} + bool u_camera_consent_sharing_enabled(struct u_camera_consent *c) { @@ -133,6 +216,7 @@ u_camera_consent_evaluate(struct u_camera_consent *c, const char *exe, const char *app_name, long pid, + uint32_t flags, struct u_camera_consent_decision *out) { memset(out, 0, sizeof(*out)); @@ -166,7 +250,9 @@ u_camera_consent_evaluate(struct u_camera_consent *c, } // A fact about the executable, reported whatever the verdict (the service // keys the RAW refusal on it). It only DECIDES at step 6. - out->delegating = st != NULL && st->is_delegating != NULL && st->is_delegating(sc, exe); + struct u_camera_consent_delegation reg; + out->delegating = get_delegation(st, sc, exe, ®); + out->delegation_scope = reg.scope; // 4. the OS switch, for every client, delegating ones included: the service // opens the camera, not the client, so the OS never sees this consumer // and nobody else will enforce the user's switch for it. @@ -186,9 +272,38 @@ u_camera_consent_evaluate(struct u_camera_consent *c, } // 6. a registered delegating client prompts per origin itself: no runtime // prompt and no stored decision needed — and nothing more than that. + // Not when the client declined it for this instance (it runs with its + // own prompt bypassed), and not when the entry cannot be trusted (a + // user-writable path without a matching signature, §7.1.1): then the + // executable is an ordinary app from here on. if (out->delegating) { - decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING); - return; + enum u_camera_consent_delegation_skip skip = (flags & U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION) != 0 + ? U_CAMERA_CONSENT_DELEGATION_DECLINED + : delegation_trust(c, exe, ®); + out->delegation_skip = skip; + if (skip == U_CAMERA_CONSENT_DELEGATION_APPLIES) { + if (reg.scope == U_CAMERA_CONSENT_DELEGATION_USER && + !seen_check_and_add(c->user_noticed, &c->user_noticed_count, exe)) { + U_LOG_W("stereo camera consent: %s allowed by a USER-level delegation entry (not an " + "installer's); `displayxr-cli camera untrust` removes it", + exe); + if (c->env != NULL && c->env->user_delegation_notice != NULL) { + c->env->user_delegation_notice(c->env_ctx, exe, app_name); + } + } + out->delegated = true; + decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING); + return; + } + if (skip != U_CAMERA_CONSENT_DELEGATION_DECLINED && + !seen_check_and_add(c->untrusted_warned, &c->untrusted_warned_count, exe)) { + U_LOG_W( + "stereo camera consent: the %s delegating entry for %s is NOT applied — %s%s%s; it is " + "treated as an ordinary app (stored decision / prompt)", + reg.scope == U_CAMERA_CONSENT_DELEGATION_SYSTEM ? "machine-level" : "user-level", exe, + u_camera_consent_delegation_skip_str(skip), reg.signer[0] ? ", entry signer: " : "", + reg.signer); + } } // 7. a stored Allow. if (stored == U_CAMERA_CONSENT_STORED_ALLOW) { @@ -269,6 +384,74 @@ u_camera_consent_path_equal(const char *a, const char *b, bool windows_rules) return *a == '\0' && *b == '\0'; } +bool +u_camera_consent_path_is_under(const char *path, const char *dir, bool windows_rules) +{ + if (path == NULL || dir == NULL || path[0] == '\0' || dir[0] == '\0') { + return false; + } + // Any ".." component could climb back out of @p dir. + for (const char *p = path; *p != '\0'; p++) { + bool at_start = p == path || p[-1] == '/' || (windows_rules && p[-1] == '\\'); + if (at_start && p[0] == '.' && p[1] == '.' && + (p[2] == '\0' || p[2] == '/' || (windows_rules && p[2] == '\\'))) { + return false; + } + } + size_t dlen = strlen(dir); + // Drop trailing separators from dir. + while (dlen > 0 && (dir[dlen - 1] == '/' || (windows_rules && dir[dlen - 1] == '\\'))) { + dlen--; + } + if (dlen == 0) { + return false; + } + for (size_t i = 0; i < dlen; i++) { + if (path[i] == '\0' || path_norm_ch(path[i], windows_rules) != path_norm_ch(dir[i], windows_rules)) { + return false; + } + } + // A separator boundary, and something after it: "C:\Program FilesX\a" is not under "C:\Program Files". + char sep = path_norm_ch(path[dlen], windows_rules); + return (sep == '/' || (windows_rules && sep == '\\')) && path[dlen + 1] != '\0'; +} + +static char +ascii_lower(char c) +{ + return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; +} + +bool +u_camera_consent_signer_equal(const char *a, const char *b) +{ + if (a == NULL || b == NULL) { + return false; + } + while (*a == ' ') { + a++; + } + while (*b == ' ') { + b++; + } + size_t la = strlen(a), lb = strlen(b); + while (la > 0 && a[la - 1] == ' ') { + la--; + } + while (lb > 0 && b[lb - 1] == ' ') { + lb--; + } + if (la == 0 || la != lb) { + return false; + } + for (size_t i = 0; i < la; i++) { + if (ascii_lower(a[i]) != ascii_lower(b[i])) { + return false; + } + } + return true; +} + bool u_camera_consent_list_find( u_camera_consent_enum_fn fn, void *ctx, const char *exe, bool windows_rules, char *match_name, size_t match_cap) diff --git a/src/xrt/auxiliary/util/u_camera_consent.h b/src/xrt/auxiliary/util/u_camera_consent.h index 28a094e37e..d42742e1eb 100644 --- a/src/xrt/auxiliary/util/u_camera_consent.h +++ b/src/xrt/auxiliary/util/u_camera_consent.h @@ -24,7 +24,11 @@ * 4. OS camera privacy denies this executable -> OS_DENIED * 5. stored per-app Deny -> REFUSED * 6. registered consent-delegating client (a browser) -> ALLOWED - * (its own per-origin prompt + indicator is the consent) + * (its own per-origin prompt + indicator is the consent) — unless the + * client DECLINED delegation for this instance + * (U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION), or the entry's executable + * lives in a USER-WRITABLE location and is not Authenticode-signed by + * the signer the entry names (see "Trusting a delegating entry" below) * 7. stored per-app Allow -> ALLOWED * 8. "Allow once" granted earlier to this same process -> ALLOWED * 9. tray prompt (" wants to use the 3D camera — @@ -38,6 +42,26 @@ * The OS switch matters most for it: the service, not the client, opens the * camera, so the OS never sees the consumer and nothing else enforces it. * + * Trusting a delegating entry (spec §7.1.1). An entry names an executable by + * full path. Where that path is admin-protected (Windows: under + * %ProgramFiles%, %ProgramFiles(x86)% or %SystemRoot%) the path alone is + * trusted: whoever can replace the file could also have written the entry. + * Anywhere else (a per-user install in %LOCALAPPDATA%, a dev tree, Downloads) + * any process of the user could drop a different binary at that path, so the + * entry must also record the required signer (its CN) and the executable must + * carry a valid Authenticode signature by exactly that signer. An entry that + * fails this is treated as if it did not exist for step 6 (stored decision / + * prompt instead), with one WARN per executable per service run naming why. + * POSIX: delegation is path-only for now (path_user_writable is wired to + * "never"; there is no platform code-signature check yet). + * + * A USER-level entry (HKCU / the user JSON — `displayxr-cli camera trust`, or + * anything else running as the user) is honoured, but the first time it + * actually allows a given executable in a service run the policy logs a WARN + * and raises env->user_delegation_notice (the tray balloon), so a silent + * self-registration cannot go unnoticed. A SYSTEM-level (installer) entry is + * silent. + * * The keyed persistentId (§7.5) also lives here: HMAC-SHA-256 under a 32-byte * secret the store generates once per user, so a page cannot derive the * device from the id and two installs never share one. @@ -61,6 +85,45 @@ extern "C" { #define U_CAMERA_CONSENT_SECRET_SIZE 32 //! "Allow once" grants remembered per (executable, pid) for the service's lifetime. #define U_CAMERA_CONSENT_ONCE_MAX 16 +//! Executables remembered for the once-per-run WARN / notice (oldest overwritten). +#define U_CAMERA_CONSENT_SEEN_MAX 16 +//! A signer common name (Authenticode subject CN), UTF-8, NUL included. +#define U_CAMERA_CONSENT_SIGNER_MAX 256 + +/*! + * @ref u_camera_consent_evaluate flags (the service maps them from the + * client's XrStereoCameraClientInfoDXR::flags). Unknown bits are ignored. + */ +//! The client declined delegation for this instance (XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR). +#define U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION (1u << 0) + +//! Which list a delegating entry came from. +enum u_camera_consent_delegation_scope +{ + U_CAMERA_CONSENT_DELEGATION_NONE = 0, //!< not registered + U_CAMERA_CONSENT_DELEGATION_SYSTEM = 1, //!< machine-level (installer): HKLM / system JSON + U_CAMERA_CONSENT_DELEGATION_USER = 2, //!< user-level: HKCU / the user JSON +}; + +//! A registered delegating entry for one executable. +struct u_camera_consent_delegation +{ + enum u_camera_consent_delegation_scope scope; + //! The signer the entry requires (Windows: the `.signer` REG_SZ + //! next to the entry). "" = none recorded. Only consulted when the + //! executable's path is user-writable. + char signer[U_CAMERA_CONSENT_SIGNER_MAX]; +}; + +//! Why a registered entry did NOT delegate (for the log line and the tests). +enum u_camera_consent_delegation_skip +{ + U_CAMERA_CONSENT_DELEGATION_APPLIES = 0, //!< not skipped (or not registered at all) + U_CAMERA_CONSENT_DELEGATION_DECLINED, //!< the client set DECLINE_DELEGATION + U_CAMERA_CONSENT_DELEGATION_NO_SIGNER, //!< user-writable path, the entry records no signer + U_CAMERA_CONSENT_DELEGATION_NOT_SIGNED, //!< user-writable path, no valid signature + U_CAMERA_CONSENT_DELEGATION_SIGNER_MISMATCH, //!< validly signed, by someone else +}; //! A stored per-executable decision. enum u_camera_consent_stored @@ -116,8 +179,19 @@ struct u_camera_consent_decision //! own prompt + indicator, follows its own visibility rule, and is never //! given RAW frames (it exposes cameras to third-party content). Set from //! step 3 on whatever the verdict (it is a fact about the executable); - //! the dev override (step 2) and a missing identity leave it false. + //! the dev override (step 2) and a missing identity leave it false. It + //! means REGISTERED: the RAW refusal keys on it even when the entry was + //! not trusted or the client declined delegation (fails closed). bool delegating; + //! Step 6 decided: the delegation was trusted and applied (not declined, + //! signer check passed). The service exempts only such a stream from the + //! foreground rule; a declined / untrusted one is an ordinary app. + bool delegated; + //! @ref delegating but step 6 did not apply it: why (APPLIES otherwise, + //! and whenever an earlier step decided). + enum u_camera_consent_delegation_skip delegation_skip; + //! The list the matching entry came from (NONE when not registered). + enum u_camera_consent_delegation_scope delegation_scope; }; /*! @@ -130,7 +204,9 @@ struct u_camera_consent_store_ops bool (*get)(void *ctx, const char *exe, enum u_camera_consent_stored *out); //! NONE removes the entry. Returns false if the store could not be written. bool (*set)(void *ctx, const char *exe, enum u_camera_consent_stored value); - bool (*is_delegating)(void *ctx, const char *exe); + //! The delegating entry for @p exe: false (or scope NONE) = not registered. + //! A machine-level entry wins over a user-level one for the same path. + bool (*get_delegation)(void *ctx, const char *exe, struct u_camera_consent_delegation *out); //! The user's "Share the 3D camera with apps" toggle (default on). bool (*sharing_enabled)(void *ctx); bool (*set_sharing_enabled)(void *ctx, bool enabled); @@ -152,6 +228,18 @@ struct u_camera_consent_env_ops //! caller holds no locks of the policy while this runs. enum u_camera_consent_prompt_answer (*prompt)( void *ctx, const char *exe, const char *app_name, long pid, uint32_t timeout_ms); + //! Could a non-admin process replace @p exe (Windows: not under + //! %ProgramFiles%, %ProgramFiles(x86)%, %SystemRoot%)? NULL = assume YES + //! (fails closed: a host that wires nothing gets signer-checked + //! delegation, which without @ref exe_signer means no delegation). + bool (*path_user_writable)(void *ctx, const char *exe); + //! Does @p exe carry a VALID code signature? If so, its signer CN into + //! @p out. NULL = nothing can be verified. + bool (*exe_signer)(void *ctx, const char *exe, char *out, size_t cap); + //! A USER-level delegating entry just allowed @p exe for the first time in + //! this service run (the tray balloon). Never called for SYSTEM entries. + //! Called with the caller's consent serialisation held: must not block. + void (*user_delegation_notice)(void *ctx, const char *exe, const char *app_name); }; struct u_camera_consent @@ -174,6 +262,13 @@ struct u_camera_consent } once[U_CAMERA_CONSENT_ONCE_MAX]; uint32_t once_count; + //! Executables already WARNed about an untrusted delegating entry. + char untrusted_warned[U_CAMERA_CONSENT_SEEN_MAX][U_CAMERA_CONSENT_EXE_MAX]; + uint32_t untrusted_warned_count; + //! Executables already noticed for a user-level delegation. + char user_noticed[U_CAMERA_CONSENT_SEEN_MAX][U_CAMERA_CONSENT_EXE_MAX]; + uint32_t user_noticed_count; + //! Secret for persistentId: the store's, else a per-process random one. uint8_t secret[U_CAMERA_CONSENT_SECRET_SIZE]; bool secret_ready; @@ -190,18 +285,30 @@ u_camera_consent_init(struct u_camera_consent *c, * Run the decision tree for one peer. Not thread-safe by itself: the caller * serialises evaluations (the service does so per stream start) and holds no * lock the prompt might need. @p exe may be "" (no verifiable identity). + * @p flags: U_CAMERA_CONSENT_FLAG_*; unknown bits are ignored. */ void u_camera_consent_evaluate(struct u_camera_consent *c, const char *exe, const char *app_name, long pid, + uint32_t flags, struct u_camera_consent_decision *out); //! Human-readable rule name for @ref u_camera_consent_why. const char * u_camera_consent_why_str(enum u_camera_consent_why why); +//! Human-readable reason for @ref u_camera_consent_delegation_skip ("" for APPLIES). +const char * +u_camera_consent_delegation_skip_str(enum u_camera_consent_delegation_skip skip); + +//! Is @p exe registered in either delegating list (whether or not it would be trusted)? +bool +u_camera_consent_is_registered_delegating(const struct u_camera_consent_store_ops *store, + void *store_ctx, + const char *exe); + //! Forget every "Allow once" grant (e.g. when the user stops sharing). void u_camera_consent_forget_once(struct u_camera_consent *c); @@ -245,6 +352,22 @@ u_camera_consent_persistent_id_keyed( bool u_camera_consent_path_equal(const char *a, const char *b, bool windows_rules); +/*! + * Is @p path inside directory @p dir (at a separator boundary)? Same + * comparison rules as @ref u_camera_consent_path_equal; a trailing separator + * on @p dir is optional. A path with a ".." component never is (it could + * climb back out); empty / NULL never is. + */ +bool +u_camera_consent_path_is_under(const char *path, const char *dir, bool windows_rules); + +/*! + * Do two signer names match? ASCII case-insensitive after trimming leading / + * trailing spaces; non-ASCII bytes compare exactly. Empty / NULL never match. + */ +bool +u_camera_consent_signer_equal(const char *a, const char *b); + //! One step of a list enumerator (see @ref u_camera_consent_list_find). enum u_camera_consent_enum_result { @@ -290,9 +413,13 @@ u_camera_consent_store_default(void); /*! * Add / remove an executable from the USER-level delegating list (the * machine-level list is the installer's). `displayxr-cli camera trust`. + * @p signer (NULL / "" = none) is the required signer CN recorded with the + * entry (Windows: the `.signer` REG_SZ next to it); trusting an + * existing entry replaces its signer. Removing an entry removes its signer. + * POSIX ignores @p signer (delegation is path-only there). */ bool -u_camera_consent_store_set_delegating(const char *exe, bool delegating); +u_camera_consent_store_set_delegating(const char *exe, bool delegating, const char *signer); /*! * List the store for diagnostics: calls @p cb for every stored app decision @@ -310,6 +437,24 @@ u_camera_consent_store_path(char *out, size_t cap); bool u_camera_consent_os_camera_allowed(const char *exe); +/*! + * Could a non-admin process replace @p exe? Windows: true unless the full path + * is under FOLDERID_ProgramFiles, FOLDERID_ProgramFilesX86 or FOLDERID_Windows + * (SHGetKnownFolderPath; ASCII case-insensitive; empty / unresolvable = true). + * POSIX: false for now — delegation is path-only there (no signature check). + */ +bool +u_camera_consent_path_user_writable(const char *exe); + +/*! + * Windows: does @p exe carry a VALID Authenticode signature (WinVerifyTrust, + * WINTRUST_ACTION_GENERIC_VERIFY_V2, no UI, no revocation check, cache-only + * URL retrieval — it never blocks on the network)? If so, the leaf signer's + * subject CN into @p out. POSIX: always false. + */ +bool +u_camera_consent_exe_signer(const char *exe, char *out, size_t cap); + #ifdef __cplusplus } #endif diff --git a/src/xrt/auxiliary/util/u_camera_consent_store.c b/src/xrt/auxiliary/util/u_camera_consent_store.c index 791e667c3f..8c2de75a4a 100644 --- a/src/xrt/auxiliary/util/u_camera_consent_store.c +++ b/src/xrt/auxiliary/util/u_camera_consent_store.c @@ -10,7 +10,12 @@ * Secret REG_BINARY 32 bytes, generated once — keys persistentId * Apps\ REG_DWORD value NAME = full executable path, data 1 = Allow, 2 = Deny * Delegating\ REG_SZ value name = any id, data = executable path (user-level list, - * `displayxr-cli camera trust`) + * `displayxr-cli camera trust [--signer CN]`) + * REG_SZ value name = ".signer", data = the signer CN the entry + * requires (optional; spec §7.1.1 — only consulted when the + * executable's path is user-writable, i.e. not under + * %ProgramFiles%, %ProgramFiles(x86)% or %SystemRoot%). + * Never itself an entry, whatever its data. * HKLM\Software\DisplayXR\CameraConsent\Delegating same shape, written by installers * The OS camera privacy switch is read from CapabilityAccessManager\ConsentStore\webcam * (HKLM global policy, HKCU global, NonPackaged = desktop apps, and the per-app entry @@ -24,6 +29,9 @@ * Linux /etc/displayxr/camera-delegating.json * macOS /Library/Application Support/DisplayXR/camera-delegating.json * {"delegating": ["", ...]} + * POSIX delegation is PATH-ONLY for now: no signer is recorded and + * u_camera_consent_path_user_writable() answers false (no code-signature + * check exists here yet — a follow-up for macOS codesign / Linux). * * Paths compare with u_camera_consent_path_equal(): Windows ASCII-case- * insensitively with either separator, POSIX byte-exact. On Windows every @@ -49,6 +57,11 @@ #endif #include #include +#include +#include +#include +#include +#include #else #include "util/u_file.h" #include "util/u_json.h" @@ -288,6 +301,43 @@ reg_enum_at(struct reg_enum *e, DWORD i, DWORD *type, DWORD *data_len) return U_CAMERA_CONSENT_ENUM_ENTRY; } +#define SIGNER_SUFFIX L".signer" + +//! Is @p name an entry's ".signer" companion value (case-insensitive suffix)? +static bool +is_signer_value_name(const wchar_t *name) +{ + size_t n = wcslen(name), s = wcslen(SIGNER_SUFFIX); + return n > s && _wcsicmp(name + (n - s), SIGNER_SUFFIX) == 0; +} + +/*! + * Read the ".signer" REG_SZ next to delegating entry @p id_u8 under + * @p root's Delegating key into @p out (UTF-8); "" when absent / unusable. + */ +static void +read_entry_signer(HKEY root, const char *id_u8, char *out, size_t cap) +{ + out[0] = '\0'; + wchar_t *wid = u8_to_w(id_u8); + if (wid == NULL) { + return; + } + size_t n = wcslen(wid) + wcslen(SIGNER_SUFFIX) + 1; + wchar_t *vname = (wchar_t *)malloc(n * sizeof(wchar_t)); + if (vname != NULL) { + _snwprintf_s(vname, n, _TRUNCATE, L"%ls%ls", wid, SIGNER_SUFFIX); + wchar_t w[U_CAMERA_CONSENT_SIGNER_MAX]; + if (reg_read_string_w(root, CONSENT_KEY L"\\Delegating", vname, w, (DWORD)(sizeof(w) / sizeof(w[0])))) { + if (!w_to_u8(w, (int)wcslen(w), out, cap)) { + out[0] = '\0'; + } + } + free(vname); + } + free(wid); +} + //! u_camera_consent_enum_fn over a Delegating key: value DATA = executable path. static enum u_camera_consent_enum_result reg_delegating_enum_fn(void *ctx, uint32_t index, char *name, size_t name_cap, char *path, size_t path_cap) @@ -301,6 +351,9 @@ reg_delegating_enum_fn(void *ctx, uint32_t index, char *name, size_t name_cap, c if (type != REG_SZ && type != REG_EXPAND_SZ) { return U_CAMERA_CONSENT_ENUM_SKIP; } + if (is_signer_value_name(e->name)) { + return U_CAMERA_CONSENT_ENUM_SKIP; // an entry's signer, not an entry + } const wchar_t *wd = (const wchar_t *)e->data; if (!w_to_u8(wd, (int)wcslen(wd), path, path_cap) || !w_to_u8(e->name, (int)wcslen(e->name), name, name_cap)) { return U_CAMERA_CONSENT_ENUM_SKIP; // does not fit / not valid UTF-16 @@ -360,10 +413,27 @@ win_set(void *ctx, const char *exe, enum u_camera_consent_stored value) } static bool -win_is_delegating(void *ctx, const char *exe) +win_get_delegation(void *ctx, const char *exe, struct u_camera_consent_delegation *out) { (void)ctx; - return win_delegating_has(HKEY_LOCAL_MACHINE, exe, NULL, 0) || win_delegating_has(HKEY_CURRENT_USER, exe, NULL, 0); + memset(out, 0, sizeof(*out)); + static const struct + { + HKEY root; + enum u_camera_consent_delegation_scope scope; + } lists[2] = { + {HKEY_LOCAL_MACHINE, U_CAMERA_CONSENT_DELEGATION_SYSTEM}, // the installer's list wins + {HKEY_CURRENT_USER, U_CAMERA_CONSENT_DELEGATION_USER}, + }; + for (int i = 0; i < 2; i++) { + char id[1024]; + if (win_delegating_has(lists[i].root, exe, id, sizeof(id))) { + out->scope = lists[i].scope; + read_entry_signer(lists[i].root, id, out->signer, sizeof(out->signer)); + return true; + } + } + return false; } static bool @@ -408,7 +478,7 @@ win_get_secret(void *ctx, uint8_t out[U_CAMERA_CONSENT_SECRET_SIZE]) static const struct u_camera_consent_store_ops win_ops = { .get = win_get, .set = win_set, - .is_delegating = win_is_delegating, + .get_delegation = win_get_delegation, .sharing_enabled = win_sharing_enabled, .set_sharing_enabled = win_set_sharing_enabled, .get_secret = win_get_secret, @@ -420,43 +490,82 @@ u_camera_consent_store_default(void) return &win_ops; } +//! Set (non-empty @p signer_u8) or delete the ".signer" companion of entry @p id (open key @p k). +static bool +write_entry_signer(HKEY k, const wchar_t *id, const char *signer_u8) +{ + size_t n = wcslen(id) + wcslen(SIGNER_SUFFIX) + 1; + wchar_t *vname = (wchar_t *)malloc(n * sizeof(wchar_t)); + if (vname == NULL) { + return false; + } + _snwprintf_s(vname, n, _TRUNCATE, L"%ls%ls", id, SIGNER_SUFFIX); + bool ok; + if (signer_u8 == NULL || signer_u8[0] == '\0') { + LSTATUS rc = RegDeleteValueW(k, vname); + ok = rc == ERROR_SUCCESS || rc == ERROR_FILE_NOT_FOUND; + } else { + wchar_t *ws = u8_to_w(signer_u8); + ok = ws != NULL && RegSetValueExW(k, vname, 0, REG_SZ, (const BYTE *)ws, + (DWORD)((wcslen(ws) + 1) * sizeof(wchar_t))) == ERROR_SUCCESS; + free(ws); + } + free(vname); + return ok; +} + bool -u_camera_consent_store_set_delegating(const char *exe, bool delegating) +u_camera_consent_store_set_delegating(const char *exe, bool delegating, const char *signer) { char name[1024]; bool present = win_delegating_has(HKEY_CURRENT_USER, exe, name, sizeof(name)); - if (!delegating) { - if (!present) { - return true; - } - wchar_t *wname = u8_to_w(name); - bool ok = wname != NULL && reg_delete_value(HKEY_CURRENT_USER, CONSENT_KEY L"\\Delegating", wname); - free(wname); - return ok; - } + wchar_t *wid = NULL; if (present) { - return true; - } - wchar_t *wexe = u8_to_w(exe); - if (wexe == NULL) { - return false; - } - // Value name: the executable's base name (unique enough; data is what matters). - const wchar_t *base = wexe; - for (const wchar_t *p = wexe; *p != L'\0'; p++) { - if (*p == L'\\' || *p == L'/') { - base = p + 1; + wid = u8_to_w(name); + if (wid == NULL) { + return false; + } + } else if (!delegating) { + return true; // nothing to remove + } else { + // New entry. Value name: the executable's base name (unique enough; data is what matters). + wchar_t *wexe = u8_to_w(exe); + if (wexe == NULL) { + return false; + } + const wchar_t *base = wexe; + for (const wchar_t *p = wexe; *p != L'\0'; p++) { + if (*p == L'\\' || *p == L'/') { + base = p + 1; + } + } + wid = _wcsdup(base); + free(wexe); + if (wid == NULL) { + return false; } } HKEY k; bool ok = false; - if (RegCreateKeyExW(HKEY_CURRENT_USER, CONSENT_KEY L"\\Delegating", 0, NULL, 0, KEY_WRITE, NULL, &k, NULL) == - ERROR_SUCCESS) { - DWORD bytes = (DWORD)((wcslen(wexe) + 1) * sizeof(wchar_t)); - ok = RegSetValueExW(k, base, 0, REG_SZ, (const BYTE *)wexe, bytes) == ERROR_SUCCESS; + if (RegCreateKeyExW(HKEY_CURRENT_USER, CONSENT_KEY L"\\Delegating", 0, NULL, 0, KEY_READ | KEY_WRITE, NULL, &k, + NULL) == ERROR_SUCCESS) { + if (!delegating) { + LSTATUS rc = RegDeleteValueW(k, wid); + ok = (rc == ERROR_SUCCESS || rc == ERROR_FILE_NOT_FOUND) && write_entry_signer(k, wid, NULL); + } else { + ok = true; + if (!present) { + wchar_t *wexe = u8_to_w(exe); + ok = wexe != NULL && + RegSetValueExW(k, wid, 0, REG_SZ, (const BYTE *)wexe, + (DWORD)((wcslen(wexe) + 1) * sizeof(wchar_t))) == ERROR_SUCCESS; + free(wexe); + } + ok = ok && write_entry_signer(k, wid, signer); + } RegCloseKey(k); } - free(wexe); + free(wid); return ok; } @@ -476,7 +585,15 @@ win_list_delegating(HKEY root, const char *origin, void (*cb)(void *, char, cons break; } if (r == U_CAMERA_CONSENT_ENUM_ENTRY) { - cb(ctx, 'd', path, origin); + char signer[U_CAMERA_CONSENT_SIGNER_MAX]; + read_entry_signer(root, name, signer, sizeof(signer)); + char value[U_CAMERA_CONSENT_SIGNER_MAX + 64]; + if (signer[0] != '\0') { + snprintf(value, sizeof(value), "%s, signer \"%s\"", origin, signer); + } else { + snprintf(value, sizeof(value), "%s, no signer", origin); + } + cb(ctx, 'd', path, value); } } reg_enum_close(&e); @@ -558,6 +675,91 @@ u_camera_consent_os_camera_allowed(const char *exe) return !denied; } +//! A known folder as UTF-8 into @p out; false if unavailable. +static bool +known_folder_u8(REFKNOWNFOLDERID id, char *out, size_t cap) +{ + PWSTR w = NULL; + bool ok = false; + if (SUCCEEDED(SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, NULL, &w)) && w != NULL) { + ok = w_to_u8(w, (int)wcslen(w), out, cap) && out[0] != '\0'; + } + CoTaskMemFree(w); + return ok; +} + +bool +u_camera_consent_path_user_writable(const char *exe) +{ + if (exe == NULL || exe[0] == '\0') { + return true; + } + // Admin-protected roots (spec §7.1.1). Known caveat, documented there: a + // few subfolders of %SystemRoot% (Temp, Tasks, ...) and any install dir + // whose ACL an installer loosened are writable all the same; the path + // rule is the cheap 99% gate, the signer check covers everything else. + static const KNOWNFOLDERID *const roots[] = {&FOLDERID_ProgramFiles, &FOLDERID_ProgramFilesX86, + &FOLDERID_Windows}; + for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) { + char dir[1024]; + if (known_folder_u8(roots[i], dir, sizeof(dir)) && u_camera_consent_path_is_under(exe, dir, true)) { + return false; + } + } + return true; +} + +bool +u_camera_consent_exe_signer(const char *exe, char *out, size_t cap) +{ + if (cap > 0) { + out[0] = '\0'; + } + if (exe == NULL || exe[0] == '\0' || cap == 0) { + return false; + } + wchar_t *wexe = u8_to_w(exe); + if (wexe == NULL) { + return false; + } + WINTRUST_FILE_INFO fi; + memset(&fi, 0, sizeof(fi)); + fi.cbStruct = sizeof(fi); + fi.pcwszFilePath = wexe; + + WINTRUST_DATA wd; + memset(&wd, 0, sizeof(wd)); + wd.cbStruct = sizeof(wd); + wd.dwUIChoice = WTD_UI_NONE; + wd.fdwRevocationChecks = WTD_REVOKE_NONE; + wd.dwUnionChoice = WTD_CHOICE_FILE; + wd.pFile = &fi; + wd.dwStateAction = WTD_STATEACTION_VERIFY; + // Never touch the network: the service must not stall a stream start on a + // CRL / AIA fetch. Revocation is not checked (WTD_REVOKE_NONE) and any URL + // retrieval is cache-only. + wd.dwProvFlags = WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE; + + GUID action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + LONG st = WinVerifyTrust((HWND)INVALID_HANDLE_VALUE, &action, &wd); + bool ok = false; + if (st == ERROR_SUCCESS) { + CRYPT_PROVIDER_DATA *pd = WTHelperProvDataFromStateData(wd.hWVTStateData); + CRYPT_PROVIDER_SGNR *sg = pd != NULL ? WTHelperGetProvSignerFromChain(pd, 0, FALSE, 0) : NULL; + CRYPT_PROVIDER_CERT *pc = sg != NULL ? WTHelperGetProvCertFromChain(sg, 0) : NULL; + if (pc != NULL && pc->pCert != NULL) { + wchar_t cn[U_CAMERA_CONSENT_SIGNER_MAX]; + DWORD n = CertGetNameStringW(pc->pCert, CERT_NAME_ATTR_TYPE, 0, (void *)szOID_COMMON_NAME, cn, + (DWORD)(sizeof(cn) / sizeof(cn[0]))); + ok = n > 1 && w_to_u8(cn, (int)wcslen(cn), out, cap) && out[0] != '\0'; + } + } + wd.dwStateAction = WTD_STATEACTION_CLOSE; + WinVerifyTrust((HWND)INVALID_HANDLE_VALUE, &action, &wd); + free(wexe); + return ok; +} + #else // POSIX /* @@ -703,24 +905,29 @@ load_system(void) return root; } +//! Path-only (no signer is recorded on POSIX yet): the system list wins over the user's. static bool -posix_is_delegating(void *ctx, const char *exe) +posix_get_delegation(void *ctx, const char *exe, struct u_camera_consent_delegation *out) { (void)ctx; - bool yes = false; + memset(out, 0, sizeof(*out)); cJSON *sys = load_system(); if (sys != NULL) { - yes = delegating_array_has(cJSON_GetObjectItem(sys, "delegating"), exe); + if (delegating_array_has(cJSON_GetObjectItem(sys, "delegating"), exe)) { + out->scope = U_CAMERA_CONSENT_DELEGATION_SYSTEM; + } cJSON_Delete(sys); } - if (!yes) { + if (out->scope == U_CAMERA_CONSENT_DELEGATION_NONE) { cJSON *user = load_user(false); if (user != NULL) { - yes = delegating_array_has(cJSON_GetObjectItem(user, "delegating"), exe); + if (delegating_array_has(cJSON_GetObjectItem(user, "delegating"), exe)) { + out->scope = U_CAMERA_CONSENT_DELEGATION_USER; + } cJSON_Delete(user); } } - return yes; + return out->scope != U_CAMERA_CONSENT_DELEGATION_NONE; } static bool @@ -805,7 +1012,7 @@ posix_get_secret(void *ctx, uint8_t out[U_CAMERA_CONSENT_SECRET_SIZE]) static const struct u_camera_consent_store_ops posix_ops = { .get = posix_get, .set = posix_set, - .is_delegating = posix_is_delegating, + .get_delegation = posix_get_delegation, .sharing_enabled = posix_sharing_enabled, .set_sharing_enabled = posix_set_sharing_enabled, .get_secret = posix_get_secret, @@ -818,8 +1025,9 @@ u_camera_consent_store_default(void) } bool -u_camera_consent_store_set_delegating(const char *exe, bool delegating) +u_camera_consent_store_set_delegating(const char *exe, bool delegating, const char *signer) { + (void)signer; // POSIX delegation is path-only for now (see the file comment) cJSON *root = load_user(true); if (root == NULL) { return false; @@ -891,4 +1099,24 @@ u_camera_consent_os_camera_allowed(const char *exe) return true; // the OS switch (macOS TCC) does not see this consumer: nothing to read } +bool +u_camera_consent_path_user_writable(const char *exe) +{ + // Path-only delegation on POSIX for now: no code-signature check exists + // here yet (macOS SecStaticCode / Linux: follow-up), so a "user-writable" + // answer could only ever refuse. Documented in spec §7.1.1. + (void)exe; + return false; +} + +bool +u_camera_consent_exe_signer(const char *exe, char *out, size_t cap) +{ + (void)exe; + if (cap > 0) { + out[0] = '\0'; + } + return false; +} + #endif diff --git a/src/xrt/include/xrt/xrt_instance.h b/src/xrt/include/xrt/xrt_instance.h index 3810207955..4fc5d6be42 100644 --- a/src/xrt/include/xrt/xrt_instance.h +++ b/src/xrt/include/xrt/xrt_instance.h @@ -126,6 +126,11 @@ struct xrt_application_info //! (u_sandbox_is_workspace_session); the server just records the claim — //! it is a scoping hint, not a privilege, so there is nothing to verify. bool workspace_session; + //! ADR-043: XrStereoCameraClientInfoDXR::flags as the app chained them + //! (XRT_STEREO_CAMERA_CLIENT_* in xrt_stereo_camera.h, same bit values). + //! Only ever RESTRICTS what the service grants (DECLINE_DELEGATION), so the + //! claim needs no verification. Unknown bits are ignored. + uint64_t stereo_camera_client_flags; }; /*! diff --git a/src/xrt/include/xrt/xrt_stereo_camera.h b/src/xrt/include/xrt/xrt_stereo_camera.h index ca06e4fcd3..9310badc19 100644 --- a/src/xrt/include/xrt/xrt_stereo_camera.h +++ b/src/xrt/include/xrt/xrt_stereo_camera.h @@ -61,6 +61,15 @@ enum xrt_stereo_camera_transport XRT_STEREO_CAMERA_TRANSPORT_AHARDWAREBUFFER = 3, }; +/*! + * xrt_application_info::stereo_camera_client_flags — the same bit values as + * XrStereoCameraClientFlagsDXR. + */ +//! XR_STEREO_CAMERA_CLIENT_CONSUMER_ONLY_BIT_DXR (mapped to the client class, not read from here). +#define XRT_STEREO_CAMERA_CLIENT_CONSUMER_ONLY (1ull << 0) +//! XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR: skip consent delegation for this instance. +#define XRT_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION (1ull << 1) + //! Format / transport sets are one bit per enum value: (1 << value). #define XRT_STEREO_CAMERA_BIT(v) (1ull << (uint32_t)(v)) diff --git a/src/xrt/ipc/server/ipc_server_stereo_camera.c b/src/xrt/ipc/server/ipc_server_stereo_camera.c index 330c98f55d..164967f60f 100644 --- a/src/xrt/ipc/server/ipc_server_stereo_camera.c +++ b/src/xrt/ipc/server/ipc_server_stereo_camera.c @@ -20,7 +20,10 @@ * (one WARN); OS camera privacy switch -> PERMISSION_INSUFFICIENT and a * stored Deny -> CONSENT_REFUSED, both for EVERY client; only then a * registered consent-DELEGATING client (the browser, by installer / - * `displayxr-cli camera trust`) -> allowed, its own prompt is the consent; + * `displayxr-cli camera trust`) -> allowed, its own prompt is the consent + * (not when the client set DECLINE_DELEGATION, nor for an entry whose + * user-writable executable lacks a matching Authenticode signer; a + * USER-level entry raises the notice provider once per exe per run); * a stored Allow; else the tray prompt (" wants to use the * 3D camera — Allow / Allow once / Deny"), which the host installs with * ipc_server_stereo_camera_set_prompt_provider(). The prompt BLOCKS the @@ -249,7 +252,10 @@ struct scam_stream char exe[512]; //!< verified peer executable ("" = unknown) long peer_pid; //!< the pid the foreground rule looks at uint32_t client_class; //!< verified class of the creating connection - bool delegating; //!< registered consent-delegating client + bool delegating; //!< registered consent-delegating client (RAW refusal keys on it) + //! Its start was allowed BY delegation (trusted, not declined): only such + //! a stream follows its client's own visibility rule (§7.1.1, §7.2). + bool delegated; int64_t vis_checked_ns; bool visible; uint32_t consent_why; //!< enum u_camera_consent_why of the start that allowed it @@ -410,6 +416,8 @@ static ipc_server_stereo_camera_prompt_fn g_prompt_fn = NULL; static void *g_prompt_ctx = NULL; static ipc_server_stereo_camera_indicator_fn g_indicator_fn = NULL; static void *g_indicator_ctx = NULL; +static ipc_server_stereo_camera_notice_fn g_notice_fn = NULL; +static void *g_notice_ctx = NULL; /* @@ -523,9 +531,39 @@ env_prompt(void *ctx, const char *exe, const char *app_name, long pid, uint32_t return fn(g_prompt_ctx, exe, app_name, pid, timeout_ms); } +//! §7.1.1: may a non-admin process replace this executable? (POSIX: no — path-only for now.) +static bool +env_path_user_writable(void *ctx, const char *exe) +{ + (void)ctx; + return u_camera_consent_path_user_writable(exe); +} + +//! §7.1.1: the executable's valid code signer (Windows Authenticode; none on POSIX). +static bool +env_exe_signer(void *ctx, const char *exe, char *out, size_t cap) +{ + (void)ctx; + return u_camera_consent_exe_signer(exe, out, cap); +} + +//! §7.1.1: first use of a USER-level delegation — the host's notice (tray balloon), if any. +static void +env_user_delegation_notice(void *ctx, const char *exe, const char *app_name) +{ + (void)ctx; + ipc_server_stereo_camera_notice_fn fn = g_notice_fn; + if (fn != NULL) { + fn(g_notice_ctx, exe, app_name); + } +} + static const struct u_camera_consent_env_ops env_ops = { .os_camera_allowed = env_os_camera_allowed, .prompt = env_prompt, + .path_user_writable = env_path_user_writable, + .exe_signer = env_exe_signer, + .user_delegation_notice = env_user_delegation_notice, }; static xrt_result_t @@ -555,17 +593,21 @@ consent_evaluate(struct ipc_server_stereo_camera *m, { char app_name[XRT_MAX_APPLICATION_NAME_SIZE]; snprintf(app_name, sizeof(app_name), "%s", (const char *)ics->client_state.info.application_name); + // Spec v3: XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR, as declared + // at xrCreateInstance (it only ever restricts, so the claim is taken as is). + uint32_t flags = 0; + if ((ics->client_state.info.stereo_camera_client_flags & XRT_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION) != 0) { + flags |= U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION; + } os_mutex_lock(&m->consent_lock); - u_camera_consent_evaluate(&m->consent, exe, app_name, ics->peer_pid, out); + u_camera_consent_evaluate(&m->consent, exe, app_name, ics->peer_pid, flags, out); os_mutex_unlock(&m->consent_lock); xrt_result_t xret = verdict_to_xret(out->verdict); - if (xret == XRT_SUCCESS) { - U_LOG_W("%s: ALLOWED for %s (pid %ld, \"%s\") — %s", what, exe[0] ? exe : "?", ics->peer_pid, app_name, - u_camera_consent_why_str(out->why)); - } else { - U_LOG_W("%s: REFUSED for %s (pid %ld, \"%s\") — %s", what, exe[0] ? exe : "?", ics->peer_pid, app_name, - u_camera_consent_why_str(out->why)); - } + // When a registered entry was not applied, say why (declined / untrusted). + const char *skip = u_camera_consent_delegation_skip_str(out->delegation_skip); + U_LOG_W("%s: %s for %s (pid %ld, \"%s\") — %s%s%s%s", what, xret == XRT_SUCCESS ? "ALLOWED" : "REFUSED", + exe[0] ? exe : "?", ics->peer_pid, app_name, u_camera_consent_why_str(out->why), + skip[0] ? " (delegation not applied: " : "", skip, skip[0] ? ")" : ""); return xret; } @@ -573,8 +615,7 @@ consent_evaluate(struct ipc_server_stereo_camera *m, static bool exe_is_delegating(struct ipc_server_stereo_camera *m, const char *exe) { - const struct u_camera_consent_store_ops *st = m->consent.store; - return exe[0] != '\0' && st != NULL && st->is_delegating != NULL && st->is_delegating(m->consent.store_ctx, exe); + return u_camera_consent_is_registered_delegating(m->consent.store, m->consent.store_ctx, exe); } //! Does @p pid own a visible top-level window right now (platform query)? @@ -617,14 +658,15 @@ pid_has_visible_window(long pid) /*! * Spec §7.2 foreground rule, evaluated per published frame with a 250 ms * cache. Window-bearing classes must own a visible top-level window; a - * delegating client follows its own rule; CAMERA_CONSUMER / DIAG have no - * window by contract and are exempt. The OS-lock gate is separate (publish - * checks m->locked first). + * client allowed BY delegation follows its own rule (a registered client + * that declined delegation, or whose entry was not trusted, is an ordinary + * app here too); CAMERA_CONSUMER / DIAG have no window by contract and are + * exempt. The OS-lock gate is separate (publish checks m->locked first). */ static bool client_visible_locked(struct scam_stream *s, int64_t now) { - if (s->delegating || s->client_class == XRT_CLIENT_CLASS_CAMERA_CONSUMER || + if (s->delegated || s->client_class == XRT_CLIENT_CLASS_CAMERA_CONSUMER || s->client_class == XRT_CLIENT_CLASS_DIAG || s->peer_pid <= 0) { return true; } @@ -2114,6 +2156,7 @@ ipc_handle_stereo_camera_stream_start(volatile struct ipc_client_state *ics, uin return XRT_ERROR_STEREO_CAMERA_BUSY; } s->delegating = dec.delegating || s->delegating; + s->delegated = dec.delegated; s->consent_why = (uint32_t)dec.why; xret = stream_allocate_locked(s, cam); if (xret != XRT_SUCCESS) { @@ -2415,6 +2458,13 @@ ipc_server_stereo_camera_set_indicator_provider(ipc_server_stereo_camera_indicat g_indicator_fn = fn; } +void +ipc_server_stereo_camera_set_notice_provider(ipc_server_stereo_camera_notice_fn fn, void *ctx) +{ + g_notice_ctx = ctx; + g_notice_fn = fn; +} + bool ipc_server_stereo_camera_get_status(struct ipc_server_stereo_camera_status *out) { diff --git a/src/xrt/ipc/server/ipc_server_stereo_camera.h b/src/xrt/ipc/server/ipc_server_stereo_camera.h index b51ddb6f3f..8a72a18983 100644 --- a/src/xrt/ipc/server/ipc_server_stereo_camera.h +++ b/src/xrt/ipc/server/ipc_server_stereo_camera.h @@ -70,6 +70,18 @@ typedef enum u_camera_consent_prompt_answer (*ipc_server_stereo_camera_prompt_fn void ipc_server_stereo_camera_set_prompt_provider(ipc_server_stereo_camera_prompt_fn fn, void *ctx); +/*! + * A notice for the user, no answer expected (Windows: a tray balloon). Raised + * the first time in a service run that a USER-level consent-delegation entry + * allows an executable (spec §7.1.1) — @p exe is the executable, @p app_name + * the client's declared name. Called on an IPC client thread with the consent + * serialisation held: must NOT block (copy the strings, post, return). + */ +typedef void (*ipc_server_stereo_camera_notice_fn)(void *ctx, const char *exe, const char *app_name); + +void +ipc_server_stereo_camera_set_notice_provider(ipc_server_stereo_camera_notice_fn fn, void *ctx); + //! Called (no lock held) whenever the in-use state may have changed. typedef void (*ipc_server_stereo_camera_indicator_fn)(void *ctx); diff --git a/src/xrt/state_trackers/oxr/oxr_instance.c b/src/xrt/state_trackers/oxr/oxr_instance.c index 1e3e1292dd..051367fdaf 100644 --- a/src/xrt/state_trackers/oxr/oxr_instance.c +++ b/src/xrt/state_trackers/oxr/oxr_instance.c @@ -572,6 +572,12 @@ oxr_instance_create(struct oxr_logger *log, if (ci != NULL && (ci->flags & XR_STEREO_CAMERA_CLIENT_CONSUMER_ONLY_BIT_DXR) != 0) { i_info.app_info.declared_client_class = XRT_CLIENT_CLASS_CAMERA_CONSUMER; } + // Spec v3: the flags travel to the service as declared (same bit + // values as XRT_STEREO_CAMERA_CLIENT_*); it acts on + // DECLINE_DELEGATION_BIT and ignores bits it does not know. + if (ci != NULL) { + i_info.app_info.stereo_camera_client_flags = (uint64_t)ci->flags; + } } #endif // #964 Phase A: is this process part of a workspace session? The workspace diff --git a/src/xrt/targets/cli/cli_cmd_camera.c b/src/xrt/targets/cli/cli_cmd_camera.c index 0256c0bc7d..7c914cc357 100644 --- a/src/xrt/targets/cli/cli_cmd_camera.c +++ b/src/xrt/targets/cli/cli_cmd_camera.c @@ -15,8 +15,13 @@ * camera consent list the store (apps, delegating, sharing) * camera allow|deny|revoke |--self stored per-app decision * camera trust|untrust |--self user-level consent-DELEGATING list - * (trust clears a stored Deny for , which - * would otherwise keep refusing it — spec §7.1) + * [--signer ""] (trust clears a stored Deny for , which + * would otherwise keep refusing it — spec §7.1; + * --signer records the Authenticode signer CN a + * user-writable must carry — §7.1.1) + * camera signer |--self what §7.1.1 sees: path class + valid signer + * list / calib / probe ... --decline-delegation declare XR_STEREO_CAMERA_CLIENT_DECLINE_ + * DELEGATION_BIT_DXR (spec v3) for this connection * is stored as the service will see the peer: made absolute / resolved * (and, on Windows, read from the UTF-16 command line, not the ANSI argv). * camera status sharing / locked / started streams (IPC) @@ -43,12 +48,10 @@ * (exit 5) if the frames came back RAW-flagged or the rows do not align: * |SIGNED median dy| > 0.5 px (a constant vertical offset), a robust spread * (MAD about that median) > 1.5 px, or fewer than 20 accepted blocks. The - * unsigned |dy| statistics are printed but not gated (block-match noise). Matching is zero-mean NCC with sub-pixel refinement; the - * disparity window comes from the camera's own f * B (a subject at 0.4 m), - * the vertical one is +-24 px, blocks under NCC 0.90 or peaking ON either - * bound are counted and excluded (a bound hit is a clamp, not a measurement — - * the first Leia SR run read "d = 64, |dy| = 10.000" that way). On the - * distorted sim fake + * unsigned |dy| statistics are printed but not gated (block-match noise). Matching is zero-mean NCC with sub-pixel + * refinement; the disparity window comes from the camera's own f * B (a subject at 0.4 m), the vertical one is +-24 px, + * blocks under NCC 0.90 or peaking ON either bound are counted and excluded (a bound hit is a clamp, not a measurement + * — the first Leia SR run read "d = 64, |dy| = 10.000" that way). On the distorted sim fake * (SIM_DISPLAY_FAKE_STEREO_CAMERA_DISTORT=1) the depths read back 2.00 m * (background) and 0.60 m (bar); `--raw` shows the misalignment it fixes. * @@ -181,12 +184,14 @@ refusal_exit_code(xrt_result_t x) } } +//! @p client_flags: XRT_STEREO_CAMERA_CLIENT_* (what XrStereoCameraClientInfoDXR::flags carries). static bool -cam_connect(struct ipc_connection *ipc_c, uint32_t client_class) +cam_connect(struct ipc_connection *ipc_c, uint32_t client_class, uint64_t client_flags) { struct xrt_instance_info ii = {0}; snprintf(ii.app_info.application_name, sizeof(ii.app_info.application_name), "%s", "displayxr-cli"); ii.app_info.declared_client_class = client_class; + ii.app_info.stereo_camera_client_flags = client_flags; xrt_result_t xret = ipc_client_connection_init(ipc_c, U_LOGGING_ERROR, &ii); if (xret != XRT_SUCCESS) { printf("displayxr-cli camera: not connected to the service (xrt_result=%d).\n", (int)xret); @@ -1065,6 +1070,43 @@ self_exe_path(char *out, size_t cap) #endif } +//! Index of @p flag in argv (from 3, after "camera "), or -1. +static int +cli_flag_index(int argc, const char **argv, const char *flag) +{ + for (int i = 3; i < argc; i++) { + if (strcmp(argv[i], flag) == 0) { + return i; + } + } + return -1; +} + +//! argv[@p idx] as UTF-8 (Windows: re-read from the UTF-16 command line, argv is ANSI). +static bool +arg_utf8(int argc, const char **argv, int idx, char *out, size_t cap) +{ + if (idx < 0 || idx >= argc || cap == 0) { + return false; + } +#if defined(XRT_OS_WINDOWS) + int wargc = 0; + wchar_t **wargv = CommandLineToArgvW(GetCommandLineW(), &wargc); + bool ok = false; + if (wargv != NULL && wargc == argc) { + ok = WideCharToMultiByte(CP_UTF8, 0, wargv[idx], -1, out, (int)cap, NULL, NULL) > 1; + } + if (wargv != NULL) { + LocalFree(wargv); + } + if (ok) { + return true; + } +#endif + snprintf(out, cap, "%s", argv[idx]); + return out[0] != '\0'; +} + //! "" or "--self" -> the path to act on. static bool resolve_exe_arg(int argc, const char **argv, int idx, char *out, size_t cap) @@ -1165,22 +1207,70 @@ cmd_store(const char *op, int argc, const char **argv) } printf("trust %s: cleared the stored Deny for it\n", exe); } - ok = u_camera_consent_store_set_delegating(exe, true); + // Spec §7.1.1: an executable outside the admin-protected folders is only + // delegated when it is Authenticode-signed by the signer recorded here. + char signer[U_CAMERA_CONSENT_SIGNER_MAX] = {0}; + int si = cli_flag_index(argc, argv, "--signer"); + if (si > 0 && !arg_utf8(argc, argv, si + 1, signer, sizeof(signer))) { + printf("usage: displayxr-cli camera trust |--self [--signer \"\"]\n"); + return 1; + } + ok = u_camera_consent_store_set_delegating(exe, true, signer); + if (ok && u_camera_consent_path_user_writable(exe)) { + char actual[U_CAMERA_CONSENT_SIGNER_MAX]; + bool is_signed = u_camera_consent_exe_signer(exe, actual, sizeof(actual)); + if (signer[0] == '\0') { + printf( + " note: %s is in a user-writable location: without --signer \"\" this entry " + "is NOT " + "applied (the app is prompted like any other)%s%s%s\n", + exe, is_signed ? "; it is signed by \"" : "", is_signed ? actual : "", + is_signed ? "\"" : ""); + } else if (!is_signed) { + printf(" note: %s has no valid code signature: this entry is NOT applied\n", exe); + } else if (!u_camera_consent_signer_equal(actual, signer)) { + printf(" note: %s is signed by \"%s\", not \"%s\": this entry is NOT applied\n", exe, + actual, signer); + } + } } else { // untrust - ok = u_camera_consent_store_set_delegating(exe, false); + ok = u_camera_consent_store_set_delegating(exe, false, NULL); } printf("%s %s: %s\n", op, exe, ok ? "ok (takes effect on the next stream start)" : "FAILED to write the store"); - if (ok && strcmp(op, "deny") == 0 && st->is_delegating(NULL, exe)) { + if (ok && strcmp(op, "deny") == 0 && u_camera_consent_is_registered_delegating(st, NULL, exe)) { printf(" note: %s is also a registered consent-delegating client; the stored Deny wins\n", exe); } return ok ? 0 : 2; } +/*! + * `camera signer |--self`: what §7.1.1 sees for an executable — is its + * path user-writable, and its valid Authenticode signer (if any). Exit 0 when + * validly signed, 3 when not (so a script can tell). + */ +static int +cmd_signer(int argc, const char **argv) +{ + char exe[1024]; + if (!resolve_exe_arg(argc, argv, 3, exe, sizeof(exe))) { + printf("usage: displayxr-cli camera signer |--self\n"); + return 1; + } + char cn[U_CAMERA_CONSENT_SIGNER_MAX]; + bool is_signed = u_camera_consent_exe_signer(exe, cn, sizeof(cn)); + bool uw = u_camera_consent_path_user_writable(exe); + printf("%s\n path: %s\n signature: %s%s%s\n", exe, + uw ? "USER-WRITABLE (a delegating entry needs a matching signer)" + : "admin-protected (a delegating entry is trusted by path)", + is_signed ? "valid, signer \"" : "none / not valid", is_signed ? cn : "", is_signed ? "\"" : ""); + return is_signed ? 0 : 3; +} + static int cmd_control(uint32_t op, uint32_t arg, const char *what) { struct ipc_connection ipc_c = {0}; - if (!cam_connect(&ipc_c, XRT_CLIENT_CLASS_DIAG)) { + if (!cam_connect(&ipc_c, XRT_CLIENT_CLASS_DIAG, 0)) { return 2; } uint32_t value = 0; @@ -1225,10 +1315,14 @@ usage(void) "[--seconds S] " "[--out DIR]\n" " [--max-disparity N] [--max-dy N (24)] [--min-ncc C (0.90)]\n" + " list|calib|probe ... --decline-delegation declare DECLINE_DELEGATION (spec v3)\n" " consent list the consent store\n" " allow|deny|revoke |--self stored per-app decision (local)\n" - " trust|untrust |--self user-level consent-delegating list (local; trust clears a stored " - "Deny)\n" + " trust |--self [--signer \"\"] | untrust |--self\n" + " user-level consent-delegating list (local; trust clears a stored " + "Deny;\n" + " a user-writable needs --signer, spec 7.1.1)\n" + " signer |--self path class + Authenticode signer (exit 3 = not validly signed)\n" " status | stop-all | sharing on|off | fake-lock on|off (over IPC, DIAG)\n" "exit codes: 4 consent refused, 6 sharing off, 7 busy, 8 ended by the service, 5 rows misaligned\n"); } @@ -1247,6 +1341,9 @@ cli_cmd_camera(int argc, const char **argv) strcmp(sub, "trust") == 0 || strcmp(sub, "untrust") == 0) { return cmd_store(sub, argc, argv); } + if (strcmp(sub, "signer") == 0) { + return cmd_signer(argc, argv); + } // Control ops: DIAG class over IPC. uint32_t onoff = 0; if (strcmp(sub, "status") == 0) { @@ -1276,7 +1373,13 @@ cli_cmd_camera(int argc, const char **argv) // The consumer path: the same class a browser's capture utility declares. struct ipc_connection ipc_c = {0}; - if (!cam_connect(&ipc_c, XRT_CLIENT_CLASS_CAMERA_CONSUMER)) { + // --decline-delegation: XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR, as a + // browser running with its own permission prompt bypassed declares it (spec v3). + uint64_t client_flags = XRT_STEREO_CAMERA_CLIENT_CONSUMER_ONLY; + if (cli_has_flag(argc, argv, "--decline-delegation")) { + client_flags |= XRT_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION; + } + if (!cam_connect(&ipc_c, XRT_CLIENT_CLASS_CAMERA_CONSUMER, client_flags)) { return 2; } int ret; diff --git a/src/xrt/targets/service/service_tray_win.c b/src/xrt/targets/service/service_tray_win.c index f6b1f8361e..d97e114c41 100644 --- a/src/xrt/targets/service/service_tray_win.c +++ b/src/xrt/targets/service/service_tray_win.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "util/u_crash_guard.h" @@ -42,6 +43,7 @@ #define WM_CAMERA_PROMPT (WM_APP + 2) //!< lParam = struct cam_prompt_req * (show) #define WM_CAMERA_PROMPT_CLOSE (WM_APP + 3) //!< lParam = struct cam_prompt_req * (close, SendMessage) #define WM_CAMERA_INDICATOR (WM_APP + 4) //!< refresh icon + tooltip now +#define WM_CAMERA_NOTICE (WM_APP + 5) //!< lParam = struct cam_notice * (heap; the tray frees it) // ADR-045: tooltip refresh of the display-processor status line. #define TRAY_STATUS_TIMER_ID 1 @@ -487,6 +489,65 @@ camera_indicator_changed(void *ctx) } } +//! A balloon to show on the tray thread (WM_CAMERA_NOTICE). +struct cam_notice +{ + wchar_t title[64]; + wchar_t body[256]; +}; + +/*! + * The manager's notice provider (IPC client thread, consent serialisation + * held): spec §7.1.1 — a USER-level consent-delegation entry allowed @p exe + * for the first time in this service run. Never blocks: format, post, return. + */ +static void +tray_camera_notice(void *ctx, const char *exe, const char *app_name) +{ + (void)ctx; + if (s_tray_hwnd == NULL) { + return; + } + struct cam_notice *n = (struct cam_notice *)calloc(1, sizeof(*n)); + if (n == NULL) { + return; + } + const char *base = exe != NULL ? exe : ""; + for (const char *p = base; *p != '\0'; p++) { + if (*p == '\\' || *p == '/') { + base = p + 1; + } + } + wchar_t wapp[128], wbase[128]; + MultiByteToWideChar(CP_UTF8, 0, app_name != NULL && app_name[0] ? app_name : "An app", -1, wapp, + ARRAYSIZE(wapp)); + MultiByteToWideChar(CP_UTF8, 0, base[0] ? base : "?", -1, wbase, ARRAYSIZE(wbase)); + wcscpy_s(n->title, ARRAYSIZE(n->title), L"3D camera: per-user trust used"); + _snwprintf_s(n->body, ARRAYSIZE(n->body), _TRUNCATE, + L"“%ls” (%ls) got the 3D camera without a DisplayXR prompt, because a per-user setting " + L"trusts it to ask you itself. Remove: displayxr-cli camera untrust.", + wapp, wbase); + if (!PostMessageW(s_tray_hwnd, WM_CAMERA_NOTICE, 0, (LPARAM)n)) { + free(n); + } +} + +//! Tray thread: show (and free) a notice balloon. +static void +show_camera_notice(struct cam_notice *cn) +{ + if (cn == NULL) { + return; + } + NOTIFYICONDATAW n = s_nid; + n.uFlags = NIF_INFO; + n.dwInfoFlags = NIIF_WARNING; + wcscpy_s(n.szInfoTitle, ARRAYSIZE(n.szInfoTitle), cn->title); + wcscpy_s(n.szInfo, ARRAYSIZE(n.szInfo), cn->body); + Shell_NotifyIconW(NIM_MODIFY, &n); + free(cn); +} + #define CAM_BTN_ALLOW 1 #define CAM_BTN_ONCE 2 #define CAM_BTN_DENY 3 @@ -1031,6 +1092,8 @@ tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; } + case WM_CAMERA_NOTICE: show_camera_notice((struct cam_notice *)lParam); return 0; + case WM_CAMERA_INDICATOR: update_camera_indicator(); update_status_tooltip(); @@ -1214,6 +1277,7 @@ service_tray_init(service_tray_shutdown_cb shutdown_cb, if (s_tray_hwnd != NULL) { ipc_server_stereo_camera_set_prompt_provider(tray_camera_prompt, NULL); ipc_server_stereo_camera_set_indicator_provider(camera_indicator_changed, NULL); + ipc_server_stereo_camera_set_notice_provider(tray_camera_notice, NULL); } return s_tray_hwnd != NULL; diff --git a/tests/tests_camera_consent.cpp b/tests/tests_camera_consent.cpp index 9cd9ee498c..9f5c29de3a 100644 --- a/tests/tests_camera_consent.cpp +++ b/tests/tests_camera_consent.cpp @@ -24,10 +24,29 @@ namespace { +struct fake_entry_info +{ + u_camera_consent_delegation_scope scope = U_CAMERA_CONSENT_DELEGATION_SYSTEM; + std::string signer; +}; + +//! insert(exe) registers a SYSTEM-level, signer-less entry (the installer's case). +struct fake_delegating_list +{ + std::map m; + void + insert(const std::string &exe, + u_camera_consent_delegation_scope scope = U_CAMERA_CONSENT_DELEGATION_SYSTEM, + const std::string &signer = "") + { + m[exe] = fake_entry_info{scope, signer}; + } +}; + struct fake_store { std::map apps; - std::set delegating; + fake_delegating_list delegating; bool sharing = true; bool have_secret = true; uint8_t secret[32] = {1, 2, 3}; @@ -62,9 +81,16 @@ fs_set(void *ctx, const char *exe, u_camera_consent_stored v) return true; } bool -fs_is_delegating(void *ctx, const char *exe) +fs_get_delegation(void *ctx, const char *exe, u_camera_consent_delegation *out) { - return ((fake_store *)ctx)->delegating.count(exe) > 0; + auto &m = ((fake_store *)ctx)->delegating.m; + auto it = m.find(exe); + if (it == m.end()) { + return false; + } + out->scope = it->second.scope; + snprintf(out->signer, sizeof(out->signer), "%s", it->second.signer.c_str()); + return true; } bool fs_sharing(void *ctx) @@ -88,8 +114,8 @@ fs_secret(void *ctx, uint8_t out[32]) return true; } -const u_camera_consent_store_ops fake_store_ops = {fs_get, fs_set, fs_is_delegating, fs_sharing, fs_set_sharing, - fs_secret}; +const u_camera_consent_store_ops fake_store_ops = {fs_get, fs_set, fs_get_delegation, + fs_sharing, fs_set_sharing, fs_secret}; struct fake_env { @@ -98,6 +124,12 @@ struct fake_env int prompts = 0; std::string last_exe; long last_pid = 0; + //! Executables whose path a non-admin process could replace (default: none). + std::set user_writable; + //! Validly signed executables -> their signer CN. + std::map signers; + int signer_queries = 0; + std::vector notices; }; bool @@ -118,7 +150,31 @@ fe_prompt(void *ctx, const char *exe, const char *app, long pid, uint32_t timeou return e->answer; } -const u_camera_consent_env_ops fake_env_ops = {fe_os, fe_prompt}; +bool +fe_user_writable(void *ctx, const char *exe) +{ + return ((fake_env *)ctx)->user_writable.count(exe) > 0; +} +bool +fe_signer(void *ctx, const char *exe, char *out, size_t cap) +{ + auto *e = (fake_env *)ctx; + e->signer_queries++; + auto it = e->signers.find(exe); + if (it == e->signers.end()) { + return false; + } + snprintf(out, cap, "%s", it->second.c_str()); + return true; +} +void +fe_notice(void *ctx, const char *exe, const char *app) +{ + (void)app; + ((fake_env *)ctx)->notices.push_back(exe); +} + +const u_camera_consent_env_ops fake_env_ops = {fe_os, fe_prompt, fe_user_writable, fe_signer, fe_notice}; struct rig { @@ -129,10 +185,11 @@ struct rig { u_camera_consent_init(&c, &fake_store_ops, &store, &fake_env_ops, &env); } - u_camera_consent_decision eval(const char *exe = "/apps/caller", long pid = 100) + u_camera_consent_decision + eval(const char *exe = "/apps/caller", long pid = 100, uint32_t flags = 0) { u_camera_consent_decision d; - u_camera_consent_evaluate(&c, exe, "Caller", pid, &d); + u_camera_consent_evaluate(&c, exe, "Caller", pid, flags, &d); return d; } }; @@ -196,9 +253,255 @@ TEST_CASE("camera consent: a delegating client passes without a prompt or a stor CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); CHECK(d.delegating); + CHECK(d.delegated); + CHECK(d.delegation_scope == U_CAMERA_CONSENT_DELEGATION_SYSTEM); CHECK(r.env.prompts == 0); CHECK(r.store.sets == 0); CHECK(r.store.apps.empty()); + CHECK(r.env.notices.empty()); // a machine-level (installer) entry is silent +} + +/* + * Spec v3: XR_STEREO_CAMERA_CLIENT_DECLINE_DELEGATION_BIT_DXR. The client says + * "do not take my own prompt as consent this time" — the delegation step is + * skipped and the executable is an ordinary app: stored Allow, Allow once, + * the runtime prompt. Every refusal still applies first. + */ +TEST_CASE("camera consent: DECLINE_DELEGATION skips the delegation step", "[camera_consent][decline]") +{ + const char *b = "/opt/browser/browser"; + const uint32_t decline = U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION; + rig r; + r.store.delegating.insert(b); + SECTION("nothing stored: the runtime prompt decides") + { + r.env.answer = U_CAMERA_CONSENT_PROMPT_DENY; + auto d = r.eval(b, 100, decline); + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_DENY); + CHECK(r.env.prompts == 1); + CHECK(d.delegating); // still REGISTERED: the RAW refusal keeps applying + CHECK(!d.delegated); // but not allowed by delegation + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_DECLINED); + // The prompt's Deny is now stored, and it wins the next time too — + // with or without the flag. + d = r.eval(b, 100, 0); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_DENY); + } + SECTION("prompt unavailable: refused") + { + auto d = r.eval(b, 100, decline); + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + } + SECTION("a stored Allow allows") + { + r.store.apps[b] = U_CAMERA_CONSENT_STORED_ALLOW; + auto d = r.eval(b, 100, decline); + CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_ALLOW); + CHECK(!d.delegated); + CHECK(r.env.prompts == 0); + } + SECTION("Allow once allows the same process again") + { + r.env.answer = U_CAMERA_CONSENT_PROMPT_ALLOW_ONCE; + auto d = r.eval(b, 7, decline); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_ALLOW_ONCE); + d = r.eval(b, 7, decline); + CHECK(d.why == U_CAMERA_CONSENT_WHY_ALLOW_ONCE); + CHECK(r.env.prompts == 1); + } + SECTION("a stored Deny still wins") + { + r.store.apps[b] = U_CAMERA_CONSENT_STORED_DENY; + r.env.answer = U_CAMERA_CONSENT_PROMPT_ALLOW; + auto d = r.eval(b, 100, decline); + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_DENY); + CHECK(r.env.prompts == 0); + } + SECTION("without the flag the same executable is delegated") + { + auto d = r.eval(b, 100, 0); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(d.delegated); + } + SECTION("unknown flag bits are ignored") + { + auto d = r.eval(b, 100, 0xfffffffeu); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + } + SECTION("an unregistered executable is unaffected by the flag") + { + r.env.answer = U_CAMERA_CONSENT_PROMPT_ALLOW; + auto d = r.eval("/apps/caller", 100, decline); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_ALLOW); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_APPLIES); + } +} + +/* + * Spec §7.1.1: a delegating entry whose executable sits in a user-writable + * location is trusted only with a recorded signer that the executable's valid + * signature matches. Otherwise step 6 is skipped exactly as if not delegating. + */ +TEST_CASE("camera consent: user-writable delegating entries need a matching signer", "[camera_consent][signer]") +{ + const char *b = "C:\\Users\\u\\AppData\\Local\\Browser\\browser.exe"; + rig r; + r.env.user_writable.insert(b); + r.env.answer = U_CAMERA_CONSENT_PROMPT_UNAVAILABLE; + SECTION("no signer recorded: not delegated, falls through to the prompt") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_USER); + r.env.signers[b] = "Browser Corp"; // validly signed, but the entry names nobody + auto d = r.eval(b); + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + CHECK(r.env.prompts == 1); + CHECK(d.delegating); + CHECK(!d.delegated); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_NO_SIGNER); + CHECK(r.env.notices.empty()); + // The WARN is once per executable per run. + CHECK(r.c.untrusted_warned_count == 1); + r.eval(b); + CHECK(r.c.untrusted_warned_count == 1); + } + SECTION("no signer recorded, a stored Allow still allows (as for any app)") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_USER); + r.store.apps[b] = U_CAMERA_CONSENT_STORED_ALLOW; + auto d = r.eval(b); + CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_ALLOW); + CHECK(!d.delegated); + } + SECTION("matching signer: delegated") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Browser Corp"); + r.env.signers[b] = "browser corp "; // case / surrounding spaces do not matter + auto d = r.eval(b); + CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(d.delegated); + CHECK(r.env.prompts == 0); + CHECK(r.env.signer_queries == 1); + } + SECTION("not validly signed: not delegated") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Browser Corp"); + auto d = r.eval(b); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + CHECK(!d.delegated); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_NOT_SIGNED); + } + SECTION("signed by someone else: not delegated") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Browser Corp"); + r.env.signers[b] = "Browser Corp Evil"; + auto d = r.eval(b); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + CHECK(!d.delegated); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_SIGNER_MISMATCH); + } + SECTION("a stored Deny still decides before the signer is even looked at") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Browser Corp"); + r.env.signers[b] = "Browser Corp"; + r.store.apps[b] = U_CAMERA_CONSENT_STORED_DENY; + auto d = r.eval(b); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_DENY); + CHECK(r.env.signer_queries == 0); + } +} + +TEST_CASE("camera consent: an admin-protected delegating entry is trusted by path alone", "[camera_consent][signer]") +{ + const char *b = "C:\\Program Files\\Browser\\browser.exe"; + rig r; // r.env.user_writable is empty: every path is admin-protected + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, ""); // no signer, unsigned + auto d = r.eval(b); + CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(d.delegated); + CHECK(r.env.signer_queries == 0); + SECTION("a recorded signer is not consulted there either") + { + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Somebody Else"); + d = r.eval(b); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(r.env.signer_queries == 0); + } +} + +TEST_CASE("camera consent: a host that cannot classify paths fails closed", "[camera_consent][signer]") +{ + // No path_user_writable / exe_signer: every path counts as user-writable + // and nothing can be verified -> a delegating entry is never applied. + fake_store store; + store.delegating.insert("/opt/browser/browser"); + fake_env env; + const u_camera_consent_env_ops minimal = {fe_os, fe_prompt, nullptr, nullptr, nullptr}; + u_camera_consent c; + u_camera_consent_init(&c, &fake_store_ops, &store, &minimal, &env); + u_camera_consent_decision d; + u_camera_consent_evaluate(&c, "/opt/browser/browser", "B", 1, 0, &d); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_NO_SIGNER); + store.delegating.insert("/opt/browser/browser", U_CAMERA_CONSENT_DELEGATION_SYSTEM, "Browser Corp"); + u_camera_consent_evaluate(&c, "/opt/browser/browser", "B", 1, 0, &d); + CHECK(d.delegation_skip == U_CAMERA_CONSENT_DELEGATION_NOT_SIGNED); + CHECK(!d.delegated); +} + +/* + * Spec §7.1.1: a USER-level entry is honoured, but its first use per + * executable per service run is announced (WARN + the host's notice). + */ +TEST_CASE("camera consent: user-level delegation raises a first-use notice", "[camera_consent][notice]") +{ + const char *b = "/opt/browser/browser"; + const char *b2 = "/opt/other/other"; + rig r; + r.store.delegating.insert(b, U_CAMERA_CONSENT_DELEGATION_USER); + auto d = r.eval(b); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(d.delegation_scope == U_CAMERA_CONSENT_DELEGATION_USER); + REQUIRE(r.env.notices.size() == 1); + CHECK(r.env.notices[0] == b); + // Once per executable per run: a second start, or another pid, is silent. + r.eval(b, 100); + r.eval(b, 200); + CHECK(r.env.notices.size() == 1); + // Another user-delegated executable gets its own. + r.store.delegating.insert(b2, U_CAMERA_CONSENT_DELEGATION_USER); + r.eval(b2); + CHECK(r.env.notices.size() == 2); + SECTION("no notice when the delegation did not apply") + { + r.store.delegating.insert("/opt/third", U_CAMERA_CONSENT_DELEGATION_USER); + r.store.apps["/opt/third"] = U_CAMERA_CONSENT_STORED_DENY; + r.eval("/opt/third"); // stored Deny decides + r.eval("/opt/third", 1, U_CAMERA_CONSENT_FLAG_DECLINE_DELEGATION); // declined + CHECK(r.env.notices.size() == 2); + } + SECTION("no notice for a system-level entry") + { + r.store.delegating.insert("/opt/sys", U_CAMERA_CONSENT_DELEGATION_SYSTEM); + CHECK(r.eval("/opt/sys").why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(r.env.notices.size() == 2); + } + SECTION("the seen-set is bounded and never overflows") + { + for (int i = 0; i < 3 * U_CAMERA_CONSENT_SEEN_MAX; i++) { + std::string e = "/opt/many/" + std::to_string(i); + r.store.delegating.insert(e, U_CAMERA_CONSENT_DELEGATION_USER); + r.eval(e.c_str()); + } + CHECK(r.env.notices.size() == 2 + 3 * U_CAMERA_CONSENT_SEEN_MAX); + } } TEST_CASE("camera consent: a delegating client is refused by the OS camera switch", "[camera_consent]") @@ -398,7 +701,7 @@ TEST_CASE("camera consent: no store and no env still decides safely", "[camera_c u_camera_consent c; u_camera_consent_init(&c, nullptr, nullptr, nullptr, nullptr); u_camera_consent_decision d; - u_camera_consent_evaluate(&c, "/apps/caller", "Caller", 1, &d); + u_camera_consent_evaluate(&c, "/apps/caller", "Caller", 1, 0, &d); CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); CHECK(u_camera_consent_sharing_enabled(&c)); @@ -431,6 +734,48 @@ TEST_CASE("consent store: path comparison rules", "[camera_consent][consent_stor CHECK(!u_camera_consent_path_equal(nullptr, "/a", false)); } +TEST_CASE("consent store: admin-protected folder test", "[camera_consent][consent_store]") +{ + const char *pf = "C:\\Program Files"; + CHECK(u_camera_consent_path_is_under("C:\\Program Files\\B\\b.exe", pf, true)); + CHECK(u_camera_consent_path_is_under("c:/program files/b/b.exe", "C:\\Program Files\\", true)); + // Not a separator boundary. + CHECK(!u_camera_consent_path_is_under("C:\\Program FilesX\\b.exe", pf, true)); + CHECK(!u_camera_consent_path_is_under("C:\\Program Files (x86)\\b.exe", pf, true)); + CHECK(u_camera_consent_path_is_under("C:\\Program Files (x86)\\b.exe", "C:\\Program Files (x86)", true)); + // The folder itself is not "inside" it. + CHECK(!u_camera_consent_path_is_under("C:\\Program Files", pf, true)); + CHECK(!u_camera_consent_path_is_under("C:\\Program Files\\", pf, true)); + // ".." climbs out. + CHECK(!u_camera_consent_path_is_under("C:\\Program Files\\..\\Users\\u\\b.exe", pf, true)); + CHECK(!u_camera_consent_path_is_under("C:\\Program Files\\B\\..", pf, true)); + CHECK(u_camera_consent_path_is_under("C:\\Program Files\\B\\..b.exe", pf, true)); // a name, not ".." + // Elsewhere. + CHECK(!u_camera_consent_path_is_under("C:\\Users\\u\\AppData\\Local\\B\\b.exe", pf, true)); + CHECK(!u_camera_consent_path_is_under("D:\\Program Files\\b.exe", pf, true)); + // POSIX rules are byte-exact. + CHECK(u_camera_consent_path_is_under("/usr/bin/x", "/usr", false)); + CHECK(!u_camera_consent_path_is_under("/USR/bin/x", "/usr", false)); + CHECK(!u_camera_consent_path_is_under("/usr\\bin", "/usr", false)); + // Empty / NULL. + CHECK(!u_camera_consent_path_is_under("", pf, true)); + CHECK(!u_camera_consent_path_is_under("C:\\x", "", true)); + CHECK(!u_camera_consent_path_is_under(nullptr, pf, true)); + CHECK(!u_camera_consent_path_is_under("C:\\x", "\\\\", true)); // only separators +} + +TEST_CASE("consent store: signer name comparison", "[camera_consent][consent_store]") +{ + CHECK(u_camera_consent_signer_equal("Browser Corp", "Browser Corp")); + CHECK(u_camera_consent_signer_equal("Browser Corp", " browser CORP ")); + CHECK(!u_camera_consent_signer_equal("Browser Corp", "Browser Corp Evil")); + CHECK(!u_camera_consent_signer_equal("Browser Corp", "Browser Corp")); + CHECK(!u_camera_consent_signer_equal("\xc3\x89 Corp", "\xc3\xa9 Corp")); // non-ASCII not folded + CHECK(!u_camera_consent_signer_equal("", "")); + CHECK(!u_camera_consent_signer_equal(" ", " ")); + CHECK(!u_camera_consent_signer_equal(nullptr, "x")); +} + namespace { struct fake_entry