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:
- The signer's page and the signing flow — review, download, sign — with integrity recomputed on every view.
- Single-use signing, the last-signer-completes rule, and the honesty caveat shown on every signing page.