Check the S/MIME half against a real server, instead of assuming it

The section offered "an OpenPGP public key or an S/MIME certificate" and
only the first half had ever been tried. Every probe behind it used
OpenPGP keys, and every message the registry returns names OpenPGP --
including for input that is not OpenPGP at all -- so the server reads as
though OpenPGP were the only format it knows. Shipping the claim on that
evidence would have been a guess dressed as a feature, which is the one
thing this section is written not to do.

It holds. Confirmed live on 0.16.20 (2026-09-05) with a self-signed
X.509 certificate carrying emailProtection and an email: SAN:
registered, read back, destroyed. And Stalwart parses it as seriously as
it parses OpenPGP -- a malformed certificate is refused by a decoder of
its own, "Failed to decode X509 certificate: BER decoding error:
Expected Tag { class: Universal, value: 16 } tag…", which is a third
rejection wording and the reason the S/MIME half is real rather than
decorative. The mock now returns it for a certificate, so the branch
exists somewhere a client can meet it.

One thing found on the way: expiresAt is the registry's field and is not
derived from the key. A certificate valid for a year registers with
expiresAt null, so the card says "No expiry set" about a credential that
does expire. Left as it is, deliberately: reading the real date means
parsing the certificate, which is the second opinion this section
refuses to offer, and a date extracted here would disagree with the
server's own field the moment the two ever differed. What the row
reports is what the registry holds, and KNOWN-ISSUES says so.
This commit is contained in:
2026-09-05 01:03:52 -07:00
parent e93d42d27e
commit 6a467d9bc4
3 changed files with 30 additions and 11 deletions
+11
View File
@@ -1236,6 +1236,12 @@ first two:
checked would be a second opinion, and the one that counts would still be the
server's — its message is shown verbatim, the way password-policy rejections
already are.
- **Certificates are parsed too, by their own decoder.** The registry is not
OpenPGP-only, though every other message it returns reads as though it were: a
self-signed X.509 certificate registers, reads back and removes cleanly, and a
malformed one is refused with *"Failed to decode X509 certificate: BER
decoding error…"*. So the S/MIME half of this section is real, and the server
is the thing checking it in both formats.
- **A key that parses can still be refused, and says something else.** A
sign-and-certify key with no encryption subkey — which is what
`gpg --quick-generate-key` produces by default — comes back *"Could not find
@@ -1252,6 +1258,11 @@ first two:
reloads the list instead of believing what came back. Renaming and removing
both work; removal leaves the registry genuinely empty.
**`expiresAt` is the registry's field, not the key's.** A certificate valid for
a year registers with no expiry set, and the card says so. Reading the real one
means parsing the certificate, which is the second opinion this section exists
not to offer.
The kind badge — OpenPGP or S/MIME — is read from the armour header alone, which
is a label rather than a parse, and the excerpt beside each key is deliberately
not called a fingerprint: computing a real one means parsing the key, and naming
+2
View File
@@ -40,6 +40,8 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- **Stalwart lets a sharee subscribe to a shared calendar but not a shared address book.** Subscribing is a write to the *owner's* account -- `isSubscribed` lives on the collection, not on the reader -- and 0.16.19 refuses it for a book shared read-only: `AddressBook/set` answers successfully with the id in `notUpdated`, `forbidden`, *"You are not allowed to modify this address book."* The identical `Calendar/set` on a shared calendar is accepted. **Confirmed live on 0.16.19 (2026-08-27)** from a second account holding both shares, which is the only place it shows: from the owner's own account the write succeeds and everything looks fine. So ihasmail asks the server first, because a preference the server holds is one every client agrees about, and keeps the answer in its own synced settings (`addedShares`) when the server will not. Two things this cost, both worth remembering: the refusal arrives as a *successful* response, so the code that ignored `notUpdated` saw nothing wrong and the button simply did nothing; and it is invisible from the owner's account, so it took two browsers signed in as two accounts to find at all. The mock now refuses the same write for the same reason, since one that accepted it agreed with the belief that shipped.
- **`shareWith` is not returned unless a client asks for it by name.** A `Calendar/get` or `AddressBook/get` with no `properties` comes back without the field at all — not null, not empty, absent — **confirmed live on 0.16.19 (2026-08-27)** against a calendar and an address book that were genuinely shared with another account: omit the list and there is no `shareWith`; name it and the sharee is right there. Every consequence was silent. Nothing was badged as shared, "Stop sharing" never appeared because nothing looked shared, and the share dialog opened on *"not shared with anyone yet"* over a live share — so the one screen that existed to manage sharing was the one most confidently wrong about it. Files never had this, because `fileNodeProps` had always named the property; calendars, address books and mail folders fetched everything and got less. Mail folders mattered in a way of their own: sharing one is withdrawn, and the only way to clear a share already made is a **Stop sharing** entry that appears when a folder looks shared — so without the property the escape hatch for the exact situation it was built for was invisible. The mock now omits it the same way, since one that hands it over unasked lets a client that never asks look correct everywhere except against a real server.
- **`x:PublicKey` is readable and writable by an ordinary user, whatever the permissions table says.** Stalwart documents every `sysPublicKey*` permission as administrative. **Confirmed live on 0.16.20 (2026-09-05)** from a normal account with no administrative rights: `x:PublicKey/get` and `x:PublicKey/query` both answered, a create with a malformed key was refused with `invalidProperties` naming `key` rather than `forbidden` — a rejection of the key, not of the person — and a create with a usable key, a rename, and a destroy all succeeded. Had the documentation been right, Settings Encryption keys would have been useless to everybody but an administrator, which is why it was probed before it was built.
- **The registry takes S/MIME certificates as well as OpenPGP keys, and parses both.** **Confirmed live on 0.16.20 (2026-09-05)** with a self-signed X.509 certificate carrying `emailProtection` and an `email:` SAN: registered, read back, and destroyed. A malformed one is refused with a decoder of its own — *"Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag…"* — which is what makes the S/MIME half of Settings Encryption keys real rather than decorative. It was worth checking rather than assuming, because every *other* message the registry returns names OpenPGP, including for input that is not OpenPGP at all, so the server reads as though OpenPGP were the only format it knows. It is not.
- **`expiresAt` is a registry field and is not derived from the key.** A certificate valid for a year registers with `expiresAt: null`, so the card says "No expiry set" about a credential that does expire. **Confirmed live on 0.16.20 (2026-09-05).** ihasmail leaves it that way on purpose: reading the expiry means parsing the certificate, which is the second opinion this whole section refuses to offer — and a date ihasmail extracted would disagree with the server's own field the moment the two ever differed. What the row reports is what the registry holds.
- **A key can parse perfectly and still be refused, for a different reason.** `gpg --quick-generate-key` makes a sign-and-certify key; exporting that and registering it comes back *"Could not find any suitable keys in OpenPGP public key"*, distinct from the parser's *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB…"*. **Confirmed live on 0.16.20 (2026-09-05)**, both wordings. The distinction matters to whoever pasted it: one says paste it again, the other says the key needs an encryption subkey and no amount of care with the clipboard will help. ihasmail shows the server's sentence verbatim rather than reducing both to "invalid key". The mock reproduces both, the second behind a `SIGNONLY` marker in the block, because a branch nothing can reach is a branch nobody has seen.
- **`emailAddresses` on a public key comes back as `{}` rather than `[]`.** An object where a JMAP list property should be an array, and only when it is empty. **Confirmed live on 0.16.20 (2026-09-05)** on a key registered without addresses. Nothing fails loudly: it is a plain `Get` response that type-checks against a hand-written interface and then throws in `join()` while the list is rendering. ihasmail checks the shape rather than trusting the type, the mock answers `{}` the same way, and a test pins it — a mock that helpfully returned `[]` would let that crash ship.
- **Creating a public key answers with the id alone.** No `createdAt` in the `created` object, so a client that read one back out of the create response would show `undefined` for the date it was added. **Confirmed live on 0.16.20 (2026-09-05).** ihasmail reloads the list after adding rather than believing the response. Patching `key` on an existing entry, incidentally, *is* allowed by the server; ihasmail does not do it and the mock refuses it, so that replacing a key stays add-then-remove and `createdAt` keeps meaning what it says.
+17 -11
View File
@@ -1012,7 +1012,7 @@ const handlers: Record<string, Handler> = {
const destroyed: string[] = [];
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
const o = obj as Obj;
const complaint = pgpComplaint(String(o.key ?? ""));
const complaint = keyComplaint(String(o.key ?? ""));
if (complaint) {
notCreated[cid] = { type: "invalidProperties", properties: ["key"], description: complaint };
continue;
@@ -1278,23 +1278,29 @@ const handlers: Record<string, Handler> = {
* that only ever saw "invalid key" would show something less useful than what
* the server was already offering.
*
* The two are worth keeping apart, because they are different problems and the
* second is the one a real person hits. A block that will not parse is usually
* a bad copy and paste. A block that parses and is still refused is a key that
* cannot encrypt -- `gpg --quick-generate-key` makes a sign-and-certify key by
* default, and exporting that gets you "Could not find any suitable keys"
* however carefully it was pasted.
* There are three, and they are worth keeping apart because they are different
* problems. A block that will not parse is usually a bad copy and paste. A
* block that parses and is still refused is a key that cannot encrypt --
* `gpg --quick-generate-key` makes a sign-and-certify key by default, and
* exporting that gets you "Could not find any suitable keys" however carefully
* it was pasted. And a certificate gets its own decoder and its own complaint:
* Stalwart parses X.509 as seriously as it parses OpenPGP, which is the thing
* that makes the S/MIME half of this section real rather than decorative.
*/
function pgpComplaint(key: string): string | null {
function keyComplaint(key: string): string | null {
const k = key.trim();
if (!k) return "Failed to decode OpenPGP public key: no key data.";
const pgp = k.startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----") && k.includes("-----END PGP PUBLIC KEY BLOCK-----");
const x509 = k.startsWith("-----BEGIN CERTIFICATE-----") && k.includes("-----END CERTIFICATE-----");
if (!pgp && !x509) return "Failed to decode OpenPGP public key: Malformed packet: Malformed CTB: MSB of ptag not set.";
const body = k.split(/\r?\n/).filter((l) => l && !l.startsWith("-----") && !l.startsWith("=") && !l.includes(":")).join("");
// Enough base64 to be a key rather than a placeholder; the real parser is
// stricter still, which is the point of surfacing its message and not ours.
if (body.length < 64) return "Failed to decode OpenPGP public key: Malformed packet: unexpected EOF.";
// Enough base64 to be a key rather than a placeholder; the real parsers are
// stricter still, which is the point of surfacing their message and not ours.
if (body.length < 64) {
return x509
? "Failed to decode X509 certificate: BER decoding error: Expected Tag { class: Universal, value: 16 } tag, actual tag: Tag { class: Application, value: 14 } (Codec: BER)"
: "Failed to decode OpenPGP public key: Malformed packet: unexpected EOF.";
}
// The mock cannot read a key, so it cannot tell whether one can encrypt.
// A "SIGNONLY" marker anywhere in the block stands in for that, which is
// crude but reachable: the branch has to be reachable from the UI, or