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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:
Trust on first use. No certificate authority anywhere, and none bundled.
Two details that took some care:
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:
x:PublicKeyis 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.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:
SET OF, not the[0] IMPLICITthey arrive as. One identifier byte. Skip it and every good signature fails; hash the message instead and every signature "passes", which is much worse.LF→CRLFon 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 -signoutput, 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:checkclean.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.