종단 간 암호화
종단 간 암호화 (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의 종단 간 암호화 워크플로는 다음과 같아요.
- 프로듀서가 주기적으로(4시간마다 또는 일정 수의 메시지를 게시한 후) 세션 키를 생성해 AES 같은 대칭 알고리즘으로 메시지 페이로드를 암호화하고, 4시간마다 비대칭 공개 키를 가져와요. 암호문은 메시지 본문으로 패킹돼요.
- 프로듀서가 RSA 같은 비대칭 알고리즘으로 컨슈머의 공개 키를 사용해 세션 키를 암호화하고, 암호화된 시크릿이 있는 별칭(alias)을 메시지 헤더에 추가해요.
- 컨슈머가 메시지 헤더를 읽고 개인 키로 세션 키를 복호화해요.
- 컨슈머가 복호화된 세션 키로 메시지 페이로드를 복호화해요.
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)
-
공개·개인 키 쌍을 모두 만들어요.
- 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
- 프로듀서, 컨슈머, 리더에
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();
})();
- 선택 사항:
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 문서를 살펴봐요.