Skip to main content

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:

PropertyMeaning
keyIdThe stable identifier for the key across all operations.
currentKeyshareThe active, reconciled share used for signing, derivation, and export. nil until the key is first reconciled.
stagedKeyshareA 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, including nils — when the SDK commits a reconciliation (or a delete(keyId:)) it writes a record with a nil slot, and your store must clear the old bytes rather than keep them.
  • read(key:) returns the record stored under key, or nil when nothing is stored. key is always the keyId.
  • 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 matching keyType) 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 write must never surface on the next read; use an atomic replace or a transaction.
  • Concurrency-safe — tolerate overlapping calls without corrupting state (an actor is 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.

CustomStorageClient.swift
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]
}
}