Quipu-Log 교과서
파트 5 · 무결성: 고치면 티가 나게 (보안 ①)

22 · 체크포인트와 외부 앵커링

머클 스파인이 레코드 변조를 잡는다면, 누군가 스파인 파일도 같이 고치면 어떻게 될까요? 디스크 전체 권한을 가진 내부자라면 로그와 스파인을 모두 삭제하고, 처음부터 다시 쌓은 자체 일관된 트리로 교체할 수 있습니다. 체크포인트와 외부 앵커링은 바로 이 시나리오를 막기 위해 존재합니다 — "내부자도 속일 수 없는 기준점"을 다른 신뢰 도메인에 심어두는 것입니다.

한 문장 요약

체크포인트는 (세그먼트 위치, 레코드 수, 머클 루트)를 RSA 서명으로 봉인하고, 외부 앵커 훅으로 그 루트를 스토어 밖으로 내보낸다 — 내부자가 로그를 전부 재작성해도 앵커된 과거 루트와 일관성 증명이 맞지 않는다.

머클 스파인만으로 충분하지 않은 이유

지금까지의 방어 체계를 정리해 봅시다:

  • 레코드 in-place 수정 → 리프 해시가 달라지고 → 루트가 달라짐 ✅
  • 세그먼트 파일 교체 → 리프 순서가 달라지고 → 루트가 달라짐 ✅
  • 꼬리 절단(세그먼트 삭제) → 트리 크기가 줄어 → 이전 체크포인트와 불일치 ✅

그러나 한 가지 시나리오가 남습니다: 공격자가 스토어 전체(세그먼트 + 스파인 + 체크포인트 파일)를 삭제하고, 조작된 레코드로 처음부터 다시 쌓은 자체 일관된 스토어를 만들면? 내부에는 비교할 "원래 값"이 없습니다.

내부 신뢰 도메인 (공격자 접근 가능) 로그 세그먼트 레코드 페이로드 merkle.spine 리프 해시 목록 checkpoints.log signed(tree_size, root) ← 이것도 공격자가 지울 수 있음 공격자: 전부 삭제 후 재작성 외부 신뢰 도메인 (공격자 도달 불가) 외부 앵커 타임스탬프 서비스 / 별도 호스트 앵커된 루트 과거 시점의 tree_size + root ← 공격자가 건드릴 수 없음 AnchorHook
체크포인트는 내부 스토어에 기록되지만, AnchorHook이 그 루트를 공격자가 도달할 수 없는 외부 신뢰 도메인으로 내보낸다. 재작성 공격은 앵커된 과거 루트와의 일관성 증명으로 탐지된다.

체크포인트: 서명된 스냅샷

체크포인트는 "특정 시점의 트리 상태"를 RSA 개인키로 서명하여 파일에 기록합니다. 핵심 필드를 보겠습니다:

crates/quipu-core/src/checkpoint.rs — Checkpoint 구조체pub struct Checkpoint {
    pub created_at:   u64,    // UTC 마이크로초
    pub segment_seq:  u64,    // 체크포인트 당시 활성 세그먼트 번호
    pub record_count: u64,    // 디스크상 레코드 수 (리텐션 후 감소 가능)
    pub tree_size:    u64,    // 머클 트리 크기 (절대 감소 안 함, 스파인은 purge 없음)
    pub merkle_root:  Hash,   // 해당 tree_size에서의 머클 루트 — 이게 "봉인"
    pub key_version:  u32,    // 서명에 사용한 RSA 키 버전
    pub signature:    Vec<u8>, // RSA PKCS#1 v1.5 / SHA-256 서명
}

tree_sizerecord_count가 분리된 점에 주목하세요. 리텐션으로 오래된 레코드가 삭제되면 record_count는 줄지만, 머클 스파인은 삭제하지 않으므로 tree_size는 절대 줄지 않습니다. 따라서 체크포인트 이후 리텐션이 돌아도, 현재 트리가 과거 체크포인트의 확장임을 일관성 증명으로 여전히 검증할 수 있습니다.

서명은 단순히 SHA-256(도메인_분리_prefix || 필드들)에 RSA PKCS#1 v1.5를 적용합니다:

crates/quipu-core/src/checkpoint.rs — 서명 바이트 구성const SIGNING_DOMAIN: &[u8] = b"quipu-checkpoint-v2\0";

fn signing_bytes(created_at, segment_seq, record_count, tree_size, merkle_root) {
    // 도메인 분리: 이 서명은 다른 용도의 RSA 서명과 절대 혼동되지 않음
    out.extend_from_slice(SIGNING_DOMAIN);
    out.extend_from_slice(&created_at.to_le_bytes());
    // ... 나머지 필드 ...
    out.extend_from_slice(merkle_root);
}

체크포인트는 언제 찍나

RSA 서명은 밀리초 단위 연산이라 모든 레코드마다 체크포인트를 찍으면 성능에 부담이 됩니다. Quipu-Log는 세 시점에만 찍습니다:

  1. 세그먼트 봉인(seal) 시점 — 세그먼트가 max_segment_bytes를 넘어 새 파일로 롤오버될 때
  2. 리텐션 실행 직후 — 오래된 세그먼트를 삭제했으니, 현재 상태를 봉인해 "삭제 후에도 일관하다"를 기록
  3. 수동 요청 — 관리자가 명시적으로 체크포인트를 원할 때

핫 패스(emit 경로)에 RSA 서명이 없으므로, 레코드 하나 쓸 때마다 지연이 생기지 않습니다.

AnchorHook: 외부로 내보내기

체크포인트 파일 자체도 스토어 안에 있으니 공격자가 지울 수 있습니다. 그래서 StoreConfiganchor 훅을 제공합니다:

crates/quipu-core/src/store.rs — AnchorHook 타입/// 체크포인트가 기록될 때마다 호출되는 콜백.
/// Arc<dyn Fn> — StoreConfig가 Clone이라 Arc 필요.
pub type AnchorHook = Arc<dyn Fn(&Checkpoint) + Send + Sync>;

// 설정 방법:
StoreConfig::new(root)
    .anchor(|cp: &Checkpoint| {
        // 외부 시스템(티켓, 별도 DB, 타임스탬프 서비스)에 cp.merkle_root_hex() 전송
        // 패닉/에러는 삼켜짐 — 가용성 > 앵커링
    })

훅이 실패하거나 패닉을 내도 쓰기 경로는 영향을 받지 않습니다. 앵커 전달 실패는 보안 문제지만, 그 때문에 감사 로그 쓰기가 중단되는 건 더 큰 문제가 될 수 있으니까요. 앵커 재전달은 훅의 책임입니다.

DB ↔ 파일시스템

DB에서는 "백업 검증"이 비슷한 역할을 합니다 — 백업이 원본과 일치하는지 체크섬으로 확인하죠. Quipu-Log에서는 한 걸음 더 나아가, DB 백업 검증은 "같은 신뢰 도메인 안에서 일치 확인"이지만, 외부 앵커링은 다른 신뢰 도메인(다른 서버, 서비스)에 기준점을 두어 내부자도 속일 수 없게 합니다.

검증: 현재 트리가 체크포인트와 일관한가

감사 또는 모니터링 시, 현재 스파인이 과거 체크포인트와 일관한지 확인하는 절차는 다음과 같습니다:

  1. 외부 앵커에서 체크포인트 루트 R_mtree_size_m을 가져온다
  2. 현재 스파인의 tree_size_n과 현재 루트 R_n을 읽는다
  3. tree_size_m ≤ tree_size_n이어야 한다 (로그는 줄지 않음)
  4. prove_consistency(first_size=tree_size_m)로 증명을 구한다
  5. verify_consistency(tree_size_m, tree_size_n, R_m, R_n, proof) → 참이면 이상 없음

만약 공격자가 스토어 전체를 삭제하고 재작성했다면, 새 스토어의 루트가 앵커된 R_m과 일관한 증명을 만들 수 없습니다 — 원래 레코드가 없으니까요.

보안 포인트

서명 키(RSA 개인키)가 데이터 노드와 다른 곳(별도 호스트, HSM, KMS)에 있어야 체크포인트가 의미를 가집니다. 개인키도 데이터 노드에 있다면, 공격자는 재작성 후 재서명까지 할 수 있습니다. README는 이를 명시합니다: "Separate the signing key for anchoring."

write-only 구성에서 체크포인트

27장에서 다룰 write-only 배포(공개키만 서버에 있는 구성)에서는, 체크포인트 서명에 필요한 개인키가 없습니다. 이 경우 Quipu-Log는 오류를 내지 않고 조용히 체크포인트를 건너뜁니다 — KeyRing::can_sign()이 false면 skip입니다. "체크포인트가 안 쌓이는데 왜지?"라는 의문이 들면, 키 구성을 먼저 확인하세요.

정리

  • 체크포인트 = (세그먼트 번호 + 레코드 수 + tree_size + 머클 루트)의 RSA 서명. 세그먼트 봉인·리텐션 후·수동으로 찍힘.
  • AnchorHook으로 각 체크포인트 직후에 루트를 외부 신뢰 도메인으로 내보냄.
  • 외부 앵커된 루트 + 일관성 증명 → 전체 재작성 공격까지 탐지 가능.
  • 서명 키는 반드시 데이터 노드 밖에. write-only 구성에서 체크포인트는 조용히 skip.
스스로 확인

① 공격자가 스토어 전체를 재작성하는 데 성공했습니다. 어떤 조건이 갖춰져야 이 공격이 외부 앵커에 의해 탐지되나요?
② 리텐션이 실행되어 오래된 세그먼트가 삭제됐습니다. 그 후 외부 감사인이 "삭제된 레코드가 있던 과거 루트"와 현재 루트 사이의 일관성 증명을 요청했습니다. 이 증명이 성공하려면 어떤 파일이 살아있어야 하나요?
③ AnchorHook에서 패닉이 발생하면 레코드 쓰기가 실패하나요? 그 이유를 설계 관점에서 설명해 보세요.