You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Open-IM-Server/docs/virgil-e2ee-client-pseudoco...

9.1 KiB

OpenIM + Virgil E2EE 客户端伪代码模板

本文给出可直接改造成业务代码的四段伪代码模板:

  1. 登录初始化
  2. 单聊加密发送与接收解密
  3. 群聊加密发送与接收解密
  4. 群密钥版本追平(增量同步)

约定:

  • OpenIM SDK 负责业务消息收发、会话管理。
  • OpenIM CryptoService 负责设备注册、JWT、群版本事件。
  • Virgil E3Kit 负责本地加解密。
  • 服务端不解密消息明文。

0. 公共结构与工具函数

type DeviceInfo = {
  deviceID: string
  platform: string
  model: string
  appVersion: string
}

type CipherEnvelope = {
  alg: "virgil-e3kit-v1"
  senderUserID: string
  senderDeviceID: string
  // base64 ciphertext
  payload: string
  // 扩展字段会话ID、消息版本等
  meta?: Record<string, string>
}

type GroupCipherEnvelope = {
  alg: "virgil-group-v1"
  groupID: string
  groupKeyVersion: number
  senderUserID: string
  senderDeviceID: string
  payload: string
  meta?: Record<string, string>
}

function toBase64(input: Uint8Array): string { /* ... */ }
function fromBase64(input: string): Uint8Array { /* ... */ }
function nowMs(): number { return Date.now() }

function buildDeviceInfo(): DeviceInfo {
  return {
    deviceID: getStableDeviceID(), // 本地持久化的设备唯一ID
    platform: getPlatformName(),   // iOS/Android/Web/Desktop
    model: getDeviceModel(),
    appVersion: getAppVersion(),
  }
}

1) 登录初始化(设备注册 + Virgil 初始化)

async function loginAndInitE2EE(userID: string, token: string) {
  // Step 1: 初始化 OpenIM SDK 登录态
  await openim.login({ userID, token })

  // Step 2: 注册设备(幂等)
  const device = buildDeviceInfo()
  await openim.crypto.registerDevice({
    deviceID: device.deviceID,
    platform: device.platform,
    deviceModel: device.model,
    appVersion: device.appVersion,
  })

  // Step 3: 获取 Virgil JWT短期
  const jwtResp = await openim.crypto.getVirgilJWT({
    deviceID: device.deviceID,
  })
  const virgilJWT = jwtResp.virgilJWT
  const virgilIdentity = jwtResp.virgilIdentity // 一般是 userID:deviceID

  // Step 4: 初始化 E3Kit客户端本地
  const e3 = await E3Kit.initialize(async () => virgilJWT)

  // Step 5: 首次设备场景(仅示例)
  // - 若本地无私钥:生成并 publishCard
  // - 若有 Keyknox 备份restorePrivateKey
  if (!(await e3.hasLocalPrivateKey())) {
    await e3.register() // 内部通常会 publish card
  }

  // Step 6: 安全预检(可选但建议)
  const precheck = await openim.crypto.securityPrecheck({
    deviceID: device.deviceID,
    action: "login_init_e2ee",
  })
  if (!precheck.allowed) {
    throw new Error(`security precheck denied: ${precheck.reason}`)
  }

  // Step 7: 持有上下文
  return {
    userID,
    deviceID: device.deviceID,
    virgilIdentity,
    e3,
  }
}

2) 单聊发收(发送加密 / 接收解密)

2.1 发送单聊加密消息

async function sendPrivateEncryptedText(
  ctx: { userID: string; deviceID: string; e3: E3Kit },
  toUserID: string,
  plainText: string
) {
  // 1) 拉取接收方 cardVirgil
  const recipientCard = await ctx.e3.findUsers(toUserID)
  if (!recipientCard) throw new Error("recipient card not found")

  // 2) 本地加密
  const encrypted = await ctx.e3.encrypt(plainText, recipientCard)

  // 3) 封装消息体(发给 OpenIM 的 content
  const envelope: CipherEnvelope = {
    alg: "virgil-e3kit-v1",
    senderUserID: ctx.userID,
    senderDeviceID: ctx.deviceID,
    payload: toBase64(encrypted),
  }

  // 4) 通过 OpenIM 普通消息通道发送(服务端仅转发存储密文)
  await openim.sendMessage({
    recvID: toUserID,
    conversationType: "single",
    contentType: "custom_e2ee_text", // 你项目定义的 contentType
    content: JSON.stringify(envelope),
  })
}

2.2 接收单聊加密消息并解密

async function onPrivateMessageReceived(
  ctx: { e3: E3Kit },
  msg: { contentType: string; content: string; sendID: string }
) {
  if (msg.contentType !== "custom_e2ee_text") return

  const envelope = JSON.parse(msg.content) as CipherEnvelope
  if (envelope.alg !== "virgil-e3kit-v1") return

  // 1) 拉取发送方 card
  const senderCard = await ctx.e3.findUsers(msg.sendID)
  if (!senderCard) {
    markMessageDecryptFailed(msg, "sender card not found")
    return
  }

  // 2) 本地解密
  try {
    const plainText = await ctx.e3.decrypt(fromBase64(envelope.payload), senderCard)
    renderMessage(msg, plainText)
  } catch (err) {
    markMessageDecryptFailed(msg, `decrypt failed: ${String(err)}`)
  }
}

3) 群聊发收(发送加密 / 接收解密)

3.1 发送群聊加密消息

async function sendGroupEncryptedText(
  ctx: { userID: string; deviceID: string; e3: E3Kit },
  groupID: string,
  plainText: string
) {
  // 1) 先确保本地群密钥版本已追平见第4段
  const version = await ensureGroupKeySynced(ctx, groupID)

  // 2) 获取/创建本地 group session示意
  const groupSession = await getOrCreateGroupSession(ctx.e3, groupID, version)

  // 3) 本地加密
  const encrypted = await groupSession.encrypt(plainText)

  // 4) 封装消息体
  const envelope: GroupCipherEnvelope = {
    alg: "virgil-group-v1",
    groupID,
    groupKeyVersion: version,
    senderUserID: ctx.userID,
    senderDeviceID: ctx.deviceID,
    payload: toBase64(encrypted),
  }

  // 5) 发送到 OpenIM
  await openim.sendMessage({
    recvID: groupID,
    conversationType: "group",
    contentType: "custom_e2ee_group_text",
    content: JSON.stringify(envelope),
  })
}

3.2 接收群聊加密消息并解密

async function onGroupMessageReceived(
  ctx: { e3: E3Kit },
  msg: { groupID: string; contentType: string; content: string }
) {
  if (msg.contentType !== "custom_e2ee_group_text") return

  const envelope = JSON.parse(msg.content) as GroupCipherEnvelope
  if (envelope.alg !== "virgil-group-v1") return

  // 1) 若消息携带版本比本地新,先追平
  const localVersion = await loadLocalGroupKeyVersion(msg.groupID)
  if (envelope.groupKeyVersion > localVersion) {
    await ensureGroupKeySynced({ e3: ctx.e3 } as any, msg.groupID)
  }

  // 2) 取本地会话并解密
  try {
    const groupSession = await getOrCreateGroupSession(ctx.e3, msg.groupID, envelope.groupKeyVersion)
    const plainText = await groupSession.decrypt(fromBase64(envelope.payload))
    renderMessage(msg, plainText)
  } catch (err) {
    markMessageDecryptFailed(msg, `group decrypt failed: ${String(err)}`)
  }
}

4) 群密钥版本追平(增量同步模板)

async function ensureGroupKeySynced(
  ctx: { userID?: string; deviceID?: string; e3: E3Kit },
  groupID: string
): Promise<number> {
  // A) 服务端当前版本
  const latestResp = await openim.crypto.getGroupKeyVersion({ groupID })
  const latestVersion = Number(latestResp.groupKeyVersion || 0)

  // B) 本地版本
  let localVersion = await loadLocalGroupKeyVersion(groupID) // 默认 0
  if (localVersion >= latestVersion) return localVersion

  // C) 拉取增量事件
  const eventsResp = await openim.crypto.getGroupKeyEvents({
    groupID,
    sinceVersion: localVersion,
  })
  const events = eventsResp.events || []

  // D) 按版本顺序应用事件(关键)
  events.sort((a, b) => Number(a.groupKeyVersion) - Number(b.groupKeyVersion))
  for (const ev of events) {
    const targetVersion = Number(ev.groupKeyVersion)
    if (targetVersion <= localVersion) continue

    // 示例:根据事件刷新 group ticket / 重新分发会话材料
    // 具体实现取决于你对 Virgil Group Tickets 的封装
    await applyGroupKeyEvent(ctx.e3, groupID, {
      eventType: ev.eventType,
      operatorUserID: ev.operatorUserID,
      targetVersion,
    })

    localVersion = targetVersion
    await saveLocalGroupKeyVersion(groupID, localVersion)
  }

  // E) 防御性校验
  if (localVersion < latestVersion) {
    // 说明事件缺失,可做一次全量恢复
    await rebuildGroupSessionFromSource(ctx.e3, groupID, latestVersion)
    localVersion = latestVersion
    await saveLocalGroupKeyVersion(groupID, localVersion)
  }

  return localVersion
}

5) 建议落地策略(简版)

  • 密钥刷新时机:应用启动、会话进入、收到群消息且版本落后、网络重连后。
  • 失败重试get_virgil_jwtget_group_key_events 使用指数退避重试。
  • 本地缓存:按 groupID -> groupKeyVersion 持久化,避免重复全量同步。
  • 消息兼容:保留 alg 字段,支持后续算法版本升级。
  • 安全日志:不要记录明文与私钥,仅记录 groupID/userID/version/eventType

6) 与当前服务端接口对应

当前服务端公开路由(internal/api/router.go

  • /crypto/register_device
  • /crypto/get_devices
  • /crypto/revoke_device
  • /crypto/get_virgil_jwt
  • /crypto/get_group_key_version
  • /crypto/get_group_key_events
  • /crypto/security_precheck
  • /crypto/integrity_report

bump_group_key_version 为内部服务调用,不提供给客户端公开使用。