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

@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.secretstorage.SecretStorage
import io.element.android.libraries.matrix.api.walletsecretstorage.WalletSecretStorage
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
@ -62,7 +62,7 @@ interface MatrixClient {
val notificationService: NotificationService
val notificationSettingsService: NotificationSettingsService
val encryptionService: EncryptionService
val secretStorage: SecretStorage
val walletSecretStorage: WalletSecretStorage
val roomDirectoryService: RoomDirectoryService
val mediaPreviewService: MediaPreviewService
val matrixMediaLoader: MatrixMediaLoader

View file

@ -1,55 +0,0 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.libraries.matrix.api.secretstorage
/**
* Interface for accessing Matrix SSSS (Secure Secret Storage and Sharing).
*
* This allows storing and retrieving encrypted secrets in the user's
* Matrix account data, using their recovery key for encryption.
*/
interface SecretStorage {
/**
* Open the secret store with a recovery key.
*
* @param recoveryKey The Matrix recovery key (base58 encoded, 48 characters)
* or passphrase that was used to set up SSSS
* @return SecretStore instance if key is valid, null if invalid or SSSS not set up
*/
suspend fun openSecretStore(recoveryKey: String): SecretStore?
}
/**
* An opened secret store that can read and write secrets.
*
* Secrets are encrypted with the recovery key and stored in the user's
* account data on the homeserver.
*/
interface SecretStore {
/**
* Store a secret encrypted with SSSS.
*
* @param secretName The secret identifier (e.g., "com.sulkta.cardano.wallet_seed")
* @param secret The secret value to store
*/
suspend fun putSecret(secretName: String, secret: String): Result<Unit>
/**
* Retrieve a secret from SSSS.
*
* @param secretName The secret identifier
* @return The decrypted secret, or null if not found
*/
suspend fun getSecret(secretName: String): Result<String?>
/**
* Export the recovery key as a base58-encoded string.
*
* This is useful for displaying the key to the user for verification.
*/
fun exportRecoveryKey(): String
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.libraries.matrix.api.walletsecretstorage
/**
* Stores the Cardano wallet seed phrase encrypted in Matrix account data.
*
* Design summary:
* - Slot name: a namespace we own (not a Matrix-spec 4S slot)
* - Encryption: AES-256-GCM keyed by HKDF-SHA256 of the user's Matrix
* recovery-key entropy + a wallet-specific info label. The info label
* guarantees wallet-derived keys never collide with Matrix's own crypto keys.
* - Storage: Matrix account data via the Rust SDK (Client.setAccountData /
* Client.accountData). The homeserver never sees plaintext.
* - Recovery: the user's existing Matrix recovery key is the sole secret
* same key they memorised when setting up Matrix crypto backup.
*
* See libraries/matrix/impl/.../walletsecretstorage/{WalletSecretEnvelope,
* RecoveryKeyDerivation}.kt for the envelope format + derivation details.
*/
interface WalletSecretStorage {
/**
* Encrypt and store [seedPhrase] under the user's recovery key.
*
* @param recoveryKey The user's Matrix recovery key (whitespace tolerated).
* @param seedPhrase The wallet seed to back up. Normally a space-separated
* BIP-39 mnemonic; any UTF-8 string is accepted.
* @return Success on write; [WalletSecretStorageException.InvalidRecoveryKey]
* if the recovery key is malformed.
*/
suspend fun putSeed(recoveryKey: String, seedPhrase: String): Result<Unit>
/**
* Fetch and decrypt the wallet seed.
*
* @param recoveryKey The user's Matrix recovery key.
* @return Success(null) if no backup exists or the envelope can't be decoded;
* Success(String) with the decrypted seed phrase if it unlocks;
* Failure([WalletSecretStorageException.InvalidRecoveryKey]) if the
* input isn't a valid recovery key format.
*
* Note: we deliberately do NOT distinguish "wrong recovery key" from
* "tampered blob" in the success path both surface as null, mirroring
* GCM's authenticated-decryption contract.
*/
suspend fun getSeed(recoveryKey: String): Result<String?>
/**
* Whether an encrypted wallet-seed blob currently exists in account data.
* Doesn't need the recovery key; useful for onboarding ("restore from backup?").
*/
suspend fun hasSeedBackup(): Result<Boolean>
/**
* Delete the stored blob from account data. Irreversible.
*/
suspend fun deleteSeed(): Result<Unit>
}
sealed class WalletSecretStorageException(message: String) : Exception(message) {
object InvalidRecoveryKey : WalletSecretStorageException(
"Recovery key is not a valid Matrix recovery key (wrong format, prefix, or parity)."
)
object WriteFailed : WalletSecretStorageException("Failed to write wallet seed to account data.")
object ReadFailed : WalletSecretStorageException("Failed to read wallet seed from account data.")
}