feat(smime): add S/MIME support via companion-app architecture#11033
Open
christine-ciphermail wants to merge 12 commits into
Open
feat(smime): add S/MIME support via companion-app architecture#11033christine-ciphermail wants to merge 12 commits into
christine-ciphermail wants to merge 12 commits into
Conversation
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.
Contributor
|
Missing report label. Set exactly one of: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Safety behaviour:
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 lostDocumentation:
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 boundarydocs/developer/writing-smime-provider.md— guide for implementers of the APIdocs/user-guide/setup/enabling-smime.md— end-user setup walkthroughWhy 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
RecipientPresenter.onSmimeCertCheckResultbranchesUSER_INTERACTION_REQUIREDflow opens the provider's unlock UI, retry succeedsNotes
plugins/smime-api/CHANGELOG.md) so providers and Thunderbird can evolve at their own pace