From fc37f8b93fff596178ad28a50eea04f3c238cce2 Mon Sep 17 00:00:00 2001 From: Julien BIANCHI Date: Wed, 28 Oct 2026 00:30:17 +0100 Subject: [PATCH] fix(ios): keep cached data out of backups and locked with the phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SQLite database holds cached answers from devnull (Remotes messages included), drafts and the outbox, and storage/ holds cached images. Both live under Application Support with the default protection class, so they were readable as soon as the phone had been unlocked once after boot, and they went into iCloud and Finder backups. NativePHP has no setting for either, so a small local plugin, devnull/ios-data-protection, does both: - its init function marks database/ and storage/ with isExcludedFromBackup at every launch; - it adds the com.apple.developer.default-data-protection entitlement with NSFileProtectionCompleteUnlessOpen. Complete would make an SQLite write still running when the phone locks fail, while CompleteUnlessOpen lets the app finish with files it has open; - files written before the entitlement keep their old class, so the first launch with the plugin raises them once. The test checks that the plugin is registered and declares the entitlement and init function. The Swift code itself can't be test-driven from Pest and needs checking on a build. 🤖 Généré avec Claude Code --- app/Providers/NativeServiceProvider.php | 2 + composer.json | 5 ++ composer.lock | 38 +++++++++++- .../devnull/ios-data-protection/README.md | 15 +++++ .../devnull/ios-data-protection/composer.json | 26 ++++++++ .../ios-data-protection/nativephp.json | 20 ++++++ .../resources/ios/DataProtection.swift | 62 +++++++++++++++++++ .../src/DataProtectionServiceProvider.php | 11 ++++ tests/Feature/DataProtectionTest.php | 14 +++++ 9 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 packages/devnull/ios-data-protection/README.md create mode 100644 packages/devnull/ios-data-protection/composer.json create mode 100644 packages/devnull/ios-data-protection/nativephp.json create mode 100644 packages/devnull/ios-data-protection/resources/ios/DataProtection.swift create mode 100644 packages/devnull/ios-data-protection/src/DataProtectionServiceProvider.php create mode 100644 tests/Feature/DataProtectionTest.php 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()'); +});