Capacitor에서 SQLite 데이터베이스를 암호화하려면 SQLCipher 기반 플러그인을 사용해 암호화 키로 데이터베이스를 열고, 키는 code이 아니라 iOS Keychain이나 Android Keystore에 보관하세요. 이 가이드에서는 다음을 다룹니다. @capgo/capacitor-fast-sql Capacitor 8 기준: 두 플랫폼의 빌드 설정, 키 생성 및 저장, 이미 사용자 데이터가 있는 데이터베이스 암호화, 키 로테이션, 그리고 반드시 처리해야 할 실패 케이스까지 다룹니다.
SQLCipher의 보호 범위와 한계
SQLCipher는 SQLite의 오픈소스 확장으로, 데이터베이스 파일의 모든 페이지를 AES-256으로 암호화합니다. 키가 없으면 파일은 무작위 바이트일 뿐입니다. WAL 및 저널 파일도 포함됩니다.
다음과 같은 위협으로부터 보호합니다:
- 제일브레이크 또는 루팅된 기기, 암호화되지 않은 백업, 약한 패스코드가 설정된 분실된 폰에서 앱 파일을 복사하는 행위
- 앱 컨테이너를 읽는 포렌식 도구
- 잘못 구성된 공유 디렉터리를 통해 파일을 읽는 다른 앱
다음은 보호하지 않습니다:
- 앱 내부에서 실행되는 code (악성 의존성, WebView의 XSS 버그 등)가 데이터 계층을 호출할 수 있는 경우,
- 키를 추출할 수 있는 공격자. 이것이 키 저장소가 암호화 방식보다 더 중요한 이유입니다.
- 다른 곳에 평문으로 저장하는 데이터: 로그,
localStorage, 캐시, 분석 이벤트.
iOS와 Android는 기기가 잠겨 있을 때 앱 저장소를 이미 암호화합니다. SQLCipher는 기기 비밀번호에 의존하지 않고 백업에서도 유지되는 추가 계층을 제공합니다. 건강, 금융, 규제 대상 데이터에는 가치가 있습니다. 저장소 위협에 대한 더 넓은 관점을 위해 SQLite 외부의 백업, 로그, 평문 캐시를 고려하세요.
사전 요구사항
Fast SQL을 설치하고 다음과 같이 구성했습니다 Capacitor 앱에서 SQLite를 사용하는 방법, iOS 로컬 네트워킹 및 Android localhost 클리어텍스트 설정 포함. 암호화는 선택 사항이므로 기본 설치에는 SQLCipher가 포함되지 않습니다.
1단계: Native Build에 SQLCipher 추가하기
Android
의존성을 다음에 추가하세요 앱 레벨 android/app/build.gradle:
dependencies {
implementation 'net.zetetic:sqlcipher-android:4.13.0'
}
플러그인 README에는 4.13.0이 고정되어 있습니다. 메이저 버전이 4로 유지되는 한 더 새로운 4.x 릴리스도 작동합니다. 이 의존성이 없으면 encrypted: true 크래시 대신 명확한 오류(“SQLCipher 네이티브 라이브러리를 찾을 수 없음”)가 발생합니다.
SQLCipher는 네이티브 .so 파일을 각 ABI별로 제공합니다. APK 크기가 몇 MB 정도 커질 수 있습니다. App Bundles는 각 기기에 필요한 ABI만 전달합니다.
iOS
플러그인을 SQLCipher subspec으로 전환하려면 ios/App/Podfile이는 bunx cap sync 기본 항목을 작성했습니다:
pod 'CapgoCapacitorFastSql/SQLCipher', :path => '../../node_modules/@capgo/capacitor-fast-sql'
그 다음:
cd ios/App && pod install
bunx cap sync Podfile의 Capacitor 섹션을 재생성하므로, sync할 때마다 이 줄을 확인하세요. subspec이 없으면 플러그인은 시스템 SQLite에 대해 빌드되며, 열기 시 encrypted: true 반환합니다 Encryption is not available in this build.
웹
웹에는 SQLCipher가 없습니다. Fast SQL의 encrypted 옵션은 iOS와 Android 전용입니다. 앱이 PWA로도 배포된다면 Capacitor.isNativePlatform() 을(를) 기준으로 분기 처리하고, 브라우저 빌드에는 민감한 테이블을 영구 저장하지 마세요.
2단계: 키 생성하기
iOS 및 Android WebView에서 사용 가능한 Web Crypto API를 사용하세요:
export function generateKey(): string {
const bytes = new Uint8Array(32); // 256 bits
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
이렇게 하면 64자리 16진수 문자열이 생성됩니다. SQLCipher는 이 문자열을 passphrase로 취급하고 PBKDF2-HMAC-SHA512(SQLCipher 4 기본값은 256,000회 반복)로 실제 키를 유도합니다. 이 유도 과정은 데이터베이스를 열 때마다 한 번씩 실행되며, 구형 기기에서는 체감될 만큼의 시간(1초 미만)이 걸리는데 이는 정상입니다.
데이터베이스를 특정 비밀번호에 묶어두려는 게 아니라면 사용자 비밀번호를 키로 직접 쓰지 마세요. 사용자는 비밀번호를 잊어버리고, 다른 기기에서 바꾸고, 약한 비밀번호를 고르기 마련입니다. 보통은 무작위 기기 키를 만들어 secure storage에 저장하는 방식을 씁니다. 사용자 인증으로 접근을 제한해야 한다면, 키 자체를 잠그는 대신 (생체 인증 등으로) 키에 대한 접근을 제한하세요.
3단계: 키를 Keychain 또는 Keystore에 저장
@capgo/capacitor-native-biometric iOS Keychain과 Android Keystore로 저장 시 암호화하여 임의의 문자열을 저장합니다. setData 및 getData (플러그인 8.6.0 이상) 사용자에게 프롬프트를 표시하지 않습니다. 다음을 추가하세요: accessControl 키를 읽기 전에 Face ID, Touch ID 또는 지문 인식을 요구하도록 설정합니다.
bun add @capgo/capacitor-native-biometric
bunx cap sync
// src/db/key.ts
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { generateKey } from './generate-key';
const KEY_NAME = 'app.db.key';
export async function getOrCreateDbKey(): Promise<{ key: string; created: boolean }> {
const { isSaved } = await NativeBiometric.isDataSaved({ key: KEY_NAME });
if (isSaved) {
const { value } = await NativeBiometric.getData({ key: KEY_NAME });
return { key: value, created: false };
}
const key = generateKey();
await NativeBiometric.setData({ key: KEY_NAME, value: key });
return { key, created: true };
}
export async function deleteDbKey(): Promise<void> {
await NativeBiometric.deleteData({ key: KEY_NAME });
}
선택 사항: 데이터베이스 잠금 해제에 생체 인증 요구
import { AccessControl, NativeBiometric } from '@capgo/capacitor-native-biometric';
// When creating the key
await NativeBiometric.setData({
key: 'app.db.key',
value: generateKey(),
accessControl: AccessControl.BIOMETRY_ANY,
});
// When opening the database
const { value: key } = await NativeBiometric.getSecureData({
key: 'app.db.key',
reason: 'Unlock your records',
});
BIOMETRY_ANY 새 지문이나 얼굴이 등록되어도 유지됩니다. BIOMETRY_CURRENT_SET 등록 정보가 바뀌면 키를 무효화합니다. 더 엄격하지만, 사용자가 지문을 추가하면 로컬 데이터베이스를 읽을 수 없게 됩니다. 서버에서 데이터를 다시 동기화할 수 있을 때만 선택하세요. 프롬프트와 fallback에 대한 자세한 내용은 다음을 참조하세요: Capacitor 앱의 생체 인증.
키를 저장하면 안 되는 곳
- JavaScript에서,
.env파일 또는capacitor.config.ts입니다. 모두 앱 번들에 포함되어 배포되며, 누구나 IPA나 APK의 압축을 풀 수 있습니다. - 다음에서
@capacitor/preferences또는localStorage입니다. 이들은 평문입니다. - 로컬 복사본 없이 매번 앱 실행 시 API에서 가져옵니다. 이렇게 하면 오프라인에서 앱이 열리지 않아 로컬 데이터베이스의 의미가 사라집니다. 로그인 후 한 번만 가져와 Keychain 또는 Keystore에 저장하는 방식이 적절합니다.
4단계: 암호화된 데이터베이스 열기
// src/db/index.ts
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
import { getOrCreateDbKey } from './key';
import { migrate } from './migrate';
let dbPromise: Promise<SQLConnection> | null = null;
export function getDb(): Promise<SQLConnection> {
if (!dbPromise) {
dbPromise = (async () => {
const { key } = await getOrCreateDbKey();
const db = await FastSQL.connect({
database: 'vault',
encrypted: true,
encryptionKey: key,
walMode: true,
});
await verifyKey(db);
await migrate(db);
return db;
})();
}
return dbPromise;
}
// SQLCipher only notices a wrong key when it reads the first page
async function verifyKey(db: SQLConnection): Promise<void> {
await db.query('SELECT count(*) AS n FROM sqlite_master');
}
verifyKey 중요합니다. SQLCipher는 열기 시점에 키를 검증하지 않습니다. 잘못된 키를 사용하면 첫 번째 실제 읽기에서 file is not a database 오류가 발생합니다. 따라서 즉시 저비용 읽기를 수행하고, 화면 중간이 아닌 한 곳에서만 오류를 처리하세요.
암호가 활성화되었는지 확인하려면 PRAGMA cipher_version 를 연결에 실행하세요. SQLCipher 버전을 반환하며, 일반 SQLite에서는 빈 결과가 반환됩니다.
5단계: 분실되거나 잘못된 키 처리
일반적인 사용 중에도 키가 사라질 수 있습니다:
- 사용자가 새 휴대폰으로 백업을 복원하는 경우입니다. 데이터베이스 파일은 복원되지만, 디바이스 전용으로 생성된 Keychain 항목과 Android Keystore 키는 새 하드웨어로 이동하지 않습니다.
- Android Auto Backup은 앱 파일을 복원하지만, 저장된 시크릿을 암호화하는 데 사용된 Keystore 키는 복원되지 않습니다.
- 사용자가 생체 인증 등록을 변경하고
BIOMETRY_CURRENT_SET.
복구 방법은 항상 동일합니다. 로컬 데이터가 사라졌으므로 파일을 삭제하고 처음부터 다시 시작하며, 백엔드에서 다시 동기화하세요.
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL } from '@capgo/capacitor-fast-sql';
import { deleteDbKey } from './key';
export async function resetEncryptedDatabase(name = 'vault'): Promise<void> {
try {
await FastSQL.disconnect(name);
} catch {
// not connected
}
// Fast SQL stores <name>.db in Documents on iOS and in files/ on Android.
// Directory.Data maps to those two locations.
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${name}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
await deleteDbKey();
}
다음 시점에 호출하세요 verifyKey 실패하면 file is not a database, 다음을 리셋하고 dbPromise, 다시 여세요. 새 키가 생성되고 빈 데이터베이스가 만들어집니다.
확인할 두 가지 관련 설정:
- Android에서는
data_extraction_rules.xml에서 데이터베이스를 Auto Backup에서 제외하는 것을 고려하세요. 복원 후에도 복호화할 수 없다면, 사용자에게 혼란스러운 리셋을 방지할 수 있습니다. - iOS에서는
Documents에 있는 파일은 iCloud 백업에 포함됩니다. 암호화되어 있으므로 키가 있어야만 읽을 수 있지만, 데이터를 다시 동기화할 수 있다면 제외하는 것이 백업 크기를 줄이는 데 도움이 됩니다.
기존 평문 데이터베이스 암호화하기
앱이 이미 암호화되지 않은 데이터베이스로 배포된 경우, 같은 파일에서 encrypted: true 을 켤 수 없습니다. SQLCipher가 평문 페이지를 복호화하려고 시도하다가 실패합니다. 데이터를 새 암호화 데이터베이스에 한 번 복사하세요:
// src/db/encrypt-existing.ts
import { Directory, Filesystem } from '@capacitor/filesystem';
import { FastSQL, type SQLConnection } from '@capgo/capacitor-fast-sql';
export async function encryptExistingDatabase(
plainName: string,
encryptedName: string,
key: string,
): Promise<SQLConnection> {
const plain = await FastSQL.connect({ database: plainName });
const enc = await FastSQL.connect({ database: encryptedName, encrypted: true, encryptionKey: key });
const objects = (await plain.query(
`SELECT type, name, sql FROM sqlite_master
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'view' THEN 2 ELSE 3 END`,
)) as { type: string; name: string; sql: string }[];
const [{ user_version }] = await plain.query('PRAGMA user_version');
const tables = objects.filter((o) => o.type === 'table');
const others = objects.filter((o) => o.type !== 'table'); // indexes, triggers, views
await enc.transaction(async (tx) => {
for (const t of tables) {
await tx.execute(t.sql);
}
for (const t of tables) {
const rows = await plain.query(`SELECT * FROM "${t.name}"`);
if (rows.length === 0) continue;
const columns = Object.keys(rows[0]);
const statement = `INSERT INTO "${t.name}" (${columns.map((c) => `"${c}"`).join(', ')})
VALUES (${columns.map(() => '?').join(', ')})`;
await tx.executeBatch(rows.map((r) => ({ statement, params: columns.map((c) => r[c]) })));
}
// Indexes and triggers after the data: faster, and triggers do not fire during the copy
for (const o of others) {
await tx.execute(o.sql);
}
await tx.execute(`PRAGMA user_version = ${Number(user_version)}`);
});
await FastSQL.disconnect(plainName);
for (const suffix of ['', '-wal', '-shm']) {
await Filesystem.deleteFile({ path: `${plainName}.db${suffix}`, directory: Directory.Data }).catch(
() => undefined,
);
}
return enc;
}
성공 후 저장되는 플래그로 한 번만 실행하고, 모든 행을 읽으므로 진행 상태를 표시하세요. 참고:
- 테이블을 먼저 만들고 채운 뒤, 인덱스, 트리거, 뷰를 만듭니다. 인덱스는 마지막에 한 번에 만드는 편이 더 빠르고, 기존 행을 복사하는 동안 트리거는 실행되지 않습니다.
sqlite_sequence는 건너뜁니다. 명시적인 id가 있는 행을 삽입할 때 SQLite가 자동으로 업데이트하므로AUTOINCREMENT는 가장 높은 id부터 이어집니다.- 가상 테이블(FTS)은 내부 섀도 테이블을 만듭니다. FTS 테이블은 해당
CREATE VIRTUAL TABLE문으로 다시 만들고, 섀도 테이블을 복사하는 대신 인덱스를 다시 빌드하세요. - 매우 큰 테이블의 경우
WHERE rowid > ? ORDER BY rowid LIMIT 5000을(를) 사용해 페이지 단위로 처리하세요. 단일SELECT *. - 평문 파일을 삭제하는 것이 이 작업의 핵심입니다. 삭제된 플래시 스토리지에는 이전 블록이 남아 있을 수 있지만, 파일 시스템으로는 더 이상 접근할 수 없습니다.
키 로테이션
SQLCipher는 PRAGMA rekey으로 전체 데이터베이스를 다시 암호화합니다. Fast SQL에서는 암호화된 연결을 연 상태에서 SQL 문으로 실행합니다:
import { NativeBiometric } from '@capgo/capacitor-native-biometric';
import { getDb } from './index';
import { generateKey } from './generate-key';
export async function rotateKey(): Promise<void> {
const db = await getDb();
const newKey = generateKey(); // hex only, safe to inline
await db.execute(`PRAGMA rekey = '${newKey}'`);
await NativeBiometric.setData({ key: 'app.db.key', value: newKey });
}
순서가 중요합니다. 두 줄 사이에서 앱이 종료되면 데이터베이스는 새 키를 사용하지만 Keychain에는 여전히 구 키가 남아 있게 됩니다. 이를 방지하려면 먼저 새 키를 두 번째 이름으로 작성한 뒤, rekey를 수행하고 이를 승격한 후 구 항목을 삭제하세요. 그리고 시작 시 메인 키가 실패하면 대기 중인 키를 시도하세요. PRAGMA 바인딩된 파라미터를 지원하지 않아 키가 인라인으로 처리됩니다. 인라인으로 사용할 값은 반드시 직접 생성한 값만 사용하세요. 사용자 입력은 절대 사용하지 마세요. rekey 모든 페이지를 다시 작성합니다.
다른 플러그인을 이용한 암호화
| 플러그인 | 암호화 작동 방식 | 키 관리 |
|---|---|---|
@capgo/capacitor-fast-sql |
iOS 및 Android에서 SQLCipher 옵트인 | 키를 전달합니다 connect() |
@capacitor-community/sqlite |
SQLCipher는 항상 번들되며, 다음에서 활성화됩니다 capacitor.config |
플러그인은 앱당 하나의 패스프레이즈를 저장하며 setEncryptionSecret(), 모드 secret, encryption (기존 파일을 암호화), newsecret |
@capgo/capacitor-data-storage-sqlite |
키-값 저장소로 SQLCipher 사용 | 패스프레이즈 설정 위치 capacitor.config, 따라서 앱에 포함되어 배포됩니다 |
커뮤니티 플러그인의 encryption 이 모드는 기존 데이터베이스를 제자리에서 암호화할 수 있어, 이미 사용 중이라면 편리합니다. passphrase를 사용하여 capacitor.config (data-storage-sqlite)를 사용할 때는 암호화를 난독화로 취급하세요. 비밀은 번들에 들어 있기 때문입니다. 정말 민감한 값에는 보안 저장소의 키를 사용하는 Fast SQL을 권장합니다. 자세한 내용은 Capacitor의 키-값 저장소 옵션에서 해당 트레이드오프를 확인하세요.
컴플라이언스 참고 사항
- App Store 수출 컴플라이언스. SQLCipher를 포함하면 앱에 암호화가 포함됩니다. App Store Connect는 빌드마다 이에 대해 묻습니다(또는 직접 설정하면
ITSAppUsesNonExemptEncryption에서Info.plist). 기기 내 사용자 자신의 데이터 암호화는 일반적으로 면제 대상에 해당하지만, 답은 앱과 배포하는 국가에 따라 달라집니다. 확실하지 않다면 Apple의 가이드를 확인하고 조언을 구하세요. 저희의 앱 암호화 개요 에서 전반적인 내용을 다룹니다. - SQLCipher 라이선스. 커뮤니티 에디션은 앱에 저작권 고지를 포함하도록 요구하는 BSD 스타일 라이선스를 사용합니다. 오픈소스 라이선스 화면에 추가하세요.
- Google Play 데이터 보안. 데이터를 저장 시 암호화로 선언할 수 있습니다. 캐시와 로그를 포함한 모든 사본이 실제로 암호화되어 있는지 확인하세요.
문제 해결
file is not a database 첫 번째 쿼리에서. 키가 잘못되었거나, 파일이 평문인데 다음으로 열었습니다 encrypted: true. 보안 스토리지의 키가 생성할 때 사용한 키와 같은지 확인한 후, 평문 파일에는 위의 마이그레이션을 사용하세요.
Encryption is not available in this build iOS에서. Podfile는 여전히 기본 subspec을 사용합니다. 다음을 다시 추가하세요 CapgoCapacitorFastSql/SQLCipher 라인을 cap sync 그리고 다음을 실행하세요: pod install.
SQLCipher native library not found Android에서. 이 sqlcipher-android 의존성이 앱 모듈에서 누락되었거나, 플러그인 또는 루트의 build.gradle 대신 사용하세요.
데이터베이스 열기 속도가 느립니다. 열기 시마다 키 파생이 실행됩니다. 쿼리마다 연결하지 말고, 시작 시 한 번만 열어 연결을 재사용하세요.
데이터베이스가 한 기기에서는 열리지만 복원 후에는 열리지 않습니다. 키가 백업과 함께 이동하지 않았습니다. 리셋 플로우로 처리하고 서버에서 다시 동기화하세요.
요약
- SQLCipher 추가:
sqlcipher-androidAndroid 앱 모듈의SQLCipheriOS의 subspec. - 첫 실행 시 무작위 256비트 키 생성.
- 다음으로 저장:
@capgo/capacitor-native-biometric(setData선택적으로accessControl). - 다음으로 열기:
encrypted: true그 후 한 번 읽어 키를 검증. - 키 분실 대비: 초기화 및 재동기화.
- 기존 평문 데이터베이스를 한 번 마이그레이션하고, 정책이 요구하면
PRAGMA rekey으로 키를 로테이션.
SQLCipher 추가와 같은 네이티브 변경 사항은 새로운 스토어 빌드가 필요합니다. Capgo 빌드 로컬 Mac 없이 서명된 iOS 및 Android 바이너리를 생성할 수 있으며, Fast SQL 문서 모든 연결 옵션을 나열합니다.