element-x-ada/MATRIX-KEY-STORAGE-REVIEW.md
Kayos 7215ebb509 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
2026-03-26 19:56:08 -07:00

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

  1. SSSS Technical Overview
  2. Can You Store Custom Secrets?
  3. Account Data Event Format
  4. Secret Sharing on New Device Verification
  5. matrix-rust-sdk API Surface
  6. Offline Device Recovery
  7. Encryption Details
  8. Security Analysis
  9. Precedents for Non-Matrix Secrets
  10. Wallet Storage Flow
  11. Wallet Restore Flow
  12. Spec Gaps and Limitations
  13. 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

  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:

{
  "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>:

  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:

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

{
  "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:

  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

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:

  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

// 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

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

  1. Use reverse-domain secret name: com.sulkta.cardano.wallet_seed

  2. Store structured data:

    {
      "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>

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:

  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