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

  1. Only the people involved can read a document
  2. The key is never stored on our servers
  3. Keys are locked to people, not to us
  4. Your passphrase and recovery key
  5. Access codes
  6. Signing links and one-time codes
  7. A sealed document can't be altered
  8. When it was signed
  9. The public anchor log
  10. The audit trail
  11. The assumptions, in one table
  12. 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)
Check it yourself

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 requests

The 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.

Check it yourself

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)

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)

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(σ)
Check it yourself

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.

Check it yourself

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
Check it yourself

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.

PrimitiveStandardAssumed hardBest known attackUsed for
AES-256-GCMFIPS 197, SP 800-38DAES is a pseudorandom permutation≈ 2254Documents
X25519RFC 7748Discrete log on Curve25519≈ 2126Locking keys to people
XSalsa20 / XChaCha20-Poly1305libsodium, RFC 8439ChaCha/Salsa are pseudorandom; Poly1305 is unforgeable≈ 2256 (key), 2−100 (forgery)Wrapped keys, private keys
Argon2idRFC 9106Memory-hard: each guess needs its memoryCost grows with memory × guessesPassphrases, access codes
SHA-256FIPS 180-4Collision and preimage resistance2128 / 2256Fingerprints, chains, Merkle trees
Ed25519RFC 8032Discrete log on edwards25519≈ 2126Seals
RFC 3161 timestampsRFC 3161The authority's key stays private(trust in the authority)Signing time
Bitcoin via OpenTimestampsopentimestamps.orgProof of work can't be economically redoneA majority of Bitcoin's hash powerFreezing 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.