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
+103
View File
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "vitest";
const call = vi.fn();
vi.mock("@/jmap/client", async (orig) => {
const real = await orig<typeof import("@/jmap/client")>();
return { ...real, client: { ...real.client, call: (...a: unknown[]) => call(...a), hasCapabilityAnywhere: () => true } };
});
vi.mock("@/store/session", () => ({
useSession: { getState: () => ({ session: { primaryAccounts: { "urn:stalwart:jmap": "v" } }, accountFor: () => "v" }) },
}));
const { isExpired, keyExcerpt, keyKind, keyKindLabel, listPublicKeys } = await import("@/lib/publicKeys");
/**
* These read a key without parsing one. Stalwart parses it — with a real
* OpenPGP implementation that says precisely what is wrong — so anything
* checked here could only be a second opinion, and the one that counts would
* still be the server's. What is left is labelling: which sort of key this is,
* whether its stated expiry has passed, and enough of the body to tell two
* keys apart in a list.
*/
const PGP = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGAbCdEFGh\nijKLmnOPqrSt\n=aBc1\n-----END PGP PUBLIC KEY BLOCK-----";
const X509 = "-----BEGIN CERTIFICATE-----\nMIIDdzCCAl+gAwIBAgIE\n-----END CERTIFICATE-----";
describe("which sort of key this is", () => {
it("reads the armour header, and only the header", () => {
expect(keyKind(PGP)).toBe("openpgp");
expect(keyKind(X509)).toBe("smime");
});
it("tolerates leading whitespace from a paste", () => {
expect(keyKind("\n\n " + PGP)).toBe("openpgp");
});
it("says so rather than guessing when the header is not one it knows", () => {
// Not "invalid" — that is the server's call to make, not this function's.
expect(keyKind("ssh-ed25519 AAAAC3Nz")).toBe("unknown");
expect(keyKind("")).toBe("unknown");
expect(keyKindLabel(keyKind("nonsense"))).toBe("Unrecognised");
});
});
describe("expiry", () => {
const now = new Date("2026-08-26T12:00:00Z");
it("is not expired when no expiry was set", () => {
expect(isExpired({ expiresAt: null }, now)).toBe(false);
});
it("compares against the given moment, not the machine clock", () => {
expect(isExpired({ expiresAt: "2026-08-25T12:00:00Z" }, now)).toBe(true);
expect(isExpired({ expiresAt: "2026-08-27T12:00:00Z" }, now)).toBe(false);
});
it("treats an unreadable date as no expiry rather than as expired", () => {
// Marking a usable key "Expired" over a date we could not read would be
// worse than saying nothing about it.
expect(isExpired({ expiresAt: "whenever" }, now)).toBe(false);
});
});
describe("telling two keys apart", () => {
it("excerpts the body, skipping armour, headers and the checksum", () => {
const x = keyExcerpt(PGP, 12);
expect(x).toBe("mQINBGAbCdEF");
expect(x).not.toContain("-----");
expect(x).not.toContain("=aBc1");
});
it("gives something rather than nothing for a key with no body", () => {
expect(keyExcerpt("-----BEGIN PGP PUBLIC KEY BLOCK-----\n-----END PGP PUBLIC KEY BLOCK-----")).toBe("—");
});
});
describe("reading the registry back", () => {
/*
* Stalwart answers an empty `emailAddresses` with `{}` -- an object, where a
* JMAP list property should be an array. Confirmed against a live 0.16.20 on
* 2026-09-05. Trusting the type would put an object through `.join(", ")`
* and throw in the middle of rendering the list, so the shape is checked
* rather than believed, and this is the test that says why.
*/
it("survives emailAddresses arriving as an object instead of an array", async () => {
call.mockResolvedValueOnce({ list: [{ id: "k1", key: PGP, description: "Work", createdAt: "2026-09-05T07:48:03Z", expiresAt: null, emailAddresses: {} }] });
const [k] = await listPublicKeys();
expect(Array.isArray(k!.emailAddresses)).toBe(true);
expect(k!.emailAddresses).toEqual([]);
});
it("keeps the addresses when the server does send a list", async () => {
call.mockResolvedValueOnce({ list: [{ id: "k1", key: PGP, emailAddresses: ["[email protected]", 7, "[email protected]"] }] });
const [k] = await listPublicKeys();
// The stray number is dropped rather than rendered as "7".
expect(k!.emailAddresses).toEqual(["[email protected]", "[email protected]"]);
});
it("fills in what a sparse object leaves out, so the card never renders undefined", async () => {
call.mockResolvedValueOnce({ list: [{ id: "k1" }] });
const [k] = await listPublicKeys();
expect(k).toEqual({ id: "k1", key: "", description: "", createdAt: null, expiresAt: null, emailAddresses: [] });
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Public keys, over Stalwart's `x:PublicKey` registry.
*
* These are the keys other people use to encrypt mail *to* this account, and
* the ones a signature is checked against. Nothing secret is involved: no
* private key is held, asked for, or sent anywhere by any of this.
*
* Established against a live 0.16.20 on 2026-09-05 rather than assumed. The
* first two because the documentation says otherwise; the rest because they
* decide how this file has to be written:
*
* - An ordinary user may read *and* write their own keys. Stalwart's
* permissions table lists the `sysPublicKey*` permissions as
* administrative; the server granted them to a normal account. A create
* with a malformed key came back `invalidProperties`, not `forbidden`,
* which is a rejection of the key rather than of the person.
*
* - Stalwart parses the key itself, with a real OpenPGP implementation, and
* says precisely what is wrong: "Failed to decode OpenPGP public key:
* Malformed packet: Malformed CTB…". So ihasmail does not validate key
* material. Anything it checked would only be a second opinion, and the
* one that mattered would still be the server's. It refuses a *readable*
* key that cannot encrypt just as firmly, and differently — "Could not
* find any suitable keys in OpenPGP public key" — which is the rejection
* an exported sign-only key gets.
*
* - `emailAddresses` comes back as `{}` when it is empty: an object where a
* JMAP list property should be an array. `normalize` therefore checks the
* shape instead of trusting it, and every reader of this type gets a real
* array. Deleting that check reintroduces a crash in `.join()`.
*
* - A create answers with the id alone — no `createdAt` — so adding a key
* reloads the list rather than believing what it got back.
*
* - `destroy` works, and leaves the registry empty. That path had never been
* run against a real server before this probe.
*/
import { CAP, client, setErrorMessage } from "@/jmap/client";
import type { GetResponse, Id, SetResponse } from "@/jmap/types";
import { useSession } from "@/store/session";
import { t } from "@/lib/i18n";
const STALWART = "urn:stalwart:jmap";
const USING = [CAP.core, STALWART];
export interface PublicKey {
id: Id;
key: string;
description: string;
createdAt: string | null;
expiresAt: string | null;
emailAddresses: string[];
}
/** What a key can be edited to; `key` itself is replaced by adding a new one. */
export type PublicKeyPatch = Partial<Pick<PublicKey, "description" | "expiresAt" | "emailAddresses">>;
const PROPS = ["id", "key", "description", "createdAt", "expiresAt", "emailAddresses"];
/** Whether this server offers the registry at all. */
export function publicKeysAvailable(): boolean {
return client.hasCapabilityAnywhere(STALWART) && Boolean(accountId());
}
function accountId(): Id | null {
const s = useSession.getState();
return s.session?.primaryAccounts?.[STALWART] ?? s.accountFor(CAP.mail);
}
function normalize(raw: Partial<PublicKey> & { id: Id }): PublicKey {
return {
id: raw.id,
key: typeof raw.key === "string" ? raw.key : "",
description: typeof raw.description === "string" ? raw.description : "",
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null,
expiresAt: typeof raw.expiresAt === "string" ? raw.expiresAt : null,
emailAddresses: Array.isArray(raw.emailAddresses) ? raw.emailAddresses.filter((a): a is string => typeof a === "string") : [],
};
}
export async function listPublicKeys(): Promise<PublicKey[]> {
const id = accountId();
if (!id) return [];
const res = await client.call<GetResponse<PublicKey>>("x:PublicKey/get", { accountId: id, ids: null, properties: PROPS }, USING);
return res.list.map((k) => normalize(k as Partial<PublicKey> & { id: Id }));
}
export async function addPublicKey(key: string, description: string, extra: PublicKeyPatch = {}): Promise<PublicKey> {
const id = accountId();
if (!id) throw new Error(t("No account to add a key to."));
const res = await client.call<SetResponse<PublicKey>>(
"x:PublicKey/set",
{ accountId: id, create: { k: { key: key.trim(), description: description.trim(), ...clean(extra) } } },
USING,
);
const err = res.notCreated?.k;
// The server's own words: it parsed the key and knows what is wrong with it.
if (err) throw new Error(setErrorMessage(err));
return normalize((res.created?.k ?? { id: "" }) as Partial<PublicKey> & { id: Id });
}
export async function updatePublicKey(keyId: Id, patch: PublicKeyPatch): Promise<void> {
const id = accountId();
if (!id) return;
const res = await client.call<SetResponse<PublicKey>>("x:PublicKey/set", { accountId: id, update: { [keyId]: clean(patch) } }, USING);
const err = res.notUpdated?.[keyId];
if (err) throw new Error(setErrorMessage(err));
}
export async function removePublicKey(keyId: Id): Promise<void> {
const id = accountId();
if (!id) return;
const res = await client.call<SetResponse<PublicKey>>("x:PublicKey/set", { accountId: id, destroy: [keyId] }, USING);
const err = res.notDestroyed?.[keyId];
if (err) throw new Error(setErrorMessage(err));
}
/** Drop keys the caller left undefined, so a patch never blanks a field by accident. */
function clean(patch: PublicKeyPatch): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(patch)) if (v !== undefined) out[k] = v;
return out;
}
/* ------------------------------------------------------------------ */
/* Reading a key without parsing one */
/* ------------------------------------------------------------------ */
export type KeyKind = "openpgp" | "smime" | "unknown";
/**
* Which kind of key this is, from its armour header alone.
*
* Deliberately not a parse. The header is a label, and reading a label is not
* the same as validating the thing it is stuck to — the server does that, and
* a second opinion here could only ever disagree with the one that counts.
*/
export function keyKind(key: string): KeyKind {
const head = key.trimStart().slice(0, 120).toUpperCase();
if (head.includes("BEGIN PGP PUBLIC KEY BLOCK")) return "openpgp";
if (head.includes("BEGIN CERTIFICATE") || head.includes("BEGIN PKCS7")) return "smime";
return "unknown";
}
/**
* "OpenPGP" and "S/MIME" are the formats' own names and stay as they are in
* every language; only the fallback is a word rather than a name, so only the
* fallback is translated.
*/
export function keyKindLabel(kind: KeyKind): string {
return kind === "openpgp" ? "OpenPGP" : kind === "smime" ? "S/MIME" : t("Unrecognised");
}
/** Whether a key has an expiry that has already passed. */
export function isExpired(k: Pick<PublicKey, "expiresAt">, now = new Date()): boolean {
if (!k.expiresAt) return false;
const at = Date.parse(k.expiresAt);
return Number.isFinite(at) && at < now.getTime();
}
/**
* A short, stable excerpt of the key body, for telling two keys apart in a
* list. Not a fingerprint: computing a real one means parsing the key, and
* calling this a fingerprint would invite someone to verify against it.
*/
export function keyExcerpt(key: string, length = 24): string {
const body = key
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("-----") && !l.includes(":") && !l.startsWith("="))
.join("");
return body.slice(0, length) || "—";
}