From 54ef03583e2ac881a0e1313f52775fc4f6ffab72 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 6 Oct 2026 00:59:20 -0700 Subject: [PATCH] fix(stereo-camera): delegation never outranks the OS camera switch or a stored Deny MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #1819 (R3 consent). As built, a registered consent- delegating client was allowed at step 4 of the consent decision, before the OS camera privacy switch (step 5) and before a stored per-app Deny (step 6), and tests_camera_consent pinned that order. Once a browser's installer registered it, it would get the 3D camera with the OS switch off and a Deny the user had stored silently ignored. Roadmap §B.3 and the pre-R3 spec say the opposite, and no rationale for the order was recorded; "the browser enforces the OS switch itself" cannot hold, because the service, not the browser, opens the camera, so the OS never sees the browser as a camera user. New order (spec §7.1 is the one statement of it): sharing off / DXR_STEREO_CAMERA=0 -> DISABLED; dev override; no identity; OS switch -> PERMISSION_INSUFFICIENT; stored Deny -> CONSENT_REFUSED; delegating -> allowed; stored Allow; Allow once; prompt. Delegation keeps meaning exactly "no runtime prompt and no stored decision needed". The decision's `delegating` flag is now reported from step 3 on whatever the verdict. `displayxr-cli camera trust ` over a stored Deny clears the Deny and says so (the user's newer explicit word, like `allow` over `deny`); an installer's machine-wide registration never touches the user's store. `camera deny` on a delegating exe notes that the Deny wins. Explicit arguments are stored the way the service sees the peer: resolved with realpath on POSIX; on Windows read from the UTF-16 command line (argv is the ANSI code page) and made absolute with GetFullPathNameW. Windows store reader (u_camera_consent_store.c), both bugs verified: - it used the ANSI registry API while the runtime compares UTF-8 paths (QueryFullProcessImageNameW -> CP_UTF8), so a non-ASCII install path could never match an installer's UTF-16 entry. Every read, write, delete and enumeration (HKLM and HKCU, Apps, Delegating, Sharing, Secret, the ConsentStore webcam keys) now uses the wide API with explicit UTF-8 conversion. - fixed 256-char name / 1024-byte data buffers turned one oversized value into ERROR_MORE_DATA and `break`, hiding every later entry. Buffers are now sized from RegQueryInfoKeyW; an entry that still does not fit, has the wrong type or does not convert is skipped, never ends the scan. The matching is the pure u_camera_consent_list_find() over an abstract enumerator plus u_camera_consent_path_equal(), both unit-tested on every OS. Tests: the delegation-first assertions are rewritten; new cases for delegating + OS switch off, + stored Deny, + sharing off / kill switch, + nothing stored (allowed, no prompt, nothing written), unregistered neighbour unchanged, path rules, and skip-not-stop enumeration. Mutation: restoring the old order fails 2 cases / 7 assertions; making the list scan stop on a skipped entry fails the enumeration case. Co-Authored-By: Claude Opus 5.5 --- docs/adr/ADR-043-stereo-camera-source.md | 16 + docs/guides/stereo-camera-consent.md | 28 +- docs/roadmap/stereo-camera-source.md | 15 +- docs/specs/extensions/XR_DXR_stereo_camera.md | 28 +- src/xrt/auxiliary/util/u_camera_consent.c | 114 ++++- src/xrt/auxiliary/util/u_camera_consent.h | 73 +++- .../auxiliary/util/u_camera_consent_store.c | 406 ++++++++++++------ src/xrt/ipc/server/ipc_server_stereo_camera.c | 12 +- src/xrt/targets/cli/CMakeLists.txt | 3 +- src/xrt/targets/cli/cli_cmd_camera.c | 55 ++- tests/tests_camera_consent.cpp | 187 +++++++- 11 files changed, 751 insertions(+), 186 deletions(-) diff --git a/docs/adr/ADR-043-stereo-camera-source.md b/docs/adr/ADR-043-stereo-camera-source.md index 8ee8c63fb4..dfa710ed70 100644 --- a/docs/adr/ADR-043-stereo-camera-source.md +++ b/docs/adr/ADR-043-stereo-camera-source.md @@ -127,3 +127,19 @@ exactly so that knowledge lives in one vendor DLL and nowhere else. development switch below the sharing-off checks — the R1 text had it as a stand-in to be deleted. +## Amendment 2 (2026-10-06) — delegation never outranks a refusal + +As built in R3 (PR #1819) a registered consent-delegating client was allowed at the fourth step, +**before** the OS camera privacy switch and before a Deny the user had stored for that executable +— so once an installer registered a browser, it would get the camera with the OS switch off and +a stored Deny silently ignored. That contradicted §B.3 of the roadmap and the pre-R3 spec, where +OS consent applied to every consumer and delegation replaced only the DisplayXR prompt. No rationale +for the order was recorded, and the plausible one ("the browser enforces the OS switch itself") +does not hold: the service, not the browser, opens the camera, so the OS never sees the browser as +a camera user. + +The order is now: sharing off / `DXR_STEREO_CAMERA=0` → (dev override) → no identity → **OS +camera switch** → **stored Deny** → delegating client → stored Allow → Allow once → prompt +(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. diff --git a/docs/guides/stereo-camera-consent.md b/docs/guides/stereo-camera-consent.md index bdacde6e06..c0d2186b54 100644 --- a/docs/guides/stereo-camera-consent.md +++ b/docs/guides/stereo-camera-consent.md @@ -36,7 +36,9 @@ XrInstanceCreateInfo ci = {XR_TYPE_INSTANCE_CREATE_INFO, &consumer, /* … XR_DX The service looks at the **OS-derived executable path** of your process (never anything you send) and walks this list; the first hit decides. The same check runs for -`xrGetStereoCameraCalibrationDXR` (calibration identifies the device). +`xrGetStereoCameraCalibrationDXR` (calibration identifies the device). The normative statement +is the table in [spec §7.1](../specs/extensions/XR_DXR_stereo_camera.md#71-authorisation-at-xrstartstereocamerastreamdxr-and-xrgetstereocameracalibrationdxr); +this is the same order in developer terms. 1. **Sharing off** — the user's tray / menu-bar toggle *"Share the 3D camera with apps"*, or `DXR_STEREO_CAMERA=0` in the service's environment → `XR_ERROR_STEREO_CAMERA_DISABLED_DXR`. @@ -44,13 +46,18 @@ send) and walks this list; the first hit decides. The same check runs for 2. `DXR_STEREO_CAMERA_DEV_ALLOW=1` in the **service's** environment → allowed. A development override; the service logs one WARN per run. Never on a user's machine. 3. Your executable path could not be verified → `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR`. -4. You are a **registered delegating client** → allowed, no prompt, nothing stored. -5. **Windows:** the OS camera privacy switch denies you (Settings → Privacy → Camera: globally, - for desktop apps, or for your app) → `XR_ERROR_PERMISSION_INSUFFICIENT`. -6. A **stored decision** for your executable (the user answered before, or an admin pre-seeded - it) → allowed, or `CONSENT_REFUSED`. -7. The user chose **Allow once** earlier for this very process → allowed. -8. The **prompt**: the service's tray (Windows) / menu-bar item (macOS) shows +4. **Windows:** the OS camera privacy switch denies you (Settings → Privacy → Camera: globally, + for desktop apps, or for your app) → `XR_ERROR_PERMISSION_INSUFFICIENT`. This applies to + delegating clients too: the service opens the camera, so the OS never sees you as its user and + only the runtime can honour the switch. +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. +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. +9. The **prompt**: the service's tray (Windows) / menu-bar item (macOS) shows > **"" wants to use the 3D camera** > *your-app.exe (pid 1234) will receive frames from the display's stereo camera. You can stop @@ -99,6 +106,11 @@ should not ask a second time per page. Register the browser's **executable path* `{"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. + +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). 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 diff --git a/docs/roadmap/stereo-camera-source.md b/docs/roadmap/stereo-camera-source.md index 34d70442a1..4089a8a1e4 100644 --- a/docs/roadmap/stereo-camera-source.md +++ b/docs/roadmap/stereo-camera-source.md @@ -85,9 +85,12 @@ process across milestones): signer) as a **consent-delegating** client of the runtime (spec §7.1), so the runtime does not prompt a second time per page. An unsigned/dev build is not delegating: the runtime prompts once for it from the tray. -- **OS camera privacy:** if Windows camera access is off globally or for desktop apps, the runtime - refuses (`PERMISSION_INSUFFICIENT`) and the device reports an error — the same outcome a normal - webcam would give, so users are not surprised by a camera that ignores the OS switch. +- **OS camera privacy:** if Windows camera access is off globally, for desktop apps or for the + browser, the runtime refuses (`PERMISSION_INSUFFICIENT`) **even though the browser is + delegating** and the device reports an error — the same outcome a normal webcam would give, so + users are not surprised by a camera that ignores the OS switch. Likewise a Deny the user stored + for the browser's executable, and the runtime's sharing switch, refuse it. Delegation only + removes the runtime's own prompt (spec §7.1 states the order). - **Runtime indicator** (tray badge naming `chrome.exe`) in addition to Chromium's; runtime kill switch hides the device entirely. - **Fingerprinting:** `device_id` is Chromium's per-origin hash of `dxr-stereo:` @@ -305,8 +308,10 @@ where: **B1 migration:** the browser's video-capture utility must chain the struct instead of enabling `XR_DXR_weave` to be classed as the browser (the weave-derived PRESENT_OWNER claim is outranked by the declaration anyway); its other processes keep the sibling rule. -- **Consent policy + store** (`u_camera_consent.{h,c}`, `u_camera_consent_store.c`): the eight- - step decision tree of spec §7.1 over two injected vtables (store, environment), the real store +- **Consent policy + store** (`u_camera_consent.{h,c}`, `u_camera_consent_store.c`): the + decision tree of spec §7.1 (since the follow-up to PR #1819: kill switches, then the OS camera + switch, then a stored Deny refuse **before** delegation allows — as built in #1819 a delegating + client was allowed ahead of both) over two injected vtables (store, environment), the real store (Windows registry under `HKCU\Software\DisplayXR\CameraConsent`, POSIX `camera_consent.json` mode 0600 in the user config dir, plus the installer-written delegating lists), the Windows `ConsentStore\webcam` read, and the keyed `persistentId` diff --git a/docs/specs/extensions/XR_DXR_stereo_camera.md b/docs/specs/extensions/XR_DXR_stereo_camera.md index 6e6e8b0f43..fd72164a8b 100644 --- a/docs/specs/extensions/XR_DXR_stereo_camera.md +++ b/docs/specs/extensions/XR_DXR_stereo_camera.md @@ -356,11 +356,27 @@ at a time (so two apps never race two prompts): | 1 | Sharing off: `DXR_STEREO_CAMERA=0` in the service environment, or the user's **"Share the 3D camera with apps"** toggle (tray / menu bar, persisted in the store) | `XR_ERROR_STEREO_CAMERA_DISABLED_DXR` (and zero cameras enumerated) | | 2 | `DXR_STEREO_CAMERA_DEV_ALLOW=1` in the service environment — development override, one WARN per service run; **never** on a user's machine | allowed | | 3 | The peer executable could not be verified (`""`) | `XR_ERROR_STEREO_CAMERA_CONSENT_REFUSED_DXR` | -| 4 | 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) | -| 5 | 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) | `XR_ERROR_PERMISSION_INSUFFICIENT` | -| 6 | The stored per-app decision (`HKCU\Software\DisplayXR\CameraConsent\Apps\`, `camera_consent.json` `apps`) | Allow → allowed; Deny → `CONSENT_REFUSED` | -| 7 | "Allow once" already granted to this same process (executable + pid) | allowed | -| 8 | 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 | +| 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) | +| 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 | + +**Delegation means exactly "no runtime prompt and no stored per-app decision needed" — nothing +more.** It never outranks a refusal: the kill switches (row 1), the OS switch (row 4) and a stored +Deny (row 5) apply to a delegating client exactly as to any other app. The OS switch matters most +for it: the service, not the client, opens the camera, so the OS never sees the browser as a +camera user and nothing else would enforce the user's switch. Each refusal keeps its own result +(`DISABLED` / `PERMISSION_INSUFFICIENT` / `CONSENT_REFUSED`), so a browser can map them (§8). + +`displayxr-cli camera trust ` over a stored Deny for the same executable **clears the Deny** +and says so — `trust` is the user's newer, explicit word on that executable, as `allow` replacing +a Deny is. An installer's machine-wide registration never touches the user's store: there the +Deny keeps winning. `camera deny` on a delegating executable notes that the Deny wins. + +This table is the one statement of the order; `u_camera_consent.h`, the developer guide and the +roadmap refer to it. 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`. @@ -435,7 +451,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 step 5); RAW asked by a browser / delegating client; a client class that may not use cameras (or `xrCreateSession` from a `CAMERA_CONSUMER`) | +| `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_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` | diff --git a/src/xrt/auxiliary/util/u_camera_consent.c b/src/xrt/auxiliary/util/u_camera_consent.c index 8a246d0cea..e16b87ddc2 100644 --- a/src/xrt/auxiliary/util/u_camera_consent.c +++ b/src/xrt/auxiliary/util/u_camera_consent.c @@ -164,35 +164,43 @@ u_camera_consent_evaluate(struct u_camera_consent *c, decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_NO_IDENTITY); return; } - // 4. a registered delegating client prompts per origin itself. - if (st != NULL && st->is_delegating != NULL && st->is_delegating(sc, exe)) { - out->delegating = true; - decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING); - return; - } - // 5. the OS switch: a camera that ignores it would surprise the user. + // 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); + // 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. if (c->env != NULL && c->env->os_camera_allowed != NULL && !c->env->os_camera_allowed(c->env_ctx, exe)) { decide(out, U_CAMERA_CONSENT_OS_DENIED, U_CAMERA_CONSENT_WHY_OS_DENIED); return; } - // 6. the stored decision. + // 5. a stored Deny is the user's explicit "no" for this executable and + // beats any registration (an installer's included). enum u_camera_consent_stored stored = U_CAMERA_CONSENT_STORED_NONE; - if (st != NULL && st->get != NULL && st->get(sc, exe, &stored)) { - if (stored == U_CAMERA_CONSENT_STORED_ALLOW) { - decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_STORED_ALLOW); - return; - } - if (stored == U_CAMERA_CONSENT_STORED_DENY) { - decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_STORED_DENY); - return; - } + if (st == NULL || st->get == NULL || !st->get(sc, exe, &stored)) { + stored = U_CAMERA_CONSENT_STORED_NONE; + } + if (stored == U_CAMERA_CONSENT_STORED_DENY) { + decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_STORED_DENY); + return; + } + // 6. a registered delegating client prompts per origin itself: no runtime + // prompt and no stored decision needed — and nothing more than that. + if (out->delegating) { + decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_DELEGATING); + return; + } + // 7. a stored Allow. + if (stored == U_CAMERA_CONSENT_STORED_ALLOW) { + decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_STORED_ALLOW); + return; } - // 7. "Allow once" already given to this process. + // 8. "Allow once" already given to this process. if (once_find(c, exe, pid)) { decide(out, U_CAMERA_CONSENT_ALLOWED, U_CAMERA_CONSENT_WHY_ALLOW_ONCE); return; } - // 8. ask. + // 9. ask. if (!c->prompt_enabled || c->env == NULL || c->env->prompt == NULL) { decide(out, U_CAMERA_CONSENT_REFUSED, U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); return; @@ -225,6 +233,74 @@ u_camera_consent_evaluate(struct u_camera_consent *c, } +/* + * + * Pure store helpers. + * + */ + +static char +path_norm_ch(char c, bool windows_rules) +{ + if (windows_rules) { + if (c == '/') { + return '\\'; + } + if (c >= 'A' && c <= 'Z') { + return (char)(c - 'A' + 'a'); + } + } + return c; +} + +bool +u_camera_consent_path_equal(const char *a, const char *b, bool windows_rules) +{ + if (a == NULL || b == NULL || a[0] == '\0' || b[0] == '\0') { + return false; + } + while (*a != '\0' && *b != '\0') { + if (path_norm_ch(*a, windows_rules) != path_norm_ch(*b, windows_rules)) { + return false; + } + a++; + b++; + } + return *a == '\0' && *b == '\0'; +} + +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) +{ + if (fn == NULL || !exe_known(exe)) { + return false; + } + char name[1024]; + char path[4096]; + for (uint32_t i = 0; i < U_CAMERA_CONSENT_LIST_MAX; i++) { + name[0] = '\0'; + path[0] = '\0'; + enum u_camera_consent_enum_result r = fn(ctx, i, name, sizeof(name), path, sizeof(path)); + if (r == U_CAMERA_CONSENT_ENUM_END) { + return false; + } + if (r != U_CAMERA_CONSENT_ENUM_ENTRY) { + continue; // one bad entry never hides the ones after it + } + name[sizeof(name) - 1] = '\0'; + path[sizeof(path) - 1] = '\0'; + if (u_camera_consent_path_equal(path, exe, windows_rules)) { + if (match_name != NULL && match_cap > 0) { + snprintf(match_name, match_cap, "%s", name); + } + return true; + } + } + return false; +} + + /* * * Keyed persistentId. diff --git a/src/xrt/auxiliary/util/u_camera_consent.h b/src/xrt/auxiliary/util/u_camera_consent.h index 4a75c4258d..28a094e37e 100644 --- a/src/xrt/auxiliary/util/u_camera_consent.h +++ b/src/xrt/auxiliary/util/u_camera_consent.h @@ -15,21 +15,29 @@ * - @ref u_camera_consent_env_ops — what only the service process knows: the * OS camera privacy switch for a given executable and the tray prompt. * - * Decision order at every start / calibration read (first hit wins): + * Decision order at every start / calibration read (first hit wins). This is + * the one statement of it in code; spec §7.1 is the normative one: * 1. sharing off (DXR_STEREO_CAMERA=0 kill switch, or the user's "Share the * 3D camera with apps" toggle) -> DISABLED * 2. DXR_STEREO_CAMERA_DEV_ALLOW=1 (dev override, logged once) -> ALLOWED * 3. no verifiable peer executable -> REFUSED - * 4. registered consent-delegating client (the browser) -> ALLOWED + * 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) - * 5. OS camera privacy denies this executable -> OS_DENIED - * 6. stored per-app decision (Allow / Deny) -> as stored - * 7. "Allow once" granted earlier to this same process -> ALLOWED - * 8. tray prompt (" wants to use the 3D camera — + * 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 — * Allow / Allow once / Deny"): Allow + Deny are written to the store, * Allow once is remembered for the process; no prompt available * (headless, DXR_STEREO_CAMERA_PROMPT=0) or unanswered in time -> REFUSED * + * Delegation (step 6) means exactly "no runtime prompt and no stored per-app + * decision needed". It never outranks a refusal: the kill switches, the OS + * switch and a stored Deny apply to a delegating client like to any other. + * 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. + * * 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. @@ -106,7 +114,9 @@ struct u_camera_consent_decision enum u_camera_consent_why why; //! The executable is a registered consent-delegating client: it shows its //! own prompt + indicator, follows its own visibility rule, and is never - //! given RAW frames (it exposes cameras to third-party content). + //! 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. bool delegating; }; @@ -218,6 +228,55 @@ u_camera_consent_persistent_id_keyed( const uint8_t *key, size_t key_len, const char *device_identity, const char *consumer_exe, char out[64]); +/* + * + * Pure helpers the platform store is built on (unit-tested on every OS). + * + */ + +/*! + * Do two executable paths name the same file the way the store compares them? + * @p windows_rules: ASCII case-insensitive, either slash a separator (Windows); else + * byte-exact (POSIX). Non-ASCII bytes always compare exactly — a UTF-8 path + * that differs only in non-ASCII letter case does NOT match (fails closed for + * a delegating entry; the Windows Apps lookup uses the registry's own + * case-insensitive value-name match instead). Empty or NULL never matches. + */ +bool +u_camera_consent_path_equal(const char *a, const char *b, bool windows_rules); + +//! One step of a list enumerator (see @ref u_camera_consent_list_find). +enum u_camera_consent_enum_result +{ + U_CAMERA_CONSENT_ENUM_ENTRY = 0, //!< name + path were written (NUL-terminated UTF-8) + U_CAMERA_CONSENT_ENUM_SKIP = 1, //!< this index is unusable (oversized, wrong type, not + //!< convertible): ignore it and go on with the next + U_CAMERA_CONSENT_ENUM_END = 2, //!< no entry at this index or after it (or the list + //!< cannot be read any further) +}; + +/*! + * Read entry @p index of a delegating list: @p name is the entry's id (the + * registry value name; "" where the list has none), @p path its executable. + */ +typedef enum u_camera_consent_enum_result (*u_camera_consent_enum_fn)( + void *ctx, uint32_t index, char *name, size_t name_cap, char *path, size_t path_cap); + +//! Upper bound on the indices @ref u_camera_consent_list_find visits. +#define U_CAMERA_CONSENT_LIST_MAX 4096 + +/*! + * Walk a delegating list for @p exe. An unusable entry (SKIP) is passed over, + * never ends the scan — one oversized value an installer wrote must not hide + * every entry after it. On a match, the entry's name is copied to + * @p match_name (if non-NULL). Stops at END or after U_CAMERA_CONSENT_LIST_MAX + * indices. + */ +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); + + /* * * The real store (u_camera_consent_store.c). diff --git a/src/xrt/auxiliary/util/u_camera_consent_store.c b/src/xrt/auxiliary/util/u_camera_consent_store.c index 44ce477d3a..791e667c3f 100644 --- a/src/xrt/auxiliary/util/u_camera_consent_store.c +++ b/src/xrt/auxiliary/util/u_camera_consent_store.c @@ -25,8 +25,11 @@ * macOS /Library/Application Support/DisplayXR/camera-delegating.json * {"delegating": ["", ...]} * - * Paths compare the way the OS does: Windows case-insensitively with either - * separator, POSIX byte-exact. + * Paths compare with u_camera_consent_path_equal(): Windows ASCII-case- + * insensitively with either separator, POSIX byte-exact. On Windows every + * registry access uses the wide API (paths are UTF-8 in the runtime, UTF-16 in + * the registry), and an oversized or malformed value is skipped, never ends a + * list scan (u_camera_consent_list_find()). * * @ingroup aux_util */ @@ -63,36 +66,6 @@ * */ -static char -norm_ch(char c) -{ -#ifdef XRT_OS_WINDOWS - if (c == '/') { - return '\\'; - } - if (c >= 'A' && c <= 'Z') { - return (char)(c - 'A' + 'a'); - } -#endif - return c; -} - -static bool -path_equal(const char *a, const char *b) -{ - if (a == NULL || b == NULL || a[0] == '\0' || b[0] == '\0') { - return false; - } - while (*a != '\0' && *b != '\0') { - if (norm_ch(*a) != norm_ch(*b)) { - return false; - } - a++; - b++; - } - return *a == '\0' && *b == '\0'; -} - static void fill_random(uint8_t *out, size_t n) { @@ -132,78 +105,235 @@ fill_random(uint8_t *out, size_t n) /* * - * Windows: registry. + * Windows: registry. Every access goes through the WIDE API: the executable + * paths the service compares are UTF-8 (QueryFullProcessImageNameW -> + * CP_UTF8), and an installer writes UTF-16. The ANSI API would convert through + * the process code page and a non-ASCII path could never match. * */ -#define CONSENT_KEY "Software\\DisplayXR\\CameraConsent" -#define CAM_CONSENT_STORE_WEBCAM "Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\webcam" +#define CONSENT_KEY L"Software\\DisplayXR\\CameraConsent" +#define CAM_CONSENT_STORE_WEBCAM \ + L"Software\\Microsoft\\Windows\\CurrentVersion\\CapabilityAccessManager\\ConsentStore\\webcam" +//! UTF-8 -> freshly allocated UTF-16 (free()); NULL on failure. +static wchar_t * +u8_to_w(const char *s) +{ + if (s == NULL) { + return NULL; + } + int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, NULL, 0); + if (n <= 0) { + return NULL; + } + wchar_t *w = (wchar_t *)malloc((size_t)n * sizeof(wchar_t)); + if (w != NULL && MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, w, n) != n) { + free(w); + w = NULL; + } + return w; +} + +//! UTF-16 (@p wlen chars, need not be terminated) -> UTF-8 in @p out; false if it does not fit. static bool -reg_read_dword(HKEY root, const char *sub, const char *name, DWORD *out) +w_to_u8(const wchar_t *w, int wlen, char *out, size_t cap) +{ + if (cap == 0) { + return false; + } + out[0] = '\0'; + if (wlen == 0) { + return true; + } + int n = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, w, wlen, out, (int)cap - 1, NULL, NULL); + if (n <= 0) { + out[0] = '\0'; + return false; + } + out[n] = '\0'; + return true; +} + +static bool +reg_read_dword(HKEY root, const wchar_t *sub, const wchar_t *name, DWORD *out) { HKEY k; - if (RegOpenKeyExA(root, sub, 0, KEY_READ, &k) != ERROR_SUCCESS) { + if (RegOpenKeyExW(root, sub, 0, KEY_READ, &k) != ERROR_SUCCESS) { return false; } DWORD type = 0, data = 0, size = sizeof(data); - LSTATUS rc = RegQueryValueExA(k, name, NULL, &type, (LPBYTE)&data, &size); + LSTATUS rc = RegQueryValueExW(k, name, NULL, &type, (LPBYTE)&data, &size); RegCloseKey(k); - if (rc != ERROR_SUCCESS || type != REG_DWORD) { + if (rc != ERROR_SUCCESS || type != REG_DWORD || size != sizeof(data)) { return false; } *out = data; return true; } +//! A short REG_SZ value (ConsentStore "Value"); false if absent, not a string, or too long. static bool -reg_read_string(HKEY root, const char *sub, const char *name, char *out, size_t cap) +reg_read_string_w(HKEY root, const wchar_t *sub, const wchar_t *name, wchar_t *out, DWORD cap_chars) { HKEY k; - if (RegOpenKeyExA(root, sub, 0, KEY_READ, &k) != ERROR_SUCCESS) { + if (RegOpenKeyExW(root, sub, 0, KEY_READ, &k) != ERROR_SUCCESS) { return false; } - DWORD type = 0, size = (DWORD)cap; - LSTATUS rc = RegQueryValueExA(k, name, NULL, &type, (LPBYTE)out, &size); + DWORD type = 0, size = (cap_chars - 1) * (DWORD)sizeof(wchar_t); + LSTATUS rc = RegQueryValueExW(k, name, NULL, &type, (LPBYTE)out, &size); RegCloseKey(k); if (rc != ERROR_SUCCESS || (type != REG_SZ && type != REG_EXPAND_SZ)) { return false; } - out[cap - 1] = '\0'; + out[size / sizeof(wchar_t)] = L'\0'; return true; } static bool -reg_write_dword(HKEY root, const char *sub, const char *name, DWORD v) +reg_write_dword(HKEY root, const wchar_t *sub, const wchar_t *name, DWORD v) { HKEY k; - if (RegCreateKeyExA(root, sub, 0, NULL, 0, KEY_WRITE, NULL, &k, NULL) != ERROR_SUCCESS) { + if (RegCreateKeyExW(root, sub, 0, NULL, 0, KEY_WRITE, NULL, &k, NULL) != ERROR_SUCCESS) { return false; } - LSTATUS rc = RegSetValueExA(k, name, 0, REG_DWORD, (const BYTE *)&v, sizeof(v)); + LSTATUS rc = RegSetValueExW(k, name, 0, REG_DWORD, (const BYTE *)&v, sizeof(v)); RegCloseKey(k); return rc == ERROR_SUCCESS; } static bool -reg_delete_value(HKEY root, const char *sub, const char *name) +reg_delete_value(HKEY root, const wchar_t *sub, const wchar_t *name) { HKEY k; - if (RegOpenKeyExA(root, sub, 0, KEY_WRITE, &k) != ERROR_SUCCESS) { + if (RegOpenKeyExW(root, sub, 0, KEY_WRITE, &k) != ERROR_SUCCESS) { return true; // nothing to delete } - LSTATUS rc = RegDeleteValueA(k, name); + LSTATUS rc = RegDeleteValueW(k, name); RegCloseKey(k); return rc == ERROR_SUCCESS || rc == ERROR_FILE_NOT_FOUND; } +/*! + * An open key's values, enumerated with buffers sized from RegQueryInfoKeyW so + * a legitimately long value fits; whatever still does not (the key grew under + * us) is reported as SKIP for that index, never as the end of the list. + */ +struct reg_enum +{ + HKEY key; + wchar_t *name; //!< name_cap chars + DWORD name_cap; + BYTE *data; //!< data_cap bytes (even), +4 so any REG_SZ can be terminated + DWORD data_cap; +}; + +static bool +reg_enum_open(struct reg_enum *e, HKEY root, const wchar_t *sub) +{ + memset(e, 0, sizeof(*e)); + if (RegOpenKeyExW(root, sub, 0, KEY_READ, &e->key) != ERROR_SUCCESS) { + return false; + } + DWORD max_name = 0, max_data = 0; + if (RegQueryInfoKeyW(e->key, NULL, NULL, NULL, NULL, NULL, NULL, NULL, &max_name, &max_data, NULL, NULL) != + ERROR_SUCCESS) { + max_name = 1024; + max_data = 4096; + } + e->name_cap = max_name + 1; + e->data_cap = (max_data + 1u) & ~1u; + e->name = (wchar_t *)malloc((size_t)e->name_cap * sizeof(wchar_t)); + e->data = (BYTE *)malloc((size_t)e->data_cap + 4); + if (e->name == NULL || e->data == NULL) { + free(e->name); + free(e->data); + RegCloseKey(e->key); + memset(e, 0, sizeof(*e)); + return false; + } + return true; +} + +static void +reg_enum_close(struct reg_enum *e) +{ + if (e->key != NULL) { + RegCloseKey(e->key); + } + free(e->name); + free(e->data); + memset(e, 0, sizeof(*e)); +} + +/*! + * Value @p i: on ENTRY, e->name is terminated, *type and *data_len are set and + * e->data has zero bytes after data_len (a REG_SZ is always terminated, even an + * odd-length or unterminated one an installer wrote). + */ +static enum u_camera_consent_enum_result +reg_enum_at(struct reg_enum *e, DWORD i, DWORD *type, DWORD *data_len) +{ + DWORD name_len = e->name_cap, dlen = e->data_cap; + LSTATUS rc = RegEnumValueW(e->key, i, e->name, &name_len, NULL, type, e->data, &dlen); + if (rc == ERROR_MORE_DATA) { + return U_CAMERA_CONSENT_ENUM_SKIP; // oversized: ignore this one, not the rest + } + if (rc != ERROR_SUCCESS) { + return U_CAMERA_CONSENT_ENUM_END; // ERROR_NO_MORE_ITEMS, or the key is gone + } + e->name[name_len < e->name_cap ? name_len : e->name_cap - 1] = L'\0'; + memset(e->data + dlen, 0, 4); + *data_len = dlen; + return U_CAMERA_CONSENT_ENUM_ENTRY; +} + +//! 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) +{ + struct reg_enum *e = (struct reg_enum *)ctx; + DWORD type = 0, dlen = 0; + enum u_camera_consent_enum_result r = reg_enum_at(e, (DWORD)index, &type, &dlen); + if (r != U_CAMERA_CONSENT_ENUM_ENTRY) { + return r; + } + if (type != REG_SZ && type != REG_EXPAND_SZ) { + return U_CAMERA_CONSENT_ENUM_SKIP; + } + 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 + } + return U_CAMERA_CONSENT_ENUM_ENTRY; +} + +//! Walk a Delegating key: each value's DATA is an executable path. +static bool +win_delegating_has(HKEY root, const char *exe, char *match_name, size_t match_cap) +{ + struct reg_enum e; + if (!reg_enum_open(&e, root, CONSENT_KEY L"\\Delegating")) { + return false; + } + bool found = u_camera_consent_list_find(reg_delegating_enum_fn, &e, exe, true, match_name, match_cap); + reg_enum_close(&e); + return found; +} + static bool win_get(void *ctx, const char *exe, enum u_camera_consent_stored *out) { (void)ctx; *out = U_CAMERA_CONSENT_STORED_NONE; + wchar_t *wexe = u8_to_w(exe); + if (wexe == NULL) { + return false; + } DWORD v = 0; - if (!reg_read_dword(HKEY_CURRENT_USER, CONSENT_KEY "\\Apps", exe, &v)) { + bool ok = reg_read_dword(HKEY_CURRENT_USER, CONSENT_KEY L"\\Apps", wexe, &v); + free(wexe); + if (!ok) { return false; } *out = v == 1 ? U_CAMERA_CONSENT_STORED_ALLOW : v == 2 ? U_CAMERA_CONSENT_STORED_DENY : U_CAMERA_CONSENT_STORED_NONE; @@ -214,43 +344,19 @@ static bool win_set(void *ctx, const char *exe, enum u_camera_consent_stored value) { (void)ctx; - if (value == U_CAMERA_CONSENT_STORED_NONE) { - return reg_delete_value(HKEY_CURRENT_USER, CONSENT_KEY "\\Apps", exe); - } - return reg_write_dword(HKEY_CURRENT_USER, CONSENT_KEY "\\Apps", exe, - value == U_CAMERA_CONSENT_STORED_ALLOW ? 1u : 2u); -} - -//! Walk the values of a Delegating key: each value's DATA is an executable path. -static bool -win_delegating_has(HKEY root, const char *exe, char *match_name, size_t match_cap) -{ - HKEY k; - if (RegOpenKeyExA(root, CONSENT_KEY "\\Delegating", 0, KEY_READ, &k) != ERROR_SUCCESS) { + wchar_t *wexe = u8_to_w(exe); + if (wexe == NULL) { return false; } - bool found = false; - for (DWORD i = 0; !found; i++) { - char name[256]; - char data[1024]; - DWORD name_len = sizeof(name), type = 0, data_len = sizeof(data); - LSTATUS rc = RegEnumValueA(k, i, name, &name_len, NULL, &type, (LPBYTE)data, &data_len); - if (rc != ERROR_SUCCESS) { - break; - } - if (type != REG_SZ && type != REG_EXPAND_SZ) { - continue; - } - data[sizeof(data) - 1] = '\0'; - if (path_equal(data, exe)) { - found = true; - if (match_name != NULL) { - snprintf(match_name, match_cap, "%s", name); - } - } + bool ok; + if (value == U_CAMERA_CONSENT_STORED_NONE) { + ok = reg_delete_value(HKEY_CURRENT_USER, CONSENT_KEY L"\\Apps", wexe); + } else { + ok = reg_write_dword(HKEY_CURRENT_USER, CONSENT_KEY L"\\Apps", wexe, + value == U_CAMERA_CONSENT_STORED_ALLOW ? 1u : 2u); } - RegCloseKey(k); - return found; + free(wexe); + return ok; } static bool @@ -265,7 +371,7 @@ win_sharing_enabled(void *ctx) { (void)ctx; DWORD v = 1; - if (reg_read_dword(HKEY_CURRENT_USER, CONSENT_KEY, "Sharing", &v)) { + if (reg_read_dword(HKEY_CURRENT_USER, CONSENT_KEY, L"Sharing", &v)) { return v != 0; } return true; @@ -275,7 +381,7 @@ static bool win_set_sharing_enabled(void *ctx, bool enabled) { (void)ctx; - return reg_write_dword(HKEY_CURRENT_USER, CONSENT_KEY, "Sharing", enabled ? 1u : 0u); + return reg_write_dword(HKEY_CURRENT_USER, CONSENT_KEY, L"Sharing", enabled ? 1u : 0u); } static bool @@ -283,18 +389,18 @@ win_get_secret(void *ctx, uint8_t out[U_CAMERA_CONSENT_SECRET_SIZE]) { (void)ctx; HKEY k; - if (RegCreateKeyExA(HKEY_CURRENT_USER, CONSENT_KEY, 0, NULL, 0, KEY_READ | KEY_WRITE, NULL, &k, NULL) != + if (RegCreateKeyExW(HKEY_CURRENT_USER, CONSENT_KEY, 0, NULL, 0, KEY_READ | KEY_WRITE, NULL, &k, NULL) != ERROR_SUCCESS) { return false; } DWORD type = 0, size = U_CAMERA_CONSENT_SECRET_SIZE; - LSTATUS rc = RegQueryValueExA(k, "Secret", NULL, &type, out, &size); + LSTATUS rc = RegQueryValueExW(k, L"Secret", NULL, &type, out, &size); if (rc == ERROR_SUCCESS && type == REG_BINARY && size == U_CAMERA_CONSENT_SECRET_SIZE) { RegCloseKey(k); return true; } fill_random(out, U_CAMERA_CONSENT_SECRET_SIZE); - rc = RegSetValueExA(k, "Secret", 0, REG_BINARY, out, U_CAMERA_CONSENT_SECRET_SIZE); + rc = RegSetValueExW(k, L"Secret", 0, REG_BINARY, out, U_CAMERA_CONSENT_SECRET_SIZE); RegCloseKey(k); return rc == ERROR_SUCCESS; } @@ -317,70 +423,87 @@ u_camera_consent_store_default(void) bool u_camera_consent_store_set_delegating(const char *exe, bool delegating) { - char name[256]; + char name[1024]; bool present = win_delegating_has(HKEY_CURRENT_USER, exe, name, sizeof(name)); if (!delegating) { - return !present || reg_delete_value(HKEY_CURRENT_USER, CONSENT_KEY "\\Delegating", name); + 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; } 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 char *base = exe; - for (const char *p = exe; *p != '\0'; p++) { - if (*p == '\\' || *p == '/') { + const wchar_t *base = wexe; + for (const wchar_t *p = wexe; *p != L'\0'; p++) { + if (*p == L'\\' || *p == L'/') { base = p + 1; } } HKEY k; - if (RegCreateKeyExA(HKEY_CURRENT_USER, CONSENT_KEY "\\Delegating", 0, NULL, 0, KEY_WRITE, NULL, &k, NULL) != + bool ok = false; + if (RegCreateKeyExW(HKEY_CURRENT_USER, CONSENT_KEY L"\\Delegating", 0, NULL, 0, KEY_WRITE, NULL, &k, NULL) == ERROR_SUCCESS) { - return false; + DWORD bytes = (DWORD)((wcslen(wexe) + 1) * sizeof(wchar_t)); + ok = RegSetValueExW(k, base, 0, REG_SZ, (const BYTE *)wexe, bytes) == ERROR_SUCCESS; + RegCloseKey(k); } - LSTATUS rc = RegSetValueExA(k, base, 0, REG_SZ, (const BYTE *)exe, (DWORD)strlen(exe) + 1); - RegCloseKey(k); - return rc == ERROR_SUCCESS; + free(wexe); + return ok; } static void win_list_delegating(HKEY root, const char *origin, void (*cb)(void *, char, const char *, const char *), void *ctx) { - HKEY k; - if (RegOpenKeyExA(root, CONSENT_KEY "\\Delegating", 0, KEY_READ, &k) != ERROR_SUCCESS) { + struct reg_enum e; + if (!reg_enum_open(&e, root, CONSENT_KEY L"\\Delegating")) { return; } - for (DWORD i = 0;; i++) { - char name[256]; - char data[1024]; - DWORD name_len = sizeof(name), type = 0, data_len = sizeof(data); - if (RegEnumValueA(k, i, name, &name_len, NULL, &type, (LPBYTE)data, &data_len) != ERROR_SUCCESS) { + char name[1024]; + char path[4096]; + for (uint32_t i = 0; i < U_CAMERA_CONSENT_LIST_MAX; i++) { + enum u_camera_consent_enum_result r = + reg_delegating_enum_fn(&e, i, name, sizeof(name), path, sizeof(path)); + if (r == U_CAMERA_CONSENT_ENUM_END) { break; } - if (type == REG_SZ || type == REG_EXPAND_SZ) { - data[sizeof(data) - 1] = '\0'; - cb(ctx, 'd', data, origin); + if (r == U_CAMERA_CONSENT_ENUM_ENTRY) { + cb(ctx, 'd', path, origin); } } - RegCloseKey(k); + reg_enum_close(&e); } void u_camera_consent_store_list(void (*cb)(void *ctx, char kind, const char *exe, const char *value), void *ctx) { cb(ctx, 's', "", win_sharing_enabled(NULL) ? "on" : "off"); - HKEY k; - if (RegOpenKeyExA(HKEY_CURRENT_USER, CONSENT_KEY "\\Apps", 0, KEY_READ, &k) == ERROR_SUCCESS) { - for (DWORD i = 0;; i++) { - char name[1024]; - DWORD name_len = sizeof(name), type = 0, v = 0, v_len = sizeof(v); - if (RegEnumValueA(k, i, name, &name_len, NULL, &type, (LPBYTE)&v, &v_len) != ERROR_SUCCESS) { + struct reg_enum e; + if (reg_enum_open(&e, HKEY_CURRENT_USER, CONSENT_KEY L"\\Apps")) { + char exe[4096]; + for (DWORD i = 0; i < U_CAMERA_CONSENT_LIST_MAX; i++) { + DWORD type = 0, dlen = 0; + enum u_camera_consent_enum_result r = reg_enum_at(&e, i, &type, &dlen); + if (r == U_CAMERA_CONSENT_ENUM_END) { break; } - if (type == REG_DWORD) { - cb(ctx, 'a', name, v == 1 ? "allow" : v == 2 ? "deny" : "?"); + if (r != U_CAMERA_CONSENT_ENUM_ENTRY || type != REG_DWORD || dlen != sizeof(DWORD) || + !w_to_u8(e.name, (int)wcslen(e.name), exe, sizeof(exe))) { + continue; } + DWORD v; + memcpy(&v, e.data, sizeof(v)); + cb(ctx, 'a', exe, v == 1 ? "allow" : v == 2 ? "deny" : "?"); } - RegCloseKey(k); + reg_enum_close(&e); } win_list_delegating(HKEY_LOCAL_MACHINE, "system", cb, ctx); win_list_delegating(HKEY_CURRENT_USER, "user", cb, ctx); @@ -389,16 +512,17 @@ u_camera_consent_store_list(void (*cb)(void *ctx, char kind, const char *exe, co bool u_camera_consent_store_path(char *out, size_t cap) { - snprintf(out, cap, "HKCU\\%s", CONSENT_KEY); + snprintf(out, cap, "HKCU\\Software\\DisplayXR\\CameraConsent"); return true; } //! "Deny" in a ConsentStore Value means off; anything else (Allow, absent) is on. static bool -consent_store_value_denies(HKEY root, const char *sub) +consent_store_value_denies(HKEY root, const wchar_t *sub) { - char v[32]; - return reg_read_string(root, sub, "Value", v, sizeof(v)) && _stricmp(v, "Deny") == 0; + wchar_t v[32]; + return reg_read_string_w(root, sub, L"Value", v, (DWORD)(sizeof(v) / sizeof(v[0]))) && + _wcsicmp(v, L"Deny") == 0; } bool @@ -407,19 +531,31 @@ u_camera_consent_os_camera_allowed(const char *exe) // Global (policy, then user), then the desktop-app class, then this app. if (consent_store_value_denies(HKEY_LOCAL_MACHINE, CAM_CONSENT_STORE_WEBCAM) || consent_store_value_denies(HKEY_CURRENT_USER, CAM_CONSENT_STORE_WEBCAM) || - consent_store_value_denies(HKEY_CURRENT_USER, CAM_CONSENT_STORE_WEBCAM "\\NonPackaged")) { + consent_store_value_denies(HKEY_CURRENT_USER, CAM_CONSENT_STORE_WEBCAM L"\\NonPackaged")) { return false; } if (exe == NULL || exe[0] == '\0') { return true; } - char sub[1600]; - int n = snprintf(sub, sizeof(sub), "%s\\NonPackaged\\", CAM_CONSENT_STORE_WEBCAM); - for (const char *p = exe; *p != '\0' && n < (int)sizeof(sub) - 1; p++) { - sub[n++] = (*p == '\\' || *p == '/') ? '#' : *p; + wchar_t *wexe = u8_to_w(exe); + if (wexe == NULL) { + return true; // not a path the OS could have stored an entry under + } + static const wchar_t prefix[] = CAM_CONSENT_STORE_WEBCAM L"\\NonPackaged\\"; + size_t plen = wcslen(prefix), elen = wcslen(wexe); + wchar_t *sub = (wchar_t *)malloc((plen + elen + 1) * sizeof(wchar_t)); + bool denied = false; + if (sub != NULL) { + memcpy(sub, prefix, plen * sizeof(wchar_t)); + for (size_t i = 0; i < elen; i++) { + sub[plen + i] = (wexe[i] == L'\\' || wexe[i] == L'/') ? L'#' : wexe[i]; + } + sub[plen + elen] = L'\0'; + denied = consent_store_value_denies(HKEY_CURRENT_USER, sub); + free(sub); } - sub[n] = '\0'; - return !consent_store_value_denies(HKEY_CURRENT_USER, sub); + free(wexe); + return !denied; } #else // POSIX @@ -431,6 +567,14 @@ u_camera_consent_os_camera_allowed(const char *exe) */ #define CONSENT_FILE "camera_consent.json" + +//! POSIX paths compare byte-exact. +static bool +path_equal(const char *a, const char *b) +{ + return u_camera_consent_path_equal(a, b, false); +} + #ifdef XRT_OS_MACOS #define SYSTEM_DELEGATING_FILE "/Library/Application Support/DisplayXR/camera-delegating.json" #else diff --git a/src/xrt/ipc/server/ipc_server_stereo_camera.c b/src/xrt/ipc/server/ipc_server_stereo_camera.c index f3504cea91..330c98f55d 100644 --- a/src/xrt/ipc/server/ipc_server_stereo_camera.c +++ b/src/xrt/ipc/server/ipc_server_stereo_camera.c @@ -14,12 +14,14 @@ * Privacy (spec §7) — R3, as built. The plug-in never sees any of it: * - consent_evaluate(): every stream start and every calibration read runs * the u_camera_consent policy (aux util, unit-tested with fakes) against - * the OS-derived peer executable: sharing off (DXR_STEREO_CAMERA=0 or the + * the OS-derived peer executable, in the order of spec §7.1 (stated once + * there and in u_camera_consent.h): sharing off (DXR_STEREO_CAMERA=0 or the * user's tray toggle) -> DISABLED; DXR_STEREO_CAMERA_DEV_ALLOW=1 -> allowed - * (one WARN); registered consent-DELEGATING client (the browser, by - * installer / `displayxr-cli camera trust`) -> allowed, its own prompt is - * the consent; OS camera privacy switch -> PERMISSION_INSUFFICIENT; the - * stored per-app Allow/Deny; else the tray prompt (" wants to use the + * (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; + * 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 * calling client thread (manager lock released, one prompt at a time) for diff --git a/src/xrt/targets/cli/CMakeLists.txt b/src/xrt/targets/cli/CMakeLists.txt index 2e5d64859a..844ff2dc0e 100644 --- a/src/xrt/targets/cli/CMakeLists.txt +++ b/src/xrt/targets/cli/CMakeLists.txt @@ -96,7 +96,8 @@ endif() # (compiled static into cli_cmd_camera.c). target_include_directories(cli PRIVATE ${PROJECT_SOURCE_DIR}/src/external/stb) if(WIN32) - target_link_libraries(cli PRIVATE d3d11 dxgi) + # shell32: CommandLineToArgvW (camera allow|deny|trust reads the path as UTF-16) + target_link_libraries(cli PRIVATE d3d11 dxgi shell32) endif() install(TARGETS cli RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) diff --git a/src/xrt/targets/cli/cli_cmd_camera.c b/src/xrt/targets/cli/cli_cmd_camera.c index 059a33627e..0256c0bc7d 100644 --- a/src/xrt/targets/cli/cli_cmd_camera.c +++ b/src/xrt/targets/cli/cli_cmd_camera.c @@ -15,6 +15,10 @@ * 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) + * 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) * camera stop-all the user kill switch (IPC) * camera sharing on|off the persistent sharing toggle (IPC) @@ -87,6 +91,7 @@ #ifdef XRT_OS_WINDOWS #include +#include // CommandLineToArgvW #else #include #include @@ -1070,7 +1075,33 @@ resolve_exe_arg(int argc, const char **argv, int idx, char *out, size_t cap) if (strcmp(argv[idx], "--self") == 0) { return self_exe_path(out, cap); } - snprintf(out, cap, "%s", argv[idx]); + // Store the path the way the service will see the peer (full, OS-derived), + // or a Deny typed with '/' or a relative path would never match it. +#if defined(XRT_OS_WINDOWS) + // argv is in the ANSI code page; the store is keyed by UTF-8. Re-read the + // argument as UTF-16 from the command line, then make it absolute. + int wargc = 0; + wchar_t **wargv = CommandLineToArgvW(GetCommandLineW(), &wargc); + bool ok = false; + if (wargv != NULL && wargc == argc) { + wchar_t full[2048]; + DWORD n = GetFullPathNameW(wargv[idx], (DWORD)(sizeof(full) / sizeof(full[0])), full, NULL); + const wchar_t *src = (n > 0 && n < sizeof(full) / sizeof(full[0])) ? full : wargv[idx]; + int m = WideCharToMultiByte(CP_UTF8, 0, src, -1, out, (int)cap, NULL, NULL); + ok = m > 1; + } + if (wargv != NULL) { + LocalFree(wargv); + } + if (!ok) { + snprintf(out, cap, "%s", argv[idx]); + } +#else + // The service reads proc_pidpath / /proc//exe: the resolved path. + char *resolved = realpath(argv[idx], NULL); // malloc'd: never overruns a PATH_MAX buffer + snprintf(out, cap, "%s", resolved != NULL ? resolved : argv[idx]); + free(resolved); +#endif return out[0] != '\0'; } @@ -1118,11 +1149,30 @@ cmd_store(const char *op, int argc, const char **argv) } else if (strcmp(op, "revoke") == 0) { ok = st->set(NULL, exe, U_CAMERA_CONSENT_STORED_NONE); } else if (strcmp(op, "trust") == 0) { + // A stored Deny outranks delegation (spec §7.1), so trusting over it + // would "succeed" and change nothing. `trust` is the user's newer, + // explicit word on this executable — like `allow` replacing a Deny — + // so it clears the Deny first and says so. (An installer's machine- + // wide registration never does: there the user's Deny keeps winning.) + enum u_camera_consent_stored prev = U_CAMERA_CONSENT_STORED_NONE; + if (st->get(NULL, exe, &prev) && prev == U_CAMERA_CONSENT_STORED_DENY) { + if (!st->set(NULL, exe, U_CAMERA_CONSENT_STORED_NONE)) { + printf( + "trust %s: FAILED to clear the stored Deny (which would keep refusing it); nothing " + "changed\n", + exe); + return 2; + } + printf("trust %s: cleared the stored Deny for it\n", exe); + } ok = u_camera_consent_store_set_delegating(exe, true); } else { // untrust ok = u_camera_consent_store_set_delegating(exe, false); } 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)) { + printf(" note: %s is also a registered consent-delegating client; the stored Deny wins\n", exe); + } return ok ? 0 : 2; } @@ -1177,7 +1227,8 @@ usage(void) " [--max-disparity N] [--max-dy N (24)] [--min-ncc C (0.90)]\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)\n" + " trust|untrust |--self user-level consent-delegating list (local; trust clears a stored " + "Deny)\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"); } diff --git a/tests/tests_camera_consent.cpp b/tests/tests_camera_consent.cpp index 4d22ffa372..9cd9ee498c 100644 --- a/tests/tests_camera_consent.cpp +++ b/tests/tests_camera_consent.cpp @@ -15,6 +15,7 @@ #include "util/u_sha256.h" #include "xrt/xrt_instance.h" +#include #include #include #include @@ -184,17 +185,105 @@ TEST_CASE("camera consent: no verifiable executable is refused, never prompted", CHECK(r.env.prompts == 0); } +// Spec §7.1: delegation means "no runtime prompt and no stored decision needed" +// — nothing more. Every refusal that applies to any app applies to it first. TEST_CASE("camera consent: a delegating client passes without a prompt or a store entry", "[camera_consent]") { rig r; r.store.delegating.insert("/opt/browser/browser"); - r.store.apps["/opt/browser/browser"] = U_CAMERA_CONSENT_STORED_DENY; // ignored: it is delegating - r.env.os_allowed = false; // checked after delegation + r.env.answer = U_CAMERA_CONSENT_PROMPT_DENY; // would refuse if it were asked auto d = r.eval("/opt/browser/browser"); CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); CHECK(d.delegating); CHECK(r.env.prompts == 0); + CHECK(r.store.sets == 0); + CHECK(r.store.apps.empty()); +} + +TEST_CASE("camera consent: a delegating client is refused by the OS camera switch", "[camera_consent]") +{ + rig r; + r.store.delegating.insert("/opt/browser/browser"); + r.env.os_allowed = false; + SECTION("nothing stored") + { + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_OS_DENIED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_OS_DENIED); + CHECK(d.delegating); // still reported: it is a fact about the executable + } + SECTION("even with a stored Allow") + { + r.store.apps["/opt/browser/browser"] = U_CAMERA_CONSENT_STORED_ALLOW; + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_OS_DENIED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_OS_DENIED); + } + CHECK(r.env.prompts == 0); + CHECK(r.store.sets == 0); +} + +TEST_CASE("camera consent: a stored Deny beats a delegating registration", "[camera_consent]") +{ + rig r; + r.store.delegating.insert("/opt/browser/browser"); + r.store.apps["/opt/browser/browser"] = U_CAMERA_CONSENT_STORED_DENY; + r.env.answer = U_CAMERA_CONSENT_PROMPT_ALLOW; + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_STORED_DENY); + CHECK(d.delegating); + CHECK(r.env.prompts == 0); + CHECK(r.store.sets == 0); + // The OS switch, when also off, is the reported reason (it is checked first). + r.env.os_allowed = false; + d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_OS_DENIED); +} + +TEST_CASE("camera consent: sharing off disables a delegating client", "[camera_consent]") +{ + rig r; + r.store.delegating.insert("/opt/browser/browser"); + SECTION("user toggle") + { + r.store.sharing = false; + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_DISABLED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_SHARING_OFF); + } + SECTION("DXR_STEREO_CAMERA=0") + { + r.c.kill_switch = true; + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_DISABLED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_KILL_SWITCH); + } + CHECK(r.env.prompts == 0); +} + +TEST_CASE("camera consent: a stored Allow on a delegating client reports delegation", "[camera_consent]") +{ + rig r; + r.store.delegating.insert("/opt/browser/browser"); + r.store.apps["/opt/browser/browser"] = U_CAMERA_CONSENT_STORED_ALLOW; + auto d = r.eval("/opt/browser/browser"); + CHECK(d.verdict == U_CAMERA_CONSENT_ALLOWED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_DELEGATING); + CHECK(d.delegating); +} + +TEST_CASE("camera consent: an unregistered executable next to a delegating one takes the ordinary path", + "[camera_consent]") +{ + rig r; + r.store.delegating.insert("/opt/browser/browser"); + auto d = r.eval("/apps/caller"); // not registered: the ordinary path + CHECK(d.verdict == U_CAMERA_CONSENT_REFUSED); + CHECK(d.why == U_CAMERA_CONSENT_WHY_PROMPT_UNAVAILABLE); + CHECK(!d.delegating); + CHECK(r.env.prompts == 1); } TEST_CASE("camera consent: the OS camera switch refuses before the store", "[camera_consent]") @@ -319,6 +408,100 @@ TEST_CASE("camera consent: no store and no env still decides safely", "[camera_c CHECK(strlen(id) == 7 + 32); } +TEST_CASE("consent store: path comparison rules", "[camera_consent][consent_store]") +{ + // Windows rules: ASCII case-insensitive, either separator. + CHECK(u_camera_consent_path_equal("C:\\Program Files\\B\\b.exe", "c:/program files/b/B.EXE", true)); + CHECK(!u_camera_consent_path_equal("C:\\B\\b.exe", "C:\\B\\b.exe2", true)); + CHECK(!u_camera_consent_path_equal("C:\\B\\b.exe", "C:\\B\\b.ex", true)); + // UTF-8 paths match byte for byte — the wide-API reader hands UTF-8 over. + const char *u8 = "C:\\Users\\Jos\xc3\xa9\\\xe6\xb5\x8f\xe8\xa7\x88\xe5\x99\xa8\\b.exe"; + CHECK(u_camera_consent_path_equal(u8, u8, true)); + CHECK(u_camera_consent_path_equal(u8, u8, false)); + // What the ANSI API used to hand back for that path (code-page bytes) does not. + CHECK(!u_camera_consent_path_equal("C:\\Users\\Jos\xe9\\???\\b.exe", u8, true)); + // Non-ASCII case is not folded (fails closed): E-acute vs e-acute. + CHECK(!u_camera_consent_path_equal("/x/\xc3\x89", "/x/\xc3\xa9", true)); + // POSIX rules: byte-exact. + CHECK(u_camera_consent_path_equal("/opt/b/browser", "/opt/b/browser", false)); + CHECK(!u_camera_consent_path_equal("/opt/b/Browser", "/opt/b/browser", false)); + CHECK(!u_camera_consent_path_equal("/opt/b\\browser", "/opt/b/browser", false)); + // Empty / NULL never match. + CHECK(!u_camera_consent_path_equal("", "", true)); + CHECK(!u_camera_consent_path_equal(nullptr, "/a", false)); +} + +namespace { + +struct fake_entry +{ + u_camera_consent_enum_result r; + std::string name, path; +}; + +struct fake_list +{ + std::vector entries; + uint32_t calls = 0; + bool never_ends = false; +}; + +u_camera_consent_enum_result +fl_enum(void *ctx, uint32_t index, char *name, size_t name_cap, char *path, size_t path_cap) +{ + auto *l = (fake_list *)ctx; + l->calls++; + if (index >= l->entries.size()) { + return l->never_ends ? U_CAMERA_CONSENT_ENUM_SKIP : U_CAMERA_CONSENT_ENUM_END; + } + const fake_entry &e = l->entries[index]; + if (e.r == U_CAMERA_CONSENT_ENUM_ENTRY) { + snprintf(name, name_cap, "%s", e.name.c_str()); + snprintf(path, path_cap, "%s", e.path.c_str()); + } + return e.r; +} + +} // namespace + +TEST_CASE("consent store: an oversized or malformed entry is skipped, not the end of the list", + "[camera_consent][consent_store]") +{ + fake_list l; + l.entries = { + {U_CAMERA_CONSENT_ENUM_ENTRY, "other", "C:\\Other\\other.exe"}, + {U_CAMERA_CONSENT_ENUM_SKIP, "", ""}, // an installer's 5 KB value + {U_CAMERA_CONSENT_ENUM_SKIP, "", ""}, // a REG_DWORD where a path belongs + {U_CAMERA_CONSENT_ENUM_ENTRY, "browser.exe", "C:\\Program Files\\B\\browser.exe"}, + }; + char name[64] = "unchanged"; + CHECK(u_camera_consent_list_find(fl_enum, &l, "c:/program files/b/browser.exe", true, name, sizeof(name))); + CHECK(std::string(name) == "browser.exe"); + + SECTION("no match walks everything once and stops at END") + { + l.calls = 0; + CHECK(!u_camera_consent_list_find(fl_enum, &l, "C:\\Nope\\x.exe", true, nullptr, 0)); + CHECK(l.calls == 5); // 4 entries + the END + } + SECTION("END ends the scan even if a match would follow") + { + l.entries[1].r = U_CAMERA_CONSENT_ENUM_END; + CHECK(!u_camera_consent_list_find(fl_enum, &l, "C:\\Program Files\\B\\browser.exe", true, nullptr, 0)); + } + SECTION("an enumerator that never ends is bounded") + { + l.never_ends = true; + l.calls = 0; + CHECK(!u_camera_consent_list_find(fl_enum, &l, "C:\\Nope\\x.exe", true, nullptr, 0)); + CHECK(l.calls == U_CAMERA_CONSENT_LIST_MAX); + } + SECTION("no identity never matches") + { + CHECK(!u_camera_consent_list_find(fl_enum, &l, "", true, nullptr, 0)); + } +} + TEST_CASE("sha256 / hmac vectors", "[camera_consent]") { static const char *hex = "0123456789abcdef";