《凤凰架构》读书笔记    原书 --------- https://icyfenix.cn/

认证

对外部类库和服务的认证需求依然普遍,但相比起五花八门的最终用户认证来说,代码认证的研究发展方向已经很固定,基本上都统一到证书签名上。在本节中,认证的范围只限于对最终用户的认证,而代码认证会安排在“分布式的基石”中的“服务安全”去讲解。

在 1999 年,随 J2EE 1.2(它是 J2EE 的首个版本,为了与 J2SE 同步,初始版本号直接就是 1.2)发布的 Servlet 2.2 中,添加了一系列用于认证的 API,主要包括下列两部分内容:

  • 标准方面,添加了四种内置的、不可扩展的认证方案,即 Client-Cert、Basic、Digest 和 Form。
  • 实现方面,添加了与认证和授权相关的一套程序接口,譬如 HttpServletRequest::isUserInRole()HttpServletRequest::getUserPrincipal()等方法。

它内置的 Basic、Digest、Form 和 Client-Cert 这四种认证方案都很有代表性,刚好分别覆盖了通信信道、协议和内容层面的认证。而这三种层面认证恰好涵盖了主流的三种认证方式,具体含义和应用场景列举如下。

  • 通信信道上的认证:你和我建立通信连接之前,要先证明你是谁。在网络传输(Network)场景中的典型是基于 SSL/TLS 传输安全层的认证。
  • 通信协议上的认证:你请求获取我的资源之前,要先证明你是谁。在互联网(Internet)场景中的典型是基于 HTTP 协议的认证。
  • 通信内容上的认证:你使用我提供的服务之前,要先证明你是谁。在万维网(World Wide Web)场景中的典型是基于 Web 内容的认证。

关于通信信道上的认证,由于内容较多,又与后续介绍微服务安全方面的话题关系密切,将会独立放到本章的“传输”里,而且 J2EE 中的 Client-Cert 其实并不是用于 TLS 的,以它引出 TLS 并不合适。下面重点了解基于通信协议和通信内容的两种认证方式。

1、HTTP 认证

IETF 在RFC 7235中定义了 HTTP 协议的通用认证框架,要求所有支持 HTTP 协议的服务器,在未授权的用户意图访问服务端保护区域的资源时,应返回 401 Unauthorized 的状态码,同时应在响应报文头里附带以下两个分别代表 网页认证 和 代理认证 的 Header 之一,告知客户端应该采取何种方式产生能代表访问者身份的凭证信息:

WWW-Authenticate: <认证方案> realm=<保护区域的描述信息>
Proxy-Authenticate: <认证方案> realm=<保护区域的描述信息>

接收到该响应后,客户端必须遵循服务端指定的认证方案,在请求资源的报文头中加入身份凭证信息,由服务端核实通过后才会允许该请求正常返回,否则将返回 403 Forbidden 错误。请求头报文应包含以下 Header 项之一:

Authorization: <认证方案> <凭证内容>
Proxy-Authorization: <认证方案> <凭证内容>

HTTP 认证框架提出认证方案是希望能把认证“要产生身份凭证”的目的与“具体如何产生凭证”的实现分离开来

 

以最基础的认证方案——HTTP Basic 认证为例来介绍认证是如何工作的。HTTP Basic 认证是一种主要以演示为目的的认证方案,也应用于一些不要求安全性的场合,譬如家里的路由器登录等。

Basic 认证产生用户身份凭证的方法是让用户输入用户名和密码,经过 Base64 编码“加密”后作为身份凭证。譬如请求资源GET /admin后(不带认证),浏览器会收到来自服务端的如下响应说明认账方案

HTTP/1.1 401 Unauthorized
Date: Mon, 24 Feb 2020 16:50:53 GMT
WWW-Authenticate: Basic realm="example from icyfenix.cn"

此时,浏览器必须询问最终用户,即弹出类似图 5-2 所示的 HTTP Basic 认证对话框,要求提供用户名和密码。

 

 

用户在对话框中输入密码信息,譬如输入用户名icyfenix,密码123456,浏览器会将字符串 icyfenix:123456 编码为 aWN5ZmVuaXg6MTIzNDU2,然后发送给服务端,HTTP 请求如下所示:

GET /admin HTTP/1.1
Authorization: Basic aWN5ZmVuaXg6MTIzNDU2

服务端接收到请求,解码后检查用户名和密码是否合法,如果合法就返回/admin的资源,否则就返回 403 Forbidden 错误,禁止下一步操作。

注意 Base64 只是一种编码方式,并非任何形式的加密,所以 Basic 认证的风险是显而易见的。除 Basic 认证外,IETF 还定义了很多种可用于实际生产环境的认证方案,列举如下。

    • Digest:RFC 7616,HTTP 摘要认证,可视为 Basic 认证的改良版本,针对 Base64 明文发送的风险,Digest 认证把用户名和密码加盐(一个被称为 Nonce 的变化值作为盐值)后再通过 MD5/SHA 等哈希算法取摘要发送出去。但是这种认证方式依然是不安全的,无论客户端使用何种加密算法加密,无论是否采用了 Nonce 这样的动态盐值去抵御重放和冒认,遇到中间人攻击时依然存在显著的安全风险。关于加解密的问题,将在“保密”小节中详细讨论。
    • Bearer:RFC 6750,基于 OAuth 2 规范来完成认证,OAuth2 是一个同时涉及认证与授权的协议,在“授权”小节将详细介绍 OAuth 2。
    • HOBA:RFC 7486 ,HOBA(HTTP Origin-Bound Authentication)是一种基于自签名证书的认证方案。基于数字证书的信任关系主要有两类模型:一类是采用 CA(Certification Authority)层次结构的模型,由 CA 中心签发证书;另一种是以 IETF 的 Token Binding 协议为基础的 OBC(Origin Bound Certificate)自签名证书模型。在“传输”小节将详细介绍数字证书。

HTTP 认证框架中的认证方案是允许自行扩展的,并不要求一定由 RFC 规范来定义,只要用户代理(User Agent,通常是浏览器,泛指任何使用 HTTP 协议的程序)能够识别这种私有的认证方案即可。因此,很多厂商也扩展了自己的认证方案。

    • AWS4-HMAC-SHA256:亚马逊 AWS 基于 HMAC-SHA256 哈希算法的认证。
    • NTLM / Negotiate:这是微软公司 NT LAN Manager(NTLM)用到的两种认证方式。
    • Windows Live ID:微软开发并提供的“统一登入”认证。
    • Twitter Basic:一个不存在的网站所改良的 HTTP 基础认证。
    • ……

 

2、WEB 认证

  但目前的信息系统,尤其是在系统对终端用户的认证场景中,直接采用 HTTP 认证框架的比例其实十分低,这不难理解,HTTP 是“超文本传输协议”,传输协议的根本职责是把资源从服务端传输到客户端,至于资源具体是什么内容,只能由客户端自行解析驱动。以 HTTP 协议为基础的认证框架也只能面向传输协议而不是具体传输内容来设计,如果用户想要从服务器中下载文件,弹出一个 HTTP 服务器的对话框,让用户登录是可接受的;但如果用户访问信息系统中的具体服务,身份认证肯定希望是由系统本身的功能去完成的,而不是由 HTTP 服务器来负责认证。这种依靠内容而不是传输协议来实现的认证方式,在万维网里被称为“Web 认证”,由于实现形式上登录表单占了绝对的主流,因此通常也被称为“表单认证"(Form Authentication)。

  直至 2019 年以前,表单认证都没有什么行业标准可循,表单是什么样,其中的用户字段、密码字段、验证码字段是否要在客户端加密,采用何种方式加密,接受表单的服务地址是什么等,都完全由服务端与客户端的开发者自行协商决定。

  表单认证与 HTTP 认证不见得是完全对立的,两者有不同的关注点,可以结合使用。以 Fenix's Bookstore 的登录功能为例,页面表单是一个自行设计的 Vue.js 页面,但认证的整个交互过程遵循 OAuth 2 规范的密码模式。

  2019 年 3 月,万维网联盟(World Wide Web Consortium,W3C)批准了由FIDO(Fast IDentity Online,一个安全、开放、防钓鱼、无密码认证标准的联盟)领导起草的世界首份 Web 内容认证的标准“WebAuthn”(在这节里,我们只讨论 WebAuthn,不会涉及 CTAP、U2F 和 UAF)。

  软件方面,直至 iOS 13.6,iPhone 和 iPad 仍未支持 WebAuthn,但 Android 和 Mac OS 系统中的 Chrome,以及 Windows 的 Edge 浏览器都已经可以正常使用 WebAuthn 了。

 

 

WebAuthn 规范涵盖了“注册”与“认证”两大流程,先来介绍注册流程,它大致可以分为以下步骤:

    1. 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息均不包含在 WebAuthn 标准的定义范围内。
    2. 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
    3. 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),验证器可理解为用户设备上 TouchID、FaceID、实体密钥等认证设备的统一接口。
    4. 验证器提示用户进行验证,如果支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),由验证器存储私钥、用户信息以及当前的域名。然后使用私钥对 Challenge 进行签名,并将签名结果、UserID 和公钥一起返回客户端。
    5. 浏览器将验证器返回的结果转发给服务器。
    6. 服务器核验信息,检查 UserID 与之前发送的是否一致,并用公钥解密后得到的结果与之前发送的 Challenge 相比较一致即表明注册通过,由服务端存储该 UserID 对应的公钥。

 

登录流程与注册流程类似,如果你理解了注册流程,就很容易理解登录流程了。登录流程大致可以分为以下步骤:

    1. 用户访问登录页面,填入用户名后即可点击登录按钮。
    2. 服务器返回随机字符串 Challenge、用户 UserID。
    3. 浏览器将 Challenge 和 UserID 转发给验证器。
    4. 验证器提示用户进行认证操作。由于在注册阶段验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密 Challenge,然后返回给浏览器。
    5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。

WebAuthn 采用非对称加密的公钥、私钥替代传统的密码,这是非常理想的认证方案,私钥是保密的,只有验证器需要知道它,连用户本人都不需要知道,也就没有人为泄漏的可能;公钥是公开的,可以被任何人看到或存储。公钥可用于验证私钥生成的签名,但不能用来签名,除了得知私钥外,没有其他途径能够生成可被公钥验证为有效的签名,这样服务器就可以通过公钥是否能够解密来判断最终用户的身份是否合法。

WebAuthn 还一揽子地解决了传统密码在网络传输上的风险,在“保密”一节中我们会讲到无论密码是否客户端进行加密、如何加密,对防御中间人攻击来说都是没有意义的。更值得夸赞的是 WebAuthn 为登录过程带来极大的便捷性,不仅注册和验证的用户体验十分优秀,而且彻底避免了用户在一个网站上泄漏密码,所有使用相同密码的网站都受到攻击的问题,这个优点使得用户无须再为每个网站想不同的密码。

当前的 WebAuthn 还很年轻,普及率暂时还很有限,但笔者相信几年之内它必定会发展成 Web 认证的主流方式,被大多数网站和系统所支持。

 

3、认证的实现

在 21 世纪的第一个十年里,以“With EJB”为口号,以 WebSphere、Jboss 等为代表的 J2EE 容器环境,与以“Without EJB”为口号、以 Spring、Hibernate 等为代表的轻量化开发框架产生了激烈的竞争,结果是后者获得了全面胜利。这个结果使得依赖于容器安全的 JAAS 无法得到大多数人的认可。

在今时今日,实际活跃于 Java 安全领域的是两个私有的(私有的意思是不由 JSR 所规范的,即没有 java/javax.*作为包名的)的安全框架:Apache ShiroSpring Security

 

授权

  • 确保授权的过程可靠:如何既让第三方系统能够访问到所需的资源,又能保证其不泄露用户的敏感数据呢?常用的多方授权协议主要有 OAuth2 和 SAML 2.0(两个协议涵盖的功能并不是直接对等的)。
  • 确保授权的结果可控:授权的结果用于对程序功能或者资源的访问控制(Access Control),成理论体系的权限控制模型有很多,譬如自主访问控制(Discretionary Access Control,DAC)、强制访问控制(Mandatory Access Control,MAC)、基于属性的访问控制(Attribute-Based Access Control,ABAC),还有最为常用的基于角色的访问控制(Role-Based Access Control,RBAC)

由于篇幅原因,在这一节里我们只介绍 Fenix's Bookstore 的代码中直接使用到的,也是日常开发中最常用到的 RBAC 和 OAuth2 这两种访问控制和授权方案。

1、RBAC

  所有的访问控制模型,实质上都是在解决同一个问题:“谁(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)”。

  这个问题初看起来并不难,一种直观的解决方案就是在用户对象上设定一些权限,当用户使用资源时,检查是否有对应的操作权限即可。很多著名的安全框架,譬如 Spring Security 的访问控制本质上就是支持这么做的。不过,这种把权限直接关联在用户身上的简单设计,在复杂系统上确实会导致一些比较烦琐的问题。试想一下,如果某个系统涉及到成百上千的资源,又有成千上万的用户,一旦两者搅合到一起,要为每个用户访问每个资源都分配合适的权限,必定导致巨大的操作量和极高的出错概率,这也正是 RBAC 所关注的问题之一。

  RBAC 将权限从用户身上剥离,改为绑定到“角色”(Role)上,将权限控制变为对“角色拥有操作哪些资源的许可”这个逻辑表达式的值是否为真的求解过程。

 

  许可是抽象权限的具象化体现。角色为的是解耦用户与权限之间的多对多关系,而许可为的是解耦操作与资源之间的多对多关系。

  RBAC 还允许对不同角色之间定义关联与约束。如不同的角色之间可以有继承性,典型的是 RBAC-1 模型的角色权限继承关系。譬如描述开发经理应该和开发人员一样具有代码提交的权限,描述开发人员都应该和任何公司员工一样具有食堂就餐的权限,就可以直接将食堂就餐赋予公司员工的角色上,把代码提交赋予到开发人员的角色上,再让开发人员的角色从公司员工派生,开发经理的角色从开发人员中派生即可。

  不同角色之间也可以具有互斥性,典型的是 RBAC-2 模型的角色职责分离关系。互斥性要求权限被赋予角色时,或角色被赋予用户时应遵循的强制性职责分离规定。举个例子,角色的互斥约束可限制同一用户只能分配到一组互斥角色集合至多一个角色,譬如不能让同一名员工既当会计,也当出纳,否则资金安全无法保证。角色的基数约束可限制某一个用户拥有的最大角色数目,譬如不能让同一名员工从产品、设计、开发、测试全部包揽,否则产品质量无法保证。

  建立访问控制模型的基本目的是为了管理垂直权限和水平权限。

  垂直权限即功能权限,譬如前面提到的审稿编辑有通过审核的权限、开发经理有代码提交的权限、出纳有从账户提取资金的权限,这一类某个角色完成某项操作的许可,都可以直接翻译为功能权限。由于实际应用与权限模型具有高度对应关系,将权限从具体的应用中抽离出来,放到通用的模型中是相对容易的,Spring Security、Apache Shiro 等权限框架就是这样的抽象产物,大多数系统都能采用这些权限框架来管理功能权限。

  与此相对,水平权限即数据权限管理起来则要困难许多。譬如用户 A、B 都属于同一个角色,但它们各自在系统中产生的数据完全有可能是私有的,A 访问或删除了 B 的数据也照样属于越权。一般来说,数据权限是很难抽象与通用的,仅在角色层面控制并不能满足全部业务的需要,很多时候只能具体到用户,甚至要具体管理到发生数据的某一行、某一列之上,因此数据权限基本只能由信息系统自主来来完成,并不存在能放之四海皆准的通用数据权限框架

  本书后面章节中的“重要角色”Kubernetes 完全遵循了 RBAC 来进行服务访问控制,Fenix's Bookstore 所使用的 Spring Security 也参考了(但并没有完全遵循)RBAC 来设计它的访问控制功能。Spring Security 的设计里,用户和角色都可以拥有权限,访问控制模型如图 5-6 所示,可与前面 RBAC 的关系图对比一下。

 

  从实现角度来看,Spring Security 中的 Role 和 Authority 的差异很小,它们完全共享同一套存储结构,唯一的差别仅是 Role 会在存储时自动带上“ROLE_”前缀罢了。但从使用角度来看,Role 和 Authority 的差异可以很大,用户可以自行决定系统中到底 Permission 只能对应到角色身上,还是可以让用户也拥有某些角色中没有的权限。这一点不符合 RBAC 的思想,但笔者个人认同这是一种创新而非破坏,在 Spring Security 的文档上说的很清楚:这取决于你自己如何使用。

 

2、OAuth2

  在 RFC 6749 正文的第一句就阐明了 OAuth2 是面向于解决第三方应用(Third-Party Application)的认证授权协议。

  OAuth2 给出了多种解决办法,这些办法的共同特征是以令牌(Token)代替用户密码作为授权的凭证。

    • 有了令牌之后,哪怕令牌被泄漏,也不会导致密码的泄漏;
    • 令牌上可以设定访问资源的范围以及时效性;
    • 每个应用都持有独立的令牌,哪个失效都不会波及其他。

这样上面提出的三个问题就都解决了。有了一层令牌之后,整个授权的流程如图 5-7 所示。

 

  • 第三方应用(Third-Party Application):需要得到授权访问我资源的那个应用,即此场景中的“Travis-CI”。
  • 授权服务器(Authorization Server):能够根据我的意愿提供授权(授权之前肯定已经进行了必要的认证过程,但它与授权可以没有直接关系)的服务器,即此场景中的“GitHub”。
  • 资源服务器(Resource Server):能够提供第三方应用所需资源的服务器,它与认证服务可以是相同的服务器,也可以是不同的服务器,此场景中的“我的代码仓库”。
  • 资源所有者(Resource Owner): 拥有授权权限的人,即此场景中的“我”。
  • 操作代理(User Agent):指用户用来访问服务器的工具,对于人类用户来说,这个通常是指浏览器,但在微服务中一个服务经常会作为另一个服务的用户,此时指的可能就是 HttpClient、RPCClient 或者其他访问途径。

“用令牌代替密码”确实是解决问题的好方法,但这充其量只能算个思路,距离可实施的步骤还是不够具体的,时序图中的“要求/同意授权”、“要求/同意发放令牌”、“要求/同意开放资源”几个服务请求、响应该如何设计,这就是执行步骤的关键了。对此,OAuth2 一共提出了四种不同的授权方式(这也是 OAuth2 复杂烦琐的主要原因),分别为:

    • 授权码模式(Authorization Code)
    • 隐式授权模式(Implicit)
    • 密码模式(Resource Owner Password Credentials)
    • 客户端模式(Client Credentials)

 

(1) 授权码模式

  授权码模式是四种模式中最严(luō)谨(suō)的,它考虑到了几乎所有敏感信息泄漏的预防和后果。具体步骤的时序如图 5-8 所示。

 

开始进行授权过程以前,第三方应用先要到授权服务器上进行注册,所谓注册,是指向认证服务器提供一个域名地址,然后从授权服务器中获取 ClientID 和 ClientSecret。授权服务器核对授权码和 ClientSecret,确认无误后,向第三方应用授予令牌。令牌可以是一个或者两个,其中必定要有的是访问令牌(Access Token),可选的是刷新令牌(Refresh Token)。访问令牌用于到资源服务器获取资源,有效期较短,刷新令牌用于在访问令牌失效后重新获取,有效期较长

  这个过程设计,已经考虑到了几乎所有合理的意外情况,笔者再举几个最容易遇到的意外状况,以便你能够更好地理解为何要这样设计 OAuth2。

    • 会不会有其他应用冒充第三方应用骗取授权?
      ClientID 代表一个第三方应用的“用户名”,这项信息是可以完全公开的。但 ClientSecret 应当只有应用自己才知道,这个代表了第三方应用的“密码”。在第 5 步发放令牌时,调用者必须能够提供 ClientSecret 才能成功完成。只要第三方应用妥善保管好 ClientSecret,就没有人能够冒充它。
    • 为什么要先发放授权码,再用授权码换令牌?
      这是因为客户端转向(通常就是一次 HTTP 302 重定向)对于用户是可见的,换而言之,授权码可能会暴露给用户以及用户机器上的其他程序,但由于用户并没有 ClientSecret,光有授权码也是无法换取到令牌的,所以避免了令牌在传输转向过程中被泄漏的风险。
    • 为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗?
      这是为了缓解 OAuth2 在实际应用中的一个主要缺陷,通常访问令牌一旦发放,除非超过了令牌中的有效期,否则很难(需要付出较大代价)有其他方式让它失效,所以访问令牌的时效性一般设计的比较短,譬如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器就可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到下一节“凭证”中去解释。

尽管授权码模式是严谨的,但是它还不够好,这不仅仅体现在它那繁复的调用过程上,还体现在它对第三方应用提出了一个“貌似不难”的要求:第三方应用必须有应用服务器,因为第4步要发起服务端转向,而且要求服务端的地址必须与注册时提供的地址在同一个域内。不要觉得要求一个系统有应用服务器是理所当然的事情,本书的官方网站(https://icyfenix.cn)就没有任何应用服务器的支持,里面使用到了Gitalk作为每篇文章的留言板,它对GitHub来说照样是第三方应用,需要OAuth 2授权来解决。除基于浏览器的应用外,现在越来越普遍的是移动或桌面端的客户端Web应用(Client-Side Web Application),譬如现在大量基于Cordova、Electron、Node-Webkit.js的PWA应用,它们都没有应用服务器的支持。由于有这样的实际需求,因此引出了OAuth 2的第二种授权模式:隐式授权。

 

(2) 隐式授权模式

  隐式授权省略掉了通过授权码换取令牌的步骤,整个授权过程都不需要服务端支持,一步到位。代价是在隐式授权中,授权服务器不会再去验证第三方应用的身份,因为已经没有应用服务器了,ClientSecret 没有人保管,就没有存在的意义了。但其实还是会限制第三方应用的回调 URI 地址必须与注册时提供的域名一致,尽管有可能被 DNS 污染之类的攻击所攻破,但仍算是尽可能努力一下。同样的原因,也不能避免令牌暴露给资源所有者,不能避免用户机器上可能意图不轨的其他程序、HTTP 的中间人攻击等风险了。

 

 还有一点,在 RFC 6749 对隐式授权的描述中,特别强调了令牌必须是“通过 Fragment 带回”的。Fragment 就是地址中#号后面的部分,譬如这个地址:

http://bookstore.icyfenix.cn/#/detail/1

后面的/detail/1便是 Fragment,这个语法是在RFC 3986中定义的,RFC 3986 中解释了 Fragment 是用于客户端定位的 URI 从属资源,譬如 HTML 中就可以使用 Fragment 来做文档内的跳转而不会发起服务端请求,你现在可以点击一下这篇文章左边菜单中的几个子标题,看看浏览器地址栏的变化。此外,RFC 3986 还规定了如果浏览器对一个带有 Fragment 的地址发出 Ajax 请求,那 Fragment 是不会跟随请求被发送到服务端的,只能在客户端通过 Script 脚本来读取。所以隐式授权巧妙地利用这个特性,尽最大努力地避免了令牌从操作代理到第三方服务之间的链路存在被攻击而泄漏出去的可能性。至于认证服务器到操作代理之间的这一段链路的安全,则只能通过 TLS(即 HTTPS)来保证中间不会受到攻击了,我们可以要求认证服务器必须都是启用 HTTPS 的,但无法要求第三方应用同样都支持 HTTPS。

 

 

(3) 密码模式

前面所说的授权码模式和隐私模式属于纯粹的授权模式,它们与认证没有直接的联系,如何认证用户的真实身份是与进行授权互相独立的过程。但在密码模式里,认证和授权就被整合成了同一个过程了。

密码模式原本的设计意图是仅限于用户对第三方应用是高度可信任的场景中使用,因为用户需要把密码明文提供给第三方应用,第三方以此向授权服务器获取令牌。这种高度可信的第三方是极为较罕见的,尽管介绍 OAuth2 的材料中,经常举的例子是“操作系统作为第三方应用向授权服务器申请资源”,但真实应用中极少遇到这样的情况,合理性依然十分有限。

笔者认为,如果要采用密码模式,那“第三方”属性就必须弱化,把“第三方”视作是系统中与授权服务器相对独立的子模块,在物理上独立于授权服务器部署,但是在逻辑上与授权服务器仍同属一个系统,这样将认证和授权一并完成的密码模式才会有合理的应用场景。

密码模式下“如何保障安全”的职责无法由 OAuth2 来承担,只能由用户和第三方应用来自行保障,尽管 OAuth2 在规范中强调到“此模式下,第三方应用不得保存用户的密码”,但这并没有任何技术上的约束力。

 

 

(4) 客户端模式

客户端模式是四种模式中最简单的,它只涉及到两个主体,第三方应用和授权服务器。如果严谨一点,现在称“第三方应用”其实已经不合适了,因为已经没有了“第二方”的存在,资源所有者、操作代理在客户端模式中都是不必出现的。甚至严格来说叫“授权”都已不太恰当,资源所有者都没有了,也就不会有谁授予谁权限的过程。

客户端模式是指第三方应用(行文一致考虑,还是继续沿用这个称呼)以自己的名义,向授权服务器申请资源许可。此模式通常用于管理操作或者自动处理类型的场景中。举个具体例子,譬如笔者开了一家叫 Fenix's Bookstore 的书店,因为小本经营,不像京东那样全国多个仓库可以调货,因此必须保证只要客户成功购买,书店就必须有货可发,不允许超卖。但经常有顾客下了订单又拖着不付款,导致部分货物处于冻结状态。所以 Fenix's Bookstore 中有一个订单清理的定时服务,自动清理超过两分钟还未付款的订单。在这个场景里,订单肯定是属于下单用户自己的资源,如果把订单清理服务看作一个独立的第三方应用的话,它是不可能向下单用户去申请授权来删掉订单的,而应该直接以自己的名义向授权服务器申请一个能清理所有用户订单的授权。

 

  微服务架构并不提倡同一个系统的各服务间有默认的信任关系,所以服务之间调用也需要先进行认证授权,然后才能通信。此时,客户端模式便是一种常用的服务间认证授权的解决方案。

OAuth2 中还有一种与客户端模式类似的授权模式,在RFC 8628中定义为“设备码模式”(Device Code),这里顺带提一下。设备码模式用于在无输入的情况下区分设备是否被许可使用,典型的应用便是手机锁网解锁(锁网在国内较少,但在国外很常见)或者设备激活(譬如某游戏机注册到某个游戏平台)的过程。它的时序如图 5-12 所示。

 

 进行验证时,设备需要从授权服务器获取一个 URI 地址和一个用户码,然后需要用户手动或设备自动地到验证 URI 中输入用户码。在这个过程中,设备会一直循环,尝试去获取令牌,直到拿到令牌或者用户码过期为止。

 

 

凭证

“如何承载认证授权信息”这个问题的不同看法,代表了软件架构对待共享状态信息的两种不同思路:状态应该维护在服务端,抑或是在客户端之中?在分布式系统崛起以前,这个问题原本已是有了较为统一的结论的,以 HTTP 协议的 Cookie-Session 机制为代表的服务端状态存储在三十年来都是主流的解决方案。不过,到了最近十年,由于分布式系统中共享数据必然会受到 CAP 不兼容原理的打击限制,迫使人们重新去审视之前已基本放弃掉的客户端状态存储,这就让原本通常只在多方系统中采用的 JWT 令牌方案,在分布式系统中也有了另一块用武之地。本节的话题,也就围绕着 Cookie-Session 和 JWT 之间的相同与不同而展开。

1、Cookie-Session

HTTP 协议是一种无状态的传输协议,无状态是指协议对事务处理没有上下文的记忆能力,每一个请求都是完全独立的,

假如你做了一个简单的网页,其中包含了 1 个 HTML、2 个 Script 脚本、3 个 CSS、还有 10 张图片,这个网页成功展示在用户屏幕前,需要完成 16 次与服务端的交互来获取上述资源,按照可能出现的响应顺序,理论上最多会有 P(16,16) = 20,922,789,888,000 种可能性。试想一下,如果 HTTP 协议不是设计成无状态的,这 16 次请求每一个都有依赖关联,先调用哪一个、先返回哪一个,都会对结果产生影响的话,那协调工作会有多么复杂。

可是,HTTP 协议的无状态特性又有悖于我们最常见的网络应用场景,典型就是认证授权,系统总得要获知用户身份才能提供合适的服务,因此,我们也希望 HTTP 能有一种手段,让服务器至少有办法能够区分出发送请求的用户是谁。了实现这个目的,RFC 6265规范定义了 HTTP 的状态管理机制,在 HTTP 协议中增加了 Set-Cookie 指令。

Set-Cookie: id=icyfenix; Expires=Wed, 21 Feb 2020 07:28:00 GMT; Secure; HttpOnly

 收到该指令以后,客户端再对同一个域的请求中就会自动附带有键值对信息id=icyfenix,譬如以下代码所示:

GET /index.html HTTP/2.0
Host: icyfenix.cn
Cookie: id=icyfenix

根据每次请求传到服务端的 Cookie,服务器就能分辨出请求来自于哪一个用户。由于 Cookie 是放在请求头上的,属于额外的传输负担,不应该携带过多的内容,而且放在 Cookie 中传输也并不安全,容易被中间人窃取或被篡改,所以通常是不会像例子中设置id=icyfenix这样的明文信息。一般来说,系统会把状态信息保存在服务端,在 Cookie 里只传输的是一个无字面意义的、不重复的字符串,习惯上以sessionid或者jsessionid为名,服务器拿这个字符串为 Key,在内存中开辟一块空间,以 Key/Entity 的结构存储每一个在线用户的上下文状态,再辅以一些超时自动清理之类的管理措施。

Cookie-Session 方案在本章的主题“安全性”上其实是有一定先天优势的:状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。Cookie-Session 方案的另一大优点是服务端有主动的状态管理能力,可根据自己的意愿随时修改、清除任意上下文信息,譬如很轻易就能实现强制某用户下线的这样功能。

Session-Cookie 在单节点的单体服务环境中是最合适的方案,但当需要水平扩展服务能力,要部署集群时就开始面临麻烦了,由于 Session 存储在服务器的内存中,当服务器水平拓展成多节点时,设计者必须在以下三种方案中选择其一:

    • 牺牲集群的一致性(Consistency),让均衡器采用亲和式的负载均衡算法,譬如根据用户 IP 或者 Session 来分配节点,每一个特定用户发出的所有请求都一直被分配到其中某一个节点来提供服务,每个节点都不重复地保存着一部分用户的状态,如果这个节点崩溃了,里面的用户状态便完全丢失。
    • 牺牲集群的可用性(Availability),让各个节点之间采用复制式的 Session,每一个节点中的 Session 变动都会发送到组播地址的其他服务器上,这样某个节点崩溃了,不会中断都某个用户的服务,但 Session 之间组播复制的同步代价高昂,节点越多时,同步成本越高。
    • 牺牲集群的分区容忍性(Partition Tolerance),让普通的服务节点中不再保留状态,将上下文集中放在一个所有服务节点都能访问到的数据节点中进行存储。此时的矛盾是数据节点就成为了单点,一旦数据节点损坏或出现网络分区,整个集群都不再能提供服务。

通过前面章节的内容,我们已经知道只要在分布式系统中共享信息,CAP 就不可兼得,所以分布式环境中的状态管理一定会受到 CAP 的局限,无论怎样都不可能完美。但如果只是解决分布式下的认证授权问题,并顺带解决少量状态的问题,就不一定只能依靠共享信息去实现。这句话的言外之意是提醒读者,接下来的 JWT 令牌与 Cookie-Session 并不是完全对等的解决方案,它只用来处理认证授权问题,充其量能携带少量非敏感的信息,只是 Cookie-Session 在认证授权问题上的替代品,而不能说 JWT 要比 Cookie-Session 更加先进,更不可能全面取代 Cookie-Session 机制。

2、JWT

当服务器存在多个,客户端只有一个时,把状态信息存储在客户端,每次随着请求发回服务器去。笔者才说过这样做的缺点是无法携带大量信息,而且有泄漏和篡改的安全风险。信息量受限的问题并没有太好的解决办法,但是要确保信息不被中间人篡改则还是可以实现的,JWT 便是这个问题的标准答案。

JWT(JSON Web Token)定义于RFC 7519标准之中,是目前广泛使用的一种令牌格式,尤其经常与 OAuth2 配合应用于分布式的、涉及多方的应用系统中。

 

以上截图来自 JWT 官网(https://jwt.io),数据则是笔者随意编的。右边的 JSON 结构是 JWT 令牌中携带的信息,左边的字符串呈现了 JWT 令牌的本体。它最常见的使用方式是附在名为 Authorization 的 Header 发送给服务端,前缀在RFC 6750中被规定为 Bearer。如果你没有忘记“认证方案”与“OAuth 2”的内容,那看到 Authorization 这个 Header 与 Bearer 这个前缀时,便应意识到它是 HTTP 认证框架中的 OAuth 2 认证方案。

GET /restful/products/1 HTTP/1.1
Host: icyfenix.cn
Connection: keep-alive
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJpY3lmZW5peCIsInNjb3BlIjpbIkFMTCJdLCJleHAiOjE1ODQ5NDg5NDcsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiIsIlJPTEVfQURNSU4iXSwianRpIjoiOWQ3NzU4NmEtM2Y0Zi00Y2JiLTk5MjQtZmUyZjc3ZGZhMzNkIiwiY2xpZW50X2lkIjoiYm9va3N0b3JlX2Zyb250ZW5kIiwidXNlcm5hbWUiOiJpY3lmZW5peCJ9.539WMzbjv63wBtx4ytYYw_Fo1ECG_9vsgAn8bheflL8

图 5-13 中右边的状态信息是对令牌使用 Base64URL 转码后得到的明文,请特别注意是明文,JWT 只解决防篡改的问题,并不解决防泄漏的问题,因此令牌默认是不加密的。尽管你自己要加密也并不难做到,接收时自行解密即可,但这样做其实没有太大意义,原因笔者将在下一节“保密”中去解释。 

从明文中可以看到 JWT 令牌是以 JSON 结构(毕竟名字就叫 JSON Web Token)存储的,结构总体上可划分为三个部分,每个部分间用点号.分隔开:

  • (1)令牌头

它描述了令牌的类型(统一为 typ:JWT)以及令牌签名(令牌的第3部分签名是加密的,JWT 令牌全部本身不是,只 BASE64 编码)的算法。

示例中 HS256 为 HMAC SHA256 算法的缩写,其他各种系统支持的签名算法可以参考https://jwt.io/网站所列。

SHA256 https://blog.csdn.net/m0_69916115/article/details/126390653 它就是一个哈希函数。对于任意长度的消息,SHA256都会产生一个256bit长度的散列值,称为消息摘要,可以用一个长度为64的十六进制字符串表示。

HMAC 哈希与普通哈希算法的差别是普通的哈希算法通过 Hash 函数结果易变性保证了原有内容未被篡改,HMAC 不仅保证了内容未被篡改过,还保证了该哈希确实是由密钥的持有人所生成的。如图 5-14 所示。

 

 

 

  • (2)负载

这是令牌真正需要向服务端传递的信息

针对认证问题,负载至少应该包含能够告知服务端“这个用户是谁”的信息;针对授权问题,令牌至少应该包含能够告知服务端“这个用户拥有什么角色/权限”的信息。

JWT 的负载部分是可以完全自定义的,根据具体要解决的问题不同,设计自己所需要的信息,只是总容量不能太大,毕竟要受到 HTTP Header 大小的限制。

而 JWT 在 RFC 7519 中推荐(非强制约束)了七项声明名称(Claim Name),如有需要用到这些内容,建议字段名与官方的保持一致:

      • iss(Issuer):签发人。
      • exp(Expiration Time):令牌过期时间。
      • sub(Subject):主题。
      • aud (Audience):令牌受众。
      • nbf (Not Before):令牌生效时间。
      • iat (Issued At):令牌签发时间。
      • jti (JWT ID):令牌编号。
  • (3)签名

使用在对象头中公开的特定签名算法,通过特定的密钥(Secret,由服务器进行保密,不能公开)对前面两部分内容进行加密计算。

以例子里使用的 JWT 默认的 HMAC SHA256 算法(一种带密钥的哈希摘要算法)为例,将通过以下公式产生签名值:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

签名的意义在于确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。因为 被签名的内容哪怕发生了一个字节的变动,也会导致整个签名发生显著变化(sha 散列特性)。此外,由于签名这件事情只能由认证授权服务器完成(只有它知道 Secret,签名后把令牌发给客户端,客户端保存),任何人都无法在篡改后重新计算出合法的签名值,所以服务端才能够完全信任客户端传上来的 JWT 中的负载信息。 

JWT 默认的签名算法 HMAC SHA256 是一种带密钥的哈希摘要算法,加密与验证过程均只能由中心化的授权服务来提供,所以这种方式一般只适合于授权服务与应用服务处于同一个进程中的单体应用。在多方系统或者授权服务与资源服务分离的分布式应用中,通常会采用非对称加密算法来进行签名,这时候除了授权服务端持有的可以用于签名的私钥外,还会对其他服务器公开一个公钥,公开方式一般遵循JSON Web Key 规范。公钥不能用来签名,但是能被其他服务用于验证签名是否由私钥所签发的。这样其他服务器也能不依赖授权服务器、无须远程通信即可独立判断 JWT 令牌中的信息的真伪。 

 

 

JWT 令牌是多方系统中一种优秀的凭证载体,它不需要任何一个服务节点保留任何一点状态信息,就能够保障认证服务与用户之间的承诺是双方当时真实意图的体现,是准确、完整、不可篡改、且不可抵赖的。同时,由于 JWT 本身可以携带少量信息,这十分有利于 RESTful API 的设计,能够较容易地做成无状态服务,在做水平扩展时就不需要像前面 Cookie-Session 方案那样考虑如何部署的问题。但是,JWT 也并非没有缺点的完美方案,它存在着以下几个经常被提及的缺点:

  • 令牌难以主动失效:JWT 令牌一旦签发,理论上就和认证服务器再没有什么瓜葛了,在到期之前就会始终有效,除非服务器部署额外的逻辑去处理失效问题,这对某些管理功能的实现是很不利的。譬如一种颇为常见的需求是:要求一个用户只能在一台设备上登录,在 B 设备登录后,之前已经登录过的 A 设备就应该自动退出。如果采用 JWT,就必须设计一个“黑名单”的额外的逻辑,用来把要主动失效的令牌集中存储起来,而无论这个黑名单是实现在 Session、Redis 或者数据库中,都会让服务退化成有状态服务,降低了 JWT 本身的价值,但黑名单在使用 JWT 时依然是很常见的做法,需要维护的黑名单一般是很小的状态量,许多场景中还是有存在价值的。
  • 相对更容易遭受重放攻击:首先说明 Cookie-Session 也是有重放攻击问题的,只是因为 Session 中的数据控制在服务端手上,应对重放攻击会相对主动一些。要在 JWT 层面解决重放攻击需要付出比较大的代价,无论是加入全局序列号(HTTPS 协议的思路)、Nonce 字符串(HTTP Digest 验证的思路)、挑战应答码(当下网银动态令牌的思路)、还是缩短令牌有效期强制频繁刷新令牌,在真正应用起来时都很麻烦。真要处理重放攻击,建议的解决方案是在信道层次(譬如启用 HTTPS)上解决,而不提倡在服务层次(譬如在令牌或接口其他参数上增加额外逻辑)上解决。
  • 只能携带相当有限的数据:HTTP 协议并没有强制约束 Header 的最大长度,但是,各种服务器、浏览器都会有自己的约束,譬如 Tomcat 就要求 Header 最大不超过 8KB,而在 Nginx 中则默认为 4KB,因此在令牌中存储过多的数据不仅耗费传输带宽,还有额外的出错风险。
  • 必须考虑令牌在客户端如何存储:严谨地说,这个并不是 JWT 的问题而是系统设计的问题。如果授权之后,操作完关掉浏览器就结束了,那把令牌放到内存里面,压根不考虑持久化才是最理想的方案。但并不是谁都能忍受一个网站关闭之后下次就一定强制要重新登录的。这样的话,想想客户端该把令牌存放到哪里?Cookie?localStorage?Indexed DB?它们都有泄漏的可能,而令牌一旦泄漏,别人就可以冒充用户的身份做任何事情。
  • 无状态也不总是好的:这个其实不也是 JWT 的问题。如果不能想像无状态会有什么不好的话,我给你提个需求:请基于无状态 JWT 的方案,做一个在线用户实时统计功能。兄弟,难搞哦。

 

密码存储和验证

这节笔者以 Fenix's Bookstore 中的真实代码为例,介绍对一个普通安全强度的信息系统,密码如何从客户端传输到服务端,然后存储进数据库的全过程。“普通安全强度”是指在具有一定保密安全性的同时,避免消耗过多的运算资源,验证起来也相对便捷。

1、先介绍密码创建的过程:

客户端

1、用户在客户端注册,输入明文密码:123456

password = 123456

2、客户端对用户密码进行简单哈希摘要,可选的算法有 MD2/4/5、SHA1/256/512、BCrypt、PBKDF1/2,等等。为了突出“简单”的哈希摘要,这里笔者故意没有排除掉 MD 系这些已经有了高效碰撞手段的算法。

client_hash = MD5(password)  // e10adc3949ba59abbe56e057f20f883e

3、为了防御彩虹表攻击应加盐处理,客户端加盐只取固定的字符串即可,如实在不安心,最多用伪动态的盐值(“伪动态”是指服务端不需要额外通信可以得到的信息,譬如由日期或用户名等自然变化的内容,加上固定字符串构成)。

client_hash = MD5(MD5(password) + salt)  // SALT = $2a$10$o5L.dWYEjZjaejOmN3x4Qu 

 假设攻击者截获了客户端发出的信息,得到了摘要结果和采用的盐值(上式的中间表达式 MD5(password) + salt ),那攻击者就可以枚举遍历所有 8 位字符以内(“8 位”只是举个例子,反正就是指弱密码,你如果拿 1024 位随机字符当密码用,加不加盐,彩虹表都跟你没什么关系)的弱密码,然后对每个密码再加盐计算,就得到一个针对固定盐值的对照彩虹表为了应对这种暴力破解,并不提倡在盐值上做动态化,更理想的方式是引入慢哈希函数来解决。

4、慢哈希函数是指这个函数执行时间是可以调节的哈希函数通常是以控制调用次数来实现的。BCrypt 算法就是一种典型的慢哈希函数,它做哈希计算时接受盐值 Salt 和执行成本 Cost 两个参数(代码层面 Cost 一般是混入在 Salt 中,譬如上面例子中的 Salt 就是混入了 10 轮运算的盐值,10 轮的意思是 210次哈希,Cost 参数是放在指数上的,最大取值就 31)。如果我们控制 BCrypt 的执行时间大概是 0.1 秒完成一次哈希计算的话算完所有的 10 位大小写字母和数字组成的弱密码大概需要P(62,10)*0.1)/(3600×24×365) = 1,237,204,169 年时间。(破解不可能短时间内完成)

client_hash = BCrypt(MD5(password) + salt)   // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2

 

服务端

5、只需防御被拖库后针对固定盐值的批量彩虹表攻击。具体做法是服务端为每一个密码(指客户端传来的哈希值)产生一个随机的盐值。笔者建议采用“密码学安全伪随机数生成器”(Cryptographically Secure Pseudo-Random Number Generator,CSPRNG)来生成一个长度与哈希值长度相等的随机字符串。对于 Java 语言,从 Java SE 7 起提供了java.security.SecureRandom类,用于支持 CSPRNG 字符串生成。

SecureRandom random = new SecureRandom();
byte server_salt[] = new byte[36];
random.nextBytes(server_salt);   // tq2pdxrblkbgp8vt8kbdpmzdh1w8bex

6、将动态盐值混入客户端传来的哈希值再做一次哈希,产生出最终的密文,并和上一步随机生成的盐值一起写入到同一条数据库记录中。由于慢哈希算法占用大量处理器资源,笔者并不推荐在服务端中采用。

server_hash = SHA256(client_hash + server_salt);  // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67
DB.save(server_hash, server_salt);

 

以上加密存储的过程相对复杂,但是运算压力最大的过程(慢哈希)是在客户端完成的,对服务端压力很小,也不惧怕因网络通信被截获而导致明文密码泄漏。

 

2、密码存储后,验证的过程

客户端

1、客户端,用户在登录页面中输入密码明文:123456,经过与注册相同的加密过程,向服务端传输加密后的结果。

authentication_hash = BCrypt(MD5(password) + salt)   // MFfTW3uNI4eqhwDkG7HP9p2mzEUu/r2

 

服务端

2、服务端,接受到客户端传输上来的哈希值,从数据库中取出登录用户对应的密文和盐值,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果。

result = SHA256(authentication_hash + server_salt);  // 55b4b5815c216cf80599990e781cd8974a1e384d49fbde7776d096e1dd436f67

3、比较上一步的结果和数据库储存的哈希值是否相同,如果相同那么密码正确,反之密码错误。

authentication = compare(result, server_hash) // yes

 

 

传输

1、摘要、加密与签名 

我们从 JWT 令牌的一小段“题外话”来引出现代密码学算法的三种主要用途:摘要、加密与签名。JWT 令牌携带信息的可信度源自于它是被签名过的信息,因此是不可篡改的,是令牌签发者真实意图的体现。然而,你是否了解过签名具体做了什么?为什么有签名就能够让负载中的信息变得不可篡改和不可抵赖呢?要解释数字签名(Digital Signature),必须先从密码学算法的另外两种基础应用“摘要”和“加密”说起。

摘要

也称之为数字摘要(Digital Digest)或数字指纹(Digital Fingerprint)。JWT 令牌中默认的签名信息是对令牌头、负载和密钥三者通过令牌头中指定的哈希算法(HMAC SHA256)计算出来的摘要值,如下所示:

signature = SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload) , secret)

理想的哈希算法都具备两个特性:

一是易变性,这是指算法的输入端发生了任何一点细微变动,都会引发雪崩效应(Avalanche Effect),使得输出端的结果产生极大的变化。这个特性常被用来做校验,保护信息未被篡改,譬如互联网上下载大文件,常会附有一个哈希校验码,以确保下载下来的文件没有因网络或其他原因与原文件产生任何偏差。

二是不可逆性,摘要的过程是单向的,不可能从摘要的结果中逆向还原出输入值来。这点只要具备初中数学知识就能想明白,世间的信息有无穷多种,而摘要的结果无论其位数是 32、128、512 Bits,再大也总归是个有限的数字,因此输入数据与输出的摘要结果必然不是一一对应的关系,如果我把一部电影做摘要形成 256 Bits 的哈希值,应该没有人会指望能从这个哈希值中还原出一部电影的。偶尔能听到 MD5、SHA1 或其他哈希算法被破解了的新闻,这里的“破解”并不是“解密”的意思,而是指找到了该算法的高效率碰撞方法,能够在合理的时间内生成两个摘要结果相同的输入比特流,但并不能指定这两个输入流中的某一个,更不代表碰撞产生的比特流就会是原来的输入源。

 

由这两个特点可见,摘要的意义是在源信息不泄漏的前提下辨别其真伪

易变性保证了从公开的特征上可以甄别出是否来自于源信息,不可逆性保证了从公开的特征并不会暴露出源信息;

这与今天用做身份甄别的指纹、面容和虹膜的生物特征是具有高度可比性的。

在一些场合中,摘要也会被借用来做加密(如保密中介绍的慢哈希 Bcrypt 算法)和签名(如 JWT 签名中的 HMAC SHA256 算法),但在严格意义上看,摘要与这两者是有本质的区别。

 

加密

  • 经典密码学时代,加密的安全主要是依靠机密性来保证的,即依靠保护加密算法或算法的执行参数不被泄漏来保障信息的安全。
  • 现代密码学不依靠机密性,加解密算法都是完全公开的,安全建立在特定问题的计算复杂度之上,具体是指算法根据输入端计算输出结果耗费的算力资源很小,但根据输出端的结果反过来推算原本的输入,耗费的算力就极其庞大。

  一个经常在课堂中用来说明计算复杂度的例子是大数的质因数分解,我们可以轻而易举的地(以 O(nlogn)的复杂度)计算出两个大素数的乘积,譬如:

97667323933 * 128764321253 = 12576066674829627448049

根据算术基本定理,质因数的分解形式是唯一的,且前面计算条件中给出的运算因子已经是质数,所以 12,576,066,674,829,627,448,049 的分解形式就只有唯一的形式,即上面所示的唯一答案。然而如何对大数进行质因数分解,迄今没有找到多项式时间的算法,甚至无法确切地知道这个问题属于哪个复杂度类(Complexity Class)。所以尽管这个过程理论上一定是可逆的,但实际上算力差异决定了逆过程无法实现。

 

对称加密:加密 与 解密共用 一个密匙

对称加密的缺点显而易见,加密和解密使用相同的密钥,当通信的成员数量增加时,为保证两两通信都都采用独立的密钥,密钥数量就与成员数量的平方成正比,这必然面临密钥管理的难题。而更尴尬的难题是当通信双方原本就不存在安全的信道时,如何才能将一个只能让通信双方才能知道的密钥传输给对方?如果有通道可以安全地传输密钥,那为何不使用现有的通道传输信息?这个“蛋鸡悖论曾在很长的时间里严重阻碍了密码学在真实世界中推广应用。

 

非对称加密:公钥加密,私钥解密加密,双方各持有一个私钥) 或 私钥加密,公钥解密签名,让所有公钥所有者验证私钥所有者的身份)

单靠非对称加密算法,既做不了加密也做不了签名。原因是不论是加密还是解密,非对称加密算法的计算复杂度都相当高,性能比对称加密要差上好几个数量级(不是好几倍)。加解密性能不仅影响速度,还导致了现行的非对称加密算法都没有支持分组加密模式。分组是指由于明文长度与密钥长度在安全上具有相关性,通俗地说就是多长的密钥决定了它能加密多长的明文,如果明文太短就需要进行填充,太长就需要进行分组。因非对称加密本身的效率所限,难以支持分组,所以主流的非对称加密算法都只能加密不超过密钥长度的数据,这决定了非对称加密不能直接用于大量数据的加密。

 

 

加密方面,现在一般会结合对称与非对称加密的优点,以混合加密来保护信道安全,具体做法是用(其实就是用来传递对称加密的密钥)非对称加密来安全地传递少量数据给通信的另一方,然后再以这些数据为密钥,采用对称加密来安全高效地大量加密传输数据,这种由多种加密算法组合的应用形式被称为“密码学套件”。非对称加密在这个场景中发挥的作用称为“密钥协商”。

 

签名方面,现在一般会结合摘要与非对称加密的优点,以摘要结果做加密的形式来保证签名的适用性。由于对任何长度的输入源做摘要之后都能得到固定长度的结果,所以只要对摘要的结果进行签名,即相当于对整个输入源进行了背书,保证一旦内容遭到篡改,摘要结果就会变化,签名也就马上失效了。

 

表 5-1 汇总了前面提到的三种算法,并列举了它们的主要特征、用途和局限性。

类型 特点 常见实现 主要用途 主要局限
哈希摘要 不可逆,即不能解密,所以并不是加密算法,只是一些场景把它当作加密算法使用。
易变性,输入发生 1 Bit 变动,就可能导致输出结果 50%的内容发生改变。
无论输入长度多少,输出长度固定(2 的 N 次幂)。
MD2/4/5/6、SHA0/1/256/512 摘要 无法解密
对称加密 加密是指加密和解密是一样的密钥。
设计难度相对较小,执行速度相对较块。
加密明文长度不受限制。
DES、AES、RC4、IDEA 加密 要解决如何把密钥安全地传递给解密者。
非对称加密 加密和解密使用的是不同的密钥。
明文长度不能超过公钥长度。
RSA、BCDSA、ElGamal

签名(先摘要缩短长度)

传递(对称加密的)密钥

性能与加密明文长度受限。

 

  这个场景里,数字签名的安全性仍存在一个致命的漏洞:公钥虽然是公开的,但在网络世界里“公开”具体是一种什么操作?如何保证每一个获取公钥的服务,拿到的公钥就是授权服务器所希望它拿到的?

  在网络传输是不可信任的前提下,公钥在网络传输过程中可能已经被篡改,如果获取公钥的网络请求被攻击者截获并篡改,返回了攻击者自己的公钥,那以后攻击者就可以用自己的私钥来签名,让资源服务器无条件信任它的所有行为了。现实世界中公开公钥,可以通过打电话、发邮件、短信息、登报纸、同时发布在多个网站上等等,很多网络通信之外的途径来达成,但在程序与网络的世界中,就必须找到一种可信任的公开方法,而且这种方法不能依赖加密来实现,否则又将陷入蛋鸡问题之中。

 

2、数字证书

目前标准的保证公钥可信分发的标准,这个标准有一个名字:公开密钥基础设施

密码学上,公开密钥基础建设借着数字证书认证中心(Certificate Authority,CA)将用户的个人身份跟公开密钥链接在一起。对每个证书中心用户的身份必须是唯一的。链接关系通过注册和发布过程创建,根据担保级别的差异,创建过程可由 CA 的各种软件或者在人为监督下完成。PKI 的确定链接关系的这一角色称为注册管理中心(Registration Authority,RA)。RA 确保公开密钥和个人身份链接,可以防抵赖。

CA 作为受信任的第三方,承担公钥体系中公钥的合法性检验的责任。

而网络世界,在假设所有网络传输都有可能被截获冒认的前提下,“去 CA 中心进行认证”本身也是一种网络操作,这与之前的“去获取公钥”本质上不是没什么差别吗?

其实还是有差别的,世间公钥成千上万不可枚举,而权威的 CA 中心则应是可数的,“可数”意味着可以不通过网络,而是浏览器操作系统出厂时就预置好,或者提前安装好(如银行的证书)

由于客户的机器上已经预置了这些权威 CA 中心本身的证书(称为 CA 证书或者根证书),使得我们能够在不依靠网络的前提下,使用根证书里面的公钥信息对其所签发的证书中的签名进行确认。到此,终于打破了鸡生蛋、蛋生鸡的循环,使得整套数字签名体系有了坚实的逻辑基础。

PKI 中采用的证书格式是X.509 标准格式它定义了证书中应该包含哪些信息,并描述了这些信息是如何编码的,里面最关键的就是认证机构的数字签名和公钥信息两项内容。一个数字证书具体包含以下内容:

1. 版本号(Version):指出该证书使用了哪种版本的 X.509 标准(版本 1、版本 2 或是版本 3),版本号会影响证书中的一些特定信息,目前的版本为 3。

Version: 3 (0x2)  

   2. 序列号(Serial Number): 由证书颁发者分配的本证书的唯一标识符

Serial Number: 04:00:00:00:00:01:15:4b:5a:c3:94 

 3.  签名算法标识符(Signature Algorithm ID):用于签发证书的算法标识,由对象标识符加上相关的参数组成,用于说明本证书所用的数字签名算法。譬如,SHA1 和 RSA 的 对象标识符就用来说明该数字签名是利用 RSA 对 SHA1 的摘要结果进行加密。

Signature Algorithm: sha1WithRSAEncryption

         4. 认证机构的数字签名(Certificate Signature):这是使用证书发布者私钥生成的签名,以确保这个证书在发放之后没有被篡改过。

    5. 认证机构(Issuer Name): 证书颁发者的可识别名。

Issuer: C=BE, O=GlobalSign nv-sa, CN=GlobalSign Organization Validation CA - SHA256 - G2

  6. 有效期限(Validity Period): 证书起始日期和时间以及终止日期和时间,指明证书在这两个时间内有效。   

Validity
	Not Before: Nov 21 08:00:00 2020 GMT
	Not After : Nov 22 07:59:59 2021 GMT

  7. 主题信息(Subject):证书持有人唯一的标识符(Distinguished Name),这个名字在整个互联网上应该是唯一的,通常使用的是网站的域名。 

Subject: C=CN, ST=GuangDong, L=Zhuhai, O=Awosome-Fenix, CN=*.icyfenix.cn

  8. 公钥信息(Public-Key): 包括证书持有人公钥、算法(指明密钥属于哪种密码系统)的标识符和其他相关的密钥参数。

 

如何验证证书有效性:

由 PKI 体系 的内容可知,对端发来的证书签名是 CA 私钥加密的,接收到证书后,先读取证书中的相关的明文信息,采用相同的散列函数计算得到信息摘要,然后利用对应 CA 的公钥解密签名数据,对比证书的信息摘要如果一致,则可以确认证书的合法性。私钥加密,公钥解密签名,让所有 公钥所有者--浏览器 验证 私钥所有者--CA证书 的身份))

浏览器并非只拿到了一个证书,而是一个证书链。如下图,就拿到了一共三个证书 

 

 

信任链:中间 CA 对它签发的末端证书用私钥生成签名,并将公钥给末端证书。根 CA 对中间 CA 用私钥生成签名,并将公钥给中间 CA。

 

   此外还要校验:

    • 证书是否吊销 revocation,有两类方式 - 离线 CRL 与在线 OCSP,不同的客户端行为会不同;
    • 有效期 expiry date,证书是否在有效时间范围;
    • 域名 domain,核查证书域名是否与当前的访问域名匹配;

 

3、传输安全层 

如何把这套烦琐的技术体系自动化地应用于无处不在的网络通信之中,便是本节的主题。在计算机科学里,隔离复杂性的最有效手段(没有之一)就是分层,最合理的做法就是在传输层之上、应用层之下  (TCP 和 HTTP 之间) 加入专门的安全层来实现,这样对上层原本基于 HTTP 的 Web 应用来说,影响甚至是无法察觉的。构建传输安全层这个想法,几乎可以说是和万维网的历史一样长,早在 1994 年,就已经有公司开始着手去实践了:

    • 2008 年,TLS 1.1 发布 2 年之后,TLS 1.2 标准发布(RFC 5246),迄今超过 90%的互联网 HTTPS 流量是由 TLS 1.2 所支持的,现在仍在使用的浏览器几乎都完美支持了该协议。
    • 2018 年,最新的 TLS 1.3(RFC 8446)发布,比起前面版本相对温和的升级,TLS 1.3 做了出了一些激烈的改动,修改了从 1.0 起一直没有大变化的两轮四次(2-RTT)握手,首次连接仅需一轮(1-RTT)握手即可完成,在有连接复用支持时,甚至将 TLS 1.2 原本的 1-RTT 下降到了 0-RTT,显著提升了访问速度。

接下来,笔者以 TLS 1.2 为例,介绍传输安全层是如何保障所有信息都是第三方无法窃听(加密传输)、无法篡改(一旦篡改通信算法会立刻发现)、无法冒充(证书验证身份)的。TLS 1.2 在传输之前的握手过程一共需要进行上下两轮、共计四次通信,时序如图 5-16 所示。

 

 

 

Client Hello 第一次通信  Client ----------> Server

(1) Client Hello

  • Version: 客户端支持的最佳协议版本。1.0 至 3.0 分别代表 SSL1.0 至 3.0,1.0 是 SSL1.0 ,2.0 是 SSL3.0。TLS1.0 则是 3.1,一直到 TLS1.3 的 3.4。
  • Random: 一个 32 字节数据,28 字节是随机生成的 (图中的 Random Bytes);剩余的 4 字节包含额外的信息,与客户端时钟有关 (图中使用的是 GMT Unix Time)。在握手时,客户端和服务器都会提供随机数,客户端的暂记作 random_C (用于后续的密钥的生成)。这种随机性对每次握手都是独一无二的,在身份验证中起着举足轻重的作用。它可以防止重放攻击,并确认初始数据交换的完整性。
  • Session ID: 指传输安全层的 Session 。在第一次连接时,会话 ID(session ID)字段是空的,这表示客户端并不希望恢复某个已存在的会话。典型的会话 ID 包含 32 字节随机生成的数据,一般由服务端生成通过 ServerHello 返回给客户端。
  • Cipher Suites: 密码套件(cipher suite)块是由客户端支持的所有密码套件组成的列表,该列表是按优先级顺序排列的
  • Compression: 客户端可以提交一个或多个支持压缩的方法。默认的压缩方法是 null,代表没有压缩。默认情况下 TLS 压缩都是关闭的,因为CRIME攻击会利用 TLS 压缩恢复加密认证 cookie,实现会话劫持,而且一般配置 gzip 等内容压缩后再压缩 TLS 分片效益不大又额外占用资源,所以一般都关闭 TLS 压缩。
  • Extensions: 扩展(extension)块由任意数量的扩展组成。这些扩展会携带额外数据

 

Server Hello 第二次通信 Server ----------> Client

(2) Server Hello

   这个消息的结构与 ClientHello 类似,只是每个字段只包含一个选项(代表服务端在客户端提供的可选选项中选择的哪一个),其中包含服务端的 random_S 参数 (用于后续的密钥协商)

  Version:服务器无需支持客户端支持的最佳版本。如果服务器不支持与客户端相同的版本,可以提供某个其他版本以期待客户端能够接受。

Cipher Suite:客户端选择的密码学套件。例如这里的 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 表示密码协商算法使用 ECDHE,非对称加密 传递密钥 使用 RSA,握手后的通信采用对称加密 AES,密钥长度 256,分组模式 GCM,摘要算法使用 SHA384

(3) Certificate

   

   典型的 Certificate 消息用于携带服务器 X.509证书链。 服务器必须保证它发送的证书与选择的算法套件一致。比方说,公钥算法与套件中使用的必须匹配。

Certificate 消息是可选的,因为并非所有套件都使用身份验证,也并非所有身份验证方法都需要证书。更进一步说,虽然消息默认使用 X.509 证书,但是也可以携带其他形式的标志;一些套件就依赖PCG密钥。

(4) [ServerKeyExchange]

对于使用 DHE/ECDHE 非对称密钥协商算法  的SSL握手,将发送该类型握手。

RSA算法不会进行该握手流程(DH、ECDH也不会发送server key exchange)。

详见 https://blog.csdn.net/mrpre/article/details/77867831

(4) ServerHelloDone

表明服务器已经将所有预计的握手消息发送完毕。在此之后,服务器会等待客户端发送消息。

 

  Client Handshake finished 第三次通信 Client ----------> Server

  在这之前要 verify certificate 验证证书有效性通过

  见上面👆

(5) ClientKeyExchange

图中使用的是 ECDHE 算法,ClientKeyExchange 传递的是 DH 算法的客户端参数,如果使用的是 RSA 算法则此处应该传递加密的预主密钥。

客户端计算产生随机数字的预主密钥(Pre-master)并用证书公钥加密发送给服务器。(之前的 random_C 和 random_S 是明文发并携带客户端为密钥交换提供的所有信息。这个消息受协商的密码套件的影响,内容随着不同的协商密码套件而不同。

Pre-master 和 之前交换的两个明文随机数 random_C 和 random_S。通过一个伪随机函数(pseudorandom function,PRF),这个函数可以生成任意数量的伪随机数据。计算得到协商密钥,即为后续内容传输时的对称加密算法所采用的密钥

enc_key = PRF(Pre_master, "master secret", random_C + random_S)

  (6) [ChangeCipherSpec]

通知服务器后续的通信都采用协商的通信密钥和加密算法进行加密通信。

(7) Finished

这个消息包含 verify_data 字段,它的值包含握手过程中所有消息的散列值。这些消息在连接两端都按照各自所见的顺序排列,并以协商得到的主密钥 (enc_key) 计算散列。两端的计算方法一致,但会使用不同的标签(finished_label):客户端使用 client finished,而服务器则使用 server finished。

verify_data = PRF(master_secret, finished_label, Hash(handshake_messages))

  

 

Server Handshake finished 第三次通信 Server ----------> Client

   在开始前

   a. 用得到的客户端发来的 Pre_master ,用私钥解密,加上之前交换的 random_C 和 random_S,用和客户端同样的方法生成协商密钥   

enc_key = PRF(Pre_master, "master secret", random_C + random_S)  

   b. 和客户端同样计算之前所有收发信息的 hash 值,用协商密钥解密客户端发来的 verify_data_c,对比以验证正确性

(6) [ChangeCipherSpec]

服务端验证通过之后,服务器同样发送 change_cipher_spec 以告知客户端后续的通信都采用协商的密钥与算法进行加密通信

(7) Finished

    和客户端的 finished 一样,也结合所有当前的通信参数信息生成一段数据 (verify_data_S) 并采用协商密钥 enc_key 与算法加密并发送给客户端验证 

verify_data = PRF(master_secret, finished_label, Hash(handshake_messages))

  

最后客户端用协商密钥解密 verify_data_S,验证服务器发送的数据和密钥,验证通过则握手完成;开始使用协商密钥与算法进行加密通信。