For the complete documentation index, see llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
Navigation principale
Theme

Déchiffrement des enregistrements PSP

Lisez le contenu client conservé à l’aide de votre clé gérée dans le cloud

Le déchiffrement PSP vous permet de lire le contenu des enregistrements PSP conservés dans votre stockage cloud connecté à l’aide d’une clé asymétrique que vous contrôlez.

Une fois que vous disposez de l’enregistrement et des métadonnées de sa clé d’origine, vous pouvez le déchiffrer à l’aide de votre service cloud de gestion des clés et de code exécuté localement. Vous n’avez pas besoin d’appeler une API d’OpenAI.

Ce guide s’applique à ZDR avec PSP. Il ne s’applique pas à Private Retention avec PSP, qui conserve les enregistrements sur une infrastructure gérée par OpenAI.

Votre service cloud de gestion des clés déchiffre une petite capsule de récupération. Votre application utilise le secret ainsi récupéré pour déchiffrer l’enregistrement localement. Votre clé privée reste dans votre service cloud de gestion des clés.

L’annexe présente la structure du paquet de données de l’enregistrement et un exemple en Python.

Avant de commencer

  • Activez le déchiffrement PSP, connectez votre stockage et sélectionnez une clé de récupération. Consultez les guides Déchiffrement PSP et Configuration des clés asymétriques.
  • Autorisez votre application à lire l’objet stocké et à effectuer des opérations de déchiffrement avec la clé de récupération RSA-3072 d’origine de l’enregistrement.
  • Conservez les informations d’enregistrement initiales de la clé, la clé publique et la version exacte de la clé cloud tant que des enregistrements en dépendent.

Votre clé peut déchiffrer uniquement les enregistrements écrits alors que le déchiffrement PSP était activé pour cette clé. Les enregistrements existants ne sont pas convertis.

Déchiffrez un enregistrement

1. Lisez l’enregistrement

Lisez l’enregistrement depuis votre stockage cloud. S’il comporte une enveloppe de stockage EKM, déchiffrez d’abord cette couche à l’aide de sa configuration EKM d’origine.

Analysez le paquet JSON obtenu. Il contient protected_record et customer_access, comme l’illustre la section Format du paquet. Si customer_access vaut null, le déchiffrement PSP n’est pas disponible pour cet enregistrement.

2. Sélectionnez la clé d’origine

Utilisez customer_access.customer_key_id pour retrouver la clé cloud d’origine et sa version dans les métadonnées que vous avez conservées lors de l’enregistrement de la clé. Vous pouvez aussi récupérer les informations d’enregistrement via l’API d’administration en appelant GET /v1/organization/external_keys/{customer_key_id} avec une clé API d’administration de l’organisation. Consultez la section Récupération des informations d’enregistrement de la clé.

Vérifiez l’empreinte de la clé publique et utilisez la version exacte de la clé cloud d’origine. Conservez la clé privée dans votre service cloud de gestion des clés. Ne la communiquez jamais à OpenAI.

3. Déchiffrez la capsule de récupération

Envoyez la valeur décodée de customer_access.recovery_secret à votre service cloud de gestion des clés pour qu’il la déchiffre avec RSA-OAEP-SHA256.

4. Déchiffrez et vérifiez le contenu

Utilisez l’exemple en Python pour vérifier que la capsule est bien liée à l’enregistrement et déchiffrer le contenu localement.

Ne renvoyez le contenu qu’une fois toutes les vérifications et l’authentification AES-GCM réussies.

Vérifiez le résultat

Si l’opération réussit, elle renvoie un contenu authentifié encodé en UTF-8. Les enregistrements de requête contiennent request_text ainsi que des métadonnées qui indiquent si le contenu a été tronqué. Consultez la section Contenu récupéré.

Dépannage

Résultat Points à vérifier
customer_access vaut null Le déchiffrement PSP n’était activé pour aucune clé de récupération lors de l’écriture de l’enregistrement. Sélectionner une clé n’active pas le déchiffrement PSP pour cet enregistrement.
Accès au KMS refusé ou clé indisponible Vérifiez que vous disposez de l’autorisation de déchiffrement et que la version d’origine de la clé cloud est disponible.
Empreinte incorrecte ou échec de la vérification du lien entre la capsule et l’enregistrement Vérifiez que vous utilisez le bon enregistrement et les informations d’enregistrement initiales de la clé. Ne modifiez pas les champs authentifiés et ne remplacez pas la clé par une autre.
Échec de l’authentification AES-GCM Vérifiez les données fournies à l’exemple en Python. Ne renvoyez aucune partie du contenu en clair.

Guides associés

Annexe : format des données et référence de déchiffrement

Cette annexe décrit la version 1 du paquet et la version v4 de l’enregistrement protégé. Utilisez l’exemple en Python ci-dessous pour mettre en œuvre le déchiffrement local sans les bibliothèques d’OpenAI.

Format du paquet

L’objet est un document JSON encodé en UTF-8 qui présente la structure suivante. Les valeurs entre chevrons sont à remplacer.

{
  "bundle_version": 1,
  "protected_record": {
    "version": "v4",
    "algorithm": "HPKE-v1-DHKEM_P256_HKDF_SHA256-EXPORT_ONLY-AES256_GCM",
    "scope": "global",
    "key_thumbprint": "<runtime public-key thumbprint>",
    "expires_at": "2026-11-20",
    "content_id": "<content ID>",
    "encrypted_dek": "<E: base64url P-256 public point>",
    "safety_review_runtime_public_key": "<R: base64url P-256 public point>",
    "nonce": "<base64url 12-byte nonce>",
    "encrypted_cot": ["<opaque encrypted CoT string>"],
    "ciphertext": "<base64url ciphertext followed by GCM tag>"
  },
  "customer_access": {
    "version": 1,
    "algorithm": "RSA-OAEP-256",
    "customer_key_id": "<original registration ID>",
    "customer_public_key_fingerprint": "<RSA public-key fingerprint>",
    "recovery_secret": "<base64url RSA ciphertext>"
  }
}

encrypted_cot contient le raisonnement détaillé (« chain-of-thought ») sous forme chiffrée. Votre clé de récupération client ne permet pas de déchiffrer ce contenu. Le tableau peut être vide.

Exemple en Python

Utilisez l’exemple ci-dessous après avoir déchiffré l’éventuelle enveloppe de stockage EKM distincte, puis la capsule de récupération avec la clé cloud d’origine. Avant d’authentifier et de déchiffrer la charge utile, le code vérifie l’empreinte de la clé, le lien entre la capsule et l’enregistrement, le scalaire récupéré et l’empreinte de la clé de l’environnement d’exécution.

Installez pyhpke, cryptography et rfc8785 dans votre environnement Python. Passez les arguments suivants à recover_record :

  • bundle_json : le paquet interne de l’enregistrement, sous forme d’octets JSON encodés en UTF-8.
  • capsule : les octets renvoyés par votre service cloud de gestion des clés.
  • original_key_id : l’identifiant d’enregistrement de la clé d’origine obtenu à l’étape 2.
  • original_public_key_pem : la clé publique RSA correspondant à cette version de la clé cloud, au format PEM, sous forme d’octets.

La fonction renvoie le contenu déchiffré sous forme de chaîne UTF-8. Si une vérification échoue, la récupération s’arrête.

Déchiffrement d’un enregistrement PSP
import base64
import hashlib
import hmac
import json

import rfc8785
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pyhpke import AEADId, CipherSuite, KDFId, KEMId, KEMKey, KEMKeyPair


def recover_record(bundle_json, capsule, original_key_id, original_public_key_pem):
    """Return plaintext from exact inner bundle bytes and a decrypted capsule.

    Use the documented schema and trusted original key ID/RSA public PEM.
    No cloud calls occur here. The RSA private key stays in the cloud.
    """
    record, access, runtime, nonce, ciphertext = validate_bundle(
        bundle_json, original_key_id, original_public_key_pem
    )
    e = recover_scalar(record, access, capsule)

    ephemeral = ec.derive_private_key(e, ec.SECP256R1())
    pair = KEMKeyPair(
        KEMKey.from_pyca_cryptography_key(ephemeral),
        KEMKey.from_pyca_cryptography_key(ephemeral.public_key()),
    )
    suite = CipherSuite.new(
        KEMId.DHKEM_P256_HKDF_SHA256, KDFId.HKDF_SHA256, AEADId.EXPORT_ONLY
    )
    enc, sender = suite.create_sender_context(
        KEMKey.from_pyca_cryptography_key(runtime),
        info=b"OpenAI-PSP-v4/HPKE-export",
        eks=pair,
    )
    require(enc == decode64(record["encrypted_dek"]), "Wrong recovered scalar")
    aad = rfc8785.dumps({k: v for k, v in record.items() if k != "ciphertext"})
    key = sender.export(b"OpenAI-PSP-v4/payload-key" + hashlib.sha256(aad).digest(), 32)
    return AESGCM(key).decrypt(nonce, ciphertext, aad).decode("utf-8")


def validate_bundle(bundle_json, original_key_id, original_public_key_pem):
    bundle = json.loads(bundle_json.decode("utf-8"), object_pairs_hook=unique_object)
    require(
        type(bundle["bundle_version"]) is int and bundle["bundle_version"] == 1,
        "Unsupported bundle version",
    )
    record, access = bundle["protected_record"], bundle["customer_access"]
    require(access is not None, "Customer recovery is unavailable")
    require(
        record["version"] == "v4"
        and record["algorithm"]
        == "HPKE-v1-DHKEM_P256_HKDF_SHA256-EXPORT_ONLY-AES256_GCM",
        "Unsupported record",
    )
    require(
        type(access["version"]) is int
        and access["version"] == 1
        and access["algorithm"] == "RSA-OAEP-256",
        "Unsupported recovery capsule",
    )
    require(access["customer_key_id"] == original_key_id, "Wrong key registration")
    require(len(decode64(access["recovery_secret"])) == 384, "Wrong RSA length")

    public_key = serialization.load_pem_public_key(original_public_key_pem)
    require(isinstance(public_key, rsa.RSAPublicKey), "Expected an RSA public key")
    require(public_key.key_size == 3072, "Expected RSA-3072")
    spki = public_key.public_bytes(
        serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
    )
    fingerprint = decode64(access["customer_public_key_fingerprint"])
    require(
        hmac.compare_digest(fingerprint, hashlib.sha256(spki).digest()), "Wrong key"
    )
    runtime = point(record["safety_review_runtime_public_key"])
    coordinates = runtime.public_numbers()
    jwk = {
        "crv": "P-256",
        "kty": "EC",
        "x": b64url(coordinates.x.to_bytes(32, "big")),
        "y": b64url(coordinates.y.to_bytes(32, "big")),
    }
    thumbprint = b64url(hashlib.sha256(rfc8785.dumps(jwk)).digest())
    require(record["key_thumbprint"] == thumbprint, "Wrong runtime key")
    nonce, ciphertext = decode64(record["nonce"]), decode64(record["ciphertext"])
    require(len(nonce) == 12 and len(ciphertext) >= 16, "Invalid AES-GCM encoding")
    return record, access, runtime, nonce, ciphertext


def recover_scalar(record, access, capsule):
    header = {k: v for k, v in access.items() if k != "recovery_secret"}
    binding_input = {
        "purpose": "OpenAI-PSP-v4/customer-recovery-binding",
        "bundle_version": 1,
        "record": record,
        "recovery_header": header,
    }
    binding = hashlib.sha256(rfc8785.dumps(binding_input)).digest()
    require(
        isinstance(capsule, bytes)
        and len(capsule) == 72
        and capsule[:8] == b"PSPv4\x00\x00\x01"
        and hmac.compare_digest(capsule[40:], binding),
        "Invalid recovery capsule binding",
    )
    e = int.from_bytes(capsule[8:40], "big")
    order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
    require(1 <= e < order, "Invalid P-256 private scalar")
    return e


def require(condition, message):
    if not condition:
        raise ValueError(message)


def unique_object(pairs):
    require(len(pairs) == len(dict(pairs)), "Duplicate JSON field")
    return dict(pairs)


def b64url(data):
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")


def decode64(value):
    # Restore omitted "=" padding: -len(value) % 4 is how many characters
    # are needed to reach a multiple of four (e.g. length 10 needs "==").
    data = base64.b64decode(
        value + "=" * (-len(value) % 4), altchars=b"-_", validate=True
    )
    require(b64url(data) == value, "Noncanonical base64url")
    return data


def point(value):
    encoded = decode64(value)
    require(len(encoded) == 65 and encoded[0] == 4, "Invalid P-256 encoding")
    return ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), encoded)

Contenu récupéré

Pour les enregistrements de requête dont le JSON récupéré contient format: "psp_request_v1", le champ request_text contient le texte de la requête qui a été conservé. Les autres champs sont reasoning_summaries (tableau de chaînes), request_text_truncated (booléen) et reasoning_summary_parts_omitted (entier supérieur ou égal à zéro). Vérifiez les champs relatifs à la troncature pour interpréter le résultat. D’autres types de contenu protégé peuvent utiliser des formats différents pour les données en clair.

Vous pouvez continuer à récupérer le contenu après l’expiration de la clé de l’environnement d’exécution d’OpenAI, à condition que votre clé de récupération client d’origine et votre instantané restent disponibles. Ne modifiez pas la valeur authentifiée de expires_at. Les blocs de données encrypted_cot, chiffrés séparément, restent illisibles lors de la récupération côté client.