종단 간 필드 암호화

요청 body의 특정 필드를 옥텟이 발급한 공개키로 암호화해 전송할 수 있습니다.
옥텟이 복호화해 원래 필드로 되돌려 놓고 기존 로직을 그대로 수행합니다.
요청 body에 encryptedFields 필드가 있으면 종단간 필드 암호화를 사용하는 것으로 판단합니다.

암호화는 엔드유저 앱에서 하고 고객사 서버가 그대로 중계할 수도 있고, 고객사 서버가 직접 할 수도 있습니다. 두 경우 모두 암호화한 지점부터 옥텟까지 평문이 드러나지 않습니다.

TIP
이 기능은 선택 사항입니다. encryptedFields 없이 호출하면 기존과 동일하게 동작합니다.
API 전문 암호화와 함께 사용할 수 있습니다. 전문 암호화는 고객사 서버가 장기 대칭키로 요청 전체를 암호화하는 기능이고, 종단간 필드 암호화는 요청 때마다 발급받는 유효기간이 짧은 공개키로 특정 필드만 암호화하는 기능입니다.


지원 API와 암호화할 수 있는 필드

암호화 대상 필드를 body로 받는 모든 API에서 사용할 수 있습니다.

암호화할 수 있는 필드사용하는 API
pin자식주소 생성, 자식주소 PIN 등록·수정, 출금 신청, NFT 출금 신청 등 PIN을 받는 모든 API
recoveryPin자식주소 생성, 자식주소 PIN 등록·수정·삭제

위 목록 밖의 필드를 암호화하면 요청이 거절됩니다(ERR_0459004). 암호화 필드의 값은 문자열이어야 합니다. 대상 필드는 앞으로 확대될 예정입니다.


동작 흐름

단계주체하는 일
1고객사 서버종단간 필드 암호화 키 발급 API를 호출해 { kid, publicKey, alg, enc, maxUseCount, expiresAt }를 받습니다. 키는 발급 후 5분간 유효하고, 그 안에는 횟수 제한 없이 사용할 수 있습니다.
2암호화 주체 (엔드유저 앱 또는 고객사 서버){ "pin": "...", "recoveryPin": "..." } JSON을 publicKey로 JWE 암호화해 문자열 하나를 만듭니다. 엔드유저 앱이 암호화하는 경우 고객사 서버가 kid와 publicKey를 앱에 전달합니다.
3고객사 서버기존 API body에서 pin, recoveryPin 대신 encryptedFields 필드로 전송합니다.
4옥텟kid로 개인키를 찾아 복호화합니다. 복호화된 필드를 body에 되돌려 놓고 기존 로직을 수행합니다. 사용 횟수를 제한한 키라면 남은 횟수를 하나 줄이고, 다 쓰면 즉시 폐기합니다.

종단간 필드 암호화하기

1. 키 발급

고객사 서버가 종단간 필드 암호화 키 발급 API(POST /2.0/e2e-field-encryption/keys)를 호출합니다.
body는 없어도 되고, 사용 횟수를 제한하려면 maxUseCount를 보냅니다.

// 요청 body (선택)
{
    "maxUseCount": 10
}
// 응답
{
    "kid": "7f3a2c9e-5b1d-4e8a-9f7d-2c5e8a1b4d6f",
    "publicKey": {
        "kty": "EC",
        "crv": "P-256",
        "x": "Vji7TZu5c-g8e7aH1CwaCRXrsaWNQCDOlc7rS6ZMcfg",
        "y": "mI_nTSD-lRIad79h-ZvTHBaVgO0vMykRbocX7_QZ4DM",
        "kid": "7f3a2c9e-5b1d-4e8a-9f7d-2c5e8a1b4d6f",
        "alg": "ECDH-ES+A256KW",
        "use": "enc"
    },
    "alg": "ECDH-ES+A256KW",
    "enc": "A256GCM",
    "maxUseCount": 10,
    "expiresAt": "2026-09-10T10:05:00.000Z"
}
  • publicKey는 JWK(RFC 7517) 형식의 EC P-256 공개키입니다. JOSE 라이브러리에 그대로 import합니다.
  • 키는 expiresAt(발급 후 5분)이 지나면 폐기됩니다. 그 안에는 횟수 제한 없이 여러 요청에 사용할 수 있습니다.
  • 사용 횟수를 제한하려면 maxUseCount(1 이상의 정수)를 함께 보냅니다. 지정한 횟수를 모두 쓰면 유효기간이 남아 있어도 키는 즉시 폐기됩니다.
  • 사용 횟수는 옥텟이 개인키를 꺼내는 시점에 줄어듭니다. ERR_0459001처럼 encryptedFields 형식이 잘못되어 키를 찾기 전에 거절된 요청은 횟수를 쓰지 않지만, 복호화·검증 단계에서 실패한 요청(ERR_0459003~ERR_0459005)이나 PIN이 틀린 요청은 횟수를 소비합니다.
  • 엔드유저 앱에서 암호화하는 경우, PIN 입력 화면을 열 때 발급받아 앱에 전달하는 것을 권장합니다.

2. 암호화

암호화할 필드들을 JSON 객체로 묶어 JWE(RFC 7516) compact serialization으로 암호화합니다.
JSON의 키 이름이 곧 어떤 필드를 암호화했는지의 선언입니다.

항목값
형식JWE compact serialization (헤더.암호화된키.IV.암호문.태그)
algECDH-ES+A256KW (P-256)
encA256GCM
kid키 발급 응답의 kid (protected header에 포함 필수)
평문문자열 값만 가진 JSON 객체. 예: {"pin":"123456","recoveryPin":"654321"}

암호학을 직접 구현할 필요는 없습니다. 발급받은 JWK와 평문을 JOSE 라이브러리에 넣으면 됩니다.
라이브러리가 메시지마다 임시 EC 키를 만들어 옥텟 공개키와 키 합의를 하고(그 임시 공개키는 헤더 epk에 자동으로 들어갑니다), 본문은 AES-256-GCM으로 암호화합니다.

// jose: 4.13 이상 (4.15.9로 검증. 브라우저·React Native·Node.js 공통)
import { CompactEncrypt, importJWK } from 'jose';

// 키 발급 API 응답의 publicKey
const publicKey = {
    kty: 'EC',
    crv: 'P-256',
    x: 'Vji7TZu5c-g8e7aH1CwaCRXrsaWNQCDOlc7rS6ZMcfg',
    y: 'mI_nTSD-lRIad79h-ZvTHBaVgO0vMykRbocX7_QZ4DM',
    kid: '7f3a2c9e-5b1d-4e8a-9f7d-2c5e8a1b4d6f',
    alg: 'ECDH-ES+A256KW',
    use: 'enc',
};

// 암호화할 필드만 넣는다 (자식주소 생성이면 pin + recoveryPin, 출금이면 pin)
const fields = { pin: '123456', recoveryPin: '654321' };

const key = await importJWK(publicKey, 'ECDH-ES+A256KW');
const encryptedFields = await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(fields)))
    .setProtectedHeader({ alg: 'ECDH-ES+A256KW', enc: 'A256GCM', kid: publicKey.kid })
    .encrypt(key);
// eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiI3ZjNhMmM5ZS0...  (약 440자)
// nimbus-jose-jwt: 9.37.3 (JDK 17). Android에서도 동일하게 사용할 수 있습니다.
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.ECDHEncrypter;
import com.nimbusds.jose.jwk.ECKey;

// 키 발급 API 응답의 publicKey(JWK) JSON 문자열
String publicKeyJson = "{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"Vji7TZu5c-g8e7aH1CwaCRXrsaWNQCDOlc7rS6ZMcfg\",\"y\":\"mI_nTSD-lRIad79h-ZvTHBaVgO0vMykRbocX7_QZ4DM\",\"kid\":\"7f3a2c9e-5b1d-4e8a-9f7d-2c5e8a1b4d6f\",\"alg\":\"ECDH-ES+A256KW\",\"use\":\"enc\"}";

// 암호화할 필드만 넣는다 (자식주소 생성이면 pin + recoveryPin, 출금이면 pin)
String fieldsJson = "{\"pin\":\"123456\",\"recoveryPin\":\"654321\"}";

ECKey jwk = ECKey.parse(publicKeyJson);
JWEHeader header = new JWEHeader.Builder(JWEAlgorithm.ECDH_ES_A256KW, EncryptionMethod.A256GCM)
    .keyID(jwk.getKeyID())
    .build();
JWEObject jwe = new JWEObject(header, new Payload(fieldsJson));
jwe.encrypt(new ECDHEncrypter(jwk));

String encryptedFields = jwe.serialize();
// eyJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJt...  (약 440자)

같은 평문도 암호화할 때마다 결과가 다릅니다(메시지마다 임시 키·난수 AES 키·IV를 사용). TypeScript와 Java의 헤더 필드 순서가 달라 출력 형태가 다르지만, 옥텟은 둘 다 동일하게 처리합니다.

3. 옥텟으로 전송

encryptedFields 문자열을 기존 API body에 암호화한 필드 대신 넣어 호출합니다. endpoint와 나머지 필드는 기존과 같습니다.

// 자식주소 생성 API Payload
// 기존
{
    "offset": 1,
    "pin": "123456",
    "recoveryPin": "654321"
}

// 종단간 필드 암호화 사용 시
{
    "offset": 1,
    "encryptedFields": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiI..."
}
// 출금 신청 API Payload (종단간 필드 암호화 사용 시)
{
    "symbol": "ETH",
    "requestId": "user1-amount0.001-symbolETH-202609031830",
    "amount": "0.001",
    "senderAddress": "0x55Bc4ba7A86A00C8A1A09C59DcC32eA2E7FAbCd4",
    "receiverAddress": "0xee3c0f0AF227dA8DaccbF3Bc33bCf20f0a222aEF",
    "encryptedUserKey": "rsa encrypted userkey",
    "encryptedFields": "eyJhbGciOiJFQ0RILUVTK0EyNTZLVyIsImVuYyI6IkEyNTZHQ00iLCJraWQiOiI..."
}
  • 암호화한 필드(pin, recoveryPin)를 평문으로 함께 보내면 요청이 거절됩니다(ERR_0459005).
  • encryptedUserKey는 고객사가 옥텟 RSA 공개키로 직접 암호화하는 값이며, 종단간 필드 암호화의 대상이 아닙니다. 이름이 비슷하지만 사용하는 키와 알고리즘이 다르므로 기존처럼 별도 필드로 전송합니다.
  • API 전문 암호화를 함께 사용하는 경우, encryptedFields가 포함된 body를 전문 암호화하고 HMAC도 그 body로 계산합니다.
  • 옥텟은 encryptedFields가 어떤 요청에 쓰이는지는 검증하지 않습니다(키가 유효한 동안 어느 API 요청에든 붙일 수 있음). 엔드유저 앱에서 암호화하는 경우, 고객사 서버는 앱이 보낸 encryptedFields를 그 유저가 요청한 API 호출에만 사용해야 합니다.
  • 사용 횟수를 제한하지 않은 키는 유효기간 동안 같은 encryptedFields를 여러 번 보낼 수 있습니다. 자식주소를 지갑마다 만드는 것처럼 같은 PIN으로 여러 번 호출해야 할 때 편리하지만, 출금처럼 반복 실행되면 안 되는 요청에는 maxUseCount: 1을 지정하거나 사용 후 키를 폐기하는 것을 권장합니다.

키 조회

발급한 키의 남은 사용 횟수와 만료 시각을 확인할 수 있습니다. 키 조회 API(GET /2.0/e2e-field-encryption/keys/{kid})를 호출합니다.
조회는 사용 횟수를 소비하지 않습니다.

// 응답
{
    "kid": "7f3a2c9e-5b1d-4e8a-9f7d-2c5e8a1b4d6f",
    "maxUseCount": 10,
    "remainingUseCount": 7,
    "expiresAt": "2026-09-10T10:05:00.000Z"
}
  • 사용 횟수를 제한하지 않은 키는 maxUseCount와 remainingUseCount가 모두 null입니다.
  • 만료되었거나 사용 횟수를 모두 소진해 폐기된 키는 조회할 수 없습니다(ERR_0459006).
  • 공개키는 발급할 때만 내려주므로 이 API로는 조회할 수 없습니다.

키 폐기

사용을 마친 키를 유효기간이 지나기 전에 없앨 수 있습니다. 키 폐기 API(DELETE /2.0/e2e-field-encryption/keys/{kid})를 호출합니다.

// 응답
{
    "result": true
}
  • 사용 횟수를 제한하지 않은 키를 쓸 때, 필요한 호출을 모두 마쳤다면 폐기해 두는 것을 권장합니다.
  • 이미 만료되었거나 사용 횟수를 모두 소진해 폐기된 키는 ERR_0459006으로 응답합니다.

에러 코드

코드상황대응
ERR_0459001encryptedFields 형식이 올바르지 않음. JWE compact가 아니거나, alg/enc가 ECDH-ES+A256KW/A256GCM이 아니거나, kid 또는 epk가 없음라이브러리 설정(alg, enc, kid)을 확인합니다.
ERR_0459002암호화에 쓴 키를 찾을 수 없음. 만료(5분)되었거나 사용 횟수를 모두 소진했거나 이미 폐기한 키키를 다시 발급받아 다시 암호화합니다.
ERR_0459003복호화 실패. 다른 키로 암호화했거나 값이 변조되었거나, 평문이 문자열 값만 가진 JSON 객체가 아님발급받은 publicKey로 암호화했는지, 평문 형식을 확인합니다.
ERR_0459004암호화할 수 없는 필드가 포함됨암호화 가능 필드(pin, recoveryPin)만 넣습니다.
ERR_0459005암호화한 필드를 평문으로도 함께 보냄body에서 평문 필드를 제거합니다.
ERR_0459006키 조회·폐기 API에서 해당 kid의 키를 찾을 수 없음만료·소진되었거나 이미 폐기된 키입니다. 필요하면 키를 새로 발급받습니다.

요청이 실패한 뒤 다시 시도할 때, 사용 횟수를 제한하지 않은 키는 유효기간이 남아 있으면 같은 키로 다시 암호화하면 됩니다. 다만 실패한 요청도 사용 횟수를 소비하므로, maxUseCount를 지정한 키가 소진되었거나 ERR_0459002를 받았다면 키를 새로 발급받아야 합니다.


테스트

JWE는 매번 난수를 사용하므로 "이 값이 나와야 한다"는 고정 검증값을 제공하지 않습니다.
테스트 환경에서 키 발급, 암호화, API 호출을 순서대로 실제로 수행해 검증해 주세요. 구현이 잘못된 경우 위 에러 코드로 원인을 확인할 수 있습니다.


Did this page help you?