diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index dfdf29b8..d756c91c 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Codingwithrk\NativephpSocialAuth\NativephpSocialAuthServiceProvider; +use Devnull\IosDataProtection\DataProtectionServiceProvider; use Illuminate\Support\ServiceProvider; use Native\Mobile\Providers\BrowserServiceProvider; use Native\Mobile\Providers\CameraServiceProvider; @@ -52,6 +53,7 @@ public function plugins(): array ShareServiceProvider::class, MobileSplashscreenServiceProvider::class, ToastKitServiceProvider::class, + DataProtectionServiceProvider::class, ...(config('devnull.apple_sign_in') ? [NativephpSocialAuthServiceProvider::class] : []), ]; } diff --git a/composer.json b/composer.json index ebda5842..ca1c9aed 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,10 @@ "type": "vcs", "url": "https://github.com/simonhamp/toastkit" }, + { + "type": "path", + "url": "packages/devnull/ios-data-protection" + }, { "name": "nativephp-plugins", "type": "composer", @@ -32,6 +36,7 @@ "require": { "php": "^8.3", "codingwithrk/nativephp-social-auth": "^1.0", + "devnull/ios-data-protection": "^1.0", "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", diff --git a/composer.lock b/composer.lock index 431125da..ea75c3dc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4c795f7f152e1855d8c1e257076e3f74", + "content-hash": "556a39822c265deb93c912a5f8d74b58", "packages": [ { "name": "bacon/bacon-qr-code", @@ -391,6 +391,42 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "devnull/ios-data-protection", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "packages/devnull/ios-data-protection", + "reference": "6e60de9d3d51a40f60af266d5ed3cd354cde5191" + }, + "require": { + "nativephp/mobile": "^4.0", + "php": "^8.3" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Devnull\\IosDataProtection\\DataProtectionServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Devnull\\IosDataProtection\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "Keeps the app's database and storage out of iCloud and Finder backups, and unreadable while the phone is locked.", + "transport-options": { + "relative": true + } + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", diff --git a/packages/devnull/ios-data-protection/README.md b/packages/devnull/ios-data-protection/README.md new file mode 100644 index 00000000..6205f1b8 --- /dev/null +++ b/packages/devnull/ios-data-protection/README.md @@ -0,0 +1,15 @@ +# devnull/ios-data-protection + +A NativePHP Mobile plugin, iOS only, that protects what the app keeps on the phone: + +- **Out of backups.** At every launch, `protectAppData()` marks `database/` and `storage/` under Application Support with + `isExcludedFromBackup`, so the SQLite database and Laravel's storage never reach iCloud or Finder backups. +- **Locked away while the phone is locked.** The `com.apple.developer.default-data-protection` entitlement is set to + `NSFileProtectionCompleteUnlessOpen`: files are unreadable once the phone locks, except those the app still has open, + so a write in progress finishes. Files written before the entitlement keep their old protection, so the first launch + with the plugin raises them once. + +It has no PHP API. Register `DataProtectionServiceProvider` in the app's `NativeServiceProvider::plugins()` and rebuild. + +Nothing here is specific to devnull: any NativePHP app that keeps private data in its database could use it, and it +could move into NativePHP itself. diff --git a/packages/devnull/ios-data-protection/composer.json b/packages/devnull/ios-data-protection/composer.json new file mode 100644 index 00000000..63590e58 --- /dev/null +++ b/packages/devnull/ios-data-protection/composer.json @@ -0,0 +1,26 @@ +{ + "name": "devnull/ios-data-protection", + "description": "Keeps the app's database and storage out of iCloud and Finder backups, and unreadable while the phone is locked.", + "version": "1.0.0", + "type": "nativephp-plugin", + "license": "MIT", + "require": { + "php": "^8.3", + "nativephp/mobile": "^4.0" + }, + "autoload": { + "psr-4": { + "Devnull\\IosDataProtection\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Devnull\\IosDataProtection\\DataProtectionServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + } +} diff --git a/packages/devnull/ios-data-protection/nativephp.json b/packages/devnull/ios-data-protection/nativephp.json new file mode 100644 index 00000000..00f264ea --- /dev/null +++ b/packages/devnull/ios-data-protection/nativephp.json @@ -0,0 +1,20 @@ +{ + "name": "devnull/ios-data-protection", + "version": "1.0.0", + "description": "Keeps the app's database and storage out of iCloud and Finder backups, and unreadable while the phone is locked.", + "namespace": "DataProtection", + "category": "security", + "license": "MIT", + "platforms": [ + "ios" + ], + "bridge_functions": [], + "ios": { + "init_function": "protectAppData", + "entitlements": { + "com.apple.developer.default-data-protection": "NSFileProtectionCompleteUnlessOpen" + } + }, + "events": [], + "service_provider": "Devnull\\IosDataProtection\\DataProtectionServiceProvider" +} diff --git a/packages/devnull/ios-data-protection/resources/ios/DataProtection.swift b/packages/devnull/ios-data-protection/resources/ios/DataProtection.swift new file mode 100644 index 00000000..e89ac83b --- /dev/null +++ b/packages/devnull/ios-data-protection/resources/ios/DataProtection.swift @@ -0,0 +1,62 @@ +import Foundation + +/// The folders under Application Support that hold what devnull sent and what +/// you haven't sent yet: the SQLite database (cached answers, Remotes messages, +/// drafts, the outbox) and Laravel's storage (cached images, files). +private let protectedFolders = ["database", "storage"] + +/// Set once every file written before this plugin has its protection raised. +private let upgradedKey = "devnull.dataProtection.upgraded" + +/// Called by NativePHP at every launch, before PHP starts (see nativephp.json). +/// +/// They are kept out of iCloud and Finder backups: what devnull sent can be +/// fetched again, and a phone restored from a backup signs in again anyway, as +/// its key stays in this phone's Keychain. Unsent drafts don't follow you to +/// another phone. The entitlement makes files written +/// from now on unreadable once the phone locks, unless the app still has them +/// open, which a write in progress needs. Files written before it keep their +/// old protection, so they are raised once here. +func protectAppData() { + guard let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return + } + + let upgrade = !UserDefaults.standard.bool(forKey: upgradedKey) + + for name in protectedFolders { + var folder = support.appendingPathComponent(name, isDirectory: true) + + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + var values = URLResourceValues() + values.isExcludedFromBackup = true + try folder.setResourceValues(values) + + if upgrade { + raiseProtection(of: folder) + } + } catch { + NSLog("[DataProtection] \(name): \(error.localizedDescription)") + } + } + + if upgrade { + UserDefaults.standard.set(true, forKey: upgradedKey) + } +} + +private func raiseProtection(of folder: URL) { + let attributes: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.completeUnlessOpen] + + try? FileManager.default.setAttributes(attributes, ofItemAtPath: folder.path) + + guard let files = FileManager.default.enumerator(at: folder, includingPropertiesForKeys: nil) else { + return + } + + for case let file as URL in files { + try? FileManager.default.setAttributes(attributes, ofItemAtPath: file.path) + } +} diff --git a/packages/devnull/ios-data-protection/src/DataProtectionServiceProvider.php b/packages/devnull/ios-data-protection/src/DataProtectionServiceProvider.php new file mode 100644 index 00000000..0d3a3738 --- /dev/null +++ b/packages/devnull/ios-data-protection/src/DataProtectionServiceProvider.php @@ -0,0 +1,11 @@ +find('devnull/ios-data-protection'); + + expect($plugin)->not->toBeNull() + ->and($registry->isRegistered('devnull/ios-data-protection'))->toBeTrue() + ->and($plugin->getIosEntitlements())->toBe(['com.apple.developer.default-data-protection' => 'NSFileProtectionCompleteUnlessOpen']) + ->and($plugin->getIosInitFunction())->toBe('protectAppData') + ->and(file_get_contents($plugin->path.'/resources/ios/DataProtection.swift'))->toContain('func protectAppData()'); +});