前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >QUIC 0-RTT实现简析及一种分布式的0-RTT实现方案

QUIC 0-RTT实现简析及一种分布式的0-RTT实现方案

原创
作者头像
glendai
修改2020-03-05 14:18:59
7.5K8
修改2020-03-05 14:18:59
举报
文章被收录于专栏:网络加速网络加速

现如今,高速且安全的网络接入服务已经成为人们的必须。传统TCP+TLS构建的安全互联服务,升级与补丁更新时有提出(如TCP Fastopen,新的TLS 1.3),但是由于基础设施僵化,升级与应用困难。为解决这个问题,Google另辟蹊径在UDP的基础上实现了带加密的更好的TCP--QUIC(Quick UDP Internet Connection), 一种基于UDP的低时延的互联网传输层协议。近期成立了Working Group也将QUIC作为制定HTTP 3.0的标准的基础, 说明QUIC的应用前景美好。本文单独就网络传输的建连问题展开了分析, 浅析了建连时间对传输的影响, 以及QUIC的0-RTT建连是如何解决建连耗时长的问题的。在此基础上,结合QUIC的源码, 浅析了QUIC的基本实现, 并描述一种可供参考的分布式环境下的0-RTT的落地实践方案。

1. 建连时间之殇

以一次简单的HTTPS请求(example.com)为例(假设访问example.com时返回的内容较小,server端可以在一个数据包里返回响应),为获取请求资源。需要经过以下4个步骤:

  1. DNS查询example.com获取IP。DNS服务一般默认是由你的ISP提供,ISP 通常都会有缓存的,这部分时间能适当减少;
  2. TCP握手,我们熟悉的TCP三次握手需要需要1个RTT;
  3. TLS握手,以目前应用最广泛的TLS 1.2而言,需要2个RTT。对于非首次建连,可以选择启用会话重用(Session Resumption),则可缩小握手时间到1个RTT;
  4. HTTP业务数据交互,假设example.com的数据在一次交互就能取回来。那么业务数据的交互需要1个RTT; 经过上面的过程分析可知,要完成一次简短的HTTPS业务数据交互,需要经历:
  • 新连接:4RTT + DNS。
  • 会话重用:3RTT + DNS

尤其对于小数据量的交互而言,抛开DNS查询时间, 建连时间占剩下的总时间的2/3至3/4不等,影响不可小觑。加之如果用户网络不好,RTT延时大的话,建连时间可能耗费数百毫秒至数秒不等,这将极大的影响用户体验。究其原因一方面是TCP和TLS分层设计导致的:分层的设计需要每个逻辑层次分别建立自己的连接状态。另一方面是TLS的握手阶段复杂的密钥协商机制导致的。要降低建连耗时,需要从这两方面着手。

针对TLS的握手阶段复杂的密钥协商机问题, TLS 1.3精简了握手交互过程,实现了1-RTT握手。在会话重用类似的理念的基础上,对非首次握手会话, 可以进一步实现0-RTT握手(在刚开始TLS密钥协商的时候,就能附送一部分经过加密的数据传递给对方)。由于TLS是建立在TCP之上的, 0-RTT没有计算TCP层的握手开销,因而对用户来说,发送数据之前还是要经历TCP层的1RTT握手, 因而不是真正的0-RTT握手。

TLS 1.3 为实现0-RTT,需要双方在刚开始建立连接的时候就已经持有一个对称密钥,这个密钥在TLS 1.3中称为PSK(Pre-Shared-Key)。PSK是TLS 1.2中的会话重用(Session Resumption)机制的一个升级,TLS 1.3握手结束后,服务器可以发送一个NST(New-Session-Ticket)的报文给客户端,该报文中记录PSK的值、名字和有效期等信息,双方下一次建立连接可以使用该PSK值作为初始密钥材料。因为PSK是从以前建立的安全信道中获得的,只要证明了双方都持有相同的PSK,不再需要证书认证,就可以证明双方的身份(因此PSK也是一种身份认证机制)。TLS 1.3新加了Early Data类型的报文, 用于在0-RTT的握手阶段传递应用层数据,实现了握手的同时就能附带加密的应用层数据从而实现0-RTT。

TLS 1.3的0-RTT特性不能防止重放攻击,需要业务在使用时评估是否有重放攻击风险。如有相关风险的话,可能需要酌情考虑禁用0-RTT特性。

下图对比了TLS各版本与场景下的延时对比:

TLS各版本与场景下的耗时
TLS各版本与场景下的耗时

从对比我们可以看到, 即使用上了TLS 1.3,精简了握手过程, 最快能做到0-RTT握手(首次是1-RTT),但是对用户感知而言, 还要加上1RTT的TCP握手开销。 Google有提出Fastopen的方案来使得TCP非首次握手就能附带用户数据, 但是由于TCP实现僵化, 无法升级应用, 相关RFC到现今都是experimental状态。这种分层设计带来的延时,有没有办法进一步降低呢? QUIC通过合并加密与连接管理解决了这个问题,我们来看看其是如何实现真正意义上的0-RTT的握手, 让与server进行第一个数据包的交互就能带上用户数据。

2. QUIC“真”0-RTT握手

QUIC为规避TCP协议僵化的问题,将QUIC协议建立在了UDP之上。考虑到安全性是网络的必备选项,加密在QUIC里强制的。传输方面参考TCP并充分优化了TCP多年发现的缺陷和不足, 实现了一套端到端的可靠加密传输。通过将加密和连接管理两层合二为一,消除了当前TCP+TLS的分层设计传输引入的时延。

QUIC协议与TCP+TLS对比
QUIC协议与TCP+TLS对比

同TLS的握手一样, QUIC的加密握手的核心在于协商出一个加密会话数据的对称密钥。QUIC的握手使用了DH密钥协商算法来协商一个对称密钥。DH密钥协商算法简单来讲, 需要通信双方各自生成自己的非对称公私钥对,双发各自保留自己的私钥,将公钥发给对方,利用对方的公钥和自己的私钥可以运算出同一个对称密钥。详细的原理这里不展开叙述,有专业的密码学书籍对其原理有详细的论述,网上也有很多好的教程对其由深入浅出的总结, 如这一篇

如上所述, DH密钥协商需要通行双方各自生成自己的非对称公私钥对。server端与客户端的关系是1对N的关系,明显server端生成一份公私钥对, 让N个客户端公用, 能明显减少生成开销, 降低管理的成本。server端的这份公私钥对就是专门用于握手使用的, 客户端一经获取,就可以缓存下来后续建连时继续使用, 这个就是达成0-RTT握手的关键, 因此server生成的这份公钥称为0-RTT握手公钥。真正的握手过程是这样(简化了实现细节):

  1. server端在握手开始前,server端需要首先生成(或加载使用上次保存下来的)握手公私钥对, 该份公私钥对是所有客户端共享的。
  2. client端首次握手时, client对server一无所知,需要1个RTT来询问server端的握手公钥(实际的握手交互还会发送诸如版本等其他数据)并缓存下来。本步骤只在首次建连时发生(0-RTT握手公钥的过期也会导致需要重走这一步),但这种情况很少发生,影响很小(也没办法避免)。
  3. client收到server端返回这份握手公钥后,生成自己的临时公私钥对后,计算出共享的对称密钥后,加密好数据,并连同client的公钥一并发给server端。照DH密钥协商的原理,此处已经可以协商出每条会话不一样的会话密钥了(因为每个client生成的公私钥是不同的), 是不是拿这个来加密会话数据就行了呢?真实的情况不是这样的!
  4. server端会再次生成一份临时的公私钥对,使用这份临时的私钥与客户端的公钥运算出最终的会话对称密钥。接下来server会拿这个最终的会话密钥加密应用层数据, 连同这份临时的server端公钥一并发给client端, client端收到后可以按照DH的原理依瓢画葫会恢复出最终的会话对称密钥。后续所有的数据都是用最终的会话对称密钥进行加密。server侧这个动作是不是多此一举呢? 不是的, 这么做的目的是为了获取所谓的前向安全特性: 因为server端的后面生成的这份公私钥是临时生成的,不会保存下来,也就杜绝了密钥泄漏导致会话数据被恶意收集后的被解密掉的风险。

首次握手要多一个RTT询问server端的0-RTT握手公钥,此询问过程不携带任何应用层数据, 因此是1-RTT握手。在首次握手完成后, client端可以缓存下第一次询问获知的server端的公钥信息, 后续的连接过程可以跳过询问,直接使用缓存的server端的公钥。公钥信息在QUIC的实现里是保存在握手数据包里的SCFG中的(Server Config), 获取0-RTT握手公钥就是要获取SCFG,后续均以获取SCFG代替。详细的握手过程,可以参见dog250博客文章的详细叙述。

从上面的分析可知,0-RTT握手的首次交互,server端使用的是保存下来的握手密钥,因而没有无法做到前向安全,不能防止重放攻击,需要业务在使用时评估是否有重放攻击风险。

QUIC 0-RTT握手
QUIC 0-RTT握手

3. QUIC 0-RTT实现简述

上文简述了QUIC握手的密钥协商过程,首次需要询问一次Server以获取SCFG,从而获取存储于其中的0-RTT握手公钥。这一交互过程中client和server在QUIC的握手协议发送的报文中分别叫Inchoate Client hello message (inchoate CHLO)和Rejection message (REJ)。因而包含server端的握手公钥的SCFG是在REJ报文中发给客户端的。有了SCFG,接下来就能发起0-RTT握手。这一过程中client和server端在QUIC的握手协议发送的报文中分别叫Full Client Hello message (full CHLO)和Server Hello message (SHLO)。下图摘自Google QUIC握手协议的官方文档,详细叙述了握手过程client侧的处理流程:

QUIC握手客户端侧处理流程
QUIC握手客户端侧处理流程

实现0-RTT的关键,在于搞清楚作为0-RTT核心的SCFG在Sever侧的和client侧的流转,不稳定的的分布式环境下如何保证服务器异常重启下SCFG的持久不丢失,以及server侧集群中不同的服务器能SCFG的一致?

下面以Google的QUIC实现为基础(QUIC实现于Chromium项目中,是项目里的一部分)首先简析下是如何在server端生成后,被client接收使用的,再分析看看如何保分布式环境下SCFG的一致性。分析的代码来源于最新版本的chromium,使用Google的chromium项目的在线源码阅读检索平台查看最新的源码并截取关键代码段进行分析。最后笔者结合自己项目经验描述了一种简而易行的分布式场景下的0-RTT方案实现方案。

3.1 SCFG在Server端的生成与使用方式

从上面的分析可知,要做到0-RTT握手,首先需要从Server端获取SCFG。Chromium项目里的QUIC是以C++面向对象的方式实现的,Server端的基本功能一般是封装成一个专门的server类来实现的,而QuicCryptoServerConfig则是Server侧的QUIC握手相关状态和逻辑管理的工具类,这个类是作为server的一个成员变量来使用的。我们知道SCFG是通过inchoate CHLO的回包REJ返回的。为避免中间人攻击,客户端需要认证SCFG确实是由server端生成的,为达到这个目的,server端需要给业务域名申请证书,用证书配套的私钥签名SCFG后,将SCFG连同证书以及SCFG的签名一并下发给client,由其使用证书里的公钥验证签名保证SCFG确实由server生成。

查看chromium的中QUIC的实现源代码,可以知道REJ回包是在QuicCryptoServerConfig::BuildRejection函数中构建的:

代码语言:txt
复制
void QuicCryptoServerConfig::BuildRejection(
    const ProcessClientHelloContext& context,
    const Config& config,
    const std::vector<uint32_t>& reject_reasons,
    CryptoHandshakeMessage* out) const {
  const QuicWallTime now = context.clock()->WallNow();

  out->set_tag(kREJ);
  out->SetStringPiece(kSCFG, config.serialized); // 往REJ中添加SCFG
  // ...
  if (context.info().valid_source_address_token ||total_size < max_unverified_size) {
    out->SetStringPiece(kCertificateTag, compressed);
    //为认证SCFG是由Server生成的,使用证书对应的私钥对SCFG进行了签名,签名保存在context.singed_config()中,此处将SCFG的签名一并打包进REJ中
    out->SetStringPiece(kPROF, context.signed_config()->proof.signature);
    // ...
  }
  // ...
}

这里的config是由入参传入,这个config又来自何处呢,查看代码可知,是在QuicCryptoServerConfig::ProcessClientHelloAfterGetProof函数中取QuicCryptoServerConfig的configs.primary传给的BuildRejectionAndRecordStats再透传至BuildRejection中:

代码语言:txt
复制
void QuicCryptoServerConfig::ProcessClientHelloAfterGetProof(
    bool found_error,
    std::unique_ptr<ProofSource::Details> proof_source_details,
    std::unique_ptr<ProcessClientHelloContext> context,
    const Configs& configs) const {
  // ...
  if (!context->info().reject_reasons.empty() || !configs.requested) {
    //QuicCryptoServerConfig::BuildRejection的入参config用的是成员变量configs.primary
    BuildRejectionAndRecordStats(*context, *configs.primary,	
                                 context->info().reject_reasons, out.get());
    context->Succeed(std::move(out), std::move(out_diversification_nonce),
                     std::move(proof_source_details));
    return;
  }
  // ...
}

void QuicCryptoServerConfig::BuildRejectionAndRecordStats(
    const ProcessClientHelloContext& context,
    const Config& config,
    const std::vector<uint32_t>& reject_reasons,
    CryptoHandshakeMessage* out) const {
  BuildRejection(context, config, reject_reasons, out);	// 透传config至BuildRejection中
  if (rejection_observer_ != nullptr) {
    rejection_observer_->OnRejectionBuilt(reject_reasons, out);
  }
}

QuicCryptoServerConfig::ProcessClientHelloAfterGetProof的configs则是从QuicCryptoServerConfig::ProcessClientHello函数中,取出的当前有效的configs:

代码语言:txt
复制
void QuicCryptoServerConfig::ProcessClientHello(
    QuicReferenceCountedPointer<ValidateClientHelloResultCallback::Result>
        validate_chlo_result,
    bool reject_only,
    QuicConnectionId connection_id,
    const QuicSocketAddress& server_address,
    const QuicSocketAddress& client_address,
    ParsedQuicVersion version,
    const ParsedQuicVersionVector& supported_versions,
    const QuicClock* clock,
    QuicRandom* rand,
    QuicCompressedCertsCache* compressed_certs_cache,
    QuicReferenceCountedPointer<QuicCryptoNegotiatedParameters> params,
    QuicReferenceCountedPointer<QuicSignedServerConfig> signed_config,
    QuicByteCount total_framing_overhead,
    QuicByteCount chlo_packet_size,
    std::unique_ptr<ProcessClientHelloResultCallback> done_cb) const {
  // ...
  Configs configs;	// 获取当前在有效的config
  if (!GetCurrentConfigs(context->clock()->WallNow(), requested_scid,
                         signed_config->config, &configs)) {
    context->Fail(QUIC_CRYPTO_INTERNAL_ERROR, "No configurations loaded");
    return;
  }
  // ...
  // No need to get a new proof if one was already generated.
  if (!context->signed_config()->chain) {
      const std::string chlo_hash = CryptoUtils::HashHandshakeMessage(
          context->client_hello(), Perspective::IS_SERVER);
      const QuicSocketAddress server_address = context->server_address();
      const std::string sni = std::string(context->info().sni);
      const QuicTransportVersion transport_version = context->transport_version();
      // 将当前的config传递给了ProcessClientHelloCallback对象,
      auto cb = std::make_unique<ProcessClientHelloCallback>(this, std::move(context), configs);
      // 此处GetProof回利用cb对当前的SCFG(configs.primary->serialized)进行了签名,保存在context对象中
      proof_source_->GetProof(server_address, sni, configs.primary->serialized, transport_version, chlo_hash, std::move(cb));
      return;
    }
    // configs传递给ProcessClientHelloAfterGetProof
    ProcessClientHelloAfterGetProof(
      /* found_error = */ false, /* proof_source_details = */ nullptr,
      std::move(context), configs);
  }

从获取configs的函数可知,获取的SCFG是当前的(函数名有current),为避免陷入细节,不将GetCurrentConfigs展开继续讲解,这里简单描述SCFG的基本设计逻辑:SCFG的作用和TLS的session key有点类似,如果一直有效,容易被抓包手段等方式获取带来安全隐患,需定时淘汰更新。每个生成的SCFG需要指定其过期时间,每个SCFG可以定一个不同的过期时间,这样不同的SCFG在时间线上占据不同的时间窗口。在使用时,当前时间落入哪个SCFG的时间窗口,则该config就是current config,这样就实现了SCFG的更新与淘汰。QuicCryptoServerConfig作为QUIC的Server端管理和维护握手阶段的各种内部状态的类,提供了生成和添加SCFG的能力,即下面的QuicCryptoServerConfig::GenerateConfig和QuicCryptoServerConfig::AddConfig函数:

代码语言:txt
复制
// static成员函数,根据指定的option生成对应的SCFG
QuicServerConfigProtobuf QuicCryptoServerConfig::GenerateConfig(
    QuicRandom* rand,
    const QuicClock* clock,
    const ConfigOptions& options) {
  CryptoHandshakeMessage msg;
  // 生成公私钥对
  const std::string curve25519_private_key =
      Curve25519KeyExchange::NewPrivateKey(rand);
  std::unique_ptr<Curve25519KeyExchange> curve25519 =
      Curve25519KeyExchange::New(curve25519_private_key);
  quiche::QuicheStringPiece curve25519_public_value =
      curve25519->public_value();

  std::string encoded_public_values;
  // First three bytes encode the length of the public value.
  DCHECK_LT(curve25519_public_value.size(), (1U << 24));
  encoded_public_values.push_back(
      static_cast<char>(curve25519_public_value.size()));
  encoded_public_values.push_back(
      static_cast<char>(curve25519_public_value.size() >> 8));
  encoded_public_values.push_back(
      static_cast<char>(curve25519_public_value.size() >> 16));
  encoded_public_values.append(curve25519_public_value.data(),
                               curve25519_public_value.size());

  std::string p256_private_key;
  if (options.p256) {
    p256_private_key = P256KeyExchange::NewPrivateKey();
    std::unique_ptr<P256KeyExchange> p256(
        P256KeyExchange::New(p256_private_key));
    quiche::QuicheStringPiece p256_public_value = p256->public_value();

    DCHECK_LT(p256_public_value.size(), (1U << 24));
    encoded_public_values.push_back(
        static_cast<char>(p256_public_value.size()));
    encoded_public_values.push_back(
        static_cast<char>(p256_public_value.size() >> 8));
    encoded_public_values.push_back(
        static_cast<char>(p256_public_value.size() >> 16));
    encoded_public_values.append(p256_public_value.data(),
                                 p256_public_value.size());
  }

  msg.set_tag(kSCFG);
  if (options.p256) {
    msg.SetVector(kKEXS, QuicTagVector{kC255, kP256});
  } else {
    msg.SetVector(kKEXS, QuicTagVector{kC255});
  }
  msg.SetVector(kAEAD, QuicTagVector{kAESG, kCC20});
  msg.SetStringPiece(kPUBS, encoded_public_values);

  if (options.expiry_time.IsZero()) {
    const QuicWallTime now = clock->WallNow();
    const QuicWallTime expiry = now.Add(QuicTime::Delta::FromSeconds(
        60 * 60 * 24 * 180 /* 180 days, ~six months */));
    const uint64_t expiry_seconds = expiry.ToUNIXSeconds();
    msg.SetValue(kEXPY, expiry_seconds);
  } else {
    msg.SetValue(kEXPY, options.expiry_time.ToUNIXSeconds());
  }

  char orbit_bytes[kOrbitSize];
  if (options.orbit.size() == sizeof(orbit_bytes)) {
    memcpy(orbit_bytes, options.orbit.data(), sizeof(orbit_bytes));
  } else {
    DCHECK(options.orbit.empty());
    rand->RandBytes(orbit_bytes, sizeof(orbit_bytes));
  }
  msg.SetStringPiece(
      kORBT, quiche::QuicheStringPiece(orbit_bytes, sizeof(orbit_bytes)));

  if (options.channel_id_enabled) {
    msg.SetVector(kPDMD, QuicTagVector{kCHID});
  }
  // 每个SCFG都要有一个唯一的ID,可以指定,不指定则生成
  if (options.id.empty()) {
    // We need to ensure that the SCID changes whenever the server config does
    // thus we make it a hash of the rest of the server config.
    std::unique_ptr<QuicData> serialized =
        CryptoFramer::ConstructHandshakeMessage(msg);

    uint8_t scid_bytes[SHA256_DIGEST_LENGTH];
    SHA256(reinterpret_cast<const uint8_t*>(serialized->data()),
           serialized->length(), scid_bytes);
    // The SCID is a truncated SHA-256 digest.
    static_assert(16 <= SHA256_DIGEST_LENGTH, "SCID length too high.");
    msg.SetStringPiece(kSCID,
                       quiche::QuicheStringPiece(
                           reinterpret_cast<const char*>(scid_bytes), 16));
  } else {
    msg.SetStringPiece(kSCID, options.id);
  }
  // Don't put new tags below this point. The SCID generation should hash over
  // everything but itself and so extra tags should be added prior to the
  // preceding if block.

  std::unique_ptr<QuicData> serialized =
      CryptoFramer::ConstructHandshakeMessage(msg);

  QuicServerConfigProtobuf config;
  config.set_config(std::string(serialized->AsStringPiece()));
  QuicServerConfigProtobuf::PrivateKey* curve25519_key = config.add_key();
  curve25519_key->set_tag(kC255);
  curve25519_key->set_private_key(curve25519_private_key);

  if (options.p256) {
    QuicServerConfigProtobuf::PrivateKey* p256_key = config.add_key();
    p256_key->set_tag(kP256);
    p256_key->set_private_key(p256_private_key);
  }

  return config;
}
// 添加SCFG的接口,生成了SCFG后,需要调用这个接口添加到QuicCryptoServerConfig中
std::unique_ptr<CryptoHandshakeMessage> QuicCryptoServerConfig::AddConfig(
    const QuicServerConfigProtobuf& protobuf,
    const QuicWallTime now) {
  std::unique_ptr<CryptoHandshakeMessage> msg =
      CryptoFramer::ParseMessage(protobuf.config());

  if (!msg) {
    QUIC_LOG(WARNING) << "Failed to parse server config message";
    return nullptr;
  }

  QuicReferenceCountedPointer<Config> config =
      ParseConfigProtobuf(protobuf, /* is_fallback = */ false);
  if (!config) {
    QUIC_LOG(WARNING) << "Failed to parse server config message";
    return nullptr;
  }

  {
    QuicWriterMutexLock locked(&configs_lock_);
    if (configs_.find(config->id) != configs_.end()) {
      QUIC_LOG(WARNING) << "Failed to add config because another with the same "
                           "server config id already exists: "
                        << quiche::QuicheTextUtils::HexEncode(config->id);
      return nullptr;
    }

    configs_[config->id] = config;
    SelectNewPrimaryConfig(now);
    DCHECK(primary_config_.get());
    DCHECK_EQ(configs_.find(primary_config_->id)->second.get(),
              primary_config_.get());
  }

  return msg;
}

从上面的分析可以,为完成QUIC的0-RTT握手, 我们需要在实现server时,生成好SCFG,并调用相关函数将其添加进QuicCryptoServerConfig即可。同时为了保证SCFG的更新, 也需要定期添加生成新的SCFG添加进QuicCryptoServerConfig。对于已有的QUIC连接, 如果server侧的SCFG有更新,新的SCFG会作为附加的数据通过连接的拥塞窗口打大小变更的通知带给client侧, 限于篇幅, 这里就不展开叙述了。详细的实现细节,可以通过上面介绍的Google的chrome源码在线管理平台搜索平台搜索关键函数BuildServerConfigUpdateMessage以及kSCUP这一关键字查看相关上下文了解具体实现细节。

3.2 SCFG在client侧的获取和使用

client侧通过发送inchoate CHLO让server端通过REJ返回SCFG,并通过证书验证SCFG的签名确认SCFG确实是由server端生成的。收到REJ后,会一并校验SCFG的签名的有效行,待校验通过,即可确认SCFG的有效性。跟server侧类似,client侧的基本功能一般是封装成一个专门的Client类来实现的,而QuicCryptoClientConfig则是client侧的QUIC握手相关状态和逻辑管理的工具类,这个类是作为Client的一个成员变量来使用的。详细的握手过程在client侧是由QuicCryptoClientHandshaker来管理的,代码为逻辑清晰,使用了状态机来处理握手过程中的状态流转, 状态的流转见如下所示代码:

代码语言:txt
复制
void QuicCryptoClientHandshaker::DoHandshakeLoop(
    const CryptoHandshakeMessage* in) {
  // SCFG收到后,会按server_id_(即server侧的域名)缓存到crypto_config_中
  QuicCryptoClientConfig::CachedState* cached = crypto_config_->LookupOrCreate(server_id_);

  QuicAsyncStatus rv = QUIC_SUCCESS;
  do {
    CHECK_NE(STATE_NONE, next_state_);
    const State state = next_state_;
    next_state_ = STATE_IDLE;
    rv = QUIC_SUCCESS;
    switch (state) {
      case STATE_INITIALIZE:
        DoInitialize(cached);
        break;
      case STATE_SEND_CHLO:
        DoSendCHLO(cached);
        return;  // return waiting to hear from server.
      case STATE_RECV_REJ:
        DoReceiveREJ(in, cached); // 会校验REJ包的合法性,并将REJ包中的数据填入cached对象中
        break;
      case STATE_VERIFY_PROOF:
        rv = DoVerifyProof(cached);	// SCFG的签名校验通过后,可以确认SCFG的有效性
        break;
      case STATE_VERIFY_PROOF_COMPLETE:
        DoVerifyProofComplete(cached);
        break;
      case STATE_RECV_SHLO:
        DoReceiveSHLO(in, cached);
        break;
      case STATE_IDLE:
        // This means that the peer sent us a message that we weren't expecting.
        stream_->OnUnrecoverableError(QUIC_INVALID_CRYPTO_MESSAGE_TYPE,
                                      "Handshake in idle state");
        return;
      case STATE_INITIALIZE_SCUP:
        DoInitializeServerConfigUpdate(cached);
        break;
      case STATE_NONE:
        QUIC_NOTREACHED();
        return;  // We are done.
    }
  } while (rv != QUIC_PENDING && next_state_ != STATE_NONE);
}

如上面代码所展示, 握手过程中收到了server侧的REJ,将其SCFG等信息缓存进管理client侧握手状体逻辑的QuicCryptoClientConfig中(见函数开头的LookupOrCreate调用), 待验SCFG的签名的校验通过后,会调用DoVerifyProof函数来处理相关逻辑,我们展开其来看看是如何实现的:

代码语言:txt
复制
void QuicCryptoClientHandshaker::DoVerifyProofComplete(
    QuicCryptoClientConfig::CachedState* cached) {
  // ...
  if (!verify_ok_) {
    // ...
    stream_->OnUnrecoverableError(QUIC_PROOF_INVALID,
                                  "Proof invalid: " + verify_error_details_);
    return;
  }

  // Check if generation_counter has changed between STATE_VERIFY_PROOF and
  // STATE_VERIFY_PROOF_COMPLETE state changes.
  if (generation_counter_ != cached->generation_counter()) {
    next_state_ = STATE_VERIFY_PROOF;
  } else {
    SetCachedProofValid(cached);	// 将cached的握手相关信息置为有效
    cached->SetProofVerifyDetails(verify_details_.release());
    if (!one_rtt_keys_available()) {
      next_state_ = STATE_SEND_CHLO;
    } else {
      // TODO: Enable Expect-Staple. https://crbug.com/631101
      next_state_ = STATE_NONE;
    }
  }
}

查看代码可见,如若验证通过,则继续调用SetCachedProofValid将cache的握手相关信息置为有效。继续展开其实现:

代码语言:txt
复制
void QuicCryptoClientHandshaker::SetCachedProofValid(
    QuicCryptoClientConfig::CachedState* cached) {
  cached->SetProofValid();
  proof_handler_->OnProofValid(*cached);	
}

可见,其实现除了置cached为有效, 就做了一件事, copy了一份cached,然后回调了proofhandler的OnProofValid方法。proofhandler是个接口类, OnProofValid是其接口函数, 在QUIC的client侧实现中一般由会话管理的session类予以实现, 如chrome浏览器中相关代卖中, 则是由QuicChromiumClientSession类来实现, 如下所示:

代码语言:txt
复制
void QuicChromiumClientSession::OnProofValid(
    const quic::QuicCryptoClientConfig::CachedState& cached) {
  DCHECK(cached.proof_valid());

  if (!server_info_) {
    return;
  }

  QuicServerInfo::State* state = server_info_->mutable_state();

  state->server_config = cached.server_config();	// 这个就是SCFG
  state->source_address_token = cached.source_address_token(); // STK对象
  state->cert_sct = cached.cert_sct(); 	//Signed cert timestamp (RFC6962) of leaf cert.
  state->chlo_hash = cached.chlo_hash();  // 握手包的hash, 用于认证握手过程
  state->server_config_sig = cached.signature();   // 这个就是SCFG的的签名
  state->certs = cached.certs();   // server端证书

  server_info_->Persist();  // 持久化进磁盘, 下次重启chrome浏览器后会加载出来继续使用
}

由代码上的注释可知,quic::QuicCryptoClientConfig::CachedState对象,除了保存SCFG及其签名意外,也保留了其他握手会用到的信息, 这些信息多需要缓存下来并在下一次握手时带上。至此我们知道了SCFG等信息是如何返回给客户端并在clien侧的以serverid(实际上是server的域名)为key在QuicCryptoClientConfig类中缓存下来的,接下来利用同一个QuicCryptoClientConfig对同一个serverid建立新的连接, 则可以直接利用缓存下来的SCFG发起0-RTT握手了。

上面的代码中, 除了SCFG等信息意外还保存了证书等其他信息, 值得一提的是, 有一个source_address_token, 简称STK。是server端在握手时对将客户端地址、时间戳等信息打包加密后生成的一段token,需要在0-RTT握手时候一并带到server端来验证,这样做的目的是避免黑客IP地址欺骗攻击(IP address spoofing attack)。source_address_token是server加密生成的,当然也知道如何解密,解密完后可以对比token里的地址是否对的上,STK非法会导致握手失败, 同时可以对比token里的时间戳信息看是否过期,STK过期也会导致握手失败

source_address_token的校验client ip并设置有效期的目的也是为了减轻UDP放大攻击(修改UDP包里的IP头中的source ip为被攻击目标,以期将服务端的回报引向被攻击的目标): source_address_token需要正确才回包,攻击这需要首先获取(通过抓包或窃听)正确的source_address_token,这增大的攻击的难度。即使获取了正确的source_address_token,过了有效期依然会被视为无效,降低了持续利用source_address_token进行攻击的风险。

在server侧的QuicCryptoServerConfig类的set_source_address_token_lifetime_secs控制source_address_token的有效期,最新版本的代码也提供了禁用source_address_token校验的开关

上面的分析, SCFG等信息被持久化后能否被再次加载进QuicCryptoClientConfig进的缓存中直接使用呢。例如如客户端crash了, 虽然SCFG等信息被持久化进文件了,但是如果不能加载出来使用, 那么也是不能继续进行0-RTT握手的, 因此QuicCryptoClientConfig::CachedState也提供了恢复能力,如:

代码语言:txt
复制
bool QuicCryptoClientConfig::CachedState::Initialize(
    quiche::QuicheStringPiece server_config,
    quiche::QuicheStringPiece source_address_token,
    const std::vector<std::string>& certs,
    const std::string& cert_sct,
    quiche::QuicheStringPiece chlo_hash,
    quiche::QuicheStringPiece signature,
    QuicWallTime now,
    QuicWallTime expiration_time) {
  DCHECK(server_config_.empty());

  if (server_config.empty()) {
    RecordDiskCacheServerConfigState(SERVER_CONFIG_EMPTY);
    return false;
  }

  std::string error_details;
  ServerConfigState state =
      SetServerConfig(server_config, now, expiration_time, &error_details);
  RecordDiskCacheServerConfigState(state);
  if (state != SERVER_CONFIG_VALID) {
    QUIC_DVLOG(1) << "SetServerConfig failed with " << error_details;
    return false;
  }

  chlo_hash_.assign(chlo_hash.data(), chlo_hash.size());
  server_config_sig_.assign(signature.data(), signature.size());
  source_address_token_.assign(source_address_token.data(),
                               source_address_token.size());
  certs_ = certs;
  cert_sct_ = cert_sct;
  return true;
}

如若想利此久化的握手信息发起0-RTT握手, 则只需调用QuicCryptoClientConfig类的LookupOrCreate方法生成一个QuicCryptoClientConfig::CachedState对象后,调用上述函数恢复从持久化的介质中将SCFG等信息到恢复到QuicCryptoClientConfig的缓存中即可继续进行0-RTT握手。

至此, 我们讨论了作为0-RTT握手核型的SCFG在Sever侧和client侧是如何流转和使用的, 接下来我们来讨论下我们在实现一个具体的QUIC服务时, 还面临和解决哪些问题。

3.3 实现完善的QUIC 0-RTT握手还需要解决的问题

作为互联网服务, 如果规模扩大到一定的地步,server侧一般需要由集群来提供服务, 如果考虑服务质量, 那么可能还需要多地部署, 由DNS调度到就近的服务器以提供更优质的服务。从上面的分析可知,SCFG是与服务的域名关联。这意味着为实现可靠的0-RTT握手, 除了在客户端上实现完整的SCFG持久化与恢复能力,还需要:

  1. 所有的server的服务器SCFG必须保持一致;
  2. SCFG更新时, 所有的server的SCFG必须同步更新;

其实这个问题和TLS的session ticket类似, 一个直观的做法是要有个中心化的服务, 负责SCFG的生成和同步更新。这对服务部署提出了一个新的要求, 那就是除了部署业务服务本身以外, 还要搭建一套可靠的“中心式”的QUIC 0-RTT的SCFG管理平台, 这套系统需要保证安全的同时,还要针对异地部署的服务提供异地SCFG的统一管理。这对普通的想使用QUIC 0-RTT特性来提升建连效率的业务带来了不小的难题。笔者在使用QUIC 0-RTT就遇到同样的问题。那有没有绕过这一问题的折衷的一种解决方案, 可以免去这个“中心式”的SCFG管理平台呢?下面看我们在实践的过程中是如何解决这一问题的。

4. 一种简单的特殊分布式0-RTT实现方案

通过上述的分析可知,SCFG是以域名为维度的全局数据,需要公用这个域名下的服务器使用同一份SCFG来实现0-RTT握手。如果不苛求SCFG是全局一份, 而是做成服务器IP维度的话, 那是不是就不需要一个“中心式”的SCFG管理平台呢? 答案是肯定的。基本要求是:

  • client侧实现以(域名, server_ip)为key进行SCFG的缓存与持久化管理。持久化的方案可以考虑磁盘, 或者更高效的共享内存的方式。
  • server侧以服务器为维度, 各自管理自己的SCFG的生成,持久化与更新。这样的server侧服务器之间解除了依赖,对扩容等操作都是极其友好的;

这种方案, 需要做的妥协是:

  • 将以域名为key进行SCFG缓存与管理的逻辑改成以(域名, server_ip)为key, 这意味着client侧必须能修改。如果客户端是标准的chrome浏览器的话, 则无法实现。好在我们的服务是自己实现的client端和server端后台服务, 这一点不成问题;
  • 由于按IP维度进行缓存的话, 每更换一次server ip的话, 都要承受一次1-RTT握手。这对我们影响不大, 我们的服务是个纯后台服务, 服务器间在启动时会建立完整的全连接。新上线的服务器只需承受一次上线就可以享受后续全程的0-RTT连接了。

我们的服务是全球多地多节点分布的, 网络环境复杂, 相比于建立一个全球范围内可以使用的安全可靠的的“中心式”的SCFG管理平台, 这些妥协是完全能接受的, 同时几乎损失很小。此方案的思想有点类似于算法里的“空间换时间”的思想, 也告诉我们实践过程中一定要根据实际情况因地制宜,没有统一的万能解决方案,换个思路可能会豁然开朗。

总结

本文就网络传输的一个方面的建连问题展开了分析, 浅析了建连时间对传输的影响。分析了TLS在缩短建连方面的努力, 以及分层网络设计在这里方面的本质局限。在此基础上进一步分析了QUIC的0-RTT建连是如何实现真0-RTT握手的。接着结合QUIC的源码, 浅析了QUIC需要如何实现以启用0-RTT特性。要实现完美的0-RTT方案需要一个安全可靠的的“中心式”的SCFG管理平台, 但要实现好这一平台的难度是十分大的。最后基于实实际条件, 实现一种去“中心式”的SCFG管理平台的分布式0-RTT方案, 虽有局限, 但简单易行, 该方案应用于笔者所在的项目中一年多以来, 服务运行稳定, 收效甚好。近得闲暇,整理成这份这份简陋的文字, 以期对进行QUIC 0-RTT相关实践工作的朋友做个参考。

参考资料:

Fastopen RFC: https://tools.ietf.org/html/rfc7413

TLS 1.3科普——新特性与协议实现: https://zhuanlan.zhihu.com/p/28850798

QUIC协议是如何做到0RTT加密传输的(addons): https://blog.csdn.net/dog250/article/details/80935534

QUIC在线源码: https://cs.chromium.org

密码技术: https://github.com/labuladong/fucking-algorithm/blob/master/%E6%8A%80%E6%9C%AF/%E5%AF%86%E7%A0%81%E6%8A%80%E6%9C%AF.md

深入浅出密码学: https://book.douban.com/subject/19986936/

QUIC Crypto Protocol: https://github.com/romain-jacotin/quic/blob/master/doc/QUIC_crypto_protocol.md

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1. 建连时间之殇
  • 2. QUIC“真”0-RTT握手
  • 3. QUIC 0-RTT实现简述
    • 3.1 SCFG在Server端的生成与使用方式
      • 3.2 SCFG在client侧的获取和使用
        • 3.3 实现完善的QUIC 0-RTT握手还需要解决的问题
          • 参考资料:
      • 4. 一种简单的特殊分布式0-RTT实现方案
      • 总结
      相关产品与服务
      云函数
      云函数(Serverless Cloud Function,SCF)是腾讯云为企业和开发者们提供的无服务器执行环境,帮助您在无需购买和管理服务器的情况下运行代码。您只需使用平台支持的语言编写核心代码并设置代码运行的条件,即可在腾讯云基础设施上弹性、安全地运行代码。云函数是实时文件处理和数据处理等场景下理想的计算平台。
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档