Deep technical analysis of Matrix Secret Storage for storing Cardano wallet seeds encrypted in user account data. Covers: - Complete SSSS technical walkthrough - matrix-rust-sdk API surface with code examples - Step-by-step storage and restore flows - Security analysis and recommendations - Spec gaps and limitations
23 KiB
Matrix Secret Storage (SSSS) Technical Review
Purpose: Evaluate Matrix's Secret Storage and Sharing Service (SSSS) for storing a Cardano wallet seed encrypted in a user's Matrix account data, enabling automatic restore on new device verification.
Goal: Matrix account = wallet backup. No seed phrase entry, no separate backup.
Table of Contents
- SSSS Technical Overview
- Can You Store Custom Secrets?
- Account Data Event Format
- Secret Sharing on New Device Verification
- matrix-rust-sdk API Surface
- Offline Device Recovery
- Encryption Details
- Security Analysis
- Precedents for Non-Matrix Secrets
- Wallet Storage Flow
- Wallet Restore Flow
- Spec Gaps and Limitations
- Recommendations
1. SSSS Technical Overview
Matrix Secret Storage (SSSS) is an encrypted key/value store backed by account data events on the homeserver.
Step-by-Step: How SSSS Works
1.1 Secret Storage Key Generation
Two methods to create a Secret Storage Key:
Method A: Random Key
- Generate 32 random bytes as the secret key material
- Generate a random key ID (32 alphanumeric characters)
- Derive a verification key using HKDF-SHA-256:
- Input: 32-byte secret key
- Salt: 32 zero bytes
- Info: empty string (
"") - Output: 64 bytes → split into AES key (32 bytes) + MAC key (32 bytes)
- Encrypt 32 zero bytes with derived key, store IV + MAC in key metadata
Method B: Passphrase-Based
- Generate random 32-character salt
- Run PBKDF2-HMAC-SHA512:
- Input: passphrase
- Salt: generated salt
- Iterations: 500,000 (default)
- Output: 32 bytes
- Continue as Method A, but store passphrase info in key metadata
1.2 Key Storage on Server
The Secret Storage Key metadata is uploaded as a global account data event:
Event Type: m.secret_storage.key.<key_id>
Content structure:
{
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "<base64>", // 16 bytes, for MAC verification
"mac": "<base64>", // 32 bytes, for key verification
"passphrase": { // Optional, only if passphrase-derived
"algorithm": "m.pbkdf2",
"salt": "<string>",
"iterations": 500000
}
}
1.3 Default Key Designation
A separate event marks which key is default:
Event Type: m.secret_storage.default_key
Content:
{
"key": "<key_id>"
}
1.4 Encrypting a Secret
For each secret with name <secret_name>:
-
Derive per-secret encryption keys using HKDF-SHA-256:
- Input: 32-byte storage key
- Salt: 32 zero bytes
- Info: secret name as UTF-8 bytes (e.g.,
"m.cross_signing.master") - Output: 64 bytes → AES key (first 32) + MAC key (last 32)
-
Generate random 16-byte IV (with bit 63 cleared for implementation compatibility)
-
Encrypt secret using AES-256-CTR with derived AES key
-
Compute HMAC-SHA-256 over ciphertext using derived MAC key
-
Store as account data event with type = secret name
1.5 Decrypting a Secret
- Fetch account data event for secret name
- Look up encrypted data for your key ID
- Derive per-secret keys using HKDF (same as encryption)
- Verify MAC before decryption
- Decrypt ciphertext using AES-256-CTR
2. Can You Store Custom Secrets?
Yes, absolutely. SSSS is designed for arbitrary secrets.
The SecretName type in Ruma/matrix-rust-sdk accepts any string:
let my_secret_name = SecretName::from("com.sulkta.cardano.wallet_seed");
Convention for custom secrets:
- Use reverse-domain notation:
com.yourorg.secret_type - The secret name becomes the account data event type
- No registration required—just use it
Well-known secrets defined by Matrix:
m.cross_signing.masterm.cross_signing.self_signingm.cross_signing.user_signingm.megolm_backup.v1(backup recovery key)
Your custom secret would be:
com.sulkta.cardano.wallet_seedor similar
3. Account Data Event Format
3.1 Secret Event Type
Each secret is stored at:
Event Type: <secret_name>
3.2 Secret Event Content
{
"encrypted": {
"<key_id>": {
"iv": "<base64>", // 16 bytes
"ciphertext": "<base64>", // encrypted secret
"mac": "<base64>" // 32 bytes HMAC
}
}
}
Example for a Cardano wallet seed:
{
"type": "com.sulkta.cardano.wallet_seed",
"content": {
"encrypted": {
"abc123": {
"iv": "gH2iNpiETFhApvW6/FFEJQ==",
"ciphertext": "lCRSSA1lChONEXj/8RyogsgAa8ouQwYDnLr4XBCheRikrZykLRzPCx3doCE=",
"mac": "NXeV1dZaOe2JLvQ6Hh6tFto7AgFFdaQnY0l9pruwdtE="
}
}
}
}
3.3 Multiple Key Support
The encrypted map can contain entries for multiple storage keys. This allows:
- Key rotation without re-fetching secrets
- Multiple authorized keys (though uncommon)
4. Secret Sharing on New Device Verification
4.1 The Secret Sharing Protocol
When a new device verifies via cross-signing, it can request secrets from existing devices:
Step 1: New device sends m.secret.request
{
"type": "m.secret.request",
"content": {
"action": "request",
"name": "com.sulkta.cardano.wallet_seed",
"requesting_device_id": "NEWDEVICE",
"request_id": "unique-request-id"
}
}
- Sent as unencrypted to-device event to all user's devices
Step 2: Existing device sends m.secret.send
{
"type": "m.secret.send",
"content": {
"request_id": "unique-request-id",
"secret": "<plaintext secret value>"
}
}
- Sent as Olm-encrypted to-device event
- Only sent to verified devices (cross-signed by owner)
4.2 Verification Requirements
Before sharing secrets, the existing device MUST verify:
- The requesting device is owned by the same user
- The requesting device is verified (cross-signed)
- The Olm session was established with the expected device
4.3 Limitations
Critical for your use case:
- Secret sharing requires at least one other device to be online
- If no other devices are online, the new device must:
- Enter the recovery key/passphrase manually
- Decrypt secrets from account data directly
5. matrix-rust-sdk API Surface
5.1 Opening the Secret Store
use matrix_sdk::Client;
use ruma::events::secret::request::SecretName;
// Open with recovery key (base58) or passphrase
let secret_store = client
.encryption()
.secret_storage()
.open_secret_store("EsTj 3yST y93F SLpB...") // or passphrase
.await?;
5.2 Storing a Secret
let wallet_seed = "your 24 word mnemonic here";
let secret_name = SecretName::from("com.sulkta.cardano.wallet_seed");
secret_store.put_secret(secret_name, wallet_seed).await?;
5.3 Retrieving a Secret
let secret_name = SecretName::from("com.sulkta.cardano.wallet_seed");
let wallet_seed = secret_store.get_secret(secret_name).await?;
match wallet_seed {
Some(seed) => println!("Restored wallet seed!"),
None => println!("No wallet seed found in SSSS"),
}
5.4 Creating a New Secret Store
// Creates new storage key, uploads to server, re-encrypts existing secrets
let secret_store = client
.encryption()
.secret_storage()
.create_secret_store()
.await?;
// Optionally with passphrase
let secret_store = client
.encryption()
.secret_storage()
.create_secret_store()
.with_passphrase("user's chosen passphrase")
.await?;
// Get the recovery key for backup
let recovery_key = secret_store.secret_storage_key();
println!("Save this recovery key: {}", recovery_key);
5.5 Recovery on New Device
// User enters recovery key or passphrase
let recovery = client.encryption().recovery();
recovery.recover("EsTj 3yST y93F SLpB...").await?;
// Now secret store is available
let secret_store = client
.encryption()
.secret_storage()
.open_secret_store("EsTj 3yST y93F SLpB...")
.await?;
// Retrieve wallet seed
let wallet_seed = secret_store
.get_secret(SecretName::from("com.sulkta.cardano.wallet_seed"))
.await?;
5.6 Importing All Secrets (Built-in Recovery Flow)
// This imports cross-signing keys + backup key, verifies device
secret_store.import_secrets().await?;
// For custom secrets, call get_secret() separately
let wallet_seed = secret_store.get_secret(my_secret_name).await?;
6. Offline Device Recovery
Question: What if no other devices are online when verifying a new device?
6.1 SSSS Retrieval Still Works
Secrets in SSSS are stored as account data events on the homeserver. They can be retrieved anytime:
- User enters recovery key/passphrase
- Client fetches
m.secret_storage.key.<key_id>event - Client derives secret storage key from passphrase (or decodes base58)
- Client fetches secret's account data event
- Client decrypts locally
No other devices needed for SSSS retrieval.
6.2 Limitations
What does require other devices online:
- Interactive verification (emoji comparison, QR scan)
- Secret sharing via
m.secret.request/m.secret.send
What doesn't require other devices:
- Recovery via passphrase/recovery key entry
- Decrypting secrets from account data
6.3 Implication for Wallet Recovery
Your wallet seed recovery flow:
- User logs into new device
- User enters Matrix recovery key OR passphrase
- Client opens secret store, retrieves wallet seed
- Wallet is restored
This works even if no other devices are online.
7. Encryption Details
7.1 Algorithm: m.secret_storage.v1.aes-hmac-sha2
| Component | Algorithm | Parameters |
|---|---|---|
| Key Derivation (passphrase) | PBKDF2-HMAC-SHA512 | 500,000 iterations, 32-byte output |
| Per-Secret Key Derivation | HKDF-SHA256 | 32 zero-byte salt, secret name as info, 64-byte output |
| Encryption | AES-256-CTR | 16-byte IV, bit 63 cleared |
| Authentication | HMAC-SHA256 | 32-byte MAC |
7.2 IV Handling
// Generate 16 random bytes
let mut iv = [0u8; 16];
rng.fill_bytes(&mut iv);
// Clear bit 63 for cross-implementation compatibility
// (Some implementations have issues with high counter values)
let mut iv = u128::from_be_bytes(iv);
iv &= !(1 << 63);
iv.to_be_bytes()
7.3 Key Derivation Flow
Passphrase → PBKDF2 → 32-byte Master Key
↓
HKDF(master_key, salt=zeros, info=secret_name)
↓
64 bytes output
/ \
AES Key (32) MAC Key (32)
7.4 Encrypt-then-MAC
- Encrypt plaintext with AES-CTR → ciphertext
- HMAC(ciphertext) → MAC
- Store: {iv, ciphertext, mac}
On decrypt:
- Verify MAC first (constant-time comparison)
- Only if MAC valid, decrypt ciphertext
8. Security Analysis
8.1 Is Storing a Cardano Wallet Seed Safe?
Answer: Yes, with caveats.
What Makes It Safe
-
Strong Encryption: AES-256-CTR + HMAC-SHA256 is a well-established authenticated encryption scheme
-
Good Key Derivation:
- PBKDF2 with 500K iterations provides solid protection against offline brute-force
- HKDF provides proper domain separation per secret
-
Server Never Sees Plaintext: Secrets are encrypted client-side before upload
-
MAC Verification: Ciphertext integrity is verified before decryption
-
Zeroization: matrix-rust-sdk zeroizes key material after use
What Must Be True for Safety
-
Strong Recovery Key/Passphrase:
- Base58 recovery key: 256 bits of entropy (excellent)
- Passphrase: security depends entirely on passphrase strength
- Recommendation: Always use the generated recovery key, not a passphrase
-
Homeserver Trust Model:
- Homeserver sees encrypted blobs only
- Homeserver cannot decrypt without recovery key
- But homeserver could:
- Delete your secrets (availability attack)
- Serve old/modified encrypted data (but MAC will fail)
- For high-value wallets: Consider self-hosted homeserver
-
Client Security:
- Recovery key must never be logged, displayed insecurely, or transmitted
- Device must be free of malware at secret creation time
-
No Key Escrow:
- Matrix recovery key is the ONLY way to decrypt
- Lose recovery key = lose access to encrypted secrets
- Critical: User must securely back up recovery key
8.2 Threat Model
| Threat | Protection | Notes |
|---|---|---|
| Homeserver compromise | AES-256-CTR + HMAC | Attacker needs recovery key |
| Network eavesdropping | TLS + encrypted at rest | Double protection |
| Weak passphrase | PBKDF2 500K iterations | Still vulnerable to offline attack with weak passphrase |
| Malicious client | None | Must trust client code |
| Lost recovery key | None | Permanent loss of secrets |
8.3 Comparison to Hardware Wallet
| Factor | SSSS | Hardware Wallet |
|---|---|---|
| Key never leaves device | ❌ (stored encrypted on server) | ✅ |
| Survives device loss | ✅ | ❌ (unless backed up) |
| Requires extra hardware | ❌ | ✅ |
| Single point of failure | Recovery key | Physical device |
| Brute-force resistant | Depends on passphrase | Very high |
8.4 Security Recommendations
- Use generated recovery key, not passphrase for wallet seeds
- Store recovery key offline (paper, metal backup, safety deposit)
- Never store recovery key digitally in cloud services
- Self-host Matrix if storing significant value
- Consider additional encryption layer (encrypt wallet seed before storing in SSSS)
- Inform users clearly: Recovery key loss = permanent wallet loss
9. Precedents for Non-Matrix Secrets
9.1 Existing Non-E2EE Secrets in SSSS
Matrix itself stores non-E2EE-specific secrets:
m.megolm_backup.v1- Key backup recovery key
This demonstrates SSSS is designed for arbitrary cryptographic material.
9.2 No Known External Precedents
Research didn't find documented cases of:
- Cryptocurrency wallets using SSSS
- Third-party applications storing secrets in Matrix
You would be pioneering this use case.
9.3 Design Considerations from Spec Authors
From Matrix spec rationale:
"The secret storage mechanism provides a way to store arbitrary secrets on the homeserver encrypted by a key that only the user has access to."
The design explicitly anticipates custom secrets.
10. Wallet Storage Flow
Step-by-Step: Storing Cardano Wallet Seed
1. User creates new wallet in Element X Ada
└── Generate 24-word mnemonic (BIP-39)
2. Check if SSSS is set up
├── If yes: Open existing secret store
└── If no: Prompt user to set up recovery
└── Create new secret storage key
└── Save recovery key for user
3. Encrypt and store wallet seed
└── secret_store.put_secret("com.sulkta.cardano.wallet_seed", mnemonic).await?
4. Account data uploaded:
Event: com.sulkta.cardano.wallet_seed
Content: {
"encrypted": {
"<key_id>": {
"iv": "...",
"ciphertext": "...",
"mac": "..."
}
}
}
5. User sees: "Wallet backed up to your Matrix account"
Code Example
use matrix_sdk::Client;
use ruma::events::secret::request::SecretName;
const WALLET_SECRET_NAME: &str = "com.sulkta.cardano.wallet_seed";
pub async fn store_wallet_seed(
client: &Client,
mnemonic: &str,
) -> Result<(), Error> {
let secret_storage = client.encryption().secret_storage();
// Check if recovery is set up
if !secret_storage.is_enabled().await? {
return Err(Error::RecoveryNotSetUp);
}
// User must enter recovery key to unlock secret store
let recovery_key = prompt_user_for_recovery_key();
let secret_store = secret_storage
.open_secret_store(&recovery_key)
.await?;
// Store the wallet seed
let secret_name = SecretName::from(WALLET_SECRET_NAME);
secret_store.put_secret(secret_name, mnemonic).await?;
Ok(())
}
11. Wallet Restore Flow
Step-by-Step: Restoring on New Device
1. User logs into Matrix on new device
└── Standard Matrix login flow
2. Element X Ada detects no local wallet
└── Check for SSSS secret: com.sulkta.cardano.wallet_seed
3. User enters recovery key
└── Open secret store with recovery key
4. Retrieve and decrypt wallet seed
└── secret_store.get_secret("com.sulkta.cardano.wallet_seed")
5. Restore Cardano wallet from mnemonic
└── BIP-39 → BIP-32 derivation
6. User sees: "Wallet restored from your Matrix account"
Code Example
pub async fn restore_wallet_seed(
client: &Client,
recovery_key: &str,
) -> Result<Option<String>, Error> {
let secret_storage = client.encryption().secret_storage();
// Open secret store with user's recovery key
let secret_store = secret_storage
.open_secret_store(recovery_key)
.await?;
// Retrieve wallet seed
let secret_name = SecretName::from(WALLET_SECRET_NAME);
let wallet_seed = secret_store.get_secret(secret_name).await?;
Ok(wallet_seed)
}
pub async fn auto_restore_on_verification(client: &Client) {
// After cross-signing verification succeeds, try to restore
let recovery = client.encryption().recovery();
// If recovery is already set up and we have the key...
if recovery.state() == RecoveryState::Enabled {
// Secret sharing: request from other devices
// This only works if other devices are online
// Fallback: prompt user for recovery key
let key = prompt_for_recovery_key();
if let Ok(seed) = restore_wallet_seed(&client, &key).await {
initialize_cardano_wallet(seed);
}
}
}
12. Spec Gaps and Limitations
12.1 No Secret Deletion
Problem: Account data events cannot be deleted, only overwritten.
Workaround: To "delete" a secret, overwrite with empty encrypted content:
{
"encrypted": {}
}
Implication: Old encrypted versions may persist in homeserver history/backups.
12.2 Secret Sharing Not Automatic for Custom Secrets
Problem: import_secrets() only imports well-known secrets (cross-signing, backup key).
Workaround: After recovery, explicitly call get_secret() for your custom secret.
12.3 No Push Notification for Secret Availability
Problem: New device doesn't know if secrets exist until it tries to fetch them.
Workaround: Check for secret existence as part of onboarding flow.
12.4 Race Conditions on Secret Update
Problem: Concurrent updates from multiple devices can overwrite each other.
Mitigation: matrix-rust-sdk uses a lock, but only within single client instance.
For wallets: Wallet creation is typically one-time; updates are rare.
12.5 No Versioning
Problem: No built-in version tracking for secrets.
Workaround: Include version in secret content:
{
"version": 1,
"mnemonic": "word word word..."
}
12.6 Secret Size Limits
Problem: Account data events have practical size limits (varies by homeserver).
Wallet seed: 24 words ≈ 200 bytes. Well under any reasonable limit.
12.7 Recovery Key UX
Problem: Users must save recovery key separately. Loss = permanent loss.
Mitigation:
- Clear user education
- Multiple recovery methods (passphrase + recovery key)
- Recovery key QR code for easier backup
13. Recommendations
13.1 Implementation Recommendations
-
Use reverse-domain secret name:
com.sulkta.cardano.wallet_seed -
Store structured data:
{ "version": 1, "format": "bip39", "mnemonic": "word word word...", "created_at": "2024-01-01T00:00:00Z" } -
Always require recovery key entry for wallet operations:
- Don't cache the secret store key
- Re-prompt for recovery key when accessing wallet seed
-
Integrate with existing recovery flow:
- Piggyback on Matrix recovery setup
- Don't create separate "wallet backup" flow
-
Handle missing SSSS gracefully:
- If user hasn't set up recovery, prompt them to do so
- Allow manual seed export as fallback
13.2 Security Recommendations
- Strongly encourage recovery key over passphrase
- Warn users about recovery key loss consequences
- Consider additional application-layer encryption
- Audit matrix-rust-sdk integration thoroughly
- Self-hosting recommendation for high-value wallets
13.3 UX Recommendations
- Unified recovery flow: One recovery key for Matrix + wallet
- Clear onboarding: Explain that Matrix account = wallet backup
- Recovery key export: QR code, downloadable file, printable
- Regular recovery key verification prompts
Appendix A: Event Schema Summary
m.secret_storage.key.<key_id>
algorithm: "m.secret_storage.v1.aes-hmac-sha2"
iv: Base64 (16 bytes) # Optional, for key verification
mac: Base64 (32 bytes) # Optional, for key verification
passphrase: # Optional
algorithm: "m.pbkdf2"
salt: String
iterations: Integer
m.secret_storage.default_key
key: String # The key_id of the default key
<secret_name> (e.g., com.sulkta.cardano.wallet_seed)
encrypted:
<key_id>:
iv: Base64 (16 bytes)
ciphertext: Base64
mac: Base64 (32 bytes)
Appendix B: Relevant Source Files (matrix-rust-sdk)
| File | Purpose |
|---|---|
crates/matrix-sdk/src/encryption/secret_storage/mod.rs |
High-level SecretStorage API |
crates/matrix-sdk/src/encryption/secret_storage/secret_store.rs |
SecretStore: put_secret, get_secret |
crates/matrix-sdk/src/encryption/recovery/mod.rs |
Recovery flows |
crates/matrix-sdk-crypto/src/secret_storage.rs |
Low-level SecretStorageKey, encryption |
crates/matrix-sdk-crypto/src/ciphers.rs |
AES-CTR + HMAC implementation |
crates/matrix-sdk-crypto/src/gossiping/mod.rs |
Secret request/send handling |
Conclusion
Matrix SSSS is technically suitable for storing a Cardano wallet seed.
Strengths:
- Strong encryption (AES-256-CTR + HMAC-SHA256)
- Clean API for custom secrets
- Works offline (no other devices needed for recovery)
- Unified with Matrix identity/key management
Risks to mitigate:
- Recovery key loss = permanent wallet loss
- Homeserver trust (for availability, not confidentiality)
- User education on security model
Recommended approach:
- Integrate wallet seed storage into existing Matrix recovery flow
- Use generated recovery key (not passphrase) for wallet secrets
- Clear user communication that recovery key is critical
- Consider additional encryption layer for defense-in-depth