just_storage 1.1.0
just_storage: ^1.1.0 copied to clipboard
Standard and secure key-value storage with typed helpers and reactive streams.
Just Storage #
A Flutter package providing standard and secure key-value storage with typed JSON helpers and reactive watch streams — via JustStandardStorage and JustSecureStorage.
Native implementations are built from scratch using only dart:io, path_provider (for directory resolution), and pointycastle (AES-256-GCM encryption). On web, storage is backed by the browser's localStorage. No third-party storage wrappers (shared_preferences, flutter_secure_storage, etc.) are used.
Contributions are welcome! Please read the Contributing Guide and Code of Conduct before opening an issue or pull request.
Features #
JustStandardStorage |
JustSecureStorage |
|
|---|---|---|
| Use case | Non-sensitive app data (settings, preferences, game state) | Sensitive data (auth tokens, credentials, PII) |
| Storage (native) | Atomic JSON file via dart:io |
AES-256-GCM encrypted JSON file |
| Storage (web) | Browser localStorage |
AES-256-GCM encrypted localStorage |
| Encryption | None | AES-256-GCM with per-value random nonces |
| Integrity check | No | Yes — GCM auth tag rejects any tampered byte |
| Persistence | ✅ | ✅ |
| Typed JSON helpers | ✅ | ✅ |
Reactive watch() stream |
✅ | ✅ |
| Atomic writes | ✅ native (rename-swap) | ✅ native (rename-swap) |
| Admin UI | ✅ via package:just_storage/ui.dart |
✅ via package:just_storage/ui.dart |
Getting started #
Add the package to your app's pubspec.yaml:
dependencies:
just_storage: ^1.0.0
Use JustStorage to obtain instances — no path_provider import required in your app:
import 'package:just_storage/just_storage.dart';
// Standard storage
final JustStandardStorage storage = await JustStorage.standard();
// Secure storage
final JustSecureStorage secure = await JustStorage.encrypted();
Register both stores in your DI container (e.g. get_it):
final standard = await JustStorage.standard();
final secure = await JustStorage.encrypted();
sl.registerLazySingleton<JustStandardStorage>(() => standard);
sl.registerLazySingleton<JustSecureStorage>(() => secure);
Usage #
Standard storage (JustStandardStorage) #
final JustStandardStorage storage = await JustStorage.standard();
// Read / write
await storage.write('theme', 'dark');
final theme = await storage.read('theme'); // 'dark'
// Check existence
final exists = await storage.containsKey('theme'); // true
// Typed JSON
await storage.writeJson('profile', profile, (p) => p.toJson());
final profile = await storage.readJson('profile', Profile.fromJson);
// Reactive watch — emits current value then every subsequent change
storage.watch('theme').listen((value) {
print('theme changed to: $value');
});
// Read all
final all = await storage.readAll();
// Delete / clear
await storage.delete('theme');
await storage.clear();
Secure storage (JustSecureStorage) #
final JustSecureStorage secure = await JustStorage.encrypted();
// Read / write (values are AES-256-GCM encrypted on disk)
await secure.write('access_token', token);
final token = await secure.read('access_token');
// Check existence
final exists = await secure.containsKey('access_token'); // true
// Typed JSON
await secure.writeJson('user_credentials', creds, (c) => c.toJson());
final creds = await secure.readJson('user_credentials', Credentials.fromJson);
// Bulk operations
final all = await secure.readAll();
await secure.clear();
// Watch
secure.watch('access_token').listen((value) {
if (value == null) print('logged out');
});
Both types share the same method surface — the only difference is what happens on disk (plain JSON vs. encrypted JSON).
Admin UI #
just_storage ships a built-in Flutter admin screen for inspecting and editing your storage data at runtime. Import it via the separate ui.dart library to keep the UI dependency optional:
import 'package:just_storage/ui.dart';
Open the admin screen #
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const JUStorageAdminScreen(),
));
The screen creates its own storage instances automatically. Pass your existing instances if your app already wires them up:
JUStorageAdminScreen(
standard: myStandard,
secure: mySecure,
)
Supply an optional theme to override the app's active theme:
JUStorageAdminScreen(
theme: ThemeData(colorSchemeSeed: Colors.teal),
)
Tabs #
| Tab | Contents |
|---|---|
| Standard | Browse, search, add, edit, and delete plain-text key-value entries |
| Secure | Same operations on AES-256-GCM encrypted entries |
| Info | Statistics, backend details, and a danger-zone clear action |
Web support #
On the web platform the factory automatically returns web-backed implementations — no code change required:
| Native | Web | |
|---|---|---|
JustStandardStorage |
FileStorage (JSON file) |
WebStorage (localStorage) |
JustSecureStorage |
EncryptedFileStorage (encrypted file) |
WebSecureStorage (AES-256-GCM on localStorage) |
Keys are stored under the just_storage: and just_secure: prefixes to avoid collisions.
Web security note: On web, the AES-256-GCM master key is stored in
localStorage, which is accessible to all scripts running on the same origin. This provides in-origin isolation rather than OS-level sandboxing. For highly sensitive data in web apps, consider a server-side solution.
Security model #
Encryption (native) #
EncryptedFileStorage uses AES-256-GCM (authenticated encryption) via the pointycastle ^3.7.3 package:
- Algorithm: AES-256-GCM
- Key length: 256 bits (32 bytes), generated once with
Random.secure() - Nonce: 96 bits (12 bytes), freshly generated per
writecall — nonce reuse is impossible - Auth tag: 128 bits appended to every ciphertext — any modification to the file throws
StorageExceptionbefore any plaintext is returned - Each value independently encrypted: compromising one entry's nonce does not affect others
Master key (native) #
The master key is stored in <appSupportDir>/.storage.key:
- Generated once with
Random.secure()and flushed withFile.writeAsBytes(flush: true) - File permissions set to
0600(owner-read-only) on POSIX platforms (Android, iOS, Linux, macOS) - Protected by the OS app sandbox — inaccessible to other apps on Android (API 29+) and iOS without root
On-disk format (native) #
{
"access_token": { "n": "<base64 nonce>", "ct": "<base64 ciphertext+GCMtag>" },
"refresh_token": { "n": "<base64 nonce>", "ct": "<base64 ciphertext+GCMtag>" }
}
Atomic writes (native) #
Both native implementations write to a .tmp file first, then call File.rename() — an atomic OS-level swap on all supported platforms. A crash mid-write never corrupts the existing store.
Error handling #
All operations throw StorageException on failure:
try {
await secure.read('token');
} on StorageException catch (e) {
print(e.message); // human-readable description
print(e.cause); // underlying exception, if any
}
A StorageException is also thrown if:
- the encrypted file was modified externally (GCM tag mismatch)
- the master key file is corrupt (wrong byte count)
- the web master key stored in
localStoragehas an unexpected byte length
Testing #
Native implementations accept a Directory via constructor, so tests simply pass Directory.systemTemp.createTempSync():
final dir = Directory.systemTemp.createTempSync('test_');
final storage = FileStorage(dir); // or EncryptedFileStorage(dir)
addTearDown(() => dir.deleteSync(recursive: true));