Android에서 BLE 기기 인증을 구현하다 보면 AES-CMAC이라는 알고리즘을 만나게 됩니다. BouncyCastle 같은 외부 라이브러리를 쓸 수도 있지만, 표준 스펙(RFC 4493)을 직접 구현하면 동작 원리를 확실히 이해할 수 있습니다.
이 글에서는 Kotlin으로 작성한 AES-CMAC 자체 구현 코드를 한 줄 한 줄, 비트 연산 레벨까지 해부합니다.
📑 목차
1. AES-CMAC이 뭔가요?
CMAC은 Cipher-based Message Authentication Code의 약자입니다. 쉽게 말하면 "이 메시지가 변조되지 않았고, 키를 아는 사람이 보낸 게 맞다"는 걸 검증하는 알고리즘입니다.
편지를 보낼 때 봉투에 밀랍 도장을 찍는 것과 같습니다. 도장(= 키)을 가진 사람만 만들 수 있고, 봉투를 뜯으면(= 데이터가 변조되면) 도장이 깨지죠. CMAC 태그가 바로 그 "디지털 밀랍 도장"입니다.
RFC 4493에 정의된 표준이며, BLE 인증, IoT 보안, 금융 프로토콜 등에서 널리 쓰입니다.
2. 상수 정의
private const val AES_BLOCK_SIZE = 16
private val ZERO_BLOCK = ByteArray(AES_BLOCK_SIZE)
| 상수 | 값 | 의미 |
|---|---|---|
AES_BLOCK_SIZE |
16 | AES는 항상 16바이트(128비트) 블록 단위로 동작합니다 |
ZERO_BLOCK |
[0x00 × 16] | 16바이트가 전부 0인 배열. 서브키 생성의 시작점입니다 |
3. aesCmac() — 메인 함수
전체 코드를 먼저 보고, 파트별로 쪼개서 설명하겠습니다.
internal fun aesCmac(key: ByteArray, data: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
val l = cipher.doFinal(ZERO_BLOCK)
val k1 = deriveSubkey(l)
val k2 = deriveSubkey(k1)
val n = if (data.isEmpty()) 1 else (data.size + AES_BLOCK_SIZE - 1) / AES_BLOCK_SIZE
val lastBlockComplete = data.isNotEmpty() && (data.size % AES_BLOCK_SIZE == 0)
val lastBlock = ByteArray(AES_BLOCK_SIZE)
if (lastBlockComplete) {
val offset = (n - 1) * AES_BLOCK_SIZE
data.copyInto(lastBlock, 0, offset, offset + AES_BLOCK_SIZE)
xorInPlace(lastBlock, k1)
} else {
val offset = (n - 1) * AES_BLOCK_SIZE
val remaining = data.size - offset
if (remaining > 0) data.copyInto(lastBlock, 0, offset, offset + remaining)
lastBlock[remaining] = 0x80.toByte()
xorInPlace(lastBlock, k2)
}
val x = ByteArray(AES_BLOCK_SIZE)
for (i in 0 until n - 1) {
val block = data.copyOfRange(i * AES_BLOCK_SIZE, (i + 1) * AES_BLOCK_SIZE)
xorInPlace(x, block)
cipher.doFinal(x).copyInto(x)
}
xorInPlace(x, lastBlock)
return cipher.doFinal(x)
}
3-1. AES 암호기 초기화
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
"ECB는 안전하지 않다"고 배웠을 텐데요. 여기서는 ECB로 블록 하나씩 개별 암호화하는 기능만 빌려 쓰는 겁니다. CBC-MAC의 체이닝(XOR → 암호화 → XOR → 암호화…)은 우리가 직접 코드로 구현하니까요.
NoPadding인 이유도 패딩을 CMAC 스펙(0x80 패딩)대로 직접 처리하기 때문입니다.
3-2. 서브키 생성 (K1, K2)
val l = cipher.doFinal(ZERO_BLOCK) // L = AES_K(0^128)
val k1 = deriveSubkey(l) // K1 = L을 GF(2^128)에서 ×2
val k2 = deriveSubkey(k1) // K2 = K1을 GF(2^128)에서 ×2
RFC 4493 Section 2.3에 정의된 과정입니다. 단계별로 보면:
| 단계 | 연산 | 설명 |
|---|---|---|
| L | AES_K(0…0) | 16바이트 제로 블록을 키로 암호화한 결과 |
| K1 | deriveSubkey(L) | L을 1비트 왼쪽 시프트, MSB가 1이면 XOR 0x87 |
| K2 | deriveSubkey(K1) | K1에 같은 연산을 한 번 더 적용 |
GF(2^128) 유한체(Galois Field)의 기약 다항식(irreducible polynomial)에서 나옵니다. 128비트 유한체에서 ×2 연산을 할 때, 오버플로(MSB=1)가 발생하면
0x87로 reduction하는 겁니다. 수학적 디테일을 몰라도, "128비트 시프트 후 넘치면 0x87로 보정한다"고 이해하면 됩니다.
3-3. 블록 수 계산 & 마지막 블록 판단
val n = if (data.isEmpty()) 1 else (data.size + AES_BLOCK_SIZE - 1) / AES_BLOCK_SIZE
val lastBlockComplete = data.isNotEmpty() && (data.size % AES_BLOCK_SIZE == 0)
n은 데이터를 16바이트 블록으로 나눌 때 총 블록 수입니다. 올림 나눗셈(ceil division) 공식을 쓰고 있어요.
| data.size | n (블록 수) | lastBlockComplete | 설명 |
|---|---|---|---|
| 0 | 1 | false | 빈 데이터도 최소 1블록 처리 |
| 16 | 1 | true | 딱 1블록, 완전 |
| 20 | 2 | false | 4바이트가 남아서 불완전 |
| 32 | 2 | true | 딱 2블록, 완전 |
lastBlockComplete가 K1을 쓸지, K2를 쓸지를 결정하는 핵심 플래그입니다.
3-4. 마지막 블록 처리
Case 1: 완전한 블록 → K1 사용
if (lastBlockComplete) {
val offset = (n - 1) * AES_BLOCK_SIZE
data.copyInto(lastBlock, 0, offset, offset + AES_BLOCK_SIZE)
xorInPlace(lastBlock, k1)
}
마지막 16바이트를 그대로 복사한 뒤 K1과 XOR합니다.
Case 2: 불완전한 블록 → 패딩 + K2 사용
else {
val offset = (n - 1) * AES_BLOCK_SIZE
val remaining = data.size - offset
if (remaining > 0) data.copyInto(lastBlock, 0, offset, offset + remaining)
lastBlock[remaining] = 0x80.toByte() // 패딩 시작 마커
// 나머지는 이미 0x00 (ByteArray 초기값)
xorInPlace(lastBlock, k2)
}
남은 바이트를 복사하고, 바로 뒤에 0x80을 넣은 다음 나머지를 0으로 채웁니다. 이걸 ISO/IEC 9797-1 Padding Method 2라고 합니다.
완전한 블록과 패딩된 블록을 암호학적으로 구분 가능하게 만들기 위해서입니다. 만약 같은 서브키를 쓰면, 공격자가 패딩된 메시지와 완전한 메시지를 구분할 수 없게 되어 위조 공격이 가능해집니다.
3-5. CBC-MAC 체이닝
val x = ByteArray(AES_BLOCK_SIZE) // X₀ = 0…0 (초기 벡터)
for (i in 0 until n - 1) {
val block = data.copyOfRange(i * AES_BLOCK_SIZE, (i + 1) * AES_BLOCK_SIZE)
xorInPlace(x, block) // X = X ⊕ Block[i]
cipher.doFinal(x).copyInto(x) // X = AES_K(X)
}
xorInPlace(x, lastBlock) // X = X ⊕ lastBlock (K1/K2 적용된)
return cipher.doFinal(x) // T = AES_K(X) → 최종 CMAC 태그
CMAC의 핵심 루프입니다. 각 블록을 순차적으로 XOR → 암호화 → XOR → 암호화… 이런 식으로 체이닝합니다.
n - 1까지만 도는 이유마지막 블록에는 서브키(K1 또는 K2)가 XOR되어 있으므로, 루프 안에서 처리하면 안 됩니다. 그래서 일반 블록은 루프에서, 마지막 블록은 루프 밖에서 따로 처리합니다.
4. deriveSubkey() — 서브키 유도
private fun deriveSubkey(input: ByteArray): ByteArray {
val result = ByteArray(AES_BLOCK_SIZE)
var carry = 0
for (i in AES_BLOCK_SIZE - 1 downTo 0) {
val b = input[i].toInt() and 0xFF
result[i] = ((b shl 1) or carry).toByte()
carry = (b shr 7) and 1
}
if (input[0].toInt() and 0x80 != 0) {
result[AES_BLOCK_SIZE - 1] =
(result[AES_BLOCK_SIZE - 1].toInt() xor 0x87).toByte()
}
return result
}
이 함수는 128비트(16바이트) 전체를 1비트 왼쪽으로 시프트합니다. Kotlin에는 바이트 배열용 시프트 연산자가 없으니 바이트 단위로 직접 구현한 겁니다.
4-1. 비트 시프트 동작 원리
뒤(index 15)에서 앞(index 0)으로 역순 순회합니다. 이유는 carry(올림 비트)가 오른쪽에서 왼쪽으로 전파되기 때문입니다.
// 각 바이트에서 일어나는 일:
val b = input[i].toInt() and 0xFF // ① 부호 없는 정수로 변환
result[i] = ((b shl 1) or carry) // ② 왼쪽 1비트 시프트 + 이전 carry 붙이기
carry = (b shr 7) and 1 // ③ 현재 MSB를 다음 carry로 저장
구체적인 예시로 2바이트([0x5A, 0xC3])만 가지고 따라가 봅시다:
4-2. 조건부 XOR 0x87
if (input[0].toInt() and 0x80 != 0) {
result[AES_BLOCK_SIZE - 1] =
(result[AES_BLOCK_SIZE - 1].toInt() xor 0x87).toByte()
}
원본 입력의 최상위 비트(index 0의 bit 7)가 1이면, 시프트 결과의 마지막 바이트(index 15)에 0x87을 XOR합니다.
0x87을 128비트로 펼치면 0x00000000000000000000000000000087입니다. 즉, 하위 8비트에만 값이 있으므로 마지막 바이트에만 XOR하면 됩니다. 이것이 GF(2^128) 유한체에서의 modular reduction입니다.
5. xorInPlace() — 바이트 배열 XOR
private fun xorInPlace(a: ByteArray, b: ByteArray) {
for (i in a.indices) a[i] = (a[i].toInt() xor b[i].toInt()).toByte()
}
배열 a의 각 바이트를 b의 같은 위치 바이트와 XOR하여 제자리(in-place)에 저장합니다.
Kotlin의
Byte 타입은 비트 연산(xor, and, or 등)을 직접 지원하지 않습니다. Int로 변환 → 비트 연산 → 다시 Byte로 캐스팅하는 패턴이 Kotlin 암호화 코드에서 매우 자주 나옵니다.
6. 전체 흐름 요약
최종 출력은 16바이트(128비트) CMAC 태그입니다. 이 태그를 BLE 기기로 전송하면, 기기가 같은 키로 같은 연산을 수행해서 태그를 비교합니다. 일치하면 "이 앱은 올바른 키를 가지고 있다"는 것이 증명되는 거죠.
'기록' 카테고리의 다른 글
| Window11에서 SuperClaude 설치하기 (0) | 2025.11.12 |
|---|---|
| Claude Agent와 Command 차이 및 활용 예시 (0) | 2025.10.22 |
| 샤오신패드 프로 글로벌롬 업데이트 성공 - 17.0.04.088버전 (0) | 2025.08.23 |
| 내가 한 일을 남에게 설명하기 어려울때 / 기억이 안날때 (0) | 2025.03.23 |
댓글