Mobile
MWA Deep Dive

MWA Deep Dive

Ce contenu est en cours de traduction et sera disponible ici dès qu'il sera prêt.

Session Establishment

MWA Deep Dive

Overview

Once transport is established, the dApp and wallet must create a secure channel. This happens through a cryptographic handshake using Elliptic Curve Diffie-Hellman (ECDH) key exchange. By the end, both parties share a symmetric encryption key that no eavesdropper can derive.

The Handshake Messages

Session establishment uses two messages:

HELLO_REQ: Sent by the dApp to the wallet HELLO_RSP: Sent by the wallet to the dApp

These are the only unencrypted protocol messages. Everything after is encrypted.

HELLO_REQ Structure

The dApp sends HELLO_REQ immediately after the WebSocket connection is established:

text
+------------------------------------------------------------------+
|  Qd (session public key)      |  Sa (signature)                  |
|      (65 bytes)               |    (64 bytes)                    |
+------------------------------------------------------------------+

Total size: 129 bytes

Components:

Qd (65 bytes): The dApp's session public key (P-256, uncompressed format). This is different from the association public key. The dApp generates two keypairs.

Sa (64 bytes): An ECDSA signature over Qd using the association private key. This proves the dApp controls the association key from the URI.

Why Two Keypairs?

The dApp uses two distinct P-256 keypairs:

KeypairUsed ForAlgorithm
Association keypairAuthenticating the dApp (signing HELLO_REQ)ECDSA
Session keypairKey exchange (deriving shared secret)ECDH

This separation follows cryptographic best practice: don't reuse keys for different purposes.

HELLO_REQ Generation

Let's trace the exact steps from the SDK:

typescript
// From createHelloReq.ts (simplified)
async function createHelloReq(
  associationPrivateKey: CryptoKey,
  sessionPublicKey: CryptoKey
): Promise<Uint8Array> {
  // Export the session public key in raw format (65 bytes)
  const sessionPublicKeyBuffer = await crypto.subtle.exportKey(
    'raw',
    sessionPublicKey
  );
  const Qd = new Uint8Array(sessionPublicKeyBuffer);

  // Sign Qd with the association private key
  const signature = await crypto.subtle.sign(
    {
      name: 'ECDSA',
      hash: 'SHA-256'
    },
    associationPrivateKey,
    Qd
  );
  
  // Convert signature from DER to raw format (64 bytes: r || s)
  const Sa = derToRaw(new Uint8Array(signature));

  // Construct the message (no message type byte - just Qd and Sa)
  const message = new Uint8Array(65 + 64);
  message.set(Qd, 0);
  message.set(Sa, 65);
  
  return message;
}

Signature Format Note

ECDSA signatures can be in DER format (variable length) or raw format (fixed 64 bytes for P-256). MWA uses raw format: the 32-byte r value concatenated with the 32-byte s value.

The SDK includes conversion functions because Web Crypto API returns DER format:

typescript
// DER signature structure:
// 0x30 [total-length] 0x02 [r-length] [r] 0x02 [s-length] [s]
// Raw signature structure:
// [r (32 bytes)] [s (32 bytes)]

HELLO_RSP Structure

The wallet responds with HELLO_RSP:

text
+------------------------------------------------------------------+
|  Qw (wallet session key)      |  Session Props                   |
|      (65 bytes)               |  (variable JSON)                 |
+------------------------------------------------------------------+

Components:

Qw (65 bytes): The wallet's session public key (P-256, uncompressed).

Session Props (variable): A JSON object encoded as UTF-8, containing session properties:

json
{
  "protocol_version": "2.0.0"
}

Why No Signature from Wallet?

Notice the wallet doesn't sign HELLO_RSP. This is intentional:

  1. The dApp already trusts the wallet (the user installed it)

  2. The wallet proves knowledge of its private key during ECDH; if it has the wrong key, encryption fails

  3. Wallet identity verification (for UI purposes) happens at a higher layer via Digital Asset Links

Key Derivation

After exchanging hello messages, both parties can derive the shared secret and session keys.

ECDH Shared Secret

The dApp computes:

text
shared_secret = ECDH(dApp_session_private_key, wallet_session_public_key)

The wallet computes:

text
shared_secret = ECDH(wallet_session_private_key, dApp_session_public_key)

Both arrive at the same 32-byte value (the x-coordinate of the ECDH result point).

HKDF Key Derivation

The shared secret alone isn't used directly. Instead, MWA uses HKDF (HMAC-based Key Derivation Function) to derive the AES-128-GCM session key:

text
session_key = HKDF-SHA256(
  IKM: shared_secret,      // 32 bytes from ECDH
  salt: Qa,                // 65 bytes - the association public key
  L: 16                    // Output length in bytes (128 bits for AES-128)
)

Note: There is no info parameter in this HKDF application. Just IKM, salt, and the output length.

HKDF in Detail

HKDF has two phases:

Extract: Combine the input key material (IKM) with the salt to produce a pseudorandom key (PRK).

text
PRK = HMAC-SHA256(salt, IKM)
    = HMAC-SHA256(Qa, shared_secret)

Expand: Use the PRK to generate the desired amount of key material.

text
T(1) = HMAC-SHA256(PRK, 0x01)
OKM = first 16 bytes of T(1)

Since we only need 16 bytes and SHA-256 produces 32 bytes, a single expansion step suffices.

The SDK implements this:

typescript
// From parseHelloRsp.ts (simplified)
async function deriveSessionKey(
  ecdhPrivateKey: CryptoKey,
  walletPublicKey: CryptoKey,
  associationPublicKey: CryptoKey
): Promise<Uint8Array> {
  // Step 1: ECDH to get shared secret
  const sharedSecret = await crypto.subtle.deriveBits(
    {
      name: 'ECDH',
      public: walletPublicKey
    },
    ecdhPrivateKey,
    256 // bits
  );

  // Step 2: Export association public key as salt
  const salt = await crypto.subtle.exportKey('raw', associationPublicKey);

  // Step 3: HKDF Extract - import shared secret as HKDF input
  const hkdfKey = await crypto.subtle.importKey(
    'raw',
    sharedSecret,
    'HKDF',
    false,
    ['deriveBits']
  );

  // Step 4: HKDF Expand - derive session key (no info parameter)
  const sessionKeyBits = await crypto.subtle.deriveBits(
    {
      name: 'HKDF',
      hash: 'SHA-256',
      salt: new Uint8Array(salt),
      info: new Uint8Array(0) // Empty - MWA spec uses no info parameter
    },
    hkdfKey,
    128 // AES-128 key size in bits
  );

  return new Uint8Array(sessionKeyBits);
}

Why This Design?

Several cryptographic decisions deserve explanation:

Why P-256 (secp256r1)?

Not secp256k1 (Bitcoin/Ethereum curve). Reasons:

  1. Hardware support: P-256 is supported by Android's hardware security module and iOS Secure Enclave

  2. Web Crypto API: Built-in support for P-256, no external libraries needed

  3. NIST standardization: Widely audited and trusted

Why Not Just Use TLS?

TLS would work for encryption but doesn't solve:

  1. App-level authentication: TLS authenticates servers, not mobile apps

  2. Localhost certificates: No certificate authority issues certs for 127.0.0.1

  3. Platform constraints: Native apps don't have the same TLS integration as browsers

Why ECDH + HKDF Instead of Direct Key Use?

  1. Key separation: HKDF ensures the session key is cryptographically independent of the raw ECDH output

  2. Salt binding: Using the association public key as salt binds the session to this specific association

  3. Flexibility: HKDF can derive multiple keys if needed (e.g., separate encryption and MAC keys)

State Machine After Hello

After successful hello exchange, the session state includes:

typescript
{
  __type: 'hello_rsp_received',
  ws: WebSocket,                    // Active connection
  associationPublicKey: CryptoKey,  // For verification
  ecdhPrivateKey: CryptoKey,        // dApp's session private key
  sessionKey: Uint8Array,           // Derived AES-128 key (16 bytes)
  sequenceNumber: 1                 // Starts at 1, not 0
}

From this point, all messages are encrypted with AES-GCM using sessionKey.

Handshake Timing

The handshake is fast, typically under 50ms on modern devices. Timing breakdown:

OperationTypical Time
Generate session keypair2-5ms
Sign Qd with association key1-2ms
Send HELLO_REQ<1ms (local)
Wallet generate keypair2-5ms
Wallet send HELLO_RSP<1ms (local)
ECDH derivation2-5ms
HKDF derivation<1ms

Total: ~10-20ms on modern hardware.

For remote connections via reflector, add network latency (typically 50-200ms round-trip).

Session Establishment Failures

What can go wrong?

Signature Verification Failed

The wallet couldn't verify Sa over Qd using the association public key from the URI.

Causes:

  • URI was tampered with

  • dApp implementation bug (signing wrong data)

  • Key format mismatch

Invalid Public Key Format

Qd or Qw isn't a valid P-256 point.

Causes:

  • Malformed message

  • Wrong curve (e.g., secp256k1 key sent as P-256)

  • Corrupted transmission (rare with WebSocket)

Session Timeout

The wallet didn't respond to HELLO_REQ within the timeout.

Causes:

  • Wallet not fully launched

  • User distracted by wallet onboarding

  • Wallet bug

Protocol Version Mismatch

The wallet's protocol_version in session props indicates incompatibility.

Causes:

  • Very old wallet version

  • Very new wallet with breaking changes

Complete Handshake Example

Let's trace byte-by-byte (with realistic but not real values):

1. dApp generates association keypair

text
Association public key (Qa): 04 a1b2c3... (65 bytes)
Association private key (da): stored securely

2. dApp generates session keypair

text
Session public key (Qd): 04 d4e5f6... (65 bytes)
Session private key (dd): stored securely

3. dApp signs Qd

text
Sa = ECDSA-Sign(da, Qd) = 7a8b9c... (64 bytes)

4. dApp sends HELLO_REQ

text
00 04d4e5f6...(65 bytes)... 7a8b9c...(64 bytes)...
│   └─── Qd ───────────────┘ └─── Sa ──────────┘
└─ message type (HELLO_REQ)

5. Wallet receives HELLO_REQ

text
- Extract Qd (bytes 1-65)
- Extract Sa (bytes 66-129)
- Import Qa from URI
- Verify: ECDSA-Verify(Qa, Qd, Sa) == true

6. Wallet generates session keypair

text
Session public key (Qw): 04 1a2b3c... (65 bytes)
Session private key (dw): stored securely

7. Wallet sends HELLO_RSP

text
01 041a2b3c...(65 bytes)... {"protocol_version":"2.0.0"}
│   └─── Qw ───────────────┘ └─── session props (JSON) ──┘
└─ message type (HELLO_RSP)

8. Both derive shared secret

text
dApp:   shared = ECDH(dd, Qw) = 5f6a7b... (32 bytes)
Wallet: shared = ECDH(dw, Qd) = 5f6a7b... (32 bytes)

9. Both derive session key

text
session_key = HKDF(shared, Qa, "session_key") = 9d8e7f... (16 bytes)

10. Session established

Both now have session_key and can encrypt/decrypt messages.

Verifying the Implementation

If you're implementing MWA or debugging issues, verify each step:

  1. Keypair generation: Log the public keys in hex. They should be 65 bytes, starting with 04.

  2. Signature: Verify manually using an online P-256 verifier. The message is Qd, the key is Qa.

  3. ECDH: Both sides should derive the same shared secret. If not, check key formats.

  4. HKDF: Use a test vector. The Python cryptography library has HKDF support for comparison.

Security Analysis

The session establishment provides:

Forward Secrecy: Each session uses fresh ECDH keys. Compromising one session doesn't compromise others.

Authentication: The dApp proves it controls the association key by signing HELLO_REQ.

Key Binding: HKDF with the association key as salt ensures the derived key is bound to this association.

No Replay: Session keys are ephemeral. An attacker can't replay an old HELLO_REQ to establish a session; they'd need the current association private key.

Limitations:

No Wallet Authentication: The wallet doesn't cryptographically prove its identity. The dApp trusts that the responding endpoint is a legitimate wallet.

Side Channel Risks: On compromised devices, private keys might be extractable from memory.

In the next lesson, we examine how messages are encrypted after session establishment: the AES-GCM protocol and sequence number handling.

Blueshift © 2026Commit: 1b8118f