Check S/MIME signatures, and remember who signed #286

Closed
opened 2026-09-05 08:43:13 +00:00 by jcoffey-dev · 0 comments
Owner

Signed mail is now checked as it is read. Verification only — nothing here signs, encrypts or decrypts, and the private-key problem that blocks those is untouched. Verifying needed none of it, because the certificate travels inside the message, which is exactly why this is the half that could be built.

The trust model is the design

A browser has no system trust store, and the signer's certificate arrives inside the message. So anyone can self-sign as anyone, and on its own a good signature shows only that whoever wrote the message held the key they attached to it.

That is why the bare word verified is never rendered, and why the reassuring case is deliberately the quiet one. What carries the weight is remembering:

what happened what you see
first signed message from an address "seen here for the first time" — grey, not congratulatory
same certificate as before "the same signer as before" — the only case that gets a tick
different certificate than before loud: both names, and told to check by another route
valid signature, certificate for another address loud: the signature is not for this sender
body changed after signing loud: does not check out
signed but uncheckable grey, and says could not check, never did not check out

Trust on first use. No certificate authority anywhere, and none bundled.

Two details that took some care:

  • Pins live in the account's settings, not the browser. One that only a single device knew would greet the same correspondent as new everywhere else, which is how people get trained to click past the one warning that matters.
  • A pin records the message that created it. Without that, the message which established a signer reads as corroborated by it on reopening — "the same signer as before", where before is itself. That fires on the very first signed message anybody receives. Caught by driving it, not by a test.

A changed, mismatched or expired signer is never pinned: writing the anomaly into the baseline makes every later message agree with it.

Declined, not attempted

All three say could not check rather than does not check out — ignorance and an accusation are different claims:

  • OpenPGP, by name. The signature carries no key, and there is nowhere to get the sender's: x:PublicKey is the account's own registry, not correspondents'. A keyserver or WKD lookup would tell a third party who you correspond with — the leak the image proxy exists to close.
  • SHA-1. Not forgeable in practice today; still not something to put a tick beside.
  • RSA-PSS. The salt length lives in parameters this does not read, and guessing wrong reports a good signature as bad.

Nothing validates a chain — no CA bundle, no revocation. "Issued by" reports what the certificate claims, and a self-signed one claims itself.

Implementation

Hand-written DER, CMS, X.509 and MIME readers, deliberately narrow. No new dependency (runtime deps stay at 9), and the whole verifier is a lazily imported 8.6 kB chunk (3.46 kB gzipped) that a reader of unsigned mail never downloads — nothing is even fetched unless the message structure says it is signed.

Two things the parsers are careful about, both with their own function and test:

  • Signed attributes are signed as a SET OF, not the [0] IMPLICIT they arrive as. One identifier byte. Skip it and every good signature fails; hash the message instead and every signature "passes", which is much worse.
  • Exact bytes throughout. Every MIME part keeps a subarray of the original buffer, never a rebuilt copy. The only transformation is lone LFCRLF on the signed part, because a store that flattens line endings would otherwise fail every signature it holds while the message looked identical on screen.

Verified against real signatures

The fixtures are openssl smime -sign output, not hand-written — a signed message written by hand only ever agrees with whatever its author believed the format to be. RSA, ECDSA (whose r/s need re-packing for WebCrypto), a tampered copy, and a valid signature by a certificate for somebody else.

Driven in a browser against the mock too, which now serves three real signed messages so every branch of the banner is reachable. All three behave, and reopening the pinning message still says "first time".

25 new tests. 1096 web + 142 server, typecheck, build and i18n:check clean.

Translations

34 new strings × 9 catalogues = 306 entries. Falling back to English is 24 before, 24 after — unchanged. German checked in the running app.

Merged 2026-09-05 as coffey-labs/ihasmail@7d6dfe4581

Rebuilt from: GH Archive, git history, session transcript.

Signed mail is now checked as it is read. **Verification only** — nothing here signs, encrypts or decrypts, and the private-key problem that blocks those is untouched. Verifying needed none of it, because the certificate travels inside the message, which is exactly why this is the half that could be built. ## The trust model is the design A browser has no system trust store, and the signer's certificate arrives **inside the message**. So anyone can self-sign as anyone, and on its own a good signature shows only that whoever wrote the message held the key they attached to it. That is why the bare word **verified** is never rendered, and why the reassuring case is deliberately the quiet one. What carries the weight is remembering: | what happened | what you see | |---|---| | first signed message from an address | *"seen here for the first time"* — grey, not congratulatory | | same certificate as before | *"the same signer as before"* — the only case that gets a tick | | **different certificate than before** | **loud**: both names, and told to check by another route | | valid signature, certificate for another address | **loud**: the signature is not for this sender | | body changed after signing | **loud**: does not check out | | signed but uncheckable | grey, and says *could not check*, never *did not check out* | Trust on first use. No certificate authority anywhere, and none bundled. Two details that took some care: - **Pins live in the account's settings, not the browser.** One that only a single device knew would greet the same correspondent as new everywhere else, which is how people get trained to click past the one warning that matters. - **A pin records the message that created it.** Without that, the message which established a signer reads as corroborated by it on reopening — "the same signer as before", where *before* is itself. That fires on the very first signed message anybody receives. Caught by driving it, not by a test. A changed, mismatched or expired signer is never pinned: writing the anomaly into the baseline makes every later message agree with it. ## Declined, not attempted All three say *could not check* rather than *does not check out* — ignorance and an accusation are different claims: - **OpenPGP, by name.** The signature carries no key, and there is nowhere to get the sender's: `x:PublicKey` is the account's **own** registry, not correspondents'. A keyserver or WKD lookup would tell a third party who you correspond with — the leak the image proxy exists to close. - **SHA-1.** Not forgeable in practice today; still not something to put a tick beside. - **RSA-PSS.** The salt length lives in parameters this does not read, and guessing wrong reports a *good* signature as bad. Nothing validates a chain — no CA bundle, no revocation. "Issued by" reports what the certificate claims, and a self-signed one claims itself. ## Implementation Hand-written DER, CMS, X.509 and MIME readers, deliberately narrow. **No new dependency** (runtime deps stay at 9), and the whole verifier is a lazily imported **8.6 kB chunk (3.46 kB gzipped)** that a reader of unsigned mail never downloads — nothing is even fetched unless the message structure says it is signed. Two things the parsers are careful about, both with their own function and test: - **Signed attributes are signed as a `SET OF`, not the `[0] IMPLICIT` they arrive as.** One identifier byte. Skip it and every good signature fails; hash the message instead and every signature "passes", which is much worse. - **Exact bytes throughout.** Every MIME part keeps a subarray of the original buffer, never a rebuilt copy. The only transformation is lone `LF` → `CRLF` on the signed part, because a store that flattens line endings would otherwise fail every signature it holds while the message looked identical on screen. ## Verified against real signatures The fixtures are `openssl smime -sign` output, not hand-written — a signed message written by hand only ever agrees with whatever its author believed the format to be. RSA, ECDSA (whose r/s need re-packing for WebCrypto), a tampered copy, and a valid signature by a certificate for somebody else. Driven in a browser against the mock too, which now serves three real signed messages so every branch of the banner is reachable. All three behave, and reopening the pinning message still says "first time". 25 new tests. 1096 web + 142 server, typecheck, build and `i18n:check` clean. ## Translations **34 new strings × 9 catalogues = 306 entries.** Falling back to English is **24 before, 24 after** — unchanged. German checked in the running app. **Merged** 2026-09-05 as coffey-labs/ihasmail@7d6dfe458114 <sub>Rebuilt from: GH Archive, git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.