DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A client-sent confidence score is not evidence, so our OCR route signs for its own work

Munchable is a gut-health scanner. Scan a barcode, get a verdict for the conditions you manage. When a product is not in the catalogue yet, you point the camera at the ingredients panel instead, the server reads it, and the product exists for whoever scans that barcode next.

Contributing a genuinely new product earns the contributor a discount on their next subscription month. That sentence is the reason this post exists, because the moment a data submission is worth money, the submission path becomes a payments path, and the things you were happily trusting stop being trustworthy.

The field that proves nothing

The capture request the app sends carries, among other things, the extracted ingredients text, a source of ocr-llm, and an ocrConfidence number.

Those last two are client-sent values. They describe what supposedly happened on the server, but they arrive from a phone, and a phone is a device a motivated person controls. Keying a reward on them means the reward is earnable with a curl call and zero photographs:

POST /api/contribute
{ "barcode": "...", "ingredientsText": "sugar, water", "ocrConfidence": 0.99, "source": "ocr-llm" }
Enter fullscreen mode Exit fullscreen mode

Nothing in that request is a lie the server can detect by looking at it. The server never saw a photo, so it has no way to know whether one existed.

The usual instinct is to make the client's claim harder to fake: sign the request, attest the app build, add a nonce. All of that raises the cost of a forged claim without changing what it is. The claim is still the client telling the server what the server did.

Make the server's own work the evidence

The read happens on the server. So let the route that did the read issue proof that it did, and let the route that pays out demand that proof.

/api/ocr returns, alongside the extracted text, a short opaque string:

v2.<expiry>.<confidence>.<signature>
Enter fullscreen mode Exit fullscreen mode

The signature is an HMAC over the account id, a hash of the text that was extracted, the confidence the extraction actually produced, and the expiry. /api/contribute recomputes it from the text it has been handed and refuses to credit anything whose receipt does not verify.

That inverts the trust. The client is no longer asserting a confidence, it is relaying one that the server committed to minutes earlier and cannot be edited without invalidating the whole receipt. Change one character of the ingredients text and the hash changes, so the signature no longer matches.

Three properties fall out of the design rather than being coded:

  • The capture flow has no edit step, and it does not need one enforced separately. A receipt that verifies is the statement that this text is the unedited machine read, which is what makes two users' captures of the same product comparable to each other.
  • Nothing has to be stored. The receipt is verified and discarded; the saved revision keeps a boolean.
  • A read done for one account cannot be replayed by another, because the account id is inside the HMAC.

The bug in the obvious hash

The receipt covers more than the ingredients list. A label's own "Contains" line and its precautionary "May contain" line ride along, because those turn into allergy warnings that every later scanner of that barcode will see. They are exactly the fields somebody would want to influence.

The first hash concatenated the three strings with a separator. That commits to the fact that these three strings were read. It does not commit to which field each one came from.

So a receipt issued for a precautionary line reading "May contain nuts" also verified when the same words were resubmitted as the "Contains" line. The text was identical, the hash was identical, the signature matched. The difference between those two fields is the difference between a soft warning and pushing a reader off a product entirely, and the sender got to choose which one, in whichever direction suited them.

The fix is to hash named, length-prefixed fields:

const field = (name: string, value: string | null | undefined) => {
  const v = typeof value === 'string' ? value.trim() : '';
  h.update(`${name}:${Buffer.byteLength(v, 'utf8')}:`, 'utf8');
  h.update(v, 'utf8');
};
field('ingredientsText', text.ingredientsText);
field('allergenStatement', text.allergenStatement);
field('precautionaryStatement', text.precautionaryStatement);
Enter fullscreen mode Exit fullscreen mode

The name binds the value to its field. The byte length closes the same hole from the other side, because with a length prefix no separator can be smuggled inside a value to fake a field boundary. This is the canonicalization rule that hits anyone who signs a structured object: you are not signing a string, you are signing a parse, and the signature has to cover the shape as well as the bytes.

Note the length is in bytes, not characters. An ingredients list is full of accented letters and the occasional fraction glyph, and String.length counts UTF-16 units.

Small things that turned out to matter

Parsing with a regex, because the payload contains dots. The receipt is four dot-separated parts, except the confidence is a decimal like 0.910 that contains a dot itself, so split('.') over-splits it. One regex parses the whole shape at once:

const m = /^v2\.(\d+)\.([01]\.\d{3})\.([A-Za-z0-9_-]+)$/.exec(receipt);
Enter fullscreen mode Exit fullscreen mode

Fixing the confidence to three decimal places is what makes that pattern possible, and it also means the value in the receipt and the value in the signature are formatted identically every time. A signature over a number you re-stringify later is a signature over whichever formatting you happened to use.

Constant-time comparison, with a length check first. timingSafeEqual throws on mismatched lengths, so compare lengths and then the bytes.

The expiry is checked in both directions. Not just exp > now, but also exp no further ahead than the maximum lifetime plus a small allowance. A receipt claiming to be valid for ten years is malformed, not valid.

No secret means nothing is eligible, never a bypass. If the signing secret is not configured, production refuses captures outright rather than accepting them uncredited, and development logs a warning and saves them without credit. The dangerous version of this code is the one where a missing environment variable is read as "skip the check".

The floor that was in the wrong function

There is a confidence floor for earning credit. Early on it lived inside the verification function, so a genuine capture at a confidence below the floor came back as "unverified".

That conflated two completely different questions. Was this read really produced by our OCR route for this account over this text? Yes, provably. Is this read confident enough that we should pay for it? Separately, no.

Because the answers were merged, a user who had done everything right was told their read could not be trusted, and the row was refused. Now verification returns the confidence rather than judging it, and the calling route decides what to do with the number. A genuine receipt at a lower confidence saves the contribution, it simply does not earn.

It is worth checking your own codebase for this shape. A function named verify that returns false for "this is authentic but does not meet a business threshold" will eventually tell an honest user they are a forger.

See the flow it protects

  • The landing page has a section headed "Not on file? Photograph the label", with the photo review step the receipt is issued during. There is no text field anywhere in that flow, which is the receipt's doing.
  • The licenses and attribution page says what happens to the photo afterwards: read, then discarded, never stored. The receipt is what lets us keep that promise and still know later that a genuine read happened.
  • Any ingredient answer page is downstream of it. Captured products join the same catalogue those pages are written from.

The general lesson is short. If a client-sent field is ever going to be worth money, do not try to make the client more honest. Find the point in your own infrastructure where the fact actually became true, and have that point sign for it.

Top comments (0)