免费 HTTPS 证书-从 StartSSL 到 Let's Encrypt

目录 互联网

在国内严峻的流量劫持情况下,为了避免网站、APP 中出现莫名其妙的广告,同时也为了保护用户隐私,从 HTTP 切换到 HTTPS 是很多网站不得不做出的选择。

随着技术的进步,还有一个同样重要的原因,那就是 HTTP/2 也建立在 SSL/TLS 基础之上,想充分利用 HTTP/2 的新特性也要求网站切换到 HTTPS。

从去年开始,我的个人站点就切换到了 HTTPS + HTTP/2 协议,通过 Nginx 实现协议切换是非常容易的事情,仅要求一个合法的证书即可完成配置。当时我选择的证书服务商是 StartCom,可以为有限个域名签发免费的有效期一年的 HTTPS 证书。虽然 HTTPS 增加了计算上的开销,但对于个人网站的流量来说,这点儿开销可以忽略不计了。

原来一直都好好的,前几天访问网站的时候,Chrome 忽然提示网站证书不可信任。想起来在公司听到的 Chrome 撤销对 Symantec 签发证书的信任,感觉不好!上网一搜,果然 Chrome 也撤销了对 StartCom 根证书和签发证书的信任:《Distrusting WoSign and StartCom Certificates》,而且快半年了。只是因为我的 Chrome 一直没升级到 56 版本以上,所以没有觉察到。于是没办法,只能寻找新的证书提供商了。

本来都打算花钱买个证书了,后来发现一个很值得信赖的证书提供商:Let’s Encrypt 。虽然是一个免费的服务,但后面有一堆知名组织和公司的支持:ISRG, Mozilla, Cisco, Chrome, Facebook 等,看起来比以前的一些免费证书服务靠谱多了,非常值得一用。

它家的证书有个局限,就是有效期只有 3 个月,然后就需要续签。这也很容易理解,一般免费都是有其它代价的。但是按照它给的安装步骤走下来,才发现它有着一套非常合理的设计。首先在你的服务器上安装一个软件 certbot,用参数指定你的网站根目录。这个证书机器人就会自动在你根目录下添加一个验证文件。远程证书服务器会访问你的网站,通过访问这个验证文件是否在你给定的域名路径下,自动验证你是否拥有域名的所有权。验证成功以后,证书就会自动签发到你服务器上的配置目录,只需要修改 Nginx 的配置,使用签发下来的证书就好了。

那每 3 个月的续签怎么办? certbot 本身就支持更新证书的参数,而且可以配置服务器自动重启的 hook,只需要把更新指令添加到服务器的 crontab,每 3 个月或者每 1 个月运行一次即可,完全不需要人工操作。

最后感叹一下,Apple 和 Google 真是互联网安全的业界良心!虽然看似专制霸道,但有多少 APP、网站是因为 Apple 的 App Transport Security 的要求才全站切 HTTPS 的?有多少证书提供商是因为 Chrome 时不时就吊销证书的行为才控制住了干坏事的冲动?真心希望中国的互联网大公司在此类事情上,不仅独善其身,也能兼济天下。

Google App API Protocol - Voice Search

目录 互联网

Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。

  1. Google App API Protocol - Text Search
  2. Google App API Protocol - Voice Search
  3. Google App API Protocol - Search History

语音搜索

语音搜索与文本搜索不同,必须先进行语音识别(语音转文字),拿到识别结果后才能进行搜索,拿到搜索结果。语音识别过程是一个录音数据流上传过程,一般采取的都是流式分片上传。Google App 的语音搜索,仍然是沿袭 Google 一贯注重效率的风格,使用两个 HTTPS 请求,完成了录音数据的流式上传,以及识别结果、搜索结果和语音播报的流式下发。具体的做法是:

在用户发起语音请求时,Google App 与 Google 服务器同时建立两个 HTTPS POST 连接,以 URL 参数 "pair" 标识这是同一用户的一对连接:

https://www.google.com/m/voice-search/up?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe
https://www.google.com/m/voice-search/down?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe

Google App 基于 chunked 编码,在 up 连接中实现录音数据流式上传,在 down 连接中实现识别结果、搜索结果和语音播报的流式下发。

语音搜索 up 连接

在 up 连接中,Google App 仅利用了 POST 请求通路的 chunked 编码流式上传录音数据,直到所有数据上传完成,Google 服务器才会返回一个 Content-Length 为 0 的 200 响应消息。而且 POST 请求的 HTTP Header/Params 很简单,仅仅包含基本的信息。

Host	www.google.com
Connection	keep-alive
Transfer-Encoding	chunked
Cache-Control	no-cache, no-store
X-S3-Send-Compressible	1
User-Agent	Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P)
                AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 
                Mobile Safari/537.36 GSA/5.9.33.19.arm
Content-Type	application/octet-stream
Accept-Encoding	gzip, deflate

这里要注意 Cotent-Type 是 application/octet-stream,这代表上传的是二进制数据流,需要用专门的协议才能 decode 到真正内容。这给解析协议带来了很大的困难。简单观察二进制数据流,发现部分内容是遵从 protobuf 协议格式,但直接使用 application/x-protobuffer 的 Dilimited List 封包格式进行 docode 并不成功。无奈之下,只好采取比较笨的方法,观察二进制数据流,跳过那些明显不符合 protobuf 协议格式的部分,尽最大努力把 protobuf 消息先解包出来。然后再根据已经解包出来的部分,逐步推测未解析部分的编码格式。经过较长时间的分析,拿到了 Google App 语音搜索 up 连接的请求消息封包格式大致如下:

(10 bytes Header)
(Big Endian Fixed32 of Msg Len)(Msg)
(Big Endian Fixed32 of Msg Len)(Msg)
......
(Big Endian Fixed32 of Msg Len)(Msg)

对于 Header 长度是否恒为 10 字节,我持怀疑态度,有待通过更多分析发现规律。做了很多二进制码的分析工作,到最后才发现消息结构如此简单,让人不得不抱怨:Google 你直接用和文本搜索一样的 application/x-protobuffer 格式不得了吗?还搞得那么复杂!不过也许有一定历史原因吧。

和文本搜索一样,先建立一个 Empty protobuf Message,逐步去推导流式协议里 Message 的结构,尽最大猜测推导出来的 Google 语音搜索请求消息的 .proto 可能是这样的:

$ more voicesearch.proto
package com.google.search.app;

message VoiceSearchRequest {
    optional int32 fin_stream = 3;
    optional UserInfo user_info = 293000;
    optional VoiceSampling voice_sampling = 293100;
    optional VoiceData voice_data = 293101;
    optional ClientInfo client_info = 294000;
    optional UserPreference user_preference = 294500;
    optional Empty VSR27301014 = 27301014;
    optional Empty VSR27423252 = 27423252;
    optional Double VSR27801516 = 27801516;
    optional GetMethod get_method = 34352150;
    optional Empty VSR61914024 = 61914024;
    optional Empty VSR77499489 = 77499489;
    optional Empty VSR82185720 = 82185720;
}

message UserInfo {
    optional Empty lang = 2;
    optional Empty locale = 3;
    optional string uid = 5;
    optional GoogleNow google_now = 9;
}

message GoogleNow {
	optional string auth_url = 1;
	optional string auth_key = 2;
}

message VoiceSampling {
    optional float sample_rate = 2;
}

message VoiceData {
    optional bytes amr_stream = 1;
}

message ClientInfo {
	optional string type = 2;
	optional string user_agent = 4;
	repeated string expids = 5;
	optional string os = 8;
	optional string model = 9;
	optional string brand = 11;
}

message UserPreference {
    optional Favorites favorites = 1;
    optional Empty UP4 = 4;
    optional Empty UP25 = 25;
}

message Favorites {
	optional string lang = 9;
	repeated Empty favorites_data = 22;
}

message GetMethod {
	optional Empty params = 1;
	optional HttpHeader headers = 2;
	optional string path = 3;
}

message HttpHeader {
    repeated string name = 1;
    repeated string text_value = 2;
    repeated bytes binary_value = 4;
}

基于这个 .proto,对语音输入『北京天气』的 POST 请求消息进行解码,最终的结果摘要如下:

$ more up.txt
######## Data block info: offset=0xa blockSize=3717
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=3654
user_info {
  lang {
    1: "cmn-Hans-CN"
    2: 1
  }
  locale {
    1: "zh_CN"
    2: 2
  }
  uid: "******"
  google_now {
    auth_url: "https://www.googleapis.com/auth/googlenow"
    auth_key: "******"
    7: 1
  }
  8: "w "
}
voice_sampling {
  sample_rate: 16000.0
  3: 9
}
client_info {
  type: "voice-search"
  user_agent: "Mozilla/5.0 (Linux; Android 4.4.4; ......"
  expids: "p2016_01_27_20_58_17"
  expids: "8501679"
  expids: "8501680"
  expids: "8502094"
  expids: "8502157"
  expids: "8502159"
  expids: "8502312"
  expids: "8502347"
  expids: "8502369"
  expids: "8502376"
  expids: "8502490"
  expids: "8502491"
  expids: "8502618"
  expids: "8502679"
  expids: "8502705"
  expids: "8502947"
  expids: "8502986"
  expids: "8503012"
  expids: "8503037"
  expids: "8503109"
  expids: "8503110"
  expids: "8503132"
  expids: "8503133"
  expids: "8503157"
  expids: "8503158"
  expids: "8503208"
  expids: "8503212"
  expids: "8503214"
  expids: "8503303"
  expids: "8503306"
  expids: "8503367"
  expids: "8503368"
  expids: "8503404"
  expids: "8503512"
  expids: "8503559"
  expids: "8503585"
  expids: "8503604"
  expids: "8503606"
  expids: "8503729"
  expids: "8503730"
  expids: "8503751"
  expids: "8503752"
  expids: "8503755"
  expids: "8503805"
  expids: "8503815"
  expids: "8503832"
  expids: "8503835"
  expids: "8503844"
  expids: "8503853"
  expids: "8503855"
  expids: "8503907"
  expids: "8503925"
  expids: "8503927"
  expids: "8503994"
  expids: "8504022"
  expids: "8504059"
  os: "Android"
  model: "KTU84P"
  brand: "HM 1S"
  1: ""
  10: "300601416"
  12: 720
  13: 1280
  14: 320
  17: "assistant-query-entry"
}
user_preference {
  favorites {
    favorites_data {
      1: 2
      2: "fresh"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-phone"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-email"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-person"
      9: "cmn-Hans-CN"
    }
  }
  UP4 {
    1: 10
    2: 250
    3: 1
  }
  UP25 {
    1: 0
  }
  3: 5
  5: 1
  7: 0
  13: 1
  14: 1
  20: 1
  22: 0
}
VSR27301014 {
  1: 460
  2: 0
  3: 460
  4: 0
  5: 1
}
VSR27423252 {
  1: "rbshUd2M8t4"
}
VSR27801516 {
  d7: 0.6037593483924866
}
get_method {
  params {
    1: "noj"
    1: "tch"
    1: "spknlang"
    1: "ar"
    1: "br"
    1: "ttsm"
    1: "client"
    1: "hl"
    1: "oe"
    1: "safe"
    1: "gcc"
    1: "ctzn"
    1: "ctf"
    1: "v"
    1: "padt"
    1: "padb"
    1: "ntyp"
    1: "ram_mb"
    1: "qsubts"
    1: "wf"
    1: "inm"
    1: "source"
    1: "entrypoint"
    1: "action"
    2: "1"
    2: "6"
    2: "cmn-Hans-CN"
    2: "0"
    2: "0"
    2: "default"
    2: "ms-android-xiaomi"
    2: "zh-CN"
    2: "utf-8"
    2: "images"
    2: "cn"
    2: "Asia/Shanghai"
    2: "1"
    2: "5.9.33.19.arm"
    2: "200"
    2: "640"
    2: "1"
    2: "870"
    2: "1458289692542"
    2: "pp1"
    2: "vs-asst"
    2: "and/assist"
    2: "android-assistant-query-entry"
    2: "devloc"
  }
  headers {
    name: "User-Agent"
    name: "X-Speech-RequestId"
    name: "Cookie"
    name: "Host"
    name: "Date"
    name: "X-Client-Instance-Id"
    name: "X-Geo"
    name: "X-Client-Opt-In-Context"
    name: "X-Client-Data"
    text_value: "Mozilla/5.0 (Linux; Android 4.4.4; ......"
    text_value: "rbshUd2M8t4"
    text_value: "******"
    text_value: "www.google.com.hk"
    text_value: "Fri, 18 Mar 2016 08:28:13 GMT"
    text_value: "c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62fd1b9d378262b3690cff"
    text_value: "w CAEQDKIBBTk6MTox"
    binary_value: "\037\213\b\000......"
    binary_value: "\b\257\363\206......gsa"
  }
  path: "/search"
  5: 0
}
VSR61914024 {
  1: 1
}
VSR77499489 {
  1: 1
}
VSR82185720 {
  1: "\b\001"
}
1: "voicesearch-web"
2: 1
4: 0

######## Data block info: offset=0xe93 blockSize=39
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=39
get_method {
  headers {
    name: "X-Geo"
    text_value: "w CAEQDKIBBTE6MTox"
    3: 2
  }
}
2: 1

######## Data block info: offset=0xebe blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "#!AMR-WB......"
}
2: 1

######## Data block info: offset=0xff7 blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "......"
}
2: 1

......

######## Data block info: offset=0x4394 blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "......"
}
2: 1

######## Data block info: offset=0x44cd blockSize=267
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=267
voice_data {
  amr_stream: "......"
}
2: 1

######## Data block info: offset=0x45dc blockSize=4
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=4
fin_stream: 1
2: 1

语音搜索 down 连接

在 down 连接中,Google App 仅利用了 POST 响应通路的 chunked 编码流式下发识别结果、搜索结果和语音播报数据。App 发起的虽然是一个 POST 请求,但在请求中并没有任何 POST Data,Content-Length 为 0。响应消息的 header 如下:

Content-Type	application/vnd.google.octet-stream-compressible
Content-Disposition	attachment
Cache-Control	no-transform
X-Content-Type-Options	nosniff
Pragma	no-cache
Content-Encoding	gzip
Date	Fri, 18 Mar 2016 08:28:14 GMT
Server	S3 v1.0
X-XSS-Protection	1; mode=block
X-Frame-Options	SAMEORIGIN
Alternate-Protocol	443:quic,p=1
Alt-Svc	quic=":443"; ma=2592000; v="31,30,29,28,27,26,25"
Transfer-Encoding	chunked

这里值得注意的,仍然是 Cotent-Type,这次是 application/vnd.google.octet-stream-compressible,又一个二进制私有协议数据流。通过与 up 流分析类似的方法,发现 Google App 语音搜索 down 连接的响应消息封包格式大致如下:

(4 bytes Header)
(Big Endian Fixed32 of Msg Len)(Msg)
(Big Endian Fixed32 of Msg Len)(Msg)
......
(Big Endian Fixed32 of Msg Len)(Msg)

可以看到,除了 Header 长度不同以外,基本与 up 连接的请求消息封包格式一样。采取同样的方式推测到 Google 语音搜索响应消息的 .proto 可能是这样的:

$ more voicesearch.proto
package com.google.search.app;
message VoiceSearchResponse {
    optional int32 fin_stream = 1;
    optional RecogBlock recog_block = 1253625;
    optional SearchResult search_result = 39442181;
    optional TtsSound tts_sound = 28599812;
}

message RecogBlock {
    optional RecogResult recog_result = 1;
    optional VoiceRecording voice_recording = 2;
    optional string inputLang = 3;
    optional string searchLang = 4;
}

message RecogResult {
    optional CandidateResults can = 3;
    repeated RecogSegment recog_segment = 4;
    optional CandidateResults embededi15 = 5;
}

message VoiceRecording {
    optional int32 record_interval = 3;
}

message RecogSegment {
    optional DisplayResult display = 1;
    optional int32 seg_time = 3;
}

message DisplayResult {
    optional string query = 1;
    optional double prob = 2;
}

message CandidateResults {
    optional CandidateResult can3 = 3;
    optional Empty can4 = 4;
}

message CandidateResult {
    repeated string query = 1; 
    repeated string queryWords = 12; 
    optional float res2 = 2;
    repeated CandidateInfo res7 = 7;
}

message CandidateInfo {
    optional CandidateInfoMore inf1 = 1;
}

message CandidateInfoMore {
    optional CandidateInfoMoreDetial more1 = 1;
    optional string more3 = 3;
}

message CandidateInfoMoreDetial {
    optional string type = 1;
    optional string detial2 = 2;
    optional float detial3 = 3;
    optional CandidateInfoMoreDetialSnip detial7 = 7;
    optional string detial8 = 8;
}

message CandidateInfoMoreDetialSnip {
    optional string query = 1;
}

message SearchResult {
    optional string header = 1;
    optional bytes bodyBytes = 3;
}

message TtsSound {
    optional bytes sound_data = 1;
    optional int32 fin_stream = 2;
    optional Empty code_rate = 3;
}

message Empty {
}

基于这个 .proto,对语音输入『北京天气』的 down 响应消息进行解码,最终的结果摘要如下:

$ more down.txt
######## Raw Data block info: offset=0x4 size=41
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=41
recog_block {
  voice_recording {
    record_interval: 140000
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x31 size=61
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=61
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北"
        prob: 0.01
      }
      seg_time: 1500000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x72 size=64
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=64
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京"
        prob: 0.01
      }
      seg_time: 1560000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0xb6 size=67
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=67
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天"
        prob: 0.01
      }
      seg_time: 1980000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0xfd size=71
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=71
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天气"
        prob: 0.01
      }
      seg_time: 2100000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x148 size=71
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=71
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天气"
        prob: 0.9
      }
      seg_time: 2700000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x193 size=14
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=14
recog_block {
  voice_recording {
    1: 1
    2: 2100000
  }
}

######## Raw Data block info: offset=0x1a5 size=40
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=40
recog_block {
  voice_recording {
    1: 2
    2: 3570000
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x1d1 size=491
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=484
recog_block {
  recog_result {
    can {
      can3 {
        query: "北京天气"
        query: "北京天気"
        query: "北京天器"
        res2: 0.9156357
        queryWords: "北 京 天 气"
        queryWords: "北 京 天 気"
        queryWords: "北 京 天 器"
        6: "\020\n\030\001"
      }
      1: 0
      2: 3570000
    }
    embededi15 {
      can3 {
        query: "北京天气"
        query: "北京天気"
        query: "北京天器"
        res2: 0.9156357
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天气"
              detial3: 1.0
              detial7 {
                query: "北京天气"
                2: 0
                3: 75
              }
              detial8: "北 京 天 气"
            }
            more3: ""
          }
        }
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天気"
              detial3: 1.0
              detial7 {
                query: "北京天気"
                2: 0
                3: 75
              }
              detial8: "北 京 天 気"
            }
            more3: ""
          }
        }
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天器"
              detial3: 1.0
              detial7 {
                query: "北京天器"
                2: 0
                3: 75
              }
              detial8: "北 京 天 器"
            }
            more3: ""
          }
        }
        queryWords: "北 京 天 气"
        queryWords: "北 京 天 気"
        queryWords: "北 京 天 器"
      }
      1: 0
      2: 3570000
    }
    1: 1
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x3c0 size=29759
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=29759
search_result {
  header: "HTTP/1.1 200 OK
  Content-Type: application/x-protobuffer
  Date: Fri, 18 Mar 2016 08:28:17 GMT
  Expires: -1
  Cache-Control: no-store
  Set-Cookie: ******
  Trailer: X-Google-GFE-Current-Request-Cost-From-GWS
  Set-Cookie: ******
  Content-Disposition: attachment; filename=\"f.txt\"
  
  "
  bodyBytes: "\301R\n\026IbzrVsTgFsK0jwP1iY34CQ .... charset=UTF-8H\001"
  2: 1
  4: 0
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=10561
search_id: "IbzrVsTgFsK0jwP1iY34CQ"
msg_type: 97000
result {
  fin_stream: 0
  text_data: "北京天气"
  html_data: "<!doctype html><html ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=16951
search_id: "IbzrVsTgFsK0jwP1iY34CQ"
result {
  fin_stream: 0
  html_data: "<style data-jiis=\"cc\" ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=1511
......

######## Raw Data block info: offset=0x7803 size=81
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=81
search_result {
  bodyBytes: "D\n\026IbzrVsTgFsK0jwP1iY34CQ\......"
  2: 1
  4: 0
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=68
......

######## Raw Data block info: offset=0x7858 size=107685
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=107685
......

## Bodybytes Proto: Textsearch.SearchResponse with Size=103014
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=2454
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=2194
......

######## Raw Data block info: offset=0x21d01 size=8480
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=8480
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=7260
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=1202
......

######## Raw Data block info: offset=0x23e25 size=1615
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1615
tts_sound {
  sound_data: "\377\363@\304\......"
  code_rate {
    1: 22050
  }
}

######## Raw Data block info: offset=0x24478 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
tts_sound {
  sound_data: "k\224ed4= \213......"
}

######## Raw Data block info: offset=0x24ac5 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x25112 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x2575f size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x25dac size=1518
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1518
......

######## Raw Data block info: offset=0x2639e size=7
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=7
tts_sound {
  fin_stream: 1
}

######## Raw Data block info: offset=0x263a9 size=2
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=2
fin_stream: 1

协议分析和启发

针对以上解码结果,对 Google App 语音搜索协议分析如下:

双向流式通信模式

上传音频采用流式方式,这个并不难想,但是识别结果、搜索结果和语音播报音频流全部在一个流里回传,需要做很多复杂的架构升级工作,带来的收益也是很明显的:

  1. 识别结果实时上屏。语音识别在解码时,需要收集到足够的语音流之后,才能识别出来文字,也就是说文字结果的出现时间比较随机。流式的识别结果下发能实现一旦有识别中间结果,就可以用最快的速度下发到 App 端展示给用户。
  2. 减少一次搜索请求开销。从逻辑上讲,应该先进行语音识别,再用识别出的 Query 发起搜索。但 Google 把这一步放在了服务端,不需要用户再发起一次搜索结果的 GET 请求。因为 Google Search 的域名和 locale 有关,新的 GET 请求可能需要发给 google.com.hk,这就需要 App 新建一个 HTTPS 连接,开销还是比较大的。而且服务器端还可以进一步优化,比如在识别出中间结果的同时请求即时搜索,不知道 Google 有没有做。
  3. 减少一次语音播报请求开销。原理同上
  4. 提升了 TCP 信道利用率。在同等传输数据量下,减少网络连接数,受 TCP 拥塞控制策略影响,TCP 信道的传输性能能得到一定提升。

录音小块回传

从 up 解码结果来看,Google App 音频流的是以固定每 300 字节一个封包,基于 AMR-WB 压缩这基本相当于 100ms 左右的录音。说明录音数据的上传还是很频繁的,这也能够让服务端尽早地识别出中间结果。还有就是,300字节的设计比较容易 fit in 1500 左右的以太网 MTU、576 的 3G、4G MTU。

搜索结果内置

搜索结果内置,其实相当于在服务器端『代理』App 发起一次内网搜索请求,那服务器端必须要知道正常的 App 请求参数是怎样的。所以在 App 端发起语音请求时,首先向服务器端传送了一些 App 端的配置信息,比如语言、地域、终端类型、用户偏好、搜索参数、搜索 Header 等。有了这些信息,服务器端就可以伪装成 App,通过内网发起搜索请求,获取搜索结果然后把结果内置到语音搜索结果里。但注意到中文语音搜索和文本搜索的域名不同,分别是 google.com 和 google.com.hk,在机房部署上如何高效地实现全球机房的高效内网访问,这仍然是个架构难题,这里无法窥知答案。

对文本搜索结果的分析我们了解到,Google App 的搜索结果是用 Protobuf Message 数组的方式下传的。但语音搜索并没有使用与文本搜索相同的数组元素 Message,所以文本搜索结果是以 bytes 方式,将序列化后的文本搜索 Message 放到语音搜索 Message 中的一个字段中。这也许是为了协议解耦,避免单方面协议改动带来的同步问题。但可能是基于效率考虑,多个文本搜索 Message 可能会被放到同一个语音搜索 Message 中,也是以 Dilimited List 的方式序列化后放入。语音搜索模块解码出来 bytes 之后,可以直接塞给文本搜索的渲染模块渲染,与文本搜索接收的协议格式完全相同。

语音播报

语音播报的音频也是以流式下传,这样可以边播边收,也有效率优势。

Google App API Protocol - Text Search

目录 互联网

Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。

  1. Google App API Protocol - Text Search
  2. Google App API Protocol - Voice Search
  3. Google App API Protocol - Search History

HTTPS 抓包分析

Google Service 已经全面普及了 HTTPS 接入,所以想探索 Google 的通讯协议,首先必备的是 HTTPS 抓包能力。所谓的 HTTPS 抓包,实质上是通过代理服务器实现对测试手机上 HTTPS 连接的中间人攻击,所以必须在测试手机上安装代理服务器的 CA 证书,才能保证测试手机相信 HTTPS 连接是安全的。

有很多测试用代理服务器支持 HTTPS 抓包,例如 FiddlerCharles,HTTPS 配置具体可以参见它们的官方文档:Configure Fiddler to Decrypt HTTPS TrafficSSL CERTIFICATES.

文本 IS 请求

Google App 上的文本搜索请求,并不一定以用户按下搜索按钮才开始,而是在输入过程中就可能发生,类似于 Instant Search 即时搜索。这一切发生在输入文本过程中的 "/s?" 请求内。

Google 在接口上,已经将搜索推荐和即时搜索合二为一,通过发起对 "https://www.google.com[.hk]/s?" 的 GET 请求,根据用户已经输入的短语,获取搜索推荐词。如果 Google 认为用户已经完成输入,它会在这个请求的应答消息中直接返回搜索结果。

以小米手机上安装的 Google App 为例,当用户输入『上海』这个词时,GET 请求的参数,主要有以下这些:

noj	1
q	上海
tch	6
ar	0
br	0
client	ms-android-xiaomi
hl	zh-CN
oe	utf-8
safe	images
gcc	cn
ctzn	Asia/Shanghai
ctf	1
v	5.9.33.19.arm
biw	360
bih	615
padt	200
padb	640
ntyp	1
ram_mb	870
qsd	3670
qsubts	1458723210129
wf	pp1
action	devloc
pf	i
sclient	qsb-android-asbl
cp	2
psi	bLxzwaswPFc.1458723200693.3
ech	2
gl	us
sla	1

请求的 HTTP Header,主要有以下这些:

Connection	keep-alive
Cache-Control	no-cache, no-store
Date	Wed, 23 Mar 2016 08:53:26 GMT
X-Client-Instance-Id	c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62f...
Cookie	******
X-Geo	w CAEQDKIBBTk6MTox
X-Client-Opt-In-Context	H4sIAAAAAAAAAONi4WAWYBD4DwOMUiwcj...
X-Client-Discourse-Context	H4sIAAAAAAAAAB1PS07DMBSUehe...
X-Client-Data	CM72hgQIjfeGBAiP94YECKj4hgQIy...
User-Agent	Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P)
 AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile
 Safari/537.36 GSA/5.9.33.19.arm
Accept-Encoding	gzip, deflate, sdch

文本 IS 应答消息解码

文本 IS 应答消息的 HTTP Header 如下所示:

HTTP/1.1 200 OK
Content-Type	application/x-protobuffer
Date	Wed, 23 Mar 2016 08:56:47 GMT
Expires	-1
Cache-Control	no-store
Content-Disposition	attachment; filename="f.txt"
Content-Encoding	gzip
Server	gws
X-XSS-Protection	1; mode=block
X-Frame-Options	SAMEORIGIN
Alternate-Protocol	443:quic,p=1
Alt-Svc	quic=":443"; ma=2592000; v="31,30,29,28,27,26,25"
Transfer-Encoding	chunked

这里最值得关注的,是 Content-Type。application/x-protobuffer 不是一个常见的 Media Type,起初我以为它就是简单的 protobuffer message 序列化二进制内容,搜索到的一些信息也是这样说的,但用 protobuf 对其 Decode,并不能正确解析消息体。后来我还是在 Charles 的这篇文档中找到了思路,其实 application/x-protobuffer 在实现时区分单个消息和多个消息的格式(但 Content-Type 里并不显式说明)。多个消息,即 Dilimited List,的封包协议是这样的:

(Varint of Msg Len)(Msg)(Varint of Msg Len)(Msg)...(Varint of Msg Len)(Msg)EOF

解包时也很简单,Protobuf 的 Java 库提供了 Message.Builder.mergeDelimitedFrom(...) 来直接从 InputStream 里循环读取多个 Message 的封包数据。

但这时候我们还不知道应答消息里的 protobuf Message 格式,无法构建 Message 的 Builder。这时候有个简单的办法去逐步推导,也就是新建一个 Empty Message,如下所示:

$ more textsearch.proto
message Empty {}

用这个 Empty Message 构建 Builder,对 IS 的应答消息进行 Decode,将 Decode 结果打印出来时会发现所有的字段都是无名字段。然后根据对 protobuf wire data 的理解,逐步推导它的 Message 格式,尽最大的努力去猜测各个字段的作用,最终推导出来 IS 应答消息的 .proto 可能是这样的:

$ more textsearch.proto
package com.google.search.app;

message SearchResponse {
    required string search_id = 1;
    optional uint32 msg_type = 4;
    optional SearchResultPart sug = 100;
    optional SearchResultPart result = 101;
    optional Empty SR102 = 102;
}

message SearchResultPart {
    optional uint32 fin_stream = 1;
    optional string text_data = 2;
    optional string html_data = 7;
    optional string encoding = 8;
}

message Empty {
}

基于这个 .proto,对 Query 『上海天气』 IS 的应答消息进行 Decode,最终的结果摘要如下:

$ more s.txt
#### Proto: Textsearch.SearchResponse with Size=758
search_id: "iVnyVrChHsTAjwPdh4PwBA"
msg_type: 97000
sug {
  fin_stream: 1
  text_data: "[\"上海天气\",[[\"上海天气\",35,[39,70],......}]"
}

#### Proto: Textsearch.SearchResponse with Size=10561
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
msg_type: 97000
result {
  fin_stream: 0
  text_data: "上海天气"
  html_data: "<!doctype html><html itemscope=\"\" ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=16951
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<style data-jiis=\"cc\" id=\"gstyle\">......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1511
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<title>上海天气 - Google 搜索</title></head><body ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=68
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: ""
  encoding: "text/html; charset=UTF-8"
  3: 0
  9: 1
}
SR102 {
  1: 1
  4: ""
}
#### Proto: Textsearch.SearchResponse with Size=104557
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<div data-jiis=\"cc\" id=\"doc-info\">......</script>"
  encoding: "text/html; charset=UTF-8"
  4: 8
  4: 10
  4: 6
  4: 13
  4: 12
  4: 2
  4: 21
  4: 20
  9: 1
  10: 1
}
#### Proto: Textsearch.SearchResponse with Size=2198
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>google.y.first.push(function()......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=2146
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=7261
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1202
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 1
  html_data: " <div id=\"main-loading-icon\" ......</div></body></html>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}

观察到 sug_data 看似是 JSON 格式的数据,专门对 sug_data 进行 JSON Decode,得到以下结果:

## JSONArray: sug_data ##
[
  "上海天气",
  [
    [
      "上海天气",
      35,
      [
        39,
        70
      ],
      {
        "ansc": "1458723207451",
        "ansb": "2338",
        "du": "/complete/deleteitems?client=qsb-android-asbl&delq=上海天气
               &deltok=AKtL3uTL0hK_EMlKCgutzvQvzXfh2VRAgg",
        "ansa": {"l": [
          {"il": {"t": [{
            "tt": 13,
            "t": "上海天气"
          }]}},
          {"il": {
            "at": {
              "tt": 12,
              "t": "周三"
            },
            "t": [
              {
                "tt": 1,
                "t": "54"
              },
              {
                "tt": 3,
                "t": "°F"
              }
            ],
            "i": {
              "d": "//ssl.gstatic.com/onebox/weather/128/partly_cloudy.png",
              "t": 3
            }
          }}
        ]},
        "zc": 602
      }
    ],
    [
      "上海天气<b>预报<\/b>",
      0,
      [],
      {"zc": 601}
    ],
    [
      "上海天气<b>预报10天<\/b>",
      0,
      [],
      {"zc": 600}
    ],
    [
      "上海天气<b>预报15天<\/b>",
      0,
      [],
      {"zc": 551}
    ]
  ],
  {
    "q": "W9D2ISTC-TXK4QauyTt2SFqJzvo",
    "n": 0
  }
]

非 IS 搜索请求和搜索应答解码

当 IS 应答消息里有搜索结果时,点击搜索按钮不会再发起一次搜索。但如果 IS 应答只有 SUG,没有搜索结果,Google App 就会发起一次非 IS 的正常搜索请求。这次请求除了请求的 URL path 从 "/s" 变成 "/search" 以外,主要的 GET 参数保持一致,会有部分附加参数的不同。以『上海天气』(有 IS 结果)和『上海天气好不好呢』(无 IS 结果)为例,GET 参数有以下区别:

-qsd	3670
-pf	i
-sclient	qsb-android-asbl
-cp	4
-psi	bLxzwaswPFc.1458723200693.3
-ech	3
-gl	us
-sla	1
+gs_lp	EhBxc2ItYW5kcm9pZC1h...
+source	and/assist
+entrypoint	android-assistant-query-entry

而非 IS 搜索应答和 IS 应答对比,区别主要在于非 IS 搜索应答消息中,没有搜索词 SUG Message 包。

协议分析和启发

请求消息协议

搜索请求是通过 GET 协议实现的,所以请求主要分为两部分:HTTP 头和 GET 参数。从请求上来看,Google 对 GET 参数的使用是非常节省的,很多字段都是极其精简的缩写。但它倒是在 HTTP 头里放了很多比较大的数据字段,从 Header 名来猜测,应该是跟设备、登录用户相关的一些加密字段。

因为搜索请求是 App 发出的,所以理论上 GET 请求和 POST 请求的实现难度是差不多的,POST 的时候可以进行数据压缩,Header 倒是不能压缩(HTTP 1.x)。那为什么 Google 反而选择把这么长的数据放在 HTTP Header 里呢?我的猜测是为了充分利用 HTTP/2 的特性。在 HTTP/2 里有个特性,叫做 Header Compression,在多次请求时,同一个 Header 原文仅需要压缩传输一次即可。但由于现在还没有 HTTP/2 的抓包工具,所以还无法判断 Google App 是已经用上了这个特性,还是为未来的使用做好准备。不过这至少给了我们一个启发,为了充分利用底层协议的特性,应用层约定可能也需要一些适配工作。

应答消息协议

Google App 的搜索结果,并没有像普通网站服务一样,直接用标准的 HTML 协议返回一个 Web Page。而是将渲染好的 Web Page 分段放到应答消息中,由 App 端提取、拼接成最终的搜索结果页。猜测有以下几点考虑:

  1. 便于与 SUG 服务集成。很多搜索框都提供 Sug 功能,但 Google 为了让用户感觉更快,在输入过程中不仅有 Sug,还会直接显示搜索结果,桌面版上叫做『即时搜索』。移动版 App 的做法跟桌面版类似,但实现上有不同的地方。移动 App 的输入框不是 HTML 的 <input> 标签,而是一个系统原生的输入框,所以无法依赖 Javascript 去响应事件,刷新结果页。而且网络请求是 App 发起的,为了充分利用网络连接,将搜索结果集成到 SUG 结果里也是顺理成章的事情。不过这还意味着在 App 上,不能仅返回数据通过 Ajax 技术无缝刷新结果页,必须得在消息里返回整个搜索结果页。
  2. 减少解码内存使用,改善性能。如果将整个搜索结果页放到一个 Protobuf Message 中,客户端为了解码这个消息,需要申请很大一块内存。而在移动设备上,内存是很 critical 的资源,尤其是在 Android 设备上,使用大块内存会导致频繁的 GC,性能很差。
  3. 模拟 Chunked 编码。Web 服务器可以通过 HTTP 1.1 引入的 Chunked transfer encoding 将网页分块传输给浏览器,浏览器无需等待网页传输结束,就能够开始页面渲染。当网页通过 Protobuf Message 传输时,无法利用浏览器的 chunked 处理技术,只好用分拆为多个 Message 的方式模拟 chunked 模式。虽然 Android 原生的 Webview 并没有支持 chunked 的 LoadData 接口,相信 Google 自己的 App 实现一个类似功能并不困难。

但 Google 这种直接下发网页数据的做法,也存在一个问题,就是没有合法的网页 URL。合法的网页 URL,有以下几个潜在的作用,Google App 做了一些额外的工作来处理:

  1. 刷新页面。Google App 没有提供页面刷新功能;
  2. 前进后退。Google App 没有提供前进后退功能,而是通过 Search History 来满足后退功能。
  3. 浏览历史。Google App 没有提供浏览历史,只提供了搜索历史。
  4. 页面分享。Google App 没有提供页面分享功能。

Android 版本的 Google App 连搜索结果都需要用第三方浏览器打开,结合上述功能处理,可以发现 Android 版本 Google App 只是想做一个精悍的搜索应用,无意于把自己变成一个完善的浏览器。但 iOS 版本 Google App 对上述问题的处理略有不同,iOS 版本内置了一个浏览器,搜索结果可以在 App 内打开。但主要的搜索结果页,仍然是采用类似于 Android 的方式处理。也就是说,iOS 版本的浏览器,可能仅仅是一个 UIWebview。