Theory

The signature, the recomputed hash, and the honest caveat

Last lesson you sent a document and emailed each signer a link. This lesson lights up the link: the signer opens their page, reviews the document, and signs. And it is where you meet the honest limit of what this signature is.

What a signature is here. Three things, captured on the signer's row the moment they sign:

  • signed_name — the full name they typed,
  • signed_at — the server timestamp,
  • signed_doc_hash — the SHA-256 of the document at that moment.

That triple is the whole signature. The third field is the load-bearing one, and how it is computed is the entire point.

Recompute the hash — never trust the stored one. When a signer signs, the server does not read documents.file_hash from the row. It re-opens the stored bytes and hashes them right now:

integ, err := h.checkIntegrity(r.Context(), doc) // re-hash the stored file this instant
// ...
q.SignSigner(ctx, db.SignSignerParams{ /* SignedDocHash: &integ.CurrentHash */ })

The signature attests to the bytes that actually exist at signing time. If the file were swapped after upload, the recomputed hash would differ from file_hash, the banner would shout TAMPER DETECTED, and the signature would honestly record the altered hash — not the one the database claims. Trusting the stored hash would defeat the whole mechanism: you would be certifying a number, not a document.

Single-use to sign, reusable to view. A signer should be able to open their link, read the document, close it, think, and come back — as many times as they like. But they must never sign twice. Both fall out of one guard in the query:

UPDATE signers SET status='signed', signed_name=$2, signed_at=now(), signed_doc_hash=$3
WHERE id = $1 AND status = 'pending';   -- only a pending signer transitions

:execrows returns how many rows changed. The first sign flips pending → signed and returns 1. A second POST of the same token matches status='pending' — no longer true — and returns 0; the handler simply shows the already-signed state. Viewing (GET) has no such guard, so it works forever. Same token, two behaviours, both correct — enforced by the WHERE, the L9 lesson a fourth time.

The last signer completes the document. After a successful sign, the handler counts remaining pending signers. If none are left, the document flips sent → completed — in the same transaction as the signature, so "signed" and "completed" can never disagree.

The caveat is the lesson, not a footnote. Say plainly what this is:

This signature is a typed name, a timestamp, and a SHA-256 fingerprint of the document at signing. That makes it tamper-evident — you can tell if the document changed afterward — and nothing more. It is not a qualified or eIDAS electronic signature, and it is not proof of identity.

This app does evidence, not prevention. It can tell you a document changed; it cannot stop someone changing it, and it does not prove who signed. Signing a tampered document is even allowed — the warning is shown, and the signature honestly records the hash it saw. Presenting simplified crypto as if it were legally-qualified signing would be the real failure. The next step makes you trigger this — a caveat you set off yourself is one you will remember.

This lesson:

  1. The signer's page and the signing flow — review, download, sign — with integrity recomputed on every view.
  2. Single-use signing, the last-signer-completes rule, and the honesty caveat shown on every signing page.