홈으로 돌아가기
암호학

암호화 구현 참조 문서

실제로 동작하는 암호화는 작성되어 있는 암호화보다 좁으며, 이 페이지는 바로 그 간극을 위한 것이다. Railgun이 실행하는 메시지 암호화는 libsodium sealed box뿐이며 — 그마저도 네이티브 libsignal 모듈이 없는 클라이언트 경로에서만 실행된다. libsignal이 실제로 로드되는 Electron 데스크톱 빌드에서는, 다이렉트 메시지를 암호화하는 핸들러가 여전히 평문을 base64로 인코딩하는 자리표시자에 불과하다. 커뮤니티 및 채널 메시지는 어떤 경로에서도 암호화되지 않는다. 이 페이지 후반부에서 설명하는 Signal 방식 프로토콜은 작성과 검토가 끝났을 뿐, 다운로드할 수 있는 어떤 클라이언트에도 연결되어 있지 않다.

이 문서는 아키텍처 참조 자료이며 독립적인 보안 감사가 아니다. 1절과 2절은 실제로 출시된 내용을 설명한다. 4절부터 7절까지는 Railgun이 목표로 삼고 있는 설계를 설명하며 전체에 걸쳐 계획됨으로 표시되어 있다. 여기의 어떤 내용도 계획됨으로 표시된 속성을 현재 Railgun 클라이언트가 보장한다는 주장으로 읽혀서는 안 된다.

현재 출시된 것

경로방식상태
다이렉트 메시지 — 브라우저 및 개발 빌드libsodium sealed box — X25519 키 합의, XSalsa20-Poly1305 인증 암호화. 네이티브 libsignal 모듈이 없을 때 선택된다.암호화됨
다이렉트 메시지 — Electron 데스크톱 빌드libsignal이 로드되므로 encryptDm은 Electron IPC 핸들러로 넘어가며, 그 핸들러는 평문을 base64로 인코딩하는 자리표시자다. 릴레이는 읽을 수 있는 텍스트를 받는다.암호화되지 않음
커뮤니티 및 채널 메시지base64로 감싼 평문으로 전송된다. 채널 암호화는 작성되어 있으나 평문임을 알리는 표식을 반환한다.암호화되지 않음
X3DH + Double Ratchetlibsignal을 대상으로 구현되어 있으나 타입으로만 임포트된다. 이를 인스턴스화하는 런타임 경로는 없다.계획됨
개인 키 저장키는 기기에서 생성되어 기기에 보관된다. 개인 키나 신원 키 자료를 전송하는 코드 경로는 없다.구현됨

1. sealed box — 실제로 동작하는 암호화

Railgun이 다이렉트 메시지를 암호화하는 경우, 그것은 libsodium sealed boxcrypto_box_seal 구성 — 으로 이루어진다. 이는 X25519 키 합의와 XSalsa20-Poly1305 인증 암호화를 결합한 것이다. 실재하고 충분히 검토된 암호 기술이다. 동시에 의도적으로 단순한 구성이며, 그 한계는 중요하다. 어떤 클라이언트가 여기에 도달하는지는 2절에서 다룬다. 브라우저와 개발 빌드는 도달하고, Electron 데스크톱 빌드는 도달하지 않는다.

sealed box는 어떻게 만들어지는가

// Sender has only the recipient's public key, PK_r

(pk_e, sk_e) = crypto_box_keypair() // fresh, single-use

nonce = BLAKE2b(pk_e || PK_r) // derived, not random

ct = crypto_box(plaintext, nonce, PK_r, sk_e)

sealed = pk_e || ct // ephemeral key is prepended

erase(sk_e)

수신자는 메시지 앞부분에서 임시 공개 키를 되찾아, 자신의 개인 키로 동일한 공유 비밀을 다시 계산하고 상자를 연다. Poly1305 태그 덕분에 변조된 암호문은 쓰레기 값으로 복호화되는 대신 아예 열리지 않는다.

이것이 주는 것

  • 릴레이와 회선상의 모든 제3자에 대한 기밀성.
  • 무결성 — 변조는 조용히 복호화되지 않고 탐지된다.
  • 발신자 익명성: 임시 키는 누가 보냈는지에 대해 아무것도 드러내지 않는다.
  • 세션 설정이 필요 없으므로 수신자가 오프라인일 때도 동작한다.

이것이 주지 않는 것

  • 전방 비밀성은 없다. 수신자의 장기 개인 키는 그에게 보내진 모든 메시지를 연다. 그 키가 유출되면 저장해 둔 암호문도 함께 유출된다.
  • 발신자 인증은 없다. sealed box는 구조상 익명이며, 누가 썼는지에 대해 아무것도 증명하지 않는다.
  • 침해 이후 복구는 없다. 키 침해를 넘어설 래칫이 존재하지 않는다.

이 세 가지 공백이야말로 X3DH와 Double Ratchet이 메우기 위해 존재하는 것이며, 그래서 그 작업이 진행 중이다. 그것이 반영되기 전까지 sealed box 기반 다이렉트 메시지에 대한 정직한 요약은 이렇다. 수동적인 네트워크 관찰자와 릴레이에 대해서는 강하고, 결국 수신자의 기기 키를 손에 넣는 공격자에 대해서는 약하다. 그리고 이 요약은 이 코드가 실제로 동작하는 곳에만 적용된다.

2. 암호화되지 않는 것

커뮤니티 및 채널 메시지 — 대부분의 Railgun 사용자가 실제로 입력하는 내용의 대부분 — 는 오늘날 어떤 클라이언트 경로에서도 암호화되지 않는다. 채널 암호화 함수는 존재하지만, 그 함수는 스스로에 대해 솔직하다. 경고를 로그로 남기고 표식 문자열 뒤에 base64로 감싼 평문을 반환하여, 하류의 누구도 그것을 암호문으로 오인하지 않게 한다.

// What encryptChannel actually returns

logger.warn("Channel encryption not yet implemented")

ciphertext = base64("[DEVCRYPTO:PLAINTEXT]" + plaintext)

실질적 결과는 이렇다. 커뮤니티와 채널 콘텐츠에 관한 한 Railgun 서버는 여느 평범한 채팅 서버와 같은 위치에 있다. 거기에 쓴 내용을 서버는 읽을 수 있다. 그룹 키 배포 — 채널별 키를 sealed box에 담아 구성원에게 전달하는 방식 — 가 계획된 해법이며, 아직 출시되지 않았다.

Electron 데스크톱 빌드에서의 다이렉트 메시지

두 번째 공백은 가장 많은 사람이 사용하는 클라이언트에서의 다이렉트 메시지이며, 거꾸로 서술하기 쉬우므로 코드가 실행하는 그대로의 선택 논리를 적는다. initCrypto()는 메인 프로세스에 libsignal이 로드되었는지 묻는다. @signalapp/libsignal-client는 실제 의존성이며 시작 시 로드되므로, 출시된 Electron 빌드에서는 실제로 로드된다 — 그리고 그 대답이 sealed box 구현 대신 Electron IPC 구현을 선택하게 만든다. 그 encryptDmcrypto:encryptDm 핸들러로 전달되며, 그 핸들러는 자리표시자다:

// crypto:encryptDm, Electron main process

const plaintextBytes = new TextEncoder().encode(plaintext)

ciphertext: toBase64(plaintextBytes) // not encryption

따라서 1절의 sealed box에 도달하는 것은 libsignal이 없을 때 — 브라우저와 개발 환경에서다. 데스크톱 클라이언트에서는 다이렉트 메시지가 base64로 인코딩된 평문 상태로 릴레이에 도착한다. 그곳에서 libsignal은 신원 키와 프리키 번들을 생성하기 위해 로드될 뿐이며, 세션을 전혀 수립하지 않고 아무것도 래칫하지 않는다 — crypto:ensureDmSession은 본문이 없는 디버그 로그다.

3. Curve25519 — 기반이 되는 곡선

출시된 sealed box도, 출시되지 않은 X3DH 설계도 같은 타원 곡선에 기반한다. 고속 Diffie-Hellman 키 교환을 위해 Daniel J. Bernstein이 설계한 Curve25519다. 이 절은 곡선 자체를 설명하며 둘 모두에 해당한다.

곡선 방정식

y² = x³ + 486662x² + x

over the prime field 𝔽ₚ where p = 2²⁵⁵ − 19

소수 2²⁵⁵ − 19는 매우 빠른 모듈러 연산을 가능하게 하기 때문에 선택되었다. 계수 486662는 요구되는 보안 속성을 갖춘 안전한 곡선을 만들어내는 가장 작은 값으로 선택되었다.

키 생성

1. 개인 키: CSPRNG(암호학적으로 안전한 의사난수 생성기)로 32바이트의 난수를 생성한다. 최하위 3비트와 최상위 비트를 0으로 지우고 두 번째로 높은 비트를 1로 설정하여 키를 클램핑한다. 이로써 키는 8의 배수가 되고 유효 범위 안에 들어온다.

a = random(32 bytes)

a[0] &= 248 // Clear low 3 bits

a[31] &= 127 // Clear high bit

a[31] |= 64 // Set second-highest bit

2. 공개 키: 개인 키와 기준점 G = 9의 스칼라 곱을 계산한다.

A = a · G // Scalar multiplication on Curve25519

보안은 타원 곡선 이산 로그 문제(ECDLP)에 의존한다. AG가 주어져도 a를 복원하는 것은 계산적으로 불가능하다. Curve25519는 약 128비트의 보안 강도를 제공한다.

Diffie-Hellman 키 교환

두 당사자(앨리스와 밥)는 공유 비밀을 한 번도 전송하지 않고 그것을 계산할 수 있다:

Alice: a (private), A = a·G (public)

Bob: b (private), B = b·G (public)

Alice computes: S = a · B = a · (b·G)

Bob computes: S = b · A = b · (a·G)

Both arrive at S = ab·G — the same shared secret

AB(둘 다 공개)를 아는 도청자는 ECDLP를 풀지 않고서는 S를 계산할 수 없다. 이것이 계산적 Diffie-Hellman(CDH) 가정이다.

sealed box는 이 교환에서 한쪽을 임시 키로 만들어 버리는 방식이다. 아래의 X3DH는 서로 다른 키 쌍에 대해 이 교환을 네 번 수행하여, 내용을 감출 뿐 아니라 양쪽 당사자를 인증하는 결과를 얻는다.

4. X3DH — 확장 삼중 Diffie-Hellman

계획됨
libsignal을 대상으로 작성되어 코드베이스에서 검토할 수 있다. 타입으로만 임포트되며, 출시된 어떤 클라이언트도 이를 구성하지 않는다. 이 절의 모든 내용은 현재 동작이 아니라 목표 설계로 받아들여야 한다.

X3DH는 어려운 문제를 푼다. 둘 중 한 명이 오프라인일 때 두 사람은 어떻게 암호화된 세션을 수립하는가? 전통적인 Diffie-Hellman은 양쪽 모두 온라인일 것을 요구한다. X3DH는 미리 업로드된 키 번들을 사용해 비동기 키 합의를 가능하게 한다.

키 유형

신원 키(IK)

장기 Curve25519 키 쌍. 한 번 생성되어 사용자를 암호학적으로 식별한다. 사용자가 다시 등록하지 않는 한 바뀌지 않는다.

서명된 프리키(SPK)

신원 키로 서명된 중기 Curve25519 키 쌍. 주기적으로 교체된다. 서명은 그것이 신원 키 보유자의 것임을 증명한다.

일회용 프리키(OPK)

일괄 업로드되는 임시 Curve25519 키 쌍. 각각 한 번만 사용된 뒤 삭제된다. 최초 메시지에 대해 추가적인 전방 비밀성을 제공한다.

임시 키(EK)

발신자가 새 세션마다 생성하는 새로운 Curve25519 키 쌍. 서버 측에 절대 저장되지 않는다.

X3DH 핸드셰이크

앨리스가 (오프라인일 수도 있는) 밥에게 메시지를 보내려 할 때, 그녀는 서버에서 밥의 프리키 번들을 가져와 네 번의 Diffie-Hellman 계산을 수행한다:

// Alice has: IKₐ (her identity key), EKₐ (fresh ephemeral)

// Bob published: IKᵦ, SPKᵦ, OPKᵦ

DH1 = DH(IKₐ, SPKᵦ) // her identity ↔ his signed pre-key

DH2 = DH(EKₐ, IKᵦ) // her ephemeral ↔ his identity

DH3 = DH(EKₐ, SPKᵦ) // her ephemeral ↔ his signed pre-key

DH4 = DH(EKₐ, OPKᵦ) // her ephemeral ↔ his one-time pre-key

SK = KDF(DH1 || DH2 || DH3 || DH4)

// SK is the shared secret, derived via HKDF-SHA-256

왜 DH 연산이 네 번인가?

  • DH1은 상호 인증을 제공한다(양쪽 신원 키가 관여한다)
  • DH2는 임시 키가 밥의 신원에 묶이도록 보장한다
  • DH3은 임시 키를 통해 전방 비밀성을 제공한다
  • DH4는 추가적인 전방 비밀성을 제공한다. OPK를 쓸 수 없어도 X3DH는 DH1–DH3만으로 동작한다

DH1은 sealed box에 없는 연산이다. 그래서 출시된 경로는 메시지가 누구에게서 왔는지 알려줄 수 없다.

KDF — 키 유도 함수

원시 DH 출력들은 HKDF-SHA-256(HMAC 기반 키 유도 함수)으로 결합된다. HKDF는 이어붙인 DH 출력에서 엔트로피를 추출하고 이를 균일하게 무작위인 공유 비밀로 확장한다:

PRK = HKDF-Extract(salt="", input=DH1||DH2||DH3||DH4)

SK = HKDF-Expand(PRK, info="RailGunX3DH", length=32)

5. Double Ratchet 알고리즘

계획됨
오늘날 키를 래칫하는 Railgun 릴리스는 없다. sealed box 경로에서는 모든 다이렉트 메시지가 동일한 장기 수신자 키로 암호화되고, Electron 데스크톱 경로에서는 전혀 암호화되지 않는다.

X3DH가 최초의 공유 비밀을 수립하면 그다음은 Double Ratchet이 이어받는다. 맞물린 두 개의 ‘래칫’을 사용해 메시지마다 새로운 고유 키를 유도한다. 이 설계는 이전 메시지를 보호하며, 새로운 래칫 단계 이후 이후 메시지에 대한 보호를 회복하는 것을 목표로 한다.

DH 래칫(비대칭)

대화의 방향이 바뀔 때마다(앨리스 → 밥, 그다음 밥 → 앨리스) 새로운 임시 키를 사용해 새 Diffie-Hellman 키 교환이 일어난다. 이것이 루트 키를 앞으로 ‘래칫’한다:

dh_out = DH(my_ratchet_key, their_ratchet_key)

root_key, chain_key = KDF(root_key, dh_out)

이것이 침해 이후 보안을 제공하게 될 요소다. 현재 상태를 훔친 공격자는 새로운 DH 래칫 단계가 일어나면 접근 권한을 잃는다.

대칭 래칫(해시 체인)

DH 래칫 단계들 사이에서는 해시 기반 래칫이 체인 키로부터 메시지마다 새 메시지 키를 유도한다:

message_key = HMAC-SHA256(chain_key, 0x01)

chain_key = HMAC-SHA256(chain_key, 0x02)

이전 체인 키는 각 단계 후 삭제된다. 메시지 키는 정확히 한 통의 메시지를 암호화한 뒤 삭제된다. 이것이 전방 비밀성 메커니즘이다.

래칫 진행

그림으로 보면 Double Ratchet은 다음과 같다. 각 화살표는 단방향 함수이며, 앞으로는 갈 수 있지만 뒤로는 결코 갈 수 없다:

Root Key Chain:

RK₀ ──DH──→ RK₁ ──DH──→ RK₂ ──DH──→ RK₃ ...

Each RK step produces a Chain Key:

↓ ↓ ↓

CK₀ CK₁ CK₂

↓ ↓ ↓

Each CK step produces Message Keys:

CK₀→MK₀ CK₁→MK₃ CK₂→MK₅

CK₀→MK₁ CK₁→MK₄ CK₂→MK₆

CK₀→MK₂ CK₂→MK₇

MKₙ은 정확히 한 통의 메시지를 암호화한 뒤 삭제되므로, MK₄를 얻은 공격자도 MK₃이나 MK₅를 유도할 수 없다. 오늘 출시된 sealed box 경로에는 훔칠 메시지별 키도, 따라갈 체인도 없다. 모든 것을 여는 하나의 장기 키가 있을 뿐이다.

6. 전방 비밀성 — 무엇이 필요한가

계획됨

전방 비밀성이란 장기 키가 침해되어도 과거의 세션 키는 침해되지 않는다는 뜻이다. Railgun에는 오늘날 그것이 없다. 그것을 제공할 메커니즘은 두 가지이며, 둘 다 위에서 설명한 계획된 프로토콜에 속한다:

X3DH의 임시 키

발신자는 새 세션마다 새로운 임시 키 쌍을 생성하고 핸드셰이크 후 그 개인 키 부분을 삭제한다. 나중에 양쪽의 신원 키를 모두 훔친 공격자라도 세션 비밀을 재구성할 수 없다. sealed box도 발신자 쪽에서는 실제로 새로운 임시 키를 사용한다 — 그러나 교환의 수신자 쪽 절반은 그들의 장기 키이며, 바로 그 때문에 이 속성이 성립하지 않는다.

래칫 키 삭제

Double Ratchet은 사용한 뒤 오래된 체인 키와 메시지 키를 삭제한다. KDF 체인은 단방향이다 — CKₙ이 주어지면 CKₙ₊₁은 계산할 수 있지만 CKₙ₋₁은 계산할 수 없다. 따라서 시점 T의 침해는 T 이전에 보낸 것을 전혀 드러내지 않는다.

침해 이후 보안

DH 래칫은 미래 비밀성도 제공한다. 현재 세션 상태를 침해한 공격자는 다음 래칫 단계에서 그것을 잃는데, 그 단계가 공격자에게 없는 무작위성을 도입하기 때문이다. 래칫이 없으면 침해된 키는 공격자에게 무기한 유용한 상태로 남는다.

7. 릴레이 및 큐 아키텍처

Railgun은 봉투를 중계하며 오프라인 전달을 위해 일시적으로 큐에 넣을 수 있다. 릴레이는 메시지 평문을 필요로 하지 않도록 만들어졌지만, 오늘날 두 가지 경우에 평문을 받는다. 어떤 클라이언트도 암호화하지 않는 채널 메시지, 그리고 IPC 핸들러가 암호화가 아니라 base64 인코딩을 하는 Electron 데스크톱 빌드의 다이렉트 메시지다. sealed box 경로에서 온 다이렉트 메시지는 릴레이가 열 수 없는 암호문으로 도착한다.

서버가 보는 것

서버가 보관하는 것:

  • 공개 키 번들
  • 사용자 등록 정보
  • 커뮤니티/채널 메타데이터와 구성원 정보
  • 열 수 없는 sealed box 암호문(브라우저/개발 경로의 DM)
  • Electron 데스크톱 빌드에서 온 base64로 인코딩된 DM 평문
  • 채널 암호화가 출시되기 전까지의 채널 메시지 내용
  • 인증 감사 기록

서버가 결코 받지 않는 것:

  • 개인 키 또는 신원 키
  • 클라이언트 세션 상태
  • 메시지 기록의 영구 사본

메시지는 온라인 수신자에게 WebSocket을 통해 실시간으로 중계된다. 오프라인 기기의 경우 봉투가 최대 30일 TTL로 Redis에 큐잉되고, 기기가 수신을 확인하면 삭제된다. 서버 측 메시지 보관소는 없다 — 클라이언트 기록은 본인 기기의 로컬 저장소에 있으므로, 기기를 잃는 것은 기록을 잃는 것이다.

8. 보안 속성 요약

속성방식상태
DM 기밀성sealed box는 브라우저 및 개발 경로에서만. Electron 데스크톱 핸들러는 base64로 인코딩한다부분적
DM 무결성sealed box 경로에서는 Poly1305 태그. Electron 데스크톱 경로는 인증되지 않은 base64를 보낸다부분적
키가 기기에 머무름로컬 키 저장소. 개인 키를 전송하는 코드 경로는 없다구현됨
오프라인 전달봉투는 30일 TTL로 Redis에 큐잉되고 수신 확인 시 삭제된다구현됨
채널 기밀성그룹 키 배포는 구현되지 않았다. 내용은 읽을 수 있는 상태로 전송된다구현되지 않음
발신자 인증sealed box는 구조상 익명이다구현되지 않음
전방 비밀성Double Ratchet이 필요하지만 어떤 클라이언트도 이를 인스턴스화하지 않는다계획됨
침해 이후 복구DH 래칫이 필요하지만 어떤 클라이언트도 이를 인스턴스화하지 않는다계획됨
독립 감사완료된 제3자 암호 감사는 주장하지 않는다완료되지 않음
포스트 양자 보장포괄적인 포스트 양자 보안 보장은 주장하지 않는다주장하지 않음

이 페이지의 출처

위의 모든 상태는 설계 문서가 아니라 클라이언트 소스 코드에서 읽어낸 것이다. 클라이언트 저장소는 현재 공개되어 있지 않아 직접 확인해 달라고 요청할 수 없으며 — 그렇기에 이 페이지는 증명이 아니라 공개 설명으로 받아들여야 한다. 특정 주장에 대한 질문은 security@railgun.chat로 환영한다.