# Document authenticity and verification

How this clinic proves that a piece of paper is one it issued — and, separately,
that the file in somebody's hand is that same document.

## The problem this solves

A QR code on a report resolves to a record. That answers one question:

> Does this clinic have a document with this identity?

It does **not** answer the question that matters in a dispute:

> Is the file in front of me that document?

The gap between them is the whole attack. Somebody photographs a genuine
report's square, pastes it onto a document of their own making, writes whatever
result suits them, and hands it over. Scanned, the square resolves to a real
record — so a system that answers only the first question endorses the forgery.

The two questions are therefore never merged here, and the wording for the first
never contains the word *verified*.

## The four layers

| Layer | What it is | Where |
|---|---|---|
| QR code | A 256-bit random token, and nothing else | `VerificationToken` |
| Official record | The register row: number, version, status, frozen results | `issued_documents` |
| Document integrity | SHA-256 of the exact issued bytes, plus an ECDSA signature over it | `IssuedPdfStore`, `DocumentSigner` |
| Verification UI | Six distinct answers, each spelled out in words | `/v/{token}` |

## How the PDF hash works

1. A report is released and rendered to PDF **once**.
2. Those exact bytes are written to the `private` disk, outside the web root.
3. `SHA-256` of the bytes is stored on the register row, with the document
   number, version and the moment it was issued.
4. The clinic's key signs `algorithm \n number \n version \n sha256`.
5. **Every later download serves the stored bytes**, never a fresh render.

Step 5 is not an optimisation. DomPDF does not emit identical bytes twice for
identical input — its trailer carries a creation time and a document id, and
this was measured rather than assumed. A regenerated file would therefore
disagree with its own digest, and the copy in a patient's hand could never match
anything. Keeping the file is the only way to be able to answer.

## How verification works

**Scanning** (`GET /v/{token}`) looks the token's digest up, and answers with
`OFFICIAL_RECORD_FOUND`, `SUPERSEDED`, `REVOKED`, `VOID` or `INVALID`. It shows
the official results as *that version* reported them, read from the register —
never from any file.

**Uploading** (`POST /v/{token}/pdf`) digests the submitted bytes and compares
them with the stored digest. A match promotes the answer to `VERIFIED`; a
mismatch produces `DOCUMENT_MISMATCH`, which says plainly that the QR is genuine
and the file is not.

The uploaded file is read, hashed and discarded. It is never stored, never
parsed, and nothing inside it is believed — a forger controls every word of
their PDF and none of its SHA-256.

## The six states

| State | Meaning |
|---|---|
| `VERIFIED` | Record genuine and current, **and** the uploaded file is byte-identical |
| `OFFICIAL_RECORD_FOUND` | Record genuine and current. The file has not been checked |
| `SUPERSEDED` | Genuinely issued; a later version exists |
| `DOCUMENT_MISMATCH` | The record is genuine; the file presented is not the one issued |
| `REVOKED` | Issued, then withdrawn by the clinic |
| `VOID` | The transaction behind it was cancelled |
| `INVALID` | No record answers to this token |

Colour is never the only signal — every state carries its words, because a
colour is invisible to a colour-blind reader and to a photocopier.

## How versioning works

Issuing is **idempotent while the content is unchanged**: printing a report a
second time returns the same document, the same number, the same code and the
same square.

When the content changes — a result amended, a value corrected — the next render
creates **version N+1** with its own token, its own file and its own digest, and
marks the previous version `SUPERSEDED`. The old row keeps its results frozen,
so scanning an outdated copy shows what *that copy* said rather than quietly
presenting the corrected figures as though they had always been there.

Nothing is ever edited in place, and no version is ever deleted.

## Privacy

The QR carries a random token and nothing else — no name, no identifier, no
result, no database key. The public page shows a masked name (`N**** A****`),
the document's identity, its status, and the results that version reported. It
does not show the patient's real name, their record number, the content digest,
the token, or any internal id.

A prescription's public page deliberately shows **no medicines**. Establishing
that a prescription is genuine does not require republishing it to whoever
scanned the square.

## Rate limiting

- `GET /v/{token}` and the typed lookups: **20 per minute per IP**
- `POST /v/{token}/pdf`: **10 per minute per IP** — it reads and hashes a file,
  so it costs more and gets less room

A wrong code and an unknown token give the **same** answer, so the endpoint
cannot be used to sort real document numbers from invented ones.

## Audit

Every event goes to the existing tamper-evident chain (`audit_logs`):
`document.issued`, `document.pdf_stored`, `document.printed`,
`document.verified`, `document.verification_failed`, `document.pdf_verified`,
`document.pdf_mismatch`, and the status transitions.

A verification attempt records the document number, whether it succeeded, and
the digest of what was submitted — so a forgery presented repeatedly is
recognisable across attempts. The submitted file itself is never kept.

## The signing key

    php artisan documents:signing-key          # create it
    php artisan documents:signing-key --show   # report the current key

ECDSA over P-256. The key lives at `storage/app/private/keys/`, outside the web
root and outside git.

**Back it up with the database.** If it is lost, signatures already issued
become unverifiable — the documents themselves stay verifiable by their digests,
but the signature layer is gone for everything issued before the loss.

If a document carries no signature the page says *"No signature on this
document"*. It never reports a signature that was not made.

### Limitation, stated plainly

There is no key-management service in this deployment — a clinic on its own
server with its own database. Anyone who can read that server's disk can read
the signing key. The signature therefore proves *the clinic's installation
vouched for these bytes*, not *a key nobody on the server could reach vouched
for them*. On infrastructure that offers a KMS or an HSM, `DocumentSigner` is
the single place to change.

## Deployment

1. `php artisan migrate --force` (on this installation, `--database=mysql_migrations`)
2. `php artisan documents:signing-key`
3. Ensure `storage/app/private` is writable and **not** served by the web server
4. Back up `storage/app/private/` alongside the database — it holds both the
   issued files and the signing key

No new environment variables are required. On a Windows PHP build where OpenSSL
cannot find `openssl.cnf`, either set `OPENSSL_CONF` or add
`clinic.openssl_config` to `config/clinic.php`; key generation looks in the
usual places first and only fails if none of them exist.

## What is deliberately not implemented

**Comparing results extracted from an uploaded PDF.** The brief offers this as
optional, and it is left out on purpose. Text extraction from a PDF is
unreliable — layout, fonts and encodings all interfere — and a comparison built
on it would produce disagreements that are artefacts rather than forgeries.
Worse, it invites the reader to treat the extraction as evidence. The digest
answers the same question exactly, and the official results are already shown
beside the verdict for a human to compare by eye.

**A patient-facing route to download the original PDF.** Full report access
stays behind the existing staff authentication and the `lab.print` grant. Adding
a public download to the verification page would hand the whole clinical record
to anybody who scanned a square, which is the opposite of what the page is for.

**Documents issued before this system existed.** Six rows predate the PDF store.
They verify as records and say plainly that no file was kept for them, rather
than reporting a mismatch and accusing their holder of something.
