Security · the math
The math behind SealProof
Last updated September 27, 2026
Every promise on our security page comes down to a handful of mathematical problems that cryptographers have studied for decades. This page shows the exact constructions the software uses, what each guarantee assumes, the numbers behind it, and how to check it without trusting us.
What this is and isn't. Each guarantee below reduces to standard, published primitives. This is not a machine-checked proof of the whole system, and SealProof hasn't had an independent cryptographic audit yet. The formulas are taken from our code, and the last section lists what math can't protect.
The guarantees
- Only the people involved can read a document
- The key is never stored on our servers
- Keys are locked to people, not to us
- Your passphrase and recovery key
- Access codes
- Signing links and one-time codes
- A sealed document can't be altered
- When it was signed
- The public anchor log
- The audit trail
- The assumptions, in one table
- What the math doesn't cover
Notation. ‖ joins byte strings. x ← random(n) means n bytes from the browser's cryptographic random number generator. "…\n" is a fixed label (a “domain separator”) so a hash made for one purpose can never be reused for another.
01
Only the people involved can read a document
Documents, titles, field values, signatures and attachments are encrypted in the browser before upload. Our servers store only ciphertext.
K ← random(32) one 256-bit key per envelope IV ← random(12) fresh for every file AAD = "sealproof.doc.v1\n" ‖ envelopeId ‖ "\n" ‖ fileId C = "SPE1" ‖ IV ‖ AES-256-GCM(K, IV, plaintext, AAD)
- Guessing the key: there are 2256 ≈ 1.2 × 1077 possible keys. The best known attack on AES-256 is only marginally faster than trying them all.
- Reusing an IV would weaken GCM, so each IV is random. With q files under one key, the chance of any two IVs colliding is at most q²/297. For 1,000 files in one envelope (far more than real use), that's below 10−23.
- Tampering: GCM's 128-bit tag makes a forged or altered ciphertext pass with probability below 2−100 per attempt, even for a 25 MB file. Decryption fails instead of returning modified data.
- Swapping files: the AAD binds each ciphertext to its envelope and file. Moving a signature or document into another envelope, or passing one file off as another, fails authentication.
Open your browser's developer tools (Network tab) and upload a document. The request body starts with the bytes SPE1, followed by random-looking data. That's all our servers ever receive.
02
The key is never stored on our servers
A signer gets the envelope key in the part of the link after “#”. Our server handles it only in memory, to build the links it sends, and never stores or logs it.
https://app.sealproof.io/sign/<token>#k=<base64url(K)>
└── fragment: kept by the browser, never sent in requestsThe internet standard for web addresses (RFC 3986, §3.5) defines the fragment as information for the browser. Browsers don't include it in the requests they send, so when a signer opens the link, our servers and logs see only the token. The token unlocks ciphertext and nothing else.
One exception, stated plainly: we email and text the links for you. To build them, the browser sends the key to our server when you send, when each signer finishes in a set order (so we can email the next one), when the envelope completes (so everyone gets their copy) and when a signer is reassigned. It's used in our server's memory only, never written to our database or logs. The full link, key included, then passes through our email provider, or our SMS provider (SignalWire or Twilio) for texts. An access code keeps the key out of that signer's email and text: the link carries no key, and the key is locked with a code you give the signer yourself. The key still passes through our server's memory at the moments above.
Open a signing link with the Network tab open. No request URL contains #k=.
03
Keys are locked to people, not to us
Account holders, teammates and signers saving to their portal get the envelope key locked to their own public key.
(esk, epk) ← new X25519 key pair (used once) s = X25519(esk, pk) shared secret with the recipient n = BLAKE2b(epk ‖ pk) nonce wrap = epk ‖ XSalsa20-Poly1305(s, n, K) (80 bytes: 32 + 32 + 16)
This is libsodium's sealed box. Only someone holding the private key for pk can recompute s. Recovering a private key from a public key is the elliptic-curve discrete logarithm problem on Curve25519. Its group has about 2252 elements, and the best known attack, Pollard's rho, needs about 2126 steps.
The server stores wrap and the public keys. Holding every row of our database doesn't give it a single envelope key.
04
Your passphrase and recovery key
Your private key is stored encrypted, under your passphrase and separately under a recovery key only you hold.
salt ← random(16) KEK = Argon2id(passphrase, salt, passes = 3, memory = 64 MiB) box = XChaCha20-Poly1305(KEK, nonce ← random(24), privateKey, AAD = your public key) recoveryKey ← random(32) shown once, as 13 groups of 4 characters box' = XChaCha20-Poly1305(recoveryKey, nonce ← random(24), privateKey, AAD)
- The recovery key is 256 random bits. Guessing it is as hopeless as guessing an AES-256 key.
- Passphrase guessing only matters to someone who already has our database. Each guess costs a full Argon2id run over 64 MiB of memory. That memory is what makes specialized cracking hardware expensive, and it's where Argon2id beats older hashes. Strength then depends on the passphrase: five random dictionary words (from a 7,776-word list) give 5 × log₂7776 ≈ 64.6 bits, and six give ≈ 77.5 bits.
- Getting the right key: after unlocking, the browser checks that X25519(privateKey) equals your public key. A corrupted or substituted record fails this check.
- Lost both? On a team, an owner or admin can approve a reset. You make a new key pair on your own device, and teammates whose app already holds an envelope's key re-wrap it to your new public key, using the same sealed box as sharing. Nobody learns your passphrase or private key, and documents only your old key could open stay unreadable.
05
Access codes
A sender can keep the key out of the email entirely. The signer types a code the sender gives them by phone.
code ← 12 symbols from "23456789ABCDEFGHJKMNPQRSTUVWXYZ" (31 symbols) KEK = Argon2id(code, salt, passes = 3, memory = 64 MiB) box = XChaCha20-Poly1305(KEK, nonce, K, AAD = "sealproof.accesscode.v1\n" ‖ envelopeId ‖ "\n" ‖ email)
- Size: 3112 ≈ 7.9 × 1017 codes, or 12 × log₂31 ≈ 59.4 bits.
- No bias: a random byte is kept only if it's below 248, the largest multiple of 31 under 256, so every symbol is exactly equally likely.
- Guessing: an attacker with the link and our stored record would need about 3.9 × 1017 Argon2id runs on average. Even at a billion guesses a second, far beyond what 64 MiB Argon2id allows on today's hardware, that's about 12 years.
- Binding: the AAD ties the code to one envelope and one signer's email, so a code is useless anywhere else.
06
Signing links and one-time codes
A link is a random secret we can check but don't keep. Optional codes add a second factor.
token ← random(32) stored = SHA-256(token) what the database holds code ← 6 random digits; valid 10 minutes; at most 5 codes an hour 5 wrong codes in total, ever → the link locks and the sender is emailed
- A stolen database gives no working links: turning a stored hash back into its token means inverting SHA-256, about 2256 work.
- Guessing a link: a 256-bit token can't be guessed. Requests are rate-limited anyway.
- Guessing a one-time code: each try has a 1 in 1,000,000 chance, and a signer gets 5 wrong tries in total, across every code and every link. So an attacker's chance is at most 5 in 1,000,000, or 1 in 200,000, ever. After that the link locks, the sender is emailed, and only the sender can send a fresh link. Every code also goes to the real signer's email or phone, so an attempt is visible.
07
A sealed document can't be altered
When everyone has signed, the final PDF, including its Certificate of Completion, is sealed with our signing key.
final = original ‖ U₁ ‖ U₂ [‖ U₃] PDF incremental updates: U₁ signatures, U₂ certificate, U₃ PAdES
payload = { document: { sha256: SHA-256(final), originalSha256: SHA-256(original) }, signers, sealedAt, issuer }
m = "sealproof.seal.v1\n" ‖ SHA-256(JCS(payload)) JCS = canonical JSON (RFC 8785)
σ = Ed25519.Sign(sk, m) sk lives in AWS KMS and can't be exported
sealId = SHA-256(σ)- Forging a seal means producing a valid Ed25519 signature (RFC 8032) without the key. The best known attack takes about 2126 steps, and the key never leaves AWS's hardware.
- Altering a sealed PDF: changing even one bit changes its SHA-256. To keep the old seal valid, an attacker would need a different PDF with the same hash (a second preimage, about 2256 work). No SHA-256 collision has ever been found, and the generic attack costs 2128.
- Same meaning, same bytes: JCS gives each payload exactly one byte encoding, so verifiers everywhere compute the same m.
- The original can't be edited during signing: the person who signs last assembles the sealed PDF in their browser, because only the people involved can read it. Signing only appends to the sender's original, so anyone can check that some prefix of the sealed file hashes to originalSha256, and the server only seals a completion whose originalSha256 is the one recorded when the document was sent. Editing any byte of the original would need a SHA-256 second preimage.
- What that check doesn't cover: an appended update can still draw on top of a page. Anyone with the document's key can close that gap in SealProof: their browser rebuilds U₁, U₂ and U₃ from the original and each signer's own encrypted submission (each step is deterministic) and compares the result with the sealed file byte for byte. So a match means every signature is exactly what its signer submitted.
Drop a signed PDF on sealproof.io/verify, or run the open command-line verifier offline: node sealproof-verify.mjs contract.pdf --bundle contract.sealproof.json --keys sealproof-keys.json --offline. Our public keys are published at app.sealproof.io/.well-known/sealproof-keys.json. Then change one byte of the PDF and run it again: verification fails. For documents sealed since September 27, 2026, the result also says whether the original is intact inside the file. Documents sealed earlier don't record the original's fingerprint, so that line isn't available for them.
08
When it was signed
Each seal is timestamped by an independent timestamp authority, not by our own clock.
token = TSA.Sign(SHA-256(σ), time, TSA certificate chain) RFC 3161
The timestamp authority signs the hash of our seal together with the time. The verifier checks that signature against root certificates built into the tool, not ones we supply at verification time. Back-dating a seal would need the authority's private key, not ours.
Base64-decode timestamp.token from a proof bundle into a file, then run openssl ts -reply -in token.tsr -token_in -text to read the time and the authority.
09
The public anchor log
Every few minutes, new seals are rolled into a Merkle tree whose root goes into a public, append-only log that's timestamped in Bitcoin.
leafᵢ = SHA-256(0x00 ‖ σᵢ)
node = SHA-256(0x01 ‖ left ‖ right) RFC 9162 construction
hᵦ = SHA-256("sealproof.anchor.v1\n" ‖ hᵦ₋₁ ‖ "\n" ‖ rootᵦ ‖ "\n" ‖ sizeᵦ ‖ "\n" ‖ timeᵦ)
hᵦ → OpenTimestamps → Bitcoin block- Small proofs: proving a seal is in a batch of n takes ⌈log₂ n⌉ hashes. For a million seals that's 20 hashes, 640 bytes.
- No leaf/node confusion: the 0x00 and 0x01 prefixes stop anyone passing off an interior node as a seal, a known weakness of unprefixed Merkle trees.
- The past can't be rewritten: each entry hashes the one before it. Changing batch b changes hᵦ and every hash after it. Once a later hash is committed to a Bitcoin block, rewriting history would mean breaking SHA-256 or redoing Bitcoin's proof of work. That holds even against us.
Every batch is listed at app.sealproof.io/anchor. Recompute the chain from the published records. Then download a batch's record and one of its .ots proofs, and check them with the official OpenTimestamps client: ots verify -f record.txt proof.ots.
10
The audit trail
Every event on an envelope (sent, viewed, verified, signed) is appended to a hash chain.
h₀ = 0²⁵⁶
hₙ = SHA-256("sealproof.audit.v1\n" ‖ hₙ₋₁ ‖ "\n" ‖ JCS(eventₙ))Editing, deleting or reordering event k changes hₖ and every hash after it, and the verifier reports the first break. The database also refuses edits and deletes from the app and ordinary roles, through triggers and permissions. A database superuser could switch those off, which is why every seal is anchored publicly and the chain is rechecked whenever a document is verified. The Certificate of Completion lists the trail's facts inside the sealed PDF, so they're covered by the seal in section 7. Related records use the same idea. The certificate prints the SHA-256 of the exact electronic-records disclosure each signer accepted.
11
The assumptions, in one table
If these hold, the guarantees above hold. All are public standards with long analysis histories.
| Primitive | Standard | Assumed hard | Best known attack | Used for |
|---|---|---|---|---|
| AES-256-GCM | FIPS 197, SP 800-38D | AES is a pseudorandom permutation | ≈ 2254 | Documents |
| X25519 | RFC 7748 | Discrete log on Curve25519 | ≈ 2126 | Locking keys to people |
| XSalsa20 / XChaCha20-Poly1305 | libsodium, RFC 8439 | ChaCha/Salsa are pseudorandom; Poly1305 is unforgeable | ≈ 2256 (key), 2−100 (forgery) | Wrapped keys, private keys |
| Argon2id | RFC 9106 | Memory-hard: each guess needs its memory | Cost grows with memory × guesses | Passphrases, access codes |
| SHA-256 | FIPS 180-4 | Collision and preimage resistance | 2128 / 2256 | Fingerprints, chains, Merkle trees |
| Ed25519 | RFC 8032 | Discrete log on edwards25519 | ≈ 2126 | Seals |
| RFC 3161 timestamps | RFC 3161 | The authority's key stays private | (trust in the authority) | Signing time |
| Bitcoin via OpenTimestamps | opentimestamps.org | Proof of work can't be economically redone | A majority of Bitcoin's hash power | Freezing the log |
All primitives come from established libraries: the browser's Web Crypto, libsodium, and AWS KMS. We don't implement cryptographic primitives ourselves.
12
What the math doesn't cover
Being straight about this matters as much as the rest.
- Your device. Encryption protects data in transit and in our storage. It can't protect a document on a device running malware, or on a screen someone is watching.
- The code your browser runs. As with every end-to-end encrypted web app, you trust the JavaScript served by app.sealproof.io. A compromised server could send code that leaks keys. The seal, the anchor log and the offline verifier don't depend on that code.
- Link delivery. The envelope key passes through our server's memory when links are built, and without an access code a signing link, key included, passes through our email or SMS provider (section 2). A compromise of that pipeline at those moments could expose links or keys.
- Links are bearer secrets. Anyone with a complete signing link can open that document. Access codes, one-time codes and photo ID checks exist for when that isn't enough.
- The person who signs last. Their browser builds the sealed copy. /verify shows the original is intact but not what was drawn on top of it; the full check needs the document's key. Certificate details that SealProof's records can't re-check, like IP addresses and devices, are as our server reported them to that browser. Our PDF digital signature is issued over a digest we can't see the document for: requests are limited and logged, and only the one in the sealed file is bound to the seal.
- Identity is evidence, not certainty. Codes prove control of an email address or phone. A photo ID check shows a real-looking ID matching a live face. Neither proves who someone is with certainty.
- Quantum computers. A large quantum computer running Shor's algorithm would break X25519 and Ed25519. That would expose wrapped keys recorded today, and new seals could be forged. AES-256 and SHA-256 would keep about 128 bits of security against Grover's algorithm. Existing seals stay trustworthy through their RFC 3161 timestamps and Bitcoin anchors, which prove they existed before any such break. We'll move to post-quantum algorithms (such as ML-KEM and ML-DSA) as they reach mainstream libraries.
- No independent audit yet. We'll publish the report when there is one. Until then, the code paths named on this page are open to review on request, and we welcome reports at security@sealproof.io.