본문 바로가기
기록

Android AES_CMAC 자체 구현 해보기 (+ 외부 라이브러리 없이, Kotlin)

by kkong93 2026. 4. 6.

Android에서 BLE 기기 인증을 구현하다 보면 AES-CMAC이라는 알고리즘을 만나게 됩니다. BouncyCastle 같은 외부 라이브러리를 쓸 수도 있지만, 표준 스펙(RFC 4493)을 직접 구현하면 동작 원리를 확실히 이해할 수 있습니다.

이 글에서는 Kotlin으로 작성한 AES-CMAC 자체 구현 코드를 한 줄 한 줄, 비트 연산 레벨까지 해부합니다.


1. AES-CMAC이 뭔가요?

CMACCipher-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는 안전하지 않다"고 배웠을 텐데요. 여기서는 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에 같은 연산을 한 번 더 적용
💡 0x87은 어디서 온 숫자인가요?
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블록, 완전

lastBlockCompleteK1을 쓸지, 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합니다.

data(마지막 16바이트): [AA BB CC DD EE FF ... 16바이트] ⊕ (XOR) K1: [k1 k1 k1 k1 k1 k1 ... 16바이트] ↓ lastBlock: [결과 16바이트]

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라고 합니다.

data 마지막: [AA BB CC] (3바이트만 남음) ↓ 패딩 적용 패딩 후: [AA BB CC 80 00 00 00 00 00 00 00 00 00 00 00 00] ⊕ (XOR) K2: [k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2 k2] ↓ lastBlock: [최종 결과 16바이트]
✅ 왜 K1과 K2를 구분하나요?
완전한 블록과 패딩된 블록을 암호학적으로 구분 가능하게 만들기 위해서입니다. 만약 같은 서브키를 쓰면, 공격자가 패딩된 메시지와 완전한 메시지를 구분할 수 없게 되어 위조 공격이 가능해집니다.

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 → 암호화… 이런 식으로 체이닝합니다.

X₀ = [00 00 00 ... 00] (16바이트 전부 0) Block[0]: X₁ = AES_K( X₀ ⊕ Block[0] ) Block[1]: X₂ = AES_K( X₁ ⊕ Block[1] ) Block[2]: X₃ = AES_K( X₂ ⊕ Block[2] ) ... Block[n-2]: X_{n-1} = AES_K( X_{n-2} ⊕ Block[n-2] ) 마지막: T = AES_K( X_{n-1} ⊕ lastBlock ) ← 이것이 CMAC 태그!
⚠️ 루프가 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])만 가지고 따라가 봅시다:

입력: [0x5A, 0xC3] === i = 1 (0xC3 처리, 오른쪽부터) === b = 0xC3 = 1100_0011 b << 1 = 1000_0110 (왼쪽으로 밀면 MSB '1'이 밀려나감) carry(이전) = 0 (최초이므로) result[1] = 1000_0110 | 0 = 0x86 carry(다음) = 1 (0xC3의 MSB가 1이었으므로) === i = 0 (0x5A 처리) === b = 0x5A = 0101_1010 b << 1 = 1011_0100 carry(이전) = 1 (0xC3에서 밀려온 비트) result[0] = 1011_0100 | 1 = 0xB5 carry(다음) = 0 (0x5A의 MSB가 0이었으므로) 결과: [0xB5, 0x86] 검증: 0x5AC3 = 0101_1010_1100_0011 << 1 = 1011_0101_1000_0110 = 0xB586 ✓

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합니다.

💡 왜 마지막 바이트에 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)에 저장합니다.

⚠️ 왜 toInt()를 거치나요?
Kotlin의 Byte 타입은 비트 연산(xor, and, or 등)을 직접 지원하지 않습니다. Int로 변환 → 비트 연산 → 다시 Byte로 캐스팅하는 패턴이 Kotlin 암호화 코드에서 매우 자주 나옵니다.

6. 전체 흐름 요약

① 준비 AES 키로 제로 블록 암호화 → L ② 서브키 유도 L → (시프트 + 조건부 XOR) → K1 K1 → (시프트 + 조건부 XOR) → K2 ③ 데이터 분할 입력 데이터를 16바이트 블록으로 나눔 ④ 마지막 블록 처리 완전 → K1 XOR 불완전 → 0x80 패딩 + K2 XOR ⑤ CBC-MAC 체이닝 X₀ = 0 → XOR Block[0] → AES → X₁ → XOR Block[1] → AES → X₂ → ... → XOR lastBlock → AES → T (CMAC 태그, 16바이트)
✅ 결과물
최종 출력은 16바이트(128비트) CMAC 태그입니다. 이 태그를 BLE 기기로 전송하면, 기기가 같은 키로 같은 연산을 수행해서 태그를 비교합니다. 일치하면 "이 앱은 올바른 키를 가지고 있다"는 것이 증명되는 거죠.
반응형

댓글