diff --git a/app.config.js b/app.config.js index be945a34..562fda1a 100644 --- a/app.config.js +++ b/app.config.js @@ -83,6 +83,10 @@ module.exports = { }, plugins: [ 'expo-secure-store', + // WebCrypto for PGP and S/MIME (#58). On iOS it raises the deployment + // target (app and pods) to 16.4, the minimum Nitro modules build for; + // Android has nothing to set. + 'react-native-quick-crypto', '@react-native-community/datetimepicker', 'expo-localization', [ diff --git a/index.ts b/index.ts index cf5677d1..1a093e29 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,9 @@ // First: wraps react-native's Text for the reading font before any module // copies it (src/theme/reading-font-text). import './src/theme/reading-font-install'; +// WebCrypto for PGP and S/MIME (#58), before openpgp or pkijs can load. +// Loads nothing native until first used. +import './src/lib/crypto/install-webcrypto'; import { createElement } from 'react'; import { AppRegistry } from 'react-native'; import { registerRootComponent } from 'expo'; diff --git a/locales/rn/en.json b/locales/rn/en.json index eb07e657..6f58ab52 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -378,6 +378,12 @@ "login_switched_title": "Account switched", "no_recipient": "(no recipient)", "no_sent_folder": "No Sent folder found", + "option_encrypt": "Encrypt", + "option_encrypt_hint": "Only the recipients (and you) can read it; every recipient needs a key", + "option_security": "Signature and encryption", + "option_sign": "Sign", + "option_sign_pgp_hint": "Add an OpenPGP signature with your key", + "option_sign_smime_hint": "Add an S/MIME signature with your certificate", "permission_camera": "Camera permission is required to take a photo.", "permission_photos": "Photo library permission is required to attach images.", "placeholder_warning": { @@ -435,6 +441,11 @@ "schedule_unsupported_title": "Scheduling unavailable", "scheduled_body": "Your message will be sent at {time}.", "scheduled_title": "Scheduled", + "secure_failed_title": "Cannot sign or encrypt", + "secure_missing_keys": "No key for {recipients}. Import one under Settings → Encryption.", + "secure_no_key": "There is no key for {email} on this device. Import one under Settings → Encryption.", + "secure_offline": "Signed or encrypted messages can't wait in the Outbox. Send it when you are online.", + "secure_unlock_reason": "Your key signs this message.", "send_confirm": { "message": "It goes to {count, plural, one {# recipient} other {# recipients}}.", "title": "Send this message?" @@ -688,6 +699,76 @@ "view_dark_mode": "View in dark mode", "view_light_mode": "View in light mode" }, + "encryption": { + "armored_key": "Armored key", + "banner": { + "checking": "Checking encryption and signature…", + "decrypted": "Decrypted ({kind})", + "failed": "Could not decrypt: {reason}", + "invalid": "Invalid signature ({kind})", + "invalid_reason": "Invalid signature ({kind}): {reason}", + "locked": "Encrypted ({kind}). Unlock your key to read it.", + "no_key": "Encrypted ({kind}) to a key that is not on this device.", + "self_signed": "The certificate is self-signed, not issued by a known authority.", + "signed": "Valid signature ({kind})", + "signed_by": "Valid signature ({kind}) from {signer}", + "signer_mismatch": "The signer is not the sender shown in From.", + "unknown_key": "Signed ({kind}) with a key you have not imported.", + "unsupported": "Encrypted with {algorithm}, which the app cannot decrypt." + }, + "check_description": "Runs every algorithm PGP and S/MIME use on this device.", + "check_run": "Run check", + "check_title": "Check encryption support", + "delete_private_message": "{key} and its private key are removed from this device. Mail encrypted to it can no longer be read here unless you import it again.", + "delete_public_message": "{key} is removed from this device.", + "delete_title": "Delete key?", + "expired": "Expired {date}", + "expires": "Expires {date}", + "find_key": "Find a key on keys.openpgp.org", + "import": "Import", + "import_cert": "Import certificate", + "import_p12": "Import S/MIME certificate (.p12)", + "import_pgp_file": "Import PGP key file", + "import_public_file": "Import public key file", + "imported": "Imported {key}", + "lock": "Lock", + "lock_all": "Lock all keys", + "lock_all_description": "Forget the unlocked keys until you enter their passphrase again.", + "locked": "Locked", + "locked_all": "Keys locked", + "lookup_confirm": "Compare this fingerprint with the one the owner gave you (in person, by phone) before you import it.", + "lookup_none": "No key found.", + "lookup_placeholder": "Email address or fingerprint", + "lookup_search": "Search", + "lookup_verified": "The key has the fingerprint you searched for.", + "no_other_keys": "None yet.", + "no_own_keys": "No keys yet.", + "not_a_key": "This is not a key or certificate the app can read.", + "others_description": "Public keys and certificates to encrypt to and to check signatures with. Keys from signed mail and keys in your contacts are used too.", + "others_title": "Other people's keys", + "own_description": "Sign and decrypt mail with OpenPGP or S/MIME. Private keys stay in this device's secure storage and are unlocked with their passphrase once per app session.", + "own_title": "Your keys", + "p12_password_message": "Enter the password the .p12 file was exported with. You will need it again to unlock the key after the app restarts.", + "p12_password_title": "Certificate password", + "passphrase": "Passphrase", + "passphrase_message": "Enter the passphrase of this key. It is checked now and asked again once per app session.", + "passphrase_title": "Key passphrase", + "password": "Password", + "paste_pgp": "Paste PGP key", + "paste_public": "Paste public key", + "share_cert": "Share certificate", + "share_key": "Share public key", + "tab": "Encryption", + "title": "Encryption", + "unlock": "Unlock", + "unlock_pgp_message": "Enter the passphrase of your PGP key {key}.", + "unlock_read_reason": "The message is encrypted to this key.", + "unlock_smime_message": "Enter the password of your S/MIME certificate {key}.", + "unlock_title": "Unlock key", + "unlocked": "Unlocked", + "unlocking": "Unlocking…", + "wrong_passphrase": "That passphrase is not right." + }, "files": { "batch_delete_confirm_message": "Are you sure you want to delete {count, plural, one {1 item} other {# items}}? This cannot be undone.", "batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}", diff --git a/metro.config.js b/metro.config.js index 3ca9fb8b..ddeb71df 100644 --- a/metro.config.js +++ b/metro.config.js @@ -43,4 +43,18 @@ config.transformer.getTransformOptions = async () => ({ }, }); +// openpgp's package exports send Metro to its Node build, which imports +// node:crypto. The browser build (same API, WebCrypto from +// src/lib/crypto/install-webcrypto.ts) is what runs on Hermes. Only openpgp +// gets it: adding the "browser" condition globally would change what every +// other package resolves to. +const OPENPGP_BROWSER_BUILD = path.join(path.dirname(require.resolve('openpgp')), '..', 'openpgp.min.mjs'); +const baseResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === 'openpgp') return { type: 'sourceFile', filePath: OPENPGP_BROWSER_BUILD }; + return baseResolveRequest + ? baseResolveRequest(context, moduleName, platform) + : context.resolveRequest(context, moduleName, platform); +}; + module.exports = config; diff --git a/package-lock.json b/package-lock.json index 01ef8cc6..5ae6b251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,12 +44,14 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "0.22.1", + "react-native-nitro-modules": "^0.37.1", + "react-native-quick-base64": "^3.0.1", + "react-native-quick-crypto": "^1.1.9", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", "react-native-svg": "15.12.1", "react-native-webview": "13.15.0", - "webcrypto-liner": "^1.4.3", "zustand": "^5.0.16" }, "devDependencies": { @@ -1560,6 +1562,30 @@ "node": ">=6.9.0" } }, + "node_modules/@craftzdog/react-native-buffer": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@craftzdog/react-native-buffer/-/react-native-buffer-6.1.2.tgz", + "integrity": "sha512-KV1HitN05FHLLDG7Zb/yftDsa+mKBYBzFMQ0PMldvUicq6vWOtAvz9mDavt7Fzozh+WNqORE+yFDkkdWysZ/SA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "ieee754": "^1.2.1", + "react-native-quick-base64": "^3.0.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2655,41 +2681,6 @@ "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz", - "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==", - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", @@ -3384,44 +3375,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@stablelib/binary": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", - "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", - "license": "MIT", - "dependencies": { - "@stablelib/int": "^1.0.1" - } - }, - "node_modules/@stablelib/hash": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz", - "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==", - "license": "MIT" - }, - "node_modules/@stablelib/int": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", - "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", - "license": "MIT" - }, - "node_modules/@stablelib/sha3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz", - "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==", - "license": "MIT", - "dependencies": { - "@stablelib/binary": "^1.0.1", - "@stablelib/hash": "^1.0.1", - "@stablelib/wipe": "^1.0.1" - } - }, - "node_modules/@stablelib/wipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", - "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3893,12 +3846,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, - "node_modules/asmcrypto.js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", - "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", - "license": "MIT" - }, "node_modules/asn1js": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", @@ -4284,12 +4231,6 @@ "node": ">=0.6" } }, - "node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4341,12 +4282,6 @@ "node": ">=8" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -4814,20 +4749,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/core-js": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", - "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-js-compat": { "version": "3.51.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.51.0.tgz", @@ -5030,16 +4951,6 @@ "node": ">= 0.8" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5167,20 +5078,6 @@ "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", "license": "ISC" }, - "node_modules/elliptic": { - "version": "6.5.0", - "resolved": "git+ssh://git@github.com/mahrud/elliptic.git#75637c76678e83c31682fd967c2fa9ff4761b3fc", - "license": "MIT", - "dependencies": { - "bn.js": "^4.4.0", - "brorand": "^1.0.1", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5331,6 +5228,15 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -6497,16 +6403,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6534,17 +6430,6 @@ "hermes-estree": "0.32.0" } }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -8213,18 +8098,6 @@ "node": ">=4" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -9026,6 +8899,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9251,6 +9133,124 @@ "react-native": "*" } }, + "node_modules/react-native-nitro-modules": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz", + "integrity": "sha512-KpW6EQVQ/bfegpCGxN9Be+ndvsfj56t4IESEvCASu9gWpJa/NDfHpIeIwCOvZQ3eXmLEj6YD4wCLqcC1FJPr2w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-quick-base64": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-3.0.1.tgz", + "integrity": "sha512-EjUP2U7WqKmlMmoY7XGyHomy8bM0q4+yCDCRg4ZezQ6zedYRwc7yVk4V2O/iSftKaLEzhuW98lpyPMdk1iPHXQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-quick-crypto": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-1.1.9.tgz", + "integrity": "sha512-u60Kr4EdAYI1ksGq7uwJfOXNJR/iWkCQ1AxfoVbjM/pEpiydddzsFXFYqwyv7EeB7eTDgg5/LiCGwNxA7W7NKA==", + "license": "MIT", + "dependencies": { + "@craftzdog/react-native-buffer": "^6.1.2", + "events": "3.3.0", + "readable-stream": "4.7.0", + "safe-buffer": "^5.2.1", + "string_decoder": "^1.3.0", + "util": "0.12.5" + }, + "peerDependencies": { + "expo": ">=48.0.0", + "react": "*", + "react-native": "*", + "react-native-nitro-modules": ">=0.31.2", + "react-native-quick-base64": ">=3.0.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/react-native-quick-crypto/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/react-native-quick-crypto/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/react-native-quick-crypto/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-native-quick-crypto/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", @@ -10947,38 +10947,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webcrypto-core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", - "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/json-schema": "^1.1.12", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/webcrypto-liner": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webcrypto-liner/-/webcrypto-liner-1.4.3.tgz", - "integrity": "sha512-gzlk7ciS5zqc8QZMwpzpRxxwkcQKDJDndhr/hHWQe18Rzafhji3a7CaSxIeA2jcL0bLcAK+P77K3lWS1QXMMYA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "@stablelib/sha3": "^1.0.1", - "asmcrypto.js": "^2.3.2", - "asn1js": "^3.0.5", - "core-js": "^3.35.1", - "des.js": "^1.1.0", - "elliptic": "git+https://github.com/mahrud/elliptic.git", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.7.8" - } - }, "node_modules/webidl-conversions": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", diff --git a/package.json b/package.json index 14dfe745..01476098 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,14 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "0.22.1", + "react-native-nitro-modules": "^0.37.1", + "react-native-quick-base64": "^3.0.1", + "react-native-quick-crypto": "^1.1.9", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", "react-native-svg": "15.12.1", "react-native-webview": "13.15.0", - "webcrypto-liner": "^1.4.3", "zustand": "^5.0.16" }, "devDependencies": { diff --git a/src/api/__tests__/email-send-raw.test.ts b/src/api/__tests__/email-send-raw.test.ts new file mode 100644 index 00000000..a15a4111 --- /dev/null +++ b/src/api/__tests__/email-send-raw.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Sending an uploaded raw message (#58): a signed or encrypted PGP / S/MIME +// message is added with Email/import and submitted like any other, and the +// envelope names every recipient, Bcc included, since the raw headers don't. + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + learnHoldLimit: vi.fn(), + getSubmissionAccountIds: vi.fn(() => ['acc-1']), + hasDelayedSend: vi.fn(() => true), + hasAccountCapability: vi.fn(() => false), + getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + getAccountCapability: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { sendEmail } from '../email'; + +const mockRequest = jmapClient.request as ReturnType; + +const OUTGOING = { + from: [{ email: 'me@example.com' }], + to: [{ email: 'you@example.com' }], + bcc: [{ email: 'hidden@example.com' }], + subject: 'Secret', + textBody: 'not sent as is', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockRequest.mockResolvedValue({ + methodResponses: [ + ['Email/import', { created: { draft: { id: 'e-raw' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'], + ], + }); +}); + +describe('sendEmail with a raw blob', () => { + it('imports the blob into Drafts, submits it with every recipient in the envelope and files it in Sent', async () => { + const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', rawBlobId: 'blob-raw' }); + const calls = mockRequest.mock.calls[0][0] as Array<[string, Record, string]>; + expect(calls.find(([name]) => name === 'Email/set')).toBeUndefined(); + const [, importArgs] = calls.find(([name]) => name === 'Email/import')!; + expect(importArgs.emails.draft).toEqual({ blobId: 'blob-raw', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true } }); + const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1]; + expect(submission.create['sub-1'].emailId).toBe('#draft'); + expect(submission.create['sub-1'].envelope).toEqual({ + mailFrom: { email: 'me@example.com' }, + rcptTo: [{ email: 'you@example.com' }, { email: 'hidden@example.com' }], + }); + expect(submission.onSuccessUpdateEmail['#sub-1'].mailboxIds).toEqual({ sent: true }); + expect(result.emailId).toBe('e-raw'); + expect(result.emailSubmissionId).toBe('s-1'); + }); + + it('reports an import the server refused', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/import', { notCreated: { draft: { type: 'invalidEmail', description: 'bad message' } } }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties' } } }, '1'], + ], + }); + await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, { rawBlobId: 'blob-raw' })).rejects.toThrow(/bad message/); + }); +}); diff --git a/src/api/__tests__/live-stalwart-secure-mail.test.ts b/src/api/__tests__/live-stalwart-secure-mail.test.ts new file mode 100644 index 00000000..bdd9452f --- /dev/null +++ b/src/api/__tests__/live-stalwart-secure-mail.test.ts @@ -0,0 +1,193 @@ +/** + * Live checks of PGP and S/MIME mail (#58) against a real Stalwart: a + * signed and encrypted message is built the way the composer builds it, + * uploaded and sent with Email/import + EmailSubmission from the first + * login to the second, fetched there as the raw message and decrypted with + * the recipient's key, signature checked. The sender's Sent copy opens with + * the sender's key (encrypt-to-self). + * + * Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`, + * `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2` + * defaults to the first password). The keys are made fresh for the run. + * + * BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \ + * BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \ + * BULWARK_LIVE_USER2=e2e-tablet-land@example.org \ + * npx vitest run src/api/__tests__/live-stalwart-secure-mail.test.ts + * + * Every message the run sends is removed from both accounts. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { destroyEmails, getMailboxes, sendEmail } from '../email'; +import { getIdentities } from '../identity'; +import { uploadBytes } from '../blob'; +import { fetchBlobBytes } from '../../lib/email-export'; +import { buildSecureMessage } from '../../lib/crypto/secure-send'; +import { openSecureMessage, type SecureKeyAccess } from '../../lib/crypto/secure-message'; +import type { MimeMessageInput } from '../../lib/crypto/mime'; +import { makePgpKey, makeSmimeCert, useNodeCrypto } from '../../lib/crypto/__tests__/helpers'; + +const SERVER = process.env.BULWARK_LIVE_JMAP; +const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org'; +const PASS = process.env.BULWARK_LIVE_PASS ?? ''; +const USER2 = process.env.BULWARK_LIVE_USER2; +const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS; +const live = SERVER && PASS && USER2 ? describe : describe.skip; + +const RUN_STARTED = Date.now(); +const TAG = `bulwark-secure-${RUN_STARTED.toString(36)}`; + +let jmap: JMAPClient; +let sender: JMAPClient; +let recipient: JMAPClient; + +const as = (client: JMAPClient) => jmap.restoreSnapshot(client.snapshot()); + +/** The raw bytes of the message with this subject in the recipient's account (waits for delivery). */ +async function receive(subject: string): Promise { + as(recipient); + for (let i = 0; i < 40; i++) { + const res = await jmap.request([ + ['Email/query', { accountId: jmap.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 100 }, '0'], + ['Email/get', { accountId: jmap.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'blobId'] }, '1'], + ]); + const hit = (res.methodResponses[1][1].list as Array<{ subject?: string; blobId: string }>).find((e) => e.subject === subject); + if (hit) return fetchBlobBytes(hit.blobId, 'message.eml', 'message/rfc822'); + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`"${subject}" did not arrive`); +} + +async function removeRunMail(client: JMAPClient): Promise { + as(client); + const res = await jmap.request([ + ['Email/query', { accountId: jmap.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'], + ['Email/get', { accountId: jmap.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'], + ]); + const ids = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }>).filter((e) => e.subject?.includes(TAG)).map((e) => e.id); + if (ids.length) await destroyEmails(ids); +} + +/** Send `raw` from the sender like the composer: upload, Email/import into Drafts, submit, file in Sent. */ +async function sendRaw(raw: Uint8Array, to: string): Promise { + as(sender); + const [identity] = await getIdentities(); + const boxes = await getMailboxes(); + const drafts = boxes.find((b) => b.role === 'drafts')!.id; + const sent = boxes.find((b) => b.role === 'sent')!.id; + const { blobId } = await uploadBytes(raw, 'message/rfc822'); + const result = await sendEmail( + { from: [{ email: identity.email }], to: [{ email: to }], subject: 'not used for a raw send' }, + identity.id, sent, undefined, { draftsMailboxId: drafts, rawBlobId: blobId }, + ); + expect(result.emailSubmissionId).toBeTruthy(); + return result.emailId!; +} + +async function sentCopy(emailId: string): Promise { + as(sender); + const res = await jmap.request([['Email/get', { accountId: jmap.accountId, ids: [emailId], properties: ['blobId', 'mailboxIds', 'keywords'] }, '0']]); + const [email] = res.methodResponses[0][1].list; + return fetchBlobBytes(email.blobId, 'message.eml', 'message/rfc822'); +} + +const message = (from: string, to: string, subject: string): MimeMessageInput => ({ + from: { name: 'Bulwark Live', email: from }, + to: [{ email: to }], + subject, + textBody: 'Geheime Grüße aus dem Live-Test', + htmlBody: '

Geheime Grüße aus dem Live-Test

', + attachments: [{ filename: 'live.txt', contentType: 'text/plain', content: new TextEncoder().encode('attachment') }], +}); + +live('live Stalwart: PGP and S/MIME mail', () => { + beforeAll(async () => { + useNodeCrypto(); + const mod = await import('../jmap-client'); + jmap = mod.jmapClient as unknown as JMAPClient; + sender = new JMAPClient(); + await sender.connect(SERVER!, USER, PASS); + recipient = new JMAPClient(); + await recipient.connect(SERVER!, USER2!, PASS2); + }, 60_000); + + afterAll(async () => { + await removeRunMail(sender).catch(() => undefined); + await removeRunMail(recipient).catch(() => undefined); + }, 60_000); + + it('sends a signed and encrypted PGP/MIME message that the recipient decrypts and verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makePgpKey(identity.email); + const them = await makePgpKey(USER2!); + const subject = `pgp ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'pgp', sign: true, encrypt: true }, { + pgp: { signingKey: me.unlocked, encryptionKeys: [them.publicKey, me.publicKey] }, + }); + const emailId = await sendRaw(raw, USER2!); + + const received = await receive(subject); + expect(new TextDecoder().decode(received)).not.toContain('Geheime'); + const access: SecureKeyAccess = { + pgpDecryptionKeys: () => [them.unlocked], + pgpOwnKeys: () => [{ id: 'them', publicKeyArmored: them.publicKey, unlocked: true }], + pgpPublicKeys: () => [me.publicKey], + smimeKeys: () => [], + }; + const res = await openSecureMessage(received, identity.email, access); + expect(res?.status).toMatchObject({ kind: 'pgp', encrypted: true, decrypted: true, signature: 'valid', signerEmail: identity.email.toLowerCase() }); + expect(res?.body?.html).toContain('Grüße'); + expect(res?.attachments.map((a) => a.name)).toEqual(['live.txt']); + + // The Sent copy opens with the sender's own key. + const own = await openSecureMessage(await sentCopy(emailId), identity.email, { + ...access, pgpDecryptionKeys: () => [me.unlocked], pgpOwnKeys: () => [{ id: 'me', publicKeyArmored: me.publicKey, unlocked: true }], + }); + expect(own?.status.decrypted).toBe(true); + }, 120_000); + + it('sends a signed and encrypted S/MIME message that the recipient decrypts and verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makeSmimeCert(identity.email); + const them = await makeSmimeCert(USER2!); + const subject = `smime ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'smime', sign: true, encrypt: true }, { + smime: { + signer: { pkcs8: me.pkcs8, certificate: me.certificate, chain: [], algorithm: 'RSA-2048' }, + recipientCerts: [them.certificate], + senderCert: me.certificate, + }, + }); + const emailId = await sendRaw(raw, USER2!); + + const received = await receive(subject); + const access = (cert: typeof me): SecureKeyAccess => ({ + pgpDecryptionKeys: () => [], pgpOwnKeys: () => [], pgpPublicKeys: () => [], + smimeKeys: () => [{ id: cert.email, certificate: cert.certificate, pkcs8: cert.pkcs8 }], + }); + const res = await openSecureMessage(received, identity.email, access(them)); + expect(res?.status).toMatchObject({ kind: 'smime', encrypted: true, decrypted: true, signature: 'valid', signerEmail: identity.email.toLowerCase() }); + expect(res?.body?.text).toContain('Grüße'); + expect(res?.attachments.map((a) => a.name)).toEqual(['live.txt']); + + const own = await openSecureMessage(await sentCopy(emailId), identity.email, access(me)); + expect(own?.status.decrypted).toBe(true); + }, 120_000); + + it('sends a detached S/MIME signature the recipient verifies', async () => { + as(sender); + const [identity] = await getIdentities(); + const me = await makeSmimeCert(identity.email); + const subject = `smime-signed ${TAG}`; + const raw = await buildSecureMessage(message(identity.email, USER2!, subject), { mode: 'smime', sign: true, encrypt: false }, { + smime: { signer: { pkcs8: me.pkcs8, certificate: me.certificate, chain: [], algorithm: 'RSA-2048' } }, + }); + await sendRaw(raw, USER2!); + const res = await openSecureMessage(await receive(subject), identity.email, { pgpDecryptionKeys: () => [], pgpOwnKeys: () => [], pgpPublicKeys: () => [], smimeKeys: () => [] }); + expect(res?.status).toMatchObject({ kind: 'smime', encrypted: false, signature: 'valid' }); + }, 120_000); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 23f2853c..31a3c724 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1754,6 +1754,13 @@ export interface SendEmailOptions { requestDsn?: boolean; /** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */ requireTls?: boolean; + /** + * A complete message already uploaded as a blob (a signed or encrypted + * PGP / S/MIME message, #58): it is added with Email/import instead of + * built with Email/set, and the envelope always names every recipient, + * since Bcc is not in its headers. The webmail's sendRawEmail. + */ + rawBlobId?: string; } export async function sendEmail( @@ -1767,7 +1774,8 @@ export async function sendEmail( opts?: SendEmailOptions, ): Promise { const accountId = opts?.accountId ?? jmapClient.accountId; - const emailCreate = buildEmailCreate(email); + const raw = !!opts?.rawBlobId; + const emailCreate: Record = raw ? { blobId: opts!.rawBlobId } : buildEmailCreate(email); const viaDrafts = !!opts?.draftsMailboxId; if (viaDrafts) { emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true }; @@ -1776,6 +1784,9 @@ export async function sendEmail( emailCreate.mailboxIds = { [sentMailboxId]: true }; emailCreate.keywords = { $seen: true }; } + const createCall: [string, Record, string] = raw + ? ['Email/import', { accountId, emails: { draft: emailCreate } }, '0'] + : ['Email/set', { accountId, create: { draft: emailCreate } }, '0']; const submissionCreate: Record = { emailId: '#draft', identityId }; // For a deferred send the envelope must be set explicitly so the HOLDFOR @@ -1791,7 +1802,7 @@ export async function sendEmail( ); const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0; const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0; - if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) { + if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom || raw) { const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] .map((r) => r.email.trim()) .filter(Boolean) @@ -1818,7 +1829,7 @@ export async function sendEmail( let res = await jmapClient.request( [ - ['Email/set', { accountId, create: { draft: emailCreate } }, '0'], + createCall, ['EmailSubmission/set', submissionArgs, '1'], // Which recipients the server took. A refused one is lost without a // bounce while the create succeeds and files the message in Sent. @@ -1830,7 +1841,7 @@ export async function sendEmail( // Stalwart 1.0 refuses a submission once the account holds `maxSubmissions` // records (overQuota). The message exists by then, so make room and submit // that copy once more instead of failing the send. - const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0'); + const copyCreated = res.methodResponses.find(([name, , callId]) => (name === 'Email/set' || name === 'Email/import') && callId === '0'); const copyId = (copyCreated?.[1] as { created?: Record } | undefined)?.created?.draft?.id; if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') { await makeRoomForSubmission(accountId); @@ -1871,7 +1882,7 @@ export async function sendEmail( failure = new Error((result as { description?: string }).description ?? 'Send failed'); break; } - if (methodName === 'Email/set') { + if (methodName === 'Email/set' || methodName === 'Email/import') { const notCreated = (result as { notCreated?: Record }).notCreated?.draft; if (notCreated) { const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : ''; diff --git a/src/components/compose/ComposerOptionsSheet.tsx b/src/components/compose/ComposerOptionsSheet.tsx index 268db679..b6472f2c 100644 --- a/src/components/compose/ComposerOptionsSheet.tsx +++ b/src/components/compose/ComposerOptionsSheet.tsx @@ -8,12 +8,23 @@ import { useLocaleStore } from '../../stores/locale-store'; import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority'; import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; +import type { SecureMode } from '../../lib/crypto/secure-send'; export interface ComposerOptions { requestReadReceipt: boolean; requestDsn: boolean; requireTls: boolean; priority: MessagePriority; + /** Sign / encrypt with OpenPGP or S/MIME (#58; the plugins' Options entries). */ + secureMode: SecureMode; + sign: boolean; + encrypt: boolean; +} + +/** Which kinds of signing/encryption the From address has a key for. */ +export interface SecureAvailability { + pgp: boolean; + smime: boolean; } /** @@ -21,23 +32,28 @@ export interface ComposerOptions { * toolbar button (webmail composer options menu `activeCount`). */ export function activeComposerOptionCount(options: ComposerOptions): number { - return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal'] + return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal', options.sign, options.encrypt] .filter(Boolean).length; } /** * The composer's per-message options (webmail email-composer.tsx options * menu): read receipt, delivery status notification and REQUIRETLS - the - * last two only when the account's submission extensions list them - and - * the message priority. + * last two only when the account's submission extensions list them - the + * message priority, and Sign / Encrypt with OpenPGP or S/MIME when the From + * address has a key (the PGP and S/MIME plugins' Options entries). */ export function ComposerOptionsSheet({ - visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose, + visible, options, dsnAvailable, requireTlsAvailable, secureAvailable, secureHint, onChange, onClose, }: { visible: boolean; options: ComposerOptions; dsnAvailable: boolean; requireTlsAvailable: boolean; + /** Own keys for the From address; the section is left out without any. */ + secureAvailable?: SecureAvailability; + /** Why the chosen sign/encrypt can't go out yet (a recipient without a key). */ + secureHint?: string | null; onChange: (next: Partial) => void; onClose: () => void; }) { @@ -46,7 +62,7 @@ export function ComposerOptionsSheet({ const t = useLocaleStore((s) => s.t); const bottomInset = useSheetBottomInset(visible); - const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => ( + const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls' | 'sign' | 'encrypt') => ( onChange({ [key]: !value })} @@ -92,6 +108,47 @@ export function ComposerOptionsSheet({ options.requireTls, 'requireTls', )} + {secureAvailable && (secureAvailable.pgp || secureAvailable.smime) && ( + <> + + {t('email_composer.option_security', 'Signature and encryption')} + {secureAvailable.pgp && secureAvailable.smime && ( + + {(['pgp', 'smime'] as const).map((mode) => { + const selected = options.secureMode === mode; + const label = mode === 'pgp' ? 'OpenPGP' : 'S/MIME'; + return ( + onChange({ secureMode: mode })} + accessibilityRole="radio" + accessibilityLabel={label} + accessibilityState={{ checked: selected, selected }} + > + {label} + + ); + })} + + )} + {toggleRow( + t('email_composer.option_sign', 'Sign'), + options.secureMode === 'pgp' + ? t('email_composer.option_sign_pgp_hint', 'Add an OpenPGP signature with your key') + : t('email_composer.option_sign_smime_hint', 'Add an S/MIME signature with your certificate'), + options.sign, + 'sign', + )} + {toggleRow( + t('email_composer.option_encrypt', 'Encrypt'), + t('email_composer.option_encrypt_hint', 'Only the recipients (and you) can read it; every recipient needs a key'), + options.encrypt, + 'encrypt', + )} + {!!secureHint && (options.sign || options.encrypt) && {secureHint}} + + )} {t('email_composer.option_priority', 'Priority')} @@ -159,6 +216,13 @@ function makeStyles(c: ThemePalette) { rowHint: { ...typography.caption, color: c.textMuted }, separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs }, groupLabel: { ...typography.captionMedium, color: c.textSecondary }, + segment: { flexDirection: 'row', gap: spacing.xs, paddingVertical: spacing.xs }, + segmentItem: { + flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: c.border, + }, + segmentItemSelected: { borderColor: c.primary, backgroundColor: c.primaryBg }, + segmentTextSelected: { color: c.primary }, + warning: { ...typography.caption, color: c.warning }, done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs }, doneText: { ...typography.bodyMedium, color: c.primary }, }); diff --git a/src/components/crypto/UnlockKeyDialog.tsx b/src/components/crypto/UnlockKeyDialog.tsx new file mode 100644 index 00000000..10a9bdc1 --- /dev/null +++ b/src/components/crypto/UnlockKeyDialog.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import Dialog from '../Dialog'; +import { useLocaleStore } from '../../stores/locale-store'; +import type { CryptoKeyRecord } from '../../stores/crypto-keys-store'; +import { unlock } from '../../lib/crypto/keyring'; +import { keyLabel } from '../../lib/crypto/key-labels'; + +/** + * Asks for the passphrase of a PGP key or the password of an S/MIME .p12 + * and unlocks it for this session (the plugins' unlock prompt). A wrong + * passphrase keeps the dialog open with the reason. + */ +export function UnlockKeyDialog({ + record, reason, onUnlocked, onCancel, +}: { + /** The key to unlock; null hides the dialog. */ + record: CryptoKeyRecord | null; + /** Why it is needed ("to read this message", "to sign"). */ + reason?: string; + onUnlocked: () => void; + onCancel: () => void; +}) { + const t = useLocaleStore((s) => s.t); + const [value, setValue] = React.useState(''); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + setValue(''); + setError(null); + setBusy(false); + }, [record?.id]); + + if (!record) return null; + const label = keyLabel(record); + const message = [ + record.kind === 'pgp' + ? t('encryption.unlock_pgp_message', 'Enter the passphrase of your PGP key {key}.', { key: label }) + : t('encryption.unlock_smime_message', 'Enter the password of your S/MIME certificate {key}.', { key: label }), + reason, + error, + ].filter(Boolean).join('\n\n'); + + const submit = async () => { + if (busy) return; + setBusy(true); + setError(null); + try { + await unlock(record, value); + setValue(''); + onUnlocked(); + } catch (err) { + setError(err instanceof Error && /passphrase|password/i.test(err.message) + ? t('encryption.wrong_passphrase', 'That passphrase is not right.') + : err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( + void submit()} + onCancel={() => { + setValue(''); + onCancel(); + }} + /> + ); +} diff --git a/src/components/email/MessageContent.tsx b/src/components/email/MessageContent.tsx index 5b17d377..17318918 100644 --- a/src/components/email/MessageContent.tsx +++ b/src/components/email/MessageContent.tsx @@ -12,6 +12,8 @@ import { AttachmentChips } from './AttachmentChips'; import { UnsubscribeBanner } from './UnsubscribeBanner'; import { ReadReceiptBanner } from './ReadReceiptBanner'; import { useBodyOverride } from './use-body-override'; +import { useSecureMessage } from './use-secure-message'; +import { SecureMessageBanner } from './SecureMessageBanner'; import { deriveHeaderInfo } from '../../lib/email-headers'; import { calendarBannerShownFor } from '../../lib/attachment-display'; @@ -66,6 +68,20 @@ export function MessageContent({ const calendarParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const headerInfo = React.useMemo(() => deriveHeaderInfo(email), [email]); const unwrap = useBodyOverride(email, jmapAccountId); + // PGP / S/MIME (#58): the decrypted or verified body replaces the + // message's own; an encrypted one that can't be opened shows nothing but + // the banner (the plugins' render takeover). + const secure = useSecureMessage(email, jmapAccountId); + const secureBody = secure.result + ? secure.result.body ?? (secure.result.status.encrypted ? { html: null, text: '' } : null) + : null; + const bodyOverride = secureBody ?? unwrap.override; + // Only an encrypted message hides its attachments from the server; a + // signed one has them listed already. + const extracted = React.useMemo( + () => (secure.result?.status.encrypted && secure.result.attachments.length ? [...secure.result.attachments, ...unwrap.extracted] : unwrap.extracted), + [secure.result, unwrap.extracted], + ); const calendarBannerShown = calendarBannerShownFor(email, calendarParsing); const from = email.from?.[0]; @@ -75,7 +91,7 @@ export function MessageContent({ jmapAccountId={jmapAccountId} calendarBannerShown={calendarBannerShown} tnefUnpacked={unwrap.tnefUnpacked} - extracted={unwrap.extracted} + extracted={extracted} /> ); @@ -114,13 +130,14 @@ export function MessageContent({ /> )} + {(secure.loading || secure.result) && } {deferBody ? ( - ) : unwrap.loading ? ( + ) : unwrap.loading || secure.loading ? ( ) : ( onShowFormatted(email) : undefined} - bodyOverride={unwrap.override} + bodyOverride={bodyOverride} onSettled={onBodySettled} fill={fill} /> diff --git a/src/components/email/MessageHeader.tsx b/src/components/email/MessageHeader.tsx index acdf833d..0ecbdf9d 100644 --- a/src/components/email/MessageHeader.tsx +++ b/src/components/email/MessageHeader.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, Pressable } from 'react-native'; import { - Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock, + Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, } from '../icons'; import type { Email, EmailAddress, Identity } from '../../api/types'; import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens'; @@ -21,7 +21,6 @@ import { type AuthenticationResults, type EmailHeaderInfo, } from '../../lib/email-headers'; import { formatSize } from '../../lib/attachment-display'; -import { isSmimeEmail } from '../../lib/smime'; import { resolveEmailTags } from '../../lib/thread-utils'; import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code'; import { VerificationCodeChip } from './VerificationCodeChip'; @@ -166,7 +165,6 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA const important = !!email.keywords?.$important; const answered = !!email.keywords?.$answered; const forwarded = !!email.keywords?.$forwarded; - const smime = React.useMemo(() => isSmimeEmail(email), [email]); const delta = deliveryDeltaMs(email); const auth: AuthenticationResults | undefined = info.auth; @@ -243,7 +241,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA - {(tags.length > 0 || important || answered || forwarded || viaIdentity || smime) && ( + {(tags.length > 0 || important || answered || forwarded || viaIdentity) && ( {important && ( @@ -280,12 +278,6 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA )} - {smime && ( - - - {t('email_viewer.smime_unsupported', 'Signed/encrypted with S/MIME - not supported on mobile')} - - )} )} diff --git a/src/components/email/SecureMessageBanner.tsx b/src/components/email/SecureMessageBanner.tsx new file mode 100644 index 00000000..9bb159df --- /dev/null +++ b/src/components/email/SecureMessageBanner.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'; +import { Lock, ShieldCheck, ShieldAlert, Shield } from '../icons'; +import Button from '../Button'; +import { UnlockKeyDialog } from '../crypto/UnlockKeyDialog'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useCryptoKeysStore, type CryptoKeyRecord } from '../../stores/crypto-keys-store'; +import type { SecureStatus } from '../../lib/crypto/secure-message'; +import { secureBannerRows, type BannerTone as Tone } from '../../lib/crypto/banner-rows'; + +/** Status banner above a PGP / S/MIME message, with Unlock for a locked key. */ +export function SecureMessageBanner({ status, loading }: { status: SecureStatus | null; loading?: boolean }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const keys = useCryptoKeysStore((s) => s.keys); + const [unlockRecord, setUnlockRecord] = React.useState(null); + + if (loading) { + return ( + + + {t('encryption.banner.checking', 'Checking encryption and signature…')} + + ); + } + if (!status) return null; + const rows = secureBannerRows(status, t); + if (rows.length === 0) return null; + const color = (tone: Tone) => (tone === 'ok' ? c.success : tone === 'warn' ? c.warning : tone === 'error' ? c.error : c.textMuted); + const lockedRecord = status.lockedKeyIds?.length + ? keys.find((k) => k.kind === status.kind && status.lockedKeyIds!.includes(k.id)) ?? null + : null; + + return ( + + {rows.map((row) => { + const Icon = row.key === 'dec' || row.key === 'locked' ? Lock : row.tone === 'ok' ? ShieldCheck : row.tone === 'muted' ? Shield : ShieldAlert; + return ( + + + {row.text} + {row.key === 'locked' && lockedRecord && ( + + )} + + ); + })} + setUnlockRecord(null)} + onCancel={() => setUnlockRecord(null)} + /> + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + wrap: { gap: spacing.xs, paddingHorizontal: spacing.lg, paddingTop: spacing.sm }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + text: { ...typography.caption, flex: 1 }, + }); +} diff --git a/src/components/email/use-secure-message.ts b/src/components/email/use-secure-message.ts new file mode 100644 index 00000000..290c89d2 --- /dev/null +++ b/src/components/email/use-secure-message.ts @@ -0,0 +1,84 @@ +import React from 'react'; +import type { Email } from '../../api/types'; +import { fetchBlobBytes } from '../../lib/email-export'; +import { secureKindHint, openSecureMessage, type SecureKeyAccess, type SecureResult } from '../../lib/crypto/secure-message'; +import { + unlockedPgpKeys, ownKeys, allPgpPublicKeys, isUnlocked, unlockedSmimeKey, onUnlockChange, rememberSignerCertificate, rememberSignerPgpKey, + unlockIfUnprotected, +} from '../../lib/crypto/keyring'; +import { base64Decode } from '../../lib/crypto/mime'; +import { usePolicyFeature } from '../../stores/policy-store'; +import type { PgpKeyRecord, SmimeCertRecord } from '../../stores/crypto-keys-store'; + +/** The key store as the reader sees it (src/lib/crypto/keyring.ts). */ +export function keyringAccess(): SecureKeyAccess { + return { + pgpDecryptionKeys: unlockedPgpKeys, + pgpOwnKeys: () => (ownKeys('pgp') as PgpKeyRecord[]).map((k) => ({ id: k.id, publicKeyArmored: k.publicKeyArmored, unlocked: isUnlocked(k) })), + pgpPublicKeys: allPgpPublicKeys, + smimeKeys: () => (ownKeys('smime') as SmimeCertRecord[]).map((k) => ({ id: k.id, certificate: base64Decode(k.certificate), pkcs8: unlockedSmimeKey(k.id)?.pkcs8 })), + rememberPgpKey: rememberSignerPgpKey, + rememberCertificate: rememberSignerCertificate, + }; +} + +const MAX_RAW_BYTES = 25 * 1024 * 1024; + +// Results by message, so paging back to a message doesn't decrypt it again. +// Dropped whenever a key is unlocked or locked. +const cache = new Map(); +onUnlockChange(() => cache.clear()); + +export interface SecureMessageState { + loading: boolean; + result: SecureResult | null; +} + +const IDLE: SecureMessageState = { loading: false, result: null }; + +/** + * Decrypt / verify a PGP or S/MIME message for the reader (#58): the + * plugins' onRenderEmailBody. Only messages whose structure or text hint at + * PGP or S/MIME are downloaded (the raw message, by its blobId). + */ +export function useSecureMessage(email: Email, jmapAccountId?: string): SecureMessageState { + const smimeAllowed = usePolicyFeature('smimeEnabled'); + const hint = React.useMemo(() => secureKindHint(email), [email]); + const enabled = !!hint && !!email.blobId && (hint === 'pgp' || smimeAllowed) && (email.size ?? 0) <= MAX_RAW_BYTES; + const cacheKey = `${jmapAccountId ?? ''}:${email.id}:${email.blobId}`; + const [state, setState] = React.useState(() => (enabled && cache.has(cacheKey) ? { loading: false, result: cache.get(cacheKey)! } : enabled ? { loading: true, result: null } : IDLE)); + const [generation, setGeneration] = React.useState(0); + + React.useEffect(() => onUnlockChange(() => setGeneration((n) => n + 1)), []); + + React.useEffect(() => { + if (!enabled) { + setState(IDLE); + return; + } + if (cache.has(cacheKey)) { + setState({ loading: false, result: cache.get(cacheKey)! }); + return; + } + let cancelled = false; + setState((s) => (s.result ? s : { loading: true, result: null })); + void (async () => { + try { + const raw = await fetchBlobBytes(email.blobId!, 'message.eml', 'message/rfc822', jmapAccountId); + // A key without a passphrase unlocks without asking. + for (const k of ownKeys('pgp')) await unlockIfUnprotected(k).catch(() => false); + let result = await openSecureMessage(raw, email.from?.[0]?.email, keyringAccess()); + if (result?.status.kind === 'smime' && !smimeAllowed) result = null; + if (cancelled) return; + cache.set(cacheKey, result); + setState({ loading: false, result }); + } catch (err) { + console.warn('[reader] secure message failed', err); + if (!cancelled) setState(IDLE); + } + })(); + return () => { cancelled = true; }; + }, [enabled, cacheKey, email.blobId, email.from, jmapAccountId, smimeAllowed, generation]); + + return state; +} diff --git a/src/components/settings/EncryptionSettings.tsx b/src/components/settings/EncryptionSettings.tsx new file mode 100644 index 00000000..91ba5d88 --- /dev/null +++ b/src/components/settings/EncryptionSettings.tsx @@ -0,0 +1,422 @@ +import React from 'react'; +import { View, Text, StyleSheet, Alert, Share, Modal, TextInput, Pressable, ScrollView, ActivityIndicator } from 'react-native'; +import * as DocumentPicker from 'expo-document-picker'; +import { File } from 'expo-file-system'; +import { SettingsSection, SettingItem } from './settings-section'; +import Button from '../Button'; +import Dialog from '../Dialog'; +import { UnlockKeyDialog } from '../crypto/UnlockKeyDialog'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { useLocaleStore } from '../../stores/locale-store'; +import { useCryptoKeysStore, isRecordCurrent, type CryptoKeyRecord } from '../../stores/crypto-keys-store'; +import { toast } from '../../stores/toast-store'; +import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations'; +import { useSheetBottomInset } from '../../lib/sheet-bottom-inset'; +import { + importPgpKey, importPkcs12File, importCertificate, deleteKey, exportPublic, isUnlocked, lockAll, onUnlockChange, needsPassphrase, unlock, +} from '../../lib/crypto/keyring'; +import { formatFingerprint, keyLabel, shortDate, shortFingerprint } from '../../lib/crypto/key-labels'; +import { lookupKey, type KeyLookupResult } from '../../lib/crypto/keyserver'; +import { PgpWrongPassphraseError } from '../../lib/crypto/pgp'; +import type { SelfTestResult } from '../../lib/crypto/self-test'; + +type Translate = (key: string, fallback: string, vars?: Record) => string; + +async function pickFileBytes(types: string[]): Promise<{ bytes: Uint8Array; name: string } | null> { + const picked = await DocumentPicker.getDocumentAsync({ type: types, copyToCacheDirectory: true, multiple: false }); + if (picked.canceled || !picked.assets?.[0]) return null; + const asset = picked.assets[0]; + return { bytes: await new File(asset.uri).bytes(), name: asset.name ?? '' }; +} + +function errorText(err: unknown, t: Translate): string { + const msg = err instanceof Error ? err.message : String(err); + if (/wrong passphrase|incorrect key passphrase|wrong password/i.test(msg)) return t('encryption.wrong_passphrase', 'That passphrase is not right.'); + if (/Misformed armored text|Unknown ASCII armor|No valid key|not a PGP|Invalid PKCS#12|ASN\.1/i.test(msg)) return t('encryption.not_a_key', 'This is not a key or certificate the app can read.'); + return msg; +} + +/** Use an unlocked key's state in render; re-renders on lock/unlock. */ +function useUnlockTick(): number { + const [tick, setTick] = React.useState(0); + React.useEffect(() => onUnlockChange(() => setTick((n) => n + 1)), []); + return tick; +} + +/** + * Settings → Encryption (#58): your PGP keys and S/MIME certificates, other + * people's public keys and certificates, keyserver lookup, and a check of + * the device's crypto. The webmail has this in its plugins' settings + * sections (repos/plugins/bulwark-gpg/src/index.js SettingsSection, + * repos/plugins/smime/src/ui.js SettingsSection). + */ +export function EncryptionSettings() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const keys = useCryptoKeysStore((s) => s.keys); + useUnlockTick(); + + const own = React.useMemo(() => keys.filter((k) => k.hasPrivate), [keys]); + const others = React.useMemo(() => keys.filter((k) => !k.hasPrivate), [keys]); + + const [busy, setBusy] = React.useState(false); + const [unlockRecord, setUnlockRecord] = React.useState(null); + const [deleteRecord, setDeleteRecord] = React.useState(null); + const [pasteMode, setPasteMode] = React.useState<'private' | 'public' | null>(null); + const [pendingPrivate, setPendingPrivate] = React.useState(null); + const [pendingP12, setPendingP12] = React.useState(null); + const [secret, setSecret] = React.useState(''); + const [secretError, setSecretError] = React.useState(null); + const [lookupOpen, setLookupOpen] = React.useState(false); + const [selfTest, setSelfTest] = React.useState(null); + const [selfTestRunning, setSelfTestRunning] = React.useState(false); + + const run = async (fn: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await fn(); + } catch (err) { + Alert.alert(t('encryption.tab', 'Encryption'), errorText(err, t)); + } finally { + setBusy(false); + } + }; + + // ── import flows ── + const importArmored = async (armored: string, kind: 'private' | 'public') => { + if (kind === 'public' || !/BEGIN PGP PRIVATE KEY/.test(armored)) { + const record = await importPgpKey(armored); + toast.success(t('encryption.imported', 'Imported {key}', { key: keyLabel(record) })); + return; + } + // Ask for the passphrase only when the key has one. + try { + const record = await importPgpKey(armored, ''); + toast.success(t('encryption.imported', 'Imported {key}', { key: keyLabel(record) })); + } catch (err) { + if (err instanceof PgpWrongPassphraseError) { + setSecret(''); + setSecretError(null); + setPendingPrivate(armored); + return; + } + throw err; + } + }; + + const pickPgpFile = (kind: 'private' | 'public') => run(async () => { + const file = await pickFileBytes(['application/pgp-keys', 'text/plain', '*/*']); + if (!file) return; + await importArmored(new TextDecoder('utf-8', { fatal: false }).decode(file.bytes), kind); + }); + + const pickP12 = () => run(async () => { + const file = await pickFileBytes(['application/x-pkcs12', '*/*']); + if (!file) return; + setSecret(''); + setSecretError(null); + setPendingP12(file.bytes); + }); + + const pickCertificate = () => run(async () => { + const file = await pickFileBytes(['application/pkix-cert', 'application/x-x509-ca-cert', 'application/x-pem-file', '*/*']); + if (!file) return; + const record = await importCertificate(file.bytes); + toast.success(t('encryption.imported', 'Imported {key}', { key: keyLabel(record) })); + }); + + const submitSecret = async () => { + setBusy(true); + setSecretError(null); + try { + const record = pendingPrivate ? await importPgpKey(pendingPrivate, secret) : await importPkcs12File(pendingP12!, secret); + setPendingPrivate(null); + setPendingP12(null); + setSecret(''); + toast.success(t('encryption.imported', 'Imported {key}', { key: keyLabel(record) })); + } catch (err) { + setSecretError(errorText(err, t)); + } finally { + setBusy(false); + } + }; + + const share = (record: CryptoKeyRecord) => run(async () => { + await Share.share({ message: await exportPublic(record), title: keyLabel(record) }); + }); + + const onUnlockPress = (record: CryptoKeyRecord) => run(async () => { + if (await needsPassphrase(record)) setUnlockRecord(record); + else await unlock(record, ''); + }); + + const runSelfTest = async () => { + setSelfTestRunning(true); + try { + const { runCryptoSelfTest } = await import('../../lib/crypto/self-test'); + setSelfTest(await runCryptoSelfTest()); + } finally { + setSelfTestRunning(false); + } + }; + + const keyRow = (record: CryptoKeyRecord, mine: boolean) => { + const kindLabel = record.kind === 'pgp' ? 'PGP' : 'S/MIME'; + const expires = record.kind === 'pgp' ? record.expiration : record.notAfter; + const current = isRecordCurrent(record); + const details = [ + `${kindLabel} · ${record.algorithm}`, + shortFingerprint(record.fingerprint), + expires ? (current ? t('encryption.expires', 'Expires {date}', { date: shortDate(expires) }) : t('encryption.expired', 'Expired {date}', { date: shortDate(expires) })) : null, + mine ? (isUnlocked(record) ? t('encryption.unlocked', 'Unlocked') : t('encryption.locked', 'Locked')) : null, + ].filter(Boolean).join('\n'); + return ( + + + {mine && !isUnlocked(record) && ( + + )} + + + + + ); + }; + + return ( + + + {own.length === 0 && {t('encryption.no_own_keys', 'No keys yet.')}} + {own.map((k) => keyRow(k, true))} + + + + + + {own.some((k) => isUnlocked(k)) && ( + + + + )} + + + + {others.length === 0 && {t('encryption.no_other_keys', 'None yet.')}} + {others.map((k) => keyRow(k, false))} + + + + + + + + + + + + + {selfTest && selfTest.map((r) => ( + + {`${r.ok ? '✓' : '✗'} ${r.name}${r.error ? ` — ${r.error}` : ''}`} + + ))} + + + setUnlockRecord(null)} onCancel={() => setUnlockRecord(null)} /> + + void submitSecret()} + onCancel={() => { setPendingPrivate(null); setPendingP12(null); setSecret(''); }} + /> + + { const r = deleteRecord; setDeleteRecord(null); if (r) void run(() => deleteKey(r)); }} + onCancel={() => setDeleteRecord(null)} + /> + + setPasteMode(null)} + onSubmit={(text) => { const mode = pasteMode!; setPasteMode(null); void run(() => importArmored(text, mode)); }} + /> + + setLookupOpen(false)} /> + + ); +} + +function PasteKeySheet({ mode, onClose, onSubmit }: { mode: 'private' | 'public' | null; onClose: () => void; onSubmit: (text: string) => void }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [text, setText] = React.useState(''); + const bottomInset = useSheetBottomInset(!!mode); + React.useEffect(() => { if (mode) setText(''); }, [mode]); + return ( + + + {}}> + + {mode === 'private' ? t('encryption.paste_pgp', 'Paste PGP key') : t('encryption.paste_public', 'Paste public key')} + + + + + + + + + + ); +} + +function KeyLookupSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const [query, setQuery] = React.useState(''); + const [result, setResult] = React.useState(null); + const [message, setMessage] = React.useState(null); + const [searching, setSearching] = React.useState(false); + const bottomInset = useSheetBottomInset(visible); + React.useEffect(() => { + if (visible) { setQuery(''); setResult(null); setMessage(null); } + }, [visible]); + + const search = async () => { + setSearching(true); + setResult(null); + setMessage(null); + try { + const found = await lookupKey(query); + if (!found) setMessage(t('encryption.lookup_none', 'No key found.')); + else setResult(found); + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)); + } finally { + setSearching(false); + } + }; + + const importFound = async () => { + if (!result) return; + try { + const record = await importPgpKey(result.armored, undefined, 'keyserver'); + toast.success(t('encryption.imported', 'Imported {key}', { key: keyLabel(record) })); + onClose(); + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)); + } + }; + + return ( + + + {}}> + + {t('encryption.find_key', 'Find a key on keys.openpgp.org')} + void search()} + style={styles.input} + accessibilityLabel={t('encryption.lookup_placeholder', 'Email address or fingerprint')} + /> + {searching && } + {message && {message}} + {result && ( + + {result.info.userIDs.map((u) => u.raw).join('\n') || result.info.keyID} + {formatFingerprint(result.info.fingerprint)} + + {result.verified + ? t('encryption.lookup_verified', 'The key has the fingerprint you searched for.') + : t('encryption.lookup_confirm', 'Compare this fingerprint with the one the owner gave you (in person, by phone) before you import it.')} + + + )} + + + {result + ? + : } + + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { gap: spacing.xxl }, + empty: { ...typography.body, color: c.textMuted, paddingVertical: spacing.sm }, + buttons: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, paddingVertical: spacing.md }, + rowActions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.xs, justifyContent: 'flex-end' }, + checkRow: { ...typography.caption, paddingVertical: 2 }, + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center', padding: spacing.lg }, + card: { + width: '100%', maxWidth: 480, maxHeight: '90%', backgroundColor: c.background, borderRadius: radius.md, + borderWidth: 1, borderColor: c.border, padding: spacing.lg, gap: spacing.sm, + }, + cardTitle: { ...typography.h3, color: c.text, marginBottom: spacing.sm }, + textArea: { + minHeight: 160, maxHeight: 320, borderWidth: 1, borderColor: c.border, borderRadius: radius.sm, padding: spacing.sm, + color: c.text, fontFamily: 'monospace', fontSize: 12, textAlignVertical: 'top', + }, + input: { borderWidth: 1, borderColor: c.border, borderRadius: radius.sm, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, color: c.text, ...typography.body }, + footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm, marginTop: spacing.md }, + lookupMessage: { ...typography.caption, color: c.textMuted, marginTop: spacing.sm }, + lookupResult: { marginTop: spacing.md, gap: spacing.xs }, + lookupUser: { ...typography.bodyMedium, color: c.text }, + fingerprint: { fontFamily: 'monospace', fontSize: 13, color: c.text }, + }); +} diff --git a/src/lib/__tests__/compose-secure-wiring.test.ts b/src/lib/__tests__/compose-secure-wiring.test.ts new file mode 100644 index 00000000..997e02cd --- /dev/null +++ b/src/lib/__tests__/compose-secure-wiring.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Sign / encrypt in the composer (#58) as wired in ComposeScreen, checked on +// its source (the suite has no RN renderer): the options sheet gets the +// availability, a secure send goes out as the uploaded raw message, and it +// never lands in the Outbox, which would send it as plain mail later. +const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); +const reader = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageContent.tsx'), 'utf8'); + +describe('ComposeScreen sign / encrypt wiring', () => { + it('passes the keys the From address has to the options sheet', () => { + expect(source).toMatch(/ { + expect(source).toMatch(/const blobId = await prepareSecureBlob\(secureChoice, outgoing, target\.accountId\);/); + expect(source).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),\s*rawBlobId,/); + }); + + it('keeps a secure send out of the Outbox', () => { + const block = source.slice(source.indexOf('if (secureChoice) {'), source.indexOf('const target = await sendTargetFor(primaryIdentity')); + expect(block).toMatch(/if \(!useNetworkStore\.getState\(\)\.online\) \{[\s\S]*?return;/); + expect(block).toMatch(/\} else \{\s*queuedOutgoing = outgoing;/); + }); +}); + +describe('reader wiring', () => { + it('shows the decrypted body and the banner', () => { + expect(reader).toMatch(/const secure = useSecureMessage\(email, jmapAccountId\);/); + expect(reader).toMatch(/bodyOverride=\{bodyOverride\}/); + expect(reader).toMatch(/ { await expect(parsed.verify(undefined, engine)).resolves.toBe(true); }); - it('webcrypto-liner adds 3DES, which legacy S/MIME mail still uses', async () => { - liner.setCrypto(webcrypto); - const crypto = new liner.Crypto() as Crypto; - const key = await crypto.subtle.generateKey({ name: 'DES-EDE3-CBC', length: 192 } as unknown as AesKeyGenParams, true, ['encrypt', 'decrypt']); - const iv = crypto.getRandomValues(new Uint8Array(8)); - const alg = { name: 'DES-EDE3-CBC', iv } as unknown as AesCbcParams; - const sealed = await crypto.subtle.encrypt(alg, key as CryptoKey, new TextEncoder().encode('legacy')); + it('3DES, which legacy S/MIME mail still uses, decrypts through the native crypto module', () => { + setNodeStyleCrypto(() => nodeCrypto as unknown as NodeStyleCrypto); + const key = nodeCrypto.randomBytes(24); + const iv = nodeCrypto.randomBytes(8); + const c = nodeCrypto.createCipheriv('des-ede3-cbc', key, iv); + const sealed = Buffer.concat([c.update('legacy'), c.final()]); expect(sealed.byteLength).toBe(8); - const opened = await crypto.subtle.decrypt(alg, key as CryptoKey, sealed); + const opened = cbcDecrypt('des-ede3-cbc', new Uint8Array(key), new Uint8Array(iv), new Uint8Array(sealed)); expect(new TextDecoder().decode(opened)).toBe('legacy'); }); }); + +describe('openpgp on a Hermes-like runtime', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Metro bundles openpgp's browser build (metro.config.js). Hermes has + // WebCrypto only through the installed module, and no web streams or + // WebAssembly: the build must work with plain strings and bytes. + it('the browser build Metro bundles encrypts, signs and decrypts without streams or WebAssembly', async () => { + vi.stubGlobal('ReadableStream', undefined); + vi.stubGlobal('TransformStream', undefined); + vi.stubGlobal('WritableStream', undefined); + vi.stubGlobal('WebAssembly', undefined); + const req = createRequire(import.meta.url); + const browserBuild = path.join(path.dirname(req.resolve('openpgp')), '..', 'openpgp.min.mjs'); + const pgp = (await import(/* @vite-ignore */ pathToFileURL(browserBuild).href)) as typeof openpgp; + + for (const keyOptions of [{ type: 'ecc', curve: 'curve25519Legacy' }, { type: 'rsa', rsaBits: 2048 }] as const) { + const { privateKey, publicKey } = await pgp.generateKey({ + ...keyOptions, + userIDs: [{ email: 'hermes@example.org' }], + passphrase: 'pw', + format: 'armored', + }); + const priv = await pgp.decryptKey({ privateKey: await pgp.readPrivateKey({ armoredKey: privateKey }), passphrase: 'pw' }); + const pub = await pgp.readKey({ armoredKey: publicKey }); + const armored = await pgp.encrypt({ + message: await pgp.createMessage({ text: 'ohne Streams' }), + encryptionKeys: pub, + signingKeys: priv, + }); + const { data, signatures } = await pgp.decrypt({ + message: await pgp.readMessage({ armoredMessage: armored as string }), + decryptionKeys: priv, + verificationKeys: pub, + }); + expect(data).toBe('ohne Streams'); + await expect(signatures[0].verified).resolves.toBe(true); + } + }, 60_000); +}); + +describe('installWebCrypto', () => { + it('puts a lazy subtle and getRandomValues on a runtime without crypto', () => { + const target: Record = {}; + installWebCrypto(target); + const crypto = target.crypto as Record; + expect(typeof crypto.getRandomValues).toBe('function'); + // The native module loads on first use of subtle, not at install. + expect(Object.getOwnPropertyDescriptor(crypto, 'subtle')?.get).toBeTypeOf('function'); + }); + + it('keeps a crypto that is already there', () => { + const subtle = {}; + const getRandomValues = () => undefined; + const target: Record = { crypto: { subtle, getRandomValues } }; + installWebCrypto(target); + expect((target.crypto as { subtle: unknown }).subtle).toBe(subtle); + expect((target.crypto as { getRandomValues: unknown }).getRandomValues).toBe(getRandomValues); + }); +}); diff --git a/src/lib/crypto/__tests__/banner-rows.test.ts b/src/lib/crypto/__tests__/banner-rows.test.ts new file mode 100644 index 00000000..6514982c --- /dev/null +++ b/src/lib/crypto/__tests__/banner-rows.test.ts @@ -0,0 +1,31 @@ +// What the reader's banner says for each PGP / S/MIME outcome (#58). +import { describe, expect, it } from 'vitest'; +import { secureBannerRows } from '../banner-rows'; + +const t = (_k: string, fallback: string, vars?: Record) => + fallback.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? '')); + +describe('secureBannerRows', () => { + it('decrypted and validly signed', () => { + expect(secureBannerRows({ kind: 'pgp', encrypted: true, decrypted: true, signature: 'valid', signerEmail: 'a@example.org' }, t)).toEqual([ + { key: 'dec', tone: 'ok', text: 'Decrypted (OpenPGP)' }, + { key: 'sig', tone: 'ok', text: 'Valid signature (OpenPGP) from a@example.org' }, + ]); + }); + + it('locked, unsupported, no key, failed', () => { + const base = { kind: 'smime' as const, encrypted: true, decrypted: false, signature: 'none' as const }; + expect(secureBannerRows({ ...base, lockedKeyIds: ['x'] }, t)[0]).toMatchObject({ key: 'locked', tone: 'warn' }); + expect(secureBannerRows({ ...base, unsupportedAlgorithm: 'RC2-CBC' }, t)[0].text).toBe('Encrypted with RC2-CBC, which the app cannot decrypt.'); + expect(secureBannerRows({ ...base, noKey: true }, t)[0]).toMatchObject({ key: 'nokey', tone: 'error' }); + expect(secureBannerRows({ ...base, error: 'boom' }, t)[0].text).toBe('Could not decrypt: boom'); + }); + + it('warns about a signer who is not the sender, a self-signed certificate, an invalid or unknown signature', () => { + const rows = secureBannerRows({ kind: 'smime', encrypted: false, decrypted: false, signature: 'valid', signerEmail: 's@example.org', signerMismatch: true, selfSigned: true }, t); + expect(rows.map((r) => [r.key, r.tone])).toEqual([['sig', 'warn'], ['mismatch', 'warn'], ['self', 'muted']]); + expect(secureBannerRows({ kind: 'pgp', encrypted: false, decrypted: false, signature: 'invalid', error: 'bad' }, t)[0].text).toBe('Invalid signature (OpenPGP): bad'); + expect(secureBannerRows({ kind: 'pgp', encrypted: false, decrypted: false, signature: 'unknown' }, t)[0].tone).toBe('warn'); + expect(secureBannerRows({ kind: 'pgp', encrypted: false, decrypted: false, signature: 'none' }, t)).toEqual([]); + }); +}); diff --git a/src/lib/crypto/__tests__/compose-secure.test.ts b/src/lib/crypto/__tests__/compose-secure.test.ts new file mode 100644 index 00000000..dac3936a --- /dev/null +++ b/src/lib/crypto/__tests__/compose-secure.test.ts @@ -0,0 +1,115 @@ +// Signing / encrypting from the composer (#58): recipient keys come from the +// stored keys or a contact card, a missing key stops the send, and the +// uploaded message carries the attachments and the composer's options. +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { prepareSecureSend, resolveRecipientKeys, SecureSendError } from '../compose-secure'; +import { importPgpKey, lockAll } from '../keyring'; +import { openSecureMessage } from '../secure-message'; +import { useCryptoKeysStore } from '../../../stores/crypto-keys-store'; +import type { ContactCard } from '../../../api/types'; +import type { OutgoingEmail } from '../../../api/email'; +import { makePgpKey, useNodeCrypto } from './helpers'; + +const secure = vi.hoisted(() => new Map()); +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6, + setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }), + getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null), + deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }), +})); + +let me: Awaited>; +let friend: Awaited>; + +beforeAll(async () => { + useNodeCrypto(); + me = await makePgpKey('me@example.org'); + friend = await makePgpKey('friend@example.org'); +}, 60_000); + +beforeEach(() => { + secure.clear(); + lockAll(); + useCryptoKeysStore.setState({ keys: [] }); +}); + +const outgoing = (bcc: string[] = []): OutgoingEmail => ({ + from: [{ name: 'Me', email: 'me@example.org' }], + to: [{ email: 'friend@example.org' }], + bcc: bcc.map((email) => ({ email })), + subject: 'Encrypted with attachment', + textBody: 'see attached', + htmlBody: '

see attached

', + attachments: [{ blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 3, disposition: 'attachment' }], + priority: 'high', + requestReadReceipt: true, +}); + +const contactWithKey = (armored: string): ContactCard => ({ + id: 'c1', + emails: { e1: { address: 'Friend@Example.org' } }, + cryptoKeys: { k1: { uri: `data:application/pgp-keys;base64,${Buffer.from(armored).toString('base64')}` } }, +} as unknown as ContactCard); + +describe('recipient keys', () => { + it('finds a key in a contact card when none is stored, and names recipients without one', async () => { + expect(resolveRecipientKeys('pgp', ['friend@example.org']).missing).toEqual(['friend@example.org']); + const r = resolveRecipientKeys('pgp', ['friend@example.org', 'stranger@example.org'], [contactWithKey(friend.publicKey)]); + expect(r.missing).toEqual(['stranger@example.org']); + expect(r.found).toEqual([friend.publicKey]); + }); +}); + +describe('prepareSecureSend', () => { + it('uploads a signed and encrypted PGP/MIME message the recipient can read, attachment and headers included', async () => { + await importPgpKey(me.privateKey, me.passphrase); + await importPgpKey(friend.publicKey); + let uploaded: Uint8Array | null = null; + const blobId = await prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: true }, { + fetchBlob: async () => new Uint8Array([1, 2, 3]), + upload: async (bytes) => { + uploaded = bytes; + return 'raw-blob'; + }, + }); + expect(blobId).toBe('raw-blob'); + const text = new TextDecoder().decode(uploaded!); + expect(text).toMatch(/^X-Priority: 1 \(Highest\)$/m); + expect(text).toMatch(/^Disposition-Notification-To: me@example.org$/m); + expect(text).toMatch(/^From: "Me" $/m); + + const res = await openSecureMessage(uploaded!, 'me@example.org', { + pgpDecryptionKeys: () => [friend.unlocked], + pgpOwnKeys: () => [{ id: 'friend', publicKeyArmored: friend.publicKey, unlocked: true }], + pgpPublicKeys: () => [me.publicKey], + smimeKeys: () => [], + }); + expect(res?.status).toMatchObject({ decrypted: true, signature: 'valid', signerEmail: 'me@example.org' }); + expect(res?.attachments.map((a) => [a.name, Array.from(a.bytes)])).toEqual([['report.pdf', [1, 2, 3]]]); + }, 60_000); + + it('never writes Bcc into the message', async () => { + await importPgpKey(me.privateKey, me.passphrase); + await importPgpKey(friend.publicKey); + let uploaded: Uint8Array | null = null; + // The Bcc recipient needs a key too: give them the friend's for the test. + const contacts = [{ id: 'c2', emails: { e: { address: 'bcc@example.org' } }, cryptoKeys: contactWithKey(friend.publicKey).cryptoKeys } as unknown as ContactCard]; + await prepareSecureSend(outgoing(['bcc@example.org']), { mode: 'pgp', sign: false, encrypt: true }, { + contacts, + fetchBlob: async () => new Uint8Array([1]), + upload: async (bytes) => { uploaded = bytes; return 'x'; }, + }); + expect(new TextDecoder().decode(uploaded!)).not.toMatch(/bcc@example.org/i); + }, 60_000); + + it('stops when a recipient has no key, or when the signing key is locked', async () => { + await importPgpKey(me.privateKey, me.passphrase); + const deps = { fetchBlob: async () => new Uint8Array(), upload: async () => 'x' }; + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: false, encrypt: true }, deps)) + .rejects.toMatchObject({ code: 'missing-recipients', detail: ['friend@example.org'] }); + lockAll(); + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toBeInstanceOf(SecureSendError); + useCryptoKeysStore.setState({ keys: [] }); + await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toMatchObject({ code: 'no-own-key' }); + }, 60_000); +}); diff --git a/src/lib/crypto/__tests__/helpers.ts b/src/lib/crypto/__tests__/helpers.ts new file mode 100644 index 00000000..5aab6027 --- /dev/null +++ b/src/lib/crypto/__tests__/helpers.ts @@ -0,0 +1,116 @@ +// Throwaway keys and certificates for the PGP / S/MIME tests (#58), made +// fresh in each run. Nothing here belongs to a real person. +import * as nodeCrypto from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import * as openpgp from 'openpgp'; +import { setNodeStyleCrypto, type NodeStyleCrypto } from '../provider'; +import { resetSmimeEngine } from '../smime/engine'; + +/** Node's crypto stands in for react-native-quick-crypto. */ +export function useNodeCrypto(): void { + setNodeStyleCrypto(() => nodeCrypto as unknown as NodeStyleCrypto); + resetSmimeEngine(); +} + +export interface TestCert { + email: string; + certificate: Uint8Array; + pkcs8: Uint8Array; + keyPem: string; + certPem: string; +} + +const engine = () => new pkijs.CryptoEngine({ name: 'test', crypto: globalThis.crypto }); + +/** A self-signed RSA-2048 S/MIME certificate for `email`. */ +export async function makeSmimeCert(email: string, opts: { notAfter?: Date } = {}): Promise { + const subtle = globalThis.crypto.subtle; + const keys = await subtle.generateKey( + { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, + true, + ['sign', 'verify'], + ) as CryptoKeyPair; + const cert = new pkijs.Certificate(); + cert.version = 2; + cert.serialNumber = new asn1js.Integer({ value: Math.floor(Math.random() * 1e9) + 1 }); + const name = () => [ + new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: `Test ${email}` }) }), + new pkijs.AttributeTypeAndValue({ type: '1.2.840.113549.1.9.1', value: new asn1js.IA5String({ value: email }) }), + ]; + cert.subject.typesAndValues.push(...name()); + cert.issuer.typesAndValues.push(...name()); + cert.notBefore.value = new Date(Date.now() - 86400_000); + cert.notAfter.value = opts.notAfter ?? new Date(Date.now() + 365 * 86400_000); + // keyUsage: digitalSignature + keyEncipherment; SAN rfc822Name. + const ku = new asn1js.BitString({ valueHex: new Uint8Array([0xa0]).buffer, unusedBits: 5 }); + cert.extensions = [ + new pkijs.Extension({ extnID: '2.5.29.15', critical: true, extnValue: ku.toBER(false), parsedValue: ku }), + new pkijs.Extension({ + extnID: '2.5.29.17', + extnValue: new pkijs.GeneralNames({ names: [new pkijs.GeneralName({ type: 1, value: email })] }).toSchema().toBER(false), + }), + ]; + await cert.subjectPublicKeyInfo.importKey(keys.publicKey, engine()); + await cert.sign(keys.privateKey, 'SHA-256', engine()); + const der = new Uint8Array(cert.toSchema().toBER(false)); + const pkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', keys.privateKey)); + const pem = (label: string, bytes: Uint8Array) => + `-----BEGIN ${label}-----\n${Buffer.from(bytes).toString('base64').replace(/(.{64})/g, '$1\n').trim()}\n-----END ${label}-----\n`; + return { email, certificate: der, pkcs8, keyPem: pem('PRIVATE KEY', pkcs8), certPem: pem('CERTIFICATE', der) }; +} + +/** A fresh curve25519 PGP key pair for `email`, protected with `passphrase`. */ +export async function makePgpKey(email: string, passphrase = 'secret', type: 'ecc' | 'rsa' = 'ecc') { + const { privateKey, publicKey } = await openpgp.generateKey({ + ...(type === 'ecc' ? { type: 'ecc' as const, curve: 'curve25519Legacy' as const } : { type: 'rsa' as const, rsaBits: 2048 }), + userIDs: [{ name: `Test ${email}`, email }], + passphrase, + format: 'armored', + }); + const unlocked = await openpgp.decryptKey({ privateKey: await openpgp.readPrivateKey({ armoredKey: privateKey }), passphrase }); + return { privateKey, publicKey, unlocked, passphrase, email }; +} + +let opensslChecked: boolean | null = null; +/** Is the openssl CLI there (interop fixtures)? */ +export function hasOpenssl(): boolean { + if (opensslChecked === null) { + try { + execFileSync('openssl', ['version'], { stdio: 'pipe' }); + opensslChecked = true; + } catch { + opensslChecked = false; + } + } + return opensslChecked; +} + +/** Run openssl in a scratch dir with the given input files; returns the named output file. */ +export function openssl(args: string[], files: Record, output: string): Uint8Array { + const dir = mkdtempSync(path.join(tmpdir(), 'bulwark-smime-')); + try { + for (const [name, content] of Object.entries(files)) writeFileSync(path.join(dir, name), content); + execFileSync('openssl', args, { cwd: dir, stdio: 'pipe', env: { ...process.env, MSYS_NO_PATHCONV: '1' } }); + return new Uint8Array(readFileSync(path.join(dir, output))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +export const CRLF_MESSAGE = 'Content-Type: text/plain; charset=utf-8\r\n\r\nHello from the S/MIME test fixture.\r\n'; + +/** Wrap CMS DER as an S/MIME message the way a client sends it. */ +export function smimeMessage(cms: Uint8Array, smimeType: string, from = 'sender@example.org'): Uint8Array { + const b64 = Buffer.from(cms).toString('base64').replace(/(.{76})/g, '$1\r\n'); + return new TextEncoder().encode( + `From: ${from}\r\nTo: rcpt@example.org\r\nSubject: test\r\nMIME-Version: 1.0\r\n` + + `Content-Type: application/pkcs7-mime; smime-type=${smimeType}; name="smime.p7m"\r\n` + + 'Content-Transfer-Encoding: base64\r\n\r\n' + + `${b64}\r\n`, + ); +} diff --git a/src/lib/crypto/__tests__/keyring.test.ts b/src/lib/crypto/__tests__/keyring.test.ts new file mode 100644 index 00000000..b75ff5ce --- /dev/null +++ b/src/lib/crypto/__tests__/keyring.test.ts @@ -0,0 +1,111 @@ +// Key management (#58): private keys go to SecureStore only, in pieces +// SecureStore can hold; public keys and certificates are listed; unlocking +// checks the passphrase and lasts for the session. +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { setSecret, getSecret, deleteSecret } from '../secret-store'; +import { + importPgpKey, importPkcs12File, importCertificate, deleteKey, exportPublic, unlock, lockAll, isUnlocked, needsPassphrase, ownKeyFor, +} from '../keyring'; +import { useCryptoKeysStore } from '../../../stores/crypto-keys-store'; +import { PgpWrongPassphraseError } from '../pgp'; +import { hasOpenssl, makePgpKey, makeSmimeCert, openssl, useNodeCrypto } from './helpers'; + +const secure = vi.hoisted(() => new Map()); + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6, + setItemAsync: vi.fn(async (k: string, v: string) => { + if (v.length > 2048) throw new Error('value too large for SecureStore'); + secure.set(k, v); + }), + getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null), + deleteItemAsync: vi.fn(async (k: string) => { + secure.delete(k); + }), +})); + +beforeAll(() => { + useNodeCrypto(); +}); + +beforeEach(() => { + secure.clear(); + lockAll(); + useCryptoKeysStore.setState({ keys: [] }); +}); + +describe('secret store', () => { + it('splits a large secret over items SecureStore can hold and reads it back whole', async () => { + const big = 'x'.repeat(7000) + 'end'; + await setSecret('pgp.abc', big); + expect([...secure.values()].every((v) => v.length <= 2048)).toBe(true); + expect(secure.size).toBeGreaterThan(3); + expect(await getSecret('pgp.abc')).toBe(big); + await deleteSecret('pgp.abc'); + expect(secure.size).toBe(0); + expect(await getSecret('pgp.abc')).toBeNull(); + }); +}); + +describe('PGP keys', () => { + it('imports a private key into SecureStore only, checks its passphrase, and unlocks it again later', async () => { + const k = await makePgpKey('me@example.org', 'correct horse', 'rsa'); + await expect(importPgpKey(k.privateKey, 'wrong')).rejects.toBeInstanceOf(PgpWrongPassphraseError); + expect(useCryptoKeysStore.getState().keys).toHaveLength(0); + + const record = await importPgpKey(k.privateKey, 'correct horse'); + expect(record).toMatchObject({ kind: 'pgp', hasPrivate: true, emails: ['me@example.org'] }); + expect([...secure.values()].join('')).toContain('BEGIN PGP PRIVATE KEY BLOCK'); + // Nothing private in what AsyncStorage gets. + expect(JSON.stringify(useCryptoKeysStore.getState().keys)).not.toContain('PRIVATE KEY'); + await vi.waitFor(async () => expect(await AsyncStorage.getItem('crypto-keys')).toContain('PUBLIC KEY'), { timeout: 3000 }); + expect(await AsyncStorage.getItem('crypto-keys')).not.toContain('PRIVATE KEY'); + + expect(isUnlocked(record)).toBe(true); + lockAll(); + expect(isUnlocked(record)).toBe(false); + expect(await needsPassphrase(record)).toBe(true); + await expect(unlock(record, 'nope')).rejects.toBeInstanceOf(PgpWrongPassphraseError); + await unlock(record, 'correct horse'); + expect(isUnlocked(record)).toBe(true); + expect(ownKeyFor('pgp', 'ME@example.org')?.id).toBe(record.id); + expect(await exportPublic(record)).toContain('BEGIN PGP PUBLIC KEY BLOCK'); + + await deleteKey(record); + expect(secure.size).toBe(0); + expect(useCryptoKeysStore.getState().keys).toHaveLength(0); + }, 60_000); + + it('a public import of a key we hold privately keeps the private flag', async () => { + const k = await makePgpKey('me@example.org'); + await importPgpKey(k.privateKey, k.passphrase); + await importPgpKey(k.publicKey); + expect(useCryptoKeysStore.getState().keys).toHaveLength(1); + expect(useCryptoKeysStore.getState().keys[0].hasPrivate).toBe(true); + }, 30_000); +}); + +describe('S/MIME keys', () => { + it('imports a certificate as a recipient certificate', async () => { + const c = await makeSmimeCert('them@example.org'); + const record = await importCertificate(new TextEncoder().encode(c.certPem)); + expect(record).toMatchObject({ kind: 'smime', hasPrivate: false, emails: ['them@example.org'], canEncrypt: true }); + expect(await exportPublic(record)).toContain('BEGIN CERTIFICATE'); + }, 30_000); + + it.skipIf(!hasOpenssl())('imports a .p12 into SecureStore and needs its password after locking', async () => { + const c = await makeSmimeCert('me@example.org'); + const p12 = openssl(['pkcs12', '-export', '-in', 'cert.pem', '-inkey', 'key.pem', '-out', 'out.p12', '-passout', 'pass:pw'], + { 'cert.pem': c.certPem, 'key.pem': c.keyPem }, 'out.p12'); + const record = await importPkcs12File(p12, 'pw'); + expect(record).toMatchObject({ kind: 'smime', hasPrivate: true, emails: ['me@example.org'] }); + expect(secure.size).toBeGreaterThan(1); + expect(JSON.stringify(useCryptoKeysStore.getState().keys)).not.toContain(Buffer.from(p12).toString('base64').slice(0, 40)); + lockAll(); + expect(await needsPassphrase(record)).toBe(true); + await expect(unlock(record, 'bad')).rejects.toThrow(/password/i); + await unlock(record, 'pw'); + expect(isUnlocked(record)).toBe(true); + }, 60_000); +}); diff --git a/src/lib/crypto/__tests__/secure-message.test.ts b/src/lib/crypto/__tests__/secure-message.test.ts new file mode 100644 index 00000000..8ccf107c --- /dev/null +++ b/src/lib/crypto/__tests__/secure-message.test.ts @@ -0,0 +1,213 @@ +// Reading and sending PGP and S/MIME mail (#58): what the app builds for a +// send is read back by the reader, signatures verify (and fail when the +// message is changed), locked and missing keys are reported. +import { beforeAll, describe, expect, it } from 'vitest'; +import * as openpgp from 'openpgp'; +import { buildSecureMessage } from '../secure-send'; +import { openSecureMessage, secureKindHint, type SecureKeyAccess } from '../secure-message'; +import type { MimeMessageInput } from '../mime'; +import { makePgpKey, makeSmimeCert, useNodeCrypto, type TestCert } from './helpers'; + +beforeAll(() => { + useNodeCrypto(); +}); + +const input = (from: string, to: string): MimeMessageInput => ({ + from: { name: 'Sender Name', email: from }, + to: [{ email: to }], + subject: 'Grüße aus dem Test', + textBody: 'Hallo Welt\nzweite Zeile mit Umlaut: äöü', + htmlBody: '

Hallo Welt

', + attachments: [{ filename: 'notes.txt', contentType: 'text/plain', content: new TextEncoder().encode('attached bytes') }], +}); + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); +const encode = (s: string) => new TextEncoder().encode(s); + +function pgpAccess(own: Array<{ id: string; publicKey: string; unlocked?: openpgp.PrivateKey }>, publics: string[]): SecureKeyAccess { + return { + pgpDecryptionKeys: () => own.flatMap((k) => (k.unlocked ? [k.unlocked] : [])), + pgpOwnKeys: () => own.map((k) => ({ id: k.id, publicKeyArmored: k.publicKey, unlocked: !!k.unlocked })), + pgpPublicKeys: () => publics, + smimeKeys: () => [], + }; +} + +describe('PGP/MIME', () => { + let alice: Awaited>; + let bob: Awaited>; + + beforeAll(async () => { + alice = await makePgpKey('alice@example.org'); + bob = await makePgpKey('bob@example.org', 'bobs pass', 'rsa'); + }, 60_000); + + it('a signed and encrypted message decrypts for the recipient with a valid signature', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'pgp', sign: true, encrypt: true }, { + pgp: { signingKey: alice.unlocked, encryptionKeys: [bob.publicKey, alice.publicKey] }, + }); + const text = decode(raw); + expect(text).toMatch(/Content-Type: multipart\/encrypted; protocol="application\/pgp-encrypted";/); + expect(text).not.toContain('Hallo Welt'); + expect(text).not.toMatch(/^Bcc:/m); + + const res = await openSecureMessage(raw, alice.email, pgpAccess([{ id: 'bob', publicKey: bob.publicKey, unlocked: bob.unlocked }], [alice.publicKey])); + expect(res?.status).toMatchObject({ kind: 'pgp', encrypted: true, decrypted: true, signature: 'valid', signerEmail: alice.email }); + expect(res?.body?.html).toContain('Welt'); + expect(res?.body?.text).toContain('äöü'); + expect(res?.attachments.map((a) => [a.name, decode(a.bytes)])).toEqual([['notes.txt', 'attached bytes']]); + }, 60_000); + + it('the sender can read their own Sent copy (encrypt-to-self)', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'pgp', sign: false, encrypt: true }, { + pgp: { encryptionKeys: [bob.publicKey, alice.publicKey] }, + }); + const res = await openSecureMessage(raw, alice.email, pgpAccess([{ id: 'alice', publicKey: alice.publicKey, unlocked: alice.unlocked }], [])); + expect(res?.status).toMatchObject({ decrypted: true, signature: 'none' }); + }, 30_000); + + it('asks to unlock when the key it is encrypted to is locked, and says so when no key fits', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'pgp', sign: false, encrypt: true }, { + pgp: { encryptionKeys: [bob.publicKey] }, + }); + const locked = await openSecureMessage(raw, alice.email, pgpAccess([{ id: 'bob', publicKey: bob.publicKey }], [])); + expect(locked?.status).toMatchObject({ encrypted: true, decrypted: false, lockedKeyIds: ['bob'] }); + expect(locked?.body).toBeNull(); + + const none = await openSecureMessage(raw, alice.email, pgpAccess([{ id: 'alice', publicKey: alice.publicKey, unlocked: alice.unlocked }], [])); + expect(none?.status).toMatchObject({ encrypted: true, decrypted: false, noKey: true }); + }, 30_000); + + it('a signed message verifies, and fails once a byte of the signed part changes', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'pgp', sign: true, encrypt: false }, { + pgp: { signingKey: alice.unlocked }, + }); + const text = decode(raw); + expect(text).toMatch(/multipart\/signed; micalg="pgp-sha256"; protocol="application\/pgp-signature";/); + const access = pgpAccess([], [alice.publicKey]); + const ok = await openSecureMessage(raw, alice.email, access); + expect(ok?.status).toMatchObject({ signature: 'valid', signerEmail: alice.email, encrypted: false }); + expect(ok?.body?.text).toContain('Hallo Welt'); + + const tampered = encode(text.replace('Hallo Welt', 'Hallo Wels')); + const bad = await openSecureMessage(tampered, alice.email, access); + expect(bad?.status.signature).toBe('invalid'); + + const unknown = await openSecureMessage(raw, alice.email, pgpAccess([], [])); + expect(unknown?.status.signature).toBe('unknown'); + }, 30_000); + + it('flags a signature by a key whose address is not the From address', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'pgp', sign: true, encrypt: false }, { + pgp: { signingKey: alice.unlocked }, + }); + const res = await openSecureMessage(raw, 'mallory@example.org', pgpAccess([], [alice.publicKey])); + expect(res?.status).toMatchObject({ signature: 'valid', signerMismatch: true }); + }, 30_000); + + it('reads inline PGP: an encrypted body and a cleartext-signed one, even quoted-printable', async () => { + const armored = await openpgp.encrypt({ message: await openpgp.createMessage({ text: 'inline secret' }), encryptionKeys: await openpgp.readKey({ armoredKey: bob.publicKey }) }); + const inlineEnc = encode(`From: ${alice.email}\r\nSubject: x\r\nContent-Type: text/plain\r\n\r\n${armored}`); + const enc = await openSecureMessage(inlineEnc, alice.email, pgpAccess([{ id: 'bob', publicKey: bob.publicKey, unlocked: bob.unlocked }], [])); + expect(enc?.status).toMatchObject({ decrypted: true }); + expect(enc?.body?.text).toBe('inline secret'); + + const signed = await openpgp.sign({ message: await openpgp.createCleartextMessage({ text: 'signed in the clear' }), signingKeys: alice.unlocked }); + const qp = (signed as string).replace(/=/g, '=3D').replace(/\r?\n/g, '\r\n'); + const inlineSigned = encode(`From: ${alice.email}\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n${qp}`); + const sig = await openSecureMessage(inlineSigned, alice.email, pgpAccess([], [alice.publicKey])); + expect(sig?.status).toMatchObject({ signature: 'valid', signerEmail: alice.email }); + expect(sig?.body?.text).toBe('signed in the clear'); + }, 30_000); + + it('leaves ordinary mail alone', async () => { + const plain = encode('From: a@example.org\r\nContent-Type: text/plain\r\n\r\nnothing to see'); + expect(await openSecureMessage(plain, 'a@example.org', pgpAccess([], []))).toBeNull(); + }); +}); + +describe('S/MIME', () => { + let alice: TestCert; + let bob: TestCert; + + function smimeAccess(keys: Array<{ cert: TestCert; unlocked: boolean }>): SecureKeyAccess { + return { + pgpDecryptionKeys: () => [], + pgpOwnKeys: () => [], + pgpPublicKeys: () => [], + smimeKeys: () => keys.map((k) => ({ id: k.cert.email, certificate: k.cert.certificate, ...(k.unlocked ? { pkcs8: k.cert.pkcs8 } : {}) })), + }; + } + + beforeAll(async () => { + alice = await makeSmimeCert('alice@example.org'); + bob = await makeSmimeCert('bob@example.org'); + }, 60_000); + + const signer = (c: TestCert) => ({ pkcs8: c.pkcs8, certificate: c.certificate, chain: [], algorithm: 'RSA-2048' }); + + it('a detached-signed message verifies and names the signer; a changed byte fails it', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'smime', sign: true, encrypt: false }, { smime: { signer: signer(alice) } }); + const text = decode(raw); + expect(text).toMatch(/multipart\/signed; protocol="application\/pkcs7-signature";/); + const ok = await openSecureMessage(raw, alice.email, smimeAccess([])); + expect(ok?.status).toMatchObject({ kind: 'smime', signature: 'valid', signerEmail: alice.email, selfSigned: true, encrypted: false }); + expect(ok?.body?.html).toContain('Welt'); + + const bad = await openSecureMessage(encode(text.replace('Hallo Welt', 'Hallo Wels')), alice.email, smimeAccess([])); + expect(bad?.status.signature).toBe('invalid'); + expect(bad?.status.error).toMatch(/digest|does not match/i); + }, 30_000); + + it('a signed and encrypted message decrypts for the recipient and verifies the inner signature', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'smime', sign: true, encrypt: true }, { + smime: { signer: signer(alice), recipientCerts: [bob.certificate], senderCert: alice.certificate }, + }); + const text = decode(raw); + expect(text).toMatch(/application\/pkcs7-mime; smime-type=enveloped-data/); + expect(text).not.toContain('Hallo'); + + const res = await openSecureMessage(raw, alice.email, smimeAccess([{ cert: bob, unlocked: true }])); + expect(res?.status).toMatchObject({ encrypted: true, decrypted: true, signature: 'valid', signerEmail: alice.email }); + expect(res?.body?.text).toContain('äöü'); + expect(res?.attachments.map((a) => a.name)).toEqual(['notes.txt']); + + // The sender's own copy. + const own = await openSecureMessage(raw, alice.email, smimeAccess([{ cert: alice, unlocked: true }])); + expect(own?.status.decrypted).toBe(true); + }, 30_000); + + it('reports a locked key and a message no key of ours can open', async () => { + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'smime', sign: false, encrypt: true }, { + smime: { recipientCerts: [bob.certificate], senderCert: bob.certificate }, + }); + const locked = await openSecureMessage(raw, alice.email, smimeAccess([{ cert: bob, unlocked: false }])); + expect(locked?.status).toMatchObject({ encrypted: true, decrypted: false, lockedKeyIds: [bob.email] }); + const none = await openSecureMessage(raw, alice.email, smimeAccess([{ cert: alice, unlocked: true }])); + expect(none?.status).toMatchObject({ noKey: true, decrypted: false }); + }, 30_000); + + it('writes AES-256-CBC EnvelopedData, which other clients read', async () => { + const { parseEntity, bytesToBinary, decodeBody } = await import('../mime'); + const pkijs = await import('pkijs'); + const asn1js = await import('asn1js'); + const raw = await buildSecureMessage(input(alice.email, bob.email), { mode: 'smime', sign: false, encrypt: true }, { + smime: { recipientCerts: [bob.certificate], senderCert: alice.certificate }, + }); + const der = decodeBody(parseEntity(bytesToBinary(raw))); + const ci = new pkijs.ContentInfo({ schema: asn1js.fromBER(der.slice().buffer).result }); + const env = new pkijs.EnvelopedData({ schema: ci.content }); + expect(env.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId).toBe('2.16.840.1.101.3.4.1.42'); + expect(env.recipientInfos).toHaveLength(2); + }, 30_000); +}); + +describe('secureKindHint', () => { + it('spots PGP and S/MIME from the structure or the text, without a download', () => { + expect(secureKindHint({ bodyStructure: { type: 'multipart/encrypted', subParts: [{ type: 'application/pgp-encrypted' }] } as never })).toBe('pgp'); + expect(secureKindHint({ bodyStructure: { type: 'multipart/signed', subParts: [{ type: 'text/plain' }, { type: 'application/pkcs7-signature' }] } as never })).toBe('smime'); + expect(secureKindHint({ bodyStructure: { type: 'application/pkcs7-mime' } as never })).toBe('smime'); + expect(secureKindHint({ preview: '-----BEGIN PGP MESSAGE----- hQEM' } as never)).toBe('pgp'); + expect(secureKindHint({ bodyStructure: { type: 'text/plain' } as never, preview: 'hello' } as never)).toBeNull(); + }); +}); diff --git a/src/lib/crypto/__tests__/self-test.test.ts b/src/lib/crypto/__tests__/self-test.test.ts new file mode 100644 index 00000000..408dac6c --- /dev/null +++ b/src/lib/crypto/__tests__/self-test.test.ts @@ -0,0 +1,44 @@ +// The encryption self-test (#58) passes on a complete crypto and names the +// check that fails on one with a gap. +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { runCryptoSelfTest } from '../self-test'; +import { useNodeCrypto } from './helpers'; +import { setNodeStyleCrypto } from '../provider'; + +afterEach(() => { + vi.unstubAllGlobals(); + useNodeCrypto(); +}); + +describe('runCryptoSelfTest', () => { + it('passes every check on a complete crypto', async () => { + useNodeCrypto(); + const results = await runCryptoSelfTest(); + expect(results.filter((r) => !r.ok)).toEqual([]); + expect(results.length).toBeGreaterThan(10); + }, 60_000); + + it('reports the checks a missing algorithm breaks, and keeps going', async () => { + useNodeCrypto(); + const real = globalThis.crypto; + const subtle = new Proxy(real.subtle, { + get(target, prop) { + if (prop === 'generateKey') { + return (alg: AlgorithmIdentifier, ...rest: unknown[]) => { + if ((typeof alg === 'string' ? alg : alg.name) === 'Ed25519') return Promise.reject(new Error('Ed25519 not supported')); + return (target.generateKey as (...a: unknown[]) => unknown).call(target, alg, ...rest); + }; + } + const v = Reflect.get(target, prop); + return typeof v === 'function' ? v.bind(target) : v; + }, + }); + vi.stubGlobal('crypto', { subtle, getRandomValues: real.getRandomValues.bind(real) }); + setNodeStyleCrypto(() => { throw new Error('no native module'); }); + const results = await runCryptoSelfTest(); + const failed = results.filter((r) => !r.ok).map((r) => r.name); + expect(failed).toContain('Ed25519 / X25519'); + expect(failed).toContain('3DES / ChaCha20-Poly1305 (legacy and Stalwart at-rest mail)'); + expect(results.find((r) => r.name === 'AES-GCM')?.ok).toBe(true); + }, 60_000); +}); diff --git a/src/lib/crypto/__tests__/smime-interop.test.ts b/src/lib/crypto/__tests__/smime-interop.test.ts new file mode 100644 index 00000000..c8489701 --- /dev/null +++ b/src/lib/crypto/__tests__/smime-interop.test.ts @@ -0,0 +1,186 @@ +// S/MIME against another implementation (#58): OpenSSL writes the mail +// other clients send (Thunderbird and Outlook use RSA PKCS#1 v1.5 key +// transport, old ones 3DES; Stalwart's encryption at rest writes +// AuthEnvelopedData) and the .p12 files people export, and it must read +// what the app sends. Needs the openssl CLI (skipped without it). The +// ChaCha20-Poly1305 message, which OpenSSL can't write, is built here the +// way Stalwart builds it (port of the smime plugin's test helper). +import { beforeAll, describe, expect, it } from 'vitest'; +import * as nodeCrypto from 'node:crypto'; +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { openPkcs12, Pkcs12PasswordError } from '../smime/pkcs12'; +import { smimeDecrypt, SmimeKeyLockedError, UnsupportedAlgorithmError } from '../smime/cms'; +import { openSecureMessage, type SecureKeyAccess } from '../secure-message'; +import { buildSecureMessage } from '../secure-send'; +import { CRLF_MESSAGE, hasOpenssl, makeSmimeCert, openssl, smimeMessage, useNodeCrypto, type TestCert } from './helpers'; + +const HAVE = hasOpenssl(); +const PLAINTEXT = 'Hello from the S/MIME test fixture.'; +const decode = (b: Uint8Array) => new TextDecoder().decode(b); + +let cert: TestCert; +const material = (unlocked = true) => [{ id: 'me', certificate: cert.certificate, ...(unlocked ? { pkcs8: cert.pkcs8 } : {}) }]; + +beforeAll(async () => { + useNodeCrypto(); + cert = await makeSmimeCert('smime-test@example.org'); +}, 60_000); + +function encryptWithOpenssl(args: string[]): Uint8Array { + return openssl(['cms', '-encrypt', '-binary', '-outform', 'DER', '-in', 'plain.eml', '-out', 'out.p7m', ...args], { 'plain.eml': CRLF_MESSAGE, 'cert.pem': cert.certPem }, 'out.p7m'); +} + +describe.skipIf(!HAVE)('decrypting what OpenSSL writes', () => { + it('AES-256-CBC with the key wrapped by RSA PKCS#1 v1.5 (Thunderbird, Outlook, Stalwart AES-CBC)', async () => { + const cms = encryptWithOpenssl(['-aes256', 'cert.pem']); + expect(decode((await smimeDecrypt(cms, material())).mimeBytes)).toContain(PLAINTEXT); + }); + + it('3DES (old Outlook)', async () => { + const cms = encryptWithOpenssl(['-des3', 'cert.pem']); + expect(decode((await smimeDecrypt(cms, material())).mimeBytes)).toContain(PLAINTEXT); + }); + + it('RSA-OAEP with SHA-1 in EnvelopedData (the plugin read it with SHA-256 and failed)', async () => { + const cms = encryptWithOpenssl(['-aes128', '-recip', 'cert.pem', '-keyopt', 'rsa_padding_mode:oaep']); + expect(decode((await smimeDecrypt(cms, material())).mimeBytes)).toContain(PLAINTEXT); + }); + + it('AuthEnvelopedData with AES-256-GCM and RSA-OAEP SHA-256 (Stalwart AES-256-GCM at rest)', async () => { + const cms = encryptWithOpenssl(['-aes-256-gcm', '-recip', 'cert.pem', '-keyopt', 'rsa_padding_mode:oaep', '-keyopt', 'rsa_oaep_md:sha256', '-keyopt', 'rsa_mgf1_md:sha256']); + const res = await openSecureMessage(smimeMessage(cms, 'authEnveloped-data'), undefined, access(material())); + expect(res?.status).toMatchObject({ encrypted: true, decrypted: true }); + expect(res?.body?.text).toContain(PLAINTEXT); + }); + + it('says the key is locked rather than failing', async () => { + const cms = encryptWithOpenssl(['-aes256', 'cert.pem']); + await expect(smimeDecrypt(cms, material(false))).rejects.toBeInstanceOf(SmimeKeyLockedError); + }); + + it('names RC2, which it cannot read', async () => { + let cms: Uint8Array; + try { + cms = encryptWithOpenssl(['-rc2-128', '-provider', 'legacy', '-provider', 'default', 'cert.pem']); + } catch { + return; // no legacy provider in this OpenSSL + } + await expect(smimeDecrypt(cms, material())).rejects.toMatchObject({ name: 'UnsupportedAlgorithmError', algorithm: 'RC2-CBC' }); + }); +}); + +describe.skipIf(!HAVE)('opening .p12 files', () => { + const files = () => ({ 'cert.pem': cert.certPem, 'key.pem': cert.keyPem }); + + it('reads an OpenSSL 3 default export (PBES2 / AES-256-CBC)', async () => { + const p12 = openssl(['pkcs12', '-export', '-in', 'cert.pem', '-inkey', 'key.pem', '-out', 'out.p12', '-passout', 'pass:fixture'], files(), 'out.p12'); + const opened = await openPkcs12(p12, 'fixture'); + expect(opened.info.emailAddresses).toEqual(['smime-test@example.org']); + expect(opened.info.capabilities).toEqual({ canSign: true, canEncrypt: true }); + expect(Buffer.from(opened.certificate).equals(Buffer.from(cert.certificate))).toBe(true); + // The key it returns decrypts mail to the certificate. + const cms = encryptWithOpenssl(['-aes256', 'cert.pem']); + const res = await smimeDecrypt(cms, [{ id: 'me', certificate: opened.certificate, pkcs8: opened.pkcs8 }]); + expect(decode(res.mimeBytes)).toContain(PLAINTEXT); + }, 30_000); + + it('reads a legacy SHA-1 + 3DES export (Windows, macOS, OpenSSL 1.x)', async () => { + const p12 = openssl(['pkcs12', '-export', '-in', 'cert.pem', '-inkey', 'key.pem', '-out', 'out.p12', '-passout', 'pass:fixture', + '-keypbe', 'PBE-SHA1-3DES', '-certpbe', 'PBE-SHA1-3DES', '-macalg', 'sha1'], files(), 'out.p12'); + const opened = await openPkcs12(p12, 'fixture'); + expect(opened.info.emailAddresses).toEqual(['smime-test@example.org']); + expect(opened.pkcs8.byteLength).toBeGreaterThan(1000); + }, 30_000); + + it('refuses a wrong password', async () => { + const p12 = openssl(['pkcs12', '-export', '-in', 'cert.pem', '-inkey', 'key.pem', '-out', 'out.p12', '-passout', 'pass:fixture'], files(), 'out.p12'); + await expect(openPkcs12(p12, 'wrong')).rejects.toBeInstanceOf(Pkcs12PasswordError); + }, 30_000); +}); + +describe.skipIf(!HAVE)('OpenSSL reads what the app sends', () => { + let rcpt: TestCert; + beforeAll(async () => { + rcpt = await makeSmimeCert('rcpt@example.org'); + }, 60_000); + + const input = () => ({ from: { email: cert.email }, to: [{ email: rcpt.email }], subject: 'interop', textBody: 'interop body' }); + + it('verifies the detached signature', async () => { + const raw = await buildSecureMessage(input(), { mode: 'smime', sign: true, encrypt: false }, { + smime: { signer: { pkcs8: cert.pkcs8, certificate: cert.certificate, chain: [], algorithm: 'RSA-2048' } }, + }); + const out = openssl(['cms', '-verify', '-noverify', '-in', 'msg.eml', '-out', 'out.txt'], { 'msg.eml': raw }, 'out.txt'); + expect(decode(out)).toContain('interop body'); + }); + + it('decrypts the encrypted message and verifies the signature inside', async () => { + const raw = await buildSecureMessage(input(), { mode: 'smime', sign: true, encrypt: true }, { + smime: { signer: { pkcs8: cert.pkcs8, certificate: cert.certificate, chain: [], algorithm: 'RSA-2048' }, recipientCerts: [rcpt.certificate], senderCert: cert.certificate }, + }); + const inner = openssl(['cms', '-decrypt', '-in', 'msg.eml', '-recip', 'rcpt.pem', '-inkey', 'rcpt.key', '-out', 'inner.eml'], + { 'msg.eml': raw, 'rcpt.pem': rcpt.certPem, 'rcpt.key': rcpt.keyPem }, 'inner.eml'); + const out = openssl(['cms', '-verify', '-noverify', '-in', 'inner.eml', '-out', 'out.txt'], { 'inner.eml': inner }, 'out.txt'); + expect(decode(out)).toContain('interop body'); + }); +}); + +// ── ChaCha20-Poly1305 AuthEnvelopedData, built the way Stalwart does ── + +function access(smime: Array<{ id: string; certificate: Uint8Array; pkcs8?: Uint8Array }>): SecureKeyAccess { + return { pgpDecryptionKeys: () => [], pgpOwnKeys: () => [], pgpPublicKeys: () => [], smimeKeys: () => smime }; +} + +async function chachaAuthEnveloped(recipient: TestCert, plaintext: Uint8Array, tamper = false): Promise { + const oid = (value: string) => new asn1js.ObjectIdentifier({ value }); + const alg = (id: string, params?: asn1js.BaseBlock) => new asn1js.Sequence({ value: [oid(id), ...(params ? [params] : [])] }); + const explicit = (tagNumber: number, value: asn1js.BaseBlock) => new asn1js.Constructed({ idBlock: { tagClass: 3, tagNumber }, value: [value] }); + const key = nodeCrypto.randomBytes(32); + const nonce = nodeCrypto.randomBytes(12); + const c = nodeCrypto.createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 }); + const ciphertext = Buffer.concat([c.update(plaintext), c.final()]); + const mac = c.getAuthTag(); + if (tamper) mac[0] ^= 1; + const rcptCert = new pkijs.Certificate({ schema: asn1js.fromBER(recipient.certificate.slice().buffer).result }); + const pub = nodeCrypto.createPublicKey({ key: Buffer.from(recipient.certPem), format: 'pem' }); + const wrapped = nodeCrypto.publicEncrypt({ key: pub, padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, key); + const sha256 = alg('2.16.840.1.101.3.4.2.1', new asn1js.Null()); + const ktri = new asn1js.Sequence({ + value: [ + new asn1js.Integer({ value: 0 }), + new asn1js.Sequence({ value: [rcptCert.issuer.toSchema(), rcptCert.serialNumber] }), + alg('1.2.840.113549.1.1.7', new asn1js.Sequence({ value: [explicit(0, sha256), explicit(1, alg('1.2.840.113549.1.1.8', sha256))] })), + new asn1js.OctetString({ valueHex: wrapped }), + ], + }); + const eci = new asn1js.Sequence({ + value: [ + oid('1.2.840.113549.1.7.1'), + alg('1.2.840.113549.1.9.16.3.18', new asn1js.OctetString({ valueHex: nonce })), + new asn1js.Primitive({ idBlock: { tagClass: 3, tagNumber: 0 }, valueHex: ciphertext }), + ], + }); + const aed = new asn1js.Sequence({ value: [new asn1js.Integer({ value: 0 }), new asn1js.Set({ value: [ktri] }), eci, new asn1js.OctetString({ valueHex: mac })] }); + return new Uint8Array(new asn1js.Sequence({ value: [oid('1.2.840.113549.1.9.16.1.23'), explicit(0, aed)] }).toBER(false)); +} + +describe('ChaCha20-Poly1305 (Stalwart encryption at rest)', () => { + it('decrypts, and refuses a message whose tag was changed', async () => { + const plaintext = new TextEncoder().encode(CRLF_MESSAGE); + const ok = await smimeDecrypt(await chachaAuthEnveloped(cert, plaintext), material()); + expect(decode(ok.mimeBytes)).toContain(PLAINTEXT); + await expect(smimeDecrypt(await chachaAuthEnveloped(cert, plaintext, true), material())).rejects.toThrow(/Failed to decrypt/); + }, 30_000); + + it('reads Stalwart\'s smime-type spelling in a full message', async () => { + const cms = await chachaAuthEnveloped(cert, new TextEncoder().encode(CRLF_MESSAGE)); + const res = await openSecureMessage(smimeMessage(cms, 'authenticated-enveloped-data'), undefined, access(material())); + expect(res?.status).toMatchObject({ encrypted: true, decrypted: true }); + expect(res?.body?.text).toContain(PLAINTEXT); + }, 30_000); + + it('is not mistaken for an unsupported algorithm', () => { + expect(new UnsupportedAlgorithmError('X').algorithm).toBe('X'); + }); +}); diff --git a/src/lib/crypto/banner-rows.ts b/src/lib/crypto/banner-rows.ts new file mode 100644 index 00000000..d1d36171 --- /dev/null +++ b/src/lib/crypto/banner-rows.ts @@ -0,0 +1,51 @@ +// Banner rows for a PGP / S/MIME status (#58). +import type { SecureStatus } from './secure-message'; + +export type BannerTone = 'ok' | 'warn' | 'error' | 'muted'; +type Translate = (key: string, fallback: string, vars?: Record) => string; + +export interface BannerRow { + key: string; + tone: BannerTone; + text: string; +} + +/** + * The banner rows for a PGP / S/MIME status, as the plugins' email-banner + * slots show them (repos/plugins/bulwark-gpg/src/index.js EmailBanner, + * repos/plugins/smime/src/ui.js bannerRows). + */ +export function secureBannerRows(status: SecureStatus, t: Translate): BannerRow[] { + const rows: BannerRow[] = []; + const kind = status.kind === 'pgp' ? 'OpenPGP' : 'S/MIME'; + if (status.encrypted) { + if (status.decrypted) rows.push({ key: 'dec', tone: 'ok', text: t('encryption.banner.decrypted', 'Decrypted ({kind})', { kind }) }); + else if (status.lockedKeyIds?.length) rows.push({ key: 'locked', tone: 'warn', text: t('encryption.banner.locked', 'Encrypted ({kind}). Unlock your key to read it.', { kind }) }); + else if (status.unsupportedAlgorithm) rows.push({ key: 'alg', tone: 'error', text: t('encryption.banner.unsupported', 'Encrypted with {algorithm}, which the app cannot decrypt.', { algorithm: status.unsupportedAlgorithm }) }); + else if (status.noKey) rows.push({ key: 'nokey', tone: 'error', text: t('encryption.banner.no_key', 'Encrypted ({kind}) to a key that is not on this device.', { kind }) }); + else rows.push({ key: 'fail', tone: 'error', text: t('encryption.banner.failed', 'Could not decrypt: {reason}', { reason: status.error ?? '' }) }); + } + if (status.signature === 'valid') { + rows.push({ + key: 'sig', + tone: status.signerMismatch ? 'warn' : 'ok', + text: status.signerEmail + ? t('encryption.banner.signed_by', 'Valid signature ({kind}) from {signer}', { kind, signer: status.signerEmail }) + : t('encryption.banner.signed', 'Valid signature ({kind})', { kind }), + }); + if (status.signerMismatch) rows.push({ key: 'mismatch', tone: 'warn', text: t('encryption.banner.signer_mismatch', 'The signer is not the sender shown in From.') }); + if (status.selfSigned) rows.push({ key: 'self', tone: 'muted', text: t('encryption.banner.self_signed', 'The certificate is self-signed, not issued by a known authority.') }); + } else if (status.signature === 'invalid') { + rows.push({ + key: 'sig', + tone: 'error', + text: status.error + ? t('encryption.banner.invalid_reason', 'Invalid signature ({kind}): {reason}', { kind, reason: status.error }) + : t('encryption.banner.invalid', 'Invalid signature ({kind})', { kind }), + }); + } else if (status.signature === 'unknown') { + rows.push({ key: 'sig', tone: 'warn', text: t('encryption.banner.unknown_key', 'Signed ({kind}) with a key you have not imported.', { kind }) }); + } + return rows; +} + diff --git a/src/lib/crypto/compose-secure.ts b/src/lib/crypto/compose-secure.ts new file mode 100644 index 00000000..053f7b30 --- /dev/null +++ b/src/lib/crypto/compose-secure.ts @@ -0,0 +1,198 @@ +// Signing / encrypting from the composer (#58): which keys a send needs, +// whether every recipient has one, and turning the composer's outgoing +// message into the raw signed/encrypted blob that sendEmail imports. The +// plugins' onComposeSend (repos/plugins/bulwark-gpg/src/index.js, +// repos/plugins/smime/src/index.js) do the same with host.jmap.sendRaw. + +import type { OutgoingEmail } from '../../api/email'; +import type { ContactCard } from '../../api/types'; +import { useCryptoKeysStore, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store'; +import { messagePriorityHeaders } from '../message-priority'; +import { base64Decode, formatHeader, encodeHeaderWords, type MimeAttachment, type MimeMessageInput } from './mime'; +import { buildSecureMessage, type SecureMode } from './secure-send'; +import { ownKeyFor, unlockedPgpKey, unlockedSmimeKey } from './keyring'; + +export interface SecureChoice { + mode: SecureMode; + sign: boolean; + encrypt: boolean; +} + +export const NO_SECURE_CHOICE: SecureChoice = Object.freeze({ mode: 'pgp', sign: false, encrypt: false }) as SecureChoice; + +export function isSecureChoice(choice: SecureChoice | null | undefined): choice is SecureChoice { + return !!choice && (choice.sign || choice.encrypt); +} + +/** A recipient key found in a contact card (JSContact cryptoKeys, vCard KEY). */ +function keysFromContacts(kind: SecureMode, email: string, contacts: ContactCard[]): Array { + const e = email.toLowerCase(); + const out: Array = []; + for (const card of contacts) { + const emails = Object.values(card.emails ?? {}).map((m) => (m.address ?? '').toLowerCase()); + if (!emails.includes(e)) continue; + for (const key of Object.values(card.cryptoKeys ?? {})) { + const decoded = decodeDataUri(key.uri); + if (!decoded) continue; + const type = (key.mediaType || decoded.type).toLowerCase(); + const text = new TextDecoder('utf-8', { fatal: false }).decode(decoded.bytes); + if (kind === 'pgp' && (type.includes('pgp') || text.includes('BEGIN PGP PUBLIC KEY BLOCK'))) { + if (text.includes('BEGIN PGP PUBLIC KEY BLOCK')) out.push(text); + } else if (kind === 'smime' && (type.includes('pkix') || type.includes('x509') || type.includes('x-x509') || text.includes('BEGIN CERTIFICATE'))) { + out.push(text.includes('BEGIN CERTIFICATE') ? base64Decode(text.replace(/-----[^-]+-----/g, '')) : decoded.bytes); + } + } + } + return out; +} + +function decodeDataUri(uri: string | undefined): { type: string; bytes: Uint8Array } | null { + const m = /^data:([^;,]*)((?:;[^,]*)*?),(.*)$/s.exec(uri ?? ''); + if (!m) return null; + const isBase64 = /;base64/i.test(m[2]); + try { + const bytes = isBase64 ? base64Decode(decodeURIComponent(m[3])) : new TextEncoder().encode(decodeURIComponent(m[3])); + return { type: m[1] || 'application/octet-stream', bytes }; + } catch { + return null; + } +} + +export interface RecipientKeys { + /** Armored PGP keys or DER certificates, one or more per found recipient. */ + found: Array; + missing: string[]; +} + +/** Public keys for every recipient: the stored keys first, then contact cards. */ +export function resolveRecipientKeys(kind: SecureMode, emails: string[], contacts: ContactCard[] = []): RecipientKeys { + const keys = useCryptoKeysStore.getState().keys; + const found: Array = []; + const missing: string[] = []; + for (const email of [...new Set(emails.map((e) => e.trim().toLowerCase()).filter(Boolean))]) { + const stored = keysForEmail(keys, email).filter((k): k is CryptoKeyRecord => k.kind === kind && isRecordCurrent(k)); + const usable = kind === 'smime' ? stored.filter((k) => (k as SmimeCertRecord).canEncrypt) : stored; + if (usable.length) { + for (const k of usable) found.push(k.kind === 'pgp' ? (k as PgpKeyRecord).publicKeyArmored : base64Decode((k as SmimeCertRecord).certificate)); + continue; + } + const fromContacts = keysFromContacts(kind, email, contacts); + if (fromContacts.length) found.push(...fromContacts); + else missing.push(email); + } + return { found, missing }; +} + +export interface SecureReadiness { + /** Your own key for the From address (needed to sign, and to keep Sent readable). */ + ownKey?: CryptoKeyRecord; + /** Recipients without a key (encrypt only). */ + missingRecipients: string[]; +} + +export function secureReadiness(choice: SecureChoice, fromEmail: string, recipients: string[], contacts: ContactCard[] = []): SecureReadiness { + const ownKey = ownKeyFor(choice.mode, fromEmail); + const missingRecipients = choice.encrypt ? resolveRecipientKeys(choice.mode, recipients, contacts).missing : []; + return { ownKey, missingRecipients }; +} + +/** Header lines the composer's options add (priority, read receipt, Reply-To). */ +function extraHeaders(outgoing: OutgoingEmail): string[] { + const lines: string[] = []; + if (outgoing.replyTo?.length) { + lines.push(formatHeader('Reply-To', outgoing.replyTo.map((a) => (a.name ? `${encodeHeaderWords(a.name)} <${a.email}>` : a.email)).join(', '))); + } + for (const [prop, value] of Object.entries(messagePriorityHeaders(outgoing.priority))) { + const name = /^header:([^:]+):/.exec(prop)?.[1]; + if (name) lines.push(formatHeader(name, value)); + } + if (outgoing.requestReadReceipt && outgoing.from[0]?.email) lines.push(formatHeader('Disposition-Notification-To', outgoing.from[0].email)); + return lines; +} + +export class SecureSendError extends Error { + code: 'no-own-key' | 'locked' | 'missing-recipients'; + detail?: string[]; + constructor(code: SecureSendError['code'], message: string, detail?: string[]) { + super(message); + this.name = 'SecureSendError'; + this.code = code; + this.detail = detail; + } +} + +/** + * Build the signed/encrypted message for `outgoing` and upload it; returns + * the blob id for sendEmail's `rawBlobId`. The own key must be unlocked + * first when signing (the composer asks for the passphrase). + */ +export async function prepareSecureSend( + outgoing: OutgoingEmail, + choice: SecureChoice, + deps: { + contacts?: ContactCard[]; + fetchBlob: (blobId: string, name: string, type: string) => Promise; + upload: (bytes: Uint8Array) => Promise; + }, +): Promise { + const fromEmail = outgoing.from[0]?.email ?? ''; + const own = ownKeyFor(choice.mode, fromEmail); + if (!own) throw new SecureSendError('no-own-key', `No ${choice.mode === 'pgp' ? 'PGP key' : 'S/MIME certificate'} of yours for ${fromEmail}`); + const recipients = [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])].map((r) => r.email); + + const attachments: MimeAttachment[] = []; + for (const a of outgoing.attachments ?? []) { + attachments.push({ + filename: a.name || 'attachment', + contentType: a.type || 'application/octet-stream', + content: await deps.fetchBlob(a.blobId, a.name || 'attachment', a.type || 'application/octet-stream'), + cid: a.disposition === 'inline' ? a.cid : undefined, + }); + } + const input: MimeMessageInput = { + from: outgoing.from[0], + to: outgoing.to, + cc: outgoing.cc, + subject: outgoing.subject, + textBody: outgoing.textBody, + htmlBody: outgoing.htmlBody, + attachments, + inReplyTo: outgoing.inReplyTo, + references: outgoing.references, + messageId: outgoing.messageId, + extraHeaders: extraHeaders(outgoing), + }; + + let raw: Uint8Array; + if (choice.mode === 'pgp') { + const record = own as PgpKeyRecord; + const signingKey = choice.sign ? unlockedPgpKey(record.id) : null; + if (choice.sign && !signingKey) throw new SecureSendError('locked', 'Unlock your PGP key to sign'); + let encryptionKeys: string[] | undefined; + if (choice.encrypt) { + const r = resolveRecipientKeys('pgp', recipients, deps.contacts); + if (r.missing.length) throw new SecureSendError('missing-recipients', `No public key for ${r.missing.join(', ')}`, r.missing); + // Encrypted to yourself too, so Sent stays readable. + encryptionKeys = [...(r.found as string[]), record.publicKeyArmored]; + } + raw = await buildSecureMessage(input, choice, { pgp: { signingKey, encryptionKeys } }); + } else { + const record = own as SmimeCertRecord; + const unlocked = choice.sign ? unlockedSmimeKey(record.id) : undefined; + if (choice.sign && !unlocked) throw new SecureSendError('locked', 'Unlock your S/MIME key to sign'); + let recipientCerts: Uint8Array[] | undefined; + if (choice.encrypt) { + const r = resolveRecipientKeys('smime', recipients, deps.contacts); + if (r.missing.length) throw new SecureSendError('missing-recipients', `No certificate for ${r.missing.join(', ')}`, r.missing); + recipientCerts = r.found as Uint8Array[]; + } + raw = await buildSecureMessage(input, choice, { + smime: { + signer: unlocked ? { pkcs8: unlocked.pkcs8, certificate: base64Decode(record.certificate), chain: record.chain.map(base64Decode), algorithm: record.algorithm } : undefined, + recipientCerts, + senderCert: base64Decode(record.certificate), + }, + }); + } + return deps.upload(raw); +} diff --git a/src/lib/crypto/install-webcrypto.ts b/src/lib/crypto/install-webcrypto.ts new file mode 100644 index 00000000..895b6999 --- /dev/null +++ b/src/lib/crypto/install-webcrypto.ts @@ -0,0 +1,54 @@ +// Installs WebCrypto for the PGP and S/MIME code (#58). Hermes has no +// `crypto` global; openpgp throws "The WebCrypto API is not available" at +// its first operation without one, and pkijs only picks an engine when it +// finds one. react-native-quick-crypto (OpenSSL, native) provides it. +// +// Imported first thing in index.ts. Nothing native loads here: `subtle` and +// the Node-style module are required on first use, so an app start or a +// headless push task pays nothing until a key or an encrypted message is +// touched. getRandomValues comes from expo-crypto, which the app already +// loads for src/lib/random.ts. + +import { setNodeStyleCrypto, type NodeStyleCrypto } from './provider'; + +type QuickCrypto = NodeStyleCrypto & { subtle: SubtleCrypto; getRandomValues(a: T): T; randomUUID(): string }; + +let quick: QuickCrypto | null = null; +function quickCrypto(): QuickCrypto { + if (!quick) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('react-native-quick-crypto') as { default?: QuickCrypto } & QuickCrypto; + quick = (mod.default ?? mod) as QuickCrypto; + } + return quick; +} + +function randomValues(array: T): T { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const expo = require('expo-crypto') as { getRandomValues?: (a: V) => V }; + if (expo.getRandomValues) return expo.getRandomValues(array); + } catch { + // fall through to OpenSSL + } + return quickCrypto().getRandomValues(array); +} + +/** Put `crypto.subtle` / `crypto.getRandomValues` on the global, loading the native module lazily. */ +export function installWebCrypto(target: Record = globalThis as unknown as Record): void { + const existing = target.crypto as Record | undefined; + const crypto: Record = existing && Object.isExtensible(existing) ? existing : { ...(existing ?? {}) }; + if (typeof crypto.getRandomValues !== 'function') crypto.getRandomValues = randomValues; + if (typeof crypto.randomUUID !== 'function') crypto.randomUUID = () => quickCrypto().randomUUID(); + if (!crypto.subtle) { + Object.defineProperty(crypto, 'subtle', { + configurable: true, + enumerable: true, + get: () => quickCrypto().subtle, + }); + } + if (crypto !== existing) target.crypto = crypto; + setNodeStyleCrypto(quickCrypto); +} + +installWebCrypto(); diff --git a/src/lib/crypto/key-labels.ts b/src/lib/crypto/key-labels.ts new file mode 100644 index 00000000..cd7d1bbe --- /dev/null +++ b/src/lib/crypto/key-labels.ts @@ -0,0 +1,28 @@ +// Display helpers for keys and certificates (#58). +import type { CryptoKeyRecord } from '../../stores/crypto-keys-store'; + +/** Fingerprint in groups of four, upper case (the plugin's fmtFp). */ +export function formatFingerprint(fp: string): string { + const hex = fp.replace(/[^0-9a-f]/gi, '').toUpperCase(); + return hex.replace(/(.{4})/g, '$1 ').trim(); +} + +/** Short form for lists: the last 16 hex digits. */ +export function shortFingerprint(fp: string): string { + const hex = fp.replace(/[^0-9a-f]/gi, '').toUpperCase(); + return formatFingerprint(hex.slice(-16)); +} + +/** "Name " or the first address, for prompts and rows. */ +export function keyLabel(record: CryptoKeyRecord): string { + if (record.kind === 'pgp') return record.userIDs[0] || record.emails[0] || shortFingerprint(record.fingerprint); + const cn = /(?:^|, )CN=([^,]+)/.exec(record.subject)?.[1]; + const email = record.emails[0]; + if (cn && email && !cn.includes(email)) return `${cn} <${email}>`; + return email || cn || shortFingerprint(record.fingerprint); +} + +/** ISO date to YYYY-MM-DD. */ +export function shortDate(iso: string | null | undefined): string { + return iso ? iso.slice(0, 10) : ''; +} diff --git a/src/lib/crypto/keyring.ts b/src/lib/crypto/keyring.ts new file mode 100644 index 00000000..3bd8a666 --- /dev/null +++ b/src/lib/crypto/keyring.ts @@ -0,0 +1,244 @@ +// Key management for PGP and S/MIME (#58): import, export, delete, unlock +// for the session, and recipient lookup. Ports the key handling of the +// webmail plugins (repos/plugins/bulwark-gpg/src/index.js SettingsSection, +// repos/plugins/smime/src/ui.js + pkcs12.js) onto SecureStore. +// +// Unlocked keys stay in memory for the app session only (the plugins keep +// them until reload/logout); passphrases are never stored. + +import type * as OpenPgp from 'openpgp'; +import { useCryptoKeysStore, upsertRecord, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store'; +import { setSecret, getSecret, deleteSecret } from './secret-store'; +import { inspectKey, unlockPrivateKey, privateKeyNeedsPassphrase, type PgpKeyInfo } from './pgp'; +import { base64Decode, base64Encode } from './mime'; + +const pgpSecretId = (id: string) => `pgp.${id}`; +const smimeSecretId = (id: string) => `smime.${id}`; + +// ── session (memory only) ──────────────────────────────────────────── + +const unlockedPgp = new Map(); +const unlockedSmime = new Map(); +const listeners = new Set<() => void>(); + +function notify(): void { + for (const l of listeners) l(); +} + +/** Subscribe to lock/unlock changes (the reader re-tries a locked message). */ +export function onUnlockChange(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function isUnlocked(record: CryptoKeyRecord): boolean { + return record.kind === 'pgp' ? unlockedPgp.has(record.id) : unlockedSmime.has(record.id); +} + +/** Forget every unlocked key (Settings > Encryption > Lock). */ +export function lockAll(): void { + unlockedPgp.clear(); + unlockedSmime.clear(); + notify(); +} + +export function unlockedPgpKeys(): OpenPgp.PrivateKey[] { + return [...unlockedPgp.values()]; +} + +export function unlockedPgpKey(id: string): OpenPgp.PrivateKey | undefined { + return unlockedPgp.get(id); +} + +export function unlockedSmimeKey(id: string): { pkcs8: Uint8Array } | undefined { + return unlockedSmime.get(id); +} + +function records(): CryptoKeyRecord[] { + return useCryptoKeysStore.getState().keys; +} + +function upsert(record: CryptoKeyRecord): void { + useCryptoKeysStore.setState({ keys: upsertRecord(records(), record) }); +} + +/** Does this private key need a passphrase to unlock? (A key without one unlocks silently.) */ +export async function needsPassphrase(record: CryptoKeyRecord): Promise { + if (isUnlocked(record)) return false; + if (record.kind === 'smime') return true; + const armored = await getSecret(pgpSecretId(record.id)); + if (!armored) throw new Error('The private key is missing from the secure storage'); + return privateKeyNeedsPassphrase(armored); +} + +/** Unlock a private key for this session. Throws on a wrong passphrase. */ +export async function unlock(record: CryptoKeyRecord, passphrase: string): Promise { + if (record.kind === 'pgp') { + const armored = await getSecret(pgpSecretId(record.id)); + if (!armored) throw new Error('The private key is missing from the secure storage'); + unlockedPgp.set(record.id, await unlockPrivateKey(armored, passphrase)); + } else { + const p12 = await getSecret(smimeSecretId(record.id)); + if (!p12) throw new Error('The private key is missing from the secure storage'); + const { openPkcs12 } = await import('./smime/pkcs12'); + const opened = await openPkcs12(base64Decode(p12), passphrase); + unlockedSmime.set(record.id, { pkcs8: opened.pkcs8 }); + } + notify(); +} + +/** Unlock a key without a passphrase when it has none. Returns whether it is unlocked now. */ +export async function unlockIfUnprotected(record: CryptoKeyRecord): Promise { + if (isUnlocked(record)) return true; + if (record.kind !== 'pgp') return false; + if (await needsPassphrase(record)) return false; + await unlock(record, ''); + return true; +} + +// ── import / export / delete ───────────────────────────────────────── + +function pgpRecord(info: PgpKeyInfo, hasPrivate: boolean, source: PgpKeyRecord['source']): PgpKeyRecord { + const emails = [...new Set(info.userIDs.map((u) => u.email).filter((e) => e.includes('@')))]; + return { + kind: 'pgp', + id: info.fingerprint.toLowerCase(), + fingerprint: info.fingerprint.toLowerCase(), + keyID: info.keyID.toLowerCase(), + emails, + userIDs: info.userIDs.map((u) => u.raw), + created: info.created, + expiration: info.expiration, + algorithm: info.algorithm, + publicKeyArmored: info.publicKeyArmored, + hasPrivate, + source, + addedAt: new Date().toISOString(), + }; +} + +/** + * Import an armored PGP key. A private key is checked against its + * passphrase (when it has one) before it is stored, so a typo doesn't leave + * a key nobody can unlock; it is stored as given, still protected. + */ +export async function importPgpKey(armored: string, passphrase?: string, source: PgpKeyRecord['source'] = 'import'): Promise { + const text = armored.trim(); + const info = await inspectKey(text); + if (info.isPrivate) { + const key = await unlockPrivateKey(text, passphrase ?? ''); + await setSecret(pgpSecretId(info.fingerprint.toLowerCase()), text); + unlockedPgp.set(info.fingerprint.toLowerCase(), key); + notify(); + } + const record = pgpRecord(info, info.isPrivate, source); + upsert(record); + return record; +} + +function smimeRecord( + info: import('./smime/certificate').CertificateInfo, + certificate: Uint8Array, + chain: Uint8Array[], + hasPrivate: boolean, + source: SmimeCertRecord['source'], +): SmimeCertRecord { + return { + kind: 'smime', + id: info.fingerprint, + fingerprint: info.fingerprint, + emails: info.emailAddresses, + subject: info.subject, + issuer: info.issuer, + serialNumber: info.serialNumber, + notBefore: info.notBefore, + notAfter: info.notAfter, + algorithm: info.algorithm, + certificate: base64Encode(certificate, 1 << 30), + chain: chain.map((c) => base64Encode(c, 1 << 30)), + canSign: info.capabilities.canSign, + canEncrypt: info.capabilities.canEncrypt, + hasPrivate, + source, + addedAt: new Date().toISOString(), + }; +} + +/** Import your own S/MIME key from a .p12/.pfx; it stays unlocked for this session. */ +export async function importPkcs12File(p12: Uint8Array, password: string): Promise { + const { openPkcs12 } = await import('./smime/pkcs12'); + const opened = await openPkcs12(p12, password); + const record = smimeRecord(opened.info, opened.certificate, opened.chain, true, 'import'); + await setSecret(smimeSecretId(record.id), base64Encode(p12, 1 << 30)); + unlockedSmime.set(record.id, { pkcs8: opened.pkcs8 }); + upsert(record); + notify(); + return record; +} + +/** Import someone's certificate (PEM or DER). */ +export async function importCertificate(bytes: Uint8Array, source: SmimeCertRecord['source'] = 'import'): Promise { + const { certificateDerFromFile, describeCertificate } = await import('./smime/certificate'); + const der = certificateDerFromFile(bytes); + const info = await describeCertificate(der); + const record = smimeRecord(info, der, [], false, source); + upsert(record); + return record; +} + +/** Delete a key; its private part goes from SecureStore too. */ +export async function deleteKey(record: CryptoKeyRecord): Promise { + if (record.hasPrivate) await deleteSecret(record.kind === 'pgp' ? pgpSecretId(record.id) : smimeSecretId(record.id)); + if (record.kind === 'pgp') unlockedPgp.delete(record.id); + else unlockedSmime.delete(record.id); + useCryptoKeysStore.getState().remove(record.kind, record.id); + notify(); +} + +/** The public half to share: armored PGP key or PEM certificate. */ +export async function exportPublic(record: CryptoKeyRecord): Promise { + if (record.kind === 'pgp') return record.publicKeyArmored; + const { derToPem } = await import('./smime/certificate'); + return derToPem(base64Decode(record.certificate)); +} + +// ── lookups ────────────────────────────────────────────────────────── + +/** Your own key for signing from this address. */ +export function ownKeyFor(kind: CryptoKeyRecord['kind'], email: string): CryptoKeyRecord | undefined { + const mine = keysForEmail(records(), email).filter((k) => k.kind === kind && k.hasPrivate && isRecordCurrent(k)); + if (kind === 'smime') return mine.find((k) => (k as SmimeCertRecord).canSign) ?? mine[0]; + return mine[0]; +} + +export function ownKeys(kind?: CryptoKeyRecord['kind']): CryptoKeyRecord[] { + return records().filter((k) => k.hasPrivate && (!kind || k.kind === kind)); +} + +export function allPgpPublicKeys(): string[] { + return records().filter((k): k is PgpKeyRecord => k.kind === 'pgp').map((k) => k.publicKeyArmored); +} + +/** Remember the key of someone who signed a message, unless it is known already. */ +export async function rememberSignerPgpKey(armored: string): Promise { + try { + const info = await inspectKey(armored); + if (info.isPrivate) return; + if (records().some((k) => k.kind === 'pgp' && k.id === info.fingerprint.toLowerCase())) return; + upsert(pgpRecord(info, false, 'message')); + } catch { + // a broken embedded key: ignore + } +} + +export async function rememberSignerCertificate(der: Uint8Array): Promise { + try { + const { describeCertificate } = await import('./smime/certificate'); + const info = await describeCertificate(der); + if (!info.emailAddresses.length) return; + if (records().some((k) => k.kind === 'smime' && k.id === info.fingerprint)) return; + upsert(smimeRecord(info, der, [], false, 'message')); + } catch { + // ignore + } +} diff --git a/src/lib/crypto/keyserver.ts b/src/lib/crypto/keyserver.ts new file mode 100644 index 00000000..83d1ee1a --- /dev/null +++ b/src/lib/crypto/keyserver.ts @@ -0,0 +1,68 @@ +// Recipient PGP key lookup on keys.openpgp.org (VKS API), by fingerprint, +// key ID or (owner-verified) address. Port of the webmail plugin's +// repos/plugins/bulwark-gpg/src/keyserver.js. The keyserver isn't trusted to +// say who owns a key: a fingerprint lookup only counts when the returned key +// has that fingerprint, and an address lookup is confirmed by the user. + +import { inspectKey, type PgpKeyInfo } from './pgp'; + +export const DEFAULT_KEYSERVER = 'https://keys.openpgp.org'; + +export type KeyQuery = { kind: 'email' | 'fingerprint' | 'keyid' | 'invalid'; value: string }; + +/** Strip a leading 0x and all whitespace, uppercase. "" if it isn't hex. */ +export function normalizeHex(input: string): string { + const hex = String(input || '').replace(/^0x/i, '').replace(/\s+/g, '').toUpperCase(); + return /^[0-9A-F]+$/.test(hex) ? hex : ''; +} + +export function classifyQuery(raw: string): KeyQuery { + const s = String(raw || '').trim(); + if (s.includes('@')) return { kind: 'email', value: s.toLowerCase() }; + const hex = normalizeHex(s); + if (hex.length === 40 || hex.length === 64) return { kind: 'fingerprint', value: hex }; + if (hex.length === 16) return { kind: 'keyid', value: hex }; + return { kind: 'invalid', value: s }; +} + +export interface KeyLookupResult { + query: KeyQuery; + armored: string; + info: PgpKeyInfo; + /** The returned key has the fingerprint asked for. */ + verified: boolean; +} + +export class KeyserverError extends Error { + constructor(message: string) { + super(message); + this.name = 'KeyserverError'; + } +} + +/** Look a key up; null when the keyserver has none. */ +export async function lookupKey(raw: string, opts: { baseUrl?: string; fetchImpl?: typeof fetch } = {}): Promise { + const query = classifyQuery(raw); + if (query.kind === 'invalid') throw new KeyserverError('Enter a full fingerprint (40 or 64 hex characters), a 16-character key ID, or an email address.'); + const base = (opts.baseUrl || DEFAULT_KEYSERVER).replace(/\/+$/, ''); + const path = query.kind === 'email' + ? `/vks/v1/by-email/${encodeURIComponent(query.value)}` + : query.kind === 'keyid' ? `/vks/v1/by-keyid/${query.value}` : `/vks/v1/by-fingerprint/${query.value}`; + let res: Response; + try { + res = await (opts.fetchImpl ?? fetch)(`${base}${path}`, { headers: { Accept: 'application/pgp-keys' } }); + } catch { + throw new KeyserverError('Could not reach the keyserver.'); + } + if (res.status === 404) return null; + if (!res.ok) throw new KeyserverError(`Keyserver returned HTTP ${res.status}.`); + const armored = await res.text(); + if (!/BEGIN PGP PUBLIC KEY BLOCK/.test(armored)) throw new KeyserverError('Keyserver did not return an OpenPGP public key.'); + const info = await inspectKey(armored); + const fp = info.fingerprint.toUpperCase(); + if (query.kind === 'fingerprint' && fp !== query.value) { + throw new KeyserverError('The keyserver returned a different key than the fingerprint asked for.'); + } + const verified = query.kind === 'fingerprint' || (query.kind === 'keyid' && fp.endsWith(query.value)); + return { query, armored, info, verified }; +} diff --git a/src/lib/crypto/mime.ts b/src/lib/crypto/mime.ts new file mode 100644 index 00000000..e6df1a96 --- /dev/null +++ b/src/lib/crypto/mime.ts @@ -0,0 +1,535 @@ +// MIME building and parsing for PGP and S/MIME mail (#58). Ports of the +// webmail plugins' builders and parsers: +// repos/plugins/smime/src/mime-builder.js (entity + header builder) +// repos/plugins/smime/src/mime-parse.js (byte-exact multipart split) +// repos/plugins/bulwark-gpg/src/pgp-mime.js (RFC 3156 wrappers) +// Everything works on "binary strings" (one char per byte, latin1) so a +// signed part can be cut out byte for byte. All line endings are CRLF. + +const CRLF = '\r\n'; + +export interface MimeAddress { + name?: string; + email: string; +} + +export interface MimeAttachment { + filename: string; + contentType: string; + content: Uint8Array; + cid?: string; +} + +export interface MimeMessageInput { + from: MimeAddress; + to: MimeAddress[]; + cc?: MimeAddress[]; + subject: string; + textBody?: string; + htmlBody?: string; + attachments?: MimeAttachment[]; + inReplyTo?: string | string[]; + references?: string | string[]; + messageId?: string; + date?: Date; + /** Extra header lines, already formatted (`Name: value`). */ + extraHeaders?: string[]; +} + +// ── bytes <-> binary strings ───────────────────────────────────────── + +export function bytesToBinary(bytes: Uint8Array): string { + let s = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + s += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK))); + } + return s; +} + +export function binaryToBytes(s: string): Uint8Array { + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff; + return out; +} + +export function utf8(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +export function base64Encode(bytes: Uint8Array, lineLength = 76): string { + const b64 = btoa(bytesToBinary(bytes)); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += lineLength) lines.push(b64.slice(i, i + lineLength)); + return lines.join(CRLF); +} + +export function base64Decode(s: string): Uint8Array { + return binaryToBytes(atob(s.replace(/[^A-Za-z0-9+/=]/g, ''))); +} + +/** LF or CR line endings to CRLF (RFC 3156 / RFC 8551 canonical form). */ +export function toCrlf(s: string): string { + return s.replace(/\r\n|\r|\n/g, CRLF); +} + +// ── building ───────────────────────────────────────────────────────── + +function randomHex(bytes: number): string { + const b = new Uint8Array(bytes); + globalThis.crypto.getRandomValues(b); + return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +} + +function generateBoundary(): string { + return `----=_Part_${randomHex(16)}`; +} + +/** Collapse CR/LF/tabs (folding leftovers, user input) to single spaces. */ +function unfold(value: string | undefined): string { + return String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/ {2,}/g, ' ').trim(); +} + +function formatAddress(addr: MimeAddress): string { + if (addr.name) { + const name = unfold(addr.name); + // Raw 8-bit in a header is rejected by some MTAs; encode it. + if (!/^[\x20-\x7e]*$/.test(name)) return `${encodeHeaderWords(name)} <${addr.email}>`; + const escaped = name.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}" <${addr.email}>`; + } + return addr.email; +} + +/** Normalise one id or a list of ids to " "; empty string if none. */ +function msgIdList(value: string | string[] | undefined): string { + const items = Array.isArray(value) ? value : value ? [value] : []; + return items + .flatMap((v) => unfold(v).split(' ')) + .map((v) => v.replace(/^<+|>+$/g, '')) + .filter((v) => v.includes('@')) + .map((v) => `<${v}>`) + .join(' '); +} + +function messageIdDomain(from: MimeAddress): string { + const domain = String(from?.email ?? '').split('@')[1]; + return domain && /^[A-Za-z0-9.-]+$/.test(domain) ? domain : 'localhost'; +} + +/** Fold a header at whitespace only (RFC 5322 §2.2.3); words are never split. */ +export function formatHeader(name: string, value: string): string { + const words = unfold(value).split(' ').filter(Boolean); + const lines: string[] = []; + let line = `${name}:`; + for (const word of words) { + if (line.length + 1 + word.length > 76 && line.length > name.length + 1) { + lines.push(line); + line = ` ${word}`; + } else { + line += ` ${word}`; + } + } + lines.push(line); + return lines.join(CRLF); +} + +/** RFC 2047 Q encoded-words, split between whole characters. */ +export function encodeHeaderWords(value: string): string { + const text = unfold(value); + if (/^[\x20-\x7e]*$/.test(text)) return text; + const prefix = '=?UTF-8?Q?'; + const words: string[] = []; + let current = ''; + for (const ch of text) { + const enc = Array.from(utf8(ch)) + .map((b) => + (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) + ? String.fromCharCode(b) + : b === 0x20 + ? '_' + : '=' + b.toString(16).toUpperCase().padStart(2, '0'), + ) + .join(''); + if (prefix.length + current.length + enc.length + 2 > 60) { + words.push(`${prefix}${current}?=`); + current = ''; + } + current += enc; + } + if (current) words.push(`${prefix}${current}?=`); + return words.join(' '); +} + +function encodeHeaderValue(value: string): string { + if (/^[\x20-\x7e]*$/.test(value)) return value.replace(/"/g, ''); + const encoded = Array.from(utf8(value)) + .map((b) => ((b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a) + ? String.fromCharCode(b) + : '=' + b.toString(16).toUpperCase().padStart(2, '0'))) + .join(''); + return `=?UTF-8?Q?${encoded}?=`; +} + +function formatDate(date: Date): string { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const p = (n: number) => String(n).padStart(2, '0'); + return `${days[date.getUTCDay()]}, ${date.getUTCDate()} ${months[date.getUTCMonth()]} ${date.getUTCFullYear()} ` + + `${p(date.getUTCHours())}:${p(date.getUTCMinutes())}:${p(date.getUTCSeconds())} +0000`; +} + +/** The message's own header lines (no MIME-Version / Content-*). */ +export function messageHeaderLines(input: MimeMessageInput): string[] { + const lines: string[] = []; + lines.push(formatHeader('From', formatAddress(input.from))); + if (input.to.length) lines.push(formatHeader('To', input.to.map(formatAddress).join(', '))); + if (input.cc?.length) lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', '))); + lines.push(formatHeader('Subject', encodeHeaderWords(input.subject))); + lines.push(formatHeader('Date', formatDate(input.date ?? new Date()))); + const messageId = input.messageId + ? `<${input.messageId.replace(/^<+|>+$/g, '')}>` + : `<${randomHex(16)}@${messageIdDomain(input.from)}>`; + lines.push(formatHeader('Message-ID', messageId)); + const inReplyTo = msgIdList(input.inReplyTo); + if (inReplyTo) lines.push(formatHeader('In-Reply-To', inReplyTo)); + const references = msgIdList(input.references); + if (references) lines.push(formatHeader('References', references)); + for (const h of input.extraHeaders ?? []) lines.push(h); + return lines; +} + +/** Quoted-printable (RFC 2045), trailing whitespace encoded. */ +export function quotedPrintableEncode(input: string): string { + const bytes = utf8(input); + const lines: string[] = []; + let line = ''; + const hardLine = (l: string): string[] => { + const last = l[l.length - 1]; + if (last !== ' ' && last !== '\t') return [l]; + const escaped = last === ' ' ? '=20' : '=09'; + const rest = l.slice(0, -1); + return rest.length + escaped.length > 76 ? [rest + '=', escaped] : [rest + escaped]; + }; + for (const b of bytes) { + if (b === 0x0a) { + if (line.endsWith('\r')) line = line.slice(0, -1); + lines.push(...hardLine(line)); + line = ''; + continue; + } + const encoded = b === 0x0d || b === 0x09 || (b >= 0x20 && b <= 0x7e && b !== 0x3d) + ? String.fromCharCode(b) + : '=' + b.toString(16).toUpperCase().padStart(2, '0'); + if (line.length + encoded.length > 75) { + lines.push(line + '='); + line = encoded; + } else { + line += encoded; + } + } + lines.push(...hardLine(line)); + return lines.join(CRLF); +} + +function textPartLines(type: 'plain' | 'html', body: string): string[] { + return [ + `Content-Type: text/${type}; charset=utf-8`, + 'Content-Transfer-Encoding: quoted-printable', + '', + quotedPrintableEncode(body), + ]; +} + +function entityLines(input: MimeMessageInput): string[] { + const hasText = !!input.textBody; + const hasHtml = !!input.htmlBody; + const attachments = input.attachments ?? []; + const bodyLines = (): string[] => { + if (hasText && hasHtml) { + const alt = generateBoundary(); + return [ + `Content-Type: multipart/alternative; boundary="${alt}"`, + '', + `--${alt}`, + ...textPartLines('plain', input.textBody!), + `--${alt}`, + ...textPartLines('html', input.htmlBody!), + `--${alt}--`, + ]; + } + if (hasHtml) return textPartLines('html', input.htmlBody!); + return textPartLines('plain', input.textBody ?? ''); + }; + if (attachments.length === 0) return bodyLines(); + + const boundary = generateBoundary(); + const lines = [`Content-Type: multipart/mixed; boundary="${boundary}"`, '', `--${boundary}`, ...bodyLines()]; + for (const att of attachments) { + lines.push(`--${boundary}`); + const name = encodeHeaderValue(att.filename); + lines.push(`Content-Type: ${att.contentType || 'application/octet-stream'}; name="${name}"`); + lines.push(`Content-Disposition: ${att.cid ? 'inline' : 'attachment'}; filename="${name}"`); + lines.push('Content-Transfer-Encoding: base64'); + if (att.cid) lines.push(`Content-ID: <${att.cid.replace(/^<+|>+$/g, '')}>`); + lines.push(''); + lines.push(base64Encode(att.content)); + } + lines.push(`--${boundary}--`); + return lines; +} + +/** The body entity (Content-* headers + body): what gets signed or encrypted. */ +export function buildMimeEntity(input: MimeMessageInput): Uint8Array { + return utf8(entityLines(input).join(CRLF)); +} + +/** A complete plain MIME message (the payload opaque S/MIME signs). */ +export function buildMimeMessage(input: MimeMessageInput): Uint8Array { + return utf8([...messageHeaderLines(input), 'MIME-Version: 1.0', ...entityLines(input)].join(CRLF)); +} + +/** RFC 3156 multipart/encrypted around an armored PGP MESSAGE. */ +export function wrapPgpEncrypted(armoredCiphertext: string, input: MimeMessageInput): Uint8Array { + const boundary = generateBoundary(); + const lines = [ + ...messageHeaderLines(input), + 'MIME-Version: 1.0', + `Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";`, + ` boundary="${boundary}"`, + '', + 'This is an OpenPGP/MIME encrypted message (RFC 3156).', + '', + `--${boundary}`, + 'Content-Type: application/pgp-encrypted', + 'Content-Description: PGP/MIME version identification', + '', + 'Version: 1', + '', + `--${boundary}`, + 'Content-Type: application/octet-stream; name="encrypted.asc"', + 'Content-Description: OpenPGP encrypted message', + 'Content-Disposition: inline; filename="encrypted.asc"', + '', + toCrlf(armoredCiphertext).replace(/\r\n$/, ''), + `--${boundary}--`, + '', + ]; + return utf8(lines.join(CRLF)); +} + +/** RFC 3156 multipart/signed: `entity` must be the exact bytes that were signed. */ +export function wrapPgpSigned(entity: Uint8Array, armoredSignature: string, input: MimeMessageInput, micalg = 'pgp-sha256'): Uint8Array { + const boundary = generateBoundary(); + const head = [ + ...messageHeaderLines(input), + 'MIME-Version: 1.0', + `Content-Type: multipart/signed; micalg="${micalg}"; protocol="application/pgp-signature";`, + ` boundary="${boundary}"`, + '', + 'This is an OpenPGP/MIME signed message (RFC 3156).', + '', + `--${boundary}`, + '', + ].join(CRLF); + const tail = [ + '', + `--${boundary}`, + 'Content-Type: application/pgp-signature; name="signature.asc"', + 'Content-Description: OpenPGP digital signature', + 'Content-Disposition: attachment; filename="signature.asc"', + '', + toCrlf(armoredSignature).replace(/\r\n$/, ''), + `--${boundary}--`, + '', + ].join(CRLF); + return concat(utf8(head), entity, utf8(tail)); +} + +/** Opaque S/MIME (application/pkcs7-mime) around CMS DER. */ +export function wrapCmsMessage(cms: Uint8Array, smimeType: 'signed-data' | 'enveloped-data', input: MimeMessageInput): Uint8Array { + const lines = [ + ...messageHeaderLines(input), + 'MIME-Version: 1.0', + `Content-Type: application/pkcs7-mime; smime-type=${smimeType}; name="smime.p7m"`, + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="smime.p7m"', + ]; + // A blank line (CRLFCRLF) ends the header block before the base64 body. + return utf8(lines.join(CRLF) + CRLF + CRLF + base64Encode(cms) + CRLF); +} + +/** A CMS blob as a nested MIME entity (S/MIME sign-then-encrypt). */ +export function cmsInnerEntity(cms: Uint8Array, smimeType: 'signed-data'): Uint8Array { + return utf8([ + `Content-Type: application/pkcs7-mime; smime-type=${smimeType}; name="smime.p7m"`, + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="smime.p7m"', + '', + base64Encode(cms), + '', + ].join(CRLF)); +} + +/** multipart/signed with a detached CMS signature (RFC 8551 §3.5.3). */ +export function wrapSmimeDetached(entity: Uint8Array, signatureDer: Uint8Array, input: MimeMessageInput): Uint8Array { + const boundary = generateBoundary(); + const head = [ + ...messageHeaderLines(input), + 'MIME-Version: 1.0', + 'Content-Type: multipart/signed; protocol="application/pkcs7-signature";', + ` micalg=sha-256; boundary="${boundary}"`, + '', + 'This is a cryptographically signed message in MIME format.', + '', + `--${boundary}`, + '', + ].join(CRLF); + // The CRLF before a boundary belongs to the boundary (RFC 2046 §5.1.1). + const tail = [ + '', + `--${boundary}`, + 'Content-Type: application/pkcs7-signature; name="smime.p7s"', + 'Content-Transfer-Encoding: base64', + 'Content-Disposition: attachment; filename="smime.p7s"', + '', + base64Encode(signatureDer), + `--${boundary}--`, + '', + ].join(CRLF); + return concat(utf8(head), entity, utf8(tail)); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.byteLength; + } + return out; +} + +// ── parsing ────────────────────────────────────────────────────────── + +export interface MimeNode { + type: string; + params: Record; + headers: Record; + cte: string; + body: string; + /** The exact entity (headers + body) as a binary string. */ + raw: string; + children: MimeNode[]; +} + +function parseHeaders(headerText: string): Record { + const unfolded = headerText.replace(/\r?\n[ \t]+/g, ' '); + const headers: Record = {}; + for (const line of unfolded.split(/\r?\n/)) { + const idx = line.indexOf(':'); + if (idx <= 0) continue; + const name = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + headers[name] = headers[name] ? `${headers[name]}, ${value}` : value; + } + return headers; +} + +export function parseContentType(value: string): { type: string; params: Record } { + const parts = value.split(';'); + const type = parts[0].trim().toLowerCase(); + const params: Record = {}; + for (let i = 1; i < parts.length; i++) { + const eq = parts[i].indexOf('='); + if (eq < 0) continue; + const k = parts[i].slice(0, eq).trim().toLowerCase(); + let v = parts[i].slice(eq + 1).trim(); + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + params[k] = v; + } + return { type, params }; +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Split a multipart body into the exact bytes of each part. The CRLF before a + * delimiter belongs to the delimiter, not the part (RFC 2046 §5.1.1), which + * is what a detached signature covers. + */ +function splitMultipart(body: string, boundary: string): string[] { + const delim = new RegExp(`(?:^|\\r?\\n)--${escapeRegExp(boundary)}(--)?[ \\t]*(?:\\r?\\n|$)`, 'g'); + const marks: Array<{ index: number; end: number; closing: boolean }> = []; + let m: RegExpExecArray | null; + while ((m = delim.exec(body))) { + marks.push({ index: m.index, end: delim.lastIndex, closing: !!m[1] }); + if (m[0].length === 0) delim.lastIndex++; + } + const parts: string[] = []; + for (let i = 0; i < marks.length - 1; i++) { + if (marks[i].closing) break; + parts.push(body.slice(marks[i].end, marks[i + 1].index)); + } + return parts; +} + +/** Parse an entity (binary string) into a tree. */ +export function parseEntity(raw: string): MimeNode { + const sep = raw.match(/\r?\n\r?\n/); + const headerText = sep && sep.index !== undefined ? raw.slice(0, sep.index) : raw; + const body = sep && sep.index !== undefined ? raw.slice(sep.index + sep[0].length) : ''; + // An entity that starts with the blank line has no headers at all. + const headers = /^\r?\n/.test(raw) ? {} : parseHeaders(headerText); + const realBody = /^\r?\n/.test(raw) ? raw.replace(/^\r?\n/, '') : body; + const { type, params } = parseContentType(headers['content-type'] || 'text/plain'); + const cte = (headers['content-transfer-encoding'] || '7bit').trim().toLowerCase(); + const node: MimeNode = { type, params, headers, cte, body: realBody, raw, children: [] }; + if (type.startsWith('multipart/') && params.boundary) { + node.children = splitMultipart(realBody, params.boundary).map(parseEntity); + } + return node; +} + +function qpDecode(input: string): Uint8Array { + const out: number[] = []; + const cleaned = input.replace(/=\r?\n/g, ''); + for (let i = 0; i < cleaned.length; i++) { + const c = cleaned[i]; + if (c === '=' && i + 2 < cleaned.length + 1) { + const hex = cleaned.substr(i + 1, 2); + if (/^[0-9A-Fa-f]{2}$/.test(hex)) { + out.push(parseInt(hex, 16)); + i += 2; + continue; + } + } + out.push(cleaned.charCodeAt(i) & 0xff); + } + return new Uint8Array(out); +} + +/** A leaf's decoded body bytes. */ +export function decodeBody(node: MimeNode): Uint8Array { + if (node.cte === 'base64') { + try { + return base64Decode(node.body); + } catch { + return new Uint8Array(0); + } + } + if (node.cte === 'quoted-printable') return qpDecode(node.body); + return binaryToBytes(node.body); +} + +/** Depth-first search for the first node that matches. */ +export function findNode(node: MimeNode, match: (n: MimeNode) => boolean): MimeNode | null { + if (match(node)) return node; + for (const child of node.children) { + const found = findNode(child, match); + if (found) return found; + } + return null; +} diff --git a/src/lib/crypto/pgp.ts b/src/lib/crypto/pgp.ts new file mode 100644 index 00000000..e8f9ff4e --- /dev/null +++ b/src/lib/crypto/pgp.ts @@ -0,0 +1,305 @@ +// OpenPGP for the app (#58): key inspection, encrypt/sign, decrypt/verify +// and PGP detection in incoming mail. Port of the webmail plugin's +// repos/plugins/bulwark-gpg/src/openpgp-engine.js and the detection half of +// pgp-mime.js. openpgp loads on first use (it is a large module). + +import type * as OpenPgp from 'openpgp'; +import { bytesToBinary, binaryToBytes, decodeBody, findNode, parseEntity, toCrlf, type MimeNode } from './mime'; + +let mod: typeof OpenPgp | null = null; +async function pgp(): Promise { + if (!mod) mod = await import('openpgp'); + return mod; +} + +export interface PgpUserId { + name: string; + email: string; + raw: string; +} + +export interface PgpKeyInfo { + isPrivate: boolean; + fingerprint: string; + keyID: string; + userIDs: PgpUserId[]; + created: string; + expiration: string | null; + publicKeyArmored: string; + algorithm: string; +} + +export type PgpSignatureStatus = 'none' | 'valid' | 'invalid' | 'unknown'; + +export interface PgpSignatureSummary { + status: PgpSignatureStatus; + keyID?: string; + error?: string; + /** First address of the key that made the signature, when we have it. */ + signerEmail?: string; + /** Every address on that key. */ + signerEmails?: string[]; +} + +function describeUserIDs(key: OpenPgp.Key): PgpUserId[] { + return key.getUserIDs().map((uid) => { + const m = /^(.*?)\s*<([^>]+)>\s*$/.exec(uid); + return m ? { name: m[1].trim(), email: m[2].trim().toLowerCase(), raw: uid } : { name: '', email: uid.trim().toLowerCase(), raw: uid }; + }); +} + +function keyAlgorithm(key: OpenPgp.Key): string { + try { + const info = key.getAlgorithmInfo() as { algorithm: string; bits?: number; curve?: string }; + return info.bits ? `${info.algorithm}-${info.bits}` : info.curve || info.algorithm; + } catch { + return 'unknown'; + } +} + +/** Parse an armored public OR private key block and return its metadata. */ +export async function inspectKey(armored: string): Promise { + const openpgp = await pgp(); + const isPrivate = /BEGIN PGP PRIVATE KEY/.test(armored); + const key: OpenPgp.Key = isPrivate + ? await openpgp.readPrivateKey({ armoredKey: armored }) + : await openpgp.readKey({ armoredKey: armored }); + let expiration: string | null = null; + try { + const exp = await key.getExpirationTime(); + expiration = exp === Infinity || exp == null ? null : new Date(exp as Date).toISOString(); + } catch { + // some keys throw on expiration lookups; treat as non-expiring + } + return { + isPrivate, + fingerprint: key.getFingerprint(), + keyID: key.getKeyID().toHex(), + userIDs: describeUserIDs(key), + created: key.getCreationTime().toISOString(), + expiration, + publicKeyArmored: isPrivate ? key.toPublic().armor() : armored, + algorithm: keyAlgorithm(key), + }; +} + +/** Thrown for a key protected with Argon2, whose WebAssembly module Hermes can't run. */ +export class PgpArgon2UnsupportedError extends Error { + constructor() { + super('This key is protected with Argon2, which the app cannot unlock yet. Export it again with the default (iterated) passphrase protection, for example with GnuPG.'); + this.name = 'PgpArgon2UnsupportedError'; + } +} + +/** Thrown for a wrong passphrase. */ +export class PgpWrongPassphraseError extends Error { + constructor() { + super('Wrong passphrase'); + this.name = 'PgpWrongPassphraseError'; + } +} + +/** Is the armored private key protected by a passphrase? */ +export async function privateKeyNeedsPassphrase(armored: string): Promise { + const openpgp = await pgp(); + const key = await openpgp.readPrivateKey({ armoredKey: armored }); + return !key.isDecrypted(); +} + +/** Unlock a (possibly passphrase-protected) armored private key. */ +export async function unlockPrivateKey(armored: string, passphrase: string): Promise { + const openpgp = await pgp(); + const privateKey = await openpgp.readPrivateKey({ armoredKey: armored }); + if (privateKey.isDecrypted()) return privateKey; + const usesArgon2 = privateKey.getKeys().some((k) => (k.keyPacket as { s2k?: { type?: string } }).s2k?.type === 'argon2'); + try { + return await openpgp.decryptKey({ privateKey, passphrase }); + } catch (err) { + if (usesArgon2 && typeof (globalThis as { WebAssembly?: unknown }).WebAssembly === 'undefined') throw new PgpArgon2UnsupportedError(); + const msg = err instanceof Error ? err.message : String(err); + if (/incorrect key passphrase|passphrase/i.test(msg)) throw new PgpWrongPassphraseError(); + throw err; + } +} + +async function readKeys(armoredList: string[]): Promise { + const openpgp = await pgp(); + const keys = await Promise.all(armoredList.map((a) => openpgp.readKey({ armoredKey: a }).catch(() => null))); + return keys.filter((k): k is OpenPgp.Key => !!k); +} + +async function summariseSignatures(signatures: OpenPgp.VerifyMessageResult['signatures'] | undefined, keys: OpenPgp.Key[]): Promise { + if (!signatures || signatures.length === 0) return { status: 'none' }; + const sig = signatures[0]; + // The signing (sub)key's id; the plugin compared primary key ids only. + const signer = keys.find((k) => k.getKeys(sig.keyID).length > 0); + const signerEmails = signer ? describeUserIDs(signer).map((u) => u.email).filter((e) => e.includes('@')) : undefined; + const who = signerEmails?.length ? { signerEmail: signerEmails[0], signerEmails } : {}; + try { + await sig.verified; // throws if the signature does not validate + return { status: 'valid', keyID: sig.keyID.toHex(), ...who }; + } catch (err) { + // "Could not find signing key" still means the bytes were signed; we just + // lack the public key to confirm who signed it. + const message = err instanceof Error ? err.message : String(err); + const unknownKey = !signer || /find signing key|No key/i.test(message); + return { status: unknownKey ? 'unknown' : 'invalid', keyID: sig.keyID.toHex(), error: message, ...who }; + } +} + +/** Encrypt (and optionally sign) bytes to the given public keys. Returns an armored PGP MESSAGE. */ +export async function encryptBytes(opts: { + data: Uint8Array; + recipientPublicKeysArmored: string[]; + signingKey?: OpenPgp.PrivateKey | null; +}): Promise { + const openpgp = await pgp(); + const encryptionKeys = await Promise.all(opts.recipientPublicKeysArmored.map((a) => openpgp.readKey({ armoredKey: a }))); + const message = await openpgp.createMessage({ binary: opts.data }); + return (await openpgp.encrypt({ + message, + encryptionKeys, + signingKeys: opts.signingKey ?? undefined, + format: 'armored', + config: { preferredSymmetricAlgorithm: openpgp.enums.symmetric.aes256 }, + })) as string; +} + +/** Detached armored signature over exact bytes (a PGP/MIME signed part). */ +export async function detachedSign(data: Uint8Array, signingKey: OpenPgp.PrivateKey): Promise { + const openpgp = await pgp(); + const message = await openpgp.createMessage({ binary: data }); + return (await openpgp.sign({ message, signingKeys: signingKey, detached: true, format: 'armored' })) as string; +} + +/** Decrypt an armored PGP MESSAGE and verify embedded signatures. */ +export async function decryptMessage(opts: { + armoredMessage: string; + decryptionKeys: OpenPgp.PrivateKey[]; + verificationKeysArmored: string[]; +}): Promise<{ data: Uint8Array; signature: PgpSignatureSummary }> { + const openpgp = await pgp(); + const message = await openpgp.readMessage({ armoredMessage: opts.armoredMessage }); + const verificationKeys = await readKeys(opts.verificationKeysArmored); + const { data, signatures } = await openpgp.decrypt({ + message, + decryptionKeys: opts.decryptionKeys, + verificationKeys, + expectSigned: false, + format: 'binary', + }); + return { data: data as Uint8Array, signature: await summariseSignatures(signatures, verificationKeys) }; +} + +/** The key ids an encrypted message is addressed to (to pick the key to unlock). */ +export async function encryptionKeyIds(armoredMessage: string): Promise { + const openpgp = await pgp(); + const message = await openpgp.readMessage({ armoredMessage }); + return message.getEncryptionKeyIDs().map((id) => id.toHex()); +} + +/** Do any of the key's (sub)keys have one of these ids? */ +export async function keyHasId(armoredKey: string, keyIds: string[]): Promise { + const openpgp = await pgp(); + const key = await openpgp.readKey({ armoredKey }); + const ids = new Set(key.getKeyIDs().map((id) => id.toHex())); + return keyIds.some((id) => ids.has(id) || id === '0000000000000000'); +} + +/** Verify a detached signature over the exact bytes it covers. */ +export async function verifyDetached(opts: { + data: Uint8Array; + armoredSignature: string; + verificationKeysArmored: string[]; +}): Promise { + const openpgp = await pgp(); + const message = await openpgp.createMessage({ binary: opts.data }); + const signature = await openpgp.readSignature({ armoredSignature: opts.armoredSignature }); + const verificationKeys = await readKeys(opts.verificationKeysArmored); + const result = await openpgp.verify({ message, signature, verificationKeys }); + return summariseSignatures(result.signatures, verificationKeys); +} + +/** Verify a cleartext-signed message ("-----BEGIN PGP SIGNED MESSAGE-----"). */ +export async function verifyCleartext(opts: { + cleartext: string; + verificationKeysArmored: string[]; +}): Promise<{ text: string; signature: PgpSignatureSummary }> { + const openpgp = await pgp(); + const signedMessage = await openpgp.readCleartextMessage({ cleartextMessage: opts.cleartext }); + const verificationKeys = await readKeys(opts.verificationKeysArmored); + const result = await openpgp.verify({ message: signedMessage, verificationKeys }); + return { text: signedMessage.getText(), signature: await summariseSignatures(result.signatures, verificationKeys) }; +} + +// ── detection (incoming) ───────────────────────────────────────────── + +const ARMOR_MESSAGE = /-----BEGIN PGP MESSAGE-----[\s\S]+?-----END PGP MESSAGE-----/; +const ARMOR_SIGNED = /-----BEGIN PGP SIGNED MESSAGE-----[\s\S]+?-----END PGP SIGNATURE-----/; +const ARMOR_SIGNATURE = /-----BEGIN PGP SIGNATURE-----[\s\S]+?-----END PGP SIGNATURE-----/; +const ARMOR_PUBKEY = /-----BEGIN PGP PUBLIC KEY BLOCK-----[\s\S]+?-----END PGP PUBLIC KEY BLOCK-----/; + +export type PgpDetection = + | { kind: 'pgp-mime-encrypted'; ciphertext: string } + | { kind: 'pgp-mime-signed'; signedBytes: Uint8Array; signature: string } + | { kind: 'inline-encrypted'; ciphertext: string } + | { kind: 'inline-signed'; cleartext: string } + | { kind: 'none' }; + +function extract(re: RegExp, text: string): string { + const m = re.exec(text || ''); + return m ? m[0] : ''; +} + +function decodedText(node: MimeNode): string { + const bytes = decodeBody(node); + try { + return new TextDecoder((node.params.charset || 'utf-8').toLowerCase(), { fatal: false }).decode(bytes); + } catch { + return new TextDecoder('utf-8', { fatal: false }).decode(bytes); + } +} + +/** Classify a raw RFC 5322 message (bytes) for PGP content. */ +export function detectPgp(raw: Uint8Array): PgpDetection { + const root = parseEntity(bytesToBinary(raw)); + + const encrypted = findNode(root, (n) => n.type === 'multipart/encrypted' && /pgp-encrypted/i.test(n.params.protocol || '')); + if (encrypted) { + const part = encrypted.children.find((p) => ARMOR_MESSAGE.test(p.body)) ?? encrypted.children[1]; + const ciphertext = part ? extract(ARMOR_MESSAGE, part.cte === 'base64' || part.cte === 'quoted-printable' ? bytesToBinary(decodeBody(part)) : part.body) : ''; + if (ciphertext) return { kind: 'pgp-mime-encrypted', ciphertext }; + } + + const signed = findNode(root, (n) => n.type === 'multipart/signed' && /pgp-signature/i.test(n.params.protocol || '')); + if (signed && signed.children.length >= 2) { + const sigPart = signed.children.find((p) => p.type === 'application/pgp-signature'); + const signature = sigPart ? extract(ARMOR_SIGNATURE, bytesToBinary(decodeBody(sigPart))) : ''; + if (signature) { + return { kind: 'pgp-mime-signed', signedBytes: binaryToBytes(signed.children[0].raw), signature }; + } + } + + // Inline PGP: armor inside a text part (decoded: quoted-printable turns the + // armor's '=' checksum line into '=3D'). + const texts: string[] = []; + const collect = (n: MimeNode) => { + if (n.children.length) n.children.forEach(collect); + else if (n.type === 'text/plain') texts.push(decodedText(n)); + }; + collect(root); + for (const text of texts) { + if (ARMOR_MESSAGE.test(text)) return { kind: 'inline-encrypted', ciphertext: extract(ARMOR_MESSAGE, text) }; + if (ARMOR_SIGNED.test(text)) return { kind: 'inline-signed', cleartext: extract(ARMOR_SIGNED, text) }; + } + return { kind: 'none' }; +} + +export function extractPublicKeyBlock(text: string): string { + return extract(ARMOR_PUBKEY, text || ''); +} + +/** CRLF-canonical bytes of a signed part (RFC 3156 §5), for a relay that dropped the CRs. */ +export function canonicalSignedBytes(bytes: Uint8Array): Uint8Array { + return binaryToBytes(toCrlf(bytesToBinary(bytes))); +} diff --git a/src/lib/crypto/provider.ts b/src/lib/crypto/provider.ts new file mode 100644 index 00000000..6f57f90b --- /dev/null +++ b/src/lib/crypto/provider.ts @@ -0,0 +1,96 @@ +// The crypto the PGP and S/MIME code (#58) runs on, beyond WebCrypto. +// +// openpgp and pkijs only need `globalThis.crypto` (getRandomValues + subtle). +// S/MIME mail still uses a few things WebCrypto has no names for: RSAES- +// PKCS1-v1_5 key transport, 3DES content encryption and ChaCha20-Poly1305 +// (Stalwart's encryption at rest). Those go through a Node-style crypto +// module: react-native-quick-crypto in the app (src/lib/crypto/install- +// webcrypto.ts registers it), node:crypto in the tests. Both are OpenSSL. + +/** The part of Node's crypto module this code uses. */ +export interface NodeStyleCrypto { + privateDecrypt( + key: { key: unknown; format?: string; type?: string; padding?: number; oaepHash?: string }, + buffer: Uint8Array, + ): Uint8Array; + createDecipheriv( + algorithm: string, + key: Uint8Array, + iv: Uint8Array, + options?: { authTagLength?: number }, + ): { + update(data: Uint8Array): Uint8Array; + final(): Uint8Array; + setAuthTag?(tag: Uint8Array): unknown; + setAAD?(aad: Uint8Array, options?: { plaintextLength: number }): unknown; + setAutoPadding?(on: boolean): unknown; + }; + constants: { RSA_PKCS1_PADDING: number }; +} + +let load: (() => NodeStyleCrypto) | null = null; +let cached: NodeStyleCrypto | null = null; + +/** Register where the Node-style crypto comes from (loaded on first use). */ +export function setNodeStyleCrypto(loader: () => NodeStyleCrypto): void { + load = loader; + cached = null; +} + +/** The registered Node-style crypto, or an error naming what is missing. */ +export function nodeStyleCrypto(): NodeStyleCrypto { + if (cached) return cached; + if (!load) throw new Error('No native crypto module is available'); + cached = load(); + return cached; +} + +/** The WebCrypto subtle the app installed, or an error the UI can show. */ +export function webCrypto(): Crypto { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (!c?.subtle || !c.getRandomValues) throw new Error('The WebCrypto API is not available'); + return c; +} + +/** Copy a view into a standalone ArrayBuffer (WebCrypto and pkijs want one). */ +export function toArrayBuffer(view: ArrayBuffer | ArrayBufferView): ArrayBuffer { + if (view instanceof ArrayBuffer) return view; + return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; +} + +/** Concatenate byte arrays. */ +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.byteLength; + } + return out; +} + +/** Decrypt RSAES-PKCS1-v1_5 (WebCrypto has no name for it) with a PKCS#8 key. */ +export function rsaPkcs1Decrypt(pkcs8: Uint8Array, ciphertext: Uint8Array): Uint8Array { + const nc = nodeStyleCrypto(); + const out = nc.privateDecrypt( + { key: pkcs8, format: 'der', type: 'pkcs8', padding: nc.constants.RSA_PKCS1_PADDING }, + ciphertext, + ); + return new Uint8Array(out); +} + +/** CBC decryption for ciphers WebCrypto lacks (3DES). PKCS#7 padding is removed. */ +export function cbcDecrypt(algorithm: 'des-ede3-cbc', key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array { + const d = nodeStyleCrypto().createDecipheriv(algorithm, key, iv); + return concatBytes(new Uint8Array(d.update(data)), new Uint8Array(d.final())); +} + +/** ChaCha20-Poly1305 (RFC 8103) decryption; the tag is the last 16 bytes of `sealed`. */ +export function chacha20Poly1305Decrypt(key: Uint8Array, nonce: Uint8Array, sealed: Uint8Array, aad?: Uint8Array): Uint8Array { + if (sealed.byteLength < 16) throw new Error('ChaCha20-Poly1305 ciphertext is too short'); + const d = nodeStyleCrypto().createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 }); + const body = sealed.subarray(0, sealed.byteLength - 16); + d.setAuthTag?.(sealed.subarray(sealed.byteLength - 16)); + if (aad) d.setAAD?.(aad, { plaintextLength: body.byteLength }); + return concatBytes(new Uint8Array(d.update(body)), new Uint8Array(d.final())); +} diff --git a/src/lib/crypto/secret-store.ts b/src/lib/crypto/secret-store.ts new file mode 100644 index 00000000..8cf31fc9 --- /dev/null +++ b/src/lib/crypto/secret-store.ts @@ -0,0 +1,49 @@ +// Private key material for PGP and S/MIME (#58) lives in SecureStore only +// (Keychain / Keystore), never in AsyncStorage. A PGP private key or a .p12 +// is often larger than the 2048 bytes SecureStore promises to hold in one +// value, so a secret is split over numbered items with a count item. + +import * as SecureStore from 'expo-secure-store'; + +const CHUNK = 1800; +const PREFIX = 'bulwark_crypto_'; + +// Not restored from a backup onto another device, and only readable while +// the device is unlocked. +const options = (): SecureStore.SecureStoreOptions => ({ keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + +function keyFor(id: string): string { + return PREFIX + id.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +export async function setSecret(id: string, value: string): Promise { + const base = keyFor(id); + await deleteSecret(id); + const parts = Math.max(1, Math.ceil(value.length / CHUNK)); + for (let i = 0; i < parts; i++) { + await SecureStore.setItemAsync(`${base}.${i}`, value.slice(i * CHUNK, (i + 1) * CHUNK), options()); + } + // The count goes last: a half-written secret reads as missing. + await SecureStore.setItemAsync(`${base}.n`, String(parts), options()); +} + +export async function getSecret(id: string): Promise { + const base = keyFor(id); + const n = Number(await SecureStore.getItemAsync(`${base}.n`, options())); + if (!Number.isInteger(n) || n <= 0) return null; + let out = ''; + for (let i = 0; i < n; i++) { + const part = await SecureStore.getItemAsync(`${base}.${i}`, options()); + if (part == null) return null; + out += part; + } + return out; +} + +export async function deleteSecret(id: string): Promise { + const base = keyFor(id); + const n = Number(await SecureStore.getItemAsync(`${base}.n`, options()).catch(() => null)); + await SecureStore.deleteItemAsync(`${base}.n`, options()).catch(() => undefined); + const count = Number.isInteger(n) && n > 0 ? n : 0; + for (let i = 0; i < count; i++) await SecureStore.deleteItemAsync(`${base}.${i}`, options()).catch(() => undefined); +} diff --git a/src/lib/crypto/secure-message.ts b/src/lib/crypto/secure-message.ts new file mode 100644 index 00000000..46a4ad08 --- /dev/null +++ b/src/lib/crypto/secure-message.ts @@ -0,0 +1,301 @@ +// Reading PGP and S/MIME mail (#58): detect, decrypt, verify, and hand the +// reader a body and a status for the banner. Port of the plugins' render +// hooks: onRenderEmailBody in repos/plugins/bulwark-gpg/src/index.js and +// repos/plugins/smime/src/index.js. The raw message comes from the JMAP blob +// download of the email's blobId (the plugins' host.jmap.fetchBlob). + +import type * as OpenPgp from 'openpgp'; +import type { BodyPart, Email } from '../../api/types'; +import { binaryToBytes, bytesToBinary, decodeBody, findNode, parseEntity, type MimeNode } from './mime'; +import { + detectPgp, decryptMessage, verifyDetached, verifyCleartext, encryptionKeyIds, keyHasId, canonicalSignedBytes, + extractPublicKeyBlock, PgpArgon2UnsupportedError, type PgpSignatureSummary, +} from './pgp'; +import type { SmimeKeyMaterial, SmimeSignatureStatus } from './smime/cms'; + +export type SecureKind = 'pgp' | 'smime'; +export type SignatureState = 'none' | 'valid' | 'invalid' | 'unknown'; + +export interface SecureStatus { + kind: SecureKind; + encrypted: boolean; + decrypted: boolean; + /** Encrypted to a key of ours that is locked: ids of the keys to unlock. */ + lockedKeyIds?: string[]; + signature: SignatureState; + signerEmail?: string; + /** The signer's address differs from From. */ + signerMismatch?: boolean; + /** S/MIME: self-signed signer certificate (not checked against a CA). */ + selfSigned?: boolean; + /** Why the signature is invalid, or why decryption failed. */ + error?: string; + /** Encrypted with an algorithm the app can't read (named). */ + unsupportedAlgorithm?: string; + /** No key of ours can decrypt it. */ + noKey?: boolean; +} + +export interface SecureAttachment { + name: string; + type: string; + size: number; + bytes: Uint8Array; +} + +export interface SecureResult { + status: SecureStatus; + /** The decrypted/verified body, or null when there is none to show (locked, failed). */ + body: { html: string | null; text: string | null } | null; + attachments: SecureAttachment[]; +} + +/** What the reader needs from the key store; injected so tests need no SecureStore. */ +export interface SecureKeyAccess { + /** Unlocked private PGP keys. */ + pgpDecryptionKeys(): OpenPgp.PrivateKey[]; + /** Own PGP keys (unlocked or not): id + armored public key. */ + pgpOwnKeys(): Array<{ id: string; publicKeyArmored: string; unlocked: boolean }>; + /** Every known PGP public key, to verify signatures. */ + pgpPublicKeys(): string[]; + /** Own S/MIME keys; `pkcs8` set when unlocked. */ + smimeKeys(): SmimeKeyMaterial[]; + rememberPgpKey?(armored: string): Promise; + rememberCertificate?(der: Uint8Array): Promise; +} + +type PartLike = Pick & { subParts?: PartLike[] }; + +/** + * Cheap check on the email object (no download): could this be PGP or + * S/MIME? multipart/encrypted, multipart/signed with a pgp/pkcs7 signature, + * pkcs7-mime parts, or inline armor in the preview or text body. + */ +export function secureKindHint(email: Pick): SecureKind | null { + const types: string[] = []; + const collect = (part: PartLike | undefined) => { + if (!part) return; + if (part.type) types.push(part.type.toLowerCase()); + part.subParts?.forEach(collect); + }; + collect(email.bodyStructure as PartLike | undefined); + (email.attachments ?? []).forEach((a) => types.push((a.type || '').toLowerCase())); + if (types.some((t) => t.includes('pkcs7-mime') || t.includes('pkcs7-signature'))) return 'smime'; + if (types.some((t) => t === 'multipart/encrypted' || t === 'application/pgp-encrypted' || t === 'application/pgp-signature')) return 'pgp'; + const text = [ + email.preview ?? '', + ...((email.textBody ?? []).map((p) => (p.partId ? email.bodyValues?.[p.partId]?.value ?? '' : ''))), + ].join('\n'); + if (/-----BEGIN PGP (MESSAGE|SIGNED MESSAGE)-----/.test(text)) return 'pgp'; + return null; +} + +/** A decrypted payload: a MIME entity, or (inline PGP) plain text. */ +async function renderPayload(bytes: Uint8Array): Promise<{ body: SecureResult['body']; attachments: SecureAttachment[] }> { + const head = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 512)); + const looksLikeMime = /^(?:[A-Za-z][A-Za-z0-9-]*:[^\r\n]*\r?\n(?:[ \t][^\r\n]*\r?\n)*)+\r?\n/.test(head) || /^content-type:/i.test(head); + if (!looksLikeMime) { + return { body: { html: null, text: new TextDecoder('utf-8', { fatal: false }).decode(bytes) }, attachments: [] }; + } + const { default: PostalMime } = await import('postal-mime'); + const parsed = await PostalMime.parse(bytes, { attachmentEncoding: 'arraybuffer' }); + let html = parsed.html ?? null; + const attachments: SecureAttachment[] = []; + for (const att of parsed.attachments ?? []) { + const data = typeof att.content === 'string' ? new TextEncoder().encode(att.content) : new Uint8Array(att.content as ArrayBuffer); + const cid = att.contentId?.replace(/^<|>$/g, ''); + if (cid && html && html.includes(`cid:${cid}`)) { + html = html.split(`cid:${cid}`).join(`data:${att.mimeType || 'application/octet-stream'};base64,${btoa(bytesToBinary(data))}`); + if (att.disposition === 'inline') continue; + } + attachments.push({ name: att.filename || 'attachment', type: att.mimeType || 'application/octet-stream', size: data.byteLength, bytes: data }); + } + let text = parsed.text ?? null; + // Non-MIME content signed or encrypted by OpenSSL and older clients: + // never show a blank message. + if (!html && !(text && text.trim()) && attachments.length === 0) { + text = new TextDecoder('utf-8', { fatal: false }).decode(bytes).trim(); + } + return { body: { html, text }, attachments }; +} + +function applyPgpSignature(status: SecureStatus, sig: PgpSignatureSummary, fromEmail?: string): void { + status.signature = sig.status; + if (sig.signerEmail) { + status.signerEmail = sig.signerEmail; + if (fromEmail && sig.status === 'valid' && sig.signerEmails && !sig.signerEmails.includes(fromEmail.toLowerCase())) status.signerMismatch = true; + } + if (sig.status === 'invalid' && sig.error) status.error = sig.error; +} + +async function openPgp(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise { + const info = detectPgp(raw); + if (info.kind === 'none') return null; + const status: SecureStatus = { kind: 'pgp', encrypted: false, decrypted: false, signature: 'none' }; + const verificationKeys = keys.pgpPublicKeys(); + try { + if (info.kind === 'pgp-mime-encrypted' || info.kind === 'inline-encrypted') { + status.encrypted = true; + const decryptionKeys = keys.pgpDecryptionKeys(); + const ids = await encryptionKeyIds(info.ciphertext); + const own = keys.pgpOwnKeys(); + const addressed: typeof own = []; + for (const k of own) if (await keyHasId(k.publicKeyArmored, ids)) addressed.push(k); + if (addressed.length === 0) { + status.noKey = true; + status.error = 'No PGP key of yours can decrypt this message'; + return { status, body: null, attachments: [] }; + } + if (!addressed.some((k) => k.unlocked) || decryptionKeys.length === 0) { + status.lockedKeyIds = addressed.map((k) => k.id); + return { status, body: null, attachments: [] }; + } + const dec = await decryptMessage({ armoredMessage: info.ciphertext, decryptionKeys, verificationKeysArmored: verificationKeys }); + status.decrypted = true; + // PGP/MIME may sign the inner entity again (multipart/signed inside). + let payload = dec.data; + let sig = dec.signature; + if (sig.status === 'none') { + const inner = detectPgp(dec.data); + if (inner.kind === 'pgp-mime-signed') { + sig = await verifySignedPart(inner.signedBytes, inner.signature, verificationKeys); + payload = inner.signedBytes; + } + } + applyPgpSignature(status, sig, fromEmail); + const rendered = await renderPayload(payload); + await maybeRememberEmbeddedKey(rendered.body?.text, keys); + return { status, ...rendered }; + } + if (info.kind === 'pgp-mime-signed') { + applyPgpSignature(status, await verifySignedPart(info.signedBytes, info.signature, verificationKeys), fromEmail); + return { status, ...(await renderPayload(info.signedBytes)) }; + } + // inline-signed + const v = await verifyCleartext({ cleartext: info.cleartext, verificationKeysArmored: verificationKeys }); + applyPgpSignature(status, v.signature, fromEmail); + await maybeRememberEmbeddedKey(v.text, keys); + return { status, body: { html: null, text: v.text }, attachments: [] }; + } catch (err) { + status.error = err instanceof PgpArgon2UnsupportedError ? err.message : err instanceof Error ? err.message : String(err); + if (status.encrypted) status.decrypted = false; + else if (status.signature === 'none') status.signature = 'invalid'; + return { status, body: null, attachments: [] }; + } +} + +async function verifySignedPart(signed: Uint8Array, signature: string, verificationKeys: string[]): Promise { + const first = await verifyDetached({ data: signed, armoredSignature: signature, verificationKeysArmored: verificationKeys }); + if (first.status !== 'invalid') return first; + // A relay that dropped the CRs: check the canonical form too. + const canonical = canonicalSignedBytes(signed); + if (canonical.length === signed.length) return first; + const retry = await verifyDetached({ data: canonical, armoredSignature: signature, verificationKeysArmored: verificationKeys }); + return retry.status === 'valid' ? retry : first; +} + +async function maybeRememberEmbeddedKey(text: string | null | undefined, keys: SecureKeyAccess): Promise { + const block = extractPublicKeyBlock(text ?? ''); + if (block && keys.rememberPgpKey) await keys.rememberPgpKey(block); +} + +function applySmimeSignature(status: SecureStatus, sig: SmimeSignatureStatus): void { + status.signature = sig.signatureValid ? 'valid' : 'invalid'; + status.signerEmail = sig.signerEmail; + status.selfSigned = sig.selfSigned; + if (sig.signerEmailMatch === false) status.signerMismatch = true; + if (!sig.signatureValid && sig.signatureError) status.error = sig.signatureError; +} + +async function openSmime(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise { + const root = parseEntity(bytesToBinary(raw)); + const isDetached = (n: MimeNode) => n.type === 'multipart/signed' && /pkcs7-signature/i.test(n.params.protocol || ''); + const isOpaque = (n: MimeNode) => n.type === 'application/pkcs7-mime' || n.type === 'application/x-pkcs7-mime'; + const node = findNode(root, (n) => isDetached(n) || isOpaque(n)); + if (!node) return null; + + const cms = await import('./smime/cms'); + const status: SecureStatus = { kind: 'smime', encrypted: false, decrypted: false, signature: 'none' }; + try { + if (isDetached(node)) { + const sigPart = node.children.find((c) => /^application\/(x-)?pkcs7-signature$/.test(c.type)); + const signed = node.children[0]; + if (!sigPart || !signed || signed === sigPart) { + status.signature = 'invalid'; + status.error = 'Malformed multipart/signed message'; + return { status, body: null, attachments: [] }; + } + const entity = binaryToBytes(signed.raw); + const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail); + applySmimeSignature(status, sig); + if (sig.signatureValid && sig.signerCertificate) await keys.rememberCertificate?.(sig.signerCertificate); + return { status, ...(await renderPayload(entity)) }; + } + + const der = decodeBody(node); + if (cms.isSignedData(der)) { + const v = await cms.smimeVerifyOpaque(der, fromEmail); + applySmimeSignature(status, v.status); + if (v.status.signatureValid && v.status.signerCertificate) await keys.rememberCertificate?.(v.status.signerCertificate); + return { status, ...(await renderPayload(v.mimeBytes)) }; + } + + status.encrypted = true; + const material = keys.smimeKeys(); + let decrypted: Uint8Array; + try { + decrypted = (await cms.smimeDecrypt(der, material)).mimeBytes; + } catch (err) { + if (err instanceof cms.SmimeKeyLockedError) { + status.lockedKeyIds = cms.matchingKeyIds(der, material).filter((id) => !material.find((m) => m.id === id)?.pkcs8); + return { status, body: null, attachments: [] }; + } + if (err instanceof cms.SmimeNoMatchingKeyError) { + status.noKey = true; + status.error = err.message; + return { status, body: null, attachments: [] }; + } + if (err instanceof cms.UnsupportedAlgorithmError) { + status.unsupportedAlgorithm = err.algorithm; + status.error = err.message; + return { status, body: null, attachments: [] }; + } + throw err; + } + status.decrypted = true; + + // Sign-then-encrypt: the decrypted content is a signed CMS, nested as a + // MIME entity (RFC 8551, Thunderbird/Outlook) or, rarely, raw DER. + let payload = decrypted; + const inner = parseEntity(bytesToBinary(decrypted.subarray(0, Math.min(decrypted.length, 1 << 20)))); + const innerSigned = inner.type === 'application/pkcs7-mime' || inner.type === 'application/x-pkcs7-mime' + ? decodeBody(parseEntity(bytesToBinary(decrypted))) + : decrypted[0] === 0x30 ? decrypted : null; + if (innerSigned && cms.isSignedData(innerSigned)) { + const v = await cms.smimeVerifyOpaque(innerSigned, fromEmail); + applySmimeSignature(status, v.status); + if (v.status.signatureValid && v.status.signerCertificate) await keys.rememberCertificate?.(v.status.signerCertificate); + payload = v.mimeBytes; + } else if (inner.type === 'multipart/signed' && /pkcs7-signature/i.test(inner.params.protocol || '')) { + const full = parseEntity(bytesToBinary(decrypted)); + const sigPart = full.children.find((c) => /^application\/(x-)?pkcs7-signature$/.test(c.type)); + const signed = full.children[0]; + if (sigPart && signed) { + const entity = binaryToBytes(signed.raw); + const sig = await cms.smimeVerifyDetached(decodeBody(sigPart), entity, fromEmail); + applySmimeSignature(status, sig); + payload = entity; + } + } + return { status, ...(await renderPayload(payload)) }; + } catch (err) { + status.error = err instanceof Error ? err.message : String(err); + if (!status.encrypted && status.signature === 'none') status.signature = 'invalid'; + return { status, body: null, attachments: [] }; + } +} + +/** Open a raw message: PGP or S/MIME result, or null for an ordinary message. */ +export async function openSecureMessage(raw: Uint8Array, fromEmail: string | undefined, keys: SecureKeyAccess): Promise { + return (await openPgp(raw, fromEmail, keys)) ?? (await openSmime(raw, fromEmail, keys)); +} diff --git a/src/lib/crypto/secure-send.ts b/src/lib/crypto/secure-send.ts new file mode 100644 index 00000000..6fbc1b46 --- /dev/null +++ b/src/lib/crypto/secure-send.ts @@ -0,0 +1,77 @@ +// Building signed and/or encrypted outgoing mail (#58). Port of the +// plugins' onComposeSend: repos/plugins/bulwark-gpg/src/index.js (PGP/MIME, +// RFC 3156) and repos/plugins/smime/src/index.js (detached or opaque +// signing, sign-then-encrypt). The result is a complete RFC 5322 message; +// the caller uploads it and sends it with Email/import + EmailSubmission +// (src/api/email.ts sendEmail with a raw blob, the plugins' host.jmap.sendRaw). + +import type * as OpenPgp from 'openpgp'; +import { + buildMimeEntity, buildMimeMessage, cmsInnerEntity, wrapCmsMessage, wrapPgpEncrypted, wrapPgpSigned, wrapSmimeDetached, + type MimeMessageInput, +} from './mime'; +import { detachedSign, encryptBytes } from './pgp'; +import type { SmimeSigner } from './smime/cms'; + +export type SecureMode = 'pgp' | 'smime'; + +export interface SecureSendOptions { + mode: SecureMode; + sign: boolean; + encrypt: boolean; +} + +export interface PgpSendKeys { + /** Unlocked; required to sign. */ + signingKey?: OpenPgp.PrivateKey | null; + /** Armored public keys of every recipient plus the sender (encrypt-to-self). */ + encryptionKeys?: string[]; +} + +export interface SmimeSendKeys { + signer?: SmimeSigner; + /** DER certificates of every recipient. */ + recipientCerts?: Uint8Array[]; + /** The sender's certificate, so Sent stays readable. */ + senderCert?: Uint8Array; +} + +/** Build the raw message for a signed and/or encrypted send. */ +export async function buildSecureMessage( + input: MimeMessageInput, + opts: SecureSendOptions, + keys: { pgp?: PgpSendKeys; smime?: SmimeSendKeys }, +): Promise { + if (!opts.sign && !opts.encrypt) throw new Error('Nothing to sign or encrypt'); + + if (opts.mode === 'pgp') { + const k = keys.pgp ?? {}; + if (opts.sign && !k.signingKey) throw new Error('No unlocked PGP key to sign with'); + const entity = buildMimeEntity(input); + if (opts.encrypt) { + if (!k.encryptionKeys?.length) throw new Error('No recipient public keys available to encrypt to'); + const armored = await encryptBytes({ data: entity, recipientPublicKeysArmored: [...new Set(k.encryptionKeys)], signingKey: opts.sign ? k.signingKey : null }); + return wrapPgpEncrypted(armored, input); + } + const signature = await detachedSign(entity, k.signingKey!); + return wrapPgpSigned(entity, signature, input); + } + + const cms = await import('./smime/cms'); + const k = keys.smime ?? {}; + if (opts.sign && !k.signer) throw new Error('No unlocked S/MIME key to sign with'); + + // Sign only: detached multipart/signed, readable without S/MIME (the + // plugin's default signature format). + if (opts.sign && !opts.encrypt) { + const entity = buildMimeEntity(input); + return wrapSmimeDetached(entity, await cms.smimeSignDetached(entity, k.signer!), input); + } + + // Encrypt, signed first when asked (RFC 8551 sign-then-encrypt). + if (!k.recipientCerts?.length && !k.senderCert) throw new Error('No recipient certificates available to encrypt to'); + let payload = buildMimeMessage(input); + if (opts.sign) payload = cmsInnerEntity(await cms.smimeSignOpaque(payload, k.signer!), 'signed-data'); + const enveloped = await cms.smimeEncrypt(payload, k.recipientCerts ?? [], k.senderCert ?? k.recipientCerts![0]); + return wrapCmsMessage(enveloped, 'enveloped-data', input); +} diff --git a/src/lib/crypto/self-test.ts b/src/lib/crypto/self-test.ts new file mode 100644 index 00000000..94641e7c --- /dev/null +++ b/src/lib/crypto/self-test.ts @@ -0,0 +1,171 @@ +// Settings > Encryption > Check encryption support (#58): runs each +// algorithm PGP and S/MIME rely on through the installed crypto +// (react-native-quick-crypto on a device) and reports which work. On a new +// platform or library version this names a gap before a message fails. + +import { cbcDecrypt, chacha20Poly1305Decrypt, nodeStyleCrypto, rsaPkcs1Decrypt, toArrayBuffer, webCrypto } from './provider'; + +export interface SelfTestResult { + name: string; + ok: boolean; + error?: string; +} + +type Check = [name: string, run: () => Promise]; + +function assert(cond: unknown, what: string): void { + if (!cond) throw new Error(what); +} + +const bytes = (s: string) => new TextEncoder().encode(s); + +function checks(): Check[] { + const subtle = () => webCrypto().subtle; + const data = bytes('bulwark self-test'); + return [ + ['SHA-1 / SHA-256 / SHA-512', async () => { + for (const h of ['SHA-1', 'SHA-256', 'SHA-512']) assert((await subtle().digest(h, data)).byteLength > 0, h); + }], + ['AES-GCM', async () => { + const key = await subtle().generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + const iv = webCrypto().getRandomValues(new Uint8Array(12)); + const ct = await subtle().encrypt({ name: 'AES-GCM', iv }, key, data); + assert(new TextDecoder().decode(await subtle().decrypt({ name: 'AES-GCM', iv }, key, ct)) === 'bulwark self-test', 'round trip'); + }], + ['AES-CBC / AES-CTR / AES-KW', async () => { + const raw = webCrypto().getRandomValues(new Uint8Array(32)); + const cbc = await subtle().importKey('raw', raw, 'AES-CBC', false, ['encrypt', 'decrypt']); + const iv = new Uint8Array(16); + const ct = await subtle().encrypt({ name: 'AES-CBC', iv }, cbc, data); + assert((await subtle().decrypt({ name: 'AES-CBC', iv }, cbc, ct)).byteLength === data.byteLength, 'cbc'); + const ctr = await subtle().importKey('raw', raw, 'AES-CTR', false, ['encrypt']); + assert((await subtle().encrypt({ name: 'AES-CTR', counter: iv, length: 64 }, ctr, data)).byteLength === data.byteLength, 'ctr'); + const kw = await subtle().importKey('raw', raw, 'AES-KW', false, ['wrapKey']); + const hmac = await subtle().importKey('raw', raw, { name: 'HMAC', hash: 'SHA-256' }, true, ['sign']); + assert((await subtle().wrapKey('raw', hmac, kw, 'AES-KW')).byteLength === 40, 'kw'); + }], + ['RSA-OAEP / RSA PKCS#1 signatures', async () => { + const oaep = await subtle().generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['encrypt', 'decrypt']) as CryptoKeyPair; + const ct = await subtle().encrypt({ name: 'RSA-OAEP' }, oaep.publicKey, data); + assert((await subtle().decrypt({ name: 'RSA-OAEP' }, oaep.privateKey, ct)).byteLength === data.byteLength, 'oaep'); + const jwk = await subtle().exportKey('jwk', oaep.privateKey); + assert(!!jwk.d, 'jwk export'); + const sig = await subtle().generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify']) as CryptoKeyPair; + const s = await subtle().sign('RSASSA-PKCS1-v1_5', sig.privateKey, data); + assert(await subtle().verify('RSASSA-PKCS1-v1_5', sig.publicKey, s, data), 'verify'); + }], + ['ECDSA / ECDH P-256', async () => { + const ecdsa = await subtle().generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']) as CryptoKeyPair; + const s = await subtle().sign({ name: 'ECDSA', hash: 'SHA-256' }, ecdsa.privateKey, data); + assert(await subtle().verify({ name: 'ECDSA', hash: 'SHA-256' }, ecdsa.publicKey, s, data), 'ecdsa'); + const a = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair; + const b = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']) as CryptoKeyPair; + assert((await subtle().deriveBits({ name: 'ECDH', public: b.publicKey }, a.privateKey, 256)).byteLength === 32, 'ecdh'); + }], + ['Ed25519 / X25519', async () => { + const ed = await subtle().generateKey('Ed25519', true, ['sign', 'verify']) as CryptoKeyPair; + const s = await subtle().sign('Ed25519', ed.privateKey, data); + assert(await subtle().verify('Ed25519', ed.publicKey, s, data), 'ed25519'); + const jwk = await subtle().exportKey('jwk', ed.privateKey); + await subtle().importKey('jwk', jwk, 'Ed25519', false, ['sign']); + const a = await subtle().generateKey('X25519', true, ['deriveBits']) as CryptoKeyPair; + const b = await subtle().generateKey('X25519', true, ['deriveBits']) as CryptoKeyPair; + assert((await subtle().deriveBits({ name: 'X25519', public: b.publicKey }, a.privateKey, 256)).byteLength === 32, 'x25519'); + }], + ['HKDF / PBKDF2', async () => { + const ikm = await subtle().importKey('raw', data, 'HKDF', false, ['deriveBits']); + assert((await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(16), info: new Uint8Array(0) }, ikm, 256)).byteLength === 32, 'hkdf'); + const pw = await subtle().importKey('raw', data, 'PBKDF2', false, ['deriveBits']); + assert((await subtle().deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: new Uint8Array(16), iterations: 1000 }, pw, 256)).byteLength === 32, 'pbkdf2'); + }], + ['RSA PKCS#1 v1.5 decryption (S/MIME key transport)', async () => { + const pair = await subtle().generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['encrypt', 'decrypt']) as CryptoKeyPair; + const pkcs8 = new Uint8Array(await subtle().exportKey('pkcs8', pair.privateKey)); + const jwk = await subtle().exportKey('jwk', pair.publicKey); + // Encrypt with PKCS#1 v1.5 padding by hand: 00 02 00 . + const n = BigInt(`0x${Array.from(atob(jwk.n!.replace(/-/g, '+').replace(/_/g, '/')), (ch) => ch.charCodeAt(0).toString(16).padStart(2, '0')).join('')}`); + const k = 256; + const em = new Uint8Array(k); + em[1] = 2; + const pad = webCrypto().getRandomValues(new Uint8Array(k - 3 - data.length)).map((x) => x || 1); + em.set(pad, 2); + em.set(data, k - data.length); + let m = 0n; + for (const b of em) m = (m << 8n) | BigInt(b); + let c = 1n; + let base = m % n; + let e = 65537n; + while (e > 0n) { + if (e & 1n) c = (c * base) % n; + base = (base * base) % n; + e >>= 1n; + } + const ct = new Uint8Array(k); + for (let i = k - 1; i >= 0; i--) { + ct[i] = Number(c & 0xffn); + c >>= 8n; + } + assert(new TextDecoder().decode(rsaPkcs1Decrypt(pkcs8, ct)) === 'bulwark self-test', 'decrypt'); + }], + ['3DES / ChaCha20-Poly1305 (legacy and Stalwart at-rest mail)', async () => { + const nc = nodeStyleCrypto() as unknown as { createCipheriv(a: string, k: Uint8Array, iv: Uint8Array, o?: object): { update(d: Uint8Array): Uint8Array; final(): Uint8Array; getAuthTag?(): Uint8Array } }; + const key24 = webCrypto().getRandomValues(new Uint8Array(24)); + const iv8 = webCrypto().getRandomValues(new Uint8Array(8)); + const des = nc.createCipheriv('des-ede3-cbc', key24, iv8); + const desCt = new Uint8Array([...des.update(data), ...des.final()]); + assert(new TextDecoder().decode(cbcDecrypt('des-ede3-cbc', key24, iv8, desCt)) === 'bulwark self-test', '3des'); + const key32 = webCrypto().getRandomValues(new Uint8Array(32)); + const nonce = webCrypto().getRandomValues(new Uint8Array(12)); + const cc = nc.createCipheriv('chacha20-poly1305', key32, nonce, { authTagLength: 16 }); + const sealed = new Uint8Array([...cc.update(data), ...cc.final(), ...(cc.getAuthTag?.() ?? [])]); + assert(new TextDecoder().decode(chacha20Poly1305Decrypt(key32, nonce, sealed)) === 'bulwark self-test', 'chacha'); + }], + ['OpenPGP: curve25519 key, sign + encrypt, decrypt + verify', async () => { + const openpgp = await import('openpgp'); + const { privateKey, publicKey } = await openpgp.generateKey({ type: 'ecc', curve: 'curve25519Legacy', userIDs: [{ email: 'self-test@example.org' }], format: 'object' }); + const armored = await openpgp.encrypt({ message: await openpgp.createMessage({ text: 'ok' }), encryptionKeys: publicKey, signingKeys: privateKey }); + const res = await openpgp.decrypt({ message: await openpgp.readMessage({ armoredMessage: armored as string }), decryptionKeys: privateKey, verificationKeys: publicKey, expectSigned: true }); + assert(res.data === 'ok', 'decrypt'); + }], + ['OpenPGP: RSA key', async () => { + const openpgp = await import('openpgp'); + const { privateKey, publicKey } = await openpgp.generateKey({ type: 'rsa', rsaBits: 2048, userIDs: [{ email: 'self-test@example.org' }], format: 'object' }); + const signed = await openpgp.sign({ message: await openpgp.createMessage({ text: 'ok' }), signingKeys: privateKey }); + const v = await openpgp.verify({ message: await openpgp.readMessage({ armoredMessage: signed as string }), verificationKeys: publicKey, expectSigned: true }); + assert(v.data === 'ok', 'verify'); + }], + ['S/MIME: certificate signature (pkijs)', async () => { + const pkijs = await import('pkijs'); + const asn1js = await import('asn1js'); + const { smimeEngine } = await import('./smime/engine'); + const engine = smimeEngine(); + const keys = await subtle().generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify']) as CryptoKeyPair; + const cert = new pkijs.Certificate(); + cert.version = 2; + cert.serialNumber = new asn1js.Integer({ value: 1 }); + const cn = () => new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: 'self-test' }) }); + cert.subject.typesAndValues.push(cn()); + cert.issuer.typesAndValues.push(cn()); + cert.notBefore.value = new Date(); + cert.notAfter.value = new Date(Date.now() + 86400_000); + await cert.subjectPublicKeyInfo.importKey(keys.publicKey, engine); + await cert.sign(keys.privateKey, 'SHA-256', engine); + const parsed = pkijs.Certificate.fromBER(toArrayBuffer(new Uint8Array(cert.toSchema().toBER(false)))); + assert(await parsed.verify(undefined, engine), 'verify'); + }], + ]; +} + +/** Run every check; each one reports on its own. */ +export async function runCryptoSelfTest(): Promise { + const out: SelfTestResult[] = []; + for (const [name, run] of checks()) { + try { + await run(); + out.push({ name, ok: true }); + } catch (err) { + out.push({ name, ok: false, error: err instanceof Error ? err.message : String(err) }); + } + } + return out; +} diff --git a/src/lib/crypto/smime/certificate.ts b/src/lib/crypto/smime/certificate.ts new file mode 100644 index 00000000..05b43471 --- /dev/null +++ b/src/lib/crypto/smime/certificate.ts @@ -0,0 +1,166 @@ +// X.509 parsing and metadata for S/MIME (#58). Port of +// repos/plugins/smime/src/certificate-utils.js. + +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { base64Decode, base64Encode } from '../mime'; +import { webCrypto } from '../provider'; + +const OID_EMAIL_PROTECTION = '1.3.6.1.5.5.7.3.4'; +const OID_SAN = '2.5.29.17'; + +export interface CertificateInfo { + subject: string; + issuer: string; + serialNumber: string; + notBefore: string; + notAfter: string; + /** SHA-256 over the DER, colon-separated hex. */ + fingerprint: string; + algorithm: string; + emailAddresses: string[]; + capabilities: { canSign: boolean; canEncrypt: boolean }; +} + +export function pemToDer(pem: string): Uint8Array { + return base64Decode(pem.replace(/-----BEGIN [^-]+-----/, '').replace(/-----END [^-]+-----/, '')); +} + +export function derToPem(der: Uint8Array, label = 'CERTIFICATE'): string { + return `-----BEGIN ${label}-----\n${base64Encode(der, 64).replace(/\r\n/g, '\n')}\n-----END ${label}-----\n`; +} + +export function parseCertificateDer(der: Uint8Array | ArrayBuffer): pkijs.Certificate { + const bytes = der instanceof Uint8Array ? der : new Uint8Array(der); + const asn1 = asn1js.fromBER(bytes.slice().buffer); + if (asn1.offset === -1) throw new Error('Invalid DER data: ASN.1 parsing failed'); + return new pkijs.Certificate({ schema: asn1.result }); +} + +/** A certificate file: PEM text or DER bytes. Returns the DER. */ +export function certificateDerFromFile(bytes: Uint8Array): Uint8Array { + const head = new TextDecoder('utf-8', { fatal: false }).decode(bytes.subarray(0, 64)); + if (head.includes('-----BEGIN ')) return pemToDer(new TextDecoder().decode(bytes)); + return bytes; +} + +function oidToName(oid: string): string { + const map: Record = { + '2.5.4.3': 'CN', '2.5.4.6': 'C', '2.5.4.7': 'L', '2.5.4.8': 'ST', '2.5.4.10': 'O', '2.5.4.11': 'OU', '1.2.840.113549.1.9.1': 'E', + }; + return map[oid] ?? oid; +} + +function rdnToString(rdn: pkijs.RelativeDistinguishedNames): string { + return rdn.typesAndValues + .map((tv) => `${oidToName(tv.type)}=${(tv.value.valueBlock as { value?: string }).value ?? ''}`) + .join(', '); +} + +export async function computeFingerprint(der: Uint8Array): Promise { + const hash = await webCrypto().subtle.digest('SHA-256', der.slice()); + return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join(':'); +} + +function extractAlgorithm(cert: pkijs.Certificate): string { + const algOid = cert.subjectPublicKeyInfo.algorithm.algorithmId; + if (algOid === '1.2.840.113549.1.1.1') { + try { + const seq = asn1js.fromBER(cert.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHexView).result as asn1js.Sequence; + const modulus = seq.valueBlock.value[0] as asn1js.Integer; + return `RSA-${(modulus.valueBlock.valueHexView.byteLength - 1) * 8}`; + } catch { + return 'RSA'; + } + } + if (algOid === '1.2.840.10045.2.1') { + const params = cert.subjectPublicKeyInfo.algorithm.algorithmParams; + if (params instanceof asn1js.ObjectIdentifier) { + const curves: Record = { '1.2.840.10045.3.1.7': 'ECDSA-P256', '1.3.132.0.34': 'ECDSA-P384', '1.3.132.0.35': 'ECDSA-P521' }; + return curves[params.valueBlock.toString()] ?? 'ECDSA'; + } + return 'ECDSA'; + } + return algOid; +} + +function extractKeyUsage(cert: pkijs.Certificate): string[] | undefined { + const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.15'); + if (!ext?.parsedValue) return undefined; + // pkijs parses keyUsage as a BIT STRING. + const bits = ext.parsedValue as asn1js.BitString; + const view = bits.valueBlock?.valueHexView; + if (!view) return undefined; + const names = ['digitalSignature', 'contentCommitment', 'keyEncipherment', 'dataEncipherment', 'keyAgreement', 'keyCertSign', 'cRLSign', 'encipherOnly', 'decipherOnly']; + const out: string[] = []; + for (let i = 0; i < names.length; i++) { + const byte = view[i >> 3] ?? 0; + if (byte & (0x80 >> (i & 7))) out.push(names[i]); + } + return out; +} + +function extractExtendedKeyUsage(cert: pkijs.Certificate): string[] | undefined { + const ext = cert.extensions?.find((e) => e.extnID === '2.5.29.37'); + return (ext?.parsedValue as { keyPurposes?: string[] } | undefined)?.keyPurposes; +} + +function extractEmailAddresses(cert: pkijs.Certificate): string[] { + const emails: string[] = []; + for (const tv of cert.subject.typesAndValues) { + if (tv.type === '1.2.840.113549.1.9.1') emails.push(String((tv.value.valueBlock as { value?: string }).value ?? '')); + } + const san = cert.extensions?.find((e) => e.extnID === OID_SAN); + if (san) { + let names = (san.parsedValue as { names?: pkijs.GeneralName[] } | undefined)?.names; + if (!names && san.extnValue) { + try { + const parsed = asn1js.fromBER(san.extnValue.valueBlock.valueHexView); + if (parsed.offset !== -1) names = new pkijs.GeneralNames({ schema: parsed.result }).names; + } catch { + // malformed SAN: skip + } + } + for (const name of names ?? []) { + if (name.type === 1 && typeof name.value === 'string' && !emails.includes(name.value)) emails.push(name.value); + } + } + return emails.map((e) => e.toLowerCase()).filter(Boolean); +} + +/** Signing/encryption capabilities from KU / EKU; absent extensions allow both. */ +export function classifyCapabilities(cert: pkijs.Certificate): { canSign: boolean; canEncrypt: boolean } { + const ku = extractKeyUsage(cert); + const eku = extractExtendedKeyUsage(cert); + let canSign = true; + let canEncrypt = true; + if (ku) { + canSign = ku.includes('digitalSignature') || ku.includes('contentCommitment'); + canEncrypt = ku.includes('keyEncipherment') || ku.includes('dataEncipherment') || ku.includes('keyAgreement'); + } + if (eku && eku.length > 0 && !eku.includes(OID_EMAIL_PROTECTION)) { + canSign = false; + canEncrypt = false; + } + return { canSign, canEncrypt }; +} + +export async function extractCertificateInfo(cert: pkijs.Certificate, der: Uint8Array): Promise { + const serial = cert.serialNumber.valueBlock.valueHexView; + return { + subject: rdnToString(cert.subject), + issuer: rdnToString(cert.issuer), + serialNumber: Array.from(new Uint8Array(serial)).map((b) => b.toString(16).padStart(2, '0')).join(':'), + notBefore: cert.notBefore.value.toISOString(), + notAfter: cert.notAfter.value.toISOString(), + fingerprint: await computeFingerprint(der), + algorithm: extractAlgorithm(cert), + emailAddresses: extractEmailAddresses(cert), + capabilities: classifyCapabilities(cert), + }; +} + +/** Parse + describe a certificate from DER. */ +export async function describeCertificate(der: Uint8Array): Promise { + return extractCertificateInfo(parseCertificateDer(der), der); +} diff --git a/src/lib/crypto/smime/cms.ts b/src/lib/crypto/smime/cms.ts new file mode 100644 index 00000000..b187ae50 --- /dev/null +++ b/src/lib/crypto/smime/cms.ts @@ -0,0 +1,543 @@ +// CMS for S/MIME (#58): decrypt (EnvelopedData and AuthEnvelopedData), +// verify (opaque and detached), sign (opaque and detached) and encrypt. +// Ports of repos/plugins/smime/src/{smime-decrypt,auth-enveloped,smime- +// verify,smime-sign,smime-encrypt}.js (plugin 1.4.1). +// +// Decryption unwraps the content key itself instead of handing the whole +// structure to pkijs + webcrypto-liner: RSA-OAEP with any of its hashes +// through WebCrypto, RSAES-PKCS1-v1_5 through the native module, and the +// content through WebCrypto (AES-CBC, AES-GCM) or the native module (3DES, +// ChaCha20-Poly1305). That covers what the plugin reads, plus RSA-OAEP with +// SHA-1 in EnvelopedData, which the plugin's pkijs path got wrong. + +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { cbcDecrypt, chacha20Poly1305Decrypt, concatBytes, rsaPkcs1Decrypt, toArrayBuffer, webCrypto } from '../provider'; +import { smimeEngine, UnsupportedAlgorithmError } from './engine'; +import { extractCertificateInfo, parseCertificateDer } from './certificate'; + +export { UnsupportedAlgorithmError }; + +const OID_DATA = '1.2.840.113549.1.7.1'; +const OID_SIGNED_DATA = '1.2.840.113549.1.7.2'; +const OID_ENVELOPED_DATA = '1.2.840.113549.1.7.3'; +export const OID_AUTH_ENVELOPED_DATA = '1.2.840.113549.1.9.16.1.23'; + +const OID_RSA_ENCRYPTION = '1.2.840.113549.1.1.1'; +const OID_RSAES_OAEP = '1.2.840.113549.1.1.7'; +const OID_MGF1 = '1.2.840.113549.1.1.8'; +const OID_P_SPECIFIED = '1.2.840.113549.1.1.9'; + +const HASHES: Record = { + '1.3.14.3.2.26': 'SHA-1', + '2.16.840.1.101.3.4.2.1': 'SHA-256', + '2.16.840.1.101.3.4.2.2': 'SHA-384', + '2.16.840.1.101.3.4.2.3': 'SHA-512', +}; + +const ALGORITHM_NAMES: Record = { + '1.2.840.113549.3.2': 'RC2-CBC', + '1.3.14.3.2.7': 'DES-CBC', + '2.16.840.1.101.3.4.1.7': 'AES-128-CCM', + '2.16.840.1.101.3.4.1.27': 'AES-192-CCM', + '2.16.840.1.101.3.4.1.47': 'AES-256-CCM', + '1.2.392.200011.61.1.1.1.2': 'Camellia-128-CBC', + '1.2.392.200011.61.1.1.1.3': 'Camellia-192-CBC', + '1.2.392.200011.61.1.1.1.4': 'Camellia-256-CBC', +}; + +function unsupported(oidOrName: string): UnsupportedAlgorithmError { + return new UnsupportedAlgorithmError(ALGORITHM_NAMES[oidOrName] || oidOrName); +} + +/** The key is there but not unlocked; the UI asks for the passphrase. */ +export class SmimeKeyLockedError extends Error { + constructor() { + super('S/MIME key is locked. Unlock it to decrypt this message.'); + this.name = 'SmimeKeyLockedError'; + } +} + +export class SmimeNoMatchingKeyError extends Error { + constructor() { + super('No imported S/MIME key matches any recipient in this encrypted message'); + this.name = 'SmimeNoMatchingKeyError'; + } +} + +/** A private key the app holds: its certificate, and the PKCS#8 when unlocked. */ +export interface SmimeKeyMaterial { + id: string; + certificate: Uint8Array; + pkcs8?: Uint8Array; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +const isContext = (el: unknown, n: number): el is asn1js.Constructed => + !!el && (el as asn1js.BaseBlock).idBlock?.tagClass === 3 && (el as asn1js.BaseBlock).idBlock?.tagNumber === n; + +function parseContentInfo(der: Uint8Array): pkijs.ContentInfo { + const asn1 = asn1js.fromBER(toArrayBuffer(der)); + if (asn1.offset === -1) throw new Error('Invalid ASN.1 data - cannot parse CMS structure'); + try { + return new pkijs.ContentInfo({ schema: asn1.result }); + } catch { + throw new Error('Invalid ASN.1 data - cannot parse CMS structure'); + } +} + +// ── Envelopes ──────────────────────────────────────────────────────── + +interface Envelope { + recipientInfos: pkijs.RecipientInfo[]; + encryptedContentInfo: pkijs.EncryptedContentInfo; + /** AuthEnvelopedData only. */ + mac?: Uint8Array; + aad?: Uint8Array; +} + +/** AuthEnvelopedData (RFC 5083); pkijs has no class for it. */ +function parseAuthEnvelopedData(schema: unknown): Envelope { + const items = schema instanceof asn1js.Sequence ? schema.valueBlock.value : []; + let i = 0; + if (!(items[i++] instanceof asn1js.Integer)) throw new Error('Invalid AuthEnvelopedData: no version'); + if (isContext(items[i], 0)) i++; // originatorInfo + const recipientSet = items[i++]; + if (!(recipientSet instanceof asn1js.Set)) throw new Error('Invalid AuthEnvelopedData: no recipientInfos'); + const recipientInfos = recipientSet.valueBlock.value.map((el) => new pkijs.RecipientInfo({ schema: el })); + const encryptedContentInfo = new pkijs.EncryptedContentInfo({ schema: items[i++] }); + // The MAC covers the authenticated attributes as DER with the SET OF tag, + // not the [1] they are sent with (RFC 5083 §2.2). + let aad: Uint8Array | undefined; + if (isContext(items[i], 1)) { + aad = new Uint8Array(items[i++].valueBeforeDecodeView); + aad[0] = 0x31; + } + const mac = items[i++]; + if (!(mac instanceof asn1js.OctetString)) throw new Error('Invalid AuthEnvelopedData: no mac'); + return { recipientInfos, encryptedContentInfo, mac: new Uint8Array(mac.getValue()), aad }; +} + +function parseEnvelope(cms: Uint8Array): Envelope { + const ci = parseContentInfo(cms); + if (ci.contentType === OID_ENVELOPED_DATA) { + const env = new pkijs.EnvelopedData({ schema: ci.content }); + return { recipientInfos: env.recipientInfos, encryptedContentInfo: env.encryptedContentInfo }; + } + if (ci.contentType === OID_AUTH_ENVELOPED_DATA) return parseAuthEnvelopedData(ci.content); + throw new Error(`Unexpected CMS content type: ${ci.contentType}`); +} + +function keyTransInfo(ri: pkijs.RecipientInfo): pkijs.KeyTransRecipientInfo | null { + if (ri instanceof pkijs.KeyTransRecipientInfo) return ri; + return ri.variant === 1 && ri.value instanceof pkijs.KeyTransRecipientInfo ? ri.value : null; +} + +function matchesRecipient(ktri: pkijs.KeyTransRecipientInfo, certificate: Uint8Array): boolean { + try { + const cert = parseCertificateDer(certificate); + const rid = ktri.rid; + if (rid instanceof pkijs.IssuerAndSerialNumber) { + if (!bytesEqual(new Uint8Array(rid.serialNumber.valueBlock.valueHexView), new Uint8Array(cert.serialNumber.valueBlock.valueHexView))) return false; + return bytesEqual(new Uint8Array(rid.issuer.toSchema().toBER(false)), new Uint8Array(cert.issuer.toSchema().toBER(false))); + } + if (rid instanceof asn1js.OctetString) { + const ski = cert.extensions?.find((e) => e.extnID === '2.5.29.14'); + if (!ski) return false; + const v = asn1js.fromBER(ski.extnValue.valueBlock.valueHexView); + if (v.offset === -1) return false; + return bytesEqual(new Uint8Array((v.result as asn1js.OctetString).valueBlock.valueHexView), new Uint8Array(rid.valueBlock.valueHexView)); + } + } catch { + // unparsable certificate: no match + } + return false; +} + +/** The ids of the keys an encrypted message is addressed to (for the unlock prompt). */ +export function matchingKeyIds(cms: Uint8Array, keys: Pick[]): string[] { + const env = parseEnvelope(cms); + const ids: string[] = []; + for (const ri of env.recipientInfos) { + const ktri = keyTransInfo(ri); + if (!ktri) continue; + for (const k of keys) if (matchesRecipient(ktri, k.certificate) && !ids.includes(k.id)) ids.push(k.id); + } + return ids; +} + +// RSAES-OAEP-params (RFC 4055): [0] hash, [1] MGF, [2] label source, all +// defaulting to SHA-1 / MGF1-SHA-1 / empty label. +function oaepParams(params: unknown): { hash: string; label?: ArrayBuffer } { + let hash = 'SHA-1'; + let mgfHash: string | undefined = 'SHA-1'; + let label: ArrayBuffer | undefined; + const fields = params instanceof asn1js.Sequence ? params.valueBlock.value : []; + for (const field of fields) { + const inner = (field as asn1js.Constructed).valueBlock.value[0]; + const alg = new pkijs.AlgorithmIdentifier({ schema: inner }); + if (isContext(field, 0)) { + const h = HASHES[alg.algorithmId]; + if (!h) throw unsupported(`RSA-OAEP with hash ${alg.algorithmId}`); + hash = h; + } else if (isContext(field, 1)) { + if (alg.algorithmId !== OID_MGF1) throw unsupported(`RSA-OAEP with mask ${alg.algorithmId}`); + mgfHash = HASHES[new pkijs.AlgorithmIdentifier({ schema: alg.algorithmParams }).algorithmId]; + } else if (isContext(field, 2)) { + if (alg.algorithmId !== OID_P_SPECIFIED) throw unsupported(`RSA-OAEP with label ${alg.algorithmId}`); + const value = alg.algorithmParams instanceof asn1js.OctetString ? alg.algorithmParams.getValue() : new ArrayBuffer(0); + if (value.byteLength > 0) label = value; + } + } + // WebCrypto uses one hash for both. + if (mgfHash !== hash) throw unsupported(`RSA-OAEP with ${hash} and MGF1-${mgfHash || 'unknown'}`); + return { hash, label }; +} + +async function unwrapContentKey(ktri: pkijs.KeyTransRecipientInfo, pkcs8: Uint8Array): Promise { + const { algorithmId, algorithmParams } = ktri.keyEncryptionAlgorithm; + const encryptedKey = new Uint8Array(ktri.encryptedKey.valueBlock.valueHexView); + if (algorithmId === OID_RSAES_OAEP) { + const { hash, label } = oaepParams(algorithmParams); + const subtle = webCrypto().subtle; + const key = await subtle.importKey('pkcs8', toArrayBuffer(pkcs8), { name: 'RSA-OAEP', hash }, false, ['decrypt']); + return new Uint8Array(await subtle.decrypt({ name: 'RSA-OAEP', ...(label ? { label } : {}) }, key, toArrayBuffer(encryptedKey))); + } + if (algorithmId === OID_RSA_ENCRYPTION) return rsaPkcs1Decrypt(pkcs8, encryptedKey); + throw unsupported(algorithmId); +} + +// GCMParameters ::= SEQUENCE { aes-nonce OCTET STRING, aes-ICVlen INTEGER DEFAULT 12 } +// pkijs writes a bare OCTET STRING nonce (the webmail plugin's own mail). +function gcmParams(params: unknown): { nonce: ArrayBuffer; tagLength: number } { + if (params instanceof asn1js.OctetString) return { nonce: params.getValue(), tagLength: 16 }; + const fields = params instanceof asn1js.Sequence ? params.valueBlock.value : []; + if (!(fields[0] instanceof asn1js.OctetString)) throw new Error('Invalid AES-GCM parameters'); + return { nonce: fields[0].getValue(), tagLength: fields[1] instanceof asn1js.Integer ? fields[1].valueBlock.valueDec : 12 }; +} + +const AES_CBC = new Set(['2.16.840.1.101.3.4.1.2', '2.16.840.1.101.3.4.1.22', '2.16.840.1.101.3.4.1.42']); +const AES_GCM = new Set(['2.16.840.1.101.3.4.1.6', '2.16.840.1.101.3.4.1.26', '2.16.840.1.101.3.4.1.46']); +const DES_EDE3_CBC = '1.2.840.113549.3.7'; +const CHACHA20_POLY1305 = '1.2.840.113549.1.9.16.3.18'; + +function ivOf(params: unknown): Uint8Array { + if (!(params instanceof asn1js.OctetString)) throw new Error('Invalid cipher parameters'); + return new Uint8Array(params.getValue()); +} + +function assertSupportedContent(eci: pkijs.EncryptedContentInfo, authenticated: boolean): void { + const oid = eci.contentEncryptionAlgorithm.algorithmId; + const ok = authenticated + ? AES_GCM.has(oid) || oid === CHACHA20_POLY1305 + : AES_CBC.has(oid) || AES_GCM.has(oid) || oid === DES_EDE3_CBC; + if (!ok) throw unsupported(oid); +} + +async function decryptContent(env: Envelope, cek: Uint8Array): Promise { + const { algorithmId: oid, algorithmParams } = env.encryptedContentInfo.contentEncryptionAlgorithm; + const ciphertext = new Uint8Array(env.encryptedContentInfo.getEncryptedContent()); + const subtle = webCrypto().subtle; + if (AES_CBC.has(oid)) { + const key = await subtle.importKey('raw', toArrayBuffer(cek), { name: 'AES-CBC' }, false, ['decrypt']); + return new Uint8Array(await subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(ivOf(algorithmParams)) }, key, toArrayBuffer(ciphertext))); + } + if (AES_GCM.has(oid)) { + const { nonce, tagLength } = gcmParams(algorithmParams); + // AuthEnvelopedData carries the tag in `mac`; EnvelopedData appends it. + const sealed = env.mac ? concatBytes(ciphertext, env.mac) : ciphertext; + if (env.mac && env.mac.byteLength !== tagLength) throw new Error('AES-GCM tag length does not match its parameters'); + const key = await subtle.importKey('raw', toArrayBuffer(cek), { name: 'AES-GCM' }, false, ['decrypt']); + return new Uint8Array(await subtle.decrypt( + { name: 'AES-GCM', iv: nonce, tagLength: (env.mac ? tagLength : 16) * 8, ...(env.aad ? { additionalData: toArrayBuffer(env.aad) } : {}) }, + key, + toArrayBuffer(sealed), + )); + } + if (oid === DES_EDE3_CBC) return cbcDecrypt('des-ede3-cbc', cek, ivOf(algorithmParams), ciphertext); + if (oid === CHACHA20_POLY1305) { + const nonce = ivOf(algorithmParams); + if (nonce.byteLength !== 12) throw new Error('ChaCha20-Poly1305 nonce must be 12 bytes'); + if (!env.mac || env.mac.byteLength !== 16) throw new Error('ChaCha20-Poly1305 tag must be 16 bytes'); + return chacha20Poly1305Decrypt(cek, nonce, concatBytes(ciphertext, env.mac), env.aad); + } + throw unsupported(oid); +} + +/** + * Decrypt CMS EnvelopedData or AuthEnvelopedData with whichever of `keys` + * it is addressed to. A matching key without its PKCS#8 (locked) raises + * SmimeKeyLockedError; no matching key raises SmimeNoMatchingKeyError. + */ +export async function smimeDecrypt(cms: Uint8Array, keys: SmimeKeyMaterial[]): Promise<{ mimeBytes: Uint8Array; keyId: string }> { + const env = parseEnvelope(cms); + const matches: Array<{ key: SmimeKeyMaterial; ktri: pkijs.KeyTransRecipientInfo }> = []; + for (const ri of env.recipientInfos) { + const ktri = keyTransInfo(ri); + if (!ktri) continue; + for (const key of keys) if (matchesRecipient(ktri, key.certificate)) matches.push({ key, ktri }); + } + if (matches.length === 0) throw new SmimeNoMatchingKeyError(); + assertSupportedContent(env.encryptedContentInfo, !!env.mac); + + let lastError: unknown; + for (const { key, ktri } of matches) { + if (!key.pkcs8) continue; + try { + const cek = await unwrapContentKey(ktri, key.pkcs8); + return { mimeBytes: await decryptContent(env, cek), keyId: key.id }; + } catch (err) { + if (err instanceof UnsupportedAlgorithmError) throw err; + lastError = err; // wrong key or a broken message: try the next one + } + } + if (matches.some((m) => !m.key.pkcs8)) throw new SmimeKeyLockedError(); + throw new Error(`Failed to decrypt the message${lastError instanceof Error ? `: ${lastError.message}` : ''}`); +} + +// ── Signatures ─────────────────────────────────────────────────────── + +export interface SmimeSignatureStatus { + signatureValid: boolean; + signatureError?: string; + /** The digest/signature check alone, before certificate dates. */ + cryptoValid: boolean; + signerEmail?: string; + signerEmailMatch?: boolean; + selfSigned?: boolean; + certExpired?: boolean; + signerFingerprint?: string; + signerSubject?: string; + /** The signer's certificate, to remember as a recipient certificate. */ + signerCertificate?: Uint8Array; +} + +function extractSignedData(ci: pkijs.ContentInfo): pkijs.SignedData { + if (ci.contentType !== OID_SIGNED_DATA) throw new Error(`Unexpected CMS content type: ${ci.contentType}`); + return new pkijs.SignedData({ schema: ci.content }); +} + +function extractInnerContent(sd: pkijs.SignedData): Uint8Array { + const eContent = sd.encapContentInfo?.eContent; + if (!eContent) throw new Error('No encapsulated content in SignedData'); + if (eContent instanceof asn1js.OctetString) { + const children = eContent.valueBlock.value as asn1js.OctetString[] | undefined; + if (children?.length) return concatBytes(...children.map((c) => new Uint8Array(c.valueBlock.valueHexView))); + return new Uint8Array(eContent.valueBlock.valueHexView); + } + throw new Error('Unable to extract content from SignedData'); +} + +function extractSignerCertificate(sd: pkijs.SignedData): pkijs.Certificate | null { + if (!sd.signerInfos?.length || !sd.certificates?.length) return null; + const sid = sd.signerInfos[0].sid; + if (sid instanceof pkijs.IssuerAndSerialNumber) { + for (const c of sd.certificates) { + if (!(c instanceof pkijs.Certificate)) continue; + if (!bytesEqual(new Uint8Array(sid.serialNumber.valueBlock.valueHexView), new Uint8Array(c.serialNumber.valueBlock.valueHexView))) continue; + if (bytesEqual(new Uint8Array(sid.issuer.toSchema().toBER(false)), new Uint8Array(c.issuer.toSchema().toBER(false)))) return c; + } + } + if (sd.certificates.length === 1 && sd.certificates[0] instanceof pkijs.Certificate) return sd.certificates[0]; + return null; +} + +async function verifyStatus(sd: pkijs.SignedData, data: ArrayBuffer | undefined, fromEmail?: string): Promise { + const signer = extractSignerCertificate(sd); + if (!signer) return { signatureValid: false, cryptoValid: false, signatureError: 'Signer certificate not found in CMS structure' }; + let cryptoValid = false; + let signatureError: string | undefined; + try { + // checkChain:false: the signature is checked cryptographically; trust in + // the issuer is shown separately (selfSigned) as the plugin does. + cryptoValid = await sd.verify({ signer: 0, checkChain: false, ...(data ? { data } : {}) }, smimeEngine()) as boolean; + } catch (err) { + signatureError = err instanceof Error ? err.message : 'Signature verification failed'; + } + const der = new Uint8Array(signer.toSchema(true).toBER(false)); + const info = await extractCertificateInfo(signer, der); + const now = Date.now(); + const certExpired = now > Date.parse(info.notAfter); + const certNotYetValid = now < Date.parse(info.notBefore); + if (!cryptoValid && !signatureError) signatureError = 'Signature does not match the message content'; + if (certExpired && !signatureError) signatureError = 'Signer certificate has expired'; + if (certNotYetValid && !signatureError) signatureError = 'Signer certificate is not yet valid'; + const signerEmail = info.emailAddresses[0]; + return { + signatureValid: cryptoValid && !certExpired && !certNotYetValid, + signatureError, + cryptoValid, + signerEmail, + signerEmailMatch: fromEmail && signerEmail ? fromEmail.toLowerCase() === signerEmail.toLowerCase() : undefined, + selfSigned: bytesEqual(new Uint8Array(signer.issuer.toSchema().toBER(false)), new Uint8Array(signer.subject.toSchema().toBER(false))), + certExpired, + signerFingerprint: info.fingerprint, + signerSubject: info.subject, + signerCertificate: der, + }; +} + +/** Is this CMS a SignedData (sign-then-encrypt nests one)? */ +export function isSignedData(cms: Uint8Array): boolean { + try { + return parseContentInfo(cms).contentType === OID_SIGNED_DATA; + } catch { + return false; + } +} + +/** Verify opaque SignedData and return the content it wraps. */ +export async function smimeVerifyOpaque(cms: Uint8Array, fromEmail?: string): Promise<{ mimeBytes: Uint8Array; status: SmimeSignatureStatus }> { + const sd = extractSignedData(parseContentInfo(cms)); + return { mimeBytes: extractInnerContent(sd), status: await verifyStatus(sd, undefined, fromEmail) }; +} + +function lfToCrlf(bytes: Uint8Array): Uint8Array | null { + let bare = 0; + for (let i = 0; i < bytes.length; i++) if (bytes[i] === 0x0a && (i === 0 || bytes[i - 1] !== 0x0d)) bare++; + if (bare === 0) return null; + const out = new Uint8Array(bytes.length + bare); + let j = 0; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0x0a && (i === 0 || bytes[i - 1] !== 0x0d)) out[j++] = 0x0d; + out[j++] = bytes[i]; + } + return out; +} + +/** Verify a detached signature over the signed entity's exact bytes. */ +export async function smimeVerifyDetached(signatureDer: Uint8Array, entity: Uint8Array, fromEmail?: string): Promise { + const sd = extractSignedData(parseContentInfo(signatureDer)); + // pkijs prefers embedded content over the data passed in, so a CMS with its + // own content would vouch for that instead of the part shown. + if (sd.encapContentInfo?.eContent) throw new Error('Detached signature carries its own content'); + let status = await verifyStatus(sd, toArrayBuffer(entity), fromEmail); + // A relay may have stripped the CRs: retry in canonical form. + const canonical = lfToCrlf(entity); + if (!status.cryptoValid && canonical) { + const retry = await verifyStatus(sd, toArrayBuffer(canonical), fromEmail); + if (retry.cryptoValid) status = retry; + } + return status; +} + +/** SMIMECapabilities: AES-256-CBC, AES-128-CBC, 3DES-CBC. */ +function smimeCapabilities(): asn1js.Sequence { + const cap = (oid: string) => new asn1js.Sequence({ value: [new asn1js.ObjectIdentifier({ value: oid })] }); + return new asn1js.Sequence({ value: [cap('2.16.840.1.101.3.4.1.42'), cap('2.16.840.1.101.3.4.1.2'), cap(DES_EDE3_CBC)] }); +} + +/** DER sorts a SET OF by encoding; pkijs encodes in array order. */ +function derSorted(attributes: pkijs.Attribute[]): pkijs.Attribute[] { + const enc = (a: pkijs.Attribute) => new Uint8Array(a.toSchema().toBER(false)); + const cmp = (x: Uint8Array, y: Uint8Array) => { + for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i]; + return x.length - y.length; + }; + return attributes.map((a) => ({ a, der: enc(a) })).sort((p, q) => cmp(p.der, q.der)).map((p) => p.a); +} + +function newSignedData(encap: pkijs.EncapsulatedContentInfo, signerCertDer: Uint8Array, chainDer: Uint8Array[], digest: ArrayBuffer): pkijs.SignedData { + const signerCert = parseCertificateDer(signerCertDer); + return new pkijs.SignedData({ + version: 1, + encapContentInfo: encap, + signerInfos: [ + new pkijs.SignerInfo({ + version: 1, + sid: new pkijs.IssuerAndSerialNumber({ issuer: signerCert.issuer, serialNumber: signerCert.serialNumber }), + // Signed attributes (RFC 5652 §11 / RFC 8551 §2.5): Outlook needs a + // signing time and the digest. + signedAttrs: new pkijs.SignedAndUnsignedAttributes({ + type: 0, + attributes: derSorted([ + new pkijs.Attribute({ type: '1.2.840.113549.1.9.3', values: [new asn1js.ObjectIdentifier({ value: OID_DATA })] }), + new pkijs.Attribute({ type: '1.2.840.113549.1.9.4', values: [new asn1js.OctetString({ valueHex: digest })] }), + new pkijs.Attribute({ type: '1.2.840.113549.1.9.5', values: [new asn1js.UTCTime({ valueDate: new Date() })] }), + new pkijs.Attribute({ type: '1.2.840.113549.1.9.15', values: [smimeCapabilities()] }), + ]), + }), + }), + ], + certificates: [signerCert, ...chainDer.map(parseCertificateDer)], + }); +} + +/** + * pkijs writes sha256WithRSAEncryption without the NULL parameters, which + * Windows CryptoAPI rejects; use rsaEncryption + NULL (RFC 5754 §3.2). + */ +function contentInfoDer(sd: pkijs.SignedData): Uint8Array { + const si = sd.signerInfos[0]; + if (['1.2.840.113549.1.1.11', '1.2.840.113549.1.1.12', '1.2.840.113549.1.1.13', '1.2.840.113549.1.1.5'].includes(si.signatureAlgorithm.algorithmId)) { + si.signatureAlgorithm = new pkijs.AlgorithmIdentifier({ algorithmId: OID_RSA_ENCRYPTION, algorithmParams: new asn1js.Null() }); + } + return new Uint8Array(new pkijs.ContentInfo({ contentType: OID_SIGNED_DATA, content: sd.toSchema(true) }).toSchema().toBER(false)); +} + +async function signingKey(pkcs8: Uint8Array, algorithm: string): Promise { + const subtle = webCrypto().subtle; + if (algorithm.startsWith('ECDSA')) { + const curve = algorithm.includes('384') ? 'P-384' : algorithm.includes('521') ? 'P-521' : 'P-256'; + return subtle.importKey('pkcs8', toArrayBuffer(pkcs8), { name: 'ECDSA', namedCurve: curve }, false, ['sign']); + } + return subtle.importKey('pkcs8', toArrayBuffer(pkcs8), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']); +} + +async function sha256(bytes: Uint8Array): Promise { + return webCrypto().subtle.digest('SHA-256', toArrayBuffer(bytes)); +} + +export interface SmimeSigner { + pkcs8: Uint8Array; + certificate: Uint8Array; + chain: Uint8Array[]; + /** "RSA-2048", "ECDSA-P256", ... (certificate.ts extractAlgorithm). */ + algorithm: string; +} + +/** Opaque SignedData wrapping `mime` (application/pkcs7-mime; smime-type=signed-data). */ +export async function smimeSignOpaque(mime: Uint8Array, signer: SmimeSigner): Promise { + const encap = new pkijs.EncapsulatedContentInfo({ eContentType: OID_DATA }); + // A plain primitive OCTET STRING, as OpenSSL and Outlook write it. + encap.eContent = new asn1js.OctetString({ valueHex: toArrayBuffer(mime) }); + const sd = newSignedData(encap, signer.certificate, signer.chain, await sha256(mime)); + await sd.sign(await signingKey(signer.pkcs8, signer.algorithm), 0, 'SHA-256', undefined, smimeEngine()); + return contentInfoDer(sd); +} + +/** Detached SignedData over a CRLF MIME entity (multipart/signed). */ +export async function smimeSignDetached(entity: Uint8Array, signer: SmimeSigner): Promise { + const sd = newSignedData(new pkijs.EncapsulatedContentInfo({ eContentType: OID_DATA }), signer.certificate, signer.chain, await sha256(entity)); + await sd.sign(await signingKey(signer.pkcs8, signer.algorithm), 0, 'SHA-256', toArrayBuffer(entity), smimeEngine()); + return contentInfoDer(sd); +} + +/** + * EnvelopedData to every recipient certificate plus the sender's, so Sent + * stays readable. RSA-OAEP (SHA-256) key transport like the plugin, but + * AES-256-CBC content: the plugin's AES-GCM inside EnvelopedData is not + * standard (RFC 5084 puts GCM in AuthEnvelopedData) and Outlook and + * Thunderbird can't read it. + */ +export async function smimeEncrypt(mime: Uint8Array, recipientCerts: Uint8Array[], senderCert: Uint8Array): Promise { + const seen = new Set(); + const unique = [...recipientCerts, senderCert].filter((der) => { + const k = Array.from(der).join(','); + if (seen.has(k)) return false; + seen.add(k); + return true; + }); + if (unique.length === 0) throw new Error('No recipient certificates provided'); + const engine = smimeEngine(); + const env = new pkijs.EnvelopedData(); + for (const der of unique) env.addRecipientByCertificate(parseCertificateDer(der), { oaepHashAlgorithm: 'SHA-256' }, undefined, engine); + await env.encrypt({ name: 'AES-CBC', length: 256 } as AesKeyAlgorithm, toArrayBuffer(mime), engine); + return new Uint8Array(new pkijs.ContentInfo({ contentType: OID_ENVELOPED_DATA, content: env.toSchema() }).toSchema().toBER(false)); +} diff --git a/src/lib/crypto/smime/engine.ts b/src/lib/crypto/smime/engine.ts new file mode 100644 index 00000000..15e8ba79 --- /dev/null +++ b/src/lib/crypto/smime/engine.ts @@ -0,0 +1,119 @@ +// pkijs engine for S/MIME (#58). Port of repos/plugins/smime/src/crypto- +// engine.js without webcrypto-liner: WebCrypto comes from the installed +// global (react-native-quick-crypto in the app), and the legacy PKCS#12 PBE +// ciphers (3DES) go through the Node-style crypto module (provider.ts). + +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { cbcDecrypt, toArrayBuffer, webCrypto } from '../provider'; + +// ── PKCS#12 legacy PBE OIDs ────────────────────────────────────────── +const PBE_SHA1_3DES_3KEY = '1.2.840.113549.1.12.1.3'; +const PBE_SHA1_3DES_2KEY = '1.2.840.113549.1.12.1.4'; +const PBE_SHA1_RC2_128 = '1.2.840.113549.1.12.1.5'; +const PBE_SHA1_RC2_40 = '1.2.840.113549.1.12.1.6'; + +/** Thrown for a cipher the app can't decrypt; `algorithm` names it for the banner. */ +export class UnsupportedAlgorithmError extends Error { + algorithm: string; + constructor(algorithm: string) { + super(`Unsupported encryption algorithm ${algorithm}`); + this.name = 'UnsupportedAlgorithmError'; + this.algorithm = algorithm; + } +} + +/** PKCS#12 key derivation, RFC 7292 Appendix B (SHA-1). */ +async function pkcs12KDF(password: Uint8Array, salt: Uint8Array, iterations: number, id: number, needed: number): Promise { + const subtle = webCrypto().subtle; + const v = 64; + const u = 20; + const D = new Uint8Array(v).fill(id); + const sLen = salt.length === 0 ? 0 : v * Math.ceil(salt.length / v); + const S = new Uint8Array(sLen); + for (let i = 0; i < sLen; i++) S[i] = salt[i % salt.length]; + const pLen = password.length === 0 ? 0 : v * Math.ceil(password.length / v); + const P = new Uint8Array(pLen); + for (let i = 0; i < pLen; i++) P[i] = password[i % password.length]; + const I = new Uint8Array(sLen + pLen); + I.set(S, 0); + I.set(P, sLen); + const c = Math.ceil(needed / u); + const result = new Uint8Array(c * u); + for (let i = 0; i < c; i++) { + const buf = new Uint8Array(v + I.length); + buf.set(D, 0); + buf.set(I, v); + let A = new Uint8Array(await subtle.digest('SHA-1', buf)); + for (let j = 1; j < iterations; j++) A = new Uint8Array(await subtle.digest('SHA-1', A)); + result.set(A, i * u); + if (i + 1 < c) { + const B = new Uint8Array(v); + for (let j = 0; j < v; j++) B[j] = A[j % u]; + for (let j = 0; j < I.length; j += v) { + let carry = 1; + for (let k = v - 1; k >= 0; k--) { + const sum = I[j + k] + B[k] + carry; + I[j + k] = sum & 0xff; + carry = sum >> 8; + } + } + } + } + return result.slice(0, needed); +} + +/** BMPString password with the trailing NUL pair (RFC 7292 §B.1). */ +function passwordToBMP(password: ArrayBuffer): Uint8Array { + const p = new Uint8Array(password); + const bmp = new Uint8Array(p.length * 2 + 2); + for (let i = 0; i < p.length; i++) bmp[i * 2 + 1] = p[i]; + return bmp; +} + +/** 2-key 3DES (K1 K2 K1) as the 24-byte key OpenSSL's des-ede3-cbc takes. */ +function expand3DesKey(key: Uint8Array): Uint8Array { + if (key.length === 24) return key; + const out = new Uint8Array(24); + out.set(key.subarray(0, 16), 0); + out.set(key.subarray(0, 8), 16); + return out; +} + +class AppCryptoEngine extends pkijs.CryptoEngine { + // pkijs only decrypts PBES2; many .p12 files (Windows, OpenSSL 1.x, macOS) + // protect their bags with the PKCS#12 SHA-1 + 3DES scheme. + override async decryptEncryptedContentInfo(parameters: pkijs.CryptoEngineDecryptParams): Promise { + const oid = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmId; + if (oid === PBE_SHA1_RC2_128 || oid === PBE_SHA1_RC2_40) throw new UnsupportedAlgorithmError('RC2-CBC'); + if (oid !== PBE_SHA1_3DES_3KEY && oid !== PBE_SHA1_3DES_2KEY) return super.decryptEncryptedContentInfo(parameters); + + const algParams = parameters.encryptedContentInfo.contentEncryptionAlgorithm.algorithmParams; + if (!algParams) throw new Error('Missing PBE algorithm parameters'); + const seq = asn1js.fromBER((algParams as asn1js.BaseBlock).toBER(false)).result as asn1js.Sequence; + const salt = new Uint8Array((seq.valueBlock.value[0] as asn1js.OctetString).valueBlock.valueHexView); + const iterations = (seq.valueBlock.value[1] as asn1js.Integer).valueBlock.valueDec; + const keyLen = oid === PBE_SHA1_3DES_3KEY ? 24 : 16; + const bmp = passwordToBMP(parameters.password); + const key = await pkcs12KDF(bmp, salt, iterations, 1, keyLen); + const iv = await pkcs12KDF(bmp, salt, iterations, 2, 8); + const ciphertext = new Uint8Array(parameters.encryptedContentInfo.getEncryptedContent()); + return toArrayBuffer(cbcDecrypt('des-ede3-cbc', expand3DesKey(key), iv, ciphertext)); + } +} + +let engine: AppCryptoEngine | null = null; + +/** The pkijs engine (also set as pkijs' global engine, which some of its paths read). */ +export function smimeEngine(): pkijs.CryptoEngine { + if (!engine) { + engine = new AppCryptoEngine({ name: 'app', crypto: webCrypto() }); + pkijs.setEngine('app', engine); + } + return engine; +} + +/** For tests: forget the engine so the next call picks up a new global crypto. */ +export function resetSmimeEngine(): void { + engine = null; +} diff --git a/src/lib/crypto/smime/pkcs12.ts b/src/lib/crypto/smime/pkcs12.ts new file mode 100644 index 00000000..366eaa15 --- /dev/null +++ b/src/lib/crypto/smime/pkcs12.ts @@ -0,0 +1,118 @@ +// PKCS#12 (.p12/.pfx) import for S/MIME (#58). Port of the parsing half of +// repos/plugins/smime/src/pkcs12.js. The app keeps the .p12 itself in +// SecureStore and opens it with its password once per session, so there is +// no second at-rest wrapping here. + +import * as asn1js from 'asn1js'; +import * as pkijs from 'pkijs'; +import { smimeEngine } from './engine'; +import { extractCertificateInfo, type CertificateInfo } from './certificate'; + +export interface Pkcs12Contents { + /** PKCS#8 PrivateKeyInfo DER. */ + pkcs8: Uint8Array; + certificate: Uint8Array; + chain: Uint8Array[]; + info: CertificateInfo; +} + +export class Pkcs12PasswordError extends Error { + constructor() { + super('Wrong password for this certificate file'); + this.name = 'Pkcs12PasswordError'; + } +} + +function passwordBuffer(password: string): ArrayBuffer { + // pkijs expects the password as bytes; it builds the BMPString itself. + return new TextEncoder().encode(password).slice().buffer; +} + +async function withEngine(fn: () => Promise): Promise { + smimeEngine(); + return fn(); +} + +/** Open a .p12 with its password: the private key, leaf certificate and chain. */ +export async function openPkcs12(p12: Uint8Array, password: string): Promise { + const asn1 = asn1js.fromBER(p12.slice().buffer); + if (asn1.offset === -1) throw new Error('Invalid PKCS#12 file'); + const pfx = new pkijs.PFX({ schema: asn1.result }); + const pass = passwordBuffer(password); + + try { + await withEngine(() => pfx.parseInternalValues({ password: pass, checkIntegrity: true })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (/integrity|password|mac/i.test(msg)) throw new Pkcs12PasswordError(); + throw err; + } + const authSafe = pfx.parsedValue?.authenticatedSafe; + if (!authSafe) throw new Error('PKCS#12 file does not contain an authenticated safe'); + const safeContentsParams = authSafe.safeContents.map((ci) => (ci.contentType === '1.2.840.113549.1.7.6' ? { password: pass } : {})); + await withEngine(() => authSafe.parseInternalValues({ safeContents: safeContentsParams })); + + let leaf: { cert: pkijs.Certificate; der: Uint8Array } | null = null; + const chain: Uint8Array[] = []; + let keyInfo: pkijs.PrivateKeyInfo | null = null; + + for (const safeContent of authSafe.parsedValue?.safeContents ?? []) { + const sc = (safeContent as { value?: pkijs.SafeContents }).value; + if (!sc) continue; + for (const bag of sc.safeBags) { + if (bag.bagId === '1.2.840.113549.1.12.10.1.3') { + const certBag = bag.bagValue as pkijs.CertBag; + let cert: pkijs.Certificate | null = null; + let der: Uint8Array | null = null; + if (certBag.parsedValue instanceof pkijs.Certificate) { + cert = certBag.parsedValue; + der = new Uint8Array(cert.toSchema(true).toBER(false)); + } else if (certBag.certId === '1.2.840.113549.1.9.22.1' && certBag.certValue) { + const bytes = (certBag.certValue as asn1js.OctetString).valueBlock.valueHexView; + const parsed = asn1js.fromBER(bytes); + if (parsed.offset !== -1) { + cert = new pkijs.Certificate({ schema: parsed.result }); + der = new Uint8Array(bytes); + } + } + if (cert && der) { + if (!leaf) leaf = { cert, der }; + else chain.push(der); + } + } else if (bag.bagId === '1.2.840.113549.1.12.10.1.1') { + keyInfo = bag.bagValue as pkijs.PrivateKeyInfo; + } else if (bag.bagId === '1.2.840.113549.1.12.10.1.2') { + const shrouded = bag.bagValue as pkijs.PKCS8ShroudedKeyBag; + if (!shrouded.parsedValue) { + // Protected in pkijs' typings, public in its build (the plugin calls it too). + const parse = shrouded as unknown as { parseInternalValues(p: { password: ArrayBuffer }): Promise }; + await withEngine(() => parse.parseInternalValues({ password: pass })); + } + if (shrouded.parsedValue) keyInfo = shrouded.parsedValue; + } + } + } + + if (!leaf) throw new Error('No certificate found in PKCS#12 file'); + if (!keyInfo) throw new Error('No private key found in PKCS#12 file'); + + // With several certificates, the leaf is the one that isn't an issuer of + // the others (the plugin took the first; some exports list the CA first). + const all = [leaf.der, ...chain]; + const certs = all.map((der) => new pkijs.Certificate({ schema: asn1js.fromBER(der.slice().buffer).result })); + const issuerDers = new Set(certs.map((c) => toKey(c.issuer))); + const leafIndex = Math.max(0, certs.findIndex((c) => !issuerDers.has(toKey(c.subject)) || certs.length === 1)); + const leafDer = all[leafIndex]; + const others = all.filter((_, i) => i !== leafIndex); + + return { + pkcs8: new Uint8Array(keyInfo.toSchema().toBER(false)), + certificate: leafDer, + chain: others, + info: await extractCertificateInfo(certs[leafIndex], leafDer), + }; +} + +function toKey(name: pkijs.RelativeDistinguishedNames): string { + return Array.from(new Uint8Array(name.toSchema().toBER(false))).join(','); +} diff --git a/src/lib/licences.json b/src/lib/licences.json index e9d4cb48..2cd1bc48 100644 --- a/src/lib/licences.json +++ b/src/lib/licences.json @@ -208,6 +208,24 @@ "license": "MIT", "url": "https://github.com/sAleksovski/react-native-android-widget" }, + { + "name": "react-native-nitro-modules", + "version": "0.37.1", + "license": "MIT", + "url": "https://nitro.margelo.com" + }, + { + "name": "react-native-quick-base64", + "version": "3.0.1", + "license": "MIT", + "url": "https://github.com/craftzdog/react-native-quick-base64" + }, + { + "name": "react-native-quick-crypto", + "version": "1.1.9", + "license": "MIT", + "url": "https://github.com/margelo/react-native-quick-crypto" + }, { "name": "react-native-safe-area-context", "version": "5.6.2", @@ -238,12 +256,6 @@ "license": "MIT", "url": "https://github.com/react-native-webview/react-native-webview" }, - { - "name": "webcrypto-liner", - "version": "1.4.3", - "license": "MIT", - "url": "https://github.com/PeculiarVentures/webcrypto-liner" - }, { "name": "zustand", "version": "5.0.16", diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index eaadff15..1c7d001f 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -18,7 +18,7 @@ export type SettingsTabId = | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads' - | 'security' | 'content_senders' + | 'security' | 'content_senders' | 'encryption' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' // about_data is Storage & backup (the id predates the split from About); // debug, licences and privacy are pages opened from inside another one. @@ -170,6 +170,12 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.downloads.strip_diacritics', 'settings.downloads.after_export', ], + encryption: [ + 'encryption.own_title', + 'encryption.others_title', + 'encryption.find_key', + 'encryption.check_title', + ], security: [ 'settings.security.screen_privacy', 'settings.security.client_cert', @@ -286,6 +292,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { folders: 'mailbox rename role', keywords: 'tags labels colors', downloads: 'download filename template eml attachment save export', + encryption: 'pgp gpg openpgp s/mime smime certificate key sign signature encrypt decrypt p12 pfx keyserver', security: 'password 2fa two-factor totp app password mfa api key certificate encryption screenshot screen recording recent apps privacy', content_senders: 'block sender remote images privacy tracking', calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`, diff --git a/src/lib/smime.ts b/src/lib/smime.ts deleted file mode 100644 index 1044d447..00000000 --- a/src/lib/smime.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { BodyPart, Email } from '../api/types'; - -/** - * True when the message is S/MIME signed or encrypted (pkcs7 parts). Port of - * the webmail client's `isSmimeEmail`. The mobile app has no crypto path, so - * the reader only shows a "not supported" note for these. - */ -export function isSmimeEmail(email: Pick): boolean { - const types: string[] = []; - const collect = (part: (BodyPart & { subParts?: BodyPart[] }) | undefined): void => { - if (!part) return; - if (part.type) types.push(part.type.toLowerCase()); - part.subParts?.forEach(collect); - }; - collect(email.bodyStructure as (BodyPart & { subParts?: BodyPart[] }) | undefined); - email.attachments?.forEach((att) => types.push((att.type || '').toLowerCase())); - return types.some((type) => - type.includes('pkcs7') - || type.includes('x-pkcs7') - || type === 'application/pkcs7-mime' - || type === 'application/pkcs7-signature', - ); -} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 0dd64dc1..0b958971 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -79,6 +79,13 @@ import { attachmentFromFileNode } from '../lib/compose-files'; import { supportsFiles } from '../api/files'; import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet'; +import { UnlockKeyDialog } from '../components/crypto/UnlockKeyDialog'; +import { ownKeyFor, isUnlocked, needsPassphrase, unlock } from '../lib/crypto/keyring'; +import { prepareSecureSend, resolveRecipientKeys, SecureSendError, type SecureChoice } from '../lib/crypto/compose-secure'; +import type { SecureMode } from '../lib/crypto/secure-send'; +import { useCryptoKeysStore, type CryptoKeyRecord } from '../stores/crypto-keys-store'; +import { usePolicyFeature } from '../stores/policy-store'; +import { fetchBlobBytes } from '../lib/email-export'; import type { MessagePriority } from '../lib/message-priority'; import { supportsSubmissionExtension } from '../lib/submission-options'; import { @@ -635,9 +642,15 @@ export default function ComposeScreen({ route, navigation }: Props) { const initialPriority: MessagePriority = draft?.priority ?? 'normal'; const [priority, setPriority] = React.useState(initialPriority); const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false); + // Sign / encrypt with OpenPGP or S/MIME (#58), per message. + const [secureMode, setSecureMode] = React.useState('pgp'); + const [secureSign, setSecureSign] = React.useState(false); + const [secureEncrypt, setSecureEncrypt] = React.useState(false); + const [unlockRecord, setUnlockRecord] = React.useState(null); + const unlockResolveRef = React.useRef<((ok: boolean) => void) | null>(null); const composerOptions = React.useMemo( - () => ({ requestReadReceipt, requestDsn, requireTls, priority }), - [requestReadReceipt, requestDsn, requireTls, priority], + () => ({ requestReadReceipt, requestDsn, requireTls, priority, secureMode, sign: secureSign, encrypt: secureEncrypt }), + [requestReadReceipt, requestDsn, requireTls, priority, secureMode, secureSign, secureEncrypt], ); // Read when the sheet opens: the session may have arrived since the // composer did. @@ -648,6 +661,9 @@ export default function ComposeScreen({ route, navigation }: Props) { if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn); if (next.requireTls !== undefined) setRequireTls(next.requireTls); if (next.priority !== undefined) setPriority(next.priority); + if (next.secureMode !== undefined) setSecureMode(next.secureMode); + if (next.sign !== undefined) setSecureSign(next.sign); + if (next.encrypt !== undefined) setSecureEncrypt(next.encrypt); }, []); const [subAddressTag, setSubAddressTag] = React.useState(''); const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); @@ -1496,6 +1512,78 @@ export default function ComposeScreen({ route, navigation }: Props) { }; }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, signatureSeparatorEnabled, t]); + // Which of OpenPGP / S/MIME the From address has a key for, and who of the + // recipients has none (read while the options sheet is open). + const cryptoKeys = useCryptoKeysStore((s) => s.keys); + const smimeAllowed = usePolicyFeature('smimeEnabled'); + const secureFromEmail = primaryIdentity ? senderAddress(primaryIdentity).from.email : ''; + const secureAvailable = React.useMemo( + () => ({ pgp: !!ownKeyFor('pgp', secureFromEmail), smime: smimeAllowed && !!ownKeyFor('smime', secureFromEmail) }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [cryptoKeys, secureFromEmail, smimeAllowed], + ); + React.useEffect(() => { + if (!secureAvailable[secureMode] && secureAvailable[secureMode === 'pgp' ? 'smime' : 'pgp']) { + setSecureMode(secureMode === 'pgp' ? 'smime' : 'pgp'); + } + }, [secureAvailable, secureMode]); + const secureHint = React.useMemo(() => { + if (!optionsSheetOpen || !secureEncrypt) return null; + const emails = [...expandRecipients(finalTo), ...expandRecipients(finalCc), ...expandRecipients(finalBcc)].map((r) => toAddress(r).email); + const { missing } = resolveRecipientKeys(secureMode, emails, useContactsStore.getState().contacts); + return missing.length + ? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: missing.join(', ') }) + : null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [optionsSheetOpen, secureEncrypt, secureMode, finalTo, finalCc, finalBcc, cryptoKeys, t]); + + // Unlock the own key for a signed send; resolves false when cancelled. + const askUnlock = (record: CryptoKeyRecord): Promise => new Promise((resolve) => { + unlockResolveRef.current = resolve; + setUnlockRecord(record); + }); + const finishUnlock = (ok: boolean) => { + setUnlockRecord(null); + unlockResolveRef.current?.(ok); + unlockResolveRef.current = null; + }; + + /** + * Build, sign/encrypt and upload the raw message for a secure send + * (#58); the blob id for sendEmail, or null when the user was told why + * it can't go out. + */ + const prepareSecureBlob = async (choice: SecureChoice, outgoing: OutgoingEmail, accountId: string | undefined): Promise => { + const title = t('email_composer.secure_failed_title', 'Cannot sign or encrypt'); + const own = ownKeyFor(choice.mode, outgoing.from[0]?.email ?? ''); + if (!own) { + Alert.alert(title, t('email_composer.secure_no_key', 'There is no key for {email} on this device. Import one under Settings → Encryption.', { email: outgoing.from[0]?.email ?? '' })); + return null; + } + if (choice.sign && !isUnlocked(own)) { + if (await needsPassphrase(own)) { + if (!(await askUnlock(own))) return null; + } else { + await unlock(own, ''); + } + } + try { + return await prepareSecureSend(outgoing, choice, { + contacts: useContactsStore.getState().contacts, + fetchBlob: (blobId, name, type) => fetchBlobBytes(blobId, name, type), + upload: async (bytes) => (await uploadBytesForLogin(ownerLoginRef.current, bytes, 'message/rfc822', accountId)).blobId, + }); + } catch (e) { + if (e instanceof SecureSendError) { + Alert.alert(title, e.code === 'missing-recipients' + ? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: (e.detail ?? []).join(', ') }) + : e.message); + return null; + } + throw e; + } + }; + // Save one draft version (create, then destroy the previous one - #849). const saveDraftOnce = async (opts: { live: boolean }): Promise => { const identity = primaryIdentity; @@ -2612,8 +2700,23 @@ export default function ComposeScreen({ route, navigation }: Props) { setSending(true); try { const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false }); - queuedOutgoing = outgoing; - if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; + const secureChoice: SecureChoice | null = secureSign || secureEncrypt + ? { mode: secureMode, sign: secureSign, encrypt: secureEncrypt } + : null; + if (secureChoice) { + // The Outbox would send it as plain mail later: a signed or + // encrypted message only goes out now. + if (!useNetworkStore.getState().online) { + Alert.alert( + t('email_composer.secure_failed_title', 'Cannot sign or encrypt'), + t('email_composer.secure_offline', 'Signed or encrypted messages can\'t wait in the Outbox. Send it when you are online.'), + ); + return; + } + } else { + queuedOutgoing = outgoing; + if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; + } // The account the submission is held in, for Undo (read before the // request: a switch may land while it is out). // A group or shared identity sends from its own account, and its Sent @@ -2621,6 +2724,13 @@ export default function ComposeScreen({ route, navigation }: Props) { const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes); if (refuseSendAfterSwitch()) return; const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined; + let rawBlobId: string | undefined; + if (secureChoice) { + const blobId = await prepareSecureBlob(secureChoice, outgoing, target.accountId); + if (!blobId) return; + if (refuseSendAfterSwitch()) return; + rawBlobId = blobId; + } const result = await sendEmail( outgoing, target.identityId, @@ -2635,6 +2745,7 @@ export default function ComposeScreen({ route, navigation }: Props) { // hides the toggles the account's submission extensions lack). requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId), requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId), + rawBlobId, }, ); draftIdRef.current = null; @@ -3661,6 +3772,15 @@ export default function ComposeScreen({ route, navigation }: Props) { requireTlsAvailable={requireTlsAvailable} onChange={onComposerOptionsChange} onClose={() => setOptionsSheetOpen(false)} + secureAvailable={secureAvailable} + secureHint={secureHint} + /> + + finishUnlock(true)} + onCancel={() => finishUnlock(false)} /> > = { privacy: 'settings.about.privacy', sharing: 'delegates.tab', recovery: 'recover_deleted.tab', + encryption: 'encryption.tab', }; const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`; @@ -178,6 +181,7 @@ const TAB_COMPONENTS: Partial>> = { recovery: RecoverDeletedSettings, security: AccountSecuritySettings, content_senders: ContentSendersSettings, + encryption: EncryptionSettings, calendar: CalendarSettings, contacts: ContactsSettings, files: FilesSettings, diff --git a/src/stores/crypto-keys-store.ts b/src/stores/crypto-keys-store.ts new file mode 100644 index 00000000..712b189d --- /dev/null +++ b/src/stores/crypto-keys-store.ts @@ -0,0 +1,104 @@ +// The PGP keys and S/MIME certificates the app knows (#58): your own (with a +// private key in SecureStore) and other people's (public only). The webmail +// plugins keep the same lists in IndexedDB (repos/plugins/bulwark-gpg/src/ +// key-storage.js, repos/plugins/smime/src/key-storage.js). +// +// Only public material and metadata are persisted here (AsyncStorage); a +// private PGP key or a .p12 goes to SecureStore (src/lib/crypto/secret- +// store.ts) under the record's id. The list is per device, not per account: +// a recipient's key is the same whichever account you write from. + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { createPersistStorage } from './persist-storage'; + +export interface PgpKeyRecord { + kind: 'pgp'; + /** Lower-case fingerprint. */ + id: string; + fingerprint: string; + keyID: string; + emails: string[]; + userIDs: string[]; + created: string; + expiration: string | null; + algorithm: string; + publicKeyArmored: string; + /** The private key is in SecureStore under `pgp.`. */ + hasPrivate: boolean; + source: 'import' | 'keyserver' | 'message' | 'contact'; + addedAt: string; +} + +export interface SmimeCertRecord { + kind: 'smime'; + /** SHA-256 fingerprint of the certificate. */ + id: string; + fingerprint: string; + emails: string[]; + subject: string; + issuer: string; + serialNumber: string; + notBefore: string; + notAfter: string; + algorithm: string; + /** Base64 DER. */ + certificate: string; + chain: string[]; + canSign: boolean; + canEncrypt: boolean; + /** The .p12 is in SecureStore under `smime.`. */ + hasPrivate: boolean; + source: 'import' | 'message' | 'contact'; + addedAt: string; +} + +export type CryptoKeyRecord = PgpKeyRecord | SmimeCertRecord; + +interface CryptoKeysState { + keys: CryptoKeyRecord[]; + /** Add or replace (by kind + id); a public import never drops a private key. */ + upsert: (record: CryptoKeyRecord) => void; + remove: (kind: CryptoKeyRecord['kind'], id: string) => void; +} + +/** Merge a record into the list; a public-only copy keeps the private flag. */ +export function upsertRecord(keys: CryptoKeyRecord[], record: CryptoKeyRecord): CryptoKeyRecord[] { + const i = keys.findIndex((k) => k.kind === record.kind && k.id === record.id); + if (i < 0) return [...keys, record]; + const prev = keys[i]; + const merged = { ...record, hasPrivate: record.hasPrivate || prev.hasPrivate, source: prev.hasPrivate ? prev.source : record.source } as CryptoKeyRecord; + const next = keys.slice(); + next[i] = merged; + return next; +} + +export const useCryptoKeysStore = create()( + persist( + (set, get) => ({ + keys: [], + upsert: (record) => set({ keys: upsertRecord(get().keys, record) }), + remove: (kind, id) => set({ keys: get().keys.filter((k) => !(k.kind === kind && k.id === id)) }), + }), + { + name: 'crypto-keys', + storage: createPersistStorage(), + partialize: (state) => ({ keys: state.keys }), + }, + ), +); + +const EMPTY: CryptoKeyRecord[] = []; + +/** Records for an address (lower-cased compare). */ +export function keysForEmail(keys: CryptoKeyRecord[], email: string): CryptoKeyRecord[] { + const e = email.trim().toLowerCase(); + if (!e) return EMPTY; + return keys.filter((k) => k.emails.includes(e)); +} + +/** Is this record usable now (not expired)? */ +export function isRecordCurrent(record: CryptoKeyRecord, now = Date.now()): boolean { + if (record.kind === 'pgp') return !record.expiration || Date.parse(record.expiration) > now; + return Date.parse(record.notAfter) > now && Date.parse(record.notBefore) <= now; +}