Skip to content

The protocol

The Ephemeral Document Signing Protocol enables multiple parties to sign a single PDF through a web-based flow that produces one self-contained artifact. Each signer generates their own cryptographic key in their browser, signs a statement binding them to the exact document they reviewed, and the server countersigns as witness. The signed PDF contains everything needed for independent verification — no server contact, no account, no special software. All server-side state is destroyed after completion or expiry.

Design constraints

The protocol is built around ten invariants grouped into three clusters. These are not aspirational goals — they are hard constraints. Any implementation that violates them is non-conformant.

The artifact. A signing package contains exactly one document. The protocol produces exactly one artifact: a PDF file. The original document pages, signature page, and canonical data all exist within this single file. There are no sidecar files, no ZIP archives, no separate JSON certificates. The PDF undergoes exactly one transformation, at completion: original pages are joined with a signature page containing all signing events, photos, and the full canonical data.

The signing model. The set of signers is fixed at package creation — no one can be added or removed after the fact. All signers sign independently and concurrently; there is no enforced order. Each signer signs with their own ephemeral ECDSA P-256 key generated in their browser via WebCrypto. The private key never leaves the browser. The server never signs on behalf of anyone. The signed PDF is produced only when every designated signer has completed the flow. If any signer fails to sign before the window expires, no signed PDF is produced and all state is destroyed.

The lifecycle. The server holds signing package state only during the active signing window, using a tiered storage architecture: metadata in a relational database, encrypted documents in object storage, ephemeral data (OTPs, rate limits) in a volatile store. Upon completion or expiry, all data across all tiers is destroyed. The signed PDF contains everything needed to verify its own integrity: all signer public keys, all signer signatures, the server countersignature, the server public key, and all hashes. Every signer must scroll through the entire document before signing — removing plausible deniability that they did not see it.

Identity model

The protocol is deliberately conservative about what each identity factor proves. Most signing platforms conflate inbox access with identity. SignNada does not.

Each signer accumulates independent identity evidence through the signing flow. The protocol does not synthesize these into a score or a confidence level. The signed PDF presents each factor directly, and the relying party interprets them in context.

FactorProvesDoes not prove
Signing link deliverySomeone with inbox access clicked the linkLegal identity, that the account owner personally clicked it
Email verification (OTP)Control of an email inbox at signing timeLegal identity, physical identity
Email identificationThe signer entered the correct email from the signer list and verified inbox accessThat the email belongs to the person the sender intended
Document review (scroll-to-end)The signer was presented with the document and scrolled to the endThat the signer read or understood the content
Live photo captureA camera-equipped device captured an image at approximately the time of signingThat the image depicts the signer, that the image is unmanipulated
Ephemeral signing keyA cryptographic key generated in this browser session signed this specific statementAnything about the key holder beyond the session

Cryptographic primitives

Five cryptographic primitives do all the work. The choice of each is deliberate.

PrimitiveUseWhy this one
ECDSA P-256Signer signatures (browser)Universal WebCrypto support with no version floor. Ed25519 in WebCrypto requires Chrome 113+, Firefox 130+, Safari 17+ — a compatibility cliff that excludes a meaningful fraction of browsers in the wild.
Ed25519Server countersignatureDeterministic signatures (same key + message = same output), fast, compact. The server controls its runtime, so browser compatibility is irrelevant.
SHA-256All hashing (document, photos, key fingerprints, certificate ID)FIPS 180-4. Universal. Output always lowercase hexadecimal.
AES-256-GCMPer-package encryption at restAuthenticated encryption (integrity + confidentiality). One key per package, wrapped with a master key via HKDF-SHA256, destroyed on completion.
PKCS#7 / RSA-2048PDF digital signature (convenience layer)Compatibility with standard PDF readers. Does not replace Ed25519 as the authoritative signature.

Signer key lifecycle and per-package encryption

A signer's key exists for the duration of a single browser session. The browser calls crypto.subtle.generateKey with ECDSA P-256. The private key is never exported, never transmitted. The public key (SPKI DER, base64) and signature (base64url) are sent to the server and recorded in the canonical data. After the signing page session ends, the key is gone.

Why not Ed25519 for signers? Because P-256 has been in WebCrypto since the API's inception. Ed25519 support landed in Chrome 113 (May 2023), Firefox 130 (September 2024), and Safari 17 (September 2023). At the time of protocol design, requiring Ed25519 would silently fail on a non-trivial share of real-world browsers. P-256 works everywhere.

Server-side data is encrypted per package. At creation, the server generates a random AES-256 key, wraps it with a key derived from the master secret via HKDF-SHA256 (using the package ID as context), and stores the wrapped key in the metadata database. Document bytes, canonical data, and photos are encrypted with this key in the object store. A metadata breach exposes only wrapped keys (useless without the master key). A document store breach exposes only encrypted blobs (useless without the per-package key). On completion or expiry, the key is zeroed and all encrypted data is deleted.

The canonical data structure

The canonical data structure is the heart of the protocol. It is embedded in the signed PDF as a human-readable rendering on the signature page and as a machine-readable ChromaCode visual stamp — an open-source data encoding format we developed for this protocol. Verification extracts and decodes the stamp; the rendered text is for human inspection.

The structure uses short field names (v3 compact format) for efficient encoding:

Canonical data structure (simplified)

{
  v:  "3",              // version
  ci: String | null,    // certificate_id
  pi: String,           // package_id
  st: String,           // status
  ca: String,           // created_at (ISO 8601)
  ea: String,           // expires_at
  ta: String | null,    // completed_at

  d: {                  // document
    fn: String,         //   original_filename
    h:  String,         //   original_hash (SHA-256 hex)
    sz: Number,         //   original_size (bytes)
  },

  se: { n, e, is },    // sender (name, email, is_signer)

  si: [{                // signers[]
    n, e, st,           //   name, email, status
    sa: String | null,  //   signed_at
    dr: Boolean,        //   document_reviewed
    ov: Boolean,        //   otp_verified
    pk: String | null,  //   public_key (SPKI base64)
    sg: String | null,  //   signature (base64url)
    ss: String | null,  //   signing_statement
  }],

  sv: {                 // server_signature
    al: "Ed25519",      //   algorithm
    ki: String,         //   key_id
    pk: String,         //   public_key
    vl: String | null,  //   value (base64url)
  }
}

The signing statement

Each signer's browser constructs a compact, pipe-delimited statement binding them to the document. This statement is what the signer's private key actually signs:

Signing statement format

SN:1|N:{name}|DH:{document_hash}|DR:{0|1}|PI:{package_id}|SE:{email}|TS:{timestamp}

The statement is deterministic: fields appear in a fixed order with pipe delimiters. The document hash (DH) cryptographically binds the signer to the exact document they reviewed. The document reviewed flag (DR:1) records that they completed the mandatory scroll-to-end review. Both facts are covered by the signer's cryptographic signature — they cannot be modified after signing without detection.

The double computation

The canonical data contains two fields that are computed from the structure they live inside: a unique identifier derived from a hash of the data, and the server's countersignature over it. This is a chicken-and-egg problem — you cannot hash a structure that contains its own hash.

The solution is a two-pass computation with null sentinels. First, both fields are set to null. The structure is serialized and hashed to derive the identifier. Then the structure (now including the identifier but with the signature still null) is serialized again and signed by the server:

Compute (at completion)

COMPUTE_FINAL_CANONICAL(canonical_data):
  1. Set ci = null, sv.vl = null
  2. Serialize: pre_id_text = Canonicalize(canonical_data)
  3. Derive: ci = H(pre_id_text).substring(0, 64)
  4. Serialize again: pre_sig_text = Canonicalize(canonical_data)
     // Now includes ci, but sv.vl is still null
  5. Sign: sv.vl = Sign(k_server, pre_sig_text)
  6. Return canonical_data

Verify (reversal)

VERIFY_CANONICAL(canonical_data, server_public_key):
  1. Save sig = sv.vl, id = ci
  2. Set sv.vl = null
  3. Serialize: pre_sig_text = Canonicalize(canonical_data)
  4. Verify: sig over pre_sig_text — if invalid, UNABLE_TO_VERIFY
  5. Set ci = null
  6. Serialize: pre_id_text = Canonicalize(canonical_data)
  7. Check: id == H(pre_id_text).substring(0, 64) — if mismatch, UNABLE_TO_VERIFY
  8. Restore sig and id
  9. Return VERIFIED

Canonicalize() produces deterministic JSON per RFC 8785 (JSON Canonicalization Scheme): lexicographically sorted keys, no whitespace, UTF-8 encoded. This ensures that every implementation — regardless of language, platform, or JSON library — produces identical byte output for the same data.

The signing flow

A signing package moves through a simple state machine:

Package lifecycle

CREATED ─── sender uploads PDF, server encrypts and stores
  │          sender receives shareable signing link
  │
ACTIVE ──── signers click link, review document, sign
  │           server decrypts/updates/re-encrypts per request
  │
  ├── all signers complete ──> SIGNED PDF BUILT ──> delivered ──> state destroyed
  │
  ├── sender revokes ─────────> REVOKED ──────────> state destroyed
  │
  ├── signer cancels ─────────> REVOKED ──────────> state destroyed
  │
  └── window expires ─────────> EXPIRED ──────────> state destroyed

The sender uploads a PDF, adds signers by name and email, and receives a signing link. The server stores the encrypted document and sends no invitation emails — the sender distributes the link directly.

Each signer opens the link, enters their email (matched against the signer list), verifies it with a one-time code, reviews the entire document by scrolling to the end, and signs. The browser generates an ECDSA P-256 key, constructs the signing statement, signs it with the private key, and sends the public key and signature to the server. The private key never leaves the browser.

When the last signer completes, the server assembles the signed PDF: original pages, a signature page with all signing events, photos, and a ChromaCode visual stamp encoding the full canonical data. The double computation (certificate ID + server countersignature) runs at this point. The signed PDF is delivered to all participants by email, and all server-side state is destroyed.

If any signer does not complete before the 72-hour window expires, no signed PDF is produced. The sender or any completed signer can also cancel the session at any time. In all terminal states — completion, expiry, cancellation — the outcome is the same: all server-side data is permanently deleted.

The signed PDF

The output of the protocol is a single PDF file. Not a ZIP. Not a JSON sidecar. One file containing everything:

Signed PDF structure

Pages 1..N:     Original document pages (unmodified)
Page N+1:       Signature page
                  - Sender block (name, email, date)
                  - Signer blocks (name, email, photo, signed date, review timestamp)
                  - Canonical data (human-readable rendering)
                  - Attestation: "Signed via SignNada. Any modification to this
                    document after signing would be detected."
                  - ChromaCode visual stamp
                    Machine-readable canonical data (RFC 8785 JCS)
                    This is what verification actually uses

The cryptographic record is embedded as a ChromaCode visual stamp on the signature page — the authoritative machine-readable representation of the canonical data.

The signed PDF also carries a PKCS#7 digital signature (RSA-2048) covering the entire file. This is the primary defense against post-signing tampering: if anyone modifies any page of the PDF — even a single byte — the PKCS#7 signature breaks and standard PDF readers (Adobe Acrobat, Preview) will flag it. This is independent of the protocol's own verification.

A verifier checks the signed PDF by extracting the embedded cryptographic record, running the double computation in reverse to verify the server countersignature, then checking each signer's ECDSA P-256 signature over their signing statement. Every signing statement is verified to contain the correct document hash. All checks pass, or the result is "unable to verify." There is no partial success, no confidence score, no intermediate state.

ChromaCode

ChromaCode is an open-source data encoding format we built for this protocol. It encodes arbitrary binary data into a colorful PNG image using RGBA color channels with Reed-Solomon error correction — a compact, visually distinctive stamp designed for digital extraction. ChromaCode is optimized for byte-perfect recovery from digital images, not camera scanning. It can be embedded in documents, stored as images, or displayed on screen.

The signature page of every signed PDF carries a ChromaCode stamp containing the full canonical data structure. This is what the verification procedure actually extracts and reads. The human-readable text rendered alongside it is for visual inspection only. If a discrepancy exists between the rendered text and the stamp, the stamp governs.

ChromaCode is a general-purpose format — not specific to document signing. It can encode any binary payload up to its capacity limit. The source code and specification are available on GitHub.

Verification procedure

Verification is a single operation with a binary outcome:

VERIFY(signed_pdf):
  1. Extract cryptographic record from ChromaCode stamp
     — if missing or unparseable: UNABLE TO VERIFY
  2. Parse canonical_data
  3. Obtain server public key from canonical_data.sv.pk
  4. Verify certificate ID and server countersignature (S6.6 reversal)
     — if either fails: UNABLE TO VERIFY
  5. For each signer:
     a. Verify ECDSA P-256 signature over signing statement
     b. Parse signing statement, verify document hash matches
     c. Verify document reviewed flag is set
     — if any check fails: UNABLE TO VERIFY
  6. VERIFIED

The server public key is embedded in the canonical data, so offline verification requires no server contact. The /.well-known/signing-keys endpoint provides an independent confirmation channel but is not required.

Temporal anchoring

Proving when something happened is harder than proving what happened. The protocol layers four independent time signals, each with an honest assessment of its strength.

Layer 1: Server timestamps. All timestamps in the canonical data are asserted by the server's system clock. This is practical for most use cases, but a malicious server could backdate or postdate them.

Layer 2: Email delivery headers. Verification and completion emails pass through third-party mail infrastructure that adds independent timestamps. These are independent of the server's clock but not cryptographically signed.

Layer 3: Signer-side timestamps. Each signer's signing statement includes a timestamp from the signer's browser. Browser clocks can be wrong, but a significant discrepancy between server and signer timestamps would be notable in a dispute.

Layer 4: RFC 3161 timestamp authority (future). An independent timestamp authority provides cryptographic proof that signing occurred before a specific time. This is the only layer that provides non-repudiable temporal evidence.

The honest-server assumption

The current protocol operates under an honest-server assumption for temporal claims. This is a real limitation, stated clearly.

What the honest-server assumption covers: timestamps are accurate, the document shown to signers matches the committed hash, and signing events are faithfully recorded.

What signer-held keys protect regardless of server honesty: the server cannot forge a signer's signature (only the signer's browser holds the private key), the server cannot retroactively attribute a signature to a non-signer, and the server cannot modify a signed statement after the signer produced it.

The honest-server assumption is a common property of web-based signing systems. SignNada is unusual in being explicit about it. Most platforms make implicit trust assumptions that are harder to evaluate because they are never stated.

Non-goals

These are permanent constraints, not a roadmap.

The protocol does not certify that a document's contents are true — only that the listed parties signed it. It does not make legal enforceability determinations — it provides evidence of signing intent. It does not verify legal identity beyond email and photo — email verification proves inbox access, photo creates a visual record, neither proves who someone is.

The server retains nothing after completion or expiry. There is no signing order — all signers sign in parallel. There are no multi-document packages — one document per package. There is no template management, no user accounts, no signing workflow management (no reminders, no escalation, no delegation, no approval chains), and no phone-based verification.

These exclusions are deliberate. A document signing protocol should sign documents. Everything else is a different product.

Signing Protocol — How Cryptographic Document Signing Works — SignNada