术→技巧, 研发

即时通讯系统之WhatsApp

钱魏Way · · 2,456 次浏览
!文章内容如有错误或排版问题,请提交反馈,非常感谢!

WhatsApp的高可用

WhatsApp以190亿美元的价格出售给了Facebook,特别引入注意的是该服务4.5亿活跃用户的公司只有32个工程师,以下内容是High Scalability创始人Tod Hoff分析的WhatsApp的高可靠架构。

信息源

需要注意的是,WhatsApp的整体架构并未公开,这里仅仅是从不同信息源中获取不同的片段。Rick Reed的讲座主要分享了使用Erlang实现单服务器200万连接数,虽然很有价值,但是并不是整个应用架构。

相关文章中列出的一些信息。

数据

这些统计是当下系统的一些数据,更多针对数据存储、消息、meta-clustering以及新加入的BEAM/OTP补丁。

  • 5亿的活跃用户,并且是史上最快达到这个数字的公司
  • 32个工程师,平均每人支撑1400万活跃用户
  • 每天收发跨7个平台的500亿消息
  • 平均每天注册用户过百万
  • 0广告开销
  • 800万投资
  • 数百个节点
  • 8000+核心
  • 数百TB内存
  • 每秒Erlang消息超过7000万
  • 在2011年,WhatsApp单服务器取得100万个tcp会话,同时还有内存和CPU剩余。在2012年,tcp会话发展到了200万。2013年WhatsApp发表tweet声明,70亿消息入站,110亿消息出战,即每天处理180亿消息,伟大的2013,2014年每天有190亿消息进站/400亿消息出站
  • 6亿图片, 2亿音频, 1亿视频
  • 峰值并发连接为47亿
  • 消息峰值为每秒2万进站/71.2万出站
  • 节日期间流量更加惊人

平台

后端

  • Erlang (经过了自己的改造)
  • FreeBSD 9.2
  • Yaws、lighttpd
  • PHP
  • BEAM定制补丁(BEAM类似于Java的JVM,但适用于Erlang)
  • 数据库使用的是Mnesia
  • ejabberd (做了大量改造,包括使用自己的协议替代XMPP)
  • 定制XMPP

前端

  • 7个客户端平台:iPhone、Android、Blackberry、Nokia Symbian 360、Nokia S40、Windows Phone和一个未知的
  • SQLite

硬件

支撑这些数据的硬件

  • 约550台服务器
  • 约150台chat server, 可以支持5亿连接
  • 约250台mms服务器
  • 数据服务器内存为512GB,标准节点是64GB
  • 除了视频,其他数据都存储在SSD上
  • 超过11000个核心
  • Mnesia使用了约2TB的内存
  • Dual Westmere Hex-core(24个逻辑CPU)
  • Dual NIC(公共面向用户的网络、私有的后端/分布)

产品

  • 聚焦消息传递。连接来自世界各地的用户,忽视他们的地理位置,无需支付高额费用,创始人Jan Koum还经常提起1992年在世界各地与家里人联系是多么的难。
  • 隐私。由Jan Koum制定,消息不会在服务器上储存,聊天记录也不会储存,目的就是不去了解用户隐私。不会保存用户姓名及性别,聊天记录只存储在电话上。
  • 由于Chatd模块偏重io,业务逻辑不复杂,按照网上公布的信息,在2013年年初,服务器的信息峰值能到35万条每秒,用Erlang实现是非常好的一种技术选择。对于图片,音频和视频这种多媒体信息,WhatsApp使用Yaws提供Web服务,使用DNS round-robin进行负荷分担,在客户端使用FFmpeg完成编码,存储使用FreeBSD UFS2。
  • WhatsApp的服务器,其核心功能就是以电话号码为目的地址的消息转发,其存储也是用户不在线的临时缓存,当用户接收到消息后,出于保护用户隐私的考虑,服务器上的消息也就删除了。基于其服务模型的简单,50人服务5亿人才成为可能。如果WhatsApp的服务模型变成Facebook这么复杂的社交类型,现有的处理和存储架构绝对是无法支撑,同时用WhatsApp这个特例来否定现有的云服务也是不可取的。

通用

  • WhatsApp服务器基本上完全使用Erlang实现
    • 做后端消息路由的服务器系统使用Erlang实现
    • 值得炫耀的是,如此庞大数量的活跃用户只使用非常少的服务器来管理,团队一致认为这很大程度上归功于Erlang。
    • 值得注意的是,Facebook Chat就是在2009年使用Erlang开发,他们弃用Erlang的原因是难以招聘到优秀的程序员。
  • WhatsApp服务器最早从Ejabberd开始
    • Ejabberd是个非常出名的开源Jabber服务器,使用Erlang实现。
    • 最初选用它的原因是开放、广受开发者关注、易于开始以及Erlang在大型通信系统上的长期口碑。
    • 接下来的许多年一直从事Ejabberd的重写和修改,包括从XMPP转换到内部开发协议、调整代码库以及重设计一些核心组件,对Erlang VM做了大量的修改以获得高性能。
  • 为了应对每天500亿消息,工作重心被放到可靠系统的打造上,货币化对于我们来说还是件遥远的事情。

系统的健康状况主要看队列的长度,每个节点上消息队列的长度都会被一直监控,超过预先设置的临界值则会发出提醒,多个警报发生则标志着系统进入了下一个瓶颈。

  • 通过上传图片、音频、视频到一个 HTTP 服务器上来发送多媒体消息,然后将链接与 Base64 编码的缩略图一起添加到内容(如果可用)。
  • 有些代码基本上每天都在变化,通常情况下是一天几次;当然,峰值期间必须避开的。Erlang 非常适用于将修改或者是新功能添加到产品,热加载意味着无需重新启动就可以实现修改,错误可以很快的得到解决,同样通过热加载,系统变得更加松耦合,这可以让更新快速的发布。
  • WhatsApp 使用了什么样的协议?WhatsApp 服务器池使用了 SSLSocket,在客户端重新连接对消息进行检索之前,所有消息都会在服务器上排队。消息的成功检索会发回给 WhatsApp 服务器,它将会被重新转发给原始发送者;一旦客户端成功接收这条消息,它就会在服务器存储中擦除。
  • WhatsApp 注册程序的内部工作机制是什么样的?WhasApp 依赖电话 IMEI 号码来建立用户名/密码,这点在最近已经修改。WhatsApp 现在会让应用发送一个包含 5 位数 Pin 的一般请求,然后给这个电话号码发送一个 SMS,这意味着 WhatsApp 客户端不再受限于某台手机。基于 Pin 的号码,应用会从 WhatsApp 请求一个唯一的键,这个键将作为未来的使用密码,这同样意味着在新的设备上注册后会无效原有设备上的键。
  • 在 Android 上使用了 Google 的推送服务。
  • 在 Android 上有更多的客户。与 Android 打交道更让大家愉快,开发者能够快速的基于一个特性构建原型,并以最短的时间推出,如果存在问题的话也可以快速修复,iOS 则不行。
  • 单服务器上 200 万连接数的探索

    • 虽然用户增长是喜闻乐见的,但是它同样意味着你得投入更多钱去购买硬件;同时,机器数量的增加也大幅增加了管理和运维复杂性。
    • 需要为流量的起伏做规划,例子就是西班牙的足球比赛和墨西哥的地震。这些现实世界中发生的大事件造成了非常高的流量峰值,因此需要有足够的剩余容量来应对流量高峰+突发事件,比如一场近期的足球比赛产生了当天 35% 的出站消息。
    • 初始的服务器负载是每个服务器 20 万并发连接。
      • 预期将会添加大量的服务器来维持用户增加。
      • 服务器会因为负载的爆发而宕机,网络及其它的故障也会发生。这时候需要做一些组件的解耦,这样一来可以添加容量以应对峰值。
      • 目标是单服务器支撑百万连接数,这个目标在只有 20 万连接数时已经制定。动态的容量规划以应对世界级事件、硬件故障及其它类型的小故障,系统需要足够的弹性去应对高使用率和故障。

    用来增强可扩展性的工具和技术

    • 编写系统活动报告工具(wsar):
    • 记录整个系统状态,包括 OS 状态、硬件状态、BEAM 状态。这是为了便于从其他系统获取状态信息,如虚拟内存。跟踪记录 CPU 利用率、系统整体利用率、用户时间、系统时间、中断时间、上下文切换、系统调用、traps、数据包发送和接收、所有进程队列中总消息数、繁忙的端口事件、通信速率、字节输入/输出、调度状态,垃圾回收状态等。
    • 最初一分钟运行一次,当系统运行变得困难时,时间段将降为 1 秒钟一次,因为一分钟无法运行的情况很少发生。了解所有系统运行情况需要非常细粒度的统计数据。
    • CPU(pmcstat)中的硬件性能计数器:
    • 通过查看 CPU 时间占用百分比,可以了解正在执行的模拟器(emulator)周期时间。假如是 16%,说明只有 16% 的时间执行模拟代码,所以即使你能消除所有 Erlang 代码的执行时间,也只能节省总运行时间 16%,这意味着你应该将重点放在其他方面以提高系统的效率。
    • dtrace、内核锁计数、fprof
    • Dtrace 是主要用于调试,而不是提高性能。
    • 在 FreeBSD 上给 BEAM 打补丁加入 CPU 时间戳。
    • 写脚本创建所有进程的聚合视图,查看哪些程序一直在占用系统资源。
    • 最大的胜利是给模拟器启用锁计数。
    • 一些问题:
    • 早期发现很多时间花在了垃圾回收程序上,这个问题已经被解决了。
    • 发现一些网络堆栈的问题,后来堆栈被调走了。
    • 大多数问题是关于模拟器的锁冲突,主要体现在锁计数的输出上。
    • 度量
    • 综合的工作负载,这意味着从你自己的测试脚本中生成流量,这对极限规模下面向用户系统调优没有价值。
      • 对于用户表这样简单的接口效果不错,生成接入然后尽可能快地读取。
      • 假如在一台服务器只支持 100 万连接,那么整个系统将需要 30 台这样的主机去打开足够的 IP 端口,生成足够多的连接;然而,这么多的开销仅仅可以测试一台服务器。对于测试 200 万个连接的服务器需要用到 60 个主机,生成这样的规模真的很难。
      • 很难生成生产环境下的那种流量。在正常的工作量下还可以估算,但在现实中看到的那些网络事件、世界事件,这主要因为多平台上客户端的不同用户行为,而且不同国家之间也有差异。
    • Tee’d 工作负载
      • 采用正常生产环境流量,然后把它放到一个单独的系统中。
      • 这对系统非常有用,因为产生的副作用会受到限制。不想看到网络拥堵,或者对用户造成影响。
      • Erlang 支持热加载,所以可以在完整生产负荷下产生想法、编译,在程序运行时加载变化,而且能即时看到变化的好坏。
      • 添加旋钮动态调节生产加载,观察它对性能的影响。观察特征输出,比如 CPU 使用率、VM 使用率、监听队列溢出并调节旋钮,看看系统会有怎样的反应。
    • 真正的生产负载
      • 最终测试。输入工作和输出工作都要测试。
      • 多次将服务器放入 DNS 中,使其得到正常情况两倍或三倍的流量。TTL 事务会产生问题,因为客户端不会遵守 DNS TTL,而且这里还会有延迟,因此无法快速做出反应以获得更多可以被处理的流量。
      • 将一台服务器的流量转移给另一台,这样可以使主机的连接数达到理想的水平。内核如果因为有个 Bug 就奔溃是不行的。
    • 结果
      • 开始时每个服务器有 20 万个并发连接。
      • 第一个瓶颈出现每台服务器 5 万个连接的时候。系统遇到了很多冲突,工作停止了。安装调度器检测有多少有用的任务被停止、睡眠,或回转了。在加载时,它开始遇到睡眠锁,整个系统只用 35-45% 的 CPU 利用率,但调度程序的 CPU 利用率却达到了 95%。
      • 第一轮修复使连接数超过 100 万个。
        • VM 利用率为 76%,CPU 利用率为 73%,BEAM 模拟器利用率为 45%,与用户百分比很吻合,这是件好事,因为模拟器得和用户一样。
        • 通常 CPU 利用率并不是好的评估方法,因为可能由于调度程序使用 CPU 导致系统看起来很忙。
      • 一个月以后解决了瓶颈,每个服务器连接数达到 200 万个。
        • BEAM 利用率为 80%,与 FreeBSD 开始分页的情况接近。CPU 利用率大致相同,有两倍的连接数。调度程序遇到了冲突,但运行得很好。
      • 看来测试可以暂停了,这时开始分析 Erlang 代码。
        • 最初每个连接有两个 Erlang 进程,消减为一个。
        • 用计时器完成一些工作。
      • 在每个服务器有 280 万连接时达到顶峰
        • 571k pkts/sec, >200k dist msgs/sec
        • 做一些内存优化,VM 加载下降到 70%。
      • 尝试过将连接数增加到 300 万,但没有成功。
        • 当系统遇到故障时,查看长消息队列(单个消息队列或消息队列总和)。
        • 将每个进程的消息队列统计添加到 BEAM 设备上。包括发送/接收了多少条消息以及发送/接收的速度。
        • 每 10 秒取样一次,可以看到一个进程有 60 万条消息,每 15 秒延迟出列 4 万条消息。预计完全出列时间是 41 秒。
      • 一些发现:
        • Erlang + BEAM + 它们的补丁——可以具有接近线性的 SMP 可扩展性。在 24 路服务器上运行系统,CPU 利用率达到 85%,持续运行负载——它可以像这样运行一整天。
          • Erlang 程序模型的证明。
          • 服务器使用的时间越长,其积累长时间运行连接就越多,但不是每个连接都很忙碌,其中大多数是闲置的,所以服务器使用时间越长能够处理的连接数也就越多。
        • 冲突是最大的问题。
          • Erlang 代码中的一些修复可以减少 BEAM 的冲突问题。
          • 向 BEAM 添加一些补丁。
          • 分区负载工作不需要频繁跨处理器运行。
          • Time-of-day 锁。每次从一个端口发送消息都会针对所有调度程序产生一个 Time-of-day 锁,这意味着所有的 CPU 都会遇到同一个锁。
          • 优化计数器的使用,移除 bif 计数器
          • 发现 IO 时间表算术增长。创建 VM 抖动使哈希表在不同的时间点重新分配,改进使用几何分配表。
          • 通过你已经打开的端口添加写入文件,以减少端口冲突。
          • Mseg 分配是所有分配器冲突的交点,因此创建好每一个调度程序。
          • 获得一个连接时会有很多端口事务,设置选项降低昂贵的端口交互。
          • 当消息队列积压太多的话,垃圾回收会破坏系统稳定性。所以暂停 GC,直到队列收缩。
        • 避免一些不必要的麻烦。
          • 从 FreeBSD 9 移植一个 TSE 计时器到 FreeBSD 8。读取计时器开销更小,快速时间,比读取芯片还要便宜。
          • 从 FreeBSD 9 移植 igp 网络驱动程序,因为多个队列会因为 NIC 锁定出问题。
          • 增加文件和套接字的数目。
          • Pmcstat 显示很多时间被用来在网络堆栈中查找 PCB,所以扩大哈希表让查询更快些。
        • BEAM 补丁
          • 之前提到过的设备补丁。植入设备调度程序用来获取使用信息、信息队列统计信息、sleep 数、发送率、消息数等。可以在 Erlang 代码中使用 procinfo(任务管理)实现,但有 100 万的连接时,这一过程会变得非常慢。
          • 统计数据收集非常高效,所以它们可以在生产中运行。
          • 统计数据保持 3 个不同衰变间隔:1 秒、10 秒和 100 秒。允许随时观测发生的问题。
          • 让锁计数为更大的异步线程计数工作。
          • 为调试锁计数器添加调试选项。
        • 调试
          • 设置低调度程序的唤醒值,因为调度程序一旦进入睡眠就再也无法唤醒。
          • mseg 分配器优于 malloc。
          • 每个调度程序每个实例都有个分配器。
          • 配置大的 carrier,而且还会越来越大。导致 FreeBSD 使用超级页,降低 TLB thrash 比率,并为相同的 CPU 提高了吞吐量。
          • 以实时优先级运行 BEAM,这样其他的东西比如 cron 作业就不会打断调度程序。防止小故障导致重要用户通信的阻塞。
          • 打补丁下调 spin 数,从而使调度程序不会 spin。
        • Mnesia
          • 相比 erlang:now,更喜欢 os:timestamp。
          • 不使用事务,用远程的备份,并行复制每个表以提高吞吐量。
          • 事实上还对许多地方进行了修改。

    经验总结

    • 优化是件非常艰辛的事情,也只有工程师去做。Rick 在回顾大量的修改后(使每个服务器连接数达到 200),更觉得头皮发麻。大量的工作包括编写工具、运行测试、增加补丁、把让人眼花缭乱的方法添加到堆栈的每一层、调试系统、寻找蛛丝马迹,每一个细节都不能放过,你需要努力让一切都在掌握之中。只有这样才能消除瓶颈,提高性能以及最大程度地实现可扩展性。
    • 获取你需要的数据;编写工具;为工具添加补丁;添加调控旋钮。扩展系统获取更多数据是 Ken 不懈的追求,为了获取他们需要的数据,需要不停地编写工具、脚本来管理和优化系统。为了数据,不惜一切代价。
    • 度量;消除瓶颈;测试;不断重复这样的过程。枯燥无聊,但你需要这样做。
    • Erlang 很给力!Erlang 继续证明其作为一个多用途、可靠、高性能平台的优良品质。虽然 Erlang 也需要大量的调整和修补,这些工作难免会让人对 Erlang 产生质疑。
    • 破解病毒式代码,获得利润。“病毒式”现在是优良品质的代名词,就像 WhatsApp 那样,只要你真得做到了,那意味着你得到了很多很多钱。
    • 价值和员工数现在已经没有直接联系了。如今,员工的数量并不能说明什么。先进的世界级电信基础设施使 WhatsApp 这样的应用程序成为可能。如果 WhatsApp 还需要做网络或手机等设备,那可能根本就不会有 WhatsApp 这样的公司存在。功能强大、价格廉价的硬件和开源的软件也无疑使 WhatsApp 的成功事半功倍。换句话说 WhatsApp 的成功在于它在正确的地点、时间为正确的用户提供了正确的产品。
    • 能够重视用户想法是很了不起的。WhatsApp 将自身定位成一个简单的消息传递应用,而不是游戏网络、广告网络或者已经面临消亡的照片网络,这一点很重要。这样的定位使他们没有在应用中添加广告,他们努力保持应用简单的同时添加新功能,傻瓜型操作方式使 WhatsApp 适用于每一个用户。
    • 考虑到简单性,有一些限制是允许的。你的身份被绑定到电话号码,所以如果你更改了电话号码你的身份就失效了。这和一般的应用程序确实有点不太一样,但却使整个系统在设计上变得更加简单了。
    • 年龄上的歧视。2009 年,因为年龄歧视,WhatsApp 创始人 Brian Acton 在 Twitter 和 Facebook 连一份工作都找不到,那就让它们后悔去吧。
    • 先从简单的开始然后再深度定制。聊天刚推出时,服务器端基于 jabberd,现在它已经被完全重写,但那个确实是 Erlang 方向上的第一步。Erlang 初次使用时就体现出的可扩展性、可靠性和可操作性,这使得到了越来越广泛的应用。
    • 保持低的服务器数量。努力让服务器尽可能的少,同时为短暂高峰期预留足够的上升空间。分析并优化直到达到收益递减点,然后再部署更多的硬件。
    • 有目的地增加冗余服务器。这可以确保在公司放假时也能为用户提供不间断的服务,员工可以享受假期而不用花时间修复过载问题。
    • 赚钱时也要考虑公司的成长。WhatsApp 免费时成长是最快的,早期每天都有 1 万次的下载量。然后转向付费时,下载量下降至每天 1000 次。在年底增加了图片消息后,他们就把按下载次数付费改成了按年收费。
    • 灵感总来自最意想不到的地方。忘记 Skype 用户名和密码的经历无疑给 WhatsApp 带来了灵感。

    Whatsapp 系统构建

    总体架构

    Whatsapp 的大致架构如下:

    上面的架构提现了下列几个关键点:

    • Whatsapp 采用了 XMPP 实时通信协议。后来逐步用内部实现的基于 XMPP 的扩展协议来替换掉了 XMPP。
    • App 端使用 SQLite 本地数据库存储已经收到的聊天信息和用户已经发送但因为网络原因没有发送成功的聊天信息。
    • Whatsapp 的后台大部分都是 Erlang 编写的。
    • 使用 Ejabberd 作为 XMPP 协议的即时通讯服务器。Ejabberd 是可扩展性最好的一种 Jabber/XMPP 服务器之一,支持多个服务器部署,并且具有容错处理,单台服务器失效不影响整个集群运作。
    • 使用 Erlang VM BEAM,同时写了大量的 patch 来对 BEAM 进行优化。
    • 服务器端操作系统使用 FreeBSD。
    • 使用 Erlang web 服务框架 Yaws 来提供 HTTP 接口,这些接口主要提供登录、注册、上传图片/视频、获取历史消息列表等功能。Yaws 服务返回的部分内容被 CDN 进行缓存。
    • 使用 Mnesia 来管理分布式数据库。Mnesia 是 Erlang 运行时中自带的一个数据库管理系统(DBMS)。
    • 即使用了关系型数据库比如 Mysql,也使用了 NoSQL 数据库 Riak。
    • 后台通过 WebSockets 向移动端 App 推送消息。

    技术栈

    • Server: Yaws
    • ServerApplication: custom ejaberd
    • Language: erlang
    • Technology: custom XMPP (funXMPP)
    • Web: PHP (WhatsAppWeb)
    • Database: mnesia
    • Encryption: RC4

    FunXMPP

    WhatsApp使用的协议是XMPP的精简版。XMPP消息的一个简单示例是:

    <message to="34123456789@s.whatsapp.net" type="text" id="message-1417651059-2" t="1417651059">
      <body>Test</body>
    </message>

    但是很明显,WhatsApp的创建者认为这太夸张了,他们找到了一种只使用几个字节来表达XMPP消息的方法,他们称之为FunXMPP。由于WhatsApp是为缺乏良好互联网连接的移动设备设计的,因此他们希望尽可能减少开销是合乎逻辑的。他们使用FunXMPP实现了这一点,同时仍然使用标准的互联网协议。

    那么FunXMPP是如何做到这一点的呢?

    首先,所有关键字都分配了一个字节。在上面的示例中,有许多在xmpp中常见的关键字(例如message、from、type、text)。如果您只需用一个字节替换它们,那么将减少大量开销。FunXMPP为此使用哈希表,其中包含大多数(如果不是全部)关键字。给定十六进制值为nn的一个字节的语法\xnn,上述示例可以简化为:

    <\x59\xa5="01234567890@\x91"\xa7="\xa2"\x44="message-1417651059-2"\xa1="1417651059">
       <\x12>Test</\x12>
    </\x58>

    请记住\xnn仅代表一个字节,这已经大大减少了大小。请注意,所有剩余的ascii值(如1417651059,测试,消息-1417651059-2)不能用任何东西替换,因为它们是可变的(即可以由用户设置)。

    XML是一种人类可读的格式,使用必须打开和关闭的标记。计算机读取XML结构真的需要这样做吗?FunXMPP的创建者肯定也有同样的想法,因为减少消息大小的另一种方法是将XML结构编码为几个字节。

    现在唯一剩下的就是XML结构。在FunXMPP中,此结构表示为一组列表。列表由\xf8字节指定。在此\xf8字节之后是一个带有列表包含的项目数的字节。这里作为一项的内容包括:标记名、键、值和主体。

    通常情况下:列表后面紧跟着列表意味着在同一级别上有多个节点,第一个列表不是标记或XML中可见的任何内容。

    TokenMapLookup

    0x03 => 'account',
    0x04 => 'ack',
    0x05 => 'action',
    0x06 => 'active',
    0x07 => 'add',
    0x08 => 'after',
    0x09 => 'all',
    0x0a => 'allow',
    0x0b => 'apple',
    0x0c => 'auth',
    0x0d => 'author',
    0x0e => 'available',
    0x0f => 'bad-protocol',
    0x10 => 'bad-request',
    0x11 => 'before',
    0x12 => 'body',
    0x13 => 'broadcast',
    0x14 => 'cancel',
    0x15 => 'category',
    0x16 => 'challenge',
    0x17 => 'chat',
    0x18 => 'clean',
    0x19 => 'code',
    0x1a => 'composing',
    0x1b => 'config',
    0x1c => 'contacts',
    0x1d => 'count',
    0x1e => 'create',
    0x1f => 'creation',
    0x20 => 'debug',
    0x21 => 'default',
    0x22 => 'delete',
    0x23 => 'delivery',
    0x24 => 'delta',
    0x25 => 'deny',
    0x26 => 'digest',
    0x27 => 'dirty',
    0x28 => 'duplicate',
    0x29 => 'elapsed',
    0x2a => 'enable',
    0x2b => 'encoding',
    0x2c => 'error',
    0x2d => 'event',
    0x2e => 'expiration',
    0x2f => 'expired',
    0x30 => 'fail',
    0x31 => 'failure',
    0x32 => 'false',
    0x33 => 'favorites',
    0x34 => 'feature',
    0x35 => 'features',
    0x36 => 'feature-not-implemented',
    0x37 => 'field',
    0x38 => 'first',
    0x39 => 'free',
    0x3a => 'from',
    0x3b => 'g.us',
    0x3c => 'get',
    0x3d => 'google',
    0x3e => 'group',
    0x3f => 'groups',
    0x40 => 'groups_v2',
    0x41 => 'http://etherx.jabber.org/streams',
    0x42 => 'http://jabber.org/protocol/chatstates',
    0x43 => 'ib',
    0x44 => 'id',
    0x45 => 'image',
    0x46 => 'img',
    0x47 => 'index',
    0x48 => 'internal-server-error',
    0x49 => 'ip',
    0x4a => 'iq',
    0x4b => 'item-not-found',
    0x4c => 'item',
    0x4d => 'jabber:iq:last',
    0x4e => 'jabber:iq:privacy',
    0x4f => 'jabber:x:event',
    0x50 => 'jid',
    0x51 => 'kind',
    0x52 => 'last',
    0x53 => 'leave',
    0x54 => 'list',
    0x55 => 'max',
    0x56 => 'mechanism',
    0x57 => 'media',
    0x58 => 'message_acks',
    0x59 => 'message',
    0x5a => 'method',
    0x5b => 'microsoft',
    0x5c => 'missing',
    0x5d => 'modify',
    0x5e => 'mute',
    0x5f => 'name',
    0x60 => 'nokia',
    0x61 => 'none',
    0x62 => 'not-acceptable',
    0x63 => 'not-allowed',
    0x64 => 'not-authorized',
    0x65 => 'notification',
    0x66 => 'notify',
    0x67 => 'off',
    0x68 => 'offline',
    0x69 => 'order',
    0x6a => 'owner',
    0x6b => 'owning',
    0x6c => 'p_o',
    0x6d => 'p_t',
    0x6e => 'paid',
    0x6f => 'participant',
    0x70 => 'participants',
    0x71 => 'participating',
    0x72 => 'paused',
    0x73 => 'picture',
    0x74 => 'pin',
    0x75 => 'ping',
    0x76 => 'platform',
    0x77 => 'port',
    0x78 => 'presence',
    0x79 => 'preview',
    0x7a => 'probe',
    0x7b => 'prop',
    0x7c => 'props',
    0x7d => 'query',
    0x7e => 'raw',
    0x7f => 'read',
    0x80 => 'readreceipts',
    0x81 => 'reason',
    0x82 => 'receipt',
    0x83 => 'relay',
    0x84 => 'remote-server-timeout',
    0x85 => 'remove',
    0x86 => 'request',
    0x87 => 'required',
    0x88 => 'resource-constraint',
    0x89 => 'resource',
    0x8a => 'response',
    0x8b => 'result',
    0x8c => 'retry',
    0x8d => 'rim',
    0x8e => 's_o',
    0x8f => 's_t',
    0x90 => 's.us',
    0x91 => 's.whatsapp.net',
    0x92 => 'seconds',
    0x93 => 'server-error',
    0x94 => 'server',
    0x95 => 'service-unavailable',
    0x96 => 'set',
    0x97 => 'show',
    0x98 => 'silent',
    0x99 => 'stat',
    0x9a => 'status',
    0x9b => 'stream:error',
    0x9c => 'stream:features',
    0x9d => 'subject',
    0x9e => 'subscribe',
    0x9f => 'success',
    0xa0 => 'sync',
    0xa1 => 't',
    0xa2 => 'text',
    0xa3 => 'timeout',
    0xa4 => 'timestamp',
    0xa5 => 'to',
    0xa6 => 'true',
    0xa7 => 'type',
    0xa8 => 'unavailable',
    0xa9 => 'unsubscribe',
    0xaa => 'uri',
    0xab => 'url',
    0xac => 'urn:ietf:params:xml:ns:xmpp-sasl',
    0xad => 'urn:ietf:params:xml:ns:xmpp-stanzas',
    0xae => 'urn:ietf:params:xml:ns:xmpp-streams',
    0xaf => 'urn:xmpp:ping',
    0xb0 => 'urn:xmpp:whatsapp:account',
    0xb1 => 'urn:xmpp:whatsapp:dirty',
    0xb2 => 'urn:xmpp:whatsapp:mms',
    0xb3 => 'urn:xmpp:whatsapp:push',
    0xb4 => 'urn:xmpp:whatsapp',
    0xb5 => 'user',
    0xb6 => 'user-not-found',
    0xb7 => 'value',0xb8 => 'version',
    0xb9 => 'w:g',
    0xba => 'w:p:r',
    0xbb => 'w:p',
    0xbc => 'w:profile:picture',
    0xbd => 'w',
    0xbe => 'wait',
    0xbf => 'WAUTH-2',
    0xc0 => 'xmlns:stream',
    0xc1 => 'xmlns',
    0xc2 => '1',
    0xc3 => 'chatstate',
    0xc4 => 'crypto',
    0xc5 => 'phash',
    0xc6 => 'enc',
    0xc7 => 'class',
    0xc8 => 'off_cnt',
    0xc9 => 'w:g2',
    0xca => 'promote',
    0xcb => 'demote',
    0xcc => 'creator',
    0xcd => 'Bell.caf',
    0xce => 'Boing.caf',
    0xcf => 'Glass.caf',
    0xd0 => 'Harp.caf',
    0xd1 => 'TimePassing.caf',
    0xd2 => 'Tri-tone.caf',
    0xd3 => 'Xylophone.caf',
    0xd4 => 'background',
    0xd5 => 'backoff',
    0xd6 => 'chunked',
    0xd7 => 'context',
    0xd8 => 'full',
    0xd9 => 'in',
    0xda => 'interactive',
    0xdb => 'out',
    0xdc => 'registration',
    0xdd => 'sid',
    0xde => 'urn:xmpp:whatsapp:sync',
    0xdf => 'flt',
    0xe0 => 's16',
    0xe1 => 'u8',
    0xe2 => 'adpcm',
    0xe3 => 'amrnb',
    0xe4 => 'amrwb',
    0xe5 => 'mp3',
    0xe6 => 'pcm',
    0xe7 => 'qcelp',
    0xe8 => 'wma',
    0xe9 => 'h263',
    0xea => 'h264',
    0xeb => 'jpeg'.
    

    WhatsApp注册流程

    WhatsApp注册过程非常复杂,或者没有官方文件可供指导。使用流量监控工具进行的请求跟踪揭示了以下关于注册过程的真相。以下是流程流程的简化版本。

    Requesting code

    https://v.whatsapp.net/v2/code?in=*********&cc=**&id=%**%DC%E7%**%C*%A8%**%26%**%4F%35%**&B1%69%AF%**&lg=es&lc=ES&sim_mcc=214&sim_mnc=007&method=sms&token=*********TOKEN*********

    • method: sms or voice
    • in: phone number without country code
    • cc: country code
    • id: identity
    • lg: Language (identified by checking sim_mnc and sim_mcc)
    • lc: Language set in the device, if not found it will set zz
    • token: token (used for requesting sms/voice code)
    • sim_mcc: mcc of sim
    • sim_mnc: mnc of sim

    Registering number

    https://v.whatsapp.net/v2/register?cc=**&in=*********&id=%**%DC%E7%**%C*%A8%**%26%**%4F%35%**&B1%69%AF%**&code=202357&lg=es&lc=ES

    • cc: country code
    • in: phone number without country code
    • id: identity
    • code: code received by sms or voice call

    Checking if the number exists (Retrieve password if number was register in that device).

    https://v.whatsapp.net/v2/exist?cc=**&in=*********&id=%**%DC%E7%**%C*%A8%**%26%**%4F%35%**&B1%69%AF%**&lg=es&lc=ES

    • cc: country code
    • in: phone number without country code
    • id: identity
    • lg: Language (identified by checking sim_mnc and sim_mcc)
    • lc: Language set in the device, if not found it will set zz

    WhatsApp加密概述

    本白皮书提供了WhatsApp端到端加密系统的技术说明。请访问WhatsApp的网站www.whatsapp.com/security了解更多。WhatsApp Messenger允许人们自由的交换消息(包括聊天,群聊,图片,视频,语音消息和文件),并在发送者和接收者之间使用端对端加密(在2016年3月31之后的版本)。Signal协议是由OpenWhisper Systems(非盈利软件开发团体)设计,是WhatsApp端对端加密的基础。这种端对端加密协议旨在防止第三方和WhatsApp对消息或通话进行明文访问。更重要的是,即使用户设备的密钥泄露,也不能解密之前传输的消息。本文档概述了Singal协议在WhatsApp中的应用。

    术语

    公钥类型

    • 身份密钥对(IdentityKeyPair)——一个长期Curve25519密钥对,安装时生成。
    • 已签名的预共享密钥(SignedPreKey)——一个中期Curve25519密钥对,安装时生成,由身份密钥签名,并定期进行轮换。
    • 一次性预共享密钥(One-TimePreKeys)——一次性使用的Curve25519密钥对队列,安装时生成,不足时补充。

    会话密钥类型

    • 根密钥(RootKey)—— 32字节的值,用于创建链密钥。
    • 链密钥(ChainKey)——32字节的值,用于创建消息密钥。
    • 消息密钥(MessageKey)——80个字节的值,用于加密消息内容。32个字节用于AES-256密钥,32个字节用于 HMAC-SHA256密钥,16个字节用于IV。

    客户端注册

    在注册时,WhatsApp客户端将身份公钥(publicIdentityKey)、已签名的预共享公钥(publicSignedPreKey)和一批一次性预共享公钥(One-TimePreKeys)发送给服务器。WhatsApp服务器存储用户身份相关的公钥。WhatsApp服务器无法访问任何客户端的私钥。

    会话初始化设置

    要与另一个WhatsApp用户通信,WhatsApp 客户端需要先建立一个加密会话。加密会话一旦被创建,客户端就不需要再重复创建会话,除非会话失效(例如重新安装应用或更换设备)。

    建立会话:

    • 会话发起人为接收人申请身份公钥(publicIdentityKey)、已签名的预共享公钥(publicSignedPreKey)和一个一次性预共享密钥(One-TimePreKey)。
    • 服务器返回所请求的公钥。一次性预共享密钥(One-Time PreKey)仅使用一次,因此请求完成后将从服务器删除。如果一次性预共享密钥(One-Time PreKey)被用完且尚未补充,则返回空。

    • 发起人将接收人的身份密钥(IdentityKey)存为 Irecipient,将已签名的预共享密钥(SignedPreKey)存为 Srecipient,将一次性预共享密钥(One-Time PreKey)存为 Orecipient。
    • 发起者生成一个临时的 Curve25519 密钥对—— Einitiator
    • 发起者加载自己的身份密钥(IdentityKey)作为 Iinitiator
    • 发起者计算主密钥 master_secret= ECDH( Iinitiator, Srecipient)|| ECDH( Einitiator, Irecipient)|| ECDH( Einitiator, Srecipient) || ECDH( Einitiator, Orecipient)。如果没有一次性预共享密钥(One-Time PreKey),最终 ECDH 将被忽略。
    • 发起者使用 HKDF 算法从 master_secret 创建一个根密钥(RootKey)和链密钥(ChainKeys)。

    接收会话设置

    在建立长期加密会话后,发起人可以立即向接收人发送消息,即使接收人处理离线状态。在接收方响应之前,发起方所有的消息都会包含创建会话所需的信息(在消息的 header 里)。其中包括发起人的 Einitiator 和 Iinitiator。当接收方收到包含会话设置的消息时:

    • 接收人使用自己的私钥和消息 header 里的公钥来计算相应的主密钥
    • 接收人删除发起人使用的一次性预共享密钥(One-Time PreKey)
    • 发起人使用 HKDF 算法从主密钥派生出相应的根密钥(RootKey)和链密钥(ChainKeys)

    交换消息

    一旦建立了会话,通过 AES256 消息密钥加密(CbC 模式)和 HMAC-SHA256 验证来保护客户端交换消息。消息密钥是短暂的且在每次发送消息后都会变化,使得用于加密消息的消息密钥不能从已发送或已接收后的会话状态中重建。消息密钥在发送消息时对发送人的链密钥(ChainKey)进行向前的“棘轮(ratchets)”派生而来。此外,每次消息巡回都执行一个新的 ECDH 协议以创建一个新的链密钥(ChainKey)。通过组合即时“哈希棘轮(hash ratchet)”和巡回“DH 棘轮(DH ratchet)”提供前向安全。

    通过链密钥(ChainKey)计算消息密钥(MessageKey)

    消息发送者每次需要新的消息密钥时,计算如下:

    • 消息密钥(MessageKey)= HMAC-SHA256(ChainKey, 0x01)
    • 链密钥(ChainKey)随后更新为:链密钥(ChainKey)= HMAC-SHA256(ChainKey, 0x02)

    这样形成向前“棘轮(ratchets)”链密钥(ChainKey),这也意味不能使用存储的消息密钥推导出当前或过去的链密钥(ChainKey)值。

    通过根密钥(RootKey)计算链密钥(ChainKey)

    每一条发送的消息都附带一个短期的 Curve25519 公钥。一旦收到响应,新的链密钥(ChainKey)计算如下:

    • ephemeral_secret = ECDH(Ephemeralsender, Ephemeralrecipient)
    • 链密钥(ChainKey),根密钥(RootKey)= HKDF(RootKey, ephemeral_secret)

    一个链密钥只能给一个用户发消息,所以消息密钥不能被重用。由于消息密钥和链密钥(ChainKeys)的计算方式,消息可能会延迟、乱序或完全丢失而不会有问题。

    传输媒体和附件

    任何类型的大附件(视频,音频,图像或文件)也都是端对端加密的:

    • 发件人(发消息的 WhatsApp 用户)生成一个 32 字节的 AES256 临时密钥和一个 32 字节 HMAC-SHA256 临时密钥。
    • 发件人通过 AES256 密钥(CBC 模式)和随机 IV 给附件加密,然后附加使用 HMAC-SHA256 密文的 MAC。
    • 发件人将加密的附件以上传到服务器以二进制存储。
    • 发件人给收件人发送一个包含加密密钥、HMAC 密钥、加密二进制的 SHA256 哈希值和指向二进制存储的指针的加密消息
    • 收件人解密消息,从服务器检索加密的二进制数据,验证 AES256 哈希,验证 MAC 并解密为明文。

    群组消息

    传统未加密的聊天应用通常对群组消息使用“服务器扇出(server-side fan-out)”来发群组消息。当一个用户向群组发消息时,服务器将消息分发给每一个群组成员。而“客户端扇出(client-side fan-out)”是客户端将消息发给每一个群组成员。WhatsApp 的群组消息基于上面列出的成对加密会话构建,以便高效实现大量群组消息通过服务器扇出(server-side fan-out)。这是通过 Signal 传输协议(Signal Messaging Protocol)的“发送者密钥(SenderKeys)”来完成的。WhatsApp 群组成员第一次发消息到群组:

    • 发送人生成一个随机 32 字节的链密钥(ChainKey)。
    • 发送人生成一个随机 Curve25519 签名密钥对。
    • 发送人将 32 位链密钥(ChainKey)和签名密钥中的公钥组合成消息发送人密钥(SenderKey)。
    • 发件人用成对传输协议为每个群组成员单独加密发送人密钥(SenderKeys)。

    所有后续发给该群组的消息:

    • 发送人从链密钥(ChainKey)中获取消息密钥(MessageKey)并更新链密钥(ChainKey)
    • 发送人在 CbC 模式下使用 AES256 加密消息
    • 发送人使用签名密钥(SignatureKey)签名密文
    • 发送人将单个密文消息发给服务器,服务器将消息分发给所有群组成员

    消息发送人链密钥(ChainKey)的“哈希棘轮(hash ratchet)”提供向前安全。当群组成员离开时时,所有剩下的群组成员都清除发送人密钥(SenderKey)并重新生成。

    通话设置

    WhatsApp 语音和视频通话也是端对端加密。当 WhatsApp 用户发起语音或视频通话时:

    • 发起人与接收人建立加密会话(如果还没有建立过)
    • 发起人生成一个随机 32 字节的安全实时传输协议(SRTP)主密钥(master secret)
    • 发起人向接收人发送一个包含安全实时传输协议(SRTP)主密钥的加密消息用于发通话信号
    • 如果应答了呼叫,跟着发起安全实时传输协议(SRTP)呼叫

    状态

    WhatsApp 状态加密方式和群组消息非常相似。给指定的一组接收人第一次发状态遵循向群组第一次发消息相同的步骤。类似地,给同一组接收人发送后续状态也遵循发群组消息相同的步骤。当状态发送人更改状态隐私设置或从地址簿种删除号码来删除接收人时,状态发送人会清除发送人密钥(SenderKey)并重新生成。

    验证密钥

    WhatsApp 用户还可以验证与之通信用户的密钥,以便他们能够确认未授权的第三方(或 WhatsApp)未发起中间人攻击。通过扫描二维码或通过比较 60 位数字来完成。二维码包括:

    • 版本号
    • 双方的用户身份
    • 双方完整的 32 字节身份公钥

    当用户扫描对方的二维码时,将比较这些密钥以确保二维码中的身份密钥与服务器检索到的相匹配。通过拼接两个用户身份密钥的 30 位数字指纹来计算 60 位数字号码。计算 30 位数字指纹步骤:

    • 重复 SHA-512 哈希身份公钥和用户标识符 5200 次
    • 获取最后输出哈希的前 30 个字节
    • 将 30 个字节分成 6 组每组 5 字节的数据块
    • 通过解析每组 5 字节数据块为 big-endian 无符号整形并且取模 10 万次转换为 5 个数字
    • 把六组每组 5 个数字连接成 30 位数字

    传输安全

    WhatsApp客户端和服务器之间所有通信都在单独的加密通道内分层。在Windows Phone、iPhone和Android上,这些端对端加密客户端可以使用噪音管道(Noise Pipes),使用噪声协议框架(Noise Protocol Framework)中的Curve25519、AES-GCM和SHA256实现长期运行的交互连接。这为客户端提供了一些不错的属性:

    • 极快的轻量级连接设置和恢复
    • 加密隐藏元数据防止未授权的网络监听。没有透露连接用户身份相关的信息。
    • 服务器上不存储客户端的安全认证信息。客户端使用Curve25519密钥进行身份验证,因此服务器仅保存客户端认证公钥(public authentication key)。如果服务器的用户数据库被入侵,也不会泄露个人认证凭证。

    结论

    WhatsApp用户之间的消息受到端对端加密协议的保护,因此第三方和WhatsApp都无法获知消息内容,消息只能由接收人解密。所有WhatsApp消息(包括聊天、群聊、图片、视频、语音消息和文件)和WhatsApp通话都受到端对端加密的保护。WhatsApp服务器无法访问WhatsApp用户的私钥,并且WhatsApp用户可以选择验证密钥以确保其通讯完整。WhatsApp使用的Signal协议库是开源的。

    whatsapp协议简单分析

    whatsapp主要采用XMPP协议来做数据包组织。那么从XMPP的几个要点来分析whatsapp的协议。

    出席(presence)

    出席通知其他实体的网络可用性,并且使你能够知道其他实体是否在线和可用于通讯。它是一个在互联网上沟通和合作的催化剂,因为人们更容易与你交流,如果他们知道你是否在线。只有通过你授权的人才能看到你是否在线。这个授权被称为出席订阅。当你在线时,你向你的服务器宣告你的出席,然后服务器将你在线通知告诉你的联系人,并且获得他们的当前出席显示在你的客户端界面上。

    那么在whatsapp上如何实现这些了?

    自己出席:

    <presence type="available"></presence>

    订阅用户请求:

    <presence type="subscribe" to="6282111233677@s.whatsapp.net"></presence>

    订阅用户响应:

    <presence from="6282111233677@s.whatsapp.net" type="unavailable" last="1585729610"></presence>

    用户上线通知:

    <presence from="6282111233677@s.whatsapp.net" type="available" last="1585729610"></presence>

    查询(iq)

    查询(IQ)节提供了一种用于请求-应答交互和简单工作流的结构.

    和<message/>节不同,一个IQ节能包含仅有一个有效载荷,用于定义处理的请求或接收人采用的行为。

    发送IQ节的实体必须总是接收一个回复(通常由目的接收者或接受者的服务器产生)。

    请求和应答通过使用id属性跟踪,id属性由请求实体生成,并被包含在应答的实体中

    信息/查询type

    • get请求实体信息,例如请求注册一个账户(类似于HTTP GET)。
    • set请求实体提供一些信息或作出一个请求(类似于HTTP POST或PUT)。
    • result应答实体返回get操作的结果(例如一个实体必须提供信息用来注册账户),或者确认一个set请求(类似于一个HTTP 200状态码)。
    • error应答实体或一个中间实体,例如XMPP服务器,通知请求实体它不能处理get或set请求(例如,因为请求的格式不正确,请求实体无权执行该操作等)。早期在HTTP中使用的数字错误代码已被可扩展错误条件的XML元素取代。

    那么在whatsapp上的一些实际例子。

    心跳请求:

    <iq id="3" xmlns="w:p" type="get" to="s.whatsapp.net"><ping></ping></iq>

    心跳响应:

    <iq from="s.whatsapp.net" type="result" id="3" t="1585729914"></iq>

    获取缩略图请求:

    <iq id="2" xmlns="w:profile:picture" type="get" to="6285320652292@s.whatsapp.net"><picture type="preview"></picture></iq>

    获取缩略图响应:

    <iq from="s.whatsapp.net" type="error" id="1"><error code="404" text="item-not-found"></error></iq>

    信息(ib)

    信息(IB)节提供了服务器主动推送一些配置信息,客户端无需回复。在whatsapp上的一些实际例子。

    通知离线消息数量

    <ib from="s.whatsapp.net"><offline count="0"></offline></ib>

    通知路由信息

    <ib from="s.whatsapp.net"><edge_routing><routing_info>【4】08080802</routing_info><dns_domain>【2】6662</dns_domain></edge_routing></ib>

    消息

    <message/>节是使用基本的“push”方法从一个地方到另一个地方得到消息,消息是不可告知的,它是一种“fire-and-forget”的机制从一个地方到另一个地方快速获取信息。

    消息的type

    • normal:单个的消息,对应的回应可能会或者可能不会很快到来。
    • chat:在两个实体间店实时对话中交换
    • groupchat:多用户聊天室中交换
    • headline:发送警告和通告,并不期望有回应
    • error:对先前发送消息发生错误,实体检测这个问题将返回一个类型error的消息。

    消息的 to: 预期收件人的 Jabber ID

    消息的 from: 发送者的 Jabber ID,from 地址不由发送客户端提供,而是由发送者的服务器添加邮戳,以避免地址欺骗。

    消息也包含有载荷元素。核心 XMPP 规格定义了一些非常基本的有效载荷,例如 <body/> 和 <subject/>,被用于人对人的聊天信息, 消息(和其他类的节)可以包含在核心 XMPP 规格中没有定义的有效载荷

    在 whatsapp 上的一些实际例子。

    新消息:

    <message from="status@broadcast" type="text" edit="7" id="33BCB0CB4252B169674AD0F560A8F32D" participant="918128670245@s.whatsapp.net" phash="1:btMzxYFi" offline="0" t="1585729514" notify="geetusing276">
    <enc v="2" type="skmsg">3308a1bdba970310051a5092600d3053b8dc373f68896ff2dec4e0fe9d6e0ffe1a34438e11f8510b9dd693762de8372e2b201aca6f789dc62690acaeb61d839d350872f1be4799569e833795a67a69374a78500852ddd208d1bf0d3dbeb48f31e61f8b3d18e4199e36dda7f50ac72aabdded1cf2006aafa4db366a8e207bf4a82bdd3df0f58b6dee518c703bb1495f3a3d4b7fc401458da0f90f06
    </enc>
    </message>

    消息到达:

    <receipt id="33BCB0CB4252B169674AD0F560A8F32D" to="status@broadcast" participant="918128670245@s.whatsapp.net"></receipt>

    参考链接:

    发表回复

    您的邮箱地址不会被公开。 必填项已用 * 标注