Add Matrix SSSS technical review for Cardano wallet storage

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
This commit is contained in:
Kayos 2026-03-26 19:56:08 -07:00
parent 34798f58a4
commit 7215ebb509

View file

@ -0,0 +1,824 @@
# 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
1. [SSSS Technical Overview](#1-ssss-technical-overview)
2. [Can You Store Custom Secrets?](#2-can-you-store-custom-secrets)
3. [Account Data Event Format](#3-account-data-event-format)
4. [Secret Sharing on New Device Verification](#4-secret-sharing-on-new-device-verification)
5. [matrix-rust-sdk API Surface](#5-matrix-rust-sdk-api-surface)
6. [Offline Device Recovery](#6-offline-device-recovery)
7. [Encryption Details](#7-encryption-details)
8. [Security Analysis](#8-security-analysis)
9. [Precedents for Non-Matrix Secrets](#9-precedents-for-non-matrix-secrets)
10. [Wallet Storage Flow](#10-wallet-storage-flow)
11. [Wallet Restore Flow](#11-wallet-restore-flow)
12. [Spec Gaps and Limitations](#12-spec-gaps-and-limitations)
13. [Recommendations](#13-recommendations)
---
## 1. SSSS Technical Overview
Matrix Secret Storage (SSSS) is an encrypted key/value store backed by [account data events](https://spec.matrix.org/latest/client-server-api/#client-config) 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**
1. Generate 32 random bytes as the secret key material
2. Generate a random key ID (32 alphanumeric characters)
3. 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)
4. Encrypt 32 zero bytes with derived key, store IV + MAC in key metadata
**Method B: Passphrase-Based**
1. Generate random 32-character salt
2. Run PBKDF2-HMAC-SHA512:
- Input: passphrase
- Salt: generated salt
- Iterations: 500,000 (default)
- Output: 32 bytes
3. 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:
```json
{
"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:
```json
{
"key": "<key_id>"
}
```
#### 1.4 Encrypting a Secret
For each secret with name `<secret_name>`:
1. 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)
2. Generate random 16-byte IV (with bit 63 cleared for implementation compatibility)
3. Encrypt secret using AES-256-CTR with derived AES key
4. Compute HMAC-SHA-256 over ciphertext using derived MAC key
5. Store as account data event with type = secret name
#### 1.5 Decrypting a Secret
1. Fetch account data event for secret name
2. Look up encrypted data for your key ID
3. Derive per-secret keys using HKDF (same as encryption)
4. Verify MAC before decryption
5. 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:
```rust
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.master`
- `m.cross_signing.self_signing`
- `m.cross_signing.user_signing`
- `m.megolm_backup.v1` (backup recovery key)
**Your custom secret would be:**
- `com.sulkta.cardano.wallet_seed` or 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
```json
{
"encrypted": {
"<key_id>": {
"iv": "<base64>", // 16 bytes
"ciphertext": "<base64>", // encrypted secret
"mac": "<base64>" // 32 bytes HMAC
}
}
}
```
Example for a Cardano wallet seed:
```json
{
"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`**
```json
{
"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`**
```json
{
"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:
1. The requesting device is owned by the same user
2. The requesting device is **verified** (cross-signed)
3. 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:
1. Enter the recovery key/passphrase manually
2. Decrypt secrets from account data directly
---
## 5. matrix-rust-sdk API Surface
### 5.1 Opening the Secret Store
```rust
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
```rust
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
```rust
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
```rust
// 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
```rust
// 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)
```rust
// 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:
1. User enters recovery key/passphrase
2. Client fetches `m.secret_storage.key.<key_id>` event
3. Client derives secret storage key from passphrase (or decodes base58)
4. Client fetches secret's account data event
5. 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:
1. User logs into new device
2. User enters Matrix recovery key OR passphrase
3. Client opens secret store, retrieves wallet seed
4. 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
```rust
// 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
1. Encrypt plaintext with AES-CTR → ciphertext
2. HMAC(ciphertext) → MAC
3. Store: {iv, ciphertext, mac}
On decrypt:
1. Verify MAC first (constant-time comparison)
2. 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
1. **Strong Encryption**: AES-256-CTR + HMAC-SHA256 is a well-established authenticated encryption scheme
2. **Good Key Derivation**:
- PBKDF2 with 500K iterations provides solid protection against offline brute-force
- HKDF provides proper domain separation per secret
3. **Server Never Sees Plaintext**: Secrets are encrypted client-side before upload
4. **MAC Verification**: Ciphertext integrity is verified before decryption
5. **Zeroization**: matrix-rust-sdk zeroizes key material after use
#### What Must Be True for Safety
1. **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
2. **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
3. **Client Security**:
- Recovery key must never be logged, displayed insecurely, or transmitted
- Device must be free of malware at secret creation time
4. **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
1. **Use generated recovery key, not passphrase** for wallet seeds
2. **Store recovery key offline** (paper, metal backup, safety deposit)
3. **Never store recovery key digitally** in cloud services
4. **Self-host Matrix** if storing significant value
5. **Consider additional encryption layer** (encrypt wallet seed before storing in SSSS)
6. **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
```rust
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
```rust
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:
```json
{
"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:
```json
{
"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
1. **Use reverse-domain secret name**: `com.sulkta.cardano.wallet_seed`
2. **Store structured data**:
```json
{
"version": 1,
"format": "bip39",
"mnemonic": "word word word...",
"created_at": "2024-01-01T00:00:00Z"
}
```
3. **Always require recovery key entry for wallet operations**:
- Don't cache the secret store key
- Re-prompt for recovery key when accessing wallet seed
4. **Integrate with existing recovery flow**:
- Piggyback on Matrix recovery setup
- Don't create separate "wallet backup" flow
5. **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
1. **Strongly encourage recovery key over passphrase**
2. **Warn users about recovery key loss consequences**
3. **Consider additional application-layer encryption**
4. **Audit matrix-rust-sdk integration thoroughly**
5. **Self-hosting recommendation for high-value wallets**
### 13.3 UX Recommendations
1. **Unified recovery flow**: One recovery key for Matrix + wallet
2. **Clear onboarding**: Explain that Matrix account = wallet backup
3. **Recovery key export**: QR code, downloadable file, printable
4. **Regular recovery key verification prompts**
---
## Appendix A: Event Schema Summary
### m.secret_storage.key.\<key_id\>
```yaml
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
```yaml
key: String # The key_id of the default key
```
### \<secret_name\> (e.g., com.sulkta.cardano.wallet_seed)
```yaml
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:
1. Integrate wallet seed storage into existing Matrix recovery flow
2. Use generated recovery key (not passphrase) for wallet secrets
3. Clear user communication that recovery key is critical
4. Consider additional encryption layer for defense-in-depth