Skip to content
This repository was archived by the owner on Nov 17, 2025. It is now read-only.

Commit e6a2e67

Browse files
authored
Merge pull request #390 from matrix-org/kegan/fallback-key-types-fix
bugfix: correctly tell clients when the fallback key has been used
2 parents a8e9c56 + eae54fb commit e6a2e67

File tree

6 files changed

+326
-18
lines changed

6 files changed

+326
-18
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,12 @@ go test -p 1 -count 1 $(go list ./... | grep -v tests-e2e) -timeout 120s
205205
Run end-to-end tests:
206206

207207
```shell
208-
# Run each line in a separate terminal windows. Will need to `docker login`
209-
# to ghcr and pull the image.
210-
docker run --rm -e "SYNAPSE_COMPLEMENT_DATABASE=sqlite" -e "SERVER_NAME=synapse" -p 8888:8008 ghcr.io/matrix-org/synapse-service:v1.72.0
208+
# Will need to `docker login` to ghcr and pull the image.
209+
docker run -d --rm -e "SYNAPSE_COMPLEMENT_DATABASE=sqlite" -e "SERVER_NAME=synapse" -p 8888:8008 ghcr.io/matrix-org/synapse-service:v1.94.0
210+
211+
export SYNCV3_SECRET=foobar
212+
export SYNCV3_SERVER=http://localhost:8888
213+
export SYNCV3_DB="user=$(whoami) dbname=syncv3_test sslmode=disable"
214+
211215
(go build ./cmd/syncv3 && dropdb syncv3_test && createdb syncv3_test && cd tests-e2e && ./run-tests.sh -count=1 .)
212216
```

internal/device_data.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type DeviceData struct {
2626
OTKCounts MapStringInt `json:"otk"`
2727
// Contains the latest device_unused_fallback_key_types value
2828
// Set whenever this field arrives down the v2 poller, and it replaces what was previously there.
29+
// If this is a nil slice this means no change. If this is an empty slice then this means the fallback key was used up.
2930
FallbackKeyTypes []string `json:"fallback"`
3031

3132
DeviceLists DeviceLists `json:"dl"`

sync2/poller.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -727,20 +727,30 @@ func (p *poller) parseE2EEData(ctx context.Context, res *SyncResponse) error {
727727
}
728728
shouldSetOTKs = true
729729
}
730-
var changedFallbackTypes []string
730+
var changedFallbackTypes []string // nil slice == don't set, empty slice = no fallback key
731731
shouldSetFallbackKeys := false
732-
if len(res.DeviceUnusedFallbackKeyTypes) > 0 {
733-
if len(p.fallbackKeyTypes) != len(res.DeviceUnusedFallbackKeyTypes) {
734-
changedFallbackTypes = res.DeviceUnusedFallbackKeyTypes
735-
} else {
736-
for i := range res.DeviceUnusedFallbackKeyTypes {
737-
if res.DeviceUnusedFallbackKeyTypes[i] != p.fallbackKeyTypes[i] {
738-
changedFallbackTypes = res.DeviceUnusedFallbackKeyTypes
739-
break
740-
}
732+
if len(p.fallbackKeyTypes) != len(res.DeviceUnusedFallbackKeyTypes) {
733+
// length mismatch always causes an update
734+
changedFallbackTypes = res.DeviceUnusedFallbackKeyTypes
735+
shouldSetFallbackKeys = true
736+
} else {
737+
// lengths match, if they are non-zero then compare each element.
738+
// if they are zero, check for nil vs empty slice.
739+
if len(res.DeviceUnusedFallbackKeyTypes) == 0 {
740+
isCurrentNil := res.DeviceUnusedFallbackKeyTypes == nil
741+
isPreviousNil := p.fallbackKeyTypes == nil
742+
if isCurrentNil != isPreviousNil {
743+
shouldSetFallbackKeys = true
744+
changedFallbackTypes = []string{}
745+
}
746+
}
747+
for i := range res.DeviceUnusedFallbackKeyTypes {
748+
if res.DeviceUnusedFallbackKeyTypes[i] != p.fallbackKeyTypes[i] {
749+
changedFallbackTypes = res.DeviceUnusedFallbackKeyTypes
750+
shouldSetFallbackKeys = true
751+
break
741752
}
742753
}
743-
shouldSetFallbackKeys = true
744754
}
745755

746756
deviceListChanges := internal.ToDeviceListChangesMap(res.DeviceLists.Changed, res.DeviceLists.Left)

sync3/extensions/e2ee.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ func (r *E2EERequest) Name() string {
2525
type E2EEResponse struct {
2626
OTKCounts map[string]int `json:"device_one_time_keys_count,omitempty"`
2727
DeviceLists *E2EEDeviceList `json:"device_lists,omitempty"`
28-
FallbackKeyTypes []string `json:"device_unused_fallback_key_types,omitempty"`
28+
FallbackKeyTypes *[]string `json:"device_unused_fallback_key_types,omitempty"`
2929
}
3030

3131
type E2EEDeviceList struct {
@@ -37,7 +37,7 @@ func (r *E2EEResponse) HasData(isInitial bool) bool {
3737
if isInitial {
3838
return true // ensure we send OTK counts immediately
3939
}
40-
return r.DeviceLists != nil || len(r.FallbackKeyTypes) > 0 || len(r.OTKCounts) > 0
40+
return r.DeviceLists != nil || r.FallbackKeyTypes != nil || len(r.OTKCounts) > 0
4141
}
4242

4343
func (r *E2EERequest) AppendLive(ctx context.Context, res *Response, extCtx Context, up caches.Update) {
@@ -63,7 +63,7 @@ func (r *E2EERequest) ProcessInitial(ctx context.Context, res *Response, extCtx
6363
extRes := &E2EEResponse{}
6464
hasUpdates := false
6565
if dd.FallbackKeyTypes != nil && (dd.FallbackKeysChanged() || extCtx.IsInitial) {
66-
extRes.FallbackKeyTypes = dd.FallbackKeyTypes
66+
extRes.FallbackKeyTypes = &dd.FallbackKeyTypes
6767
hasUpdates = true
6868
}
6969
if dd.OTKCounts != nil && (dd.OTKCountChanged() || extCtx.IsInitial) {

tests-e2e/encryption_test.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package syncv3_test
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/matrix-org/complement/b"
9+
"github.com/matrix-org/complement/client"
10+
"github.com/matrix-org/sliding-sync/sync3"
11+
"github.com/matrix-org/sliding-sync/sync3/extensions"
12+
"github.com/matrix-org/sliding-sync/testutils/m"
13+
)
14+
15+
func TestEncryptionFallbackKey(t *testing.T) {
16+
alice := registerNewUser(t)
17+
bob := registerNewUser(t)
18+
roomID := alice.MustCreateRoom(t, map[string]interface{}{
19+
"preset": "public_chat",
20+
})
21+
bob.JoinRoom(t, roomID, nil)
22+
23+
// snaffled from rust SDK
24+
keysUploadBody := fmt.Sprintf(`{
25+
"device_keys": {
26+
"algorithms": [
27+
"m.olm.v1.curve25519-aes-sha2",
28+
"m.megolm.v1.aes-sha2"
29+
],
30+
"device_id": "MUPCQIATEC",
31+
"keys": {
32+
"curve25519:MUPCQIATEC": "NroPrV4HHJ/Wj0A0XMrHt7IuThVnwpT6tRZXQXkO4kI",
33+
"ed25519:MUPCQIATEC": "G9zNR/pZb24Rm0FXiQYutSzcbQvii+AZn/4cmi6LOUI"
34+
},
35+
"signatures": {
36+
"%s": {
37+
"ed25519:MUPCQIATEC": "2CHK2tJO/p2OiNWC2jLKsH5t+pHwnomSHOIpAPuEVi2vJZ4BRRsb4tSFYzEx4cUDg3KCYjoQuCymYHpnk1uqDQ"
38+
}
39+
},
40+
"user_id": "%s"
41+
},
42+
"fallback_keys": {
43+
"signed_curve25519:AAAAAAAAAAA": {
44+
"fallback": true,
45+
"key": "s5+eOJYK1s5xPt51BlYEXx8fQ8NqpwAUjE1mVxw05V8",
46+
"signatures": {
47+
"%s": {
48+
"ed25519:MUPCQIATEC": "TLGi0LJEDxgt37gBCpd8huZa72h0UTB8jIEUoTz/rjbCcGQo1xOlvA5rU+RoTkF1KwVtduOMbZcSGg4ZTfBkDQ"
49+
}
50+
}
51+
}
52+
},
53+
"one_time_keys": {
54+
"signed_curve25519:AAAAAAAAAA0": {
55+
"key": "IuCQvr2AaZC70tCG6g1ZardACNe3mcKZ2PjKJ2p49UM",
56+
"signatures": {
57+
"%s": {
58+
"ed25519:MUPCQIATEC": "FXBkzwuLkfriWJ1B2z9wTHvi7WTOZGvs2oSNJ7CycXJYC6k06sa7a+OMQtpMP2RTuIpiYC+wZ3nFoKp1FcCcBQ"
59+
}
60+
}
61+
},
62+
"signed_curve25519:AAAAAAAAAA4": {
63+
"key": "pgeLFCJPLYUtyLPKDPr76xRYgPjjY4/lEUH98tExxCo",
64+
"signatures": {
65+
"%s": {
66+
"ed25519:MUPCQIATEC": "/o44D5qjTdiYORSXmCVYE3Vzvbz2OlIBC58ELe+EAAgIZTJyDxmBJIFotP6CIuFmB/p4lGCd41Fb6T5BnmLvBQ"
67+
}
68+
}
69+
},
70+
"signed_curve25519:AAAAAAAAAA8": {
71+
"key": "gAhoEOtrGTEG+gfAsCU+JS7+wJTlC51+kZ9vLr9BZGA",
72+
"signatures": {
73+
"%s": {
74+
"ed25519:MUPCQIATEC": "DLDj1c2UncqcCrEwSUEf31ni6W+E6D58EEGFIWj++ydBxuiEnHqFMF7AZU8GGcjQBDIH13uNe8xxO7/KeBbUDQ"
75+
}
76+
}
77+
}
78+
}
79+
}`, bob.UserID, bob.UserID, bob.UserID, bob.UserID, bob.UserID, bob.UserID)
80+
81+
bob.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "upload"},
82+
client.WithRawBody([]byte(keysUploadBody)), client.WithContentType("application/json"),
83+
)
84+
85+
res := bob.SlidingSync(t, sync3.Request{
86+
Extensions: extensions.Request{
87+
E2EE: &extensions.E2EERequest{
88+
Core: extensions.Core{
89+
Enabled: &boolTrue,
90+
},
91+
},
92+
},
93+
})
94+
m.MatchResponse(t, res, m.MatchFallbackKeyTypes([]string{"signed_curve25519"}), m.MatchOTKCounts(map[string]int{
95+
"signed_curve25519": 3,
96+
}))
97+
98+
// claim a OTK, it should decrease the count
99+
mustClaimOTK(t, alice, bob)
100+
// claiming OTKs does not wake up the sync loop, so send something to kick it.
101+
alice.MustSendTyping(t, roomID, true, 1000)
102+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
103+
// OTK was claimed so change should be included.
104+
// fallback key was not touched so should be missing.
105+
MatchOTKAndFallbackTypes(map[string]int{
106+
"signed_curve25519": 2,
107+
}, nil),
108+
)
109+
110+
mustClaimOTK(t, alice, bob)
111+
alice.MustSendTyping(t, roomID, false, 1000)
112+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
113+
// OTK was claimed so change should be included.
114+
// fallback key was not touched so should be missing.
115+
MatchOTKAndFallbackTypes(map[string]int{
116+
"signed_curve25519": 1,
117+
}, nil),
118+
)
119+
120+
mustClaimOTK(t, alice, bob)
121+
alice.MustSendTyping(t, roomID, true, 1000)
122+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
123+
// OTK was claimed so change should be included.
124+
// fallback key was not touched so should be missing.
125+
MatchOTKAndFallbackTypes(map[string]int{
126+
"signed_curve25519": 0,
127+
}, nil),
128+
)
129+
130+
mustClaimOTK(t, alice, bob)
131+
alice.MustSendTyping(t, roomID, false, 1000)
132+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
133+
// no OTK change here so it shouldn't be included.
134+
// we should be explicitly sent device_unused_fallback_key_types: []
135+
MatchOTKAndFallbackTypes(nil, []string{}),
136+
)
137+
138+
// now re-upload a fallback key, it should be repopulated.
139+
keysUploadBody = fmt.Sprintf(`{
140+
"fallback_keys": {
141+
"signed_curve25519:AAAAAAAAADA": {
142+
"fallback": true,
143+
"key": "N8DKj83RTN7lLZrH6shMqHbVhNrxd96OQseQVFmNgTU",
144+
"signatures": {
145+
"%s": {
146+
"ed25519:MUPCQIATEC": "ZnKsVcNmOLBv0LMGeNpCfCO2am9L223EiyddWPx9wPOtuYt6KZIPox/SFwVmqBwkUdnmeTb6tVgCpZwcH8doDw"
147+
}
148+
}
149+
}
150+
}
151+
}`, bob.UserID)
152+
bob.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "upload"},
153+
client.WithRawBody([]byte(keysUploadBody)), client.WithContentType("application/json"),
154+
)
155+
156+
alice.MustSendTyping(t, roomID, true, 1000)
157+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
158+
// no OTK change here so it shouldn't be included.
159+
// we should be explicitly sent device_unused_fallback_key_types: ["signed_curve25519"]
160+
MatchOTKAndFallbackTypes(nil, []string{"signed_curve25519"}),
161+
)
162+
163+
// another claim should remove it
164+
mustClaimOTK(t, alice, bob)
165+
166+
alice.MustSendTyping(t, roomID, false, 1000)
167+
res = bob.SlidingSyncUntil(t, res.Pos, sync3.Request{},
168+
// no OTK change here so it shouldn't be included.
169+
// we should be explicitly sent device_unused_fallback_key_types: []
170+
MatchOTKAndFallbackTypes(nil, []string{}),
171+
)
172+
}
173+
174+
// Regression test to make sure EX uploads a fallback key initially.
175+
// EX relies on device_unused_fallback_key_types: [] being present in the
176+
// sync response before it will upload any fallback keys at all, it doesn't
177+
// automatically do it on first login.
178+
func TestEncryptionFallbackKeyToldIfMissingInitially(t *testing.T) {
179+
alice := registerNewUser(t)
180+
bob := registerNewUser(t)
181+
roomID := alice.MustCreateRoom(t, map[string]interface{}{
182+
"preset": "public_chat",
183+
})
184+
bob.JoinRoom(t, roomID, nil)
185+
res := bob.SlidingSync(t, sync3.Request{
186+
Extensions: extensions.Request{
187+
E2EE: &extensions.E2EERequest{
188+
Core: extensions.Core{
189+
Enabled: &boolTrue,
190+
},
191+
},
192+
},
193+
})
194+
m.MatchResponse(t, res, m.MatchFallbackKeyTypes([]string{}))
195+
196+
// upload a fallback key and do another initial request => should include key
197+
keysUploadBody := fmt.Sprintf(`{
198+
"fallback_keys": {
199+
"signed_curve25519:AAAAAAAAADA": {
200+
"fallback": true,
201+
"key": "N8DKj83RTN7lLZrH6shMqHbVhNrxd96OQseQVFmNgTU",
202+
"signatures": {
203+
"%s": {
204+
"ed25519:MUPCQIATEC": "ZnKsVcNmOLBv0LMGeNpCfCO2am9L223EiyddWPx9wPOtuYt6KZIPox/SFwVmqBwkUdnmeTb6tVgCpZwcH8doDw"
205+
}
206+
}
207+
}
208+
}
209+
}`, bob.UserID)
210+
bob.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "upload"},
211+
client.WithRawBody([]byte(keysUploadBody)), client.WithContentType("application/json"),
212+
)
213+
sentinelEventID := bob.SendEventSynced(t, roomID, b.Event{
214+
Type: "m.room.message",
215+
Content: map[string]interface{}{
216+
"msgtype": "m.text",
217+
"body": "Sentinel",
218+
},
219+
})
220+
bob.SlidingSyncUntilEventID(t, "", roomID, sentinelEventID)
221+
res = bob.SlidingSync(t, sync3.Request{
222+
Extensions: extensions.Request{
223+
E2EE: &extensions.E2EERequest{
224+
Core: extensions.Core{
225+
Enabled: &boolTrue,
226+
},
227+
},
228+
},
229+
})
230+
m.MatchResponse(t, res, m.MatchFallbackKeyTypes([]string{"signed_curve25519"}))
231+
232+
// consume the fallback key and do another initial request => should be []
233+
mustClaimOTK(t, alice, bob)
234+
sentinelEventID = bob.SendEventSynced(t, roomID, b.Event{
235+
Type: "m.room.message",
236+
Content: map[string]interface{}{
237+
"msgtype": "m.text",
238+
"body": "Sentinel 2",
239+
},
240+
})
241+
bob.SlidingSyncUntilEventID(t, "", roomID, sentinelEventID)
242+
res = bob.SlidingSync(t, sync3.Request{
243+
Extensions: extensions.Request{
244+
E2EE: &extensions.E2EERequest{
245+
Core: extensions.Core{
246+
Enabled: &boolTrue,
247+
},
248+
},
249+
},
250+
})
251+
m.MatchResponse(t, res, m.MatchFallbackKeyTypes([]string{}))
252+
}
253+
254+
func MatchOTKAndFallbackTypes(otkCount map[string]int, fallbackKeyTypes []string) m.RespMatcher {
255+
return func(r *sync3.Response) error {
256+
err := m.MatchOTKCounts(otkCount)(r)
257+
if err != nil {
258+
return err
259+
}
260+
// we should explicitly be sent device_unused_fallback_key_types: []
261+
return m.MatchFallbackKeyTypes(fallbackKeyTypes)(r)
262+
}
263+
}
264+
265+
func mustClaimOTK(t *testing.T, claimer, claimee *CSAPI) {
266+
claimRes := claimer.MustDo(t, "POST", []string{"_matrix", "client", "v3", "keys", "claim"}, client.WithJSONBody(t, map[string]any{
267+
"one_time_keys": map[string]any{
268+
claimee.UserID: map[string]any{
269+
claimee.DeviceID: "signed_curve25519",
270+
},
271+
},
272+
}))
273+
var res struct {
274+
Failures map[string]any `json:"failures"`
275+
OTKs map[string]map[string]any `json:"one_time_keys"`
276+
}
277+
if err := json.NewDecoder(claimRes.Body).Decode(&res); err != nil {
278+
t.Fatalf("failed to decode OTK response: %s", err)
279+
}
280+
if len(res.Failures) > 0 {
281+
t.Fatalf("OTK response had failures: %+v", res.Failures)
282+
}
283+
otk := res.OTKs[claimee.UserID][claimee.DeviceID]
284+
if otk == nil {
285+
t.Fatalf("OTK was not claimed for %s|%s", claimee.UserID, claimee.DeviceID)
286+
}
287+
}

testutils/m/match.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,13 @@ func MatchFallbackKeyTypes(fallbackKeyTypes []string) RespMatcher {
344344
if res.Extensions.E2EE == nil {
345345
return fmt.Errorf("MatchFallbackKeyTypes: no E2EE extension present")
346346
}
347-
if !reflect.DeepEqual(res.Extensions.E2EE.FallbackKeyTypes, fallbackKeyTypes) {
347+
if res.Extensions.E2EE.FallbackKeyTypes == nil { // not supplied
348+
if fallbackKeyTypes == nil {
349+
return nil
350+
}
351+
return fmt.Errorf("MatchFallbackKeyTypes: FallbackKeyTypes is missing but want %v", fallbackKeyTypes)
352+
}
353+
if !reflect.DeepEqual(*res.Extensions.E2EE.FallbackKeyTypes, fallbackKeyTypes) {
348354
return fmt.Errorf("MatchFallbackKeyTypes: got %v want %v", res.Extensions.E2EE.FallbackKeyTypes, fallbackKeyTypes)
349355
}
350356
return nil

0 commit comments

Comments
 (0)