From 4677a96ce00d92165b317f1827ff68cdc2dd6256 Mon Sep 17 00:00:00 2001 From: Dwiki Riyadi Date: Tue, 20 Oct 2026 20:25:56 +0700 Subject: [PATCH] docs: sync the Guide with the Dart S4 SecureStore findings and ADR-26 Co-Authored-By: Claude Opus 5.5 --- .../development/guide/02-packages/08-storage.mdx | 14 +++++++------- .../development/guide/04-project/02-decisions.mdx | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/internal/development/guide/02-packages/08-storage.mdx b/docs/internal/development/guide/02-packages/08-storage.mdx index 099ea44..bd0cadb 100644 --- a/docs/internal/development/guide/02-packages/08-storage.mdx +++ b/docs/internal/development/guide/02-packages/08-storage.mdx @@ -291,26 +291,26 @@ suspend fun page(page: Int, limit: Int): PagingEntity = pagedQuery( ### 1. Why {#dart-1-why} -On Flutter, the base stores map onto two well-known plugins: `shared_preferences` for preferences and `flutter_secure_storage` for secrets. This note configures them safely, adds the iOS reinstall rule and the 4 KB limit, and gives the storage guard its handlers for the errors these plugins and the common database packages throw. +On Flutter, the base stores sit on two well-known plugins: `shared_preferences` for preferences, and `flutter_secure_storage` for the key that encrypts secrets (ADR-26). This note configures them safely and gives the storage guard its handlers for the errors these plugins and the common database packages throw. ### 2. Shape {#dart-2-shape} | Store | Backed by | Configuration | |---|---|---| | `KeyValueStore` | `SharedPreferencesAsync` (no in-memory cache, always read from the platform) | keys prefixed with the store's name | -| `SecureStore` | `flutter_secure_storage` | iOS/macOS `accessibility: KeychainAccessibility.first_unlock_this_device`; Android's default Keystore-backed ciphers; web uses Web Crypto-wrapped `localStorage` (weaker, documented) | +| `SecureStore` | a `KeyValueStore` (`_secure`) whose values `FieldCipher` (`sinew.secure.`) encrypts, as in Kotlin (ADR-26) | `:` as associated data; the cipher's key in `flutter_secure_storage` (iOS/macOS `first_unlock_this_device`, Keystore-protected on Android, Web Crypto-wrapped `localStorage` on web: weaker, documented) | ```dart abstract interface class SecureStore implements InspectableStore { - static Future create({String namespace = 'sinew', bool clearOnReinstall = true}); + static Future create({String namespace = 'sinew'}); Future read(String key); - Future write(String key, String value); // over 4 KB → StorageWriteException through the guard + Future write(String key, String value); Future delete(String key); Future clearUserData(); } ``` -**The reinstall rule.** `SecureStore.create` and `BiometricVault.create` both look for a marker in `shared_preferences`, which an uninstall deletes. With no marker, each deletes its own items (the store's namespace, the vault's Keychain item), then writes the marker. On iOS and macOS this clears Keychain items left by a previous install. Elsewhere it's harmless. +**The reinstall rule.** The secrets are in the app's preferences, which an uninstall deletes, so a reinstall starts empty with no marker; on iOS the cipher's key survives in the Keychain, unused. `BiometricVault` keeps its own Keychain item and clears it with its own install marker (`clearOnReinstall`). **Serialized writes.** Each store chains its writes on one `Future`, so two writes from different `async` calls never interleave. @@ -348,7 +348,7 @@ Future> page(int page, int limit) => pagedQuery(page, lim ### 4. Build steps {#dart-4-build-steps} 1. `KeyValueStore`, with `SharedPreferencesAsync`'s in-memory platform in unit tests and an integration test per platform. -2. `SecureStore`, unit-tested with `FlutterSecureStorage.setMockInitialValues`. Then integration tests: +2. `SecureStore`, unit-tested with `FlutterSecureStorage.setMockInitialValues` and `SharedPreferencesAsync`'s in-memory platform. Then integration tests: - the reinstall rule on the iOS simulator; - an undecryptable value reads as absent on Android. 3. `processStorageCall` with one test per handler row. @@ -369,7 +369,7 @@ Future> page(int page, int limit) => pagedQuery(page, lim |---|---| | `flutter_secure_storage` fails to read after an Android restore (keys invalid) | Treated as corruption: the value is absent, and the store deletes it. | | Web private mode | Storage may be cleared when the window closes. Values read as absent, the session ends. | -| A secure value over 4 KB | Rejected with `StorageWriteException`. Large data goes in the database, encrypted with `FieldCipher`. | +| A large secure value | No limit is enforced (the values are in the preferences, not the Keychain), but `SecureStore` is for small secrets: large data goes in the database, encrypted with `FieldCipher`. | :::info[Decided by default (revisit during implementation)] diff --git a/docs/internal/development/guide/04-project/02-decisions.mdx b/docs/internal/development/guide/04-project/02-decisions.mdx index 7582099..1001a7c 100644 --- a/docs/internal/development/guide/04-project/02-decisions.mdx +++ b/docs/internal/development/guide/04-project/02-decisions.mdx @@ -144,7 +144,7 @@ A short log of the decisions that shape Sinew. An ADR is never edited after it's - **Decision:** `store(secret, reason)` returns `BiometricStore` (`Stored | Failed(reason)`): Android prompts once when the user opts in; iOS adds its protected item without a prompt. `isStored()` answers without a prompt. A hybrid scheme (a public-key wrap needing no match) was rejected: it composes primitives the security rules keep out. Decided 2026-10-15. - **Consequences:** opting in confirms the finger works on Android. `unlock` with nothing stored returns `Failed(Invalidated)`. -### ADR-26 Kotlin's SecureStore is one encrypted store on every target +### ADR-26 SecureStore is one encrypted store on every target - **Context:** the plan kept `SecureStore` secrets as Keychain items on iOS, with a reinstall marker to clear them, and an encrypted store elsewhere; `FieldCipher` and `KeyValueStore` now exist on every target. - **Decision:** on Kotlin, `SecureStore` is a dedicated `KeyValueStore` (`_secure`) whose values a `FieldCipher` (`sinew.secure.`) encrypts, with the namespace and key as associated data, on every target. The cipher's key is in the platform's key storage (on iOS, the Keychain, this-device-only). `clearOnReinstall` is dropped: the data file goes with the app, so a reinstall starts empty with no marker. A value that can't be decrypted reads as absent and is deleted. Decided 2026-10-18. -- **Consequences:** one implementation and one test suite. A restore onto another device or a lost key reads as absent (signed out). Flutter keeps its Keychain items and marker. +- **Consequences:** one implementation and one test suite. A restore onto another device or a lost key reads as absent (signed out). Flutter follows on 2026-10-19 (S4, #27): the same design over its `KeyValueStore` and `FieldCipher`, with only the cipher's key in `flutter_secure_storage`, in place of one Keychain item per secret and a reinstall marker.