diff --git a/app.config.js b/app.config.js index be945a34..562fda1a 100644 --- a/app.config.js +++ b/app.config.js @@ -83,6 +83,10 @@ module.exports = { }, plugins: [ 'expo-secure-store', + // WebCrypto for PGP and S/MIME (#58). On iOS it raises the deployment + // target (app and pods) to 16.4, the minimum Nitro modules build for; + // Android has nothing to set. + 'react-native-quick-crypto', '@react-native-community/datetimepicker', 'expo-localization', [ diff --git a/index.ts b/index.ts index cf5677d1..1a093e29 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,9 @@ // First: wraps react-native's Text for the reading font before any module // copies it (src/theme/reading-font-text). import './src/theme/reading-font-install'; +// WebCrypto for PGP and S/MIME (#58), before openpgp or pkijs can load. +// Loads nothing native until first used. +import './src/lib/crypto/install-webcrypto'; import { createElement } from 'react'; import { AppRegistry } from 'react-native'; import { registerRootComponent } from 'expo'; diff --git a/metro.config.js b/metro.config.js index 3ca9fb8b..ddeb71df 100644 --- a/metro.config.js +++ b/metro.config.js @@ -43,4 +43,18 @@ config.transformer.getTransformOptions = async () => ({ }, }); +// openpgp's package exports send Metro to its Node build, which imports +// node:crypto. The browser build (same API, WebCrypto from +// src/lib/crypto/install-webcrypto.ts) is what runs on Hermes. Only openpgp +// gets it: adding the "browser" condition globally would change what every +// other package resolves to. +const OPENPGP_BROWSER_BUILD = path.join(path.dirname(require.resolve('openpgp')), '..', 'openpgp.min.mjs'); +const baseResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + if (moduleName === 'openpgp') return { type: 'sourceFile', filePath: OPENPGP_BROWSER_BUILD }; + return baseResolveRequest + ? baseResolveRequest(context, moduleName, platform) + : context.resolveRequest(context, moduleName, platform); +}; + module.exports = config; diff --git a/package-lock.json b/package-lock.json index 01ef8cc6..5ae6b251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,12 +44,14 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "0.22.1", + "react-native-nitro-modules": "^0.37.1", + "react-native-quick-base64": "^3.0.1", + "react-native-quick-crypto": "^1.1.9", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", "react-native-svg": "15.12.1", "react-native-webview": "13.15.0", - "webcrypto-liner": "^1.4.3", "zustand": "^5.0.16" }, "devDependencies": { @@ -1560,6 +1562,30 @@ "node": ">=6.9.0" } }, + "node_modules/@craftzdog/react-native-buffer": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@craftzdog/react-native-buffer/-/react-native-buffer-6.1.2.tgz", + "integrity": "sha512-KV1HitN05FHLLDG7Zb/yftDsa+mKBYBzFMQ0PMldvUicq6vWOtAvz9mDavt7Fzozh+WNqORE+yFDkkdWysZ/SA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "ieee754": "^1.2.1", + "react-native-quick-base64": "^3.0.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", @@ -2655,41 +2681,6 @@ "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.11.1.tgz", - "integrity": "sha512-jaU9OaCcbZUdyXx9hMETZB9xoNmxTGjOGFqd3cOwKFkpKnyKd9WxF3uqj4PvX4cyOMP9bSdHg5YyML2qtx/d/g==", - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@peculiar/json-schema": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", - "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, "node_modules/@react-native-async-storage/async-storage": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", @@ -3384,44 +3375,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@stablelib/binary": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/binary/-/binary-1.0.1.tgz", - "integrity": "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==", - "license": "MIT", - "dependencies": { - "@stablelib/int": "^1.0.1" - } - }, - "node_modules/@stablelib/hash": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/hash/-/hash-1.0.1.tgz", - "integrity": "sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==", - "license": "MIT" - }, - "node_modules/@stablelib/int": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/int/-/int-1.0.1.tgz", - "integrity": "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==", - "license": "MIT" - }, - "node_modules/@stablelib/sha3": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/sha3/-/sha3-1.0.1.tgz", - "integrity": "sha512-82OHZcxWsJAS34L64VItIbqZdcdYgBJmeToYaou9lUA+iMjajdfOVZDDrditfV8C8yXUDrlS3BuMRWmKf9NQhQ==", - "license": "MIT", - "dependencies": { - "@stablelib/binary": "^1.0.1", - "@stablelib/hash": "^1.0.1", - "@stablelib/wipe": "^1.0.1" - } - }, - "node_modules/@stablelib/wipe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", - "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3893,12 +3846,6 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, - "node_modules/asmcrypto.js": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/asmcrypto.js/-/asmcrypto.js-2.3.2.tgz", - "integrity": "sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==", - "license": "MIT" - }, "node_modules/asn1js": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz", @@ -4284,12 +4231,6 @@ "node": ">=0.6" } }, - "node_modules/bn.js": { - "version": "4.12.5", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", - "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4341,12 +4282,6 @@ "node": ">=8" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -4814,20 +4749,6 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, - "node_modules/core-js": { - "version": "3.51.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.51.0.tgz", - "integrity": "sha512-7bDKx/5jm5MxzsXxc93g6Z2+4ZsuT/MddrfRmTDJNAkWsYWHU8EVNEsBiIEWnMAMCHMmRfUEjkuo3jeNBOIHRw==", - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-js-compat": { "version": "3.51.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.51.0.tgz", @@ -5030,16 +4951,6 @@ "node": ">= 0.8" } }, - "node_modules/des.js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", - "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -5167,20 +5078,6 @@ "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", "license": "ISC" }, - "node_modules/elliptic": { - "version": "6.5.0", - "resolved": "git+ssh://git@github.com/mahrud/elliptic.git#75637c76678e83c31682fd967c2fa9ff4761b3fc", - "license": "MIT", - "dependencies": { - "bn.js": "^4.4.0", - "brorand": "^1.0.1", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.0" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5331,6 +5228,15 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -6497,16 +6403,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -6534,17 +6430,6 @@ "hermes-estree": "0.32.0" } }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -8213,18 +8098,6 @@ "node": ">=4" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -9026,6 +8899,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -9251,6 +9133,124 @@ "react-native": "*" } }, + "node_modules/react-native-nitro-modules": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz", + "integrity": "sha512-KpW6EQVQ/bfegpCGxN9Be+ndvsfj56t4IESEvCASu9gWpJa/NDfHpIeIwCOvZQ3eXmLEj6YD4wCLqcC1FJPr2w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-quick-base64": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-3.0.1.tgz", + "integrity": "sha512-EjUP2U7WqKmlMmoY7XGyHomy8bM0q4+yCDCRg4ZezQ6zedYRwc7yVk4V2O/iSftKaLEzhuW98lpyPMdk1iPHXQ==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-quick-crypto": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-1.1.9.tgz", + "integrity": "sha512-u60Kr4EdAYI1ksGq7uwJfOXNJR/iWkCQ1AxfoVbjM/pEpiydddzsFXFYqwyv7EeB7eTDgg5/LiCGwNxA7W7NKA==", + "license": "MIT", + "dependencies": { + "@craftzdog/react-native-buffer": "^6.1.2", + "events": "3.3.0", + "readable-stream": "4.7.0", + "safe-buffer": "^5.2.1", + "string_decoder": "^1.3.0", + "util": "0.12.5" + }, + "peerDependencies": { + "expo": ">=48.0.0", + "react": "*", + "react-native": "*", + "react-native-nitro-modules": ">=0.31.2", + "react-native-quick-base64": ">=3.0.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/react-native-quick-crypto/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/react-native-quick-crypto/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/react-native-quick-crypto/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-native-quick-crypto/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", @@ -10947,38 +10947,6 @@ "defaults": "^1.0.3" } }, - "node_modules/webcrypto-core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", - "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.7.0", - "@peculiar/json-schema": "^1.1.12", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/webcrypto-liner": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/webcrypto-liner/-/webcrypto-liner-1.4.3.tgz", - "integrity": "sha512-gzlk7ciS5zqc8QZMwpzpRxxwkcQKDJDndhr/hHWQe18Rzafhji3a7CaSxIeA2jcL0bLcAK+P77K3lWS1QXMMYA==", - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/json-schema": "^1.1.12", - "@stablelib/sha3": "^1.0.1", - "asmcrypto.js": "^2.3.2", - "asn1js": "^3.0.5", - "core-js": "^3.35.1", - "des.js": "^1.1.0", - "elliptic": "git+https://github.com/mahrud/elliptic.git", - "pvtsutils": "^1.3.5", - "tslib": "^2.6.2", - "webcrypto-core": "^1.7.8" - } - }, "node_modules/webidl-conversions": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", diff --git a/package.json b/package.json index 14dfe745..01476098 100644 --- a/package.json +++ b/package.json @@ -53,12 +53,14 @@ "react": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "0.22.1", + "react-native-nitro-modules": "^0.37.1", + "react-native-quick-base64": "^3.0.1", + "react-native-quick-crypto": "^1.1.9", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-sse": "^1.2.1", "react-native-svg": "15.12.1", "react-native-webview": "13.15.0", - "webcrypto-liner": "^1.4.3", "zustand": "^5.0.16" }, "devDependencies": { diff --git a/src/lib/__tests__/crypto-libraries.test.ts b/src/lib/__tests__/crypto-libraries.test.ts index 56efb0a9..cecca024 100644 --- a/src/lib/__tests__/crypto-libraries.test.ts +++ b/src/lib/__tests__/crypto-libraries.test.ts @@ -1,17 +1,20 @@ // 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 // plugins (repos/plugins/bulwark-gpg, repos/plugins/smime). This runs under -// Node, which has WebCrypto; on Hermes openpgp and pkijs need a WebCrypto -// subtle installed first (docs in the S/MIME and PGP feature work). -import { describe, it, expect } from 'vitest'; +// Node, which has WebCrypto; on Hermes the app installs one from +// react-native-quick-crypto (src/lib/crypto/install-webcrypto.ts). +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as nodeCrypto 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 pkijs from 'pkijs'; import * as asn1js from 'asn1js'; import { Convert } from 'pvtsutils'; -// @ts-expect-error The ES build has no typings; the webmail's smime plugin -// imports it the same way, since the "browser" build has no named exports. -import * as liner from 'webcrypto-liner/build/index.es.js'; +import { cbcDecrypt, setNodeStyleCrypto, type NodeStyleCrypto } from '../crypto/provider'; +import { installWebCrypto } from '../crypto/install-webcrypto'; const subtle = webcrypto.subtle as unknown as SubtleCrypto; @@ -76,15 +79,76 @@ describe('crypto libraries', () => { await expect(parsed.verify(undefined, engine)).resolves.toBe(true); }); - it('webcrypto-liner adds 3DES, which legacy S/MIME mail still uses', async () => { - liner.setCrypto(webcrypto); - const crypto = new liner.Crypto() as Crypto; - const key = await crypto.subtle.generateKey({ name: 'DES-EDE3-CBC', length: 192 } as unknown as AesKeyGenParams, true, ['encrypt', 'decrypt']); - const iv = crypto.getRandomValues(new Uint8Array(8)); - const alg = { name: 'DES-EDE3-CBC', iv } as unknown as AesCbcParams; - const sealed = await crypto.subtle.encrypt(alg, key as CryptoKey, new TextEncoder().encode('legacy')); + it('3DES, which legacy S/MIME mail still uses, decrypts through the native crypto module', () => { + setNodeStyleCrypto(() => nodeCrypto as unknown as NodeStyleCrypto); + const key = nodeCrypto.randomBytes(24); + const iv = nodeCrypto.randomBytes(8); + const c = nodeCrypto.createCipheriv('des-ede3-cbc', key, iv); + const sealed = Buffer.concat([c.update('legacy'), c.final()]); expect(sealed.byteLength).toBe(8); - const opened = await crypto.subtle.decrypt(alg, key as CryptoKey, sealed); + const opened = cbcDecrypt('des-ede3-cbc', new Uint8Array(key), new Uint8Array(iv), new Uint8Array(sealed)); expect(new TextDecoder().decode(opened)).toBe('legacy'); }); }); + +describe('openpgp on a Hermes-like runtime', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Metro bundles openpgp's browser build (metro.config.js). Hermes has + // WebCrypto only through the installed module, and no web streams or + // WebAssembly: the build must work with plain strings and bytes. + it('the browser build Metro bundles encrypts, signs and decrypts without streams or WebAssembly', async () => { + vi.stubGlobal('ReadableStream', undefined); + vi.stubGlobal('TransformStream', undefined); + vi.stubGlobal('WritableStream', undefined); + vi.stubGlobal('WebAssembly', undefined); + const req = createRequire(import.meta.url); + const browserBuild = path.join(path.dirname(req.resolve('openpgp')), '..', 'openpgp.min.mjs'); + const pgp = (await import(/* @vite-ignore */ pathToFileURL(browserBuild).href)) as typeof openpgp; + + for (const keyOptions of [{ type: 'ecc', curve: 'curve25519Legacy' }, { type: 'rsa', rsaBits: 2048 }] as const) { + const { privateKey, publicKey } = await pgp.generateKey({ + ...keyOptions, + userIDs: [{ email: 'hermes@example.org' }], + passphrase: 'pw', + format: 'armored', + }); + const priv = await pgp.decryptKey({ privateKey: await pgp.readPrivateKey({ armoredKey: privateKey }), passphrase: 'pw' }); + const pub = await pgp.readKey({ armoredKey: publicKey }); + const armored = await pgp.encrypt({ + message: await pgp.createMessage({ text: 'ohne Streams' }), + encryptionKeys: pub, + signingKeys: priv, + }); + const { data, signatures } = await pgp.decrypt({ + message: await pgp.readMessage({ armoredMessage: armored as string }), + decryptionKeys: priv, + verificationKeys: pub, + }); + expect(data).toBe('ohne Streams'); + await expect(signatures[0].verified).resolves.toBe(true); + } + }, 60_000); +}); + +describe('installWebCrypto', () => { + it('puts a lazy subtle and getRandomValues on a runtime without crypto', () => { + const target: Record = {}; + installWebCrypto(target); + const crypto = target.crypto as Record; + expect(typeof crypto.getRandomValues).toBe('function'); + // The native module loads on first use of subtle, not at install. + expect(Object.getOwnPropertyDescriptor(crypto, 'subtle')?.get).toBeTypeOf('function'); + }); + + it('keeps a crypto that is already there', () => { + const subtle = {}; + const getRandomValues = () => undefined; + const target: Record = { crypto: { subtle, getRandomValues } }; + installWebCrypto(target); + expect((target.crypto as { subtle: unknown }).subtle).toBe(subtle); + expect((target.crypto as { getRandomValues: unknown }).getRandomValues).toBe(getRandomValues); + }); +}); diff --git a/src/lib/crypto/install-webcrypto.ts b/src/lib/crypto/install-webcrypto.ts new file mode 100644 index 00000000..895b6999 --- /dev/null +++ b/src/lib/crypto/install-webcrypto.ts @@ -0,0 +1,54 @@ +// Installs WebCrypto for the PGP and S/MIME code (#58). Hermes has no +// `crypto` global; openpgp throws "The WebCrypto API is not available" at +// its first operation without one, and pkijs only picks an engine when it +// finds one. react-native-quick-crypto (OpenSSL, native) provides it. +// +// Imported first thing in index.ts. Nothing native loads here: `subtle` and +// the Node-style module are required on first use, so an app start or a +// headless push task pays nothing until a key or an encrypted message is +// touched. getRandomValues comes from expo-crypto, which the app already +// loads for src/lib/random.ts. + +import { setNodeStyleCrypto, type NodeStyleCrypto } from './provider'; + +type QuickCrypto = NodeStyleCrypto & { subtle: SubtleCrypto; getRandomValues(a: T): T; randomUUID(): string }; + +let quick: QuickCrypto | null = null; +function quickCrypto(): QuickCrypto { + if (!quick) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('react-native-quick-crypto') as { default?: QuickCrypto } & QuickCrypto; + quick = (mod.default ?? mod) as QuickCrypto; + } + return quick; +} + +function randomValues(array: T): T { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const expo = require('expo-crypto') as { getRandomValues?: (a: V) => V }; + if (expo.getRandomValues) return expo.getRandomValues(array); + } catch { + // fall through to OpenSSL + } + return quickCrypto().getRandomValues(array); +} + +/** Put `crypto.subtle` / `crypto.getRandomValues` on the global, loading the native module lazily. */ +export function installWebCrypto(target: Record = globalThis as unknown as Record): void { + const existing = target.crypto as Record | undefined; + const crypto: Record = existing && Object.isExtensible(existing) ? existing : { ...(existing ?? {}) }; + if (typeof crypto.getRandomValues !== 'function') crypto.getRandomValues = randomValues; + if (typeof crypto.randomUUID !== 'function') crypto.randomUUID = () => quickCrypto().randomUUID(); + if (!crypto.subtle) { + Object.defineProperty(crypto, 'subtle', { + configurable: true, + enumerable: true, + get: () => quickCrypto().subtle, + }); + } + if (crypto !== existing) target.crypto = crypto; + setNodeStyleCrypto(quickCrypto); +} + +installWebCrypto(); diff --git a/src/lib/crypto/provider.ts b/src/lib/crypto/provider.ts new file mode 100644 index 00000000..6f57f90b --- /dev/null +++ b/src/lib/crypto/provider.ts @@ -0,0 +1,96 @@ +// The crypto the PGP and S/MIME code (#58) runs on, beyond WebCrypto. +// +// openpgp and pkijs only need `globalThis.crypto` (getRandomValues + subtle). +// S/MIME mail still uses a few things WebCrypto has no names for: RSAES- +// PKCS1-v1_5 key transport, 3DES content encryption and ChaCha20-Poly1305 +// (Stalwart's encryption at rest). Those go through a Node-style crypto +// module: react-native-quick-crypto in the app (src/lib/crypto/install- +// webcrypto.ts registers it), node:crypto in the tests. Both are OpenSSL. + +/** The part of Node's crypto module this code uses. */ +export interface NodeStyleCrypto { + privateDecrypt( + key: { key: unknown; format?: string; type?: string; padding?: number; oaepHash?: string }, + buffer: Uint8Array, + ): Uint8Array; + createDecipheriv( + algorithm: string, + key: Uint8Array, + iv: Uint8Array, + options?: { authTagLength?: number }, + ): { + update(data: Uint8Array): Uint8Array; + final(): Uint8Array; + setAuthTag?(tag: Uint8Array): unknown; + setAAD?(aad: Uint8Array, options?: { plaintextLength: number }): unknown; + setAutoPadding?(on: boolean): unknown; + }; + constants: { RSA_PKCS1_PADDING: number }; +} + +let load: (() => NodeStyleCrypto) | null = null; +let cached: NodeStyleCrypto | null = null; + +/** Register where the Node-style crypto comes from (loaded on first use). */ +export function setNodeStyleCrypto(loader: () => NodeStyleCrypto): void { + load = loader; + cached = null; +} + +/** The registered Node-style crypto, or an error naming what is missing. */ +export function nodeStyleCrypto(): NodeStyleCrypto { + if (cached) return cached; + if (!load) throw new Error('No native crypto module is available'); + cached = load(); + return cached; +} + +/** The WebCrypto subtle the app installed, or an error the UI can show. */ +export function webCrypto(): Crypto { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (!c?.subtle || !c.getRandomValues) throw new Error('The WebCrypto API is not available'); + return c; +} + +/** Copy a view into a standalone ArrayBuffer (WebCrypto and pkijs want one). */ +export function toArrayBuffer(view: ArrayBuffer | ArrayBufferView): ArrayBuffer { + if (view instanceof ArrayBuffer) return view; + return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; +} + +/** Concatenate byte arrays. */ +export function concatBytes(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.byteLength, 0)); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.byteLength; + } + return out; +} + +/** Decrypt RSAES-PKCS1-v1_5 (WebCrypto has no name for it) with a PKCS#8 key. */ +export function rsaPkcs1Decrypt(pkcs8: Uint8Array, ciphertext: Uint8Array): Uint8Array { + const nc = nodeStyleCrypto(); + const out = nc.privateDecrypt( + { key: pkcs8, format: 'der', type: 'pkcs8', padding: nc.constants.RSA_PKCS1_PADDING }, + ciphertext, + ); + return new Uint8Array(out); +} + +/** CBC decryption for ciphers WebCrypto lacks (3DES). PKCS#7 padding is removed. */ +export function cbcDecrypt(algorithm: 'des-ede3-cbc', key: Uint8Array, iv: Uint8Array, data: Uint8Array): Uint8Array { + const d = nodeStyleCrypto().createDecipheriv(algorithm, key, iv); + return concatBytes(new Uint8Array(d.update(data)), new Uint8Array(d.final())); +} + +/** ChaCha20-Poly1305 (RFC 8103) decryption; the tag is the last 16 bytes of `sealed`. */ +export function chacha20Poly1305Decrypt(key: Uint8Array, nonce: Uint8Array, sealed: Uint8Array, aad?: Uint8Array): Uint8Array { + if (sealed.byteLength < 16) throw new Error('ChaCha20-Poly1305 ciphertext is too short'); + const d = nodeStyleCrypto().createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 }); + const body = sealed.subarray(0, sealed.byteLength - 16); + d.setAuthTag?.(sealed.subarray(sealed.byteLength - 16)); + if (aad) d.setAAD?.(aad, { plaintextLength: body.byteLength }); + return concatBytes(new Uint8Array(d.update(body)), new Uint8Array(d.final())); +} diff --git a/src/lib/licences.json b/src/lib/licences.json index e9d4cb48..2cd1bc48 100644 --- a/src/lib/licences.json +++ b/src/lib/licences.json @@ -208,6 +208,24 @@ "license": "MIT", "url": "https://github.com/sAleksovski/react-native-android-widget" }, + { + "name": "react-native-nitro-modules", + "version": "0.37.1", + "license": "MIT", + "url": "https://nitro.margelo.com" + }, + { + "name": "react-native-quick-base64", + "version": "3.0.1", + "license": "MIT", + "url": "https://github.com/craftzdog/react-native-quick-base64" + }, + { + "name": "react-native-quick-crypto", + "version": "1.1.9", + "license": "MIT", + "url": "https://github.com/margelo/react-native-quick-crypto" + }, { "name": "react-native-safe-area-context", "version": "5.6.2", @@ -238,12 +256,6 @@ "license": "MIT", "url": "https://github.com/react-native-webview/react-native-webview" }, - { - "name": "webcrypto-liner", - "version": "1.4.3", - "license": "MIT", - "url": "https://github.com/PeculiarVentures/webcrypto-liner" - }, { "name": "zustand", "version": "5.0.16",