Shine's dev log

[JAVA] 대칭키와 비대칭키를 활용한 채팅 프로그램 본문

기타

[JAVA] 대칭키와 비대칭키를 활용한 채팅 프로그램

dong1 2020. 8. 6. 19:07

0. 개요

 

https://github.com/godeastone/Chatting-Program

 

자바를 활용하여 1:1로 통신할 수 있는 채팅 프로그램을 개발하였다. 일반적인 채팅 프로그램이 아니라 암호화를 적용한 채팅 프로그램이다.

 

서버와 클라이언트는 대칭키 암호화(AES)를 통해 서로의 메시지를 암호화/복호화 하여 통신한다.

 

이 때 사용되는 대칭키(비밀키)는 클라이언트가 생성하여 비대칭키 암호화(RSA)를 통해 서버와 공유한다.

 

전체적은 구조는 아래 그림과 같다.

 

Structure

 

 

 

1. 채팅 프로그램 구현

 

채팅 프로그램이라는 것이 두 명 이상의 사용자가 서로 문자열을 주고받는 행위라고 할 수 있다. 본 프로그램에서는 두명의 사용자가 채팅을 하는 상황을 가정하는데, 한 명은 Server이고 다른 한명은 Client이다.

 

전체적으로 큰 틀을 살펴보면, 먼저 Server가 소켓을 생성하고 Client가 접속하기를 기다리면 Client 가 자신의 소켓을 생성하고, Server의 소켓에 접속을 한다. 그러면 ServerClient와 통신할 수 있는 소켓을 얻게 된다.

 

이후 ServerClient는 생성된 소켓을 통해 데이터를 보내기 위한 출력 스트림과 데이터를 읽기 위한 입력 스트림을 생성한다. 이렇게 스트림이 생성되면 ClientServer간에 데이터 송/수신을 할 수 있는 환경이 된다. 만들어진 환경에서 서로 통신을 하다가 더이상 통신할 내용이 없을 경우 ServerClient측에서 “exit”을 입력하면 “exit”을 받은 측에서 다시 상대방에게 “exit”을 날려주고, 소켓을 닫아 줌으로써 통신이 끝나게 된다.

 

이 때 ServerClient는 서로 언제든지 메시지를 송수신할 수 있어야 한다. 블로킹 소켓을 사용할 경우 단일 스레드 환경에서는 하나의 스레드가 입력 스트림과 출력 스트림을 모두 담당하는 것이 어려우므로 두개의 스레드를 이용하여 구현하였다.

 

지금부터는 위에서 설명한 큰 틀을 어떻게 코드로 구현했는지 살펴보겠다. 먼저 Server측의 코드를 살펴보자.

 

public class Server implements Runnable

앞서 말했듯이 채팅 프로그램을 구현할 때 두개의 쓰레드를 생성할 것이다. 이 프로그램에서는 Runnable 인터페이스를 구현하였다.

 

//Thread for write
t1 = new Thread(this);
//Thread for read
t2 = new Thread(this);

채팅을 쓰기를 담당할 쓰레드 t1과 채팅 읽기를 담당할 쓰레드 t2를 생성해준다.

 

serversocket = new ServerSocket(PORT);

위의 코드는 Server측에서 통신에 사용할 소켓을 만들어 주는 과정이다. PORT변수에는 통신에 사용할 포트 번호를 입력하면 된다.

 

socket = serversocket.accept();

위의 코드는 Client측에서 접속하기를 기다리는 코드이다. Client가 접속할 때까지 블로킹 된다.

 

t1.start();
t2.start();

이제 아까 만들어 놓은 두개의 쓰레드를 실행시킨다.

 

if(Thread.currentThread() == t1)

위의 코드를 이용하여 쓰레드를 분리한다. 먼저 쓰기를 담당하는 쓰레드를 살펴보자.

 

os = socket.getOutputStream();
outO = new ObjectOutputStream(os);
outO.writeObject(pub_RSA);
outO.flush();

위의 코드에서 osOutputStream 클래스이다. 생성된 소켓의 outputstreamos가 가지게 된다. 이후 outO라는 ObjectOutputStream 객체가 이 outputstream을 가지게 된다. outO를 통해서 생성된 RSA public keywrite 해주고 있다. 쓰고 난 뒤에는 flush() 함수를 통해 스트림 버퍼를 비워준다. 위의 코드는 프로그램을 시작하고 한 번만 수행해준다.

이후에 Server AES secret key를 받게 될 것이고, 이 키를 이용하여 본격적으로 채팅을 하면 된다. 채팅에서 쓰는 과정은 계속해서 반복될 것이므로 아래의 코드들은 모두 반복문 안에서 작동한다.

 

reader1 = new BufferedReader(new InputStreamReader(System.in));
writer = new PrintWriter(socket.getOutputStream(), true);

위의 코드에서 reader1 BufferedReader 클래스이고, writer PrintWriter 클래스이다. 먼저 reader1이 사용자의 입력을 담아주고 writer는 소켓의 출력 스트림에 출력을 해준다.

 

Long timeStamp = System.currentTimeMillis();
SimpleDateFormat sdf=new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss]");
String sd = sdf.format(
new Date(Long.parseLong(String.valueOf(timeStamp))));

위의 코드를 통해 메시지를 보낼 때 타임스탬프를 sd라는 문자열 변수에 현재 시각의 값을 넣어주었다.

 

in = reader1.readLine();
if(exit == true)  break;

in이라는 문자열 변수에 사용자의 입력 값을 reader1.readLine(); 코드를 통해 읽어온다. 이 때, 만약 exit라는 boolean 변수의 값이 참일 경우, 바로 반복문을 나가준다.

 

String send = "\"" + in +"\"" + " " + sd;
writer.println(send_encrypt);

Client에게 보낼 문자열인 send에 입력 받은 문자열인 in과 타임스탬프를 나타낸 문자열인 sd를 합쳐 하나의 문자열로 만들어준다. 그 후 소켓의 출력 스트림인 writer을 이용해 암호화된 send 문자열을 전송해준다.

 

다음으로 읽기를 수행하는 쓰레드 t2가 수행하는 코드를 살펴보자.

우선 처음에 읽기를 수행하는 쓰레드는 Client로부터 암호화된 AES비밀키를 받고, 복호화 하는 과정을 거친다.

 

is = socket.getInputStream();
inO = new ObjectInputStream(is);

is라는 InputStream이 소켓의 InputStream을 갖게 한다. 이후에 inO라는 ObjectInputStream 객체가 is를 가지게 한다. 이 스트림을 통해 암호화된 AES 비밀키를 받는다. 자세한 암/복호화 과정은 추후에 설명하겠다.

이제 Client로부터 문자열을 읽어 들이는 채팅부분을 살펴보자. 채팅을 읽어 들이는 부분은 계속해서 반복될 것이므로 아래의 코드들을 반복문 안에서 실행된다.

 

reader2 = new BufferedReader(
new InputStreamReader(socket.getInputStream()));
out = reader2.readLine();

위의 코드는 reader2라는 BufferedReader 클래스를 이용하여 소켓의 inputstream을 가지게 된다. out이라는 문자열 변수에 reader2로부터 읽어 들인(소켓으로부터 받은) 문자열을 저장한다.

이제 받은 문자열을 복호화 하여 콘솔에 출력해주면 된다. 이 때, exit라는 메시지를 받으면 연결을 종료 시켜야 한다. 만약 exit를 받은 경우 연결을 종료 시키는 방법은 다음과 같다.

 

writer = new PrintWriter(socket.getOutputStream(), true);
Long timeStamp = System.currentTimeMillis();
SimpleDateFormat sdf=new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss]");
String sd = sdf.format(new Date(Long.parseLong(String.valueOf(timeStamp))));
in = "exit";
String send = "\"" + in +"\"" + " " + sd;
writer.println(send_encrypt);

문자열을 읽어 들이는 쓰레드이지만, Client에게 연결 종료 메시지를 보내야 하므로, 위의 코드를 통해 새로운 PrintWriter 객체를 생성하여 Client에게 exit메시지와 타임스탬프를 보낸다.

이제 exit 변수를 false로 설정하고 반복문을 탈출한다.

 

if(Thread.currentThread() == t1) {
           socket.close();
           System.out.println("connection closed");
           System.out.println("##Program End##");
}

위의 코드는 반복문을 탈출한 t1쓰레드가 socket.close(); 코드를 통해 소켓을 종료 시킨 것을 나타낸다. 이 코드구간은 t1t2 모두 거쳐갈 것인데, 만약 두 쓰레드가 모두 socket.close()를 한다면 오류가 발생할 것이므로 t1을 지정하여 소켓을 종료 시켜준다.

 

소켓을 닫아도 t2 쓰레드는 사용자의 입력을 읽어 들이는 부분 (in = reader1.readLine();)에 블로킹 되어있을 것이다. 따라서 프로그램을 종료하려면 엔터키를 눌러 달라는 문구를 통해 ‘\n’를 입력 받으면 exit값이 false로 설정되어 있으므로 반복문을 탈출하고, 완벽하게 프로그램이 종료된다.

다음으로 Client측의 채팅프로그램 구현 코드를 살펴보아야 하는데, 사실 ServerClient의 채팅 프로그램 구현 구조는 동일하다.

 

단지, 읽기 쓰레드를 통해 Server로부터 받은 RSA public key를 이용해 생성한 AES secret key를 암호화하고, 쓰기 쓰레드를 통해 Server에게 보낸다는 점만 다르다. 따라서 Client의 채팅 프로그램 구현 코드는 Server의 채팅 프로그램 구현 코드를 참고하면 된다.

 

2. RSA를 이용한 키 교환

 

이 프로그램에서 RSA를 이용한 AES secret key교환이 일어나는 부분을 살펴보자.

##Server관점##

우선 RSA를 이용하려면 public key 하나와 private key하나가 필요하다. key pair를 만들 때 자바의 KeyPairGenerator 클래스를 이용하였다.

 

SecureRandom random = new SecureRandom();
KeyPairGenerator generator;
generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048, random);
keyPair = generator.generateKeyPair();

위의 코드는 ServerKeyPairGenerator 클래스를 이용하여 2048비트 RSA keypair를 생성하는 과정이다.

 

pub_RSA = keypair_RSA.getPublic();
priv_RSA = keypair_RSA.getPrivate();

위의 코드는 생성된 keypair 객체에서 public key private key를 얻는 과정이다.

이제 이렇게 생성된 public keypub_RSA를 소켓을 이용해 Client에게 보낸다. 이 과정은 위의 채팅프로그램 구현 항목에서 이미 설명하였다.

 

##Client 관점##

Client는 소켓을 통해 Server가 보낸 RSA public key를 받았다. 이제 AES secret key를 생성하여 Server에게 암호화하여 보내야한다.

 

KeyGenerator gen = KeyGenerator.getInstance("AES");
gen.init(256);
key = gen.generateKey();

위의 코드는 KeyGenerator 클래스를 사용하여 AES 256비트 키를 생성하는 과정이다.

이제 이 생성된 secret key를 방금 받은 RSA public key로 암호화해야 한다.

Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
encryptedSecret = cipher.doFinal(plaintext);

위의 코드는 byte 배열로 인코딩 된 secret key를 담은 변수 plaintextpublicKey로 암호화하는 과정이다. 생성된 공개키로 암호화된 secret keyencryptedSecret 역시byte배열의 형식이다.

이제 Client측에서 임의로 생성한 iv도 위와 같은 과정을 통해 RSA public key로 암호화해서 Server에게 소켓을 통해 보낸다.

 

##Server 관점##

Server는 소켓을 통해 RSA private key를 통해 암호화된 AES secret keyiv를 받을 것이다. 이제 RSA private key를 통해 복호화 시키면 된다.

 

Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, privateKey);
decrypted_RSA = cipher.doFinal(encrypted);

위의 코드는 받은 암호화된 secret keyiv를 복호화 하는 Decrypted_RSA메소드의 일부분이다. privateKey가 사용된 것을 확인할 수 있다. 복호화 된 결과값은 byte 배열 형태이다.

 

sKey =
new SecretKeySpec(Decrypt_RSA(encrypted_AESkey, priv_RSA), "AES");

위의 코드를 통해 원래의 AES secret key를 복호화 할 수 있다.

 

decrypted_iv = Decrypt_RSA(encrypted_iv, priv_RSA);
iv = new String(decrypted_iv, "UTF-8");

또한 위의 코드를 이용해 암호화된 iv로부터 원래의 iv를 복호화 할 수 있다.

위의 과정을 거치고 나면, ServerClient 모두 AES secret keyiv를 가지게 된다. 즉 키교환이 성공적으로 이루어졌다.

 

3. AES 암호화 통신

 

ServerClientwriting thread를 통해 문자열을 생성하고 AES로 암호화를 한 뒤 소켓을 통해 전송한다. 또한, reading thread를 통해 소켓으로부터 암호화된 문자열을 읽어 들이고 AES로 복호화를 한다.

 

String send_encrypt = Encrypt_AES(send, sKey);
writer.println(send_encrypt);

위의 코드는 writing threadEncrypt_AES 메소드를 통해 보내려는 문자열 send를 비밀키 sKey로 암호화하고 소켓으로 보내는 과정이다.

out = reader2.readLine();
String decrypted_out = Decrypt_AES(out, sKey);

위의 코드는 reading threadDecrypt_AES 메소드를 통해 읽으려는 문자열 out을 소켓으로 읽어 들이고 비밀키 sKey로 복호화 하는 과정이다.

이제 사용된 메소드 Encrypt_AESDecrypt_AES를 살펴보자.

 

public static String Encrypt_AES(String plaintext, SecretKey key){
           String result = null;
           try {
                        
           Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
           c.init(Cipher.ENCRYPT_MODE, key,
new IvParameterSpec(iv.getBytes()));
                      
           byte[] encrypted = c.doFinal(plaintext.getBytes("UTF-8"));
           result = new String(Base64.getEncoder().encode(encrypted));
                     } catch (Exception e) {
                                e.printStackTrace();
                     }
                     return result;
           }

위의 메소드는 Encrypt_AES이다. 받아들인 문자열을 Cipher객체를 통해 AES, CBC모드로 PKCS7 패딩을 설정한다. byte배열에 암호화 시킨 데이터를 저장하는데, 이 때 plaintext를 바이트 배열로 인코딩할 때 UTF-8 인코딩을 사용한다. 나온 결과를 문자열로 바꿔 리턴 해준다.

 

public static String Decrypt_AES(String ciphertext, SecretKey key) {
           String result = null;
           try {
           Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding");
           c.init(Cipher.DECRYPT_MODE, key,
new IvParameterSpec(iv.getBytes("UTF-8")));
           byte[] decrypted = Base64.getDecoder().decode
(ciphertext.getBytes("UTF-8"));
           result = new String(c.doFinal(decrypted), "UTF-8");
} catch (Exception e) {
                                e.printStackTrace();
                     }
                     return result;
           }

위의 메소드는 Decrypt_AES이다. 받은 암호문 문자열을 Cipher 객체를 통해 AES, CBC모드로 PKCS7 패딩을 설정한다. byte 배열에 ciphertext를 디코딩하고, c.doFinal로 복호화를 수행한다. 이 때 다시 문자열로 변환해주는데, utf-8 로 설정해준다.

 

 

4. 결론

 

우선 실제 실행결과는 다음과 같다.

 

result

 

이번 프로젝트를 수행하며RSAAES /복호화 방식과 같은 암/복호화 과정에 대한 이해가 부족하기 보다는 문자열이나 키를 암/복호화 하기 위해 byte배열로 변환하고 이를 암/복호화 하고 또 다시 인코딩하고 하는 등의 데이터 변환 과정에 대한 지식이 부족하여 이 부분에서 많이 헤맸다.

또한, 블로킹 방식의 소켓 통신을 하다 보니, 복잡하고 코드가 명료하지 못하게 구현했는데, 프로젝트를 진행하면서 non-blocking 방식의 소켓 통신이 있다는 것을 알게 되었다. 다음에는 non-blocking 방식의 소켓 통신을 활용하여 보다 명료하고 깔끔하게 구현하는 것이 더 바람직할 것 같다.

이번 프로젝트를 진행하며 실제 많이 쓰이는 RSAAES암호화를 그리 어렵지 않게 구현할 수 있다는 것을 알게 되었고, 다른 컴퓨터 분야도 이처럼 직접 구현해보면 더 보기보다 어렵지 않을 것이라는 것을 느꼈다. , 구현에 대한 두려움을 많이 깰 수 있었던 프로젝트였다.

 

 

 

5. 참고자료

 

https://www.baeldung.com/java-executor-wait-for-threads - 쓰레드 관리 방법

https://stackoverflow.com/questions/22719106/java-client-server-chatting-program

- 채팅 프로그램 기본 틀 참고

https://offbyone.tistory.com/346 - 자바 RSA 구현

https://docs.oracle.com/javase/tutorial/security/apisign/vstep2.html - 키 인코딩 방법

https://stackoverflow.com/questions/5355466/converting-secret-key-into-a-string-and-vice-versa

- 키를 문자열로 바꾸는 방법

https://lktprogrammer.tistory.com/62 - 소켓 프로그래밍

http://www.javased.com/index.php?api=java.text.SimpleDateFormat타임 스탬프 출력 방법