mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Merge branch 'cu-pgp' into catch-up-2026-11
This commit is contained in:
@@ -83,6 +83,10 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
'expo-secure-store',
|
'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',
|
'@react-native-community/datetimepicker',
|
||||||
'expo-localization',
|
'expo-localization',
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
// First: wraps react-native's Text for the reading font before any module
|
// First: wraps react-native's Text for the reading font before any module
|
||||||
// copies it (src/theme/reading-font-text).
|
// copies it (src/theme/reading-font-text).
|
||||||
import './src/theme/reading-font-install';
|
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 { createElement } from 'react';
|
||||||
import { AppRegistry } from 'react-native';
|
import { AppRegistry } from 'react-native';
|
||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from 'expo';
|
||||||
|
|||||||
@@ -378,6 +378,12 @@
|
|||||||
"login_switched_title": "Account switched",
|
"login_switched_title": "Account switched",
|
||||||
"no_recipient": "(no recipient)",
|
"no_recipient": "(no recipient)",
|
||||||
"no_sent_folder": "No Sent folder found",
|
"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_camera": "Camera permission is required to take a photo.",
|
||||||
"permission_photos": "Photo library permission is required to attach images.",
|
"permission_photos": "Photo library permission is required to attach images.",
|
||||||
"placeholder_warning": {
|
"placeholder_warning": {
|
||||||
@@ -435,6 +441,11 @@
|
|||||||
"schedule_unsupported_title": "Scheduling unavailable",
|
"schedule_unsupported_title": "Scheduling unavailable",
|
||||||
"scheduled_body": "Your message will be sent at {time}.",
|
"scheduled_body": "Your message will be sent at {time}.",
|
||||||
"scheduled_title": "Scheduled",
|
"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": {
|
"send_confirm": {
|
||||||
"message": "It goes to {count, plural, one {# recipient} other {# recipients}}.",
|
"message": "It goes to {count, plural, one {# recipient} other {# recipients}}.",
|
||||||
"title": "Send this message?"
|
"title": "Send this message?"
|
||||||
@@ -688,6 +699,76 @@
|
|||||||
"view_dark_mode": "View in dark mode",
|
"view_dark_mode": "View in dark mode",
|
||||||
"view_light_mode": "View in light 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": {
|
"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_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}}",
|
"batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}",
|
||||||
|
|||||||
@@ -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;
|
module.exports = config;
|
||||||
|
|||||||
Generated
+163
-195
@@ -44,12 +44,14 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-android-widget": "0.22.1",
|
"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-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-webview": "13.15.0",
|
"react-native-webview": "13.15.0",
|
||||||
"webcrypto-liner": "^1.4.3",
|
|
||||||
"zustand": "^5.0.16"
|
"zustand": "^5.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1560,6 +1562,30 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@expo/code-signing-certificates": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
"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"
|
"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": {
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
"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"
|
"@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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -3893,12 +3846,6 @@
|
|||||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/asn1js": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
|
||||||
@@ -4284,12 +4231,6 @@
|
|||||||
"node": ">=0.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": {
|
"node_modules/boolbase": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
@@ -4341,12 +4282,6 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.29.5",
|
"version": "4.29.5",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
||||||
@@ -4814,20 +4749,6 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/core-js-compat": {
|
||||||
"version": "3.51.0",
|
"version": "3.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.51.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.51.0.tgz",
|
||||||
@@ -5030,16 +4951,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/destroy": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
@@ -5167,20 +5078,6 @@
|
|||||||
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
@@ -5331,6 +5228,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
@@ -6497,16 +6403,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
@@ -6534,17 +6430,6 @@
|
|||||||
"hermes-estree": "0.32.0"
|
"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": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz",
|
||||||
@@ -8213,18 +8098,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"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": "^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": {
|
"node_modules/process-nextick-args": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
@@ -9251,6 +9133,124 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/react-native-safe-area-context": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz",
|
"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"
|
"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": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -53,12 +53,14 @@
|
|||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-android-widget": "0.22.1",
|
"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-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-webview": "13.15.0",
|
"react-native-webview": "13.15.0",
|
||||||
"webcrypto-liner": "^1.4.3",
|
|
||||||
"zustand": "^5.0.16"
|
"zustand": "^5.0.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
|
||||||
|
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, any>, 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Uint8Array> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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: '<p>Geheime <b>Grüße</b> aus dem Live-Test</p>',
|
||||||
|
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('<b>Grüße</b>');
|
||||||
|
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);
|
||||||
|
});
|
||||||
+16
-5
@@ -1754,6 +1754,13 @@ export interface SendEmailOptions {
|
|||||||
requestDsn?: boolean;
|
requestDsn?: boolean;
|
||||||
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
||||||
requireTls?: boolean;
|
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(
|
export async function sendEmail(
|
||||||
@@ -1767,7 +1774,8 @@ export async function sendEmail(
|
|||||||
opts?: SendEmailOptions,
|
opts?: SendEmailOptions,
|
||||||
): Promise<SendEmailResult> {
|
): Promise<SendEmailResult> {
|
||||||
const accountId = opts?.accountId ?? jmapClient.accountId;
|
const accountId = opts?.accountId ?? jmapClient.accountId;
|
||||||
const emailCreate = buildEmailCreate(email);
|
const raw = !!opts?.rawBlobId;
|
||||||
|
const emailCreate: Record<string, unknown> = raw ? { blobId: opts!.rawBlobId } : buildEmailCreate(email);
|
||||||
const viaDrafts = !!opts?.draftsMailboxId;
|
const viaDrafts = !!opts?.draftsMailboxId;
|
||||||
if (viaDrafts) {
|
if (viaDrafts) {
|
||||||
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
|
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
|
||||||
@@ -1776,6 +1784,9 @@ export async function sendEmail(
|
|||||||
emailCreate.mailboxIds = { [sentMailboxId]: true };
|
emailCreate.mailboxIds = { [sentMailboxId]: true };
|
||||||
emailCreate.keywords = { $seen: true };
|
emailCreate.keywords = { $seen: true };
|
||||||
}
|
}
|
||||||
|
const createCall: [string, Record<string, unknown>, string] = raw
|
||||||
|
? ['Email/import', { accountId, emails: { draft: emailCreate } }, '0']
|
||||||
|
: ['Email/set', { accountId, create: { draft: emailCreate } }, '0'];
|
||||||
|
|
||||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
// 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 hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||||||
const hasRcptToParams = Object.keys(parameters.rcptTo).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 ?? [])]
|
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||||
.map((r) => r.email.trim())
|
.map((r) => r.email.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -1818,7 +1829,7 @@ export async function sendEmail(
|
|||||||
|
|
||||||
let res = await jmapClient.request(
|
let res = await jmapClient.request(
|
||||||
[
|
[
|
||||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
createCall,
|
||||||
['EmailSubmission/set', submissionArgs, '1'],
|
['EmailSubmission/set', submissionArgs, '1'],
|
||||||
// Which recipients the server took. A refused one is lost without a
|
// Which recipients the server took. A refused one is lost without a
|
||||||
// bounce while the create succeeds and files the message in Sent.
|
// 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`
|
// Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
|
||||||
// records (overQuota). The message exists by then, so make room and submit
|
// records (overQuota). The message exists by then, so make room and submit
|
||||||
// that copy once more instead of failing the send.
|
// 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<string, { id?: string }> } | undefined)?.created?.draft?.id;
|
const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
|
||||||
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
|
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
|
||||||
await makeRoomForSubmission(accountId);
|
await makeRoomForSubmission(accountId);
|
||||||
@@ -1871,7 +1882,7 @@ export async function sendEmail(
|
|||||||
failure = new Error((result as { description?: string }).description ?? 'Send failed');
|
failure = new Error((result as { description?: string }).description ?? 'Send failed');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (methodName === 'Email/set') {
|
if (methodName === 'Email/set' || methodName === 'Email/import') {
|
||||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
|
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
|
||||||
if (notCreated) {
|
if (notCreated) {
|
||||||
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
|
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
|
||||||
|
|||||||
@@ -8,12 +8,23 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
|
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
|
||||||
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
import type { SecureMode } from '../../lib/crypto/secure-send';
|
||||||
|
|
||||||
export interface ComposerOptions {
|
export interface ComposerOptions {
|
||||||
requestReadReceipt: boolean;
|
requestReadReceipt: boolean;
|
||||||
requestDsn: boolean;
|
requestDsn: boolean;
|
||||||
requireTls: boolean;
|
requireTls: boolean;
|
||||||
priority: MessagePriority;
|
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`).
|
* toolbar button (webmail composer options menu `activeCount`).
|
||||||
*/
|
*/
|
||||||
export function activeComposerOptionCount(options: ComposerOptions): number {
|
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;
|
.filter(Boolean).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The composer's per-message options (webmail email-composer.tsx options
|
* The composer's per-message options (webmail email-composer.tsx options
|
||||||
* menu): read receipt, delivery status notification and REQUIRETLS - the
|
* menu): read receipt, delivery status notification and REQUIRETLS - the
|
||||||
* last two only when the account's submission extensions list them - and
|
* last two only when the account's submission extensions list them - the
|
||||||
* the message priority.
|
* 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({
|
export function ComposerOptionsSheet({
|
||||||
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose,
|
visible, options, dsnAvailable, requireTlsAvailable, secureAvailable, secureHint, onChange, onClose,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
options: ComposerOptions;
|
options: ComposerOptions;
|
||||||
dsnAvailable: boolean;
|
dsnAvailable: boolean;
|
||||||
requireTlsAvailable: 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<ComposerOptions>) => void;
|
onChange: (next: Partial<ComposerOptions>) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -46,7 +62,7 @@ export function ComposerOptionsSheet({
|
|||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const bottomInset = useSheetBottomInset(visible);
|
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') => (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.row}
|
style={styles.row}
|
||||||
onPress={() => onChange({ [key]: !value })}
|
onPress={() => onChange({ [key]: !value })}
|
||||||
@@ -92,6 +108,47 @@ export function ComposerOptionsSheet({
|
|||||||
options.requireTls,
|
options.requireTls,
|
||||||
'requireTls',
|
'requireTls',
|
||||||
)}
|
)}
|
||||||
|
{secureAvailable && (secureAvailable.pgp || secureAvailable.smime) && (
|
||||||
|
<>
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<Text style={styles.groupLabel}>{t('email_composer.option_security', 'Signature and encryption')}</Text>
|
||||||
|
{secureAvailable.pgp && secureAvailable.smime && (
|
||||||
|
<View style={styles.segment} accessibilityRole="radiogroup">
|
||||||
|
{(['pgp', 'smime'] as const).map((mode) => {
|
||||||
|
const selected = options.secureMode === mode;
|
||||||
|
const label = mode === 'pgp' ? 'OpenPGP' : 'S/MIME';
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={mode}
|
||||||
|
style={[styles.segmentItem, selected && styles.segmentItemSelected]}
|
||||||
|
onPress={() => onChange({ secureMode: mode })}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={{ checked: selected, selected }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.rowLabel, selected && styles.segmentTextSelected]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{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) && <Text style={styles.warning}>{secureHint}</Text>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View style={styles.separator} />
|
<View style={styles.separator} />
|
||||||
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
|
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
|
||||||
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
|
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
|
||||||
@@ -159,6 +216,13 @@ function makeStyles(c: ThemePalette) {
|
|||||||
rowHint: { ...typography.caption, color: c.textMuted },
|
rowHint: { ...typography.caption, color: c.textMuted },
|
||||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
|
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
|
||||||
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
|
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 },
|
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
|
||||||
doneText: { ...typography.bodyMedium, color: c.primary },
|
doneText: { ...typography.bodyMedium, color: c.primary },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Dialog
|
||||||
|
visible
|
||||||
|
title={t('encryption.unlock_title', 'Unlock key')}
|
||||||
|
message={message}
|
||||||
|
confirmText={busy ? t('encryption.unlocking', 'Unlocking…') : t('encryption.unlock', 'Unlock')}
|
||||||
|
confirmDisabled={busy || (record.kind === 'smime' ? false : value.length === 0)}
|
||||||
|
input={{
|
||||||
|
value,
|
||||||
|
onChangeText: setValue,
|
||||||
|
placeholder: record.kind === 'pgp' ? t('encryption.passphrase', 'Passphrase') : t('encryption.password', 'Password'),
|
||||||
|
props: { secureTextEntry: true, autoCapitalize: 'none', autoCorrect: false, textContentType: 'password' },
|
||||||
|
}}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={() => {
|
||||||
|
setValue('');
|
||||||
|
onCancel();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ import { AttachmentChips } from './AttachmentChips';
|
|||||||
import { UnsubscribeBanner } from './UnsubscribeBanner';
|
import { UnsubscribeBanner } from './UnsubscribeBanner';
|
||||||
import { ReadReceiptBanner } from './ReadReceiptBanner';
|
import { ReadReceiptBanner } from './ReadReceiptBanner';
|
||||||
import { useBodyOverride } from './use-body-override';
|
import { useBodyOverride } from './use-body-override';
|
||||||
|
import { useSecureMessage } from './use-secure-message';
|
||||||
|
import { SecureMessageBanner } from './SecureMessageBanner';
|
||||||
import { deriveHeaderInfo } from '../../lib/email-headers';
|
import { deriveHeaderInfo } from '../../lib/email-headers';
|
||||||
import { calendarBannerShownFor } from '../../lib/attachment-display';
|
import { calendarBannerShownFor } from '../../lib/attachment-display';
|
||||||
|
|
||||||
@@ -66,6 +68,20 @@ export function MessageContent({
|
|||||||
const calendarParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
const calendarParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
|
||||||
const headerInfo = React.useMemo(() => deriveHeaderInfo(email), [email]);
|
const headerInfo = React.useMemo(() => deriveHeaderInfo(email), [email]);
|
||||||
const unwrap = useBodyOverride(email, jmapAccountId);
|
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 calendarBannerShown = calendarBannerShownFor(email, calendarParsing);
|
||||||
const from = email.from?.[0];
|
const from = email.from?.[0];
|
||||||
|
|
||||||
@@ -75,7 +91,7 @@ export function MessageContent({
|
|||||||
jmapAccountId={jmapAccountId}
|
jmapAccountId={jmapAccountId}
|
||||||
calendarBannerShown={calendarBannerShown}
|
calendarBannerShown={calendarBannerShown}
|
||||||
tnefUnpacked={unwrap.tnefUnpacked}
|
tnefUnpacked={unwrap.tnefUnpacked}
|
||||||
extracted={unwrap.extracted}
|
extracted={extracted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -114,13 +130,14 @@ export function MessageContent({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(secure.loading || secure.result) && <SecureMessageBanner status={secure.result?.status ?? null} loading={secure.loading} />}
|
||||||
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
|
<CalendarInvitationBanner email={email} jmapAccountId={jmapAccountId} />
|
||||||
<StructuredMailBanner email={email} jmapAccountId={jmapAccountId} />
|
<StructuredMailBanner email={email} jmapAccountId={jmapAccountId} />
|
||||||
|
|
||||||
<View style={[styles.body, fill && styles.fill]}>
|
<View style={[styles.body, fill && styles.fill]}>
|
||||||
{deferBody ? (
|
{deferBody ? (
|
||||||
<BodyPlaceholder styles={styles} fill={fill} />
|
<BodyPlaceholder styles={styles} fill={fill} />
|
||||||
) : unwrap.loading ? (
|
) : unwrap.loading || secure.loading ? (
|
||||||
<View style={styles.loading}><ActivityIndicator color={c.primary} /></View>
|
<View style={styles.loading}><ActivityIndicator color={c.primary} /></View>
|
||||||
) : (
|
) : (
|
||||||
<EmailBodyView
|
<EmailBodyView
|
||||||
@@ -132,7 +149,7 @@ export function MessageContent({
|
|||||||
themeOverride={themeOverride}
|
themeOverride={themeOverride}
|
||||||
plainView={plainView}
|
plainView={plainView}
|
||||||
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
||||||
bodyOverride={unwrap.override}
|
bodyOverride={bodyOverride}
|
||||||
onSettled={onBodySettled}
|
onSettled={onBodySettled}
|
||||||
fill={fill}
|
fill={fill}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||||
import {
|
import {
|
||||||
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion, Lock,
|
Star, ChevronDown, ChevronUp, Reply, Forward, ShieldCheck, ShieldAlert, ShieldQuestion,
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import type { Email, EmailAddress, Identity } from '../../api/types';
|
import type { Email, EmailAddress, Identity } from '../../api/types';
|
||||||
import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, typography as tokenTypography, componentSizes, type ThemePalette } from '../../theme/tokens';
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
type AuthenticationResults, type EmailHeaderInfo,
|
type AuthenticationResults, type EmailHeaderInfo,
|
||||||
} from '../../lib/email-headers';
|
} from '../../lib/email-headers';
|
||||||
import { formatSize } from '../../lib/attachment-display';
|
import { formatSize } from '../../lib/attachment-display';
|
||||||
import { isSmimeEmail } from '../../lib/smime';
|
|
||||||
import { resolveEmailTags } from '../../lib/thread-utils';
|
import { resolveEmailTags } from '../../lib/thread-utils';
|
||||||
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
|
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
|
||||||
import { VerificationCodeChip } from './VerificationCodeChip';
|
import { VerificationCodeChip } from './VerificationCodeChip';
|
||||||
@@ -166,7 +165,6 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
const important = !!email.keywords?.$important;
|
const important = !!email.keywords?.$important;
|
||||||
const answered = !!email.keywords?.$answered;
|
const answered = !!email.keywords?.$answered;
|
||||||
const forwarded = !!email.keywords?.$forwarded;
|
const forwarded = !!email.keywords?.$forwarded;
|
||||||
const smime = React.useMemo(() => isSmimeEmail(email), [email]);
|
|
||||||
const delta = deliveryDeltaMs(email);
|
const delta = deliveryDeltaMs(email);
|
||||||
|
|
||||||
const auth: AuthenticationResults | undefined = info.auth;
|
const auth: AuthenticationResults | undefined = info.auth;
|
||||||
@@ -243,7 +241,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(tags.length > 0 || important || answered || forwarded || viaIdentity || smime) && (
|
{(tags.length > 0 || important || answered || forwarded || viaIdentity) && (
|
||||||
<View style={styles.badges}>
|
<View style={styles.badges}>
|
||||||
{important && (
|
{important && (
|
||||||
<View style={[styles.badge, { backgroundColor: c.warningBg }]}>
|
<View style={[styles.badge, { backgroundColor: c.warningBg }]}>
|
||||||
@@ -280,12 +278,6 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{smime && (
|
|
||||||
<View style={styles.badge}>
|
|
||||||
<Lock size={11} color={c.textMuted} />
|
|
||||||
<Text style={styles.badgeText}>{t('email_viewer.smime_unsupported', 'Signed/encrypted with S/MIME - not supported on mobile')}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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<CryptoKeyRecord | null>(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row} accessibilityRole="progressbar">
|
||||||
|
<ActivityIndicator size="small" color={c.textMuted} />
|
||||||
|
<Text style={[styles.text, { color: c.textMuted }]}>{t('encryption.banner.checking', 'Checking encryption and signature…')}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<View style={styles.wrap} accessibilityRole="summary">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const Icon = row.key === 'dec' || row.key === 'locked' ? Lock : row.tone === 'ok' ? ShieldCheck : row.tone === 'muted' ? Shield : ShieldAlert;
|
||||||
|
return (
|
||||||
|
<View key={row.key} style={[styles.row, { borderColor: color(row.tone) }]}>
|
||||||
|
<Icon size={16} color={color(row.tone)} />
|
||||||
|
<Text style={[styles.text, { color: color(row.tone) }]}>{row.text}</Text>
|
||||||
|
{row.key === 'locked' && lockedRecord && (
|
||||||
|
<Button size="sm" variant="outline" onPress={() => setUnlockRecord(lockedRecord)}>{t('encryption.unlock', 'Unlock')}</Button>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<UnlockKeyDialog
|
||||||
|
record={unlockRecord}
|
||||||
|
reason={t('encryption.unlock_read_reason', 'The message is encrypted to this key.')}
|
||||||
|
onUnlocked={() => setUnlockRecord(null)}
|
||||||
|
onCancel={() => setUnlockRecord(null)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<string, SecureResult | null>();
|
||||||
|
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<SecureMessageState>(() => (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;
|
||||||
|
}
|
||||||
@@ -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, string | number>) => 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<CryptoKeyRecord | null>(null);
|
||||||
|
const [deleteRecord, setDeleteRecord] = React.useState<CryptoKeyRecord | null>(null);
|
||||||
|
const [pasteMode, setPasteMode] = React.useState<'private' | 'public' | null>(null);
|
||||||
|
const [pendingPrivate, setPendingPrivate] = React.useState<string | null>(null);
|
||||||
|
const [pendingP12, setPendingP12] = React.useState<Uint8Array | null>(null);
|
||||||
|
const [secret, setSecret] = React.useState('');
|
||||||
|
const [secretError, setSecretError] = React.useState<string | null>(null);
|
||||||
|
const [lookupOpen, setLookupOpen] = React.useState(false);
|
||||||
|
const [selfTest, setSelfTest] = React.useState<SelfTestResult[] | null>(null);
|
||||||
|
const [selfTestRunning, setSelfTestRunning] = React.useState(false);
|
||||||
|
|
||||||
|
const run = async (fn: () => Promise<void>) => {
|
||||||
|
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 (
|
||||||
|
<SettingItem key={`${record.kind}:${record.id}`} label={keyLabel(record)} description={details}>
|
||||||
|
<View style={styles.rowActions}>
|
||||||
|
{mine && !isUnlocked(record) && (
|
||||||
|
<Button size="sm" variant="outline" onPress={() => onUnlockPress(record)}>{t('encryption.unlock', 'Unlock')}</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onPress={() => share(record)}>
|
||||||
|
{record.kind === 'pgp' ? t('encryption.share_key', 'Share public key') : t('encryption.share_cert', 'Share certificate')}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructiveOutline" onPress={() => setDeleteRecord(record)}>{t('common.delete', 'Delete')}</Button>
|
||||||
|
</View>
|
||||||
|
</SettingItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<SettingsSection
|
||||||
|
title={t('encryption.own_title', 'Your keys')}
|
||||||
|
description={t('encryption.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.length === 0 && <Text style={styles.empty}>{t('encryption.no_own_keys', 'No keys yet.')}</Text>}
|
||||||
|
{own.map((k) => keyRow(k, true))}
|
||||||
|
<View style={styles.buttons}>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={() => pickPgpFile('private')}>{t('encryption.import_pgp_file', 'Import PGP key file')}</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={() => setPasteMode('private')}>{t('encryption.paste_pgp', 'Paste PGP key')}</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={pickP12}>{t('encryption.import_p12', 'Import S/MIME certificate (.p12)')}</Button>
|
||||||
|
</View>
|
||||||
|
{own.some((k) => isUnlocked(k)) && (
|
||||||
|
<SettingItem label={t('encryption.lock_all', 'Lock all keys')} description={t('encryption.lock_all_description', 'Forget the unlocked keys until you enter their passphrase again.')} noBorder>
|
||||||
|
<Button size="sm" variant="outline" onPress={() => { lockAll(); toast.info(t('encryption.locked_all', 'Keys locked')); }}>{t('encryption.lock', 'Lock')}</Button>
|
||||||
|
</SettingItem>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
title={t('encryption.others_title', 'Other people\'s keys')}
|
||||||
|
description={t('encryption.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.length === 0 && <Text style={styles.empty}>{t('encryption.no_other_keys', 'None yet.')}</Text>}
|
||||||
|
{others.map((k) => keyRow(k, false))}
|
||||||
|
<View style={styles.buttons}>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={() => pickPgpFile('public')}>{t('encryption.import_public_file', 'Import public key file')}</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={() => setPasteMode('public')}>{t('encryption.paste_public', 'Paste public key')}</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={pickCertificate}>{t('encryption.import_cert', 'Import certificate')}</Button>
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onPress={() => setLookupOpen(true)}>{t('encryption.find_key', 'Find a key on keys.openpgp.org')}</Button>
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
title={t('encryption.check_title', 'Check encryption support')}
|
||||||
|
description={t('encryption.check_description', 'Runs every algorithm PGP and S/MIME use on this device.')}
|
||||||
|
>
|
||||||
|
<View style={styles.buttons}>
|
||||||
|
<Button size="sm" variant="outline" loading={selfTestRunning} disabled={selfTestRunning} onPress={() => void runSelfTest()}>{t('encryption.check_run', 'Run check')}</Button>
|
||||||
|
</View>
|
||||||
|
{selfTest && selfTest.map((r) => (
|
||||||
|
<Text key={r.name} style={[styles.checkRow, { color: r.ok ? c.success : c.error }]}>
|
||||||
|
{`${r.ok ? '✓' : '✗'} ${r.name}${r.error ? ` — ${r.error}` : ''}`}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<UnlockKeyDialog record={unlockRecord} onUnlocked={() => setUnlockRecord(null)} onCancel={() => setUnlockRecord(null)} />
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={!!pendingPrivate || !!pendingP12}
|
||||||
|
title={pendingP12 ? t('encryption.p12_password_title', 'Certificate password') : t('encryption.passphrase_title', 'Key passphrase')}
|
||||||
|
message={[
|
||||||
|
pendingP12
|
||||||
|
? t('encryption.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.')
|
||||||
|
: t('encryption.passphrase_message', 'Enter the passphrase of this key. It is checked now and asked again once per app session.'),
|
||||||
|
secretError,
|
||||||
|
].filter(Boolean).join('\n\n')}
|
||||||
|
confirmText={t('encryption.import', 'Import')}
|
||||||
|
confirmDisabled={busy}
|
||||||
|
input={{ value: secret, onChangeText: setSecret, placeholder: pendingP12 ? t('encryption.password', 'Password') : t('encryption.passphrase', 'Passphrase'), props: { secureTextEntry: true, autoCapitalize: 'none', autoCorrect: false } }}
|
||||||
|
onConfirm={() => void submitSecret()}
|
||||||
|
onCancel={() => { setPendingPrivate(null); setPendingP12(null); setSecret(''); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={!!deleteRecord}
|
||||||
|
variant="destructive"
|
||||||
|
title={t('encryption.delete_title', 'Delete key?')}
|
||||||
|
message={deleteRecord?.hasPrivate
|
||||||
|
? t('encryption.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.', { key: deleteRecord ? keyLabel(deleteRecord) : '' })
|
||||||
|
: t('encryption.delete_public_message', '{key} is removed from this device.', { key: deleteRecord ? keyLabel(deleteRecord) : '' })}
|
||||||
|
confirmText={t('common.delete', 'Delete')}
|
||||||
|
onConfirm={() => { const r = deleteRecord; setDeleteRecord(null); if (r) void run(() => deleteKey(r)); }}
|
||||||
|
onCancel={() => setDeleteRecord(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasteKeySheet
|
||||||
|
mode={pasteMode}
|
||||||
|
onClose={() => setPasteMode(null)}
|
||||||
|
onSubmit={(text) => { const mode = pasteMode!; setPasteMode(null); void run(() => importArmored(text, mode)); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KeyLookupSheet visible={lookupOpen} onClose={() => setLookupOpen(false)} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!mode} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
|
||||||
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
|
<Text style={styles.cardTitle} accessibilityRole="header">
|
||||||
|
{mode === 'private' ? t('encryption.paste_pgp', 'Paste PGP key') : t('encryption.paste_public', 'Paste public key')}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
multiline
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
placeholder="-----BEGIN PGP ... KEY BLOCK-----"
|
||||||
|
placeholderTextColor={c.mutedForeground}
|
||||||
|
style={styles.textArea}
|
||||||
|
accessibilityLabel={t('encryption.armored_key', 'Armored key')}
|
||||||
|
/>
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button size="sm" variant="outline" onPress={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||||
|
<Button size="sm" disabled={!/-----BEGIN PGP/.test(text)} onPress={() => onSubmit(text)}>{t('encryption.import', 'Import')}</Button>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<KeyLookupResult | null>(null);
|
||||||
|
const [message, setMessage] = React.useState<string | null>(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 (
|
||||||
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||||
|
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
|
||||||
|
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
|
||||||
|
<ScrollView keyboardShouldPersistTaps="handled">
|
||||||
|
<Text style={styles.cardTitle} accessibilityRole="header">{t('encryption.find_key', 'Find a key on keys.openpgp.org')}</Text>
|
||||||
|
<TextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
placeholder={t('encryption.lookup_placeholder', 'Email address or fingerprint')}
|
||||||
|
placeholderTextColor={c.mutedForeground}
|
||||||
|
onSubmitEditing={() => void search()}
|
||||||
|
style={styles.input}
|
||||||
|
accessibilityLabel={t('encryption.lookup_placeholder', 'Email address or fingerprint')}
|
||||||
|
/>
|
||||||
|
{searching && <ActivityIndicator color={c.primary} style={{ marginTop: spacing.md }} />}
|
||||||
|
{message && <Text style={styles.lookupMessage}>{message}</Text>}
|
||||||
|
{result && (
|
||||||
|
<View style={styles.lookupResult}>
|
||||||
|
<Text style={styles.lookupUser}>{result.info.userIDs.map((u) => u.raw).join('\n') || result.info.keyID}</Text>
|
||||||
|
<Text style={styles.fingerprint} selectable>{formatFingerprint(result.info.fingerprint)}</Text>
|
||||||
|
<Text style={[styles.lookupMessage, { color: result.verified ? c.success : c.warning }]}>
|
||||||
|
{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.')}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<Button size="sm" variant="outline" onPress={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||||
|
{result
|
||||||
|
? <Button size="sm" onPress={() => void importFound()}>{t('encryption.import', 'Import')}</Button>
|
||||||
|
: <Button size="sm" disabled={!query.trim() || searching} onPress={() => void search()}>{t('encryption.lookup_search', 'Search')}</Button>}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(/<ComposerOptionsSheet[\s\S]{0,400}secureAvailable=\{secureAvailable\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the signed / encrypted blob through sendEmail', () => {
|
||||||
|
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(/<SecureMessageBanner status=/);
|
||||||
|
// A signed message's attachments are already listed by the server.
|
||||||
|
expect(reader).toMatch(/secure\.result\?\.status\.encrypted && secure\.result\.attachments\.length/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,17 +1,20 @@
|
|||||||
// The PGP and S/MIME libraries (#58), installed ahead of the features: each
|
// The PGP and S/MIME libraries (#58), installed ahead of the features: each
|
||||||
// one loads and does one basic operation. The same libraries as the webmail's
|
// one loads and does one basic operation. The same libraries as the webmail's
|
||||||
// plugins (repos/plugins/bulwark-gpg, repos/plugins/smime). This runs under
|
// plugins (repos/plugins/bulwark-gpg, repos/plugins/smime). This runs under
|
||||||
// Node, which has WebCrypto; on Hermes openpgp and pkijs need a WebCrypto
|
// Node, which has WebCrypto; on Hermes the app installs one from
|
||||||
// subtle installed first (docs in the S/MIME and PGP feature work).
|
// react-native-quick-crypto (src/lib/crypto/install-webcrypto.ts).
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import * as nodeCrypto from 'node:crypto';
|
||||||
import { webcrypto } from 'node:crypto';
|
import { webcrypto } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
import * as openpgp from 'openpgp';
|
import * as openpgp from 'openpgp';
|
||||||
import * as pkijs from 'pkijs';
|
import * as pkijs from 'pkijs';
|
||||||
import * as asn1js from 'asn1js';
|
import * as asn1js from 'asn1js';
|
||||||
import { Convert } from 'pvtsutils';
|
import { Convert } from 'pvtsutils';
|
||||||
// @ts-expect-error The ES build has no typings; the webmail's smime plugin
|
import { cbcDecrypt, setNodeStyleCrypto, type NodeStyleCrypto } from '../crypto/provider';
|
||||||
// imports it the same way, since the "browser" build has no named exports.
|
import { installWebCrypto } from '../crypto/install-webcrypto';
|
||||||
import * as liner from 'webcrypto-liner/build/index.es.js';
|
|
||||||
|
|
||||||
const subtle = webcrypto.subtle as unknown as SubtleCrypto;
|
const subtle = webcrypto.subtle as unknown as SubtleCrypto;
|
||||||
|
|
||||||
@@ -76,15 +79,76 @@ describe('crypto libraries', () => {
|
|||||||
await expect(parsed.verify(undefined, engine)).resolves.toBe(true);
|
await expect(parsed.verify(undefined, engine)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('webcrypto-liner adds 3DES, which legacy S/MIME mail still uses', async () => {
|
it('3DES, which legacy S/MIME mail still uses, decrypts through the native crypto module', () => {
|
||||||
liner.setCrypto(webcrypto);
|
setNodeStyleCrypto(() => nodeCrypto as unknown as NodeStyleCrypto);
|
||||||
const crypto = new liner.Crypto() as Crypto;
|
const key = nodeCrypto.randomBytes(24);
|
||||||
const key = await crypto.subtle.generateKey({ name: 'DES-EDE3-CBC', length: 192 } as unknown as AesKeyGenParams, true, ['encrypt', 'decrypt']);
|
const iv = nodeCrypto.randomBytes(8);
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(8));
|
const c = nodeCrypto.createCipheriv('des-ede3-cbc', key, iv);
|
||||||
const alg = { name: 'DES-EDE3-CBC', iv } as unknown as AesCbcParams;
|
const sealed = Buffer.concat([c.update('legacy'), c.final()]);
|
||||||
const sealed = await crypto.subtle.encrypt(alg, key as CryptoKey, new TextEncoder().encode('legacy'));
|
|
||||||
expect(sealed.byteLength).toBe(8);
|
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');
|
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<string, unknown> = {};
|
||||||
|
installWebCrypto(target);
|
||||||
|
const crypto = target.crypto as Record<string, unknown>;
|
||||||
|
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<string, unknown> = { crypto: { subtle, getRandomValues } };
|
||||||
|
installWebCrypto(target);
|
||||||
|
expect((target.crypto as { subtle: unknown }).subtle).toBe(subtle);
|
||||||
|
expect((target.crypto as { getRandomValues: unknown }).getRandomValues).toBe(getRandomValues);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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<string, string | number>) =>
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>());
|
||||||
|
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<ReturnType<typeof makePgpKey>>;
|
||||||
|
let friend: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
|
||||||
|
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: '<p>see attached</p>',
|
||||||
|
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" <me@example.org>$/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);
|
||||||
|
});
|
||||||
@@ -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<TestCert> {
|
||||||
|
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<string, string | Uint8Array>, 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`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>());
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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: '<p>Hallo <b>Welt</b></p>',
|
||||||
|
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<ReturnType<typeof makePgpKey>>;
|
||||||
|
let bob: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
|
||||||
|
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('<b>Welt</b>');
|
||||||
|
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('<b>Welt</b>');
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<Uint8Array> {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, string | number>) => 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string | Uint8Array> {
|
||||||
|
const e = email.toLowerCase();
|
||||||
|
const out: Array<string | Uint8Array> = [];
|
||||||
|
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<string | Uint8Array>;
|
||||||
|
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<string | Uint8Array> = [];
|
||||||
|
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<Uint8Array>;
|
||||||
|
upload: (bytes: Uint8Array) => Promise<string>;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<T extends ArrayBufferView>(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<T extends ArrayBufferView>(array: T): T {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const expo = require('expo-crypto') as { getRandomValues?: <V extends ArrayBufferView>(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<string, unknown> = globalThis as unknown as Record<string, unknown>): void {
|
||||||
|
const existing = target.crypto as Record<string, unknown> | undefined;
|
||||||
|
const crypto: Record<string, unknown> = 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();
|
||||||
@@ -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 <email>" 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) : '';
|
||||||
|
}
|
||||||
@@ -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<string, OpenPgp.PrivateKey>();
|
||||||
|
const unlockedSmime = new Map<string, { pkcs8: Uint8Array }>();
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<PgpKeyRecord> {
|
||||||
|
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<SmimeCertRecord> {
|
||||||
|
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<SmimeCertRecord> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<KeyLookupResult | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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 "<a@b> <c@d>"; 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<string, string>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
cte: string;
|
||||||
|
body: string;
|
||||||
|
/** The exact entity (headers + body) as a binary string. */
|
||||||
|
raw: string;
|
||||||
|
children: MimeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeaders(headerText: string): Record<string, string> {
|
||||||
|
const unfolded = headerText.replace(/\r?\n[ \t]+/g, ' ');
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
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<string, string> } {
|
||||||
|
const parts = value.split(';');
|
||||||
|
const type = parts[0].trim().toLowerCase();
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<typeof OpenPgp> {
|
||||||
|
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<PgpKeyInfo> {
|
||||||
|
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<boolean> {
|
||||||
|
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<OpenPgp.PrivateKey> {
|
||||||
|
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<OpenPgp.Key[]> {
|
||||||
|
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<PgpSignatureSummary> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<PgpSignatureSummary> {
|
||||||
|
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)));
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
rememberCertificate?(der: Uint8Array): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartLike = Pick<BodyPart, 'type'> & { 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<Email, 'bodyStructure' | 'attachments' | 'preview' | 'textBody' | 'bodyValues'>): 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<SecureResult | null> {
|
||||||
|
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<PgpSignatureSummary> {
|
||||||
|
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<void> {
|
||||||
|
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<SecureResult | null> {
|
||||||
|
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<SecureResult | null> {
|
||||||
|
return (await openPgp(raw, fromEmail, keys)) ?? (await openSmime(raw, fromEmail, keys));
|
||||||
|
}
|
||||||
@@ -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<Uint8Array> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<void>];
|
||||||
|
|
||||||
|
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 <nonzero random> 00 <data>.
|
||||||
|
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<SelfTestResult[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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<string> {
|
||||||
|
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<string, string> = { '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<CertificateInfo> {
|
||||||
|
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<CertificateInfo> {
|
||||||
|
return extractCertificateInfo(parseCertificateDer(der), der);
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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<string, string> = {
|
||||||
|
'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<SmimeKeyMaterial, 'id' | 'certificate'>[]): 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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<SmimeSignatureStatus> {
|
||||||
|
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<SmimeSignatureStatus> {
|
||||||
|
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<CryptoKey> {
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
@@ -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<Uint8Array> {
|
||||||
|
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<ArrayBuffer> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
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<Pkcs12Contents> {
|
||||||
|
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<void> };
|
||||||
|
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(',');
|
||||||
|
}
|
||||||
+18
-6
@@ -208,6 +208,24 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"url": "https://github.com/sAleksovski/react-native-android-widget"
|
"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",
|
"name": "react-native-safe-area-context",
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
@@ -238,12 +256,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"url": "https://github.com/react-native-webview/react-native-webview"
|
"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",
|
"name": "zustand",
|
||||||
"version": "5.0.16",
|
"version": "5.0.16",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type SettingsTabId =
|
|||||||
| 'appearance' | 'layout'
|
| 'appearance' | 'layout'
|
||||||
| 'reading' | 'composing' | 'identities' | 'vacation'
|
| 'reading' | 'composing' | 'identities' | 'vacation'
|
||||||
| 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads'
|
| 'filters' | 'recovery' | 'templates' | 'folders' | 'keywords' | 'downloads'
|
||||||
| 'security' | 'content_senders'
|
| 'security' | 'content_senders' | 'encryption'
|
||||||
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
| 'calendar' | 'contacts' | 'files' | 'sidebar_apps'
|
||||||
// about_data is Storage & backup (the id predates the split from About);
|
// about_data is Storage & backup (the id predates the split from About);
|
||||||
// debug, licences and privacy are pages opened from inside another one.
|
// debug, licences and privacy are pages opened from inside another one.
|
||||||
@@ -170,6 +170,12 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
|
|||||||
'settings.downloads.strip_diacritics',
|
'settings.downloads.strip_diacritics',
|
||||||
'settings.downloads.after_export',
|
'settings.downloads.after_export',
|
||||||
],
|
],
|
||||||
|
encryption: [
|
||||||
|
'encryption.own_title',
|
||||||
|
'encryption.others_title',
|
||||||
|
'encryption.find_key',
|
||||||
|
'encryption.check_title',
|
||||||
|
],
|
||||||
security: [
|
security: [
|
||||||
'settings.security.screen_privacy',
|
'settings.security.screen_privacy',
|
||||||
'settings.security.client_cert',
|
'settings.security.client_cert',
|
||||||
@@ -286,6 +292,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
|
|||||||
folders: 'mailbox rename role',
|
folders: 'mailbox rename role',
|
||||||
keywords: 'tags labels colors',
|
keywords: 'tags labels colors',
|
||||||
downloads: 'download filename template eml attachment save export',
|
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',
|
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',
|
content_senders: 'block sender remote images privacy tracking',
|
||||||
calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`,
|
calendar: `event schedule appointment meeting timezone${supportsDeviceSync ? ' android phone device sync' : ''}`,
|
||||||
|
|||||||
@@ -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<Email, 'bodyStructure' | 'attachments'>): 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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -79,6 +79,13 @@ import { attachmentFromFileNode } from '../lib/compose-files';
|
|||||||
import { supportsFiles } from '../api/files';
|
import { supportsFiles } from '../api/files';
|
||||||
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
||||||
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
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 type { MessagePriority } from '../lib/message-priority';
|
||||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||||
import {
|
import {
|
||||||
@@ -635,9 +642,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
||||||
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
|
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
|
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
|
||||||
|
// Sign / encrypt with OpenPGP or S/MIME (#58), per message.
|
||||||
|
const [secureMode, setSecureMode] = React.useState<SecureMode>('pgp');
|
||||||
|
const [secureSign, setSecureSign] = React.useState(false);
|
||||||
|
const [secureEncrypt, setSecureEncrypt] = React.useState(false);
|
||||||
|
const [unlockRecord, setUnlockRecord] = React.useState<CryptoKeyRecord | null>(null);
|
||||||
|
const unlockResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
|
||||||
const composerOptions = React.useMemo<ComposerOptions>(
|
const composerOptions = React.useMemo<ComposerOptions>(
|
||||||
() => ({ requestReadReceipt, requestDsn, requireTls, priority }),
|
() => ({ requestReadReceipt, requestDsn, requireTls, priority, secureMode, sign: secureSign, encrypt: secureEncrypt }),
|
||||||
[requestReadReceipt, requestDsn, requireTls, priority],
|
[requestReadReceipt, requestDsn, requireTls, priority, secureMode, secureSign, secureEncrypt],
|
||||||
);
|
);
|
||||||
// Read when the sheet opens: the session may have arrived since the
|
// Read when the sheet opens: the session may have arrived since the
|
||||||
// composer did.
|
// composer did.
|
||||||
@@ -648,6 +661,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
|
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
|
||||||
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
|
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
|
||||||
if (next.priority !== undefined) setPriority(next.priority);
|
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 [subAddressTag, setSubAddressTag] = React.useState('');
|
||||||
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
|
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]);
|
}, [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<boolean> => 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<string | null> => {
|
||||||
|
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).
|
// Save one draft version (create, then destroy the previous one - #849).
|
||||||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||||||
const identity = primaryIdentity;
|
const identity = primaryIdentity;
|
||||||
@@ -2612,8 +2700,23 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
||||||
queuedOutgoing = outgoing;
|
const secureChoice: SecureChoice | null = secureSign || secureEncrypt
|
||||||
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;
|
? { 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
|
// The account the submission is held in, for Undo (read before the
|
||||||
// request: a switch may land while it is out).
|
// request: a switch may land while it is out).
|
||||||
// A group or shared identity sends from its own account, and its Sent
|
// 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);
|
const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
|
||||||
if (refuseSendAfterSwitch()) return;
|
if (refuseSendAfterSwitch()) return;
|
||||||
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined;
|
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(
|
const result = await sendEmail(
|
||||||
outgoing,
|
outgoing,
|
||||||
target.identityId,
|
target.identityId,
|
||||||
@@ -2635,6 +2745,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// hides the toggles the account's submission extensions lack).
|
// hides the toggles the account's submission extensions lack).
|
||||||
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||||
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||||
|
rawBlobId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
draftIdRef.current = null;
|
draftIdRef.current = null;
|
||||||
@@ -3661,6 +3772,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
requireTlsAvailable={requireTlsAvailable}
|
requireTlsAvailable={requireTlsAvailable}
|
||||||
onChange={onComposerOptionsChange}
|
onChange={onComposerOptionsChange}
|
||||||
onClose={() => setOptionsSheetOpen(false)}
|
onClose={() => setOptionsSheetOpen(false)}
|
||||||
|
secureAvailable={secureAvailable}
|
||||||
|
secureHint={secureHint}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UnlockKeyDialog
|
||||||
|
record={unlockRecord}
|
||||||
|
reason={t('email_composer.secure_unlock_reason', 'Your key signs this message.')}
|
||||||
|
onUnlocked={() => finishUnlock(true)}
|
||||||
|
onCancel={() => finishUnlock(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilesPickerSheet
|
<FilesPickerSheet
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
Palette, User, Shield, UserPen, Palmtree, Calendar,
|
||||||
Filter, FileText, FolderOpen, Tags, HardDrive,
|
Filter, FileText, FolderOpen, Tags, HardDrive,
|
||||||
BookUser, PanelLeftClose, Bell, RefreshCw,
|
BookUser, PanelLeftClose, Bell, RefreshCw,
|
||||||
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download,
|
LayoutGrid, BookOpen, PenLine, EyeOff, Languages, Info, Download, KeyRound,
|
||||||
Database, Bug, Scale, Users, RotateCcw,
|
Database, Bug, Scale, Users, RotateCcw,
|
||||||
type AppIcon,
|
type AppIcon,
|
||||||
} from '../components/icons';
|
} from '../components/icons';
|
||||||
@@ -38,6 +38,7 @@ import { FilterSettings } from '../components/settings/FilterSettings';
|
|||||||
import { TemplateSettings } from '../components/settings/TemplateSettings';
|
import { TemplateSettings } from '../components/settings/TemplateSettings';
|
||||||
import { KeywordSettings } from '../components/settings/KeywordSettings';
|
import { KeywordSettings } from '../components/settings/KeywordSettings';
|
||||||
import { AccountSecuritySettings } from '../components/settings/AccountSecuritySettings';
|
import { AccountSecuritySettings } from '../components/settings/AccountSecuritySettings';
|
||||||
|
import { EncryptionSettings } from '../components/settings/EncryptionSettings';
|
||||||
import { SidebarAppsSettings } from '../components/settings/SidebarAppsSettings';
|
import { SidebarAppsSettings } from '../components/settings/SidebarAppsSettings';
|
||||||
import { ThemesSettings } from '../components/settings/ThemesSettings';
|
import { ThemesSettings } from '../components/settings/ThemesSettings';
|
||||||
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
|
import { ContentSendersSettings } from '../components/settings/ContentSendersSettings';
|
||||||
@@ -128,6 +129,8 @@ const TABS: TabDef[] = [
|
|||||||
// Privacy & Security
|
// Privacy & Security
|
||||||
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
|
{ id: 'security', label: 'Security', icon: Shield, group: 'privacy', implemented: true },
|
||||||
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true },
|
{ id: 'content_senders', label: 'Content & Senders', icon: EyeOff, group: 'privacy', implemented: true },
|
||||||
|
// PGP and S/MIME keys (#58); the webmail has them in its plugins' settings.
|
||||||
|
{ id: 'encryption', label: 'Encryption', icon: KeyRound, group: 'privacy', implemented: true },
|
||||||
|
|
||||||
// Apps
|
// Apps
|
||||||
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: true },
|
{ id: 'calendar', label: 'Calendar', icon: Calendar, group: 'apps', implemented: true },
|
||||||
@@ -135,8 +138,7 @@ const TABS: TabDef[] = [
|
|||||||
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: true },
|
{ id: 'files', label: 'Files', icon: HardDrive, group: 'apps', implemented: true },
|
||||||
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: true },
|
{ id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: true },
|
||||||
|
|
||||||
// Advanced. S/MIME and plugins come from webmail plugins, so the app has
|
// Advanced. Plugins come from the webmail, so the app has no page for them.
|
||||||
// no page for them.
|
|
||||||
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', implemented: true },
|
{ id: 'themes', label: 'Themes', icon: Palette, group: 'advanced', implemented: true },
|
||||||
{ id: 'about_data', label: 'Storage & backup', icon: Database, group: 'advanced', implemented: true },
|
{ id: 'about_data', label: 'Storage & backup', icon: Database, group: 'advanced', implemented: true },
|
||||||
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
|
{ id: 'updates', label: 'Updates', icon: RefreshCw, group: 'advanced', implemented: true },
|
||||||
@@ -155,6 +157,7 @@ const TAB_LABEL_KEYS: Partial<Record<Tab, string>> = {
|
|||||||
privacy: 'settings.about.privacy',
|
privacy: 'settings.about.privacy',
|
||||||
sharing: 'delegates.tab',
|
sharing: 'delegates.tab',
|
||||||
recovery: 'recover_deleted.tab',
|
recovery: 'recover_deleted.tab',
|
||||||
|
encryption: 'encryption.tab',
|
||||||
};
|
};
|
||||||
const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`;
|
const tabLabelKey = (id: Tab) => TAB_LABEL_KEYS[id] ?? `settings.tabs.${id}`;
|
||||||
|
|
||||||
@@ -178,6 +181,7 @@ const TAB_COMPONENTS: Partial<Record<Tab, React.ComponentType<any>>> = {
|
|||||||
recovery: RecoverDeletedSettings,
|
recovery: RecoverDeletedSettings,
|
||||||
security: AccountSecuritySettings,
|
security: AccountSecuritySettings,
|
||||||
content_senders: ContentSendersSettings,
|
content_senders: ContentSendersSettings,
|
||||||
|
encryption: EncryptionSettings,
|
||||||
calendar: CalendarSettings,
|
calendar: CalendarSettings,
|
||||||
contacts: ContactsSettings,
|
contacts: ContactsSettings,
|
||||||
files: FilesSettings,
|
files: FilesSettings,
|
||||||
|
|||||||
@@ -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.<id>`. */
|
||||||
|
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.<id>`. */
|
||||||
|
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<CryptoKeysState>()(
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user