크롬 확장에 위장 공간 만들기: AES-GCM과 티어드 패딩으로 Plausible Deniability 구현

단순 암호화는 내용만 숨기고 존재는 드러냅니다. 크롬 확장에서 Plausible Deniability를 달성하기 위해 AES-GCM 고정 슬롯, 티어드 패딩, CSPRNG, 그리고 Auto-Timeout UX까지 네 레이어를 쌓은 실전 사례를 공유합니다.

크롬 확장에 위장 공간 만들기: AES-GCM과 티어드 패딩으로 Plausible Deniability 구현

1. 문제 상황: 암호화만으로는 "숨긴다"가 아니다

사이드 프로젝트로 만들고 있는 크롬 확장에는 PIN으로 잠글 수 있는 비밀 북마크 공간 기능이 있습니다. 처음에는 단순하게 시작했습니다.

// 나이브한 접근: PIN으로 암호화해서 그대로 저장
const encrypted = await encrypt(bookmarks, pin);
await chrome.storage.local.set({ secretBookmarks: encrypted });

동작은 잘 됩니다. PIN이 맞으면 열리고, 틀리면 에러가 납니다. 하지만 이 구현에는 치명적인 UX 결함이 있습니다. DevTools를 여는 것만으로 문제가 드러납니다.

1.1 DevTools 한 번이면 모든 게 드러난다

크롬 확장의 chrome.storage.localchrome://extensions/ → 배경 페이지 검사 → Application 탭에서 누구나 열어볼 수 있습니다. 공격자가 특별한 도구 없이 맨손으로 다음을 확인할 수 있습니다.

// 악의적인 관찰자의 시점
chrome.storage.local {
  secretBookmarks: { iv: "...", ct: "AAAAAA...32KB..." }
  //              ↑ "아, 암호화된 뭔가가 있네. PIN이 있다는 뜻이군."
}

데이터의 존재 자체가 메타데이터입니다. 암호문을 깨뜨리지 못해도, "여기 비밀이 있다"는 정보 한 줄로 충분한 상황이 존재합니다.

1.2 위협 모델: 강요된 PIN 공개

구체적인 위협 시나리오를 정리하면 이렇습니다.

  • 적의 능력: DevTools를 열어 chrome.storage의 모든 키/값을 조회할 수 있음
  • 적의 한계: 사용자의 PIN을 모르고, 메모리상의 복호화된 상태에 접근 불가
  • 사용자의 목표: 단순히 복호화를 막는 것이 아니라, 비밀 공간이 존재하지 않는 것처럼 보여야 함

이런 요구 사항을 보안 공학에서는 Plausible Deniability(그럴듯한 부인 가능성) 이라고 부릅니다. 암호문이 아무리 강력해도 "여기 뭔가 있다"는 신호 자체가 새어 나가면 목적을 달성할 수 없습니다.

1.3 이 글이 다루는 범위

본문에서는 PBKDF2 키 유도의 기초 이론(반복 횟수, 솔트 길이, 해시 함수 선택)은 다루지 않습니다. 이 부분은 이전 글 Web Crypto API로 안전한 해싱 구현하기: SHA-256에서 PBKDF2까지를 참고해 주세요. 본문은 "PBKDF2로 키를 유도한 뒤, 그 키를 어떻게 스토리지 레이아웃에 녹이느냐" 에 집중합니다.


2. 원인 분석: 단순 암호화가 새는 메타데이터 3가지

단일 암호화 블롭 방식에서 새는 정보를 분해해 보면 다음과 같습니다.

2.1 존재 정보 (Existence)

키 이름 "secretBookmarks"가 있음 → 비밀 북마크 기능을 씀
키 이름이 없음 → 비밀 북마크를 쓰지 않음

키가 있는지 없는지만으로도 사용자의 행동이 프로파일링됩니다. 해결책은 키가 항상 존재하게 만드는 것입니다.

2.2 크기 정보 (Size)

ct 길이 48 bytes    → 빈 공간일 확률 높음
ct 길이 32 KB       → 북마크 수십 개가 들어있음
ct 길이 200 KB      → 꽤 많이 쓰고 있음

AES-GCM 같은 스트림 기반 암호는 plaintext 크기와 ciphertext 크기가 거의 같습니다(+16바이트 auth tag뿐). 이는 네트워크 트래픽 분석에서 암호화된 채팅의 메시지 길이로 언어를 추정하는 전형적인 공격 벡터와 같습니다. 해결책은 크기를 정규화하는 것입니다.

2.3 엔트로피 정보 (Entropy)

// 초기 구현에서 저지른 실수
const dummyData = Math.random().toString(36); // ← 문제: 예측 가능

가짜 데이터를 만들 때 Math.random()을 쓰면 안 됩니다. JavaScript의 Math.random()암호학적으로 안전한 난수(CSPRNG)가 아닙니다. V8 엔진의 xorshift128+ 알고리즘은 내부 상태가 단 128비트이고, 충분한 출력을 관찰하면 다음 값을 예측할 수 있다는 논문도 나와 있습니다.

더미 슬롯이 실제 슬롯과 구별되지 않으려면, 더미의 비트도 진짜 랜덤이어야 합니다.


3. 해결 방법: 4단 쌓기로 구축하는 Plausible Deniability

네 가지 층을 쌓습니다. 하나만으로는 부족하고, 하나라도 빠지면 구멍이 뚫립니다.

해결하는 문제 핵심 기법
① 고정 슬롯 존재 정보 누출 설치 시 N개 슬롯 미리 할당
② 티어드 패딩 크기 정보 누출 4KB/16KB/64KB/256KB 정규화
③ Auth Tag 매칭 더미와 실제 구분 tierSize + 16 바이트 생성
④ CSPRNG 엔트로피 누출 crypto.getRandomValues()

그리고 마지막으로 UX 층 — auto-timeout이 어디로 돌아가야 하는가도 같은 목적을 공유합니다.

3.1 고정 슬롯: 모든 슬롯이 "존재"한다

핵심 아이디어는 단순합니다. 확장 설치 시점에 N개 슬롯을 미리 생성하고, 그 시점의 슬롯은 전부 가짜입니다. 사용자가 PIN으로 공간을 만들면 가짜 슬롯 중 하나가 진짜 암호문으로 덮어씌워집니다. 외부에서 보면 슬롯 수는 변하지 않고, 각 슬롯의 내용물을 구별할 방법이 없습니다.

// extension/shared/space-manager.js
const SLOTS_KEY = '_c';   // ← 의미 없는 이름으로 스키마 힌트도 제거
const SALT_KEY = '_s';
const MAX_SLOTS = 8;

async function initializeSlots() {
  const existing = await chrome.storage.local.get(SLOTS_KEY);
  if (existing[SLOTS_KEY]) return; // 이미 초기화됨

  const salt = Crypto.generateSalt();
  const slots = Array.from(
    { length: MAX_SLOTS },
    () => Crypto.generateDummySlot() // ← 8개 전부 더미로 시작
  );
  await chrome.storage.local.set({
    [SALT_KEY]: salt,
    [SLOTS_KEY]: slots,
    [HAS_CREATED_KEY]: false,
    [NEXT_SLOT_KEY]: 0,
  });
}

이 시점에서 이미 비결이 하나 등장합니다. 스토리지 키 이름도 의미 없는 _c, _s 로 둡니다. secretBookmarks, encryptedSpaces 같은 이름을 쓰면 키 이름 자체가 메타데이터입니다.

그리고 공간을 찾는 함수 findSpace()타이밍 사이드채널도 고려해야 합니다.

async function findSpace(pin) {
  const salt = await getSalt();
  const slots = (await chrome.storage.local.get(SLOTS_KEY))[SLOTS_KEY] || [];

  // PBKDF2는 한 번만 수행, 8개 슬롯을 순회하며 각각 복호화 시도
  const key = await Crypto.deriveKey(pin, salt);
  let found = null;

  for (let i = 0; i < slots.length; i++) {
    const decrypted = await Crypto.decrypt(slots[i], key, salt);
    if (decrypted !== null && !found) {
      found = { index: i, data: decrypted, key };
    }
    // ← 찾아도 continue: 모든 슬롯을 동일하게 순회
  }
  return found;
}

주석의 // 찾아도 continue가 핵심입니다. 일반적인 탐색 코드라면 찾는 즉시 break하고 싶지만, 그러면 "몇 번째 슬롯에서 멈췄는가"가 타이밍으로 새어 나갑니다. 복호화 시도 횟수가 PIN과 연관되면 사이드채널이 됩니다. 그래서 항상 8번 전부 수행합니다.

3.2 티어드 패딩: 크기 정규화

슬롯이 8개 존재하는 것만으로는 부족합니다. 그중 하나만 32KB이고 나머지 7개가 4KB면 "아, 32KB가 진짜네"가 드러납니다. 그래서 모든 슬롯의 크기를 몇 개의 고정 티어로 반올림합니다.

// extension/shared/crypto.js
const PADDING_TIERS = [4096, 16384, 65536, 262144]; // 4KB → 16KB → 64KB → 256KB

function tierFor(byteLength) {
  return PADDING_TIERS.find(t => t >= byteLength) || PADDING_TIERS[PADDING_TIERS.length - 1];
}

티어는 4단계로 잡았습니다. 4KB는 거의 빈 공간, 16KB는 북마크 100개 정도, 64KB는 북마크 수백 개, 256KB는 수천 개를 담기에 충분한 상한입니다. 이보다 세분화하면 티어 자체가 지문이 됩니다(예: 7.5KB만 유별나게 많으면 "아, 특정 사용 패턴이네"가 됨).

패딩은 암호문이 아니라 plaintext에 주입합니다.

async function encrypt(data, pinOrKey, saltB64, minSize) {
  const key = (pinOrKey instanceof CryptoKey)
    ? pinOrKey
    : await deriveKey(pinOrKey, saltB64);
  const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
  const enc = new TextEncoder();

  let payload = data;
  if (minSize && minSize > 0) {
    const json = JSON.stringify(data);
    const currentSize = enc.encode(json).length;
    if (currentSize < minSize) {
      // JSON 오버헤드 고려: ',"_pad":""}' 만큼 빼고 계산
      const overhead = ',"_pad":""}'.length;
      const jsonWithoutClosing = json.slice(0, -1);
      const padLength = minSize - enc.encode(jsonWithoutClosing).length - overhead;
      if (padLength > 0) {
        payload = { ...data, _pad: randomPadString(padLength) }; // ← 핵심
      }
    }
  }

  const ct = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    enc.encode(JSON.stringify(payload))
  );
  return { iv: bytesToB64(iv), ct: bytesToB64(new Uint8Array(ct)) };
}

_pad 필드는 plaintext JSON 안에 살기 때문에 암호화된 뒤에는 그 존재조차 드러나지 않습니다. 복호화 측에서는 자동으로 벗겨냅니다.

async function decrypt(slot, pinOrKey, saltB64) {
  const key = /* ... */;
  try {
    const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
    const result = JSON.parse(new TextDecoder().decode(pt));
    delete result._pad; // ← plaintext에서 벗겨냄, 호출자는 존재를 모름
    return result;
  } catch (e) {
    // AES-GCM auth failure = 키가 다름(=다른 슬롯) → null
    if (e instanceof DOMException && e.name === 'OperationError') return null;
    throw e;
  }
}

catch 블록도 자세히 볼 가치가 있습니다. AES-GCM에서 키가 틀리면 복호화가 던지는 에러는 OperationError 하나로 통일됩니다. 이걸 throw가 아니라 null 반환으로 변환해야 findSpace()의 루프가 "이 슬롯은 내 PIN으론 못 열어"를 조용히 넘길 수 있습니다. 다만 OperationError가 아닌 다른 에러는 진짜 버그 신호이므로 그대로 throw해서 삼키지 않습니다. "알려진 실패는 null, 예상 못 한 실패는 throw"라는 원칙입니다.

3.3 Auth Tag 매칭: 더미와 실제를 byte 단위로 맞추기

여기서 처음 놓쳤던 버그가 등장합니다. 초기 구현의 더미 슬롯은 이렇게 생겼었습니다.

// ❌ 초기 버전 — 16바이트가 비는 버그
function generateDummySlot() {
  const tierSize = pickRandomTier();
  return {
    iv: bytesToB64(crypto.getRandomValues(new Uint8Array(IV_BYTES))),
    ct: bytesToB64(crypto.getRandomValues(new Uint8Array(tierSize))), // ← 여기
  };
}

PR 리뷰에서 지적받은 포인트: AES-GCM으로 실제 암호화된 슬롯은 plaintext 크기 + 16바이트(auth tag) 로 결과가 나옵니다. 그런데 더미는 tierSize 바이트 그대로였습니다. 즉, 진짜 슬롯은 정확히 16바이트 더 길었고, DevTools에서 slot.ct.length를 비교하면 구분 가능했습니다.

수정은 간단하지만, 이런 디테일을 놓치면 전체 설계가 무너집니다.

// ✅ 수정 버전
const GCM_TAG_BYTES = 16;

function generateDummySlot() {
  // CSPRNG로 티어 선택 + 256KB 티어 포함 (실제 큰 공간과의 쌍 유지)
  const r = crypto.getRandomValues(new Uint8Array(1))[0] / 256;
  const tierIndex = r < 0.55 ? 0 : r < 0.85 ? 1 : r < 0.97 ? 2 : 3;
  const tierSize = PADDING_TIERS[tierIndex];

  // AES-GCM auth tag 크기까지 맞춰 byte 단위로 진짜 슬롯과 동일하게
  return {
    iv: bytesToB64(crypto.getRandomValues(new Uint8Array(IV_BYTES))),
    ct: bytesToB64(crypto.getRandomValues(new Uint8Array(tierSize + GCM_TAG_BYTES))),
    //                                                   ↑ 16바이트 보정
  };
}

더불어 티어 분포를 잘 선택해야 합니다. 더미가 전부 4KB 티어라면 "가장 작은 공간은 전부 가짜"가 패턴이 됩니다. 실제 공간이 어떤 크기로 분포하든 대응 가능하도록, 더미도 4개 티어에 걸쳐 확률 분포를 갖습니다.

티어 크기 더미 확률 의도
0 4 KB 55% 가장 흔한 크기대 — 빈 공간, 적은 데이터
1 16 KB 30% 중간 규모 사용
2 64 KB 12% 활발한 사용자
3 256 KB 3% 최대치 — 드물지만 존재해야 "진짜일 리 없네"가 안 됨

256KB 티어에 더미가 단 3%라도 있는 게 중요합니다. 만약 여기가 0%면, 256KB 슬롯은 항상 진짜라는 공식이 성립합니다. 확률이 낮더라도 "가능은 하다"가 성립해야 부인 가능성이 유지됩니다.

3.4 CSPRNG: Math.random()은 보안 코드에 들이지 말 것

같은 PR 리뷰에서 붙잡힌 또 다른 포인트입니다. 티어 인덱스 선택에 Math.random()을 쓰고 있었습니다.

// ❌ 문제 코드
const r = Math.random();
const tierIndex = r < 0.6 ? 0 : r < 0.9 ? 1 : 2;

Math.random()이 Plausible Deniability를 망치는 시나리오는 이론적이지만 분명합니다.

  • V8의 Math.random()은 내부 상태가 제한된 PRNG로, 충분한 출력을 관찰하면 시드를 역산할 수 있습니다.
  • 확장이 특정 시점에 만든 더미들의 티어 분포가 예측 가능해집니다.
  • 공격자가 브라우저를 통제하는 상황에서는 "이 시점에 설치된 확장이라면 더미 티어가 [0, 1, 1, 2, 0, 3, 1, 0]이어야 한다"는 기대값이 생깁니다.
  • 실제 분포가 이 기대와 다르면 "아, 어떤 슬롯이 진짜로 대체됐구나" 가 드러납니다.

수정은 한 줄입니다.

// ✅ CSPRNG로 전환
const r = crypto.getRandomValues(new Uint8Array(1))[0] / 256;
const tierIndex = r < 0.55 ? 0 : r < 0.85 ? 1 : r < 0.97 ? 2 : 3;

crypto.getRandomValues()는 OS의 CSPRNG(예: macOS /dev/urandom, Linux getrandom(), Windows BCryptGenRandom)에 직접 연결됩니다. 원칙은 단순합니다.

보안에 걸리는 랜덤은 무조건 crypto.getRandomValues() — 예외 없음.

Math.random()을 허용할 수 있는 경우는 오로지 출력의 예측 가능성이 문제되지 않는 곳뿐입니다. 애니메이션 지터, UI A/B 그룹 나누기 같은 영역. 이 글의 맥락에서는 전부 crypto.getRandomValues()로 통일됩니다.

3.5 UX 결정: Auto-Timeout은 Lock Screen이 아니라 Default Space로

기술 레이어가 끝나면 UX 레이어가 남습니다. "비활성 시 자동으로 잠그는 타이머"를 어떻게 마무리해야 할까요?

순진한 구현은 Lock Screen을 띄우는 것입니다. 하지만 Lock Screen 자체가 "여기 잠긴 뭔가가 있다" 는 UI 신호입니다. 팝업을 여는 순간 잠금 화면이 덮이면, 어깨 너머로 보는 사람에게 "아, 이 사람은 숨긴 게 있구나"가 즉시 전달됩니다.

그래서 방향을 바꿨습니다. 타임아웃이 발동되면 기본(잠기지 않은) 공간으로 돌아갑니다.

// extension/background.js
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === AUTO_LOCK_ALARM_NAME) {
    if (SpaceManager.isInSpace()) {
      SpaceManager.exitToDefault(); // ← 잠금 화면이 아니라 default로
      console.debug('[Ext] Auto-returned to default space due to inactivity');
      chrome.runtime.sendMessage({ type: 'spaceChanged', space: 'default' }).catch(() => {});
    }
  }
});

그리고 exitToDefault()는 메모리 상의 민감 상태를 전부 null로 돌립니다.

function exitToDefault() {
  activeSpace = null;
  activeSlotIndex = -1;
  activePin = null;
  activeCryptoKey = null; // ← 캐시된 CryptoKey도 날림
}

외부 관찰자 입장에서 팝업은 단지 "평범한 기본 북마크 관리 확장"으로 보입니다. 그게 진실의 전부이기도 하죠 — 기본 공간은 진짜로 비어 있거나 전혀 민감하지 않은 내용만 담겨 있습니다. 비밀 공간이 있는지 없는지는 사용자만 압니다.

디테일 하나 더: activeCryptoKey도 같이 null 처리합니다. 성능을 위해 PBKDF2 결과를 캐시하고 있었기 때문에, 이 참조가 남아 있으면 재진입 없이 저장이 가능해집니다. 타임아웃의 의미를 살리려면 캐시도 같이 날아가야 합니다.


4. 핵심 개념 정리

4.1 레이어별 책임 요약

레이어 책임 핵심 코드
키 유도 PIN → AES 키 Crypto.deriveKey() (PBKDF2 100K iter, SHA-256)
스키마 은닉 키 이름조차 숨김 SLOTS_KEY = '_c', SALT_KEY = '_s'
슬롯 초기화 설치 시 N개 더미 할당 initializeSlots()
암호화 + 패딩 plaintext에 _pad 주입 후 암호화 Crypto.encrypt(data, key, salt, minSize)
복호화 + strip 복호화 후 _pad 자동 제거 Crypto.decrypt(slot, key, salt)
더미 생성 tierSize + 16 CSPRNG 바이트 Crypto.generateDummySlot()
슬롯 탐색 전체 순회(타이밍 방어) SpaceManager.findSpace(pin)
상태 정리 타임아웃 시 null 복귀 SpaceManager.exitToDefault()

4.2 슬롯 구조 다이어그램

chrome.storage.local
├── _s : "…base64 salt…"              ← 32바이트 솔트 (전역 1개)
├── _c : [
│         { iv, ct:  4120 B },        ← 티어 0 (4KB + 24)
│         { iv, ct: 16408 B },        ← 티어 1 (16KB + 24)
│         { iv, ct: 16408 B },        ← ←── 실제 공간일 수도, 더미일 수도
│         { iv, ct:  4120 B },
│         { iv, ct: 65560 B },        ← 티어 2
│         { iv, ct:  4120 B },
│         { iv, ct: 16408 B },
│         { iv, ct:262168 B },        ← 티어 3 (256KB + 24)
│       ]
├── _nextSlot : 3                     ← 다음에 쓸 슬롯 인덱스(내부용)
└── hasCreatedSpace : true            ← UI 힌트용 플래그

외부에서 보이는 것은 8개의 고정 슬롯과 4개의 가능한 크기대뿐입니다. 어느 슬롯이 진짜인지, 진짜가 몇 개인지 알 수 없습니다.

4.3 왜 _pad가 plaintext 안에 있어야 하나

패딩을 ciphertext 밖에 붙이면(예: ct 뒤에 랜덤 바이트 append) 복호화 전에 벗겨내야 하고, 그러려면 "어디까지가 진짜 ct인지"를 외부에 드러내야 합니다. 그 자체가 메타데이터입니다.

plaintext에 _pad 필드를 주입하면 모든 게 AES-GCM 암호화의 보호막 안에 들어갑니다. 공격자에게 보이는 건 "길이가 정확히 4120바이트인 암호문" 하나뿐입니다. 내부 구조는 키를 가진 사람만 볼 수 있습니다.


5. 베스트 프랙티스

스토리지 기반 Plausible Deniability를 구현할 때 체크할 목록입니다.

5.1 반드시 해야 할 것

  • 키 이름을 의미 없게 만들 것: secretBookmarks 같은 설명적 키 이름 금지. _c, _s 같은 한두 글자가 좋음
  • 데이터가 없어도 키가 있어야 함: 최초 설치 시점에 더미로 채워서 "이 기능을 쓴 적 없음"이 메타데이터가 되지 않게
  • 모든 슬롯을 동일하게 순회: 찾아도 break 금지 (타이밍 사이드채널 방지)
  • 보안용 랜덤은 crypto.getRandomValues(): Math.random()이 보안 경로에 들어오면 설계가 무너짐
  • 패딩은 암호화 안쪽에: _pad 필드를 plaintext JSON에 주입 후 전체를 암호화
  • AES-GCM auth tag 크기 보정: 더미 바이트 길이는 tierSize + 16
  • 에러 메시지 통일: "틀린 PIN"과 "슬롯 없음"이 같은 결과로 귀결돼야 함
  • Auto-timeout은 중립 상태로 복귀: Lock Screen 말고 기본 공간으로

5.2 하지 말아야 할 것

  • chrome.storagehasSecretSpace: true 같은 플래그 저장 — 존재 자체가 누출
  • ❌ 빈 배열을 그대로 암호화 — 크기가 너무 작아 식별 가능
  • ❌ 찾는 즉시 break로 루프 종료 — 타이밍 공격
  • ❌ 더미 생성 시 Math.random() 사용 — 예측 가능한 패턴
  • ❌ Lock Screen을 기본 UI로 띄우기 — "뭔가 잠겨 있다"가 시각적 단서
  • ❌ 성공/실패 분기마다 다른 로그 메시지 — 로그가 부인 가능성을 깨뜨림

5.3 "단순 암호화로 충분한가?" 체크리스트

새 기능을 설계할 때 이 질문들을 순서대로 돌려봅니다.

  1. 누구로부터 숨기는가? (공격자 모델)
  2. 공격자가 DevTools/파일시스템/백업을 열 수 있는가? (Yes면 Plausible Deniability 필요)
  3. "암호화된 뭔가가 있다"는 사실 자체가 문제인가? (Yes면 고정 슬롯 필요)
  4. 데이터 크기가 사용량과 상관관계가 있는가? (Yes면 패딩 필요)
  5. 랜덤이 쓰이는 모든 자리에 CSPRNG를 쓰는가? (한 자리라도 아니면 전부 점검)

6. FAQ

Q1. 왜 슬롯을 8개로 고정했나요? 사용자가 늘릴 수 있게 해야 하지 않나요?

A. 슬롯 개수 자체가 메타데이터입니다. 6개 쓰는 사람과 12개 쓰는 사람이 달라 보이면, "이 확장에선 평균 몇 개까지 쓴다"는 통계가 무의미해집니다. MAX_SLOTS = 8은 모든 설치 인스턴스의 외형을 같게 만드는 상수입니다. 늘리면 기능은 좋아지지만 부인 가능성이 약해집니다.

Q2. PBKDF2 반복 횟수 100,000은 너무 작지 않나요?

A. OWASP 2023 가이드라인 기준 SHA-256 기반 PBKDF2의 최소 권장치가 600,000입니다. 확장 컨텍스트에서 100,000을 택한 이유는 UX(언락 응답 시간)와의 균형 때문입니다. 모바일이 아닌 데스크톱 크롬 기준으로 100K 반복이 200~400ms 정도 걸리는데, 이 이상 늘리면 "PIN 치고 기다리는 시간"이 눈에 띄게 길어집니다. 위협 모델이 서버측 패스워드 데이터베이스 유출이 아니라 로컬 브라우저 공격이므로, 반복 횟수보다 Plausible Deniability 레이어가 방어의 주력이라는 판단입니다.

Q3. 티어가 4단계면 너무 거친 것 아닌가요?

A. 거친 게 오히려 유리합니다. 티어가 세분화될수록 각 티어가 지문이 됩니다. 예를 들어 티어 10개 중 "8.5KB 티어"만 유달리 많은 사용자는 특정 패턴을 드러냅니다. 4단계는 4KB/16KB/64KB/256KB로 64배 스팬을 커버하면서도 분류 해상도는 낮게 유지합니다.

Q4. _pad 필드 이름이 JSON에 남는데 그게 문제 아닌가요?

A. plaintext JSON 안에만 존재하고, 전체가 AES-GCM으로 암호화된 뒤에는 ct 블롭에서 _pad라는 문자열이 보이지 않습니다. 암호화는 패턴을 없애기 때문입니다. 외부에서 보이는 건 랜덤 바이트뿐입니다.

Q5. 더미 슬롯이 64KB/256KB면 storage 용량을 낭비하는 거 아닌가요?

A. chrome.storage.local의 기본 한도는 Chrome에서 5MB 이상입니다(정확히는 unlimited permission이 필요할 수 있음). 8개 슬롯 × 최대 256KB = 2MB로 여유가 있습니다. 평균적인 더미 분포(티어 0 55%, 티어 3 3%)로는 200KB 수준입니다. 부인 가능성의 대가로는 저렴합니다.

Q6. AES-GCM 대신 AES-CBC나 ChaCha20-Poly1305를 써도 되나요?

A. Web Crypto API가 브라우저에서 기본 지원하는 AEAD는 AES-GCM 하나입니다. ChaCha20-Poly1305는 브라우저 지원이 고르지 않아 Chrome 확장 컨텍스트에서 쓰기 어렵습니다. AES-CBC는 AEAD가 아니라 무결성 검증이 빠져 있고, 별도로 HMAC을 얹는 것보다 AES-GCM이 단순·안전합니다.

Q7. findSpace()에서 for 루프를 Promise.all()로 병렬화하면 더 빠르지 않나요?

A. 빠르지만 타이밍 사이드채널이 생길 수 있습니다. Promise.all()의 실제 실행 순서와 타이밍은 일관되지 않고, 슬롯마다 복호화 성공/실패 경로가 다르면 전체 시간 차이가 관찰 가능합니다. 순차 루프가 더 느리지만 결과 시간이 균일합니다. PBKDF2는 어차피 한 번만 수행되므로(루프 밖에서 키 유도 후 루프 안에서 decrypt만) 성능 부담도 크지 않습니다.


7. 참고 자료


8. 다음 단계

이 글은 크롬 확장의 암호화 레이어에 초점을 맞췄습니다. 하지만 암호화된 공간이 생기면 자연스럽게 따라오는 질문이 있습니다. "공간마다 설정, 북마크, 라이선스, 탭이 격리되려면 어디서부터 스코핑해야 하나?" 이건 메시지 핸들러 설계의 문제이고, 다음 글에서 다룰 예정입니다.

시리즈 (크롬 확장 Plausible Deniability 계열):

  1. Web Crypto API로 안전한 해싱 구현하기: SHA-256에서 PBKDF2까지
  2. 크롬 확장에 위장 공간 만들기: AES-GCM과 티어드 패딩으로 Plausible Deniability 구현 ← 현재 글
  3. 단일 크롬 확장에서 멀티테넌트 구현하기: Space Isolation 패턴 (예정)