For the complete documentation index, see llms.txt. Markdown versions of documentation pages are available by appending .md to the page URL.
メインナビゲーション
Theme

PSP レコードの復号

クラウドで管理する鍵を使った保存済み顧客コンテンツの読み取り

PSP 復号では、自分で管理する非対称鍵を使って、接続済みのクラウドストレージに保存された PSP レコードの内容を読み取れます。

レコードと、そのレコードに使われた鍵のメタデータがあれば、クラウドの鍵管理サービスとローカルで実行するコードで復号できます。OpenAI API を呼び出す必要はありません。

このガイドは、PSP を併用する ZDR が対象です。PSP を併用する Private Retention では、OpenAI が管理するインフラにレコードが保存されるため、このガイドは適用されません。

クラウドの鍵管理サービスが小さな復元用カプセルを復号します。アプリケーションは、カプセルから取り出したシークレットを使ってレコードをローカルで復号します。秘密鍵はクラウドの鍵管理サービス内に留まります。

付録にレコードバンドルの形式と Python のコード例を掲載しています。

事前準備

  • PSP 復号を有効にし、ストレージを接続して、復元鍵を選択します。PSP 復号と非対称鍵の設定を参照してください。
  • 保存されたオブジェクトを読み取る権限と、レコードの書き込み時に指定した RSA-3072 復元鍵で復号する権限をアプリケーションに付与してください。
  • レコードの復号に必要な間は、元の登録情報と公開鍵を保持し、クラウド上の鍵も該当するバージョンのまま保持してください。

ある鍵で復号できるのは、その鍵での PSP 復号が有効な状態で書き込まれたレコードだけです。既存のレコードは変換されません。

レコードの復号手順

1. レコードの読み取り

クラウドストレージからレコードを読み取ります。レコードが EKM ストレージエンベロープで保護されている場合は、まず元の EKM 設定でその暗号化層を復号してください。

こうして得られた JSON バンドルを解析します。バンドルには protected_record と customer_access が含まれます。構造はバンドル形式を参照してください。customer_access が null の場合、そのレコードでは PSP 復号を利用できません。

2. 元の鍵の選択

customer_access.customer_key_id を使って、保存しておいた登録メタデータから元のクラウド鍵とそのバージョンを特定します。組織の Admin API キーを使い、Admin API で GET /v1/organization/external_keys/{customer_key_id} を呼び出して登録情報を取得することもできます。鍵の登録情報の取得を参照してください。

公開鍵のフィンガープリントを確認し、登録情報と一致するクラウド鍵の該当バージョンを使ってください。秘密鍵はクラウドの鍵管理サービス内に保管し、OpenAI とは決して共有しないでください。

3. 復元用カプセルの復号

customer_access.recovery_secret をデコードしてクラウドの鍵管理サービスに送信し、そのサービスで RSA-OAEP-SHA256 による復号を行います。

4. コンテンツの復号と検証

Python のコード例に従って、カプセルが対象のレコードに紐付いていることを検証し、コンテンツをローカルで復号します。

すべての検証と AES-GCM 認証に成功した場合にのみ、コンテンツを返してください。

結果の確認

処理に成功すると、認証済みの UTF-8 コンテンツが返されます。リクエストレコードには request_text と、切り詰めの有無を示すメタデータが含まれます。復元されたコンテンツを参照してください。

トラブルシューティング

結果 確認事項
customer_access が null レコードの書き込み時に、復元鍵を使った PSP 復号が有効になっていませんでした。後から鍵を選択しても、そのレコードの PSP 復号は有効になりません。
KMS へのアクセスが拒否された、または鍵を利用できない 復号の権限があることと、元のクラウド鍵の該当バージョンが利用できることを確認してください。
フィンガープリントが一致しない、またはカプセルが対象のレコードに紐付いていない 対象のレコードと元の登録情報を確認してください。認証対象のフィールドを変更したり、別の鍵に差し替えたりしないでください。
AES-GCM 認証に失敗する Python のコード例に渡す入力を確認してください。平文は一部でも返さないでください。

関連ガイド

付録:データ形式と復号のリファレンス

この付録では、バンドルのバージョン 1 と保護対象レコードのバージョン v4 を扱います。以下の Python のコード例を使うと、OpenAI のライブラリを使わずにローカルでの復号を実装できます。

バンドル形式

オブジェクトは、次の構造を持つ UTF-8 の JSON です。山括弧で囲んだ部分はプレースホルダーです。

{
  "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 には、思考の連鎖の内容が暗号化されて格納されています。この内容は顧客の復元鍵では復号できません。配列は空の場合もあります。

Python のコード例

別途 EKM ストレージエンベロープがある場合はそれを復号し、元のクラウド鍵で復元用カプセルを復号してから、以下のコード例を使ってください。このコードでは、鍵のフィンガープリント、カプセルとレコードの紐付け、復元したスカラー値、ランタイム公開鍵のサムプリントを検証したうえで、ペイロードを認証して復号します。

Python 環境に pyhpke、cryptography、rfc8785 をインストールしてください。recover_record には次の引数を渡します。

  • bundle_json:内側のレコードバンドルを UTF-8 の JSON で表したバイト列
  • capsule:クラウドの鍵管理サービスから返されたバイト列
  • original_key_id:手順 2 で特定した元の鍵の登録 ID
  • original_public_key_pem:該当するバージョンのクラウド鍵に対応する RSA 公開鍵を PEM 形式で表したバイト列

この関数は、復号したコンテンツを UTF-8 文字列として返します。いずれかの検証に失敗すると、復元処理は停止します。

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)

復元されたコンテンツ

リクエストレコードから復元した JSON に format: "psp_request_v1" がある場合、request_text には保存されていたリクエストのテキストが含まれます。ほかに reasoning_summaries(文字列の配列)、request_text_truncated(ブール値)、reasoning_summary_parts_omitted(非負整数)の各フィールドがあります。結果を解釈する際は、切り詰めに関するフィールドを確認してください。ほかの種類の保護対象コンテンツでは、平文の形式が異なる場合があります。

顧客側で元の復元鍵とスナップショットを引き続き利用できれば、OpenAI のランタイム鍵の有効期限が切れた後も復元できます。認証対象の expires_at の値は変更しないでください。別途暗号化されている encrypted_cot の各 BLOB は、顧客側でレコードを復元しても内容を読み取れません。