2012. 6. 20. 14:10

SQLite 암호화(encrypt) 지원하기 (SQLITE_HAS_CODEC)

SQLite로 DB를 만들게 되면 항상 동반되는 것이 ".db 파일"입니다. 해당 파일은 쉽게 노출되기 때문에 암호화를 고려하게 됩니다. 그래서 예를들어, data만 base64로 인코딩하여 저장하기도 하나, 스키마등 주요 정보의 노출은 피할 수 없습니다. 또한, data를 암호화하여 저장하게되면, SELECT FROM WHERE등을 이용한 고급 search가 불가능해 집니다(예를 들어, 대문자로 변환된 "ABC" text를 찾는 경우). 그래서 파일 전체의 암호화를 시도해 볼 필요가 있는데, SQLite에서는 이를 "상용"으로 제공합니다.

SQLite는 Public License의 Open Source인데, 코드를 들여다보면, 암복호화 관련 부분이 있습니다.
허나 "상용" 지원을 위해 상당 부분을 비워두었습니다. 즉, 함수의 prototype은 있는데, body가 없습니다.


그래서 본 포스트에서는 해당 body를 채워넣고, 편리하게 재정의 할 수 있는 방법을 공유하겠습니다.

(참고) 주요한 3rd party SQLite 암호화 자료

  • SQLCipher
    ; windows의 빌드 과정이 어렵다. (해당 빌드본을 판매하고 있다.)
  • botan 암호화 모듈
    ; botan 암호화 모듈내에 SQLite wrapper 구현.
  • wxSQLite3
    ; 내부에 SQLite3 암화화 코드 포함됨.

위와 같이 몇몇 3rd party에서 SQLite 암호화 모듈을 제공하고 있습니다. 하지만 그들이 제공하는 암호화 모듈을 사용하기 때문에, SQLite 내부에 대한 이해없이 암호화 모듈을 직접 Custom하여 사용하기 어렵습니다. 
그래서 본 Post에서는 SQLite 암호화 지원을 위해,

  • SQLite3의 암호화 관련된 간단한 작동 원리
  • callback이 아닌 extern 형태로 외부에서 암호화(Decrypt / Encrypt) 함수를 정의하여 사용
  • 단순 XOR 암호화 / Windows Crypto API의 AES 256 암호화 / blowfish 암호화로 암호화 테스트 & 샘플 코드 

등을 제공하도록 하겠습니다.

주의) 상업적 용도로는 사용 불가인 CCL license를 걸어둡니다.

0. Quick Guide


  1. SQLite를 사용하는 'Prj'라는 프로젝트가 있다.
  2. 만일 'Prj'에서 사용하는 sqlite가 amalgamation version(즉, 큰 sqlite3.h / sqlite3.c로만 구성됨)으로 되어 있다면, http://www.sqlite.org/download.html에서 sqlite-preprocessed-xxxxxxx.zip를 가져와서 교체하고 어떻게 하든, 빌드를 성공시킨다. (아래 section 2. 참고)
  3. 'Prj'의 definition 설정에 SQLITE_CORE;SQLITE_HAS_CODEC를 추가한다. DEBUG에서는 SQLITE_DEBUG도 추가하면 좋다.
  4. sqlite 소스 코드가 있는 directory에, codec.hcodec.c를 넣고, codec.c를 빌드에 추가한다.
  5. 소스 코드 내의 main part에,
    * HANDLE_CODEC SQLiteCodecInit(IN_PARAM const void* pKey, IN_PARAM int nCbKey, OUT_OPTINAL_PARAM unsigned int* punCbBlockSize);
    * void  SQLiteCodecDeInit(IN_PARAM HANDLE_CODEC hHandle);
    * int SQLiteCodecEncode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, * IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest);
    * int SQLiteCodecDecode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest); 
    를 구현한다. 이들은 암호화를 위한 context 초기화/해제화 함수와, 암호화/복호화 함수이다. 사용할 암호화 함수를 내부에서 연결하여 호출하도록 한다. (cpp 파일인 경우, #ifdef __cplusplus 내부에서 구현한다)
    자세한건, section 6.을 참고한다.
  6. 기존 소스 코드에 "PRAGMA KEY='....'" SQL execute 혹은 sqlite3_key(...) 함수를 호출하여 암호화 key를 지정하여 암호화를 시작된다.
  7. 기존의 plain db 파일은 sqlite3_backup_step(...) 함수 호출로 암호화된 db 파일로 변환이 가능하다. 물론 그 역방향도 가능하며 다른 key로도 변환도 지원된다.

1. 프로젝트 생성


이제부터 Console 프로젝트(SQLiteEncrypt.zip) 진행을
공유합니다.(Visual Studio 2008 기준)


와 같이 E:\Temp\_SQLite_에 Console 프로젝트를 생성하였다고 가정합니다.

2. SQLite 다운로드 & 구성 & 통상적인 Compile & 테스트

http://www.sqlite.org/download.html
에서 SQLite Source code를 다운로드 합니다.
단, amalgamation이 아니라, Legacy Source Code를 받습니다.
(amalgamation은 .h / .c 가 통합되는데, .c에 struct 같은것이 정의되어, 암호화 지원을 위한 코드 수정이 어려웠습니다. 따라서, 수십개의 .h / .c로 분리된 버전을 사용합니다.)

E:\Temp\_SQLite_\SQLiteEncrypt에 압축을 풀고 넣습니다.

그리고, project에 해당 다음과 같이 파일을 넣습니다.
즉, SQLite.3071300 folder에 SQLite의 *.c 파일을 추가해 넣습니다.
단, shell.c / tclsqlite.c 는 삭제합니다.
그리고, SQLite.3071300에 있는 모든 *.c의 속성을 열어, Precompiled header 사용을 off합니다.


그리고 다음과 같이 Project Setting의 값을 조정합니다.



이렇게 되면 다음과 같이 compile이 성공됩니다.
여기까지가 통상적인 SQLite 빌드 과정입니다.
1>------ Build started: Project: SQLiteEncrypt, Configuration: Debug Win32 ------
1>Compiling...
1>stdafx.cpp
1>Compiling...
1>SQLiteEncrypt.cpp
1>Compiling...
1>alter.c
1>analyze.c
.... (중략) ...
1>wal.c
1>walker.c
1>where.c
1>icu.c
1>Generating Code...
1>Linking...
1>Embedding manifest...
1>Build log was saved at "file://e:\Temp\_SQLite_\SQLiteEncrypt\Debug\BuildLog.htm"
1>SQLiteEncrypt - 0 error(s), 0 warning(s)
========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========
그리고, 다음과 같이 TestSQLite3을 구성하여, (john, newyork), (kim, seoul)을 쓰도록 합니다.
자세한 코드는 첨부된 SQLiteEncrypt.zip 프로젝트를 참고하십시요.
// SQLiteEncrypt.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"
#include "sqlite3.h"
#include <Windows.h>
#include <assert.h>
#include <strsafe.h>

VOID TestSQLite3(IN BOOL bReadOnly);

int _tmain(int argc, _TCHAR* argv[])
{
	_tprintf(TEXT("**************\r\n"));
	_tprintf(TEXT("Write and Read\r\n"));
	_tprintf(TEXT("**************\r\n"));
	TestSQLite3(FALSE);
	_tprintf(TEXT("**************\r\n"));
	_tprintf(TEXT("Read\r\n"));
	_tprintf(TEXT("**************\r\n"));
	TestSQLite3(TRUE);
	return 0;
}

int callback(void* arg, int columns, char** value, char** name)
{
	CHAR	szMsg[MAX_PATH]	= {0,};
	CHAR	szTmp[MAX_PATH]	= {0,};
	INT	i		= 0;
	for (i=0; i<columns; i++)
	{
		StringCchPrintfA(szTmp, MAX_PATH, ("[%s=%s]\r\n"), name[i], value[i]);
		StringCchCatA(szMsg, MAX_PATH, szTmp);
	}

	printf(szMsg);
	printf("\r\n");

	return 0;
}

VOID TestSQLite3(IN BOOL bReadOnly)
{
	sqlite3*		db		= NULL;
	int		nResult		= 0;
	LPSTR		lpszErrMsg	= NULL;
	sqlite3_stmt*	pStmt		= NULL;

	// db 파일을 생성한다.
	{
		if (FALSE == bReadOnly)
		{
			// Test를 위해 기존 파일을 삭제한다.~
			::DeleteFile(TEXT("E:\\temp\\_SQLite_\\test.db.txt"));
		}

		// sqlite3_open16을 사용하면, 내부적으로 UTF16(wchar)로 저장된다.
		nResult = sqlite3_open("E:\\temp\\_SQLite_\\test.db.txt", &db);
		if (SQLITE_OK != nResult)
		{
			assert(FALSE);
			return;
		}
	}

	// Table을 생성한다.
	{
		// test123은 암호화 Key로 지정된다.
		nResult = sqlite3_exec(db, "PRAGMA KEY='test123';", NULL, NULL, &lpszErrMsg);
		if (SQLITE_OK != nResult)
		{
			if (NULL != lpszErrMsg)
			{
				//
			}
			assert(FALSE);
			goto FINAL;
		}

		nResult = sqlite3_exec(db,
			("CREATE TABLE IF NOT EXISTS tb_test	\
			 (ID INTEGER PRIMARY KEY,		\
			 NAME TEXT,								\
			 LOCAL TEXT)"),
			 NULL,
			 NULL,
			 &lpszErrMsg);
		if (SQLITE_OK != nResult)
		{
			if (NULL != lpszErrMsg)
			{
				// lpszErrMsg를 참고한다.
			}
			assert(FALSE);
			goto FINAL;
		}
	}

	// Record를 추가한다.
	{
		if (FALSE == bReadOnly)
		{
			// stmt를 준비한다.
			nResult = sqlite3_prepare_v2(db,
				("INSERT INTO tb_test (NAME,LOCAL) VALUES(?,?)"),
				-1,
				&pStmt,
				NULL);
			if (SQLITE_OK != nResult)
			{
				assert(FALSE);
				goto FINAL;
			}

			// 추가~
			{
				sqlite3_reset(pStmt);
				sqlite3_bind_text(pStmt, 1, ("john"), -1, SQLITE_STATIC);
				sqlite3_bind_text(pStmt, 2, ("newyork"), -1, SQLITE_STATIC);
				sqlite3_step(pStmt);

				sqlite3_reset(pStmt);
				sqlite3_bind_text(pStmt, 1, ("kim"), -1, SQLITE_STATIC);
				sqlite3_bind_text(pStmt, 2, ("seoul"), -1, SQLITE_STATIC);
				sqlite3_step(pStmt);
			}
		}
	}

	// Query 실행
	{
		// 대부분 callback에서 처리된다.
		nResult = sqlite3_exec(db, 
			("SELECT * FROM tb_test"),
			callback,
			NULL,
			&lpszErrMsg);
	}

FINAL:

	if (NULL != pStmt)
	{
		sqlite3_finalize(pStmt);
		pStmt = NULL;
	}

	if (NULL != lpszErrMsg)
	{
		sqlite3_free(lpszErrMsg);
		lpszErrMsg = NULL;
	}

	if (NULL != db)
	{
		sqlite3_close(db);
		db = NULL;
	}
}
이것을 실행하면, 다음과 같습니다. (중간의 SQLite 암호화 Key를 전달하는데, 현재는 무시됩니다.)

**************
Write and Read
**************
[ID=1]
[NAME=john]
[LOCAL=newyork]

[ID=2]
[NAME=kim]
[LOCAL=seoul]

**************
Read
**************
[ID=1]
[NAME=john]
[LOCAL=newyork]

[ID=2]
[NAME=kim]
[LOCAL=seoul]

그리고, 해당 .db를 열어보면(E:\temp\_SQLite_\test.db.txt)
다음과 같습니다. 즉, john 이라던지 seoul와 같은 text가 눈에 보입니다.


...


...

즉, 해당 파일을 대략 열어보면, SQLite3 .db 파일 / db schema / db data, ... 등을 대충 확인할 수 있습니다. 이제 이 .db 파일을 암호화해 보도록 합시다. (물론, SQL을 사용하는 코드의 수정은 없는 상태에서...)

3. SQLITE_HAS_CODEC define을 추가하여 SQLite 암호화 지원하기

와 같이 SQLITE_HAS_CODEC definition을 추가합니다.
그럼 SQLite의 암호화 부분이 활성화되나, 다음과 같이 Compile 오류가 발생합니다.

1>------ Build started: Project: SQLiteEncrypt, Configuration: Debug Win32 ------
1>Compiling...
1>stdafx.cpp
1>Compiling...
1>SQLiteEncrypt.cpp
1>Compiling...
...
1>e:\temp\_sqlite_\sqliteencrypt\sqlite-preprocessed-3071300\backup.c(246) : warning C4013: 'sqlite3PagerGetCodec' undefined; assuming extern returning int
1>bitvec.c
...
1>icu.c
1>Generating Code...
1>Linking...
1>attach.obj : error LNK2001: unresolved external symbol _sqlite3CodecGetKey
1>vacuum.obj : error LNK2001: unresolved external symbol _sqlite3CodecGetKey
1>attach.obj : error LNK2001: unresolved external symbol _sqlite3CodecAttach
1>pragma.obj : error LNK2001: unresolved external symbol _sqlite3_activate_see
1>pragma.obj : error LNK2001: unresolved external symbol _sqlite3_rekey
1>pragma.obj : error LNK2001: unresolved external symbol _sqlite3_key
1>E:\Temp\_SQLite_\SQLiteEncrypt\Debug\SQLiteEncrypt.exe : fatal error LNK1120: 5 unresolved externals
1>Build log was saved at "file://e:\Temp\_SQLite_\SQLiteEncrypt\Debug\BuildLog.htm"
1>SQLiteEncrypt - 7 error(s), 1 warning(s)
========== Build: 0 succeeded, 1 failed, 0 up-to-date, 0 skipped ==========
즉, 5개의 함수 body만 구현하면 SQLite의 암호화를 지원할 수 있습니다.

4. 5개 함수 구현 & 간단한 XOR 암호화


우선, 5개의 함수가 들어가는 .\sqlite-preprocessed-3071300\codec.c와 codec.h를 생성하고 codec.c를 프로젝트에 추가합니다. 물론, Precompiled Header 설정은 Off합니다.

그리고, codec.h / codec.c를 다음과 같이 추가합니다.

codec.h

#ifndef HEADER_CODEC_H
#define HEADER_CODEC_H

#include "sqliteInt.h"
#include "btreeInt.h"

typedef struct tagST_CODEC_INTERNAL_CONTEXT
{
	unsigned char*	key;
	int		nKey;
	void*		pUserData;
	unsigned char*	pBuffer;
} ST_CODEC_INTERNAL_CONTEXT, *LPST_CODEC_INTERNAL_CONTEXT;

#endif // HEADER_CODEC_H
codec.c
#ifdef SQLITE_HAS_CODEC

// SQLITE_HAS_CODEC definition이 있는 경우 빌드됨

#include "codec.h"

// Internal Callback Prototype
static void* CodecInternal(void* ctx, void* data, Pgno pgNo, int mode);
static void CodecFreeInternal(void* ctx);

// reference function in pager.c
extern void sqlite3PagerSetCodec(Pager *pPager,void *(*xCodec)(void*,void*,Pgno,int),void (*xCodecSizeChng)(void*,int,int),void (*xCodecFree)(void*),void *pCodec);

//////////////////////////////////////////////////////////////////////////
// Function for SQLITE_HAS_CODEC

void sqlite3CodecGetKey(sqlite3* db, int nDb, void** zKey, int* nKey)
{
	// do nothing...
}

// ATTACH DATABASE db AS dba KEY k
// 혹은
// PRAGMA KEY=k
// 로 부터 호출받는다. PRAGMA KEY=를 통할때는, sqlite_attach(...)로 부터 호출받는다.
// KEY가 지정되지 않으면 zKey는 NULL이 전달된다.
// nKey는 zKey의 byte 길이로,
//		음수 - zKey는 암호화 키. abs(nKey)는 길이가 됨
//		0    - 암호화 사용 안함
//		양수 - zKey는 패스프레이즈(passphrase). nKey는 길이가 됨
int sqlite3CodecAttach(sqlite3* db, int nDb, const void* zKey, int nKey)
{
	int				rc		= SQLITE_OK;	// return code
	struct Db*			pDb		= NULL;
	LPST_CODEC_INTERNAL_CONTEXT		pstContext	= NULL;

	if (NULL == db)
	{
		rc = SQLITE_INTERNAL;
		goto FINAL;
	}

	if ((NULL == zKey) || (0 == nKey))
	{
		rc = SQLITE_OK;
		goto FINAL;
	}

	if (NULL == db->aDb)
	{
		rc = SQLITE_INTERNAL;
		goto FINAL;
	}

	// db를 구한다.
	pDb = &db->aDb[nDb];

	if ((NULL == pDb->pBt) || (NULL == pDb->pBt->pBt))
	{
		rc = SQLITE_INTERNAL;
		goto FINAL;
	}

	// context를 할당한다.
	pstContext = sqlite3MallocZero(sizeof(ST_CODEC_INTERNAL_CONTEXT));
	if (NULL == pstContext)
	{
		rc = SQLITE_NOMEM;
		goto FINAL;
	}

	// 암복호화에 사용될 buffer를 할당한다.
	pstContext->pBuffer = sqlite3MallocZero(SQLITE_DEFAULT_PAGE_SIZE);
	if (NULL == pstContext->pBuffer)
	{
		rc = SQLITE_NOMEM;
		goto FINAL;
	}

	// context 설정
	pstContext->nKey = nKey;
	pstContext->key  = (unsigned char*)sqlite3MallocZero(pstContext->nKey);
	if (NULL == pstContext->key)
	{
		rc = SQLITE_NOMEM;
		goto FINAL;
	}
	memcpy(pstContext->key, zKey, nKey);

	// Codec 설정
	// pager에 대해 codec 초기화/해제 함수를 등록
	sqlite3PagerSetCodec(pDb->pBt->pBt->pPager, CodecInternal, NULL, CodecFreeInternal, (void*)pstContext);

FINAL:
	return rc;
}

void sqlite3_activate_see(const char *zPassPhrase)
{
	return;
}

int sqlite3_rekey(sqlite3 *db, const void *pKey, int nKey)
{
	return SQLITE_INTERNAL;
}

// PRAGMA KEY='xxxx'; 한 값이 들어온다.
int sqlite3_key(sqlite3 *db, const void *pKey, int nKey)
{
	if ((0 == nKey) || (NULL == pKey))
	{
		// key를 지정하지 않음
		return SQLITE_OK;
	}

	return sqlite3CodecAttach(db, 0, pKey, nKey);
}

//////////////////////////////////////////////////////////////////////////
// Internal used

// Codec callback
static void* CodecInternal(void* ctx, void* data, Pgno pgNo, int mode)
{
	LPST_CODEC_INTERNAL_CONTEXT		pstContext	= NULL;
	unsigned char*			pRtnValue		= NULL;
	int				i		= 0;

	pstContext = (LPST_CODEC_INTERNAL_CONTEXT)ctx;
	if (NULL == pstContext)
	{
		goto FINAL;
	}

	pRtnValue = (unsigned char*)data;

	switch (mode)
	{
		//////////////////////////////////////////////////////////////////////////
		// decrypt
		case 0:
		case 2:
		case 3:
		{
			for (i=0; i<SQLITE_DEFAULT_PAGE_SIZE; i++)
			{
				pstContext->pBuffer[i] = pRtnValue[i];
				pRtnValue[i] = pstContext->pBuffer[i] ^ 255;
			}
		}
		break;

		//////////////////////////////////////////////////////////////////////////
		// encrypt
		case 6:
		case 7:
		{
			for (i=0; i<SQLITE_DEFAULT_PAGE_SIZE; i++)
			{
				pstContext->pBuffer[i] = ((unsigned char*)pRtnValue)[i] ^ 255;
			}
			pRtnValue = pstContext->pBuffer;
		}
		break;
	}

FINAL:
	return pRtnValue;
}

// Codec callback
static void CodecFreeInternal(void* ctx)
{

}

#else // SQLITE_HAS_CODEC
	#pragma message("[codec.c] SQLITE_HAS_CODEC is not defined.")
#endif // SQLITE_HAS_CODEC
이렇게 하면 XOR 처리가 되어, .db 파일 내용은 다음과 같이 됩니다.

...


...

즉, 간단한 XOR 만으로도 나름 충분한 효과가 있어 보입니다. 즉, 외부에서 봤을때는, SQLite를 사용하는지 여부를 내부 코드를 해킹하지 않는한 쉽지 않을 것입니다.

일단 여기까지의 문제는, 암복호화가 codec.c에 hardcode된다는 점입니다.
즉, caller가 정의한 암복호화 code로 연결되기만 한다면 본 포스트의 목적이 달성될 것 같습니다. 

5. 간단한 동작 원리


db 암호화에 사용될 key를 외부에서 전달하기 위해서는 SQL 구문 실행 혹은 sqlite3 함수를 호출해야 합니다. SQL 구문은 PRAGMA KEY=를 사용하면 됩니다. 그때는 sqlite3CodecAttach()가 호출됩니다. 그리고 sqlite3_key()라는 sqlite3 함수를 직접 호출할 수 있습니다. 그래서 sqlite3_key()함수는 sqlite3CodecAttach()를 호출하도록 하여 중복코드를 제거합니다.

sqlite3CodecAttach()에서는 sqlite3PagerSetCodec()를 호출하여 callback 함수를 등록해야 합니다.
   - 암/복호화(codec)가 필요할 때 경우 호출될 함수
   - page 크기가 변경될때 수신할 함수
   - 전체 암/복호화(codec)가 종료될 때 호출될 함수
각 함수마다 ctx라는 context를 전달받습니다.

여기에서 중요한 개념중 하나는 SQLite에서 사용하는 page 입니다.
page는 그 크기가 SQLITE_DEFAULT_PAGE_SIZE(=1024)로 보통 설정되는데, 이것은 .db 파일에 쓰는 단위가 됩니다. db에서 저장할 전체 data가 하나의 page를 가득 채워서 저장되는 것이 아니라, 여러개의 page로 분산되어 저장됩니다. 즉, 위에서 공유한 .db 파일의 hex 내용을 보면, 하나의 page에 몰려서 저장되는 것이 아니라, page 단위로 여러개 분산되어 저장됨이 확인됩니다.

보통 암/복호화를 하게 되면, input data / input size / output data / output size가 관여됩니다. 즉, 이는 input size와 output size가 달라지는(보통 커짐) 경우가 있다는 것입니다. 그런데 문제는, 암/복호화시 사용되는 내부 callback 함수의 prototype은,
    static void* xCodec(void* ctx, void* data, Pgno pgNo, int mode)
와 같습니다. 즉, input data / output data (data와 return 값)은 전달할 수 있는데, 문제는 input size / output size와 같이 size capacity의 전달이 없다는 겁니다.

input size / output size가 없는 이유는 아마도 sqlite는 page를 사용하기 때문이라 보여집니다. 즉, page단위로 읽고 쓰기 때문에, size를 명시할 필요가 없기 때문입니다. 그런데 문제는 암호화를 하게 되면, size가 커질 수 있다는 사실입니다. 1024 크기가 넘어왔는데, 암호화하여 1024+16으로 되면, page가 넘어가게 됩니다. 이를 방지하기 위해 sqlite에서는 page에 reserved 영역이라는 개념을 추가하였습니다. reserved 영역을 100으로 설정하면, page는 1024이지만, 끝의 100은 사용자가 정의할 수 있도록 비워두게 됩니다. 즉, 그런 경우, 실제 page 크기는 1024로 동일하지만, data는 924가 들어가고, 끝의 100은 비워지게 됩니다. 이것을 이용하면 암호화시 data 크기가 커져 page가 넘어가는 이슈를 해결할 수 있습니다.

이제, 암호화에 대한 설명을 해봐야 할거 같습니다.
암호화 자체가 방대한 내용이라 자세한 설명은 어렵지만, 크게 대칭형 - 비대칭형으로 분류됩니다. 대칭형은 동일한 암호(key)로 암/복호화하는 경우로, 지금 우리가 적용할 암호화 알고리즘에 해당됩니다. 그리고, 대칭형은 크게 block 암호화와 stream 암호화로 나눠집니다. stream은 일반적으로 input과 output 크기가 동일하게 적용되는데 속도는 빠르지만 암호화 레벨은 낮아지게 됩니다(앞선 XOR 예). block 암호는 대신 padding 정보가 추가될 수 있습니다. 예를 들어, AES 256인 경우 ""를 암호화하면 16byte 크기의 data가 전달됩니다. 즉, ""를 암호화하여 전달된 data의 길이를 알아야 앞선 sqlite의 page에서 reserve 영영으로 지정할 수 있습니다. 즉, padding 영역을 reserved화 시키면, 암호화시 page를 넘어가는 일은 발생할 수 없게 됩니다.

6. 외부에서 암복호화 알고리즘 정의하기

이제 마지막으로, caller에서 정의한 암복호화 알고리즘을 사용해보도록 수정해 보겠습니다.
암복호화 알고리즘으로 Windows Crypto API의 AES256 알고리즘을 이용해 보겠습니다. 그리고 blowfish도 추가하였습니다. (미국으로 제품을 수출하려면 암호화키가 40byte이하여야 하는데, 이를 위해 보통 blowfish가 응용되기도 합니다.) 수정 전략은, caller에서 정의한 암복호화 공유 함수를 codec.c에서 extern으로 사용하는 것입니다. 그렇기 때문에 caller에서 해당 공유 함수를 정의하지 않았다면, compile시, unresolved external 오류가 발생할 것이니 주의하기 바랍니다.

정의할 함수는,
void* SQLiteCodecInit(IN_PARAM const void* pKey, IN_PARAM int nCbKey);
void SQLiteCodecDeInit(IN_PARAM void* hHandle);
int SQLiteCodecEncode(IN_PARAM void* hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest);
int SQLiteCodecDecode(IN_PARAM void* hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest);
와 같이 4개가 대상이 됩니다.

즉, 위 함수를 정의하지 않으면, compile link 시 unresolved external symbol 오류가 발생합니다.

AES 256으로 암호화 하기
//////////////////////////////////////////////////////////////////////////
// <AES256 암호화> (block 알고리즘. block size : 16byte)
//////////////////////////////////////////////////////////////////////////
#ifdef __cplusplus
extern "C" {
#endif

#include <WinCrypt.h>
typedef struct tagST_CODEC_HANDLE
{
	HCRYPTPROV	hCryptProv;
	HCRYPTHASH	hHash;
	HCRYPTKEY	hKey;
} ST_CODEC_HANDLE, *LPST_CODEC_HANDLE;

HANDLE_CODEC SQLiteCodecInit(IN_PARAM const void* pKey, IN_PARAM int nCbKey, OUT_OPTINAL_PARAM unsigned int* punCbBlockSize)
{
	void*			pRtnValue	= NULL;
	LPST_CODEC_HANDLE	pstCodecHandle	= NULL;

	pstCodecHandle = new ST_CODEC_HANDLE;
	ZeroMemory(pstCodecHandle, sizeof(ST_CODEC_HANDLE));

	if (FALSE == ::CryptAcquireContext(&pstCodecHandle->hCryptProv, NULL, MS_ENH_RSA_AES_PROV, PROV_RSA_AES, 0))
	{
		assert(FALSE);
		goto FINAL;
	}

	if (FALSE == ::CryptCreateHash(pstCodecHandle->hCryptProv, CALG_SHA_256, 0, 0, &pstCodecHandle->hHash))
	{
		assert(FALSE);
		goto FINAL;
	}

	if (FALSE == ::CryptHashData(pstCodecHandle->hHash, (LPBYTE)pKey, nCbKey, 0))
	{
		assert(FALSE);
		goto FINAL;
	}

	if (FALSE == ::CryptDeriveKey(pstCodecHandle->hCryptProv, CALG_AES_256, pstCodecHandle->hHash, CRYPT_EXPORTABLE, &pstCodecHandle->hKey))
	{
		assert(FALSE);
		goto FINAL;
	}

	// AES 256 block 크기
	// 즉, AES 256는 단순히 비어있는 문자열 ""를 암호화해도 16byte 크기로 암호화된다.
	// 여기는 그 값을 넣으면 된다.
	*punCbBlockSize = 16;

	pRtnValue = pstCodecHandle;

FINAL:
	return pRtnValue;
}

void SQLiteCodecDeInit(IN_PARAM HANDLE_CODEC hHandle)
{
	LPST_CODEC_HANDLE pstHandle = NULL;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		goto FINAL;
	}

	if (NULL != pstHandle->hKey)
	{
		::CryptDestroyKey(pstHandle->hKey);
		pstHandle->hKey = NULL;
	}

	if (NULL != pstHandle->hHash)
	{
		::CryptDestroyHash(pstHandle->hHash);
		pstHandle->hHash = NULL;
	}

	if (NULL != pstHandle->hCryptProv)
	{
		::CryptReleaseContext(pstHandle->hCryptProv, 0);
		pstHandle->hCryptProv = NULL;
	}

	delete hHandle;
	hHandle = NULL;

FINAL:
	return;
}

int SQLiteCodecEncode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest)
{
	int			nRtnValue	= 1;
	LPST_CODEC_HANDLE	pstHandle	= NULL;
	DWORD			dwCbLength	= 0;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}

	dwCbLength = nCbSource;
	CopyMemory(pDest, pSource, nCbDestBufSize);
	if (FALSE == ::CryptEncrypt(pstHandle->hKey, NULL, TRUE, 0, (LPBYTE)pDest, &dwCbLength, nCbDestBufSize))
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}
	*pnCbDest = dwCbLength;

FINAL:
	return nRtnValue;
}

int SQLiteCodecDecode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest)
{
	int			nRtnValue	= 1;
	LPST_CODEC_HANDLE	pstHandle	= NULL;
	DWORD			dwCbLength	= 0;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}

	dwCbLength = nCbDestBufSize;
	CopyMemory(pDest, pSource, nCbDestBufSize);
	if (FALSE == ::CryptDecrypt(pstHandle->hKey, NULL, TRUE, 0, (LPBYTE)pDest, &dwCbLength))
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}
	*pnCbDest = dwCbLength;

FINAL:
	return nRtnValue;
}

#ifdef __cplusplus
}
#endif

blowfish로 암호화하기 (SQLiteEncrypt.zip 참고)
//////////////////////////////////////////////////////////////////////////
// <Blowfish 암호화> (block 알고리즘. block size : 8byte)
//////////////////////////////////////////////////////////////////////////
#ifdef __cplusplus
extern "C" {
#endif

typedef struct tagST_CODEC_HANDLE
{
	// Blowfish instance가 들어간다.
	Blowfish *pBF;
} ST_CODEC_HANDLE, *LPST_CODEC_HANDLE;

HANDLE_CODEC SQLiteCodecInit(IN_PARAM const void* pKey, IN_PARAM int nCbKey, OUT_OPTINAL_PARAM unsigned int* punCbBlockSize)
{
	void*		pRtnValue		= NULL;
	LPST_CODEC_HANDLE	pstCodecHandle	= NULL;
	LPSTR		lpszKey		= NULL;

	pstCodecHandle = new ST_CODEC_HANDLE;
	if (NULL == pstCodecHandle)
	{
		assert(FALSE);
		goto FINAL;
	}
	ZeroMemory(pstCodecHandle, sizeof(ST_CODEC_HANDLE));

	lpszKey = new CHAR[nCbKey+1];
	if (NULL == lpszKey)
	{
		assert(FALSE);
		goto FINAL;
	}
	ZeroMemory(lpszKey, sizeof(CHAR)*(nCbKey+1));
	StringCchCopyA(lpszKey, nCbKey+1, (LPSTR)pKey);

	// Blowfish instance를 만들고, password 세팅한다.
	pstCodecHandle->pBF = new Blowfish;
	pstCodecHandle->pBF->Set_Passwd(lpszKey);

	// blowfish block 크기
	// 알고리즘 특징상 input size와 output size가 같다.
	// 단, size는 8의 배수배 단위여야 한다.
	// (sqlite의 page 단위가 2^n이고, 512보다 크므로, 8byte 단위임이 확실하기 때문이다.)
	*punCbBlockSize = 0;

	pRtnValue = pstCodecHandle;

FINAL:
	if (NULL != lpszKey)
	{
		delete [] lpszKey;
		lpszKey = NULL;
	}
	return pRtnValue;
}

void SQLiteCodecDeInit(IN_PARAM HANDLE_CODEC hHandle)
{
	LPST_CODEC_HANDLE pstHandle = NULL;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		goto FINAL;
	}

	if (NULL != pstHandle->pBF)
	{
		delete pstHandle->pBF;
		pstHandle->pBF = NULL;
	}

	delete hHandle;
	hHandle = NULL;

FINAL:
	return;
}

int SQLiteCodecEncode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest)
{
	int			nRtnValue	= 1;
	LPST_CODEC_HANDLE	pstHandle	= NULL;
	DWORD			dwCbLength	= 0;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}

	if (0 == nCbSource % 8)
	{
		// good
	}
	else
	{
		// bad
		// blowfish는 8byte 단위가 들어와야 한다.
		assert(FALSE);
		goto FINAL;
	}

	// 암호화!
	dwCbLength = nCbSource;
	CopyMemory(pDest, pSource, nCbDestBufSize);
	pstHandle->pBF->Encrypt(pDest, dwCbLength);
	*pnCbDest = dwCbLength;

FINAL:
	return nRtnValue;
}

int SQLiteCodecDecode(IN_PARAM HANDLE_CODEC hHandle, IN_PARAM void* pSource, IN_PARAM int nCbSource, OUT_PARAM void* pDest, IN_PARAM int nCbDestBufSize, OUT_PARAM int* pnCbDest)
{
	int			nRtnValue	= 1;
	LPST_CODEC_HANDLE	pstHandle	= NULL;
	DWORD			dwCbLength	= 0;

	pstHandle = (LPST_CODEC_HANDLE)hHandle;
	if (NULL == pstHandle)
	{
		nRtnValue = 0;
		assert(FALSE);
		goto FINAL;
	}

	if (0 == nCbSource % 8)
	{
		// good
	}
	else
	{
		// bad
		// blowfish는 8byte 단위가 들어와야 한다.
		assert(FALSE);
		goto FINAL;
	}

	// 복호화!
	dwCbLength = nCbSource;
	CopyMemory(pDest, pSource, nCbDestBufSize);
	pstHandle->pBF->Decrypt(pDest, dwCbLength);
	*pnCbDest = dwCbLength;

FINAL:
	return nRtnValue;
}

#ifdef __cplusplus
}
#endif

즉, 이와 같이 구현이 가능한데, 다른 암호화 알고리즘도도 쉽게 응용이 가능할 겁니다. 중요한건 context에 되도록이면 암호에 관련된 key 정보를 저장하지 않도록 합니다. 그리고, *punCbBlockSize 값을 각 알고리즘의 특징에 해당되는 block size를 넣어야 합니다. 해당 값은 ""를 암호화하였을 때, 전달되는 크기로 확인할 수 있습니다. blowfish 같은 경우, input size / output size가 같기 때문에, block size는 0으로 전달해도 무방합니다.

7. SQLiteEncrypt project 테스트 정리

SQLiteEncrypt.exe는 codec.c / codec.h를 이용한 SQLite 암호화 테스트 코드가 포함되어 있습니다.
SQLiteEncrypt.cpp를 주로 참고하면 됩니다. 암호화 sample 코드도 있는데,
   - 단순 XOR 암호화
   - AES 256 암호화
   - blowfish 암호화
가 있습니다.

그리고, 테스트 코드는 Write / Read 뿐만 아니라, Attach db, backup db, memory db등이 포함되어 있습니다. 해당 부분은 SQLite의 Codec 지원으로 인해 영향을 받을 수 있는 부분들로 이뤄져 있습니다. 물론 vacuum 명령도 포함되어 있습니다.

Attach db는 SQLite의 특징중 하나로, 하나의 .db에서 다른 .db를 붙여서 사용할 수 있습니다. 이런 경우, 암호화된 .db 파일이 조합되면 경우의 수가 늘어나게 되는데, 이에 대한 테스트가 수행되었습니다. 자세한건 해당 코드(SQLiteEncrypt.cpp)를 참고하시기 바랍니다.

그리고 backup db는 sqlite3_backup 함수군을 호출하는 것으로,
   - 암호화 .db -> plain .db
   - plain .db -> 암호화 .db
   - 암호화 .db -> 암호화 .db (동일 key)
   - 암호화 .db -> 암호화 .db (다른 key)
와 같이 복제가 가능합니다. 기존에 진행했던 plain .db를 migration하는데 응용할 수 있으며, plain .db로 변환하면 시중에 많은 SQLite tool에서 사용할 수 있습니다.

SQLiteEncrypt.exe를 실행하면 다음과 같습니다.
**************
Write and Read
**************
[INDEX_ID=1]
[NAME=john]
[LOCAL=newyork]
	* Length -> 1 4 7 

[INDEX_ID=2]
[NAME=kim]
[LOCAL=seoul]
	* Length -> 1 3 5 

[INDEX_ID=3]
[NAME=big]
[LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified
	* Length -> 1 3 1500 

[INDEX_ID=4]
[NAME=big2]
[LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified
	* Length -> 1 4 1500 
...
Write and Read 테스트는 SQLiteEncrypt.exe 실행 경로에 test.db.txt.bin 파일을 생성합니다.
해당 부분으로 Encrypt 테스트를 할 수 있습니다. Write 한 db로 Read하면 decrypt는 발생하지 않습니다. 일단, 길이가 1500 글자인 Text를 Write 하고 Read했는데, 동일한 Text가 확인되어 Verify되었습니다. 보통 SQLite의 page 크기가 1024 정도인데, 그 크기를 넘어서는 text로 테스트되었습니다. 참고로 실행경로의 test.db.txt.bin을 열어보면 다음과 같습니다. (AES 256으로 암호화됨)

즉, 파일만 열어봐서는 SQLite .db 파일인지 구별이 불가능해 보입니다.
...





************** Read ************** [INDEX_ID=1] [NAME=john] [LOCAL=newyork] * Length -> 1 4 7 [INDEX_ID=2] [NAME=kim] [LOCAL=seoul] * Length -> 1 3 5 [INDEX_ID=3] [NAME=big] [LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified * Length -> 1 3 1500 [INDEX_ID=4] [NAME=big2] [LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified * Length -> 1 4 1500 ...
Read 테스트가 수행되었습니다. 위 test.db.txt.bin를 열어 Query만 수행하였습니다. 이런 경우 decrypt가 호출됩니다. 역시 1500길이 text가 verify되었습니다.
...
**************
Attach
**************
[INDEX_ID=1]
[NAME=111]
[LOCAL=*111*]
	* Length -> 1 3 5 

[INDEX_ID=2]
[NAME=aaa]
[LOCAL=*aaa*]
	* Length -> 1 3 5 

..........................
| attached data from db2 |
..........................

[INDEX_ID=1]
[NAME=222]
[LOCAL=*222*]
	* Length -> 1 3 5 

[INDEX_ID=2]
[NAME=bbb]
[LOCAL=*bbb*]
	* Length -> 1 3 5 
...
db attach가 테스트되었습니다. attach_01.db.txt.bin과 attach_02.db.txt.bin 두개의 .db를 생성합니다. 1번 db에는 111, aaa를, 2번 db에는 222, bbb가 저장되어 있습니다. d1에서 db2를 attach하고, d1에서 attach된 db2를 query하여 222, bbb가 나옴이 확인됩니다. db1, db2 모두 다른 key를 가지고 있습니다. (물론 attach 하기 위해서는 두개 모두 key를 알아야 합니다.)
...
************** Memory ************** [INDEX_ID=1] [NAME=memory] [LOCAL=memory_data] * Length -> 1 6 11 ...
memory db도 잘 동작하더군요.
...
**************
Backup
**************

backup progress 16%   
backup progress 33%   
backup progress 50%   
backup progress 66%   
backup progress 83%   
backup progress 100%   

[INDEX_ID=1]
[NAME=john]
[LOCAL=newyork]
	* Length -> 1 4 7 

[INDEX_ID=2]
[NAME=kim]
[LOCAL=seoul]
	* Length -> 1 3 5 

[INDEX_ID=3]
[NAME=big]
[LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified
	* Length -> 1 3 1500 

[INDEX_ID=4]
[NAME=big2]
[LOCAL=012345678..(omit)..90123456789abcdefghij] => Verified
	* Length -> 1 4 1500 

Press a key to exits
마지막으로 db backup 입니다. test.db.txt.bin을 test.db.backup.txt.bin으로 backup 합니다. key를 제거한, 즉, plain db로 수행하였습니다.

위와 같이 plain db로 들어감이 확인됩니다. 그리고, 1500 text 비교도 verify되었습니다. 즉, plain db로 backup된 이후에도 그 내용은 유지됨이 확인되었습니다.

ps. 성능 측정

추가로 성능 측정 자료를 공유합니다.
아래 결과에서도 나타나지만 예상과 다르게 Key를 지정하지 않는 경우가 가장 느린 것으로 나타났습니다. 그리고 Key를 지정한 경우엔 각 알고리즘별로 큰 차이는 나타나지 않아 보입니다. 다만 전반적으로 blowfish가 가장 빨랐습니다.

- 테스트 정보
 ; 위 section 7의 "Write And Read"를 100회 수행
 ; 1회당 1500 길이의 Text를 100번 Insert (즉, 100개의 record)
 ; Win7 X86 4GB RAM
 ; Plain(key 지정 없음) / XOR / AES256 / blowfish로 각각 테스트
 ; 각 테스트 항목별 3번 수행한 평균으로 정리

- 결과
  Write And Read 시간 (초)
Plain 1,102
XOR 1,031
AES256 1,020
blowfish 1,004


- 결론
 ; Key를 지정하지 않을때가 가장 느림. 대략 10% 정도 느림.
   즉, encoding / decoding callback 수행하지 않을 때가 오히려 느림.
 ; XOR 방식이 느림.
 ; blowfish가 AES256 보다 대략 3% 빠름.