Custom Storage Client
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. |
The two-slot design is what makes keyshare transitions crash-safe — see Keygen with Reconcile and KeyRefresh with Reconcile. Your only job is to persist and return the record faithfully.
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 duo
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]
}
}