גישה למשאבים שלא מנוהלים על ידי IAM ב-Google Cloud

אם הגישה למשאבים המוגנים שלכם לא מנוהלת על ידי IAM של Google Cloud, למשל, אם המשאבים מאוחסנים בשירות ענן אחר, בשרת מקומי או במכשיר מקומי כמו טלפון נייד, עדיין תוכלו לאמת עומס עבודה של Confidential Space למכשיר או למערכת שמספקים גישה למשאבים האלה, שנקראים גם צד מסתמך.

לשם כך, הצד המסתמך צריך לקבל טוקן אימות משירות אימות, כמו Google Cloud Attestation, עם קהל בהתאמה אישית וערכי nonce אופציונליים. כשמבקשים אסימון אישור כזה, יכול להיות שהצד המסתמך יצטרך לבצע אימות משלו של האסימון לפני שיאשר גישה למשאבים.

במסמכים הבאים מוסברים המושגים שקשורים לשימוש ב-Confidential Space עם משאבים מחוץ ל- Google Cloud, כולל הוראות לשילוב עומסי העבודה של Confidential Space עם משאבי AWS. למדריך מפורט, אפשר לעיין ב-codelab.

תהליך הטוקן של האימות

עומס העבודה מבקש אסימוני אימות מטעם צד מסתמך, ושירות האימות מחזיר אותם. בהתאם לצרכים שלכם, אתם יכולים להגדיר קהל בהתאמה אישית ולספק צפנים חד-פעמיים (אופציונלי).

ללא הצפנה

כדי להקל על ההבנה של תהליך אחזור האסימון, התהליך שמוצג כאן לא משתמש בהצפנה. בפועל, מומלץ להצפין את התקשורת באמצעות TLS.

בתרשים הבא מוצג התהליך:

תרשים זרימה של תהליך יצירת טוקן האישור

  1. הצד המסתמך שולח בקשת אסימון לעומס העבודה, עם ערכי nonce אופציונליים שהוא יצר.

  2. עומס העבודה קובע את הקהל, מוסיף את הקהל לבקשה ושולח את הבקשה למפעיל של Confidential Space.

  3. המשגר שולח את הבקשה לשירות האימות.

  4. שירות האימות יוצר טוקן שמכיל את קהל היעד שצוין וערכי nonce אופציונליים.

  5. שירות האימות מחזיר את האסימון למרכז האפליקציות.

  6. מרכז האפליקציות מחזיר את האסימון לעומס העבודה.

  7. עומס העבודה מחזיר את האסימון ל-Relying Party.

  8. הצד המסתמך מאמת את הטענות, כולל הקהל והצפנים הקריפטוגרפיים החד-פעמיים (nonces) האופציונליים.

מוצפנת באמצעות TLS

תהליך לא מוצפן חושף את הבקשה למתקפות מסוג 'מכונה באמצע'. מכיוון שערך ה-nonce לא קשור לפלט הנתונים או לסשן TLS, תוקף יכול ליירט את הבקשה ולהתחזות לעומס העבודה.

כדי למנוע מתקפות מסוג כזה, אפשר להגדיר סשן TLS בין הצד המסתמך לבין עומס העבודה ולהשתמש בחומר מפתח מיוצא של TLS‏ (EKM) כערך חד-פעמי. חומר המפתח שיוצא מ-TLS קושר את האימות לסשן TLS ומאשר שהבקשה לאימות נשלחה דרך ערוץ מאובטח. התהליך הזה נקרא גם שיוך ערוץ.

בתרשים הבא מוצג התהליך באמצעות קישור ערוץ:

דיאגרמת זרימה של תהליך יצירת טוקן לקישור ערוץ

  1. הצד הנסמך מגדיר סשן TLS מאובטח עם Confidential VM שבו פועל עומס העבודה.

  2. הצד המסתמך שולח בקשת טוקן באמצעות סשן TLS מאובטח.

  3. עומס העבודה קובע את הקהל ומייצר צופן חד-פעמי באמצעות חומר המפתח שיוצא מ-TLS.

  4. עומס העבודה שולח את הבקשה אל Confidential Space Launcher.

  5. המשגר שולח את הבקשה לשירות האימות.

  6. שירות האימות יוצר טוקן שמכיל את קהל היעד ואת הצופן החד-פעמי שצוינו.

  7. שירות האימות מחזיר את האסימון למרכז האפליקציות.

  8. מרכז האפליקציות מחזיר את האסימון לעומס העבודה.

  9. עומס העבודה מחזיר את האסימון ל-Relying Party.

  10. הצד המסתמך יוצר מחדש את ה-nonce באמצעות חומר המפתח שיוצא מ-TLS.

  11. הצד המסתמך מאמת את הטענות, כולל הקהל וה-nonce. הערך של ה-nonce באסימון חייב להיות זהה לערך של ה-nonce שנוצר מחדש על ידי הצד המסתמך.

מבנה טוקן האימות

אסימוני אימות הם אסימוני אינטרנט מסוג JSON עם המבנה הבא:

  • כותרת: תיאור של אלגוריתם החתימה. בנוסף, טוקנים של PKI מאחסנים את שרשרת האישורים בכותרת בשדה x5c.

  • מטען ייעודי (payload) של נתוני JSON חתומים: מכיל טענות לגבי עומס העבודה עבור הצד המסתמך, כמו נושא, מנפיק, קהל, ערכים חד-פעמיים וזמן תפוגה.

  • חתימה: מספקת אימות לכך שהטוקן לא השתנה במהלך ההעברה. למידע נוסף על השימוש בחתימה, אפשר לקרוא את המאמר איך מאמתים אסימון מזהה של OpenID Connect.

בדוגמת הקוד הבאה מוצג אסימון אימות מקודד ללא החתימה. אפשר להשתמש בכתובת https://jwt.io/ כדי לפענח את האסימון.

האסימון נוצר על ידי קובץ האימג' 240500 של Confidential Space ו-Google Cloud Attestation. יכול להיות שתמונות חדשות יותר יכילו שדות נוספים.

eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQ1IiwidHlwIjoiSldUIn0.eyJhdWQiOiJBVURJRU5DRV9OQU1FIiwiZGJnc3RhdCI6ImRpc2FibGVkLXNpbmNlLWJvb3QiLCJlYXRfbm9uY2UiOlsiTk9OQ0VfMSIsIk5PTkNFXzIiXSwiZWF0X3Byb2ZpbGUiOiJodHRwczovL2Nsb3VkLmdvb2dsZS5jb20vY29uZmlkZW50aWFsLWNvbXB1dGluZy9jb25maWRlbnRpYWwtc3BhY2UvZG9jcy9yZWZlcmVuY2UvdG9rZW4tY2xhaW1zIiwiZXhwIjoxNzIxMzMwMDc1LCJnb29nbGVfc2VydmljZV9hY2NvdW50cyI6WyJQUk9KRUNUX0lELWNvbXB1dGVAZGV2ZWxvcGVyLmdzZXJ2aWNlYWNjb3VudC5jb20iXSwiaHdtb2RlbCI6IkdDUF9BTURfU0VWIiwiaWF0IjoxNzIxMzI2NDc1LCJpc3MiOiJodHRwczovL2NvbmZpZGVudGlhbGNvbXB1dGluZy5nb29nbGVhcGlzLmNvbSIsIm5iZiI6MTcyMTMyNjQ3NSwib2VtaWQiOjExMTI5LCJzZWNib290Ijp0cnVlLCJzdWIiOiJodHRwczovL3d3dy5nb29nbGVhcGlzLmNvbS9jb21wdXRlL3YxL3Byb2plY3RzL1BST0pFQ1RfSUQvem9uZXMvdXMtY2VudHJhbDEtYS9pbnN0YW5jZXMvSU5TVEFOQ0VfTkFNRSIsInN1Ym1vZHMiOnsiY29uZmlkZW50aWFsX3NwYWNlIjp7Im1vbml0b3JpbmdfZW5hYmxlZCI6eyJtZW1vcnkiOmZhbHNlfSwic3VwcG9ydF9hdHRyaWJ1dGVzIjpbIkxBVEVTVCIsIlNUQUJMRSIsIlVTQUJMRSJdfSwiY29udGFpbmVyIjp7ImFyZ3MiOlsiL2N1c3RvbW5vbmNlIiwiL2RvY2tlci1lbnRyeXBvaW50LnNoIiwibmdpbngiLCItZyIsImRhZW1vbiBvZmY7Il0sImVudiI6eyJIT1NUTkFNRSI6IkhPU1RfTkFNRSIsIk5HSU5YX1ZFUlNJT04iOiIxLjI3LjAiLCJOSlNfUkVMRUFTRSI6IjJ-Ym9va3dvcm0iLCJOSlNfVkVSU0lPTiI6IjAuOC40IiwiUEFUSCI6Ii91c3IvbG9jYWwvc2JpbjovdXNyL2xvY2FsL2JpbjovdXNyL3NiaW46L3Vzci9iaW46L3NiaW46L2JpbiIsIlBLR19SRUxFQVNFIjoiMn5ib29rd29ybSJ9LCJpbWFnZV9kaWdlc3QiOiJzaGEyNTY6Njc2ODJiZGE3NjlmYWUxY2NmNTE4MzE5MmI4ZGFmMzdiNjRjYWU5OWM2YzMzMDI2NTBmNmY4YmY1ZjBmOTVkZiIsImltYWdlX2lkIjoic2hhMjU2OmZmZmZmYzkwZDM0M2NiY2IwMWE1MDMyZWRhYzg2ZGI1OTk4YzUzNmNkMGEzNjY1MTQxMjFhNDVjNjcyMzc2NWMiLCJpbWFnZV9yZWZlcmVuY2UiOiJkb2NrZXIuaW8vbGlicmFyeS9uZ2lueDpsYXRlc3QiLCJpbWFnZV9zaWduYXR1cmVzIjpbeyJrZXlfaWQiOiI8aGV4YWRlY2ltYWwtc2hhMjU2LWZpbmdlcnByaW50LXB1YmxpYy1rZXkxPiIsInNpZ25hdHVyZSI6IjxiYXNlNjQtZW5jb2RlZC1zaWduYXR1cmU-Iiwic2lnbmF0dXJlX2FsZ29yaXRobSI6IlJTQVNTQV9QU1NfU0hBMjU2In0seyJrZXlfaWQiOiI8aGV4YWRlY2ltYWwtc2hhMjU2LWZpbmdlcnByaW50LXB1YmxpYy1rZXkyPiIsInNpZ25hdHVyZSI6IjxiYXNlNjQtZW5jb2RlZC1zaWduYXR1cmU-Iiwic2lnbmF0dXJlX2FsZ29yaXRobSI6IlJTQVNTQV9QU1NfU0hBMjU2In0seyJrZXlfaWQiOiI8aGV4YWRlY2ltYWwtc2hhMjU2LWZpbmdlcnByaW50LXB1YmxpYy1rZXkzPiIsInNpZ25hdHVyZSI6IjxiYXNlNjQtZW5jb2RlZC1zaWduYXR1cmU-Iiwic2lnbmF0dXJlX2FsZ29yaXRobSI6IkVDRFNBX1AyNTZfU0hBMjU2In1dLCJyZXN0YXJ0X3BvbGljeSI6Ik5ldmVyIn0sImdjZSI6eyJpbnN0YW5jZV9pZCI6IklOU1RBTkNFX0lEIiwiaW5zdGFuY2VfbmFtZSI6IklOU1RBTkNFX05BTUUiLCJwcm9qZWN0X2lkIjoiUFJPSkVDVF9JRCIsInByb2plY3RfbnVtYmVyIjoiUFJPSkVDVF9OVU1CRVIiLCJ6b25lIjoidXMtY2VudHJhbDEtYSJ9fSwic3duYW1lIjoiQ09ORklERU5USUFMX1NQQUNFIiwic3d2ZXJzaW9uIjpbIjI0MDUwMCJdfQ.29V71ymnt7LY5Ny6OJFb9AClT4XNLPi0TIcddKDp5pk

זו הגרסה המפוענחת של הדוגמה הקודמת:

{
  "alg": "HS256",
  "kid": "12345",
  "typ": "JWT"
}.
{
  "aud": "AUDIENCE_NAME",
  "dbgstat": "disabled-since-boot",
  "eat_nonce": [
    "NONCE_1",
    "NONCE_2"
  ],
  "eat_profile": "https://cloud.google.com/confidential-computing/confidential-space/docs/reference/token-claims",
  "exp": 1721330075,
  "google_service_accounts": [
    "PROJECT_ID-compute@developer.gserviceaccount.com"
  ],
  "hwmodel": "GCP_AMD_SEV",
  "iat": 1721326475,
  "iss": "https://confidentialcomputing.googleapis.com",
  "nbf": 1721326475,
  "oemid": 11129,
  "secboot": true,
  "sub": "https://www.googleapis.com/compute/v1/projects/PROJECT_ID/zones/us-central1-a/instances/INSTANCE_NAME",
  "submods": {
    "confidential_space": {
      "monitoring_enabled": {
        "memory": false
      },
      "support_attributes": [
        "LATEST",
        "STABLE",
        "USABLE"
      ]
    },
    "container": {
      "args": [
        "/customnonce",
        "/docker-entrypoint.sh",
        "nginx",
        "-g",
        "daemon off;"
      ],
      "env": {
        "HOSTNAME": "HOST_NAME",
        "NGINX_VERSION": "1.27.0",
        "NJS_RELEASE": "2~bookworm",
        "NJS_VERSION": "0.8.4",
        "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
        "PKG_RELEASE": "2~bookworm"
      },
      "image_digest": "sha256:67682bda769fae1ccf5183192b8daf37b64cae99c6c3302650f6f8bf5f0f95df",
      "image_id": "sha256:fffffc90d343cbcb01a5032edac86db5998c536cd0a366514121a45c6723765c",
      "image_reference": "docker.io/library/nginx:latest",
      "image_signatures": [
        {
          "key_id": "<hexadecimal-sha256-fingerprint-public-key1>",
          "signature": "<base64-encoded-signature>",
          "signature_algorithm": "RSASSA_PSS_SHA256"
        },
        {
          "key_id": "<hexadecimal-sha256-fingerprint-public-key2>",
          "signature": "<base64-encoded-signature>",
          "signature_algorithm": "RSASSA_PSS_SHA256"
        },
        {
          "key_id": "<hexadecimal-sha256-fingerprint-public-key3>",
          "signature": "<base64-encoded-signature>",
          "signature_algorithm": "ECDSA_P256_SHA256"
        }
      ],
      "restart_policy": "Never"
    },
    "gce": {
      "instance_id": "INSTANCE_ID",
      "instance_name": "INSTANCE_NAME",
      "project_id": "PROJECT_ID",
      "project_number": "PROJECT_NUMBER",
      "zone": "us-central1-a"
    }
  },
  "swname": "CONFIDENTIAL_SPACE",
  "swversion": [
    "240500"
  ]
}

הסבר מפורט יותר על השדות של אסימון האימות זמין במאמר הצהרות של אסימון אימות.

אחזור טוקנים של אימות (attestation)

כדי להטמיע אסימוני אישור בסביבת Confidential Space:

  1. מגדירים לקוח HTTP בעומס העבודה.

  2. בסביבת העבודה, משתמשים בלקוח HTTP כדי לשלוח בקשת HTTP לכתובת ה-URL להאזנה, דרך שקע דומיין של Unix שנמצא בכתובת /run/container_launcher/teeserver.sock. כתובת ה-URL להאזנה משתנה בהתאם לשירות האימות שבו אתם משתמשים:

    • Google Cloud Attestation: http://localhost/v1/token

    • Intel Trust Authority: ‏ http://localhost/v1/intel/token

.

כשמתבצעת בקשה לכתובת ה-URL להאזנה, כלי ההפעלה של Confidential Space מטפל באיסוף של ראיות האימות, שולח בקשה לאסימון אימות משירות אימות (מעביר את כל הפרמטרים המותאמים אישית) ואז מחזיר את האסימון שנוצר לעומס העבודה.

בדוגמת הקוד הבאה ב-Go אפשר לראות איך מתקשרים עם שרת ה-HTTP של ה-launcher באמצעות IPC, על סמך שירות האימות שבו אתם משתמשים.

Google Cloud Attestation

func getCustomTokenBytes(body string) ([]byte, error) {
  httpClient := http.Client{
    Transport: &http.Transport{
      // Set the DialContext field to a function that creates
      // a new network connection to a Unix domain socket
      DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
        return net.Dial("unix", "/run/container_launcher/teeserver.sock")
      },
    },
  }

  // Get the token from the IPC endpoint
  url := "http://localhost/v1/token"

  resp, err := httpClient.Post(url, "application/json", strings.NewReader(body))
  if err != nil {
    return nil, fmt.Errorf("failed to get raw token response: %w", err)
  }
  tokenbytes, err := io.ReadAll(resp.Body)
  if err != nil {
    return nil, fmt.Errorf("failed to read token body: %w", err)
  }
  fmt.Println(string(tokenbytes))
  return tokenbytes, nil
}

Intel Trust Authority

‫Intel Trust Authority תומך רק במכונות וירטואליות שמופעלות באמצעות Intel TDX.

type tokenRequest struct {
  Audience         string           `json:"audience"`
  Nonces           []string         `json:"nonces"`
  TokenType        string           `json:"token_type"`
  TokenTypeOptions tokenTypeOptions `json:"aws_principal_tag_options"`
}

func getITAToken(body string) (string, error) {
  httpClient := http.Client{
    Transport: &http.Transport{
      // Set the DialContext field to a function that creates
      // a new network connection to a Unix domain socket
      DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
        return net.Dial(
          "unix",
          "/run/container_launcher/teeserver.sock"
          )
      },
    },
  }

  contentType := "application/json"
  itaTokenEndpoint := "http://localhost/v1/intel/token"
  resp, err := httpClient.Post(itaTokenEndpoint, contentType, strings.NewReader(body))

  if err != nil { ... }

  defer resp.Body.Close()

  if resp.StatusCode != http.StatusOK {
    return "", fmt.Errorf(...)
  }

  tokenbytes, err := io.ReadAll(resp.Body)
  if err != nil { ... }

  return string(tokenbytes), nil
}

func main() {
  // Get token from the Confidential Space launcher
  body := tokenRequest{
    Audience:  "http://test.audience",
    TokenType: "OIDC", // Set this to AWS_PRINCIPAL_TAGS for AWS tokens.
  }

  val, err := json.Marshal(body)
  if err != nil {...}

  token, err := getITAToken(string(val))
  if err != nil {...}
  fmt.Printf("Token received: %v", token)

  // Use your ITA token to gain access to resources on AWS or Google Cloud
}

בקשת אסימון אימות עם קהל בהתאמה אישית

ה-method של ה-HTTP וכתובת ה-URL:

POST http://localhost/v1/token

תוכן בקשת JSON:

{
  "audience": "AUDIENCE_NAME",
  "token_type": "TOKEN_TYPE",
  "nonces": [
      "NONCE_1",
      "NONCE_2",
      ...
  ]
}

מספקים את הערכים הבאים:

  • AUDIENCE_NAME: שדה חובה. ערך הקהל, שהוא השם שנתתם לצד המסתמך. ההגדרה הזו נקבעת לפי עומס העבודה.

    ערך ברירת המחדל של השדה הזה הוא https://sts.google.com עבור טוקנים ללא קהל מותאם אישית. אי אפשר להשתמש בערך https://sts.google.com כשמגדירים קהל בהתאמה אישית. האורך המקסימלי הוא 512 בייטים.

    כדי לכלול קהל בהתאמה אישית באסימון, עומס העבודה – ולא הצד המסתמך – צריך להוסיף אותו לבקשת אסימון האימות לפני שליחת הבקשה לשירות אימות. כך אפשר למנוע מהצד המסתמך לבקש אסימון למשאב מוגן שאסור לו לגשת אליו.

  • TOKEN_TYPE: שדה חובה. סוג האסימון שיוחזר. בוחרים אחד מהסוגים הבאים:

    במקום מפתחות ציבוריים עם תוקף קצר, נעשה שימוש באימות טוקנים באמצעות אישורים עם תוקף ארוך, ולכן כתובות ה-IP שלכם לא נחשפות לשרתים של Google בתדירות גבוהה. המשמעות היא שטוקנים של PKI מציעים פרטיות גבוהה יותר מטוקנים של OIDC.

    אפשר לאמת את טביעת האצבע של האישור באמצעות OpenSSL:

    openssl x509 -fingerprint -in confidential_space_root.crt
    

    טביעת האצבע צריכה להתאים ל-SHA-1 digest הבא:

    B9:51:20:74:2C:24:E3:AA:34:04:2E:1C:3B:A3:AA:D2:8B:21:23:21
    
  • NONCE: אופציונלי. ערך ייחודי, אקראי ואטום שמאמת שאפשר להשתמש באסימון רק פעם אחת. הערך מוגדר על ידי הצד המסתמך. אפשר להשתמש בעד שישה ערכי nonce. כל ערך nonce חייב להיות בין 10 ל-74 בייט, כולל.

    כשכוללים nonce, הצד המסתמך צריך לוודא שה-nonces שנשלחו בבקשת אסימון האישור זהים ל-nonces באסימון שמוחזר. אם הם שונים, הצד המסתמך צריך לדחות את האסימון.

ניתוח ואימות של טוקנים של אימות

בדוגמאות הקוד הבאות ב-Go אפשר לראות איך מאמתים אסימוני אישור.

אסימוני אימות (attestation) של OIDC

package main

import (
  "context"
  "crypto/rsa"
  "encoding/base64"
  "encoding/json"
  "errors"
  "fmt"
  "io"
  "math/big"
  "net"
  "net/http"
  "strings"

  "github.com/golang-jwt/jwt/v4"
)

const (
  socketPath     = "/run/container_launcher/teeserver.sock"
  expectedIssuer = "https://confidentialcomputing.googleapis.com"
  wellKnownPath  = "/.well-known/openid-configuration"
)

type jwksFile struct {
  Keys []jwk `json:"keys"`
}

type jwk struct {
  N   string `json:"n"`   // "nMMTBwJ7H6Id8zUCZd-L7uoNyz9b7lvoyse9izD9l2rtOhWLWbiG-7pKeYJyHeEpilHP4KdQMfUo8JCwhd-OMW0be_XtEu3jXEFjuq2YnPSPFk326eTfENtUc6qJohyMnfKkcOcY_kTE11jM81-fsqtBKjO_KiSkcmAO4wJJb8pHOjue3JCP09ZANL1uN4TuxbM2ibcyf25ODt3WQn54SRQTV0wn098Y5VDU-dzyeKYBNfL14iP0LiXBRfHd4YtEaGV9SBUuVhXdhx1eF0efztCNNz0GSLS2AEPLQduVuFoUImP4s51YdO9TPeeQ3hI8aGpOdC0syxmZ7LsL0rHE1Q",
  E   string `json:"e"`   // "AQAB" or 65537 as an int
  Kid string `json:"kid"` // "1f12fa916c3a0ef585894b4b420ad17dc9d6cdf5",

  // Unused fields:
  // Alg string `json:"alg"` // "RS256",
  // Kty string `json:"kty"` // "RSA",
  // Use string `json:"use"` // "sig",
}

type wellKnown struct {
  JwksURI string `json:"jwks_uri"` // "https://www.googleapis.com/service_accounts/v1/metadata/jwk/signer@confidentialspace-sign.iam.gserviceaccount.com"

  // Unused fields:
  // Iss                                   string `json:"issuer"`                                // "https://confidentialcomputing.googleapis.com"
  // Subject_types_supported               string `json:"subject_types_supported"`               // [ "public" ]
  // Response_types_supported              string `json:"response_types_supported"`              // [ "id_token" ]
  // Claims_supported                      string `json:"claims_supported"`                      // [ "sub", "aud", "exp", "iat", "iss", "jti", "nbf", "dbgstat", "eat_nonce", "google_service_accounts", "hwmodel", "oemid", "secboot", "submods", "swname", "swversion" ]
  // Id_token_signing_alg_values_supported string `json:"id_token_signing_alg_values_supported"` // [ "RS256" ]
  // Scopes_supported                      string `json:"scopes_supported"`                      // [ "openid" ]
}

func getWellKnownFile() (wellKnown, error) {
  httpClient := http.Client{}
  resp, err := httpClient.Get(expectedIssuer + wellKnownPath)
  if err != nil {
    return wellKnown{}, fmt.Errorf("failed to get raw .well-known response: %w", err)
  }

  wellKnownJSON, err := io.ReadAll(resp.Body)
  if err != nil {
    return wellKnown{}, fmt.Errorf("failed to read .well-known response: %w", err)
  }

  wk := wellKnown{}
  json.Unmarshal(wellKnownJSON, &wk)
  return wk, nil
}

func getJWKFile() (jwksFile, error) {
  wk, err := getWellKnownFile()
  if err != nil {
    return jwksFile{}, fmt.Errorf("failed to get .well-known json: %w", err)
  }

  // Get JWK URI from .wellknown
  uri := wk.JwksURI
  fmt.Printf("jwks URI: %v\n", uri)

  httpClient := http.Client{}
  resp, err := httpClient.Get(uri)
  if err != nil {
    return jwksFile{}, fmt.Errorf("failed to get raw JWK response: %w", err)
  }

  jwkbytes, err := io.ReadAll(resp.Body)
  if err != nil {
    return jwksFile{}, fmt.Errorf("failed to read JWK body: %w", err)
  }

  file := jwksFile{}
  err = json.Unmarshal(jwkbytes, &file)
  if err != nil {
    return jwksFile{}, fmt.Errorf("failed to unmarshall JWK content: %w", err)
  }

  return file, nil
}

// N and E are 'base64urlUInt' encoded: https://www.rfc-editor.org/rfc/rfc7518#section-6.3
func base64urlUIntDecode(s string) (*big.Int, error) {
  b, err := base64.RawURLEncoding.DecodeString(s)
  if err != nil {
    return nil, err
  }
  z := new(big.Int)
  z.SetBytes(b)
  return z, nil
}

func getRSAPublicKeyFromJWKsFile(t *jwt.Token) (any, error) {
  keysfile, err := getJWKFile()
  if err != nil {
    return nil, fmt.Errorf("failed to fetch the JWK file: %w", err)
  }

  // Multiple keys are present in this endpoint to allow for key rotation.
  // This method finds the key that was used for signing to pass to the validator.
  kid := t.Header["kid"]
  for _, key := range keysfile.Keys {
    if key.Kid != kid {
      continue // Select the key used for signing
    }

    n, err := base64urlUIntDecode(key.N)
    if err != nil {
      return nil, fmt.Errorf("failed to decode key.N %w", err)
    }
    e, err := base64urlUIntDecode(key.E)
    if err != nil {
      return nil, fmt.Errorf("failed to decode key.E %w", err)
    }

    // The parser expects an rsa.PublicKey: https://github.com/golang-jwt/jwt/blob/main/rsa.go#L53
    // or an array of keys. We chose to show passing a single key in this example as its possible
    // not all validators accept multiple keys for validation.
    return &rsa.PublicKey{
      N: n,
      E: int(e.Int64()),
    }, nil
  }

  return nil, fmt.Errorf("failed to find key with kid '%v' from well-known endpoint", kid)
}

func decodeAndValidateToken(tokenBytes []byte, keyFunc func(t *jwt.Token) (any, error)) (*jwt.Token, error) {
  var err error
  fmt.Println("Unmarshalling token and checking its validity...")
  token, err := jwt.NewParser().Parse(string(tokenBytes), keyFunc)

  fmt.Printf("Token valid: %v", token.Valid)
  if token.Valid {
    return token, nil
  }
  if ve, ok := err.(*jwt.ValidationError); ok {
    if ve.Errors&jwt.ValidationErrorMalformed != 0 {
      return nil, fmt.Errorf("token format invalid. Please contact the Confidential Space team for assistance")
    }
    if ve.Errors&(jwt.ValidationErrorNotValidYet) != 0 {
      // If device time is not synchronized with the attestation service,
      // you may need to account for that here.
      return nil, errors.New("token is not active yet")
    }
    if ve.Errors&(jwt.ValidationErrorExpired) != 0 {
      return nil, fmt.Errorf("token is expired")
    }
    return nil, fmt.Errorf("unknown validation error: %v", err)
  }

  return nil, fmt.Errorf("couldn't handle this token or couldn't read a validation error: %v", err)
}

func main() {
  // Get a token from a workload running in Confidential Space
  tokenbytes, err := getTokenBytesFromWorkload()

  // Write a method to return a public key from the well-known endpoint
  keyFunc := getRSAPublicKeyFromJWKsFile

  // Verify properties of the original Confidential Space workload that generated the attestation
  // using the token claims.
  token, err := decodeAndValidateToken(tokenbytes, keyFunc)
  if err != nil {
    panic(err)
  }

  claimsString, err := json.MarshalIndent(token.Claims, "", "  ")
  if err != nil {
    panic(err)
  }
  fmt.Println(string(claimsString))
}

טוקנים של אישור PKI

כדי לאמת את האסימון, הצד המסתמך צריך לבצע את השלבים הבאים:

  1. מנתחים את הכותרת של האסימון כדי לקבל את שרשרת האישורים.

  2. אימות שרשרת האישורים מול אישור הבסיס שמאוחסן. צריך להוריד מראש את אישור הבסיס מכתובת ה-URL שצוינה בשדה root_ca_uri שמוחזר בנקודת הקצה לאימות טוקן PKI.

  3. בודקים את התוקף של אישור העלה.

  4. משתמשים באישור העלה כדי לאמת את חתימת האסימון, באמצעות האלגוריתם שצוין במפתח alg בכותרת.

אחרי שהטוקן עובר אימות, האתר שמוגדר כ-Relying Party יכול לנתח את ההצהרות של הטוקן.

// This code is an example of how to validate a PKI token. This library is not an official library,
// nor is it endorsed by Google.

// ValidatePKIToken validates that the PKI token returned from an attestation service is valid.
// Returns a valid jwt.Token or returns an error if invalid.
func ValidatePKIToken(storedRootCertificate x509.Certificate, attestationToken string) (jwt.Token, error) {
  // IMPORTANT: The attestation token should be considered untrusted until the certificate chain and
  // the signature is verified.

  jwtHeaders, err := ExtractJWTHeaders(attestationToken)
  if err != nil {
    return jwt.Token{}, fmt.Errorf("ExtractJWTHeaders(token) returned error: %v", err)
  }

  if jwtHeaders["alg"] != "RS256" {
    return jwt.Token{}, fmt.Errorf("ValidatePKIToken(string, *attestpb.Attestation, *v1mainpb.VerifyAttestationRequest) - got Alg: %v, want: %v", jwtHeaders["alg"], "RS256")
  }

  // Additional Check: Validate the ALG in the header matches the certificate SPKI.
  // https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7
  // This is included in golangs jwt.Parse function

  x5cHeaders := jwtHeaders["x5c"].([]any)
  certificates, err := ExtractCertificatesFromX5CHeader(x5cHeaders)
  if err != nil {
    return jwt.Token{}, fmt.Errorf("ExtractCertificatesFromX5CHeader(x5cHeaders) returned error: %v", err)
  }

  // Verify the leaf certificate signature algorithm is an RSA key
  if certificates.LeafCert.SignatureAlgorithm != x509.SHA256WithRSA {
    return jwt.Token{}, fmt.Errorf("leaf certificate signature algorithm is not SHA256WithRSA")
  }

  // Verify the leaf certificate public key algorithm is RSA
  if certificates.LeafCert.PublicKeyAlgorithm != x509.RSA {
    return jwt.Token{}, fmt.Errorf("leaf certificate public key algorithm is not RSA")
  }

  // Verify the storedRootCertificate is the same as the root certificate returned in the token.
  // storedRootCertificate is downloaded from the confidential computing well known endpoint
  // https://confidentialcomputing.googleapis.com/.well-known/attestation-pki-root
  err = CompareCertificates(storedRootCertificate, *certificates.RootCert)
  if err != nil {
    return jwt.Token{}, fmt.Errorf("failed to verify certificate chain: %v", err)
  }

  err = VerifyCertificateChain(certificates)
  if err != nil {
    return jwt.Token{}, fmt.Errorf("VerifyCertificateChain(string, *attestpb.Attestation, *v1mainpb.VerifyAttestationRequest) - error verifying x5c chain: %v", err)
  }

  keyFunc := func(token *jwt.Token) (any, error) {
    return certificates.LeafCert.PublicKey, nil
  }

  verifiedJWT, err := jwt.Parse(attestationToken, keyFunc)
  return *verifiedJWT, err
}

// ExtractJWTHeaders parses the JWT and returns the headers.
func ExtractJWTHeaders(token string) (map[string]any, error) {
  parser := &jwt.Parser{}

  // The claims returned from the token are unverified at this point
  // Do not use the claims until the algorithm, certificate chain verification and root certificate
  // comparison is successful
  unverifiedClaims := &jwt.MapClaims{}
  parsedToken, _, err := parser.ParseUnverified(token, unverifiedClaims)
  if err != nil {
    return nil, fmt.Errorf("Failed to parse claims token: %v", err)
  }

  return parsedToken.Header, nil
}

// PKICertificates contains the certificates extracted from the x5c header.
type PKICertificates struct {
  LeafCert         *x509.Certificate
  IntermediateCert *x509.Certificate
  RootCert         *x509.Certificate
}

// ExtractCertificatesFromX5CHeader extracts the certificates from the given x5c header.
func ExtractCertificatesFromX5CHeader(x5cHeaders []any) (PKICertificates, error) {
  if x5cHeaders == nil {
    return PKICertificates{}, fmt.Errorf("VerifyAttestation(string, *attestpb.Attestation, *v1mainpb.VerifyAttestationRequest) - x5c header not set")
  }

  x5c := []string{}
  for _, header := range x5cHeaders {
    x5c = append(x5c, header.(string))
  }

  // The PKI token x5c header should have 3 certificates - leaf, intermediate and root
  if len(x5c) != 3 {
    return PKICertificates{}, fmt.Errorf("incorrect number of certificates in x5c header, expected 3 certificates, but got %v", len(x5c))
  }

  leafCert, err := DecodeAndParseDERCertificate(x5c[0])
  if err != nil {
    return PKICertificates{}, fmt.Errorf("cannot parse leaf certificate: %v", err)
  }

  intermediateCert, err := DecodeAndParseDERCertificate(x5c[1])
  if err != nil {
    return PKICertificates{}, fmt.Errorf("cannot parse intermediate certificate: %v", err)
  }

  rootCert, err := DecodeAndParseDERCertificate(x5c[2])
  if err != nil {
    return PKICertificates{}, fmt.Errorf("cannot parse root certificate: %v", err)
  }

  certificates := PKICertificates{
    LeafCert:         leafCert,
    IntermediateCert: intermediateCert,
    RootCert:         rootCert,
  }
  return certificates, nil
}

// DecodeAndParseDERCertificate decodes the given DER certificate string and parses it into an x509 certificate.
func DecodeAndParseDERCertificate(certificate string) (*x509.Certificate, error) {
  bytes, _ := base64.StdEncoding.DecodeString(certificate)

  cert, err := x509.ParseCertificate(bytes)
  if err != nil {
    return nil, fmt.Errorf("cannot parse certificate: %v", err)
  }

  return cert, nil
}

// DecodeAndParsePEMCertificate decodes the given PEM certificate string and parses it into an x509 certificate.
func DecodeAndParsePEMCertificate(certificate string) (*x509.Certificate, error) {
  block, _ := pem.Decode([]byte(certificate))
  if block == nil {
    return nil, fmt.Errorf("cannot decode certificate")
  }

  cert, err := x509.ParseCertificate(block.Bytes)
  if err != nil {
    return nil, fmt.Errorf("cannot parse certificate: %v", err)
  }

  return cert, nil
}

// VerifyCertificateChain verifies the certificate chain from leaf to root.
// It also checks that all certificate lifetimes are valid.
func VerifyCertificateChain(certificates PKICertificates) error {
  if isCertificateLifetimeValid(certificates.LeafCert) {
    return fmt.Errorf("leaf certificate is not valid")
  }

  if isCertificateLifetimeValid(certificates.IntermediateCert) {
    return fmt.Errorf("intermediate certificate is not valid")
  }
  interPool := x509.NewCertPool()
  interPool.AddCert(certificates.IntermediateCert)

  if isCertificateLifetimeValid(certificates.RootCert) {
    return fmt.Errorf("root certificate is not valid")
  }
  rootPool := x509.NewCertPool()
  rootPool.AddCert(certificates.RootCert)

  _, err := certificates.LeafCert.Verify(x509.VerifyOptions{
    Intermediates: interPool,
    Roots:         rootPool,
    KeyUsages:     []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
  })

  if err != nil {
    return fmt.Errorf("failed to verify certificate chain: %v", err)
  }

  return nil
}

func isCertificateLifetimeValid(certificate *x509.Certificate) bool {
  currentTime := time.Now()
  // check the current time is after the certificate NotBefore time
  if !currentTime.After(certificate.NotBefore) {
    return false
  }

  // check the current time is before the certificate NotAfter time
  if currentTime.Before(certificate.NotAfter) {
    return false
  }

  return true
}

// CompareCertificates compares two certificate fingerprints.
func CompareCertificates(cert1 x509.Certificate, cert2 x509.Certificate) error {
  fingerprint1 := sha256.Sum256(cert1.Raw)
  fingerprint2 := sha256.Sum256(cert2.Raw)
  if fingerprint1 != fingerprint2 {
    return fmt.Errorf("certificate fingerprint mismatch")
  }
  return nil
}

שילוב משאבי AWS

אתם יכולים לשלב את עומסי העבודה של Confidential Space עם משאבי AWS (כמו מפתחות או נתונים) באמצעות תגי principal של AWS. השילוב הזה משתמש באימות המאובטח שמסופק על ידי Confidential Space כדי להעניק גישה פרטנית למשאבי AWS.

תגי principal ב-AWS

Google Cloud Attestation יוצרת אסימוני זהות שניתנים לאימות ומכילים הצהרות לגבי השלמות וההגדרה של עומס העבודה ב-Confidential Space. קבוצת משנה של הטענות האלה תואמת ל-AWS, ומאפשרת לכם לשלוט בגישה למשאבי AWS. התלונות האלה מופיעות בתלונות https://aws.amazon.com/tags, באובייקט principal_tags באסימון האישור. מידע נוסף זמין במאמר בנושא הצהרות על תגים של חשבון משתמש ב-AWS.

דוגמה למבנה של הצהרת זכויות יוצרים https://aws.amazon.com/tags:

{
  "https://aws.amazon.com/tags": {
    "principal_tags": {
      "confidential_space.support_attributes": [
        "LATEST=STABLE=USABLE"
      ],
      "container.image_digest": [
        "sha256:6eccbcf1a1de8bf50aefbb37e8c3600d5b59f4a12cf7d964b6f8ef964b782eb2"
      ],
      "gce.project_id": [
        "confidentialcomputing-e2e"
      ],
      "gce.zone": [
        "us-west1-a"
      ],
      "hwmodel": [
        "GCP_AMD_SEV"
      ],
      "swname": [
        "CONFIDENTIAL_SPACE"
      ],
      "swversion": [
        "250101"
      ]
    }
  }
}

מדיניות AWS עם הצהרות על חתימות של קובצי אימג' של קונטיינרים

אפשר להשתמש בטוקנים של AWS גם כדי לאמת הצהרות חתימה של קובץ אימג' של קונטיינר. ההצהרות האלה מתאימות לשינויים תכופים בעומסי עבודה ולטיפול בכמה משתפי פעולה או צדדים מסתמכים.

תלונות על חתימות של קובצי אימג' של קונטיינרים כוללות מזהי מפתחות, שמופרדים באמצעות תו מפריד. כדי לכלול את ההצהרות האלה באסימון AWS, צריך לספק רשימת היתרים של מזהי המפתחות האלה כפרמטר נוסף בבקשת האסימון.

רק מזהי המפתחות שתואמים למפתחות שמשמשים לחתימה על עומס העבודה מתווספים לאסימון. התהליך הזה עוזר לוודא שרק חתימות מאושרות מתקבלות.

כשכותבים את מדיניות AWS, חשוב לזכור שמזהי המפתחות מתווספים לאסימון כמחרוזת אחת עם תווים להגדרת גבולות. צריך למיין את רשימת מזהי המפתחות בסדר אלפביתי ולבנות את ערך המחרוזת. לדוגמה, אם יש לכם מזהי מפתחות aKey1, zKey2 ו-bKey3, ערך ההצהרה התואם במדיניות צריך להיות aKey1=bKey3=zKey2.

כדי לתמוך בכמה קבוצות של מפתחות, אפשר להוסיף כמה ערכים למדיניות.

"aws:RequestTag/container.signatures.key_ids": [
  "aKey1=bKey3=zKey2",
  "aKey1=bKey3",
  "zKey2"
]

הצהרת חתימות של קובץ אימג' של קונטיינר (container.signatures.key_ids) והצהרת תקציר של קובץ אימג' של קונטיינר (container.image_digest) לא מופיעות יחד בטוקן יחיד. אם אתם משתמשים ב-container.signatures.key_ids, עליכם להסיר את כל ההפניות ל-container.image_digest ממדיניות AWS.

זוהי דוגמה למבנה של הצהרת זכויות יוצרים https://aws.amazon.com/tags שמכילה container.signatures.key_ids:

{
  "https://aws.amazon.com/tags": {
    "principal_tags": {
      "confidential_space.support_attributes": [
        "LATEST=STABLE=USABLE"
      ],
      "container.signatures.key_ids": [
        "keyid1=keyid2=keyid3"
      ],
      "gce.project_id": [
        "confidentialcomputing-e2e"
      ],
      "gce.zone": [
        "us-west1-a"
      ],
      "hwmodel": [
        "GCP_AMD_SEV"
      ],
      "swname": [
        "CONFIDENTIAL_SPACE"
      ],
      "swversion": [
        "250101"
      ]
    }
  }
}

הסבר מפורט יותר על השדות של אסימון האימות זמין במאמר הצהרות של אסימון אימות.

הגדרת משאבי AWS: צד נסמך

לפני שצד מסתמך יכול להגדיר את משאבי AWS שלו, הוא צריך להגדיר את AWS IAM כדי להגדיר את Confidential Space כספק OIDC מאוחד וליצור את תפקיד AWS IAM הנדרש.

הגדרת AWS IAM

  1. כדי להוסיף את שירות האימות של Google Cloud כספק זהויות ב-AWS IAM, מבצעים את הפעולות הבאות:

    1. במסוף AWS, עוברים לדף Identity providers.

      כניסה למסוף AWS

    2. בקטע סוג ספק, בוחרים באפשרות OpenID Connect.

    3. בשדה Provider URL (כתובת ה-URL של הספק), מזינים https://confidentialcomputing.googleapis.com.

    4. בשדה קהל, מזינים את כתובת ה-URL שרשמתם אצל ספק הזהויות וששולחת בקשות ל-AWS. לדוגמה, https://example.com.

    5. לוחצים על הוספת ספק.

  2. כדי ליצור תפקיד AWS IAM לאסימונים של Confidential Space:

    1. נכנסים לדף Roles במסוף AWS.

    2. לוחצים על יצירת תפקיד.

    3. בקטע Trusted entity type (סוג ישות מהימנה), בוחרים באפשרות Web identity (זהות באינטרנט).

    4. בקטע זהות באינטרנט, בוחרים את ספק הזהויות ואת קהל היעד בהתאם לשלב הקודם.

    5. לוחצים על הבא. אפשר לדלג על עריכת מדיניות AWS בשלב הזה.

    6. לוחצים על הבא ומוסיפים תגים לפי הצורך.

    7. בשדה שם התפקיד, מזינים את שם התפקיד.

    8. אופציונלי: בשדה תיאור, מזינים תיאור לתפקיד החדש.

    9. בודקים את הפרטים ולוחצים על יצירת תפקיד.

  3. עורכים את מדיניות AWS של התפקיד שיצרתם כדי להעניק גישה רק לעומס העבודה שבחרתם.

    מדיניות AWS הזו מאפשרת לכם לבדוק טענות ספציפיות בטוקן, כמו:

    • התקציר של קובץ האימג' של הקונטיינר של עומס העבודה.

    • הקהל שאליו מיועד האסימון.

    • התוכנה CONFIDENTIAL_SPACE פועלת במכונה הווירטואלית. מידע נוסף זמין במאמר swname בנושא הצהרות של אסימון אישור.

    • מאפיין התמיכה בתמונה של Confidential Space בייצור. מידע נוסף זמין במאמר confidential_space.support_attributes.

    בדוגמה הבאה מוצגת מדיניות AWS שמעניקה גישה לעומס עבודה עם תקציר וקהל יעד שצוינו, CONFIDENTIAL_SPACE בתור התוכנה שפועלת במכונה הווירטואלית ו-STABLE בתור מאפיין התמיכה:

    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Principal": {
            "Federated": "arn:aws:iam::232510754029:oidc-provider/confidentialcomputing.googleapis.com"
          },
          "Action": [
            "sts:AssumeRoleWithWebIdentity",
            "sts:TagSession"
          ],
          "Condition": {
            "StringEquals": {
              "confidentialcomputing.googleapis.com:aud": "https://integration.test",
              "aws:RequestTag/swname": "CONFIDENTIAL_SPACE",
              "aws:RequestTag/container.image_digest": "sha256:ac74cbeca443e36325bad15a7c28f2598b22966aa94681a444553f0b838717cf"
            },
            "StringLike": {
              "aws:RequestTag/confidential_space.support_attributes": "*STABLE*"
            }
          }
        }
      ]
    }
    

הגדרת משאבי AWS

אחרי שתשלימו את השילוב, תצטרכו להגדיר את משאבי AWS. השלב הזה תלוי בתרחיש השימוש הספציפי שלכם. לדוגמה, אתם יכולים ליצור קטגוריה ב-S3, מפתח KMS או משאבים אחרים ב-AWS. חשוב לוודא שהקציתם לתפקיד AWS IAM שיצרתם קודם את ההרשאות הנדרשות כדי לגשת למשאבים האלה.

הגדרת עומס העבודה ב-Confidential Space: מחבר עומס העבודה

כדי ליצור בקשות לאסימון, פועלים לפי ההוראות במאמר בנושא בקשת אסימון אימות עם קהל בהתאמה אישית.

לגבי תלונות על הפרת זכויות יוצרים:AWS_PrincipalTag

  • שדה nonce הוא אופציונלי בבקשת האסימון שלכם לשילוב עם AWS.

  • כוללים את הקהל שהגדרתם בקטע הגדרת משאבי AWS: צד נסמך.

  • מגדירים את token_type לערך AWS_PRINCIPALTAGS.

דוגמה לגוף בקשת הצהרה AWS_PrincipalTag:

body := `{
  "audience": "https://example.com",
  "token_type": "AWS_PRINCIPALTAGS",
}`

המאמרים הבאים

מידע נוסף על הצהרות של אסימוני אימות זמין במאמר הצהרות של אסימוני אימות.