diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9a408dfc2..a8a2f1abaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -437,6 +437,7 @@ jobs: fi apt-get update --allow-releaseinfo-change apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev libasound2-dev libunwind-dev + apt-get install -y pkg-config libsecret-1-0 libsecret-1-dev apt-get install -y \ libmpv-dev mpv \ libgstreamer1.0-dev \ @@ -718,6 +719,7 @@ jobs: flet-map flet-permission-handler flet-rive + flet-secure-storage flet-video flet-webview ) @@ -835,6 +837,7 @@ jobs: flet_map \ flet_permission_handler \ flet_rive \ + flet_secure_storage \ flet_video \ flet_webview; do uv publish dist/**/${pkg}-* diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml index 360eed4270..3d99c9acb9 100644 --- a/client/android/app/src/main/AndroidManifest.xml +++ b/client/android/app/src/main/AndroidManifest.xml @@ -15,10 +15,12 @@ + + - + ? args]) async { flet_flashlight.Extension(), flet_datatable2.Extension(), flet_charts.Extension(), + flet_secure_storage.Extension(), // --FAT_CLIENT_START-- // --RIVE_EXTENSION_START-- flet_rive.Extension(), @@ -90,10 +93,8 @@ void main([List? args]) async { assetsDir = args[2]; debugPrint("Args contain a path assets directory: $assetsDir}"); } - } else if (!kDebugMode && - (Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { - throw Exception( - 'In desktop mode Flet app URL must be provided as a first argument.'); + } else if (!kDebugMode && (Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { + throw Exception('In desktop mode Flet app URL must be provided as a first argument.'); } } diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 5de1042d4b..5bf6455299 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -52,6 +52,9 @@ dependencies: flet_audio_recorder: path: ../sdk/python/packages/flet-audio-recorder/src/flutter/flet_audio_recorder + flet_charts: + path: ../sdk/python/packages/flet-charts/src/flutter/flet_charts + flet_datatable2: path: ../sdk/python/packages/flet-datatable2/src/flutter/flet_datatable2 @@ -70,12 +73,12 @@ dependencies: flet_permission_handler: path: ../sdk/python/packages/flet-permission-handler/src/flutter/flet_permission_handler + flet_secure_storage: + path: ../sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage + flet_webview: path: ../sdk/python/packages/flet-webview/src/flutter/flet_webview - flet_charts: - path: ../sdk/python/packages/flet-charts/src/flutter/flet_charts - cupertino_icons: ^1.0.6 wakelock_plus: ^1.4.0 package_info_plus: ^9.0.0 diff --git a/packages/flet/lib/src/models/control.dart b/packages/flet/lib/src/models/control.dart index 35772172f0..6c707ba483 100644 --- a/packages/flet/lib/src/models/control.dart +++ b/packages/flet/lib/src/models/control.dart @@ -371,35 +371,39 @@ class Control extends ChangeNotifier { var node = getPatchTarget(op[1]); var index = op[2]; var value = op[3]; - if (node.obj is! List) { - throw Exception("Add operation can be applied to lists only: $op"); - } - node.obj - .insert(index, _transformIfControl(value, node.control, backend)); - if (shouldNotify) { - node.control.notify(); + if (node.obj is Map) { + node.obj[index] = _transformIfControl(value, node.control, backend); + } else if (node.obj is List) { + node.obj.insert(index, _transformIfControl(value, node.control, backend)); + } else { + throw Exception("Add operation can be applied to lists or maps: $op"); } + if (shouldNotify) node.control.notify(); } else if (opType == OperationType.remove) { // REMOVE var node = getPatchTarget(op[1]); var index = op[2]; - if (node.obj is! List) { - throw Exception("Remove operation can be applied to lists only: $op"); - } - node.obj.removeAt(index); - if (shouldNotify) { - node.control.notify(); + if (node.obj is List) { + node.obj.removeAt(index); + } else if (node.obj is Map) { + node.obj.remove(index); + } else { + throw Exception("Remove operation can be applied to lists or maps: $op"); } + if (shouldNotify) node.control.notify(); } else if (opType == OperationType.move) { // MOVE var fromNode = getPatchTarget(op[1]); var fromIndex = op[2]; var toNode = getPatchTarget(op[3]); var toIndex = op[4]; - if (fromNode.obj is! List || toNode.obj is! List) { - throw Exception("Move operation can be applied to lists only: $op"); + if (fromNode.obj is List && toNode.obj is List) { + toNode.obj.insert(toIndex, fromNode.obj.removeAt(fromIndex)); + } else if (fromNode.obj is Map && toNode.obj is Map) { + toNode.obj[toIndex] = fromNode.obj.remove(fromIndex); + } else { + throw Exception("Move operation can only be applied to lists or maps: $op"); } - toNode.obj.insert(toIndex, fromNode.obj.removeAt(fromIndex)); if (shouldNotify) { if (fromNode.control.id != toNode.control.id) { fromNode.control.notify(); diff --git a/sdk/python/examples/services/secure_storage/basic.py b/sdk/python/examples/services/secure_storage/basic.py new file mode 100644 index 0000000000..6e65a34fba --- /dev/null +++ b/sdk/python/examples/services/secure_storage/basic.py @@ -0,0 +1,94 @@ +import base64 +import os + +import flet as ft +import flet_secure_storage as fss + + +def main(page: ft.Page): + page.vertical_alignment = ft.MainAxisAlignment.CENTER + page.horizontal_alignment = ft.CrossAxisAlignment.CENTER + storage = fss.SecureStorage( + web_options=fss.WebOptions( + db_name="customstorage", + public_key="publickey", + wrap_key=base64.urlsafe_b64encode(os.urandom(32)).decode(), + wrap_key_iv=base64.urlsafe_b64encode(os.urandom(16)).decode(), + ), + android_options=fss.AndroidOptions( + reset_on_error=True, + migrate_on_algorithm_change=True, + enforce_biometrics=True, + key_cipher_algorithm=fss.KeyCipherAlgorithm.AES_GCM_NO_PADDING, + storage_cipher_algorithm=fss.StorageCipherAlgorithm.AES_GCM_NO_PADDING, + ), + ) + + key = ft.TextField(label="Key", value="example_key") + value = ft.TextField(label="Value", value="secret_value") + result = ft.Text(theme_style=ft.TextThemeStyle.TITLE_LARGE) + + async def set_value(e): + await storage.set(key.value, value.value) + result.value = "Value saved" + page.update() + + async def get_value(e): + result.value = await storage.get(key.value) + page.update() + + async def remove_value(e): + await storage.remove(key.value) + result.value = "Value removed" + page.update() + + async def clear_storage(e): + await storage.clear() + result.value = "Storage cleared" + page.update() + + async def contains_key(e): + exists = await storage.contains_key(key.value) + result.value = f"Key exists: {exists}" + page.update() + + async def get_availability(e): + is_availability = await storage.get_availability() + page.show_dialog( + ft.SnackBar( + content=ft.Text( + value=f"Protected data available: {is_availability}" + if is_availability + else "Protected data available: None" + ) + ) + ) + page.update() + + page.add( + ft.Column( + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=10, + controls=[ + result, + key, + value, + ft.Row( + width=300, + wrap=True, + controls=[ + ft.Button("Set", on_click=set_value), + ft.Button("Get", on_click=get_value), + ft.Button("Contains key", on_click=contains_key), + ft.Button("Remove", on_click=remove_value), + ft.Button("Clear", on_click=clear_storage), + ft.Button("Check Data Availability", on_click=get_availability), + ], + ), + ], + ), + ) + + +ft.run(main) diff --git a/sdk/python/packages/flet-secure-storage/LICENSE b/sdk/python/packages/flet-secure-storage/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sdk/python/packages/flet-secure-storage/pyproject.toml b/sdk/python/packages/flet-secure-storage/pyproject.toml new file mode 100644 index 0000000000..96be7d71c7 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "flet-secure-storage" +version = "0.1.0" +description = "Secure Storage control for Flet" +authors = [{name = "Appveyor Systems Inc.", email = "hello@flet.dev"}] +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "flet", +] + +[project.urls] +Homepage = "https://flet.dev" +Documentation = "https://docs.flet.dev/secure-storage" +Repository = "https://github.com/flet-dev/flet/tree/main/sdk/python/packages/flet-secure-storage" +Issues = "https://github.com/flet-dev/flet/issues" + +[tool.setuptools.package-data] +"flutter.flet_secure_storage" = ["**/*"] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" diff --git a/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/__init__.py b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/__init__.py new file mode 100644 index 0000000000..33ce1e2720 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/__init__.py @@ -0,0 +1,28 @@ +from flet_secure_storage.secure_storage import SecureStorage, SecureStorageEvent +from flet_secure_storage.types import ( + AccessControlFlag, + AndroidOptions, + AppleOptions, + IOSOptions, + KeychainAccessibility, + KeyCipherAlgorithm, + MacOsOptions, + StorageCipherAlgorithm, + WebOptions, + WindowsOptions, +) + +__all__ = [ + "AccessControlFlag", + "AndroidOptions", + "AppleOptions", + "IOSOptions", + "KeyCipherAlgorithm", + "KeychainAccessibility", + "MacOsOptions", + "SecureStorage", + "SecureStorageEvent", + "StorageCipherAlgorithm", + "WebOptions", + "WindowsOptions", +] diff --git a/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/secure_storage.py b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/secure_storage.py new file mode 100644 index 0000000000..6c28e70716 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/secure_storage.py @@ -0,0 +1,288 @@ +from dataclasses import dataclass, field +from typing import Any, Optional + +import flet as ft +from flet.controls.base_control import control +from flet.controls.services.service import Service +from flet_secure_storage.types import ( + AndroidOptions, + IOSOptions, + MacOsOptions, + WebOptions, + WindowsOptions, +) + + +@dataclass +class SecureStorageEvent(ft.Event["SecureStorage"]): + """ + The event fired by SecureStorage when availability changes. + """ + + available: Optional[bool] + """ + The availability of secure storage. True if secure storage is available, + False if not, None if unknown. + """ + + +@control("SecureStorage") +class SecureStorage(Service): + """ + A class to manage secure storage in a Flet application across multiple platforms. + """ + + ios_options: IOSOptions = field(default_factory=lambda: IOSOptions()) + """ + iOS-specific configuration for secure storage. + """ + + android_options: AndroidOptions = field(default_factory=lambda: AndroidOptions()) + """ + Android-specific configuration for secure storage. + """ + + windows_options: WindowsOptions = field(default_factory=lambda: WindowsOptions()) + """ + Windows-specific configuration for secure storage. + """ + + macos_options: MacOsOptions = field(default_factory=lambda: MacOsOptions()) + """ + macOS-specific configuration for secure storage. + """ + + web_options: WebOptions = field(default_factory=lambda: WebOptions()) + """ + Web-specific configuration for secure storage. + """ + + on_change: Optional[ft.EventHandler["SecureStorageEvent"]] = None + """ + Fires when secure storage availability changes. + + iOS only feature. For unsupported platforms, this event will never fire. + The payload is a `SecureStorageEvent` object with the `available` field. + """ + + async def get_availability(self) -> Optional[bool]: + """ + Gets the current availability status of secure storage. + + iOS and macOS only. On macOS, available only on macOS 12+. + On older macOS versions, always returns True. + On unsupported platforms, returns None. + + Returns: + A boolean indicating storage availability, or None if unsupported. + """ + return await self._invoke_method("get_availability") + + async def set( + self, + key: str, + value: Any, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> None: + """ + Stores a value in secure storage under the given key. + + Args: + key: The key to store the value under. + value: The value to store (cannot be None). + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + + Raises: + ValueError: If `value` is None. + """ + if value is None: + raise ValueError("value can't be None") + return await self._invoke_method( + method_name="set", + arguments={ + "key": key, + "value": value, + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) + + async def get( + self, + key: str, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> Optional[str]: + """ + Retrieves the value stored under the given key in secure storage. + + Args: + key: The key to retrieve. + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + + Returns: + The stored string value, or None if the key does not exist. + """ + return await self._invoke_method( + method_name="get", + arguments={ + "key": key, + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) + + async def get_all( + self, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> dict[str, str]: + """ + Retrieves all key-value pairs from secure storage. + + Args: + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + + Returns: + A dictionary with all stored key-value pairs. + """ + return await self._invoke_method( + method_name="get_all", + arguments={ + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) + + async def contains_key( + self, + key: str, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> bool: + """ + Checks whether the given key exists in secure storage. + + Args: + key: The key to check. + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + + Returns: + True if the key exists, False otherwise. + """ + return await self._invoke_method( + method_name="contains_key", + arguments={ + "key": key, + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) + + async def remove( + self, + key: str, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> None: + """ + Removes the value stored under the given key in secure storage. + + Args: + key: The key to remove. + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + """ + return await self._invoke_method( + method_name="remove", + arguments={ + "key": key, + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) + + async def clear( + self, + *, + web: Optional[WebOptions] = None, + ios: Optional[IOSOptions] = None, + macos: Optional[MacOsOptions] = None, + android: Optional[AndroidOptions] = None, + windows: Optional[WindowsOptions] = None, + ) -> None: + """ + Clears all key-value pairs from secure storage. + + Args: + web: Optional web-specific configuration. + ios: Optional iOS-specific configuration. + macos: Optional macOS-specific configuration. + android: Optional Android-specific configuration. + windows: Optional Windows-specific configuration. + """ + return await self._invoke_method( + method_name="clear", + arguments={ + "web": web, + "ios": ios, + "macos": macos, + "android": android, + "windows": windows, + }, + ) diff --git a/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/types.py b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/types.py new file mode 100644 index 0000000000..3beb9c3f2f --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flet_secure_storage/types.py @@ -0,0 +1,453 @@ +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Optional + + +class KeychainAccessibility(Enum): + """ + KeyChain accessibility attributes for iOS/macOS platforms. + + These attributes determine when the app can access secure values + stored in the Keychain. + """ + + PASSCODE = "passcode" + """ + The data in the keychain can only be accessed when the device is unlocked. + + Only available if a passcode is set on the device. + Items with this attribute do not migrate to a new device. + """ + + UNLOCKED = "unlocked" + """ + The data in the keychain item can be accessed only while + the device is unlocked by the user. + """ + + UNLOCKED_THIS_DEVICE = "unlocked_this_device" + """ + The data in the keychain item can be accessed only while + the device is unlocked by the user. + + Items with this attribute do not migrate to a new device. + """ + + FIRST_UNLOCK = "first_unlock" + """ + The data in the keychain item cannot be accessed after a restart until + the device has been unlocked once by the user. + + Enables access to secure values after the device is unlocked for the first + time after a reboot. + """ + + FIRST_UNLOCK_THIS_DEVICE = "first_unlock_this_device" + """ + The data in the keychain item cannot be accessed after + a restart until the device has been unlocked once by the user. + + Items with this attribute do not migrate to a new device. + + Allows access to secure values only after the device is unlocked for the first time + since installation on this device. + """ + + +class AccessControlFlag(Enum): + """ + Keychain access control flags that define security conditions for accessing items. + + These flags can be combined to create complex access control policies using + the `access_control_flags` parameter in `IOSOptions` or `MacOsOptions`. + + Rules for combining flags: + - Use `AccessControlFlag.OR` to allow access if any condition is met + - Use `AccessControlFlag.AND` to require that all specified conditions are met + - Only one logical operator (OR/AND) can be used per combination + """ + + DEVICE_PASSCODE = "devicePasscode" + """ + Constraint to access an item with a passcode. + """ + + BIOMETRY_ANY = "biometryAny" + """ + Constraint to access an item with biometrics (Touch ID/Face ID). + """ + + BIOMETRY_CURRENT_SET = "biometryCurrentSet" + """ + Constraint to access an item with the currently enrolled biometrics. + """ + + USER_PRESENCE = "userPresence" + """ + Constraint to access an item with either biometry or passcode. + """ + + WATCH = "watch" + """ + Constraint to access an item with a paired watch. + """ + + OR = "or" + """ + Combine multiple constraints with an OR operation. + """ + + AND = "and" + """ + Combine multiple constraints with an AND operation. + """ + + APPLICATION_PASSWORD = "applicationPassword" + """ + Use an application-provided password for encryption. + """ + + PRIVATE_KEY_USAGE = "privateKeyUsage" + """ + Enable private key usage for signing operations. + """ + + +class KeyCipherAlgorithm(Enum): + """ + Algorithm used to encrypt/wrap the secret key in Android KeyStore. + + Different algorithms provide different security guarantees and compatibility levels: + + - RSA algorithms wrap the AES encryption key with RSA (no biometric support) + - AES algorithm stores the key directly in Android KeyStore + (supports biometric authentication) + + See the [AndroidOptions] class for usage examples and combinations. + """ + + RSA_ECB_PKCS1_PADDING = "RSA_ECB_PKCS1Padding" + """ + Legacy RSA/ECB/PKCS1Padding for backwards compatibility. + """ + + RSA_ECB_OAEP_WITH_SHA256_AND_MGF1_PADDING = "RSA_ECB_OAEPwithSHA_256andMGF1Padding" + """ + RSA/ECB/OAEPWithSHA-256AndMGF1Padding (API 23+). + + This is the default and recommended algorithm for most use cases. + Provides strong authenticated encryption without biometrics. + """ + + AES_GCM_NO_PADDING = "AES_GCM_NoPadding" + """ + AES/GCM/NoPadding for KeyStore-based key wrapping (supports biometrics). + + Use this algorithm when you need biometric authentication support. + Requires API 23+ for basic use, API 28+ for enforced biometric authentication. + """ + + +class StorageCipherAlgorithm(Enum): + """ + Algorithm used to encrypt stored data on Android. + + Modern applications should use `AES_GCM_NO_PADDING` for better security. + The legacy `AES_CBC_PKCS7_PADDING` is provided for backwards compatibility only. + """ + + AES_CBC_PKCS7_PADDING = "AES_CBC_PKCS7Padding" + """ + Legacy AES/CBC/PKCS7Padding for backwards compatibility. + """ + + AES_GCM_NO_PADDING = "AES_GCM_NoPadding" + """ + AES/GCM/NoPadding (API 23+). + + This is the default and recommended storage cipher algorithm. + Provides authenticated encryption with associated data (AEAD). + """ + + +@dataclass +class AndroidOptions: + """ + Specific options for Android platform for secure storage. + + Provides configurable options for encryption, key wrapping, biometric enforcement, + and shared preferences naming. + """ + + reset_on_error: bool = True + """ + When an error is detected, automatically reset all data to prevent fatal errors + with unknown keys. + + Be aware that data is PERMANENTLY erased when this occurs. + """ + + migrate_on_algorithm_change: bool = True + """ + When the encryption algorithm changes, automatically migrate existing data + to the new algorithm. Preserves data across algorithm upgrades. + + If False, data may be lost when algorithm changes unless + reset_on_error is True. + """ + + enforce_biometrics: bool = False + """ + Whether to enforce biometric or PIN authentication. + + When True: + - The plugin throws an exception if no biometric/PIN is enrolled. + - The encryption key is generated with authentication required. + + When False: + - The plugin gracefully degrades if biometrics are unavailable. + - The key is generated without authentication required. + """ + + key_cipher_algorithm: KeyCipherAlgorithm = ( + KeyCipherAlgorithm.RSA_ECB_OAEP_WITH_SHA256_AND_MGF1_PADDING + ) + """ + Algorithm used to encrypt the secret key. + + Legacy RSA/ECB/PKCS1Padding is available for backwards compatibility. + """ + + storage_cipher_algorithm: StorageCipherAlgorithm = ( + StorageCipherAlgorithm.AES_GCM_NO_PADDING + ) + """ + Algorithm used to encrypt stored data. + + Legacy AES/CBC/PKCS7Padding is available for backwards compatibility. + """ + + shared_preferences_name: Optional[str] = None + """ + The name of the shared preferences database to use. + + Changing this will prevent access to already saved preferences. + """ + + preferences_key_prefix: Optional[str] = None + """ + Prefix for shared preference keys. Ensures keys are unique to your app. + + An underscore (_) is added automatically. + + Changing this prevents access to existing preferences. + """ + + biometric_prompt_title: str = "Authenticate to access" + """ + Title displayed in the biometric authentication prompt. + """ + + biometric_prompt_subtitle: str = "Use biometrics or device credentials" + """ + Subtitle displayed in the biometric authentication prompt. + """ + + +@dataclass +class AppleOptions: + """ + Specific options for Apple platforms (iOS/macOS) for secure storage. + + This class allows configuring keychain access and storage behavior. + Use `IOSOptions` for iOS-specific configuration + or `MacOsOptions` for macOS-specific configuration. + + Note: + - Most options apply to both iOS and macOS + - Some options (like `group_id` on macOS) only apply when + certain keychain flags are set + - See individual option documentation for platform-specific behavior + """ + + account_name: Optional[str] = "flet_secure_storage_service" + """ + Represents the service or application name associated with the item. + + Typically used to group related keychain items. + """ + + group_id: Optional[str] = None + """ + Specifies the app group for shared access. Allows multiple apps in the + same app group to access the item. + """ + + accessibility: Optional[KeychainAccessibility] = KeychainAccessibility.UNLOCKED + """ + Defines the accessibility level of the keychain item. + + Controls when the item is accessible (e.g., when device is unlocked + or after first unlock). + """ + + synchronizable: bool = False + """ + Indicates whether the keychain item should be synchronized with iCloud. + + - True: Enables synchronization across user's devices + - False: Item stays local to this device only + """ + + label: Optional[str] = None + """ + A user-visible label for the keychain item. + Helps identify the item in keychain management tools. + """ + + description: Optional[str] = None + """ + A description of the keychain item. + Can describe a category of items (shared) or a specific item (unique). + """ + + comment: Optional[str] = None + """ + A comment associated with the keychain item. + Often used for metadata or debugging information. + """ + + invisible: Optional[bool] = None + """ + Indicates whether the keychain item is hidden from user-visible lists. + Can apply to all items in a category (shared) or specific items (unique). + """ + + is_negative: Optional[bool] = None + """ + Indicates whether the item is a placeholder or a negative entry. + Typically unique to individual keychain items. + """ + + creation_date: Optional[datetime] = None + """ + The creation date of the keychain item. + Automatically set by the system when an item is created. + """ + + last_modified_date: Optional[datetime] = None + """ + The last modification date of the keychain item. + Automatically updated when an item is modified. + """ + + result_limit: Optional[int] = None + """ + Specifies the maximum number of results to return in a query. + For example, 1 for a single result, or `None` for all matching results. + """ + + is_persistent: Optional[bool] = None + """ + Indicates whether to return a persistent reference to the keychain item. + Used for persistent access across app sessions. + """ + + auth_ui_behavior: Optional[str] = None + """ + Controls how authentication UI is presented during secure operations. + Determines whether authentication prompts are displayed to the user. + """ + + access_control_flags: list[AccessControlFlag] = field(default_factory=list) + """ + Keychain access control flags that define security conditions for accessing items. + """ + + +@dataclass +class IOSOptions(AppleOptions): + """ + iOS-specific configuration for secure storage. + + All configurable options are inherited from `AppleOptions`. + There are currently no iOS-only options. + """ + + +@dataclass +class MacOsOptions(AppleOptions): + """ + Specific options for macOS platform. + Extends `AppleOptions` and adds the `usesDataProtectionKeychain` parameter. + """ + + uses_data_protection_keychain: bool = True + """ + Indicates whether the macOS data protection keychain is used. + """ + + +@dataclass +class WebOptions: + """ + Specific options for the Web platform for secure storage. + + Configures database, encryption, and storage behavior on web platforms. + """ + + db_name: str = "FletEncryptedStorage" + """ + The name of the database used for secure storage. + """ + + public_key: str = "FletSecureStorage" + """ + The public key used for encryption. + """ + + wrap_key: str = "" + """ + The key used to wrap the encryption key. + """ + + wrap_key_iv: str = "" + """ + The initialization vector (IV) used for the wrap key. + """ + + use_session_storage: bool = False + """ + Whether to use session storage instead of local storage. + """ + + +@dataclass +class WindowsOptions: + """ + Specific options for Windows platform for secure storage. + + Allows configuring backward compatibility when reading/writing + values from previous versions of storage. + + Note: + You need the C++ ATL libraries installed along with Visual Studio Build Tools. + Download from: https://visualstudio.microsoft.com/downloads/?q=build+tools + Make sure the C++ ATL under optional components is installed as well. + """ + + use_backward_compatibility: bool = False + """ + If True, attempts to read values written by previous versions of the storage. + When reading or writing old storage values, they will be automatically + migrated to new storage. + + Note: + - May introduce performance overhead. + - May cause errors for keys with `"`, `<`, `>`, `|`, `:`, `*`, `?`, `/`, `\\`. + or any ASCII control characters. + - May cause errors for keys containing `/../`, `\\..\\`, or similar patterns. + - May cause errors for very long keys (length depends on app's product name, + company name, and executing account). + """ diff --git a/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/flet_secure_storage.dart b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/flet_secure_storage.dart new file mode 100644 index 0000000000..faabfd5681 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/flet_secure_storage.dart @@ -0,0 +1,3 @@ +library flet_secure_storage; + +export "src/extension.dart" show Extension; diff --git a/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/extension.dart b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/extension.dart new file mode 100644 index 0000000000..46135b14f1 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/extension.dart @@ -0,0 +1,15 @@ +import 'package:flet/flet.dart'; + +import 'secure_storage.dart'; + +class Extension extends FletExtension { + @override + FletService? createService(Control control) { + switch (control.type) { + case "SecureStorage": + return SecureStorageService(control: control); + default: + return null; + } + } +} diff --git a/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/secure_storage.dart b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/secure_storage.dart new file mode 100644 index 0000000000..ed194b68b9 --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/secure_storage.dart @@ -0,0 +1,143 @@ +import 'dart:async'; + +import 'package:flet/flet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'utils/secure_storage.dart'; + +class SecureStorageService extends FletService { + SecureStorageService({required super.control}); + + FlutterSecureStorage? _storage; + StreamSubscription? _onSecureDataChanged; + AndroidOptions androidOptions = AndroidOptions.defaultOptions; + WindowsOptions windowsOptions = WindowsOptions.defaultOptions; + LinuxOptions linuxOptions = LinuxOptions.defaultOptions; + MacOsOptions macOsOptions = MacOsOptions.defaultOptions; + IOSOptions iosOptions = IOSOptions.defaultOptions; + WebOptions webOptions = WebOptions.defaultOptions; + + FlutterSecureStorage get storage { + return _storage ??= FlutterSecureStorage( + lOptions: linuxOptions, + aOptions: parseAndroidOptions(control.get("android_options"), androidOptions)!, + wOptions: parseWindowsOptions(control.get("windows_options"), windowsOptions)!, + mOptions: parseMacOptions(control.get("macos_options"), macOsOptions)!, + iOptions: parseIosOptions(control.get("ios_options"), iosOptions)!, + webOptions: parseWebOptions(control.get("web_options"), webOptions)!, + ); + } + + @override + void init() { + super.init(); + debugPrint("SecureStorageService(${control.id}).init: ${control.properties}"); + control.addInvokeMethodListener(_invokeMethod); + _updateListeners(); + } + + @override + void update() { + debugPrint("SecureStorageService(${control.id}).update: ${control.properties}"); + _updateListeners(); + } + + void _updateListeners() { + final listenChange = control.getBool("on_change") == true; + if (listenChange && _onSecureDataChanged == null) { + _onSecureDataChanged = storage.onCupertinoProtectedDataAvailabilityChanged?.listen( + (bool result) { + control.triggerEvent("change", {"available": result}); + }, onError: (error) { + debugPrint("SecureStorageService: error listening to connectivity: $error"); + } + ); + } else if (!listenChange && _onSecureDataChanged != null) { + _onSecureDataChanged?.cancel(); + _onSecureDataChanged = null; + } + } + + Future _invokeMethod(String name, dynamic args) async { + AndroidOptions aOptions = parseAndroidOptions(args?["android"], storage.aOptions)!; + IOSOptions iOptions = parseIosOptions(args?["ios"], storage.iOptions)!; + LinuxOptions lOptions = storage.lOptions; + WindowsOptions wOptions = parseWindowsOptions(args?["windows"], storage.wOptions)!; + WebOptions webOptions = parseWebOptions(args?["web"], storage.webOptions)!; + MacOsOptions mOptions = parseMacOptions(args?["macos"], storage.mOptions as MacOsOptions)!; + switch (name) { + case "set": + return await storage.write( + key: args["key"]!, + value: args["value"]!, + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "get": + return await storage.read( + key: args["key"]!, + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "get_all": + return await storage.readAll( + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "contains_key": + return await storage.containsKey( + key: args["key"]!, + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "remove": + return await storage.delete( + key: args["key"]!, + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "clear": + return await storage.deleteAll( + aOptions: aOptions, + iOptions: iOptions, + lOptions: lOptions, + wOptions: wOptions, + webOptions: webOptions, + mOptions: mOptions, + ); + case "get_availability": + return await storage.isCupertinoProtectedDataAvailable(); + default: + throw Exception("Unknown SecureStorage method: $name"); + } + } + + @override + void dispose() { + debugPrint("SecureStorageService(${control.id}).dispose()"); + control.removeInvokeMethodListener(_invokeMethod); + _onSecureDataChanged?.cancel(); + super.dispose(); + } +} diff --git a/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/utils/secure_storage.dart b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/utils/secure_storage.dart new file mode 100644 index 0000000000..16ae34884d --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/lib/src/utils/secure_storage.dart @@ -0,0 +1,123 @@ +import 'package:collection/collection.dart'; +import 'package:flet/flet.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +IOSOptions? parseIosOptions(dynamic value, [IOSOptions? defaultValue]) { + if (value == null) return defaultValue; + return IOSOptions( + accountName: value["account_name"], + groupId: value["group_id"], + accessibility: parseKeychainAccessibility(value["accessibility"], KeychainAccessibility.unlocked), + synchronizable: parseBool(value["synchronizable"], false)!, + label: value["label"], + description: value["description"], + comment: value["comment"], + isInvisible: parseBool(value["is_invisible"]), + isNegative: parseBool(value["is_negative"]), + creationDate: value["creation_date"], + lastModifiedDate: value["last_modified_date"], + resultLimit: parseInt(value["result_limit"]), + shouldReturnPersistentReference: parseBool(value["is_persistent"], false)!, + authenticationUIBehavior: value["auth_ui_behavior"], + accessControlFlags: parseAccessControlFlags(value["access_control_flags"], const [])!, + ); +} + +MacOsOptions? parseMacOptions(dynamic value, [MacOsOptions? defaultValue]) { + if (value == null) return defaultValue; + return MacOsOptions( + accountName: value["account_name"], + groupId: value["group_id"], + accessibility: parseKeychainAccessibility(value["accessibility"], KeychainAccessibility.unlocked), + synchronizable: parseBool(value["synchronizable"], false)!, + label: value["label"], + description: value["description"], + comment: value["comment"], + isInvisible: parseBool(value["is_invisible"]), + isNegative: parseBool(value["is_negative"]), + creationDate: value["creation_date"], + lastModifiedDate: value["last_modified_date"], + resultLimit: parseInt(value["result_limit"]), + shouldReturnPersistentReference: parseBool(value["is_persistent"]), + authenticationUIBehavior: value["auth_ui_behavior"], + accessControlFlags: parseAccessControlFlags(value["access_control_flags"], const [])!, + usesDataProtectionKeychain: parseBool(value["uses_data_protection_keychain"], true)! + ); +} + +AndroidOptions? parseAndroidOptions(dynamic value, [AndroidOptions? defaultValue]) { + if (value == null) return defaultValue; + return AndroidOptions( + resetOnError: parseBool(value['reset_on_error'], true)!, + migrateOnAlgorithmChange: parseBool(value['migrate_on_algorithm_change'], true)!, + enforceBiometrics: parseBool(value['enforce_biometrics'], false)!, + keyCipherAlgorithm: parseKeyCipherAlgorithm( + value['key_cipher_algorithm'], + KeyCipherAlgorithm.RSA_ECB_OAEPwithSHA_256andMGF1Padding + )!, + storageCipherAlgorithm: parseStorageCipherAlgorithm( + value['storage_cipher_algorithm'], + StorageCipherAlgorithm.AES_GCM_NoPadding + )!, + sharedPreferencesName: value['shared_preferences_name'], + preferencesKeyPrefix: value['preferences_key_prefix'], + biometricPromptTitle: value['biometric_prompt_title'], + biometricPromptSubtitle: value['biometric_prompt_subtitle'], + ); +} + +WindowsOptions? parseWindowsOptions(dynamic value, [WindowsOptions? defaultValue]) { + if (value == null) return defaultValue; + return WindowsOptions( + useBackwardCompatibility: parseBool(value["use_backward_compatibility"], false)!, + ); +} + +WebOptions? parseWebOptions(dynamic value, [WebOptions? defaultValue]) { + if (value == null) return defaultValue; + return WebOptions( + dbName: value["db_name"] ?? "FlutterEncryptedStorage", + publicKey: value["public_key"] ?? "FlutterSecureStorage", + wrapKey: value["wrap_key"] ?? "", + wrapKeyIv: value["wrap_key_iv"] ?? "", + useSessionStorage: parseBool(value["use_session_storage"], false)!, + ); +} + +KeychainAccessibility? parseKeychainAccessibility(String? value, [KeychainAccessibility? defaultValue]) { + if (value == null) return defaultValue; + return KeychainAccessibility.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase() + ) ?? defaultValue; +} + +KeyCipherAlgorithm? parseKeyCipherAlgorithm(String? value, [KeyCipherAlgorithm? defaultValue]) { + if (value == null) return defaultValue; + return KeyCipherAlgorithm.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase() + ) ?? defaultValue; +} + +StorageCipherAlgorithm? parseStorageCipherAlgorithm(String? value, [StorageCipherAlgorithm? defaultValue]) { + if (value == null) return defaultValue; + return StorageCipherAlgorithm.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase() + ) ?? defaultValue; +} + +AccessControlFlag? parseAccessControlFlag(String? value, [AccessControlFlag? defaultValue]) { + if (value == null) return defaultValue; + return AccessControlFlag.values.firstWhereOrNull( + (e) => e.name.toLowerCase() == value.toLowerCase() + ) ?? defaultValue; +} + +List? parseAccessControlFlags(List? value, [List? defaultValue,]) { + if (value == null) return defaultValue; + + return value + .whereType() + .map((e) => parseAccessControlFlag(e)).nonNulls + .whereType() + .toList(); +} diff --git a/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/pubspec.yaml b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/pubspec.yaml new file mode 100644 index 0000000000..eb1322748e --- /dev/null +++ b/sdk/python/packages/flet-secure-storage/src/flutter/flet_secure_storage/pubspec.yaml @@ -0,0 +1,22 @@ +name: flet_secure_storage +description: Flet Secure Storage control +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + flutter_secure_storage: 10.0.0 + + flet: + path: ../../../../../../../packages/flet + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 diff --git a/sdk/python/packages/flet/docs/extend/built-in-extensions.md b/sdk/python/packages/flet/docs/extend/built-in-extensions.md index a3e8fac70f..c66f8431e9 100644 --- a/sdk/python/packages/flet/docs/extend/built-in-extensions.md +++ b/sdk/python/packages/flet/docs/extend/built-in-extensions.md @@ -11,6 +11,7 @@ Flet controls based on 3rd-party Flutter packages that used to be a part of Flet * [flet-map](https://pypi.org/project/flet-map/) * [flet-permission-handler](https://pypi.org/project/flet-permission-handler/) * [flet-rive](https://pypi.org/project/flet-rive/) +* [flet-secure-storage](https://pypi.org/project/flet-secure-storage/) * [flet-video](https://pypi.org/project/flet-video/) * [flet-webview](https://pypi.org/project/flet-webview/) diff --git a/sdk/python/packages/flet/docs/secure_storage/index.md b/sdk/python/packages/flet/docs/secure_storage/index.md new file mode 100644 index 0000000000..0e1d9b8cc1 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/index.md @@ -0,0 +1,60 @@ +--- +class_name: flet_secure_storage.SecureStorage +examples: ../../examples/services/secure_storage +--- + +# Secure Storage + +A service for safely storing sensitive key–value data using the platform’s native secure storage mechanisms—Keychain on iOS/macOS, Windows Credential Manager, libsecret on Linux, and Keystore on Android. + +Powered by Flutter's [`flutter_secure_storage`](https://pub.dev/packages/flutter_secure_storage) package. + +/// admonition + type: note +You need `libsecret-1-dev` on your machine to build the project, and `libsecret-1-0` to run the application (add it as a dependency after packaging your app). If you using snapcraft to build the project use the following. + +Apart from `libsecret` you also need a keyring service, for that you need either [`gnome-keyring`](https://wiki.gnome.org/Projects/GnomeKeyring) (for Gnome users) or [`kwalletmanager`](https://wiki.archlinux.org/title/KDE_Wallet) (for KDE users) or other light provider like [`secret-service`](https://github.com/yousefvand/secret-service). + +```bash +sudo apt-get install libsecret-1-dev libsecret-1-0 +``` +/// + +## Platform Support + +| Platform | Windows | macOS | Linux | iOS | Android | Web | +|----------|---------|-------|-------|-----|---------|-----| +| Supported| ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +## Usage + +Add `flet-secure-storage` to your project dependencies: + +/// tab | uv +```bash +uv add flet-secure-storage +``` + +/// +/// tab | pip +```bash +pip install flet-secure-storage # (1)! +``` + +1. After this, you will have to manually add this package to your `requirements.txt` or `pyproject.toml`. +/// + +/// admonition | Hosting Rive files + type: tip +Host `.riv` files locally or load them from a CDN. Use `placeholder` to keep layouts responsive while animations load. +/// + +## Example + +```python +--8<-- "{{ examples }}/basic.py" +``` + +## Description + +{{ class_all_options(class_name) }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/access_control_flag.md b/sdk/python/packages/flet/docs/secure_storage/types/access_control_flag.md new file mode 100644 index 0000000000..945b0550f5 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/access_control_flag.md @@ -0,0 +1,15 @@ +{{ class_all_options("flet_secure_storage.types.AccessControlFlag", separate_signature=False) }} + + +## Usage example +Require biometrics OR device passcode: + +```python +options = IOSOptions( + access_control_flags=[ + AccessControlFlag.BIOMETRY_ANY, + AccessControlFlag.OR, + AccessControlFlag.DEVICE_PASSCODE + ] +) +``` diff --git a/sdk/python/packages/flet/docs/secure_storage/types/android_options.md b/sdk/python/packages/flet/docs/secure_storage/types/android_options.md new file mode 100644 index 0000000000..60b4c7ea1d --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/android_options.md @@ -0,0 +1,129 @@ +{{ class_all_options("flet_secure_storage.types.AndroidOptions") }} + +## Disabling Auto Backup + +By default Android backups data on Google Drive. It can cause exception `java.security.InvalidKeyException: Failed to unwrap key`. +You need to: + +- [Disable autobackup](https://developer.android.com/guide/topics/data/autobackup#EnablingAutoBackup), [details](https://github.com/juliansteenbakker/flutter_secure_storage/issues/13#issuecomment-421083742) +- [Exclude sharedprefs](https://developer.android.com/guide/topics/data/autobackup#IncludingFiles) used by `SecureStorage` + +Add the following to your `pyproject.toml`: + +```toml +[tool.flet.android.manifest_application] +"allowBackup" = "false" +"fullBackupContent" = "false" +``` + +## Encryption Options + +### Default +```python +AndroidOptions() +``` + +- **Key Cipher:** RSA/ECB/OAEPWithSHA-256AndMGF1Padding +- **Storage Cipher:** AES/GCM/NoPadding +- **Biometric Support:** No +- **Description:** Standard secure storage with RSA OAEP key wrapping. Strong authenticated encryption without biometrics. Recommended for most use cases. + +### Optional Biometrics +```python +AndroidOptions( + enforce_biometrics=False, + key_cipher_algorithm=KeyCipherAlgorithm.AES_GCM_NO_PADDING, +) +``` + +- **Key Cipher:** AES/GCM/NoPadding +- **Storage Cipher:** AES/GCM/NoPadding +- **Biometric Support:** Optional +- **Description:** KeyStore-based with optional biometric authentication. Gracefully degrades if biometrics unavailable. + +### Required Biometrics +```python +AndroidOptions( + enforce_biometrics=True, + key_cipher_algorithm=KeyCipherAlgorithm.AES_GCM_NO_PADDING, +) +``` + +- **Key Cipher:** AES/GCM/NoPadding +- **Storage Cipher:** AES/GCM/NoPadding +- **Biometric Support:** Required (API 28+) +- **Description:** KeyStore-based requiring biometric/PIN authentication. Throws error if device security not available. + +## Custom Cipher Combinations + +For advanced users, all combinations below are supported using the `AndroidOptions()` constructor with custom parameters: + +| Key Cipher Algorithm | Storage Cipher Algorithm | Implementation | Biometric Support | +|---------------------------------------------|--------------------------|-----------------|-------------------------------------| +| `RSA_ECB_PKCS1_PADDING` | `AES_CBC_PKCS7_PADDING` | RSA-wrapped AES | No | +| `RSA_ECB_PKCS1_PADDING` | `AES_GCM_NO_PADDING` | RSA-wrapped AES | No | +| `RSA_ECB_OAEP_WITH_SHA256_AND_MGF1_PADDING` | `AES_CBC_PKCS7_PADDING` | RSA-wrapped AES | No | +| `RSA_ECB_OAEP_WITH_SHA256_AND_MGF1_PADDING` | `AES_GCM_NO_PADDING` | RSA-wrapped AES | No | +| `AES_GCM_NO_PADDING` | `AES_CBC_PKCS7_PADDING` | KeyStore AES | Optional (via `enforce_biometrics`) | +| `AES_GCM_NO_PADDING` | `AES_GCM_NO_PADDING` | KeyStore AES | Optional (via `enforce_biometrics`) | + + +## Biometric Authentication + +Secure Storage supports biometric authentication (fingerprint, face recognition, etc.) on Android API 23+. + +### Required Permissions + +To use biometric authentication on Android, you need to grant the necessary permissions (`USE_BIOMETRIC` and optionally `USE_FINGERPRINT`) in your project. + +For configure permissions in your `pyproject.toml` or when building the app using `flet build`. + +See the official Flet documentation for details: [Android Permissions in Flet](https://docs.flet.dev/publish/android/#permissions) + +Example configuration in `pyproject.toml`: + +```toml +[tool.flet.android.permission] +"android.permission.USE_BIOMETRIC" = true +"android.permission.USE_FINGERPRINT" = true +``` + +You can also pass permissions when building your Android app: + +```bash +flet build \ + --android-permissions android.permission.USE_BIOMETRIC=True \ + android.permission.USE_FINGERPRINT=True +``` + +This ensures that biometric authentication works correctly on all supported Android devices. + +### Using Biometric Authentication + +You can enable biometric authentication: + +```python +# Optional biometric authentication (graceful degradation) +storage = SecureStorage( + android_options=AndroidOptions( + enforce_biometrics=False, # Default - works without biometrics + biometric_prompt_title='Unlock to access your data', + biometric_prompt_subtitle='Use fingerprint or face unlock', + ), +) + +# Strict biometric enforcement (requires device security) +storage = SecureStorage( + android_options=AndroidOptions( + enforce_biometrics=True, # Requires biometric/PIN/pattern + biometric_prompt_title: 'Biometric authentication required', + ), +) +``` + +### Requirements + +- **API Level**: Android 6.0 (API 23) minimum for basic encryption +- **API Level**: Android 9.0 (API 28) minimum for enforced biometric authentication +- **Device Security**: Device must have a PIN, pattern, password, or biometric enrolled (when using `enforce_biometrics = True`) +- **Permissions**: `USE_BIOMETRIC` permission in [pyproject.toml](#required-permissions) diff --git a/sdk/python/packages/flet/docs/secure_storage/types/apple_options.md b/sdk/python/packages/flet/docs/secure_storage/types/apple_options.md new file mode 100644 index 0000000000..fbee05330c --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/apple_options.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.AppleOptions") }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/ios_options.md b/sdk/python/packages/flet/docs/secure_storage/types/ios_options.md new file mode 100644 index 0000000000..944bab266f --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/ios_options.md @@ -0,0 +1,32 @@ +{{ class_all_options("flet_secure_storage.types.IOSOptions") }} + +## Usage Example + +### Usage with accessibility control +```python +from flet_secure_storage import SecureStorage +from flet_secure_storage.types import IOSOptions, KeychainAccessibility + +storage = SecureStorage( + ios_options=IOSOptions( + accessibility=KeychainAccessibility.FIRST_UNLOCK + ) +) + +await storage.set(key="token", value="secret_value") +``` + +### Biometric authentication: +```python +from flet_secure_storage.types import IOSOptions, AccessControlFlag + +options = IOSOptions( + access_control_flags=[ + AccessControlFlag.BIOMETRY_ANY, + AccessControlFlag.OR, + AccessControlFlag.DEVICE_PASSCODE + ] +) + +await storage.set(key="secure_key", value="secure_value", ios=options) +``` diff --git a/sdk/python/packages/flet/docs/secure_storage/types/key_cipher_algorithm.md b/sdk/python/packages/flet/docs/secure_storage/types/key_cipher_algorithm.md new file mode 100644 index 0000000000..3bcbe3ecd0 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/key_cipher_algorithm.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.KeyCipherAlgorithm", separate_signature=False) }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/keychain_accessibility.md b/sdk/python/packages/flet/docs/secure_storage/types/keychain_accessibility.md new file mode 100644 index 0000000000..6e2a758def --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/keychain_accessibility.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.KeychainAccessibility", separate_signature=False) }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/macos_options.md b/sdk/python/packages/flet/docs/secure_storage/types/macos_options.md new file mode 100644 index 0000000000..1012ddf3c0 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/macos_options.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.MacOsOptions") }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/secure_storage_event.md b/sdk/python/packages/flet/docs/secure_storage/types/secure_storage_event.md new file mode 100644 index 0000000000..319bbea415 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/secure_storage_event.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.SecureStorageEvent") }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/storage_cipher_algorithm.md b/sdk/python/packages/flet/docs/secure_storage/types/storage_cipher_algorithm.md new file mode 100644 index 0000000000..ced3a994fa --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/storage_cipher_algorithm.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.StorageCipherAlgorithm", separate_signature=False) }} diff --git a/sdk/python/packages/flet/docs/secure_storage/types/web_options.md b/sdk/python/packages/flet/docs/secure_storage/types/web_options.md new file mode 100644 index 0000000000..d21e291dbf --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/web_options.md @@ -0,0 +1,35 @@ +{{ class_all_options("flet_secure_storage.types.WebOptions") }} + +## Important Security Considerations + +SecureStorage uses an experimental implementation using WebCrypto API. +Use at your own risk. The browser creates the private key, and encrypted +strings in localStorage are not portable to other browsers or machines +and will only work on the same domain. + +You MUST have HTTP Strict Forward Secrecy enabled and proper +headers applied to your responses, or you could be subject to JavaScript hijacking. + +Required security measures: + +- Enable HSTS (HTTP Strict Transport Security) +- Use proper security headers + +References: + +- https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security +- https://www.netsparker.com/blog/web-security/http-security-headers/ + +## Application-Specific Key Wrapping + +On web, all keys are stored in LocalStorage. You can wrap this stored key +with an application-specific key to make it more difficult to analyze: + +```python +storage = SecureStorage( + web_options=WebOptions( + wrap_key='your_application_specific_key', + wrap_key_iv='your_application_specific_iv', + ), +) +``` diff --git a/sdk/python/packages/flet/docs/secure_storage/types/windows_options.md b/sdk/python/packages/flet/docs/secure_storage/types/windows_options.md new file mode 100644 index 0000000000..65b406ca00 --- /dev/null +++ b/sdk/python/packages/flet/docs/secure_storage/types/windows_options.md @@ -0,0 +1 @@ +{{ class_all_options("flet_secure_storage.types.WindowsOptions") }} diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 768bc2cbfe..b9d8c5dd5e 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -477,6 +477,7 @@ nav: - SemanticsService: services/semanticsservice.md - ShakeDetector: services/shakedetector.md - Share: services/share.md + - SecureStorage: secure_storage/index.md - SharedPreferences: services/sharedpreferences.md - StoragePaths: services/storagepaths.md - UrlLauncher: services/urllauncher.md @@ -683,6 +684,13 @@ nav: - ResponsiveRowBreakpoint: types/responsiverowbreakpoint.md - Rotate: types/rotate.md - Scale: types/scale.md + - SecureStorage: + - WindowsOptions: secure_storage/types/windows_options.md + - AndroidOptions: secure_storage/types/android_options.md + - AppleOptions: secure_storage/types/apple_options.md + - MacOsOptions: secure_storage/types/macos_options.md + - IOSOptions: secure_storage/types/ios_options.md + - WebOptions: secure_storage/types/web_options.md - ShapeBorder: types/shapeborder.md - Size: types/size.md - StrutStyle: types/strutstyle.md @@ -851,6 +859,10 @@ nav: - WebRenderer: types/webrenderer.md - WindowEventType: types/windoweventtype.md - WindowResizeEdge: types/windowresizeedge.md + - AccessControlFlag: secure_storage/types/access_control_flag.md + - KeychainAccessibility: secure_storage/types/keychain_accessibility.md + - KeyCipherAlgorithm: secure_storage/types/key_cipher_algorithm.md + - StorageCipherAlgorithm: secure_storage/types/storage_cipher_algorithm.md - Events: - AccelerometerReadingEvent: types/accelerometerreadingevent.md - Ads: @@ -907,6 +919,7 @@ nav: - ShareFile: types/sharefile.md - ShareCupertinoActivityType: types/sharecupertinoactivitytype.md - ScrollEvent: types/scrollevent.md + - SecureStorageEvent: secure_storage/types/secure_storage_event.md - TabBarHoverEvent: types/tabbarhoverevent.md - TapEvent: types/tapevent.md - TextSelectionChangeEvent: types/textselectionchangeevent.md diff --git a/sdk/python/packages/flet/pyproject.toml b/sdk/python/packages/flet/pyproject.toml index 2c24e61774..c80f91cc27 100644 --- a/sdk/python/packages/flet/pyproject.toml +++ b/sdk/python/packages/flet/pyproject.toml @@ -51,6 +51,7 @@ extensions = [ "flet-map", "flet-permission-handler", "flet-rive", + "flet-secure-storage", "flet-video", "flet-webview", ] diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 3408f6d2b8..aec3874617 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -22,8 +22,9 @@ dependencies = [ "flet-map", "flet-permission-handler", "flet-rive", + "flet-secure-storage", "flet-video", - "flet-webview" + "flet-webview", ] [tool.uv.sources] @@ -43,6 +44,7 @@ flet-lottie = { workspace = true } flet-map = { workspace = true } flet-permission-handler = { workspace = true } flet-rive = { workspace = true } +flet-secure-storage = { workspace = true } flet-video = { workspace = true } flet-webview = { workspace = true } mkdocs-external-images = { git = "https://github.com/flet-dev/mkdocs-external-images", tag = "v0.2.0" } @@ -126,6 +128,7 @@ isort = { known-first-party = [ "flet_map", "flet_permission_handler", "flet_rive", + "flet_secure_storage", "flet_video", "flet_webview" ] }