crossbind
GitHub

OpenSSL for iOS

v4.0.2iOS

OpenSSL 4.0.2 for React Native apps on iOS, precompiled for arm64 devices and simulators as @crossbind/port-openssl-ios.

npm install @crossbind/port-openssl-ios@beta

Install

shell
npm install @crossbind/plugin-react-native@beta @crossbind/plugin-react-native-ios-helper@beta @crossbind/port-openssl-ios@beta
npm install --save-dev @crossbind/plugin-metro@beta
cd ios && pod install
crossbind.config.mjs
import opensslIos from '@crossbind/port-openssl-ios/crossbind.config.js';
 
export default {
dependencies: [opensslIos],
paths: { config: import.meta.url },
};
metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const CrossbindMetroPlugin = require('@crossbind/plugin-metro');
 
const defaultConfig = getDefaultConfig(__dirname);
 
const config = {
...CrossbindMetroPlugin(defaultConfig),
};
 
module.exports = mergeConfig(defaultConfig, config);

The whole flow, including Expo, is in the React Native playbook.

Usage

The examples the WebAssembly page runs, as iOS compiles them: the same headers and the same calls. They are checked on the WebAssembly build.

Read a certificate like openssl x509 -text

What people open a certificate for, from C++: PEM_read_bio_X509 parses it, X509_NAME_print_ex writes the subject and issuer, ASN1_TIME_print_ex the validity, X509V3_EXT_print the alternative names, X509_check_host matches a host name the way a TLS client does and X509_digest takes the SHA-256 fingerprint. WebCrypto has no X.509 parser.

src/native/certificate.h
#pragma once
 
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <openssl/x509v3.h>
 
#include <stdexcept>
#include <string>
 
// A certificate read from PEM text, and the fields `openssl x509 -text` shows first.
class Certificate {
public:
explicit Certificate(const std::string& pem) {
const std::string text = unindent(pem);
BIO* input = BIO_new_mem_buf(text.data(), static_cast<int>(text.size()));
cert = PEM_read_bio_X509(input, nullptr, nullptr, nullptr);
BIO_free(input);
if (!cert) fail("not a PEM certificate");
}
~Certificate() { X509_free(cert); }
Certificate(const Certificate&) = delete;
Certificate& operator=(const Certificate&) = delete;
 
// Names as RFC 2253 writes them, most specific first: "CN=example.com,O=Example,C=US".
std::string subject() const { return distinguishedName(X509_get_subject_name(cert)); }
std::string issuer() const { return distinguishedName(X509_get_issuer_name(cert)); }
 
// "2026-05-30 00:00:00Z"
std::string notBefore() const { return isoTime(X509_get0_notBefore(cert)); }
std::string notAfter() const { return isoTime(X509_get0_notAfter(cert)); }
 
// The subjectAltName extension as the CLI prints it: "DNS:example.com, IP Address:192.0.2.1".
std::string altNames() const {
const int index = X509_get_ext_by_NID(cert, NID_subject_alt_name, -1);
if (index < 0) return "";
BIO* out = BIO_new(BIO_s_mem());
X509V3_EXT_print(out, X509_get_ext(cert, index), 0, 0);
return drain(out);
}
 
// "EC prime256v1", "RSA", "ED25519", "ML-DSA-65", ...
std::string keyType() const {
EVP_PKEY* key = X509_get0_pubkey(cert);
std::string type = EVP_PKEY_get0_type_name(key);
char group[80];
if (EVP_PKEY_get_group_name(key, group, sizeof group, nullptr)) type += std::string(" ") + group;
return type;
}
 
int keyBits() const { return EVP_PKEY_get_bits(X509_get0_pubkey(cert)); }
 
// Whether a TLS client would accept this certificate for the host name, wildcards included.
bool covers(const std::string& host) const { return X509_check_host(cert, host.data(), host.size(), 0, nullptr) == 1; }
 
// Issued by itself, with a signature its own key verifies.
bool selfSigned() const { return X509_self_signed(cert, 1) == 1; }
 
// The SHA-256 fingerprint, written like `openssl x509 -fingerprint -sha256`.
std::string sha256() const {
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int size = 0;
if (!X509_digest(cert, EVP_sha256(), digest, &size)) fail("digest failed");
static const char hex[] = "0123456789ABCDEF";
std::string out;
for (unsigned int i = 0; i < size; i += 1) {
if (i) out += ':';
out += hex[digest[i] >> 4];
out += hex[digest[i] & 0x0F];
}
return out;
}
 
private:
X509* cert = nullptr;
 
// PEM lines must start in the first column; text pasted from YAML, JSON or an indented
// template literal often does not.
static std::string unindent(const std::string& text) {
std::string out;
bool lineStart = true;
for (const char character : text) {
if (lineStart && (character == ' ' || character == '\t')) continue;
out += character;
lineStart = character == '\n';
}
return out;
}
 
static std::string distinguishedName(const X509_NAME* value) {
BIO* out = BIO_new(BIO_s_mem());
X509_NAME_print_ex(out, value, 0, XN_FLAG_RFC2253);
return drain(out);
}
 
static std::string isoTime(const ASN1_TIME* value) {
BIO* out = BIO_new(BIO_s_mem());
ASN1_TIME_print_ex(out, value, ASN1_DTFLGS_ISO8601);
return drain(out);
}
 
static std::string drain(BIO* out) {
char* data = nullptr;
const long size = BIO_get_mem_data(out, &data);
std::string text(data, size > 0 ? static_cast<size_t>(size) : 0);
BIO_free(out);
return text;
}
 
// OpenSSL queues its errors; the first one says what went wrong.
[[noreturn]] static void fail(const std::string& what) {
char reason[256] = "";
const unsigned long code = ERR_get_error();
if (code) ERR_error_string_n(code, reason, sizeof reason);
ERR_clear_error();
throw std::runtime_error(code ? what + ": " + reason : what);
}
};
main.js
import { initNative, Certificate } from './native/certificate.h';
 
await initNative();
// A test certificate for shop.example.com, issued by a test CA.
const pem = `-----BEGIN CERTIFICATE-----
MIICIjCCAcigAwIBAgICEAEwCgYIKoZIzj0EAwIwQzELMAkGA1UEBhMCVVMxFTAT
BgNVBAoMDEV4YW1wbGUgU2hvcDEdMBsGA1UEAwwURXhhbXBsZSBTaG9wIFRlc3Qg
Q0EwHhcNMjYwMzAxMDAwMDAwWhcNMjYwNTMwMDAwMDAwWjA/MQswCQYDVQQGEwJV
UzEVMBMGA1UECgwMRXhhbXBsZSBTaG9wMRkwFwYDVQQDDBBzaG9wLmV4YW1wbGUu
Y29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZR6VvLcXeY1MN21YB02ab2Qc
OzohfHo98QuwSjxUrEEni8Yj0oXx53RyQtlAUlDkddzJOt6ldz90XgKXuWIOGKOB
rzCBrDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggr
BgEFBQcDATA3BgNVHREEMDAughBzaG9wLmV4YW1wbGUuY29tghR3d3cuc2hvcC5l
eGFtcGxlLmNvbYcEwAACCjAdBgNVHQ4EFgQU1y3zWDMZI93FAyhp8QnFGfs10k4w
HwYDVR0jBBgwFoAUPExNl7RaXtxsfW/VdAj+Rlc4gpIwCgYIKoZIzj0EAwIDSAAw
RQIgZtZXDYEsjrz91CjoZyFE5coj51aR5sZi/wKllD1qYq8CIQCOKkQWCapO2G/A
p1aRCXKO+JxjPWGnkYe0B8RLljl9Pw==
-----END CERTIFICATE-----`;
const cert = await new Certificate(pem);
console.log(await cert.subject());
console.log('issued by', await cert.issuer());
console.log('valid', await cert.notBefore(), 'to', await cert.notAfter());
console.log(await cert.altNames());
console.log(`${await cert.keyType()} ${await cert.keyBits()} bits, self-signed ${await cert.selfSigned()}`);
for (const host of ['www.shop.example.com', 'shop.example.org']) console.log(host, await cert.covers(host));
console.log(await cert.sha256());
PRINTS
CN=shop.example.com,O=Example Shop,C=US
issued by CN=Example Shop Test CA,O=Example Shop,C=US
valid 2026-03-01 00:00:00Z to 2026-05-30 00:00:00Z
DNS:shop.example.com, DNS:www.shop.example.com, IP Address:192.0.2.10
EC prime256v1 256 bits, self-signed false
www.shop.example.com true
shop.example.org false
68:2D:81:9E:75:7E:2C:15:C1:92:FB:84:3D:BE:AA:99:B4:54:58:D4:5F:A3:F3:3A:C8:13:1E:9D:03:9C:89:3C

Hash and HMAC

EVP_Q_digest hashes data already in memory with any digest by name, EVP_DigestUpdate takes it in pieces as a file or a download arrives, and EVP_Q_mac makes the HMAC that webhook and API request signatures use. WebCrypto stops at SHA-1 and SHA-2; SHA-3 and BLAKE2 come from OpenSSL here. Every line is the published test vector of its standard.

src/native/hashing.h
#pragma once
 
#include <openssl/err.h>
#include <openssl/evp.h>
 
#include <stdexcept>
#include <string>
 
// Digests and HMAC by algorithm name: "SHA256", "SHA3-256", "BLAKE2B-512", "SHA512-256", "SM3" or
// any other digest OpenSSL's default provider has. Text goes in as its UTF-8 bytes.
class Digest {
public:
// Streaming: update with the data in as many pieces as it arrives in, then read the digest once.
explicit Digest(const std::string& algorithm) : context(EVP_MD_CTX_new()) {
EVP_MD* md = EVP_MD_fetch(nullptr, algorithm.c_str(), nullptr);
const bool ready = md && EVP_DigestInit_ex2(context, md, nullptr);
EVP_MD_free(md);
if (!ready) fail("unknown digest " + algorithm);
}
~Digest() { EVP_MD_CTX_free(context); }
Digest(const Digest&) = delete;
Digest& operator=(const Digest&) = delete;
 
void update(const std::string& data) {
if (!EVP_DigestUpdate(context, data.data(), data.size())) fail("digest update failed");
}
 
std::string hex() {
unsigned char digest[EVP_MAX_MD_SIZE];
unsigned int size = 0;
if (!EVP_DigestFinal_ex(context, digest, &size)) fail("digest already read");
return toHex(digest, size);
}
 
// One call for data that is already in memory.
static std::string of(const std::string& algorithm, const std::string& data) {
unsigned char digest[EVP_MAX_MD_SIZE];
size_t size = 0;
if (!EVP_Q_digest(nullptr, algorithm.c_str(), nullptr, data.data(), data.size(), digest, &size)) fail("unknown digest " + algorithm);
return toHex(digest, size);
}
 
// HMAC with the named digest, as webhooks and API request signatures use it.
static std::string hmac(const std::string& algorithm, const std::string& key, const std::string& data) {
unsigned char mac[EVP_MAX_MD_SIZE];
size_t size = 0;
const auto* bytes = reinterpret_cast<const unsigned char*>(data.data());
if (!EVP_Q_mac(nullptr, "HMAC", nullptr, algorithm.c_str(), nullptr, key.data(), key.size(), bytes, data.size(), mac, sizeof mac, &size)) {
fail("HMAC with " + algorithm + " failed");
}
return toHex(mac, size);
}
 
private:
EVP_MD_CTX* context;
 
static std::string toHex(const unsigned char* data, size_t size) {
static const char hex[] = "0123456789abcdef";
std::string out;
for (size_t i = 0; i < size; i += 1) {
out += hex[data[i] >> 4];
out += hex[data[i] & 0x0F];
}
return out;
}
 
[[noreturn]] static void fail(const std::string& what) {
ERR_clear_error();
throw std::runtime_error(what);
}
};
main.js
import { initNative, Digest } from './native/hashing.h';
 
await initNative();
for (const algorithm of ['SHA256', 'SHA3-256', 'BLAKE2B-512']) console.log(algorithm, await Digest.of(algorithm, 'abc'));
console.log('HMAC-SHA256', await Digest.hmac('SHA256', 'Jefe', 'what do ya want for nothing?'));
 
// A million "a" in ten pieces, the way a file arrives.
const digest = await new Digest('SHA256');
for (let piece = 0; piece < 10; piece += 1) await digest.update('a'.repeat(100000));
console.log('streamed SHA256', await digest.hex());
PRINTS
SHA256 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
SHA3-256 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532
BLAKE2B-512 ba80a53f981c4d0d6a2797b69f12f6e94c212f14685ac4b74b12bb6fdbffa2d17d87c5392aab792dc252d5de4533cc9518d38aa8dbf1925ab92386edd4009923
HMAC-SHA256 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843
streamed SHA256 cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0

Encrypt and authenticate with AES-256-GCM

EVP_CipherInit_ex2 with AES-256-GCM encrypts and authenticates in one pass; EVP_CTRL_AEAD_GET_TAG reads the 16-byte tag, and with EVP_CTRL_AEAD_SET_TAG decryption refuses anything that changed, including the associated data that travels in the clear. The output is ciphertext then tag, the layout WebCrypto reads.

src/native/aes_gcm.h
#pragma once
 
#include <openssl/crypto.h>
#include <openssl/err.h>
#include <openssl/evp.h>
 
#include <memory>
#include <stdexcept>
#include <string>
 
// AES in GCM mode: encryption and an authentication tag in one pass. The key is 16, 24 or 32 bytes
// (AES-128, -192 or -256) and every message needs a fresh 12-byte nonce; reusing a nonce with the
// same key gives the plaintext away.
class AesGcm {
public:
explicit AesGcm(const std::string& keyHex) : key(fromHex(keyHex)) {
if (key.size() != 16 && key.size() != 24 && key.size() != 32) throw std::invalid_argument("AES keys are 16, 24 or 32 bytes");
}
~AesGcm() { OPENSSL_cleanse(&key[0], key.size()); }
AesGcm(const AesGcm&) = delete;
AesGcm& operator=(const AesGcm&) = delete;
 
// The ciphertext followed by the 16-byte tag, in hex: the layout WebCrypto's AES-GCM uses too.
// `aad` is authenticated but not encrypted, such as a record id the ciphertext belongs to.
std::string encrypt(const std::string& nonceHex, const std::string& plaintext, const std::string& aad) {
Context context(start(1, nonceHex, aad));
std::string out(plaintext.size() + TAG_SIZE, '\0');
int written = 0;
int last = 0;
if (!EVP_EncryptUpdate(context.get(), bytes(out), &written, bytes(plaintext), static_cast<int>(plaintext.size()))
|| !EVP_EncryptFinal_ex(context.get(), bytes(out) + written, &last)
|| !EVP_CIPHER_CTX_ctrl(context.get(), EVP_CTRL_AEAD_GET_TAG, TAG_SIZE, bytes(out) + written + last)) {
fail("encryption failed");
}
return toHex(out);
}
 
// Throws unless the tag matches, so a changed byte anywhere in the ciphertext, the tag, the nonce
// or the AAD is refused, never decrypted to garbage.
std::string decrypt(const std::string& nonceHex, const std::string& sealedHex, const std::string& aad) {
std::string sealed = fromHex(sealedHex);
if (sealed.size() < TAG_SIZE) throw std::invalid_argument("shorter than a tag");
const size_t size = sealed.size() - TAG_SIZE;
Context context(start(0, nonceHex, aad));
std::string out(size, '\0');
int written = 0;
int last = 0;
if (!EVP_DecryptUpdate(context.get(), bytes(out), &written, bytes(sealed), static_cast<int>(size))
|| !EVP_CIPHER_CTX_ctrl(context.get(), EVP_CTRL_AEAD_SET_TAG, TAG_SIZE, bytes(sealed) + size)
|| EVP_DecryptFinal_ex(context.get(), bytes(out) + written, &last) != 1) {
OPENSSL_cleanse(&out[0], out.size());
fail("authentication failed: wrong key, nonce or AAD, or the data was changed");
}
return out;
}
 
private:
static constexpr int TAG_SIZE = 16;
struct Free {
void operator()(EVP_CIPHER_CTX* context) const { EVP_CIPHER_CTX_free(context); }
};
using Context = std::unique_ptr<EVP_CIPHER_CTX, Free>;
 
std::string key;
 
EVP_CIPHER_CTX* start(int encrypting, const std::string& nonceHex, const std::string& aad) {
const std::string nonce = fromHex(nonceHex);
if (nonce.size() != 12) throw std::invalid_argument("GCM nonces here are 12 bytes");
const char* name = key.size() == 32 ? "AES-256-GCM" : key.size() == 24 ? "AES-192-GCM" : "AES-128-GCM";
EVP_CIPHER* cipher = EVP_CIPHER_fetch(nullptr, name, nullptr);
EVP_CIPHER_CTX* context = EVP_CIPHER_CTX_new();
int ignored = 0;
const bool ready = cipher && context && EVP_CipherInit_ex2(context, cipher, bytes(key), bytes(nonce), encrypting, nullptr)
&& (aad.empty() || EVP_CipherUpdate(context, nullptr, &ignored, bytes(aad), static_cast<int>(aad.size())));
EVP_CIPHER_free(cipher);
if (!ready) {
EVP_CIPHER_CTX_free(context);
fail("cannot start AES-GCM");
}
return context;
}
 
static unsigned char* bytes(std::string& data) { return reinterpret_cast<unsigned char*>(&data[0]); }
static const unsigned char* bytes(const std::string& data) { return reinterpret_cast<const unsigned char*>(data.data()); }
 
static std::string toHex(const std::string& data) {
static const char hex[] = "0123456789abcdef";
std::string out;
for (const unsigned char byte : data) {
out += hex[byte >> 4];
out += hex[byte & 0x0F];
}
return out;
}
 
static std::string fromHex(const std::string& hex) {
const auto nibble = [](char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
throw std::invalid_argument("not hex");
};
if (hex.size() % 2) throw std::invalid_argument("hex has an odd length");
std::string out(hex.size() / 2, '\0');
for (size_t i = 0; i < out.size(); i += 1) out[i] = static_cast<char>(nibble(hex[2 * i]) * 16 + nibble(hex[2 * i + 1]));
return out;
}
 
[[noreturn]] static void fail(const std::string& what) {
ERR_clear_error();
throw std::runtime_error(what);
}
};
main.js
import { initNative, AesGcm } from './native/aes_gcm.h';
 
await initNative();
// A fixed test key and nonce, so the output repeats. Use 32 random bytes as the key, and never
// use a nonce twice with it.
const gcm = await new AesGcm('000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f');
const nonce = '000000000000000000000001';
const sealed = await gcm.encrypt(nonce, 'meet at the north gate at 7', 'message-1');
console.log('ciphertext', sealed.slice(0, -32));
console.log('tag', sealed.slice(-32));
console.log(await gcm.decrypt(nonce, sealed, 'message-1'));
try {
await gcm.decrypt(nonce, sealed, 'message-2');
} catch (error) {
console.log('refused:', error.message);
}
PRINTS
ciphertext 78b3da886495443e7a46341982c948837dbf7b3510d774e5072034
tag 4f8c8f6e9e6177f201ed83f957fbe6d7
meet at the north gate at 7
refused: std::runtime_error: authentication failed: wrong key, nonce or AAD, or the data was changed

Generate a key, sign and verify

EVP_PKEY_Q_keygen makes a key pair, PEM_write_bio_PUBKEY exports its public half, EVP_DigestSign signs and EVP_DigestVerify checks with nothing but that PEM. The same calls sign with ECDSA P-256, Ed25519 and ML-DSA-65, the post-quantum signature of FIPS 204. Keys are random, so the example prints what stays the same: sizes, strength and the verdicts.

src/native/signing.h
#pragma once
 
#include <openssl/bio.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
 
#include <stdexcept>
#include <string>
 
// A fresh key pair, signatures with it, and verification with nothing but the public key's PEM, as
// the receiving side does it. "P-256" signs with ECDSA over SHA-256, "RSA-2048" with PKCS#1 v1.5
// over SHA-256; "ED25519" and the post-quantum "ML-DSA-44", "ML-DSA-65" and "ML-DSA-87" sign the
// message itself.
class KeyPair {
public:
explicit KeyPair(const std::string& algorithm) : key(generate(algorithm)) {
if (!key) fail("cannot generate a " + algorithm + " key");
}
~KeyPair() { EVP_PKEY_free(key); }
KeyPair(const KeyPair&) = delete;
KeyPair& operator=(const KeyPair&) = delete;
 
// "EC", "RSA", "ED25519", "ML-DSA-65", ...
std::string type() const { return EVP_PKEY_get0_type_name(key); }
 
// The strength OpenSSL rates the key at, in bits.
int securityBits() const { return EVP_PKEY_get_security_bits(key); }
 
// The largest signature the key makes, in bytes.
int maxSignatureSize() const { return EVP_PKEY_get_size(key); }
 
// "-----BEGIN PUBLIC KEY-----": what you hand to whoever verifies.
std::string publicKeyPem() const {
BIO* out = BIO_new(BIO_s_mem());
PEM_write_bio_PUBKEY(out, key);
return drain(out);
}
 
// "-----BEGIN PRIVATE KEY-----", unencrypted PKCS#8: keep it to yourself.
std::string privateKeyPem() const {
BIO* out = BIO_new(BIO_s_mem());
PEM_write_bio_PrivateKey(out, key, nullptr, nullptr, 0, nullptr, nullptr);
return drain(out);
}
 
// The signature, in hex.
std::string sign(const std::string& message) const {
EVP_MD_CTX* context = EVP_MD_CTX_new();
size_t size = 0;
std::string signature;
bool signed_ = EVP_DigestSignInit_ex(context, nullptr, digestFor(key), nullptr, nullptr, key, nullptr) == 1
&& EVP_DigestSign(context, nullptr, &size, bytes(message), message.size()) == 1;
if (signed_) {
signature.resize(size);
signed_ = EVP_DigestSign(context, reinterpret_cast<unsigned char*>(&signature[0]), &size, bytes(message), message.size()) == 1;
signature.resize(size);
}
EVP_MD_CTX_free(context);
if (!signed_) fail("signing failed");
return toHex(signature);
}
 
static bool verify(const std::string& publicKeyPem, const std::string& message, const std::string& signatureHex) {
BIO* input = BIO_new_mem_buf(publicKeyPem.data(), static_cast<int>(publicKeyPem.size()));
EVP_PKEY* publicKey = PEM_read_bio_PUBKEY(input, nullptr, nullptr, nullptr);
BIO_free(input);
if (!publicKey) fail("not a PEM public key");
const std::string signature = fromHex(signatureHex);
EVP_MD_CTX* context = EVP_MD_CTX_new();
const bool valid = EVP_DigestVerifyInit_ex(context, nullptr, digestFor(publicKey), nullptr, nullptr, publicKey, nullptr) == 1
&& EVP_DigestVerify(context, bytes(signature), signature.size(), bytes(message), message.size()) == 1;
EVP_MD_CTX_free(context);
EVP_PKEY_free(publicKey);
ERR_clear_error();
return valid;
}
 
private:
EVP_PKEY* key;
 
static EVP_PKEY* generate(const std::string& algorithm) {
if (algorithm == "P-256") return EVP_PKEY_Q_keygen(nullptr, nullptr, "EC", "P-256");
if (algorithm == "RSA-2048") return EVP_PKEY_Q_keygen(nullptr, nullptr, "RSA", static_cast<size_t>(2048));
return EVP_PKEY_Q_keygen(nullptr, nullptr, algorithm.c_str());
}
 
// ECDSA and RSA sign a digest of the message; Ed25519 and ML-DSA take the message whole.
static const char* digestFor(const EVP_PKEY* key) { return EVP_PKEY_is_a(key, "EC") || EVP_PKEY_is_a(key, "RSA") ? "SHA256" : nullptr; }
 
static const unsigned char* bytes(const std::string& data) { return reinterpret_cast<const unsigned char*>(data.data()); }
 
static std::string drain(BIO* out) {
char* data = nullptr;
const long size = BIO_get_mem_data(out, &data);
std::string text(data, size > 0 ? static_cast<size_t>(size) : 0);
BIO_free(out);
return text;
}
 
static std::string toHex(const std::string& data) {
static const char hex[] = "0123456789abcdef";
std::string out;
for (const unsigned char byte : data) {
out += hex[byte >> 4];
out += hex[byte & 0x0F];
}
return out;
}
 
static std::string fromHex(const std::string& hex) {
const auto nibble = [](char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
throw std::invalid_argument("not hex");
};
if (hex.size() % 2) throw std::invalid_argument("hex has an odd length");
std::string out(hex.size() / 2, '\0');
for (size_t i = 0; i < out.size(); i += 1) out[i] = static_cast<char>(nibble(hex[2 * i]) * 16 + nibble(hex[2 * i + 1]));
return out;
}
 
[[noreturn]] static void fail(const std::string& what) {
ERR_clear_error();
throw std::runtime_error(what);
}
};
main.js
import { initNative, KeyPair } from './native/signing.h';
 
await initNative();
const message = 'release 2.4.0, sha256 3a985da74fe225b2';
for (const algorithm of ['P-256', 'ED25519', 'ML-DSA-65']) {
const key = await new KeyPair(algorithm);
const publicKey = await key.publicKeyPem();
const signature = await key.sign(message);
const valid = await KeyPair.verify(publicKey, message, signature);
const altered = await KeyPair.verify(publicKey, message.replace('2.4.0', '2.4.1'), signature);
const size = await key.maxSignatureSize();
console.log(`${algorithm}: ${await key.type()}, ${await key.securityBits()}-bit security, signatures up to ${size} bytes, valid ${valid}, altered message ${altered}`);
}
PRINTS
P-256: EC, 128-bit security, signatures up to 72 bytes, valid true, altered message false
ED25519: ED25519, 128-bit security, signatures up to 64 bytes, valid true, altered message false
ML-DSA-65: ML-DSA-65, 192-bit security, signatures up to 3309 bytes, valid true, altered message false

Make a self-signed certificate for localhost

The most asked OpenSSL question, answered in C++: X509_new, a random serial, X509V3_EXT_conf_nid for the subjectAltName browsers match, and X509_sign with the key it certifies, the fields openssl req -x509 -addext subjectAltName=... writes. The key comes from the signing example, and the certificate example reads the result back.

src/native/self_signed.h
#pragma once
 
#include <openssl/bio.h>
#include <openssl/bn.h>
#include <openssl/err.h>
#include <openssl/evp.h>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <openssl/x509v3.h>
 
#include <stdexcept>
#include <string>
 
// The certificate `openssl req -x509 -key key.pem -subj /CN=<name> -addext subjectAltName=<names>`
// makes: version 3, its own issuer, the alternative names and a subject key identifier, signed by
// the key it certifies. Browsers match the alternative names, not the common name.
class SelfSigned {
public:
// `altNames` as the CLI takes them, "DNS:localhost,IP:127.0.0.1"; the dates as ASN.1 times,
// "20260101000000Z".
static std::string create(const std::string& keyPem, const std::string& commonName, const std::string& altNames,
const std::string& notBefore, const std::string& notAfter) {
BIO* input = BIO_new_mem_buf(keyPem.data(), static_cast<int>(keyPem.size()));
EVP_PKEY* key = PEM_read_bio_PrivateKey(input, nullptr, nullptr, nullptr);
BIO_free(input);
if (!key) fail("not a PEM private key");
X509* cert = X509_new();
X509_NAME* subject = X509_NAME_new();
const auto* name = reinterpret_cast<const unsigned char*>(commonName.c_str());
const bool made = X509_set_version(cert, X509_VERSION_3) && randomSerial(cert)
&& X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_UTF8, name, -1, -1, 0)
&& X509_set_subject_name(cert, subject) && X509_set_issuer_name(cert, subject)
&& ASN1_TIME_set_string_X509(X509_getm_notBefore(cert), notBefore.c_str())
&& ASN1_TIME_set_string_X509(X509_getm_notAfter(cert), notAfter.c_str()) && X509_set_pubkey(cert, key)
&& extend(cert, NID_subject_alt_name, altNames) && extend(cert, NID_subject_key_identifier, "hash")
&& X509_sign(cert, key, EVP_PKEY_is_a(key, "EC") || EVP_PKEY_is_a(key, "RSA") ? EVP_sha256() : nullptr) > 0;
std::string pem;
if (made) {
BIO* out = BIO_new(BIO_s_mem());
PEM_write_bio_X509(out, cert);
char* data = nullptr;
const long size = BIO_get_mem_data(out, &data);
pem.assign(data, size > 0 ? static_cast<size_t>(size) : 0);
BIO_free(out);
}
X509_NAME_free(subject);
X509_free(cert);
EVP_PKEY_free(key);
if (!made) fail("cannot make the certificate");
return pem;
}
 
private:
// A random 159-bit serial, as the CLI picks: Firefox refuses a new certificate that repeats the
// issuer and serial of one it has seen, which fixed serials on regenerated certificates do.
static bool randomSerial(X509* cert) {
BIGNUM* serial = BN_new();
const bool ok = serial && BN_rand(serial, 159, BN_RAND_TOP_ANY, BN_RAND_BOTTOM_ANY)
&& BN_to_ASN1_INTEGER(serial, X509_get_serialNumber(cert));
BN_free(serial);
return ok;
}
 
static bool extend(X509* cert, int nid, const std::string& value) {
X509V3_CTX context;
X509V3_set_ctx(&context, cert, cert, nullptr, nullptr, 0);
X509_EXTENSION* extension = X509V3_EXT_conf_nid(nullptr, &context, nid, value.c_str());
const bool added = extension && X509_add_ext(cert, extension, -1);
X509_EXTENSION_free(extension);
return added;
}
 
[[noreturn]] static void fail(const std::string& what) {
char reason[256] = "";
const unsigned long code = ERR_get_error();
if (code) ERR_error_string_n(code, reason, sizeof reason);
ERR_clear_error();
throw std::runtime_error(code ? what + ": " + reason : what);
}
};
main.js
import { initNative, SelfSigned } from './native/self_signed.h';
import { KeyPair } from './native/signing.h';
import { Certificate } from './native/certificate.h';
 
await initNative();
const key = await new KeyPair('P-256');
const pem = await SelfSigned.create(await key.privateKeyPem(), 'localhost', 'DNS:localhost,IP:127.0.0.1', '20260101000000Z', '20270101000000Z');
console.log(pem.split('\n')[0]);
 
const cert = await new Certificate(pem);
console.log(await cert.subject(), await cert.altNames());
console.log('valid', await cert.notBefore(), 'to', await cert.notAfter());
console.log(`${await cert.keyType()} ${await cert.keyBits()} bits, self-signed ${await cert.selfSigned()}`);
for (const host of ['localhost', 'example.com']) console.log(host, await cert.covers(host));
PRINTS
-----BEGIN CERTIFICATE-----
CN=localhost DNS:localhost, IP Address:127.0.0.1
valid 2026-01-01 00:00:00Z to 2027-01-01 00:00:00Z
EC prime256v1 256 bits, self-signed true
localhost true
example.com false

What is different on iOS

  • pod install compiles your headers with the library through the plugin's podspec, and the app build links the result.
  • Named imports from ./native/<header>.h work as on the web: await initNative() once, then call the classes.
  • There is no m.FS and no /memfs: files live in the app's own storage, and your C++ takes their paths.
  • No Worker and no COOP or COEP: runtime: 'mt' uses pthreads directly.

Other platforms

Facts on this page come from the port manifests in the repository and from what npm served on beta when the site was built. See the Libraries guide for the full consumer flow.

MORE LIBRARIES
cURLExpatGDALGEOSGeoTIFFiconvLERClibjpeg-turbolibTIFFPROJSpatiaLiteSQLiteWebPzlibZstandard
Type to search every guide page and section.
↑↓ navigate↵ openesc close