당신의 문서가 존재했음을 증명하는 98 바이트

발행: (2026년 2월 21일 오후 01:48 GMT+9)
17 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 링크만으로는 번역할 본문이 포함되어 있지 않습니다. 번역을 원하는 텍스트(본문)를 그대로 복사해서 알려주시면, 요청하신 대로 마크다운 형식과 코드 블록, URL은 그대로 유지하면서 한국어로 번역해 드리겠습니다.

Source:

ATL Protocol Checkpoint – Fixed‑Size Wire Format

ATL Protocol의 체크포인트는 투명성 로그 상태의 서명된 스냅샷입니다.
특정 트리 크기와 특정 시점, 특정 로그 인스턴스에서의 Merkle 트리 루트 해시를 캡처합니다.
체크포인트와 해당 서명이 검증되면, 해당 시점에 로그가 정확히 어떤 상태였는지 알 수 있습니다.

Note – 전체 와이어 포맷은 98 바이트 길이의 고정 크기이며, 가변 길이 필드가 없고 파서가 필요하지 않습니다.

Byte Layout

OffsetSize (bytes)Field
018Magic bytes: "ATL-Protocol-v1-CP" (ASCII)
1832Origin ID (SHA‑256 of instance UUID)
508Tree size (u64, little‑endian)
588Timestamp (u64, little‑endian, Unix nanoseconds)
6632Root hash (SHA‑256)
Total98(signed blob)

Ed25519 서명(64 바이트)과 키 ID(32 바이트)는 별도로 저장됩니다 – 이들은 98‑바이트 블롭의 일부가 아닙니다. 이 분리는 의도적인 설계 결정이며(아래에 설명됨).

왜 고정‑크기 바이너리 Blob인가?

1. 서명된 컨텍스트에서 모호성 없음

일반적인 직렬화 포맷(JSON, Protobuf, CBOR, MessagePack)은 API와 설정 파일에 적합하지만, 암호학적으로 서명해야 하는 데이터에 사용할 경우 모호성을 초래합니다:

포맷모호성 원인
JSON키 순서가 달라질 수 있어 동일한 논리 객체라도 서로 다른 바이트 시퀀스가 생성됨(따라서 RFC 8785 필요).
Protobuf필드 순서는 기술적으로 정의되지 않아 구현마다 다른 바이트 순서를 출력할 수 있음.
CBOR동일한 값에 대해 여러 유효한 인코딩이 존재함.
MessagePackCBOR와 유사 – 여러 정규 형태가 존재함.

체크포인트에 서명할 때는 정확히 “이 98바이트”를 서명했다는 것을 알아야 합니다. 직렬화가 모호하면 검증도 모호해지고, 두 구현이 호환되지 않는 서명을 만들 수 있습니다.

2. 파싱 오버헤드 제로

98바이트 Blob은 어떤 언어에서도 결정적으로 읽을 수 있습니다:

  1. 18 바이트 읽기 → 매직.
  2. 32 바이트 읽기 → 오리진 ID.
  3. 8 바이트(리틀‑엔디언) 읽기 → 트리 크기.
  4. 8 바이트(리틀‑엔디언) 읽기 → 타임스탬프.
  5. 32 바이트 읽기 → 루트 해시.

길이 프리픽스, 구분자, TLV 구조가 필요 없습니다. 바이트 자체가 정규 형태가 됩니다.

Rust 구현 – to_bytes()

pub fn to_bytes(&self) -> [u8; CHECKPOINT_BLOB_SIZE] {
    let mut blob = [0u8; CHECKPOINT_BLOB_SIZE];
    blob[0..18].copy_from_slice(CHECKPOINT_MAGIC);
    blob[18..50].copy_from_slice(&self.origin);
    blob[50..58].copy_from_slice(&self.tree_size.to_le_bytes());
    blob[58..66].copy_from_slice(&self.timestamp.to_le_bytes());
    blob[66..98].copy_from_slice(&self.root_hash);
    blob
}
  • No allocations – 스택에 할당된 배열입니다.
  • No error paths – 반환 타입이 [u8; 98]이며, Vec이나 Result가 아닙니다.
  • Deterministic – 서명된 정확한 데이터를 항상 생성합니다.

서명 및 키 ID – 별도로 저장

서명키 ID서명된 블롭의 일부가 아닙니다.

왜 별도로 보관해야 할까?

  • 닭‑달걀 문제 – 이미 자체 서명이 포함된 데이터를 서명할 수 없습니다.
  • 이중 포맷 위험 – 많은 시스템이 “서명 입력”(서명이 없는 블롭)과 “저장 포맷”(블롭 + 서명)을 정의합니다. 이는 동일한 논리 객체에 대해 두 개의 직렬화 규칙을 만들며, 검증 시 잘못된 포맷을 사용하게 되는 버그를 초래합니다.

검증 예시

pub fn verify(&self, verifier: &CheckpointVerifier) -> AtlResult {
    // Fast‑reject on key‑ID mismatch (cheap SHA‑256 compare)
    if self.key_id != verifier.key_id {
        return Err(AtlError::InvalidSignature(format!(
            "key_id mismatch: checkpoint has {}, verifier has {}",
            hex::encode(self.key_id),
            hex::encode(verifier.key_id)
        )));
    }

    // Re‑create the exact signed blob
    let blob = self.to_bytes();
    verifier.verify(&blob, &self.signature)
}
  • 키‑ID 검사(공개 키의 SHA‑256)는 비용이 많이 드는 Ed25519 검증 이전에 수행되어 빠른 거부 경로를 제공합니다.

Source:

매직 바이트 – “ATL‑Protocol‑v1‑CP”

첫 18바이트는 두 가지 목적을 가집니다:

  1. 포맷 식별 – JPEG, Protobuf 메시지, 혹은 임의의 98바이트 버퍼가 체크포인트 파서에 전달될 경우, 매직 바이트가 일치하지 않아 InvalidCheckpointMagic 오류가 명확히 발생합니다. 이는 모호한 다운스트림 오류를 방지합니다.
  2. 버전 관리 – 매직 문자열에 포함된 v1은 와이어 포맷 버전을 데이터 자체에 연결합니다. 포맷이 변경될 경우(예: 새로운 필드, 다른 해시 알고리즘) 매직 문자열을 "ATL-Protocol-v2-CP"와 같이 업데이트할 수 있습니다. v1 파서는 v2 체크포인트를 만나면 바이트를 잘못 해석하는 대신 깨끗하게 거부합니다.

18바이트는 매직 문자열로서는 넉넉한 길이이지만, 향후 확장을 위한 충분한 여지를 제공하면서 포맷을 단순하고 명확하게 유지합니다.

TL;DR

  • 98‑byte 고정 바이너리 → 모호함 없고 파싱 복잡성도 없음.
  • Signature & key ID를 별도로 저장 → 닭‑달걀 문제와 이중‑포맷 함정을 피함.
  • Magic bytes → 포맷 식별 + 버전 관리.
  • Rust to_bytes() → 결정적이며, 할당 없이, 항상 서명된 데이터를 반환.

이 설계는 뒤돌아보면 명백해 보이지만, 많은 구현이 가변 길이 인코딩을 섞거나 서명된 데이터와 저장된 표현을 혼동함으로써 오류를 범합니다. 서명된 블롭을 불변하고 최소하게 유지하면 이러한 종류의 버그를 제거할 수 있습니다.

매직 바이트 (Hex Representation)

체크포인트 블롭은 짧은 바이너리 매직 넘버 대신 사람이 읽을 수 있는 문자열로 시작합니다.
매직 바이트는:

41544C2D50726F746F636F6C2D76312D4350

이며, 이는 ASCII 텍스트 **ATL-Protocol-v1-CP**에 해당합니다.
읽을 수 있는 문자열을 사용하면 헥스 덤프, 로그 파일, 디버깅 세션에서 블롭을 쉽게 찾을 수 있습니다.

타임스탬프

  • 필드 유형: u64
  • 인코딩: Unix 나노초 (초나 밀리초가 아님)

u64 나노초 범위는 1970년부터 대략 2554년까지이며, 충분히 넉넉합니다.

왜 나노초 정밀도가 필요한가?
투명성 로그는 동일한 밀리초 내에 여러 항목을 처리할 수 있습니다. 타임스탬프가 밀리초 단위 해상도만 있다면 두 체크포인트가 동일한 타임스탬프를 갖게 되어 순서가 모호해질 수 있습니다. 나노초 해상도는 마이크로초 차이로 처리된 항목이라도 고유한 타임스탬프를 보장합니다.

타임스탬프는 current_timestamp_nanos() 로 생성되며, 시스템 시간이 표현 가능한 범위를 초과하는 (이론적인) 경우를 대비해 u64::MAX 로 제한됩니다.

리틀 엔디언 인코딩

u64 필드(트리 크기와 타임스탬프)는 리틀 엔디언으로 인코딩됩니다.

  • 이것은 기본값이 아닌 명시적인 설계 선택입니다.
  • 현대 하드웨어(x86, ARM 기본, RISC‑V)는 리틀 엔디언이므로 u64를 리틀 엔디언으로 인코딩하는 것은 가장 일반적인 플랫폼에서 연산이 없습니다.
  • 해당 플랫폼에서 바이트 스와핑 버그 전체를 제거합니다.

엔디언 테스트

// test_endianness: 0x0102_0304_0506_0708 encodes as
// [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]

테스트 test_endianness는 바이트 순서가 한 플랫폼에서는 쉽게 올바르게 동작하지만 다른 플랫폼에서는 조용히 잘못될 수 있기 때문에 존재합니다. 이는 포맷의 속성으로 인코딩을 문서화하고 검증합니다.

인간이 읽을 수 있는 JSON 표현

체크포인트는 API, 디버깅 및 원시 바이너리를 제대로 처리하지 못하는 저장 시스템을 위해 가독 가능한 형태가 필요합니다.

  • 문자열 인코딩을 제공하는 CheckpointJson 구조체가 있습니다:

    • 해시: "sha256:"
    • 서명: "base64:"
  • 변환 메서드:

    • to_json() – 바이너리 체크포인트 → JSON
    • from_json() – JSON → 바이너리 체크포인트

중요: 암호화 작업은 98바이트 바이너리 블롭에 대해 항상 수행되며, JSON에 대해서는 수행되지 않습니다. Ed25519 서명은 to_bytes()를 기준으로 계산되며, serde_json::to_string()을 기준으로 계산되지 않습니다.

이를 강제하기 위해, 메인 Checkpoint 구조체는 SerializeDeserialize를 파생하지 않습니다. 직접 직렬화하려고 시도하면 컴파일 타임 오류가 발생하여 호출자가 명시적인 변환 메서드를 사용하도록 강제합니다.

서명 신뢰 모델 (ATL 프로토콜 v2.0)

  • 체크포인트에 대한 Ed25519 서명은 무결성 검사이며, 신뢰 앵커가 아닙니다.
  • 이는 증명합니다: “이 체크포인트는 해당 개인 키 보유자에 의해 발행되었습니다.”
  • 이는 증명하지 않습니다: “이 키를 신뢰해야 합니다.”

외부 신뢰 앵커

  1. RFC 3161 TSA 타임스탬프 – 신뢰할 수 있는 제3자 타임스탬프 기관이 체크포인트가 존재했던 시점을 증명합니다.
  2. Bitcoin OTS – 체크포인트 해시가 비트코인 블록체인에 고정되어, 단일 당사자가 위조할 수 없는 불변의 타임스탬프를 제공합니다.

이러한 앵커는 체크포인트가 언제 존재했는지를 확립합니다; Ed25519 서명은 단지 체크포인트를 특정 로그 인스턴스와 연결할 뿐입니다.

결과: Ed25519 서명 키가 나중에 유출되더라도, 이미 앵커된 과거 체크포인트는 외부 앵커가 서명 키와 독립적이므로 신뢰할 수 있습니다.

테스트 스위트 (와이어‑포맷 커버리지)

테스트목적
test_checkpoint_blob_size블롭이 정확히 98 바이트인지 확인합니다.
test_magic_bytes첫 18바이트가 "ATL-Protocol-v1-CP"와 일치하는지 검사합니다.
test_endiannessu64 필드가 리틀‑엔디안으로 인코딩되는지 확인합니다.
test_wire_format_layout모든 필드가 올바른 바이트 오프셋에 배치되어 있는지 검증합니다.
test_sign_and_verify라운드‑트립: 체크포인트 생성 → 서명 → 검증.
test_verify_wrong_key_fails키 A로 만든 서명이 키 B로는 검증되지 않음을 확인합니다.
test_verify_tampered_data_fails체크포인트의 바이트를 하나 바꾸면 검증이 실패함을 확인합니다.
test_verify_tampered_signature_fails서명의 바이트를 하나 바꾸면 검증이 실패함을 확인합니다.
test_json_roundtrip바이너리 → JSON → 바이너리 변환이 동일한 바이트를 생성하는지 확인합니다.
test_empty_tree_checkpointtree_size = 0인 체크포인트가 유효함을 검증합니다.

각 테스트 이름은 포맷의 특정 속성을 문서화합니다; 테스트가 실패하면 무엇이 깨졌는지와 중요한지를 즉시 알 수 있습니다.

Blob Size Breakdown (Why 98 bytes?)

Bytes의미
18Magic bytes / format identifier ("ATL-Protocol-v1-CP").
32Origin identifier (which log instance).
8Tree size (number of entries).
8Timestamp (nanoseconds).
32Root hash (cryptographic commitment to the entire log).
Total: 98 bytes (the signed statement).

전체 98 bytes는 모두 필수입니다:

  • Magic bytes는 잘못된 식별을 방지합니다.
  • Origin은 로그 간 혼동을 피합니다.
  • Tree sizetimestamp은 로그 히스토리에서 스냅샷 위치를 지정합니다.
  • Root hash는 지금까지 기록된 모든 항목에 대한 커밋을 제공합니다.

어느 하나의 필드를 제거하면 체크포인트가 모호해지거나 위조될 수 있습니다.

서명된 블롭에 포함되지 않은 내용

98 바이트를 초과하는 모든 것—예를 들어 서명 자체, 키 ID, 메타데이터, 주석 등—은 외부에 해당합니다. 서명된 블롭은 변경 불가능한 진술이며, 그 외의 모든 것은 부가 설명에 불과합니다.

구현 세부 사항

  • 저장소: (Apache‑2.0)
  • 논의된 파일: src/core/ (체크포인트 구현이 여기 있습니다).
# `checkpoint.rs`

**Description**  
- Implements the checkpoint wire format.  
- Handles serialization, signing, and verification.  

**Details**  
- **File size**: 1080 lines of Rust code.  
- **Signature scheme**: Ed25519 signatures using the **`ed25519-dalek`** crate.  
0 조회
Back to Blog

관련 글

더 보기 »

스틸 뱅크 Common Lisp

Steel Bank Common Lisp(SBCL)에 대해: SBCL은 고성능 Common Lisp 컴파일러입니다. 오픈 소스·무료 소프트웨어이며, 관대한 라이선스를 가지고 있습니다. 또한…

서브넷팅 설명

Subnetting이란 무엇인가? 큰 아파트 건물을 여러 층으로 나누는 것과 같다. 각 층 서브넷은 자체 번호가 매겨진 유닛(hosts)을 가지고, 그리고 건물…