chore: provide webcrypto from react-native-quick-crypto instead of webcrypto-liner #58

This commit is contained in:
Linus Rath
2026-10-31 16:33:18 +01:00
parent 051f6bf2d5
commit 4c818d64ea
9 changed files with 433 additions and 216 deletions
+78 -14
View File
@@ -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<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);
});
});
+54
View File
@@ -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();
+96
View File
@@ -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()));
}
+18 -6
View File
@@ -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",