fix(listener): make the selfSigned QUIC certificate per user, in-process, without a race
QUICServerCredentials{selfSigned} shelled out to the openssl CLI and kept
the pair in the fixed /tmp/crafter-network-quic-cert/, guarded by an
in-process mutex only. Three things went wrong with that:
- Processes started at once (crafter-build runs tests in parallel, and
inside the Flatpak they share one /tmp) each found no pair and each
ran `openssl req` over the same key.pem and cert.pem, which it writes
in place one after the other. A process could load one run's key with
another's certificate and fail in ConfigurationLoadCredential.
- /tmp is shared by every user of the machine: whoever made the
directory first could keep everyone else from writing there, or plant
a pair in it.
- Without the openssl CLI there was no certificate at all, and
ComputeCertificateHashSHA256 piped the file through it as well.
The pair now lives in $XDG_CACHE_HOME/crafter-network/quic-cert/ (else
~/.cache/..., else $XDG_RUNTIME_DIR/...). Missing directories are made
0700, and the last is opened O_NOFOLLOW, refused unless the user owns
it, and made 0700 if it was not. A POSIX record lock on a file in it,
given up on after 10 s, covers both the check of the pair on disk and
its replacement, so one process makes the pair and the rest read it;
each file is written under a temporary name, fsynced and renamed into
place; and the listener hands msquic the pair from memory as PKCS#12
(QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12) instead of the two paths, so
a replacement cannot land between its reads of the key and the
certificate. A pair that does not parse, whose key does not match, or
that has less than a day left is replaced, which also repairs one a
crash tore between the two renames. A record lock rather than flock: an
flock is shared with a child forked while another thread holds it, and
the child holds it for its whole life. Threads of one process are kept
apart by the existing mutex, and the process keeps the pair it loaded,
so all its listeners serve one certificate until it nears its end.
The certificate is made in-process by :TLS in the shape the CLI
produced (ECDSA P-256, ecdsa-with-SHA256, 10 days, BasicConstraints
CA:FALSE, KeyUsage digitalSignature, EKU serverAuth, SANs localhost,
127.0.0.1 and ::1), with a random serial where the CLI also drew one;
ComputeCertificateHashSHA256 is X509_digest over the file's
certificate. The OpenSSL helpers are module-internal declarations in
:TLS, so libssl stays behind that one partition, and the TLS
partition's own development certificate is unchanged.
As before, the pair is reused across restarts while valid, so a
browser's pinned hash survives one; it is now replaced a day before it
expires rather than after, so a server is not started on a certificate
about to lapse. In the Flatpak, where /tmp went away with the last
instance, it now survives between runs too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5068f0acd7
commit
61d3e8c26a
5 changed files with 404 additions and 141 deletions
|
|
@ -3,15 +3,18 @@
|
||||||
|
|
||||||
module;
|
module;
|
||||||
#include <msquic.h>
|
#include <msquic.h>
|
||||||
|
#include <cerrno>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
#include <netdb.h>
|
#include <netdb.h>
|
||||||
#include <sys/socket.h>
|
#include <sys/socket.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
module Crafter.Network:ListenerQUIC_impl;
|
module Crafter.Network:ListenerQUIC_impl;
|
||||||
import :ListenerQUIC;
|
import :ListenerQUIC;
|
||||||
import :ClientQUIC;
|
import :ClientQUIC;
|
||||||
|
import :TLS;
|
||||||
import Crafter.Thread;
|
import Crafter.Thread;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
|
|
@ -91,88 +94,231 @@ namespace {
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lazily generate (and cache) a self-signed cert + key pair on disk by
|
// ── The self-signed certificate ──────────────────────────────────────
|
||||||
// shelling out to the openssl CLI. Used by ListenerQUIC when the caller
|
// QUICServerCredentials{selfSigned=true} serves one pair per user, kept
|
||||||
// passes QUICServerCredentials{selfSigned=true}. The pair lives in a
|
// on disk so that it, and with it the SHA-256 a browser pins through
|
||||||
// mkdtemp'd directory under /tmp for the lifetime of the process.
|
// serverCertificateHashes, survives a server restart while it is valid:
|
||||||
// Intended for dev / LAN play / tests — production should pass real
|
// 3DForts publishes that hash to its pages, and one that changed on
|
||||||
// cert/key paths.
|
// every restart would break every page still holding the old one. The
|
||||||
|
// pair is made in-process by :TLS (MakeWebTransportCertificatePem, whose
|
||||||
|
// comment has the shape Chromium demands), so nothing has to be
|
||||||
|
// installed; it used to take the openssl CLI.
|
||||||
//
|
//
|
||||||
// Cert shape: ECDSA P-256, validity 13 days, SAN={DNS:localhost,
|
// Where: $XDG_CACHE_HOME/crafter-network/quic-cert/, else
|
||||||
// IP:127.0.0.1, IP:::1}. These constraints are mandated by Chromium's
|
// $HOME/.cache/crafter-network/quic-cert/, else
|
||||||
// WebTransport `serverCertificateHashes` (must be ECDSA P-256, validity
|
// $XDG_RUNTIME_DIR/crafter-network/quic-cert/ (missing parents made
|
||||||
// <14 days, SAN with the connect target). msquic accepts the same cert
|
// 0700, as the XDG spec asks). It used to be the fixed
|
||||||
// unchanged, so existing pure-QUIC callers are unaffected.
|
// /tmp/crafter-network-quic-cert/, which every user of the machine
|
||||||
|
// shared: whoever made it first could keep everyone else from writing
|
||||||
|
// there, or plant a pair. The last directory is opened O_NOFOLLOW and
|
||||||
|
// refused unless it is this user's, and made 0700 if it was not.
|
||||||
|
//
|
||||||
|
// Several processes: test binaries the runner starts at once all find no
|
||||||
|
// pair and all make one. With the CLI writing key.pem and cert.pem in
|
||||||
|
// place, one after the other, under an in-process mutex only, a process
|
||||||
|
// could load one run's key with another's certificate, and the listener
|
||||||
|
// failed in ConfigurationLoadCredential. Now:
|
||||||
|
// - a POSIX record lock on `lock` in the directory covers the check
|
||||||
|
// and the replacement, so one process makes the pair and the rest
|
||||||
|
// read it. A record lock rather than flock: it belongs to the
|
||||||
|
// process, so a child forked while another thread holds it does not
|
||||||
|
// inherit it and hold it for its whole life; threads of one process
|
||||||
|
// are kept apart by the mutex in GetSelfSignedCert instead;
|
||||||
|
// - each file is written under a temporary name and renamed over the
|
||||||
|
// old one, so a reader sees an old file or a new one, never half;
|
||||||
|
// - the listener loads the pair from memory, as PKCS#12, not from the
|
||||||
|
// files, so a replacement cannot land between its reads of the two;
|
||||||
|
// - a pair that does not parse, does not match, or has less than a day
|
||||||
|
// left is replaced; so is one a crash tore between the two renames.
|
||||||
struct SelfSignedCert {
|
struct SelfSignedCert {
|
||||||
std::string certPath;
|
std::string certPath;
|
||||||
std::string keyPath;
|
std::string keyPath;
|
||||||
|
TLSCertificatePem pem;
|
||||||
|
std::chrono::system_clock::time_point notAfter;
|
||||||
};
|
};
|
||||||
SelfSignedCert& GetSelfSignedCert() {
|
|
||||||
static std::mutex mtx;
|
|
||||||
static std::optional<SelfSignedCert> cached;
|
|
||||||
std::lock_guard lk(mtx);
|
|
||||||
if (cached) return *cached;
|
|
||||||
|
|
||||||
// Stable on-disk location so the cert (and therefore its SHA-256) is
|
// A pair this close to its end is replaced rather than served: a server
|
||||||
// reused across server restarts. Without this, a browser peer that
|
// started on it would outlive it.
|
||||||
// pinned the cert hash on a previous run would see a hash mismatch
|
constexpr std::chrono::hours renewWithin{ 24 };
|
||||||
// the moment we restart. We only regenerate if the cert file is
|
|
||||||
// missing or has expired.
|
|
||||||
std::filesystem::path dir = "/tmp/crafter-network-quic-cert";
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(dir, ec);
|
|
||||||
if (ec) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"could not create cert cache dir {}: {}", dir.string(), ec.message()));
|
|
||||||
}
|
|
||||||
SelfSignedCert s;
|
|
||||||
s.keyPath = (dir / "key.pem").string();
|
|
||||||
s.certPath = (dir / "cert.pem").string();
|
|
||||||
|
|
||||||
bool needRegen = !std::filesystem::exists(s.certPath, ec)
|
// The lock is held for a read of two small files, or for making a pair,
|
||||||
|| !std::filesystem::exists(s.keyPath, ec);
|
// milliseconds either way; the bound is for a holder that was stopped.
|
||||||
if (!needRegen) {
|
constexpr std::chrono::seconds lockTimeout{ 10 };
|
||||||
// Use openssl to ask whether the cert is still valid. -checkend 0
|
|
||||||
// returns 0 if the cert is still good, non-zero if expired.
|
// A file descriptor, closed on scope exit.
|
||||||
int rc = std::system(std::format(
|
struct Descriptor {
|
||||||
"openssl x509 -in '{}' -noout -checkend 0 >/dev/null 2>&1",
|
int fd = -1;
|
||||||
s.certPath).c_str());
|
explicit Descriptor(int fd) : fd(fd) {}
|
||||||
if (rc != 0) needRegen = true;
|
Descriptor(Descriptor&& other) noexcept : fd(std::exchange(other.fd, -1)) {}
|
||||||
}
|
Descriptor(const Descriptor&) = delete;
|
||||||
if (needRegen) {
|
Descriptor& operator=(const Descriptor&) = delete;
|
||||||
// Inline openssl config so we get exactly the extensions Chromium's
|
~Descriptor() { if (fd >= 0) ::close(fd); }
|
||||||
// WebTransport cert-hash verifier accepts (BasicConstraints CA:FALSE,
|
};
|
||||||
// KeyUsage digitalSignature, EKU serverAuth, SAN). Skipping the
|
|
||||||
// implicit subjectKeyIdentifier / authorityKeyIdentifier that
|
[[noreturn]] void ThrowCertificateError(std::string_view what, const std::filesystem::path& path) {
|
||||||
// `openssl req -x509 -addext ...` would otherwise add.
|
throw QUICException(std::format("selfSigned: {} {}: {}; pass certPath/keyPath instead",
|
||||||
std::string cmd = std::format(
|
what, path.string(), std::strerror(errno)));
|
||||||
"openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 "
|
}
|
||||||
"-keyout '{}' -out '{}' -days 10 -nodes "
|
|
||||||
"-extensions v3_wt "
|
std::filesystem::path CertificateDirectory() {
|
||||||
"-config /dev/stdin >/dev/null 2>&1 <<'CONFIG'\n"
|
// The XDG spec says to ignore a relative path in these.
|
||||||
"[req]\n"
|
auto absolute = [](const char* name) -> std::optional<std::filesystem::path> {
|
||||||
"distinguished_name = req_dn\n"
|
const char* value = std::getenv(name);
|
||||||
"prompt = no\n"
|
if (value == nullptr || value[0] != '/') return std::nullopt;
|
||||||
"[req_dn]\n"
|
return std::filesystem::path(value);
|
||||||
"CN = localhost\n"
|
};
|
||||||
"[v3_wt]\n"
|
if (auto cache = absolute("XDG_CACHE_HOME")) return *cache / "crafter-network" / "quic-cert";
|
||||||
"basicConstraints = critical, CA:FALSE\n"
|
if (auto home = absolute("HOME")) return *home / ".cache" / "crafter-network" / "quic-cert";
|
||||||
"keyUsage = critical, digitalSignature\n"
|
if (auto runtime = absolute("XDG_RUNTIME_DIR")) return *runtime / "crafter-network" / "quic-cert";
|
||||||
"extendedKeyUsage = serverAuth\n"
|
throw QUICException("selfSigned: no per-user directory for the certificate "
|
||||||
"subjectAltName = DNS:localhost, IP:127.0.0.1, IP:::1\n"
|
"(XDG_CACHE_HOME, HOME and XDG_RUNTIME_DIR are all unset); "
|
||||||
"CONFIG\n",
|
"pass certPath/keyPath instead");
|
||||||
s.keyPath, s.certPath);
|
}
|
||||||
int rc = std::system(cmd.c_str());
|
|
||||||
if (rc != 0) {
|
// Makes whatever is missing of `path`, 0700, and opens the last
|
||||||
throw QUICException(std::format(
|
// directory, which must be this user's and nobody else's.
|
||||||
"openssl CLI failed to generate self-signed cert "
|
Descriptor OpenPrivateDirectory(const std::filesystem::path& path) {
|
||||||
"(exit {}); install openssl or pass certPath/keyPath",
|
std::filesystem::path partial;
|
||||||
rc));
|
for (const std::filesystem::path& part : path) {
|
||||||
|
partial /= part;
|
||||||
|
if (::mkdir(partial.c_str(), 0700) != 0 && errno != EEXIST) {
|
||||||
|
ThrowCertificateError("could not create", partial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cached = std::move(s);
|
Descriptor directory(::open(path.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC));
|
||||||
|
if (directory.fd < 0) ThrowCertificateError("could not open", path);
|
||||||
|
struct stat info {};
|
||||||
|
if (::fstat(directory.fd, &info) != 0) ThrowCertificateError("could not stat", path);
|
||||||
|
if (info.st_uid != ::geteuid()) {
|
||||||
|
throw QUICException(std::format("selfSigned: {} belongs to another user; "
|
||||||
|
"pass certPath/keyPath instead", path.string()));
|
||||||
|
}
|
||||||
|
if ((info.st_mode & 077) != 0 && ::fchmod(directory.fd, 0700) != 0) {
|
||||||
|
ThrowCertificateError("could not make private", path);
|
||||||
|
}
|
||||||
|
return directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exclusive record lock on `lock` in `directory`, held until the
|
||||||
|
// returned descriptor closes, and given up on after lockTimeout.
|
||||||
|
Descriptor LockDirectory(int directory, const std::filesystem::path& path) {
|
||||||
|
Descriptor lock(::openat(directory, "lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0600));
|
||||||
|
if (lock.fd < 0) ThrowCertificateError("could not open", path / "lock");
|
||||||
|
struct flock whole {};
|
||||||
|
whole.l_type = F_WRLCK;
|
||||||
|
whole.l_whence = SEEK_SET;
|
||||||
|
const auto deadline = std::chrono::steady_clock::now() + lockTimeout;
|
||||||
|
while (::fcntl(lock.fd, F_SETLK, &whole) != 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
if (errno != EACCES && errno != EAGAIN) ThrowCertificateError("could not lock", path / "lock");
|
||||||
|
if (std::chrono::steady_clock::now() >= deadline) {
|
||||||
|
throw QUICException(std::format("selfSigned: another process held {} for {} s",
|
||||||
|
(path / "lock").string(), lockTimeout.count()));
|
||||||
|
}
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
}
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `name` in `directory`, or nothing when it is missing, unreadable, or
|
||||||
|
// far too big for a PEM.
|
||||||
|
std::optional<std::string> ReadAt(int directory, const char* name) {
|
||||||
|
Descriptor file(::openat(directory, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC));
|
||||||
|
if (file.fd < 0) return std::nullopt;
|
||||||
|
std::string contents;
|
||||||
|
char buffer[4096];
|
||||||
|
for (;;) {
|
||||||
|
const ssize_t got = ::read(file.fd, buffer, sizeof(buffer));
|
||||||
|
if (got == 0) return contents;
|
||||||
|
if (got < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
contents.append(buffer, static_cast<std::size_t>(got));
|
||||||
|
if (contents.size() > 64 * 1024) return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replaces `name` in `directory` by writing a temporary file and
|
||||||
|
// renaming it over the old one. Only the holder of the lock writes here,
|
||||||
|
// so a temporary file found is one a crashed writer left.
|
||||||
|
void WriteAt(int directory, const std::filesystem::path& path, const std::string& name,
|
||||||
|
const std::string& contents, mode_t mode) {
|
||||||
|
const std::string temporary = name + ".new";
|
||||||
|
::unlinkat(directory, temporary.c_str(), 0);
|
||||||
|
Descriptor file(::openat(directory, temporary.c_str(),
|
||||||
|
O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, mode));
|
||||||
|
if (file.fd < 0) ThrowCertificateError("could not create", path / temporary);
|
||||||
|
for (std::size_t written = 0; written < contents.size();) {
|
||||||
|
const ssize_t put = ::write(file.fd, contents.data() + written, contents.size() - written);
|
||||||
|
if (put < 0) {
|
||||||
|
if (errno == EINTR) continue;
|
||||||
|
ThrowCertificateError("could not write", path / temporary);
|
||||||
|
}
|
||||||
|
written += static_cast<std::size_t>(put);
|
||||||
|
}
|
||||||
|
if (::fsync(file.fd) != 0) ThrowCertificateError("could not flush", path / temporary);
|
||||||
|
if (::renameat(directory, temporary.c_str(), directory, name.c_str()) != 0) {
|
||||||
|
ThrowCertificateError("could not rename into", path / name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pair on disk if it is good for more than renewWithin, else a new
|
||||||
|
// one written there. Under the directory's lock throughout.
|
||||||
|
SelfSignedCert LoadOrMakeSelfSignedCert() {
|
||||||
|
const std::filesystem::path path = CertificateDirectory();
|
||||||
|
const Descriptor directory = OpenPrivateDirectory(path);
|
||||||
|
const Descriptor lock = LockDirectory(directory.fd, path);
|
||||||
|
SelfSignedCert cert;
|
||||||
|
cert.certPath = (path / "cert.pem").string();
|
||||||
|
cert.keyPath = (path / "key.pem").string();
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
|
||||||
|
std::optional<std::string> certificate = ReadAt(directory.fd, "cert.pem");
|
||||||
|
std::optional<std::string> key = ReadAt(directory.fd, "key.pem");
|
||||||
|
if (certificate && key) {
|
||||||
|
cert.pem = TLSCertificatePem{ std::move(*certificate), std::move(*key) };
|
||||||
|
const auto expiry = CertificatePairExpiry(cert.pem);
|
||||||
|
if (expiry && *expiry - now > renewWithin) {
|
||||||
|
cert.notAfter = *expiry;
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::chrono::system_clock::time_point> expiry;
|
||||||
|
try {
|
||||||
|
cert.pem = MakeWebTransportCertificatePem();
|
||||||
|
expiry = CertificatePairExpiry(cert.pem);
|
||||||
|
} catch (const TLSException& e) {
|
||||||
|
throw QUICException(std::format("selfSigned: {}", e.what()));
|
||||||
|
}
|
||||||
|
if (!expiry) throw QUICException("selfSigned: the certificate just made does not check out");
|
||||||
|
cert.notAfter = *expiry;
|
||||||
|
// The key first, readable by this user alone; the certificate is
|
||||||
|
// public. Each rename is atomic, the pair is not, and that is fine:
|
||||||
|
// every reader holds the lock.
|
||||||
|
WriteAt(directory.fd, path, "key.pem", cert.pem.privateKey, 0600);
|
||||||
|
WriteAt(directory.fd, path, "cert.pem", cert.pem.certificate, 0644);
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This process's pair: loaded on first use and again once it has less
|
||||||
|
// than renewWithin left, so every listener in between serves the same
|
||||||
|
// certificate, the one GetSelfSignedCertificatePath() names.
|
||||||
|
SelfSignedCert GetSelfSignedCert() {
|
||||||
|
static std::mutex mutex;
|
||||||
|
static std::optional<SelfSignedCert> cached;
|
||||||
|
std::lock_guard lock(mutex);
|
||||||
|
if (!cached || cached->notAfter - std::chrono::system_clock::now() <= renewWithin) {
|
||||||
|
cached = LoadOrMakeSelfSignedCert();
|
||||||
|
}
|
||||||
return *cached;
|
return *cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The PKCS#12 a selfSigned listener hands msquic is made and parsed in
|
||||||
|
// memory within one call, so its password protects nothing; msquic's
|
||||||
|
// own code does the same with a random one.
|
||||||
|
constexpr const char* pkcs12Password = "crafter-network";
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ListenerQUIC::Impl {
|
struct ListenerQUIC::Impl {
|
||||||
|
|
@ -255,6 +401,36 @@ static HQUIC OpenServerConfiguration(const std::string& alpn,
|
||||||
std::vector<std::uint8_t>& alpnBufOut) {
|
std::vector<std::uint8_t>& alpnBufOut) {
|
||||||
QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBufOut);
|
QUIC_BUFFER alpnBuffer = MakeAlpn(alpn, alpnBufOut);
|
||||||
|
|
||||||
|
// The credential first, since getting it can throw and nothing is open
|
||||||
|
// yet. selfSigned reaches msquic from memory, as PKCS#12, so the files,
|
||||||
|
// which another process may replace, are read once and under their
|
||||||
|
// lock (see GetSelfSignedCert); a certPath/keyPath pair is msquic's to
|
||||||
|
// read.
|
||||||
|
QUIC_CREDENTIAL_CONFIG cc{};
|
||||||
|
QUIC_CERTIFICATE_FILE certFile{};
|
||||||
|
QUIC_CERTIFICATE_PKCS12 pkcs12{};
|
||||||
|
std::vector<std::uint8_t> pkcs12Blob;
|
||||||
|
std::string certDescription = creds.certPath;
|
||||||
|
if (creds.selfSigned) {
|
||||||
|
const SelfSignedCert cert = GetSelfSignedCert();
|
||||||
|
try {
|
||||||
|
pkcs12Blob = EncodePkcs12(cert.pem, pkcs12Password);
|
||||||
|
} catch (const TLSException& e) {
|
||||||
|
throw QUICException(std::format("selfSigned: {}", e.what()));
|
||||||
|
}
|
||||||
|
pkcs12.Asn1Blob = pkcs12Blob.data();
|
||||||
|
pkcs12.Asn1BlobLength = static_cast<std::uint32_t>(pkcs12Blob.size());
|
||||||
|
pkcs12.PrivateKeyPassword = pkcs12Password;
|
||||||
|
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12;
|
||||||
|
cc.CertificatePkcs12 = &pkcs12;
|
||||||
|
certDescription = cert.certPath;
|
||||||
|
} else {
|
||||||
|
certFile.CertificateFile = creds.certPath.c_str();
|
||||||
|
certFile.PrivateKeyFile = creds.keyPath.c_str();
|
||||||
|
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
|
||||||
|
cc.CertificateFile = &certFile;
|
||||||
|
}
|
||||||
|
|
||||||
QUIC_SETTINGS settings{};
|
QUIC_SETTINGS settings{};
|
||||||
settings.IsSet.IdleTimeoutMs = 1;
|
settings.IsSet.IdleTimeoutMs = 1;
|
||||||
settings.IdleTimeoutMs = creds.settings.idleTimeoutMs;
|
settings.IdleTimeoutMs = creds.settings.idleTimeoutMs;
|
||||||
|
|
@ -274,29 +450,15 @@ static HQUIC OpenServerConfiguration(const std::string& alpn,
|
||||||
&settings, sizeof(settings), nullptr, &cfg);
|
&settings, sizeof(settings), nullptr, &cfg);
|
||||||
if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
if (QUIC_FAILED(s)) throw QUICException(std::format("ConfigurationOpen failed: 0x{:x}", static_cast<unsigned>(s)));
|
||||||
|
|
||||||
QUIC_CREDENTIAL_CONFIG cc{};
|
// Synchronous, without QUIC_CREDENTIAL_FLAG_LOAD_ASYNCHRONOUS: msquic has
|
||||||
QUIC_CERTIFICATE_FILE certFile{};
|
// parsed the PKCS#12 into an SSL_CTX of its own before this returns.
|
||||||
std::string effectiveCertPath;
|
|
||||||
std::string effectiveKeyPath;
|
|
||||||
if (creds.selfSigned) {
|
|
||||||
const SelfSignedCert& s = GetSelfSignedCert();
|
|
||||||
effectiveCertPath = s.certPath;
|
|
||||||
effectiveKeyPath = s.keyPath;
|
|
||||||
} else {
|
|
||||||
effectiveCertPath = creds.certPath;
|
|
||||||
effectiveKeyPath = creds.keyPath;
|
|
||||||
}
|
|
||||||
certFile.CertificateFile = effectiveCertPath.c_str();
|
|
||||||
certFile.PrivateKeyFile = effectiveKeyPath.c_str();
|
|
||||||
cc.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
|
|
||||||
cc.CertificateFile = &certFile;
|
|
||||||
s = Runtime().api->ConfigurationLoadCredential(cfg, &cc);
|
s = Runtime().api->ConfigurationLoadCredential(cfg, &cc);
|
||||||
if (QUIC_FAILED(s)) {
|
if (QUIC_FAILED(s)) {
|
||||||
Runtime().api->ConfigurationClose(cfg);
|
Runtime().api->ConfigurationClose(cfg);
|
||||||
throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}"
|
throw QUICException(std::format("ConfigurationLoadCredential failed: 0x{:x}"
|
||||||
" (selfSigned={}, cert={})",
|
" (selfSigned={}, cert={})",
|
||||||
static_cast<unsigned>(s),
|
static_cast<unsigned>(s),
|
||||||
creds.selfSigned, creds.certPath));
|
creds.selfSigned, certDescription));
|
||||||
}
|
}
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
@ -472,49 +634,16 @@ void ListenerQUIC::ListenAsyncAsync() {
|
||||||
}
|
}
|
||||||
|
|
||||||
std::array<std::uint8_t, 32> Crafter::ComputeCertificateHashSHA256(const std::string& certPath) {
|
std::array<std::uint8_t, 32> Crafter::ComputeCertificateHashSHA256(const std::string& certPath) {
|
||||||
// Convert PEM → DER → SHA-256 via openssl, capture hex digest from stdout.
|
// In-process through :TLS; this used to pipe the file through two
|
||||||
// openssl pipes give us "SHA2-256(stdin)= <hex>\n"; we parse the trailing
|
// openssl CLI invocations and parse the hex back out of the output.
|
||||||
// hex run. Shelling out keeps msquic the sole TLS dependency.
|
std::ifstream file(certPath, std::ios::binary);
|
||||||
std::string cmd = std::format(
|
if (!file) throw QUICException(std::format("could not open {} to hash it", certPath));
|
||||||
"openssl x509 -in '{}' -outform der | openssl dgst -sha256 2>/dev/null",
|
const std::string pem((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
|
||||||
certPath);
|
try {
|
||||||
FILE* p = popen(cmd.c_str(), "r");
|
return CertificateSha256(pem);
|
||||||
if (!p) throw QUICException("popen failed while computing cert hash");
|
} catch (const TLSException& e) {
|
||||||
std::string out;
|
throw QUICException(std::format("could not compute the SHA-256 of {}: {}", certPath, e.what()));
|
||||||
char chunk[256];
|
|
||||||
while (auto n = std::fread(chunk, 1, sizeof(chunk), p)) {
|
|
||||||
out.append(chunk, n);
|
|
||||||
}
|
}
|
||||||
int rc = pclose(p);
|
|
||||||
if (rc != 0) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"openssl failed to compute SHA-256 of {} (exit {})", certPath, rc));
|
|
||||||
}
|
|
||||||
auto eq = out.rfind('=');
|
|
||||||
if (eq == std::string::npos) {
|
|
||||||
throw QUICException("could not parse openssl dgst output");
|
|
||||||
}
|
|
||||||
std::string hex;
|
|
||||||
for (std::size_t i = eq + 1; i < out.size(); ++i) {
|
|
||||||
char c = out[i];
|
|
||||||
if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
||||||
hex.push_back(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (hex.size() != 64) {
|
|
||||||
throw QUICException(std::format(
|
|
||||||
"unexpected SHA-256 hex length {} (expected 64)", hex.size()));
|
|
||||||
}
|
|
||||||
std::array<std::uint8_t, 32> result{};
|
|
||||||
for (std::size_t i = 0; i < 32; ++i) {
|
|
||||||
auto nibble = [](char c) -> std::uint8_t {
|
|
||||||
if (c >= '0' && c <= '9') return c - '0';
|
|
||||||
if (c >= 'a' && c <= 'f') return 10 + (c - 'a');
|
|
||||||
return 10 + (c - 'A');
|
|
||||||
};
|
|
||||||
result[i] = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Crafter::GetSelfSignedCertificatePath() {
|
std::string Crafter::GetSelfSignedCertificatePath() {
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ module;
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
#include <climits>
|
#include <climits>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
#include <openssl/bio.h>
|
#include <openssl/bio.h>
|
||||||
|
#include <openssl/bn.h>
|
||||||
#include <openssl/err.h>
|
#include <openssl/err.h>
|
||||||
#include <openssl/evp.h>
|
#include <openssl/evp.h>
|
||||||
#include <openssl/pem.h>
|
#include <openssl/pem.h>
|
||||||
|
#include <openssl/pkcs12.h>
|
||||||
#include <openssl/ssl.h>
|
#include <openssl/ssl.h>
|
||||||
#include <openssl/sslerr.h>
|
#include <openssl/sslerr.h>
|
||||||
#include <openssl/x509v3.h>
|
#include <openssl/x509v3.h>
|
||||||
|
|
@ -78,6 +81,8 @@ namespace {
|
||||||
using OwnedBio = Owned<BIO, BIO_free_all>;
|
using OwnedBio = Owned<BIO, BIO_free_all>;
|
||||||
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
using OwnedKey = Owned<EVP_PKEY, EVP_PKEY_free>;
|
||||||
using OwnedCertificate = Owned<X509, X509_free>;
|
using OwnedCertificate = Owned<X509, X509_free>;
|
||||||
|
using OwnedBignum = Owned<BIGNUM, BN_free>;
|
||||||
|
using OwnedPkcs12 = Owned<PKCS12, PKCS12_free>;
|
||||||
|
|
||||||
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
// Drain OpenSSL's per-thread error queue into a readable message. Without
|
||||||
// this every TLS failure reads as "handshake failed" with no hint as to
|
// this every TLS failure reads as "handshake failed" with no hint as to
|
||||||
|
|
@ -167,7 +172,9 @@ namespace {
|
||||||
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
if (added != 1) throw TLSException(Describe("could not add certificate extension"));
|
||||||
}
|
}
|
||||||
|
|
||||||
TLSCertificatePem MakeSelfSignedCertificate() {
|
// `keyUsage` is the KeyUsage extension's value; `randomSerial` draws the
|
||||||
|
// serial number instead of using 1.
|
||||||
|
TLSCertificatePem MakeSelfSignedCertificate(const char* keyUsage, bool randomSerial) {
|
||||||
OwnedKey key(EVP_EC_gen("P-256"));
|
OwnedKey key(EVP_EC_gen("P-256"));
|
||||||
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
if (!key) throw TLSException(Describe("could not generate a P-256 key"));
|
||||||
|
|
||||||
|
|
@ -177,7 +184,17 @@ namespace {
|
||||||
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
// X509_set_version takes the zero-based version, so 2 is v3 — which is
|
||||||
// what the extensions below require.
|
// what the extensions below require.
|
||||||
X509_set_version(certificate.get(), 2);
|
X509_set_version(certificate.get(), 2);
|
||||||
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
if (randomSerial) {
|
||||||
|
// 127 bits: positive, and within the 20 octets RFC 5280 allows.
|
||||||
|
OwnedBignum serial(BN_new());
|
||||||
|
if (!serial
|
||||||
|
|| BN_rand(serial.get(), 127, BN_RAND_TOP_ANY, BN_RAND_BOTTOM_ANY) != 1
|
||||||
|
|| BN_to_ASN1_INTEGER(serial.get(), X509_get_serialNumber(certificate.get())) == nullptr) {
|
||||||
|
throw TLSException(Describe("could not draw a serial number"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1);
|
||||||
|
}
|
||||||
// Backdated an hour so a peer whose clock runs slightly behind ours
|
// Backdated an hour so a peer whose clock runs slightly behind ours
|
||||||
// does not reject a certificate we just minted.
|
// does not reject a certificate we just minted.
|
||||||
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
X509_gmtime_adj(X509_getm_notBefore(certificate.get()), -3600);
|
||||||
|
|
@ -198,8 +215,7 @@ namespace {
|
||||||
nullptr, nullptr, 0);
|
nullptr, nullptr, 0);
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
AddExtension(certificate.get(), &extensionContext, NID_basic_constraints,
|
||||||
"critical,CA:FALSE");
|
"critical,CA:FALSE");
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_key_usage,
|
AddExtension(certificate.get(), &extensionContext, NID_key_usage, keyUsage);
|
||||||
"critical,digitalSignature,keyEncipherment");
|
|
||||||
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
AddExtension(certificate.get(), &extensionContext, NID_ext_key_usage, "serverAuth");
|
||||||
// The SANs are what a verifying client actually matches on; a bare CN
|
// The SANs are what a verifying client actually matches on; a bare CN
|
||||||
// has not been accepted by anything for years.
|
// has not been accepted by anything for years.
|
||||||
|
|
@ -716,6 +732,79 @@ const TLSCertificatePem& Crafter::GetSelfSignedCertificatePem() {
|
||||||
static std::mutex mutex;
|
static std::mutex mutex;
|
||||||
static std::optional<TLSCertificatePem> cached;
|
static std::optional<TLSCertificatePem> cached;
|
||||||
std::lock_guard lock(mutex);
|
std::lock_guard lock(mutex);
|
||||||
if (!cached) cached = MakeSelfSignedCertificate();
|
if (!cached) cached = MakeSelfSignedCertificate("critical,digitalSignature,keyEncipherment", false);
|
||||||
return *cached;
|
return *cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Module-internal: :ListenerQUIC's self-signed credential ──────────────
|
||||||
|
|
||||||
|
TLSCertificatePem Crafter::MakeWebTransportCertificatePem() {
|
||||||
|
// No keyEncipherment, unlike the TLS certificate above: the openssl
|
||||||
|
// invocation this replaces asked for digitalSignature alone, which is
|
||||||
|
// all an ECDSA key can do anyway.
|
||||||
|
return MakeSelfSignedCertificate("critical,digitalSignature", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::chrono::system_clock::time_point>
|
||||||
|
Crafter::CertificatePairExpiry(const TLSCertificatePem& pem) {
|
||||||
|
constexpr std::size_t limit = 1 << 20;
|
||||||
|
if (pem.certificate.empty() || pem.privateKey.empty()
|
||||||
|
|| pem.certificate.size() > limit || pem.privateKey.size() > limit) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
OwnedBio certificateBio(BIO_new_mem_buf(pem.certificate.data(), static_cast<int>(pem.certificate.size())));
|
||||||
|
OwnedBio keyBio(BIO_new_mem_buf(pem.privateKey.data(), static_cast<int>(pem.privateKey.size())));
|
||||||
|
if (!certificateBio || !keyBio) {
|
||||||
|
ERR_clear_error();
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
std::optional<std::chrono::system_clock::time_point> expiry;
|
||||||
|
std::tm notAfter{};
|
||||||
|
// X509_cmp_current_time is -1 for a time already past, so notBefore has
|
||||||
|
// to compare below 0; 0 is an unreadable time.
|
||||||
|
if (certificate && key
|
||||||
|
&& X509_check_private_key(certificate.get(), key.get()) == 1
|
||||||
|
&& X509_cmp_current_time(X509_get0_notBefore(certificate.get())) < 0
|
||||||
|
&& ASN1_TIME_to_tm(X509_get0_notAfter(certificate.get()), ¬After) == 1) {
|
||||||
|
expiry = std::chrono::system_clock::from_time_t(timegm(¬After));
|
||||||
|
}
|
||||||
|
// What a bad pair left on the queue is not anyone else's error.
|
||||||
|
ERR_clear_error();
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> Crafter::EncodePkcs12(const TLSCertificatePem& pem, const std::string& password) {
|
||||||
|
OwnedBio certificateBio = MemoryBio(pem.certificate);
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(certificateBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
||||||
|
OwnedBio keyBio = MemoryBio(pem.privateKey);
|
||||||
|
OwnedKey key(PEM_read_bio_PrivateKey(keyBio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!key) throw TLSException(Describe("could not read the private key PEM"));
|
||||||
|
// Zeros pick OpenSSL 3's defaults: AES-256-CBC under PBKDF2 for both
|
||||||
|
// bags and an HMAC-SHA-256 MAC, all of which its default provider has.
|
||||||
|
OwnedPkcs12 pkcs12(PKCS12_create(password.c_str(), nullptr, key.get(), certificate.get(),
|
||||||
|
nullptr, 0, 0, 0, 0, 0));
|
||||||
|
if (!pkcs12) throw TLSException(Describe("could not encode the pair as PKCS#12"));
|
||||||
|
unsigned char* der = nullptr;
|
||||||
|
const int length = i2d_PKCS12(pkcs12.get(), &der);
|
||||||
|
if (length <= 0 || der == nullptr) throw TLSException(Describe("could not encode the PKCS#12 as DER"));
|
||||||
|
std::vector<std::uint8_t> encoded(der, der + length);
|
||||||
|
OPENSSL_free(der);
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::array<std::uint8_t, 32> Crafter::CertificateSha256(const std::string& pem) {
|
||||||
|
OwnedBio bio = MemoryBio(pem);
|
||||||
|
OwnedCertificate certificate(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr));
|
||||||
|
if (!certificate) throw TLSException(Describe("could not read the certificate PEM"));
|
||||||
|
// X509_digest hashes the DER encoding, which is what a browser pins.
|
||||||
|
std::array<std::uint8_t, 32> digest{};
|
||||||
|
unsigned int length = 0;
|
||||||
|
if (X509_digest(certificate.get(), EVP_sha256(), digest.data(), &length) != 1
|
||||||
|
|| length != digest.size()) {
|
||||||
|
throw TLSException(Describe("could not hash the certificate"));
|
||||||
|
}
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ namespace Crafter {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Server certificate sources. Pick one of (filePaths) or (selfSigned).
|
// Server certificate sources. Pick one of (filePaths) or (selfSigned).
|
||||||
// selfSigned generates an in-memory ephemeral cert — fine for dev/LAN.
|
// selfSigned serves a development certificate made in-process and kept
|
||||||
|
// per user under $XDG_CACHE_HOME/crafter-network/quic-cert/, reused
|
||||||
|
// while valid (see GetSelfSignedCertificatePath) — fine for dev/LAN.
|
||||||
export struct QUICServerCredentials {
|
export struct QUICServerCredentials {
|
||||||
std::string certPath;
|
std::string certPath;
|
||||||
std::string keyPath;
|
std::string keyPath;
|
||||||
|
|
|
||||||
|
|
@ -86,12 +86,23 @@ namespace Crafter {
|
||||||
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
||||||
// hash to a browser peer (Chrome's WebTransport requires the client to
|
// hash to a browser peer (Chrome's WebTransport requires the client to
|
||||||
// pass this hash via `serverCertificateHashes` when peering against a
|
// pass this hash via `serverCertificateHashes` when peering against a
|
||||||
// cert that's not in the system trust store). Shells out to openssl.
|
// cert that's not in the system trust store). In-process, through
|
||||||
|
// OpenSSL; throws QUICException for a file it cannot read or parse.
|
||||||
export std::array<std::uint8_t, 32> ComputeCertificateHashSHA256(const std::string& certPath);
|
export std::array<std::uint8_t, 32> ComputeCertificateHashSHA256(const std::string& certPath);
|
||||||
|
|
||||||
// Path of the lazily-generated self-signed cert (PEM). Triggers generation
|
// Path of the self-signed cert (PEM) QUICServerCredentials{selfSigned}
|
||||||
// on first call. Useful for piping into ComputeCertificateHashSHA256 so
|
// serves; makes it on first use. Useful for piping into
|
||||||
// a browser peer can be told the hash to put in `serverCertificateHashes`.
|
// ComputeCertificateHashSHA256 so a browser peer can be told the hash to
|
||||||
|
// put in `serverCertificateHashes`.
|
||||||
|
//
|
||||||
|
// One pair per user, in $XDG_CACHE_HOME/crafter-network/quic-cert/
|
||||||
|
// (else ~/.cache/..., else $XDG_RUNTIME_DIR/...), a 0700 directory, made
|
||||||
|
// in-process (no openssl CLI). It is reused across restarts, and so its
|
||||||
|
// hash stays pinned, until it has less than a day of its ten left; then
|
||||||
|
// the next process to need it makes a new one. Processes starting at
|
||||||
|
// once share one pair: a lock covers the check and the replacement.
|
||||||
|
// Throws QUICException when no such directory can be had (none of those
|
||||||
|
// variables set, or the directory belongs to someone else).
|
||||||
export std::string GetSelfSignedCertificatePath();
|
export std::string GetSelfSignedCertificatePath();
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -175,5 +175,37 @@ namespace Crafter {
|
||||||
// valid for 10 days. Development and tests only — it is regenerated on
|
// valid for 10 days. Development and tests only — it is regenerated on
|
||||||
// every process start and no peer has any reason to trust it.
|
// every process start and no peer has any reason to trust it.
|
||||||
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
export const TLSCertificatePem& GetSelfSignedCertificatePem();
|
||||||
|
|
||||||
|
// ── Module-internal, not exported ────────────────────────────────────
|
||||||
|
// The certificate work :ListenerQUIC's self-signed credential needs.
|
||||||
|
// It lives here so that libssl stays behind this one partition: the
|
||||||
|
// listener keeps the pair on disk with plain POSIX calls and leaves
|
||||||
|
// every OpenSSL call to these.
|
||||||
|
|
||||||
|
// A fresh pair shaped for Chromium's WebTransport
|
||||||
|
// serverCertificateHashes, which pins a hash instead of trusting a CA
|
||||||
|
// and in exchange demands: ECDSA P-256 signed with SHA-256, a validity
|
||||||
|
// of under 14 days (10, backdated an hour), BasicConstraints CA:FALSE,
|
||||||
|
// KeyUsage digitalSignature, EKU serverAuth, and SANs covering the name
|
||||||
|
// dialled (localhost, 127.0.0.1, ::1). A random serial, since the pair
|
||||||
|
// is replaced every ten days under the same issuer name. Throws
|
||||||
|
// TLSException.
|
||||||
|
TLSCertificatePem MakeWebTransportCertificatePem();
|
||||||
|
|
||||||
|
// When the certificate in `pem` stops being valid, provided it parses,
|
||||||
|
// has begun being valid, and `pem.privateKey` is its key; nothing for
|
||||||
|
// anything else. Never throws: a bad pair on disk is one to replace.
|
||||||
|
std::optional<std::chrono::system_clock::time_point>
|
||||||
|
CertificatePairExpiry(const TLSCertificatePem& pem);
|
||||||
|
|
||||||
|
// The pair as a DER-encoded PKCS#12 under `password`: the in-memory
|
||||||
|
// credential msquic takes as QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12.
|
||||||
|
// Throws TLSException.
|
||||||
|
std::vector<std::uint8_t> EncodePkcs12(const TLSCertificatePem& pem,
|
||||||
|
const std::string& password);
|
||||||
|
|
||||||
|
// SHA-256 of the DER encoding of the first certificate in `pem`, the
|
||||||
|
// value serverCertificateHashes pins. Throws TLSException.
|
||||||
|
std::array<std::uint8_t, 32> CertificateSha256(const std::string& pem);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue