Manage public keys, over Stalwart's x:PublicKey registry

A new Settings section, next to Identities & signatures: list, add,
rename and remove the OpenPGP public keys and S/MIME certificates
published on this account. Only public material -- no private key is
stored, requested or sent by any of this.

This is PR #67 revived. That branch was built against 0.16.19, closed
unmerged on 2026-08-26, and shares no ancestry with main after the email
scrub, so it is ported rather than rebased: the four files it added are
carried over, the three it edited are applied by hand, and everything it
claimed was re-probed against the live 0.16.20 on 2026-09-05. The i18n
work is new -- nine catalogues landed on 2026-08-31, after that branch
was written.

What the re-probe confirmed, unchanged from 0.16.19:

  - An ordinary user may read *and* write their own keys, though the
    permissions table lists every sysPublicKey* permission as
    administrative. get and query both answered for a normal account,
    and a malformed create came back invalidProperties naming `key`
    rather than forbidden -- a rejection of the key, not of the person.

  - The server parses the key and says precisely what is wrong. So
    ihasmail does not validate key material; the server's message is
    shown verbatim, as password-policy rejections already are.

  - urn:stalwart:jmap is still absent from the session's top-level
    capabilities and present per-account, so the check that reads all
    three places is still the one that works.

What it added, none of which was known before:

  - A key can parse perfectly and still be refused, with different
    words: a sign-and-certify key with no encryption subkey -- what
    `gpg --quick-generate-key` produces -- gets "Could not find any
    suitable keys in OpenPGP public key". That is the rejection somebody
    exporting from GnuPG will actually meet, and it is not a paste
    error, so collapsing both to "invalid key" would send them back to
    the clipboard for a problem that is in the key.

  - emailAddresses comes back as {} when empty -- an object where a JMAP
    list property should be an array. It type-checks, then throws in
    join() while the list renders. normalize() checked the shape
    already; there is now a test saying why, and the mock answers {} the
    same way, because one that helpfully returned [] would let that
    crash ship.

  - A create answers with the id alone, no createdAt, so adding a key
    reloads rather than believing the response.

  - destroy works and leaves the registry empty. PR #67 shipped that
    path untested -- its live probe was refused before anything was
    created, so there was nothing to destroy.

  - Patching `key` is allowed by the server. The mock still refuses it,
    now deliberately rather than for want of evidence: ihasmail replaces
    a key by adding one and removing the old, which keeps createdAt
    meaning what it says.

x:EncryptionAtRest still does not exist on 0.16.20 -- asking for it is
an unknownMethod. encryptionAtRest is a field on x:AccountSettings, and
its value is a typed object ({"@type":"Disabled"}) rather than the bare
string ROADMAP described. Nothing here writes it.

An empty description is now sent as empty rather than filled in with
"Key". The description is stored on the server, so a default invented in
the client would be whichever language the adder happened to be using;
the list labels a blank one at render time instead.

Verified in a browser against the mock, not only in tests: both
rejections reach the toast in the server's own words with the form still
filled in, a good key renders its card, the kind is labelled from the
armour header, renaming persists, removing asks first and empties the
list, and the whole section reads correctly in German.
This commit is contained in:
2026-09-05 00:56:31 -07:00
parent 1f9c17ad18
commit e93d42d27e
19 changed files with 1087 additions and 3 deletions
+110
View File
@@ -66,6 +66,9 @@ const nextState = () => String(state.n++);
/** Push subscriptions, as a fresh account has none. */
const pushSubscriptions: Obj[] = [];
/** Registered public keys. Empty to start, like a fresh account. */
const publicKeys: Obj[] = [];
const mailboxes: Obj[] = [
mb("inbox", "Inbox", "inbox"),
mb("drafts", "Drafts", "drafts"),
@@ -975,6 +978,82 @@ const handlers: Record<string, Handler> = {
}
return setResp({ created, notCreated, updated, notUpdated, destroyed });
},
/*
* Public keys, as 0.16.20 actually behaves -- established against a live
* server on 2026-09-05, because the documentation disagrees on the first two:
*
* - an ordinary user may read AND write their own keys. The docs list the
* sysPublicKey* permissions as administrative; the server granted them.
*
* - the server parses the key. A malformed one comes back invalidProperties
* naming `key`, with the parser's own complaint in the description --
* not a bland "invalid". A mock that took any string would let a client
* ship without ever handling the rejection, which is the shape of every
* bug this mock has been taught to reproduce since.
*
* - a well-formed key with nothing to encrypt to is refused just as
* firmly, and says something different: "Could not find any suitable
* keys in OpenPGP public key". A sign-only key parses perfectly and is
* still no use to a server whose reason for holding one is encryption.
* This is the rejection somebody exporting from GnuPG will actually
* meet, so the mock has to be able to produce it.
*
* - `created` carries the id and nothing else -- no createdAt. A client
* that read one back out of the create response would get undefined,
* which is why adding a key reloads the list.
*/
"x:PublicKey/get": (a) => genericGet(publicKeys)(a),
"x:PublicKey/query": () => ({ accountId: ACCOUNT, queryState: String(state.n), canCalculateChanges: true, position: 0, ids: publicKeys.map((k) => k.id as string), total: publicKeys.length }),
"x:PublicKey/set": (a) => {
const created: Obj = {};
const notCreated: Obj = {};
const updated: Obj = {};
const notUpdated: Obj = {};
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 ?? ""));
if (complaint) {
notCreated[cid] = { type: "invalidProperties", properties: ["key"], description: complaint };
continue;
}
const id = `pk${randomUUID().slice(0, 6)}`;
publicKeys.push({
id,
key: o.key,
description: o.description ?? "",
createdAt: new Date().toISOString(),
expiresAt: o.expiresAt ?? null,
// An empty `emailAddresses` comes back from the real server as `{}` --
// an object where a JMAP list property should be an array. The client
// survives it by checking rather than trusting, and it only survives
// because something reproduced it: a mock answering `[]` would have
// let `.join(", ")` ship and throw against a real server.
emailAddresses: Array.isArray(o.emailAddresses) && o.emailAddresses.length ? o.emailAddresses : {},
});
// Only the id: the live server sends no createdAt here.
created[cid] = { id };
state.n++;
}
for (const [id, patch] of Object.entries((a.update as Obj) ?? {})) {
const k = publicKeys.find((x) => x.id === id);
if (!k) { notUpdated[id] = { type: "notFound" }; continue; }
// The live server ALLOWS this -- patching `key` on 0.16.20 answers
// `updated`. The mock refuses it anyway, and deliberately: ihasmail
// replaces a key by adding one and removing the old, which keeps
// createdAt meaning what it says, and a mock that permitted the patch
// would quietly bless a path the client is not supposed to take.
if ("key" in (patch as Obj)) { notUpdated[id] = { type: "invalidProperties", properties: ["key"], description: "Property cannot be changed." }; continue; }
Object.assign(k, patch);
updated[id] = null;
state.n++;
}
for (const id of (a.destroy as string[]) ?? []) {
const i = publicKeys.findIndex((x) => x.id === id);
if (i >= 0) { publicKeys.splice(i, 1); destroyed.push(id); state.n++; }
}
return setResp({ created, notCreated, updated, notUpdated, destroyed });
},
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
"x:AppPassword/set": (a) => {
const created: Obj = {};
@@ -1193,6 +1272,37 @@ const handlers: Record<string, Handler> = {
},
};
/**
* What Stalwart says when it will not take a key. Both wordings are the
* server's own, taken verbatim from a live 0.16.20 on 2026-09-05 -- a client
* 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.
*/
function pgpComplaint(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.";
// 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
// nobody will ever see the message it exists to return.
if (pgp && k.includes("SIGNONLY")) return "Could not find any suitable keys in OpenPGP public key";
return null;
}
/* ---------- http ---------- */
function unauthorized(res: ServerResponse) {
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });