feat(wallet): rewrite SSSS on account data + AES-256-GCM envelope
The Rust SDK removed the low-level SecretStoreWrapper.putSecret/getSecret API between 26.03.x and 26.04.x — it was an escape hatch we were using to pin arbitrary bytes into a Matrix 4S slot. The SDK maintainers never contracted that primitive; locking it down lets their recovery code evolve without worrying about third-party storage. This commit replaces that dependency with a self-contained design we own end-to-end, so future SDK moves no longer break our backup flow. ### Design - Slot: `com.sulkta.wallet.seed.v1` in Matrix account data. Our namespace, not a Matrix-spec 4S slot — we are NOT impersonating Matrix secret storage, we are holding our own opaque blob. - Envelope (JSON): version tag, algorithm tag, random 12-byte IV, GCM output (ciphertext || tag), AAD = slot name. AES-256-GCM via stock javax.crypto. AAD binds a blob to its slot so a blob can't be lifted from one namespace and successfully opened in another. - Key: derived from the user's existing Matrix recovery key via HKDF-SHA256 with info label "sulkta.wallet.seed.v1". The info label guarantees we never produce the same key bytes Matrix uses for its own crypto — same secret, different domain. - I/O: client.setAccountData(key, json) + client.accountData(key) via the SDK; the homeserver only ever sees the opaque encrypted blob. ### Files - api/walletsecretstorage/WalletSecretStorage.kt — new interface - impl/walletsecretstorage/WalletSecretEnvelope.kt — AES-GCM envelope (with unit tests: round-trip, wrong key, tampered ct, tampered iv, wrong AAD, wrong version, malformed JSON) - impl/walletsecretstorage/RecoveryKeyDerivation.kt — base58 decode + parity check + HKDF-SHA256 (with unit tests: determinism, whitespace tolerance, distinct info labels → distinct keys) - impl/walletsecretstorage/MatrixAccountDataWalletSecretStorage.kt — WalletSecretStorage impl wrapping Client account data - test/walletsecretstorage/FakeWalletSecretStorage.kt — in-memory fake - api/MatrixClient.kt: old .secretStorage → .walletSecretStorage - features/wallet/.../WalletBackupServiceImpl.kt — rewired to use the new interface; hasBackupWithoutKey now goes through the same path instead of manually poking the raw Matrix HTTP API. - DELETED: api/secretstorage/SecretStorage.kt, SecretStore.kt, impl/ secretstorage/RustSecretStorage.kt — the old SDK-dependent path. ### Backward compat note Users who backed up a wallet seed on the OLD SDK have a blob in Matrix's 4S at `com.sulkta.cardano.wallet_seed`. This branch cannot read those. Since the prior integration was only tested internally, acceptable today — anyone with an old backup re-enters their mnemonic.
This commit is contained in:
parent
a944499eda
commit
de2edafe61
13 changed files with 797 additions and 166 deletions
|
|
@ -11,90 +11,68 @@ import dev.zacsweers.metro.ContributesBinding
|
|||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.wallet.api.backup.WalletBackupService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.api.walletsecretstorage.WalletSecretStorage
|
||||
import io.element.android.libraries.matrix.api.walletsecretstorage.WalletSecretStorageException
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Implementation of [WalletBackupService] that stores the wallet seed
|
||||
* phrase in Matrix SSSS (Secure Secret Storage and Sharing).
|
||||
* [WalletBackupService] implementation that stores the Cardano wallet
|
||||
* seed phrase encrypted in Matrix account data via [WalletSecretStorage].
|
||||
*
|
||||
* We persist the mnemonic as a single space-separated string — the wire
|
||||
* form of the seed most BIP-39 tools already accept. The backup service
|
||||
* re-splits on read.
|
||||
*
|
||||
* History note: prior to 2026-04, this class used a low-level
|
||||
* `SecretStoreWrapper.putSecret` API that the Rust SDK removed between
|
||||
* 26.03.24 and 26.04.x. The new path uses Matrix account data under our
|
||||
* own namespace with our own AES-256-GCM envelope, so we no longer depend
|
||||
* on any SDK-internal secret-storage primitive.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class WalletBackupServiceImpl @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
) : WalletBackupService {
|
||||
|
||||
private val storage: WalletSecretStorage
|
||||
get() = matrixClient.walletSecretStorage
|
||||
|
||||
override suspend fun backupSeed(recoveryKey: String, mnemonic: List<String>): Result<Unit> {
|
||||
return runCatching {
|
||||
val secretStore = matrixClient.secretStorage.openSecretStore(recoveryKey)
|
||||
?: throw WalletBackupException.InvalidRecoveryKey()
|
||||
|
||||
// Store mnemonic as space-separated string
|
||||
val seedString = mnemonic.joinToString(" ")
|
||||
secretStore.putSecret(WalletBackupService.SECRET_NAME, seedString).getOrThrow()
|
||||
|
||||
Timber.d("Wallet seed backed up to SSSS")
|
||||
}
|
||||
val seedString = mnemonic.joinToString(" ")
|
||||
return storage.putSeed(recoveryKey, seedString)
|
||||
.onSuccess { Timber.d("[WalletBackup] seed stored in account data") }
|
||||
.onFailure { Timber.w(it, "[WalletBackup] seed storage failed") }
|
||||
}
|
||||
|
||||
override suspend fun restoreSeed(recoveryKey: String): Result<List<String>?> {
|
||||
return runCatching {
|
||||
val secretStore = matrixClient.secretStorage.openSecretStore(recoveryKey)
|
||||
?: throw WalletBackupException.InvalidRecoveryKey()
|
||||
|
||||
val seedString = secretStore.getSecret(WalletBackupService.SECRET_NAME).getOrThrow()
|
||||
|
||||
seedString?.split(" ")?.takeIf { it.size in listOf(12, 15, 18, 21, 24) }
|
||||
return storage.getSeed(recoveryKey).map { seedString ->
|
||||
seedString?.split(" ")?.takeIf { it.size in VALID_MNEMONIC_LENGTHS }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hasBackup(recoveryKey: String): Result<Boolean> {
|
||||
// A successful decrypt into a valid-length mnemonic is our criterion.
|
||||
// Distinguishes "blob exists but wrong key" from "blob exists and opens".
|
||||
return restoreSeed(recoveryKey).map { it != null }
|
||||
}
|
||||
|
||||
override suspend fun hasBackupWithoutKey(): Result<Boolean> {
|
||||
return runCatching {
|
||||
// Get server name from user ID (e.g., "sulkta.com" from "@user:sulkta.com")
|
||||
val serverName = matrixClient.userIdServerName()
|
||||
val userId = matrixClient.sessionId.value
|
||||
val secretName = WalletBackupService.SECRET_NAME
|
||||
return storage.hasSeedBackup()
|
||||
.onFailure { Timber.w(it, "[WalletBackup] hasSeedBackup probe failed") }
|
||||
}
|
||||
|
||||
// Construct full URL to check account data
|
||||
val url = "https://$serverName/_matrix/client/v3/user/$userId/account_data/$secretName"
|
||||
|
||||
Timber.d("Checking for wallet backup at: $url")
|
||||
|
||||
try {
|
||||
// Try to fetch the account data
|
||||
val response = matrixClient.getUrl(url).getOrThrow()
|
||||
val content = response.decodeToString()
|
||||
Timber.d("Account data check response: ${content.take(100)}")
|
||||
|
||||
// If we got a response with content (not empty or error), backup exists
|
||||
// The content will be encrypted - we just need to know it exists
|
||||
content.isNotEmpty() && content != "{}" && !content.contains("\"errcode\"")
|
||||
} catch (e: ClientException.Generic) {
|
||||
// Check if it's a 404 (not found)
|
||||
if (e.message?.contains("404") == true) {
|
||||
Timber.d("No wallet backup found (404)")
|
||||
false
|
||||
} else {
|
||||
Timber.w(e, "Error checking for wallet backup")
|
||||
// On error, assume no backup to avoid blocking setup
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error checking for wallet backup")
|
||||
// On error, assume no backup to avoid blocking setup
|
||||
false
|
||||
}
|
||||
}
|
||||
private companion object {
|
||||
/** BIP-39 permits these mnemonic word counts; anything else is corrupt. */
|
||||
val VALID_MNEMONIC_LENGTHS = setOf(12, 15, 18, 21, 24)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exceptions for wallet backup operations.
|
||||
* Exceptions surfaced by wallet backup operations. Kept for compatibility
|
||||
* with call sites that pattern-match; the underlying storage failures now
|
||||
* come from [WalletSecretStorageException].
|
||||
*/
|
||||
sealed class WalletBackupException(message: String) : Exception(message) {
|
||||
class InvalidRecoveryKey : WalletBackupException("Recovery key is invalid or SSSS is not set up")
|
||||
class NoBackupFound : WalletBackupException("No wallet backup found in SSSS")
|
||||
class InvalidRecoveryKey : WalletBackupException("Recovery key is invalid or could not unlock the backup")
|
||||
class NoBackupFound : WalletBackupException("No wallet backup found")
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue