Skip to content

feat(smime): add S/MIME support via companion-app architecture#11033

Open
christine-ciphermail wants to merge 12 commits into
thunderbird:mainfrom
christine-ciphermail:upstream-smime-pr
Open

feat(smime): add S/MIME support via companion-app architecture#11033
christine-ciphermail wants to merge 12 commits into
thunderbird:mainfrom
christine-ciphermail:upstream-smime-pr

Conversation

@christine-ciphermail
Copy link
Copy Markdown

Summary

Adds end-to-end S/MIME support to Thunderbird for Android via a companion-app architecture, mirroring the existing OpenPGP integration: key material and cryptographic operations live in a separate provider app, and Thunderbird talks to it over a stable AIDL API.

A reference provider implementation (CipherMail for Android) is the first consumer of this API, but any S/MIME provider can implement it.

What's included

New module plugins/smime-api/ — versioned AIDL + Parcelables + client helper. Pluggable across providers, mirrored in the CipherMail companion repo. Independent licensing under Apache-2.0.

Integration in legacy core / UI:

  • Account-level Sign/Encrypt toggles, with per-account defaults (account settings → S/MIME)
  • Compose: per-message Sign/Encrypt checkboxes; encrypt-implies-sign enforced; lock/sign icons in the recipient bar reflect provider's certificate availability check
  • Message view: separate "signed" / "encrypted" status icons and an "Open in provider" action for messages Thunderbird can't render inline
  • S/MIME state persisted separately from the legacy crypto-enabled flag so a message is never sent silently unencrypted when the provider is unavailable

Safety behaviour:

  • If the provider isn't installed or returns USER_INTERACTION_REQUIRED (e.g. provider keystore is locked), the compose flow pauses and surfaces the action to the user — the message is preserved, not lost
  • Drafts are offered a folder assignment when the composer is closed before a save completes

Documentation:

  • docs/adr/0009-smime-companion-app-architecture.md — architectural decision and rationale (why companion app vs. built-in)
  • docs/security/smime-companion-threat-model.md — threat model for the inter-process boundary
  • docs/developer/writing-smime-provider.md — guide for implementers of the API
  • docs/user-guide/setup/enabling-smime.md — end-user setup walkthrough

Why companion-app and not built-in?

Same reasons OpenPGP went this route in K-9 Mail / Thunderbird for Android: cryptographic key material, certificate stores, PIN unlock flows, and smartcard support are non-trivial concerns that benefit from living in a single, dedicated, hardened process. Thunderbird stays a mail client; providers stay providers. See ADR-0009 for the full discussion.

Test plan

  • Build cleanly on AGP 9 / Gradle 9 / JDK 21
  • Existing unit tests pass; new tests cover RecipientPresenter.onSmimeCertCheckResult branches
  • Manual: enable S/MIME on an account, sign-only, encrypt+sign, send/receive between two devices using CipherMail provider
  • Manual: uninstall provider, confirm error path (no silent unencrypted send, message preserved)
  • Manual: provider keystore locked → USER_INTERACTION_REQUIRED flow opens the provider's unlock UI, retry succeeds

Notes

  • No breaking changes to existing behaviour for users who don't enable S/MIME on an account
  • API module is versioned independently (plugins/smime-api/CHANGELOG.md) so providers and Thunderbird can evolve at their own pace

christine-ciphermail and others added 12 commits May 21, 2026 15:45
Adds support for delegating S/MIME crypto operations to a separate
companion app over an AIDL service, paralleling the existing OpenPGP /
OpenKeychain integration. The reference provider is CipherMail
(com.ciphermail.android); other providers can implement the same API.

API surface (new module `plugins/smime-api/smime-api/`)
  - ISmimeService AIDL — execute(Intent, ParcelFileDescriptor, int) +
    createOutputPipe(int) for streaming bulk MIME data.
  - SmimeApi helper (sync + async execution wrappers, pipe management).
  - SmimeServiceConnection (bind lifecycle helper).
  - Parcelables: SmimeError, SmimeSignatureResult, SmimeDecryptionResult,
    SmimeCertificateInfo. All carry PARCELABLE_VERSION = 1.
  - Actions: CHECK_PERMISSION, DECRYPT_VERIFY, SIGN_AND_ENCRYPT,
    GET_CERTIFICATES, IMPORT_CERTIFICATE.

Receive-side integration
  - SmimeCryptoHelper (parallels MessageCryptoHelper for OpenPGP):
    detects S/MIME parts, binds to the provider, dispatches DECRYPT_VERIFY,
    surfaces RESULT_CODE_USER_INTERACTION_REQUIRED via PendingIntent so
    the host can launch the provider's passphrase dialog.
  - MessageCryptoStructureDetector.isSmimePart and helpers — detect
    application/pkcs7-mime and PKCS#7 multipart/signed.
  - CryptoResultAnnotation: new S/MIME fields and createSmime* factories.
  - MessageCryptoDisplayStatus: S/MIME signature/encryption mappings to
    the existing display-status badges.
  - MessageLoaderHelper, MessageCryptoPresenter, MessageViewInfoExtractor
    wired through.

Send-side integration
  - SmimeMessageBuilder (parallels PgpMessageBuilder): binds to the
    provider on a background thread, calls SIGN_AND_ENCRYPT, returns the
    wrapped MIME message for SMTP transport. Drafts bypass crypto.
  - MessageCompose: S/MIME branch in createMessageBuilder(), checked
    before PGP.
  - RecipientPresenter.asyncUpdateSmimeCertStatus: calls
    GET_CERTIFICATES on recipient changes; drives the compose lock-icon
    state (green = all certs present, red = missing).

Per-account configuration
  - LegacyAccount / LegacyAccountDto: smimeProvider field +
    isSmimeProviderConfigured.
  - LegacyAccountStorageHandler + DefaultLegacyAccountDataMapper:
    persist smimeProvider.
  - AccountSettingsFragment: S/MIME PreferenceScreen + provider picker.
  - SmimeAppSelectDialog: enumerates installed providers via
    SmimeApi.SERVICE_INTENT and lets the user choose. Binding always
    uses setPackage(account.smimeProvider) to avoid intent-filter
    interception.

Manifest plumbing
  - app-k9mail and app-thunderbird AndroidManifest: <queries> for
    ISmimeService discovery on Android 11+.
  - legacy/ui/legacy AndroidManifest: register SmimeAppSelectDialog.

Cross-process passphrase handshake
  - When the provider's keystore is locked it returns
    RESULT_CODE_USER_INTERACTION_REQUIRED with an immutable PendingIntent
    for its passphrase activity. Thunderbird launches via
    startIntentSenderForResult and retries on RESULT_OK. No inline
    prompting, no IPC timeouts.
…rovider guide

Adds end-to-end documentation for the S/MIME companion integration:

Library docs (`plugins/smime-api/`)
  - README — client-side tutorial (bind, execute, result + PendingIntent
    flow) and action-by-action reference. Mirrors openpgp-api-lib/README.
  - CHANGELOG — Version 1 inventory of actions / extras / Parcelables.
  - LICENSE — Apache 2.0 (matches openpgp-api-lib).

mdbook docs
  - architecture/adr/0009 — Companion App + AIDL Service for S/MIME:
    decision record covering the three alternatives (in-process library,
    embedded crypto core, companion app) and why the companion-app model
    was chosen. Includes a Mermaid sequence diagram for the sign+encrypt
    + passphrase-unlock flow.
  - security/smime-companion-threat-model — STRIDE pass on the IPC trust
    boundary: provider discovery, binding, request/result tampering,
    PendingIntent hijacking, pipe DoS, cert-lookup honesty. Risks ranked,
    residual-risk notes for the two trade-offs inherent to the model.
  - user-guide/setup/enabling-smime — end-user walkthrough (install
    provider, set keystore passphrase, import certificate, enable
    S/MIME on the account, first send/receive). Includes a compose
    lock-icon state reference and a translator's inventory of the new
    string resources.
  - developer/writing-smime-provider — normative spec for implementing
    an alternative S/MIME provider: manifest declarations, AIDL contract,
    per-action behaviour and edge cases, the user-interaction handshake,
    EXTRA_API_VERSION negotiation, security obligations (caller identity,
    no outbound network, trust-signal honesty), a testing checklist.
  - SUMMARY.md — all four new documents wired into the mdbook TOC.
The SmimeCertificateInfo class loader was only set inside the
RESULT_CODE_SUCCESS branch, after extras had already been read.
Move it above the resultCode check so all extras access uses
the correct loader.
…ranches

Open up onSmimeCertCheckResult for testing (private -> internal +
@VisibleForTesting) and add four basic tests for the result-code
branches: all certs valid, one cert invalid, missing certificates
extra, and a non-success result code. Ensures the cert-status UI
mapping won't silently regress as the S/MIME companion API evolves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sending on an S/MIME account when the provider app (e.g. CipherMail) was
uninstalled discarded the composed message: the build failed and
onMessageBuildException only showed a toast.

- MessageCompose.checkToSendMessage(): pre-send check that the configured
  provider package is installed; if not, save to Drafts first, then show
  an explanatory dialog and close the composer.
- MessageCompose.onMessageBuildException(): rescue-save the composed
  message to Drafts on any send-build failure before reporting it; a
  reset-per-send guard prevents a failing rescue from recursing.
- SmimeMessageBuilder: bound the service-bind wait (30s) so a bind that
  never connects fails the build instead of hanging the compose thread
  forever.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…er assignment

Follow-ups to the "never lose a message" fix:

- handleSmimeProviderMissing(): close the composer only once BOTH the
  async draft save has completed and the user has dismissed the dialog
  (smimeMissingDraftSaved + smimeMissingDialogAcknowledged). Previously a
  fast tap on the dialog's OK called finish() before SaveMessageTask ran,
  so the background save was preempted and the draft was lost.

- No Drafts folder assigned (account.hasDraftsFolder() == false) left the
  user with no way to keep a message: the send-time rescue couldn't save
  and DIALOG_CONFIRM_DISCARD_ON_BACK only offered Discard/Cancel. Both
  dialogs now offer "Assign Drafts folder…" which opens Account Settings >
  Folders (mirrors the existing Sent-folder-not-found flow); the composer
  is never silently discarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nencrypted

Previously "S/MIME enabled" was derived from smimeProvider != null. Turning
S/MIME on with no provider installed couldn't store anything, so the
setting silently stayed off and mail went out unencrypted; the settings
screen also auto-cleared a configured-but-missing provider back to off.

- LegacyAccountDto/LegacyAccount: new persisted smimeEnabled flag,
  independent of the resolved provider package. isSmimeProviderConfigured
  now means "S/MIME turned on". Storage handler + mapper updated;
  backward-compatible default (accounts with a provider load as enabled).

- Account settings: enabling with no provider installed persists ON,
  stays on the page, warns via dialog. Configured-but-missing provider no
  longer auto-clears — shows ON with a "CipherMail not installed" summary.
  Auto-resolves the provider package when exactly one becomes installed
  (handles install-later and debug/release package swap).

- Send: the pre-send block now fires whenever S/MIME is enabled but the
  provider is missing, so an S/MIME account can never silently send in the
  clear. The block dialog offers a clear choice: install CipherMail (Play
  Store), turn S/MIME off and send unencrypted, or keep the message (save
  to Drafts / assign a Drafts folder). Removed the now-redundant
  rescue-gate; the save path reuses the race-free checkToSaveDraftAndSave.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
S/MIME previously always signed and encrypted (hardcoded). Give the user
per-message control plus a configurable default.

- LegacyAccountDto/LegacyAccount: new persisted smimeSign/smimeEncrypt
  (default true), serving as both the per-account default and last-used
  memory. Storage handler + mapper updated.

- Composer: inline "S/MIME  [ ] Sign  [ ] Encrypt" bar
  (message_compose_recipients.xml), shown only for S/MIME-enabled
  accounts, pre-selected from the account, and written back on change so
  the last choice is remembered. createMessageBuilder honours the boxes;
  both unchecked sends a plain message (no provider call), and the
  provider-missing pre-send block only fires when crypto is requested.

- Account settings: "Sign by default" / "Encrypt by default"
  CheckBoxPreferences under the S/MIME switch, shown while S/MIME is on,
  bound to the same account fields so settings and composer stay in sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Maintain the invariant in all three places sign/encrypt is decided:
- composer checkboxes: checking Encrypt forces Sign on; unchecking Sign
  forces Encrypt off; initial state coerced (sign = sign || encrypt)
- account-settings default checkboxes: same coupling, kept in sync and
  persisted
- createMessageBuilder: smimeSign = checked || smimeEncrypt, defensive
  against stale persisted state (e.g. upgraded accounts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…r" action

- Split the combined S/MIME status indicator into separate
  "signed" and "encrypted" icons in the message view header
- Add an "Open in CipherMail" action for incoming S/MIME messages
  that Thunderbird cannot render inline
- Rework the message_view_header layout to fit the new icons
Tint the icon green instead of blue so it doesn't visually merge with
the adjacent blue "to ..." line, and align its leading margin with the
sign/encrypt icons so spacing is consistent across the row.
@github-actions
Copy link
Copy Markdown
Contributor

Missing report label. Set exactly one of: report: include, report: exclude OR report: highlight.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants