diff --git a/src/lib/__tests__/crypto-libraries.test.ts b/src/lib/__tests__/crypto-libraries.test.ts index cecca024..da0d75d0 100644 --- a/src/lib/__tests__/crypto-libraries.test.ts +++ b/src/lib/__tests__/crypto-libraries.test.ts @@ -3,7 +3,7 @@ // plugins (repos/plugins/bulwark-gpg, repos/plugins/smime). This runs under // 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 { describe, it, expect, afterEach } from 'vitest'; import * as nodeCrypto from 'node:crypto'; import { webcrypto } from 'node:crypto'; import { createRequire } from 'node:module'; @@ -15,6 +15,9 @@ import * as asn1js from 'asn1js'; import { Convert } from 'pvtsutils'; import { cbcDecrypt, setNodeStyleCrypto, type NodeStyleCrypto } from '../crypto/provider'; import { installWebCrypto } from '../crypto/install-webcrypto'; +import { prepareOpenPgp } from '../crypto/openpgp-module'; +import { decryptMessage, detachedSign, encryptBytes, verifyDetached } from '../crypto/pgp'; +import { useHermesRuntime } from '../crypto/__tests__/hermes-runtime'; const subtle = webcrypto.subtle as unknown as SubtleCrypto; @@ -92,22 +95,13 @@ describe('crypto libraries', () => { }); describe('openpgp on a Hermes-like runtime', () => { + let restore: (() => void) | null = null; afterEach(() => { - vi.unstubAllGlobals(); + restore?.(); + restore = null; }); - // 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; - + async function roundTrip(pgp: typeof openpgp): Promise { for (const keyOptions of [{ type: 'ecc', curve: 'curve25519Legacy' }, { type: 'rsa', rsaBits: 2048 }] as const) { const { privateKey, publicKey } = await pgp.generateKey({ ...keyOptions, @@ -126,10 +120,48 @@ describe('openpgp on a Hermes-like runtime', () => { message: await pgp.readMessage({ armoredMessage: armored as string }), decryptionKeys: priv, verificationKeys: pub, + expectSigned: true, }); expect(data).toBe('ohne Streams'); await expect(signatures[0].verified).resolves.toBe(true); + + const signed = await pgp.sign({ message: await pgp.createMessage({ text: 'signiert' }), signingKeys: priv }); + const verified = await pgp.verify({ + message: await pgp.readMessage({ armoredMessage: signed as string }), + verificationKeys: pub, + expectSigned: true, + }); + expect(verified.data).toBe('signiert'); } + } + + // Metro bundles openpgp's browser build (metro.config.js), and the app + // prepares it in loadOpenPgp() (src/lib/crypto/openpgp-module.ts). + for (const streams of ['expo', 'none'] as const) { + it(`the browser build Metro bundles encrypts, signs, decrypts and verifies (${streams === 'expo' ? "Expo's stream polyfill" : 'no web streams'}, no Symbol.species, no WebAssembly)`, async () => { + restore = useHermesRuntime(streams); + const req = createRequire(import.meta.url); + const browserBuild = path.join(path.dirname(req.resolve('openpgp')), '..', 'openpgp.min.mjs'); + const pgp = prepareOpenPgp((await import(/* @vite-ignore */ pathToFileURL(browserBuild).href)) as typeof openpgp); + await roundTrip(pgp); + }, 60_000); + } + + it('the app decrypts and verifies a signed message without Symbol.species', async () => { + const { privateKey, publicKey } = await openpgp.generateKey({ + type: 'ecc', + curve: 'curve25519Legacy', + userIDs: [{ email: 'hermes@example.org' }], + format: 'object', + }); + restore = useHermesRuntime('expo'); + const data = new TextEncoder().encode('Grüße'); + const armoredMessage = await encryptBytes({ data, recipientPublicKeysArmored: [publicKey.armor()], signingKey: privateKey }); + const opened = await decryptMessage({ armoredMessage, decryptionKeys: [privateKey], verificationKeysArmored: [publicKey.armor()] }); + expect(new TextDecoder().decode(opened.data)).toBe('Grüße'); + expect(opened.signature.status).toBe('valid'); + const armoredSignature = await detachedSign(data, privateKey); + await expect(verifyDetached({ data, armoredSignature, verificationKeysArmored: [publicKey.armor()] })).resolves.toMatchObject({ status: 'valid' }); }, 60_000); }); diff --git a/src/lib/crypto/__tests__/hermes-runtime.ts b/src/lib/crypto/__tests__/hermes-runtime.ts new file mode 100644 index 00000000..df215127 --- /dev/null +++ b/src/lib/crypto/__tests__/hermes-runtime.ts @@ -0,0 +1,36 @@ +// Make Node look like the app's Hermes runtime for the PGP tests (#58). +// +// Hermes has no Symbol.species: Array methods on a subclass (openpgp's +// PacketList) return a plain Array. It has no WebAssembly and no +// (De)CompressionStream. Web streams exist in the app only as the polyfill +// Expo injects (expo/virtual/streams.js); a headless task may run without it. +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { vi } from 'vitest'; + +const STREAM_GLOBALS = ['ReadableStream', 'WritableStream', 'TransformStream'] as const; + +function expoStreams(): Record { + const req = createRequire(import.meta.url); + const file = path.join(path.dirname(req.resolve('expo/package.json')), 'virtual', 'streams.js'); + const target: Record = {}; + // The polyfill installs on `globalThis`; hand it a stand-in instead. + new Function('globalThis', readFileSync(file, 'utf8'))(target); + return target; +} + +/** Strip what Hermes lacks; returns a function that puts it back. */ +export function useHermesRuntime(streams: 'expo' | 'none'): () => void { + const species = Object.getOwnPropertyDescriptor(Array, Symbol.species); + delete (Array as unknown as Record)[Symbol.species]; + vi.stubGlobal('WebAssembly', undefined); + vi.stubGlobal('CompressionStream', undefined); + vi.stubGlobal('DecompressionStream', undefined); + const polyfill = streams === 'expo' ? expoStreams() : {}; + for (const name of STREAM_GLOBALS) vi.stubGlobal(name, polyfill[name]); + return () => { + if (species) Object.defineProperty(Array, Symbol.species, species); + vi.unstubAllGlobals(); + }; +} diff --git a/src/lib/crypto/__tests__/self-test.test.ts b/src/lib/crypto/__tests__/self-test.test.ts index 408dac6c..529f1ab6 100644 --- a/src/lib/crypto/__tests__/self-test.test.ts +++ b/src/lib/crypto/__tests__/self-test.test.ts @@ -4,8 +4,13 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { runCryptoSelfTest } from '../self-test'; import { useNodeCrypto } from './helpers'; import { setNodeStyleCrypto } from '../provider'; +import { useHermesRuntime } from './hermes-runtime'; + +let restoreRuntime: (() => void) | null = null; afterEach(() => { + restoreRuntime?.(); + restoreRuntime = null; vi.unstubAllGlobals(); useNodeCrypto(); }); @@ -18,6 +23,17 @@ describe('runCryptoSelfTest', () => { expect(results.length).toBeGreaterThan(10); }, 60_000); + // Hermes has no Symbol.species; openpgp's verify broke on it (#58). + it('passes the OpenPGP checks on a Hermes-like runtime', async () => { + useNodeCrypto(); + restoreRuntime = useHermesRuntime('expo'); + const results = await runCryptoSelfTest(); + expect(results.filter((r) => r.name.startsWith('OpenPGP'))).toEqual([ + { name: 'OpenPGP: curve25519 key, sign + encrypt, decrypt + verify', ok: true }, + { name: 'OpenPGP: RSA key', ok: true }, + ]); + }, 60_000); + it('reports the checks a missing algorithm breaks, and keeps going', async () => { useNodeCrypto(); const real = globalThis.crypto; diff --git a/src/lib/crypto/openpgp-module.ts b/src/lib/crypto/openpgp-module.ts new file mode 100644 index 00000000..cde45958 --- /dev/null +++ b/src/lib/crypto/openpgp-module.ts @@ -0,0 +1,61 @@ +// openpgp for the app (#58), loaded on first use and fixed up for Hermes. +// Load it through loadOpenPgp(), never with a bare import('openpgp'). +// +// openpgp's PacketList extends Array and counts on Array methods returning a +// PacketList (Symbol.species): Message.verify does +// `packets.concat(...)` and then calls `.filterByTag()` on the result. +// Hermes does not implement Symbol.species, so concat, filter, slice and the +// rest return a plain Array there, and every decrypt or verify of a signed +// message failed with "undefined is not a function". Node and browsers +// implement it, which is why the tests never saw it. + +import type * as OpenPgp from 'openpgp'; + +type ArraySubclass = new () => unknown[]; + +// The Array methods that build their result through Symbol.species. +const SPECIES_METHODS = ['concat', 'filter', 'map', 'slice', 'splice', 'flat', 'flatMap'] as const; + +/** + * Make the species-built Array methods return an instance of `Sub` on + * instances of `Sub`, as they do on an engine with Symbol.species. On such an + * engine the result already is one and passes through unchanged. + */ +export function keepArraySubclass(Sub: ArraySubclass): void { + const proto = Sub.prototype as unknown as Record; + const arrayProto = Array.prototype as unknown as Record unknown[]>; + for (const name of SPECIES_METHODS) { + if (Object.prototype.hasOwnProperty.call(proto, name)) continue; + const base = arrayProto[name]; + Object.defineProperty(proto, name, { + configurable: true, + writable: true, + value: function speciesResult(this: unknown[], ...args: unknown[]): unknown[] { + const result = base.apply(this, args); + if ((result as object) instanceof Sub) return result; + const out = new Sub(); + for (let i = 0; i < result.length; i++) out.push(result[i]); + return out; + }, + }); + } +} + +/** Apply the Hermes fix-ups to a loaded openpgp module. */ +export function prepareOpenPgp>(mod: T): T { + keepArraySubclass(mod.PacketList as unknown as ArraySubclass); + return mod; +} + +let loading: Promise | null = null; + +/** openpgp, loaded once on first use and ready for Hermes. */ +export function loadOpenPgp(): Promise { + if (!loading) { + loading = import('openpgp').then(prepareOpenPgp, (err: unknown) => { + loading = null; + throw err; + }); + } + return loading; +} diff --git a/src/lib/crypto/pgp.ts b/src/lib/crypto/pgp.ts index e8f9ff4e..1475167e 100644 --- a/src/lib/crypto/pgp.ts +++ b/src/lib/crypto/pgp.ts @@ -5,12 +5,9 @@ import type * as OpenPgp from 'openpgp'; import { bytesToBinary, binaryToBytes, decodeBody, findNode, parseEntity, toCrlf, type MimeNode } from './mime'; +import { loadOpenPgp } from './openpgp-module'; -let mod: typeof OpenPgp | null = null; -async function pgp(): Promise { - if (!mod) mod = await import('openpgp'); - return mod; -} +const pgp = loadOpenPgp; export interface PgpUserId { name: string; diff --git a/src/lib/crypto/self-test.ts b/src/lib/crypto/self-test.ts index 94641e7c..00abd778 100644 --- a/src/lib/crypto/self-test.ts +++ b/src/lib/crypto/self-test.ts @@ -3,6 +3,7 @@ // (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 { loadOpenPgp } from './openpgp-module'; import { cbcDecrypt, chacha20Poly1305Decrypt, nodeStyleCrypto, rsaPkcs1Decrypt, toArrayBuffer, webCrypto } from './provider'; export interface SelfTestResult { @@ -121,14 +122,14 @@ function checks(): Check[] { 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 openpgp = await loadOpenPgp(); 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 openpgp = await loadOpenPgp(); 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 });