驗證
每個已完成的信封都受到兩項彼此獨立的證明保護:
- 封印,位於 PDF 內部:由公開時間戳記機構核發的 PAdES 文件時間戳記。Adobe Acrobat 和 Reader 會檢查它,並顯示文件自封存後未經修改。
- 錨點,位於 PDF 外部:文件的指紋是一棵僅附加 Merkle 樹中的葉,Bona Fide 會以 Ed25519 對每個樹根簽章。任何人都能以公布的公開金鑰檢查該證明,不需要帳戶,也不必信任任何 PDF 檢視器。
最後一位收件者簽署後,信封即告完成(envelope.completed),接著由封存程式接手:
- 將原始文件的 SHA-256 與建立信封時記錄的值進行比對。
- 平面化文件:欄位值(文字、核取方塊、手寫、鍵入或上傳的簽名)會繪製到頁面內容中,所有表單欄位、註解、JavaScript 動作和內嵌檔案都會移除。
- 附加完成證明書:信封 ID、原始文件的 SHA-256、每位簽署人及其實際使用的驗證方式(例如 電子郵件連結(唯一連結已寄至 …) 或 電子郵件連結 + 一次性驗證碼,請參閱簽署人驗證)、時間戳記、IP 位址和裝置,以及事件時間軸。每一頁都會加上頁尾 Bona Fide Envelope ID … · Verify at bonafidesign.com/verify。
- 加上由公開時間戳記機構核發的 PAdES 文件時間戳記(
/DocTimeStamp、ETSI.RFC3161、SHA-256)(由多家公開受信任的供應商提供,並可自動容錯移轉),並內嵌 TSA 憑證鏈的驗證資料(DSS 中的 OCSP/CRL),使其能長期驗證(LTV)。 - 驗證本身的輸出並加以儲存,記錄封存後的 SHA-256(
sealedDocumentHash)以及一個供錨點日誌使用的葉。
接著會觸發 envelope.sealed,GET /v1/envelopes/{id}/document?version=sealed 即可傳回該檔案。如果所有 TSA 都無法使用,封存會重試;在此期間,信封會維持 completed 狀態,並帶有 seal: "pending"。
每個已封存的信封會提供一個葉,也就是下列內容的標準化 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)),其樹根會以經過簽章的錨點形式發布:
{ "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 會將每個錨點與前一個錨點鏈結(第一個錨點指向 64 個零),因此任何錨點都無法在不破壞鏈結的情況下遭到移除或重新排序,而且錨點資料表僅能附加。
簽章金鑰會連同其有效期間一併公布;已停用的金鑰仍會保留在清單中,讓舊的錨點依然可以驗證:
{ "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 中。
- 文件:您檔案的 SHA-256 等於
leaf.sealed_document_sha256(或original_document_sha256)。 - 葉:
SHA-256(0x00 ‖ canonical JSON of leaf)等於leaf_hash。 - 包含:從葉雜湊開始逐一套用
proof.hashes(RFC 9162 §2.1.3.2,使用leaf_index和tree_size == anchor.leaf_count),會得到anchor.root。 - 金鑰:
anchor.key_id位於keys.json中,金鑰 ID 是原始公開金鑰 SHA-256 值的前 16 個十六進位字元,而且anchor.created_at落在該金鑰的有效期間內。 - 簽章:Ed25519 簽章針對
bona-fide-anchor-v1|sequence|root|previous_root|created_at|leaf_count驗證通過。
# pip install requests cryptographyimport base64, hashlib, json, sys, requestsfrom 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"] 進行比對。
參考驗證程式 @bona-fide/verify 是同構的(可在瀏覽器和 Node 中執行,不含任何 Bona Fide 機密),/verify 頁面執行的就是它:
import { sha256Hex, verifyProof, type KeySet, type PublicVerifyResponse } from '@bona-fide/verify';import { readFile } from 'node:fs/promises';
const api = 'https://api.bonafidesign.com';const sha256 = sha256Hex(new Uint8Array(await readFile('signed.pdf')));const body = (await (await fetch(`${api}/v1/public/verify`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sha256 }),})).json()) as PublicVerifyResponse;const keys = (await (await fetch(`${api}/.well-known/bona-fide/keys.json`)).json()) as KeySet;
for (const m of body.results) { if (!m.leaf || !m.proof || !m.anchor) continue; // pending_anchor const r = await verifyProof(m.leaf, m.proof, m.anchor, keys, { documentSha256: sha256, expectedLeafHash: m.leaf_hash ?? undefined }); console.log(r.valid ? 'VERIFIED' : 'INVALID', m.envelope.id, r.checks);}此套件也提供命令列檢查工具(bona-fide-verify <file.pdf | sha256> [--api URL] [--json];在 Bona Fide 存放庫的簽出目錄中:pnpm --filter @bona-fide/verify cli signed.pdf)。它會在本機計算檔案雜湊、檢查上述每個步驟,驗證通過時以 0 結束,找不到或無效時以 1 結束,文件尚未錨定時則以 2 結束。
使用 keys.json 中的 PEM 金鑰檢查錨點的簽章(OpenSSL 3):
curl -s https://api.bonafidesign.com/v1/public/anchors/412 > anchor.jsoncurl -s https://api.bonafidesign.com/.well-known/bona-fide/keys.json > keys.json
printf %s "$(jq -r .signed_message anchor.json)" > message.txtjq -r .signature anchor.json | python3 -c 'import base64,sys; s=sys.stdin.read().strip(); sys.stdout.buffer.write(base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)))' > signature.binKEY_ID=$(jq -r '.key_id' anchor.json)jq -r --arg k "$KEY_ID" '.keys[] | select(.key_id == $k) | .public_key_pem' keys.json > key.pem
openssl pkeyutl -verify -pubin -inkey key.pem -rawin -in message.txt -sigfile signature.bin# Signature Verified Successfully錨點會依由新到舊的順序透過 GET /v1/public/anchors?limit=20&before=<sequence> 列出(傳入 next_before 即可往前翻頁),也可以透過 GET /v1/public/anchors/{sequence} 逐一取得。