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에 적용됩니다. OpenAI가 관리하는 인프라에 레코드를 저장하는 Private Retention with PSP에는 적용되지 않습니다.

클라우드 키 관리 서비스는 작은 복구 캡슐을 복호화합니다. 애플리케이션은 캡슐에서 복구한 비밀값으로 로컬에서 레코드를 복호화합니다. 개인 키는 클라우드 키 관리 서비스에 계속 보관됩니다.

이 가이드의 부록에서 레코드 번들 형식과 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 키로 GET /v1/organization/external_keys/{customer_key_id}를 호출해 Admin API에서 등록 정보를 조회할 수도 있습니다. 키 등록 정보 조회를 참고하세요.

공개 키 지문을 확인하고 원래 등록 정보와 정확히 일치하는 클라우드 키와 버전을 사용하세요. 개인 키는 클라우드 키 관리 서비스에 보관하고 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(0 이상의 정수)입니다. 결과를 해석할 때는 요청 텍스트가 잘렸는지, 추론 요약의 일부가 생략되었는지 해당 필드에서 확인하세요. 다른 유형의 보호된 콘텐츠는 평문 형식이 다를 수 있습니다.

원래 고객 복구 키와 스냅샷을 계속 사용할 수 있으면 OpenAI 런타임 키가 만료된 후에도 고객이 콘텐츠를 복구할 수 있습니다. 인증 대상인 expires_at 값은 변경하지 마세요. 별도로 암호화된 encrypted_cot 블롭의 내용은 고객 복구 키로 확인할 수 없습니다.