Complete native QUIC certificate validation
This commit is contained in:
parent
02f99cc1c8
commit
5e742c26ae
3 changed files with 97 additions and 5 deletions
|
|
@ -11,6 +11,7 @@
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
|
@ -58,8 +59,22 @@ struct Client {
|
||||||
std::mutex gate;
|
std::mutex gate;
|
||||||
std::deque<std::vector<uint8_t>> received;
|
std::deque<std::vector<uint8_t>> received;
|
||||||
std::string error;
|
std::string error;
|
||||||
|
std::string trace;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
void AppendTrace(Client* client, const std::string& event) {
|
||||||
|
if (client == nullptr || client->state.load() == ClientState::Connected) return;
|
||||||
|
std::lock_guard<std::mutex> lock(client->gate);
|
||||||
|
if (!client->trace.empty()) client->trace += ",";
|
||||||
|
client->trace += event;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string TraceSnapshot(Client* client) {
|
||||||
|
if (client == nullptr) return "none";
|
||||||
|
std::lock_guard<std::mutex> lock(client->gate);
|
||||||
|
return client->trace.empty() ? "none" : client->trace;
|
||||||
|
}
|
||||||
|
|
||||||
void SetFailure(Client* client, const std::string& message) {
|
void SetFailure(Client* client, const std::string& message) {
|
||||||
if (client == nullptr || client->closing.load()) return;
|
if (client == nullptr || client->closing.load()) return;
|
||||||
{
|
{
|
||||||
|
|
@ -184,8 +199,30 @@ bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) {
|
||||||
return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0;
|
return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string Hex(uint64_t value) {
|
||||||
|
std::ostringstream text;
|
||||||
|
text << "0x" << std::hex << std::uppercase << value;
|
||||||
|
return text.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string LoadedMsQuicPath() {
|
||||||
|
const auto module = GetModuleHandleW(L"msquic.dll");
|
||||||
|
if (module == nullptr) return "unresolved";
|
||||||
|
std::array<wchar_t, 32768> path{};
|
||||||
|
const auto length = GetModuleFileNameW(module, path.data(), static_cast<DWORD>(path.size()));
|
||||||
|
if (length == 0 || length >= path.size()) return "unresolved";
|
||||||
|
const auto required = WideCharToMultiByte(
|
||||||
|
CP_UTF8, 0, path.data(), static_cast<int>(length), nullptr, 0, nullptr, nullptr);
|
||||||
|
if (required <= 0) return "unresolved";
|
||||||
|
std::string utf8(static_cast<size_t>(required), '\0');
|
||||||
|
WideCharToMultiByte(
|
||||||
|
CP_UTF8, 0, path.data(), static_cast<int>(length), utf8.data(), required, nullptr, nullptr);
|
||||||
|
return utf8;
|
||||||
|
}
|
||||||
|
|
||||||
QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) {
|
QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) {
|
||||||
auto* client = static_cast<Client*>(context);
|
auto* client = static_cast<Client*>(context);
|
||||||
|
AppendTrace(client, "event=" + std::to_string(static_cast<int>(event->Type)));
|
||||||
switch (event->Type) {
|
switch (event->Type) {
|
||||||
case QUIC_CONNECTION_EVENT_CONNECTED:
|
case QUIC_CONNECTION_EVENT_CONNECTED:
|
||||||
client->state.store(ClientState::Connected);
|
client->state.store(ClientState::Connected);
|
||||||
|
|
@ -200,14 +237,40 @@ QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CO
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED:
|
case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED:
|
||||||
if (!CertificateMatchesPin(client, event->PEER_CERTIFICATE_RECEIVED.Certificate)) {
|
{
|
||||||
|
AppendTrace(
|
||||||
|
client,
|
||||||
|
"certificate-status=" +
|
||||||
|
Hex(static_cast<uint64_t>(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus)));
|
||||||
|
const auto matches = CertificateMatchesPin(
|
||||||
|
client,
|
||||||
|
event->PEER_CERTIFICATE_RECEIVED.Certificate);
|
||||||
|
if (!matches) {
|
||||||
SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin.");
|
SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin.");
|
||||||
return QUIC_STATUS_BAD_CERTIFICATE;
|
|
||||||
}
|
}
|
||||||
return QUIC_STATUS_SUCCESS;
|
const auto completion = client->api->ConnectionCertificateValidationComplete(
|
||||||
|
connection,
|
||||||
|
matches ? TRUE : FALSE,
|
||||||
|
matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE);
|
||||||
|
if (QUIC_FAILED(completion)) {
|
||||||
|
SetFailure(
|
||||||
|
client,
|
||||||
|
"CultMesh QUIC certificate validation completion failed (status=" +
|
||||||
|
Hex(static_cast<uint64_t>(completion)) + ").");
|
||||||
|
return completion;
|
||||||
|
}
|
||||||
|
return QUIC_STATUS_PENDING;
|
||||||
|
}
|
||||||
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT:
|
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT:
|
||||||
if (!client->closing.load())
|
if (!client->closing.load()) {
|
||||||
SetFailure(client, "CultMesh QUIC connection was shut down by the transport.");
|
SetFailure(
|
||||||
|
client,
|
||||||
|
"CultMesh QUIC connection was shut down by the transport "
|
||||||
|
"(status=" + Hex(static_cast<uint64_t>(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) +
|
||||||
|
", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) +
|
||||||
|
", msquic=" + LoadedMsQuicPath() +
|
||||||
|
", events=" + TraceSnapshot(client) + ").");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER:
|
case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER:
|
||||||
if (!client->closing.load())
|
if (!client->closing.load())
|
||||||
|
|
|
||||||
|
|
@ -94,9 +94,18 @@ authentication to provider authorization. Test-only trust-all callbacks are not
|
||||||
a release configuration. A trusted discovery record may carry
|
a release configuration. A trusted discovery record may carry
|
||||||
`cert-sha256=<fingerprint>` on its QUIC route; absent a caller validator, the
|
`cert-sha256=<fingerprint>` on its QUIC route; absent a caller validator, the
|
||||||
connector accepts a self-signed provider certificate only when that pin matches.
|
connector accepts a self-signed provider certificate only when that pin matches.
|
||||||
|
The native client uses MsQuic's deferred-certificate contract: it completes the
|
||||||
|
pin decision through `ConnectionCertificateValidationComplete` and returns
|
||||||
|
`QUIC_STATUS_PENDING` from the certificate event. Transport failures report the
|
||||||
|
MsQuic status, wire error, loaded runtime path, and pre-connect event sequence.
|
||||||
|
|
||||||
## Local parity evidence
|
## Local parity evidence
|
||||||
|
|
||||||
|
The native suite can also connect to an independently running managed provider
|
||||||
|
by setting `CULTMESH_NATIVE_EXTERNAL_ENDPOINT` to its advertised pinned QUIC
|
||||||
|
route. This keeps the managed server and native client in separate processes,
|
||||||
|
so one process-local MsQuic DLL cannot counterfeit cross-runtime parity.
|
||||||
|
|
||||||
On 2026-07-19, the parity harness moved a 13 MiB payload on loopback using
|
On 2026-07-19, the parity harness moved a 13 MiB payload on loopback using
|
||||||
1 MiB content chunks. Raw TCP reached 127.4 MiB/s, verified CultMesh TCP content
|
1 MiB content chunks. Raw TCP reached 127.4 MiB/s, verified CultMesh TCP content
|
||||||
reached 59.0 MiB/s, raw MsQuic reached 51.5 MiB/s, and legacy CultMesh RUDP
|
reached 59.0 MiB/s, raw MsQuic reached 51.5 MiB/s, and legacy CultMesh RUDP
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,26 @@ namespace GameCult.Mesh.Quic.Native.Tests;
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public sealed class CultMeshNativeQuicRealtimeTransportTests
|
public sealed class CultMeshNativeQuicRealtimeTransportTests
|
||||||
{
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task NativeConnectorReceivesFromExternalManagedProvider()
|
||||||
|
{
|
||||||
|
var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT");
|
||||||
|
if (string.IsNullOrWhiteSpace(endpoint))
|
||||||
|
Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/managed parity.");
|
||||||
|
|
||||||
|
var connector = new CultMeshNativeQuicRealtimeTransportConnector();
|
||||||
|
using var client = await connector.ConnectAsync(
|
||||||
|
new CultMeshTransportCandidate(endpoint!),
|
||||||
|
CultMeshEndpointId.Parse("aetheria.local"));
|
||||||
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||||
|
var frame = await client.ReceiveAsync(timeout.Token);
|
||||||
|
|
||||||
|
client.TransportId.Should().Be("msquic-native-realtime");
|
||||||
|
frame.SchemaId.Should().NotBeNullOrWhiteSpace();
|
||||||
|
frame.BodyId.Should().NotBeNullOrWhiteSpace();
|
||||||
|
frame.Payload.Should().NotBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame()
|
public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue