Customize the Storage Provider
Overview
The IStorageProvider interface is designed to be implemented by platform-specific storage solutions, such as SQLite or FileSystem. This interface allows the Mobile SDK to interact with the storage layer without being tied to a specific implementation.
The SDK provides a default implementation InMemoryStorageProvider. This is useful for testing or development purposes, but it is not suitable for production use. But we can create a custom storage provider to persist data.
Implementing a Custom Storage Provider with AsyncStorage
import { CloudWebSocketClient, type IStorageProvider, type StorageDomain } from '@silencelaboratories/silent-shard-sdk';
import { createEcdsaDuoSession } from '@silencelaboratories/silent-shard-sdk/ecdsa';
import AsyncStorage from '@react-native-async-storage/async-storage';
class AsyncStorageProvider implements IStorageProvider {
async create(keyId: string, domain: StorageDomain, item: string): Promise<void> {
const storageKey = this.getStorageKey(keyId, domain);
// Check if item already exists
const existing = await this.exists(keyId, domain);
if (existing) {
throw new Error(`Item already exists for key: ${storageKey}`);
}
await AsyncStorage.setItem(storageKey, item);
}
async read(keyId: string, domain: StorageDomain): Promise<string | null> {
const storageKey = this.getStorageKey(keyId, domain);
return AsyncStorage.getItem(storageKey);
}
async update(keyId: string, domain: StorageDomain, item: string): Promise<void> {
const storageKey = this.getStorageKey(keyId, domain);
// Verify existence before update
const existing = await this.exists(keyId, domain);
if (!existing) {
throw new Error(`Item not found for update: ${storageKey}`);
}
await AsyncStorage.setItem(storageKey, item);
}
async delete(keyId: string, domain: StorageDomain): Promise<void> {
const storageKey = this.getStorageKey(keyId, domain);
await AsyncStorage.removeItem(storageKey);
}
async exists(keyId: string, domain: StorageDomain): Promise<boolean> {
const storageKey = this.getStorageKey(keyId, domain);
const value = await AsyncStorage.getItem(storageKey);
return value !== null;
}
// Helper: Generate storage key from keyId and domain
private getStorageKey(keyId: string, domain: StorageDomain): string {
return `mpc-${domain}-${keyId}`;
}
}
// Then you can use the CustomCloudClient in Session objects
const customStorageProvider = new AsyncStorageProvider();
const client = new CloudWebSocketClient('CLOUD_NODE_URL', true); // true for WSS, false for WS
export const initSession = async () => {
return createEcdsaDuoSession({
client: client,
storage: customStorageProvider,
cloudVerifyingKey: 'YOUR_CLOUD_VERIFICATION_KEY', // Replace with your actual key,
messageSigner: {
// This is a mock signer, replace with your actual implementation
publicKeyB64: '',
sign: async (message: string) => message,
keyType: 'ED25519',
},
});
};
Overview
The StorageClientInterface is designed to be implemented by platform-specific storage solutions, such as SQLite or secure file storage. This interface allows the Mobile SDK to interact with the storage layer without being tied to a specific implementation.
The SDK provides a default implementation SimpleStorageClient. This is useful for testing or development purposes, but it is not suitable for production use. But we can create a custom storage client to persist data.
Implementing a Custom Storage Client
import 'package:silent_shard_core/storage/storage_client_interface.dart';
import 'package:silent_shard_core/storage/storage_keyshare.dart';
/// Simple storage client that implements the [StorageClientInterface] interface.
/// It is used to store the keyshares in the run time memory in simple list.
/// This enables quick integration with the SDK, but it is not recommended for production use.
class SimpleStorageClient implements StorageClientInterface<StorageKeyshare> {
final List<StorageKeyshare> storageKeyshares = [];
SimpleStorageClient([List<StorageKeyshare>? sk]) {
storageKeyshares.addAll(sk ?? []);
}
/// Sets the list of [StorageKeyshare] to the storage client.
setStorageKeyshares(List<StorageKeyshare> sk) {
storageKeyshares.clear();
storageKeyshares.addAll(sk);
}
/// Returns a list of [StorageKeyshare] for a given [keyId].
Future<StorageKeyshare?> readKeyshare(String keyId, StorageDomain domain) async {
final keyshare = storageKeyshares.where((e) => (e.keyId == keyId && e.domain == domain)).toList();
if (keyshare.length > 1) {
throw 'Multiple keyshares found for keyId $keyId and domain $domain';
}
if (keyshare.isEmpty) {
return null;
}
return keyshare[0];
}
/// Creates a new [StorageKeyshare] in the storage client.
Future<void> createKeyshare(String keyId, StorageDomain domain, StorageKeyshare data) async {
final isKeyshareIdExist = storageKeyshares.where((e) => e.keyId == keyId && e.domain == domain).length > 0;
if (isKeyshareIdExist) throw 'id already exist';
storageKeyshares.add(StorageKeyshare(
keyId: data.keyId,
domain: data.domain,
type: data.type,
keyshareData: data.keyshareData,
sessionId: data.sessionId,
));
}
/// Updates a [StorageKeyshare] in the storage client for a given [id].
Future<void> updateKeyshare(String keyId, StorageDomain domain, StorageKeyshare newKeyshare) async {
final index = storageKeyshares.indexWhere((e) => e.keyId == keyId && e.domain == domain);
final newKeyshareToInsert = StorageKeyshare(
keyId: newKeyshare.keyId,
domain: newKeyshare.domain,
type: newKeyshare.type,
keyshareData: newKeyshare.keyshareData,
sessionId: newKeyshare.sessionId,
);
storageKeyshares[index] = newKeyshareToInsert;
}
/// Deletes a [StorageKeyshare] from the storage client for a given [id].
Future<void> deleteKeyshare(String keyId, StorageDomain domain) async {
final index = storageKeyshares.indexWhere((e) => e.keyId == keyId && e.domain == domain);
storageKeyshares.removeAt(index);
}
}
Overview
The StorageClient is the persistence boundary between the SDK and your app. The SDK never writes keyshare bytes to disk itself — it calls your implementation — which makes your StorageClient the single source of truth for keyshares. Back it with whatever your platform offers: SQLite, ObjectBox, the file system, secure hardware, etc.
Every keyshare is addressed by an opaque keyId string, so your implementation is a key/value store keyed by keyId. The SDK stores one ReconcileStoreDao per key, holding two slots plus the id:
| Field | Meaning |
|---|---|
keyId | The stable identifier for the key across all operations. |
currentKeyshare | The active, reconciled share used for signing, derivation, and export. null until the key is first reconciled. |
stagedKeyshare | A freshly produced share awaiting reconciliation. null in the steady state. |
Reconciliation is what moves a share from staged to current. See Keygen with Reconcile and KeyRefresh with Reconcile.
The contract
write(dao)receives a complete snapshot of the record. Persist every field exactly as given, including nulls — when the SDK commits a reconciliation (or adelete(keyId)) it writes a record with anullslot, and your store must clear the old bytes rather than keep them.read(key)returns the record stored underkey, ornullwhen nothing is stored.keyis always thekeyId.keyType: KeyType— one ofECDSA,EdDSA, orMLDSA— declares the algorithm family this client serves. If your app runs more than one algorithm, provide a separate client per family (each with a matchingkeyType) rather than multiplexing one store.
Production requirements
Keyshares are long-lived, high-value secrets. The in-memory map shown below is fine for tests and development, but a production implementation must be:
- Confidential — encrypt keyshare bytes at rest (e.g. with a key from the Android Keystore); never log them.
- Durable — survive process death and device restarts.
- Atomic — a partially applied
writemust never surface on the nextread; use an atomic replace or a transaction. - Concurrency-safe — tolerate overlapping calls without corrupting state.
Extend SilentShardStorageClient — a convenience base for StorageClient that narrows read to return ReconcileStoreDao directly, so you skip casting the generic StorageDao.
Implementing a Custom StorageClient
The demo below is an in-memory map keyed by keyId — useful for testing or development, but not suitable for production.
import com.silencelaboratories.silentshard.storage.KeyType
import com.silencelaboratories.silentshard.storage.StorageClient
import com.silencelaboratories.silentshard.storage.StorageDao
import com.silencelaboratories.silentshard.storage.silentshard.ReconcileStoreDao
class CustomStorageClient(
// The algorithm family this client stores keyshares for. Provide a separate
// client (with a matching keyType) per algorithm if you run more than one.
override val keyType: KeyType = KeyType.ECDSA,
) : StorageClient {
/**
* Representing an in-memory database keyed by keyId. In the real world this
* should be a SQL-based DB, secure storage, or custom hardware — it is up to
* the implementing app's use-case.
*/
private val entries = mutableMapOf<String, ReconcileStoreDao>()
/**
* Write the complete ReconcileStoreDao snapshot to storage. The dao holds the
* current state of the keyshare from the SDK's point of view; persist every
* field exactly as given, including nulls (the SDK uses them to clear state).
*/
override suspend fun write(dao: StorageDao) {
require(dao is ReconcileStoreDao) { "Expected ReconcileStoreDao" }
// Write to storage.
entries[dao.keyId] = dao
}
/**
* Read the record stored under [key] (always a keyId), or null if none exists.
*/
override suspend fun read(key: String): StorageDao? {
// Read from storage.
return entries[key]
}
}
Overview
The StorageClient is the persistence boundary between the SDK and your app. The SDK never writes keyshare bytes to disk itself — it calls your implementation — which makes your StorageClient the single source of truth for keyshares. Back it with whatever your platform offers: SQLite, Core Data, the file system, the Keychain, secure hardware, etc.
Every keyshare is addressed by an opaque keyId string, so your implementation is a key/value store keyed by keyId. The SDK stores one ReconcileStoreDao per key, holding two slots plus the id:
| Property | Meaning |
|---|---|
keyId | The stable identifier for the key across all operations. |
currentKeyshare | The active, reconciled share used for signing, derivation, and export. nil until the key is first reconciled. |
stagedKeyshare | A freshly produced share awaiting reconciliation. nil in the steady state. |
Reconciliation is what moves a share from stagedKeyshare to currentKeyshare. See Keygen with Reconcile and KeyRefresh with Reconcile.
The contract
Both methods are async throws:
write(dao:)receives a complete snapshot of the record. Persist every property exactly as given, includingnils — when the SDK commits a reconciliation (or adelete(keyId:)) it writes a record with anilslot, and your store must clear the old bytes rather than keep them.read(key:)returns the record stored underkey, ornilwhen nothing is stored.keyis always thekeyId.keyType: StorageKeyType— one of.ecdsa,.edDSA, or.mlDSA— declares the algorithm family this client serves. If your app runs more than one algorithm, provide a separate client per family (each with a matchingkeyType) rather than multiplexing one store.
Production requirements
Keyshares are long-lived, high-value secrets. The in-memory store shown below is fine for tests and development, but a production implementation must be:
- Confidential — encrypt keyshare bytes at rest (e.g. with a key from the Keychain / Secure Enclave); never log them.
- Durable — survive process death and device restarts.
- Atomic — a partially applied
writemust never surface on the nextread; use an atomic replace or a transaction. - Concurrency-safe — tolerate overlapping calls without corrupting state (an
actoris a natural fit).
ReconcileStoreDao conforms to Codable (its Data fields encode as Base64 in JSON), so you can persist and reload it directly — encode it into UserDefaults, a file, or the Keychain, and decode it back on read.
Implementing a Custom StorageClient
The demo below is an in-memory store keyed by keyId — useful for testing or development, but not suitable for production.
import trio
class CustomStorageClient: StorageClient {
// The algorithm family this client stores keyshares for. Provide a separate
// client (with a matching keyType) per algorithm if you run more than one.
let keyType: StorageKeyType = .ecdsa
/*
* An in-memory database keyed by keyId. In the real world this should be a
* SQL-based DB, secure storage, or custom hardware. ReconcileStoreDao is
* Codable, so it persists directly (its Data fields encode as Base64 JSON).
*/
private var byKeyId: [String: ReconcileStoreDao] = [:]
func write(dao: ReconcileStoreDao) async throws {
// Persist the whole snapshot, including any nil fields.
byKeyId[dao.keyId] = dao
}
func read(key: String) async throws -> ReconcileStoreDao? {
byKeyId[key]
}
}