Skip to main content

vector_core/
webxdc_permissions.rs

1//! Mini App Permissions System
2//!
3//! This module handles optional permissions that Mini Apps can request.
4//! Permissions are declared when publishing an app and must be explicitly
5//! granted by users before they take effect.
6//!
7//! ## Security Model
8//!
9//! By default, ALL sensitive permissions are denied via the Permissions-Policy header.
10//! Apps can request specific permissions in their metadata, but users must:
11//! 1. Be prompted on first launch (if any permissions are requested)
12//! 2. Explicitly grant each permission
13//! 3. Have the ability to toggle permissions at any time in App Details
14//!
15//! ## Available Permissions
16//!
17//! - `microphone` - Access to microphone for voice chat, recording, etc.
18//! - `camera` - Access to camera for video calls, photos, etc.
19//! - `geolocation` - Access to device location
20//! - `clipboard-read` - Read from clipboard
21//! - `clipboard-write` - Write to clipboard
22//! - `fullscreen` - Allow fullscreen mode
23//! - `autoplay` - Allow media autoplay
24//! - `display-capture` - Screen capture via getDisplayMedia()
25//! - `midi` - Web MIDI API for musical instruments
26//! - `picture-in-picture` - Picture-in-Picture video mode
27//! - `screen-wake-lock` - Prevent screen from sleeping
28//! - `speaker-selection` - Select audio output device
29//! - `accelerometer` - Device acceleration sensor
30//! - `gyroscope` - Device orientation/rotation sensor
31//! - `magnetometer` - Compass/magnetic field sensor
32//! - `ambient-light-sensor` - Ambient light level sensor
33//! - `bluetooth` - Web Bluetooth API
34//!
35//! ## Nostr Event Tag Format
36//!
37//! Permissions are stored as a tag in the marketplace event:
38//! ```json
39//! ["permissions", "microphone,camera,fullscreen"]
40//! ```
41//!
42//! The value is a comma-separated list of permission names.
43
44use serde::{Deserialize, Serialize};
45use std::fmt;
46use std::collections::HashSet;
47use std::str::FromStr;
48
49/// Available permissions that Mini Apps can request
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum MiniAppPermission {
53    /// Access to microphone for voice chat, recording, etc.
54    Microphone,
55    /// Access to camera for video calls, photos, etc.
56    Camera,
57    /// Access to device location
58    Geolocation,
59    /// Read from clipboard
60    ClipboardRead,
61    /// Write to clipboard
62    ClipboardWrite,
63    /// Allow fullscreen mode
64    Fullscreen,
65    /// Allow media autoplay
66    Autoplay,
67    /// Screen capture via getDisplayMedia()
68    DisplayCapture,
69    /// Web MIDI API for musical instruments
70    Midi,
71    /// Picture-in-Picture video mode
72    PictureInPicture,
73    /// Prevent screen from sleeping
74    ScreenWakeLock,
75    /// Select audio output device
76    SpeakerSelection,
77    /// Device acceleration sensor
78    Accelerometer,
79    /// Device orientation/rotation sensor
80    Gyroscope,
81    /// Compass/magnetic field sensor
82    Magnetometer,
83    /// Ambient light level sensor
84    AmbientLightSensor,
85    /// Web Bluetooth API
86    Bluetooth,
87}
88
89impl MiniAppPermission {
90    /// Get all available permissions
91    pub fn all() -> Vec<MiniAppPermission> {
92        vec![
93            MiniAppPermission::Microphone,
94            MiniAppPermission::Camera,
95            MiniAppPermission::Geolocation,
96            MiniAppPermission::ClipboardRead,
97            MiniAppPermission::ClipboardWrite,
98            MiniAppPermission::Fullscreen,
99            MiniAppPermission::Autoplay,
100            MiniAppPermission::DisplayCapture,
101            MiniAppPermission::Midi,
102            MiniAppPermission::PictureInPicture,
103            MiniAppPermission::ScreenWakeLock,
104            MiniAppPermission::SpeakerSelection,
105            MiniAppPermission::Accelerometer,
106            MiniAppPermission::Gyroscope,
107            MiniAppPermission::Magnetometer,
108            MiniAppPermission::AmbientLightSensor,
109            MiniAppPermission::Bluetooth,
110        ]
111    }
112
113    /// Get the Permissions-Policy directive name for this permission
114    pub fn policy_name(&self) -> &'static str {
115        match self {
116            MiniAppPermission::Microphone => "microphone",
117            MiniAppPermission::Camera => "camera",
118            MiniAppPermission::Geolocation => "geolocation",
119            MiniAppPermission::ClipboardRead => "clipboard-read",
120            MiniAppPermission::ClipboardWrite => "clipboard-write",
121            MiniAppPermission::Fullscreen => "fullscreen",
122            MiniAppPermission::Autoplay => "autoplay",
123            MiniAppPermission::DisplayCapture => "display-capture",
124            MiniAppPermission::Midi => "midi",
125            MiniAppPermission::PictureInPicture => "picture-in-picture",
126            MiniAppPermission::ScreenWakeLock => "screen-wake-lock",
127            MiniAppPermission::SpeakerSelection => "speaker-selection",
128            MiniAppPermission::Accelerometer => "accelerometer",
129            MiniAppPermission::Gyroscope => "gyroscope",
130            MiniAppPermission::Magnetometer => "magnetometer",
131            MiniAppPermission::AmbientLightSensor => "ambient-light-sensor",
132            MiniAppPermission::Bluetooth => "bluetooth",
133        }
134    }
135
136    /// Get a human-readable label for this permission
137    pub fn label(&self) -> &'static str {
138        match self {
139            MiniAppPermission::Microphone => "Microphone",
140            MiniAppPermission::Camera => "Camera",
141            MiniAppPermission::Geolocation => "Location",
142            MiniAppPermission::ClipboardRead => "Read Clipboard",
143            MiniAppPermission::ClipboardWrite => "Write Clipboard",
144            MiniAppPermission::Fullscreen => "Fullscreen",
145            MiniAppPermission::Autoplay => "Autoplay Media",
146            MiniAppPermission::DisplayCapture => "Screen Capture",
147            MiniAppPermission::Midi => "MIDI Devices",
148            MiniAppPermission::PictureInPicture => "Picture-in-Picture",
149            MiniAppPermission::ScreenWakeLock => "Keep Screen On",
150            MiniAppPermission::SpeakerSelection => "Speaker Selection",
151            MiniAppPermission::Accelerometer => "Accelerometer",
152            MiniAppPermission::Gyroscope => "Gyroscope",
153            MiniAppPermission::Magnetometer => "Magnetometer",
154            MiniAppPermission::AmbientLightSensor => "Light Sensor",
155            MiniAppPermission::Bluetooth => "Bluetooth",
156        }
157    }
158
159    /// Get a description of what this permission allows
160    pub fn description(&self) -> &'static str {
161        match self {
162            MiniAppPermission::Microphone => "Access your microphone for voice chat or recording",
163            MiniAppPermission::Camera => "Access your camera for video calls or photos",
164            MiniAppPermission::Geolocation => "Access your device location",
165            MiniAppPermission::ClipboardRead => "Read text from your clipboard",
166            MiniAppPermission::ClipboardWrite => "Copy text to your clipboard",
167            MiniAppPermission::Fullscreen => "Enter fullscreen mode",
168            MiniAppPermission::Autoplay => "Automatically play audio and video",
169            MiniAppPermission::DisplayCapture => "Capture your screen or window",
170            MiniAppPermission::Midi => "Connect to MIDI instruments and controllers",
171            MiniAppPermission::PictureInPicture => "Play video in a floating window",
172            MiniAppPermission::ScreenWakeLock => "Prevent your screen from sleeping",
173            MiniAppPermission::SpeakerSelection => "Choose which speaker to use for audio",
174            MiniAppPermission::Accelerometer => "Detect device acceleration and movement",
175            MiniAppPermission::Gyroscope => "Detect device rotation and orientation",
176            MiniAppPermission::Magnetometer => "Access compass and magnetic field data",
177            MiniAppPermission::AmbientLightSensor => "Detect ambient light levels",
178            MiniAppPermission::Bluetooth => "Connect to Bluetooth devices",
179        }
180    }
181
182    /// Get an icon name for this permission (for UI display)
183    pub fn icon(&self) -> &'static str {
184        match self {
185            MiniAppPermission::Microphone => "microphone",
186            MiniAppPermission::Camera => "camera",
187            MiniAppPermission::Geolocation => "location",
188            MiniAppPermission::ClipboardRead => "clipboard",
189            MiniAppPermission::ClipboardWrite => "clipboard",
190            MiniAppPermission::Fullscreen => "maximize",
191            MiniAppPermission::Autoplay => "play",
192            MiniAppPermission::DisplayCapture => "monitor",
193            MiniAppPermission::Midi => "music",
194            MiniAppPermission::PictureInPicture => "picture-in-picture",
195            MiniAppPermission::ScreenWakeLock => "sun",
196            MiniAppPermission::SpeakerSelection => "speaker",
197            MiniAppPermission::Accelerometer => "activity",
198            MiniAppPermission::Gyroscope => "rotate-3d",
199            MiniAppPermission::Magnetometer => "compass",
200            MiniAppPermission::AmbientLightSensor => "lightbulb",
201            MiniAppPermission::Bluetooth => "bluetooth",
202        }
203    }
204}
205
206impl fmt::Display for MiniAppPermission {
207    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
208        write!(f, "{}", self.policy_name())
209    }
210}
211
212impl FromStr for MiniAppPermission {
213    type Err = String;
214
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        match s.to_lowercase().trim() {
217            "microphone" => Ok(MiniAppPermission::Microphone),
218            "camera" => Ok(MiniAppPermission::Camera),
219            "geolocation" | "location" => Ok(MiniAppPermission::Geolocation),
220            "clipboard-read" | "clipboardread" => Ok(MiniAppPermission::ClipboardRead),
221            "clipboard-write" | "clipboardwrite" => Ok(MiniAppPermission::ClipboardWrite),
222            "fullscreen" => Ok(MiniAppPermission::Fullscreen),
223            "autoplay" => Ok(MiniAppPermission::Autoplay),
224            "display-capture" | "displaycapture" => Ok(MiniAppPermission::DisplayCapture),
225            "midi" => Ok(MiniAppPermission::Midi),
226            "picture-in-picture" | "pictureinpicture" | "pip" => Ok(MiniAppPermission::PictureInPicture),
227            "screen-wake-lock" | "screenwakelock" => Ok(MiniAppPermission::ScreenWakeLock),
228            "speaker-selection" | "speakerselection" => Ok(MiniAppPermission::SpeakerSelection),
229            "accelerometer" => Ok(MiniAppPermission::Accelerometer),
230            "gyroscope" => Ok(MiniAppPermission::Gyroscope),
231            "magnetometer" => Ok(MiniAppPermission::Magnetometer),
232            "ambient-light-sensor" | "ambientlightsensor" => Ok(MiniAppPermission::AmbientLightSensor),
233            "bluetooth" => Ok(MiniAppPermission::Bluetooth),
234            other => Err(format!("Unknown permission: {}", other)),
235        }
236    }
237}
238
239/// Parse a comma-separated string of permissions into a set
240pub fn parse_permissions(permissions_str: &str) -> HashSet<MiniAppPermission> {
241    permissions_str
242        .split(',')
243        .filter_map(|s| s.trim().parse().ok())
244        .collect()
245}
246
247/// Serialize a set of permissions to a comma-separated string
248#[cfg(test)]
249pub fn serialize_permissions(permissions: &HashSet<MiniAppPermission>) -> String {
250    let mut perms: Vec<_> = permissions.iter().map(|p| p.to_string()).collect();
251    perms.sort();
252    perms.join(",")
253}
254
255/// Permission info for frontend display
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct PermissionInfo {
258    pub id: String,
259    pub label: String,
260    pub description: String,
261    pub icon: String,
262}
263
264impl From<MiniAppPermission> for PermissionInfo {
265    fn from(perm: MiniAppPermission) -> Self {
266        PermissionInfo {
267            id: perm.to_string(),
268            label: perm.label().to_string(),
269            description: perm.description().to_string(),
270            icon: perm.icon().to_string(),
271        }
272    }
273}
274
275/// Get all available permissions as PermissionInfo for frontend display
276pub fn get_all_permission_info() -> Vec<PermissionInfo> {
277    MiniAppPermission::all().into_iter().map(|p| p.into()).collect()
278}
279
280// ─── Permissions-Policy header ──────────────────────────────────────────────
281
282/// Base Permissions Policy that denies all sensitive APIs by default
283/// This is a comprehensive list from DeltaChat based on W3C spec
284/// https://github.com/w3c/webappsec-permissions-policy/blob/main/features.md
285///
286/// NOTE: Some permissions can be dynamically enabled if the user grants them.
287/// See `build_permissions_policy()` for dynamic generation.
288const PERMISSIONS_POLICY_DENY_ALL: &str = concat!(
289    "accelerometer=(), ",
290    "ambient-light-sensor=(), ",
291    "attribution-reporting=(), ",
292    "autoplay=(self), ",
293    "battery=(), ",
294    "bluetooth=(), ",
295    "camera=(), ",
296    "ch-ua=(), ",
297    "ch-ua-arch=(), ",
298    "ch-ua-bitness=(), ",
299    "ch-ua-full-version=(), ",
300    "ch-ua-full-version-list=(), ",
301    "ch-ua-high-entropy-values=(), ",
302    "ch-ua-mobile=(), ",
303    "ch-ua-model=(), ",
304    "ch-ua-platform=(), ",
305    "ch-ua-platform-version=(), ",
306    "ch-ua-wow64=(), ",
307    "compute-pressure=(), ",
308    "cross-origin-isolated=(), ",
309    "direct-sockets=(), ",
310    "display-capture=(), ",
311    "encrypted-media=(), ",
312    "execution-while-not-rendered=(), ",
313    "execution-while-out-of-viewport=(), ",
314    "fullscreen=(), ",
315    "geolocation=(), ",
316    "gyroscope=(), ",
317    "hid=(), ",
318    "identity-credentials-get=(), ",
319    "idle-detection=(), ",
320    "keyboard-map=(), ",
321    "magnetometer=(), ",
322    "mediasession=(), ",
323    "microphone=(), ",
324    "midi=(), ",
325    "navigation-override=(), ",
326    "otp-credentials=(), ",
327    "payment=(), ",
328    "picture-in-picture=(), ",
329    "publickey-credentials-create=(), ",
330    "publickey-credentials-get=(), ",
331    "screen-wake-lock=(), ",
332    "serial=(), ",
333    "sync-xhr=(), ",
334    "storage-access=(), ",
335    "usb=(), ",
336    "web-share=(), ",
337    "window-management=(), ",
338    "xr-spatial-tracking=(), ",
339    "autofill=(), ",
340    "clipboard-read=(), ",
341    "clipboard-write=(), ",
342    "deferred-fetch=(), ",
343    "gamepad=(self), ",
344    "language-detector=(), ",
345    "language-model=(), ",
346    "manual-text=(), ",
347    "rewriter=(), ",
348    "speaker-selection=(), ",
349    "summarizer=(), ",
350    "translator=(), ",
351    "writer=(), ",
352    "all-screens-capture=(), ",
353    "browsing-topics=(), ",
354    "captured-surface-control=(), ",
355    "conversion-measurement=(), ",
356    "digital-credentials-get=(), ",
357    "digital-credentials-create=(), ",
358    "focus-without-user-activation=(), ",
359    "join-ad-interest-group=(), ",
360    "local-fonts=(), ",
361    "monetization=(), ",
362    "run-ad-auction=(), ",
363    "smart-card=(), ",
364    "sync-script=(), ",
365    "trust-token-redemption=(), ",
366    "unload=(), ",
367    "vertical-scroll=(), ",
368    "document-domain=(), ",
369    "window-placement=()",
370);
371
372/// Permission policies that can be dynamically enabled based on user grants
373/// Maps permission name -> (policy directive name, allow value when enabled)
374const GRANTABLE_PERMISSIONS: &[(&str, &str)] = &[
375    ("microphone", "microphone"),
376    ("camera", "camera"),
377    ("geolocation", "geolocation"),
378    ("clipboard-read", "clipboard-read"),
379    ("clipboard-write", "clipboard-write"),
380    ("fullscreen", "fullscreen"),
381    ("autoplay", "autoplay"),
382    ("display-capture", "display-capture"),
383    ("midi", "midi"),
384    ("picture-in-picture", "picture-in-picture"),
385    ("screen-wake-lock", "screen-wake-lock"),
386    ("speaker-selection", "speaker-selection"),
387    ("accelerometer", "accelerometer"),
388    ("gyroscope", "gyroscope"),
389    ("magnetometer", "magnetometer"),
390    ("ambient-light-sensor", "ambient-light-sensor"),
391    ("bluetooth", "bluetooth"),
392];
393
394/// Build a dynamic Permissions-Policy header based on granted permissions
395///
396/// This takes the base deny-all policy and enables specific permissions
397/// that the user has granted for this app.
398///
399/// # Arguments
400/// * `granted_permissions` - Comma-separated string of granted permission names
401///
402/// # Returns
403/// The complete Permissions-Policy header value
404pub fn build_permissions_policy(granted_permissions: &str) -> String {
405    // Parse granted permissions into a set for fast lookup
406    let granted: std::collections::HashSet<&str> = granted_permissions
407        .split(',')
408        .map(|s| s.trim())
409        .filter(|s| !s.is_empty())
410        .collect();
411
412    // If no permissions granted, use the static deny-all policy
413    if granted.is_empty() {
414        return PERMISSIONS_POLICY_DENY_ALL.to_string();
415    }
416
417    // Build the policy by modifying the base policy
418    // For each grantable permission, if granted, change from () to (self)
419    let mut policy = PERMISSIONS_POLICY_DENY_ALL.to_string();
420
421    for (perm_name, directive) in GRANTABLE_PERMISSIONS {
422        if granted.contains(*perm_name) {
423            // Replace "directive=()" with "directive=(self)"
424            let deny_pattern = format!("{}=()", directive);
425            let allow_pattern = format!("{}=(self)", directive);
426            policy = policy.replace(&deny_pattern, &allow_pattern);
427        }
428    }
429
430    policy
431}
432
433/// Permissions-Policy for an app served cross-origin isolated: the user's
434/// grants plus `cross-origin-isolated=(self)`. Chromium honours `()` and
435/// would switch isolation (and with it SharedArrayBuffer) back off.
436pub fn build_isolated_permissions_policy(granted_permissions: &str) -> String {
437    let policy = build_permissions_policy(granted_permissions);
438    debug_assert!(policy.contains("cross-origin-isolated=()"));
439    policy.replacen("cross-origin-isolated=()", "cross-origin-isolated=(self)", 1)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn isolated_policy_allows_only_cross_origin_isolation_for_self() {
448        let base = build_permissions_policy("camera");
449        let isolated = build_isolated_permissions_policy("camera");
450        assert!(base.contains("cross-origin-isolated=()"));
451        assert!(isolated.contains("cross-origin-isolated=(self)"));
452        assert!(!isolated.contains("cross-origin-isolated=()"));
453        assert_eq!(base.replace("cross-origin-isolated=()", "cross-origin-isolated=(self)"), isolated);
454        assert!(isolated.contains("camera=(self)") && isolated.contains("microphone=()"));
455        assert!(build_isolated_permissions_policy("").contains("cross-origin-isolated=(self)"));
456    }
457
458    #[test]
459    fn test_parse_permissions() {
460        let perms = parse_permissions("microphone, camera, fullscreen");
461        assert!(perms.contains(&MiniAppPermission::Microphone));
462        assert!(perms.contains(&MiniAppPermission::Camera));
463        assert!(perms.contains(&MiniAppPermission::Fullscreen));
464        assert_eq!(perms.len(), 3);
465    }
466
467    #[test]
468    fn test_serialize_permissions() {
469        let mut perms = HashSet::new();
470        perms.insert(MiniAppPermission::Camera);
471        perms.insert(MiniAppPermission::Microphone);
472        let serialized = serialize_permissions(&perms);
473        // Should be sorted alphabetically
474        assert!(serialized == "camera,microphone" || serialized == "microphone,camera");
475    }
476
477    #[test]
478    fn test_parse_unknown_permission() {
479        let perms = parse_permissions("microphone, unknown, camera");
480        assert_eq!(perms.len(), 2); // unknown is filtered out
481    }
482}