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:
Cobb 2026-04-17 10:16:53 -07:00
parent a944499eda
commit de2edafe61
13 changed files with 797 additions and 166 deletions

View file

@ -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")
}