MQTT와 AES-256으로 만드는 나만의 보안 웹 채팅 프로그램: Talk

**보안형 MQTT 채팅 프로그램(Talk)**의 주요 기능과 구현 시 고려했던 핵심 보안 사항들입니다.

스크린샷, 클릭시 새 탭에서 실행

아래에서 직접 실행해 보기


🛠️ 주요 기능 요약

구분기능 명칭상세 내용
접속 제어Web-based Loginprompt 대신 웹 UI를 통해 암호 키를 입력받아 보안성과 사용자 편의성 동시 확보
통신 보안End-to-End EncryptionCryptoJS를 이용해 메시지를 AES-256 방식으로 암호화하여 전송
경로 은닉Dynamic Topic Hashing암호 키를 SHA-256으로 해싱하여 고유한 토픽(data_...) 생성
익명성Auto Nickname형용사와 명사 조합에 랜덤 ID를 붙여 사용자 익명성 보장 (예: 다정한 피자)
안정성Auto Reconnect연결이 끊겼을 때 3초 후 자동으로 재연결을 시도하는 로직 포함
리소스Local Library외부 CDN 의존도를 낮추기 위해 JS 파일을 서버 로컬 경로로 설정

🔐 구현 시 고려했던 핵심 보안 사항

1. 데이터 평문 노출 차단 (AES-256 암호화)

공용 MQTT 브로커(EMQX)를 사용하면 누군가 토픽을 구독하여 대화 내용을 가로챌 수 있다는 점이 가장 큰 위협이었습니다.

  • 해결: 메시지가 브라우저를 떠나기 전 암호화하고, 받는 쪽에서만 복호화하게 하여 브로커 관리자조차 내용을 알 수 없게 설계했습니다.
  • 데이터 타입 변경: 메시지 유형을 chat이 아닌 일반적인 string으로 정의하여 MQTT Explorer 상에서도 일반 데이터 스트림처럼 보이게 위장했습니다.

2. 대화방 경로의 기밀성 (SHA-256 토픽 생성)

고정된 토픽 주소를 사용하면 누군가 지속적으로 모니터링할 위험이 있었습니다.

  • 해결: 사용자가 입력한 비밀번호를 그대로 토픽으로 쓰지 않고, **해시값(Hash)**으로 변환하여 사용했습니다.
  • 효과: 비밀번호를 모르는 사람은 우리가 어느 방(Topic)에서 대화하는지 경로조차 유추할 수 없습니다.

3. 클라이언트 사이드 보안 (Memory-only Key)

비밀번호를 소스 코드에 저장하거나 하드코딩하면 보안이 무의미해집니다.

  • 해결: SECRET_KEY를 브라우저의 변수(메모리)에만 저장하고 페이지를 새로고침하거나 닫으면 즉시 삭제되도록 구현했습니다.
  • 로컬 라이브러리: 스크립트 파일을 본인 서버(micro2iot.com)에서 불러오게 하여, 외부 서버 해킹을 통한 스크립트 변조(XSS 공격) 가능성을 차단했습니다.

4. 사후 흔적 제거 (Will Message & No History)

  • 유언 메시지(Last Will): 사용자가 비정상적으로 종료되었을 때 브로커가 자동으로 퇴장 메시지를 보내도록 설정하여 세션 관리를 깔끔하게 했습니다.
  • 기록 비저장: 별도의 데이터베이스를 두지 않아, 브라우저를 닫는 순간 모든 대화 기록이 휘발되어 물리적인 증거가 남지 않습니다.

HTML chat.html

<!DOCTYPE html>
<html lang="ko">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
        <title>Talk - Secure MQTT Chat</title>
        <link rel="icon" type="image/webp" href="favicon.webp" />
        <script src="mqttws31.min.js"></script>
        <script src="crypto-js.min.js"></script>
        <style>
            :root {
                --bg-color: #f0f2f5;
                --container-bg: #ffffff;
                --text-color: #1c1c1e;
                --msg-other-bg: #f2f2f7;
                --msg-my-bg: #3498db;
                --msg-my-text: #ffffff;
                --header-bg: #f8f9fa;
                --input-bg: #ffffff;
                --border-color: #eee;
                --sb-track: #f0f2f5;
                --sb-thumb: #ccc;
            }

            [data-theme='dark'] {
                --bg-color: #121212;
                --container-bg: #1e1e1e;
                --text-color: #e0e0e0;
                --msg-other-bg: #2c2c2e;
                --msg-my-bg: #2980b9;
                --msg-my-text: #ffffff;
                --header-bg: #000000;
                --input-bg: #2c2c2e;
                --border-color: #38383a;
                --sb-track: #1e1e1e;
                --sb-thumb: #444;
            }

            * {
                box-sizing: border-box;
                transition: background 0.3s, color 0.1s;
            }
            body {
                margin: 0;
                padding: 10px;
                font-family: sans-serif;
                display: flex;
                height: 100vh;
                background: var(--bg-color);
                gap: 8px;
                overflow: hidden;
                color: var(--text-color);
            }

            #main-chat {
                flex: 3;
                display: flex;
                flex-direction: column;
                min-width: 0;
            }
            #user-list-panel {
                flex: 1.2;
                border-left: 2px solid var(--border-color);
                padding-left: 8px;
                background: var(--container-bg);
                font-size: 0.8rem;
                overflow-y: auto;
                border-radius: 8px;
            }

            .theme-switch {
                position: fixed;
                top: 6px;
                right: 35px;
                z-index: 1001;
                cursor: pointer;
                font-size: 20px;
                background: none;
                border: none;
            }

            #status-bar {
                font-size: 0.75rem;
                padding: 8px;
                border-bottom: 2px solid var(--msg-my-bg);
                display: flex;
                flex-direction: column;
                gap: 4px;
                background: var(--header-bg);
                border-radius: 8px 8px 0 0;
            }
            .status-line {
                display: flex;
                justify-content: space-between;
                align-items: center;
            }
            .topic-info {
                color: #888;
                font-family: monospace;
                background: var(--msg-other-bg);
                padding: 2px 5px;
                border-radius: 3px;
                font-size: 0.7rem;
            }

            #chat-box {
                flex: 1;
                overflow-y: auto;
                margin: 8px 0;
                padding: 10px;
                font-size: 0.85rem;
                background: var(--container-bg);
                border: 1px solid var(--border-color);
                border-radius: 4px;
            }
            #chat-box::-webkit-scrollbar {
                width: 6px;
            }
            #chat-box::-webkit-scrollbar-thumb {
                background: var(--sb-thumb);
                border-radius: 10px;
            }

            .msg-row {
                margin-bottom: 8px;
                word-break: break-all;
                line-height: 1.4;
            }
            .msg-time {
                font-size: 0.7rem;
                color: #888;
                margin-right: 5px;
                font-family: monospace;
            }
            .msg-user {
                font-weight: bold;
                margin-right: 3px;
            }
            .sys-msg {
                color: #888;
                font-style: italic;
                font-size: 0.75rem;
                text-align: center;
                margin: 8px 0;
                border-top: 1px dashed var(--border-color);
                padding-top: 5px;
            }

            .input-area {
                display: flex;
                gap: 4px;
            }
            input {
                flex: 1;
                padding: 10px;
                border: 1px solid var(--border-color);
                border-radius: 4px;
                font-size: 0.9rem;
                outline: none;
                background: var(--input-bg);
                color: var(--text-color);
            }
            button {
                padding: 8px 15px;
                background: var(--msg-my-bg);
                color: white;
                border: none;
                border-radius: 4px;
                cursor: pointer;
                font-weight: bold;
            }

            #loginOverlay {
                position: fixed;
                top: 0;
                left: 0;
                width: 100%;
                height: 100%;
                background: var(--bg-color);
                display: flex;
                justify-content: center;
                align-items: center;
                z-index: 1000;
            }
            .login-box {
                background: var(--container-bg);
                padding: 30px;
                border-radius: 15px;
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
                width: 340px;
                text-align: center;
            }
            .info-box {
                font-size: 11px;
                color: #888;
                background: var(--msg-other-bg);
                padding: 10px;
                border-radius: 8px;
                margin-bottom: 15px;
                text-align: left;
                line-height: 1.5;
            }
            .login-label {
                display: block;
                text-align: left;
                font-size: 0.75rem;
                margin: 10px 0 3px 2px;
                color: #7f8c8d;
                font-weight: bold;
            }

            .user-item {
                padding: 6px;
                margin: 4px 0;
                border-radius: 4px;
                background: var(--msg-other-bg);
                color: var(--msg-my-bg);
                font-weight: bold;
                font-size: 0.75rem;
            }

            /* 요청하신 Footer 스타일 적용 */
            .footer-msg {
                position: fixed;
                bottom: 18px;
                right: 60px;
                font-size: 0.65rem;
                color: #888;
                text-align: center;
                z-index: 999;
            }
            .footer-msg a {
                color: var(--msg-my-bg);
                text-decoration: none;
                font-weight: bold;
                display: block;
            }
        </style>
    </head>
    <body>
        <button class="theme-switch" onclick="toggleTheme()">☀️</button>

        <div id="loginOverlay">
            <div class="login-box">
                <h2 style="margin-top: 0; color: var(--msg-my-bg)">Secure Connect</h2>
                <div class="info-box">
                    🇰🇷
                    <b>같은 비밀번호</b>
                    로 접속하면 같은 방에서 대화할 수 있습니다.
                    <br />
                    🇺🇸 Users with the
                    <b>same password</b>
                    enter the same room.
                </div>

                <span class="login-label">Nickname (대화명)</span>
                <input type="text" id="nickInput" style="margin-bottom: 10px; width: 100%" placeholder="대화명 입력..." />

                <span class="login-label">Password (비밀번호)</span>
                <input type="password" id="passInput" value="public_chat" style="width: 100%" placeholder="비밀번호 입력..." onkeypress="if(event.keyCode==13) enterChat()" />

                <button onclick="enterChat()" style="width: 100%; margin-top: 15px; height: 45px">접속하기</button>
            </div>
        </div>

        <div id="main-chat">
            <div id="status-bar">
                <div class="status-line">
                    <span id="user-display" style="font-weight: bold">연결 중...</span>
                    <span id="net-stat" style="color: #e74c3c; font-size: 0.7rem">● Offline</span>
                </div>
                <div class="status-line">
                    <span id="topic-display" class="topic-info">Topic: Waiting...</span>
                </div>
            </div>
            <div id="chat-box"></div>
            <div class="input-area">
                <input type="text" id="msg-input" placeholder="메시지 입력..." onkeypress="if(event.keyCode==13) send()" />
                <button onclick="send()">전송</button>
            </div>
        </div>

        <div id="user-list-panel">
            <div style="padding: 10px; border-bottom: 1px solid var(--border-color)">
                <strong style="color: #7f8c8d">
                    접속자 (
                    <span id="user-count">0</span>
                    )
                </strong>
            </div>
            <div id="user-list" style="padding: 8px"></div>
        </div>

        <div class="footer-msg">
            Powered by
            <a href="https://micro2iot.com" target="_blank">micro2iot.com</a>
        </div>

        <script>
            let client, SECRET_KEY, MQTT_TOPIC, myFullId;
            let activeUsers = {};
            const chatBox = document.getElementById('chat-box');

            function generateRandomNick() {
                const adjs = ['빛나는', '빠른', '다정한', '용감한', '영리한', '포근한', '단단한', '즐거운', '신비한', '행복한'];
                const nouns = ['돌고래🐬', '강아지🐶', '새싹🌱', '우주선🚀', '컴퓨터💻', '아이스크림🍦', '피자🍕', '눈사람⛄', '고양이🐱', '무지개🌈'];
                return `${adjs[Math.floor(Math.random() * adjs.length)]} ${nouns[Math.floor(Math.random() * nouns.length)]}`;
            }

            function setCookie(n, v, d) {
                let date = new Date();
                date.setTime(date.getTime() + d * 24 * 60 * 60 * 1000);
                document.cookie = n + '=' + v + ';expires=' + date.toUTCString() + ';path=/';
            }
            function getCookie(n) {
                let v = '; ' + document.cookie;
                let p = v.split('; ' + n + '=');
                if (p.length === 2) return p.pop().split(';').shift();
            }
            function applyTheme(t) {
                const b = document.documentElement;
                const btn = document.querySelector('.theme-switch');
                if (t === 'dark') {
                    b.setAttribute('data-theme', 'dark');
                    btn.innerText = '🌙';
                } else {
                    b.removeAttribute('data-theme');
                    btn.innerText = '☀️';
                }
            }
            function toggleTheme() {
                const t = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
                applyTheme(t);
                setCookie('user_theme', t, 30);
            }

            function enterChat() {
                SECRET_KEY = document.getElementById('passInput').value.trim();
                let inputNick = document.getElementById('nickInput').value.trim();

                if (!SECRET_KEY || !inputNick) return alert('정보를 입력하세요.');

                const randomSuffix = Math.random().toString(36).substring(2, 5).toUpperCase();
                myFullId = `${inputNick}(${randomSuffix})`;

                const hash = CryptoJS.SHA256(SECRET_KEY).toString().substring(0, 12);
                MQTT_TOPIC = `micro2iot/world/test_${hash}`;

                document.getElementById('loginOverlay').style.display = 'none';
                document.getElementById('user-display').innerText = `👤 ${myFullId}`;
                document.getElementById('topic-display').innerText = `Topic: ${MQTT_TOPIC}`;
                startMqtt();
            }

            function startMqtt() {
                client = new Paho.MQTT.Client('broker.emqx.io', 8084, `cl_${Math.random().toString(36).substring(2, 10)}`);
                client.onMessageArrived = (msg) => {
                    const data = JSON.parse(msg.payloadString);
                    if (data.type === 'chat') {
                        try {
                            const bytes = CryptoJS.AES.decrypt(data.text, SECRET_KEY);
                            const originalText = bytes.toString(CryptoJS.enc.Utf8);
                            if (!originalText) return;
                            const isMe = data.user === myFullId;
                            const timeStr = new Date().toLocaleTimeString('ko-KR', { hour12: false, hour: '2-digit', minute: '2-digit' });
                            const div = document.createElement('div');
                            div.className = 'msg-row';
                            div.innerHTML = `<span class="msg-time">[${timeStr}]</span><span class="msg-user" style="color:${isMe ? 'var(--msg-my-bg)' : '#888'};">${
                                data.user
                            }</span>: ${originalText}`;
                            chatBox.appendChild(div);
                        } catch (e) {
                            console.error('Decryption error');
                        }
                    } else if (data.type === 'presence') {
                        if (!activeUsers[data.user]) appendSystemMsg(`${data.user}님이 입장하셨습니다.`);
                        activeUsers[data.user] = Date.now();
                        renderUserList();
                    } else if (data.type === 'leave') {
                        if (activeUsers[data.user]) {
                            delete activeUsers[data.user];
                            appendSystemMsg(`${data.user}님이 퇴장하셨습니다.`);
                            renderUserList();
                        }
                    }
                    chatBox.scrollTop = chatBox.scrollHeight;
                };

                const connOpt = {
                    useSSL: true,
                    onSuccess: () => {
                        client.subscribe(MQTT_TOPIC);
                        document.getElementById('net-stat').innerText = '● Online';
                        document.getElementById('net-stat').style.color = '#2ecc71';
                        sendPresence();
                        setInterval(sendPresence, 5000);
                    },
                    onFailure: () => setTimeout(startMqtt, 5000),
                    willMessage: new Paho.MQTT.Message(JSON.stringify({ type: 'leave', user: myFullId })),
                };
                connOpt.willMessage.destinationName = MQTT_TOPIC;
                client.connect(connOpt);
            }

            function send() {
                const input = document.getElementById('msg-input');
                if (!input.value.trim() || !client.isConnected()) return;
                const encrypted = CryptoJS.AES.encrypt(input.value, SECRET_KEY).toString();
                const msg = new Paho.MQTT.Message(JSON.stringify({ type: 'chat', user: myFullId, text: encrypted }));
                msg.destinationName = MQTT_TOPIC;
                client.send(msg);
                input.value = '';
                input.focus();
            }

            function sendPresence() {
                if (!client || !client.isConnected()) return;
                const msg = new Paho.MQTT.Message(JSON.stringify({ type: 'presence', user: myFullId }));
                msg.destinationName = MQTT_TOPIC;
                client.send(msg);
            }

            function appendSystemMsg(text) {
                const div = document.createElement('div');
                div.className = 'sys-msg';
                div.innerText = text;
                chatBox.appendChild(div);
            }

            function renderUserList() {
                const listDiv = document.getElementById('user-list');
                const now = Date.now();
                listDiv.innerHTML = '';
                let count = 0;
                for (let user in activeUsers) {
                    if (now - activeUsers[user] < 12000) {
                        const uDiv = document.createElement('div');
                        uDiv.className = 'user-item';
                        uDiv.innerText = (user === myFullId ? '⭐ ' : '● ') + user;
                        listDiv.appendChild(uDiv);
                        count++;
                    }
                }
                document.getElementById('user-count').innerText = count;
            }

            window.onload = () => {
                const savedTheme = getCookie('user_theme');
                if (savedTheme) applyTheme(savedTheme);
                document.getElementById('nickInput').value = generateRandomNick();
                document.getElementById('passInput').focus();
            };
        </script>
    </body>
</html>

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다