mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: decrypt and verify signed pgp mail on the device
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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 });
|
||||
|
||||
Reference in New Issue
Block a user