Chrome Verified Access 开发者指南

关于本指南

Chrome Verified Access API 允许网络服务(例如 VPN、内网页面等)以加密方式验证其客户端真实性以及符合公司政策。大多数大型企业都要求仅允许企业管理的设备访问其 WPA2 EAP-TLS 网络、在 VPN 中实现更高级别的访问,以及允许双向 TLS 内网页面。许多现有解决方案依赖于可能遭到入侵的同一客户端的启发式检查。这带来了一个挑战,即用来证明设备合法状态的信号本身可能已被伪造。本指南将由硬件支持,以加密方式保证设备的身份、设备状态在启动时未经修改且符合政策;称为“已验证的访问权限”。

主要受众群体 企业 IT 网域管理员
技术组件 ChromeOS、Google Verified Access API

已验证的访问权限的前提条件

在实施“已验证的访问权限”流程之前,请完成以下设置。

启用 API

设置 Google API 控制台项目并启用 API:

  1. Google API 控制台中创建或使用现有项目。
  2. 转到已启用的 API 和服务页面。
  3. 启用 Chrome Verified Access API
  4. 按照 Google Cloud API 文档中的说明操作,为您的应用创建 API 密钥。

创建服务账号

为使网络服务能够访问 Chrome Verified Access API 验证您的质询响应,请创建服务帐号和服务帐号密钥(您无需创建新的 Cloud 项目,也可以使用相同的项目)。

创建服务帐号密钥后,您应该下载服务帐号私钥文件。这是私钥的唯一副本,因此请务必妥善保存。

注册受管理的 Chromebook 设备

您需要使用 Chrome 扩展程序进行适当管理的 Chromebook 设备设置,才能使用“已验证的访问权限”设置。

  1. Chromebook 设备必须已注册企业或教育机构管理服务
  2. 设备用户必须是同一网域的注册用户。
  3. “已验证的访问权限”Chrome 扩展程序必须安装在设备上
  4. 政策配置为启用已验证的访问权限、将 Chrome 扩展程序列入许可名单,以及为表示网络服务的服务帐号授予对 API 的访问权限(请参阅 Google 管理控制台帮助文档)。

验证用户和设备

开发者可以使用“已验证的访问权限”进行用户或设备验证,或同时使用两者,以提高安全性:

  • 设备验证 - 如果成功,设备验证可保证 Chrome 设备已在受管理网域中注册,并且符合网域管理员指定的启动时验证模式设备政策。如果网络服务被授予查看设备身份的权限(请参阅 Google 管理控制台帮助文档),则它还会收到可用于审核、跟踪或调用 Directory API 的设备 ID。

  • 用户验证 - 如果成功,用户验证可保证已登录的 Chrome 用户是受管理的用户、使用的是已注册的设备,并且符合网域管理员指定的启动时验证模式用户政策。如果网络服务被授予接收其他用户数据的权限,它也会获得用户发出的证书签名请求(采用签名公钥和质询形式的 CSR,也称为 SPKAC,也称为密钥生成格式)。

如何验证用户和设备

  1. 获取验证方式 - 设备上的 Chrome 扩展程序会联系 Verified Access API 以获取验证。质询是一个不透明数据结构(由 Google 签名的 blob),有效期为 1 分钟,这意味着如果使用过时质询,质询响应验证(第 3 步)会失败。

    在最简单的用例中,用户可通过点击扩展程序生成的按钮来启动此流程(这也是 Google 提供的示例扩展程序的作用)。

    var apiKey = 'YOUR_API_KEY_HERE';
    var challengeUrlString =
      'https://verifiedaccess.googleapis.com/v2/challenge:generate?key=' + apiKey;
    
    // Request challenge from URL
    var xmlhttp = new XMLHttpRequest();
    xmlhttp.open('POST', challengeUrlString, true);
    xmlhttp.send();
    xmlhttp.onreadystatechange = function() {
      if (xmlhttp.readyState == 4) {
        var challenge = xmlhttp.responseText;
        console.log('challenge: ' + challenge);
        // v2 of the API returns an encoded challenge so no further challenge processing is needed
      }
    };
    

    用于对质询进行编码的帮助代码 - 如果您使用的是 v1 版本的 API,则需要对质询进行编码。

    // This can be replaced by using a third-party library such as
    // [https://github.com/dcodeIO/ProtoBuf.js/wiki](https://github.com/dcodeIO/ProtoBuf.js/wiki)
    /**
     * encodeChallenge convert JSON challenge into base64 encoded byte array
     * @param {string} challenge JSON encoded challenge protobuf
     * @return {string} base64 encoded challenge protobuf
     */
    var encodeChallenge = function(challenge) {
      var jsonChallenge = JSON.parse(challenge);
      var challengeData = jsonChallenge.challenge.data;
      var challengeSignature = jsonChallenge.challenge.signature;
    
      var protobufBinary = protoEncodeChallenge(
          window.atob(challengeData), window.atob(challengeSignature));
    
      return window.btoa(protobufBinary);
    };
    
    /**
     * protoEncodeChallenge produce binary encoding of the challenge protobuf
     * @param {string} dataBinary binary data field
     * @param {string} signatureBinary binary signature field
     * @return {string} binary encoded challenge protobuf
     */
    var protoEncodeChallenge = function(dataBinary, signatureBinary) {
      var protoEncoded = '';
    
      // See https://developers.google.com/protocol-buffers/docs/encoding
      // for encoding details.
    
      // 0x0A (00001 010, field number 1, wire type 2 [length-delimited])
      protoEncoded += '\u000A';
    
      // encode length of the data
      protoEncoded += varintEncode(dataBinary.length);
      // add data
      protoEncoded += dataBinary;
    
      // 0x12 (00010 010, field number 2, wire type 2 [length-delimited])
      protoEncoded += '\u0012';
      // encode length of the signature
      protoEncoded += varintEncode(signatureBinary.length);
      // add signature
      protoEncoded += signatureBinary;
    
      return protoEncoded;
    };
    
    /**
     * varintEncode produce binary encoding of the integer number
     * @param {number} number integer number
     * @return {string} binary varint-encoded number
     */
    var varintEncode = function(number) {
      // This only works correctly for numbers 0 through 16383 (0x3FFF)
      if (number <= 127) {
        return String.fromCharCode(number);
      } else {
        return String.fromCharCode(128 + (number & 0x7f), number >>> 7);
      }
    };
    
  2. 生成质询响应 - Chrome 扩展程序使用其在第 1 步中收到的质询来调用 enterprise.platformKeys Chrome API。这会生成一个经过签名和加密的质询响应,该扩展会将其包含在它发送至网络服务的访问请求中。

    在此步骤中,不会尝试定义扩展程序和网络服务用于通信的协议。这两个实体都由外部开发者实现,并且未明确说明它们彼此的通信方式。例如,以查询字符串参数的形式发送(网址编码)质询响应、使用 HTTP POST 或使用特殊的 HTTP 标头。

    以下是生成质询响应的示例代码:

    生成质询响应

      // Generate challenge response
      var encodedChallenge; // obtained by generate challenge API call
      try {
        if (isDeviceVerification) { // isDeviceVerification set by external logic
          chrome.enterprise.platformKeys.challengeKey(
              {
                scope: 'MACHINE',
                challenge: decodestr2ab(encodedChallenge),
              },
              ChallengeCallback);
        } else {
          chrome.enterprise.platformKeys.challengeKey(
              {
                scope: 'USER',
                challenge: decodestr2ab(encodedChallenge),
                registerKey: { 'RSA' }, // can also specify 'ECDSA'
              },
              ChallengeCallback);
        }
      } catch (error) {
        console.log('ERROR: ' + error);
      }
    

    质询回调函数

      var ChallengeCallback = function(response) {
        if (chrome.runtime.lastError) {
          console.log(chrome.runtime.lastError.message);
        } else {
          var responseAsString = ab2base64str(response);
          console.log('resp: ' + responseAsString);
        ... // send on to network service
       };
      }
    

    ArrayBuffer 转换的帮助程序代码

      /**
       * ab2base64str convert an ArrayBuffer to base64 string
       * @param {ArrayBuffer} buf ArrayBuffer instance
       * @return {string} base64 encoded string representation
       * of the ArrayBuffer
       */
      var ab2base64str = function(buf) {
        var binary = '';
        var bytes = new Uint8Array(buf);
        var len = bytes.byteLength;
        for (var i = 0; i < len; i++) {
          binary += String.fromCharCode(bytes[i]);
        }
        return window.btoa(binary);
      }
    
      /**
       * decodestr2ab convert a base64 encoded string to ArrayBuffer
       * @param {string} str string instance
       * @return {ArrayBuffer} ArrayBuffer representation of the string
       */
      var decodestr2ab = function(str) {
        var binary_string =  window.atob(str);
        var len = binary_string.length;
        var bytes = new Uint8Array(len);
        for (var i = 0; i < len; i++)        {
            bytes[i] = binary_string.charCodeAt(i);
        }
        return bytes.buffer;
      }
    
  3. 验证质询响应 - 收到来自设备的质询响应(可能是现有身份验证协议的扩展)后,网络服务应调用 Verified Access API 来验证设备身份和政策状态(请参阅下面的示例代码)。为了打击欺骗行为,我们建议网络服务识别正与之通信的客户端,并在其请求中包含该客户端的预期身份:

    • 对于设备验证,应提供预期的设备网域。在许多情况下,这可能是一个硬编码值,因为网络服务会保护特定网域的资源。如果事先无法得知这一点,则可以从用户身份推断出。
    • 对于用户验证,应提供预期用户的电子邮件地址。我们希望网络服务知道其用户(通常需要用户登录)。

    调用 Google API 时,它会执行多项检查,例如:

    • 验证质询响应是否由 ChromeOS 生成,并且在传输过程中未经过修改
    • 验证设备或用户是否由企业管理。
    • 验证设备/用户的身份是否与预期的身份一致(如果提供了后者)。
    • 验证所响应的质询是否为最新(不超过 1 分钟)。
    • 验证设备或用户是否符合网域管理员指定的政策。
    • 验证调用方(网络服务)是否已被授予调用该 API 的权限。
    • 如果调用方被授予获取其他设备或用户数据的权限,请在响应中包含设备 ID 或用户的证书签名请求 (CSR)。

    此示例使用 gRPC 库

    import com.google.auth.oauth2.GoogleCredentials;
    import com.google.auth.oauth2.ServiceAccountCredentials;
    import com.google.chrome.verifiedaccess.v2.VerifiedAccessGrpc;
    import com.google.chrome.verifiedaccess.v2.VerifyChallengeResponseRequest;
    import com.google.chrome.verifiedaccess.v2.VerifyChallengeResponseResult;
    import com.google.protobuf.ByteString;
    
    import io.grpc.ClientInterceptor;
    import io.grpc.ClientInterceptors;
    import io.grpc.ManagedChannel;
    import io.grpc.auth.ClientAuthInterceptor;
    import io.grpc.netty.GrpcSslContexts;
    import io.grpc.netty.NettyChannelBuilder;
    
    import java.io.File;
    import java.io.FileInputStream;
    import java.util.Arrays;
    import java.util.concurrent.Executors;
    
    // https://cloud.google.com/storage/docs/authentication#generating-a-private-key
    private final String clientSecretFile = "PATH_TO_GENERATED_JSON_SECRET_FILE";
    
    private ManagedChannel channel;
    private VerifiedAccessGrpc.VerifiedAccessBlockingStub client;
    
    void setup() {
    
       channel = NettyChannelBuilder.forAddress("verifiedaccess.googleapis.com", 443)
          .sslContext(GrpcSslContexts.forClient().ciphers(null).build())
          .build();
    
       List<ClientInterceptor> interceptors = Lists.newArrayList();
       // Attach a credential for my service account and scope it for the API.
       GoogleCredentials credentials =
           ServiceAccountCredentials.class.cast(
               GoogleCredentials.fromStream(
                   new FileInputStream(new File(clientSecretFile))));
      credentials = credentials.createScoped(
          Arrays.<String>asList("https://www.googleapis.com/auth/verifiedaccess"));
      interceptors.add(
           new ClientAuthInterceptor(credentials, Executors.newSingleThreadExecutor()));
    
      // Create a stub bound to the channel with the interceptors applied
      client = VerifiedAccessGrpc.newBlockingStub(
          ClientInterceptors.intercept(channel, interceptors));
    }
    
    /**
     * Invokes the synchronous RPC call that verifies the device response.
     * Returns the result protobuf as a string.
     *
     * @param signedData base64 encoded signedData blob (this is a response from device)
     * @param expectedIdentity expected identity (domain name or user email)
     * @return the verification result protobuf as string
     */
    public String verifyChallengeResponse(String signedData, String expectedIdentity)
      throws IOException, io.grpc.StatusRuntimeException {
      VerifyChallengeResponseResult result =
        client.verifyChallengeResponse(newVerificationRequest(signedData,
            expectedIdentity)); // will throw StatusRuntimeException on error.
    
      return result.toString();
    }
    
    private VerifyChallengeResponseRequest newVerificationRequest(
      String signedData, String expectedIdentity) throws IOException {
      return VerifyChallengeResponseRequest.newBuilder()
        .setChallengeResponse(
            ByteString.copyFrom(BaseEncoding.base64().decode(signedData)))
        .setExpectedIdentity(expectedIdentity == null ? "" : expectedIdentity)
        .build();
    }
    
  4. 授予访问权限 - 此步骤也因网络服务而异。这是一种建议实现(非推荐实现)。可能的操作包括:

    • 创建会话 Cookie
    • 为用户或设备颁发证书。如果用户验证成功,并假设网络服务已获得访问其他用户数据的权限(通过 Google 管理控制台政策),则服务将收到由用户签名的 CSR,然后可以使用该 CSR 从证书授权机构获取实际证书。与 Microsoft CA 集成时,网络服务可以充当中介并利用 ICertRequest 接口。

将客户端证书与已验证的访问权限搭配使用

使用具备“已验证的访问权限”设置的客户端证书。

在大型组织中,可能会有多个网络服务(VPN 服务器、Wi-Fi 接入点、防火墙和多个内网网站)受益于已验证的访问权限。不过,在上述每项网络服务中构建第 2-4 步(参见上文)步骤的逻辑可能并不实际。通常,许多此类网络服务已经能够要求将客户端证书作为其身份验证的一部分(例如,EAP-TLS 或双向 TLS 内网页面)。因此,如果颁发这些客户端证书的企业证书授权机构可以实施第 2-4 步,并在质询响应验证中调节客户端证书的颁发,则拥有该证书即可证明客户端是正品且符合公司政策。此后,每个 Wi-Fi 接入点、VPN 服务器等都可以检查此客户端证书,而无需执行第 2-4 步。

换句话说,这里的 CA(向企业设备颁发客户端证书)充当图 1 中的网络服务的角色。它需要调用 Verified Access API,并且仅在通过质询响应验证后向客户端提供证书。向客户端提供证书相当于图 1 中的“第 4 步 - 授予访问权限”。

这篇文章介绍了将客户端证书安全地获取到 Chromebook 的过程。如果遵循本段中所述的设计,则“已验证的访问权限”扩展程序和客户端证书初始配置扩展程序可合并为一个。详细了解如何编写客户端证书初始配置扩展程序