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| State | Meaning | What the user can do |
|---|---|---|
unverified | No one has verified yet | Cannot send messages. Incoming ciphertext goes to quarantine. |
my_side_verified | This client confirmed the peer's code | Cannot send yet. Still waiting for the peer to verify. |
verified | Both sides confirmed | Chat unlocked. Quarantined messages are decrypted and surfaced. |
Type:
import type { SessionTrustState } from '@daomessage_sdk/sdk';
// 'unverified' | 'my_side_verified' | 'verified'Read the current state for a conversation:
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 dashesThe two helpers you need:
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:
import { computeSecurityCode, formatSecurityCode } from '@daomessage_sdk/sdk';
const hex60 = computeSecurityCode(myEcdhPub, theirEcdhPub);
const display = formatSecurityCode(hex60); // "ABCD EFGH IJKL ..." groupedEnd-to-End Flow
Step 1 — Compute and share my code
Triggered when the user opens the verification UI for an unverified conversation.
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.
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."
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_verifiedevent for a session that this client never marked locally, the SDK refuses to upgrade. The relay cannot promote a client pastmy_side_verifiedon its own.
After upgrade:
trustStatebecomesverified- Sending is unlocked
- Quarantined ciphertext (received during the unverified window) is decrypted in order and surfaced via the normal
messageevent
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:
import { verifyReset } from '@daomessage_sdk/sdk';
await verifyReset(
'https://relay.daomessage.com',
authToken,
friendshipId,
conversationId,
);This:
- Clears
verified_at,user_a_verified_at,user_b_verified_aton the relay. - Publishes
friend_verify_resetto both sides over NATS. - Both clients reset their local
trustStatetounverified. - 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):
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):
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:
trustState | What the chat should show |
|---|---|
unverified | Hard overlay, "Start verification" button, no input bar |
my_side_verified | Soft overlay, "Waiting for peer" indicator, no input bar |
verified | Normal 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:
- UI layer — Block message input until
verified. (Easy to bypass, but makes the right thing the default.) - SDK layer —
client.messages.send()throwsUNVERIFIED_SESSIONif the session is notverified. (Stops your own UI bugs from leaking plaintext.) - Relay layer — The Go server refuses to forward messages between users whose
friendship.verified_atis 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_verifiedalone. The SDK already guards this, but if you're implementing the equivalent in another language, the rule is: a session can only becomeverifiedif it wasmy_side_verifiedfirst. 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
| Function | Purpose |
|---|---|
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.