diff --git a/app-k9mail/src/main/AndroidManifest.xml b/app-k9mail/src/main/AndroidManifest.xml index 222feb98e98..449a6ade994 100644 --- a/app-k9mail/src/main/AndroidManifest.xml +++ b/app-k9mail/src/main/AndroidManifest.xml @@ -19,6 +19,12 @@ android:theme="@style/Theme.K9.DayNight.Dialog.Translucent" /> + + + + ` query for `com.ciphermail.smime.api.ISmimeService`. + When more than one provider is installed, `SmimeAppSelectDialog` lets the user choose. +- The service contract exposes five actions: `CHECK_PERMISSION`, `DECRYPT_VERIFY`, `SIGN_AND_ENCRYPT`, + `GET_CERTIFICATES`, `IMPORT_CERTIFICATE`. Bulk MIME data streams through `ParcelFileDescriptor` pipes rather than + Intent extras, keeping Binder transactions small. +- Cross-process user interaction (e.g. keystore-passphrase entry) follows the same `RESULT_CODE_USER_INTERACTION_REQUIRED` + + `PendingIntent` pattern OpenKeychain already uses. The provider's passphrase dialog runs in the provider's process; + Thunderbird launches it via `startIntentSenderForResult` and retries the operation on `RESULT_OK`. +- Per-account configuration mirrors OpenPGP: a new `smimeProvider` field on `LegacyAccount` selects which installed + provider that account uses, surfaced as a Preference under Settings → S/MIME. + +### Request flow — sign and encrypt (with passphrase unlock) + +```mermaid +sequenceDiagram + autonumber + participant TB as Thunderbird + participant SVC as Provider service
(ISmimeService) + participant DIA as Provider passphrase
dialog + participant KEY as Provider keystore + + TB->>SVC: createOutputPipe(pipeId) + SVC-->>TB: ParcelFileDescriptor (write end) + TB->>SVC: execute(SIGN_AND_ENCRYPT, inputPipe, pipeId) + SVC->>KEY: isUnlocked() + KEY-->>SVC: false + SVC-->>TB: RESULT_CODE_USER_INTERACTION_REQUIRED
+ immutable PendingIntent + + TB->>DIA: startIntentSenderForResult(PendingIntent) + Note over DIA: user enters passphrase + DIA-)KEY: unlock (provider-internal) + DIA-->>TB: RESULT_OK + + TB->>SVC: execute(SIGN_AND_ENCRYPT, inputPipe, pipeId) [retry] + SVC->>KEY: sign + encrypt + SVC->>TB: stream wrapped MIME bytes via output pipe + SVC-->>TB: RESULT_CODE_SUCCESS +``` + +The `USER_INTERACTION_REQUIRED` reply returns within tens of milliseconds. +The retry succeeds without further user input because the provider's +keystore is now unlocked. Decrypt + verify follows the same shape, with +`SmimeDecryptionResult` and `SmimeSignatureResult` returned as result +Parcelables in step 11. + +## Consequences + +### Positive Consequences + +- S/MIME implementation, key material, and certificate stores stay outside the Thunderbird process and APK. +- License isolation: the GPL crypto core ships in a separate app with its own distribution channel. +- Symmetry with OpenPGP makes the integration easy to reason about; existing patterns (`MessageCryptoHelper`, + `RecipientPresenter`, `MessageCompose`) extend directly. +- Multi-provider support is free: any app that declares the AIDL service appears in `SmimeAppSelectDialog`. +- Thunderbird's binary size and dependency graph are essentially unchanged — only the small AIDL stub is added. + +### Negative Consequences + +- Users must install two apps to use S/MIME. CipherMail is published on Google Play (and is planned for F-Droid once + this companion API lands upstream), so the install is a single search-and-install step, but it is still a separate + user action that Thunderbird cannot perform automatically. The S/MIME preference row surfaces an explanatory message + when no provider is installed. +- Cross-process round-trips add latency on the first call after process start (provider cold-start, keystore unlock). + Subsequent calls are fast. +- The cross-process passphrase-unlock dance is more involved than an in-process prompt and was a source of early bugs + (cached-passphrase singletons, broadcast receivers, AndroidKeyStore key loss after reinstall). +- The contract between Thunderbird and the provider becomes a versioned external API; breaking changes require an + `EXTRA_API_VERSION` bump and explicit incompatibility handling in `SmimeError.INCOMPATIBLE_API_VERSIONS`. +- Two repositories must be kept in sync: `plugins/smime-api/smime-api/` here, and the mirrored module in the CipherMail + repository at `smime-api/`. diff --git a/docs/developer/writing-smime-provider.md b/docs/developer/writing-smime-provider.md new file mode 100644 index 00000000000..b476d37b043 --- /dev/null +++ b/docs/developer/writing-smime-provider.md @@ -0,0 +1,296 @@ +# Writing an S/MIME provider + +This document specifies, normatively, what an Android app must do to act as +an S/MIME provider for Thunderbird via the API in `plugins/smime-api/`. It +is the implementer's guide; for the client-side counterpart see +[`plugins/smime-api/README.md`](../../plugins/smime-api/README.md), and for +the architectural rationale see +[ADR 0009](../architecture/adr/0009-smime-companion-app-architecture.md). + +Use this document when: + +- Reviewing a new provider for inclusion in the picker. +- Auditing the reference provider (CipherMail) against the contract. +- Considering a change to the wire protocol (bump `API_VERSION` if any rule + below changes). + +## Overview + +A provider is an Android app that: + +1. Declares a **bound service** implementing `ISmimeService`, discoverable by + the `com.ciphermail.smime.api.ISmimeService` intent action. +2. Performs S/MIME operations on behalf of mail clients, with all key + material and certificate state living **inside the provider's own + process** — never crossing the IPC boundary. +3. Surfaces user interaction (passphrase, consent) via `PendingIntent`s + returned in a `RESULT_CODE_USER_INTERACTION_REQUIRED` reply. + +Conformance to this guide is mandatory. Clients (Thunderbird) trust the +provider for both the cryptographic outcome **and** for honest reporting of +that outcome — failing to comply is a security defect, not a usability one. + +## 1. Manifest declarations + +Add to your `AndroidManifest.xml`: + +```xml + + + + + +``` + +Rules: + +- The service **must** be `exported="true"` — clients live in another + process. +- Do **not** guard the service with a custom `android:permission`. The + obvious choice (a `normal`-protection bind permission) does not provide + meaningful security — any app can declare `` for it — + and creates a real install-order trap: if a client app is installed + before the provider, the system never grants the permission and binds + silently fail until the client is reinstalled. Authorisation belongs in + `handleCheckPermission` (see §3) where caller identity can be checked + per-call. +- The intent-filter `action` **must** match the constant + `SmimeApi.SERVICE_INTENT`. +- Any activity launched via `PendingIntent` for user interaction (e.g. a + passphrase dialog) **must** be `exported="false"`. The PendingIntent + carries the launch grant; the activity itself must not be directly + invocable. + +## 2. The AIDL contract + +`ISmimeService.aidl` has exactly two methods: + +```java +ParcelFileDescriptor createOutputPipe(in int pipeId); +Intent execute(in Intent data, in ParcelFileDescriptor input, int pipeId); +``` + +`createOutputPipe` is called *before* `execute` for any operation that +produces a bulk byte stream (decrypted MIME, signed/encrypted output). The +provider must: + +- Use a fresh anonymous pipe (`ParcelFileDescriptor.createPipe()`) per call. +- Keep the read end internally, return the write end. The client reads from + its own read end of the pair it created independently. +- Associate the pipe with `pipeId` so the subsequent `execute()` call can + look it up. Pipe ids are scoped per-client; do not assume global + uniqueness. + +`execute` is the operation dispatch. The provider must: + +- Inspect `data.getAction()` to route. +- Inspect `data.getIntExtra(EXTRA_API_VERSION, 0)` first. If unsupported, + return `RESULT_CODE_ERROR` with `SmimeError.INCOMPATIBLE_API_VERSIONS` and + do nothing else. +- Block until the operation completes or fails. Long-running operations are + acceptable; the client is on a worker thread. +- Close the write end of the output pipe before returning. Failure to close + leaks an FD on the client side and stalls its read loop. + +## 3. Action-by-action behaviour + +### `ACTION_CHECK_PERMISSION` + +- **Purpose:** Let the client confirm consent without performing crypto. +- **Streams:** None. +- **Required behaviour:** On first call after install (or after the user + revoked consent), return `RESULT_CODE_USER_INTERACTION_REQUIRED` with a + `PendingIntent` for a consent activity. On subsequent calls, return + `RESULT_CODE_SUCCESS`. +- **Anti-pattern:** Returning `SUCCESS` unconditionally without a consent + step. The reference implementation has a `TODO` here today; new providers + must not copy that gap. + +### `ACTION_DECRYPT_VERIFY` + +- **Streams:** Input = raw MIME bytes of an `application/pkcs7-mime` part + or a `multipart/signed` part. Output = the decrypted/inner MIME content. +- **Required result extras:** + - `RESULT_DECRYPTION` — `SmimeDecryptionResult`. + - `RESULT_NOT_ENCRYPTED` for signed-only or plaintext inputs. + - `RESULT_ENCRYPTED` after successful decryption. + - `RESULT_SIGNATURE` — `SmimeSignatureResult`. Must be present whenever + the input carried a signature, even if invalid. Use `RESULT_NO_SIGNATURE` + when no signature was present. +- **Trust signal honesty:** + - `RESULT_VALID_TRUSTED` only if the certificate chain validates **to a + root in the user's trust store** at the time of the operation. Pinned + or self-signed certs are `RESULT_VALID_UNTRUSTED`. + - Expiry / revocation must be reported through their own codes, not + masked as `INVALID_SIGNATURE`. +- **Failure modes:** If decryption fails because the user's keystore is + locked, return `RESULT_CODE_USER_INTERACTION_REQUIRED` (see §4). Other + failures return `RESULT_CODE_ERROR` with an appropriate `SmimeError`. + +### `ACTION_SIGN_AND_ENCRYPT` + +- **Streams:** Input = plain MIME message bytes. Output = wrapped S/MIME + bytes ready for SMTP transport. +- **Required extras:** `EXTRA_USER_IDS` (recipient addresses). +- **Optional extras:** `EXTRA_SIGN` (default `true`), `EXTRA_ENCRYPT` + (default `true`), `EXTRA_FROM` (the composing account's sender address — + see below). +- **Required behaviour:** + - The signing identity is selected by `EXTRA_FROM`. A provider that holds + more than one signing identity **must** sign with the certificate + matching `EXTRA_FROM` and **must not** fall back to a different + identity's certificate. If signing was requested and no usable + certificate matches `EXTRA_FROM`, return `RESULT_CODE_ERROR` — signing + as the wrong identity is a fail-open defect, not a convenience. When + `EXTRA_FROM` is absent (older client), a provider may fall back to a + single configured default identity. + - When `EXTRA_ENCRYPT` is `true` and any recipient lacks a certificate, + return `RESULT_CODE_ERROR` with `SmimeError.NO_CERTIFICATE_FOR_RECIPIENT`. + **Do not** silently send to a partial set or downgrade to sign-only — + that is a fail-open defect. + - When keystore is locked, return `RESULT_CODE_USER_INTERACTION_REQUIRED` + immediately. Do not block on an inline prompt; the client process owns + no UI on your behalf. + +### `ACTION_GET_CERTIFICATES` + +- **Streams:** None. +- **Required extras:** `EXTRA_USER_IDS`. +- **Required result extras:** `RESULT_CERTIFICATES` — one + `SmimeCertificateInfo` per *input* address, in the same order, with + `hasValidCertificate` set honestly. Returning a partial list violates the + contract; the client uses index-aligned positions to drive its lock-icon + state. +- **Performance:** Must complete within a few hundred milliseconds for typical + recipient counts (≤10). Clients call this on every recipient-field change + in the compose screen. Slow lookups will visibly stall typing. +- **Privacy:** The recipient list is shared with you. Do not log it, persist + it, or transmit it off-device. This is the IPC boundary at which the user + is implicitly trusting your provider. + +### `ACTION_IMPORT_CERTIFICATE` + +- **Streams:** Input = DER- or PEM-encoded certificate bytes. +- **Behaviour:** Import into the provider's certificate store. Duplicates + should be deduplicated, not error. +- **No result Parcelables required**, only `RESULT_CODE`. + +## 4. The user-interaction handshake + +When an operation needs user input (locked keystore, missing consent), do +**not** block waiting for it. Instead: + +1. Build a `PendingIntent` that, when fired, launches your provider-owned + activity (e.g. passphrase dialog). +2. Set `FLAG_IMMUTABLE`. Never `FLAG_MUTABLE`. The client must not be able + to mutate the launch intent. +3. Target an **explicit `ComponentName`**, not just an action. +4. Set the result extras: + ``` + RESULT_CODE = RESULT_CODE_USER_INTERACTION_REQUIRED + RESULT_INTENT = + ``` +5. Return immediately. + +The client launches via `startIntentSenderForResult`, the user completes the +interaction, and the client retries the original request from scratch. On +the retry, the precondition that prompted the prompt (e.g. cached +passphrase) must hold without further user input — otherwise the client will +loop. + +If the interaction is async-broadcast-based (as in CipherMail's +`PASSPHRASE_BROADCAST_ACTION`): + +- Register the receiver with `RECEIVER_NOT_EXPORTED` (Android 13+). +- Restrict the broadcast intent with `setPackage(getPackageName())`. +- On older Android, gate via a `signature`-level permission you own. +- Never carry passphrase material in an exported broadcast. + +## 5. Version negotiation + +`SmimeApi.API_VERSION` is `1` today. Providers **must**: + +- Accept the current `API_VERSION` value. +- Return `SmimeError.INCOMPATIBLE_API_VERSIONS` for any unknown version. +- When `API_VERSION` is bumped (additive change), continue to accept the + older value with reduced functionality where possible; bump only when a + breaking change is unavoidable. + +Adding new optional extras to an existing action is **not** a version bump. +Removing or changing the semantics of an existing action/extra is. + +## 6. Security obligations + +The provider is part of the user's TCB for email confidentiality and +authenticity. Concretely: + +- **Caller identity.** For any operation that exposes private keys (sign, + decrypt), the provider should verify the calling package via + `Binder.getCallingUid()` + `PackageManager`. If your security model + permits any caller (e.g. you intend to serve multiple mail clients), + document that explicitly and surface the caller package in any consent + UI. +- **No outbound network.** S/MIME operations must not touch the network as + a side effect. OCSP / CRL fetching is acceptable only if explicitly + enabled by the user and logged. +- **Key material at rest.** Private keys must be encrypted with a + device-bound key (AndroidKeyStore) wrapping a user-passphrase-derived + key. Reinstall correctly forces a re-import (the device-bound key is + wiped). +- **Passphrase cache lifetime.** Cache should clear on logout, device lock + (configurable), or explicit user action. Indefinite caching is a defect. +- **Logging.** No PII, no passphrases, no key bytes, no decrypted content + in logs at any log level. Recipient lists are PII. +- **Trust signal honesty.** As detailed in §3 — never report a stronger + signature/encryption status than was actually achieved. + +## 7. Multiple providers on one device + +The user may have more than one S/MIME provider installed. The picker in +`SmimeAppSelectDialog` enumerates everything matching the service intent. +Implications: + +- Your service must be discoverable by `setPackage(...)`-targeted binds; + do not require any custom auto-discovery handshake. +- Surface your application label and icon clearly — they are how the user + distinguishes providers in the picker. +- Do not assume yours is the only provider Thunderbird talks to; do not + store per-account state in your provider keyed by account identifiers + beyond what the user explicitly configured. + +## 8. Testing checklist + +Before shipping a provider, verify against this checklist: + +- [ ] `EXTRA_API_VERSION` missing → `INCOMPATIBLE_API_VERSIONS`. +- [ ] `EXTRA_API_VERSION` = 999 → `INCOMPATIBLE_API_VERSIONS`. +- [ ] `ACTION_GET_CERTIFICATES` with N addresses returns N `SmimeCertificateInfo` entries. +- [ ] `ACTION_SIGN_AND_ENCRYPT` with one missing recipient → `NO_CERTIFICATE_FOR_RECIPIENT`, no output produced. +- [ ] `ACTION_SIGN_AND_ENCRYPT` from an `EXTRA_FROM` with no matching signing certificate → `RESULT_CODE_ERROR`, not signed with another identity's certificate. +- [ ] Locked keystore → `RESULT_CODE_USER_INTERACTION_REQUIRED` + immutable `PendingIntent`, returned in <50 ms. +- [ ] Successful retry after unlock writes valid wrapped MIME to the output pipe. +- [ ] Output pipe is closed on every return path, including errors. +- [ ] Large messages (≥10 MB) round-trip without OOM on either side (pipe streams, no in-memory buffering of full payload). +- [ ] `ACTION_DECRYPT_VERIFY` on a tampered signature → `RESULT_INVALID_SIGNATURE`, not `RESULT_VALID_*`. +- [ ] `ACTION_DECRYPT_VERIFY` on an expired-signer message → `RESULT_CERT_EXPIRED`, not `RESULT_INVALID_SIGNATURE`. +- [ ] Recipient list passed via `EXTRA_USER_IDS` does not appear in any persistent log. + +## 9. Submitting a new provider + +We are not currently maintaining a registry of S/MIME providers. If you +ship a public provider that conforms to this contract, please: + +1. Open an issue against `thunderbird-android` describing your provider, + its package name, distribution channel, and signing identity. +2. Provide a brief security argument: who can use the provider, what + guarantees it makes about caller verification, and how it handles the + testing checklist above. +3. Update this document with a link from the "Known providers" section + below. + +### Known providers + +- **CipherMail** (`com.ciphermail.android`) — reference implementation; + source at . diff --git a/docs/security/smime-companion-threat-model.md b/docs/security/smime-companion-threat-model.md new file mode 100644 index 00000000000..eb6a98a5082 --- /dev/null +++ b/docs/security/smime-companion-threat-model.md @@ -0,0 +1,157 @@ +# Threat Model — S/MIME via Companion App + +Applies the [STRIDE workflow](threat-modeling-guide.md) to Thunderbird's +S/MIME integration, which delegates all cryptographic operations to a +separate companion app (the reference provider is **CipherMail**) over an +AIDL bound service. Architectural rationale is in +[ADR 0009](../architecture/adr/0009-smime-companion-app-architecture.md); +the wire protocol is documented in +[`plugins/smime-api/README.md`](../../plugins/smime-api/README.md). + +## 1. Project Overview + +* **Project Name**: Thunderbird for Android — S/MIME companion integration. +* **Description**: Thunderbird binds at runtime to an installed S/MIME + provider app and delegates sign / encrypt / decrypt / verify / certificate + lookup over an AIDL service. The provider owns all private key material, + the certificate store, and the passphrase UX. MIME bytes stream over + ParcelFileDescriptor pipes rather than Binder transactions. +* **Key Features**: Per-account provider selection, recipient-certificate + lookup driving the compose lock icon, cross-process passphrase unlock via + `PendingIntent`, support for multiple coexisting providers via intent + filter discovery. + +## 2. Scope + +The IPC trust boundary between Thunderbird and any S/MIME provider, the +passphrase unlock dance, and the compose-screen recipient/cert state. Out of +scope: the cryptographic correctness of the provider's S/MIME implementation +(CMS, OCSP, CRL, certificate-chain validation) — that lives inside the +provider and has its own threat model. + +## 3. System Diagram + +```mermaid +flowchart LR + USER[User] + ATTACKER[Attacker: malicious app, rooted device,
network MITM, weaponised cert] + + subgraph Device + subgraph "Thunderbird process" + TB[Thunderbird UI] + SMIME_API[smime-api client lib] + RECIPIENTS[RecipientPresenter
compose lock icon] + COMPOSE[MessageCompose +
SmimeMessageBuilder] + VIEW[MessageView +
SmimeCryptoHelper] + end + + subgraph "Provider process (CipherMail)" + SVC[ISmimeService] + KEYSTORE[Keystore +
CachingPasswordProvider] + CERTS[Certificate store] + DIALOG[KeyStorePassphraseDialog] + end + + BINDER[(Binder IPC)] + PIPES[(ParcelFileDescriptor pipes)] + end + + USER --> TB + ATTACKER -.->|impersonate provider /
hijack PendingIntent /
weaponised attachment| TB + + TB --> SMIME_API + RECIPIENTS --> SMIME_API + COMPOSE --> SMIME_API + VIEW --> SMIME_API + + SMIME_API -->|execute Intent| BINDER --> SVC + SMIME_API <-->|MIME bytes| PIPES <--> SVC + SVC --> KEYSTORE + SVC --> CERTS + SVC -.->|PendingIntent on lock| DIALOG + DIALOG -->|passphrase broadcast| KEYSTORE + + classDef external fill:#fff3,stroke:#f66,stroke-width:2px; + class ATTACKER external; +``` + +## 4. Assets + +* **Data**: Recipients' public certificates, the user's private signing / + decryption key, decrypted message plaintext in transit through the pipes, + the keystore passphrase, the per-account `smimeProvider` package name. +* **Functionality**: Ability to read encrypted mail and to send + authentically signed and encrypted mail. +* **Reputation**: A cryptographic feature failing open (sending in + plaintext when the user expected encryption, or accepting a forged + signature as valid) is far more damaging than a feature simply being + unavailable. +* **Availability**: Provider reachable, keystore unlockable, send path + uninterrupted by repeated passphrase prompts. + +## 5. Threats + +| Component / Flow | Spoofing | Tampering | Repudiation | Information Disclosure | DoS | Elevation of Privilege | +|--------------------------------------------|-----------------------------------|------------------------------------|----------------------------|---------------------------------|--------------------------------------|------------------------------| +| Provider discovery (intent filter scan) | Malicious app declares filter | Manifest queries metadata altered | — | Provider list leaks installed apps | Hundreds of fake providers shown | App promoted to crypto role | +| Binding to `ISmimeService` | Wrong package gets bound | Binder transaction altered | User denies which app bound | Package list visible to user | Provider refuses connections | Caller UID not verified | +| `execute(Intent, PFD, pipeId)` request | Caller masquerades as Thunderbird | Intent extras / action modified | Service denies request | EXTRA_USER_IDS leaks recipient list | Oversized intent crashes provider | Caller bypasses consent gate | +| Input pipe (MIME → service) | — | Bytes mutated mid-stream | — | Other process reads pipe | Slowloris-style infinite writer | — | +| Output pipe (service → client) | — | Bytes mutated mid-stream | — | Other process reads pipe | Provider never closes pipe | — | +| Passphrase dialog (`PendingIntent`) | Forged passphrase activity | PendingIntent mutated mid-flight | User denies entering pass | Passphrase shoulder-surfed | PendingIntent never fires | Mutable PI → privilege uplift | +| Passphrase broadcast (provider-internal) | Other app sends fake broadcast | Broadcast contents altered | — | Broadcast leaks passphrase | Broadcast flood | Receiver runs in wrong context | +| Certificate lookup (`GET_CERTIFICATES`) | Provider lies about cert presence | Wrong cert returned for address | — | Recipient list leaks to provider | Slow lookup blocks compose UI | — | +| Send result interpretation (lock icon) | Provider reports green falsely | RESULT_CODE altered | — | — | UI never settles | User assumes encrypted when not | +| Decrypt result (signature trust) | Forged signer identity | SmimeSignatureResult tampered | Signer denies signing | Signer email leaks via UI cache | Slow verify stalls message-view | — | +| Per-account `smimeProvider` setting | Settings restore points to evil | Pref file modified on rooted device | — | Pref readable in backups | Repeated provider switches | Account silently rebound | +| Reinstall / `DeviceKeyLostException` path | — | Pref state inconsistent | — | Stale cached pass remains in RAM | Loops on "wrong passphrase" | User induced to lower passphrase strength | + +## 6. Mitigations + +| Threat | Mitigation | +|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Malicious app declares S/MIME intent filter | Show the user's selected provider's package + signature in account settings; warn when the chosen provider changes signature; require explicit user re-confirmation when the previously bound provider is uninstalled | +| Binding to the wrong package | Always `setPackage(account.smimeProvider)` before bind; never resolve by intent alone. Verify caller UID inside the service (provider side) using `Binder.getCallingUid()` for any privileged action | +| Caller masquerades as Thunderbird (provider side) | Provider validates caller signature against an allowlist for any operation that exposes private keys; CipherMail's manifest declares `BIND` permission with `signature`-level protection where feasible | +| EXTRA_USER_IDS leaks recipient list | Recipient list is intentionally shared with the provider — that's the contract for cert lookup. Document this in user-facing docs so users understand the trust placed in the provider | +| Oversized intent / pipe DoS | Provider enforces hard upper bound on Intent extras and on bytes read from input pipe; client uses a timeout on `executeApiAsync`; report explicit `SmimeError` rather than hang | +| Pipe contents readable by other processes | Pipes created via `ParcelFileDescriptor.createPipe()` are anonymous and only the file-descriptor holders can read/write; never expose pipe fds to other components | +| Forged passphrase activity | Provider's manifest must mark `KeyStorePassphraseDialog` as `exported=false` for components not invoked via the API; Thunderbird launches **only** the `PendingIntent` returned by the bound service in the current session — never a hand-built Intent | +| PendingIntent mutation | Provider must construct passphrase PendingIntents with `FLAG_IMMUTABLE`; never `FLAG_MUTABLE`; target an explicit `ComponentName` not just an action | +| Passphrase broadcast hijack | Provider registers receiver with `RECEIVER_NOT_EXPORTED` on Android 13+; broadcast Intent restricted to `setPackage(getPackageName())`; on older Android, restrict by signature permission | +| Provider reports green/red falsely | Threat resides entirely in the provider — Thunderbird cannot independently verify cert presence without itself owning a cert store. Document this in user-facing docs and surface the provider's identity prominently in compose-screen status | +| RESULT_CODE / SmimeSignatureResult tampering | Bind is to a specific package + caller-verified service; in-process tampering of result Parcelables is not part of the IPC threat model. Re-check `PARCELABLE_VERSION` on read and fail closed on mismatch | +| Pref restored from backup points to evil provider | Validate `smimeProvider` package against the installed-providers list on every account-load; if missing, surface `account_settings_smime_missing` and refuse send | +| Stale cached pass on `DeviceKeyLostException` | `CachingPasswordProvider` clears the cached pass and forces NEW-mode passphrase entry; never silently downgrade or reuse a previous pass after a device-key loss event | +| User assumes encrypted-but-actually-not | Fail closed: when `GET_CERTIFICATES` reports any missing recipient, the lock icon is red and the message is not auto-encrypted to a partial set. Sending in cleartext requires an explicit user-initiated override | +| Slow lookup blocks compose UI | `RecipientPresenter.asyncUpdateSmimeCertStatus` runs off the UI thread; status is displayed as "Configuring…" while in flight | + +## 7. Risk Ranking + +* **High**: + * Forged passphrase activity / mutable PendingIntent (passphrase capture). + * Pref restored from backup points to a malicious provider (silent rebind). + * Failing open: silently sending plaintext when the user expected encryption. +* **Medium**: + * Malicious app declares the intent filter and appears in provider picker. + * Provider lies about certificate presence (mitigated only by trusting the provider). + * `EXTRA_USER_IDS` recipient list disclosure to the provider (inherent to the design; documented). +* **Low**: + * Output-pipe DoS (bounded by provider-side timeouts; recoverable). + * Repudiation of which provider bound (the user picked it; the picked package is in settings). + +## 8. Notes on residual risk + +Two risks are inherent to the companion-app model and cannot be fully +eliminated in Thunderbird: + +1. **The provider is in the TCB.** Once the user selects a provider, + Thunderbird trusts everything that provider reports about signatures and + certificate presence. Mitigation is bounded to choosing the provider + carefully and surfacing its identity in the UI. +2. **Recipient identity leaks to the provider.** Cert lookup necessarily + shares the recipient list across the IPC boundary. This is documented + trade-off for the architecture; a privacy-preserving lookup would + require a different protocol (e.g. PIR) not present in S/MIME today. + +These are accepted with rationale rather than mitigated. diff --git a/docs/user-guide/setup/enabling-smime.md b/docs/user-guide/setup/enabling-smime.md new file mode 100644 index 00000000000..c1c429cd439 --- /dev/null +++ b/docs/user-guide/setup/enabling-smime.md @@ -0,0 +1,215 @@ +# Enabling S/MIME via CipherMail + +Thunderbird for Android supports **S/MIME** (signing and encrypting mail with +X.509 certificates) by binding to a companion app that owns the keystore and +certificates. The reference companion is **CipherMail for Android**. This +guide walks you through the one-time setup. + +If you only need OpenPGP, see the existing OpenKeychain integration instead — +S/MIME and OpenPGP coexist in Thunderbird and can be enabled per account. + +## What you'll need + +- Thunderbird for Android installed (this app). +- CipherMail for Android installed. +- Your personal S/MIME certificate as a `.p12` / `.pfx` file (PKCS#12), along + with the passphrase that protects it. Typically issued by your organisation + or by a public CA. If you don't have one yet, your IT department or + certificate provider can issue one. +- The certificates of the people you want to **send encrypted mail to** — + usually obtained from a previous signed mail they sent you, or from a + directory service. + +## Step 1 — Install CipherMail + +CipherMail is a separate Android app. It can be installed from: + +- **Google Play**. +- The CipherMail download page at . + +CipherMail is planned for F-Droid once this S/MIME companion API is accepted +upstream; until then, F-Droid users should use the direct download. + +After installing, open CipherMail at least once so it can complete its +initial setup. The first launch will start a short wizard. + +## Step 2 — Set a keystore passphrase + +The first time you open CipherMail, the **Start Wizard** prompts you to +create a keystore passphrase. This passphrase protects your private key on +the device; it is **not** the same as your `.p12` import passphrase. + +1. Tap **Next** through the welcome screen. +2. Enter a strong passphrase and confirm. Choose something you can remember + — you will be asked to re-enter it whenever the keystore has been locked + (e.g. after a device reboot or after the cache is cleared). +3. Tap **Next** to continue to account setup. + +You can later re-run the wizard from CipherMail's menu (**⋮ → Setup**) +without clearing app data. + +## Step 3 — Import or generate your S/MIME certificate + +Inside the wizard, after the account step: + +1. Choose **Import certificate**. +2. Browse to your `.p12` / `.pfx` file (or open it from Files / Downloads). +3. Enter the **PKCS#12 passphrase** that was set when the file was exported. + (Again: this is the import passphrase, distinct from the keystore + passphrase from Step 2.) +4. CipherMail will list the certificate(s) it found. Confirm to import. + +The certificate is now stored in CipherMail's keystore. CipherMail will use +it for signing outgoing mail from any matching email address. + +## Step 4 — Enable S/MIME on your Thunderbird account + +1. In Thunderbird, open **Settings**. +2. Tap the account you want to protect with S/MIME. +3. Scroll to **S/MIME** and tap **Enable S/MIME support**. +4. If CipherMail is the only S/MIME provider installed, it's selected + automatically. If more than one is installed (rare today), pick + **CipherMail** from the list. +5. The summary line under "Enable S/MIME support" should now read + **Connected to CipherMail**. + +If you see **No S/MIME app found**, CipherMail isn't installed — go back to +Step 1. If you see **Missing S/MIME app**, the previously selected provider +was uninstalled; tap the row and pick a new one. + +## Step 5 — Send your first signed and encrypted mail + +1. Compose a new message to a recipient whose certificate CipherMail has. +2. Look at the **lock icon** in the compose toolbar: + - **Green**: certificates available for every recipient — the message + will be signed and encrypted. + - **Red**: at least one recipient is missing a certificate — the message + will be signed but not encrypted to the missing recipient(s). Tap the + icon for details. +3. Tap **Send**. + +The first time you send after a reboot (or after CipherMail's keystore +cache has expired), Thunderbird hands you off to CipherMail's **passphrase +prompt**. Enter the keystore passphrase you set in Step 2. After OK, +Thunderbird automatically retries the send and CipherMail signs and encrypts +without prompting again until the cache clears. + +## Step 6 — Read an encrypted or signed mail + +When an S/MIME-encrypted mail arrives: + +1. Open the message. Thunderbird routes it to CipherMail for decryption. +2. If the keystore is locked, you'll see the passphrase prompt again — + same dialog as in Step 5. +3. After unlock, the message is displayed in plaintext with an indicator + showing whether the signature was valid and trusted. + +Signature indicators in the message view: + +- **Valid, trusted** — the signature verified and the signer's certificate + chains to a trusted root. +- **Valid, untrusted** — the signature verified but the signer's certificate + is self-signed or chains to a root your device doesn't trust. The message + is genuine but the signer's identity isn't confirmed. +- **Invalid** — the signature did not verify; the message may have been + tampered with. +- **Certificate missing / expired / revoked** — the signature can't be + evaluated for the reason shown. + +## Understanding the compose lock icon + +While composing, the lock icon next to the recipient field reflects what +will happen when you tap **Send**. CipherMail tells Thunderbird which +recipients have usable certificates; Thunderbird translates that into one +of four states: + +| State | Meaning | When you see it | +|------------------------|--------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------| +| **Hidden** | S/MIME is not configured on this account. | The account's S/MIME provider preference is empty. Enable it (Step 4) to show. | +| **Gray (disabled)** | S/MIME is enabled but there are no recipients to check yet. | After enabling S/MIME, before you add anyone to To / Cc / Bcc. | +| **Green (trusted)** | Every recipient has a valid certificate. The message will be signed **and** encrypted on send. | Once every address in To / Cc / Bcc resolves to a usable certificate. | +| **Red (error)** | At least one recipient is missing a valid certificate, **or** Thunderbird could not reach CipherMail. | Most common cause: you don't have that recipient's cert yet. Tap for details. | + +**Tapping the icon** shows a short message: + +- Green → *"S/MIME: signing and encrypting"* +- Red → *"S/MIME: missing certificates for some recipients"* + +The state refreshes each time you add or remove a recipient. CipherMail's +certificate lookup is asynchronous, so on slow devices you may briefly see +the previous state before it updates. + +### What to do when the icon is red + +1. Tap the icon to confirm it's a missing-certificate issue (and not a + provider error). +2. Ask the recipient to send you any signed mail — CipherMail will extract + their certificate from the signature automatically when you read it. +3. Alternatively, import their certificate manually via CipherMail's + **Certificates** screen if you have it as a `.cer` / `.crt` file. +4. The icon will turn green automatically the next time you re-focus the + recipient field (or you can briefly toggle a recipient to force a + refresh). + +If you send while the icon is red, the message is **not** silently dropped +or sent in plaintext to the missing recipient — it fails with an explicit +error. To send in plain text on purpose, disable S/MIME on the account +first. + +### Notes for translators + +The strings the user sees here all live in +`legacy/ui/legacy/src/main/res/values/strings.xml`: + +| Key | English source | +|-------------------------------------------|-------------------------------------------------------------------------------------------------| +| `smime_status_active` | S/MIME: signing and encrypting | +| `smime_status_missing_certs` | S/MIME: missing certificates for some recipients | +| `account_settings_smime` | S/MIME | +| `account_settings_smime_app` | Enable S/MIME support | +| `account_settings_smime_app_select_title` | Select S/MIME app | +| `account_settings_smime_summary_off` | No S/MIME app configured | +| `account_settings_smime_summary_on` | Connected to %s | +| `account_settings_smime_summary_config` | Configuring… | +| `account_settings_smime_missing` | Missing S/MIME app - was it uninstalled? | +| `account_settings_smime_no_provider_title`| No S/MIME app found | +| `account_settings_smime_no_provider_msg` | No S/MIME provider app is installed. Please install CipherMail to enable S/MIME support. | + +When translating, keep the "S/MIME" trademark intact — it's an industry +term, not a brand we're free to localise. The provider product name +(*"CipherMail"*) is also a proper noun and should not be translated. + +## Troubleshooting + +**"Connected to CipherMail" never appears.** Re-open CipherMail and make +sure the wizard completed (a certificate is imported, a passphrase is set). +Then return to Thunderbird's account settings and toggle **Enable S/MIME +support** off and on again. + +**Sending fails with "missing certificate for recipient".** You don't have +an S/MIME certificate for at least one recipient. Either: + +- Ask them to send you a signed mail first — CipherMail extracts their + certificate from the signature automatically. +- Or, if you have their certificate file, import it via CipherMail's + **Certificates** screen. + +**Passphrase prompt keeps reappearing.** That's expected after a reboot or +after the cache has been cleared. The prompt appears once per "unlock +session" — subsequent sends and receives reuse the cached passphrase. + +**After reinstalling CipherMail, the keystore is gone.** Reinstall wipes +the device-bound key that protects CipherMail's keystore at rest. You'll +need to set a new passphrase and re-import your certificate. Your sent +messages and counterparties' certificates that lived only inside +CipherMail's local store are lost; ones cached in Thunderbird-side +sources are not affected. + +**I uninstalled CipherMail.** Thunderbird will show **Missing S/MIME app** +on the account. Re-install CipherMail, or change the account's S/MIME +provider to a different one in **Settings → account → S/MIME**. + +## See also + +- [ADR 0009 — Companion App + AIDL Service for S/MIME](../../architecture/adr/0009-smime-companion-app-architecture.md) — why Thunderbird uses a separate app for S/MIME at all. +- [`plugins/smime-api/README.md`](../../../plugins/smime-api/README.md) — for developers integrating against the same API. diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt index 8c8d0488a16..6c8156810b5 100644 --- a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/LegacyAccountStorageHandler.kt @@ -226,6 +226,12 @@ class LegacyAccountStorageHandler( replaceIdentities(loadIdentities(data.id, storage)) openPgpProvider = storage.getStringOrDefault(keyGen.create("openPgpProvider"), "") + smimeProvider = storage.getStringOrDefault(keyGen.create("smimeProvider"), "") + // Default true for legacy accounts that already had a provider set, + // so upgrading keeps S/MIME enabled. + smimeEnabled = storage.getBoolean(keyGen.create("smimeEnabled"), smimeProvider != null) + smimeSign = storage.getBoolean(keyGen.create("smimeSign"), true) + smimeEncrypt = storage.getBoolean(keyGen.create("smimeEncrypt"), true) openPgpKey = storage.getLong(keyGen.create("cryptoKey"), AccountDefaultsProvider.Companion.NO_OPENPGP_KEY) isOpenPgpHideSignOnly = storage.getBoolean(keyGen.create("openPgpHideSignOnly"), true) isOpenPgpEncryptSubject = storage.getBoolean(keyGen.create("openPgpEncryptSubject"), true) @@ -395,6 +401,10 @@ class LegacyAccountStorageHandler( editor.putBoolean(keyGen.create("openPgpEncryptSubject"), isOpenPgpEncryptSubject) editor.putBoolean(keyGen.create("openPgpEncryptAllDrafts"), isOpenPgpEncryptAllDrafts) editor.putString(keyGen.create("openPgpProvider"), openPgpProvider) + editor.putString(keyGen.create("smimeProvider"), smimeProvider) + editor.putBoolean(keyGen.create("smimeEnabled"), smimeEnabled) + editor.putBoolean(keyGen.create("smimeSign"), smimeSign) + editor.putBoolean(keyGen.create("smimeEncrypt"), smimeEncrypt) editor.putBoolean(keyGen.create("autocryptMutualMode"), autocryptPreferEncryptMutual) editor.putBoolean(keyGen.create("remoteSearchFullText"), isRemoteSearchFullText) editor.putInt(keyGen.create("remoteSearchNumResults"), remoteSearchNumResults) @@ -507,6 +517,10 @@ class LegacyAccountStorageHandler( editor.remove(keyGen.create("cryptoKey")) editor.remove(keyGen.create("cryptoSupportSignOnly")) editor.remove(keyGen.create("openPgpProvider")) + editor.remove(keyGen.create("smimeProvider")) + editor.remove(keyGen.create("smimeEnabled")) + editor.remove(keyGen.create("smimeSign")) + editor.remove(keyGen.create("smimeEncrypt")) editor.remove(keyGen.create("openPgpHideSignOnly")) editor.remove(keyGen.create("openPgpEncryptSubject")) editor.remove(keyGen.create("openPgpEncryptAllDrafts")) diff --git a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountDataMapper.kt b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountDataMapper.kt index 3306329ea95..f82a97c6104 100644 --- a/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountDataMapper.kt +++ b/feature/account/storage/legacy/src/main/kotlin/net/thunderbird/feature/account/storage/legacy/mapper/DefaultLegacyAccountDataMapper.kt @@ -81,6 +81,10 @@ internal class DefaultLegacyAccountDataMapper : LegacyAccountDataMapper { isStripSignature = dto.isStripSignature, isSyncRemoteDeletions = dto.isSyncRemoteDeletions, openPgpProvider = dto.openPgpProvider, + smimeProvider = dto.smimeProvider, + smimeEnabled = dto.smimeEnabled, + smimeSign = dto.smimeSign, + smimeEncrypt = dto.smimeEncrypt, openPgpKey = dto.openPgpKey, autocryptPreferEncryptMutual = dto.autocryptPreferEncryptMutual, isOpenPgpHideSignOnly = dto.isOpenPgpHideSignOnly, @@ -187,6 +191,10 @@ internal class DefaultLegacyAccountDataMapper : LegacyAccountDataMapper { isStripSignature = domain.isStripSignature isSyncRemoteDeletions = domain.isSyncRemoteDeletions openPgpProvider = domain.openPgpProvider + smimeProvider = domain.smimeProvider + smimeEnabled = domain.smimeEnabled + smimeSign = domain.smimeSign + smimeEncrypt = domain.smimeEncrypt openPgpKey = domain.openPgpKey autocryptPreferEncryptMutual = domain.autocryptPreferEncryptMutual isOpenPgpHideSignOnly = domain.isOpenPgpHideSignOnly diff --git a/legacy/common/src/main/AndroidManifest.xml b/legacy/common/src/main/AndroidManifest.xml index 6e51b381d65..4150aa5d2a6 100644 --- a/legacy/common/src/main/AndroidManifest.xml +++ b/legacy/common/src/main/AndroidManifest.xml @@ -263,6 +263,7 @@ android:name="com.fsck.k9.provider.RawMessageProvider" android:authorities="${applicationId}.rawmessageprovider" android:exported="false" + android:grantUriPermissions="true" > extraParts = new ArrayList<>(); Part cryptoContentPart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, extraParts); @@ -84,6 +90,14 @@ public MessageViewInfo extractMessageForView(Message message, @Nullable MessageC .withCryptoData(noProviderAnnotation, null, null); } + boolean isSmimeEncrypted = MessageCryptoStructureDetector.isSmimeEncryptedOrSignedData(cryptoContentPart); + if (!smimeProviderConfigured && isSmimeEncrypted) { + CryptoResultAnnotation noProviderAnnotation = CryptoResultAnnotation.createErrorAnnotation( + CryptoError.SMIME_ENCRYPTED_NO_PROVIDER, null); + return MessageViewInfo.createWithErrorState(message, false) + .withCryptoData(noProviderAnnotation, null, null); + } + MessageViewInfo messageViewInfo = getMessageContent(message, cryptoAnnotations, extraParts, cryptoContentPart); messageViewInfo = extractSubject(messageViewInfo); diff --git a/legacy/core/src/main/java/com/fsck/k9/message/SmimeMessageBuilder.kt b/legacy/core/src/main/java/com/fsck/k9/message/SmimeMessageBuilder.kt new file mode 100644 index 00000000000..00c20fcf4bf --- /dev/null +++ b/legacy/core/src/main/java/com/fsck/k9/message/SmimeMessageBuilder.kt @@ -0,0 +1,252 @@ +package com.fsck.k9.message + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.content.IntentCompat +import app.k9mail.legacy.di.DI +import com.ciphermail.smime.api.ISmimeService +import com.ciphermail.smime.api.SmimeError +import com.ciphermail.smime.api.util.SmimeApi +import com.ciphermail.smime.api.util.SmimeServiceConnection +import com.fsck.k9.CoreResourceProvider +import com.fsck.k9.mail.Message.RecipientType +import com.fsck.k9.mail.BoundaryGenerator +import com.fsck.k9.mail.internet.MessageIdGenerator +import com.fsck.k9.mail.internet.MimeMessage +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.preference.GeneralSettingsManager +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Send-side S/MIME orchestration for the compose path. + * + * Companion to `PgpMessageBuilder`: builds the plain MIME message via [MessageBuilder.build], + * binds to the user's configured S/MIME provider, calls `ACTION_SIGN_AND_ENCRYPT` over the AIDL + * pipe, and parses the resulting wrapped message back into a [MimeMessage] that can be queued + * for SMTP transport. + * + * **Threading.** [buildMessageInternal] is called on `AsyncTask.doInBackground` from the compose + * activity, so this class blocks on a [CountDownLatch] for the service-bind callback rather than + * threading callbacks through to the UI. The blocking is intentional and safe because the caller + * is already off the main thread. + * + * **Drafts and "send without crypto" short-circuit.** If [isDraft] or both [shouldSign] and + * [shouldEncrypt] are false, the plain MIME message is returned without involving the provider. + * + * If the provider returns `RESULT_CODE_USER_INTERACTION_REQUIRED` (locked keystore), the + * returned [PendingIntent] is queued via `queueMessageBuildPendingIntent` so the host activity + * can launch it; [buildMessageOnActivityResult] resumes the build after the user unlocks. + */ +class SmimeMessageBuilder internal constructor( + context: Context, + messageIdGenerator: MessageIdGenerator, + boundaryGenerator: BoundaryGenerator, + resourceProvider: CoreResourceProvider, + settingsManager: GeneralSettingsManager, +) : MessageBuilder(messageIdGenerator, boundaryGenerator, resourceProvider, settingsManager) { + + private val context: Context = context.applicationContext + private var smimeProvider: String? = null + private var shouldSign: Boolean = true + private var shouldEncrypt: Boolean = true + + private var currentProcessedMimeMessage: MimeMessage? = null + + /** + * Set the provider package this builder will bind to. Required before [buildMessageInternal] + * runs. Typically pulled from the account's `smimeProvider` setting. + */ + fun setSmimeProvider(smimeProvider: String) { + this.smimeProvider = smimeProvider + } + + /** Toggle outgoing-signature production. Defaults to `true`. */ + fun setShouldSign(shouldSign: Boolean) { + this.shouldSign = shouldSign + } + + /** + * Toggle outgoing encryption. Defaults to `true`. When false, only a signature is produced; + * the message body remains in plaintext. + */ + fun setShouldEncrypt(shouldEncrypt: Boolean) { + this.shouldEncrypt = shouldEncrypt + } + + override fun buildMessageInternal() { + check(currentProcessedMimeMessage == null) { "message can only be built once!" } + + val mimeMessage = try { + build() + } catch (me: MessagingException) { + queueMessageBuildException(me) + return + } + currentProcessedMimeMessage = mimeMessage + + if (isDraft || (!shouldSign && !shouldEncrypt)) { + queueMessageBuildSuccess(mimeMessage) + return + } + + startOrContinueBuildMessage() + } + + override fun buildMessageOnActivityResult(requestCode: Int, userInteractionResult: Intent) { + checkNotNull(currentProcessedMimeMessage) { + "build message from activity result must not be called individually" + } + // Retry after user interaction (e.g. keystore unlock); the service handles the rest. + startOrContinueBuildMessage() + } + + private fun startOrContinueBuildMessage() { + // We're on a background thread (AsyncTask.doInBackground), so we can block for bind. + val latch = CountDownLatch(1) + val serviceRef = AtomicReference() + val errorRef = AtomicReference() + + val connection = SmimeServiceConnection( + context, + smimeProvider, + object : SmimeServiceConnection.OnBound { + override fun onBound(service: ISmimeService) { + serviceRef.set(service) + latch.countDown() + } + + override fun onError(e: Exception) { + errorRef.set(e) + latch.countDown() + } + }, + ) + connection.bindToService() + + val bound = try { + latch.await(SERVICE_BIND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + queueMessageBuildException(MessagingException("Interrupted waiting for S/MIME service")) + return + } + + if (!bound) { + // bindToService() returned without error but the connection never landed + // (provider gone or service never came up). Don't block the compose thread + // forever — release the registered connection and fail the build so the + // host can save the message instead of losing it. + connection.unbindFromService() + queueMessageBuildException( + MessagingException("Timed out connecting to S/MIME service"), + ) + return + } + + errorRef.get()?.let { error -> + queueMessageBuildException( + MessagingException("Could not connect to S/MIME service: ${error.message}"), + ) + return + } + + try { + performSignAndEncrypt(SmimeApi(serviceRef.get())) + } finally { + connection.unbindFromService() + } + } + + private fun performSignAndEncrypt(api: SmimeApi) { + val mimeMessage = currentProcessedMimeMessage ?: error("currentProcessedMimeMessage is null") + val messageBytes = try { + ByteArrayOutputStream().apply { mimeMessage.writeTo(this) }.toByteArray() + } catch (e: IOException) { + queueMessageBuildException(MessagingException("Failed to serialize message for S/MIME signing", e)) + return + } catch (e: MessagingException) { + queueMessageBuildException(MessagingException("Failed to serialize message for S/MIME signing", e)) + return + } + + val smimeOutput = ByteArrayOutputStream() + val result = api.executeApi(buildSignAndEncryptIntent(), ByteArrayInputStream(messageBytes), smimeOutput) + result.setExtrasClassLoader(SmimeError::class.java.classLoader) + + when (result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR)) { + SmimeApi.RESULT_CODE_SUCCESS -> handleSuccess(smimeOutput.toByteArray()) + + SmimeApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent = IntentCompat.getParcelableExtra( + result, SmimeApi.RESULT_INTENT, PendingIntent::class.java, + ) + if (pendingIntent == null) { + queueMessageBuildException( + MessagingException("S/MIME service requires user interaction but returned no PendingIntent"), + ) + } else { + queueMessageBuildPendingIntent(pendingIntent, REQUEST_USER_INTERACTION) + } + } + + else -> { + val error = IntentCompat.getParcelableExtra(result, SmimeApi.RESULT_ERROR, SmimeError::class.java) + queueMessageBuildException(MessagingException(error?.message ?: "Unknown S/MIME error")) + } + } + } + + private fun buildSignAndEncryptIntent(): Intent = Intent(SmimeApi.ACTION_SIGN_AND_ENCRYPT).apply { + putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION) + putExtra(SmimeApi.EXTRA_SIGN, shouldSign) + putExtra(SmimeApi.EXTRA_ENCRYPT, shouldEncrypt) + currentProcessedMimeMessage?.from?.firstOrNull()?.address?.let { fromAddress -> + putExtra(SmimeApi.EXTRA_FROM, fromAddress) + } + if (shouldEncrypt) { + putExtra(SmimeApi.EXTRA_USER_IDS, collectRecipientAddresses()) + } + } + + private fun collectRecipientAddresses(): Array { + val message = currentProcessedMimeMessage ?: return emptyArray() + return ( + message.getRecipients(RecipientType.TO).asSequence() + + message.getRecipients(RecipientType.CC).asSequence() + + message.getRecipients(RecipientType.BCC).asSequence() + ).map { it.address }.toList().toTypedArray() + } + + private fun handleSuccess(smimeBytes: ByteArray) { + try { + val smimeMessage = MimeMessage.parseMimeMessage(ByteArrayInputStream(smimeBytes), true) + queueMessageBuildSuccess(smimeMessage) + } catch (e: IOException) { + queueMessageBuildException(MessagingException("Failed to parse S/MIME output message", e)) + } catch (e: MessagingException) { + queueMessageBuildException(MessagingException("Failed to parse S/MIME output message", e)) + } + } + + companion object { + private const val REQUEST_USER_INTERACTION = 1 + + /** Upper bound on waiting for the provider's service-bind callback. */ + private const val SERVICE_BIND_TIMEOUT_SECONDS = 30L + + @JvmStatic + fun newInstance(context: Context): SmimeMessageBuilder = SmimeMessageBuilder( + context, + MessageIdGenerator.getInstance(), + BoundaryGenerator.getInstance(), + DI.get(CoreResourceProvider::class.java), + DI.get(GeneralSettingsManager::class.java), + ) + } +} diff --git a/legacy/ui/legacy/build.gradle.kts b/legacy/ui/legacy/build.gradle.kts index 697ef5cc7cc..93cf908fcfb 100644 --- a/legacy/ui/legacy/build.gradle.kts +++ b/legacy/ui/legacy/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { compileOnly(projects.mail.protocols.imap) implementation(projects.plugins.openpgpApiLib.openpgpApi) + implementation(projects.plugins.smimeApi.smimeApi) implementation(libs.androidx.appcompat) implementation(libs.androidx.preference) diff --git a/legacy/ui/legacy/src/main/AndroidManifest.xml b/legacy/ui/legacy/src/main/AndroidManifest.xml index 517cd602b03..24b7dbcc0f0 100644 --- a/legacy/ui/legacy/src/main/AndroidManifest.xml +++ b/legacy/ui/legacy/src/main/AndroidManifest.xml @@ -48,6 +48,11 @@ + + + + + diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java index bc57aaeaf29..96421360814 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageCompose.java @@ -40,6 +40,8 @@ import android.view.View.OnClickListener; import android.view.View.OnFocusChangeListener; import android.view.ViewStub; +import android.widget.CheckBox; +import android.widget.CompoundButton; import android.widget.EditText; import android.widget.ImageView; import android.widget.LinearLayout; @@ -116,6 +118,7 @@ import com.fsck.k9.message.MessageBuilder; import com.fsck.k9.message.PgpMessageBuilder; import com.fsck.k9.message.QuotedTextMode; +import com.fsck.k9.message.SmimeMessageBuilder; import com.fsck.k9.message.SimpleMessageBuilder; import com.fsck.k9.message.SimpleMessageFormat; import com.fsck.k9.ui.R; @@ -167,6 +170,9 @@ public class MessageCompose extends BaseActivity implements OnClickListener, private static final int DIALOG_SAVE_OR_DISCARD_DRAFT_MESSAGE = 1; private static final int DIALOG_CONFIRM_DISCARD_ON_BACK = 2; + /** Play Store package of the CipherMail S/MIME provider app. */ + private static final String CIPHERMAIL_PACKAGE = "com.ciphermail.android.application"; + private final DatabaseUpgradeInterceptor databaseUpgradeInterceptor = DI.get(DatabaseUpgradeInterceptor.class); private static final int DIALOG_CHOOSE_IDENTITY = 3; private static final int DIALOG_CONFIRM_DISCARD = 4; @@ -270,6 +276,9 @@ public class MessageCompose extends BaseActivity implements OnClickListener, private RecipientPresenter recipientPresenter; private MessageBuilder currentMessageBuilder; + private View smimeCryptoBar; + private CheckBox smimeSignCheckbox; + private CheckBox smimeEncryptCheckbox; private ReplyToPresenter replyToPresenter; private boolean finishAfterDraftSaved; private boolean alreadyNotifiedUserOfEmptySubject = false; @@ -298,6 +307,8 @@ public class MessageCompose extends BaseActivity implements OnClickListener, private boolean navigateUp; private boolean sendMessageHasBeenTriggered = false; + /** Guards the onMessageBuildException rescue-save so a failing rescue can't recurse. */ + private boolean draftSavedAfterSendFailure = false; private boolean ignoreSentFolderNotAssigned = false; @Override @@ -366,6 +377,7 @@ public void onCreate(Bundle savedInstanceState) { DI.get(AutocryptDraftStateHeaderParser.class)); recipientPresenter.asyncUpdateCryptoStatus(); + setupSmimeCryptoBar(); subjectView = findViewById(R.id.subject); subjectView.getInputExtras(true).putBoolean("allowEmoji", true); @@ -776,8 +788,24 @@ private MessageBuilder createMessageBuilder(boolean isDraft) { return null; } + boolean smimeEncrypt = smimeEncryptCheckbox.isChecked(); + // Encrypt implies sign — never encrypt without signing (defensive against + // stale persisted state; the UI also enforces this). + boolean smimeSign = smimeSignCheckbox.isChecked() || smimeEncrypt; + // Use the S/MIME builder only when S/MIME is enabled AND the user wants + // signing and/or encryption. With neither checked, fall through to a plain + // message (no provider involved). + boolean shouldUseSmimeMessageBuilder = + account.isSmimeProviderConfigured() && (smimeSign || smimeEncrypt); boolean shouldUsePgpMessageBuilder = cryptoStatus.isOpenPgpConfigured(); - if (shouldUsePgpMessageBuilder) { + if (shouldUseSmimeMessageBuilder) { + SmimeMessageBuilder smimeBuilder = SmimeMessageBuilder.newInstance(this); + smimeBuilder.setSmimeProvider(account.getSmimeProvider()); + smimeBuilder.setShouldSign(smimeSign); + smimeBuilder.setShouldEncrypt(smimeEncrypt); + recipientPresenter.builderSetProperties(smimeBuilder); + builder = smimeBuilder; + } else if (shouldUsePgpMessageBuilder) { SendErrorState maybeSendErrorState = cryptoStatus.getSendErrorStateOrNull(); if (maybeSendErrorState != null) { recipientPresenter.showPgpSendError(maybeSendErrorState); @@ -837,9 +865,138 @@ private void checkToSendMessage() { return; } + // Only block on a missing provider if the user actually asked for S/MIME + // crypto. With both Sign and Encrypt unchecked the message is sent plain + // and needs no provider. + if (account.isSmimeProviderConfigured() && isSmimeCryptoRequested() + && !isSmimeProviderInstalled()) { + handleSmimeProviderMissing(); + return; + } + performSendAfterChecks(); } + /** + * Wire up the inline S/MIME Sign/Encrypt checkboxes. The bar is only shown for + * S/MIME-enabled accounts. The two flags are persisted per account so the user's + * last choice is pre-selected on the next message. + */ + private void setupSmimeCryptoBar() { + smimeCryptoBar = findViewById(R.id.smime_crypto_bar); + smimeSignCheckbox = findViewById(R.id.smime_sign_checkbox); + smimeEncryptCheckbox = findViewById(R.id.smime_encrypt_checkbox); + + smimeCryptoBar.setVisibility(account.isSmimeProviderConfigured() ? View.VISIBLE : View.GONE); + // Invariant: encrypt implies sign (you can't encrypt without signing). + smimeSignCheckbox.setChecked(account.getSmimeSign() || account.getSmimeEncrypt()); + smimeEncryptCheckbox.setChecked(account.getSmimeEncrypt()); + + CompoundButton.OnCheckedChangeListener listener = (button, checked) -> { + // Keep the encrypt-implies-sign invariant: turning Encrypt on forces + // Sign on; turning Sign off forces Encrypt off. (Setting an already- + // correct checkbox is a no-op, so this converges without looping.) + if (button == smimeEncryptCheckbox && checked) { + smimeSignCheckbox.setChecked(true); + } else if (button == smimeSignCheckbox && !checked) { + smimeEncryptCheckbox.setChecked(false); + } + account.setSmimeSign(smimeSignCheckbox.isChecked()); + account.setSmimeEncrypt(smimeEncryptCheckbox.isChecked()); + preferences.saveAccount(account); + }; + smimeSignCheckbox.setOnCheckedChangeListener(listener); + smimeEncryptCheckbox.setOnCheckedChangeListener(listener); + } + + /** @return whether the user wants this S/MIME message signed and/or encrypted. */ + private boolean isSmimeCryptoRequested() { + return smimeSignCheckbox.isChecked() || smimeEncryptCheckbox.isChecked(); + } + + /** + * @return {@code true} if the account's configured S/MIME provider package is installed. + * A message on an S/MIME account can't be signed/encrypted without it, so sending + * is blocked (the message is saved to Drafts instead) when this returns {@code false}. + */ + private boolean isSmimeProviderInstalled() { + String providerPackage = account.getSmimeProvider(); + if (providerPackage == null) { + return false; + } + try { + getPackageManager().getPackageInfo(providerPackage, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + /** + * Handle a send attempt blocked because S/MIME is enabled for the account but its provider + * app (CipherMail) is not installed. Never send silently unencrypted: present the user a + * clear choice — install the provider, turn S/MIME off and send in the clear, or keep the + * message (save to Drafts, or assign a Drafts folder if none is configured). Back/outside + * tap leaves the composer untouched with the text intact. + */ + private void handleSmimeProviderMissing() { + boolean hasDrafts = account.hasDraftsFolder(); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.smime_provider_missing_title) + .setMessage(hasDrafts + ? R.string.smime_provider_missing_message + : R.string.smime_provider_missing_no_drafts_message) + .setPositiveButton(R.string.smime_install_provider_action, + (dialog, which) -> openCipherMailInPlayStore()) + .setNegativeButton(R.string.smime_disable_and_send_action, (dialog, which) -> { + disableSmimeForAccount(); + Toast.makeText(this, R.string.smime_disabled_toast, Toast.LENGTH_LONG).show(); + performSendAfterChecks(); + }) + .setNeutralButton(hasDrafts + ? R.string.save_draft_action + : R.string.assign_drafts_folder_action, + (dialog, which) -> { + if (hasDrafts) { + // Race-free: finish() happens inside onMessageBuildSuccess + // right after SaveMessageTask is started. + checkToSaveDraftAndSave(); + } else { + openFolderSettingsToAssignDraftsFolder(); + } + }) + .show(); + } + + /** Turn S/MIME off for this account and forget the provider binding, then persist. */ + private void disableSmimeForAccount() { + account.setSmimeEnabled(false); + account.setSmimeProvider(null); + preferences.saveAccount(account); + } + + /** Open the CipherMail listing in Google Play (browser fallback if Play is absent). */ + private void openCipherMailInPlayStore() { + try { + startActivity(new Intent(Intent.ACTION_VIEW, + Uri.parse("market://details?id=" + CIPHERMAIL_PACKAGE))); + } catch (android.content.ActivityNotFoundException e) { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse( + "https://play.google.com/store/apps/details?id=" + CIPHERMAIL_PACKAGE))); + } + } + + /** + * Open this account's folder settings so the user can assign a Drafts folder. Used when a + * message can't be saved because no Drafts folder is configured. Mirrors the + * Sent-folder-not-found flow (see {@link #setupSentFolderNotFoundDialogResults()}). The + * composer stays in the back stack with its content intact, so the user can return and + * save once a folder is assigned. + */ + private void openFolderSettingsToAssignDraftsFolder() { + AccountSettingsActivity.start(this, account.getUuid(), AccountSettingsFragment.PREFERENCE_FOLDERS); + } + private void checkToSaveDraftAndSave() { if (!account.hasDraftsFolder()) { Toast.makeText(this, R.string.compose_error_no_draft_folder, Toast.LENGTH_SHORT).show(); @@ -893,6 +1050,7 @@ public void performSendAfterChecks() { currentMessageBuilder = createMessageBuilder(false); if (currentMessageBuilder != null) { sendMessageHasBeenTriggered = true; + draftSavedAfterSendFailure = false; changesMadeSinceLastSave = false; setProgressBarIndeterminateVisibility(true); currentMessageBuilder.buildAsync(this); @@ -1308,13 +1466,20 @@ public void onClick(DialogInterface dialog, int whichButton) { case DIALOG_CONFIRM_DISCARD_ON_BACK: return new MaterialAlertDialogBuilder(this) .setTitle(R.string.confirm_discard_draft_message_title) - .setMessage(R.string.confirm_discard_draft_message) + .setMessage(R.string.confirm_discard_draft_no_drafts_folder) .setPositiveButton(com.fsck.k9.ui.base.R.string.cancel_action, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { dismissDialog(DIALOG_CONFIRM_DISCARD_ON_BACK); } }) + .setNeutralButton(R.string.assign_drafts_folder_action, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + dismissDialog(DIALOG_CONFIRM_DISCARD_ON_BACK); + openFolderSettingsToAssignDraftsFolder(); + } + }) .setNegativeButton(R.string.discard_action, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { @@ -1754,11 +1919,37 @@ public void onMessageBuildCancel() { @Override public void onMessageBuildException(MessagingException me) { Log.e(me, "Error sending message"); - Toast.makeText(MessageCompose.this, - getString(R.string.send_failed_reason, me.getLocalizedMessage()), Toast.LENGTH_LONG).show(); + boolean wasSendAttempt = sendMessageHasBeenTriggered; sendMessageHasBeenTriggered = false; currentMessageBuilder = null; setProgressBarIndeterminateVisibility(false); + + if (wasSendAttempt && !draftSavedAfterSendFailure && account.hasDraftsFolder()) { + // The send build failed (provider error, locked keystore cancelled, serialization + // failure, ...). Don't lose the user's message: persist it to Drafts before + // reporting the failure. The guard flag prevents a failing rescue save from + // recursing back into this handler. The composer stays open so the user can + // retry once the underlying problem is resolved. + draftSavedAfterSendFailure = true; + showSendFailedDialog(getString(R.string.send_failed_message_saved_to_drafts, me.getLocalizedMessage())); + finishAfterDraftSaved = false; + performSaveAfterChecks(); + } else { + showSendFailedDialog(getString(R.string.send_failed_reason, me.getLocalizedMessage())); + } + } + + /** + * Report a send-build failure with a dismissible dialog rather than a Toast. A Toast vanishes + * after a few seconds and is easily missed if the user looks away; the failure (e.g. no S/MIME + * signing certificate for the From address) needs an explicit acknowledgement. + */ + private void showSendFailedDialog(String message) { + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.send_failed_title) + .setMessage(message) + .setPositiveButton(com.fsck.k9.ui.base.R.string.okay_action, null) + .show(); } @Override diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java index c208bee84ea..846e05123aa 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/MessageLoaderHelper.java @@ -31,6 +31,7 @@ import com.fsck.k9.ui.crypto.MessageCryptoCallback; import com.fsck.k9.ui.crypto.MessageCryptoHelper; import com.fsck.k9.ui.crypto.OpenPgpApiFactory; +import com.fsck.k9.ui.crypto.SmimeCryptoHelper; import com.fsck.k9.ui.message.LocalMessageExtractorLoader; import com.fsck.k9.ui.message.LocalMessageLoader; import net.thunderbird.core.android.account.LegacyAccountDto; @@ -96,6 +97,7 @@ public class MessageLoaderHelper { private OpenPgpDecryptionResult cachedDecryptionResult; private MessageCryptoHelper messageCryptoHelper; + private SmimeCryptoHelper smimeCryptoHelper; public MessageLoaderHelper(Context context, LoaderManager loaderManager, FragmentManager fragmentManager, @@ -144,6 +146,8 @@ public void asyncReloadMessage() { public void resumeCryptoOperationIfNecessary() { if (messageCryptoHelper != null) { messageCryptoHelper.resumeCryptoOperationIfNecessary(); + } else if (smimeCryptoHelper != null) { + smimeCryptoHelper.resumeCryptoOperationIfNecessary(); } } @@ -155,9 +159,14 @@ public void asyncRestartMessageCryptoProcessing() { String openPgpProvider = account.getOpenPgpProvider(); if (openPgpProvider != null) { startOrResumeCryptoOperation(openPgpProvider); - } else { - startOrResumeDecodeMessage(); + return; } + String smimeProvider = account.getSmimeProvider(); + if (smimeProvider != null) { + startOrResumeSmimeCryptoOperation(smimeProvider); + return; + } + startOrResumeDecodeMessage(); } /** Cancels all loading processes, prevents future callbacks, and destroys all loading state. */ @@ -166,6 +175,9 @@ public void onDestroy() { if (messageCryptoHelper != null) { messageCryptoHelper.cancelIfRunning(); } + if (smimeCryptoHelper != null) { + smimeCryptoHelper.cancelIfRunning(); + } callback = null; context = null; @@ -182,6 +194,9 @@ public void onDestroyChangingConfigurations() { if (messageCryptoHelper != null) { messageCryptoHelper.detachCallback(); } + if (smimeCryptoHelper != null) { + smimeCryptoHelper.detachCallback(); + } callback = null; context = null; @@ -196,7 +211,11 @@ public void downloadCompleteMessage() { @UiThread public void onActivityResult(int requestCode, int resultCode, Intent data) { - messageCryptoHelper.onActivityResult(requestCode, resultCode, data); + if (messageCryptoHelper != null) { + messageCryptoHelper.onActivityResult(requestCode, resultCode, data); + } else if (smimeCryptoHelper != null) { + smimeCryptoHelper.onActivityResult(requestCode, resultCode, data); + } } @@ -246,6 +265,12 @@ private void onLoadMessageFromDatabaseFinished() { return; } + String smimeProvider = account.getSmimeProvider(); + if (smimeProvider != null) { + startOrResumeSmimeCryptoOperation(smimeProvider); + return; + } + startOrResumeDecodeMessage(); } @@ -315,6 +340,18 @@ private void startOrResumeCryptoOperation(String openPgpProvider) { localMessage, messageCryptoCallback, cachedDecryptionResult, !account.isOpenPgpHideSignOnly()); } + private void startOrResumeSmimeCryptoOperation(String smimeProvider) { + RetainFragment retainFragment = getSmimeCryptoHelperRetainFragment(true); + if (retainFragment.hasData()) { + smimeCryptoHelper = retainFragment.getData(); + } + if (smimeCryptoHelper == null || !smimeCryptoHelper.isConfiguredForSmimeProvider(smimeProvider)) { + smimeCryptoHelper = new SmimeCryptoHelper(context, smimeProvider); + retainFragment.setData(smimeCryptoHelper); + } + smimeCryptoHelper.asyncStartOrResumeProcessingMessage(localMessage, messageCryptoCallback); + } + private void cancelAndClearCryptoOperation() { RetainFragment retainCryptoHelperFragment = getMessageCryptoHelperRetainFragment(false); if (retainCryptoHelperFragment != null) { @@ -325,6 +362,16 @@ private void cancelAndClearCryptoOperation() { } retainCryptoHelperFragment.clearAndRemove(fragmentManager); } + + RetainFragment smimeRetainFragment = getSmimeCryptoHelperRetainFragment(false); + if (smimeRetainFragment != null) { + if (smimeRetainFragment.hasData()) { + smimeCryptoHelper = smimeRetainFragment.getData(); + smimeCryptoHelper.cancelIfRunning(); + smimeCryptoHelper = null; + } + smimeRetainFragment.clearAndRemove(fragmentManager); + } } private RetainFragment getMessageCryptoHelperRetainFragment(boolean createIfNotExists) { @@ -335,6 +382,14 @@ private RetainFragment getMessageCryptoHelperRetainFragment } } + private RetainFragment getSmimeCryptoHelperRetainFragment(boolean createIfNotExists) { + if (createIfNotExists) { + return RetainFragment.findOrCreate(fragmentManager, "smime_crypto_helper_" + messageReference.hashCode()); + } else { + return RetainFragment.findOrNull(fragmentManager, "smime_crypto_helper_" + messageReference.hashCode()); + } + } + private MessageCryptoCallback messageCryptoCallback = new MessageCryptoCallback() { @Override public void onCryptoHelperProgress(int current, int max) { diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt index a86e847a1d3..a0575cbcbc0 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientMvpView.kt @@ -367,6 +367,11 @@ class RecipientMvpView(private val activity: MessageCompose) : View.OnFocusChang Toast.makeText(activity, R.string.error_crypto_inline_attach, Toast.LENGTH_LONG).show() } + fun showSmimeStatusInfo(allCertsPresent: Boolean) { + val msgRes = if (allCertsPresent) R.string.smime_status_active else R.string.smime_status_missing_certs + Toast.makeText(activity, msgRes, Toast.LENGTH_SHORT).show() + } + override fun onFocusChange(view: View, hasFocus: Boolean) { if (!hasFocus) return diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt index 107b9b3b2f5..50f8d3f682a 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/activity/compose/RecipientPresenter.kt @@ -9,11 +9,17 @@ import android.net.Uri import android.os.AsyncTask import android.os.Bundle import android.view.Menu +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.loader.app.LoaderManager +import com.ciphermail.smime.api.ISmimeService +import com.ciphermail.smime.api.SmimeCertificateInfo +import com.ciphermail.smime.api.util.SmimeApi +import com.ciphermail.smime.api.util.SmimeServiceConnection import com.fsck.k9.K9 import com.fsck.k9.activity.compose.ComposeCryptoStatus.AttachErrorState import com.fsck.k9.activity.compose.ComposeCryptoStatus.SendErrorState +import com.fsck.k9.activity.compose.RecipientMvpView.CryptoStatusDisplayType import com.fsck.k9.autocrypt.AutocryptDraftStateHeader import com.fsck.k9.autocrypt.AutocryptDraftStateHeaderParser import com.fsck.k9.helper.MailTo @@ -27,6 +33,7 @@ import com.fsck.k9.message.ComposePgpEnableByDefaultDecider import com.fsck.k9.message.ComposePgpInlineDecider import com.fsck.k9.message.MessageBuilder import com.fsck.k9.message.PgpMessageBuilder +import com.fsck.k9.message.SmimeMessageBuilder import com.fsck.k9.ui.R import com.fsck.k9.view.RecipientSelectView.Recipient import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY @@ -82,6 +89,9 @@ class RecipientPresenter( var currentCachedCryptoStatus: ComposeCryptoStatus? = null private set + private var smimeCertCheckConnection: SmimeServiceConnection? = null + private var smimeAllCertsPresent: Boolean = false + val toAddresses: List
get() = recipientMvpView.toAddresses @@ -418,6 +428,7 @@ class RecipientPresenter( if (openPgpProviderState != OpenPgpProviderState.OK) { currentCachedCryptoStatus = composeCryptoStatus redrawCachedCryptoStatusIcon() + if (account.isSmimeProviderConfigured) asyncUpdateSmimeCertStatus() return } @@ -436,6 +447,7 @@ class RecipientPresenter( } redrawCachedCryptoStatusIcon() + if (account.isSmimeProviderConfigured) asyncUpdateSmimeCertStatus() } }.execute() } @@ -444,10 +456,80 @@ class RecipientPresenter( val cryptoStatus = checkNotNull(currentCachedCryptoStatus) { "must have cached crypto status to redraw it!" } recipientMvpView.setRecipientTokensShowCryptoEnabled(cryptoStatus.isEncryptionEnabled) - recipientMvpView.showCryptoStatus(cryptoStatus.displayType) + // When S/MIME is active the cert check manages the crypto_status view independently. + if (!account.isSmimeProviderConfigured) { + recipientMvpView.showCryptoStatus(cryptoStatus.displayType) + } recipientMvpView.showCryptoSpecialMode(cryptoStatus.specialModeDisplayType) } + /** + * Refresh the compose-screen lock icon for S/MIME by asking the + * configured provider whether every current recipient has a usable + * certificate. + * + * Calls `ACTION_GET_CERTIFICATES` off the UI thread; the result drives + * `CryptoStatusDisplayType` (green = all certs present and we'll encrypt, + * red = at least one missing). Cancels any prior in-flight bind to avoid + * racing service connections when recipients are typed quickly. + * + * No-op when the account has no `smimeProvider` configured. + */ + private fun asyncUpdateSmimeCertStatus() { + smimeCertCheckConnection?.unbindFromService() + smimeCertCheckConnection = null + + val smimeProvider = account.smimeProvider ?: return + + val recipientEmails = (toAddresses + ccAddresses + bccAddresses) + .map { it.address }.toTypedArray() + + if (recipientEmails.isEmpty()) { + recipientMvpView.showCryptoStatus(CryptoStatusDisplayType.AVAILABLE) + return + } + + smimeCertCheckConnection = SmimeServiceConnection( + context, + smimeProvider, + object : SmimeServiceConnection.OnBound { + override fun onBound(service: ISmimeService) { + val intent = Intent(SmimeApi.ACTION_GET_CERTIFICATES).apply { + putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION) + putExtra(SmimeApi.EXTRA_USER_IDS, recipientEmails) + } + SmimeApi(service).executeApiAsync(intent, null, null) { result -> + smimeCertCheckConnection?.unbindFromService() + smimeCertCheckConnection = null + onSmimeCertCheckResult(result) + } + } + + override fun onError(e: Exception) { + smimeCertCheckConnection = null + recipientMvpView.showCryptoStatus(CryptoStatusDisplayType.ENABLED_ERROR) + } + }, + ) + smimeCertCheckConnection!!.bindToService() + } + + @VisibleForTesting + internal fun onSmimeCertCheckResult(result: Intent) { + result.setExtrasClassLoader(SmimeCertificateInfo::class.java.classLoader) + val resultCode = result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR) + val allPresent = if (resultCode == SmimeApi.RESULT_CODE_SUCCESS) { + val certs = result.getParcelableArrayExtra(SmimeApi.RESULT_CERTIFICATES) + certs != null && certs.all { (it as SmimeCertificateInfo).hasValidCertificate } + } else { + false + } + smimeAllCertsPresent = allPresent + recipientMvpView.showCryptoStatus( + if (allPresent) CryptoStatusDisplayType.ENABLED_TRUSTED else CryptoStatusDisplayType.ENABLED_ERROR, + ) + } + fun onToTokenAdded() { asyncUpdateCryptoStatus() } @@ -565,6 +647,10 @@ class RecipientPresenter( } fun onClickCryptoStatus() { + if (account.isSmimeProviderConfigured) { + recipientMvpView.showSmimeStatusInfo(smimeAllCertsPresent) + return + } when (openPgpApiManager.openPgpProviderState) { OpenPgpProviderState.UNCONFIGURED -> { Log.e("click on crypto status while unconfigured - this should not really happen?!") @@ -665,12 +751,21 @@ class RecipientPresenter( require(messageBuilder !is PgpMessageBuilder) { "PpgMessageBuilder must be called with ComposeCryptoStatus argument!" } + require(messageBuilder !is SmimeMessageBuilder) { + "SmimeMessageBuilder must be called with smime-specific builderSetProperties!" + } messageBuilder.setTo(toAddresses) messageBuilder.setCc(ccAddresses) messageBuilder.setBcc(bccAddresses) } + fun builderSetProperties(smimeMessageBuilder: SmimeMessageBuilder) { + smimeMessageBuilder.setTo(toAddresses) + smimeMessageBuilder.setCc(ccAddresses) + smimeMessageBuilder.setBcc(bccAddresses) + } + fun builderSetProperties(pgpMessageBuilder: PgpMessageBuilder, cryptoStatus: ComposeCryptoStatus) { pgpMessageBuilder.setTo(toAddresses) pgpMessageBuilder.setCc(ccAddresses) diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/SmimeCryptoHelper.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/SmimeCryptoHelper.kt new file mode 100644 index 00000000000..d469331e152 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/crypto/SmimeCryptoHelper.kt @@ -0,0 +1,324 @@ +package com.fsck.k9.ui.crypto + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.content.IntentCompat +import com.ciphermail.smime.api.ISmimeService +import com.ciphermail.smime.api.SmimeDecryptionResult +import com.ciphermail.smime.api.SmimeError +import com.ciphermail.smime.api.SmimeSignatureResult +import com.ciphermail.smime.api.util.SmimeApi +import com.ciphermail.smime.api.util.SmimeServiceConnection +import com.fsck.k9.crypto.MessageCryptoStructureDetector +import com.fsck.k9.mail.Message +import com.fsck.k9.mail.Part +import com.fsck.k9.mail.internet.MimeBodyPart +import com.fsck.k9.mailstore.CryptoResultAnnotation +import com.fsck.k9.mailstore.MessageCryptoAnnotations +import com.fsck.k9.mailstore.MimePartStreamParser +import com.fsck.k9.provider.DecryptedFileProvider +import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.logging.legacy.Log +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException + +/** + * Receive-side S/MIME orchestration for the message-view path. + * + * Mirrors [MessageCryptoHelper] for OpenPGP: detects S/MIME parts in an incoming message, + * binds to the user's configured S/MIME provider over AIDL, asks it to decrypt and verify, and + * stitches the result back into the message's [MessageCryptoAnnotations]. Owned by + * `MessageLoaderHelper` and lives for the lifetime of a single message view. + * + * The helper handles the asynchronous `RESULT_CODE_USER_INTERACTION_REQUIRED` dance: when the + * provider's keystore is locked, the returned [PendingIntent] is queued and surfaced to the host + * via `MessageCryptoCallback.startPendingIntentForCryptoHelper`; on `RESULT_OK` from the + * resulting passphrase dialog, [onActivityResult] re-runs the decrypt path with the now-unlocked + * keystore. + * + * One [SmimeCryptoHelper] instance is bound to one provider package (the account's + * `smimeProvider` setting). If the account is later reconfigured to a different provider, the + * host must construct a new instance — see [isConfiguredForSmimeProvider]. + */ +@Suppress("TooManyFunctions") // state-machine helper; splitting would harm readability +class SmimeCryptoHelper(context: Context, private val smimeProvider: String) { + private val context: Context = context.applicationContext + private val callbackLock = Any() + + private var callback: MessageCryptoCallback? = null + private var currentMessage: Message? = null + private var messageAnnotations: MessageCryptoAnnotations? = null + private var queuedResult: MessageCryptoAnnotations? = null + private var queuedPendingIntent: PendingIntent? = null + private var isCancelled: Boolean = false + + private var pendingSmimePart: Part? = null + private var smimeServiceConnection: SmimeServiceConnection? = null + + /** + * @return `true` if this helper is bound to the given provider package and can be reused; + * `false` if the host must rebuild it. + */ + fun isConfiguredForSmimeProvider(provider: String): Boolean = smimeProvider == provider + + /** + * Begin (or resume, after a configuration change) S/MIME processing for a message. + * + * If a previous call is still in flight for the same `message`, the callback is rebound to + * the existing operation rather than starting a new one. Calling with a different message + * after one is already in flight is a programming error. + */ + fun asyncStartOrResumeProcessingMessage(message: Message, callback: MessageCryptoCallback) { + if (currentMessage != null) { + reattachCallback(message, callback) + return + } + messageAnnotations = MessageCryptoAnnotations() + currentMessage = message + this.callback = callback + startProcessing() + } + + private fun startProcessing() { + val message = currentMessage ?: return + val smimePart = MessageCryptoStructureDetector.findPrimaryEncryptedOrSignedPart(message, ArrayList()) + if (smimePart == null || !MessageCryptoStructureDetector.isSmimePart(smimePart)) { + callbackReturnResult() + return + } + pendingSmimePart = smimePart + connectToSmimeService(smimePart) + } + + private fun connectToSmimeService(smimePart: Part) { + smimeServiceConnection = SmimeServiceConnection( + context, + smimeProvider, + object : SmimeServiceConnection.OnBound { + override fun onBound(service: ISmimeService) { + processSmimePartAsync(service, smimePart) + } + + override fun onError(e: Exception) { + Log.e(e, "Couldn't connect to SmimeService") + addGenericErrorAnnotation(smimePart) + callbackReturnResult() + } + }, + ).apply { bindToService() } + } + + private fun processSmimePartAsync(service: ISmimeService, smimePart: Part) { + val api = SmimeApi(service) + val decryptIntent = Intent(SmimeApi.ACTION_DECRYPT_VERIFY).apply { + putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION) + } + + val messageBytes = try { + ByteArrayOutputStream().apply { currentMessage?.writeTo(this) }.toByteArray() + } catch (e: IOException) { + Log.e(e, "Failed to serialize message for S/MIME processing") + addGenericErrorAnnotation(smimePart) + callbackReturnResult() + return + } catch (e: MessagingException) { + Log.e(e, "Failed to serialize message for S/MIME processing") + addGenericErrorAnnotation(smimePart) + callbackReturnResult() + return + } + + val decryptedOutput = ByteArrayOutputStream() + api.executeApiAsync(decryptIntent, ByteArrayInputStream(messageBytes), decryptedOutput) { result -> + if (!isCancelled) { + onSmimeOperationResult(smimePart, result, decryptedOutput.toByteArray()) + } + } + } + + private fun onSmimeOperationResult(smimePart: Part, result: Intent, decryptedBytes: ByteArray) { + result.setExtrasClassLoader(SmimeError::class.java.classLoader) + when (result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR)) { + SmimeApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent = IntentCompat.getParcelableExtra( + result, SmimeApi.RESULT_INTENT, PendingIntent::class.java, + ) + if (pendingIntent != null) { + callbackPendingIntent(pendingIntent) + } else { + addGenericErrorAnnotation(smimePart) + callbackReturnResult() + } + } + SmimeApi.RESULT_CODE_ERROR -> { + val error = IntentCompat.getParcelableExtra(result, SmimeApi.RESULT_ERROR, SmimeError::class.java) + Log.w("S/MIME API error: %s", error?.message ?: "unknown") + addApiErrorAnnotation(smimePart, error) + callbackReturnResult() + } + SmimeApi.RESULT_CODE_SUCCESS -> { + onSmimeOperationSuccess(smimePart, result, decryptedBytes) + callbackReturnResult() + } + else -> { + Log.e("Unknown S/MIME result code: %d", result.getIntExtra(SmimeApi.RESULT_CODE, -1)) + addGenericErrorAnnotation(smimePart) + callbackReturnResult() + } + } + } + + private fun onSmimeOperationSuccess(smimePart: Part, result: Intent, decryptedBytes: ByteArray) { + val decryptionResult = IntentCompat.getParcelableExtra( + result, SmimeApi.RESULT_DECRYPTION, SmimeDecryptionResult::class.java, + ) + val signatureResult = IntentCompat.getParcelableExtra( + result, SmimeApi.RESULT_SIGNATURE, SmimeSignatureResult::class.java, + ) + val pendingIntent = IntentCompat.getParcelableExtra( + result, SmimeApi.RESULT_INTENT, PendingIntent::class.java, + ) + + val wasEncrypted = decryptionResult?.result == SmimeDecryptionResult.RESULT_ENCRYPTED + val decryptedPart: MimeBodyPart? = if (wasEncrypted && decryptedBytes.isNotEmpty()) { + try { + MimePartStreamParser.parse( + DecryptedFileProvider.getFileFactory(context), + ByteArrayInputStream(decryptedBytes), + ) + } catch (e: IOException) { + Log.e(e, "Error parsing decrypted S/MIME part") + null + } catch (e: MessagingException) { + Log.e(e, "Error parsing decrypted S/MIME part") + null + } + } else { + null + } + + val annotation = CryptoResultAnnotation.createSmimeResultAnnotation( + decryptionResult, signatureResult, pendingIntent, decryptedPart, false, + ) + messageAnnotations?.put(smimePart, annotation) + } + + private fun addGenericErrorAnnotation(smimePart: Part) { + val annotation = CryptoResultAnnotation.createErrorAnnotation( + CryptoResultAnnotation.CryptoError.SMIME_ENCRYPTED_API_ERROR, null, + ) + messageAnnotations?.put(smimePart, annotation) + } + + private fun addApiErrorAnnotation(smimePart: Part, error: SmimeError?) { + val annotation = if (MessageCryptoStructureDetector.isSmimeSignedMultipart(smimePart)) { + CryptoResultAnnotation.createSmimeSignatureErrorAnnotation(error, null) + } else { + CryptoResultAnnotation.createSmimeEncryptionErrorAnnotation(error) + } + messageAnnotations?.put(smimePart, annotation) + } + + /** + * Hook for the host activity to forward results from the provider's passphrase dialog + * (launched via a [PendingIntent] when the service returned + * `RESULT_CODE_USER_INTERACTION_REQUIRED`). On `RESULT_OK` the decrypt path is retried; + * otherwise a generic error annotation is added and the host callback is invoked. + */ + fun onActivityResult(requestCode: Int, resultCode: Int, @Suppress("UnusedParameter") data: Intent?) { + if (isCancelled) return + check(requestCode == REQUEST_CODE_USER_INTERACTION) { + "got an activity result that wasn't meant for us. this is a bug!" + } + val smimePart = pendingSmimePart + if (resultCode == Activity.RESULT_OK && smimePart != null) { + synchronized(callbackLock) { queuedPendingIntent = null } + connectToSmimeService(smimePart) + } else { + if (smimePart != null) addGenericErrorAnnotation(smimePart) + callbackReturnResult() + } + } + + fun resumeCryptoOperationIfNecessary() { + synchronized(callbackLock) { + if (queuedPendingIntent != null) deliverResult() + } + } + + /** + * Cancel any in-flight operation and release the service binding. Safe to call multiple times. + * After this call, no callbacks fire. + */ + fun cancelIfRunning() { + isCancelled = true + detachCallback() + smimeServiceConnection?.unbindFromService() + smimeServiceConnection = null + } + + /** + * Detach the host callback without cancelling the in-flight operation. Results produced while + * detached are queued and delivered when a new callback is reattached via + * [asyncStartOrResumeProcessingMessage]. Used when the host activity is being recreated + * (configuration change). + */ + fun detachCallback() { + synchronized(callbackLock) { callback = null } + } + + private fun reattachCallback(message: Message, callback: MessageCryptoCallback) { + require(message == currentMessage) { "Callback may only be reattached for the same message!" } + synchronized(callbackLock) { + this.callback = callback + if (queuedResult != null || queuedPendingIntent != null) deliverResult() + } + } + + private fun callbackReturnResult() { + synchronized(callbackLock) { + smimeServiceConnection?.unbindFromService() + smimeServiceConnection = null + queuedResult = messageAnnotations + messageAnnotations = null + deliverResult() + } + } + + private fun callbackPendingIntent(pendingIntent: PendingIntent) { + synchronized(callbackLock) { + queuedPendingIntent = pendingIntent + deliverResult() + } + } + + private fun deliverResult() { + if (isCancelled) return + val cb = callback + if (cb == null) { + Log.d("Keeping S/MIME crypto result in queue for later delivery") + return + } + val result = queuedResult + val pending = queuedPendingIntent + when { + result != null -> { + queuedResult = null + cb.onCryptoOperationsFinished(result) + } + pending != null -> { + if (cb.startPendingIntentForCryptoHelper(pending.intentSender, REQUEST_CODE_USER_INTERACTION)) { + queuedPendingIntent = null + } + } + else -> error("deliverResult() called with no result!") + } + } + + companion object { + private const val REQUEST_CODE_USER_INTERACTION = 125 + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageExtractorLoader.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageExtractorLoader.java index fcb42e4e19a..fb70596e313 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageExtractorLoader.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/message/LocalMessageExtractorLoader.java @@ -51,7 +51,9 @@ public void deliverResult(MessageViewInfo messageViewInfo) { @WorkerThread public MessageViewInfo loadInBackground() { try { - return messageViewInfoExtractor.extractMessageForView(message, annotations, message.getAccount().isOpenPgpProviderConfigured()); + return messageViewInfoExtractor.extractMessageForView(message, annotations, + message.getAccount().isOpenPgpProviderConfigured(), + message.getAccount().isSmimeProviderConfigured()); } catch (Exception e) { Log.e(e, "Error while decoding message"); return null; diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java index 04a58f99a44..f1549002929 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageCryptoPresenter.java @@ -6,14 +6,22 @@ import android.content.Context; import android.content.Intent; import android.content.IntentSender; +import android.content.pm.PackageManager; import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; import android.graphics.drawable.Drawable; import android.os.Parcelable; import androidx.annotation.Nullable; import android.text.TextUtils; +import java.util.List; +import com.ciphermail.smime.api.SmimeDecryptionResult; +import com.ciphermail.smime.api.SmimeSignatureResult; +import com.ciphermail.smime.api.util.SmimeApi; import com.fsck.k9.mailstore.CryptoResultAnnotation; import com.fsck.k9.mailstore.MessageViewInfo; +import com.fsck.k9.ui.R; import com.fsck.k9.view.MessageCryptoDisplayStatus; +import com.fsck.k9.view.MessageHeader; import net.thunderbird.core.android.account.LegacyAccountDto; import net.thunderbird.core.logging.legacy.Log; @@ -57,7 +65,12 @@ public boolean maybeHandleShowMessage(MessageTopView messageView, LegacyAccountD return false; } - messageView.getMessageHeaderView().setCryptoStatus(displayStatus); + CryptoResultAnnotation annotation = messageViewInfo.cryptoResultAnnotation; + if (annotation != null && annotation.isSmimeResult()) { + showSmimeHeaderStatus(messageView, account, annotation); + } else { + messageView.getMessageHeaderView().setCryptoStatus(displayStatus); + } switch (displayStatus) { case CANCELLED: { @@ -74,7 +87,7 @@ public boolean maybeHandleShowMessage(MessageTopView messageView, LegacyAccountD case ENCRYPTED_ERROR: case UNSUPPORTED_ENCRYPTED: { - Drawable providerIcon = getOpenPgpApiProviderIcon(messageView.getContext(), account.getOpenPgpProvider()); + Drawable providerIcon = getCryptoProviderIcon(messageView.getContext(), account, messageViewInfo.cryptoResultAnnotation); messageView.showMessageCryptoErrorView(messageViewInfo, providerIcon); break; } @@ -99,6 +112,83 @@ public boolean maybeHandleShowMessage(MessageTopView messageView, LegacyAccountD return true; } + /** + * Render the S/MIME-specific header indicators: separate encrypted/signed icons plus an + * "open in CipherMail" action when the provider is installed. Replaces the single combined + * badge for S/MIME messages so the user can tell encryption and signing apart. + */ + private void showSmimeHeaderStatus(MessageTopView messageView, LegacyAccountDto account, + CryptoResultAnnotation annotation) { + SmimeDecryptionResult decryptionResult = annotation.getSmimeDecryptionResult(); + SmimeSignatureResult signatureResult = annotation.getSmimeSignatureResult(); + + boolean encrypted = decryptionResult != null + && decryptionResult.getResult() == SmimeDecryptionResult.RESULT_ENCRYPTED; + + int signedIconRes = 0; + int signedColorAttr = 0; + if (signatureResult != null) { + switch (signatureResult.getResult()) { + case SmimeSignatureResult.RESULT_VALID_TRUSTED: + signedIconRes = R.drawable.status_signature_dots_3; + signedColorAttr = R.attr.openpgp_green; + break; + case SmimeSignatureResult.RESULT_VALID_UNTRUSTED: + case SmimeSignatureResult.RESULT_CERT_MISSING: + signedIconRes = R.drawable.status_signature_dots_3; + signedColorAttr = R.attr.openpgp_orange; + break; + case SmimeSignatureResult.RESULT_INVALID_SIGNATURE: + case SmimeSignatureResult.RESULT_CERT_EXPIRED: + case SmimeSignatureResult.RESULT_CERT_REVOKED: + signedIconRes = R.drawable.status_lock_error; + signedColorAttr = R.attr.openpgp_grey; + break; + default: // RESULT_NO_SIGNATURE and anything else: no signature icon + break; + } + } + + String providerPackage = resolveSmimeProviderPackage(messageView.getContext(), account); + + MessageHeader header = messageView.getMessageHeaderView(); + header.setSmimeCryptoStatus(encrypted, signedIconRes, signedColorAttr, providerPackage != null); + if (providerPackage != null) { + header.setOpenInSmimeProviderClickListener( + v -> messageCryptoMvpView.onClickOpenMessageInSmimeProvider(providerPackage)); + } + } + + /** + * Resolve the installed S/MIME provider package. Prefers the account's configured + * provider, but falls back to any installed app exposing the S/MIME service — the + * account's {@code smimeProvider} can be null even when S/MIME works and CipherMail + * is installed (S/MIME-enabled is tracked independently of the resolved provider). + */ + @Nullable + private String resolveSmimeProviderPackage(Context context, LegacyAccountDto account) { + PackageManager packageManager = context.getPackageManager(); + + String providerPackage = account.getSmimeProvider(); + if (providerPackage != null) { + try { + packageManager.getPackageInfo(providerPackage, 0); + return providerPackage; + } catch (PackageManager.NameNotFoundException e) { + // configured provider is gone; fall back to discovery below + } + } + + Intent serviceIntent = new Intent(SmimeApi.SERVICE_INTENT); + List services = packageManager.queryIntentServices(serviceIntent, 0); + for (ResolveInfo resolveInfo : services) { + if (resolveInfo.serviceInfo != null) { + return resolveInfo.serviceInfo.packageName; + } + } + return null; + } + @SuppressWarnings("UnusedParameters") // for consistency with Activity.onActivityResult public void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_CODE_UNKNOWN_KEY) { @@ -161,6 +251,30 @@ private static Drawable getOpenPgpApiProviderIcon(Context context, String openPg } } + @Nullable + private static Drawable getCryptoProviderIcon(Context context, LegacyAccountDto account, + CryptoResultAnnotation annotation) { + String providerPackage; + if (annotation != null && isSmimeCryptoError(annotation)) { + providerPackage = account.getSmimeProvider(); + } else { + providerPackage = account.getOpenPgpProvider(); + } + return getOpenPgpApiProviderIcon(context, providerPackage); + } + + private static boolean isSmimeCryptoError(CryptoResultAnnotation annotation) { + switch (annotation.getErrorType()) { + case SMIME_OK: + case SMIME_SIGNED_API_ERROR: + case SMIME_ENCRYPTED_API_ERROR: + case SMIME_ENCRYPTED_NO_PROVIDER: + return true; + default: + return false; + } + } + public void onClickConfigureProvider() { reloadOnResumeWithoutRecreateFlag = true; messageCryptoMvpView.showCryptoConfigDialog(); @@ -174,5 +288,8 @@ void startPendingIntentForCryptoPresenter(IntentSender intentSender, Integer req throws IntentSender.SendIntentException; void showCryptoConfigDialog(); + + /** Hand the raw message to the given S/MIME provider package for inspection. */ + void onClickOpenMessageInSmimeProvider(String providerPackage); } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt index 3d171b18bd8..d8ee42257a9 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt @@ -210,9 +210,9 @@ class MessageTopView( val view = layoutInflater.inflate(R.layout.message_content_crypto_error, containerView, false) setCryptoProviderIcon(providerIcon, view) val cryptoErrorText = view.findViewById(R.id.crypto_error_text) - val openPgpError = messageViewInfo.cryptoResultAnnotation.openPgpError - if (openPgpError != null) { - val errorText = openPgpError.message + val annotation = messageViewInfo.cryptoResultAnnotation + val errorText = annotation.openPgpError?.message ?: annotation.smimeError?.message + if (errorText != null) { cryptoErrorText.text = errorText } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt index 45d7db9a624..2437211ff71 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageViewFragment.kt @@ -1087,6 +1087,25 @@ class MessageViewFragment : override fun showCryptoConfigDialog() { AccountSettingsActivity.startCryptoSettings(requireActivity(), account.uuid) } + + override fun onClickOpenMessageInSmimeProvider(providerPackage: String) { + val rawMessageUri = RawMessageProvider.getRawMessageUri(messageReference) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(rawMessageUri, "application/eml") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setPackage(providerPackage) + } + try { + startActivity(intent) + } catch (e: android.content.ActivityNotFoundException) { + Log.e(e, "S/MIME provider cannot open the message") + Toast.makeText( + requireContext(), + R.string.smime_open_in_provider_failed, + Toast.LENGTH_LONG, + ).show() + } + } } interface MessageViewFragmentListener { diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt index 19591306ba6..debc2d58398 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/AccountSettingsFragment.kt @@ -3,6 +3,7 @@ package com.fsck.k9.ui.settings.account import android.annotation.SuppressLint import android.app.Activity import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.view.Menu @@ -16,12 +17,14 @@ import androidx.core.net.toUri import androidx.core.view.MenuHost import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle +import androidx.preference.CheckBoxPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.SwitchPreference import app.k9mail.feature.launcher.FeatureLauncherActivity import app.k9mail.feature.launcher.FeatureLauncherTarget +import com.ciphermail.smime.api.util.SmimeApi import com.fsck.k9.activity.ManageIdentities import com.fsck.k9.activity.setup.AccountSetupComposition import com.fsck.k9.controller.MessagingController @@ -38,11 +41,13 @@ import com.fsck.k9.ui.settings.onClick import com.fsck.k9.ui.settings.oneTimeClickListener import com.fsck.k9.ui.settings.remove import com.fsck.k9.ui.settings.removeEntry +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.takisoft.preferencex.PreferenceFragmentCompat import net.thunderbird.core.android.account.AccountDefaultsProvider.Companion.NO_OPENPGP_KEY import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.android.account.QuoteStyle import net.thunderbird.core.common.provider.AppNameProvider +import net.thunderbird.core.logging.legacy.Log import net.thunderbird.feature.account.settings.api.BackgroundAccountRemover import net.thunderbird.feature.mail.folder.api.FolderType import net.thunderbird.feature.mail.folder.api.RemoteFolder @@ -107,6 +112,7 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr initializeMessageAge(account) initializeAdvancedPushSettings(account) initializeCryptoSettings(account) + initializeSmimeSettings(account) initializeFolderSettings(account) initializeNotifications(account) } @@ -414,6 +420,154 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr } } + private fun initializeSmimeSettings(account: LegacyAccountDto) { + findPreference(PREFERENCE_SMIME)?.let { + configureSmimePreferences(account) + } + } + + private fun configureSmimePreferences(account: LegacyAccountDto) { + // S/MIME stays enabled even when the provider package is missing (so outgoing + // mail is blocked at send time instead of being silently sent unencrypted). + // If it's enabled but the bound provider is gone/unset and exactly one + // provider is now installed, adopt it automatically (handles the + // install-later and debug/release-package-swap cases). + if (account.smimeEnabled && getSmimeProviderName(account.smimeProvider) == null) { + val installed = getSmimeProviderPackages() + if (installed.size == 1 && installed[0] != account.smimeProvider) { + setSmimeProvider(account, installed[0]) + configureSmimePreferences(account) + return + } + } + + configureEnableSmimeSupport(account) + configureSmimeDefaults(account) + } + + /** + * Default Sign / Encrypt checkboxes, shown only while S/MIME is on. These are the + * per-account defaults pre-selected for each new message (the composer also writes + * the user's last choice back here). + */ + private fun configureSmimeDefaults(account: LegacyAccountDto) { + val signPref = findPreference(PREFERENCE_SMIME_SIGN_DEFAULT) + val encryptPref = findPreference(PREFERENCE_SMIME_ENCRYPT_DEFAULT) + + signPref?.apply { + isVisible = account.smimeEnabled + // Invariant: encrypt implies sign (you can't encrypt without signing). + isChecked = account.smimeSign || account.smimeEncrypt + setOnPreferenceChangeListener { _, newValue -> + val sign = newValue as Boolean + account.smimeSign = sign + if (!sign && account.smimeEncrypt) { + account.smimeEncrypt = false + encryptPref?.isChecked = false + } + dataStore.saveSettingsInBackground() + true + } + } + encryptPref?.apply { + isVisible = account.smimeEnabled + isChecked = account.smimeEncrypt + setOnPreferenceChangeListener { _, newValue -> + val encrypt = newValue as Boolean + account.smimeEncrypt = encrypt + if (encrypt && !account.smimeSign) { + account.smimeSign = true + signPref?.isChecked = true + } + dataStore.saveSettingsInBackground() + true + } + } + } + + private fun getSmimeProviderName(smimeProvider: String?): String? { + if (smimeProvider == null) return null + return try { + requireActivity().packageManager + .getApplicationInfo(smimeProvider, 0) + .loadLabel(requireActivity().packageManager) + .toString() + } catch (e: PackageManager.NameNotFoundException) { + Log.d(e, "S/MIME provider package not installed: %s", smimeProvider) + null + } + } + + private fun configureEnableSmimeSupport(account: LegacyAccountDto) { + val providerName = getSmimeProviderName(account.smimeProvider) + (findPreference(PREFERENCE_SMIME_ENABLE) as SwitchPreference).apply { + if (!account.smimeEnabled) { + isChecked = false + setSummary(R.string.account_settings_smime_summary_off) + oneTimeClickListener(clickHandled = false) { + val smimeProviders = getSmimeProviderPackages() + when { + smimeProviders.size == 1 -> { + setSmimeProvider(account, smimeProviders[0]) + setSmimeEnabled(account, true) + configureSmimePreferences(account) + } + smimeProviders.size > 1 -> { + setSmimeEnabled(account, true) + summary = getString(R.string.account_settings_smime_summary_config) + SmimeAppSelectDialog.startSmimeChooserActivity(requireActivity(), account) + } + else -> { + // No provider installed: still turn S/MIME on so outgoing + // mail is blocked at send time (never silently unencrypted). + // Stay on this screen; just warn. + setSmimeEnabled(account, true) + configureSmimePreferences(account) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.account_settings_smime_no_provider_title) + .setMessage(R.string.account_settings_smime_no_provider_msg) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + } else { + isChecked = true + summary = if (providerName != null) { + getString(R.string.account_settings_smime_summary_on, providerName) + } else { + getString(R.string.account_settings_smime_summary_missing_provider) + } + oneTimeClickListener { + setSmimeEnabled(account, false) + removeSmimeProvider(account) + configureSmimePreferences(account) + } + } + } + } + + private fun getSmimeProviderPackages(): List { + val intent = android.content.Intent(SmimeApi.SERVICE_INTENT) + val resInfo = requireActivity().packageManager.queryIntentServices(intent, 0) + return resInfo.mapNotNull { it.serviceInfo?.packageName } + } + + private fun setSmimeProvider(account: LegacyAccountDto, smimeProviderPackage: String) { + account.smimeProvider = smimeProviderPackage + dataStore.saveSettingsInBackground() + } + + private fun removeSmimeProvider(account: LegacyAccountDto) { + account.smimeProvider = null + dataStore.saveSettingsInBackground() + } + + private fun setSmimeEnabled(account: LegacyAccountDto, enabled: Boolean) { + account.smimeEnabled = enabled + dataStore.saveSettingsInBackground() + } + private fun initializeFolderSettings(account: LegacyAccountDto) { findPreference(PREFERENCE_FOLDERS)?.let { if (!messagingController.supportsFolderSubscriptions(account)) { @@ -525,6 +679,10 @@ class AccountSettingsFragment : PreferenceFragmentCompat(), ConfirmationDialogFr private const val PREFERENCE_OPENPGP_ENABLE = "openpgp_provider" private const val PREFERENCE_OPENPGP_KEY = "openpgp_key" private const val PREFERENCE_AUTOCRYPT_TRANSFER = "autocrypt_transfer" + private const val PREFERENCE_SMIME = "smime" + private const val PREFERENCE_SMIME_ENABLE = "smime_provider" + private const val PREFERENCE_SMIME_SIGN_DEFAULT = "smime_sign_default" + private const val PREFERENCE_SMIME_ENCRYPT_DEFAULT = "smime_encrypt_default" internal const val PREFERENCE_FOLDERS = "folders" private const val PREFERENCE_AUTO_SELECT_FOLDER = "auto_select_folder" private const val PREFERENCE_SUBSCRIBED_FOLDERS_ONLY = "subscribed_folders_only" diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/SmimeAppSelectDialog.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/SmimeAppSelectDialog.kt new file mode 100644 index 00000000000..d8af71350b0 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/settings/account/SmimeAppSelectDialog.kt @@ -0,0 +1,165 @@ +package com.fsck.k9.ui.settings.account + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.ImageView +import androidx.fragment.app.DialogFragment +import com.ciphermail.smime.api.util.SmimeApi +import com.ciphermail.smime.api.util.SmimeServiceConnection +import com.fsck.k9.Preferences +import com.fsck.k9.ui.R +import com.fsck.k9.ui.base.BaseActivity +import com.fsck.k9.ui.base.ThemeType +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import net.thunderbird.core.android.account.LegacyAccountDto +import net.thunderbird.core.logging.legacy.Log + +/** + * Provider-picker dialog hosted by an activity. + * + * Enumerates every package on the device that exposes an [com.ciphermail.smime.api.ISmimeService] + * (matching the [SmimeApi.SERVICE_INTENT] action). The user's selection is persisted on the account + * as [LegacyAccountDto.smimeProvider]. From then on, [SmimeServiceConnection] binds with an + * explicit `setPackage()` so we never re-resolve by intent alone — a malicious app cannot + * intercept the binding by declaring a higher priority filter. + * + * Launched from `AccountSettingsFragment`'s S/MIME preference row when the user enables S/MIME + * support. If no providers are installed, the row shows + * `R.string.account_settings_smime_no_provider_title` instead of opening this picker. + */ +class SmimeAppSelectDialog : BaseActivity(ThemeType.DIALOG) { + private lateinit var account: LegacyAccountDto + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val accountUuid = requireNotNull(intent.getStringExtra(EXTRA_ACCOUNT)) { + "missing $EXTRA_ACCOUNT extra" + } + account = requireNotNull(Preferences.getPreferences().getAccount(accountUuid)) { + "no such account: $accountUuid" + } + } + + override fun onStart() { + super.onStart() + val smimeProviderPackages = getSmimeProviderPackages() + when { + smimeProviderPackages.isEmpty() -> finish() + smimeProviderPackages.size == 1 -> { + Log.d("Only one S/MIME provider - just choosing that one!") + persistSmimeProviderSetting(smimeProviderPackages[0]) + finish() + } + else -> showSmimeSelectDialogFragment() + } + } + + private fun getSmimeProviderPackages(): List { + val intent = Intent(SmimeApi.SERVICE_INTENT) + return packageManager.queryIntentServices(intent, 0) + .mapNotNull { it.serviceInfo?.packageName } + } + + private fun showSmimeSelectDialogFragment() { + SmimeAppSelectFragment().show(supportFragmentManager, FRAG_SMIME_SELECT) + } + + fun onSelectProvider(selectedPackage: String?) { + if (selectedPackage != null) { + persistSmimeProviderSetting(selectedPackage) + } + finish() + } + + private fun persistSmimeProviderSetting(selectedPackage: String) { + account.smimeProvider = selectedPackage + account.smimeEnabled = true + Preferences.getPreferences().saveAccount(account) + } + + class SmimeAppSelectFragment : DialogFragment() { + private val smimeProviderList = mutableListOf() + private var selectedPackage: String? = null + + private fun populateAppList() { + smimeProviderList.clear() + val context = requireActivity() + val intent = Intent(SmimeApi.SERVICE_INTENT) + context.packageManager.queryIntentServices(intent, 0).forEach { resolveInfo -> + val serviceInfo = resolveInfo.serviceInfo ?: return@forEach + smimeProviderList.add( + SmimeProviderEntry( + packageName = serviceInfo.packageName, + simpleName = serviceInfo.loadLabel(context.packageManager).toString(), + icon = serviceInfo.loadIcon(context.packageManager), + ), + ) + } + } + + override fun onStop() { + super.onStop() + dismissAllowingStateLoss() + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + populateAppList() + val activity = requireActivity() + val adapter = object : ArrayAdapter( + activity, + R.layout.select_openpgp_app_item, + android.R.id.text1, + smimeProviderList, + ) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val v = super.getView(position, convertView, parent) + v.findViewById(android.R.id.icon1) + .setImageDrawable(smimeProviderList[position].icon) + return v + } + } + + return MaterialAlertDialogBuilder(activity) + .setTitle(R.string.account_settings_smime_app_select_title) + .setSingleChoiceItems(adapter, -1) { dialog, which -> + selectedPackage = smimeProviderList[which].packageName + dialog.dismiss() + } + .create() + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (activity as? SmimeAppSelectDialog)?.onSelectProvider(selectedPackage) + } + } + + private data class SmimeProviderEntry( + val packageName: String, + val simpleName: String, + val icon: Drawable, + ) { + override fun toString(): String = simpleName + } + + companion object { + private const val EXTRA_ACCOUNT = "account" + const val FRAG_SMIME_SELECT = "smime_select" + + /** Launch the picker for the given account. */ + @JvmStatic + fun startSmimeChooserActivity(context: Context, account: LegacyAccountDto) { + val intent = Intent(context, SmimeAppSelectDialog::class.java).apply { + putExtra(EXTRA_ACCOUNT, account.uuid) + } + context.startActivity(intent) + } + } +} diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt index d9181742ebb..f97e74c4315 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageCryptoDisplayStatus.kt @@ -4,6 +4,8 @@ import androidx.annotation.AttrRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes import app.k9mail.core.ui.legacy.designsystem.atom.icon.Icons +import com.ciphermail.smime.api.SmimeDecryptionResult +import com.ciphermail.smime.api.SmimeSignatureResult import com.fsck.k9.mailstore.CryptoResultAnnotation import com.fsck.k9.mailstore.CryptoResultAnnotation.CryptoError import com.fsck.k9.ui.R @@ -230,6 +232,7 @@ enum class MessageCryptoDisplayStatus( companion object { @JvmStatic + @Suppress("CyclomaticComplexMethod") // flat 1:1 enum dispatch; complexity is the enum's fun fromResultAnnotation( cryptoResult: CryptoResultAnnotation?, ): MessageCryptoDisplayStatus { @@ -244,6 +247,10 @@ enum class MessageCryptoDisplayStatus( CryptoError.OPENPGP_SIGNED_API_ERROR -> UNENCRYPTED_SIGN_ERROR CryptoError.OPENPGP_ENCRYPTED_API_ERROR -> ENCRYPTED_ERROR CryptoError.OPENPGP_ENCRYPTED_NO_PROVIDER -> ENCRYPTED_NO_PROVIDER + CryptoError.SMIME_OK -> getDisplayStatusForSmimeResult(cryptoResult) + CryptoError.SMIME_SIGNED_API_ERROR -> UNENCRYPTED_SIGN_ERROR + CryptoError.SMIME_ENCRYPTED_API_ERROR -> ENCRYPTED_ERROR + CryptoError.SMIME_ENCRYPTED_NO_PROVIDER -> ENCRYPTED_NO_PROVIDER } } @@ -334,5 +341,66 @@ enum class MessageCryptoDisplayStatus( UNKNOWN -> UNENCRYPTED_SIGN_UNVERIFIED } } + + /** + * Translate an S/MIME result annotation into the corresponding + * display status for the message-view badge. Branches on whether + * the message was encrypted, then defers to the signature mapper. + * Mirrors the OpenPGP equivalent above. + */ + private fun getDisplayStatusForSmimeResult( + cryptoResult: CryptoResultAnnotation, + ): MessageCryptoDisplayStatus { + val decryptionResult = cryptoResult.smimeDecryptionResult + val signatureResult = cryptoResult.smimeSignatureResult + if (decryptionResult == null || signatureResult == null) { + throw AssertionError("Both S/MIME results must be non-null at this point!") + } + return when (decryptionResult.result) { + SmimeDecryptionResult.RESULT_ENCRYPTED -> getStatusForSmimeEncryptedResult(signatureResult) + SmimeDecryptionResult.RESULT_NOT_ENCRYPTED -> getStatusForSmimeUnencryptedResult(signatureResult) + else -> throw AssertionError("Unhandled S/MIME decryption result: ${decryptionResult.result}") + } + } + + /** + * Map a signature result onto the encrypted-message badge set. + * Unknown codes fall back to `ENCRYPTED_SIGN_ERROR` (fail closed). + */ + private fun getStatusForSmimeEncryptedResult( + signatureResult: SmimeSignatureResult, + ): MessageCryptoDisplayStatus { + return when (signatureResult.result) { + SmimeSignatureResult.RESULT_NO_SIGNATURE -> ENCRYPTED_UNSIGNED + SmimeSignatureResult.RESULT_VALID_TRUSTED -> ENCRYPTED_SIGN_VERIFIED + SmimeSignatureResult.RESULT_VALID_UNTRUSTED -> ENCRYPTED_SIGN_UNVERIFIED + SmimeSignatureResult.RESULT_CERT_MISSING -> ENCRYPTED_SIGN_UNKNOWN + SmimeSignatureResult.RESULT_INVALID_SIGNATURE -> ENCRYPTED_SIGN_ERROR + SmimeSignatureResult.RESULT_CERT_EXPIRED -> ENCRYPTED_SIGN_EXPIRED + SmimeSignatureResult.RESULT_CERT_REVOKED -> ENCRYPTED_SIGN_REVOKED + else -> ENCRYPTED_SIGN_ERROR + } + } + + /** + * Map a signature result onto the unencrypted-message badge set + * (used for signed-only S/MIME parts). `RESULT_NO_SIGNATURE` + * collapses to `DISABLED` — the message is plaintext with no + * crypto annotation to show. + */ + private fun getStatusForSmimeUnencryptedResult( + signatureResult: SmimeSignatureResult, + ): MessageCryptoDisplayStatus { + return when (signatureResult.result) { + SmimeSignatureResult.RESULT_NO_SIGNATURE -> DISABLED + SmimeSignatureResult.RESULT_VALID_TRUSTED -> UNENCRYPTED_SIGN_VERIFIED + SmimeSignatureResult.RESULT_VALID_UNTRUSTED -> UNENCRYPTED_SIGN_UNVERIFIED + SmimeSignatureResult.RESULT_CERT_MISSING -> UNENCRYPTED_SIGN_UNKNOWN + SmimeSignatureResult.RESULT_INVALID_SIGNATURE -> UNENCRYPTED_SIGN_ERROR + SmimeSignatureResult.RESULT_CERT_EXPIRED -> UNENCRYPTED_SIGN_EXPIRED + SmimeSignatureResult.RESULT_CERT_REVOKED -> UNENCRYPTED_SIGN_REVOKED + else -> UNENCRYPTED_SIGN_ERROR + } + } } } diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java index 13b163c821e..098639ba429 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/view/MessageHeader.java @@ -60,7 +60,11 @@ public class MessageHeader extends LinearLayout implements OnClickListener, OnLo private ImageView starView; private ImageView contactPictureView; private MaterialTextView fromView; + private View cryptoStatusBar; private ImageView cryptoStatusIcon; + private ImageView smimeEncryptedIcon; + private ImageView smimeSignedIcon; + private ImageView openInCipherMailIcon; private RecipientNamesView recipientNamesView; private MaterialTextView dateView; private ImageView menuPrimaryActionView; @@ -91,7 +95,11 @@ protected void onFinishInflate() { starView = findViewById(R.id.flagged); contactPictureView = findViewById(R.id.contact_picture); fromView = findViewById(R.id.from); + cryptoStatusBar = findViewById(R.id.crypto_status_bar); cryptoStatusIcon = findViewById(R.id.crypto_status_icon); + smimeEncryptedIcon = findViewById(R.id.smime_encrypted_icon); + smimeSignedIcon = findViewById(R.id.smime_signed_icon); + openInCipherMailIcon = findViewById(R.id.open_in_ciphermail_icon); recipientNamesView = findViewById(R.id.recipients); dateView = findViewById(R.id.date); @@ -340,6 +348,41 @@ public void setSubject(@NonNull String subject) { public void hideCryptoStatus() { cryptoStatusIcon.setVisibility(View.GONE); + smimeEncryptedIcon.setVisibility(View.GONE); + smimeSignedIcon.setVisibility(View.GONE); + openInCipherMailIcon.setVisibility(View.GONE); + cryptoStatusBar.setVisibility(View.GONE); + } + + /** + * Show the S/MIME-specific header indicators: a separate "encrypted" icon and "signed" + * icon (the latter coloured by trust), plus an optional "open in CipherMail" action. + * Used instead of the single combined badge for S/MIME messages. + * + * @param signedIconRes drawable for the signature icon, or 0 to hide it + */ + public void setSmimeCryptoStatus(boolean encrypted, int signedIconRes, int signedColorAttr, + boolean showOpenInProvider) { + cryptoStatusIcon.setVisibility(View.GONE); + + smimeEncryptedIcon.setVisibility(encrypted ? View.VISIBLE : View.GONE); + + if (signedIconRes != 0) { + smimeSignedIcon.setImageResource(signedIconRes); + smimeSignedIcon.setColorFilter(ThemeUtils.getStyledColor(getContext(), signedColorAttr)); + smimeSignedIcon.setVisibility(View.VISIBLE); + } else { + smimeSignedIcon.setVisibility(View.GONE); + } + + openInCipherMailIcon.setVisibility(showOpenInProvider ? View.VISIBLE : View.GONE); + + boolean anyVisible = encrypted || signedIconRes != 0 || showOpenInProvider; + cryptoStatusBar.setVisibility(anyVisible ? View.VISIBLE : View.GONE); + } + + public void setOpenInSmimeProviderClickListener(OnClickListener listener) { + openInCipherMailIcon.setOnClickListener(listener); } public void setCryptoStatusLoading() { @@ -356,10 +399,15 @@ public void setCryptoStatus(MessageCryptoDisplayStatus displayStatus) { private void setCryptoDisplayStatus(MessageCryptoDisplayStatus displayStatus) { int color = ThemeUtils.getStyledColor(getContext(), displayStatus.getColorAttr()); + // Combined PGP/legacy badge: hide the S/MIME-specific icons. + smimeEncryptedIcon.setVisibility(View.GONE); + smimeSignedIcon.setVisibility(View.GONE); + openInCipherMailIcon.setVisibility(View.GONE); cryptoStatusIcon.setEnabled(displayStatus.isEnabled()); cryptoStatusIcon.setVisibility(View.VISIBLE); cryptoStatusIcon.setImageResource(displayStatus.getStatusIconRes()); cryptoStatusIcon.setColorFilter(color); + cryptoStatusBar.setVisibility(View.VISIBLE); } public void setMessageHeaderClickListener(MessageHeaderClickListener messageHeaderClickListener) { diff --git a/legacy/ui/legacy/src/main/res/drawable/ic_open_in_ciphermail.xml b/legacy/ui/legacy/src/main/res/drawable/ic_open_in_ciphermail.xml new file mode 100644 index 00000000000..e809cbd4ec1 --- /dev/null +++ b/legacy/ui/legacy/src/main/res/drawable/ic_open_in_ciphermail.xml @@ -0,0 +1,10 @@ + + + diff --git a/legacy/ui/legacy/src/main/res/layout/message_compose_recipients.xml b/legacy/ui/legacy/src/main/res/layout/message_compose_recipients.xml index d9c88ebf54b..dbd18dabfb7 100644 --- a/legacy/ui/legacy/src/main/res/layout/message_compose_recipients.xml +++ b/legacy/ui/legacy/src/main/res/layout/message_compose_recipients.xml @@ -208,6 +208,46 @@ + + + + + + + + + + - + > + + + + + + + + + + Missing OpenPGP app - was it uninstalled? + S/MIME + Enable S/MIME support + Select S/MIME app + No S/MIME app configured + Connected to %s + Configuring… + On, but CipherMail isn\'t installed — messages can\'t be sent until it is + Sign by default + Pre-select \"Sign\" for new messages + Encrypt by default + Pre-select \"Encrypt\" for new messages + Missing S/MIME app - was it uninstalled? + No S/MIME app found + No S/MIME provider app is installed. Please install CipherMail to enable S/MIME support. + Open in CipherMail + Couldn\'t open the message in CipherMail. + S/MIME + Sign + Encrypt + S/MIME: signing and encrypting + S/MIME: missing certificates for some recipients + Can\'t sign or encrypt + This account uses an S/MIME app to sign and encrypt mail, but that app is not installed. Your message has been saved to Drafts. Reinstall the S/MIME app, then open the draft and send it again. + This account uses an S/MIME app to sign and encrypt mail, but that app is not installed. Your message can\'t be saved because this account has no Drafts folder. Assign a Drafts folder to keep it, then reinstall the S/MIME app and send again. + Send failed: %s\nYour message was saved to Drafts. + Assign Drafts folder… + Install CipherMail + Turn off S/MIME and send + S/MIME turned off for this account. + This account has no Drafts folder, so this message can\'t be saved. Assign a Drafts folder to keep it. + Folder settings Show in top group @@ -754,6 +785,7 @@ No suitable application for this action found. Send failed: %s + Send failed Save draft message? Save or Discard this message? diff --git a/legacy/ui/legacy/src/main/res/xml/account_settings.xml b/legacy/ui/legacy/src/main/res/xml/account_settings.xml index e3de53a2d0c..f0431225054 100644 --- a/legacy/ui/legacy/src/main/res/xml/account_settings.xml +++ b/legacy/ui/legacy/src/main/res/xml/account_settings.xml @@ -415,4 +415,29 @@ + + + + + + + + + + diff --git a/legacy/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientPresenterTest.kt b/legacy/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientPresenterTest.kt index 1a7e5538d6d..6b4f4230427 100644 --- a/legacy/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientPresenterTest.kt +++ b/legacy/ui/legacy/src/test/java/com/fsck/k9/activity/compose/RecipientPresenterTest.kt @@ -1,11 +1,14 @@ package com.fsck.k9.activity.compose +import android.content.Intent import androidx.test.core.app.ApplicationProvider import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNull import assertk.assertions.isTrue +import com.ciphermail.smime.api.SmimeCertificateInfo +import com.ciphermail.smime.api.util.SmimeApi import com.fsck.k9.K9RobolectricTest import com.fsck.k9.activity.compose.RecipientMvpView.CryptoSpecialModeDisplayType import com.fsck.k9.activity.compose.RecipientMvpView.CryptoStatusDisplayType @@ -288,6 +291,64 @@ class RecipientPresenterTest : K9RobolectricTest() { } } + @Test + fun onSmimeCertCheckResult_successAllCertsValid_showsEnabledTrusted() { + val result = Intent().apply { + putExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_SUCCESS) + putExtra( + SmimeApi.RESULT_CERTIFICATES, + arrayOf( + SmimeCertificateInfo("a@example.com", true, "CN=A"), + SmimeCertificateInfo("b@example.com", true, "CN=B"), + ), + ) + } + + recipientPresenter.onSmimeCertCheckResult(result) + + verify(recipientMvpView).showCryptoStatus(CryptoStatusDisplayType.ENABLED_TRUSTED) + } + + @Test + fun onSmimeCertCheckResult_successOneCertInvalid_showsEnabledError() { + val result = Intent().apply { + putExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_SUCCESS) + putExtra( + SmimeApi.RESULT_CERTIFICATES, + arrayOf( + SmimeCertificateInfo("a@example.com", true, "CN=A"), + SmimeCertificateInfo("b@example.com", false, null), + ), + ) + } + + recipientPresenter.onSmimeCertCheckResult(result) + + verify(recipientMvpView).showCryptoStatus(CryptoStatusDisplayType.ENABLED_ERROR) + } + + @Test + fun onSmimeCertCheckResult_successMissingCertsExtra_showsEnabledError() { + val result = Intent().apply { + putExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_SUCCESS) + } + + recipientPresenter.onSmimeCertCheckResult(result) + + verify(recipientMvpView).showCryptoStatus(CryptoStatusDisplayType.ENABLED_ERROR) + } + + @Test + fun onSmimeCertCheckResult_errorResultCode_showsEnabledError() { + val result = Intent().apply { + putExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR) + } + + recipientPresenter.onSmimeCertCheckResult(result) + + verify(recipientMvpView).showCryptoStatus(CryptoStatusDisplayType.ENABLED_ERROR) + } + private fun runBackgroundTask() { assertThat(Robolectric.getBackgroundThreadScheduler().runOneTask()).isTrue() } diff --git a/plugins/smime-api/CHANGELOG.md b/plugins/smime-api/CHANGELOG.md new file mode 100644 index 00000000000..448baa6a714 --- /dev/null +++ b/plugins/smime-api/CHANGELOG.md @@ -0,0 +1,47 @@ +# Version history + +## Version 1 — initial release + +First public iteration of the S/MIME companion API. Defines the contract +between an S/MIME-aware mail client (e.g. Thunderbird) and an S/MIME provider +(CipherMail). Parallels the OpenPGP API surface. + +Service binding intent action: + * `com.ciphermail.smime.api.ISmimeService` (constant `SmimeApi.SERVICE_INTENT`) + +Actions: + * `ACTION_CHECK_PERMISSION` — caller asks the provider to confirm consent. + * `ACTION_DECRYPT_VERIFY` — decrypt and/or verify a single MIME part. + * `ACTION_SIGN_AND_ENCRYPT` — sign and/or encrypt an outgoing MIME message. + * `ACTION_GET_CERTIFICATES` — query certificate availability for one or more + recipient email addresses (used to drive compose-screen lock icons). + * `ACTION_IMPORT_CERTIFICATE` — import a DER- or PEM-encoded certificate. + +Request extras: + * `EXTRA_API_VERSION` (int, required) + * `EXTRA_USER_IDS` (String[]) + * `EXTRA_SIGN` (boolean, default true) + * `EXTRA_ENCRYPT` (boolean, default true) + +Result extras: + * `RESULT_CODE` (`RESULT_CODE_SUCCESS`, `RESULT_CODE_ERROR`, + `RESULT_CODE_USER_INTERACTION_REQUIRED`) + * `RESULT_INTENT` (PendingIntent, when user interaction is required) + * `RESULT_ERROR` (`SmimeError`) + * `RESULT_DECRYPTION` (`SmimeDecryptionResult`) + * `RESULT_SIGNATURE` (`SmimeSignatureResult`) + * `RESULT_CERTIFICATES` (`SmimeCertificateInfo[]`) + +Parcelables (all marked `PARCELABLE_VERSION = 1`): + * `SmimeError` + * `SmimeSignatureResult` + * `SmimeDecryptionResult` + * `SmimeCertificateInfo` + +Helper classes: + * `SmimeApi` — sync (`executeApi`) and async (`executeApiAsync`) execution + wrappers; manages ParcelFileDescriptor pipes for streaming MIME data. + * `SmimeServiceConnection` — service-binding lifecycle helper. + +Bulk data (message bytes) transfers through ParcelFileDescriptor pipes rather +than Intent extras to keep large messages off the Binder transaction. diff --git a/plugins/smime-api/LICENSE b/plugins/smime-api/LICENSE new file mode 100644 index 00000000000..d6456956733 --- /dev/null +++ b/plugins/smime-api/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/smime-api/README.md b/plugins/smime-api/README.md new file mode 100644 index 00000000000..6f175c6f63e --- /dev/null +++ b/plugins/smime-api/README.md @@ -0,0 +1,186 @@ +# S/MIME API library + +The S/MIME API provides methods to execute S/MIME operations — sign, encrypt, +decrypt, verify, and certificate lookup — without user interaction from +background threads. This is done by binding your mail client to a remote +service provided by [CipherMail](https://www.ciphermail.com) or another +S/MIME provider. + +The design parallels the [OpenPGP API](../openpgp-api-lib/README.md): a single +AIDL service with an `execute(Intent, ParcelFileDescriptor, int)` entry point, +operations selected by Intent action, bulk MIME data streamed over pipes +rather than passed as Intent extras. + +### News + +#### Version 1 + * Initial release. See `CHANGELOG.md` for the full action / extra / result + inventory. + +### License + +The reference provider (CipherMail) is GPL; this API library is licensed under +[Apache License v2](LICENSE) so it can be embedded in any S/MIME-aware mail +client regardless of the client's license. + +### Add the API library to your project + +The library is consumed as a Gradle module within this repository: + +```kotlin +dependencies { + implementation(projects.plugins.smimeApi.smimeApi) +} +``` + +The `` element required for service discovery on Android 11+ is +already declared in `plugins/smime-api/smime-api/src/main/AndroidManifest.xml` +and is merged into the host app's manifest automatically. + +### API + +[`SmimeApi`](smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java) +defines every Intent action, extra, and result constant. The AIDL contract is +defined in +[`ISmimeService.aidl`](smime-api/src/main/aidl/com/ciphermail/smime/api/ISmimeService.aidl). + +### Short tutorial + +The API is **not** driven by `startActivityForResult`; operations run as +background calls and only surface UI (via a `PendingIntent`) when the provider +needs user input — typically to unlock its keystore. + +#### 1. Bind to the provider + +```java +SmimeServiceConnection serviceConnection; + +@Override +protected void onCreate(Bundle state) { + super.onCreate(state); + serviceConnection = new SmimeServiceConnection( + this, + "com.ciphermail.android", // provider package + new SmimeServiceConnection.OnBound() { + @Override public void onBound(ISmimeService service) { /* ready */ } + @Override public void onError(Exception e) { /* handle */ } + }); + serviceConnection.bindToService(); +} + +@Override +protected void onDestroy() { + super.onDestroy(); + if (serviceConnection != null) serviceConnection.unbindFromService(); +} +``` + +#### 2. Build the request Intent and run the operation + +```java +Intent request = new Intent(SmimeApi.ACTION_SIGN_AND_ENCRYPT); +request.putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION); +request.putExtra(SmimeApi.EXTRA_USER_IDS, + new String[] { "alice@example.com", "bob@example.com" }); + +InputStream mimeBody = new ByteArrayInputStream(rawMimeBytes); +ByteArrayOutputStream smimeOutput = new ByteArrayOutputStream(); + +SmimeApi api = new SmimeApi(serviceConnection.getService()); +api.executeApiAsync(request, mimeBody, smimeOutput, this::onSmimeResult); +``` + +`executeApiAsync` runs `executeApi` on a worker thread and posts the result +back via `SmimeCallback`. If you are already on a background thread you can +call `executeApi(...)` directly — it is blocking. Calling it on the main +thread will fail Android's strict-mode network/disk-on-main checks for any +non-trivial message. + +#### 3. Handle the result + +```java +private void onSmimeResult(Intent result) { + int code = result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR); + switch (code) { + case SmimeApi.RESULT_CODE_SUCCESS: { + // smimeOutput now contains the wrapped MIME bytes. + SmimeSignatureResult sig = + result.getParcelableExtra(SmimeApi.RESULT_SIGNATURE); + // ... + break; + } + case SmimeApi.RESULT_CODE_USER_INTERACTION_REQUIRED: { + PendingIntent pi = result.getParcelableExtra(SmimeApi.RESULT_INTENT); + try { + startIntentSenderForResult(pi.getIntentSender(), REQ_UNLOCK, + null, 0, 0, 0); + } catch (IntentSender.SendIntentException e) { + // ... + } + break; + } + case SmimeApi.RESULT_CODE_ERROR: { + SmimeError err = result.getParcelableExtra(SmimeApi.RESULT_ERROR); + // err.getErrorId(): see SmimeError.* constants + break; + } + } +} +``` + +#### 4. Retry after user interaction + +If the provider returned `RESULT_CODE_USER_INTERACTION_REQUIRED`, its +`PendingIntent` will launch a provider-owned activity (for CipherMail, the +keystore passphrase dialog). On `RESULT_OK` simply rebuild the request and +call `executeApiAsync` again — the second call will find the cached +credentials and return `RESULT_CODE_SUCCESS`. + +```java +@Override +protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQ_UNLOCK && resultCode == RESULT_OK) { + retrySignAndEncrypt(); // same code as step 2 + } +} +``` + +### Actions at a glance + +| Action | Input stream | Output stream | Notable result extras | +|------------------------------|--------------------------|--------------------|------------------------------------------| +| `ACTION_CHECK_PERMISSION` | — | — | `RESULT_INTENT` (consent dialog) | +| `ACTION_DECRYPT_VERIFY` | encrypted MIME bytes | decrypted MIME | `RESULT_DECRYPTION`, `RESULT_SIGNATURE` | +| `ACTION_SIGN_AND_ENCRYPT` | plain MIME bytes | wrapped S/MIME | (uses output stream only) | +| `ACTION_GET_CERTIFICATES` | — | — | `RESULT_CERTIFICATES` | +| `ACTION_IMPORT_CERTIFICATE` | DER- or PEM-encoded cert | — | (uses RESULT_CODE only) | + +### Tips + +* `api.executeApi(data, is, os);` is blocking. Use `executeApiAsync` for a + fire-and-callback variant. +* To bind to CipherMail's **debug** build during development, use + `com.ciphermail.android.debug` as the provider package — release and debug + are installable side-by-side with distinct package IDs. +* To let the user choose between S/MIME providers (when more than one is + installed), use Thunderbird's `SmimeAppSelectDialog` (legacy/ui/legacy). + It enumerates all packages that declare an `ISmimeService` service in their + manifest. +* Bulk message data never travels as an Intent extra — always through the + `ParcelFileDescriptor` pipes set up by `executeApi`. This keeps Binder + transactions small even for multi-megabyte attachments. +* The provider may take several seconds for the first call after process + start (keystore initialisation, certificate cache warm-up). Show a + progress indicator in compose / message-view UI rather than blocking the + user thread. + +### Cross-process passphrase unlock + +CipherMail's keystore is locked by default. When a sign/encrypt or +decrypt/verify call lands while the keystore is locked, the service returns +`RESULT_CODE_USER_INTERACTION_REQUIRED` immediately (no IPC timeout) with a +`PendingIntent` for its `KeyStorePassphraseDialog`. The dialog broadcasts the +passphrase back to a singleton `CachingPasswordProvider` on success, which +caches it for subsequent calls. The client's only job is to launch the +`PendingIntent` via `startIntentSenderForResult` and retry on `RESULT_OK`. diff --git a/plugins/smime-api/smime-api/build.gradle.kts b/plugins/smime-api/smime-api/build.gradle.kts new file mode 100644 index 00000000000..83cc25d2f77 --- /dev/null +++ b/plugins/smime-api/smime-api/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id(ThunderbirdPlugins.Library.android) +} + +android { + namespace = "com.ciphermail.smime.api" + + buildFeatures { + aidl = true + } +} + +codeCoverage { + branchCoverage = 0 + lineCoverage = 0 +} diff --git a/plugins/smime-api/smime-api/src/main/AndroidManifest.xml b/plugins/smime-api/smime-api/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..cc947c56799 --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/plugins/smime-api/smime-api/src/main/aidl/com/ciphermail/smime/api/ISmimeService.aidl b/plugins/smime-api/smime-api/src/main/aidl/com/ciphermail/smime/api/ISmimeService.aidl new file mode 100644 index 00000000000..9ea29e422cb --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/aidl/com/ciphermail/smime/api/ISmimeService.aidl @@ -0,0 +1,36 @@ +package com.ciphermail.smime.api; + +/** + * S/MIME companion service API. + * + * All operation semantics travel as Intent extras (see SmimeApi for constants). + * Bulk data (message bytes) streams through ParcelFileDescriptor pipes so that + * large messages never cross the IPC boundary as in-memory byte arrays. + * + * Callers (e.g. Thunderbird): + * 1. Call createOutputPipe(pipeId) to get the write end of the output pipe. + * 2. Call execute(intent, inputPipe, pipeId) with the action Intent and the + * read end of the input pipe. + * 3. Read from the output pipe while execute() is running (it blocks until done). + * 4. Inspect the returned Intent for RESULT_CODE and result Parcelables. + */ +interface ISmimeService { + + /** + * Create the write end of an output pipe identified by pipeId. + * The caller reads from the corresponding read end while the service writes + * the processed message bytes to this write end. + */ + ParcelFileDescriptor createOutputPipe(in int pipeId); + + /** + * Execute an S/MIME operation. + * + * @param data Intent carrying action string and all EXTRA_* parameters. + * @param input Read end of the input pipe (raw MIME bytes), or null for + * actions that need no message input (e.g. ACTION_GET_CERTIFICATES). + * @param pipeId Matches the pipeId passed to createOutputPipe(). + * @return Intent carrying RESULT_CODE and result Parcelables. + */ + Intent execute(in Intent data, in ParcelFileDescriptor input, int pipeId); +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeCertificateInfo.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeCertificateInfo.java new file mode 100644 index 00000000000..3250b962466 --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeCertificateInfo.java @@ -0,0 +1,67 @@ +package com.ciphermail.smime.api; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Certificate availability info for a single recipient email address. + * Returned by ACTION_GET_CERTIFICATES so the mail client can show per-recipient + * lock icons in the compose screen. + */ +public class SmimeCertificateInfo implements Parcelable { + + public static final int PARCELABLE_VERSION = 1; + + /** Email address this record describes. */ + public final String email; + /** True if CipherMail has a usable (non-expired, non-revoked) certificate for this address. */ + public final boolean hasValidCertificate; + /** Human-readable Subject DN, or null if no certificate is available. */ + public final String subjectDn; + + public SmimeCertificateInfo(String email, boolean hasValidCertificate, String subjectDn) { + this.email = email; + this.hasValidCertificate = hasValidCertificate; + this.subjectDn = subjectDn; + } + + @Override + public int describeContents() { return 0; } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(PARCELABLE_VERSION); + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeString(email); + dest.writeInt(hasValidCertificate ? 1 : 0); + dest.writeString(subjectDn); + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SmimeCertificateInfo createFromParcel(Parcel source) { + int version = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + String email = source.readString(); + boolean hasValidCertificate = source.readInt() == 1; + String subjectDn = source.readString(); + + source.setDataPosition(startPosition + parcelableSize); + return new SmimeCertificateInfo(email, hasValidCertificate, subjectDn); + } + + @Override + public SmimeCertificateInfo[] newArray(int size) { + return new SmimeCertificateInfo[size]; + } + }; +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeDecryptionResult.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeDecryptionResult.java new file mode 100644 index 00000000000..b20281c3a8c --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeDecryptionResult.java @@ -0,0 +1,70 @@ +package com.ciphermail.smime.api; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Decryption outcome returned alongside {@code ACTION_DECRYPT_VERIFY}. + * + *

Distinguishes "this message was actually encrypted and we decrypted it" + * from "this message was already plaintext" so the client can label the + * message appropriately. Signature state is reported separately by + * {@link SmimeSignatureResult}.

+ */ +public class SmimeDecryptionResult implements Parcelable { + + public static final int PARCELABLE_VERSION = 1; + + /** Message was not encrypted (plain or signed-only). */ + public static final int RESULT_NOT_ENCRYPTED = -1; + /** Message was encrypted and successfully decrypted. */ + public static final int RESULT_ENCRYPTED = 1; + + public final int result; + + public SmimeDecryptionResult(int result) { + this.result = result; + } + + public int getResult() { + return result; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(PARCELABLE_VERSION); + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeInt(result); + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SmimeDecryptionResult createFromParcel(Parcel source) { + int version = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + int result = source.readInt(); + + source.setDataPosition(startPosition + parcelableSize); + return new SmimeDecryptionResult(result); + } + + @Override + public SmimeDecryptionResult[] newArray(int size) { + return new SmimeDecryptionResult[size]; + } + }; +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java new file mode 100644 index 00000000000..e5fd5a294e1 --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeError.java @@ -0,0 +1,100 @@ +package com.ciphermail.smime.api; + +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Error result returned from the S/MIME service when {@code RESULT_CODE} is + * {@code SmimeApi.RESULT_CODE_ERROR}. + * + *

The integer error identifier ({@link #getErrorId()}) is stable across + * versions and can be used to drive client-side messaging without parsing + * the human-readable {@link #getMessage()} string.

+ * + *

Construction is asymmetric on purpose: the service produces these and + * the client only reads them. New error codes may be added in future API + * versions; clients should treat unknown ids as equivalent to + * {@link #GENERIC_ERROR}.

+ */ +public class SmimeError implements Parcelable { + + public static final int PARCELABLE_VERSION = 1; + + /** + * Error that originated in the client-side helper (e.g. failed to set up + * the ParcelFileDescriptor pipe) rather than in the provider service. + */ + public static final int CLIENT_SIDE_ERROR = -1; + + /** Unspecified failure in the provider. Inspect {@link #getMessage()}. */ + public static final int GENERIC_ERROR = 0; + + /** + * The caller supplied an {@code EXTRA_API_VERSION} that the provider + * cannot serve. The client should not retry without code changes. + */ + public static final int INCOMPATIBLE_API_VERSIONS = 1; + + /** + * One or more recipient addresses (passed via {@code EXTRA_USER_IDS}) have + * no usable certificate. Specific to {@code ACTION_SIGN_AND_ENCRYPT}. + */ + public static final int NO_CERTIFICATE_FOR_RECIPIENT = 2; + + /** + * Provider's keystore is locked and the user did not complete the + * passphrase dialog. Clients normally see + * {@code RESULT_CODE_USER_INTERACTION_REQUIRED} instead of this code; + * this code is only set when interaction was offered and declined. + */ + public static final int KEYSTORE_LOCKED = 3; + + private final int errorId; + private final String message; + + public SmimeError(int errorId, String message) { + this.errorId = errorId; + this.message = message; + } + + public int getErrorId() { return errorId; } + public String getMessage() { return message; } + + @Override + public int describeContents() { return 0; } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(PARCELABLE_VERSION); + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeInt(errorId); + dest.writeString(message); + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SmimeError createFromParcel(Parcel source) { + int version = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + int errorId = source.readInt(); + String message = source.readString(); + + source.setDataPosition(startPosition + parcelableSize); + return new SmimeError(errorId, message); + } + + @Override + public SmimeError[] newArray(int size) { + return new SmimeError[size]; + } + }; +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java new file mode 100644 index 00000000000..3106d723b6a --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/SmimeSignatureResult.java @@ -0,0 +1,99 @@ +package com.ciphermail.smime.api; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.Nullable; + +/** + * Signature verification result returned alongside + * {@code ACTION_DECRYPT_VERIFY}. + * + *

The {@link #getResult()} code summarises the cryptographic outcome and + * the trust state of the signer certificate. {@link #getSignerEmail()} and + * {@link #getSignerSubjectDn()} carry identity information extracted from + * the signer certificate for UI display; both may be {@code null} when no + * signature is present.

+ * + *

Clients should treat unknown result codes as + * {@link #RESULT_INVALID_SIGNATURE} to fail safe.

+ */ +public class SmimeSignatureResult implements Parcelable { + + public static final int PARCELABLE_VERSION = 1; + + /** Message was not signed. */ + public static final int RESULT_NO_SIGNATURE = -1; + /** Signature is cryptographically invalid. */ + public static final int RESULT_INVALID_SIGNATURE = 0; + /** Valid signature; certificate chain traces to a trusted root. */ + public static final int RESULT_VALID_TRUSTED = 1; + /** Valid signature; certificate is not trusted (unknown CA, self-signed, etc.). */ + public static final int RESULT_VALID_UNTRUSTED = 2; + /** Signer certificate could not be found. */ + public static final int RESULT_CERT_MISSING = 3; + /** Signer certificate has expired. */ + public static final int RESULT_CERT_EXPIRED = 4; + /** Signer certificate has been revoked. */ + public static final int RESULT_CERT_REVOKED = 5; + + private final int result; + /** Email address from the signer's certificate SubjectAltName / Subject CN. */ + @Nullable private final String signerEmail; + /** Human-readable Subject DN of the signer's certificate. */ + @Nullable private final String signerSubjectDn; + + public SmimeSignatureResult(int result, @Nullable String signerEmail, @Nullable String signerSubjectDn) { + this.result = result; + this.signerEmail = signerEmail; + this.signerSubjectDn = signerSubjectDn; + } + + public int getResult() { return result; } + + @Nullable + public String getSignerEmail() { return signerEmail; } + + @Nullable + public String getSignerSubjectDn() { return signerSubjectDn; } + + @Override + public int describeContents() { return 0; } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(PARCELABLE_VERSION); + int sizePosition = dest.dataPosition(); + dest.writeInt(0); + int startPosition = dest.dataPosition(); + // version 1 + dest.writeInt(result); + dest.writeString(signerEmail); + dest.writeString(signerSubjectDn); + int parcelableSize = dest.dataPosition() - startPosition; + dest.setDataPosition(sizePosition); + dest.writeInt(parcelableSize); + dest.setDataPosition(startPosition + parcelableSize); + } + + public static final Creator CREATOR = new Creator() { + @Override + public SmimeSignatureResult createFromParcel(Parcel source) { + int version = source.readInt(); + int parcelableSize = source.readInt(); + int startPosition = source.dataPosition(); + + int result = source.readInt(); + String signerEmail = source.readString(); + String signerSubjectDn = source.readString(); + + source.setDataPosition(startPosition + parcelableSize); + return new SmimeSignatureResult(result, signerEmail, signerSubjectDn); + } + + @Override + public SmimeSignatureResult[] newArray(int size) { + return new SmimeSignatureResult[size]; + } + }; +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java new file mode 100644 index 00000000000..0998d2c5e8f --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeApi.java @@ -0,0 +1,260 @@ +package com.ciphermail.smime.api.util; + +import android.content.Intent; +import android.os.AsyncTask; +import android.os.ParcelFileDescriptor; + +import com.ciphermail.smime.api.ISmimeService; +import com.ciphermail.smime.api.SmimeError; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Client-side helper for the S/MIME companion service API. + * + * Usage pattern (mirrors OpenPgpApi): + * + * SmimeApi api = new SmimeApi(context, boundService); + * + * Intent request = new Intent(SmimeApi.ACTION_DECRYPT_VERIFY); + * request.putExtra(SmimeApi.EXTRA_API_VERSION, SmimeApi.API_VERSION); + * + * api.executeApiAsync(request, inputStream, outputStream, result -> { + * int code = result.getIntExtra(SmimeApi.RESULT_CODE, SmimeApi.RESULT_CODE_ERROR); + * // handle code + * }); + */ +public class SmimeApi { + + // ------------------------------------------------------------------------- + // Service binding intent action — used to bind to CipherMail's service + // ------------------------------------------------------------------------- + public static final String SERVICE_INTENT = "com.ciphermail.smime.api.ISmimeService"; + + public static final int API_VERSION = 1; + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + /** + * Check whether the caller has permission to use the API. + * Returns RESULT_CODE_SUCCESS or RESULT_CODE_USER_INTERACTION_REQUIRED (first-run consent). + * No input stream needed. + */ + public static final String ACTION_CHECK_PERMISSION = + "com.ciphermail.smime.api.action.CHECK_PERMISSION"; + + /** + * Decrypt and/or verify an S/MIME message part. + * + * Input stream: raw bytes of the MIME part (application/pkcs7-mime or multipart/signed). + * Output stream: decrypted/verified MIME content. + * + * Returned extras: RESULT_DECRYPTION, RESULT_SIGNATURE (both Parcelable). + */ + public static final String ACTION_DECRYPT_VERIFY = + "com.ciphermail.smime.api.action.DECRYPT_VERIFY"; + + /** + * Sign and/or encrypt an outgoing MIME message. + * + * Required extras: EXTRA_USER_IDS (recipient email addresses). + * Optional extras: EXTRA_SIGN (default true), EXTRA_ENCRYPT (default true), + * EXTRA_FROM (sender address; selects the signing identity). + * + * Input stream: raw MIME bytes of the message to protect. + * Output stream: S/MIME wrapped MIME bytes ready for transport. + */ + public static final String ACTION_SIGN_AND_ENCRYPT = + "com.ciphermail.smime.api.action.SIGN_AND_ENCRYPT"; + + /** + * Query certificate availability for a list of email addresses. + * Used by the compose screen to determine per-recipient lock icon state. + * + * Required extras: EXTRA_USER_IDS. + * No input stream needed; no output stream produced. + * + * Returned extras: RESULT_CERTIFICATES (SmimeCertificateInfo[]). + */ + public static final String ACTION_GET_CERTIFICATES = + "com.ciphermail.smime.api.action.GET_CERTIFICATES"; + + /** + * Import a certificate from a byte stream (DER or PEM encoded). + * + * Input stream: certificate bytes. + * No output stream produced. + */ + public static final String ACTION_IMPORT_CERTIFICATE = + "com.ciphermail.smime.api.action.IMPORT_CERTIFICATE"; + + // ------------------------------------------------------------------------- + // Request extras + // ------------------------------------------------------------------------- + + /** int — always required. Must equal API_VERSION. */ + public static final String EXTRA_API_VERSION = "api_version"; + + /** String[] — recipient email addresses (for SIGN_AND_ENCRYPT and GET_CERTIFICATES). */ + public static final String EXTRA_USER_IDS = "user_ids"; + + /** boolean — whether to sign the outgoing message (default true). */ + public static final String EXTRA_SIGN = "sign"; + + /** boolean — whether to encrypt the outgoing message (default true). */ + public static final String EXTRA_ENCRYPT = "encrypt"; + + /** + * String — sender (From) email address (for SIGN_AND_ENCRYPT). Selects which + * personal certificate is used to sign the message and to encrypt it to self. + * Optional; when absent the service falls back to its configured default identity. + */ + public static final String EXTRA_FROM = "from"; + + // ------------------------------------------------------------------------- + // Result codes + // ------------------------------------------------------------------------- + + /** int extra key carrying the result code. */ + public static final String RESULT_CODE = "result_code"; + + public static final int RESULT_CODE_ERROR = 0; + public static final int RESULT_CODE_SUCCESS = 1; + /** Service needs user interaction (e.g. keystore unlock). Launch RESULT_INTENT. */ + public static final int RESULT_CODE_USER_INTERACTION_REQUIRED = 2; + + // ------------------------------------------------------------------------- + // Result extras + // ------------------------------------------------------------------------- + + /** PendingIntent — present when RESULT_CODE == RESULT_CODE_USER_INTERACTION_REQUIRED. */ + public static final String RESULT_INTENT = "intent"; + + /** SmimeError Parcelable — present when RESULT_CODE == RESULT_CODE_ERROR. */ + public static final String RESULT_ERROR = "error"; + + /** SmimeDecryptionResult Parcelable — present after ACTION_DECRYPT_VERIFY. */ + public static final String RESULT_DECRYPTION = "decryption_result"; + + /** SmimeSignatureResult Parcelable — present after ACTION_DECRYPT_VERIFY. */ + public static final String RESULT_SIGNATURE = "signature_result"; + + /** SmimeCertificateInfo[] Parcelable array — present after ACTION_GET_CERTIFICATES. */ + public static final String RESULT_CERTIFICATES = "certificates"; + + // ------------------------------------------------------------------------- + // Async execution + // ------------------------------------------------------------------------- + + public interface SmimeCallback { + void onReturn(Intent result); + } + + private final ISmimeService mService; + private static final AtomicInteger sPipeIdCounter = new AtomicInteger(0); + + public SmimeApi(ISmimeService service) { + this.mService = service; + } + + /** + * Execute an API call asynchronously, streaming data through pipes. + * + * @param data Request Intent with action and extras. + * @param inputStream Source bytes (the MIME message to process), or null. + * @param outputStream Destination for processed bytes, or null. + * @param callback Called on the calling thread with the result Intent. + */ + public void executeApiAsync(final Intent data, + final InputStream inputStream, + final OutputStream outputStream, + final SmimeCallback callback) { + new AsyncTask() { + @Override + protected Intent doInBackground(Void... params) { + return executeApi(data, inputStream, outputStream); + } + + @Override + protected void onPostExecute(Intent result) { + callback.onReturn(result); + } + }.execute(); + } + + /** + * Execute an API call synchronously. Must not be called on the main thread. + */ + public Intent executeApi(Intent data, InputStream inputStream, OutputStream outputStream) { + ParcelFileDescriptor inputFd = null; + ParcelFileDescriptor outputFd = null; + Thread inputThread = null; + Thread outputThread = null; + + try { + final int pipeId = sPipeIdCounter.incrementAndGet(); + + // Wire up the output pipe (service writes → we read) + if (outputStream != null) { + outputFd = mService.createOutputPipe(pipeId); + final ParcelFileDescriptor readEnd = outputFd; + outputThread = new Thread(() -> { + try (ParcelFileDescriptor.AutoCloseInputStream in = + new ParcelFileDescriptor.AutoCloseInputStream(readEnd)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) { + outputStream.write(buf, 0, n); + } + } catch (IOException ignored) {} + }); + outputThread.start(); + } + + // Wire up the input pipe (we write → service reads) + if (inputStream != null) { + ParcelFileDescriptor[] inputPipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor writeEnd = inputPipe[1]; + inputFd = inputPipe[0]; + inputThread = new Thread(() -> { + try (ParcelFileDescriptor.AutoCloseOutputStream out = + new ParcelFileDescriptor.AutoCloseOutputStream(writeEnd)) { + byte[] buf = new byte[8192]; + int n; + while ((n = inputStream.read(buf)) != -1) { + out.write(buf, 0, n); + } + } catch (IOException ignored) {} + }); + inputThread.start(); + } + + Intent result = mService.execute(data, inputFd, pipeId); + + if (outputThread != null) outputThread.join(); + if (inputThread != null) inputThread.join(); + + return result; + + } catch (Exception e) { + Intent error = new Intent(); + error.putExtra(RESULT_CODE, RESULT_CODE_ERROR); + error.putExtra(RESULT_ERROR, new SmimeError(SmimeError.CLIENT_SIDE_ERROR, e.getMessage())); + return error; + } finally { + closeQuietly(inputFd); + closeQuietly(outputFd); + } + } + + private static void closeQuietly(ParcelFileDescriptor fd) { + if (fd != null) { + try { fd.close(); } catch (IOException ignored) {} + } + } +} diff --git a/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java new file mode 100644 index 00000000000..9e13fad9902 --- /dev/null +++ b/plugins/smime-api/smime-api/src/main/java/com/ciphermail/smime/api/util/SmimeServiceConnection.java @@ -0,0 +1,104 @@ +package com.ciphermail.smime.api.util; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; + +import com.ciphermail.smime.api.ISmimeService; + +/** + * Manages binding to the CipherMail S/MIME service. + * Mirrors OpenPgpServiceConnection so Thunderbird's integration layer can + * follow the same lifecycle pattern for both crypto providers. + */ +public class SmimeServiceConnection { + + /** + * Listener fired on successful bind or bind failure. Methods run on the + * main thread (delivered by {@link android.content.ServiceConnection}). + */ + public interface OnBound { + void onBound(ISmimeService service); + void onError(Exception e); + } + + private final Context mApplicationContext; + private final String mProviderPackageName; + private final OnBound mOnBoundListener; + + private ISmimeService mService; + + /** Build a connection without a listener — use {@link #isBound()} to poll. */ + public SmimeServiceConnection(Context context, String providerPackageName) { + this(context, providerPackageName, null); + } + + /** + * @param context Any context; the application context is kept internally. + * @param providerPackageName Package id of the S/MIME provider (e.g. + * {@code "com.ciphermail.android"} or + * {@code "com.ciphermail.android.debug"}). + * @param onBoundListener Optional bind-result callback. + */ + public SmimeServiceConnection(Context context, String providerPackageName, OnBound onBoundListener) { + this.mApplicationContext = context.getApplicationContext(); + this.mProviderPackageName = providerPackageName; + this.mOnBoundListener = onBoundListener; + } + + /** @return the bound {@link ISmimeService}, or {@code null} if not yet bound. */ + public ISmimeService getService() { return mService; } + + /** @return {@code true} once {@code onServiceConnected} has fired. */ + public boolean isBound() { return mService != null; } + + private final ServiceConnection mServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + mService = ISmimeService.Stub.asInterface(service); + if (mOnBoundListener != null) { + mOnBoundListener.onBound(mService); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + } + }; + + /** + * Bind to the provider's S/MIME service. Idempotent: if already bound, + * fires {@code onBound} again on the listener without rebinding. + * On failure the listener's {@code onError} is invoked synchronously. + */ + public void bindToService() { + if (mService != null) { + if (mOnBoundListener != null) mOnBoundListener.onBound(mService); + return; + } + try { + Intent serviceIntent = new Intent(SmimeApi.SERVICE_INTENT); + serviceIntent.setPackage(mProviderPackageName); + boolean connected = mApplicationContext.bindService( + serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE); + if (!connected) { + throw new Exception("bindService() returned false for package: " + mProviderPackageName); + } + } catch (Exception e) { + if (mOnBoundListener != null) mOnBoundListener.onError(e); + } + } + + /** + * Unbind from the provider. Safe to call multiple times; safe to call + * before {@link #bindToService()} has succeeded only if the connection + * has been at least registered (otherwise Android throws). + */ + public void unbindFromService() { + mApplicationContext.unbindService(mServiceConnection); + mService = null; + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1e0ed3eb8d7..e0cdde8bb86 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -248,6 +248,7 @@ include( ) include(":plugins:openpgp-api-lib:openpgp-api") +include(":plugins:smime-api:smime-api") include( ":cli:autodiscovery-cli",