종단 간 암호화

종단 간 암호화 (End-to-End Encryption)

애플리케이션은 Pulsar 종단 간 암호화(E2EE)를 사용해 프로듀서 측에서 메시지를 암호화하고 컨슈머 측에서 복호화할 수 있어요. 애플리케이션이 구성하는 공개·개인 키 쌍으로 암호화·복호화를 수행해요. 유효한 키를 가진 컨슈머만 암호화된 메시지를 복호화할 수 있어요. 이번에는 Pulsar에서 E2EE가 어떻게 동작하는지, 어떻게 설정하는지 함께 살펴볼게요.

출처: 문서

본문

Pulsar에서 종단 간 암호화가 동작하는 방식 (How end-to-end encryption works in Pulsar)

Pulsar는 동적으로 생성된 대칭 세션 키를 사용해 메시지(데이터)를 암호화해요. 애플리케이션이 제공하는 ECDSA(Elliptic Curve Digital Signature Algorithm) 또는 RSA(Rivest–Shamir–Adleman) 키 쌍으로 세션 키(데이터 키)를 암호화할 수 있어서, 모든 사람에게 비밀을 공유할 필요가 없어요.

아래 그림은 Pulsar가 프로듀서 측에서 메시지를 암호화하고 컨슈머 측에서 복호화하는 방식을 보여줘요.

Pulsar의 종단 간 암호화 워크플로는 다음과 같아요.

  1. 프로듀서가 주기적으로(4시간마다 또는 일정 수의 메시지를 게시한 후) 세션 키를 생성해 AES 같은 대칭 알고리즘으로 메시지 페이로드를 암호화하고, 4시간마다 비대칭 공개 키를 가져와요. 암호문은 메시지 본문으로 패킹돼요.
  2. 프로듀서가 RSA 같은 비대칭 알고리즘으로 컨슈머의 공개 키를 사용해 세션 키를 암호화하고, 암호화된 시크릿이 있는 별칭(alias)을 메시지 헤더에 추가해요.
  3. 컨슈머가 메시지 헤더를 읽고 개인 키로 세션 키를 복호화해요.
  4. 컨슈머가 복호화된 세션 키로 메시지 페이로드를 복호화해요.

note

  • 컨슈머의 공개 키는 프로듀서와 공유되지만, 개인 키에 접근할 수 있는 것은 컨슈머뿐이에요.
  • Pulsar는 Pulsar 서비스 어디에도 암호화 키를 저장하지 않아요. 개인 키를 잃어버리거나 삭제하면 메시지는 영구적으로 손실되어 복구할 수 없어요.

Pulsar는 키 관리를 격리하고 공개 키에 접근하는 인터페이스(CryptoKeyReader)만 제공해요. 프로덕션 시스템에서는 클라우드 키 관리(KMS 또는 CKM)나 PKI(공개 키 인프라, 예: freeIPA)로 CryptoKeyReader를 확장/구현하는 것을 강력히 권장해요.

생성된 메시지가 애플리케이션 경계를 넘어 소비된다면, 다른 애플리케이션의 컨슈머가 메시지를 복호화할 수 있는 개인 키 중 하나에 접근할 수 있도록 해야 해요. 두 가지 방법이 있어요.

  • 컨슈머 애플리케이션이 제공하는 공개 키에 접근해 프로듀서의 키에 추가해요.
  • 프로듀서가 사용하는 키 쌍 중 하나의 개인 키에 접근 권한을 부여해요.

시작하기 (Get started)

Pulsar에서 종단 간 암호화를 활성화하려면 다음 단계를 완료해요.

사전 준비 (Prerequisites)

  • Pulsar Java/Python/C++/Node.js 클라이언트 2.7.1 이상 버전.
  • Pulsar Go 클라이언트 0.6.0 이상 버전.

1단계: 종단 간 암호화 구성 (Step 1: Configure end-to-end encryption)

  1. 공개·개인 키 쌍을 모두 만들어요.

    • ECDSA (Java, Go 클라이언트용)
    • RSA (Python, C++, Node.js 클라이언트용)
openssl ecparam -name secp521r1 -genkey -param_enc explicit -out test_ecdsa_privkey.pem
openssl ec -in test_ecdsa_privkey.pem -pubout -outform pem -out test_ecdsa_pubkey.pem
openssl genrsa -out test_rsa_privkey.pem 2048
openssl rsa -in test_rsa_privkey.pem -pubout -outform PEM -out test_rsa_pubkey.pem
  1. 프로듀서, 컨슈머, 리더에 CryptoKeyReader를 구성해요.
  • Java
  • Python
  • C++
  • Go
  • Node.js

Java:

PulsarClient pulsarClient = PulsarClient.builder().serviceUrl("pulsar://localhost:6650").build();
String topic = "persistent://my-tenant/my-ns/my-topic";
// RawFileKeyReader is just an example implementation that's not provided by Pulsar
CryptoKeyReader keyReader = new RawFileKeyReader("test_ecdsa_pubkey.pem", "test_ecdsa_privkey.pem");
Producer<byte[]> producer = pulsarClient.newProducer()
     .topic(topic)
     .cryptoKeyReader(keyReader)
     .addEncryptionKey("myappkey")
     .create();
Consumer<byte[]> consumer = pulsarClient.newConsumer()
     .topic(topic)
     .subscriptionName("my-subscriber-name")
     .cryptoKeyReader(keyReader)
     .subscribe();
Reader<byte[]> reader = pulsarClient.newReader()
     .topic(topic)
     .startMessageId(MessageId.earliest)
     .cryptoKeyReader(keyReader)
     .create();

Python:

from pulsar import Client, CryptoKeyReader
client = Client('pulsar://localhost:6650')
topic = 'my-topic'
# CryptoKeyReader is a built-in implementation that reads public key and private key from files
key_reader = CryptoKeyReader('test_rsa_pubkey.pem', 'test_rsa_privkey.pem')
producer = client.create_producer(
    topic=topic,
    encryption_key='myappkey',
    crypto_key_reader=key_reader)
consumer = client.subscribe(
    topic=topic,
    subscription_name='my-subscriber-name',
    crypto_key_reader=key_reader)
reader = client.create_reader(
    topic=topic,
    start_message_id=MessageId.earliest,
    crypto_key_reader=key_reader)
client.close()

C++:

Client client("pulsar://localhost:6650");
std::string topic = "persistent://my-tenant/my-ns/my-topic";
// DefaultCryptoKeyReader is a built-in implementation that reads public key and private key from files
auto keyReader = std::make_shared<DefaultCryptoKeyReader>("test_rsa_pubkey.pem", "test_rsa_privkey.pem");
Producer producer;
ProducerConfiguration producerConf;
producerConf.setCryptoKeyReader(keyReader);
producerConf.addEncryptionKey("myappkey");
client.createProducer(topic, producerConf, producer);
Consumer consumer;
ConsumerConfiguration consumerConf;
consumerConf.setCryptoKeyReader(keyReader);
client.subscribe(topic, "my-subscriber-name", consumerConf, consumer);
Reader reader;
ReaderConfiguration readerConf;
readerConf.setCryptoKeyReader(keyReader);
client.createReader(topic, MessageId::earliest(), readerConf, reader);

Go:

client, err := pulsar.NewClient(pulsar.ClientOptions{
  URL: "pulsar://localhost:6650",
})
if err != nil {
    log.Fatal(err)
}
defer client.Close()
topic := "persistent://my-tenant/my-ns/my-topic"
keyReader := crypto.NewFileKeyReader("test_ecdsa_pubkey.pem", "test_ecdsa_privkey.pem")
producer, err := client.CreateProducer(pulsar.ProducerOptions{
    Topic: topic,
    Encryption: &pulsar.ProducerEncryptionInfo{
    	KeyReader: keyReader,
    	Keys:      []string{"myappkey"},
    },
})
if err != nil {
	log.Fatal(err)
}
defer producer.Close()
consumer, err := client.Subscribe(pulsar.ConsumerOptions{
    Topic:            topic,
    SubscriptionName: "my-subscriber-name",
    Decryption: &pulsar.MessageDecryptionInfo{ 
	   KeyReader: keyReader,
    },
})
if err != nil {
    log.Fatal(err)
}
defer consumer.Close()
reader, err := client.CreateReader(pulsar.ReaderOptions{
    Topic: topic,
    Decryption: &pulsar.MessageDecryptionInfo{ 
	   KeyReader: keyReader,
    },
})
if err != nil {
    log.Fatal(err)
}
defer reader.Close()

Node.js:

const Pulsar = require('pulsar-client');
const topic = 'persistent://my-tenant/my-ns/my-topic';
(async () => {
// Create a client
const client = new Pulsar.Client({
    serviceUrl: 'pulsar://localhost:6650',
    operationTimeoutSeconds: 30,
});
// Create a producer
const producer = await client.createProducer({
    topic: topic,
    sendTimeoutMs: 30000,
    batchingEnabled: true,
    publicKeyPath: "test_rsa_pubkey.pem",
    encryptionKey: "encryption-key"
});
// Create a consumer
const consumer = await client.subscribe({
    topic: topic,
    subscription: 'my-subscriber-name',
    subscriptionType: 'Shared',
    ackTimeoutMs: 10000,
    privateKeyPath: "test_rsa_privkey.pem"
});
await consumer.close();
await producer.close();
await client.close();
})();
  1. 선택 사항: CryptoKeyReader 구현을 커스터마이즈해요.
  • Java
  • Python
  • C++
  • Go
  • Node.js

Java:

class RawFileKeyReader implements CryptoKeyReader {
 String publicKeyFile = "";
 String privateKeyFile = "";
 RawFileKeyReader(String pubKeyFile, String privKeyFile) {
     publicKeyFile = pubKeyFile;
     privateKeyFile = privKeyFile;
 }
 @Override
 public EncryptionKeyInfo getPublicKey(String keyName, Map<String, String> keyMeta) {
     EncryptionKeyInfo keyInfo = new EncryptionKeyInfo();
     try {
         keyInfo.setKey(Files.readAllBytes(Paths.get(publicKeyFile)));
     } catch (IOException e) {
         System.out.println("ERROR: Failed to read public key from file " + publicKeyFile);
         e.printStackTrace();
     }
     return keyInfo;
 }
 @Override
 public EncryptionKeyInfo getPrivateKey(String keyName, Map<String, String> keyMeta) {
     EncryptionKeyInfo keyInfo = new EncryptionKeyInfo();
     try {
         keyInfo.setKey(Files.readAllBytes(Paths.get(privateKeyFile)));
     } catch (IOException e) {
         System.out.println("ERROR: Failed to read private key from file " + privateKeyFile);
         e.printStackTrace();
     }
     return keyInfo;
 }
}

현재 Python에서는 CryptoKeyReader 구현 커스터마이즈를 지원하지 않아요. 하지만 개인 키와 공개 키 경로를 지정해 기본 구현을 사용할 수 있어요.

C++:

class CustomCryptoKeyReader : public CryptoKeyReader {
 public:
 Result getPublicKey(const std::string& keyName, std::map<std::string, std::string>& metadata,
                     EncryptionKeyInfo& encKeyInfo) const override {
     // TODO
     return ResultOk;
 }
 Result getPrivateKey(const std::string& keyName, std::map<std::string, std::string>& metadata,
                     EncryptionKeyInfo& encKeyInfo) const override {
     // TODO
     return ResultOk;
 }
};

Go:

type CustomKeyReader struct {
    publicKeyPath  string
    privateKeyPath string
}
func (c *CustomKeyReader) PublicKey(keyName string, keyMeta map[string]string) (*EncryptionKeyInfo, error) {
    keyInfo := &EncryptionKeyInfo{}
    // TODO
    return keyInfo, nil
}
// PrivateKey read private key from the given path
func (c *CustomKeyReader) PrivateKey(keyName string, keyMeta map[string]string) (*EncryptionKeyInfo, error) {
    keyInfo := &EncryptionKeyInfo{}
    // TODO
    return keyInfo, nil
}

현재 Node.js 클라이언트에서는 CryptoKeyReader 구현 커스터마이즈를 지원하지 않아요. 하지만 개인 키와 공개 키 경로를 지정해 기본 구현을 사용할 수 있어요.

2단계: 여러 키로 메시지 암호화 (Step 2: Encrypt a message with multiple keys)

note 이 기능은 Java 클라이언트에서만 사용할 수 있어요.

메시지를 둘 이상의 키로 암호화할 수 있어요. 프로듀서가 그러한 모든 키를 구성에 추가하고, 컨슈머는 키 중 하나에만 접근할 수 있으면 메시지를 복호화할 수 있어요. 메시지 암호화에 사용된 키 중 하나라도 메시지를 복호화하기에 충분해요.

예를 들어 2개 키(myapp.messagekey1, myapp.messagekey2)로 메시지를 암호화하려면:

PulsarClient.newProducer().addEncryptionKey("myapp.messagekey1").addEncryptionKey("myapp.messagekey2");

문제 해결 (Troubleshoot)

  • 프로듀서/컨슈머가 키에 대한 접근 권한을 잃음
    • 프로듀서 동작은 실패 원인을 나타내지 못해요. 이런 경우 애플리케이션은 암호화되지 않은 메시지를 계속 보내는 옵션을 가질 수 있어요. PulsarClient.newProducer().cryptoFailureAction(ProducerCryptoFailureAction)을 호출해 프로듀서 동작을 제어해요. 기본 동작은 요청을 실패시키는 것이에요.
    • 컨슈머에서 복호화 실패나 키 누락으로 소비가 실패하면, 애플리케이션은 암호화된 메시지를 소비하거나 폐기하는 옵션을 가질 수 있어요. PulsarClient.newConsumer().cryptoFailureAction(ConsumerCryptoFailureAction)을 호출해 컨슈머 동작을 제어해요. 기본 동작은 요청을 실패시키는 것이에요. 개인 키가 영구적으로 유실되면 애플리케이션은 절대 메시지를 복호화할 수 없어요.
  • 배치 메시징
    • 복호화가 실패하고 메시지에 배치 메시지가 포함되어 있으면 클라이언트는 배치의 개별 메시지를 검색할 수 없어요. 따라서 cryptoFailureAction()ConsumerCryptoFailureAction.CONSUME으로 설정되어 있어도 메시지 소비가 실패해요.
    • 복호화가 실패하면 메시지 소비가 중지되고, 애플리케이션은 클라이언트 로그의 복호화 실패 메시지와 함께 백로그(backlog) 증가를 알게 돼요. 애플리케이션이 메시지를 복호화할 개인 키에 접근할 수 없다면 유일한 선택지는 백로그된 메시지를 건너뛰거나 폐기하는 것이에요.

더 알아보기 (Learn more)

  • 전송 계층 암호화는 TLS transport 문서를 참고해요.
  • BouncyCastle 의존성 구성은 Bouncy Castle 문서를 봐요.
  • 인증과 권한 부여의 기본 개념은 Security overview 문서를 살펴봐요.