콘텐츠로 이동

검증

완료된 모든 봉투는 서로 독립적인 두 가지 증명으로 보호됩니다.

  1. PDF 안에 있는 봉인: 공개 타임스탬프 기관의 PAdES 문서 타임스탬프입니다. Adobe Acrobat과 Reader가 이를 확인하고, 봉인된 이후 문서가 수정되지 않았음을 표시합니다.
  2. PDF 밖에 있는 앵커: 문서의 지문은 추가 전용 Merkle 트리의 리프이며, Bona Fide는 각 트리 루트에 Ed25519로 서명합니다. 누구나 공개된 공개 키로 이 증명을 확인할 수 있으며, 계정도, PDF 뷰어에 대한 신뢰도 필요 없습니다.

마지막 수신자가 서명하면 봉투가 완료되고(envelope.completed) 봉인 처리기가 작업을 이어받습니다.

  1. 원본의 SHA-256을 봉투를 만들 때 기록한 값과 대조합니다.
  2. 문서를 평탄화합니다. 필드 값(텍스트, 체크박스, 그리거나 입력하거나 업로드한 서명)을 페이지 콘텐츠에 그려 넣고, 모든 양식 필드, 주석, JavaScript 동작, 포함된 파일을 제거합니다.
  3. 완료 인증서를 덧붙입니다. 봉투 ID, 원본의 SHA-256, 실제로 사용된 인증 방식과 함께 모든 서명자 (예: 이메일 링크(…로 보낸 고유 링크) 또는 이메일 링크 + 일회용 코드. 서명자 인증 참조), 타임스탬프, IP 주소와 기기, 이벤트 타임라인이 기재됩니다. 모든 페이지에는 바닥글 Bona Fide Envelope ID … · Verify at bonafidesign.com/verify가 들어갑니다.
  4. 공개 타임스탬프 기관(자동 장애 조치를 지원하는, 공개적으로 신뢰받는 여러 제공업체)의 PAdES 문서 타임스탬프 (/DocTimeStamp, ETSI.RFC3161, SHA-256)를 추가하고, TSA 인증서 체인의 검증 데이터(DSS의 OCSP/CRL)를 포함해 장기간 검증할 수 있도록(LTV) 합니다.
  5. 자체 출력을 검증하고 저장한 뒤, 봉인된 SHA-256(sealedDocumentHash)과 앵커 로그용 리프를 기록합니다.

그런 다음 envelope.sealed가 발생하고 GET /v1/envelopes/{id}/document?version=sealed가 파일을 반환합니다. 모든 TSA를 사용할 수 없으면 봉인을 재시도하며, 그동안 봉투는 seal: "pending"인 completed 상태로 유지됩니다.

봉인된 각 봉투는 하나의 리프를 제공합니다. 리프는 다음 내용의 정규화된 JSON(키 정렬, 공백 없음)입니다.

{
"completed_at": "2026-10-13T14:09:52.004Z",
"envelope_id": "env_8Gm2kQ4rT1",
"event_chain_head": "<head of the envelope's audit hash chain>",
"original_document_sha256": "<SHA-256 of the document as sent>",
"sealed_document_sha256": "<SHA-256 of the sealed PDF>"
}

매시간 이전 앵커 이후 기록된 리프로 Merkle 트리(RFC 6962 / RFC 9162: leaf = SHA-256(0x00 ‖ leaf JSON), node = SHA-256(0x01 ‖ left ‖ right))를 만들고, 그 루트를 서명된 앵커로 게시합니다.

GET /v1/public/anchors/latest
{
"id": "…",
"version": "bona-fide-anchor-v1",
"sequence": 412,
"root": "<hex Merkle root>",
"previous_root": "<root of anchor 411>",
"leaf_count": 37,
"created_at": "2026-10-13T15:00:00.118Z",
"key_id": "<first 16 hex of SHA-256(public key)>",
"signature": "<base64url Ed25519 signature>",
"signed_message": "bona-fide-anchor-v1|412|<root>|<previous_root>|2026-10-13T15:00:00.118Z|37"
}

Ed25519 서명은 bona-fide-anchor-v1|sequence|root|previous_root|created_at|leaf_count(UTF-8)를 대상으로 합니다. previous_root는 모든 앵커를 직전 앵커와 체인으로 연결하므로(첫 번째 앵커는 0이 64개인 값을 가리킴), 체인을 깨뜨리지 않고는 앵커를 제거하거나 순서를 바꿀 수 없으며, 앵커 테이블은 추가 전용입니다.

서명 키는 유효 기간과 함께 공개됩니다. 사용이 끝난 키도 목록에 남아 있으므로 오래된 앵커도 계속 검증할 수 있습니다.

GET /.well-known/bona-fide/keys.json
{
"issuer": "Bona Fide",
"anchor_format": "bona-fide-anchor-v1|sequence|root|previous_root|created_at|leaf_count (UTF-8, Ed25519; root = RFC 6962 SHA-256 Merkle tree)",
"keys": [
{
"key_id": "<16 hex>",
"algorithm": "Ed25519",
"public_key": "<base64url raw 32-byte key>",
"public_key_pem": "-----BEGIN PUBLIC KEY-----…",
"valid_from": "2026-10-14T00:00:00.000Z",
"valid_until": null,
"status": "active"
}
]
}

가장 빠른 방법은 공개 페이지입니다: bonafidesign.com/verify. 이 페이지는 PDF의 해시를 브라우저에서 계산하고(파일은 업로드되지 않습니다), 해시를 조회한 다음, 증명과 서명을 사용자의 기기에서 확인합니다. 앵커 로그에는 모든 앵커가 나열됩니다.

같은 조회를 공개 API로도 할 수 있습니다. 키가 필요 없고, 어떤 출처에서든 호출할 수 있으며, IP별 속도 제한이 적용됩니다. SHA-256만 보내세요.

터미널 창
curl https://api.bonafidesign.com/v1/public/verify \
-H "Content-Type: application/json" \
-d "{\"sha256\": \"$(shasum -a 256 signed.pdf | cut -d' ' -f1)\"}"
{
"sha256": "…",
"status": "verified",
"results": [
{
"status": "verified",
"matched": "sealed_document",
"envelope": {
"id": "env_8Gm2kQ4rT1",
"status": "completed",
"completed_at": "2026-10-13T14:09:52.004Z",
"sealed_at": "2026-10-13T14:10:31.442Z",
"organization_name": "Acme Robotics, Inc.",
"signer_count": 1,
"signers": [{ "name": "Jane Doe", "email": "jane@acme.dev", "role": "client", "verification_method": "Email OTP", "signed_at": "…" }],
"verification_methods": ["Email OTP"],
"test_mode": false
},
"leaf": { "envelope_id": "env_8Gm2kQ4rT1", "…": "…" },
"leaf_hash": "…",
"proof": { "leaf_index": 12, "tree_size": 37, "hashes": ["…", "…"] },
"anchor": { "sequence": 412, "…": "…" },
"links": { "anchor": "…", "keys": "…/.well-known/bona-fide/keys.json" }
}
]
}

status는 verified, pending_anchor(완료되어 기록되었고 다음 정시 앵커를 기다리는 중), 또는 not_found(이 파일과 정확히 같은 파일을 가진 완료된 봉투가 없음. 즉, 서명된 문서가 아니거나 이후에 수정되었음) 중 하나입니다. 봉인된 파일과 발송 당시의 원본 모두 일치 대상입니다.

API의 답을 그대로 믿을 필요는 없습니다. 답을 확인하는 데 필요한 모든 것이 응답과 keys.json에 들어 있습니다.

  1. 문서: 파일의 SHA-256이 leaf.sealed_document_sha256(또는 original_document_sha256)과 같습니다.
  2. 리프: SHA-256(0x00 ‖ canonical JSON of leaf)가 leaf_hash와 같습니다.
  3. 포함: 리프 해시에서 시작해 proof.hashes를 따라가면(RFC 9162 §2.1.3.2, leaf_index와 tree_size == anchor.leaf_count 사용) anchor.root가 나옵니다.
  4. 키: anchor.key_id가 keys.json에 있고, 키 ID가 원시 공개 키의 SHA-256 앞 16자리 16진수 문자이며, anchor.created_at이 키의 유효 기간 안에 있습니다.
  5. 서명: Ed25519 서명이 bona-fide-anchor-v1|sequence|root|previous_root|created_at|leaf_count에 대해 검증됩니다.
# pip install requests cryptography
import base64, hashlib, json, sys, requests
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
API = "https://api.bonafidesign.com"
b64url = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
sha = lambda b: hashlib.sha256(b).digest()
def root_from_proof(leaf: bytes, index: int, size: int, path: list[bytes]) -> bytes:
fn, sn, r = index, size - 1, leaf
for p in path:
if sn == 0: raise ValueError("proof too long")
if fn % 2 == 1 or fn == sn:
r = sha(b"\x01" + p + r)
while fn % 2 == 0 and fn != 0:
fn, sn = fn // 2, sn // 2
else:
r = sha(b"\x01" + r + p)
fn, sn = fn // 2, sn // 2
if sn != 0: raise ValueError("proof too short")
return r
digest = hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest()
res = requests.post(f"{API}/v1/public/verify", json={"sha256": digest}).json()
keys = {k["key_id"]: k for k in requests.get(f"{API}/.well-known/bona-fide/keys.json").json()["keys"]}
for m in res["results"]:
leaf, proof, anchor = m["leaf"], m["proof"], m["anchor"]
if not anchor:
print("pending anchor"); continue
assert digest in (leaf["sealed_document_sha256"], leaf["original_document_sha256"])
leaf_json = json.dumps(leaf, sort_keys=True, separators=(",", ":")).encode()
leaf_hash = sha(b"\x00" + leaf_json)
assert proof["tree_size"] == anchor["leaf_count"]
root = root_from_proof(leaf_hash, proof["leaf_index"], proof["tree_size"], [bytes.fromhex(h) for h in proof["hashes"]])
assert root.hex() == anchor["root"], "not included under the anchor root"
key = keys[anchor["key_id"]]
raw = b64url(key["public_key"])
assert hashlib.sha256(raw).hexdigest()[:16] == anchor["key_id"]
message = "|".join([anchor["version"], str(anchor["sequence"]), anchor["root"], anchor["previous_root"],
anchor["created_at"], str(anchor["leaf_count"])]).encode()
Ed25519PublicKey.from_public_bytes(raw).verify(b64url(anchor["signature"]), message) # raises if invalid
print(f"VERIFIED {leaf['envelope_id']} in anchor #{anchor['sequence']}")

리프는 ASCII 키와 문자열 값만 가지므로 json.dumps(…, sort_keys=True, separators=(",", ":"))로 같은 정규화된 JSON을 얻을 수 있습니다. 키의 valid_from / valid_until도 anchor["created_at"]과 비교해 확인하세요.

앵커는 GET /v1/public/anchors?limit=20&before=<sequence>로 최신순 목록을 조회하고(이전 페이지로 가려면 next_before를 전달), GET /v1/public/anchors/{sequence}로 하나씩 가져올 수 있습니다.