Skip to content

Commit 04acb1a

Browse files
committed
Reuse PQ key pairs
1 parent 77da353 commit 04acb1a

3 files changed

Lines changed: 107 additions & 21 deletions

File tree

dnscrypt-proxy/pq.go

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,23 +131,26 @@ func pqEncapsulate(pk []byte) (kemSS, ct []byte, err error) {
131131
return xwing.Encapsulate(pk, nil)
132132
}
133133

134-
// pqResumptionState holds the most recent resumption ticket for a server.
135-
type pqResumptionState struct {
134+
// pqSessionState holds a server's reusable PQ key material.
135+
type pqSessionState struct {
136136
mu sync.Mutex
137137
ticket []byte
138138
resumeSecret [32]byte
139139
expiry time.Time
140140
epoch uint64
141+
encap []byte
142+
encapKey [32]byte
143+
encapEpoch uint64
141144
}
142145

143-
func newPqResumptionState(c CryptoConstruction) *pqResumptionState {
146+
func newPqSessionState(c CryptoConstruction) *pqSessionState {
144147
if c != XWingPQ {
145148
return nil
146149
}
147-
return &pqResumptionState{}
150+
return &pqSessionState{}
148151
}
149152

150-
func (s *pqResumptionState) store(ticket []byte, resumeSecret [32]byte, expiry time.Time, epoch uint64) {
153+
func (s *pqSessionState) store(ticket []byte, resumeSecret [32]byte, expiry time.Time, epoch uint64) {
151154
if s == nil {
152155
return
153156
}
@@ -159,7 +162,7 @@ func (s *pqResumptionState) store(ticket []byte, resumeSecret [32]byte, expiry t
159162
s.epoch = epoch
160163
}
161164

162-
func (s *pqResumptionState) get(currentEpoch uint64) (ticket []byte, resumeSecret [32]byte, ok bool) {
165+
func (s *pqSessionState) get(currentEpoch uint64) (ticket []byte, resumeSecret [32]byte, ok bool) {
163166
if s == nil {
164167
return nil, [32]byte{}, false
165168
}
@@ -177,8 +180,37 @@ func (s *pqResumptionState) get(currentEpoch uint64) (ticket []byte, resumeSecre
177180
return append([]byte(nil), s.ticket...), s.resumeSecret, true
178181
}
179182

183+
func (s *pqSessionState) getCachedEncapsulation(currentEpoch uint64) (ct []byte, key [32]byte, ok bool) {
184+
if s == nil {
185+
return nil, [32]byte{}, false
186+
}
187+
s.mu.Lock()
188+
defer s.mu.Unlock()
189+
if s.encap == nil {
190+
return nil, [32]byte{}, false
191+
}
192+
if s.encapEpoch != currentEpoch {
193+
s.encap = nil
194+
s.encapKey = [32]byte{}
195+
return nil, [32]byte{}, false
196+
}
197+
return append([]byte(nil), s.encap...), s.encapKey, true
198+
}
199+
200+
func (s *pqSessionState) storeEncapsulation(ct []byte, key [32]byte, epoch uint64) {
201+
if s == nil {
202+
return
203+
}
204+
s.mu.Lock()
205+
defer s.mu.Unlock()
206+
s.encap = append([]byte(nil), ct...)
207+
s.encapKey = key
208+
s.encapEpoch = epoch
209+
}
210+
180211
// encryptPQ builds a PQ query: a resumed query when a valid ticket is held,
181-
// otherwise a query that carries a fresh X-Wing ciphertext.
212+
// otherwise a query that carries an X-Wing ciphertext, reusing the cached
213+
// encapsulation when one is available for the current network epoch.
182214
func (proxy *Proxy) encryptPQ(
183215
serverInfo *ServerInfo,
184216
packet []byte,
@@ -192,7 +224,7 @@ func (proxy *Proxy) encryptPQ(
192224
}
193225
copy(nonce, clientNonce)
194226

195-
if ticket, resumeSecret, ok := serverInfo.pqResumption.get(queryEpoch); ok {
227+
if ticket, resumeSecret, ok := serverInfo.pqSession.get(queryEpoch); ok {
196228
key := pqResumedSharedKey(resumeSecret, serverInfo.MagicQuery, clientNonce, ticket)
197229
padded := pqPad(packet, 256)
198230
ct := xsecretbox.Seal(nil, nonce, padded, key[:])
@@ -207,11 +239,15 @@ func (proxy *Proxy) encryptPQ(
207239
return &key, out, clientNonce, queryEpoch, nil
208240
}
209241

210-
kemSS, ctKem, err := pqEncapsulate(serverInfo.PqPublicKey)
211-
if err != nil {
212-
return nil, nil, nil, queryEpoch, err
242+
ctKem, key, ok := serverInfo.pqSession.getCachedEncapsulation(queryEpoch)
243+
if !ok {
244+
var kemSS []byte
245+
if kemSS, ctKem, err = pqEncapsulate(serverInfo.PqPublicKey); err != nil {
246+
return nil, nil, nil, queryEpoch, err
247+
}
248+
key = pqDeriveSharedKey(kemSS, serverInfo.MagicQuery, serverInfo.PqCertContext, ctKem)
249+
serverInfo.pqSession.storeEncapsulation(ctKem, key, queryEpoch)
213250
}
214-
key := pqDeriveSharedKey(kemSS, serverInfo.MagicQuery, serverInfo.PqCertContext, ctKem)
215251
padded := pqPad(packet, 64)
216252
ct := xsecretbox.Seal(nil, nonce, padded, key[:])
217253
out := make([]byte, 0, PQClientMagicLen+len(ctKem)+HalfNonceSize+len(ct))
@@ -269,6 +305,6 @@ func (proxy *Proxy) pqProcessControl(
269305
}
270306
resumeSecret := pqResumeSecret(*sharedKey, serverInfo.MagicQuery, clientNonce)
271307
expiry := time.Now().Add(time.Duration(lifetime) * time.Second)
272-
serverInfo.pqResumption.store(ticket, resumeSecret, expiry, queryEpoch)
308+
serverInfo.pqSession.store(ticket, resumeSecret, expiry, queryEpoch)
273309
dlog.Debugf("[%v] stored a PQ resumption ticket (lifetime %ds)", serverInfo.Name, lifetime)
274310
}

dnscrypt-proxy/pq_test.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bytes"
45
"crypto/sha256"
56
"encoding/binary"
67
"encoding/hex"
@@ -117,8 +118,8 @@ func TestPQAppendix3Vectors(t *testing.T) {
117118
}
118119
}
119120

120-
func TestPQResumptionStateEpoch(t *testing.T) {
121-
state := newPqResumptionState(XWingPQ)
121+
func TestPQSessionResumptionEpoch(t *testing.T) {
122+
state := newPqSessionState(XWingPQ)
122123
ticket := []byte("ticket")
123124
var resumeSecret [32]byte
124125
resumeSecret[0] = 42
@@ -142,25 +143,74 @@ func TestPQResumptionStateEpoch(t *testing.T) {
142143
}
143144
}
144145

146+
// TestEncryptPQReusesEncapsulation checks that post-quantum queries reuse a
147+
// single X-Wing key exchange instead of running a fresh one every time, and
148+
// that a network change forces a new one.
149+
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+
}
164+
query := mustHex("12340100000100000000000003777777076578616d706c6503636f6d0000010001")
165+
166+
ctOf := func(out []byte) []byte {
167+
return out[PQClientMagicLen : PQClientMagicLen+PQXWingCiphertextSize]
168+
}
169+
170+
key1, out1, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
171+
if err != nil {
172+
t.Fatal(err)
173+
}
174+
key2, out2, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
175+
if err != nil {
176+
t.Fatal(err)
177+
}
178+
if !bytes.Equal(ctOf(out1), ctOf(out2)) {
179+
t.Fatal("expected the X-Wing ciphertext to be reused across queries")
180+
}
181+
if *key1 != *key2 {
182+
t.Fatal("expected the shared key to be reused across queries")
183+
}
184+
185+
monitor.epochValue.Store(2)
186+
_, out3, _, _, err := proxy.encryptPQ(serverInfo, query, "udp")
187+
if err != nil {
188+
t.Fatal(err)
189+
}
190+
if bytes.Equal(ctOf(out1), ctOf(out3)) {
191+
t.Fatal("expected a new key exchange after a network change")
192+
}
193+
}
194+
145195
func TestPQProcessControlDiscardsTicketAfterNetworkChange(t *testing.T) {
146196
monitor := newNetworkMonitor()
147197
monitor.epochValue.Store(2)
148198
proxy := &Proxy{netMonitor: monitor}
149199
serverInfo := &ServerInfo{
150-
Name: "test",
151-
pqResumption: newPqResumptionState(XWingPQ),
200+
Name: "test",
201+
pqSession: newPqSessionState(XWingPQ),
152202
}
153203
sharedKey := &[32]byte{1, 2, 3}
154204
clientNonce := []byte("abcdefghijkl")
155205
control := buildTestPQControl([]byte("ticket"), 60)
156206

157207
proxy.pqProcessControl(serverInfo, sharedKey, clientNonce, control, 1)
158-
if _, _, ok := serverInfo.pqResumption.get(2); ok {
208+
if _, _, ok := serverInfo.pqSession.get(2); ok {
159209
t.Fatal("expected ticket from old epoch to be discarded")
160210
}
161211

162212
proxy.pqProcessControl(serverInfo, sharedKey, clientNonce, control, 2)
163-
if ticket, _, ok := serverInfo.pqResumption.get(2); !ok || string(ticket) != "ticket" {
213+
if ticket, _, ok := serverInfo.pqSession.get(2); !ok || string(ticket) != "ticket" {
164214
t.Fatal("expected ticket to be stored when query epoch still matches")
165215
}
166216
}

dnscrypt-proxy/serversInfo.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ type ServerInfo struct {
6262
MagicQuery [8]byte
6363
PqPublicKey []byte
6464
PqCertContext []byte
65-
pqResumption *pqResumptionState
65+
pqSession *pqSessionState
6666
knownBugs ServerBugs
6767
Proto stamps.StampProtoType
6868
useGet bool
@@ -914,7 +914,7 @@ func fetchDNSCryptServerInfo(proxy *Proxy, name string, stamp stamps.ServerStamp
914914
CryptoConstruction: certInfo.CryptoConstruction,
915915
PqPublicKey: certInfo.PqPublicKey,
916916
PqCertContext: certInfo.PqCertContext,
917-
pqResumption: newPqResumptionState(certInfo.CryptoConstruction),
917+
pqSession: newPqSessionState(certInfo.CryptoConstruction),
918918
Name: name,
919919
Timeout: proxy.timeout,
920920
UDPAddr: remoteUDPAddr,

0 commit comments

Comments
 (0)