Skip to content

Commit 7532398

Browse files
committed
PQ: pad resumed UDP queries to the regular question size target
Also discard oversized tickets and fall back to TCP when a query cannot fit a datagram.
1 parent 8a3dde1 commit 7532398

3 files changed

Lines changed: 156 additions & 32 deletions

File tree

dnscrypt-proxy/crypto.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ func (proxy *Proxy) Encrypt(
146146
proto string,
147147
) (sharedKey *[32]byte, encrypted []byte, clientNonce []byte, queryEpoch uint64, err error) {
148148
if serverInfo.CryptoConstruction == XWingPQ {
149-
return proxy.encryptPQ(serverInfo, packet, proto)
149+
sharedKey, encrypted, clientNonce, queryEpoch, err = proxy.encryptPQ(serverInfo, packet, proto)
150+
if err == nil && proto == "udp" && len(encrypted) > pqUDPPacketLimit(serverInfo) {
151+
err = errors.New("Question too large; cannot be padded")
152+
}
153+
return sharedKey, encrypted, clientNonce, queryEpoch, err
150154
}
151155
nonce, clientNonce := make([]byte, NonceSize), make([]byte, HalfNonceSize)
152156
if _, err := crypto_rand.Read(clientNonce); err != nil {

dnscrypt-proxy/pq.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const (
2525
PQExtVersion = 0x01
2626
PQKdfID = 0x01
2727
PQAeadID = 0x01
28+
PQResumedPaddingFloor = 256
2829
)
2930

3031
var (
@@ -113,16 +114,13 @@ func pqResumedSharedKey(resumeSecret [32]byte, clientMagic [8]byte, clientNonce,
113114
// pqPad applies ISO/IEC 7816-4 padding to the next multiple of 64, with a
114115
// minimum floor (itself a multiple of 64).
115116
func pqPad(packet []byte, floor int) []byte {
116-
padded := make([]byte, len(packet), len(packet)+64)
117-
copy(padded, packet)
118-
padded = append(padded, 0x80)
119-
target := (len(padded) + 63) &^ 63
117+
target := (len(packet) + 1 + 63) &^ 63
120118
if target < floor {
121119
target = floor
122120
}
123-
for len(padded) < target {
124-
padded = append(padded, 0)
125-
}
121+
padded := make([]byte, target)
122+
copy(padded, packet)
123+
padded[len(packet)] = 0x80
126124
return padded
127125
}
128126

@@ -208,13 +206,41 @@ func (s *pqSessionState) storeEncapsulation(ct []byte, key [32]byte, epoch uint6
208206
s.encapEpoch = epoch
209207
}
210208

209+
// pqResumedOverhead is the wire size of a resumed query around its encrypted
210+
// padded payload.
211+
func pqResumedOverhead(ticketLen int) int {
212+
return len(PQResumeMagic) + 2 + ticketLen + HalfNonceSize + TagSize
213+
}
214+
215+
// pqUDPPacketLimit is the largest UDP datagram worth sending to this server,
216+
// honoring the same fragmentation workaround as the classic path.
217+
func pqUDPPacketLimit(serverInfo *ServerInfo) int {
218+
if serverInfo.knownBugs.fragmentsBlocked {
219+
return MaxDNSUDPSafePacketSize
220+
}
221+
return MaxDNSUDPPacketSize
222+
}
223+
224+
// pqResumedPaddingFloor returns the padding floor for a resumed query. Over
225+
// UDP, the encrypted response can never be larger than the query, so the
226+
// query is grown to the regular UDP question size target, leaving the
227+
// resolver the same response budget as a classic query.
228+
func (proxy *Proxy) pqResumedPaddingFloor(serverInfo *ServerInfo, ticketLen int, proto string) int {
229+
if proto != "udp" {
230+
return PQResumedPaddingFloor
231+
}
232+
overhead := pqResumedOverhead(ticketLen)
233+
want := proxy.questionSizeEstimator.MinQuestionSize() - overhead
234+
return Max(PQResumedPaddingFloor, Min((want+63)&^63, (pqUDPPacketLimit(serverInfo)-overhead)&^63))
235+
}
236+
211237
// encryptPQ builds a PQ query: a resumed query when a valid ticket is held,
212238
// otherwise a query that carries an X-Wing ciphertext, reusing the cached
213239
// encapsulation when one is available for the current network epoch.
214240
func (proxy *Proxy) encryptPQ(
215241
serverInfo *ServerInfo,
216242
packet []byte,
217-
_ string,
243+
proto string,
218244
) (sharedKey *[32]byte, encrypted []byte, clientNonce []byte, queryEpoch uint64, err error) {
219245
queryEpoch = proxy.networkEpoch()
220246
nonce := make([]byte, NonceSize)
@@ -226,7 +252,7 @@ func (proxy *Proxy) encryptPQ(
226252

227253
if ticket, resumeSecret, ok := serverInfo.pqSession.get(queryEpoch); ok {
228254
key := pqResumedSharedKey(resumeSecret, serverInfo.MagicQuery, clientNonce, ticket)
229-
padded := pqPad(packet, 256)
255+
padded := pqPad(packet, proxy.pqResumedPaddingFloor(serverInfo, len(ticket), proto))
230256
ct := xsecretbox.Seal(nil, nonce, padded, key[:])
231257
out := make([]byte, 0, len(PQResumeMagic)+2+len(ticket)+HalfNonceSize+len(ct))
232258
out = append(out, PQResumeMagic[:]...)
@@ -299,6 +325,10 @@ func (proxy *Proxy) pqProcessControl(
299325
return
300326
}
301327
ticket := control[11 : 11+ticketLen]
328+
if pqResumedOverhead(len(ticket))+PQResumedPaddingFloor > MaxDNSUDPPacketSize {
329+
dlog.Debugf("[%v] discarded an oversized PQ resumption ticket (%d bytes)", serverInfo.Name, len(ticket))
330+
return
331+
}
302332
if queryEpoch != proxy.networkEpoch() {
303333
dlog.Debugf("[%v] discarded a PQ resumption ticket after a network change", serverInfo.Name)
304334
return

dnscrypt-proxy/pq_test.go

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,33 @@ func mustHex(s string) []byte {
2828
return b
2929
}
3030

31+
func newTestPQProxy(epoch uint64) *Proxy {
32+
monitor := newNetworkMonitor()
33+
monitor.epochValue.Store(epoch)
34+
proxy := &Proxy{netMonitor: monitor}
35+
proxy.questionSizeEstimator = NewQuestionSizeEstimator()
36+
return proxy
37+
}
38+
39+
func newTestPQServerInfo() *ServerInfo {
40+
_, pk := xwing.DeriveKeyPair(iotaBytes(32, 0x20))
41+
pkb, _ := pk.MarshalBinary()
42+
return &ServerInfo{
43+
Name: "test",
44+
CryptoConstruction: XWingPQ,
45+
PqPublicKey: pkb,
46+
PqCertContext: iotaBytes(48, 0x01),
47+
MagicQuery: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
48+
pqSession: newPqSessionState(XWingPQ),
49+
}
50+
}
51+
52+
// maxStorableTicketLen is the largest resumption ticket pqProcessControl
53+
// accepts; one byte more and a resumed query cannot fit a UDP datagram.
54+
func maxStorableTicketLen() int {
55+
return MaxDNSUDPPacketSize - PQResumedPaddingFloor - pqResumedOverhead(0)
56+
}
57+
3158
// TestPQAppendix3Vectors checks the client's PQ crypto against the pinned
3259
// values of Appendix 3 of the draft, the same anchor the server validates.
3360
func TestPQAppendix3Vectors(t *testing.T) {
@@ -147,20 +174,8 @@ func TestPQSessionResumptionEpoch(t *testing.T) {
147174
// single X-Wing key exchange instead of running a fresh one every time, and
148175
// that a network change forces a new one.
149176
func TestEncryptPQReusesEncapsulation(t *testing.T) {
150-
monitor := newNetworkMonitor()
151-
monitor.epochValue.Store(1)
152-
proxy := &Proxy{netMonitor: monitor}
153-
154-
_, pk := xwing.DeriveKeyPair(iotaBytes(32, 0x20))
155-
pkb, _ := pk.MarshalBinary()
156-
serverInfo := &ServerInfo{
157-
Name: "test",
158-
CryptoConstruction: XWingPQ,
159-
PqPublicKey: pkb,
160-
PqCertContext: iotaBytes(48, 0x01),
161-
MagicQuery: [8]byte{1, 2, 3, 4, 5, 6, 7, 8},
162-
pqSession: newPqSessionState(XWingPQ),
163-
}
177+
proxy := newTestPQProxy(1)
178+
serverInfo := newTestPQServerInfo()
164179
query := mustHex("12340100000100000000000003777777076578616d706c6503636f6d0000010001")
165180

166181
ctOf := func(out []byte) []byte {
@@ -182,7 +197,7 @@ func TestEncryptPQReusesEncapsulation(t *testing.T) {
182197
t.Fatal("expected the shared key to be reused across queries")
183198
}
184199

185-
monitor.epochValue.Store(2)
200+
proxy.netMonitor.epochValue.Store(2)
186201
_, out3, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
187202
if err != nil {
188203
t.Fatal(err)
@@ -192,14 +207,54 @@ func TestEncryptPQReusesEncapsulation(t *testing.T) {
192207
}
193208
}
194209

195-
func TestPQProcessControlDiscardsTicketAfterNetworkChange(t *testing.T) {
196-
monitor := newNetworkMonitor()
197-
monitor.epochValue.Store(2)
198-
proxy := &Proxy{netMonitor: monitor}
199-
serverInfo := &ServerInfo{
200-
Name: "test",
201-
pqSession: newPqSessionState(XWingPQ),
210+
// TestPQResumedQueryMeetsUDPSizeTarget checks that a resumed query sent over
211+
// UDP is padded up to the regular question size target, since the resolver
212+
// cannot send a UDP response larger than the query. Servers that cannot
213+
// receive fragmented datagrams keep the classic reduced cap, and over TCP
214+
// the smaller fixed floor is kept.
215+
func TestPQResumedQueryMeetsUDPSizeTarget(t *testing.T) {
216+
proxy := newTestPQProxy(1)
217+
serverInfo := newTestPQServerInfo()
218+
ticket := iotaBytes(130, 0x24)
219+
serverInfo.pqSession.store(ticket, [32]byte{42}, time.Now().Add(time.Minute), 1)
220+
query := mustHex("12340100000100000000000003777777076578616d706c6503636f6d0000010001")
221+
overhead := pqResumedOverhead(len(ticket))
222+
223+
_, udpOut, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
224+
if err != nil {
225+
t.Fatal(err)
226+
}
227+
if len(udpOut) < InitialMinQuestionSize {
228+
t.Fatalf("resumed UDP query is only %d bytes, below the %d response budget", len(udpOut), InitialMinQuestionSize)
229+
}
230+
if plaintextLen := len(udpOut) - overhead; plaintextLen%64 != 0 {
231+
t.Fatalf("resumed UDP query padding is not a multiple of 64: %d", plaintextLen)
232+
}
233+
234+
_, tcpOut, _, _, err := proxy.encryptPQ(serverInfo, query, "tcp")
235+
if err != nil {
236+
t.Fatal(err)
202237
}
238+
if expected := overhead + PQResumedPaddingFloor; len(tcpOut) != expected {
239+
t.Fatalf("resumed TCP query length %d, expected %d", len(tcpOut), expected)
240+
}
241+
242+
serverInfo.knownBugs.fragmentsBlocked = true
243+
for i := 0; i < 3; i++ {
244+
proxy.questionSizeEstimator.blindAdjust()
245+
}
246+
_, safeOut, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
247+
if err != nil {
248+
t.Fatal(err)
249+
}
250+
if len(safeOut) > MaxDNSUDPSafePacketSize {
251+
t.Fatalf("resumed UDP query is %d bytes despite the fragmentation workaround", len(safeOut))
252+
}
253+
}
254+
255+
func TestPQProcessControlDiscardsTicketAfterNetworkChange(t *testing.T) {
256+
proxy := newTestPQProxy(2)
257+
serverInfo := newTestPQServerInfo()
203258
sharedKey := &[32]byte{1, 2, 3}
204259
clientNonce := []byte("abcdefghijkl")
205260
control := buildTestPQControl([]byte("ticket"), 60)
@@ -215,6 +270,41 @@ func TestPQProcessControlDiscardsTicketAfterNetworkChange(t *testing.T) {
215270
}
216271
}
217272

273+
func TestPQQueryTooLargeForUDPFallsBackToTCP(t *testing.T) {
274+
proxy := newTestPQProxy(1)
275+
serverInfo := newTestPQServerInfo()
276+
277+
hugeQuery := iotaBytes(3000, 0x02)
278+
if _, _, _, _, err := proxy.Encrypt(serverInfo, hugeQuery, "udp"); err == nil {
279+
t.Fatal("expected an error for a full query that cannot fit in a UDP datagram")
280+
}
281+
if _, _, _, _, err := proxy.Encrypt(serverInfo, hugeQuery, "tcp"); err != nil {
282+
t.Fatal(err)
283+
}
284+
285+
ticket := iotaBytes(maxStorableTicketLen(), 0x01)
286+
serverInfo.pqSession.store(ticket, [32]byte{42}, time.Now().Add(time.Minute), 1)
287+
query := iotaBytes(300, 0x02)
288+
if _, _, _, _, err := proxy.Encrypt(serverInfo, query, "udp"); err == nil {
289+
t.Fatal("expected an error for a resumed query that cannot fit in a UDP datagram")
290+
}
291+
if _, _, _, _, err := proxy.Encrypt(serverInfo, query, "tcp"); err != nil {
292+
t.Fatal(err)
293+
}
294+
}
295+
296+
func TestPQProcessControlDiscardsOversizedTicket(t *testing.T) {
297+
proxy := newTestPQProxy(1)
298+
serverInfo := newTestPQServerInfo()
299+
oversized := iotaBytes(maxStorableTicketLen()+1, 0x01)
300+
control := buildTestPQControl(oversized, 60)
301+
302+
proxy.pqProcessControl(serverInfo, &[32]byte{1}, []byte("abcdefghijkl"), control, 1)
303+
if _, _, ok := serverInfo.pqSession.get(1); ok {
304+
t.Fatal("expected an oversized ticket to be discarded")
305+
}
306+
}
307+
218308
func buildTestPQControl(ticket []byte, lifetime uint32) []byte {
219309
control := append([]byte{}, PQControlMagic[:]...)
220310
control = append(control, PQControlVersion)

0 commit comments

Comments
 (0)