Skip to content

feat: certificate pinning for native and browser clients#1698

Merged
kixelated merged 4 commits into
mainfrom
claude/elegant-tereshkova-b9a2f2
Jun 12, 2026
Merged

feat: certificate pinning for native and browser clients#1698
kixelated merged 4 commits into
mainfrom
claude/elegant-tereshkova-b9a2f2

Conversation

@kixelated

@kixelated kixelated commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

Trusting a self-signed or privately-issued peer previously meant disabling TLS verification entirely. This adds proper pinning to both the native and browser clients so callers can trust a specific certificate without turning verification off.

The unifying idea: a server already reports its certificate's SHA-256 fingerprint as hex (native cert_fingerprints(), the /certificate.sha256 endpoint). Now both clients accept that same hex value directly — no insecure runtime fetch, no manual byte-wrangling.

Native (moq-native / moq-ffi / py)

  • New tls::Client.fingerprint field (--tls-fingerprint, MOQ_CLIENT_TLS_FINGERPRINT): pin the peer's leaf certificate to one or more hex SHA-256 hashes, bypassing the CA chain, hostname match, and expiry. The native equivalent of WebTransport serverCertificateHashes. FingerprintVerifier is ungated from the quinn/noq backends so it applies to every transport, and matches against a set to support rotation.
  • The existing PEM-root knob (--tls-root) is now reachable from the FFI too.
  • FFI: MoqClient.set_tls_roots(paths) and set_tls_fingerprints(hashes) alongside set_tls_disable_verify.
  • Python: Client(url, tls_roots=..., tls_fingerprints=...).
  • moq-cli picks up --tls-root / --tls-fingerprint for free via the flattened config.

Browser (js/net)

  • connect()'s webtransport.serverCertificateHashes values now accept a hex string (decoded automatically), not just raw bytes.
  • New webtransport.serverCertificate option: pin from a PEM string or DER bytes with the SHA-256 computed in JS.
  • Exported certificateHash(cert) helper and Hex.fromBytes (counterpart to toBytes).

Motivation / scope

This came out of looking at pipecat#4629. Worth being precise about what helps where:

  • pipecat's serve mode (bot = server, browser pins via serverCertificateHashes) is a browser-side flow. The native client change doesn't touch it; the JS ergonomics here do (hex-string + serverCertificate instead of hand-built hash structs).
  • pipecat's client mode (bot dials a relay as a native moq.Client(tls_verify=...)) only had a bool. tls_fingerprints / tls_roots is the upgrade if that relay is ever self-signed.

So neither half is strictly required by that PR, but both close a real "verify or disable, nothing in between" gap on their respective clients.

Notes for reviewers

  • Public API: additive only — #[non_exhaustive] config field + new FFI methods (native); widened option types (existing {value: BufferSource} callers still compile) + new exports (JS). Non-breaking, hence main.
  • Hex chosen everywhere to match cert_fingerprints() / /certificate.sha256.
  • swift/kt/go have no hand-written client wrappers, so uniffi auto-generates setTlsRoots / setTlsFingerprints on the next moq-ffi-v* mirror. libmoq exposes no client TLS-verify surface today, so nothing to mirror there.

Test plan

  • cargo test -p moq-native--tls-fingerprint TOML/CLI parsing, the TOML→CLI merge-clobber regression, and a FingerprintVerifier roundtrip (match / mismatch / invalid-hex)
  • bun test in js/netcertificateHash over DER and PEM against a known SHA-256 vector, hex round-trip (145/145 pass)
  • clippy + fmt (nix) on moq-native + moq-ffi; tsc + biome on js/net
  • End-to-end against a live self-signed peer (native needs a maturin rebuild of moq_ffi)

🤖 Generated with Claude Code

(Written by Claude)

Verifying a self-signed peer previously meant either disabling TLS
verification entirely or letting the client fetch the certificate hash
over an insecure http:// request. Neither the PEM-root knob nor the
fingerprint path was reachable from the FFI, so Python/Swift/Kotlin
callers were stuck with set_tls_disable_verify.

Add a tls.Client.fingerprint field (--tls-fingerprint,
MOQ_CLIENT_TLS_FINGERPRINT) that pins the peer's leaf certificate to one
or more hex SHA-256 hashes, bypassing the CA chain. This is the native
equivalent of WebTransport serverCertificateHashes and accepts the exact
values a server already reports via cert_fingerprints(). FingerprintVerifier
is ungated from the quinn/noq backends so it applies to every transport,
and now matches against a set to support rotation.

Expose both knobs through moq-ffi (MoqClient.set_tls_roots /
set_tls_fingerprints) and the Python wrapper (Client tls_roots /
tls_fingerprints), so callers can trust a self-signed cert without
turning verification off. moq-cli picks up --tls-fingerprint / --tls-root
for free via the flattened config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0fba0ae5-189e-45d5-b755-bf930accdfc7

📥 Commits

Reviewing files that changed from the base of the PR and between 42114c6 and aecc668.

📒 Files selected for processing (5)
  • js/net/src/connection/cert.test.ts
  • js/net/src/connection/connect.ts
  • js/net/src/connection/reload.ts
  • js/net/src/util/hex.ts
  • rs/moq-native/src/tls.rs
✅ Files skipped from review due to trivial changes (1)
  • js/net/src/connection/reload.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • rs/moq-native/src/tls.rs

Walkthrough

This PR implements SHA-256 certificate fingerprint pinning and optional TLS root overrides across native Rust, FFI, Python, and JS surfaces. The Rust TLS layer accepts multiple hex fingerprints, validates/decodes them in Client::build(), and installs a verifier that accepts any matching fingerprint. Connection code paths now pass fingerprints as vectors. UniFFI exposes set_tls_roots and set_tls_fingerprints. The Python Client gains tls_roots/tls_fingerprints parameters and README docs were updated. JS WebTransport gained certificate hashing, pin normalization, types, and tests.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding certificate pinning support to both native and browser clients.
Description check ✅ Passed The description comprehensively explains the changes, motivation, and scope across native and browser clients with clear API details.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/elegant-tereshkova-b9a2f2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
rs/moq-native/src/tls.rs (1)

210-218: 💤 Low value

Consider validating fingerprint length during decode.

The code correctly decodes hex fingerprints and installs the verifier, but it doesn't validate that each decoded fingerprint is exactly 32 bytes (SHA-256). A fingerprint of the wrong length would never match at handshake time, potentially confusing users who made a typo.

💡 Optional: Add length validation
 		let fingerprints = self
 			.fingerprint
 			.iter()
-			.map(|fp| hex::decode(fp.trim()).map_err(Error::Fingerprint))
+			.map(|fp| {
+				let bytes = hex::decode(fp.trim()).map_err(Error::Fingerprint)?;
+				if bytes.len() != 32 {
+					// Could add a new error variant, or reuse Fingerprint with a custom message
+					return Err(Error::Fingerprint(hex::FromHexError::InvalidStringLength));
+				}
+				Ok(bytes)
+			})
 			.collect::<Result<Vec<_>>>()?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rs/moq-native/src/tls.rs` around lines 210 - 218, The hex-decoding pipeline
for self.fingerprint should validate each decoded fingerprint is exactly 32
bytes (SHA-256) before constructing the FingerprintVerifier; after
hex::decode(...) (or immediately after collecting the Vec<u8>), check each
decoded value's len() == 32 and return an appropriate error (e.g., extend or
reuse Error::Fingerprint to indicate bad length) if any fingerprint is the wrong
size, then only call FingerprintVerifier::new(provider, fingerprints) and
tls.dangerous().set_certificate_verifier(...) when all entries pass validation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rs/moq-native/src/tls.rs`:
- Around line 210-218: The hex-decoding pipeline for self.fingerprint should
validate each decoded fingerprint is exactly 32 bytes (SHA-256) before
constructing the FingerprintVerifier; after hex::decode(...) (or immediately
after collecting the Vec<u8>), check each decoded value's len() == 32 and return
an appropriate error (e.g., extend or reuse Error::Fingerprint to indicate bad
length) if any fingerprint is the wrong size, then only call
FingerprintVerifier::new(provider, fingerprints) and
tls.dangerous().set_certificate_verifier(...) when all entries pass validation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2c5da6f3-99d2-42a9-8ea3-3315abbc523f

📥 Commits

Reviewing files that changed from the base of the PR and between 28505fc and 42114c6.

📒 Files selected for processing (7)
  • py/moq-rs/README.md
  • py/moq-rs/moq/client.py
  • rs/moq-ffi/src/session.rs
  • rs/moq-native/src/client.rs
  • rs/moq-native/src/noq.rs
  • rs/moq-native/src/quinn.rs
  • rs/moq-native/src/tls.rs

Pinning a self-signed server from the browser meant hand-building the DOM
`serverCertificateHashes` struct: `{ algorithm: "sha-256", value: <bytes> }`
with a manual hex decode. And there was no way to pin from the certificate
itself, only from a precomputed hash.

Widen `connect()`'s WebTransport options so `serverCertificateHashes`
values accept a hex string (the format moq servers report), decoded
automatically, and add `serverCertificate` to pin from a PEM/DER cert with
the SHA-256 computed in JS. Export `certificateHash()` for callers that want
the hash directly, and add `Hex.fromBytes` as the counterpart to `toBytes`.

This mirrors the native client's `tls_fingerprints` (both now take hex
strings) so the same value a server reports via its certificate fingerprints
works in either client without conversion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kixelated kixelated changed the title feat(native): pin TLS peers by SHA-256 fingerprint or PEM root feat: certificate pinning for native and browser clients Jun 12, 2026
A typo'd or truncated --tls-fingerprint would decode fine as hex but never
match at handshake time, leaving the user with a confusing connection
failure. Validate the SHA-256 length at config-build time instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Addressed the fingerprint-length nitpick in 34239b1: Client::build() now rejects any --tls-fingerprint that doesn't decode to exactly 32 bytes (new Error::FingerprintLength), so a typo'd or truncated hash fails at config time with a clear message instead of silently never matching at handshake. Added a build_rejects_wrong_length_fingerprint test.

(Written by Claude)

Comment thread js/net/src/connection/connect.ts Outdated
Comment thread js/net/src/connection/connect.ts Outdated
Address review feedback on certificate pinning:

- pemToDer now requires the -----BEGIN/END CERTIFICATE----- armor and
  throws a clear error instead of silently atob-ing whatever it's given.
- connectWebTransport accumulates caller-provided pins into a single
  array and appends the http:// fetched fingerprint, so a fetched hash
  can never clobber hashes passed via options (it was already additive
  via concat, but two assignment sites made it non-obvious).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kixelated
kixelated enabled auto-merge (squash) June 12, 2026 21:23
@kixelated
kixelated merged commit 35659a4 into main Jun 12, 2026
1 check passed
@kixelated
kixelated deleted the claude/elegant-tereshkova-b9a2f2 branch June 12, 2026 21:39
This was referenced Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant