fix: decrypt and verify signed pgp mail on the device

This commit is contained in:
Linus Rath
2026-10-31 20:18:00 +01:00
parent b9dcbdea52
commit 67cfe0a97d
6 changed files with 164 additions and 21 deletions
+46 -14
View File
@@ -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<void> {
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);
});
@@ -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<string, unknown> {
const req = createRequire(import.meta.url);
const file = path.join(path.dirname(req.resolve('expo/package.json')), 'virtual', 'streams.js');
const target: Record<string, unknown> = {};
// 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, unknown>)[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();
};
}
@@ -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;
+61
View File
@@ -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<string, unknown>;
const arrayProto = Array.prototype as unknown as Record<string, (...args: unknown[]) => 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<T extends Pick<typeof OpenPgp, 'PacketList'>>(mod: T): T {
keepArraySubclass(mod.PacketList as unknown as ArraySubclass);
return mod;
}
let loading: Promise<typeof OpenPgp> | null = null;
/** openpgp, loaded once on first use and ready for Hermes. */
export function loadOpenPgp(): Promise<typeof OpenPgp> {
if (!loading) {
loading = import('openpgp').then(prepareOpenPgp, (err: unknown) => {
loading = null;
throw err;
});
}
return loading;
}
+2 -5
View File
@@ -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<typeof OpenPgp> {
if (!mod) mod = await import('openpgp');
return mod;
}
const pgp = loadOpenPgp;
export interface PgpUserId {
name: string;
+3 -2
View File
@@ -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 });