1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 13:23:34 +00:00

initial sandbox implementation for mac app store builds

This commit is contained in:
John Harrington
2025-11-18 16:20:12 -07:00
parent 9efc31534b
commit f40233fce4
14 changed files with 601 additions and 8 deletions

View File

@@ -610,6 +610,7 @@ dependencies = [
"cbc",
"dirs",
"hex",
"libc",
"oo7",
"pbkdf2",
"rand 0.9.1",

View File

@@ -25,6 +25,7 @@ tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { workspace = true }
libc = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { workspace = true, features = [
@@ -39,3 +40,6 @@ oo7 = { workspace = true }
[lints]
workspace = true
[features]
sandbox = []

View File

@@ -59,10 +59,23 @@ impl InstalledBrowserRetriever for DefaultInstalledBrowserRetriever {
fn get_installed_browsers() -> Result<Vec<String>> {
let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len());
#[allow(unused_variables)] // config only used in non-sandbox mode
for (browser, config) in SUPPORTED_BROWSER_MAP.iter() {
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
browsers.push((*browser).to_string());
#[cfg(all(target_os = "macos", feature = "sandbox"))]
{
// macOS sandbox mode: check if we have stored security-scoped bookmark
if platform::ScopedBrowserAccess::has_stored_access(browser) {
browsers.push((*browser).to_string());
}
}
#[cfg(not(all(target_os = "macos", feature = "sandbox")))]
{
// All other platforms OR macOS without sandbox: check file system directly
let data_dir = get_browser_data_dir(config)?;
if data_dir.exists() {
browsers.push((*browser).to_string());
}
}
}
@@ -75,10 +88,21 @@ pub fn get_available_profiles(browser_name: &String) -> Result<Vec<ProfileInfo>>
Ok(get_profile_info(&local_state))
}
/// Request access to browser directory (sandbox mode only)
#[cfg(all(target_os = "macos", feature = "sandbox"))]
pub fn request_browser_access(browser_name: &String) -> Result<()> {
let _access = platform::ScopedBrowserAccess::request_and_start(browser_name)?;
Ok(())
}
pub async fn import_logins(
browser_name: &String,
profile_id: &String,
) -> Result<Vec<LoginImportResult>> {
// In sandbox mode, resume access to browser directory
#[cfg(all(target_os = "macos", feature = "sandbox"))]
let _access = platform::ScopedBrowserAccess::resume(browser_name)?;
let (data_dir, local_state) = load_local_state_for_browser(browser_name)?;
let mut crypto_service = platform::get_crypto_service(browser_name, &local_state)

View File

@@ -0,0 +1,243 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use security_framework::passwords::get_generic_password;
use std::ffi::CString;
use std::os::raw::c_char;
use crate::chromium::{BrowserConfig, CryptoService, LocalState};
use crate::util;
//
// Sandbox
//
// FFI declarations
extern "C" {
fn requestBrowserAccess(browser_name: *const c_char) -> *mut c_char;
fn hasStoredBrowserAccess(browser_name: *const c_char) -> bool;
fn startBrowserAccess(browser_name: *const c_char) -> *mut c_char;
fn stopBrowserAccess(browser_name: *const c_char);
}
pub struct ScopedBrowserAccess {
browser_name: String,
}
impl ScopedBrowserAccess {
/// Request permission from user and start accessing browser directory
pub fn request_and_start(browser_name: &str) -> Result<Self> {
let c_name = CString::new(browser_name)?;
// Request permission (shows dialog)
let bookmark_ptr = unsafe { requestBrowserAccess(c_name.as_ptr()) };
if bookmark_ptr.is_null() {
return Err(anyhow!("User declined access or browser not found"));
}
unsafe { libc::free(bookmark_ptr as *mut libc::c_void) };
// Start accessing
let path_ptr = unsafe { startBrowserAccess(c_name.as_ptr()) };
if path_ptr.is_null() {
return Err(anyhow!("Failed to start accessing browser directory"));
}
unsafe { libc::free(path_ptr as *mut libc::c_void) };
Ok(Self {
browser_name: browser_name.to_string(),
})
}
/// Resume access using previously stored bookmark
pub fn resume(browser_name: &str) -> Result<Self> {
let c_name = CString::new(browser_name)?;
if !unsafe { hasStoredBrowserAccess(c_name.as_ptr()) } {
return Err(anyhow!("No stored access for this browser"));
}
let path_ptr = unsafe { startBrowserAccess(c_name.as_ptr()) };
if path_ptr.is_null() {
return Err(anyhow!("Failed to resume access (bookmark may be stale)"));
}
unsafe { libc::free(path_ptr as *mut libc::c_void) };
Ok(Self {
browser_name: browser_name.to_string(),
})
}
/// Check if we have stored access (doesn't verify validity)
pub fn has_stored_access(browser_name: &str) -> bool {
let Ok(c_name) = CString::new(browser_name) else {
return false;
};
unsafe { hasStoredBrowserAccess(c_name.as_ptr()) }
}
}
impl Drop for ScopedBrowserAccess {
fn drop(&mut self) {
let Ok(c_name) = CString::new(self.browser_name.as_str()) else {
return;
};
unsafe { stopBrowserAccess(c_name.as_ptr()) };
}
}
//
// Existing Public API
// TODO the rest of this file exactly matches macos.rs, move sandbox code there and cfg gate it behind sandbox feature
//
pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[
BrowserConfig {
name: "Chrome",
data_dir: "Library/Application Support/Google/Chrome",
},
BrowserConfig {
name: "Chromium",
data_dir: "Library/Application Support/Chromium",
},
BrowserConfig {
name: "Microsoft Edge",
data_dir: "Library/Application Support/Microsoft Edge",
},
BrowserConfig {
name: "Brave",
data_dir: "Library/Application Support/BraveSoftware/Brave-Browser",
},
BrowserConfig {
name: "Arc",
data_dir: "Library/Application Support/Arc/User Data",
},
BrowserConfig {
name: "Opera",
data_dir: "Library/Application Support/com.operasoftware.Opera",
},
BrowserConfig {
name: "Vivaldi",
data_dir: "Library/Application Support/Vivaldi",
},
];
pub(crate) fn get_crypto_service(
browser_name: &String,
_local_state: &LocalState,
) -> Result<Box<dyn CryptoService>> {
let config = KEYCHAIN_CONFIG
.iter()
.find(|b| b.browser == browser_name)
.ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?;
Ok(Box::new(MacCryptoService::new(config)))
}
//
// Private
//
#[derive(Debug)]
struct KeychainConfig {
browser: &'static str,
service: &'static str,
account: &'static str,
}
const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [
KeychainConfig {
browser: "Chrome",
service: "Chrome Safe Storage",
account: "Chrome",
},
KeychainConfig {
browser: "Chromium",
service: "Chromium Safe Storage",
account: "Chromium",
},
KeychainConfig {
browser: "Microsoft Edge",
service: "Microsoft Edge Safe Storage",
account: "Microsoft Edge",
},
KeychainConfig {
browser: "Brave",
service: "Brave Safe Storage",
account: "Brave",
},
KeychainConfig {
browser: "Arc",
service: "Arc Safe Storage",
account: "Arc",
},
KeychainConfig {
browser: "Opera",
service: "Opera Safe Storage",
account: "Opera",
},
KeychainConfig {
browser: "Vivaldi",
service: "Vivaldi Safe Storage",
account: "Vivaldi",
},
];
const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character)
//
// CryptoService
//
struct MacCryptoService {
config: &'static KeychainConfig,
master_key: Option<Vec<u8>>,
}
impl MacCryptoService {
fn new(config: &'static KeychainConfig) -> Self {
Self {
config,
master_key: None,
}
}
}
#[async_trait]
impl CryptoService for MacCryptoService {
async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result<String> {
if encrypted.is_empty() {
return Ok(String::new());
}
// On macOS only v10 is supported
let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?;
// This might bring up the admin password prompt
if self.master_key.is_none() {
self.master_key = Some(get_master_key(self.config.service, self.config.account)?);
}
let key = self
.master_key
.as_ref()
.ok_or_else(|| anyhow!("Failed to retrieve key"))?;
let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix)
.map_err(|e| anyhow!("Failed to decrypt: {}", e))?;
let plaintext =
String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?;
Ok(plaintext)
}
}
fn get_master_key(service: &str, account: &str) -> Result<Vec<u8>> {
let master_password = get_master_password(service, account)?;
let key = util::derive_saltysalt(&master_password, 1003)?;
Ok(key)
}
fn get_master_password(service: &str, account: &str) -> Result<Vec<u8>> {
let password = get_generic_password(service, account)
.map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?;
Ok(password)
}

View File

@@ -1,9 +1,13 @@
// Platform-specific code
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows/mod.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
#[cfg_attr(all(target_os = "macos", not(feature = "sandbox")), path = "macos.rs")]
// TODO rework this to place sandbox code in macos and cfg gate it there
#[cfg_attr(all(target_os = "macos", feature = "sandbox"), path = "macos_sandbox.rs")]
mod native;
// Windows exposes public const
#[allow(unused_imports)]
pub use native::*;
pub use native::*;

View File

@@ -12,6 +12,7 @@ crate-type = ["cdylib"]
[features]
default = []
manual_test = []
sandbox = ["chromium_importer/sandbox"]
[dependencies]
anyhow = { workspace = true }

View File

@@ -12,3 +12,12 @@ if (isRelease) {
}
execSync(`napi build --platform --js false`, { stdio: 'inherit', env: process.env });
/* Mac App Store build with sandboxing - Does this belong here?
const target = process.env.npm_config_target || '';
const featuresArg = target.includes('mas') ? '--features sandbox' : '';
execSync(`napi build --platform --js false ${featuresArg}`, { stdio: 'inherit', env: process.env });
*/

View File

@@ -1181,6 +1181,13 @@ pub mod chromium_importer {
.map(|logins| logins.into_iter().map(LoginImportResult::from).collect())
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[cfg_attr(all(target_os = "macos", feature = "sandbox"), napi)]
#[cfg(all(target_os = "macos", feature = "sandbox"))]
pub fn request_browser_access(browser: String) -> napi::Result<()> {
chromium_importer::chromium::request_browser_access(&browser)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]

View File

@@ -11,7 +11,7 @@ default = []
[dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -1,9 +1,56 @@
#[cfg(target_os = "macos")]
fn main() {
use glob::glob;
use std::process::Command;
let out_dir = std::env::var("OUT_DIR").unwrap();
// Compile Swift files FIRST (generates Bitwarden-Swift.h for browser_access.m)
let swift_files: Vec<String> = glob("src/native/**/*.swift")
.expect("Failed to read Swift glob pattern")
.filter_map(Result::ok)
.map(|p| {
println!("cargo::rerun-if-changed={}", p.display());
p.to_str().unwrap().to_string()
})
.collect();
if !swift_files.is_empty() {
// Compile Swift into a static library
let status = Command::new("swiftc")
.args(&[
"-emit-library",
"-static",
"-module-name",
"Bitwarden",
"-import-objc-header",
"src/native/bridging-header.h",
"-emit-objc-header-path",
&format!("{}/Bitwarden-Swift.h", out_dir),
"-o",
&format!("{}/libbitwarden_swift.a", out_dir),
])
.args(&swift_files)
.status()
.expect("Failed to compile Swift code");
if !status.success() {
panic!("Swift compilation failed");
}
// Tell cargo to link the Swift library
println!("cargo:rustc-link-search=native={}", out_dir);
println!("cargo:rustc-link-lib=static=bitwarden_swift");
// Link required Swift/Foundation frameworks
println!("cargo:rustc-link-lib=framework=Foundation");
println!("cargo:rustc-link-lib=framework=AppKit");
}
// Compile Objective-C files (Bitwarden-Swift.h exists now)
let mut builder = cc::Build::new();
// Auto compile all .m files in the src/native directory
// Compile all .m files in the src/native directory
for entry in glob("src/native/**/*.m").expect("Failed to read glob pattern") {
let path = entry.expect("Failed to read glob entry");
builder.file(path.clone());
@@ -11,8 +58,9 @@ fn main() {
}
builder
.include(&out_dir) // Add OUT_DIR to include path so Bitwarden-Swift.h can be found
.flag("-fobjc-arc") // Enable Auto Reference Counting (ARC)
.compile("autofill");
.compile("objc_code");
}
#[cfg(not(target_os = "macos"))]

View File

@@ -0,0 +1,3 @@
#import <Foundation/Foundation.h>
#import "utils.h"
#import "interop.h"

View File

@@ -0,0 +1,22 @@
#ifndef BROWSER_ACCESS_H
#define BROWSER_ACCESS_H
#include <stdbool.h>
// Request user permission to access browser directory
// Returns base64-encoded bookmark data, or NULL if declined
// Caller must free returned string
char* requestBrowserAccess(const char* browserName);
// Check if we have stored bookmark (doesn't verify validity)
bool hasStoredBrowserAccess(const char* browserName);
// Start accessing browser using stored bookmark
// Returns resolved path, or NULL if bookmark invalid
// Caller must free returned string and call stopBrowserAccess when done
char* startBrowserAccess(const char* browserName);
// Stop accessing browser (MUST be called after startBrowserAccess)
void stopBrowserAccess(const char* browserName);
#endif

View File

@@ -0,0 +1,58 @@
#import <Foundation/Foundation.h>
#import "browser_access.h"
#import "../utils.h"
// Import the Swift-generated header
// The name matches the module-name in build.rs: "Bitwarden"
#import "Bitwarden-Swift.h"
static BrowserAccessManager* sharedManager = nil;
static BrowserAccessManager* getManager() {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedManager = [[BrowserAccessManager alloc] init];
});
return sharedManager;
}
char* requestBrowserAccess(const char* browserName) {
@autoreleasepool {
NSString* name = [NSString stringWithUTF8String:browserName];
// Note: Matches the Swift method name with typo "Broswer"
NSString* result = [getManager() requestAccessToBroswerDir:name];
if (result == nil) {
return NULL;
}
return strdup([result UTF8String]);
}
}
bool hasStoredBrowserAccess(const char* browserName) {
@autoreleasepool {
NSString* name = [NSString stringWithUTF8String:browserName];
return [getManager() hasStoredAccess:name];
}
}
char* startBrowserAccess(const char* browserName) {
@autoreleasepool {
NSString* name = [NSString stringWithUTF8String:browserName];
NSString* result = [getManager() startAccessingBrowser:name];
if (result == nil) {
return NULL;
}
return strdup([result UTF8String]);
}
}
void stopBrowserAccess(const char* browserName) {
@autoreleasepool {
NSString* name = [NSString stringWithUTF8String:browserName];
[getManager() stopAccessingBrowser:name];
}
}

View File

@@ -0,0 +1,169 @@
import Cocoa
import Foundation
@objc public class BrowserAccessManager: NSObject {
private let bookmarkKey = "com.bitwarden.chromiumImporter.bookmarks"
private let browserPaths: [String: String] = [
"Chrome": "Library/Application Support/Google/Chrome",
"Chromium": "Library/Application Support/Chromium",
"Microsoft Edge": "Library/Application Support/Microsoft Edge",
"Brave": "Library/Application Support/BraveSoftware/Brave-Browser",
"Arc": "Library/Application Support/Arc/User Data",
"Opera": "Library/Application Support/com.operasoftware.Opera",
"Vivaldi": "Library/Application Support/Vivaldi",
]
/// Request access to a specific browser's directory
/// Returns security bookmark data (used to persist permissions) as base64 string, or nil if user declined
@objc public func requestAccessToBroswerDir(_ browserName: String) -> String? {
guard let relativePath = browserPaths[browserName] else {
NSLog("Unknown browser: \(browserName)")
return nil
}
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let browserPath = homeDir.appendingPathComponent(relativePath)
// Open file picker at home directory and provide instructions to grant access to browserPath
// Mac OS will automatically request permission to access this location from the sandbox when the location is selected here
let openPanel = NSOpenPanel()
openPanel.message =
"Please select your \(browserName) data folder\n\nExpected location:\n\(browserPath.path)"
openPanel.prompt = "Grant Access"
openPanel.allowsMultipleSelection = false
openPanel.canChooseDirectories = true
openPanel.canChooseFiles = false
openPanel.directoryURL = browserPath.deletingLastPathComponent() // home directory
guard openPanel.runModal() == .OK, let url = openPanel.url else {
NSLog("User cancelled access request")
return nil
}
let localStatePath = url.appendingPathComponent("Local State")
guard FileManager.default.fileExists(atPath: localStatePath.path) else {
NSLog("Selected folder doesn't appear to be a valid \(browserName) directory")
let alert = NSAlert()
alert.messageText = "Invalid Folder"
alert.informativeText =
"The selected folder doesn't appear to be a valid \(browserName) data directory. Please select the correct folder."
alert.alertStyle = .warning
alert.runModal()
return nil
}
// access is temporary right now, persist it by creating a security bookmark
do {
let bookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
saveBookmark(bookmarkData, forBrowser: browserName)
return bookmarkData.base64EncodedString()
} catch {
NSLog("Failed to create bookmark: \(error)")
return nil
}
}
/// Check if we have stored bookmark for browser (doesn't verify it's still valid)
@objc public func hasStoredAccess(_ browserName: String) -> Bool {
return loadBookmark(forBrowser: browserName) != nil
}
/// Start accessing a browser directory using stored bookmark
/// Returns the resolved path, or nil if bookmark is invalid/revoked
/*
This could return nil if:
The user doesnt have access to this URL
The URL isnt a security scoped URL
T The directory doesnt need it (~/Downloads)
https://benscheirman.com/2019/10/troubleshooting-appkit-file-permissions.html
*/
@objc public func startAccessingBrowser(_ browserName: String) -> String? {
guard let bookmarkData = loadBookmark(forBrowser: browserName) else {
return nil
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
if isStale {
NSLog("Security bookmark for \(browserName) is stale, attempting to re-create it")
do {
let newBookmarkData = try url.bookmarkData(
options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
saveBookmark(newBookmarkData, forBrowser: browserName)
} catch {
NSLog("Failed to create bookmark: \(error)")
return nil
}
}
guard url.startAccessingSecurityScopedResource() else {
NSLog("Failed to start accessing security-scoped resource")
return nil
}
return url.path
} catch {
NSLog("Failed to resolve bookmark: \(error)")
return nil
}
}
/// Stop accessing a browser directory (must be called after startAccessingBrowser)
@objc public func stopAccessingBrowser(_ browserName: String) {
guard let bookmarkData = loadBookmark(forBrowser: browserName) else {
return
}
do {
var isStale = false
let url = try URL(
resolvingBookmarkData: bookmarkData,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &isStale
)
url.stopAccessingSecurityScopedResource()
} catch {
NSLog("Failed to resolve bookmark for stop: \(error)")
}
}
private func bookmarkKeyFor(_ browserName: String) -> String {
return "\(bookmarkKey).\(browserName)"
}
private func saveBookmark(_ data: Data, forBrowser browserName: String) {
let defaults = UserDefaults.standard
let key = bookmarkKeyFor(browserName)
defaults.set(data, forKey: key)
defaults.synchronize()
}
private func loadBookmark(forBrowser browserName: String) -> Data? {
let defaults = UserDefaults.standard
let key = bookmarkKeyFor(browserName)
return defaults.data(forKey: key)
}
}