diff --git a/FEATURES.md b/FEATURES.md index a5403ae..7bd9ecc 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1212,6 +1212,64 @@ Over Stalwart's own registry objects, so there is no administrator in the loop: credentials". Doing it properly means implementing OAuth; that is in [ROADMAP.md](ROADMAP.md). +## Encryption keys + +Settings › Encryption keys publishes OpenPGP public keys and S/MIME certificates +on the account, over Stalwart's `x:PublicKey` registry: list, add, rename, +remove. Only public material — no private key is stored, requested or sent by +any of it. + +Everything below was established against a live Stalwart **0.16.20** on +2026-09-05, rather than taken from the documentation, which is wrong on the +first two: + +- **An ordinary user may read *and* write their own keys.** Stalwart's + permissions table lists every `sysPublicKey*` permission as administrative. + The server granted them to a normal account: `x:PublicKey/get` and + `x:PublicKey/query` both answered, and a create carrying a deliberately + malformed key came back `invalidProperties` naming `key` — a rejection of the + key, not of the person. Had the documentation been right this section would + have been useless to everyone but an administrator. +- **Stalwart parses the key itself** and says precisely what is wrong with it: + *"Failed to decode OpenPGP public key: Malformed packet: Malformed CTB: MSB of + ptag not set."* So ihasmail does not validate key material. Anything it + 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. +- **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 + any suitable keys in OpenPGP public key"*. That is the rejection somebody + exporting from GnuPG is most likely to meet, and it is not a paste error: + the fix is to add an encryption subkey, not to paste more carefully. Showing + the server's own two sentences distinguishes the two; showing "invalid key" + would not. +- **`emailAddresses` arrives as `{}` when it is empty** — an object, where a + JMAP list property should be an array. ihasmail checks the shape rather than + trusting the type, so nothing puts an object through `join()` halfway through + rendering the list. +- **Creating a key answers with its id and nothing else**, so adding one + reloads the list instead of believing what came back. Renaming and removing + both work; removal leaves the registry genuinely empty. + +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 +it a fingerprint would invite somebody to verify against it. + +**What this does not do.** Nothing signs, encrypts, decrypts or verifies with +these keys. The page says so. Adding one does not by itself start encrypting +your mail — the one thing known to consume a registered key is +`encryptionAtRest`, a field on `x:AccountSettings` beside `description`, +`locale` and `timeZone`. There is no `x:EncryptionAtRest` object whatever the +documentation suggests — asking for one on 0.16.20 is an `unknownMethod` — and +the field's value is a typed object, `{"@type": "Disabled"}`, not a bare string. +ihasmail does not yet offer it, and it carries a caveat of its own: turning it +off does not decrypt what is already there. Signing and decrypting need a private key in a page +served by the same host that would handle it, which is a security model to agree +before it is a feature to build. Verifying a signature needs only public keys +and is tractable on its own. See [ROADMAP.md](ROADMAP.md). + ## Privacy by default Remote images blocked, the proxy on, read receipts never automatic, no diff --git a/KNOWN-ISSUES.md b/KNOWN-ISSUES.md index 838533d..e5c7b22 100644 --- a/KNOWN-ISSUES.md +++ b/KNOWN-ISSUES.md @@ -11,7 +11,8 @@ names, and the dates still say so: the upgrade was read against the 0.16.19→0.16.20 diff rather than re-run, and nothing in it touches the session capabilities, blob, quota, submission or registry paths these entries describe. The calendar entries below carrying a 2026-08-31 date are the exception: those -were exercised against the live 0.16.20 directly. +were exercised against the live 0.16.20 directly, as are the public-key entries +dated 2026-09-05. What remains here is not a list of unknowns but of things worth knowing — where Stalwart departs from a spec, where a setting has to be turned on for a feature to work, and what ihasmail deliberately does not do. @@ -38,6 +39,10 @@ works the same way — and dropped where 0.15 was the whole subject. Support for - **Address book sharing works, and was briefly withdrawn by mistake.** It was taken out alongside mail folders on 2026-08-27 on a report that it behaved the same way; the report was mistaken and the feature was put back the same day. Nothing was ever shown to be wrong with it, and Stalwart documents address books as shareable. Recorded because the withdrawal is in the history and would otherwise read as a finding. Shared books now appear in the Contacts pane under "Shared with me" rather than behind an account switch, and their contacts are offered when addressing a message. - **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. +- **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. - **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. - **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. diff --git a/README.md b/README.md index f656512..280db97 100644 --- a/README.md +++ b/README.md @@ -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 - **Contacts** — JMAP Contacts / JSContact: address books, groups, full editor, vCard import/export - **Files** — JMAP FileNode: browse, upload, download, rename, move, delete +- **Encryption keys** — publish OpenPGP public keys and S/MIME certificates on the account over Stalwart's `x:PublicKey` registry. Public material only: no private key is stored, requested or sent. Managing them is all it does — nothing signs, encrypts, decrypts or verifies yet, and the page says so - **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) - **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 diff --git a/ROADMAP.md b/ROADMAP.md index 188b92e..fd8d82f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,7 +15,13 @@ 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. - **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. -- **S/MIME.** Backlogged, to be built when it is feature-ready — not refused. It is on this page rather than in the tracker because nothing is in progress and there is no ticket to watch, and it is written down at all because until now it existed only in conversation, which is the state a plan is in just before it is forgotten. [PR #67](https://github.com/Coffey-Labs/ihasmail/pull/67) is the starting point: a working public-key manager over Stalwart's `x:PublicKey` registry, built and then closed unmerged on 2026-08-26. Nothing of it is in the tree today. What the probing behind it established, against a live Stalwart **0.16.19** on 2026-08-26 and not re-run since the 0.16.20 upgrade: `encryptionAtRest` is a *field on* `x:AccountSettings` beside `description`, `locale` and `timeZone` — there is no `x:EncryptionAtRest` object, whatever the docs suggest — it is self-service with no administrator involved, taking `Disabled` / `Aes128` / `Aes256` plus `publicKey`, `encryptOnAppend` and `allowSpamTraining`; ordinary users can read and write their own `x:PublicKey` entries even though Stalwart's permissions table lists every `sysPublicKey*` permission as admin-only; and Stalwart parses key material itself and returns a precise complaint, so a client should surface the server's message rather than invent one. **The design caveat that has to be got right before any encryption UI ships:** turning encryption-at-rest *off does not decrypt what is already there*. Enabling it is a one-way door — every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key. That is a property of Stalwart rather than a policy choice, and it is the part a user cannot undo by changing their mind. +- **Signing, encrypting, decrypting and verifying mail.** *Managing* keys is built and no longer on this page: Settings › Encryption keys publishes OpenPGP public keys and S/MIME certificates over Stalwart's `x:PublicKey` registry — see [FEATURES.md](FEATURES.md#encryption-keys). What is not built is anything that *uses* them, and the reason is not Stalwart. S/MIME itself is CMS over the message body: JMAP hands a client the MIME blob and the rest is the client's work. + + 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 tractable on its own. + + Encryption at rest is the one thing known to consume a registered key, and ihasmail does not offer it yet. It is `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 with no administrator involved. **The design caveat that has to be got right before any encryption UI ships:** turning it *off does not decrypt what is already there*. Enabling it is a one-way door — every message delivered while it was on stays encrypted on disk, readable only by a client holding the private key. That is a property of Stalwart rather than a policy choice, and it is the part a user cannot undo by changing their mind. + + All of the above was probed against a live Stalwart **0.16.20** on 2026-09-05; the findings, including two rejections worth telling apart and a list property that arrives as an object, are in [KNOWN-ISSUES.md](KNOWN-ISSUES.md). **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. diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index f69de92..f0020f9 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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 = { } 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 = { }, }; +/** + * 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"' }); diff --git a/web/src/lib/__tests__/publicKeys.test.ts b/web/src/lib/__tests__/publicKeys.test.ts new file mode 100644 index 0000000..7ab03c6 --- /dev/null +++ b/web/src/lib/__tests__/publicKeys.test.ts @@ -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(); + 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: ["a@example.com", 7, "b@example.com"] }] }); + const [k] = await listPublicKeys(); + // The stray number is dropped rather than rendered as "7". + expect(k!.emailAddresses).toEqual(["a@example.com", "b@example.com"]); + }); + + 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: [] }); + }); +}); diff --git a/web/src/lib/publicKeys.ts b/web/src/lib/publicKeys.ts new file mode 100644 index 0000000..b6a680f --- /dev/null +++ b/web/src/lib/publicKeys.ts @@ -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>; + +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 & { 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 { + const id = accountId(); + if (!id) return []; + const res = await client.call>("x:PublicKey/get", { accountId: id, ids: null, properties: PROPS }, USING); + return res.list.map((k) => normalize(k as Partial & { id: Id })); +} + +export async function addPublicKey(key: string, description: string, extra: PublicKeyPatch = {}): Promise { + const id = accountId(); + if (!id) throw new Error(t("No account to add a key to.")); + const res = await client.call>( + "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 & { id: Id }); +} + +export async function updatePublicKey(keyId: Id, patch: PublicKeyPatch): Promise { + const id = accountId(); + if (!id) return; + const res = await client.call>("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 { + const id = accountId(); + if (!id) return; + const res = await client.call>("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 { + const out: Record = {}; + 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, 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) || "—"; +} diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index c647c04..b74c249 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -1331,6 +1331,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "„{name}“ leeren?", "Delete them": "Alle löschen", "Nothing was deleted": "Es wurde nichts gelöscht", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Verschlüsselungsschlüssel", + "Not available on this server": "Auf diesem Server nicht verfügbar", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Öffentliche Schlüssel liegen in der Registry von Stalwart, die dieser Server nicht anbietet. ihasmail benötigt dafür Stalwart 0.16 oder neuer.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Öffentliche Schlüssel für dieses Konto — das, woran andere verschlüsseln und woran eine Signatur geprüft wird. Sie sind naturgemäß öffentlich: ihasmail speichert, erfragt und sendet keinen privaten Schlüssel.", + "No keys yet": "Noch keine Schlüssel", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Fügen Sie einen öffentlichen OpenPGP-Schlüssel oder ein S/MIME-Zertifikat hinzu, um es für dieses Konto zu veröffentlichen.", + "Key added": "Schlüssel hinzugefügt", + "Key removed": "Schlüssel entfernt", + "Remove “{name}”?": "„{name}“ entfernen?", + "this key": "diesen Schlüssel", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "Wer ihn besitzt, kann ihn weiterhin verwenden — das Entfernen hier bewirkt nur, dass dieses Konto ihn nicht mehr anbietet.", + "Remove key": "Schlüssel entfernen", + "Key description": "Schlüsselbeschreibung", + "Untitled key": "Unbenannter Schlüssel", + "Click to rename": "Zum Umbenennen klicken", + "Expired": "Abgelaufen", + "Added": "Hinzugefügt", + "Addresses": "Adressen", + "No expiry set": "Kein Ablaufdatum festgelegt", + "Any address on this account": "Jede Adresse dieses Kontos", + "cryptography\u0004Key": "Schlüssel", + "Public key": "Öffentlicher Schlüssel", + "Work key": "Arbeitsschlüssel", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Fügen Sie den gesamten ASCII-Block samt Kopfzeilen ein. Der Server prüft ihn und sagt, was nicht stimmt, falls er ihn nicht lesen kann.", + "Add key": "Schlüssel hinzufügen", + "Add a key": "Einen Schlüssel hinzufügen", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart speichert diese Schlüssel, und diese Version verwaltet sie lediglich: ihasmail signiert, verschlüsselt, entschlüsselt und prüft damit noch nichts. Einen hinzuzufügen beginnt nicht von selbst, Ihre E-Mails zu verschlüsseln.", + "Unrecognised": "Nicht erkannt", + "No account to add a key to.": "Kein Konto vorhanden, zu dem ein Schlüssel hinzugefügt werden könnte.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/es.ts b/web/src/locales/es.ts index f0ffcd1..86efa53 100644 --- a/web/src/locales/es.ts +++ b/web/src/locales/es.ts @@ -1304,6 +1304,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "¿Vaciar «{name}»?", "Delete them": "Eliminarlos", "Nothing was deleted": "No se ha eliminado nada", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Claves de cifrado", + "Not available on this server": "No disponible en este servidor", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Las claves públicas se guardan en el registro de Stalwart, que este servidor no ofrece. ihasmail necesita Stalwart 0.16 o posterior para ello.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Claves públicas de esta cuenta: aquello con lo que otras personas cifran, y aquello con lo que se comprueba una firma. Son públicas por naturaleza: ihasmail no almacena, no solicita ni envía ninguna clave privada.", + "No keys yet": "Aún no hay claves", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Añada una clave pública OpenPGP o un certificado S/MIME para publicarlo en esta cuenta.", + "Key added": "Clave añadida", + "Key removed": "Clave quitada", + "Remove “{name}”?": "¿Quitar «{name}»?", + "this key": "esta clave", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "Quien la tenga podrá seguir usándola: quitarla aquí solo hace que esta cuenta deje de ofrecerla.", + "Remove key": "Quitar clave", + "Key description": "Descripción de la clave", + "Untitled key": "Clave sin título", + "Click to rename": "Haga clic para cambiar el nombre", + "Expired": "Caducada", + "Added": "Añadida", + "Addresses": "Direcciones", + "No expiry set": "Sin caducidad establecida", + "Any address on this account": "Cualquier dirección de esta cuenta", + "cryptography\u0004Key": "Clave", + "Public key": "Clave pública", + "Work key": "Clave del trabajo", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Pegue el bloque blindado completo, cabeceras incluidas. El servidor lo comprueba y dice qué falla si no puede leerlo.", + "Add key": "Añadir clave", + "Add a key": "Añadir una clave", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart guarda estas claves y esta versión no hace más que gestionarlas: ihasmail todavía no firma, cifra, descifra ni verifica nada con ellas. Añadir una no empieza por sí sola a cifrar su correo.", + "Unrecognised": "No reconocida", + "No account to add a key to.": "No hay ninguna cuenta a la que añadir una clave.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index c63778c..9592f76 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -1309,6 +1309,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "Vider « {name} » ?", "Delete them": "Les supprimer", "Nothing was deleted": "Rien n’a été supprimé", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Clés de chiffrement", + "Not available on this server": "Non disponible sur ce serveur", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Les clés publiques sont conservées dans le registre de Stalwart, que ce serveur ne propose pas. ihasmail nécessite Stalwart 0.16 ou plus récent pour cela.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Clés publiques de ce compte — ce avec quoi les autres chiffrent, et ce sur quoi une signature est vérifiée. Elles sont publiques par nature : ihasmail ne stocke, ne demande ni n'envoie aucune clé privée.", + "No keys yet": "Aucune clé pour l'instant", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Ajoutez une clé publique OpenPGP ou un certificat S/MIME pour le publier sur ce compte.", + "Key added": "Clé ajoutée", + "Key removed": "Clé retirée", + "Remove “{name}”?": "Retirer « {name} » ?", + "this key": "cette clé", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "Quiconque la détient peut toujours l'utiliser — la retirer ici empêche seulement ce compte de la proposer.", + "Remove key": "Retirer la clé", + "Key description": "Description de la clé", + "Untitled key": "Clé sans titre", + "Click to rename": "Cliquez pour renommer", + "Expired": "Expirée", + "Added": "Ajoutée", + "Addresses": "Adresses", + "No expiry set": "Aucune expiration définie", + "Any address on this account": "Toute adresse de ce compte", + "cryptography\u0004Key": "Clé", + "Public key": "Clé publique", + "Work key": "Clé professionnelle", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Collez le bloc ASCII complet, en-têtes compris. Le serveur le vérifie et indique ce qui ne va pas s'il ne peut pas le lire.", + "Add key": "Ajouter la clé", + "Add a key": "Ajouter une clé", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart conserve ces clés, et cette version ne fait que les gérer : ihasmail ne signe, ne chiffre, ne déchiffre et ne vérifie encore rien avec elles. En ajouter une ne commence pas à chiffrer votre courrier.", + "Unrecognised": "Non reconnue", + "No account to add a key to.": "Aucun compte auquel ajouter une clé.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index 1ece5ef..d40645f 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -1312,6 +1312,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "「{name}」を空にしますか?", "Delete them": "削除する", "Nothing was deleted": "何も削除されませんでした", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "暗号鍵", + "Not available on this server": "このサーバーでは利用できません", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "公開鍵は Stalwart のレジストリに保存されますが、このサーバーはそれを提供していません。ihasmail には Stalwart 0.16 以降が必要です。", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "このアカウントの公開鍵です。他の人が暗号化に使い、署名の検証にも使われます。公開鍵は本来公開されるものです。ihasmail は秘密鍵を保存も要求も送信もしません。", + "No keys yet": "鍵はまだありません", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "OpenPGP 公開鍵または S/MIME 証明書を追加して、このアカウントで公開します。", + "Key added": "鍵を追加しました", + "Key removed": "鍵を削除しました", + "Remove “{name}”?": "「{name}」を削除しますか?", + "this key": "この鍵", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "すでに持っている人は引き続き使用できます。ここで削除しても、このアカウントが提供しなくなるだけです。", + "Remove key": "鍵を削除", + "Key description": "鍵の説明", + "Untitled key": "名前のない鍵", + "Click to rename": "クリックして名前を変更", + "Expired": "期限切れ", + "Added": "追加日", + "Addresses": "アドレス", + "No expiry set": "有効期限なし", + "Any address on this account": "このアカウントのすべてのアドレス", + "cryptography\u0004Key": "鍵", + "Public key": "公開鍵", + "Work key": "仕事用の鍵", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "ヘッダーを含め、ブロック全体を貼り付けてください。サーバーが検証し、読み取れない場合は理由を表示します。", + "Add key": "鍵を追加", + "Add a key": "鍵を追加", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart はこれらの鍵を保存し、このリリースでは管理のみを行います。ihasmail はまだ署名も暗号化も復号も検証も行いません。鍵を追加しただけでメールの暗号化が始まるわけではありません。", + "Unrecognised": "認識できません", + "No account to add a key to.": "鍵を追加するアカウントがありません。", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/nl.ts b/web/src/locales/nl.ts index 05b0789..031c3f1 100644 --- a/web/src/locales/nl.ts +++ b/web/src/locales/nl.ts @@ -1300,6 +1300,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "„{name}” leegmaken?", "Delete them": "Verwijderen", "Nothing was deleted": "Er is niets verwijderd", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Versleutelingssleutels", + "Not available on this server": "Niet beschikbaar op deze server", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Openbare sleutels worden bewaard in het register van Stalwart, dat deze server niet aanbiedt. ihasmail heeft daarvoor Stalwart 0.16 of nieuwer nodig.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Openbare sleutels voor dit account — waarmee anderen naar u versleutelen, en waaraan een handtekening wordt gecontroleerd. Ze zijn van nature openbaar: ihasmail bewaart, vraagt en verstuurt geen enkele privésleutel.", + "No keys yet": "Nog geen sleutels", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Voeg een openbare OpenPGP-sleutel of een S/MIME-certificaat toe om het op dit account te publiceren.", + "Key added": "Sleutel toegevoegd", + "Key removed": "Sleutel verwijderd", + "Remove “{name}”?": "“{name}” verwijderen?", + "this key": "deze sleutel", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "Wie hem al heeft, kan hem blijven gebruiken — hem hier verwijderen zorgt er alleen voor dat dit account hem niet meer aanbiedt.", + "Remove key": "Sleutel verwijderen", + "Key description": "Sleutelomschrijving", + "Untitled key": "Naamloze sleutel", + "Click to rename": "Klik om te hernoemen", + "Expired": "Verlopen", + "Added": "Toegevoegd", + "Addresses": "Adressen", + "No expiry set": "Geen vervaldatum ingesteld", + "Any address on this account": "Elk adres van dit account", + "cryptography\u0004Key": "Sleutel", + "Public key": "Openbare sleutel", + "Work key": "Werksleutel", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Plak het hele beveiligde blok, inclusief de kopregels. De server controleert het en zegt wat er mis is als hij het niet kan lezen.", + "Add key": "Sleutel toevoegen", + "Add a key": "Een sleutel toevoegen", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart bewaart deze sleutels, en deze versie doet niet meer dan ze beheren: ihasmail ondertekent, versleutelt, ontsleutelt en verifieert er nog niets mee. Er een toevoegen begint niet vanzelf uw e-mail te versleutelen.", + "Unrecognised": "Niet herkend", + "No account to add a key to.": "Geen account om een sleutel aan toe te voegen.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/pt-BR.ts b/web/src/locales/pt-BR.ts index 7c11054..fb2b6e0 100644 --- a/web/src/locales/pt-BR.ts +++ b/web/src/locales/pt-BR.ts @@ -1307,6 +1307,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "Esvaziar “{name}”?", "Delete them": "Excluir todos", "Nothing was deleted": "Nada foi excluído", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Chaves de criptografia", + "Not available on this server": "Não disponível neste servidor", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "As chaves públicas ficam no registro do Stalwart, que este servidor não oferece. O ihasmail precisa do Stalwart 0.16 ou mais recente para isso.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Chaves públicas desta conta — o que outras pessoas usam para criptografar, e aquilo com que uma assinatura é conferida. Elas são públicas por natureza: o ihasmail não armazena, não pede e não envia nenhuma chave privada.", + "No keys yet": "Ainda não há chaves", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Adicione uma chave pública OpenPGP ou um certificado S/MIME para publicá-lo nesta conta.", + "Key added": "Chave adicionada", + "Key removed": "Chave removida", + "Remove “{name}”?": "Remover “{name}”?", + "this key": "esta chave", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "Quem já a tem continua podendo usá-la — removê-la aqui apenas faz esta conta parar de oferecê-la.", + "Remove key": "Remover chave", + "Key description": "Descrição da chave", + "Untitled key": "Chave sem título", + "Click to rename": "Clique para renomear", + "Expired": "Expirada", + "Added": "Adicionada", + "Addresses": "Endereços", + "No expiry set": "Sem validade definida", + "Any address on this account": "Qualquer endereço desta conta", + "cryptography\u0004Key": "Chave", + "Public key": "Chave pública", + "Work key": "Chave do trabalho", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Cole o bloco blindado inteiro, cabeçalhos incluídos. O servidor confere e diz o que está errado se não conseguir lê-lo.", + "Add key": "Adicionar chave", + "Add a key": "Adicionar uma chave", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "O Stalwart guarda essas chaves, e esta versão não faz mais do que gerenciá-las: o ihasmail ainda não assina, criptografa, descriptografa nem verifica nada com elas. Adicionar uma não começa a criptografar seus e-mails por si só.", + "Unrecognised": "Não reconhecida", + "No account to add a key to.": "Não há conta à qual adicionar uma chave.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index 0f03961..afd7188 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -1306,6 +1306,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "Очистить «{name}»?", "Delete them": "Удалить их", "Nothing was deleted": "Ничего не удалено", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Ключи шифрования", + "Not available on this server": "Недоступно на этом сервере", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Открытые ключи хранятся в реестре Stalwart, которого этот сервер не предоставляет. Для этого ihasmail требуется Stalwart 0.16 или новее.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Открытые ключи этой учётной записи — то, чем другие шифруют письма для вас, и то, по чему проверяется подпись. Они открыты по своей природе: ihasmail не хранит, не запрашивает и не отправляет закрытые ключи.", + "No keys yet": "Ключей пока нет", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Добавьте открытый ключ OpenPGP или сертификат S/MIME, чтобы опубликовать его в этой учётной записи.", + "Key added": "Ключ добавлен", + "Key removed": "Ключ убран", + "Remove “{name}”?": "Убрать «{name}»?", + "this key": "этот ключ", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "У того, кто его уже получил, он останется — здесь это лишь прекращает предлагать его от этой учётной записи.", + "Remove key": "Убрать ключ", + "Key description": "Описание ключа", + "Untitled key": "Ключ без названия", + "Click to rename": "Нажмите, чтобы переименовать", + "Expired": "Истёк", + "Added": "Добавлен", + "Addresses": "Адреса", + "No expiry set": "Срок действия не задан", + "Any address on this account": "Любой адрес этой учётной записи", + "cryptography\u0004Key": "Ключ", + "Public key": "Открытый ключ", + "Work key": "Рабочий ключ", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Вставьте весь блок целиком, вместе с заголовками. Сервер проверит его и сообщит, что не так, если не сможет прочитать.", + "Add key": "Добавить ключ", + "Add a key": "Добавить ключ", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart хранит эти ключи, а эта версия лишь управляет ими: ihasmail пока ничего ими не подписывает, не шифрует, не расшифровывает и не проверяет. Добавление ключа само по себе не начинает шифровать вашу почту.", + "Unrecognised": "Не распознан", + "No account to add a key to.": "Нет учётной записи, в которую можно добавить ключ.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/uk.ts b/web/src/locales/uk.ts index 860bdad..b20b114 100644 --- a/web/src/locales/uk.ts +++ b/web/src/locales/uk.ts @@ -1300,6 +1300,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "Очистити «{name}»?", "Delete them": "Видалити їх", "Nothing was deleted": "Нічого не видалено", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "Ключі шифрування", + "Not available on this server": "Недоступно на цьому сервері", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "Відкриті ключі зберігаються в реєстрі Stalwart, якого цей сервер не надає. Для цього ihasmail потребує Stalwart 0.16 або новішої версії.", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "Відкриті ключі цього облікового запису — те, чим інші шифрують листи для вас, і те, за чим перевіряється підпис. Вони відкриті за своєю природою: ihasmail не зберігає, не запитує і не надсилає закритих ключів.", + "No keys yet": "Ключів ще немає", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "Додайте відкритий ключ OpenPGP або сертифікат S/MIME, щоб опублікувати його в цьому обліковому записі.", + "Key added": "Ключ додано", + "Key removed": "Ключ прибрано", + "Remove “{name}”?": "Прибрати «{name}»?", + "this key": "цей ключ", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "У того, хто вже його має, він залишиться — тут це лише припиняє пропонувати його від цього облікового запису.", + "Remove key": "Прибрати ключ", + "Key description": "Опис ключа", + "Untitled key": "Ключ без назви", + "Click to rename": "Натисніть, щоб перейменувати", + "Expired": "Закінчився", + "Added": "Додано", + "Addresses": "Адреси", + "No expiry set": "Термін дії не задано", + "Any address on this account": "Будь-яка адреса цього облікового запису", + "cryptography\u0004Key": "Ключ", + "Public key": "Відкритий ключ", + "Work key": "Робочий ключ", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "Вставте весь блок цілком, разом із заголовками. Сервер перевірить його і скаже, що не так, якщо не зможе прочитати.", + "Add key": "Додати ключ", + "Add a key": "Додати ключ", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart зберігає ці ключі, а ця версія лише керує ними: ihasmail поки нічого ними не підписує, не шифрує, не розшифровує і не перевіряє. Додавання ключа саме собою не починає шифрувати вашу пошту.", + "Unrecognised": "Не розпізнано", + "No account to add a key to.": "Немає облікового запису, до якого можна додати ключ.", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/locales/zh-Hans.ts b/web/src/locales/zh-Hans.ts index 869eff7..728db6f 100644 --- a/web/src/locales/zh-Hans.ts +++ b/web/src/locales/zh-Hans.ts @@ -1311,6 +1311,36 @@ export const catalog: Catalog = { "Empty “{name}”?": "清空“{name}”?", "Delete them": "删除", "Nothing was deleted": "未删除任何内容", + // ── Encryption keys, over Stalwart's x:PublicKey registry (#67) ── + "Encryption keys": "加密密钥", + "Not available on this server": "此服务器不支持", + "Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.": "公钥保存在 Stalwart 的注册表中,而此服务器未提供该功能。ihasmail 需要 Stalwart 0.16 或更高版本。", + "Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.": "此账户的公钥——他人用它加密,签名也据此校验。公钥本就是公开的:ihasmail 不会存储、索取或发送任何私钥。", + "No keys yet": "尚无密钥", + "Add an OpenPGP public key or an S/MIME certificate to publish it on this account.": "添加 OpenPGP 公钥或 S/MIME 证书,将其发布到此账户。", + "Key added": "已添加密钥", + "Key removed": "已移除密钥", + "Remove “{name}”?": "移除「{name}」?", + "this key": "此密钥", + "Anyone holding it can still use it — removing it here only stops this account offering it.": "已经持有它的人仍可继续使用——在此移除只会让此账户不再提供它。", + "Remove key": "移除密钥", + "Key description": "密钥说明", + "Untitled key": "未命名密钥", + "Click to rename": "点击以重命名", + "Expired": "已过期", + "Added": "添加时间", + "Addresses": "地址", + "No expiry set": "未设置有效期", + "Any address on this account": "此账户的任意地址", + "cryptography\u0004Key": "密钥", + "Public key": "公钥", + "Work key": "工作密钥", + "Paste the whole armoured block, headers included. The server checks it and says what is wrong if it cannot read it.": "请粘贴完整的密钥文本块,包括头尾行。服务器会进行校验,无法读取时会说明原因。", + "Add key": "添加密钥", + "Add a key": "添加密钥", + "Stalwart stores these keys, and this release does no more than manage them: ihasmail does not yet sign, encrypt, decrypt or verify anything with them. Adding one does not by itself start encrypting your mail.": "Stalwart 会保存这些密钥,而此版本仅负责管理:ihasmail 尚不会用它们签名、加密、解密或验证。添加密钥本身并不会开始加密您的邮件。", + "Unrecognised": "无法识别", + "No account to add a key to.": "没有可添加密钥的账户。", }, plurals: { // ── Third pass ───────────────────────────────────────────────────── diff --git a/web/src/views/settings/KeysSettings.tsx b/web/src/views/settings/KeysSettings.tsx new file mode 100644 index 0000000..9afad3f --- /dev/null +++ b/web/src/views/settings/KeysSettings.tsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useState } from "react"; +import { KeyRound, Plus, Trash2 } from "lucide-react"; +import { + addPublicKey, + isExpired, + keyExcerpt, + keyKind, + keyKindLabel, + listPublicKeys, + publicKeysAvailable, + removePublicKey, + updatePublicKey, + type PublicKey, +} from "@/lib/publicKeys"; +import { formatFullDate } from "@/lib/format"; +import { confirmDialog } from "@/ui/dialog"; +import { Empty, Spinner } from "@/ui/misc"; +import { toast } from "@/ui/toast"; +import { t, tc } from "@/lib/i18n"; + +/** + * Public keys for this account: the ones other people encrypt to, and the ones + * a signature is checked against. Nothing here handles a private key, and + * nothing here asks for one. + * + * The page is careful not to overstate what adding a key achieves. Stalwart + * stores keys, and `encryptionAtRest` on `x:AccountSettings` is the one thing + * known to consume one; nothing in ihasmail signs, encrypts, decrypts or + * verifies with them yet. Saying otherwise would be a guess dressed as a + * feature. + */ +export function KeysSettings() { + const [keys, setKeys] = useState(null); + const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [busy, setBusy] = useState(false); + const [editing, setEditing] = useState(null); + const [draftKey, setDraftKey] = useState(""); + const [draftName, setDraftName] = useState(""); + const available = publicKeysAvailable(); + + const load = useCallback(async () => { + if (!available) { + setKeys([]); + return; + } + try { + setKeys(await listPublicKeys()); + setError(null); + } catch (err) { + setError((err as Error).message); + setKeys([]); + } + }, [available]); + + useEffect(() => { + void load(); + }, [load]); + + const add = async () => { + if (!draftKey.trim()) return; + setBusy(true); + try { + // An empty description is left empty rather than filled in with an + // English default: the description is stored on the server, so a name + // invented here would be whatever language the adder happened to use. + // The list labels a blank one at render time instead. + await addPublicKey(draftKey, draftName); + setDraftKey(""); + setDraftName(""); + setAdding(false); + toast.success(t("Key added")); + await load(); + } catch (err) { + // Stalwart parsed the key and knows exactly what is wrong with it, in + // more detail than anything invented here could manage. + toast.error((err as Error).message); + } finally { + setBusy(false); + } + }; + + const rename = async (k: PublicKey, description: string) => { + if (description === k.description) return; + try { + await updatePublicKey(k.id, { description }); + await load(); + } catch (err) { + toast.error((err as Error).message); + await load(); + } + }; + + const remove = async (k: PublicKey) => { + const ok = await confirmDialog({ + title: t("Remove “{name}”?", { name: k.description || t("this key") }), + message: t("Anyone holding it can still use it — removing it here only stops this account offering it."), + confirmLabel: t("Remove key"), + danger: true, + }); + if (!ok) return; + try { + await removePublicKey(k.id); + toast.success(t("Key removed")); + } catch (err) { + toast.error((err as Error).message); + } + await load(); + }; + + if (!available) { + return ( +
+

{t("Encryption keys")}

+ } title={t("Not available on this server")}> + {t("Public keys are kept in Stalwart's registry, which this server does not offer. ihasmail needs Stalwart 0.16 or newer for it.")} + +
+ ); + } + + return ( +
+

{t("Encryption keys")}

+

+ {t("Public keys for this account — what other people encrypt to, and what a signature is checked against. These are public by nature: no private key is stored, requested, or sent by ihasmail.")} +

+ + {error &&
{error}
} + + {keys === null ? ( +
+ ) : keys.length === 0 && !adding ? ( + } title={t("No keys yet")}> + {t("Add an OpenPGP public key or an S/MIME certificate to publish it on this account.")} + + ) : ( + keys.map((k) => { + const kind = keyKind(k.key); + const expired = isExpired(k); + return ( +
+
+ + {editing === k.id ? ( + { void rename(k, e.target.value.trim() || k.description); setEditing(null); }} + onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setEditing(null); }} + /> + ) : ( +

setEditing(k.id)} title={t("Click to rename")}>{k.description || t("Untitled key")}

+ )} + {keyKindLabel(kind)} + {expired && {t("Expired")}} + +
+ + + + + + + +
{t("Added")}{k.createdAt ? formatFullDate(k.createdAt) : "—"}
{t("Expires")}{k.expiresAt ? formatFullDate(k.expiresAt) : t("No expiry set")}
{t("Addresses")}{k.emailAddresses.length ? k.emailAddresses.join(", ") : t("Any address on this account")}
{tc("cryptography", "Key")}{keyExcerpt(k.key)}…
+
+ ); + }) + )} + + {adding ? ( +
+
+ + setDraftName(e.target.value)} /> +
+
+ +