Skip to main content

Storage client

The SDK only requires a StorageClient (Android) / BaseStorageClient (iOS) implementation that knows how to read and write a ReconcileStoreDao. Anything underneath that contract is the integrator's choice. The full SDK contract and a minimal in-memory implementation are documented in Custom storage client (Kotlin) and Custom storage client (Swift).

This page documents what the example apps put underneath that contract — specifically, AES-256-GCM with the symmetric key held in TEE-backed storage so the keyshare bytes are encrypted at rest and the key material itself never leaves the secure hardware.

Two-class layering

The example splits the storage layer into two classes per platform:

  • EncryptedFileStorage — low-level. Knows about files, AES-256-GCM, the TEE-backed key. Doesn't know what a "keyshare" is. Just writeEncrypted(keyId, bytes) and readDecrypted(keyId).
  • EncryptedStorageClient — implements the SDK's StorageClient / BaseStorageClient protocol. Serializes a ReconcileStoreDao into bytes (with the algorithm tag prefixed) and hands them to EncryptedFileStorage. Deserializes on read into a VaultReconcileStoreDao (Android) / VaultReconcileRecord (iOS).

The split exists so the encryption code never has to think about MPC fields, and vice versa.

Where the secret key lives

The AES-256-GCM key is generated inside Android Keystore on first use, then loaded by alias on every subsequent launch. On hardware that supports it, the key material lives inside the TEE (Trusted Execution Environment) and is non-extractable — the app holds a handle, not the bytes.

vault/storage/EncryptedFileStorage.kt
private fun loadOrGenerateSecretKey(): SecretKey {
if (keyStore.containsAlias(KEY_ALIAS)) {
val entry = keyStore.getEntry(KEY_ALIAS, null) as KeyStore.SecretKeyEntry
return entry.secretKey
}
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE
)
val keySpec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}

The keystore alias is per-product (silentshard_duo_vault_storage_key) so a duo install and a trio install on the same device do not share keys.

Where the encrypted file lives

Each keyshare is one file at:

${context.noBackupFilesDir}/vault_keyshares/<keyId>.vault

noBackupFilesDir is used instead of filesDir so Android Auto Backup will not sync the keyshare to Google Drive — the encryption key it's protected with is device-bound, so a copy on another device is meaningless (and recovering it via cloud backup would defeat the encryption-at-rest property).

Each encrypt call generates a fresh random 12-byte IV, so identical plaintext produces different ciphertext every time. Writes are atomic via temp-file + rename.

What's inside the encrypted blob

The SDK's ReconcileStoreDao doesn't carry an algorithm tag — only keyId, currentKeyshare, stagedKeyshare. The example bolts the algorithm on by serializing the bound KeyType as the first line of the encrypted record:

<keyType>\n
<keyId>\n
<currentKeyshare base64>\n
<stagedKeyshare base64>

Both keyshare fields are base64 (NO_WRAP on Android, default on iOS) so the bytes can contain newlines without confusing the parser. Empty/null fields produce empty lines.

The serialization is the same on both platforms, so an Android-format file would parse on iOS (and vice versa) — the difference is just where the AES key lives. The read path constructs a wrapper around the SDK's dao so callers can recover KeyType via cast:

vault/storage/EncryptedStorageClient.kt
override suspend fun read(key: String): VaultReconcileStoreDao? {
val decrypted = store.readDecrypted(key) ?: return null
val parts = decrypted.toString(Charsets.UTF_8).split('\n', limit = 4)
if (parts.size < 2) return null

return VaultReconcileStoreDao(
keyType = KeyType.valueOf(parts[0]),
keyId = parts[1],
currentKeyshare = parts.getOrNull(2)?.takeIf { it.isNotEmpty() }
?.let { Base64.decode(it, Base64.NO_WRAP) },
stagedKeyshare = parts.getOrNull(3)?.takeIf { it.isNotEmpty() }
?.let { Base64.decode(it, Base64.NO_WRAP) },
)
}
Vault/Storage/EncryptedStorageClient.swift
func read(key: String) async throws -> ReconcileStoreDao? {
guard let record = readRecordSync(keyId: key) else { return nil }
return ReconcileStoreDao(
keyId: record.keyId,
currentKeyshare: record.currentKeyshare,
stagedKeyshare: record.stagedKeyshare
)
}

func readRecord(keyId: String) -> VaultReconcileRecord? {
readRecordSync(keyId: keyId)
}

The Swift version exposes both the protocol-conformant read(key:) (returning the SDK's ReconcileStoreDao because BaseStorageClient requires that exact type) and a separate readRecord(keyId:) helper that returns the KeyType-bearing wrapper. The Android version uses Kotlin's covariant return type and returns the subclass directly from the protocol method.

The "only writer" property

EncryptedStorageClient.write is only called by the SDK during keygen, key refresh, and reconcile. The example app code never persists keyshares directly — every operation that needs a keyshare goes through VaultSessionManager, which only reads from storage. This is a deliberate property: it lets the example treat the vault layer as a closed system where keyshare-byte handling cannot leak into the app layer by mistake.

If you copy this design into your own app, the property survives as long as you keep EncryptedStorageClient as the only thing you pass to SilentShard.<Algorithm>.createDuoSession(...) and never call write from outside the SDK callback path.