注意
本文档适用于 Ceph 的开发版本。
Cephx 认证协议的详细说明
Peter Reiher 2012年7月13日
本文档详细介绍了 Cephx 授权协议,其高级流程已在 Yehuda 的备忘录(2009年12月19日)中描述。由于本备忘录讨论了所调用的例程和所使用的变量的细节,因此它代表了一个快照。代码可能会在此文档创建后进行更改,并且文档不太可能同步更新。幸运的是,代码注释将指示协议实现方式的重大更改。
简介
该协议的基本思想基于 Kerberos。客户端希望从服务器获取一些东西。服务器只会向授权客户端提供所请求的服务。系统不要求每个服务器都处理身份验证和授权问题,而是使用授权服务器。因此,客户端必须首先与授权服务器通信以进行身份验证并获取凭据,这些凭据将授予它访问所需服务的权限。
授权与身份验证不同。身份验证提供证据证明某个方是其声称的身份。授权提供证据证明特定方被允许执行某项操作。通常,安全授权意味着安全身份验证(因为没有身份验证,您可能会为冒名顶替者授权),但反之则不一定成立。可以在不授权的情况下进行身份验证。本协议的目的是授权。
基本方法是全程使用对称加密。每个客户端 C 都有自己的秘密密钥,只有它自己和授权服务器 A 知道。每个服务器 S 都有自己的秘密密钥,只有它自己和授权服务器 A 知道。授权信息将通过票据传递,使用提供服务的实体的秘密密钥进行加密。A 会给 C 一个票据,允许 C 向 A 索要其他票据。此票据将使用 A 的密钥加密,因为 A 需要检查它。稍后 A 会签发票据,允许 C 与 S 通信以请求服务。这些票据将使用 S 的密钥加密,因为 S 需要检查它们。由于我们希望提供通信安全,因此会与票据一起设置会话密钥。目前,这些会话密钥仅在此协议和客户端 C 与服务器 S 之间的握手期间用于身份验证目的,即当客户端提供其服务票据时。通过对系统进行一些更改,它们可以全程用于身份验证或保密性。
如果本协议要实现其预期的安全效果,则各方需要相互证明一些事情。
1. 客户端 C 必须向认证器 A 证明它确实是 C。由于所有操作都是通过消息完成的,因此客户端还必须证明证明真实性的消息是新鲜的,并且没有被攻击者重放。
2. 认证器 A 必须向客户端 C 证明它确实是认证器。同样,还需要证明没有发生重放。
3. A 和 C 必须安全地共享一个会话密钥,用于它们之间后续授权材料的分发。同样,不允许重放,并且该密钥必须只有 A 和 C 知道。
4. A 必须从 C 接收证据,允许 A 查找 C 在服务器 S 上的授权操作。
5. C 必须从 A 接收一个票据,该票据将向 S 证明 C 可以执行其授权操作。此票据只能由 C 使用。
6. C 必须从 A 接收一个会话密钥,以保护 C 和 S 之间的通信。会话密钥必须是新鲜的,而不是重放的结果。
阶段 I:
客户端设置为知道它需要某些东西,使用一个名为 need 的变量,它是 AuthClientHandler 类的一部分,而 CephxClientHandler 继承自该类。此时,need 变量中编码的一件事是 CEPH_ENTITY_TYPE_AUTH,表明我们需要从头开始认证协议。由于我们总是与同一个授权服务器通信,如果之前我们已经完成了协议的这一步骤(并且由此产生的票据/会话尚未超时),我们可以跳过此步骤,直接请求客户端票据。但它必须最初完成,我们假设我们处于该状态。
C 在阶段 I 发送给 A 的消息是在 CephxClientHandler::build_request()(在 auth/cephx/CephxClientHandler.cc 中)中构建的。此例程用于多种目的。在这种情况下,我们首先调用 validate_tickets()(来自 auth/cephx/CephxProtocol.h 中的例程 CephXTicketManager::validate_tickets())。此代码遍历可能的票据列表,以确定我们需要什么,并根据需要在 need 标志中设置值。然后我们调用 ticket.get_handler()。此例程(在 CephxProtocol.h 中)在票据映射中查找指定类型(用于执行授权的票据)的票据,为其创建一个票据处理程序对象,并将该处理程序放入映射中的正确位置。然后我们遇到专门的代码来处理个别情况。这里的情况是当我们仍然需要向 A 进行身份验证时(if (need & CEPH_ENTITY_TYPE_AUTH) 分支)。
我们现在创建类型为 CEPHX_GET_AUTH_SESSION_KEY 的消息。我们需要使用 C 的秘密密钥来验证此消息,因此我们从本地密钥存储库中获取该密钥。我们创建一个随机挑战,其目的是防止重放。我们使用 cephx_calc_client_server_challenge() 加密该挑战。我们已经从 pre-cephx 阶段得到了一个服务器挑战(一组类似的随机字节,但由服务器创建并发送给客户端)。我们将这两个挑战和我们的秘密密钥组合起来,生成一个加密的挑战值,该值进入 req.key。
如果我们有旧票据,我们将其存储在 req.old_ticket 中。我们即将获得一个新票据。
整个 req 结构,包括旧票据和两个挑战的加密哈希,都被放入消息中。然后我们从这个函数返回,消息被发送出去。
我们现在切换到认证器端 A。服务器接收到发送的消息,类型为 CEPH_GET_AUTH_SESSION_KEY。该消息在 mon/AuthMonitor.cc 中的 prep_auth() 中处理,该例程调用 CephxServiceHandler.cc 中的 handle_request() 来完成大部分工作。此例程也处理多种情况。
控制流由与消息关联的 cephx_header 中的 request_type 确定。这里我们的情况是 CEPH_GET_AUTH_SESSION_KEY。我们需要 A 与 C 共享的秘密密钥,因此我们从本地密钥存储库中调用 get_secret() 来获取它。(在代码中它被称为 key_server,但它实际上不是一个独立的机器或处理实体。它更像是保存本地使用密钥的地方。)我们应该已经与此客户端设置了服务器挑战,所以我们确保我们确实有一个。(此变量特定于 CephxServiceHandler,因此我们创建的每个此类结构都有一个不同的变量,大概每个 A 正在处理的客户端有一个。)如果没有挑战,我们将需要重新开始,因为我们需要检查客户端的加密哈希,它部分依赖于服务器挑战。
我们现在调用客户端用于计算哈希的相同例程,基于相同的值:客户端挑战(在传入消息中)、服务器挑战(我们保存的)和客户端密钥(我们刚刚获得的)。我们检查客户端是否发送了我们期望的相同内容。如果是,我们知道我们正在与正确的客户端通信。我们知道会话是新鲜的,因为它使用了我们发送给它的挑战来计算其加密哈希。所以我们可以给它一个身份验证票据。
我们获取 C 的 eauth 结构。它包含一个 ID、一个密钥和一组功能(capabilities)。
如果客户端有旧票据,它会将其在消息中发送给我们。如果是,我们将标志 should_enc_ticket 设置为 true,并将全局 ID 设置为旧票据中的全局 ID。如果解码旧票据失败(很可能是因为它没有),should_enc_ticket 仍为 false。现在我们设置新票据,填充时间戳、C 的名称和方法调用中提供的全局 ID(除非有旧票据)。我们需要一个新的会话密钥来帮助客户端与我们安全通信,而不是使用其永久密钥。我们将服务 ID 设置为 CEPH_ENTITY_TYPE_AUTH,这将告诉客户端 C 如何处理我们发送给它的消息。我们构建一个 cephx 响应头并调用 cephx_build_service_ticket_reply()。
cephx_build_service_ticket_reply() 位于 auth/cephx/CephxProtocol.cc 中。此例程将构建响应消息。大部分内容将其参数中的数据复制到消息结构中。其中一部分信息(会话密钥和有效期)使用 C 的永久密钥进行加密。如果设置了 should_encrypt_ticket 标志,则使用旧票据的密钥对其进行加密。否则,没有旧票据密钥,因此新票据未加密。(当然,它已经用 A 的永久密钥加密。)推测第二次加密的目的是减少用永久密钥加密的材料的暴露。
然后我们对实体名称调用密钥服务器的 get_service_caps() 例程,使用标志 CEPH_ENTITY_TYPE_MON 和功能,该例程将填充这些功能。使用该常量标志意味着我们将获取客户端用于 A 的功能,而不是用于其他数据服务器的功能。这里的票据是访问授权器 A,而不是服务 S。此调用的结果是 caps 变量(我们正在使用的例程的参数)填充了监视器功能,这些功能将允许 C 访问 A 的授权服务。
handle_request() 本身不发送响应消息。它构建 result_bl,它基本上包含该消息的内容和功能结构,但不发送消息。为此,我们回到 mon/AuthMonitor.cc 中的 prep_auth()。此例程对刚刚填充的 caps 结构进行了一些修改。由于此修改,会生成一个全局 ID,并将其放入回复消息中。回复消息在此处构建(主要来自 response_bl 缓冲区)并发送出去。
这完成了协议的第一阶段。此时,C 已向 A 进行了身份验证,A 已生成新的会话密钥和票据,允许 C 从 A 获取服务器票据。
阶段 II
此阶段从 C 收到 A 包含新票据和会话密钥的消息时开始。此阶段的目标是向 C 提供一个会话密钥和票据,允许它与 S 通信。
A 发送给 C 的消息被分派到 CephxClientHandler.cc 中的 build_request(),这是第一阶段早期用于构建协议中第一条消息的相同例程。这次,当调用 validate_tickets() 时,need 变量将不包含 CEPH_ENTITY_TYPE_AUTH,因此将使用例程主体中的不同分支。这是由 if (need) 指示的分支。我们有授权器的票据,但我们仍然需要服务票据。
我们必须向 A 发送另一条消息,以获取服务器 S 的票据(和会话密钥)。我们将消息的 request_type 设置为 CEPHX_GET_PRINCIPAL_SESSION_KEY,并调用 ticket_handler.build_authorizer() 以获取授权器。此例程位于 CephxProtocol.cc 中。我们将此授权器的密钥设置为我们刚从 A 获得的会话密钥,并创建一个新的 nonce。我们将全局 ID、服务 ID 和票据放入授权器的一部分消息缓冲区中。然后我们创建一个新的 CephXAuthorize 结构。我们刚刚创建的 nonce 将进入其中。我们使用当前会话密钥加密此 CephXAuthorize 结构,并将其放入授权器的缓冲区中。我们返回授权器。
回到 build_request() 中,我们取出刚刚构建的授权器的一部分(它的缓冲区,而不是会话密钥或任何其他东西),并将其推入我们正在为将发送到 A 的消息创建的缓冲区中。然后我们删除授权器。我们将我们想要的需求放入 req.keys 中,然后将 req 放入缓冲区。然后我们返回,消息被发送出去。
授权器 A 收到此消息,类型为 CEPHX_GET_PRINCIPAL_SESSION_KEY。该消息在 mon/AuthMonitor.cc 中的 prep_auth() 中处理,该例程再次调用 CephxServiceHandler.cc 中的 handle_request() 来完成大部分工作。
在这种情况下,handle_request() 将处理 CEPHX_GET_PRINCIPAL_SESSION_KEY 情况。它将调用 CephxProtocol.cc 中的 cephx_verify_authorizer()。在这里,我们将从输入缓冲区中获取大量数据,包括全局和服务 ID 以及 A 的票据。该票据包含一个 secret_id,指示正在使用哪个密钥。如果从票据中提取的 secret ID 为 -1,则票据未指定 A 应该使用哪个秘密密钥。在这种情况下,A 应该使用 C 想要联系的特定实体的密钥,而不是由所有相同类型的服务器实体共享的轮换密钥。为了获取该密钥,A 必须查阅密钥存储库以找到正确的密钥。否则,已经有一个从密钥存储库获取的结构来保存必要的秘密。服务器秘密以时间过期为基础轮换(此文档不涉及密钥轮换),因此遍历该结构以找到其当前秘密。无论哪种方式,A 现在都知道用于创建此票据的秘密密钥。现在使用此密钥解密票据的加密部分。它应该是一个用于 A 的票据。
该票据还包含一个会话密钥,C 应该使用它来加密此消息的其他部分。使用该会话密钥解密消息的其余部分。
创建一个 CephXAuthorizeReply 来保存我们的回复。提取 nonce(它在我们刚刚解密的内容中),将其加 1,然后将结果放入回复中。加密回复并将其放入在调用 cephx_verify_authorizer() 时提供的缓冲区中,然后返回到 handle_request()。这将用于向 C 证明是 A(而不是攻击者)创建了此响应。
验证消息有效且来自 C 后,现在我们需要为 S 构建一个票据。我们需要知道它想要与哪个 S 通信以及它想要哪些服务。从其消息中取出描述这些内容的票据请求。现在遍历票据请求以查看它想要什么。(他可能在同一请求中要求多个不同的服务,但为了本次讨论,我们假设只有一个。)一旦我们知道它要哪个服务 ID,就调用 build_session_auth_info()。
build_session_auth_info() 位于 CephxKeyServer.cc 中。它检查 S 的 service_ID 的秘密是否可用,并将其放入其中一个参数的子字段中,并调用位于同一文件中的同名 _build_session_auth_info()。此例程使用 S 的 ID、票据和该票据的一些时间戳加载新的 auth_info 结构。它生成一个新的会话密钥并将其放入结构中。然后它调用 get_caps() 来填充 info.ticket caps 字段。get_caps() 也位于 CephxKeyServer.cc 中。它用允许 C 访问 S 的功能填充提供给它的 caps_info 结构。
一旦 build_session_auth_info() 返回,A 就拥有了允许 C 访问 S 的功能列表。我们将基于当前 TTL 的有效期放入 info 结构中,并将其放入我们正在准备响应消息的 info_vec 结构中。
现在调用 build_cephx_response_header(),它也在 CephxServiceHandler.cc 中。填充 request_type,即 CEPHX_GET_PRINCIPAL_SESSION_KEY,状态为 0,以及结果缓冲区。
现在调用 cephx_build_service_ticket_reply(),它位于 CephxProtocol.cc 中。在第一阶段 A 处理其响应的末尾使用了相同的例程。在这里,会话密钥(现在是与 S 通信的会话密钥,而不是与 A 通信的会话密钥)和该密钥的有效期将使用 C 和 A 之间共享的现有会话密钥进行加密。这里的 should_encrypt_ticket 参数为 false,并且未提供用于该加密的密钥。有问题的票据,一旦 C 将其发送到 S,将已经用 S 的秘密密钥加密。因此,本质上,此例程将把 ID 信息、加密的会话密钥和允许 C 与 S 通信的票据放入要发送给 C 的缓冲区中。
此例程返回后,我们退出 handle_request(),返回到 prep_auth(),最终返回到底层消息发送代码。
客户端收到此消息。当消息通过 msg/SimpleMessager.cc 中的 Pipe::connect() 时,nonce 会被检查。在此例程中间的一个冗长的 while(1) 循环中,它获取一个授权器。如果获取成功,最终它会调用 verify_reply(),它会检查 nonce。connect() 从不明确检查它是否获得了授权器,这表明未能提供授权器将允许攻击者跳过 nonce 的检查。但是,在许多地方,如果没有授权器,重要的连接字段将设置为零,这最终将导致连接无法提供数据。这值得测试,但看起来未能提供包含 nonce 的授权器对攻击者没有帮助。
该消息最终通过 CephxClientHandler.cc 中的 handle_response()。在此例程中,我们调用 get_handler() 来获取一个票据处理程序,以保存我们刚刚收到的票据。此例程嵌入在 CephXTicketManager 结构的定义中。它接受一个类型(本例中为 CEPH_ENTITY_TYPE_AUTH),并在 tickets_map 中查找该类型。应该有一个,并且其条目中应该包含 C 和 A 之间会话的会话密钥。此密钥将用于解密 A 提供的信息,特别是允许 C 与 S 通信的新会话密钥。
然后我们调用 CephxProtocol.cc 中的 verify_service_ticket_reply()。此例程需要确定票据是否正常,并获取与此票据关联的会话密钥。它使用与 A 共享的会话密钥解密消息缓冲区的加密部分。此票据未加密(好吧,不是两次加密——票据总是加密的,但有时是双重加密的,而这个不是)。因此它可以直接存储在服务票据缓冲区中。我们现在从该缓冲区中取出票据。
我们用 C 和 A 之间共享的会话密钥解密的内容包括新的会话密钥。那是我们此票据的当前会话密钥,因此将其设置。检查有效性并设置过期时间。如果走到这一步,则返回 true。
回到 handle_response() 中,我们现在调用 validate_tickets() 来调整我们认为需要的东西,因为我们现在拥有了以前没有的票据。如果我们已经处理了所有需要的东西,我们将返回 0。
这结束了协议的第二阶段。我们现在已成功为客户端 C 设置了与服务器 S 通信的票据和会话密钥。S 将知道 C 是它声称的身份,因为 A 会验证它。C 将知道它正在与 S 通信,同样因为 A 验证了它。C 和 S 之间通信的会话密钥的唯一副本分别在 C 和 S 的永久密钥下加密发送,因此没有其他方(除了所有人都信任的 A)知道该会话密钥。票据将安全地向 S 指示 C 被允许做什么,并由 A 作证。A 和 C 之间来回传递的 nonce 确保它们没有遭受重放攻击。C 尚未真正与 S 通信,但它已准备好。
如果其中一个永久密钥被泄露,这里的许多安全性都会崩溃。C 的密钥被泄露意味着攻击者可以冒充 C 并获得 C 的所有权限,并且可以窃听 C 的合法对话。他还可以假装是 A,但仅限于与 C 的对话。由于它(根据假设)没有任何服务的密钥,因此他无法为服务生成任何新票据,尽管他可以重放旧票据和会话密钥,直到 S 的永久密钥更改或旧票据超时。
S 的密钥被泄露意味着攻击者可以冒充 S 接触任何人,并可以窃听任何用户与 S 的对话。除非某个客户端的密钥也泄露,否则攻击者无法为 S 生成新的虚假客户端票据,因为这样做需要他使用他不知道的客户端密钥将自己认证为 A。