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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue