1. 引言

ProtonMail 是实验室负责人使用的邮件服务之一。 想必读者也颇有了解:这一邮件厂家的口碑很好,主要是因为其提供全部邮件使用PGP加密的服务。 无论是存储在信箱里的邮件,还是 ProtonMail 用户之间的通信,都是经过这种保护的, 即使是 ProtonMail 官方自己,也无法查看邮件正文。

这个安全模型是否完美呢?基本上来说,可以接受,但不是没有改进空间。

ProtonMail 的安全性是有前提的。虽然在服务器端没有人能阅读用户的邮件,但:

  1. 客户端的安全就成为了关键。 ProtonMail 官方目前只提供如下访问方式:网页客户端、手机App,和一个 IMAP/SMTP 代理(还在试用中,而且只提供给付费用户)。 首先排除使用手机等客户端访问 ProtonMail 的方式。 手机几乎无法保证数据安全,如果要访问 ProtonMail,就必须输入私钥的解密密码。 这个密码要么必须由人记住(不符合实验室负责人的安全模型),要么必须存储在手机上,因此都是不可接受的选项。
  2. 因为 IMAP/SMTP 代理还在试验中,所以目前只能通过PC上的网页客户端访问。

PC上网页客户端的安全性,由如下因素得以保证:

  1. 操作系统干净。没有病毒和恶意软件。
  2. 浏览器靠谱。所以只推荐使用火狐或者基于火狐的 Tor 浏览器。
  3. ProtonMail 提供的网页客户端靠谱。详细叙述如下。

1.1 ProtonMail 可能出现的问题

在使用 ProtonMail 的时候,需要登录输入一个或者两个密码。

旧版用户使用2个密码,一个是用于确定用户的身份,一个是用于解密邮箱。 这两个密码的区别在于,第一个密码是某种形式存储在服务器上的,第二个则完完全全存储在浏览器自己的内存中; 新版用户使用单一的密码,但这个密码也不是存储在服务器上,而是在登录的时候通过一种特殊的密码算法,向服务器证明“用户确实知道这个密码”。

如果一个黑客获得了第一个密码,他可以登录你的邮箱,但无法下载并查看你的邮件。 而密码二一旦暴露,他就可以解密你的全部邮件。

这就看出密码二的关键意义了: 因为密码二从来不会被发送到 ProtonMail 的服务器,所以,即使我们假设 ProtonMail 被黑客入侵, 或者它想要主动配合政府部门的调查,因为不知道这个密码,ProtonMail 最多只能给出一堆被加密的邮件正文。 (但 ProtonMail 说邮件标题是不被加密的,所以标题是可以被泄露出去的)。

问题在于,为什么“密码二从来不会被发送到 ProtonMail 的服务器”呢?

目前而言,这是因为我们每次登录网页客户端,浏览器从 ProtonMail 获得的程序脚本中都是这样设计,没有将这个密码发送出去的机制。 但实际上没有任何办法可以阻止 ProtonMail 向我们发送一个修改过的新脚本,增加一个功能,指令浏览器将密码框中的内容通过一个上传 API 发送给服务器。

换句话说,假使有一天 ProtonMail 主动或者被迫想要这么做,那么只需要在服务器上增加一个逻辑, 在适当的时候向我们发送“经过修改的、有后门的程序脚本”,密码二就会被获取。 而且问题在于,如果这一逻辑是针对特定用户实施的,那么大多数用户甚至没有发现这个问题的机会!

当然,除了 ProtonMail 之外,还有其他的因素也可能导致上述情景。 比如,如果用户使用了不安全的 HTTPS 连接,导致发生了中间人攻击,那么脚本就可以由进行攻击的第三方修改。 又比如,如果浏览器上安装了不安全的插件,那么这一插件完全可以获得 ProtonMail 的密码。 不过最后这种情况是浏览器本身的安全问题,超出了当前的讨论范围,所以我们姑且不讨论。

1.2 如何避免这一情况?

虽然我们到现在为止还非常信任 ProtonMail ,但这种信任,实际上只是建立在对这一公司所在的国家(瑞士)的司法体系之上。 但前面所述的后门攻击,是司法上也许不会去做的事情,技术上却是完全可以发生的。

俄罗斯谚语说过,信任它,但还是要确认它(Trust but verify)。 要在技术上阻止这种情况,最好的办法是使用我们确信没有问题的客户端来访问 ProtonMail。 但这种客户端需要在计算机上额外安装,官方没有提供,自己编写也相当耗费精力。

另一个稍差的办法,是限定 ProtonMail 向我们发送的网页客户端,让它只能使用已知的、无后门的脚本。 将网页上使用的脚本锁定为给定的版本,拒绝更新的版本,可以避免新版本中可能植入的后门。 但缺点是,也无法使用新版本的功能,而且旧版本中如果有Bug或者安全漏洞,也无法得以修正。

NeoAtlantis应用科学和神秘学实验室为了实现这一办法,已经编写了 neutron 这个插件。 本文将介绍具体的技术细节。

2. 干涉 ProtonMail 的加载过程

neutron这一插件的目的,是检查并干涉 ProtonMail 网页客户端的加载过程。

一个网页客户端由很多文件构成:在用户输入网址并点击访问后,浏览器首先请求加载网址对应的HTML网页。 在获得了HTML内容后,浏览器根据其中的指示,进而加载需要的样式文件、图片,还有脚本等资源。 脚本加载后就开始运行,进行各种各样的操作,比如绘制用户界面,同时也会加载它所需的更多脚本,直至程序认为任务完成停下来。

在不受插件控制的默认情况下,上述过程是自动的。插件可以利用浏览器内置的API,按照需要控制上述过程,例如: 修改请求和响应中的部分参数、取消加载,或者将加载过程重定向到其他网址。

2.1 使用 webRequest 拦截 ProtonMail 的脚本请求

本插件的主要作用,技术上讲就是完成如下步骤:

  1. 监听 ProtonMail 的所有加载请求。
  2. 选定其中加载脚本的部分,进行干涉。
  3. 干涉的具体策略是:如果脚本从某个已知的URL加载,就用自己保存的版本替代;如果来自一个未知的URL,就取消(禁止)加载。

编写插件的时候,Firefox为我们提供了一个重要的工具,来实现上述步骤:browser.webRequest对象。 使用这个工具,可以在要求浏览器在加载网页的多个阶段“咨询”我们的意见:

比如browser.webRequest.onBeforeRequest方法,可以要求浏览器在发送每个请求之前(即浏览器还没有实际传送请求时),调用我们给定的一个函数。 我们的函数除了可以得知这个请求的具体数据(网址、参数等),还可以返回一个对象,指令浏览器取消请求,或者将请求重定向到别的网址。

使用browser.webRequest.onHeadersReceived方法,可以在服务器刚刚返回响应头,但又没开始传送实际主体数据的时候调用我们的函数。 和前者类似,我们的函数可以控制这一响应,这也是我们可以干涉的最后时机。 不同的是,我们还可以在这个时刻要求得到有关浏览器连接的安全信息(browser.webRequest.getSecurityInfo), 比如得知这个连接是否安全,使用了何种加密算法,甚至于使用了哪些证书用于验证等。 我们可以根据这些额外的信息,决定是否中断连接,以策安全。

我们拦截 ProtonMail 的脚本请求,理论上这两个时机都可以利用。 区别在于,如果使用第一种方法,而我们直接中断或者重定向加载过程,那么服务器将不会收到任何加载的请求,也不会获得请求头中的参数(例如Cookies等)。 使用第二种方法,服务器就不会这样容易发现我们干涉了这一过程。 如果这一区别对于服务器而言很重要,还是应当仔细抉择。

以第二种方法为例。为了告知浏览器我们需要获取并处理这些请求,可以在插件的背景脚本 [*] 中加入如下代码。

[*]: 背景脚本是属于插件自己的后台进程,和浏览器窗口的「地位」相似,但与之隔离。

async function onHeadersReceived(responseDetails){
    // ... 这个函数是我们用于处理响应的。见下文描述。
}

browser.webRequest.onHeadersReceived.addListener(
    onHeadersReceived, // 上面定义的函数,作为参数传到这里
    {   // addListener的第二个参数,可以定义一些其他选项,比如下面给出
        // urls是一个过滤器,告诉浏览器我们只对在这两个形式的网址上发生
        // 的通信感兴趣
        urls: [
            "https://*.protonmail.com/*",
            "https://protonmail.com/*",
        ]
    },
    // 下面`responseHeader`,要求浏览器告知我们的函数响应头的具体内容。
    // 这些内容包含在 `responseDetails.responseHeaders` 中。
    // `blocking`要求浏览器等待我们的函数结果,以便按我们的指令修改请求。
    // 如不指定,则我们的处理过程和加载过程同时进行,无法干涉。
    ["responseHeaders", "blocking"]
);

我们定义了onHeadersReceived函数。根据 Mozilla官方文档 的说明,(从火狐版本52开始)传入browser.webRequest.onHeadersReceived.addListener的函数可以是一个同步的函数,也可以是一个Promise类型的异步函数结果。 无论哪种情况,浏览器都会等待我们的函数执行完毕,再进行下一步加载。 但对于我们自己的程序而言,同步函数执行的同时,就无法进行其他的操作,会因此影响性能。因此我们将这一函数定义为async,调用这一函数后浏览器自动得到一个Promise

2.1.1 得知连接的安全性信息

在我们的onHeadersReceived函数中,使用

const securityInfo = await browser.webRequest.getSecurityInfo(
    responseDetails.requestId,
    { certificateChain: true }
);

可以等待getSecurityInfo方法告知这一连接的安全性信息。 这个方法是异步的,所以如果我们要在得知安全性信息后再采取下一步决定,就要用await等待结果。

2.1.2 responseDetails的内容

如前所述,浏览器在调用我们的函数时,在responseDetails中传递了有关服务器响应的具体内容。

responseDetails.type可以得知这一请求的目的。 如果其值为"main_frame",就是在加载浏览器窗口中的页面。 如果是"script",就是在加载一个脚本。 "xmlhttprequest",则是通过AJAX获取内容。

为了专注处理脚本请求,我们可以据此过滤只满足responseDetails.type == "script"的情况。

2.1.3 处理完毕,返回指令

为了告知浏览器应当如何处理这一响应,我们的函数在结束之前,需要返回一个适当的结果。

浏览器期望我们返回一个 Object 对象,其中包含一条或多条指令:

var responseObject = {}; // 定义返回对象

// 要中断请求,设定 cancel 为 true
responseObject.cancel = true;

// 要将请求重定向到另一个网址,设定 redirectUrl 参数
responseObject.redirectUrl = "目标网址";

// 要修改请求返回给浏览器的数据头(headers),设定 responseHeaders
responseObject.responseHeaders = [/* 新的数据头,见下文 */];

因为我们希望遇到已知的脚本时,使用插件自带的脚本进行替换,所以要在适当的时候将redirectUrl设定为插件中的资源URL,以实现重定向。 在Firefox的插件中,这类资源可以通过类似moz-extension://<UUID>/<路径>的方式访问,但具体UUID并不确定。 为了解决这个问题,Firefox提供browser.runtime.getURL这个函数。

但是需要注意,插件中的资源如果需要可被外界访问,插件的manifest.json中,必须有web_accessible_resource选项事先予以指出。

2.2 锁定服务器返回的内容安全策略(CSP)参数

如上一节所述,我们可以通过拦截服务器发回的脚本,将其转向至我们自己的资源,来避免服务器返回有问题的数据。

然而,有另一种形式,可以直接在网页的HTML中插入<script>/* 代码 */</script>的嵌入脚本。如果服务器选择直接发送一个有问题的HTML,而不是脚本文件,上一节的办法就落空了。

一般对于网页设计者而言,这种直接嵌入脚本的做法是不推荐的,因为如果允许这样做,那么因为种种原因在网页上生成的恶意代码也就有了运行的机会。 若有设计不好的网站,直接将未经处理的用户输入展示在网页上(比如用户名),而这一输入包含恶意代码,那么浏览器就会执行它,导致不可预料的后果。

为了避免这一情况,现有一种所谓“内容安全策略”(Content-Security-Policy,缩写CSP)方案。 这一方案是在网页的<head>部分,或在发送网页之前的HTTP头中,包含一项CSP参数,其「预先」告知浏览器什么内容可以加载,什么内容不可以。 例如,可以限定禁止网页上的嵌入脚本、限定只能从给定的主机名(host)加载脚本……还可以限制图片、样式表等其他资源的来源。 在获得了CSP参数后,即使网页上存在恶意脚本,如果策略上已经做出限制,其仍然不会被浏览器执行。

ProtonMail 的服务器确实运用了CSP技术,要求其网页客户端只能运行来自mail.protonmail.com自身的脚本代码。 然而,如果以后此CSP参数被服务器更改,以便允许网页内的嵌入脚本 (这可以经由在CSP中指定嵌入脚本的散列值启用,或者直接指定unsafe-inline允许全部嵌入脚本,又或者干脆直接取消发送CSP参数),那么就为网页中加入后门开了绿灯。

为此,我们不但要提防脚本文件本身,还要提防不安全的内容安全策略。我们需要替换来自服务器的HTTP响应头,将其中的CSP换为自己的。

2.2.1 在onHeadersReceived处理过程中过滤HTTP响应头

2.1.3 中已经提到了responseHeaders这个参数的作用,就是可以设定浏览器加载过程中实际收到的HTTP响应头。

在Firefox调用我们的处理函数时,responseDetails参数中已经包含了一个responseHeaders项目。 这一项是一个数组列表,其包含的每一项有如{ "name": "<HTTP响应头的一个参数名>", "value": "对应这个参数的值" }的形式。

所以,我们只要过滤这个数组,检查其中每一项的name,看是不是等于"Content-Security-Policy"。如果不是,直接追加至最终结果responseObject.responseHeaders中。 否则,用一个{ "name": "Content-Security-Policy", "value": "...我们自己想要的CSP..." }代替,就可以了。

3. 检查并阻止不可靠的 HTTPS 连接

HTTPS 技术,是在互联网上实现安全通信的基础。

HTTPS 可以在浏览器和 ProtonMail 的服务器之间建立加密连接,通过密码算法保护数据的安全。 如果 HTTPS 出现问题,那么上述手段都无法避免数据的泄露。即使使用了可靠的脚本代码。

HTTPS 的主要问题其实都不是本插件应当考虑的。检查 HTTPS 连接是否安全,主要是浏览器自己的任务。 多数时候,如果连接本身不够安全,那么浏览器根本不会去试图加载数据,插件也更无法拦截到加载请求。

但是我们仍然需要提防一些情况:

A. 如果用户无意、或者在无知的情况下,无视/跳过了浏览器的HTTPS安全警告。

Firefox的安全警告是可以跳过的,甚至是在服务器或者HTTPS的攻击者发送了错误证书的情况下。 作为额外的安全措施,我们希望在这种情况下仍然可以阻止用户的访问。

B. 如果连接使用了错误的证书链进行认证。

Firefox评价一个HTTPS连接是否安全,主要的证据是查看服务器给出的证书是否可以被一系列证书颁发机构认证。 这些颁发机构的证书,有些是随同服务器自己的证书一起传入浏览器的,有些是所谓的“根证书”,预置于浏览器内,是被浏览器无条件信任的。

根证书通过密码算法签署认证下一级的证书颁发机构,然后这一机构签署下一级,直到最后认证服务器的证书,这个结构,就是所谓的“证书链”。 从证书链可以得知对服务器的信任是有逻辑依据的。

我们需要提防的情况是,一个不可信的根证书颁发者,或者其下级机构,向攻击者颁发了假的证书,而这一证书被用在连接中。 这种情况下,连接已经不再安全(因为假证书的持有人不是 ProtonMail),但因为证书链仍然可靠(至少在攻击的这一小段时间内),所以浏览器不会报错。

虽然说绝大多数证书颁发机构不会冒天下之大不韪做出这种事情(因为假证书一旦被发现,就成了证书颁发机构不可信的铁证),但是,管理不善本身也可以成为一个原因。 而在实际中,几年前的 StartCom 和 WoSign 失去浏览器的信任,其根证书被删除,也向我们指出验证证书信任链的重要性,显示出即使有如此大的经济风险,还是有证书颁发机构确实不可信。

3.1 使用白名单来认证 HTTPS 连接

2.1.1 中提到,用异步过程await securityInfo=browser.webRequest.getSecurityInfo(...),可以得到 HTTPS 连接的细节。

这个办法得到的securityInfo是一个Object,包含很多内容:

securityInfo.state是浏览器的意见,说明这个连接是否安全。实际操作中,这一项只能有两个值:"secure""weak",视乎安全连接所用的加密算法而定。 因为我们对 ProtonMail 的安全性要求很高,可以认为只要这一项不是"secure"就可以直接中断连接。

但是在出现了证书错误而用户仍然选择建立连接的情况下,securityInfo.state的值还是"secure"。这一项并不是说明连接是否可信的。 为了说明这一点,securityInfo.isUntrusted一项有一个布尔类型的结果,当其值为true时,连接不可信。如果检查到这一点,也可以直接中断连接。

前面谈到的证书链问题,因为 ProtonMail 不会经常更换证书,所以可以直接用已知的几个证书作为白名单。 只要证书链中见到了不在白名单中的证书指纹,就可以中断连接。

证书链在securityInfo.certificates中,是一个数组。每一项都具体记载了对应证书的信息,包括fingerprint下存在的指纹。 证书指纹会用多种算法给出,比如笔者见到的就有sha1sha256,建议检查后者,将其与白名单进行比对。

3.2 HTTPS 保护的缺点

值得说明的是,虽然我们可以在必要的时候中断HTTPS连接,但在保护用户信息方面,该方法还是存在一个问题。

因为securityInfo只能在onHeadersReceived处理过程中调用,而此时HTTPS连接已经建立,说明服务器已经向外发送了用户的一些信息,比如Cookies。 在这种时候中断不安全的连接,只能起到防止更多信息泄露的作用,但不是完全没有信息泄露出去。

如果用户一开始没有登录,那么这种泄露是无关紧要的。但是如果用户已经登录,而在刷新页面,或者HTTPS中间人攻击发生在登录之后的某个时候, 那么攻击者拿到用户的Cookies,还是可以进行相当程度的攻击。

笔者对此尚没有特别好的解决方案。可以设想的是一个“心跳”过程,通过主动访问服务器,查看是否出现 3.1 中列出的各种HTTPS问题。 因为HTTPS的加密特性,一个潜在的攻击者不可能得知这一测试访问而不被我们发现(不实施攻击,就无法得知我们的流量。实施攻击,我们通过 3.1 中的诸项检验可以立刻发现)。 如果出现,就通过一个“死锁”机制,在发出通信之前(browser.webRequest.onBeforeRequest处理过程)完全阻止未来的连接。 如何具体可靠地实现这一机制,还需要仔细研究。

4. 总结

本文介绍了NeoAtlantis应用科学和神秘学实验室开发的Firefox插件neutron的技术细节,包括锁定可信脚本、锁定内容安全策略,和锁定HTTPS证书链三个方面。