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
|
|
@ -51,7 +51,9 @@ namespace Crafter {
|
|||
};
|
||||
|
||||
// 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 {
|
||||
std::string certPath;
|
||||
std::string keyPath;
|
||||
|
|
|
|||
|
|
@ -86,12 +86,23 @@ namespace Crafter {
|
|||
// Returns the 32-byte digest. Intended for surfacing the self-signed cert
|
||||
// hash to a browser peer (Chrome's WebTransport requires the client to
|
||||
// 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);
|
||||
|
||||
// Path of the lazily-generated self-signed cert (PEM). Triggers generation
|
||||
// on first call. Useful for piping into ComputeCertificateHashSHA256 so
|
||||
// a browser peer can be told the hash to put in `serverCertificateHashes`.
|
||||
// Path of the self-signed cert (PEM) QUICServerCredentials{selfSigned}
|
||||
// serves; makes it on first use. Useful for piping into
|
||||
// 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();
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -175,5 +175,37 @@ namespace Crafter {
|
|||
// valid for 10 days. Development and tests only — it is regenerated on
|
||||
// every process start and no peer has any reason to trust it.
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue