Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions docs/internal/development/guide/02-packages/08-storage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -291,26 +291,26 @@ suspend fun page(page: Int, limit: Int): PagingEntity<OrderEntity> = 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` (`<namespace>_secure`) whose values `FieldCipher` (`sinew.secure.<namespace>`) encrypts, as in Kotlin (ADR-26) | `<namespace>:<key>` 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<SecureStore> create({String namespace = 'sinew', bool clearOnReinstall = true});
static Future<SecureStore> create({String namespace = 'sinew'});
Future<String?> read(String key);
Future<void> write(String key, String value); // over 4 KB → StorageWriteException through the guard
Future<void> write(String key, String value);
Future<void> delete(String key);
Future<void> 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.

Expand Down Expand Up @@ -348,7 +348,7 @@ Future<PagingEntity<OrderRow>> 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.
Expand All @@ -369,7 +369,7 @@ Future<PagingEntity<OrderRow>> 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)]

Expand Down
4 changes: 2 additions & 2 deletions docs/internal/development/guide/04-project/02-decisions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<namespace>_secure`) whose values a `FieldCipher` (`sinew.secure.<namespace>`) 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.
Loading