Skip to content

Verification

Two independent proofs protect every completed envelope:

  1. The seal, inside the PDF: a PAdES document timestamp from a public timestamp authority. Adobe Acrobat and Reader check it and show the document as unmodified since it was sealed.
  2. The anchor, outside the PDF: the document’s fingerprint is a leaf in an append-only Merkle tree, and Bona Fide signs each tree root with Ed25519. Anyone can check that proof with the published public keys, no account and no trust in a PDF viewer needed.

When the last recipient signs, the envelope completes (envelope.completed) and the sealer takes over:

  1. Checks the original’s SHA-256 against the one recorded when the envelope was created.
  2. Flattens the document: field values (text, checkboxes, drawn, typed or uploaded signatures) are drawn into the page content, and every form field, annotation, JavaScript action and embedded file is removed.
  3. Appends the certificate of completion: envelope id, the original’s SHA-256, every signer with their verification method, timestamps, IP address and device, and the event timeline. Every page gets the footer Bona Fide Envelope ID … · Verify at bonafidesign.com/verify.
  4. Adds a PAdES document timestamp (/DocTimeStamp, ETSI.RFC3161, SHA-256) from a public timestamp authority (several publicly trusted providers with automatic failover), and embeds the validation data of the TSA’s certificate chain (OCSP/CRL in the DSS) so it stays verifiable long term (LTV).
  5. Validates its own output, stores it, and records the sealed SHA-256 (sealedDocumentHash) and a leaf for the anchor log.

Then envelope.sealed fires and GET /v1/envelopes/{id}/document?version=sealed returns the file. If every TSA is unavailable, sealing is retried; the envelope stays completed with seal: "pending" meanwhile.

Each sealed envelope contributes one leaf, the canonical JSON (keys sorted, no whitespace) of:

{
"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>"
}

Every hour, the leaves recorded since the previous anchor become a Merkle tree (RFC 6962 / RFC 9162: leaf = SHA-256(0x00 ‖ leaf JSON), node = SHA-256(0x01 ‖ left ‖ right)), and its root is published as a signed anchor:

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"
}

The Ed25519 signature covers bona-fide-anchor-v1|sequence|root|previous_root|created_at|leaf_count (UTF-8). previous_root chains every anchor to the one before it (the first one points at 64 zeros), so an anchor cannot be removed or reordered without breaking the chain, and the anchor tables are append-only.

The signing keys are published with their validity periods; retired keys stay listed so old anchors remain verifiable:

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"
}
]
}

The quickest way is the public page: bonafide.republic.global/verify. It hashes the PDF in your browser (the file is never uploaded), looks the hash up, and checks the proof and the signature on your device. The anchor log lists every anchor.

The same lookup is a public API: no key, any origin, rate limited per IP. Send only the SHA-256:

Terminal window
curl https://bonafide-api.republic.global/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 is verified, pending_anchor (completed and recorded, waiting for the next hourly anchor) or not_found: no completed envelope has this exact file, so it is not the document that was signed (or it was modified since). Both the sealed file and the original as sent match.

Do not take the API’s word for it: everything needed to check the answer is in the response and in keys.json.

  1. Document: the SHA-256 of your file equals leaf.sealed_document_sha256 (or original_document_sha256).
  2. Leaf: SHA-256(0x00 ‖ canonical JSON of leaf) equals leaf_hash.
  3. Inclusion: walking proof.hashes from the leaf hash (RFC 9162 §2.1.3.2, with leaf_index and tree_size == anchor.leaf_count) gives anchor.root.
  4. Key: anchor.key_id is in keys.json, the key id is the first 16 hex characters of SHA-256 of the raw public key, and anchor.created_at is inside the key’s validity period.
  5. Signature: the Ed25519 signature verifies over 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://bonafide-api.republic.global"
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']}")

The leaf has only string values with ASCII keys, so json.dumps(…, sort_keys=True, separators=(",", ":")) gives the same canonical JSON. Check the key’s valid_from / valid_until against anchor["created_at"] as well.

Anchors are listed newest first with GET /v1/public/anchors?limit=20&before=<sequence> (pass next_before to page back) and fetched one by one with GET /v1/public/anchors/{sequence}.