Skip to content

Security Verification

DAO Message ships with mandatory bilateral key verification (强制双边密钥核对). Every conversation is gated until both parties verify each other's identity through a separate channel (WeChat, email, SMS, in person, etc.). Until that happens, messages cannot be sent and incoming ciphertext is held in a quarantine queue.

This page describes the protocol and the SDK surface. The corresponding spec is in docs/spec/强制双边密钥核对-设计方案-V1.md.

Why Bilateral

The relay server only forwards encrypted bytes — it cannot read messages. The remaining attack surface is key substitution: a malicious server hands Alice "Bob's public key" that actually belongs to the server, then proxies the ciphertext. To detect this, both sides must compare a value derived from the real keys through a channel the server does not control.

Bilateral (rather than unilateral) verification closes a subtle hole: if only one side has to verify, an attacker can forge the "your friend verified you" notification to the other side. Requiring both sides to actively verify, gated by local cryptographic state, makes that forgery detectable.

Three-State Trust Machine

Every conversation has one of three states, persisted in IndexedDB and mirrored on the relay's friendship row:

unverified  →  my_side_verified  →  verified
StateMeaningWhat the user can do
unverifiedNo one has verified yetCannot send messages. Incoming ciphertext goes to quarantine.
my_side_verifiedThis client confirmed the peer's codeCannot send yet. Still waiting for the peer to verify.
verifiedBoth sides confirmedChat unlocked. Quarantined messages are decrypted and surfaced.

Type:

typescript
import type { SessionTrustState } from '@daomessage_sdk/sdk';
// 'unverified' | 'my_side_verified' | 'verified'

Read the current state for a conversation:

typescript
import { loadSession } from '@daomessage_sdk/sdk';

const session = await loadSession(conversationId);
const state: SessionTrustState = session?.trustState ?? 'unverified';

The Verification Code (方案 Y)

Each user sees a different code in their UI — Alice and Bob do not see the same string. This is intentional and not a bug. Both codes are derived from the same ECDH shared secret using HKDF with directional salt, so each side can locally compute "the code my peer should be sending me" and compare it to what the peer actually shared.

Algorithm (16-character base32, formatted with dashes):

shared = X25519(myPriv, theirPub)              // ECDH shared secret
code   = HKDF-SHA256(
           ikm  = shared,
           salt = fromPub || toPub,            // direction matters
           info = "dao-message-verify-v1",
           L    = 10 bytes,
         )
       → base32 encode → 16 chars → group with dashes

The two helpers you need:

typescript
import {
  computeSharedSecret,
  computeDirectionalCode,
  normalizeDirectionalCode,
} from '@daomessage_sdk/sdk';

const shared = computeSharedSecret(myEcdhPriv, theirEcdhPub);

// What the user sees (and shares with the peer over WeChat/email/SMS).
const myCode = computeDirectionalCode(shared, myEcdhPub, theirEcdhPub);

// What the peer should be sending — used to compare against user input.
const expectedTheirCode = computeDirectionalCode(shared, theirEcdhPub, myEcdhPub);

// Compare after normalizing (strips dashes, spaces, lowercases).
const ok = normalizeDirectionalCode(userInput) === normalizeDirectionalCode(expectedTheirCode);

normalizeDirectionalCode is the only correct way to compare — never compare raw strings, because the user's pasted input may contain extra spaces, line breaks, lowercase letters, or copy-pasted invisible characters.

Advanced Mode: Full Public Key Fingerprint

For paranoid users (or in-person verification with QR codes), expose the full 60-character SHA-256 fingerprint. This value is identical on both sides, unlike the directional code:

typescript
import { computeSecurityCode, formatSecurityCode } from '@daomessage_sdk/sdk';

const hex60   = computeSecurityCode(myEcdhPub, theirEcdhPub);
const display = formatSecurityCode(hex60);  // "ABCD EFGH IJKL ..." grouped

End-to-End Flow

Step 1 — Compute and share my code

Triggered when the user opens the verification UI for an unverified conversation.

typescript
import {
  loadIdentity,
  deriveIdentity,
  loadSession,
  fromBase64,
  computeSharedSecret,
  computeDirectionalCode,
} from '@daomessage_sdk/sdk';

const stored   = await loadIdentity();
const identity = deriveIdentity(stored!.mnemonic);
const myPriv   = identity.ecdhKey.privateKey;
const myPub    = identity.ecdhKey.publicKey;

const session  = await loadSession(conversationId);
const theirPub = fromBase64(session!.theirEcdhPublicKey);

const shared          = computeSharedSecret(myPriv, theirPub);
const myCode          = computeDirectionalCode(shared, myPub, theirPub);
const expectedPeerCode = computeDirectionalCode(shared, theirPub, myPub);

// Show `myCode` in the UI. User sends it to the peer through an
// external channel (WeChat / email / SMS / in person).

Step 2 — Verify the peer's code locally

The user pastes the code their peer sent them. Compare it locally — never send raw codes to the server.

typescript
import { normalizeDirectionalCode } from '@daomessage_sdk/sdk';

const ok = normalizeDirectionalCode(userInput)
        === normalizeDirectionalCode(expectedPeerCode);

if (!ok) {
  // Three strikes UX: show a hard MITM warning.
  // Do NOT proceed to step 3 — the peer's code does not match the keys
  // we have for them. Either copy-paste error or a real attack.
}

Step 3 — Mark "my side" verified on the relay

Only call this after step 2 passed. This both writes the local state and tells the server "I am done."

typescript
import { markMyVerified } from '@daomessage_sdk/sdk';

await markMyVerified(
  'https://relay.daomessage.com',
  authToken,            // JWT from client.http.getToken()
  friendshipId,         // From session / friend list
  conversationId,
);
// Local trustState is now 'my_side_verified'.
// Server's friendship.user_X_verified_at column is now set.

The session is now my_side_verified. The user still cannot send messages. The UI should switch to a "waiting for peer" state.

Step 4 — The peer does the same

The relay accepts both sides' verify-mark calls atomically. The moment the second side calls it, the relay sets friendship.verified_at and publishes a friend_verified event over NATS to both users.

Step 5 — Both sides upgrade to verified

The SDK handles this automatically. When the NATS event arrives, the SDK calls maybeMarkSessionVerified internally, which only upgrades if the local state is already my_side_verified. This guard is critical:

If the relay forges a friend_verified event for a session that this client never marked locally, the SDK refuses to upgrade. The relay cannot promote a client past my_side_verified on its own.

After upgrade:

  • trustState becomes verified
  • Sending is unlocked
  • Quarantined ciphertext (received during the unverified window) is decrypted in order and surfaced via the normal message event

Resetting Verification

If a contact recovers their account on a new device, restores from a different mnemonic, or you suspect compromise, reset the session and re-verify from scratch:

typescript
import { verifyReset } from '@daomessage_sdk/sdk';

await verifyReset(
  'https://relay.daomessage.com',
  authToken,
  friendshipId,
  conversationId,
);

This:

  1. Clears verified_at, user_a_verified_at, user_b_verified_at on the relay.
  2. Publishes friend_verify_reset to both sides over NATS.
  3. Both clients reset their local trustState to unverified.
  4. The chat UI re-locks until both sides re-verify.

Quarantine Queue

While trustState !== 'verified', incoming ciphertext is not decrypted. Instead, it is stored in the IndexedDB quarantineMessages store (managed by the SDK):

typescript
import type { QuarantinedMessage } from '@daomessage_sdk/sdk';

You almost never need to interact with this directly — the SDK replays quarantined ciphertext after upgrade to verified. If verifyReset is called, the queue is discarded, not replayed, because anything received during a possibly-attacked window cannot be trusted.

Reacting to State Changes in the UI

The SDK exposes an internal onTrustStateChange hook on the underlying MessageModule. The recommended pattern is to re-read the session from IndexedDB when the hook fires (the hook itself is intentionally minimal):

typescript
const inner = (client as any).messages?.inner;
if (inner) {
  inner.onTrustStateChange = async ({ conversationId: cId }) => {
    const fresh = await loadSession(cId);
    const newState = fresh?.trustState ?? 'unverified';
    // Update component state, close modals if newState === 'verified', etc.
  };
}

UI rules of thumb:

trustStateWhat the chat should show
unverifiedHard overlay, "Start verification" button, no input bar
my_side_verifiedSoft overlay, "Waiting for peer" indicator, no input bar
verifiedNormal chat UI, green shield in header

Three-Layer Defense

The SDK's bilateral verification is part of a defense-in-depth model. Do not skip any of these layers, even though they overlap:

  1. UI layer — Block message input until verified. (Easy to bypass, but makes the right thing the default.)
  2. SDK layerclient.messages.send() throws UNVERIFIED_SESSION if the session is not verified. (Stops your own UI bugs from leaking plaintext.)
  3. Relay layer — The Go server refuses to forward messages between users whose friendship.verified_at is NULL. (Stops modified clients.)

Each layer assumes the others might fail. A correctly-built client touches all three.

Common Pitfalls

  • Comparing raw strings instead of normalized. Always run normalizeDirectionalCode() on both inputs before ===.
  • Showing the same code to both users. That's the legacy fingerprint mode. The directional code is intentionally different per side. Tell users this in the UI ("It's normal that both sides see different codes").
  • Auto-marking verified on the basis of the server's friend_verified alone. The SDK already guards this, but if you're implementing the equivalent in another language, the rule is: a session can only become verified if it was my_side_verified first. Anything else is a forgery vector.
  • Not handling verifyReset. If a peer resets and you don't, your local UI shows "verified" while the server refuses to forward — the user sees broken sending without explanation.
  • Dropping the quarantine on app launch. It must persist across restarts. If you re-implement, store it in durable storage (the SDK uses an IndexedDB object store).

API Reference

FunctionPurpose
computeSharedSecret(myPriv, theirPub)X25519 ECDH.
computeDirectionalCode(shared, fromPub, toPub)16-char base32 directional code.
normalizeDirectionalCode(input)Strip whitespace/dashes, lowercase, for comparison.
computeSecurityCode(myPub, theirPub)60-hex full fingerprint (advanced mode).
formatSecurityCode(hex60)Pretty grouping for display.
markMyVerified(apiBase, token, friendshipId, convId)Mark this side verified on relay + locally.
verifyReset(apiBase, token, friendshipId, convId)Reset both sides back to unverified.
loadSession(convId)Read current trustState and ECDH keys.
maybeMarkSessionVerified(convId)(Internal) Guarded upgrade to verified.
markSessionMyVerified(convId)(Internal) Used by markMyVerified.
resetSessionTrust(convId)(Internal) Used by verifyReset.

Types: SessionTrustState, SessionRecord, QuarantinedMessage.

Zero-Knowledge E2EE Protocol — Decentralized Communication