Check S/MIME signatures, and remember who signed
A signed message now says whether that holds up, as it is read. This is
verification only: nothing here signs, encrypts or decrypts, and the
private-key question that blocks those is untouched. Verifying needed
none of it, because the certificate travels inside the message -- which
is why this is the half that could be built.
What it checks. For multipart/signed carrying PKCS#7, the exact bytes of
the signed part -- headers included, canonicalised to CRLF -- are hashed
against the messageDigest attribute, and the signature over the signed
attributes is verified with WebCrypto against the certificate inside the
message. RSA PKCS#1 v1.5 and ECDSA over P-256/384/521, with SHA-256, 384
or 512.
The trust model is the design, and it is deliberately small. A browser
has no system trust store, and the certificate arrives inside the
message, so anyone can self-sign as anyone: on its own a good signature
shows only that the sender held the key they attached. So the word
"verified" is never rendered, and the reassuring case is not the loud
one. What carries the weight is remembering -- the first signed message
from an address pins its fingerprint, later ones are compared, and a
signer that changed is reported with both names and told to check by
another route. Trust on first use, no certificate authority anywhere.
The pins live in the account's settings rather than the browser: one
that only a single device knew would greet the same correspondent as new
everywhere else, which is how people are trained to click past the one
warning that matters. A pin records the message that created it, so the
message that established a signer keeps saying so instead of appearing
to be corroborated by itself -- without that, the very first signed
message anybody receives reads as "the same signer as before", where
before is itself. A changed, mismatched or expired signer is never
pinned, since writing the anomaly into the baseline makes every later
message agree with it.
Three things are declined rather than attempted, and all three say
"could not check" rather than "does not check out", because 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, and
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, whose salt length lives in parameters this does not read.
Guessing wrong would report a good signature as bad.
Nothing validates a chain: no CA bundle is shipped and revocation is not
checked. "Issued by" reports what the certificate claims, and a
self-signed one claims itself.
The DER, CMS, X.509 and MIME readers are hand-written and deliberately
narrow -- no new dependency, and the whole verifier is a lazily imported
8.6 kB chunk that a reader of unsigned mail never downloads. The one
place this is easy to get quietly wrong has its own function and its own
test: signed attributes are signed as a SET OF, not as the [0] IMPLICIT
they arrive as, and hashing the message instead would make every
signature "pass".
Tested against real `openssl smime -sign` output rather than hand-built
fixtures -- RSA, ECDSA, a tampered copy, and a valid signature by a
certificate for somebody else -- because a signed message written by
hand only agrees with whatever its author believed the format to be.
Also driven in a browser against the mock, which now serves three real
signed messages so every branch of the banner is reachable.
Translations: 34 new strings in all nine catalogues, 306 entries.
Falling back to English is unchanged at 24 per language.
This commit is contained in:
+62
@@ -1212,6 +1212,68 @@ Over Stalwart's own registry objects, so there is no administrator in the loop:
|
|||||||
credentials". Doing it properly means implementing OAuth; that is in
|
credentials". Doing it properly means implementing OAuth; that is in
|
||||||
[ROADMAP.md](ROADMAP.md).
|
[ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
|
## Checking a signature
|
||||||
|
|
||||||
|
A signed message says who signed it, and ihasmail checks whether that holds up.
|
||||||
|
This is S/MIME only, and it stops at reading: nothing here signs, encrypts or
|
||||||
|
decrypts anything.
|
||||||
|
|
||||||
|
**What it checks.** For a `multipart/signed` message carrying a PKCS#7
|
||||||
|
signature, the exact bytes of the signed part — headers included, canonicalised
|
||||||
|
to CRLF — are hashed and compared against the `messageDigest` the signature
|
||||||
|
covers, and the signature over the signed attributes is verified with WebCrypto
|
||||||
|
against the certificate travelling inside the message. RSA (PKCS#1 v1.5) and
|
||||||
|
ECDSA over P-256, P-384 and P-521 are supported, with SHA-256, SHA-384 or
|
||||||
|
SHA-512.
|
||||||
|
|
||||||
|
**What a check is allowed to claim, which is the whole design.** A browser has
|
||||||
|
no system trust store, and the certificate arrives inside the message, so anyone
|
||||||
|
can self-sign as anyone. On its own a verified signature proves only that
|
||||||
|
whoever wrote the message held the key attached to it — which is why ihasmail
|
||||||
|
never renders the bare word *verified*.
|
||||||
|
|
||||||
|
What makes it worth anything is remembering. The first signed message from an
|
||||||
|
address pins that certificate's fingerprint in your settings; later ones are
|
||||||
|
compared against it. That is trust on first use, and it needs no certificate
|
||||||
|
authority:
|
||||||
|
|
||||||
|
| what happened | what you see |
|
||||||
|
|---|---|
|
||||||
|
| first signed message from this address | *"Signed by X, seen here for the first time"* — grey, and deliberately 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 some other route |
|
||||||
|
| valid signature, certificate for a different address | **loud**: the signature is not for this sender |
|
||||||
|
| body changed after signing | **loud**: the signature does not check out |
|
||||||
|
| signed, but uncheckable | grey, and careful to say *could not check* rather than *did not check out* |
|
||||||
|
|
||||||
|
The pins live in the account's settings file rather than in the browser, so the
|
||||||
|
same correspondent is not greeted as new on every device — which is what trains
|
||||||
|
people to click past the one warning that matters. A pin records the message
|
||||||
|
that created it, so the message which established a signer keeps saying so
|
||||||
|
rather than appearing to be corroborated by itself. A signer that changed, one
|
||||||
|
whose certificate does not name the sender, or one already expired is never
|
||||||
|
pinned: writing an anomaly into the baseline would make every later message
|
||||||
|
agree with it.
|
||||||
|
|
||||||
|
**What it will not do.**
|
||||||
|
|
||||||
|
- **OpenPGP is not checked**, and says so by name rather than as an unknown
|
||||||
|
format. The signature does not carry the key, and ihasmail has nowhere to get
|
||||||
|
a correspondent's public key from — `x:PublicKey` holds the account's *own*
|
||||||
|
keys, and fetching from a keyserver or WKD would leak who you correspond with
|
||||||
|
to a third party, which is the exact thing the image proxy exists to prevent.
|
||||||
|
- **No chain of trust.** Nothing is validated against a certificate authority,
|
||||||
|
no CA bundle is shipped, and revocation is not checked. "Issued by" reports
|
||||||
|
what the certificate says, and a self-signed certificate says it issued
|
||||||
|
itself.
|
||||||
|
- **SHA-1 signatures are refused**, not reported as valid.
|
||||||
|
- **RSA-PSS is declined** rather than attempted, because guessing the salt
|
||||||
|
length wrong would report a good signature as bad — a worse thing to say than
|
||||||
|
"cannot check".
|
||||||
|
|
||||||
|
The verifier is a separate bundle chunk, loaded only when a message's structure
|
||||||
|
says it is signed, so reading ordinary mail costs nothing for any of this.
|
||||||
|
|
||||||
## Privacy by default
|
## Privacy by default
|
||||||
|
|
||||||
Remote images blocked, the proxy on, read receipts never automatic, no
|
Remote images blocked, the proxy on, read receipts never automatic, no
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
|
|||||||
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
- **A create answers with the id alone**, no `createdAt`, so anything that reads the date back out of the create response gets `undefined`. **Patching `key` on an existing entry is allowed**, which is worth knowing and probably worth not doing: replacing a key by adding one and removing the old keeps `createdAt` meaning what it says.
|
||||||
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
- **`expiresAt` is the registry's own field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`. Reading the real date means parsing the certificate, and a date a client extracted would disagree with the server's field the moment the two ever differed.
|
||||||
|
|
||||||
|
- **Signature checking is done here, and its trust model is deliberately small.** Stalwart does not verify S/MIME or OpenPGP signatures and exposes no result for one, so ihasmail does it in the browser: raw message, MIME split, PKCS#7 parse, WebCrypto. What is worth knowing is what it does *not* do, because the gap is a design choice rather than an omission. **No chain of trust is validated** — a browser has no system trust store, no CA bundle is shipped, and revocation is not checked — so a verified signature on its own shows only that the sender held the key inside their own message, which anyone can self-sign. What carries the weight instead is trust on first use: the first signed message from an address pins its fingerprint in the account's settings, and a later message signed by a different certificate is reported loudly. That is why the interface never says the bare word "verified", why a first sighting is grey rather than green, and why a changed signer never overwrites the pin. Verified against real `openssl smime -sign` output rather than hand-built fixtures — RSA and ECDSA, plus a tampered copy — because a signed message written by hand only ever agrees with whatever the author believed the format to be.
|
||||||
|
- **OpenPGP signatures cannot be checked at all, for a reason that is not effort.** A PGP signature carries no key, so verifying one needs the sender's public key in advance, and there is nowhere to get it: `x:PublicKey` holds the *account's own* keys, not correspondents'. Fetching from a keyserver or via WKD would tell a third party who you correspond with each time you opened a message — the same leak the image proxy exists to close — so it is not done. Such a message says so by name rather than failing as an unknown format, and it says *could not check* rather than *did not check out*, which is a distinction worth keeping: one is ignorance and the other is an accusation.
|
||||||
|
- **Two signature shapes are declined rather than attempted.** SHA-1 signatures are refused outright — one nobody can forge in practice today is still not one to put a tick beside. RSA-PSS is declined because the salt length lives in parameters ihasmail does not read, and guessing wrong would report a perfectly good signature as *bad*, which is a far worse thing to say than "cannot check". Both are shown as uncheckable, not as broken.
|
||||||
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
- **Read receipts are built here, not by the server** — JMAP has an extension for them, [RFC 9007](https://www.rfc-editor.org/rfc/rfc9007.html)'s `MDN/send`, and Stalwart does not implement it: `urn:ietf:params:jmap:mdn` is not among its capabilities. So ihasmail assembles the `multipart/report` itself and sends it the long way round — raw MIME uploaded as a blob, `Email/import`, then `EmailSubmission` — which is also why the receipt lands in Sent, where it honestly belongs. Non-ASCII parts are base64 rather than `8bit`, so nothing depends on 8BITMIME surviving every hop. There is deliberately no "always send" setting: a receipt confirms to whoever asked that the address is live and when it was read, to an address of the sender's choosing, so each one is a decision. Verified against the mock end to end (upload, import, submit, `$mdnsent`), and **confirmed live on 0.16.19 (2026-08-26)**: a receipt asked for by a real sender was assembled, uploaded, imported and submitted, landed in Sent, and set `$mdnsent` so a second look does not offer to send another.
|
||||||
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
- **Where 0.16 advertises `urn:stalwart:jmap`** — not where a JMAP client would look, and this now decides whether a sign-in is allowed at all. Stalwart builds the session-level `capabilities` from a fixed list (`Session::new`, plus WebSocket) that has never contained this capability, in any 0.16.x from 0.16.0 to 0.16.19. It hands it out per-account instead, so it appears in `primaryAccounts` and in each account's `accountCapabilities`. ihasmail tested for it in `capabilities` alone, which made every real 0.16 server read as older than 0.16 — and that one check drove three things: self-service credentials fell back to `POST /api/account/auth`, which 0.16 removed, so password changes, 2FA and app passwords all failed with "this mail server does not offer self-service credential management"; About reported the wrong generation; and Files took the older code path. It now looks in all three places, and is covered by tests on each. Worth restating plainly, because the stakes went up when 0.15 support was dropped: there is no longer a fallback path for this check to be wrong *into*. Getting it wrong now refuses every sign-in against a perfectly good server — a loud failure rather than a quiet misrouting, which is the trade the removal was making.
|
||||||
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
- **HTML signatures** — Stalwart caps a signature at 2047 **bytes** (`value.len() < 2048` on a Rust string, so UTF-8 bytes, not characters). ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker; other clients see a text fallback. Confirmed live on 0.15.5 (2026-08-24): oversized, non-ASCII and inline-image signatures all save, and a test message arrived intact at Gmail with the logo inline.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ More, including the mobile layout, on [ihasmail.org](https://ihasmail.org/#scree
|
|||||||
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
- **Calendar** — JMAP Calendars / JSCalendar: month/week/day/agenda, recurrence, attendees and free-busy, colour categories
|
||||||
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
- **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export
|
||||||
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
- **Files** — JMAP FileNode: browse, upload, download, rename, move, delete
|
||||||
|
- **Signature checking** — S/MIME signed mail is verified as you read it, and the signer is remembered: a later message from the same address signed by somebody else is called out loudly. No certificate authority is involved and none is bundled, so ihasmail never claims more than it can show — see [Checking a signature](FEATURES.md#checking-a-signature)
|
||||||
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
- **Settings that follow the account**, not the browser — kept in a `settings.json` in the account's own JMAP Files, so ihasmail itself stays stateless
|
||||||
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
- **Runs read-only** — one optional write path, and with it switched off the container needs no volume and no writable root. `IMMUTABLE=1` is checked at startup rather than trusted, so a half-applied switch refuses to boot instead of failing quietly. See [Running immutably](#running-immutably)
|
||||||
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
- **Nine new interface languages** — German, Spanish, French, Dutch, Portuguese (Brazil), Russian, Ukrainian, Simplified Chinese and Japanese, alongside English and separate from the date-and-time locale. Every one is marked **Beta**: they were made by AI and no native speaker has read them yet, which Settings says plainly, with a link for reporting anything wrong
|
||||||
|
|||||||
+6
-4
@@ -15,13 +15,15 @@ See [KNOWN-ISSUES.md](KNOWN-ISSUES.md) for what is built but worth knowing about
|
|||||||
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
|
- **A translation anybody has checked.** The translations themselves shipped on 2026-08-31 and are no longer on this page: nine of them, alongside English, and the extraction that had always been the hard half is done — see [FEATURES.md](FEATURES.md#interface-language). What is *not* done is the other half, and it is the half that cannot be bought or automated. All nine were produced by AI against standard dictionaries and **not one has been read by anybody who speaks the language**, which is exactly where a bad translation does harm rather than merely looking untidy. They ship marked Beta, with that said in Settings and a link for reporting anything wrong, because shipping them quietly would ask people to trust text nobody has checked. A language loses the Beta mark when a speaker reads it and says so — a deliberate act by a person, not something a coverage percentage earns. If you speak one of them and are willing to read a few hundred strings, that is the single most useful thing anyone could contribute right now.
|
||||||
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
- **Right-to-left languages.** Arabic, Hebrew and Persian are held back deliberately, and not for want of translators. RTL is bidi and layout work throughout — mirrored panes, gesture directions, icon sides, the message list's own geometry — and a catalogue without it produces a page that is translated and unusable. Adding one is not another entry in the picker.
|
||||||
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see [Quick start](README.md#quick-start-docker)), and Settings › Security offers no way to switch 2FA *on* — only off, for an account that already has it. Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Came out of [#75](https://github.com/Coffey-Labs/ihasmail/issues/75), which is closed: what was reported there was a sign-in refused with nothing but "Invalid credentials", and that was fixed by saying what is actually happening and pointing at app passwords. The OAuth work it uncovered is tracked here rather than as an open issue, so there is no ticket to watch for it.
|
||||||
- **Signed and encrypted mail — and, for now, managing the keys for it.** Nothing signs, encrypts, decrypts or verifies, and the reason is not Stalwart. S/MIME and OpenPGP are both work the *client* does over the message body: JMAP hands it the MIME blob and the rest is ours.
|
- **Signing and encrypting mail.** *Reading* a signature is built: S/MIME signed mail is checked as it is read, and the signer is remembered so a change is called out — see [Checking a signature](FEATURES.md#checking-a-signature). What is not built is anything that produces a signature or touches ciphertext, and the reason is not Stalwart. This is client work over the message body: JMAP hands over the MIME blob and the rest is ours.
|
||||||
|
|
||||||
The blocker is a security model, not code. Signing and decrypting need a private key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. That question has to be answered out loud before it is a feature to build, and it has not been. **Verifying a signature is the exception** — it needs only public keys, touches none of this, and is the one piece that could make a key worth having.
|
The blocker is a security model, not code, and it is the same one it has always been. Signing and decrypting need a **private** key in a page served by the same host that would handle it, which runs straight into two things ihasmail says about itself: that it never stores a credential, and that it runs immutably with nowhere to keep one. Verifying needed none of that — the certificate travels inside the message — which is exactly why it could be built first and why it went first.
|
||||||
|
|
||||||
*Managing* keys is a smaller thing and has been built twice — [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67) and [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285) — and withdrawn twice, on the same reasoning both times: a Settings section for keys that nothing uses is furniture, not a feature, and it ends up saying so in its own footnote. Stalwart's `x:PublicKey` registry is not the obstacle. It works, it holds OpenPGP keys and S/MIME certificates alike, and it is fully probed — see [KNOWN-ISSUES.md](KNOWN-ISSUES.md), which is where those findings live precisely so that the third attempt does not have to rediscover them. Key management ships when something consumes a key, and verifying an incoming signature is the shortest route to that.
|
**OpenPGP signatures are not checked, and this is a harder problem than it looks.** A PGP signature does not carry the key, so verifying one means having the sender's public key already. ihasmail has no source for it: `x:PublicKey` is the account's *own* registry, and fetching from a keyserver or WKD would tell a third party who you correspond with, which is precisely the leak the image proxy exists to close. A local store of correspondents' keys is possible and is not a small feature; nobody has asked for it yet.
|
||||||
|
|
||||||
**Encryption at rest is not the route, and is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user. That is a property of Stalwart rather than a policy choice, which is exactly why the answer here is a policy choice.
|
*Managing* keys — publishing your own to `x:PublicKey` — has been built twice ([PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67), [PR #285](https://github.com/Coffey-Labs/ihasmail/pull/285)) and withdrawn twice, because a Settings page for keys nothing uses is furniture. That reasoning is now partly spent: something does use a key. But what signature checking uses is the certificate inside the message, not anything in the registry, so publishing your own key remains a feature waiting for a consumer.
|
||||||
|
|
||||||
|
**Encryption at rest is refused rather than deferred.** Stalwart offers it as `encryptionAtRest`, a field on `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object whatever the docs suggest, and its value is a typed object (`{"@type": "Disabled"}`) rather than a bare string. It is self-service, needs no administrator, and would be easy to offer. It will not be: turning it *off does not decrypt what is already there*. Every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key, so switching it on is a one-way door — and a toggle that reads as "make my mail safer" while quietly being irreversible is the wrong thing to hand an ordinary user.
|
||||||
|
|
||||||
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponised in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
|
**Why S/MIME rather than OpenPGP, and why neither is urgent.** End-to-end encrypted mail never reached the mainstream and is not on its way there: as a share of the world's email, PGP-encrypted messages are a rounding error, and the most successful use of OpenPGP is signing packages rather than sending mail. The reasons are structural rather than a matter of better tooling. Everyone in a thread has to take part, so the network effect works against it from the first reply. Key discovery was never solved — keyservers were unauthenticated and got weaponised in the 2019 certificate-flooding attacks, which made specific people's keys unusable by any client that fetched them, and WKD is better without being universal. There is no forward secrecy, so one compromised key retroactively opens everything ever received. The metadata stays in the clear: subject lines are cleartext in classic PGP/MIME, and who corresponded with whom is often the sensitive part. Losing a key loses the mail permanently. And it breaks the client — no server-side search, degraded spam filtering, awkward on a phone — while EFAIL showed in 2018 that the clients themselves were exploitable through MIME and HTML handling. Meanwhile the actual privacy win arrived invisibly and without anyone participating, in STARTTLS, MTA-STS and DANE.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { signedMessage, type SIGNED_MESSAGES } from "./signedMessages.js";
|
||||||
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
import { expandOccurrences, occurrenceAt, occurrenceView, parseSyntheticId, slotOfOccurrence, splitOccurrencePatch, syntheticId, type Occurrence } from "./recurrence.js";
|
||||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||||
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
import { holdUntilOf, undoStatusOf } from "./futurerelease.js";
|
||||||
@@ -136,6 +137,58 @@ function winmailDat(): Buffer {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A really signed message, served as the raw blob a client verifies against.
|
||||||
|
*
|
||||||
|
* The signature is over exact bytes, so this deliberately does not go through
|
||||||
|
* addEmail: that builds a message out of parts and would hand back a body it
|
||||||
|
* had assembled rather than the one that was signed. Here the blob *is* the
|
||||||
|
* fixture, byte for byte, and the JMAP metadata is arranged around it.
|
||||||
|
*
|
||||||
|
* `bodyStructure` says multipart/signed because that is what the client checks
|
||||||
|
* before deciding to download anything -- a mock that omitted it would leave
|
||||||
|
* the whole path unreachable while every stored byte was still correct.
|
||||||
|
*/
|
||||||
|
function addSignedEmail(o: { which: keyof typeof SIGNED_MESSAGES; from: [string, string]; subject: string; daysAgo: number; mailbox: string; unread?: boolean }) {
|
||||||
|
const id = `e${counter++}`;
|
||||||
|
const raw = signedMessage(o.which);
|
||||||
|
const received = new Date(Date.now() - o.daysAgo * 86400_000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
const body = "The Analytical Engine has no pretensions whatever to originate anything.";
|
||||||
|
const textBlob = putBlob(body, "text/plain");
|
||||||
|
const e: Obj = {
|
||||||
|
id,
|
||||||
|
blobId: putBlob(raw, "message/rfc822"),
|
||||||
|
threadId: `t${id}`,
|
||||||
|
mailboxIds: { [o.mailbox]: true },
|
||||||
|
keywords: o.unread ? {} : { $seen: true },
|
||||||
|
size: raw.length,
|
||||||
|
receivedAt: received,
|
||||||
|
sentAt: received,
|
||||||
|
messageId: [`${id}@mock`],
|
||||||
|
inReplyTo: null,
|
||||||
|
references: null,
|
||||||
|
from: [{ name: o.from[0], email: o.from[1] }],
|
||||||
|
to: [{ name: "Demo User", email: USER }],
|
||||||
|
cc: null, bcc: null, replyTo: null, sender: null,
|
||||||
|
subject: o.subject,
|
||||||
|
hasAttachment: false,
|
||||||
|
preview: body.slice(0, 120),
|
||||||
|
textBody: [{ partId: "1", blobId: textBlob, size: body.length, name: null, type: "text/plain", charset: "utf-8", disposition: null, cid: null }],
|
||||||
|
htmlBody: [],
|
||||||
|
attachments: [],
|
||||||
|
bodyValues: { "1": { value: body, isEncodingProblem: false, isTruncated: false } },
|
||||||
|
bodyStructure: {
|
||||||
|
partId: null, blobId: null, size: raw.length, type: "multipart/signed", name: null, charset: null, disposition: null, cid: null,
|
||||||
|
subParts: [
|
||||||
|
{ partId: "1", blobId: textBlob, size: body.length, type: "text/plain", name: null, charset: "utf-8", disposition: null, cid: null },
|
||||||
|
{ partId: "2", blobId: null, size: 0, type: "application/x-pkcs7-signature", name: "smime.p7s", charset: null, disposition: "attachment", cid: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
emails.push(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||||
const id = `e${counter++}`;
|
const id = `e${counter++}`;
|
||||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
@@ -192,6 +245,15 @@ for (let i = 0; i < 45; i++) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Three signed messages, so every branch of the signature banner can be seen
|
||||||
|
* without staging a certificate authority. Read "A note" first: that pins Ada's
|
||||||
|
* certificate, after which the other two have something to disagree with.
|
||||||
|
*/
|
||||||
|
addSignedEmail({ which: "good", from: ["Ada Lovelace", "[email protected]"], subject: "A note", daysAgo: 0.2, mailbox: "inbox", unread: true });
|
||||||
|
addSignedEmail({ which: "tampered", from: ["Ada Lovelace", "[email protected]"], subject: "A note (altered in transit)", daysAgo: 0.25, mailbox: "inbox", unread: true });
|
||||||
|
addSignedEmail({ which: "imposter", from: ["Ada Lovelace", "[email protected]"], subject: "A note (signed by somebody else)", daysAgo: 0.3, mailbox: "inbox", unread: true });
|
||||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
||||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Real signed messages, for driving signature checking against the mock.
|
||||||
|
*
|
||||||
|
* These are not hand-written. Each was produced by `openssl smime -sign` with a
|
||||||
|
* generated certificate and is stored base64 so no editor, formatter or
|
||||||
|
* checkout setting can touch a byte of it -- a signature is over exact octets,
|
||||||
|
* and a stray line-ending normalisation would turn a working fixture into a
|
||||||
|
* broken one for reasons invisible in a diff.
|
||||||
|
*
|
||||||
|
* The same files back the unit tests, in web/src/lib/smime/__tests__/fixtures.
|
||||||
|
*
|
||||||
|
* good Ada Lovelace <[email protected]>, RSA/SHA-256, intact
|
||||||
|
* tampered the same message with one word of the body changed and the
|
||||||
|
* signature untouched -- what the feature exists to catch
|
||||||
|
* imposter signed with a certificate for [email protected] while claiming
|
||||||
|
* to be from Ada, which is a valid signature by the wrong person
|
||||||
|
*/
|
||||||
|
export const SIGNED_MESSAGES = {
|
||||||
|
good: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0ZXZlciB0byBvcmlnaW5hdGUgYW55dGhpbmcuDQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTIKQ29udGVudC1UeXBlOiBhcHBsaWNhdGlvbi94LXBrY3M3LXNpZ25hdHVyZTsgbmFtZT0ic21pbWUucDdzIgpDb250ZW50LVRyYW5zZmVyLUVuY29kaW5nOiBiYXNlNjQKQ29udGVudC1EaXNwb3NpdGlvbjogYXR0YWNobWVudDsgZmlsZW5hbWU9InNtaW1lLnA3cyIKCk1JSUdKd1lKS29aSWh2Y05BUWNDb0lJR0dEQ0NCaFFDQVFFeER6QU5CZ2xnaGtnQlpRTUVBZ0VGQURBTEJna3EKaGtpRzl3MEJCd0dnZ2dPTk1JSURpVENDQW5HZ0F3SUJBZ0lVUGc0OW12c1VhQ0ZvSUdYV1ZyRTlyNWFGVm1NdwpEUVlKS29aSWh2Y05BUUVMQlFBd05ERVZNQk1HQTFVRUF3d01RV1JoSUV4dmRtVnNZV05sTVJzd0dRWURWUVFLCkRCSkJibUZzZVhScFkyRnNJRVZ1WjJsdVpYTXdIaGNOTWpZd09UQTFNRGd5TnpRNFdoY05Nell3T1RBeU1EZ3kKTnpRNFdqQTBNUlV3RXdZRFZRUUREQXhCWkdFZ1RHOTJaV3hoWTJVeEd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsagpZV3dnUlc1bmFXNWxjekNDQVNJd0RRWUpLb1pJaHZjTkFRRUJCUUFEZ2dFUEFEQ0NBUW9DZ2dFQkFKU0JGYnJCCmtTTFRySG91Zlc1V05Zb0hmUFFZZCtrZWVTc1puaGw4TkdjVFVpb1hMRlBnWCt1ZW9sZWJNQlJ2U1ErZUZuWFYKY1lnRHR1NHllNXFmeVlMM1d2Q1dRb2l3Z3UyblA4ejZrRlRpUUtsdTJaUkNZc20vMCtEU0QyOHdIUUZ4KzlOcwpsTFlDZGsyMmZsVWhNbmtDa1d2ZFJiMDQ4K0o3NjJCY3h4bkRDRXphK0RQZ3ROcy9rSTJVcWNoaStWUVpaV1F1Ck1mRTU4ZzJVTTJaM3NlNTVRZlMydll0NGo3cFFYanRjVHNqT3hUUlVmenNzbGFoR0xjTklTR2w1a2RqTDV3cngKMUx3dzNZRWwxbnVjUzFRWkR0N3BjU0dOVVFsZE83ZTFyaDBReFFabG5SekZ5a09FSEpSakRvMDdZOWJjdmhuYQp2NWVPUHlPRDBweFdTMWNDQXdFQUFhT0JrakNCanpBZEJnTlZIUTRFRmdRVVFPamRqbHJwVkY0TXBsdDNISmNQCkhFVG9tN1F3SHdZRFZSMGpCQmd3Rm9BVVFPamRqbHJwVkY0TXBsdDNISmNQSEVUb203UXdEd1lEVlIwVEFRSC8KQkFVd0F3RUIvekFhQmdOVkhSRUVFekFSZ1E5aFpHRkFaWGhoYlhCc1pTNWpiMjB3Q3dZRFZSMFBCQVFEQWdlQQpNQk1HQTFVZEpRUU1NQW9HQ0NzR0FRVUZCd01FTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFCSXFHRjRoQmwyClRBTUIxeU9MK3gySiswQVNWYXJyemZ5eVZST2JZK0JaL0dwTG04RGozYkU5a243cVBldjc5dzVqWGlqdkUzOWEKaFpqRG9KWmxsd1ZxbEdNSjZBbWRDR0VkMHcxQStpZnB4SUo2SUs2cTk4SE9vTUVOR0tRZ0RrdTFoUURISVZrLwpsYWVRTEx4Wk12KzlZbHpRTEltR0kyOUl0R2ZFTks2YnZqSzlVaXJyWmNBaGVpSkhCN2ZBOVoyOFRmRkgrTXNPCkpuQlRhbkdrc3d4WUkyZzJKblZiZnNLU3pHVXppUzhQYTVMSTR3UUJqTnZ2OUtMV0tvMk9SbEdlUXltdlRVK2sKL0o5Sk83QngzakphZUpJS0t1K25SVEdjZVFNOE9qandxVzlFQXJYVmZhOTcySWg5bitYditXZitoekVocDZGYQpjT20rNXMweUlVQ0tNWUlDWGpDQ0Fsb0NBUUV3VERBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4Ckd6QVpCZ05WQkFvTUVrRnVZV3g1ZEdsallXd2dSVzVuYVc1bGN3SVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUYKVm1Nd0RRWUpZSVpJQVdVREJBSUJCUUNnZ2VRd0dBWUpLb1pJaHZjTkFRa0RNUXNHQ1NxR1NJYjNEUUVIQVRBYwpCZ2txaGtpRzl3MEJDUVV4RHhjTk1qWXdPVEExTURneU56UTRXakF2QmdrcWhraUc5dzBCQ1FReElnUWdENnROCkV1RVc1VWxZdmFuODhqZGJSMEh3RkpuSnhoMFl0SFVHQTlOSXlmOHdlUVlKS29aSWh2Y05BUWtQTVd3d2FqQUwKQmdsZ2hrZ0JaUU1FQVNvd0N3WUpZSVpJQVdVREJBRVdNQXNHQ1dDR1NBRmxBd1FCQWpBS0JnZ3Foa2lHOXcwRApCekFPQmdncWhraUc5dzBEQWdJQ0FJQXdEUVlJS29aSWh2Y05Bd0lDQVVBd0J3WUZLdzREQWdjd0RRWUlLb1pJCmh2Y05Bd0lDQVNnd0RRWUpLb1pJaHZjTkFRRUJCUUFFZ2dFQWppV1VvSmtGbUN4ZGN3cFNRVFdqUmlseTY4NU0KNEpRNTgzMlZSbFdBM0toQ2tuMC9yc3ptR0NzQ1R0MERBQkVWWU1XMU42ck4wbjBpTEt5ZkNlVVNkL1BQVUFWLwp2UEI3b20veWhCWnBTS1NDWWtBajVMOHFzc3M4cEZRVUczUjVtOFBwcjFkN0Vvcm5ydkVxWnJLc2s3S3grMk81CmxGUExSRUpHWUtnSDVoOHI4NGRIek9Hek9sUjZKVVdqbVFUSEJVQ0dkZUhKdmJOaHp1TFoyQnFvU3VVYzJXcEYKWXNnVGJiSWZQSXdaZFRNZVBtUHIrYzBMYkloRE05S0JhL1J6OWVZRjlOUitEL2ZvRnZVQ2dML0tXcEtnZ2FtUQprVjVndUt2T3FzYUtmNC9kYjE4OEl1UkVibkdVd3NjeVR1TlV1OXUrc0toaVlnZDAydFZyS2RZUGhBPT0KCi0tLS0tLTEyRDAwRUJDMEI1RDMzNTJGMEUyRjI1QjFBNUQzNTUyLS0KCg==",
|
||||||
|
tampered: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogQSBub3RlCk1JTUUtVmVyc2lvbjogMS4wCkNvbnRlbnQtVHlwZTogbXVsdGlwYXJ0L3NpZ25lZDsgcHJvdG9jb2w9ImFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlIjsgbWljYWxnPSJzaGEtMjU2IjsgYm91bmRhcnk9Ii0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MiIKClRoaXMgaXMgYW4gUy9NSU1FIHNpZ25lZCBtZXNzYWdlCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IHRleHQvcGxhaW47IGNoYXJzZXQ9dXRmLTgNCg0KVGhlIEFuYWx5dGljYWwgRW5naW5lIGhhcyBubyBwcmV0ZW5zaW9ucyB3aGF0c29ldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS0xMkQwMEVCQzBCNUQzMzUyRjBFMkYyNUIxQTVEMzU1MgpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJR0p3WUpLb1pJaHZjTkFRY0NvSUlHR0RDQ0JoUUNBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ09OTUlJRGlUQ0NBbkdnQXdJQkFnSVVQZzQ5bXZzVWFDRm9JR1hXVnJFOXI1YUZWbU13CkRRWUpLb1pJaHZjTkFRRUxCUUF3TkRFVk1CTUdBMVVFQXd3TVFXUmhJRXh2ZG1Wc1lXTmxNUnN3R1FZRFZRUUsKREJKQmJtRnNlWFJwWTJGc0lFVnVaMmx1WlhNd0hoY05Nall3T1RBMU1EZ3lOelE0V2hjTk16WXdPVEF5TURneQpOelE0V2pBME1SVXdFd1lEVlFRRERBeEJaR0VnVEc5MlpXeGhZMlV4R3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqCllXd2dSVzVuYVc1bGN6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUpTQkZickIKa1NMVHJIb3VmVzVXTllvSGZQUVlkK2tlZVNzWm5obDhOR2NUVWlvWExGUGdYK3Vlb2xlYk1CUnZTUStlRm5YVgpjWWdEdHU0eWU1cWZ5WUwzV3ZDV1FvaXdndTJuUDh6NmtGVGlRS2x1MlpSQ1lzbS8wK0RTRDI4d0hRRngrOU5zCmxMWUNkazIyZmxVaE1ua0NrV3ZkUmIwNDgrSjc2MkJjeHhuRENFemErRFBndE5zL2tJMlVxY2hpK1ZRWlpXUXUKTWZFNThnMlVNMlozc2U1NVFmUzJ2WXQ0ajdwUVhqdGNUc2pPeFRSVWZ6c3NsYWhHTGNOSVNHbDVrZGpMNXdyeAoxTHd3M1lFbDFudWNTMVFaRHQ3cGNTR05VUWxkTzdlMXJoMFF4UVpsblJ6RnlrT0VISlJqRG8wN1k5YmN2aG5hCnY1ZU9QeU9EMHB4V1MxY0NBd0VBQWFPQmtqQ0JqekFkQmdOVkhRNEVGZ1FVUU9qZGpscnBWRjRNcGx0M0hKY1AKSEVUb203UXdId1lEVlIwakJCZ3dGb0FVUU9qZGpscnBWRjRNcGx0M0hKY1BIRVRvbTdRd0R3WURWUjBUQVFILwpCQVV3QXdFQi96QWFCZ05WSFJFRUV6QVJnUTloWkdGQVpYaGhiWEJzWlM1amIyMHdDd1lEVlIwUEJBUURBZ2VBCk1CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUVNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJJcUdGNGhCbDIKVEFNQjF5T0wreDJKKzBBU1ZhcnJ6Znl5VlJPYlkrQlovR3BMbThEajNiRTlrbjdxUGV2Nzl3NWpYaWp2RTM5YQpoWmpEb0pabGx3VnFsR01KNkFtZENHRWQwdzFBK2lmcHhJSjZJSzZxOThIT29NRU5HS1FnRGt1MWhRREhJVmsvCmxhZVFMTHhaTXYrOVlselFMSW1HSTI5SXRHZkVOSzZidmpLOVVpcnJaY0FoZWlKSEI3ZkE5WjI4VGZGSCtNc08KSm5CVGFuR2tzd3hZSTJnMkpuVmJmc0tTekdVemlTOFBhNUxJNHdRQmpOdnY5S0xXS28yT1JsR2VReW12VFUrawovSjlKTzdCeDNqSmFlSklLS3UrblJUR2NlUU04T2pqd3FXOUVBclhWZmE5NzJJaDluK1h2K1dmK2h6RWhwNkZhCmNPbSs1czB5SVVDS01ZSUNYakNDQWxvQ0FRRXdUREEwTVJVd0V3WURWUVFEREF4QlpHRWdURzkyWld4aFkyVXgKR3pBWkJnTlZCQW9NRWtGdVlXeDVkR2xqWVd3Z1JXNW5hVzVsY3dJVVBnNDltdnNVYUNGb0lHWFdWckU5cjVhRgpWbU13RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUhBVEFjCkJna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZ0Q2dE4KRXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3YWpBTApCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUc5dzBECkJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSUtvWkkKaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBamlXVW9Ka0ZtQ3hkY3dwU1FUV2pSaWx5Njg1TQo0SlE1ODMyVlJsV0EzS2hDa24wL3Jzem1HQ3NDVHQwREFCRVZZTVcxTjZyTjBuMGlMS3lmQ2VVU2QvUFBVQVYvCnZQQjdvbS95aEJacFNLU0NZa0FqNUw4cXNzczhwRlFVRzNSNW04UHByMWQ3RW9ybnJ2RXFacktzazdLeCsyTzUKbEZQTFJFSkdZS2dINWg4cjg0ZEh6T0d6T2xSNkpVV2ptUVRIQlVDR2RlSEp2Yk5oenVMWjJCcW9TdVVjMldwRgpZc2dUYmJJZlBJd1pkVE1lUG1QcitjMExiSWhETTlLQmEvUno5ZVlGOU5SK0QvZm9GdlVDZ0wvS1dwS2dnYW1RCmtWNWd1S3ZPcXNhS2Y0L2RiMTg4SXVSRWJuR1V3c2N5VHVOVXU5dStzS2hpWWdkMDJ0VnJLZFlQaEE9PQoKLS0tLS0tMTJEMDBFQkMwQjVEMzM1MkYwRTJGMjVCMUE1RDM1NTItLQoK",
|
||||||
|
imposter: "VG86IHlvdUBleGFtcGxlLmNvbQpGcm9tOiBBZGEgTG92ZWxhY2UgPGFkYUBleGFtcGxlLmNvbT4KU3ViamVjdDogTm90IHJlYWxseSBBZGEKTUlNRS1WZXJzaW9uOiAxLjAKQ29udGVudC1UeXBlOiBtdWx0aXBhcnQvc2lnbmVkOyBwcm90b2NvbD0iYXBwbGljYXRpb24veC1wa2NzNy1zaWduYXR1cmUiOyBtaWNhbGc9InNoYS0yNTYiOyBib3VuZGFyeT0iLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3IgoKVGhpcyBpcyBhbiBTL01JTUUgc2lnbmVkIG1lc3NhZ2UKCi0tLS0tLUEzNzZENzYzQzdGNDc1MDk3MUY3QzA0QjI3QTM4Q0E3CkNvbnRlbnQtVHlwZTogdGV4dC9wbGFpbjsgY2hhcnNldD11dGYtOA0KDQpUaGUgQW5hbHl0aWNhbCBFbmdpbmUgaGFzIG5vIHByZXRlbnNpb25zIHdoYXRldmVyIHRvIG9yaWdpbmF0ZSBhbnl0aGluZy4NCgotLS0tLS1BMzc2RDc2M0M3RjQ3NTA5NzFGN0MwNEIyN0EzOENBNwpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL3gtcGtjczctc2lnbmF0dXJlOyBuYW1lPSJzbWltZS5wN3MiCkNvbnRlbnQtVHJhbnNmZXItRW5jb2Rpbmc6IGJhc2U2NApDb250ZW50LURpc3Bvc2l0aW9uOiBhdHRhY2htZW50OyBmaWxlbmFtZT0ic21pbWUucDdzIgoKTUlJRnlnWUpLb1pJaHZjTkFRY0NvSUlGdXpDQ0JiY0NBUUV4RHpBTkJnbGdoa2dCWlFNRUFnRUZBREFMQmdrcQpoa2lHOXcwQkJ3R2dnZ05NTUlJRFNEQ0NBakNnQXdJQkFnSVVGZEtOZmhPdkJDaloxMUk3UGpYM1NtNlBTaFF3CkRRWUpLb1pJaHZjTkFRRUxCUUF3R0RFV01CUUdBMVVFQXd3TlUyOXRaV0p2WkhrZ1JXeHpaVEFlRncweU5qQTUKTURVd09ESTNORGhhRncwek5qQTVNREl3T0RJM05EaGFNQmd4RmpBVUJnTlZCQU1NRFZOdmJXVmliMlI1SUVWcwpjMlV3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQ2xkZ3RlR0lwSTdiemlpazJQCmxvc3JkWVdKS1pTZy9FSjQ0YW05QmFiemUrTkNKVHhNdkUvZnpZRFVuVWdVeUw3WEtVNmRhaGtPQlJyS0VqTEgKRW5SblRjcjhrNlpxc2tGSnd3V2FTQUhqdklUZ0hPUTd3R01Jd2NKbGROdy9ZKzRaUUhlSFVuY1RiYUc4YnlONwpkVnRsNE1HNFBvZFdaTVlYRlVLSDJiRW1QUW5yVG1LZm9oL2l4T2xWbk54dTQrUy9HOXFIK0VJOHNaeXJCeUlXClBZNkNoV1hEbzNDNTdpZkpDdHNxdlNEaUhZeEVOOWROL0RIZ2xLMzhielFPdzRRNzRYVG93aUw0NytwbVUwYkYKRFNnMjdxMmUvaC9ESEFIczE4Vy9YRGNEa3hwRU9IL0IwZS9HdEFLL1I1cUFnZm1uVnZJM2Y3d1JpZlc0bWovUAplRXFmQWdNQkFBR2pnWWt3Z1lZd0hRWURWUjBPQkJZRUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQjhHCkExVWRJd1FZTUJhQUZMWWYyd2dLVXBsTE8rYlNYNVZDc3B5d1NPSkpNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHcKSGdZRFZSMFJCQmN3RllFVGJXRnNiRzl5ZVVCbGVHRnRjR3hsTG01bGREQVRCZ05WSFNVRUREQUtCZ2dyQmdFRgpCUWNEQkRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQVFFQUxrUkxrdXNmdHFyUkZOa2hiWmZiT3d0NEtPdGZGa1FuClluNEp6T1lOZnVlU2lkcldLWTNGMnMzWGZCaWNoQmVQVjZ0MXRvT2owK2VhZ1lOK3hpSTlLZnR5eWtWVlliNS8KZFBabEhMUmdSRmF2eGxxTExnMjViQlVFenB3M0xwYU1NYTYyWmhjMUNwME44aUFUVms5dnBNeE4vREZOMnc2SApxZSswQ29RWVJNOGFXL0QzYW9zK0VZS2JOc0IxWlYwQVp6dC9NSlllSnZSaHA3b0gyUUE0c1hwODJmMEYwUkFWCnVTWkNYMzhXemJwZnZsRE9vYXNVVWxPZFBFdnhCQmFRSXB0S0cxcTJER2pxTFpVaUh5eW9udGRqelI2K1ZhaVYKeXBCOGdzNy9vRlNLTm9RRVc4d3pvRW51YlhoNzBBMzZQcXIxVkZGWnRMa05KRFVmYkJZelBqR0NBa0l3Z2dJKwpBZ0VCTURBd0dERVdNQlFHQTFVRUF3d05VMjl0WldKdlpIa2dSV3h6WlFJVUZkS05maE92QkNqWjExSTdQalgzClNtNlBTaFF3RFFZSllJWklBV1VEQkFJQkJRQ2dnZVF3R0FZSktvWklodmNOQVFrRE1Rc0dDU3FHU0liM0RRRUgKQVRBY0Jna3Foa2lHOXcwQkNRVXhEeGNOTWpZd09UQTFNRGd5TnpRNFdqQXZCZ2txaGtpRzl3MEJDUVF4SWdRZwpENnRORXVFVzVVbFl2YW44OGpkYlIwSHdGSm5KeGgwWXRIVUdBOU5JeWY4d2VRWUpLb1pJaHZjTkFRa1BNV3d3CmFqQUxCZ2xnaGtnQlpRTUVBU293Q3dZSllJWklBV1VEQkFFV01Bc0dDV0NHU0FGbEF3UUJBakFLQmdncWhraUcKOXcwREJ6QU9CZ2dxaGtpRzl3MERBZ0lDQUlBd0RRWUlLb1pJaHZjTkF3SUNBVUF3QndZRkt3NERBZ2N3RFFZSQpLb1pJaHZjTkF3SUNBU2d3RFFZSktvWklodmNOQVFFQkJRQUVnZ0VBSEtKbXQ0SmYrZ1kvTmtIS0xueTc3VC9KCkQxc3lBM2xGWjAwOGlUR3htQU5mQVV3VlFXeTdmSEd6UG1mMkZCdjN5ais3bGJTQUo0YjBKSVRDbFowMUVlalcKUUdkaE1ybVZBZ2QwTTU1ckNFZGNMcms3aFBzWlE3VU9kamMyTzIyY1MyWkJ3WkdrUzRyZThhMHF5NUQydEhBaAowZm5tdTB6RG9Wd1p1bzc4UGFuYzk2dGgzU2pTcExsSlU4andNeWl3bFJIWHpQMG5ITzhDUFdTRE1Lemk1KzhICkVIRHZjaml2ekdudFZPSHZhZmVva0UyTm1ySXZKcENFdk1FTVZ5Vm15c2dVRU5UUUpZT0loRWJYdTJrdEs3OWYKangrdzZpWVVaam5wRUhKNzlPTEFyWnNWNitlN3g5bnE2bGhBM3pTdDJDd3BPR3ZmUk0xN3ROMGMrT0FMcGc9PQoKLS0tLS0tQTM3NkQ3NjNDN0Y0NzUwOTcxRjdDMDRCMjdBMzhDQTctLQoK",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The message as bytes, ready to be served as a blob. */
|
||||||
|
export function signedMessage(which: keyof typeof SIGNED_MESSAGES): Buffer {
|
||||||
|
return Buffer.from(SIGNED_MESSAGES[which], "base64");
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Grace Hopper <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----1F14F13899656314019C2DF4C8728ED3"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIDtQYJKoZIhvcNAQcCoIIDpjCCA6ICAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggHjMIIB3zCCAYagAwIBAgIUejcvY0YWeoAxAMTf4bp3jOkMCVww
|
||||||
|
CgYIKoZIzj0EAwIwKzEVMBMGA1UEAwwMR3JhY2UgSG9wcGVyMRIwEAYDVQQKDAlD
|
||||||
|
b21waWxlcnMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgyNzQ4WjArMRUwEwYD
|
||||||
|
VQQDDAxHcmFjZSBIb3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVyczBZMBMGByqGSM49
|
||||||
|
AgEGCCqGSM49AwEHA0IABOcBiRNb2IF0vlQJlnVKJXFr0taIz2LoU+8p0GQZ9kas
|
||||||
|
DnlHQaCR17JtDfGA6azi7w2ZOYZg0ZjGxbcKoTWKdR6jgYcwgYQwHQYDVR0OBBYE
|
||||||
|
FGqS/6/0hXUay/WvkZrl3DCTY1sTMB8GA1UdIwQYMBaAFGqS/6/0hXUay/WvkZrl
|
||||||
|
3DCTY1sTMA8GA1UdEwEB/wQFMAMBAf8wHAYDVR0RBBUwE4ERZ3JhY2VAZXhhbXBs
|
||||||
|
ZS5jb20wEwYDVR0lBAwwCgYIKwYBBQUHAwQwCgYIKoZIzj0EAwIDRwAwRAIgbJ+R
|
||||||
|
K6iGkQ6qrzT7m7E09D2rW+O3/LYoRPlwo5EJiZkCIHAvYgUyxqeRGNr/adeVmqvv
|
||||||
|
borP2nkAD8LjCUdtv1+NMYIBljCCAZICAQEwQzArMRUwEwYDVQQDDAxHcmFjZSBI
|
||||||
|
b3BwZXIxEjAQBgNVBAoMCUNvbXBpbGVycwIUejcvY0YWeoAxAMTf4bp3jOkMCVww
|
||||||
|
DQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAcBgkq
|
||||||
|
hkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tNEuEW
|
||||||
|
5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajALBglg
|
||||||
|
hkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0DBzAO
|
||||||
|
BggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcN
|
||||||
|
AwICASgwCgYIKoZIzj0EAwIERjBEAiBR36dlKMTvZKbufvpVZNylX7yxQrra+xHP
|
||||||
|
+KnbTB8rzAIgIW0ArgkEKCYF8eF76KyjOcfAlXT1MJKjiCjYj9I13Ug=
|
||||||
|
|
||||||
|
------1F14F13899656314019C2DF4C8728ED3--
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
|
||||||
|
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
|
||||||
|
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
|
||||||
|
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
|
||||||
|
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
|
||||||
|
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
|
||||||
|
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
|
||||||
|
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
|
||||||
|
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
|
||||||
|
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
|
||||||
|
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
|
||||||
|
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
|
||||||
|
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
|
||||||
|
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
|
||||||
|
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
|
||||||
|
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
|
||||||
|
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
|
||||||
|
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
|
||||||
|
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
|
||||||
|
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
|
||||||
|
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
|
||||||
|
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
|
||||||
|
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
|
||||||
|
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
|
||||||
|
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
|
||||||
|
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
|
||||||
|
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
|
||||||
|
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
|
||||||
|
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
|
||||||
|
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
|
||||||
|
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
|
||||||
|
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552--
|
||||||
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: A note
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----12D00EBC0B5D3352F0E2F25B1A5D3552"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatsoever to originate anything.
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIGJwYJKoZIhvcNAQcCoIIGGDCCBhQCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggONMIIDiTCCAnGgAwIBAgIUPg49mvsUaCFoIGXWVrE9r5aFVmMw
|
||||||
|
DQYJKoZIhvcNAQELBQAwNDEVMBMGA1UEAwwMQWRhIExvdmVsYWNlMRswGQYDVQQK
|
||||||
|
DBJBbmFseXRpY2FsIEVuZ2luZXMwHhcNMjYwOTA1MDgyNzQ4WhcNMzYwOTAyMDgy
|
||||||
|
NzQ4WjA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2UxGzAZBgNVBAoMEkFuYWx5dGlj
|
||||||
|
YWwgRW5naW5lczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJSBFbrB
|
||||||
|
kSLTrHoufW5WNYoHfPQYd+keeSsZnhl8NGcTUioXLFPgX+ueolebMBRvSQ+eFnXV
|
||||||
|
cYgDtu4ye5qfyYL3WvCWQoiwgu2nP8z6kFTiQKlu2ZRCYsm/0+DSD28wHQFx+9Ns
|
||||||
|
lLYCdk22flUhMnkCkWvdRb048+J762BcxxnDCEza+DPgtNs/kI2Uqchi+VQZZWQu
|
||||||
|
MfE58g2UM2Z3se55QfS2vYt4j7pQXjtcTsjOxTRUfzsslahGLcNISGl5kdjL5wrx
|
||||||
|
1Lww3YEl1nucS1QZDt7pcSGNUQldO7e1rh0QxQZlnRzFykOEHJRjDo07Y9bcvhna
|
||||||
|
v5eOPyOD0pxWS1cCAwEAAaOBkjCBjzAdBgNVHQ4EFgQUQOjdjlrpVF4Mplt3HJcP
|
||||||
|
HETom7QwHwYDVR0jBBgwFoAUQOjdjlrpVF4Mplt3HJcPHETom7QwDwYDVR0TAQH/
|
||||||
|
BAUwAwEB/zAaBgNVHREEEzARgQ9hZGFAZXhhbXBsZS5jb20wCwYDVR0PBAQDAgeA
|
||||||
|
MBMGA1UdJQQMMAoGCCsGAQUFBwMEMA0GCSqGSIb3DQEBCwUAA4IBAQBIqGF4hBl2
|
||||||
|
TAMB1yOL+x2J+0ASVarrzfyyVRObY+BZ/GpLm8Dj3bE9kn7qPev79w5jXijvE39a
|
||||||
|
hZjDoJZllwVqlGMJ6AmdCGEd0w1A+ifpxIJ6IK6q98HOoMENGKQgDku1hQDHIVk/
|
||||||
|
laeQLLxZMv+9YlzQLImGI29ItGfENK6bvjK9UirrZcAheiJHB7fA9Z28TfFH+MsO
|
||||||
|
JnBTanGkswxYI2g2JnVbfsKSzGUziS8Pa5LI4wQBjNvv9KLWKo2ORlGeQymvTU+k
|
||||||
|
/J9JO7Bx3jJaeJIKKu+nRTGceQM8OjjwqW9EArXVfa972Ih9n+Xv+Wf+hzEhp6Fa
|
||||||
|
cOm+5s0yIUCKMYICXjCCAloCAQEwTDA0MRUwEwYDVQQDDAxBZGEgTG92ZWxhY2Ux
|
||||||
|
GzAZBgNVBAoMEkFuYWx5dGljYWwgRW5naW5lcwIUPg49mvsUaCFoIGXWVrE9r5aF
|
||||||
|
VmMwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEHATAc
|
||||||
|
BgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQgD6tN
|
||||||
|
EuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWwwajAL
|
||||||
|
BglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG9w0D
|
||||||
|
BzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZI
|
||||||
|
hvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAjiWUoJkFmCxdcwpSQTWjRily685M
|
||||||
|
4JQ5832VRlWA3KhCkn0/rszmGCsCTt0DABEVYMW1N6rN0n0iLKyfCeUSd/PPUAV/
|
||||||
|
vPB7om/yhBZpSKSCYkAj5L8qsss8pFQUG3R5m8Ppr1d7EornrvEqZrKsk7Kx+2O5
|
||||||
|
lFPLREJGYKgH5h8r84dHzOGzOlR6JUWjmQTHBUCGdeHJvbNhzuLZ2BqoSuUc2WpF
|
||||||
|
YsgTbbIfPIwZdTMePmPr+c0LbIhDM9KBa/Rz9eYF9NR+D/foFvUCgL/KWpKggamQ
|
||||||
|
kV5guKvOqsaKf4/db188IuREbnGUwscyTuNUu9u+sKhiYgd02tVrKdYPhA==
|
||||||
|
|
||||||
|
------12D00EBC0B5D3352F0E2F25B1A5D3552--
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
To: [email protected]
|
||||||
|
From: Ada Lovelace <[email protected]>
|
||||||
|
Subject: Not really Ada
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: multipart/signed; protocol="application/x-pkcs7-signature"; micalg="sha-256"; boundary="----A376D763C7F4750971F7C04B27A38CA7"
|
||||||
|
|
||||||
|
This is an S/MIME signed message
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7
|
||||||
|
Content-Type: text/plain; charset=utf-8
|
||||||
|
|
||||||
|
The Analytical Engine has no pretensions whatever to originate anything.
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7
|
||||||
|
Content-Type: application/x-pkcs7-signature; name="smime.p7s"
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment; filename="smime.p7s"
|
||||||
|
|
||||||
|
MIIFygYJKoZIhvcNAQcCoIIFuzCCBbcCAQExDzANBglghkgBZQMEAgEFADALBgkq
|
||||||
|
hkiG9w0BBwGgggNMMIIDSDCCAjCgAwIBAgIUFdKNfhOvBCjZ11I7PjX3Sm6PShQw
|
||||||
|
DQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZTAeFw0yNjA5
|
||||||
|
MDUwODI3NDhaFw0zNjA5MDIwODI3NDhaMBgxFjAUBgNVBAMMDVNvbWVib2R5IEVs
|
||||||
|
c2UwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCldgteGIpI7bziik2P
|
||||||
|
losrdYWJKZSg/EJ44am9Babze+NCJTxMvE/fzYDUnUgUyL7XKU6dahkOBRrKEjLH
|
||||||
|
EnRnTcr8k6ZqskFJwwWaSAHjvITgHOQ7wGMIwcJldNw/Y+4ZQHeHUncTbaG8byN7
|
||||||
|
dVtl4MG4PodWZMYXFUKH2bEmPQnrTmKfoh/ixOlVnNxu4+S/G9qH+EI8sZyrByIW
|
||||||
|
PY6ChWXDo3C57ifJCtsqvSDiHYxEN9dN/DHglK38bzQOw4Q74XTowiL47+pmU0bF
|
||||||
|
DSg27q2e/h/DHAHs18W/XDcDkxpEOH/B0e/GtAK/R5qAgfmnVvI3f7wRifW4mj/P
|
||||||
|
eEqfAgMBAAGjgYkwgYYwHQYDVR0OBBYEFLYf2wgKUplLO+bSX5VCspywSOJJMB8G
|
||||||
|
A1UdIwQYMBaAFLYf2wgKUplLO+bSX5VCspywSOJJMA8GA1UdEwEB/wQFMAMBAf8w
|
||||||
|
HgYDVR0RBBcwFYETbWFsbG9yeUBleGFtcGxlLm5ldDATBgNVHSUEDDAKBggrBgEF
|
||||||
|
BQcDBDANBgkqhkiG9w0BAQsFAAOCAQEALkRLkusftqrRFNkhbZfbOwt4KOtfFkQn
|
||||||
|
Yn4JzOYNfueSidrWKY3F2s3XfBichBePV6t1toOj0+eagYN+xiI9KftyykVVYb5/
|
||||||
|
dPZlHLRgRFavxlqLLg25bBUEzpw3LpaMMa62Zhc1Cp0N8iATVk9vpMxN/DFN2w6H
|
||||||
|
qe+0CoQYRM8aW/D3aos+EYKbNsB1ZV0AZzt/MJYeJvRhp7oH2QA4sXp82f0F0RAV
|
||||||
|
uSZCX38WzbpfvlDOoasUUlOdPEvxBBaQIptKG1q2DGjqLZUiHyyontdjzR6+VaiV
|
||||||
|
ypB8gs7/oFSKNoQEW8wzoEnubXh70A36Pqr1VFFZtLkNJDUfbBYzPjGCAkIwggI+
|
||||||
|
AgEBMDAwGDEWMBQGA1UEAwwNU29tZWJvZHkgRWxzZQIUFdKNfhOvBCjZ11I7PjX3
|
||||||
|
Sm6PShQwDQYJYIZIAWUDBAIBBQCggeQwGAYJKoZIhvcNAQkDMQsGCSqGSIb3DQEH
|
||||||
|
ATAcBgkqhkiG9w0BCQUxDxcNMjYwOTA1MDgyNzQ4WjAvBgkqhkiG9w0BCQQxIgQg
|
||||||
|
D6tNEuEW5UlYvan88jdbR0HwFJnJxh0YtHUGA9NIyf8weQYJKoZIhvcNAQkPMWww
|
||||||
|
ajALBglghkgBZQMEASowCwYJYIZIAWUDBAEWMAsGCWCGSAFlAwQBAjAKBggqhkiG
|
||||||
|
9w0DBzAOBggqhkiG9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYI
|
||||||
|
KoZIhvcNAwICASgwDQYJKoZIhvcNAQEBBQAEggEAHKJmt4Jf+gY/NkHKLny77T/J
|
||||||
|
D1syA3lFZ008iTGxmANfAUwVQWy7fHGzPmf2FBv3yj+7lbSAJ4b0JITClZ01EejW
|
||||||
|
QGdhMrmVAgd0M55rCEdcLrk7hPsZQ7UOdjc2O22cS2ZBwZGkS4re8a0qy5D2tHAh
|
||||||
|
0fnmu0zDoVwZuo78Panc96th3SjSpLlJU8jwMyiwlRHXzP0nHO8CPWSDMKzi5+8H
|
||||||
|
EHDvcjivzGntVOHvafeokE2NmrIvJpCEvMEMVyVmysgUENTQJYOIhEbXu2ktK79f
|
||||||
|
jx+w6iYUZjnpEHJ79OLArZsV6+e7x9nq6lhA3zSt2CwpOGvfRM17tN0c+OALpg==
|
||||||
|
|
||||||
|
------A376D763C7F4750971F7C04B27A38CA7--
|
||||||
|
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { judge, shouldRemember, verifyMessage, type KnownSigner } from "../verify";
|
||||||
|
import { certCovers } from "../x509";
|
||||||
|
import { parseMime, toCanonicalCrlf } from "../mime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These fixtures are real. Each was produced by `openssl smime -sign` against a
|
||||||
|
* generated certificate, not written by hand — a hand-built signed message
|
||||||
|
* tests the parser against the author's belief about the format, agrees with
|
||||||
|
* every mistake in it, and is exactly how a verifier ends up passing its own
|
||||||
|
* suite and failing on the first message anybody actually sends.
|
||||||
|
*
|
||||||
|
* The tampered fixture is the same signed message with one word of the body
|
||||||
|
* changed and the signature left alone, which is the case the whole feature
|
||||||
|
* exists to catch.
|
||||||
|
*/
|
||||||
|
// Read through the filesystem rather than an import, so the bytes arrive
|
||||||
|
// exactly as they were signed. A bundler transform in the middle -- even one
|
||||||
|
// that only touched line endings -- would be testing the transform.
|
||||||
|
const fixture = (name: string) => new Uint8Array(readFileSync(resolve(__dirname, "fixtures", name)));
|
||||||
|
|
||||||
|
describe("a genuinely signed message", () => {
|
||||||
|
it("verifies an RSA signature and reads the signer off the certificate", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
expect(result.kind).toBe("intact");
|
||||||
|
if (result.kind !== "intact") return;
|
||||||
|
expect(result.cert.subject.commonName).toBe("Ada Lovelace");
|
||||||
|
expect(result.cert.emails).toContain("[email protected]");
|
||||||
|
expect(result.cert.fingerprint).toMatch(/^[0-9a-f]{64}$/);
|
||||||
|
expect(result.signer.digest).toBe("SHA-256");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verifies an ECDSA signature, whose r and s need re-packing for WebCrypto", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-ec.eml"));
|
||||||
|
expect(result.kind).toBe("intact");
|
||||||
|
if (result.kind !== "intact") return;
|
||||||
|
expect(result.cert.subject.commonName).toBe("Grace Hopper");
|
||||||
|
expect(result.cert.publicKey).toEqual({ kind: "ec", namedCurve: "P-256" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches a body edited after signing", async () => {
|
||||||
|
const result = await verifyMessage(fixture("signed-tampered.eml"));
|
||||||
|
expect(result.kind).toBe("broken");
|
||||||
|
if (result.kind !== "broken") return;
|
||||||
|
expect(result.reason).toBe("digest-mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing is signed when nothing is", async () => {
|
||||||
|
const plain = new TextEncoder().encode("From: [email protected]\r\nSubject: hi\r\n\r\nJust text.\r\n");
|
||||||
|
expect((await verifyMessage(plain)).kind).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("declines OpenPGP by name, rather than as an unknown format", async () => {
|
||||||
|
const pgp = new TextEncoder().encode(
|
||||||
|
'From: [email protected]\r\nContent-Type: multipart/signed; protocol="application/pgp-signature"; boundary="b"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nhi\r\n--b\r\nContent-Type: application/pgp-signature\r\n\r\nsig\r\n--b--\r\n',
|
||||||
|
);
|
||||||
|
const result = await verifyMessage(pgp);
|
||||||
|
expect(result.kind).toBe("unsupported");
|
||||||
|
if (result.kind !== "unsupported") return;
|
||||||
|
// A code, so the sentence can be translated where it is shown.
|
||||||
|
expect(result.reason).toBe("openpgp");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the signature is allowed to mean", () => {
|
||||||
|
const ada = "[email protected]";
|
||||||
|
|
||||||
|
it("a first sighting is pinned, and says so", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const report = judge(crypto, ada, undefined);
|
||||||
|
expect(report.trust).toBe("first-seen");
|
||||||
|
expect(report.warnings).toEqual([]);
|
||||||
|
expect(shouldRemember(report)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the same certificate again is recognised", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||||
|
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||||
|
const report = judge(crypto, ada, known);
|
||||||
|
expect(report.trust).toBe("same-as-before");
|
||||||
|
// Nothing to write: it already matches what is stored.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a different certificate for a known address is the loud case", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const known: KnownSigner = { fingerprint: "0".repeat(64), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
|
||||||
|
const report = judge(crypto, ada, known);
|
||||||
|
expect(report.trust).toBe("changed");
|
||||||
|
expect(report.previous).toBe(known);
|
||||||
|
// A changed signer must never overwrite the pin -- that would launder the
|
||||||
|
// very substitution this is here to report.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notices a valid signature by a certificate for somebody else", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-wrong-address.eml"));
|
||||||
|
expect(crypto.kind).toBe("intact");
|
||||||
|
const report = judge(crypto, ada, undefined);
|
||||||
|
expect(report.warnings).toContain("address-mismatch");
|
||||||
|
// Cryptographically fine, and still not to be pinned as Ada's signer.
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an expired certificate without calling the signature broken", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
const report = judge(crypto, ada, undefined, new Date("2099-01-01T00:00:00Z"));
|
||||||
|
expect(report.crypto.kind).toBe("intact");
|
||||||
|
expect(report.warnings).toContain("certificate-expired");
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a non-verifying result straight through with no trust claim", () => {
|
||||||
|
const report = judge({ kind: "broken", reason: "signature-mismatch" }, ada, undefined);
|
||||||
|
expect(report.trust).toBeUndefined();
|
||||||
|
expect(shouldRemember(report)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("matching a certificate to an address", () => {
|
||||||
|
it("is case-insensitive, as addresses are", async () => {
|
||||||
|
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
|
||||||
|
if (crypto.kind !== "intact") throw new Error("fixture should verify");
|
||||||
|
expect(certCovers(crypto.cert, "[email protected]")).toBe(true);
|
||||||
|
expect(certCovers(crypto.cert, "[email protected]")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canonicalisation", () => {
|
||||||
|
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
|
||||||
|
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
|
||||||
|
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op on content that is already canonical", () => {
|
||||||
|
const already = new TextEncoder().encode("a\r\nb\r\n");
|
||||||
|
expect(toCanonicalCrlf(already)).toBe(already);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The reason canonicalisation is applied at all: a store that hands back a
|
||||||
|
* message with bare LFs would otherwise fail every signature it holds, and
|
||||||
|
* the message would look identical on screen while doing it.
|
||||||
|
*/
|
||||||
|
it("verifies a signed message whose line endings were flattened in storage", async () => {
|
||||||
|
const original = fixture("signed-rsa.eml");
|
||||||
|
const flattened = new TextEncoder().encode(new TextDecoder().decode(original).replace(/\r\n/g, "\n"));
|
||||||
|
expect((await verifyMessage(flattened)).kind).toBe("intact");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reading the message structure", () => {
|
||||||
|
it("finds the two parts of a signed message and keeps their bytes intact", () => {
|
||||||
|
const root = parseMime(fixture("signed-rsa.eml"));
|
||||||
|
expect(root.contentType).toBe("multipart/signed");
|
||||||
|
expect(root.parts).toHaveLength(2);
|
||||||
|
expect(root.parts[0]!.contentType).toBe("text/plain");
|
||||||
|
expect(root.parts[1]!.contentType).toBe("application/x-pkcs7-signature");
|
||||||
|
// The signed part keeps its own headers: they are inside what was signed.
|
||||||
|
expect(new TextDecoder().decode(root.parts[0]!.raw)).toMatch(/^Content-Type: text\/plain/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* CMS SignedData (RFC 5652), enough of it to check a detached S/MIME signature.
|
||||||
|
*
|
||||||
|
* The one place this is easy to get quietly wrong is what the signature covers.
|
||||||
|
* When signed attributes are present — and for S/MIME they always are, because
|
||||||
|
* the content type and message digest are required — the signature is **not**
|
||||||
|
* over the message. It is over the DER encoding of the SignedAttributes, and
|
||||||
|
* those appear in the blob tagged `[0] IMPLICIT`, which must be re-tagged to
|
||||||
|
* the universal `SET OF` before hashing. Skip that and every valid signature
|
||||||
|
* fails; hash the message instead and every signature "passes", which is very
|
||||||
|
* much worse. `signedAttrsForSigning` is that step, kept on its own so it can
|
||||||
|
* be tested on its own.
|
||||||
|
*/
|
||||||
|
import { at, children, DerError, expect, integerHex, oid, parse, TAG, time, type Asn1 } from "./der";
|
||||||
|
|
||||||
|
const OID = {
|
||||||
|
signedData: "1.2.840.113549.1.7.2",
|
||||||
|
data: "1.2.840.113549.1.7.1",
|
||||||
|
contentType: "1.2.840.113549.1.9.3",
|
||||||
|
messageDigest: "1.2.840.113549.1.9.4",
|
||||||
|
signingTime: "1.2.840.113549.1.9.5",
|
||||||
|
|
||||||
|
sha256: "2.16.840.1.101.3.4.2.1",
|
||||||
|
sha384: "2.16.840.1.101.3.4.2.2",
|
||||||
|
sha512: "2.16.840.1.101.3.4.2.3",
|
||||||
|
sha1: "1.3.14.3.2.26",
|
||||||
|
|
||||||
|
rsaEncryption: "1.2.840.113549.1.1.1",
|
||||||
|
sha256WithRsa: "1.2.840.113549.1.1.11",
|
||||||
|
sha384WithRsa: "1.2.840.113549.1.1.12",
|
||||||
|
sha512WithRsa: "1.2.840.113549.1.1.13",
|
||||||
|
rsaPss: "1.2.840.113549.1.1.10",
|
||||||
|
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
||||||
|
ecdsaWithSha384: "1.2.840.10045.4.3.3",
|
||||||
|
ecdsaWithSha512: "1.2.840.10045.4.3.4",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Digest = "SHA-256" | "SHA-384" | "SHA-512";
|
||||||
|
export type SignatureKind = "rsa-pkcs1" | "rsa-pss" | "ecdsa";
|
||||||
|
|
||||||
|
export interface SignerInfo {
|
||||||
|
digest: Digest;
|
||||||
|
signature: SignatureKind;
|
||||||
|
/** Raw signature bytes. */
|
||||||
|
value: Uint8Array;
|
||||||
|
/** DER of the SignedAttributes, already re-tagged as a SET OF, ready to hash. */
|
||||||
|
signedAttrs: Uint8Array;
|
||||||
|
/** The messageDigest signed attribute: what the content must hash to. */
|
||||||
|
messageDigest: Uint8Array;
|
||||||
|
/** Claimed signing time, if the signer included one. Not evidence of anything. */
|
||||||
|
signingTime?: Date;
|
||||||
|
/** Issuer name DER + serial, how the signer's certificate is usually named. */
|
||||||
|
issuerDer?: Uint8Array;
|
||||||
|
serial?: string;
|
||||||
|
/** subjectKeyIdentifier, used instead of issuer-and-serial by version 3 signers. */
|
||||||
|
subjectKeyId?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedData {
|
||||||
|
/** DER of each certificate carried along, in the order they appeared. */
|
||||||
|
certificates: Uint8Array[];
|
||||||
|
signers: SignerInfo[];
|
||||||
|
/** Present only for an opaque signature, where the content travels inside. */
|
||||||
|
encapsulatedContent?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a PKCS#7 / CMS blob into the parts a verifier needs. */
|
||||||
|
export function parseSignedData(der: Uint8Array): SignedData {
|
||||||
|
const info = children(expect(parse(der), TAG.sequence, "a ContentInfo"));
|
||||||
|
if (oid(at(info, 0, "a content type")) !== OID.signedData) throw new DerError("Not a CMS SignedData.");
|
||||||
|
const wrapper = at(info, 1, "the SignedData [0]");
|
||||||
|
const signedData = children(expect(children(wrapper)[0] ?? wrapper, TAG.sequence, "a SignedData"));
|
||||||
|
|
||||||
|
// SignedData ::= version, digestAlgorithms, encapContentInfo,
|
||||||
|
// [0] certificates, [1] crls, signerInfos
|
||||||
|
const encap = children(expect(at(signedData, 2, "an encapContentInfo"), TAG.sequence, "an encapContentInfo"));
|
||||||
|
const encapsulatedContent = encap[1] ? children(encap[1])[0]?.content : undefined;
|
||||||
|
|
||||||
|
const certificates: Uint8Array[] = [];
|
||||||
|
const certSet = signedData.find((n) => n.cls === 2 && n.tag === 0);
|
||||||
|
if (certSet) {
|
||||||
|
for (const c of children(certSet)) {
|
||||||
|
// Only plain certificates; the other CHOICE arms are context-tagged and
|
||||||
|
// are attribute certificates, which nothing here knows how to read.
|
||||||
|
if (c.cls === 0 && c.tag === TAG.sequence) certificates.push(c.bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signerSet = signedData[signedData.length - 1];
|
||||||
|
if (!signerSet || signerSet.tag !== TAG.set) throw new DerError("No signerInfos.");
|
||||||
|
const signers = children(signerSet).map(readSigner);
|
||||||
|
if (signers.length === 0) throw new DerError("SignedData carries no signer.");
|
||||||
|
|
||||||
|
return { certificates, signers, encapsulatedContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSigner(node: Asn1): SignerInfo {
|
||||||
|
const p = children(expect(node, TAG.sequence, "a SignerInfo"));
|
||||||
|
let i = 1; // skip version
|
||||||
|
|
||||||
|
// sid ::= issuerAndSerialNumber | [0] subjectKeyIdentifier
|
||||||
|
const sid = at(p, i++, "a signer identifier");
|
||||||
|
let issuerDer: Uint8Array | undefined;
|
||||||
|
let serial: string | undefined;
|
||||||
|
let subjectKeyId: Uint8Array | undefined;
|
||||||
|
if (sid.cls === 2 && sid.tag === 0) {
|
||||||
|
subjectKeyId = sid.content;
|
||||||
|
} else {
|
||||||
|
const pair = children(sid);
|
||||||
|
issuerDer = at(pair, 0, "an issuer name").bytes;
|
||||||
|
serial = integerHex(at(pair, 1, "a serial number"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = digestFrom(oid(at(children(at(p, i++, "a digest algorithm")), 0, "a digest algorithm id")));
|
||||||
|
|
||||||
|
// [0] IMPLICIT SignedAttributes, optional but always present for S/MIME.
|
||||||
|
const attrsNode = p[i]?.cls === 2 && p[i]?.tag === 0 ? p[i++]! : undefined;
|
||||||
|
if (!attrsNode) throw new DerError("Signature carries no signed attributes; S/MIME requires them.");
|
||||||
|
|
||||||
|
const algNode = children(at(p, i++, "a signature algorithm"));
|
||||||
|
const signature = signatureFrom(oid(at(algNode, 0, "a signature algorithm id")));
|
||||||
|
const value = expect(at(p, i++, "a signature"), TAG.octetString, "a signature").content;
|
||||||
|
|
||||||
|
const attrs = children(attrsNode);
|
||||||
|
const messageDigest = findAttr(attrs, OID.messageDigest, (v) => expect(v, TAG.octetString, "a message digest").content);
|
||||||
|
if (!messageDigest) throw new DerError("Signature has no messageDigest attribute.");
|
||||||
|
const contentType = findAttr(attrs, OID.contentType, (v) => oid(v));
|
||||||
|
if (contentType && contentType !== OID.data) throw new DerError(`Signed content type is ${contentType}, not plain data.`);
|
||||||
|
// A claimed signing time is shown, never checked: the signer chose it, so it
|
||||||
|
// is a statement rather than evidence. An unreadable one must not fail the
|
||||||
|
// signature, which is why this swallows rather than throws.
|
||||||
|
const signingTime = findAttr(attrs, OID.signingTime, (v) => {
|
||||||
|
try {
|
||||||
|
return time(v);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { digest, signature, value, signedAttrs: signedAttrsForSigning(attrsNode), messageDigest, signingTime, issuerDer, serial, subjectKeyId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bytes the signature is actually over.
|
||||||
|
*
|
||||||
|
* SignedAttributes travel as `[0] IMPLICIT`, tag 0xA0. RFC 5652 §5.4 says the
|
||||||
|
* signature is computed over their DER encoding as a `SET OF`, tag 0x31. Only
|
||||||
|
* the identifier octet changes; the length and contents are already correct,
|
||||||
|
* which is why this is a single byte and also why it is so easy to miss.
|
||||||
|
*/
|
||||||
|
export function signedAttrsForSigning(attrs: Asn1): Uint8Array {
|
||||||
|
const copy = attrs.bytes.slice();
|
||||||
|
copy[0] = 0x31;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAttr<T>(attrs: Asn1[], want: string, read: (v: Asn1) => T): T | undefined {
|
||||||
|
for (const attr of attrs) {
|
||||||
|
const kv = children(attr);
|
||||||
|
if (kv.length < 2) continue;
|
||||||
|
if (oid(at(kv, 0, "an attribute type")) !== want) continue;
|
||||||
|
const values = children(at(kv, 1, "an attribute value set"));
|
||||||
|
if (values[0]) return read(values[0]);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestFrom(o: string): Digest {
|
||||||
|
if (o === OID.sha256) return "SHA-256";
|
||||||
|
if (o === OID.sha384) return "SHA-384";
|
||||||
|
if (o === OID.sha512) return "SHA-512";
|
||||||
|
// SHA-1 is refused rather than supported. A signature nobody can forge in
|
||||||
|
// practice today is still one this should not be putting a tick beside.
|
||||||
|
if (o === OID.sha1) throw new DerError("Signed with SHA-1, which is too weak to report as verified.");
|
||||||
|
throw new DerError(`Unsupported digest algorithm ${o}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function signatureFrom(o: string): SignatureKind {
|
||||||
|
if (o === OID.rsaEncryption || o === OID.sha256WithRsa || o === OID.sha384WithRsa || o === OID.sha512WithRsa) return "rsa-pkcs1";
|
||||||
|
if (o === OID.rsaPss) return "rsa-pss";
|
||||||
|
if (o === OID.ecdsaWithSha256 || o === OID.ecdsaWithSha384 || o === OID.ecdsaWithSha512) return "ecdsa";
|
||||||
|
throw new DerError(`Unsupported signature algorithm ${o}.`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Just enough DER to read a CMS signature and an X.509 certificate.
|
||||||
|
*
|
||||||
|
* This is deliberately small. It is not a general ASN.1 library and should not
|
||||||
|
* grow into one: everything here exists because some byte of a signed message
|
||||||
|
* has to be looked at, and a parser that can read shapes nothing sends is a
|
||||||
|
* parser with corners nobody has tested.
|
||||||
|
*
|
||||||
|
* Two rules it keeps, both of which matter for verification rather than for
|
||||||
|
* tidiness:
|
||||||
|
*
|
||||||
|
* - Every node keeps `bytes`, the whole tag-length-value as it arrived. A
|
||||||
|
* signature is computed over encoded bytes, so anything that re-encodes a
|
||||||
|
* structure it means to hash has already lost. Nothing here re-encodes.
|
||||||
|
* - Indefinite lengths are refused rather than guessed at. S/MIME signatures
|
||||||
|
* are DER, which forbids them; a blob using one is either BER from an
|
||||||
|
* unusual producer or is not what it claims, and treating the two alike
|
||||||
|
* would mean inventing a parse for input this has never seen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Asn1 {
|
||||||
|
/** Tag number, without the class and constructed bits. */
|
||||||
|
tag: number;
|
||||||
|
/** 0 universal, 1 application, 2 context-specific, 3 private. */
|
||||||
|
cls: number;
|
||||||
|
constructed: boolean;
|
||||||
|
/** Content octets: the V of TLV. */
|
||||||
|
content: Uint8Array;
|
||||||
|
/** The whole TLV as it arrived, for anything that must hash or re-present it. */
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TAG = {
|
||||||
|
boolean: 0x01,
|
||||||
|
integer: 0x02,
|
||||||
|
bitString: 0x03,
|
||||||
|
octetString: 0x04,
|
||||||
|
null: 0x05,
|
||||||
|
oid: 0x06,
|
||||||
|
utf8String: 0x0c,
|
||||||
|
sequence: 0x10,
|
||||||
|
set: 0x11,
|
||||||
|
printableString: 0x13,
|
||||||
|
ia5String: 0x16,
|
||||||
|
utcTime: 0x17,
|
||||||
|
generalizedTime: 0x18,
|
||||||
|
bmpString: 0x1e,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class DerError extends Error {}
|
||||||
|
|
||||||
|
/** Read one TLV at `offset`. Returns the node and where the next one starts. */
|
||||||
|
export function readNode(buf: Uint8Array, offset = 0): { node: Asn1; next: number } {
|
||||||
|
if (offset + 2 > buf.length) throw new DerError("Truncated: no room for a tag and a length.");
|
||||||
|
const id = buf[offset]!;
|
||||||
|
const cls = id >> 6;
|
||||||
|
const constructed = (id & 0x20) !== 0;
|
||||||
|
let tag = id & 0x1f;
|
||||||
|
let i = offset + 1;
|
||||||
|
|
||||||
|
// High-tag-number form: 0b11111 says the number continues in the following
|
||||||
|
// octets, seven bits at a time. Rare, but a context tag above 30 is legal.
|
||||||
|
if (tag === 0x1f) {
|
||||||
|
tag = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (i >= buf.length) throw new DerError("Truncated inside a multi-byte tag.");
|
||||||
|
const b = buf[i++]!;
|
||||||
|
tag = (tag << 7) | (b & 0x7f);
|
||||||
|
if ((b & 0x80) === 0) break;
|
||||||
|
if (tag > 0xffffff) throw new DerError("Unreasonable tag number.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i >= buf.length) throw new DerError("Truncated: no length octet.");
|
||||||
|
const first = buf[i++]!;
|
||||||
|
let length: number;
|
||||||
|
if (first < 0x80) {
|
||||||
|
length = first;
|
||||||
|
} else if (first === 0x80) {
|
||||||
|
throw new DerError("Indefinite length: this is BER, and a signature must be DER.");
|
||||||
|
} else {
|
||||||
|
const n = first & 0x7f;
|
||||||
|
if (n > 4) throw new DerError("Length field too large to be real.");
|
||||||
|
if (i + n > buf.length) throw new DerError("Truncated inside a length field.");
|
||||||
|
length = 0;
|
||||||
|
for (let k = 0; k < n; k++) length = length * 256 + buf[i++]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = i + length;
|
||||||
|
if (end > buf.length) throw new DerError(`Truncated: a node claims ${length} bytes and only ${buf.length - i} remain.`);
|
||||||
|
return {
|
||||||
|
node: { tag, cls, constructed, content: buf.subarray(i, end), bytes: buf.subarray(offset, end) },
|
||||||
|
next: end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single top-level node, refusing trailing rubbish. */
|
||||||
|
export function parse(buf: Uint8Array): Asn1 {
|
||||||
|
const { node, next } = readNode(buf, 0);
|
||||||
|
if (next !== buf.length) throw new DerError(`${buf.length - next} trailing byte(s) after the top-level value.`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The immediate children of a constructed node. */
|
||||||
|
export function children(node: Asn1): Asn1[] {
|
||||||
|
if (!node.constructed) throw new DerError("Asked for the children of a primitive value.");
|
||||||
|
const out: Asn1[] = [];
|
||||||
|
let at = 0;
|
||||||
|
while (at < node.content.length) {
|
||||||
|
const { node: child, next } = readNode(node.content, at);
|
||||||
|
out.push(child);
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A child by position, checked, because "undefined" is a poor error message. */
|
||||||
|
export function at(nodes: Asn1[], index: number, what: string): Asn1 {
|
||||||
|
const n = nodes[index];
|
||||||
|
if (!n) throw new DerError(`Missing ${what}.`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expect(node: Asn1, tag: number, what: string): Asn1 {
|
||||||
|
if (node.cls !== 0 || node.tag !== tag) throw new DerError(`Expected ${what} (universal tag ${tag}), found class ${node.cls} tag ${node.tag}.`);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A context-specific child, e.g. [0] — returns undefined when absent. */
|
||||||
|
export function contextChild(nodes: Asn1[], tag: number): Asn1 | undefined {
|
||||||
|
return nodes.find((n) => n.cls === 2 && n.tag === tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dotted OID, decoded from the packed base-128 form. */
|
||||||
|
export function oid(node: Asn1): string {
|
||||||
|
expect(node, TAG.oid, "an object identifier");
|
||||||
|
const c = node.content;
|
||||||
|
if (c.length === 0) throw new DerError("Empty object identifier.");
|
||||||
|
// The first octet packs two arcs: 40*first + second.
|
||||||
|
const parts = [Math.floor(c[0]! / 40), c[0]! % 40];
|
||||||
|
let value = 0;
|
||||||
|
for (let i = 1; i < c.length; i++) {
|
||||||
|
const b = c[i]!;
|
||||||
|
value = value * 128 + (b & 0x7f);
|
||||||
|
if ((b & 0x80) === 0) {
|
||||||
|
parts.push(value);
|
||||||
|
value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contents of a BIT STRING, refusing the padded case nothing here should meet. */
|
||||||
|
export function bitString(node: Asn1): Uint8Array {
|
||||||
|
expect(node, TAG.bitString, "a bit string");
|
||||||
|
if (node.content.length === 0) throw new DerError("Empty bit string.");
|
||||||
|
const unused = node.content[0]!;
|
||||||
|
if (unused !== 0) throw new DerError(`Bit string with ${unused} unused bits; expected a whole number of bytes.`);
|
||||||
|
return node.content.subarray(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An INTEGER as a hex string, since serial numbers overflow a JS number. */
|
||||||
|
export function integerHex(node: Asn1): string {
|
||||||
|
expect(node, TAG.integer, "an integer");
|
||||||
|
let hex = "";
|
||||||
|
for (const b of node.content) hex += b.toString(16).padStart(2, "0");
|
||||||
|
return hex.replace(/^(00)+(?=.)/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small INTEGER, for versions and the like. */
|
||||||
|
export function integer(node: Asn1): number {
|
||||||
|
expect(node, TAG.integer, "an integer");
|
||||||
|
if (node.content.length > 4) throw new DerError("Integer larger than this reads.");
|
||||||
|
let v = 0;
|
||||||
|
for (const b of node.content) v = v * 256 + b;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTCTime or GeneralizedTime.
|
||||||
|
*
|
||||||
|
* UTCTime carries a two-digit year, and RFC 5280 pins the window: 50-99 mean
|
||||||
|
* 1950-1999 and 00-49 mean 2000-2049. Guessing "20" + yy instead works until
|
||||||
|
* 2050 and then silently dates certificates a century early, which is the kind
|
||||||
|
* of bug that is written once and found by somebody else.
|
||||||
|
*/
|
||||||
|
export function time(node: Asn1): Date {
|
||||||
|
const s = new TextDecoder().decode(node.content);
|
||||||
|
let iso: string;
|
||||||
|
if (node.tag === TAG.utcTime) {
|
||||||
|
const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?Z$/.exec(s);
|
||||||
|
if (!m) throw new DerError(`Unreadable UTCTime "${s}".`);
|
||||||
|
const yy = Number(m[1]);
|
||||||
|
const year = yy >= 50 ? 1900 + yy : 2000 + yy;
|
||||||
|
iso = `${year}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
|
||||||
|
} else if (node.tag === TAG.generalizedTime) {
|
||||||
|
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})?(?:\.\d+)?Z$/.exec(s);
|
||||||
|
if (!m) throw new DerError(`Unreadable GeneralizedTime "${s}".`);
|
||||||
|
iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6] ?? "00"}Z`;
|
||||||
|
} else {
|
||||||
|
throw new DerError(`Expected a time, found tag ${node.tag}.`);
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) throw new DerError(`Unreadable time "${s}".`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text from any of the string types a name or an address turns up in. */
|
||||||
|
export function text(node: Asn1): string {
|
||||||
|
if (node.tag === TAG.bmpString) {
|
||||||
|
// UTF-16BE. Rare, but Windows-issued certificates do use it for names.
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i + 1 < node.content.length; i += 2) s += String.fromCharCode((node.content[i]! << 8) | node.content[i + 1]!);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return new TextDecoder().decode(node.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase hex of some bytes, for fingerprints. */
|
||||||
|
export function hex(bytes: Uint8Array): string {
|
||||||
|
let s = "";
|
||||||
|
for (const b of bytes) s += b.toString(16).padStart(2, "0");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Enough MIME to find a signed part and hand back the exact bytes it covers.
|
||||||
|
*
|
||||||
|
* This works on bytes, not on a string, and that is the whole point. A
|
||||||
|
* signature is over an octet sequence: decode it to text, re-encode it, or let
|
||||||
|
* anything normalise a line ending on the way past, and the digest changes
|
||||||
|
* while the message still looks identical on screen. Every part here keeps a
|
||||||
|
* subarray of the original buffer rather than a rebuilt copy.
|
||||||
|
*
|
||||||
|
* The one transformation that *is* applied is a lone LF becoming CRLF, and it
|
||||||
|
* is applied only to the signed part. RFC 1847 requires the protected content
|
||||||
|
* to be in canonical MIME form, which means CRLF; a store that hands back a
|
||||||
|
* message with bare LFs — and they do — would otherwise fail every signature it
|
||||||
|
* has ever held, for a reason nobody could see by looking at the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MimePart {
|
||||||
|
/** Lowercased header name to raw value, first occurrence winning. */
|
||||||
|
headers: Map<string, string>;
|
||||||
|
/** Lowercased `type/subtype`, or "text/plain" when unstated. */
|
||||||
|
contentType: string;
|
||||||
|
/** Lowercased content-type parameters. */
|
||||||
|
params: Record<string, string>;
|
||||||
|
/** The body, exactly as it appeared. */
|
||||||
|
body: Uint8Array;
|
||||||
|
/** Headers and body together, exactly as they appeared. */
|
||||||
|
raw: Uint8Array;
|
||||||
|
parts: MimePart[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CR = 13;
|
||||||
|
const LF = 10;
|
||||||
|
|
||||||
|
function indexOfSeq(hay: Uint8Array, needle: number[], from = 0): number {
|
||||||
|
outer: for (let i = from; i + needle.length <= hay.length; i++) {
|
||||||
|
for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the headers stop: the first blank line, in either line ending. */
|
||||||
|
function headerEnd(buf: Uint8Array): { bodyAt: number; headersEnd: number } {
|
||||||
|
const crlf = indexOfSeq(buf, [CR, LF, CR, LF]);
|
||||||
|
const lf = indexOfSeq(buf, [LF, LF]);
|
||||||
|
if (crlf >= 0 && (lf < 0 || crlf <= lf)) return { headersEnd: crlf, bodyAt: crlf + 4 };
|
||||||
|
if (lf >= 0) return { headersEnd: lf, bodyAt: lf + 2 };
|
||||||
|
return { headersEnd: buf.length, bodyAt: buf.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaders(block: string): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
// Unfold first: a continuation line begins with space or tab and belongs to
|
||||||
|
// the header above it. Folding a long boundary parameter is ordinary, so a
|
||||||
|
// parser that reads line by line loses boundaries on real messages.
|
||||||
|
const unfolded = block.replace(/\r?\n[ \t]+/g, " ");
|
||||||
|
for (const line of unfolded.split(/\r?\n/)) {
|
||||||
|
const c = line.indexOf(":");
|
||||||
|
if (c <= 0) continue;
|
||||||
|
const name = line.slice(0, c).trim().toLowerCase();
|
||||||
|
if (!out.has(name)) out.set(name, line.slice(c + 1).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split `text/plain; charset="utf-8"` into its type and its parameters. */
|
||||||
|
export function parseContentType(value: string | undefined): { type: string; params: Record<string, string> } {
|
||||||
|
if (!value) return { type: "text/plain", params: {} };
|
||||||
|
const [head, ...rest] = value.split(";");
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const p of rest) {
|
||||||
|
const eq = p.indexOf("=");
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const k = p.slice(0, eq).trim().toLowerCase();
|
||||||
|
let v = p.slice(eq + 1).trim();
|
||||||
|
if (v.startsWith('"')) v = v.slice(1, v.lastIndexOf('"') > 0 ? v.lastIndexOf('"') : undefined);
|
||||||
|
params[k] = v;
|
||||||
|
}
|
||||||
|
return { type: (head ?? "").trim().toLowerCase() || "text/plain", params };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a message, or a part of one, into a tree. */
|
||||||
|
export function parseMime(raw: Uint8Array): MimePart {
|
||||||
|
const { bodyAt, headersEnd } = headerEnd(raw);
|
||||||
|
const headers = parseHeaders(new TextDecoder("utf-8", { fatal: false }).decode(raw.subarray(0, headersEnd)));
|
||||||
|
const { type, params } = parseContentType(headers.get("content-type"));
|
||||||
|
const body = raw.subarray(bodyAt);
|
||||||
|
const part: MimePart = { headers, contentType: type, params, body, raw, parts: [] };
|
||||||
|
|
||||||
|
if (type.startsWith("multipart/") && params.boundary) part.parts = splitMultipart(body, params.boundary);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a multipart body on its boundary.
|
||||||
|
*
|
||||||
|
* The subtle bit is what belongs to a part and what belongs to the delimiter.
|
||||||
|
* RFC 2046 puts the CRLF *before* a boundary line into the delimiter, not into
|
||||||
|
* the part above it. Keeping that CRLF appends two bytes to the signed content
|
||||||
|
* and fails every signature; dropping one too many does the same. So each part
|
||||||
|
* ends at the byte before the CRLF that introduces the next boundary.
|
||||||
|
*/
|
||||||
|
function splitMultipart(body: Uint8Array, boundary: string): MimePart[] {
|
||||||
|
const marker = [...`--${boundary}`].map((c) => c.charCodeAt(0));
|
||||||
|
const offsets: number[] = [];
|
||||||
|
for (let i = 0; i >= 0 && i < body.length; ) {
|
||||||
|
const found = indexOfSeq(body, marker, i);
|
||||||
|
if (found < 0) break;
|
||||||
|
// Only at the start of a line.
|
||||||
|
if (found === 0 || body[found - 1] === LF) offsets.push(found);
|
||||||
|
i = found + marker.length;
|
||||||
|
}
|
||||||
|
if (offsets.length < 2) return [];
|
||||||
|
|
||||||
|
const parts: MimePart[] = [];
|
||||||
|
for (let k = 0; k < offsets.length - 1; k++) {
|
||||||
|
const delimiter = offsets[k]!;
|
||||||
|
// Step over the boundary line itself to reach the part's first header byte.
|
||||||
|
let start = delimiter + marker.length;
|
||||||
|
while (start < body.length && body[start] !== LF) start++;
|
||||||
|
start++;
|
||||||
|
// The part ends before the CRLF that belongs to the *next* delimiter.
|
||||||
|
let end = offsets[k + 1]!;
|
||||||
|
if (end > 0 && body[end - 1] === LF) end--;
|
||||||
|
if (end > 0 && body[end - 1] === CR) end--;
|
||||||
|
if (start < end) parts.push(parseMime(body.subarray(start, end)));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-first search for the first part matching a predicate. */
|
||||||
|
export function findPart(part: MimePart, want: (p: MimePart) => boolean): MimePart | undefined {
|
||||||
|
if (want(part)) return part;
|
||||||
|
for (const child of part.parts) {
|
||||||
|
const hit = findPart(child, want);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical CRLF form, applied only to content about to be hashed.
|
||||||
|
*
|
||||||
|
* A lone LF becomes CRLF; an existing CRLF is left alone. Nothing else is
|
||||||
|
* touched -- no trailing-whitespace tidying, no re-wrapping -- because every
|
||||||
|
* other "helpful" change is one the signer did not make.
|
||||||
|
*/
|
||||||
|
export function toCanonicalCrlf(bytes: Uint8Array): Uint8Array {
|
||||||
|
let lone = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) lone++;
|
||||||
|
if (lone === 0) return bytes;
|
||||||
|
const out = new Uint8Array(bytes.length + lone);
|
||||||
|
let j = 0;
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (bytes[i] === LF && (i === 0 || bytes[i - 1] !== CR)) out[j++] = CR;
|
||||||
|
out[j++] = bytes[i]!;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undo base64 or quoted-printable so a signature blob can be read as DER. */
|
||||||
|
export function decodeTransfer(part: MimePart): Uint8Array {
|
||||||
|
const encoding = (part.headers.get("content-transfer-encoding") ?? "").trim().toLowerCase();
|
||||||
|
if (encoding === "base64") {
|
||||||
|
const text = new TextDecoder().decode(part.body).replace(/[^A-Za-z0-9+/=]/g, "");
|
||||||
|
const binary = atob(text);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (encoding === "quoted-printable") {
|
||||||
|
const text = new TextDecoder().decode(part.body).replace(/=\r?\n/g, "");
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === "=" && i + 2 < text.length) {
|
||||||
|
out.push(parseInt(text.slice(i + 1, i + 3), 16));
|
||||||
|
i += 2;
|
||||||
|
} else out.push(text.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return new Uint8Array(out);
|
||||||
|
}
|
||||||
|
return part.body;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Checking the signature on the message being read.
|
||||||
|
*
|
||||||
|
* Two things this is careful about, both about not doing work:
|
||||||
|
*
|
||||||
|
* - The verifier is imported dynamically. Signed mail is rare, and DER
|
||||||
|
* parsing plus certificate reading has no business in the bundle everybody
|
||||||
|
* downloads to read an unsigned message.
|
||||||
|
* - Nothing is fetched unless the message says it is signed. The structure
|
||||||
|
* already came with the message, so the common answer costs one string
|
||||||
|
* comparison and no network at all.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { client } from "@/jmap/client";
|
||||||
|
import type { Email, EmailBodyPart, Id } from "@/jmap/types";
|
||||||
|
import { useSettings, type SignerPin } from "@/store/settings";
|
||||||
|
import type { SignatureReport } from "./verify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many signers are remembered before the oldest pin is dropped.
|
||||||
|
*
|
||||||
|
* A cap is needed because this rides in the account's settings file, which is
|
||||||
|
* fetched on every sign-in. Evicting is not free — a dropped signer is greeted
|
||||||
|
* as new next time, which is a quieter message than it should be — so the limit
|
||||||
|
* is set far above what S/MIME's actual prevalence will produce rather than at
|
||||||
|
* a number that trades safety for bytes.
|
||||||
|
*/
|
||||||
|
const MAX_PINS = 500;
|
||||||
|
|
||||||
|
export type SignatureState = { status: "idle" } | { status: "checking" } | { status: "done"; report: SignatureReport };
|
||||||
|
|
||||||
|
/** Whether anything in this message's structure claims to be signed. */
|
||||||
|
export function structureLooksSigned(part: EmailBodyPart | undefined): boolean {
|
||||||
|
if (!part) return false;
|
||||||
|
if (part.type === "multipart/signed") return true;
|
||||||
|
return (part.subParts ?? []).some(structureLooksSigned);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSignature(email: Email | undefined, accountId: Id | null): SignatureState {
|
||||||
|
const [state, setState] = useState<SignatureState>({ status: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!email || !accountId || !structureLooksSigned(email.bodyStructure)) {
|
||||||
|
setState({ status: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let live = true;
|
||||||
|
setState({ status: "checking" });
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const [{ judge, shouldRemember, verifyMessage }, blob] = await Promise.all([
|
||||||
|
import("./verify"),
|
||||||
|
client.fetchBlob(accountId, email.blobId, "message/rfc822"),
|
||||||
|
]);
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
const raw = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
const from = (email.from?.[0]?.email ?? "").toLowerCase();
|
||||||
|
const crypto = await verifyMessage(raw);
|
||||||
|
const stored = useSettings.getState().settings.knownSigners[from];
|
||||||
|
// A pin this very message created is not corroboration of it. Treated
|
||||||
|
// as absent, so the message that established a signer keeps saying so
|
||||||
|
// however many times it is reopened.
|
||||||
|
const known = stored && stored.messageId === email.id ? undefined : stored;
|
||||||
|
const report = judge(crypto, from, known);
|
||||||
|
if (!live) return;
|
||||||
|
|
||||||
|
if (from && shouldRemember(report) && report.crypto.kind === "intact") {
|
||||||
|
pin(from, {
|
||||||
|
fingerprint: report.crypto.cert.fingerprint,
|
||||||
|
name: report.crypto.cert.subject.commonName || report.crypto.cert.subject.emailAddress || from,
|
||||||
|
firstSeen: new Date().toISOString(),
|
||||||
|
messageId: email.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setState({ status: "done", report });
|
||||||
|
} catch (err) {
|
||||||
|
if (!live) return;
|
||||||
|
// A failure to *look* is not a failure to verify, and must not be shown
|
||||||
|
// as one: a dropped connection is not a bad signature.
|
||||||
|
setState({ status: "done", report: { crypto: { kind: "unsupported", reason: "other", detail: (err as Error).message }, warnings: [] } });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [email, accountId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pin(address: string, entry: SignerPin): void {
|
||||||
|
const { settings, update } = useSettings.getState();
|
||||||
|
const next = { ...settings.knownSigners, [address]: entry };
|
||||||
|
const keys = Object.keys(next);
|
||||||
|
if (keys.length > MAX_PINS) {
|
||||||
|
const oldest = keys.sort((a, b) => (next[a]!.firstSeen < next[b]!.firstSeen ? -1 : 1)).slice(0, keys.length - MAX_PINS);
|
||||||
|
for (const k of oldest) delete next[k];
|
||||||
|
}
|
||||||
|
update({ knownSigners: next });
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Checking an S/MIME signature, and deciding what may honestly be said about it.
|
||||||
|
*
|
||||||
|
* Two questions are kept deliberately apart, because conflating them is how
|
||||||
|
* signature UI becomes a lie:
|
||||||
|
*
|
||||||
|
* 1. **Did this signature verify?** Pure arithmetic. Either the bytes hash to
|
||||||
|
* what the signature says they hash to, or they do not.
|
||||||
|
* 2. **Does that mean anything?** Much weaker. The certificate travels inside
|
||||||
|
* the message, so anyone can self-sign as anyone: on its own, a verified
|
||||||
|
* signature proves only that whoever wrote the message also held the key
|
||||||
|
* in the certificate attached to it.
|
||||||
|
*
|
||||||
|
* What makes the second question worth asking at all is remembering the answer.
|
||||||
|
* The first signed message from an address pins that certificate's fingerprint;
|
||||||
|
* later ones are compared against it. That is trust on first use, and it is a
|
||||||
|
* genuinely useful thing to tell somebody -- "the same signer as every time
|
||||||
|
* before", or, much more loudly, "this is not the signer you saw before" --
|
||||||
|
* without a certificate authority anywhere in the picture.
|
||||||
|
*
|
||||||
|
* So nothing here ever renders the bare word "verified". The caller is given
|
||||||
|
* the crypto result and the trust judgement separately, and has to say both.
|
||||||
|
*/
|
||||||
|
import { parseSignedData, type SignerInfo } from "./cms";
|
||||||
|
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
|
||||||
|
import { certCovers, parseCertificate, type Certificate } from "./x509";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a signature could not be checked, or did not hold.
|
||||||
|
*
|
||||||
|
* A code rather than a sentence, because the sentence has to be translated and
|
||||||
|
* this file is deliberately free of anything to do with the interface. Only
|
||||||
|
* `other` carries prose, and that prose is a parser's complaint about a
|
||||||
|
* malformed structure -- technical by nature, and shown as detail beside a
|
||||||
|
* translated headline rather than as the headline itself.
|
||||||
|
*/
|
||||||
|
export type Reason =
|
||||||
|
| "openpgp"
|
||||||
|
| "rsa-pss"
|
||||||
|
| "no-certificate"
|
||||||
|
| "not-signed-properly"
|
||||||
|
| "digest-mismatch"
|
||||||
|
| "signature-mismatch"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
/** What the signature itself established, before any question of trust. */
|
||||||
|
export type Crypto =
|
||||||
|
| { kind: "none" }
|
||||||
|
| { kind: "unsupported"; reason: Reason; detail?: string }
|
||||||
|
| { kind: "broken"; reason: Reason; detail?: string }
|
||||||
|
| { kind: "intact"; cert: Certificate; signer: SignerInfo };
|
||||||
|
|
||||||
|
/** What remembering previous signers adds to it. */
|
||||||
|
export type Trust = "first-seen" | "same-as-before" | "changed";
|
||||||
|
|
||||||
|
export type Warning = "address-mismatch" | "certificate-expired" | "certificate-not-yet-valid";
|
||||||
|
|
||||||
|
export interface KnownSigner {
|
||||||
|
fingerprint: string;
|
||||||
|
/** Who the certificate said it was, kept so a change can be described. */
|
||||||
|
name: string;
|
||||||
|
/** ISO date this fingerprint was first pinned. */
|
||||||
|
firstSeen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignatureReport {
|
||||||
|
crypto: Crypto;
|
||||||
|
trust?: Trust;
|
||||||
|
previous?: KnownSigner;
|
||||||
|
warnings: Warning[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PKCS7_SIGNATURE = new Set(["application/pkcs7-signature", "application/x-pkcs7-signature"]);
|
||||||
|
|
||||||
|
/** Whether a raw message even claims to be signed — cheap, for deciding to look further. */
|
||||||
|
export function looksSigned(root: MimePart): boolean {
|
||||||
|
return Boolean(findPart(root, (p) => p.contentType === "multipart/signed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the signature on a raw RFC822 message.
|
||||||
|
*
|
||||||
|
* Answers only the arithmetic question. Whether the certificate has anything to
|
||||||
|
* do with the sender is `judge`'s business, and keeping the two apart is what
|
||||||
|
* lets the interesting cases be tested without staging a message for each.
|
||||||
|
*/
|
||||||
|
export async function verifyMessage(raw: Uint8Array): Promise<Crypto> {
|
||||||
|
let root: MimePart;
|
||||||
|
try {
|
||||||
|
root = parseMime(raw);
|
||||||
|
} catch (err) {
|
||||||
|
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedPart = findPart(root, (p) => p.contentType === "multipart/signed");
|
||||||
|
if (!signedPart) return { kind: "none" };
|
||||||
|
if (signedPart.parts.length < 2) return { kind: "unsupported", reason: "not-signed-properly" };
|
||||||
|
|
||||||
|
const [content, signature] = signedPart.parts as [MimePart, MimePart];
|
||||||
|
if (!PKCS7_SIGNATURE.has(signature.contentType)) {
|
||||||
|
// OpenPGP lands here, and says so rather than pretending not to understand.
|
||||||
|
if (signature.contentType === "application/pgp-signature") {
|
||||||
|
return { kind: "unsupported", reason: "openpgp" };
|
||||||
|
}
|
||||||
|
return { kind: "unsupported", reason: "other", detail: signature.contentType };
|
||||||
|
}
|
||||||
|
|
||||||
|
let signed;
|
||||||
|
try {
|
||||||
|
signed = parseSignedData(decodeTransfer(signature));
|
||||||
|
} catch (err) {
|
||||||
|
return { kind: "unsupported", reason: "other", detail: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = signed.signers[0]!;
|
||||||
|
if (signer.signature === "rsa-pss") {
|
||||||
|
// Refused rather than attempted. The salt length lives in parameters this
|
||||||
|
// does not read, and guessing it wrong fails a good signature -- which
|
||||||
|
// would be reported as "does not verify", a far worse thing to say than
|
||||||
|
// "cannot check".
|
||||||
|
return { kind: "unsupported", reason: "rsa-pss" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signature covers the first part exactly as it arrived, headers and all,
|
||||||
|
// in canonical CRLF form.
|
||||||
|
const covered = toCanonicalCrlf(content.raw);
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest(signer.digest, covered.slice().buffer as ArrayBuffer));
|
||||||
|
if (!sameBytes(digest, signer.messageDigest)) {
|
||||||
|
return { kind: "broken", reason: "digest-mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const certs = await Promise.all(
|
||||||
|
signed.certificates.map(async (der) => {
|
||||||
|
try {
|
||||||
|
return await parseCertificate(der);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const usable = certs.filter((c): c is Certificate => c !== null);
|
||||||
|
if (usable.length === 0) return { kind: "unsupported", reason: "no-certificate" };
|
||||||
|
|
||||||
|
// Prefer the certificate the signer names, but fall back to trying each in
|
||||||
|
// turn: what settles it is which key the signature verifies under, and that
|
||||||
|
// is a stronger test than matching an issuer string.
|
||||||
|
const named = usable.find((c) => signer.issuerDer && sameBytes(c.issuerDer, signer.issuerDer) && c.serial === signer.serial);
|
||||||
|
for (const cert of named ? [named, ...usable.filter((c) => c !== named)] : usable) {
|
||||||
|
if (await signatureHolds(cert, signer)) return { kind: "intact", cert, signer };
|
||||||
|
}
|
||||||
|
return { kind: "broken", reason: "signature-mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signatureHolds(cert: Certificate, signer: SignerInfo): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const spki = cert.spki.slice().buffer as ArrayBuffer;
|
||||||
|
const data = signer.signedAttrs.slice().buffer as ArrayBuffer;
|
||||||
|
if (cert.publicKey.kind === "rsa") {
|
||||||
|
const key = await crypto.subtle.importKey("spki", spki, { name: "RSASSA-PKCS1-v1_5", hash: signer.digest }, false, ["verify"]);
|
||||||
|
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, signer.value.slice().buffer as ArrayBuffer, data);
|
||||||
|
}
|
||||||
|
const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: cert.publicKey.namedCurve }, false, ["verify"]);
|
||||||
|
const raw = ecdsaDerToRaw(signer.value, cert.publicKey.namedCurve);
|
||||||
|
if (!raw) return false;
|
||||||
|
return await crypto.subtle.verify({ name: "ECDSA", hash: signer.digest }, key, raw.slice().buffer as ArrayBuffer, data);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ECDSA signatures arrive as a DER SEQUENCE of two INTEGERs; WebCrypto wants
|
||||||
|
* r and s as fixed-width bytes, concatenated. Getting the width from the curve
|
||||||
|
* rather than from the integers matters: a leading zero byte is stripped in
|
||||||
|
* DER, so r and s are frequently different lengths and neither is the answer.
|
||||||
|
*/
|
||||||
|
export function ecdsaDerToRaw(der: Uint8Array, curve: "P-256" | "P-384" | "P-521"): Uint8Array | null {
|
||||||
|
const size = curve === "P-256" ? 32 : curve === "P-384" ? 48 : 66;
|
||||||
|
try {
|
||||||
|
if (der[0] !== 0x30) return null;
|
||||||
|
let i = 2;
|
||||||
|
if (der[1]! > 0x80) i = 2 + (der[1]! & 0x7f);
|
||||||
|
const out = new Uint8Array(size * 2);
|
||||||
|
for (const slot of [0, 1]) {
|
||||||
|
if (der[i] !== 0x02) return null;
|
||||||
|
const len = der[i + 1]!;
|
||||||
|
let start = i + 2;
|
||||||
|
let n = len;
|
||||||
|
while (n > 0 && der[start] === 0x00) {
|
||||||
|
start++;
|
||||||
|
n--;
|
||||||
|
}
|
||||||
|
if (n > size) return null;
|
||||||
|
out.set(der.subarray(start, start + n), slot * size + (size - n));
|
||||||
|
i = i + 2 + len;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a crypto result plus what we remember into something sayable.
|
||||||
|
*
|
||||||
|
* Pure, and separate from both the network and the store, so the interesting
|
||||||
|
* cases -- a changed signer, a certificate for the wrong address -- are
|
||||||
|
* ordinary function calls to test rather than scenarios to stage.
|
||||||
|
*/
|
||||||
|
export function judge(crypto: Crypto, fromAddress: string, known: KnownSigner | undefined, now = new Date()): SignatureReport {
|
||||||
|
if (crypto.kind !== "intact") return { crypto, warnings: [] };
|
||||||
|
|
||||||
|
const warnings: Warning[] = [];
|
||||||
|
if (!certCovers(crypto.cert, fromAddress)) warnings.push("address-mismatch");
|
||||||
|
if (crypto.cert.notAfter < now) warnings.push("certificate-expired");
|
||||||
|
if (crypto.cert.notBefore > now) warnings.push("certificate-not-yet-valid");
|
||||||
|
|
||||||
|
const trust: Trust = !known ? "first-seen" : known.fingerprint === crypto.cert.fingerprint ? "same-as-before" : "changed";
|
||||||
|
return { crypto, trust, previous: trust === "changed" ? known : undefined, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this result should be pinned as the signer for an address.
|
||||||
|
*
|
||||||
|
* Only a clean first sighting is remembered. Pinning a certificate that does
|
||||||
|
* not name the sender, or one already expired, would write the anomaly into the
|
||||||
|
* baseline and make every later message agree with it.
|
||||||
|
*/
|
||||||
|
export function shouldRemember(report: SignatureReport): boolean {
|
||||||
|
return report.crypto.kind === "intact" && report.trust === "first-seen" && report.warnings.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* The parts of an X.509 certificate a signed message needs, and no more.
|
||||||
|
*
|
||||||
|
* Not a validator. Nothing here decides whether a certificate is trustworthy —
|
||||||
|
* it reads what the certificate says about itself, and what it says is only
|
||||||
|
* ever as good as whoever issued it. The trust decision lives one level up, in
|
||||||
|
* `verify.ts`, and is deliberately a small and honest one.
|
||||||
|
*/
|
||||||
|
import { at, children, DerError, expect, hex, integerHex, oid, parse, TAG, text, time, type Asn1 } from "./der";
|
||||||
|
|
||||||
|
/** Relative distinguished-name attributes worth naming. */
|
||||||
|
const OID = {
|
||||||
|
commonName: "2.5.4.3",
|
||||||
|
emailAddress: "1.2.840.113549.1.9.1",
|
||||||
|
organization: "2.5.4.10",
|
||||||
|
subjectAltName: "2.5.29.17",
|
||||||
|
rsaEncryption: "1.2.840.113549.1.1.1",
|
||||||
|
ecPublicKey: "1.2.840.10045.2.1",
|
||||||
|
curveP256: "1.2.840.10045.3.1.7",
|
||||||
|
curveP384: "1.3.132.0.34",
|
||||||
|
curveP521: "1.3.132.0.35",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
/** SHA-256 over the whole DER, lowercase hex. What TOFU remembers. */
|
||||||
|
fingerprint: string;
|
||||||
|
serial: string;
|
||||||
|
subject: { commonName?: string; organization?: string; emailAddress?: string };
|
||||||
|
issuer: { commonName?: string; organization?: string };
|
||||||
|
/** rfc822Name entries from the subjectAltName extension, plus the subject's emailAddress. */
|
||||||
|
emails: string[];
|
||||||
|
notBefore: Date;
|
||||||
|
notAfter: Date;
|
||||||
|
/** SubjectPublicKeyInfo, DER, ready for crypto.subtle.importKey("spki", …). */
|
||||||
|
spki: Uint8Array;
|
||||||
|
publicKey: { kind: "rsa" } | { kind: "ec"; namedCurve: "P-256" | "P-384" | "P-521" };
|
||||||
|
/** Whole DER, kept so a signer can be matched and a fingerprint recomputed. */
|
||||||
|
der: Uint8Array;
|
||||||
|
/** Issuer name and serial, the pair a SignerInfo usually identifies a certificate by. */
|
||||||
|
issuerDer: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one certificate from its DER encoding. */
|
||||||
|
export async function parseCertificate(der: Uint8Array): Promise<Certificate> {
|
||||||
|
const cert = parse(der);
|
||||||
|
const top = children(expect(cert, TAG.sequence, "a Certificate"));
|
||||||
|
const tbs = children(expect(at(top, 0, "tbsCertificate"), TAG.sequence, "a tbsCertificate"));
|
||||||
|
|
||||||
|
// tbsCertificate ::= [0] version, serial, signature, issuer, validity,
|
||||||
|
// subject, subjectPublicKeyInfo, … — version is optional and explicit, so
|
||||||
|
// everything after it shifts by one when it is absent.
|
||||||
|
let i = 0;
|
||||||
|
if (tbs[0]?.cls === 2 && tbs[0].tag === 0) i = 1;
|
||||||
|
|
||||||
|
const serial = integerHex(at(tbs, i++, "serialNumber"));
|
||||||
|
i++; // signature AlgorithmIdentifier: the outer one, not used here
|
||||||
|
const issuerNode = at(tbs, i++, "issuer");
|
||||||
|
const validity = children(expect(at(tbs, i++, "validity"), TAG.sequence, "a validity"));
|
||||||
|
const subjectNode = at(tbs, i++, "subject");
|
||||||
|
const spkiNode = at(tbs, i++, "subjectPublicKeyInfo");
|
||||||
|
|
||||||
|
const notBefore = time(at(validity, 0, "notBefore"));
|
||||||
|
const notAfter = time(at(validity, 1, "notAfter"));
|
||||||
|
|
||||||
|
const subject = readName(subjectNode);
|
||||||
|
const issuer = readName(issuerNode);
|
||||||
|
|
||||||
|
const emails = new Set<string>();
|
||||||
|
if (subject.emailAddress) emails.add(subject.emailAddress.toLowerCase());
|
||||||
|
for (const e of subjectAltEmails(tbs.slice(i))) emails.add(e.toLowerCase());
|
||||||
|
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", der.slice().buffer as ArrayBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fingerprint: hex(new Uint8Array(digest)),
|
||||||
|
serial,
|
||||||
|
subject,
|
||||||
|
issuer: { commonName: issuer.commonName, organization: issuer.organization },
|
||||||
|
emails: [...emails],
|
||||||
|
notBefore,
|
||||||
|
notAfter,
|
||||||
|
spki: spkiNode.bytes,
|
||||||
|
publicKey: readKeyKind(spkiNode),
|
||||||
|
der,
|
||||||
|
issuerDer: issuerNode.bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Name is a sequence of RDN sets; the last occurrence of an attribute wins. */
|
||||||
|
function readName(node: Asn1): { commonName?: string; organization?: string; emailAddress?: string } {
|
||||||
|
const out: { commonName?: string; organization?: string; emailAddress?: string } = {};
|
||||||
|
for (const rdn of children(node)) {
|
||||||
|
for (const attr of children(rdn)) {
|
||||||
|
const kv = children(attr);
|
||||||
|
if (kv.length < 2) continue;
|
||||||
|
const key = oid(at(kv, 0, "an attribute type"));
|
||||||
|
const value = text(at(kv, 1, "an attribute value"));
|
||||||
|
if (key === OID.commonName) out.commonName = value;
|
||||||
|
else if (key === OID.organization) out.organization = value;
|
||||||
|
else if (key === OID.emailAddress) out.emailAddress = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rfc822Name entries from subjectAltName.
|
||||||
|
*
|
||||||
|
* This is where a modern certificate puts the address; the subject's
|
||||||
|
* emailAddress attribute is the older place and is often absent. Reading only
|
||||||
|
* one of the two means failing to match the sender on half the certificates in
|
||||||
|
* circulation.
|
||||||
|
*/
|
||||||
|
function subjectAltEmails(rest: Asn1[]): string[] {
|
||||||
|
// Extensions are [3] EXPLICIT SEQUENCE OF Extension.
|
||||||
|
const ext = rest.find((n) => n.cls === 2 && n.tag === 3);
|
||||||
|
if (!ext) return [];
|
||||||
|
const seq = children(ext)[0];
|
||||||
|
if (!seq) return [];
|
||||||
|
for (const extension of children(seq)) {
|
||||||
|
const parts = children(extension);
|
||||||
|
if (parts.length < 2) continue;
|
||||||
|
if (oid(at(parts, 0, "an extension id")) !== OID.subjectAltName) continue;
|
||||||
|
// The value is an OCTET STRING wrapping the real structure. Critical flag
|
||||||
|
// may sit between the two, so take the last part rather than index 1.
|
||||||
|
const wrapper = parts[parts.length - 1]!;
|
||||||
|
try {
|
||||||
|
const names = children(parse(wrapper.content));
|
||||||
|
// GeneralName ::= CHOICE, and rfc822Name is [1] IMPLICIT IA5String.
|
||||||
|
return names.filter((n) => n.cls === 2 && n.tag === 1).map((n) => new TextDecoder().decode(n.content));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readKeyKind(spki: Asn1): Certificate["publicKey"] {
|
||||||
|
const parts = children(spki);
|
||||||
|
const alg = children(at(parts, 0, "an algorithm identifier"));
|
||||||
|
const algOid = oid(at(alg, 0, "an algorithm"));
|
||||||
|
if (algOid === OID.rsaEncryption) return { kind: "rsa" };
|
||||||
|
if (algOid === OID.ecPublicKey) {
|
||||||
|
const curve = alg[1] ? oid(alg[1]) : "";
|
||||||
|
if (curve === OID.curveP256) return { kind: "ec", namedCurve: "P-256" };
|
||||||
|
if (curve === OID.curveP384) return { kind: "ec", namedCurve: "P-384" };
|
||||||
|
if (curve === OID.curveP521) return { kind: "ec", namedCurve: "P-521" };
|
||||||
|
throw new DerError(`Unsupported elliptic curve ${curve}.`);
|
||||||
|
}
|
||||||
|
throw new DerError(`Unsupported public key algorithm ${algOid}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the certificate names this address, case-insensitively. */
|
||||||
|
export function certCovers(cert: Certificate, address: string): boolean {
|
||||||
|
const a = address.trim().toLowerCase();
|
||||||
|
return cert.emails.includes(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short, readable name for the human holding the certificate. */
|
||||||
|
export function certDisplayName(cert: Certificate): string {
|
||||||
|
return cert.subject.commonName || cert.subject.emailAddress || cert.emails[0] || cert.subject.organization || cert.serial;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fingerprint in the grouped form people actually compare by eye. */
|
||||||
|
export function formatFingerprint(fp: string): string {
|
||||||
|
return (fp.match(/.{2}/g) ?? [fp]).join(":").toUpperCase();
|
||||||
|
}
|
||||||
@@ -1331,6 +1331,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "„{name}“ leeren?",
|
"Empty “{name}”?": "„{name}“ leeren?",
|
||||||
"Delete them": "Alle löschen",
|
"Delete them": "Alle löschen",
|
||||||
"Nothing was deleted": "Es wurde nichts gelöscht",
|
"Nothing was deleted": "Es wurde nichts gelöscht",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Zertifikat gilt für",
|
||||||
|
"Details": "Details",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Frühere Nachrichten von dieser Adresse wurden von {previous} signiert. Diese ist von {current} signiert.",
|
||||||
|
"Fingerprint": "Fingerabdruck",
|
||||||
|
"Hide details": "Details ausblenden",
|
||||||
|
"Issued by": "Ausgestellt von",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Sie ist mit OpenPGP signiert, und ihasmail hat keine Möglichkeit, den öffentlichen Schlüssel des Absenders zu beschaffen.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Sie verwendet ein Signaturverfahren, das ihasmail noch nicht prüfen kann.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Sie wurde mit einem Zertifikat von {name} erstellt, das diese Adresse nicht abdeckt.",
|
||||||
|
"Previous fingerprint": "Vorheriger Fingerabdruck",
|
||||||
|
"Signed at": "Signiert am",
|
||||||
|
"Signed by {name} — the same signer as before.": "Signiert von {name} — derselbe Unterzeichner wie zuvor.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Signiert von {name}, hier zum ersten Mal gesehen.",
|
||||||
|
"Signer": "Unterzeichner",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Das kann ein erneuertes Zertifikat bedeuten, und es kann jemand anderes sein. Fragen Sie auf einem anderen Weg nach, bevor Sie dem vertrauen.",
|
||||||
|
"The certificate has expired.": "Das Zertifikat ist abgelaufen.",
|
||||||
|
"The certificate is not valid yet.": "Das Zertifikat ist noch nicht gültig.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Die Nachricht stimmt nicht mit dem Signierten überein — sie wurde nach dem Signieren verändert oder unterwegs beschädigt.",
|
||||||
|
"The signature carries no certificate that can be read.": "Die Signatur enthält kein lesbares Zertifikat.",
|
||||||
|
"The signature could not be read.": "Die Signatur konnte nicht gelesen werden.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Die Signatur passt nicht zum mitgesendeten Zertifikat.",
|
||||||
|
"The signature is not for this sender.": "Die Signatur gehört nicht zu diesem Absender.",
|
||||||
|
"The signed part is missing either the message or the signature.": "Im signierten Teil fehlt entweder die Nachricht oder die Signatur.",
|
||||||
|
"The signer has changed.": "Der Unterzeichner hat gewechselt.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Diese Nachricht ist signiert, und ihasmail konnte die Signatur nicht prüfen.",
|
||||||
|
"This signature does not check out.": "Diese Signatur stimmt nicht.",
|
||||||
|
"Valid until": "Gültig bis",
|
||||||
|
"a different certificate": "einem anderen Zertifikat",
|
||||||
|
"an unnamed signer": "einem unbenannten Unterzeichner",
|
||||||
|
"as claimed by the signer": "laut Angabe des Unterzeichners",
|
||||||
|
"first seen {date}": "zuerst gesehen {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail weist Sie darauf hin, wenn eine spätere Nachricht von dieser Adresse von jemand anderem signiert ist.",
|
||||||
|
"itself, or an issuer it does not name": "sich selbst, oder einem nicht genannten Aussteller",
|
||||||
|
"no address": "keine Adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1304,6 +1304,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "¿Vaciar «{name}»?",
|
"Empty “{name}”?": "¿Vaciar «{name}»?",
|
||||||
"Delete them": "Eliminarlos",
|
"Delete them": "Eliminarlos",
|
||||||
"Nothing was deleted": "No se ha eliminado nada",
|
"Nothing was deleted": "No se ha eliminado nada",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "El certificado cubre",
|
||||||
|
"Details": "Detalles",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Los mensajes anteriores de esta dirección los firmaba {previous}. Este lo firma {current}.",
|
||||||
|
"Fingerprint": "Huella digital",
|
||||||
|
"Hide details": "Ocultar detalles",
|
||||||
|
"Issued by": "Emitido por",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está firmado con OpenPGP, y ihasmail no tiene forma de obtener la clave pública del remitente.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Usa un algoritmo de firma que ihasmail todavía no puede comprobar.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Se hizo con un certificado de {name}, que no cubre esta dirección.",
|
||||||
|
"Previous fingerprint": "Huella digital anterior",
|
||||||
|
"Signed at": "Firmado el",
|
||||||
|
"Signed by {name} — the same signer as before.": "Firmado por {name}: el mismo firmante que antes.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Firmado por {name}, visto aquí por primera vez.",
|
||||||
|
"Signer": "Firmante",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Puede tratarse de un certificado renovado, y puede tratarse de otra persona. Compruébelo con ella por otra vía antes de confiar.",
|
||||||
|
"The certificate has expired.": "El certificado ha caducado.",
|
||||||
|
"The certificate is not valid yet.": "El certificado aún no es válido.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "El mensaje no coincide con lo que se firmó: se alteró después de firmarlo, o se dañó por el camino.",
|
||||||
|
"The signature carries no certificate that can be read.": "La firma no lleva ningún certificado que se pueda leer.",
|
||||||
|
"The signature could not be read.": "No se ha podido leer la firma.",
|
||||||
|
"The signature does not match the certificate sent with it.": "La firma no coincide con el certificado enviado con ella.",
|
||||||
|
"The signature is not for this sender.": "La firma no corresponde a este remitente.",
|
||||||
|
"The signed part is missing either the message or the signature.": "A la parte firmada le falta el mensaje o la firma.",
|
||||||
|
"The signer has changed.": "El firmante ha cambiado.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Este mensaje está firmado, y ihasmail no ha podido comprobar la firma.",
|
||||||
|
"This signature does not check out.": "Esta firma no cuadra.",
|
||||||
|
"Valid until": "Válido hasta",
|
||||||
|
"a different certificate": "un certificado distinto",
|
||||||
|
"an unnamed signer": "un firmante sin nombre",
|
||||||
|
"as claimed by the signer": "según declara el firmante",
|
||||||
|
"first seen {date}": "visto por primera vez el {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail le avisará si un mensaje posterior de esta dirección lo firma otra persona.",
|
||||||
|
"itself, or an issuer it does not name": "sí mismo, o un emisor que no nombra",
|
||||||
|
"no address": "ninguna dirección",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1309,6 +1309,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "Vider « {name} » ?",
|
"Empty “{name}”?": "Vider « {name} » ?",
|
||||||
"Delete them": "Les supprimer",
|
"Delete them": "Les supprimer",
|
||||||
"Nothing was deleted": "Rien n’a été supprimé",
|
"Nothing was deleted": "Rien n’a été supprimé",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Le certificat couvre",
|
||||||
|
"Details": "Détails",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Les messages précédents de cette adresse étaient signés par {previous}. Celui-ci est signé par {current}.",
|
||||||
|
"Fingerprint": "Empreinte",
|
||||||
|
"Hide details": "Masquer les détails",
|
||||||
|
"Issued by": "Délivré par",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Il est signé avec OpenPGP, et ihasmail n'a aucun moyen de récupérer la clé publique de l'expéditeur.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Il utilise un algorithme de signature qu'ihasmail ne sait pas encore vérifier.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Elle a été faite avec un certificat appartenant à {name}, qui ne couvre pas cette adresse.",
|
||||||
|
"Previous fingerprint": "Empreinte précédente",
|
||||||
|
"Signed at": "Signé le",
|
||||||
|
"Signed by {name} — the same signer as before.": "Signé par {name} — le même signataire que précédemment.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Signé par {name}, vu ici pour la première fois.",
|
||||||
|
"Signer": "Signataire",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Cela peut signifier un certificat renouvelé, comme cela peut être quelqu'un d'autre. Vérifiez avec la personne par un autre moyen avant de faire confiance.",
|
||||||
|
"The certificate has expired.": "Le certificat a expiré.",
|
||||||
|
"The certificate is not valid yet.": "Le certificat n'est pas encore valide.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Le message ne correspond pas à ce qui a été signé : il a été modifié après signature, ou abîmé en chemin.",
|
||||||
|
"The signature carries no certificate that can be read.": "La signature ne contient aucun certificat lisible.",
|
||||||
|
"The signature could not be read.": "La signature n'a pas pu être lue.",
|
||||||
|
"The signature does not match the certificate sent with it.": "La signature ne correspond pas au certificat envoyé avec elle.",
|
||||||
|
"The signature is not for this sender.": "La signature ne correspond pas à cet expéditeur.",
|
||||||
|
"The signed part is missing either the message or the signature.": "Il manque à la partie signée soit le message, soit la signature.",
|
||||||
|
"The signer has changed.": "Le signataire a changé.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Ce message est signé, et ihasmail n'a pas pu vérifier la signature.",
|
||||||
|
"This signature does not check out.": "Cette signature ne tient pas.",
|
||||||
|
"Valid until": "Valable jusqu'au",
|
||||||
|
"a different certificate": "un certificat différent",
|
||||||
|
"an unnamed signer": "un signataire sans nom",
|
||||||
|
"as claimed by the signer": "selon le signataire",
|
||||||
|
"first seen {date}": "vu pour la première fois le {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail vous préviendra si un message ultérieur de cette adresse est signé par quelqu'un d'autre.",
|
||||||
|
"itself, or an issuer it does not name": "lui-même, ou un émetteur qu'il ne nomme pas",
|
||||||
|
"no address": "aucune adresse",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1312,6 +1312,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "「{name}」を空にしますか?",
|
"Empty “{name}”?": "「{name}」を空にしますか?",
|
||||||
"Delete them": "削除する",
|
"Delete them": "削除する",
|
||||||
"Nothing was deleted": "何も削除されませんでした",
|
"Nothing was deleted": "何も削除されませんでした",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "証明書の対象",
|
||||||
|
"Details": "詳細",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "このアドレスからの以前のメールは {previous} が署名していました。このメールの署名者は {current} です。",
|
||||||
|
"Fingerprint": "フィンガープリント",
|
||||||
|
"Hide details": "詳細を隠す",
|
||||||
|
"Issued by": "発行者",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "OpenPGP で署名されており、ihasmail には送信者の公開鍵を取得する手段がありません。",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "ihasmail がまだ検証できない署名アルゴリズムが使われています。",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "{name} の証明書で署名されており、この証明書はこのアドレスを対象にしていません。",
|
||||||
|
"Previous fingerprint": "以前のフィンガープリント",
|
||||||
|
"Signed at": "署名日時",
|
||||||
|
"Signed by {name} — the same signer as before.": "{name} による署名です。以前と同じ署名者です。",
|
||||||
|
"Signed by {name}, seen here for the first time.": "{name} による署名です。ここで見るのは初めてです。",
|
||||||
|
"Signer": "署名者",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "証明書を更新しただけの場合もあれば、別人の場合もあります。信頼する前に、別の手段で本人に確認してください。",
|
||||||
|
"The certificate has expired.": "証明書の有効期限が切れています。",
|
||||||
|
"The certificate is not valid yet.": "証明書はまだ有効ではありません。",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "メールが署名された内容と一致しません。署名後に変更されたか、途中で壊れています。",
|
||||||
|
"The signature carries no certificate that can be read.": "署名に読み取れる証明書が含まれていません。",
|
||||||
|
"The signature could not be read.": "署名を読み取れませんでした。",
|
||||||
|
"The signature does not match the certificate sent with it.": "署名が、一緒に送られた証明書と一致しません。",
|
||||||
|
"The signature is not for this sender.": "この署名はこの送信者のものではありません。",
|
||||||
|
"The signed part is missing either the message or the signature.": "署名された部分に、本文か署名のどちらかが欠けています。",
|
||||||
|
"The signer has changed.": "署名者が変わりました。",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "このメールには署名がありますが、ihasmail は署名を検証できませんでした。",
|
||||||
|
"This signature does not check out.": "この署名は正しくありません。",
|
||||||
|
"Valid until": "有効期限",
|
||||||
|
"a different certificate": "別の証明書",
|
||||||
|
"an unnamed signer": "名前のない署名者",
|
||||||
|
"as claimed by the signer": "署名者の申告による",
|
||||||
|
"first seen {date}": "初回は {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "このアドレスからの以降のメールが別の人の署名だった場合、ihasmail がお知らせします。",
|
||||||
|
"itself, or an issuer it does not name": "自分自身、または名前のない発行者",
|
||||||
|
"no address": "アドレスなし",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1300,6 +1300,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "„{name}” leegmaken?",
|
"Empty “{name}”?": "„{name}” leegmaken?",
|
||||||
"Delete them": "Verwijderen",
|
"Delete them": "Verwijderen",
|
||||||
"Nothing was deleted": "Er is niets verwijderd",
|
"Nothing was deleted": "Er is niets verwijderd",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Certificaat geldt voor",
|
||||||
|
"Details": "Details",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Eerdere berichten van dit adres waren ondertekend door {previous}. Dit bericht is ondertekend door {current}.",
|
||||||
|
"Fingerprint": "Vingerafdruk",
|
||||||
|
"Hide details": "Details verbergen",
|
||||||
|
"Issued by": "Uitgegeven door",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Het is ondertekend met OpenPGP, en ihasmail kan de openbare sleutel van de afzender niet ophalen.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Het gebruikt een ondertekeningsalgoritme dat ihasmail nog niet kan controleren.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Hij is gemaakt met een certificaat van {name}, dat dit adres niet dekt.",
|
||||||
|
"Previous fingerprint": "Vorige vingerafdruk",
|
||||||
|
"Signed at": "Ondertekend op",
|
||||||
|
"Signed by {name} — the same signer as before.": "Ondertekend door {name} — dezelfde ondertekenaar als eerder.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Ondertekend door {name}, hier voor het eerst gezien.",
|
||||||
|
"Signer": "Ondertekenaar",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Dat kan een vernieuwd certificaat betekenen, en het kan iemand anders zijn. Vraag het langs een andere weg na voordat u erop vertrouwt.",
|
||||||
|
"The certificate has expired.": "Het certificaat is verlopen.",
|
||||||
|
"The certificate is not valid yet.": "Het certificaat is nog niet geldig.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Het bericht komt niet overeen met wat er is ondertekend — het is na ondertekening gewijzigd, of onderweg beschadigd.",
|
||||||
|
"The signature carries no certificate that can be read.": "De handtekening bevat geen leesbaar certificaat.",
|
||||||
|
"The signature could not be read.": "De handtekening kon niet worden gelezen.",
|
||||||
|
"The signature does not match the certificate sent with it.": "De handtekening hoort niet bij het meegestuurde certificaat.",
|
||||||
|
"The signature is not for this sender.": "De handtekening is niet van deze afzender.",
|
||||||
|
"The signed part is missing either the message or the signature.": "In het ondertekende deel ontbreekt het bericht of de handtekening.",
|
||||||
|
"The signer has changed.": "De ondertekenaar is veranderd.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Dit bericht is ondertekend, en ihasmail kon de handtekening niet controleren.",
|
||||||
|
"This signature does not check out.": "Deze handtekening klopt niet.",
|
||||||
|
"Valid until": "Geldig tot",
|
||||||
|
"a different certificate": "een ander certificaat",
|
||||||
|
"an unnamed signer": "een naamloze ondertekenaar",
|
||||||
|
"as claimed by the signer": "volgens de ondertekenaar",
|
||||||
|
"first seen {date}": "voor het eerst gezien op {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail laat het weten als een later bericht van dit adres door iemand anders is ondertekend.",
|
||||||
|
"itself, or an issuer it does not name": "zichzelf, of een uitgever die het niet noemt",
|
||||||
|
"no address": "geen adres",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1307,6 +1307,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "Esvaziar “{name}”?",
|
"Empty “{name}”?": "Esvaziar “{name}”?",
|
||||||
"Delete them": "Excluir todos",
|
"Delete them": "Excluir todos",
|
||||||
"Nothing was deleted": "Nada foi excluído",
|
"Nothing was deleted": "Nada foi excluído",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "O certificado cobre",
|
||||||
|
"Details": "Detalhes",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "As mensagens anteriores deste endereço eram assinadas por {previous}. Esta é assinada por {current}.",
|
||||||
|
"Fingerprint": "Impressão digital",
|
||||||
|
"Hide details": "Ocultar detalhes",
|
||||||
|
"Issued by": "Emitido por",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Está assinada com OpenPGP, e o ihasmail não tem como obter a chave pública do remetente.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Usa um algoritmo de assinatura que o ihasmail ainda não consegue conferir.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Foi feita com um certificado de {name}, que não cobre este endereço.",
|
||||||
|
"Previous fingerprint": "Impressão digital anterior",
|
||||||
|
"Signed at": "Assinado em",
|
||||||
|
"Signed by {name} — the same signer as before.": "Assinado por {name} — o mesmo signatário de antes.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Assinado por {name}, visto aqui pela primeira vez.",
|
||||||
|
"Signer": "Signatário",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Isso pode ser um certificado renovado, e pode ser outra pessoa. Confirme com ela por outro caminho antes de confiar.",
|
||||||
|
"The certificate has expired.": "O certificado expirou.",
|
||||||
|
"The certificate is not valid yet.": "O certificado ainda não é válido.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "A mensagem não corresponde ao que foi assinado — ela foi alterada depois da assinatura, ou danificada no caminho.",
|
||||||
|
"The signature carries no certificate that can be read.": "A assinatura não traz nenhum certificado que possa ser lido.",
|
||||||
|
"The signature could not be read.": "Não foi possível ler a assinatura.",
|
||||||
|
"The signature does not match the certificate sent with it.": "A assinatura não corresponde ao certificado enviado com ela.",
|
||||||
|
"The signature is not for this sender.": "A assinatura não é deste remetente.",
|
||||||
|
"The signed part is missing either the message or the signature.": "Falta à parte assinada ou a mensagem ou a assinatura.",
|
||||||
|
"The signer has changed.": "O signatário mudou.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Esta mensagem está assinada, e o ihasmail não conseguiu conferir a assinatura.",
|
||||||
|
"This signature does not check out.": "Esta assinatura não confere.",
|
||||||
|
"Valid until": "Válido até",
|
||||||
|
"a different certificate": "um certificado diferente",
|
||||||
|
"an unnamed signer": "um signatário sem nome",
|
||||||
|
"as claimed by the signer": "conforme declarado pelo signatário",
|
||||||
|
"first seen {date}": "visto pela primeira vez em {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "O ihasmail avisará você se uma mensagem posterior deste endereço for assinada por outra pessoa.",
|
||||||
|
"itself, or an issuer it does not name": "ele mesmo, ou um emissor que ele não nomeia",
|
||||||
|
"no address": "nenhum endereço",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1306,6 +1306,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "Очистить «{name}»?",
|
"Empty “{name}”?": "Очистить «{name}»?",
|
||||||
"Delete them": "Удалить их",
|
"Delete them": "Удалить их",
|
||||||
"Nothing was deleted": "Ничего не удалено",
|
"Nothing was deleted": "Ничего не удалено",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Сертификат распространяется на",
|
||||||
|
"Details": "Подробности",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Прежние письма с этого адреса подписывал {previous}. Это подписано {current}.",
|
||||||
|
"Fingerprint": "Отпечаток",
|
||||||
|
"Hide details": "Скрыть подробности",
|
||||||
|
"Issued by": "Кем выдан",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Письмо подписано OpenPGP, а ihasmail не может получить открытый ключ отправителя.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Использован алгоритм подписи, который ihasmail пока не умеет проверять.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Подпись сделана сертификатом, принадлежащим {name}, который не покрывает этот адрес.",
|
||||||
|
"Previous fingerprint": "Прежний отпечаток",
|
||||||
|
"Signed at": "Подписано",
|
||||||
|
"Signed by {name} — the same signer as before.": "Подписано {name} — тот же подписавший, что и раньше.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Подписано {name}, встречается здесь впервые.",
|
||||||
|
"Signer": "Подписавший",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Это может быть обновлённый сертификат, а может быть и другой человек. Прежде чем доверять, свяжитесь с отправителем другим способом.",
|
||||||
|
"The certificate has expired.": "Срок действия сертификата истёк.",
|
||||||
|
"The certificate is not valid yet.": "Сертификат ещё не действителен.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Письмо не совпадает с тем, что было подписано — его изменили после подписания или повредили в пути.",
|
||||||
|
"The signature carries no certificate that can be read.": "В подписи нет сертификата, который удалось бы прочитать.",
|
||||||
|
"The signature could not be read.": "Подпись не удалось прочитать.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Подпись не соответствует приложенному сертификату.",
|
||||||
|
"The signature is not for this sender.": "Подпись не принадлежит этому отправителю.",
|
||||||
|
"The signed part is missing either the message or the signature.": "В подписанной части не хватает либо письма, либо подписи.",
|
||||||
|
"The signer has changed.": "Подписавший изменился.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Это письмо подписано, и ihasmail не смог проверить подпись.",
|
||||||
|
"This signature does not check out.": "Эта подпись не сходится.",
|
||||||
|
"Valid until": "Действует до",
|
||||||
|
"a different certificate": "другим сертификатом",
|
||||||
|
"an unnamed signer": "неназванным подписавшим",
|
||||||
|
"as claimed by the signer": "по словам подписавшего",
|
||||||
|
"first seen {date}": "впервые замечен {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail сообщит, если следующее письмо с этого адреса подпишет кто-то другой.",
|
||||||
|
"itself, or an issuer it does not name": "самим собой или неназванным издателем",
|
||||||
|
"no address": "нет адреса",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1300,6 +1300,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "Очистити «{name}»?",
|
"Empty “{name}”?": "Очистити «{name}»?",
|
||||||
"Delete them": "Видалити їх",
|
"Delete them": "Видалити їх",
|
||||||
"Nothing was deleted": "Нічого не видалено",
|
"Nothing was deleted": "Нічого не видалено",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "Сертифікат поширюється на",
|
||||||
|
"Details": "Подробиці",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "Попередні листи з цієї адреси підписував {previous}. Цей підписаний {current}.",
|
||||||
|
"Fingerprint": "Відбиток",
|
||||||
|
"Hide details": "Сховати подробиці",
|
||||||
|
"Issued by": "Ким видано",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "Лист підписано OpenPGP, а ihasmail не може отримати відкритий ключ відправника.",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "Використано алгоритм підпису, який ihasmail поки не вміє перевіряти.",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "Підпис зроблено сертифікатом, що належить {name} і не покриває цю адресу.",
|
||||||
|
"Previous fingerprint": "Попередній відбиток",
|
||||||
|
"Signed at": "Підписано",
|
||||||
|
"Signed by {name} — the same signer as before.": "Підписано {name} — той самий підписувач, що й раніше.",
|
||||||
|
"Signed by {name}, seen here for the first time.": "Підписано {name}, трапляється тут уперше.",
|
||||||
|
"Signer": "Підписувач",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "Це може бути оновлений сертифікат, а може бути й інша людина. Перш ніж довіряти, зв'яжіться з відправником іншим шляхом.",
|
||||||
|
"The certificate has expired.": "Термін дії сертифіката минув.",
|
||||||
|
"The certificate is not valid yet.": "Сертифікат ще не чинний.",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "Лист не збігається з тим, що було підписано — його змінили після підписання або пошкодили в дорозі.",
|
||||||
|
"The signature carries no certificate that can be read.": "У підписі немає сертифіката, який вдалося б прочитати.",
|
||||||
|
"The signature could not be read.": "Підпис не вдалося прочитати.",
|
||||||
|
"The signature does not match the certificate sent with it.": "Підпис не відповідає надісланому з ним сертифікату.",
|
||||||
|
"The signature is not for this sender.": "Підпис не належить цьому відправникові.",
|
||||||
|
"The signed part is missing either the message or the signature.": "У підписаній частині бракує або листа, або підпису.",
|
||||||
|
"The signer has changed.": "Підписувач змінився.",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "Цей лист підписано, і ihasmail не зміг перевірити підпис.",
|
||||||
|
"This signature does not check out.": "Цей підпис не сходиться.",
|
||||||
|
"Valid until": "Чинний до",
|
||||||
|
"a different certificate": "іншим сертифікатом",
|
||||||
|
"an unnamed signer": "неназваним підписувачем",
|
||||||
|
"as claimed by the signer": "за словами підписувача",
|
||||||
|
"first seen {date}": "уперше побачено {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "ihasmail повідомить, якщо наступний лист із цієї адреси підпише хтось інший.",
|
||||||
|
"itself, or an issuer it does not name": "самим собою або неназваним видавцем",
|
||||||
|
"no address": "немає адреси",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1311,6 +1311,41 @@ export const catalog: Catalog = {
|
|||||||
"Empty “{name}”?": "清空“{name}”?",
|
"Empty “{name}”?": "清空“{name}”?",
|
||||||
"Delete them": "删除",
|
"Delete them": "删除",
|
||||||
"Nothing was deleted": "未删除任何内容",
|
"Nothing was deleted": "未删除任何内容",
|
||||||
|
// ── Checking an S/MIME signature, and what may be said about it ──
|
||||||
|
"Certificate covers": "证书适用于",
|
||||||
|
"Details": "详情",
|
||||||
|
"Earlier messages from this address were signed by {previous}. This one is signed by {current}.": "此地址以前的邮件由 {previous} 签名,而这封由 {current} 签名。",
|
||||||
|
"Fingerprint": "指纹",
|
||||||
|
"Hide details": "隐藏详情",
|
||||||
|
"Issued by": "颁发者",
|
||||||
|
"It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.": "该邮件使用 OpenPGP 签名,而 ihasmail 无法获取发件人的公钥。",
|
||||||
|
"It uses a signature algorithm ihasmail cannot check yet.": "它使用了 ihasmail 尚不能校验的签名算法。",
|
||||||
|
"It was made with a certificate belonging to {name}, which does not cover this address.": "签名使用的是 {name} 的证书,该证书并不包含此地址。",
|
||||||
|
"Previous fingerprint": "以前的指纹",
|
||||||
|
"Signed at": "签名时间",
|
||||||
|
"Signed by {name} — the same signer as before.": "由 {name} 签名——与此前是同一签名者。",
|
||||||
|
"Signed by {name}, seen here for the first time.": "由 {name} 签名,这是首次在此见到。",
|
||||||
|
"Signer": "签名者",
|
||||||
|
"That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.": "这可能只是更换了证书,也可能是另一个人。信任之前,请通过其他途径向对方核实。",
|
||||||
|
"The certificate has expired.": "证书已过期。",
|
||||||
|
"The certificate is not valid yet.": "证书尚未生效。",
|
||||||
|
"The message does not match what was signed — it was altered after signing, or damaged on the way.": "邮件与所签名的内容不一致——它在签名后被改动,或在传输中损坏。",
|
||||||
|
"The signature carries no certificate that can be read.": "签名中没有可读取的证书。",
|
||||||
|
"The signature could not be read.": "无法读取签名。",
|
||||||
|
"The signature does not match the certificate sent with it.": "签名与随附的证书不匹配。",
|
||||||
|
"The signature is not for this sender.": "该签名不属于此发件人。",
|
||||||
|
"The signed part is missing either the message or the signature.": "签名部分缺少邮件正文或签名。",
|
||||||
|
"The signer has changed.": "签名者已更换。",
|
||||||
|
"This message is signed, and ihasmail could not check the signature.": "此邮件带有签名,但 ihasmail 无法校验该签名。",
|
||||||
|
"This signature does not check out.": "此签名不成立。",
|
||||||
|
"Valid until": "有效期至",
|
||||||
|
"a different certificate": "另一份证书",
|
||||||
|
"an unnamed signer": "未具名的签名者",
|
||||||
|
"as claimed by the signer": "据签名者声称",
|
||||||
|
"first seen {date}": "首次见于 {date}",
|
||||||
|
"ihasmail will tell you if a later message from this address is signed by anybody else.": "如果此地址之后的邮件由他人签名,ihasmail 会提醒您。",
|
||||||
|
"itself, or an issuer it does not name": "其自身,或一个未具名的颁发者",
|
||||||
|
"no address": "无地址",
|
||||||
},
|
},
|
||||||
plurals: {
|
plurals: {
|
||||||
// ── Third pass ─────────────────────────────────────────────────────
|
// ── Third pass ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ export interface Template {
|
|||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A signer remembered for an address. See `knownSigners`. */
|
||||||
|
export interface SignerPin {
|
||||||
|
/** SHA-256 of the certificate's DER, lowercase hex. */
|
||||||
|
fingerprint: string;
|
||||||
|
/** What the certificate called its holder, so a change can be described. */
|
||||||
|
name: string;
|
||||||
|
/** When this fingerprint was first pinned, ISO 8601. */
|
||||||
|
firstSeen: string;
|
||||||
|
/**
|
||||||
|
* The message that established the pin.
|
||||||
|
*
|
||||||
|
* Without this, the message that *created* a pin reads as corroborated by it
|
||||||
|
* the next time it is opened — "the same signer as before", where before is
|
||||||
|
* itself. That is a claim of corroboration from evidence that does not
|
||||||
|
* exist, and it appears on the very first signed message somebody receives.
|
||||||
|
*/
|
||||||
|
messageId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
/**
|
/**
|
||||||
* Kept, and kept correct, for a device still running a build that only knows
|
* Kept, and kept correct, for a device still running a build that only knows
|
||||||
@@ -91,6 +110,23 @@ export interface Settings {
|
|||||||
* already follow the reader between devices.
|
* already follow the reader between devices.
|
||||||
*/
|
*/
|
||||||
addedShares: string[];
|
addedShares: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S/MIME signers pinned on first sight, keyed by lowercased address.
|
||||||
|
*
|
||||||
|
* This is the whole trust model for signature checking, and it is a small
|
||||||
|
* one: a browser has no system trust store, and the certificate that signs a
|
||||||
|
* message travels inside it, so "this signature verifies" on its own says
|
||||||
|
* only that the sender held the key they attached. What makes it worth
|
||||||
|
* anything is remembering — the same signer as last time is reassuring, and a
|
||||||
|
* different one is worth interrupting somebody over.
|
||||||
|
*
|
||||||
|
* It lives in the synced settings rather than in this browser because a pin
|
||||||
|
* that only one device knows about would greet the same correspondent as new
|
||||||
|
* on every other one, which trains people to click past exactly the warning
|
||||||
|
* this exists to raise.
|
||||||
|
*/
|
||||||
|
knownSigners: Record<string, SignerPin>;
|
||||||
imagePolicy: ImagePolicy;
|
imagePolicy: ImagePolicy;
|
||||||
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
|
/** Let messages follow the app's light/dark theme instead of always sitting on white. */
|
||||||
themeMessageBody: boolean;
|
themeMessageBody: boolean;
|
||||||
@@ -268,6 +304,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
markReadDelay: 0,
|
markReadDelay: 0,
|
||||||
addedShares: [],
|
addedShares: [],
|
||||||
|
knownSigners: {},
|
||||||
imagePolicy: "ask",
|
imagePolicy: "ask",
|
||||||
themeMessageBody: false,
|
themeMessageBody: false,
|
||||||
undoSendSeconds: 8,
|
undoSendSeconds: 8,
|
||||||
|
|||||||
@@ -1781,6 +1781,20 @@ button.dp-open:disabled { cursor: default; opacity: .5; }
|
|||||||
|
|
||||||
/* The banner naming a sender from outside the organisation. */
|
/* The banner naming a sender from outside the organisation. */
|
||||||
.remote-banner.external-banner { background: var(--warn-soft); border-color: var(--warn); }
|
.remote-banner.external-banner { background: var(--warn-soft); border-color: var(--warn); }
|
||||||
|
/*
|
||||||
|
* The signature banner's colour is the claim it is making, so the quiet tones
|
||||||
|
* are deliberate. "Signed by somebody new" is grey, because an unknown
|
||||||
|
* certificate that verifies against itself has established nothing worth a
|
||||||
|
* green tick; green is kept for the one case that earned it, a signer matching
|
||||||
|
* what was pinned the first time. Red is for a signer that changed.
|
||||||
|
*/
|
||||||
|
.remote-banner.signature-banner { align-items: flex-start; }
|
||||||
|
.remote-banner.signature-banner.good { background: var(--success-soft); color: var(--success); }
|
||||||
|
.remote-banner.signature-banner.warn { background: var(--warn-soft); color: var(--warn); }
|
||||||
|
.remote-banner.signature-banner.danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.remote-banner.signature-banner.quiet { background: var(--bg-sunken); color: var(--fg-muted); }
|
||||||
|
.remote-banner.signature-banner .sessions-table td { padding: 2px 8px 2px 0; }
|
||||||
|
.remote-banner.signature-banner .hint { opacity: .8; }
|
||||||
|
|
||||||
/* The line offering the whole folder once the page is selected. */
|
/* The line offering the whole folder once the page is selected. */
|
||||||
.list-hint.select-all-hint { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
.list-hint.select-all-hint { background: var(--accent-soft); color: var(--accent-soft-fg); }
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useContacts } from "@/store/contacts";
|
|||||||
import { useCalendar } from "@/store/calendar";
|
import { useCalendar } from "@/store/calendar";
|
||||||
import { startAppointment } from "@/lib/appointment";
|
import { startAppointment } from "@/lib/appointment";
|
||||||
import { client } from "@/jmap/client";
|
import { client } from "@/jmap/client";
|
||||||
|
import { SignatureBanner } from "./SignatureBanner";
|
||||||
|
import { useSignature } from "@/lib/smime/useSignature";
|
||||||
import { emlFilename } from "@/lib/emlName";
|
import { emlFilename } from "@/lib/emlName";
|
||||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||||
@@ -47,6 +49,7 @@ interface Props {
|
|||||||
|
|
||||||
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
|
export const MessageView = memo(function MessageView({ email: e, expanded, wasUnread, onToggle, actions }: Props) {
|
||||||
const accountId = useMail((s) => s.accountId)!;
|
const accountId = useMail((s) => s.accountId)!;
|
||||||
|
const signature = useSignature(e, accountId);
|
||||||
const settings = useSettings((s) => s.settings);
|
const settings = useSettings((s) => s.settings);
|
||||||
const updateSettings = useSettings((s) => s.update);
|
const updateSettings = useSettings((s) => s.update);
|
||||||
|
|
||||||
@@ -387,6 +390,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SignatureBanner state={signature} />
|
||||||
{externalSender && (
|
{externalSender && (
|
||||||
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
<div className="remote-banner external-banner" style={{ margin: "0 16px 8px" }}>
|
||||||
<ShieldAlert size={16} />
|
<ShieldAlert size={16} />
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { BadgeCheck, ShieldAlert, ShieldQuestion, ShieldX } from "lucide-react";
|
||||||
|
import { formatFingerprint } from "@/lib/smime/x509";
|
||||||
|
import type { SignatureState } from "@/lib/smime/useSignature";
|
||||||
|
import type { Reason } from "@/lib/smime/verify";
|
||||||
|
import { t, tNode } from "@/lib/i18n";
|
||||||
|
import { formatFullDate } from "@/lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a checked signature is allowed to say on screen.
|
||||||
|
*
|
||||||
|
* The wording here is the feature. ihasmail has no certificate authority to ask
|
||||||
|
* and none is bundled, so the strong word — "verified", full stop — is never
|
||||||
|
* used: the certificate arrives inside the message, and on its own a good
|
||||||
|
* signature only shows that whoever wrote the message held the key attached to
|
||||||
|
* it. What can honestly be said is whether this is the same signer as last
|
||||||
|
* time, and that is what the banner leads with.
|
||||||
|
*
|
||||||
|
* Which means the *reassuring* case is deliberately the quiet one and the
|
||||||
|
* changed-signer case is the loud one. A green tick on a first sighting would
|
||||||
|
* be telling somebody that an unknown certificate is trustworthy because it
|
||||||
|
* verified against itself.
|
||||||
|
*/
|
||||||
|
export function SignatureBanner({ state }: { state: SignatureState }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (state.status !== "done") return null;
|
||||||
|
const { crypto, trust, previous, warnings } = state.report;
|
||||||
|
if (crypto.kind === "none") return null;
|
||||||
|
|
||||||
|
if (crypto.kind === "unsupported") {
|
||||||
|
return (
|
||||||
|
<Banner tone="quiet" icon={<ShieldQuestion size={16} />}>
|
||||||
|
<span className="grow">
|
||||||
|
{t("This message is signed, and ihasmail could not check the signature.")} {explain(crypto.reason)}
|
||||||
|
{crypto.detail && <span className="hint"> {crypto.detail}</span>}
|
||||||
|
</span>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (crypto.kind === "broken") {
|
||||||
|
return (
|
||||||
|
<Banner tone="danger" icon={<ShieldX size={16} />}>
|
||||||
|
<span className="grow">
|
||||||
|
<strong>{t("This signature does not check out.")}</strong> {explain(crypto.reason)}
|
||||||
|
</span>
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = crypto.cert.subject.commonName || crypto.cert.emails[0] || t("an unnamed signer");
|
||||||
|
const changed = trust === "changed";
|
||||||
|
const mismatch = warnings.includes("address-mismatch");
|
||||||
|
const tone = changed || mismatch ? "danger" : warnings.length > 0 ? "warn" : trust === "same-as-before" ? "good" : "quiet";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Banner tone={tone} icon={changed || mismatch ? <ShieldAlert size={16} /> : trust === "same-as-before" ? <BadgeCheck size={16} /> : <ShieldQuestion size={16} />}>
|
||||||
|
<span className="grow">
|
||||||
|
{changed ? (
|
||||||
|
<>
|
||||||
|
<strong>{t("The signer has changed.")}</strong>{" "}
|
||||||
|
{tNode("Earlier messages from this address were signed by {previous}. This one is signed by {current}.", {
|
||||||
|
previous: <strong className="notranslate" translate="no">{previous?.name ?? t("a different certificate")}</strong>,
|
||||||
|
current: <strong className="notranslate" translate="no">{name}</strong>,
|
||||||
|
})}{" "}
|
||||||
|
{t("That can mean a renewed certificate, and it can mean somebody else. Check with them by some other route before trusting it.")}
|
||||||
|
</>
|
||||||
|
) : mismatch ? (
|
||||||
|
<>
|
||||||
|
<strong>{t("The signature is not for this sender.")}</strong>{" "}
|
||||||
|
{tNode("It was made with a certificate belonging to {name}, which does not cover this address.", {
|
||||||
|
name: <strong className="notranslate" translate="no">{name}</strong>,
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : trust === "same-as-before" ? (
|
||||||
|
tNode("Signed by {name} — the same signer as before.", { name: <strong className="notranslate" translate="no">{name}</strong> })
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{tNode("Signed by {name}, seen here for the first time.", { name: <strong className="notranslate" translate="no">{name}</strong> })}{" "}
|
||||||
|
{t("ihasmail will tell you if a later message from this address is signed by anybody else.")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{warnings.includes("certificate-expired") && <> {t("The certificate has expired.")}</>}
|
||||||
|
{warnings.includes("certificate-not-yet-valid") && <> {t("The certificate is not valid yet.")}</>}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => setOpen(!open)}>{open ? t("Hide details") : t("Details")}</button>
|
||||||
|
{open && (
|
||||||
|
<table className="sessions-table" style={{ marginTop: 8, width: "100%" }}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>{t("Signer")}</td>
|
||||||
|
<td className="notranslate" translate="no">{name}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("Certificate covers")}</td>
|
||||||
|
<td className="notranslate" translate="no">{crypto.cert.emails.join(", ") || t("no address")}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("Issued by")}</td>
|
||||||
|
<td className="notranslate" translate="no">{crypto.cert.issuer.commonName || crypto.cert.issuer.organization || t("itself, or an issuer it does not name")}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{t("Valid until")}</td>
|
||||||
|
<td>{formatFullDate(crypto.cert.notAfter.toISOString())}</td>
|
||||||
|
</tr>
|
||||||
|
{crypto.signer.signingTime && (
|
||||||
|
<tr>
|
||||||
|
<td>{t("Signed at")}</td>
|
||||||
|
<td>
|
||||||
|
{formatFullDate(crypto.signer.signingTime.toISOString())} <span className="hint">{t("as claimed by the signer")}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
<tr>
|
||||||
|
<td>{t("Fingerprint")}</td>
|
||||||
|
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
|
||||||
|
{formatFingerprint(crypto.cert.fingerprint)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{previous && (
|
||||||
|
<tr>
|
||||||
|
<td>{t("Previous fingerprint")}</td>
|
||||||
|
<td className="mono" style={{ fontSize: ".8em", wordBreak: "break-all" }}>
|
||||||
|
{formatFingerprint(previous.fingerprint)} <span className="hint">{t("first seen {date}", { date: formatFullDate(previous.firstSeen) })}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Banner>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sayable version of why a check did not happen, or did not hold. */
|
||||||
|
function explain(reason: Reason): string {
|
||||||
|
switch (reason) {
|
||||||
|
case "openpgp":
|
||||||
|
return t("It is signed with OpenPGP, and ihasmail has no way to fetch the sender's public key.");
|
||||||
|
case "rsa-pss":
|
||||||
|
return t("It uses a signature algorithm ihasmail cannot check yet.");
|
||||||
|
case "no-certificate":
|
||||||
|
return t("The signature carries no certificate that can be read.");
|
||||||
|
case "not-signed-properly":
|
||||||
|
return t("The signed part is missing either the message or the signature.");
|
||||||
|
case "digest-mismatch":
|
||||||
|
return t("The message does not match what was signed — it was altered after signing, or damaged on the way.");
|
||||||
|
case "signature-mismatch":
|
||||||
|
return t("The signature does not match the certificate sent with it.");
|
||||||
|
case "other":
|
||||||
|
return t("The signature could not be read.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Banner({ tone, icon, children }: { tone: "good" | "warn" | "danger" | "quiet"; icon: React.ReactNode; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`remote-banner signature-banner ${tone}`} style={{ margin: "0 16px 8px" }}>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { SignatureBanner } from "../SignatureBanner";
|
||||||
|
import type { SignatureState } from "@/lib/smime/useSignature";
|
||||||
|
import type { Certificate } from "@/lib/smime/x509";
|
||||||
|
import type { SignerInfo } from "@/lib/smime/cms";
|
||||||
|
import type { SignatureReport } from "@/lib/smime/verify";
|
||||||
|
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The wording is the feature here, so it is worth asserting rather than
|
||||||
|
* eyeballing. The rule this pins down: ihasmail has no certificate authority to
|
||||||
|
* ask, so a signature that merely verifies against the certificate travelling
|
||||||
|
* beside it must never be dressed as an endorsement. "Verified" full stop is
|
||||||
|
* the word that would be a lie, and the tone must not be the reassuring one
|
||||||
|
* until a previous sighting actually corroborates the signer.
|
||||||
|
*/
|
||||||
|
const cert = (over: Partial<Certificate> = {}): Certificate =>
|
||||||
|
({
|
||||||
|
fingerprint: "ab".repeat(32),
|
||||||
|
serial: "01",
|
||||||
|
subject: { commonName: "Ada Lovelace" },
|
||||||
|
issuer: { commonName: "Ada Lovelace" },
|
||||||
|
emails: ["[email protected]"],
|
||||||
|
notBefore: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
notAfter: new Date("2030-01-01T00:00:00Z"),
|
||||||
|
spki: new Uint8Array(),
|
||||||
|
publicKey: { kind: "rsa" },
|
||||||
|
der: new Uint8Array(),
|
||||||
|
issuerDer: new Uint8Array(),
|
||||||
|
...over,
|
||||||
|
}) as Certificate;
|
||||||
|
|
||||||
|
const signer = { digest: "SHA-256" } as SignerInfo;
|
||||||
|
|
||||||
|
const done = (report: SignatureReport): SignatureState => ({ status: "done", report });
|
||||||
|
|
||||||
|
describe("what the signature banner says", () => {
|
||||||
|
let host: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
|
||||||
|
const render = async (state: SignatureState) => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<SignatureBanner state={state} />);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing at all for an unsigned message", async () => {
|
||||||
|
await render(done({ crypto: { kind: "none" }, warnings: [] }));
|
||||||
|
expect(host.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing while the check is still running", async () => {
|
||||||
|
await render({ status: "checking" });
|
||||||
|
expect(host.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not congratulate a signer it has never seen before", async () => {
|
||||||
|
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "first-seen", warnings: [] }));
|
||||||
|
expect(host.textContent).toContain("seen here for the first time");
|
||||||
|
// Grey, not green: an unknown certificate that verifies against itself has
|
||||||
|
// established nothing worth a tick.
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
|
||||||
|
expect(host.textContent).not.toMatch(/\bverified\b/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps green for the one case that earned it", async () => {
|
||||||
|
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: [] }));
|
||||||
|
expect(host.textContent).toContain("the same signer as before");
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("good");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is loud when the signer changed, and names both", async () => {
|
||||||
|
await render(
|
||||||
|
done({
|
||||||
|
crypto: { kind: "intact", cert: cert({ subject: { commonName: "Somebody Else" } }), signer },
|
||||||
|
trust: "changed",
|
||||||
|
previous: { fingerprint: "cd".repeat(32), name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" },
|
||||||
|
warnings: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
|
||||||
|
expect(host.textContent).toContain("The signer has changed");
|
||||||
|
expect(host.textContent).toContain("Ada Lovelace");
|
||||||
|
expect(host.textContent).toContain("Somebody Else");
|
||||||
|
// And tells the reader what to do about it, rather than only that it happened.
|
||||||
|
expect(host.textContent).toMatch(/some other route/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is loud when a good signature is by a certificate for somebody else", async () => {
|
||||||
|
await render(done({ crypto: { kind: "intact", cert: cert({ emails: ["[email protected]"] }), signer }, trust: "first-seen", warnings: ["address-mismatch"] }));
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
|
||||||
|
expect(host.textContent).toContain("not for this sender");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls a failed check a failed check", async () => {
|
||||||
|
await render(done({ crypto: { kind: "broken", reason: "digest-mismatch" }, warnings: [] }));
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("danger");
|
||||||
|
expect(host.textContent).toContain("does not check out");
|
||||||
|
expect(host.textContent).toContain("altered after signing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it could not check, rather than that the signature is bad", async () => {
|
||||||
|
await render(done({ crypto: { kind: "unsupported", reason: "openpgp" }, warnings: [] }));
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("quiet");
|
||||||
|
expect(host.textContent).toContain("could not check the signature");
|
||||||
|
expect(host.textContent).toContain("OpenPGP");
|
||||||
|
// The distinction that matters: "cannot check" is not "does not check out".
|
||||||
|
expect(host.textContent).not.toContain("does not check out");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mentions an expired certificate without downgrading the signature", async () => {
|
||||||
|
await render(done({ crypto: { kind: "intact", cert: cert(), signer }, trust: "same-as-before", warnings: ["certificate-expired"] }));
|
||||||
|
expect(host.textContent).toContain("The certificate has expired.");
|
||||||
|
expect(host.querySelector(".signature-banner")?.className).toContain("warn");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user