Skip to content

Commit ff8b3a8

Browse files
committed
Verify a pinned certificate on the QUIC carrier
A node carrying `pin` was refused rather than carried without verification — honest, and unusable. It now works on both carriers, and the refusal narrows to the case that genuinely is not implemented: chain verification against an `sni` name, which needs Android's trust store fed into the QUIC stack's TLS backend and is a different order of work (D-20 splits the two for exactly this reason). The comparison happens in the bridge, inside a BoringSSL custom verify callback, against the SHA-256 of the leaf's DER encoding — the same value `pin=` carries and the TLS carrier already compares. Constant time through CRYPTO_memcmp: a short-circuiting comparison leaks how many leading bytes matched, and that a forger would need a fresh handshake per byte makes it slow rather than impossible. The chain is deliberately not consulted, because a pinned certificate is commonly self-signed and checking the chain first would reject the very certificate the pin names — the TLS carrier makes the same choice for the same reason. A pin crosses the JNI boundary as 32 bytes, and a wrong length is refused at both ends rather than padded or truncated: either would produce a connection verifying against something nobody asked for. One conversion from hex serves both carriers, so they cannot disagree about what a pin is, and a pin built by hand out of something that is not hex throws instead of translating quietly. The device case reads the pin off the Portal's **own leaf over an independent TLS connection** and hands it back, so a pass cannot be the client agreeing with itself; it also establishes that this Portal serves one certificate on both listeners, which was not previously known. Verified to fail: with the callback not installed, `oneWrongNibbleStopsTheHandshake` reports "a QUIC connection accepted a certificate the pin does not name". Two diagnostics came out of getting this wrong first. A refused certificate ends the handshake without ending the wait, so the failure arrived as a bare "the peer did not complete the QUIC handshake in time" — it now carries whatever the bridge last said, including the digest actually presented, because a mismatch has two very different causes and they are indistinguishable without it. And e2e-fakeip.sh published only TCP, so every QUIC connection to that Portal timed out rather than being refused; a round of this work was spent debugging that as a verification defect before the port mapping was the answer.
1 parent e81721e commit ff8b3a8

11 files changed

Lines changed: 394 additions & 30 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.quic
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import eu.nodepass.somewhere.vpn.E2eEnvironment
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertNotNull
10+
import org.junit.Assert.assertTrue
11+
import org.junit.Test
12+
import org.junit.runner.RunWith
13+
import java.net.InetSocketAddress
14+
import java.net.Socket
15+
import java.security.MessageDigest
16+
import java.security.cert.X509Certificate
17+
import javax.net.ssl.SSLContext
18+
import javax.net.ssl.SSLSocket
19+
import javax.net.ssl.X509TrustManager
20+
21+
/**
22+
* `pin` on the QUIC carrier, checked against the certificate a real Portal
23+
* actually presents.
24+
*
25+
* ## Why the pin is read rather than written down
26+
*
27+
* A test that pins a value it also configured proves that the client agrees
28+
* with itself. So the pin here is read off the Portal's own leaf over an
29+
* **independent TLS connection** — a different socket, a different protocol, a
30+
* different code path from the one under test — and only then handed back as
31+
* `pin=`. The same shape the TLS carrier's own test uses, for the same reason.
32+
*
33+
* ## The control is the finding
34+
*
35+
* A matching pin completing the handshake proves nothing on its own: the
36+
* connection would have completed anyway, because a QUIC connection with no pin
37+
* verifies nothing at all, which is upstream's behaviour and this client's
38+
* default. What has to be true is the other half — that one wrong nibble stops
39+
* it. Both are here, and the second is the one worth reading.
40+
*/
41+
@RunWith(AndroidJUnit4::class)
42+
class QuicPinTest {
43+
@Test
44+
fun aPinReadFromThePortalsOwnCertificateCompletesTheHandshake() {
45+
val portal = E2eEnvironment.requirePortal()
46+
val pin = leafDigest(portal)
47+
connect(portal, pin).use { connection ->
48+
connection.completeHandshake()
49+
assertTrue("a correct pin refused the Portal's own certificate", connection.handshakeCompleted)
50+
}
51+
}
52+
53+
@Test
54+
fun oneWrongNibbleStopsTheHandshake() {
55+
// The whole test. Everything else here would pass with verification
56+
// switched off entirely.
57+
val portal = E2eEnvironment.requirePortal()
58+
val pin = leafDigest(portal).copyOf()
59+
pin[0] = (pin[0].toInt() xor 0x01).toByte()
60+
61+
val failure =
62+
runCatching {
63+
connect(portal, pin).use { it.completeHandshake() }
64+
}.exceptionOrNull()
65+
66+
assertNotNull("a QUIC connection accepted a certificate the pin does not name", failure)
67+
}
68+
69+
@Test
70+
fun aPinOfTheWrongLengthIsRefusedBeforeAnySocketIsOpened() {
71+
// Neither padded nor truncated. Either would produce a connection
72+
// verifying against something nobody asked for, which is worse than
73+
// not connecting.
74+
val portal = E2eEnvironment.requirePortal()
75+
val refused =
76+
runCatching { connect(portal, ByteArray(16)) }.exceptionOrNull()
77+
assertTrue(
78+
"a 16-byte pin should be refused at the boundary, not carried",
79+
refused is IllegalArgumentException,
80+
)
81+
}
82+
83+
@Test
84+
fun aPinIsThirtyTwoBytesBecauseItIsASha256() {
85+
assertEquals(32, QuicConnection.PIN_LENGTH)
86+
}
87+
88+
/**
89+
* The SHA-256 of the leaf the Portal presents, over its TLS listener.
90+
*
91+
* The Portal serves both carriers from one certificate; if that ever stops
92+
* being true this test says so by failing, which is worth knowing.
93+
*/
94+
private fun leafDigest(portal: String): ByteArray {
95+
val host = portal.substringBeforeLast(':')
96+
val port = portal.substringAfterLast(':').toInt()
97+
val context =
98+
SSLContext.getInstance("TLS").apply {
99+
init(null, arrayOf(AcceptAnything), java.security.SecureRandom())
100+
}
101+
val raw = Socket()
102+
raw.connect(InetSocketAddress(host, port), PROBE_TIMEOUT_MILLIS)
103+
val socket = context.socketFactory.createSocket(raw, host, port, true) as SSLSocket
104+
socket.soTimeout = PROBE_TIMEOUT_MILLIS
105+
return socket.use {
106+
it.startHandshake()
107+
val leaf = it.session.peerCertificates.first() as X509Certificate
108+
MessageDigest.getInstance("SHA-256").digest(leaf.encoded)
109+
}
110+
}
111+
112+
private fun connect(
113+
portal: String,
114+
pin: ByteArray?,
115+
): QuicConnection {
116+
val host = portal.substringBeforeLast(':')
117+
val port = portal.substringAfterLast(':').toInt()
118+
return QuicConnection.open(
119+
remote = InetSocketAddress(host, port),
120+
alpn = ALPN,
121+
serverName = null,
122+
pinSha256 = pin,
123+
// No tunnel is up in this test, so there is nothing to protect
124+
// against.
125+
protect = { true },
126+
)
127+
}
128+
129+
/** Reads the certificate without judging it; the pin is the judgement. */
130+
private object AcceptAnything : X509TrustManager {
131+
override fun checkClientTrusted(
132+
chain: Array<X509Certificate>,
133+
authType: String,
134+
) = Unit
135+
136+
override fun checkServerTrusted(
137+
chain: Array<X509Certificate>,
138+
authType: String,
139+
) = Unit
140+
141+
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
142+
}
143+
144+
private companion object {
145+
const val ALPN = "now/1"
146+
const val PROBE_TIMEOUT_MILLIS = 10_000
147+
}
148+
}

‎app/src/main/jni/quic/quic_conn_jni.c‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@
5454
#include <openssl/ssl.h>
5555
#include <openssl/rand.h>
5656
#include <openssl/err.h>
57+
#include <openssl/sha.h>
58+
#include <openssl/mem.h>
59+
#include <openssl/pool.h>
5760

5861
// Refusals that are this bridge's own, kept clear of ngtcp2's error space,
5962
// which is negative and small.
@@ -127,6 +130,16 @@ struct quic_conn {
127130
ngtcp2_ccerr last_error;
128131
char message[192];
129132

133+
// The certificate this connection will accept, and nothing else.
134+
//
135+
// SHA-256 of the leaf's DER encoding, which is what a `pin=` parameter
136+
// carries and what the TLS carrier already compares. Absent is the ordinary
137+
// case and means no verification at all -- upstream's own behaviour when a
138+
// node names neither `sni` nor `pin`, and what every URL current dashboards
139+
// emit produces.
140+
uint8_t pin[SHA256_DIGEST_LENGTH];
141+
int has_pin;
142+
130143
// Datagrams waiting to go out, and ones that arrived. Both are FIFO, kept
131144
// as head/tail so a busy flow does not walk the list to append.
132145
struct quic_datagram *tx_head, *tx_tail;
@@ -450,6 +463,62 @@ static int fill_addr(struct sockaddr_storage *out, socklen_t *outlen,
450463
return -1;
451464
}
452465

466+
// Accepts exactly one certificate: the one whose DER encoding hashes to the pin.
467+
//
468+
// The chain is deliberately not consulted. A pinned certificate is commonly
469+
// self-signed, and checking the chain first would reject the very certificate
470+
// the pin names -- the TLS carrier makes the same choice for the same reason.
471+
//
472+
// Constant time, through CRYPTO_memcmp. A short-circuiting comparison leaks how
473+
// many leading bytes matched, which is enough to find a matching prefix one byte
474+
// at a time; that it would take a new handshake per byte makes it slow, not
475+
// impossible.
476+
static enum ssl_verify_result_t verify_pin(SSL *ssl, uint8_t *out_alert) {
477+
ngtcp2_crypto_conn_ref *ref;
478+
struct quic_conn *c;
479+
const STACK_OF(CRYPTO_BUFFER) *chain;
480+
const CRYPTO_BUFFER *leaf;
481+
uint8_t digest[SHA256_DIGEST_LENGTH];
482+
483+
ref = (ngtcp2_crypto_conn_ref *)SSL_get_app_data(ssl);
484+
c = ref ? (struct quic_conn *)ref->user_data : NULL;
485+
if (!c || !c->has_pin) {
486+
// Installed without a pin to compare against. Refusing is the only safe
487+
// answer: this callback exists because verification was asked for.
488+
if (c) {
489+
say(c, "the verification callback could not find the pin it was installed for");
490+
}
491+
*out_alert = SSL_AD_INTERNAL_ERROR;
492+
return ssl_verify_invalid;
493+
}
494+
495+
chain = SSL_get0_peer_certificates(ssl);
496+
if (!chain || sk_CRYPTO_BUFFER_num(chain) == 0) {
497+
say(c, "the peer presented no certificate to pin against");
498+
*out_alert = SSL_AD_BAD_CERTIFICATE;
499+
return ssl_verify_invalid;
500+
}
501+
502+
leaf = sk_CRYPTO_BUFFER_value(chain, 0);
503+
SHA256(CRYPTO_BUFFER_data(leaf), CRYPTO_BUFFER_len(leaf), digest);
504+
if (CRYPTO_memcmp(digest, c->pin, sizeof(digest)) != 0) {
505+
// The presented digest goes into the message. A mismatch has two very
506+
// different causes -- a wrong `pin=`, or a Portal serving a different
507+
// certificate on this listener than on the one the pin was taken from
508+
// -- and they are indistinguishable without it.
509+
char hex[2 * SHA256_DIGEST_LENGTH + 1];
510+
size_t i;
511+
for (i = 0; i < SHA256_DIGEST_LENGTH; i++) {
512+
snprintf(hex + i * 2, 3, "%02x", digest[i]);
513+
}
514+
snprintf(c->message, sizeof(c->message),
515+
"the certificate does not match the pin; it presented %s", hex);
516+
*out_alert = SSL_AD_BAD_CERTIFICATE;
517+
return ssl_verify_invalid;
518+
}
519+
return ssl_verify_ok;
520+
}
521+
453522
static int ssl_init(struct quic_conn *c, const char *server_name,
454523
const uint8_t *alpn, size_t alpnlen) {
455524
uint8_t wire[256];
@@ -492,6 +561,13 @@ static int ssl_init(struct quic_conn *c, const char *server_name,
492561
if (server_name && server_name[0] != '\0') {
493562
SSL_set_tlsext_host_name(c->ssl, server_name);
494563
}
564+
565+
// Only when a pin was given. Without one this stays at BoringSSL's client
566+
// default, which verifies nothing -- the state every node that names
567+
// neither `sni` nor `pin` is in, and the one upstream is in too.
568+
if (c->has_pin) {
569+
SSL_set_custom_verify(c->ssl, SSL_VERIFY_PEER, verify_pin);
570+
}
495571
return 0;
496572
}
497573

@@ -614,11 +690,11 @@ JNIEXPORT jlong JNICALL
614690
Java_eu_nodepass_somewhere_quic_QuicConnection_nativeOpen(
615691
JNIEnv *env, jclass clazz, jbyteArray local_ip, jint local_port,
616692
jbyteArray remote_ip, jint remote_port, jstring server_name,
617-
jbyteArray alpn) {
693+
jbyteArray alpn, jbyteArray pin) {
618694
(void)clazz;
619695
struct quic_conn *c;
620696
jbyte local_buf[16], remote_buf[16], alpn_buf[255];
621-
jsize local_len, remote_len, alpn_len;
697+
jsize local_len, remote_len, alpn_len, pin_len = 0;
622698
const char *name = NULL;
623699
int ok;
624700

@@ -630,6 +706,15 @@ Java_eu_nodepass_somewhere_quic_QuicConnection_nativeOpen(
630706
alpn_len > 255) {
631707
return 0;
632708
}
709+
// A pin of the wrong length is refused here rather than truncated or
710+
// padded. Either would produce a connection that verifies against
711+
// something nobody asked for, which is worse than not connecting.
712+
if (pin) {
713+
pin_len = (*env)->GetArrayLength(env, pin);
714+
if (pin_len != SHA256_DIGEST_LENGTH) {
715+
return 0;
716+
}
717+
}
633718
(*env)->GetByteArrayRegion(env, local_ip, 0, local_len, local_buf);
634719
(*env)->GetByteArrayRegion(env, remote_ip, 0, remote_len, remote_buf);
635720
(*env)->GetByteArrayRegion(env, alpn, 0, alpn_len, alpn_buf);
@@ -643,6 +728,11 @@ Java_eu_nodepass_somewhere_quic_QuicConnection_nativeOpen(
643728
c->conn_ref.user_data = c;
644729
ngtcp2_ccerr_default(&c->last_error);
645730

731+
if (pin_len == SHA256_DIGEST_LENGTH) {
732+
(*env)->GetByteArrayRegion(env, pin, 0, pin_len, (jbyte *)c->pin);
733+
c->has_pin = 1;
734+
}
735+
646736
if (fill_addr(&c->local_addr, &c->local_addrlen, (uint8_t *)local_buf,
647737
(size_t)local_len, local_port) != 0 ||
648738
fill_addr(&c->remote_addr, &c->remote_addrlen, (uint8_t *)remote_buf,

‎app/src/main/kotlin/eu/nodepass/somewhere/protocol/url/NowhereUrl.kt‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,33 @@ sealed interface CertificateVerification {
5252
*/
5353
data class Pin(
5454
val sha256: String,
55-
) : CertificateVerification
55+
) : CertificateVerification {
56+
/**
57+
* The same value as bytes, for the carriers that compare it there.
58+
*
59+
* The TLS path compares hex strings because it has a certificate object
60+
* to hash; the QUIC path hands 32 bytes across a JNI boundary to be
61+
* compared in C. One conversion, here, so the two cannot disagree about
62+
* what a pin is.
63+
*/
64+
val bytes: ByteArray
65+
get() =
66+
ByteArray(sha256.length / 2) { index ->
67+
((digit(sha256[index * 2]) shl 4) or digit(sha256[index * 2 + 1])).toByte()
68+
}
69+
70+
private fun digit(character: Char): Int =
71+
when (character) {
72+
in '0'..'9' -> character - '0'
73+
in 'a'..'f' -> character - 'a' + 10
74+
in 'A'..'F' -> character - 'A' + 10
75+
// Unreachable through the parser, which accepts lower-case hex
76+
// of exactly the right length and nothing else. Stated rather
77+
// than assumed, because a pin built by hand and silently
78+
// mistranslated would verify against an arbitrary digest.
79+
else -> throw IllegalStateException("a pin holds hex and nothing else")
80+
}
81+
}
5682

5783
val isVerified: Boolean get() = this !is Skipped
5884
}

0 commit comments

Comments
 (0)