Chrome 已驗證存取權開發人員指南

關於這份指南

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 格式為 signed-public-key-and-challenge 或 SPKAC,也稱為 Keygen 格式)。

如何驗證使用者和裝置

  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
      }
    };
    

    編碼驗證問題的輔助程式碼:如果您使用 API v1,挑戰將會需要編碼。

    // 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,可用於取得憑證授權單位的實際憑證。與 Microsoft CA 整合時,網路服務可做為「中介商」,並使用 ICertRequest 介面。

搭配「已驗證存取權」使用用戶端憑證

搭配「已驗證存取權」使用用戶端憑證。

在大型機構中,可能有多個網路服務 (VPN 伺服器、Wi-Fi 存取點、防火牆及多個內部網路網站) 都能受益於「已驗證存取權」。不過,在這些網路服務中,步驟 2 到 4 的邏輯可能不太實際。通常,其中許多網路服務都已具備需要用戶端憑證的功能,作為驗證的一部分 (例如 EAP-TLS 或雙向傳輸層安全標準 (TLS) 內部網路頁面)。因此,如果核發這些用戶端憑證的企業憑證授權單位可執行步驟 2 至 4,並在驗證/回應驗證條件核發用戶端憑證,則憑證的擁有狀態可能是用戶端的真品且符合公司政策的證明。之後,每個 Wi-Fi 存取點、VPN 伺服器等都可以檢查這個用戶端憑證,不需要執行步驟 2 至 4。

換句話說,這裡的憑證授權單位 (核發用戶端憑證給企業裝置) 會採用圖 1 中的網路服務角色。應用程式需要叫用 Verified Access API,且只會在通過驗證回應驗證後向用戶端提供憑證。提供憑證給用戶端的方法與步驟 4 - 授予存取權的方式如圖 1 所示。

如何安全地將用戶端憑證傳送至 Chromebook。請參閱這篇文章。如果已遵循本節所述的設計,則可將已驗證存取權擴充功能和用戶端憑證新手上路擴充功能合併為一項。進一步瞭解如何編寫用戶端憑證新手上路擴充功能