Finding
The custom ServerCertVerifier used for every renderer-to-server QUIC connection performs no verification of any kind. TofuVerifier::verify_server_cert unconditionally returns ServerCertVerified::assertion(), and both verify_tls12_signature and verify_tls13_signature unconditionally return HandshakeSignatureValid::assertion() without evaluating the signature over the TLS handshake transcript. This defeats both halves of TLS peer authentication at once:
- The handshake-signature stubs mean the peer is never required to prove possession of the private key matching the presented certificate.
- The
verify_server_cert stub means the certificate's identity is never checked against any stored value, so the server certificate fingerprint that the protocol exchanges for exactly this purpose (PairingChallenge.cert_fingerprint) is never consulted by the TLS layer.
The TOFU model legitimately skips CA-chain validation, but it must still (a) verify the handshake signature to bind the session to the private-key holder and (b) pin the certificate fingerprint after first contact. TofuVerifier does neither.
Evidence
crates/syndesis/src/tls/mod.rs:163-165 — the verifier is documented as accepting any certificate and is a unit struct holding no pinned fingerprint:
/// TOFU certificate verifier that accepts any certificate on first contact.
#[derive(Debug)]
struct TofuVerifier;
crates/syndesis/src/tls/mod.rs:176 — server certificate accepted unconditionally; _end_entity and the OCSP/name inputs are all ignored:
Ok(rustls::client::danger::ServerCertVerified::assertion())
crates/syndesis/src/tls/mod.rs:185 — TLS 1.2 handshake signature accepted without checking _message, _cert, or _dss:
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
crates/syndesis/src/tls/mod.rs:194 — same for TLS 1.3 (the live path: QUIC mandates TLS 1.3):
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
crates/syndesis/src/tls/mod.rs:208 — this stub is wired in as the certificate verifier for every client connection:
.with_custom_certificate_verifier(Arc::new(TofuVerifier))
crates/syndesis/src/protocol/session_frame.rs:35-40 — the protocol already transports the value the verifier should pin, but nothing feeds it to TofuVerifier:
pub struct PairingChallenge {
/// Human-readable server name.
pub server_name: String,
/// Hex-encoded SHA-256 fingerprint of the server's TLS certificate.
pub cert_fingerprint: String,
}
Why this matters
Under the counter-surveillance threat model the renderer link must withstand an active on-path adversary. As written, two independent active-MitM paths both succeed:
- An attacker holding only the server's public certificate (no private key) can present it during the QUIC handshake with their own ephemeral key; the signature stubs accept it.
- An attacker holding their own certificate and key (a different fingerprint) can present it on any connection, including a post-pairing reconnect; the
verify_server_cert stub accepts it because the pinned fingerprint is never checked.
Either way the adversary terminates the renderer's TLS session. The PairingComplete frame and every SessionInit frame carry the API key inside that session, so the attacker captures the key and can then authenticate to the real server as a legitimate renderer. The server-side TOFU check (fingerprint stored at pairing, verified on authenticate) cannot help, because the channel is already compromised at the TLS layer before any application frame is sent.
Desired correction
Replace the no-op TofuVerifier with a verifier that performs real authentication while still skipping only the CA-chain check:
- Hold an optional pinned SHA-256 fingerprint, supplied at construction (sourced from
PairingChallenge.cert_fingerprint).
- In
verify_server_cert, compute the SHA-256 fingerprint of end_entity; when a pin is present, return Err(rustls::Error::General(...)) on mismatch; on first contact (no pin), accept and surface the observed fingerprint to the caller for storage.
- Implement
verify_tls12_signature / verify_tls13_signature by delegating to the provider's signature-verification algorithms (e.g. via rustls::crypto::ring::default_provider().signature_verification_algorithms) instead of asserting validity.
Done when: a certificate presented with a private key that does not match it is rejected during the handshake; a client configured with pinned fingerprint A rejects a server presenting a certificate with fingerprint B while accepting the original certificate; and tests cover both the wrong-key and wrong-fingerprint rejection cases.
Finding
The custom
ServerCertVerifierused for every renderer-to-server QUIC connection performs no verification of any kind.TofuVerifier::verify_server_certunconditionally returnsServerCertVerified::assertion(), and bothverify_tls12_signatureandverify_tls13_signatureunconditionally returnHandshakeSignatureValid::assertion()without evaluating the signature over the TLS handshake transcript. This defeats both halves of TLS peer authentication at once:verify_server_certstub means the certificate's identity is never checked against any stored value, so the server certificate fingerprint that the protocol exchanges for exactly this purpose (PairingChallenge.cert_fingerprint) is never consulted by the TLS layer.The TOFU model legitimately skips CA-chain validation, but it must still (a) verify the handshake signature to bind the session to the private-key holder and (b) pin the certificate fingerprint after first contact.
TofuVerifierdoes neither.Evidence
crates/syndesis/src/tls/mod.rs:163-165— the verifier is documented as accepting any certificate and is a unit struct holding no pinned fingerprint:crates/syndesis/src/tls/mod.rs:176— server certificate accepted unconditionally;_end_entityand the OCSP/name inputs are all ignored:crates/syndesis/src/tls/mod.rs:185— TLS 1.2 handshake signature accepted without checking_message,_cert, or_dss:crates/syndesis/src/tls/mod.rs:194— same for TLS 1.3 (the live path: QUIC mandates TLS 1.3):crates/syndesis/src/tls/mod.rs:208— this stub is wired in as the certificate verifier for every client connection:crates/syndesis/src/protocol/session_frame.rs:35-40— the protocol already transports the value the verifier should pin, but nothing feeds it toTofuVerifier:Why this matters
Under the counter-surveillance threat model the renderer link must withstand an active on-path adversary. As written, two independent active-MitM paths both succeed:
verify_server_certstub accepts it because the pinned fingerprint is never checked.Either way the adversary terminates the renderer's TLS session. The
PairingCompleteframe and everySessionInitframe carry the API key inside that session, so the attacker captures the key and can then authenticate to the real server as a legitimate renderer. The server-side TOFU check (fingerprint stored at pairing, verified on authenticate) cannot help, because the channel is already compromised at the TLS layer before any application frame is sent.Desired correction
Replace the no-op
TofuVerifierwith a verifier that performs real authentication while still skipping only the CA-chain check:PairingChallenge.cert_fingerprint).verify_server_cert, compute the SHA-256 fingerprint ofend_entity; when a pin is present, returnErr(rustls::Error::General(...))on mismatch; on first contact (no pin), accept and surface the observed fingerprint to the caller for storage.verify_tls12_signature/verify_tls13_signatureby delegating to the provider's signature-verification algorithms (e.g. viarustls::crypto::ring::default_provider().signature_verification_algorithms) instead of asserting validity.Done when: a certificate presented with a private key that does not match it is rejected during the handshake; a client configured with pinned fingerprint A rejects a server presenting a certificate with fingerprint B while accepting the original certificate; and tests cover both the wrong-key and wrong-fingerprint rejection cases.