머클 스파인이 레코드 변조를 잡는다면, 누군가 스파인 파일도 같이 고치면 어떻게 될까요? 디스크 전체 권한을 가진 내부자라면 로그와 스파인을 모두 삭제하고, 처음부터 다시 쌓은 자체 일관된 트리로 교체할 수 있습니다. 체크포인트와 외부 앵커링은 바로 이 시나리오를 막기 위해 존재합니다 — "내부자도 속일 수 없는 기준점"을 다른 신뢰 도메인에 심어두는 것입니다.
체크포인트는 (세그먼트 위치, 레코드 수, 머클 루트)를 RSA 서명으로 봉인하고, 외부 앵커 훅으로 그 루트를 스토어 밖으로 내보낸다 — 내부자가 로그를 전부 재작성해도 앵커된 과거 루트와 일관성 증명이 맞지 않는다.
머클 스파인만으로 충분하지 않은 이유
지금까지의 방어 체계를 정리해 봅시다:
- 레코드 in-place 수정 → 리프 해시가 달라지고 → 루트가 달라짐 ✅
- 세그먼트 파일 교체 → 리프 순서가 달라지고 → 루트가 달라짐 ✅
- 꼬리 절단(세그먼트 삭제) → 트리 크기가 줄어 → 이전 체크포인트와 불일치 ✅
그러나 한 가지 시나리오가 남습니다: 공격자가 스토어 전체(세그먼트 + 스파인 + 체크포인트 파일)를 삭제하고, 조작된 레코드로 처음부터 다시 쌓은 자체 일관된 스토어를 만들면? 내부에는 비교할 "원래 값"이 없습니다.
체크포인트: 서명된 스냅샷
체크포인트는 "특정 시점의 트리 상태"를 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_size와 record_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는 세 시점에만 찍습니다:
- 세그먼트 봉인(seal) 시점 — 세그먼트가
max_segment_bytes를 넘어 새 파일로 롤오버될 때 - 리텐션 실행 직후 — 오래된 세그먼트를 삭제했으니, 현재 상태를 봉인해 "삭제 후에도 일관하다"를 기록
- 수동 요청 — 관리자가 명시적으로 체크포인트를 원할 때
핫 패스(emit 경로)에 RSA 서명이 없으므로, 레코드 하나 쓸 때마다 지연이 생기지 않습니다.
AnchorHook: 외부로 내보내기
체크포인트 파일 자체도 스토어 안에 있으니 공격자가 지울 수 있습니다. 그래서 StoreConfig에 anchor 훅을 제공합니다:
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에서는 "백업 검증"이 비슷한 역할을 합니다 — 백업이 원본과 일치하는지 체크섬으로 확인하죠. Quipu-Log에서는 한 걸음 더 나아가, DB 백업 검증은 "같은 신뢰 도메인 안에서 일치 확인"이지만, 외부 앵커링은 다른 신뢰 도메인(다른 서버, 서비스)에 기준점을 두어 내부자도 속일 수 없게 합니다.
검증: 현재 트리가 체크포인트와 일관한가
감사 또는 모니터링 시, 현재 스파인이 과거 체크포인트와 일관한지 확인하는 절차는 다음과 같습니다:
- 외부 앵커에서 체크포인트 루트
R_m과tree_size_m을 가져온다 - 현재 스파인의
tree_size_n과 현재 루트R_n을 읽는다 tree_size_m ≤ tree_size_n이어야 한다 (로그는 줄지 않음)prove_consistency(first_size=tree_size_m)로 증명을 구한다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에서 패닉이 발생하면 레코드 쓰기가 실패하나요? 그 이유를 설계 관점에서 설명해 보세요.