Verify a Renter's Vault evidence bundle

If you've scanned a QR code on a Renter's Vault PDF or been handed an evidence bundle by a tenant, this page shows you how to independently verify the cryptographic signature on the manifest. No app install required.

What this page is for

Pro-tier Renter's Vault PDF exports include a "Cryptographic Signature" block on the evidence manifest page. The block contains an Ed25519 signature, a Merkle root over the photos and videos in the bundle, and the public key the signature was made with. This page tells you how to verify the signature yourself.

A successful verification proves one specific thing: the holder of the private key paired with the printed public key signed this Merkle root. It does not, by itself, prove that the photos and videos in the PDF are unaltered, that the timestamps are accurate, or that the bundle would be admissible in court. It is integrity verification, not legal authentication. See What verification proves and doesn't prove below.

What's in the signed-manifest block

The block on the PDF lists six fields. Copy these out exactly as printed (whitespace inside hex and base64 strings is for legibility — strip it before verifying).

  • Merkle Root — 64-character lowercase hex string. The SHA-256 root of a binary Merkle tree built over the bundle's photos and videos. (See Manifest version for the leaf format and what each version covers.)
  • Ed25519 Signature — base64-encoded (88 characters when stripped of padding/whitespace). Signs the UTF-8 bytes of the Merkle root hex string.
  • Public Key — base64-encoded raw 32-byte Ed25519 public key. Embedded in the PDF so you do not need any external key directory to verify.
  • Public Key Fingerprint — 64-character lowercase hex string. The SHA-256 of the raw public-key bytes. Use this as a short-form identifier when comparing across multiple PDFs from the same account.
  • Signed At — UTC timestamp from the device when the signature was made. Not bound by the signature, so treat it as advisory.
  • Device ID — random UUID generated on first install. Identifies the physical device, rotates on reinstall. Not bound by the signature.
  • Merkle Tree Verification appendix — a separate appendix at the end of the PDF lists the per-leaf inputs (item ID, SHA-256, capture timestamp in milliseconds, Drive file ID, plus duration for videos) in signer order. These are the raw values that hash into the Merkle leaves, so a verifier can recompute the root from the PDF alone. See Step 3: recompute the Merkle root.

Verification recipe (Python)

The script below runs on any computer with Python 3.8 or later. It reads the four base64 / hex strings from the PDF, recomputes the public key fingerprint, and verifies the Ed25519 signature.

Install the one dependency:

pip install cryptography

Save the script as verify.py, paste the four strings from the PDF into the placeholders, and run python verify.py.

# verify.py — verify a Renter's Vault signed-manifest block.
# Requires: pip install cryptography

import base64
import hashlib
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

# --- Paste these four values from the PDF's signed-manifest block ---
merkle_root        = ""  # 64-char lowercase hex
signature_b64      = ""  # base64
public_key_b64     = ""  # base64 (32 raw bytes encoded)
fingerprint_hex    = ""  # 64-char lowercase hex (printed on PDF)

# --- Normalise: strip whitespace and lowercase hex strings, so values
# pasted directly from the PDF (which may include line breaks for
# legibility) verify cleanly. Note: str.split() splits on ASCII
# whitespace only — it does NOT strip zero-width characters
# (U+200B etc.). Step 3's leaf-build loop applies an extra
# zero-width-strip pass, since PDF viewers can inject those when
# copying wrapped strings like the Drive file ID and SHA-256 hash. ---
merkle_root     = "".join(merkle_root.split()).lower()
signature_b64   = "".join(signature_b64.split())
public_key_b64  = "".join(public_key_b64.split())
fingerprint_hex = "".join(fingerprint_hex.split()).lower()

if not all([merkle_root, signature_b64, public_key_b64, fingerprint_hex]):
    raise SystemExit(
        "Fill in all four values from the PDF before running this script."
    )

# --- Decode ---
sig = base64.b64decode(signature_b64)
pub = base64.b64decode(public_key_b64)

# --- Step 1: recompute the public-key fingerprint ---
computed_fp = hashlib.sha256(pub).hexdigest()
if computed_fp != fingerprint_hex.lower():
    raise SystemExit(
        f"Fingerprint mismatch.\n"
        f"  Printed:  {fingerprint_hex.lower()}\n"
        f"  Computed: {computed_fp}\n"
        "The public key on the PDF does not match the fingerprint. "
        "Either the PDF has been altered or the values were copied incorrectly."
    )

# --- Step 2: verify the Ed25519 signature ---
# The signature covers the UTF-8 bytes of the hex Merkle-root string,
# not the raw 32-byte digest. This is intentional and matches the app.
key = Ed25519PublicKey.from_public_bytes(pub)
try:
    key.verify(sig, merkle_root.encode("utf-8"))
except InvalidSignature:
    raise SystemExit(
        "Signature INVALID. The signature does not match the Merkle root "
        "for this public key. The PDF may have been altered, or the values "
        "were copied incorrectly."
    )

print("Signature valid.")
print(f"  Public key fingerprint: {computed_fp}")
print(f"  Merkle root:            {merkle_root.lower()}")

Step 3: recompute the Merkle root

Step 2 proves the signature matches the printed Merkle root. Step 3 proves the printed root is the one the listed photos and videos actually hash to. Run this after verify.py succeeds.

Read the manifest vN label printed near the signature block to set manifest_version. Then copy each row from the "Merkle Tree Verification" appendix into the photos or videos list, in the order they appear on the PDF. Order matters — photos first (timestamp ASC, id ASC), then videos (timestamp ASC, id ASC).

# verify_merkle.py — recompute the Merkle root from per-leaf inputs.
# Run after verify.py confirms the signature is valid.

import hashlib

# Strip zero-width characters that PDF viewers may include when copying
# wrapped strings (U+200B ZERO WIDTH SPACE, U+200C, U+200D, U+FEFF).
# Escape sequences are used so the values are auditable in any editor.
_ZW_CHARS = ("\u200b", "\u200c", "\u200d", "\ufeff")

def _strip_zw(value: str) -> str:
    for ch in _ZW_CHARS:
        value = value.replace(ch, "")
    return value

# --- Manifest version printed on the PDF (1 or 2) ---
manifest_version = 2  # match the "manifest vN" label on the PDF

# --- Paste the Merkle root from Step 2 here so we can compare ---
merkle_root = ""  # 64-char lowercase hex, same value used in verify.py

# --- Per-leaf inputs from the "Merkle Tree Verification" appendix ---
# Order matters: photos first (timestamp ASC, id ASC),
# then videos (timestamp ASC, id ASC).
photos = [
    # (photo_id, sha256_hex, captured_at_millis, drive_file_id)
    # (42, "abcd...", 1715200000000, "1A2B..."),
]
videos = [
    # (video_id, sha256_hex, captured_at_millis, drive_file_id, duration_ms)
    # (7, "ef01...", 1715200600000, "9X8Y...", 12345),
]

# SHA-256 of the empty string — the Merkle root when there are no leaves.
EMPTY_MERKLE_ROOT = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"


def hash_leaf(canonical: str) -> bytes:
    """SHA-256 of the UTF-8 bytes of the canonical leaf string."""
    return hashlib.sha256(canonical.encode("utf-8")).digest()


def merkle_reduce(leaves: list[bytes]) -> bytes:
    """Pairwise SHA-256 reduction. Odd-length levels duplicate the last digest."""
    if not leaves:
        return bytes.fromhex(EMPTY_MERKLE_ROOT)
    level = list(leaves)
    while len(level) > 1:
        if len(level) % 2 == 1:
            level.append(level[-1])  # duplicate last digest on odd levels
        level = [
            hashlib.sha256(level[i] + level[i + 1]).digest()
            for i in range(0, len(level), 2)
        ]
    return level[0]


# --- Build leaf strings per manifest version, then hash each one ---
# Defensive: PDF viewers may inject zero-width chars when copying wrapped
# values, so strip them from each string field before hashing.
leaves: list[bytes] = []
if manifest_version == 1:
    # v1: photos only, no namespace prefix.
    for (pid, sha, ms, drive_id) in photos:
        sha = _strip_zw(sha)
        drive_id = _strip_zw(drive_id)
        leaves.append(hash_leaf(f"{pid}|{sha}|{int(ms)}|{drive_id}"))
elif manifest_version == 2:
    # v2: photos prefixed with P|, videos prefixed with V| (with durationMs).
    for (pid, sha, ms, drive_id) in photos:
        sha = _strip_zw(sha)
        drive_id = _strip_zw(drive_id)
        leaves.append(hash_leaf(f"P|{pid}|{sha}|{int(ms)}|{drive_id}"))
    for (vid, sha, ms, drive_id, dur_ms) in videos:
        sha = _strip_zw(sha)
        drive_id = _strip_zw(drive_id)
        leaves.append(
            hash_leaf(f"V|{vid}|{sha}|{int(ms)}|{drive_id}|{int(dur_ms)}")
        )
else:
    raise SystemExit(f"Unknown manifest version: {manifest_version}")

# --- Reduce to the root and compare to the printed value ---
computed_root_hex = merkle_reduce(leaves).hex()
printed_root_hex = "".join(merkle_root.split()).lower()

print(f"Computed Merkle root: {computed_root_hex}")
print(f"Printed Merkle root:  {printed_root_hex}")

if not printed_root_hex:
    raise SystemExit(
        "Paste the Merkle root from the PDF into merkle_root before running."
    )
if computed_root_hex != printed_root_hex:
    raise SystemExit(
        "Merkle root MISMATCH — the printed leaves do not produce the printed root. "
        "Either a leaf row was mistyped, the leaves are out of signer order, "
        "or the manifest_version does not match the PDF."
    )
print("Merkle root OK — printed leaves match the signed root.")

Sanity check

Before pasting your own values, run Step 3 with this fixed example. It uses one v2 photo leaf with all-zero SHA-256 and a known timestamp, so the script's output is deterministic. If it prints Merkle root OK, your environment matches the Dart implementation and you can substitute your bundle's values.

# Replace the relevant blocks in verify_merkle.py with these values:

manifest_version = 2

merkle_root = "c76a63fa26414838d92316e8dc28e311378b9cc87e6ef643aa95028491101966"

photos = [
    (1,
     "0000000000000000000000000000000000000000000000000000000000000000",
     1715200000000,
     "example_drive_id"),
]
videos = []

# Expected output:
#   Computed Merkle root: c76a63fa26414838d92316e8dc28e311378b9cc87e6ef643aa95028491101966
#   Printed Merkle root:  c76a63fa26414838d92316e8dc28e311378b9cc87e6ef643aa95028491101966
#   Merkle root OK — printed leaves match the signed root.

What verification proves and doesn't prove

If the script prints "Signature valid" it proves:

  • The Merkle root, signature, and public key on the PDF are mathematically consistent.
  • Whoever signed this Merkle root held the private key that pairs with the printed public key.
  • The Merkle root has not been altered since the signature was made — flipping a single character in the hex string would invalidate the signature.

It does not prove:

  • That the Merkle root accurately summarises the photos and videos in this PDF on its own. Step 2 only checks signature consistency. To prove the root is the one this bundle's items hash to, run Step 3 against the per-leaf inputs printed in the "Merkle Tree Verification" appendix. In v1 PDFs the root covers photos only; in v2 it covers photos and videos (see Manifest version).
  • That the timestamps or GPS coordinates on individual photos or videos are accurate. Those are recorded by the device camera and are subject to the device's clock and location services.
  • That the bundle is admissible in court. Renter's Vault provides integrity verification only. Whether a court accepts the bundle as evidence depends on the rules of evidence in your jurisdiction and the testimony of the person who made it.

Verification is a useful first check: it tells you the PDF's signature block is internally consistent and was made by a single, identifiable Renter's Vault signing key. Treat it as one piece of supporting evidence, not as a stamp of legal authority.

Public key format reference

  • Algorithm: Ed25519 (RFC 8032), 32-byte raw public key.
  • Encoding on the PDF: standard base64 (with + and /, not the URL-safe variant). Decoded length is exactly 32 bytes.
  • Signature length: 64 raw bytes, also base64-encoded on the PDF.
  • Fingerprint: SHA-256 of the 32 raw public-key bytes, rendered as 64 lowercase hex characters.
  • Message signed: UTF-8 bytes of the lowercase hex Merkle-root string. Length is exactly 64 bytes.
Why the signature covers the hex string, not the raw 32-byte digest? Embedding the human-readable hex form in the PDF means the printed Merkle root is exactly what gets signed — there's no encoding ambiguity between what a verifier sees and what the device signed. Both forms would work cryptographically; this one is friendlier to copy-and-paste verification.

Manifest version

The PDF prints a manifest version label (e.g. manifest v2) near the signature block. Steps 1 and 2 of the recipe are version-agnostic — they verify the Ed25519 signature over the printed Merkle root and never re-derive the root. Step 3 branches on the version because the canonical leaf format changed between v1 and v2; set manifest_version in the script to match the PDF's label.

Leaf canonical bytes (UTF-8 encoded, then SHA-256):

  • v1 — photos only. Leaf: <photoId>|<sha256>|<msSinceEpoch>|<driveFileId>
  • v2 — photos and videos. Photo leaf: P|<photoId>|<sha256>|<msSinceEpoch>|<driveFileId>. Video leaf: V|<videoId>|<sha256>|<msSinceEpoch>|<driveFileId>|<durationMs>

Tree reduction is identical across versions: pairwise SHA-256 of left || right raw 32-byte digests, with the last leaf duplicated when a level has an odd count, repeated until one root remains.

Where the signing keys come from

Each Renter's Vault account holds one Ed25519 keypair. The keypair is generated on the device the first time a user exports a signed PDF, then stored in the user's Google Drive Application Data folder (a hidden, app-private area of the user's Drive — not visible in normal Drive browsing). On reinstall, the app recovers the same key from Drive and continues signing with it, so all PDFs from one account share a stable public-key fingerprint.

Renter's Vault never sees the private key. There is no key-escrow service and no central directory. If a user signs out of Drive or revokes the app's access, the key remains in their Drive but the app loses access until they reauthorise.

Trouble verifying

If the recipe reports "Fingerprint mismatch" or "Signature INVALID", the most common causes are transcription mistakes — a missing character, a copied space, mixed-case hex. Re-copy each value directly from a digital PDF rather than retyping from a printed page.

If the values are correct and the signature still fails, email support@rentersvault.com with the four printed strings and the date the PDF was made. We can confirm the public-key fingerprint against our records of which signing keys are in use.