mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
chore: provide webcrypto from react-native-quick-crypto instead of webcrypto-liner #58
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user