Skip to content

Commit 9a41588

Browse files
committed
extract_username_from_access_token lua
1 parent 1c2a65e commit 9a41588

7 files changed

Lines changed: 390 additions & 2 deletions

File tree

charts/synapse/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ apiVersion: v2
22
appVersion: 1.151.0
33
description: matrix synapse kubernetes deployment
44
name: synapse
5-
version: 2.4.13
5+
version: 2.4.14

charts/synapse/scripts/synapse.lua

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,69 @@ local function extract_localpart(user_id)
123123
return string.sub(user_id, 2, colon_index - 1)
124124
end
125125

126+
-- Pure-Lua decoder for unpadded standard base64 (the alphabet and padding
127+
-- behaviour produced by Synapse's `unpaddedbase64.encode_base64`). Used to
128+
-- decode the localpart embedded directly in Synapse-issued access tokens -
129+
-- see `extract_username_from_access_token` below.
130+
local b64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
131+
132+
local function decode_unpadded_base64(data)
133+
if string.find(data, "[^" .. b64_chars .. "=]") ~= nil then
134+
return nil
135+
end
136+
137+
local bits = (data:gsub(".", function(char)
138+
if char == "=" then
139+
return ""
140+
end
141+
142+
local value = b64_chars:find(char, 1, true) - 1
143+
local bit_string = ""
144+
for i = 5, 0, -1 do
145+
bit_string = bit_string .. (math.floor(value / 2 ^ i) % 2)
146+
end
147+
return bit_string
148+
end))
149+
150+
return (bits:gsub("%d%d%d?%d?%d?%d?%d?%d?", function(byte_bits)
151+
if string.len(byte_bits) ~= 8 then
152+
return ""
153+
end
154+
155+
local byte = 0
156+
for i = 1, 8 do
157+
byte = byte + (string.sub(byte_bits, i, i) == "1" and 2 ^ (8 - i) or 0)
158+
end
159+
return string.char(byte)
160+
end))
161+
end
162+
163+
-- Synapse-issued access tokens embed the user's localpart directly:
164+
-- syt_<unpadded-base64(localpart)>_<random string>_<base62 crc check>
165+
-- (see synapse/handlers/auth.py: AuthHandler.generate_access_token). Decoding
166+
-- it locally gives us a stable per-user X-Hash-Key - identical across all of
167+
-- a user's devices/sessions - without an internal whoami round-trip and its
168+
-- failure modes (timeouts, transient errors). Tokens that don't match this
169+
-- format (application-service tokens, delegated-auth/MSC3861 tokens, ...)
170+
-- return nil here and fall through to the whoami lookup below.
171+
local function extract_username_from_access_token(token)
172+
if token == nil then
173+
return nil
174+
end
175+
176+
local _, _, b64local = string.find(token, "^syt_([A-Za-z0-9+/]+)_")
177+
if b64local == nil then
178+
return nil
179+
end
180+
181+
local ok, localpart = pcall(decode_unpadded_base64, b64local)
182+
if not ok or localpart == nil or string.len(localpart) == 0 then
183+
return nil
184+
end
185+
186+
return localpart
187+
end
188+
126189
local function extract_user_id_from_whoami_body(body)
127190
local _, _, user_id = string.find(body, '"user_id"%s*:%s*"([^"]+)"')
128191

@@ -213,6 +276,16 @@ local function get_user_identifier_from_request(request_handle, options)
213276
return nil
214277
end
215278

279+
-- Fast path: most tokens are issued by Synapse itself and embed the
280+
-- localpart, so we can derive a stable per-user hash key with no network
281+
-- call at all - this is what keeps a user's /sync traffic pinned to the
282+
-- same httpd-user-hash worker regardless of how many devices they use.
283+
local decoded_username = extract_username_from_access_token(token)
284+
if decoded_username ~= nil then
285+
log(request_handle, options, "warn", "decoded username from token " .. truncate_token(token, options) .. " -> " .. decoded_username)
286+
return decoded_username
287+
end
288+
216289
local cached_username = get_cached_username(token, options)
217290
if cached_username ~= nil then
218291
log(request_handle, options, "warn", "cache hit for token " .. truncate_token(token, options) .. " -> " .. cached_username)
@@ -225,6 +298,10 @@ local function get_user_identifier_from_request(request_handle, options)
225298
return username
226299
end
227300

301+
-- Last resort: pin on the raw token. It's only stable per device/session
302+
-- (not per user across devices), but that's still far better than
303+
-- x-request-id - it keeps a given session's requests landing on the same
304+
-- worker instead of scattering randomly on every single request.
228305
log(request_handle, options, "warn", "whoami lookup failed, falling back to token-based routing")
229306
return token
230307
end

charts/synapse/scripts/synapse_test.lua

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,82 @@ local function test_access_token_from_query()
7676
)
7777
end
7878

79+
local function test_username_decoded_from_synapse_token_without_whoami()
80+
local request_handle = new_request_handle({
81+
[":path"] = "/_matrix/client/v3/sync",
82+
-- syt_<unpadded-base64("alice")>_<random>_<crc>
83+
["authorization"] = "Bearer syt_YWxpY2U_abcdefghij1234567890_crc123"
84+
}, function()
85+
error("decodable token should not call whoami")
86+
end)
87+
88+
assert_equal(
89+
synapse.get_user_identifier_from_request(request_handle, {}),
90+
"alice",
91+
"localpart should be decoded directly from a syt_ token"
92+
)
93+
end
94+
95+
local function test_username_decoded_consistent_across_devices()
96+
local handle_a = new_request_handle({
97+
[":path"] = "/_matrix/client/v3/sync",
98+
["authorization"] = "Bearer syt_Y2Fyb2wtdGVzdF85OQ_deviceAtokenAAAAAAA_crc111"
99+
}, function()
100+
error("decodable token should not call whoami")
101+
end)
102+
local handle_b = new_request_handle({
103+
[":path"] = "/_matrix/client/v3/sync",
104+
["authorization"] = "Bearer syt_Y2Fyb2wtdGVzdF85OQ_deviceBtokenBBBBBBB_crc222"
105+
}, function()
106+
error("decodable token should not call whoami")
107+
end)
108+
109+
local key_a = synapse.get_user_identifier_from_request(handle_a, {})
110+
local key_b = synapse.get_user_identifier_from_request(handle_b, {})
111+
112+
assert_equal(key_a, "carol-test_99", "device A should decode to the localpart")
113+
assert_equal(key_b, "carol-test_99", "device B should decode to the same localpart")
114+
assert_equal(key_a, key_b, "the same user on different devices must hash to the same key")
115+
end
116+
117+
local function test_non_synapse_token_falls_back_to_whoami()
118+
local calls = 0
119+
local request_handle = new_request_handle({
120+
[":path"] = "/_matrix/client/v3/sync",
121+
-- application-service / delegated-auth tokens don't follow the syt_ format
122+
["authorization"] = "Bearer as_some_opaque_appservice_token"
123+
}, function(_, cluster, headers, body, timeout_ms)
124+
calls = calls + 1
125+
return { [":status"] = "200" }, '{"user_id":"@bridgebot:example.org"}'
126+
end)
127+
128+
assert_equal(
129+
synapse.get_user_identifier_from_request(request_handle, {}),
130+
"bridgebot",
131+
"non-syt tokens should resolve via whoami"
132+
)
133+
assert_equal(calls, 1, "whoami should be used for tokens that aren't locally decodable")
134+
end
135+
136+
local function test_malformed_synapse_token_falls_back_to_whoami()
137+
local calls = 0
138+
local request_handle = new_request_handle({
139+
[":path"] = "/_matrix/client/v3/sync",
140+
-- looks like a syt_ token, but the middle segment isn't valid base64
141+
["authorization"] = "Bearer syt_!!!not-base64!!!_randomstring12345_crc999"
142+
}, function(_, cluster, headers, body, timeout_ms)
143+
calls = calls + 1
144+
return { [":status"] = "200" }, '{"user_id":"@erin:example.org"}'
145+
end)
146+
147+
assert_equal(
148+
synapse.get_user_identifier_from_request(request_handle, {}),
149+
"erin",
150+
"malformed syt_ tokens should fall back to whoami rather than crash"
151+
)
152+
assert_equal(calls, 1, "whoami should be used when local decoding fails")
153+
end
154+
79155
local function test_whoami_lookup_returns_localpart()
80156
local calls = 0
81157
local request_handle = new_request_handle({
@@ -214,6 +290,10 @@ local tests = {
214290
test_room_id_from_encoded_path,
215291
test_access_token_from_authorization_header,
216292
test_access_token_from_query,
293+
test_username_decoded_from_synapse_token_without_whoami,
294+
test_username_decoded_consistent_across_devices,
295+
test_non_synapse_token_falls_back_to_whoami,
296+
test_malformed_synapse_token_falls_back_to_whoami,
217297
test_whoami_lookup_returns_localpart,
218298
test_whoami_lookup_forwards_xff,
219299
test_whoami_lookup_without_xff,

charts/synapse/tests/golden/fixtures/test-envoy-configmap-msc4306.golden.yaml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1567,6 +1567,69 @@ data:
15671567
return string.sub(user_id, 2, colon_index - 1)
15681568
end
15691569
1570+
-- Pure-Lua decoder for unpadded standard base64 (the alphabet and padding
1571+
-- behaviour produced by Synapse's `unpaddedbase64.encode_base64`). Used to
1572+
-- decode the localpart embedded directly in Synapse-issued access tokens -
1573+
-- see `extract_username_from_access_token` below.
1574+
local b64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
1575+
1576+
local function decode_unpadded_base64(data)
1577+
if string.find(data, "[^" .. b64_chars .. "=]") ~= nil then
1578+
return nil
1579+
end
1580+
1581+
local bits = (data:gsub(".", function(char)
1582+
if char == "=" then
1583+
return ""
1584+
end
1585+
1586+
local value = b64_chars:find(char, 1, true) - 1
1587+
local bit_string = ""
1588+
for i = 5, 0, -1 do
1589+
bit_string = bit_string .. (math.floor(value / 2 ^ i) % 2)
1590+
end
1591+
return bit_string
1592+
end))
1593+
1594+
return (bits:gsub("%d%d%d?%d?%d?%d?%d?%d?", function(byte_bits)
1595+
if string.len(byte_bits) ~= 8 then
1596+
return ""
1597+
end
1598+
1599+
local byte = 0
1600+
for i = 1, 8 do
1601+
byte = byte + (string.sub(byte_bits, i, i) == "1" and 2 ^ (8 - i) or 0)
1602+
end
1603+
return string.char(byte)
1604+
end))
1605+
end
1606+
1607+
-- Synapse-issued access tokens embed the user's localpart directly:
1608+
-- syt_<unpadded-base64(localpart)>_<random string>_<base62 crc check>
1609+
-- (see synapse/handlers/auth.py: AuthHandler.generate_access_token). Decoding
1610+
-- it locally gives us a stable per-user X-Hash-Key - identical across all of
1611+
-- a user's devices/sessions - without an internal whoami round-trip and its
1612+
-- failure modes (timeouts, transient errors). Tokens that don't match this
1613+
-- format (application-service tokens, delegated-auth/MSC3861 tokens, ...)
1614+
-- return nil here and fall through to the whoami lookup below.
1615+
local function extract_username_from_access_token(token)
1616+
if token == nil then
1617+
return nil
1618+
end
1619+
1620+
local _, _, b64local = string.find(token, "^syt_([A-Za-z0-9+/]+)_")
1621+
if b64local == nil then
1622+
return nil
1623+
end
1624+
1625+
local ok, localpart = pcall(decode_unpadded_base64, b64local)
1626+
if not ok or localpart == nil or string.len(localpart) == 0 then
1627+
return nil
1628+
end
1629+
1630+
return localpart
1631+
end
1632+
15701633
local function extract_user_id_from_whoami_body(body)
15711634
local _, _, user_id = string.find(body, '"user_id"%s*:%s*"([^"]+)"')
15721635
@@ -1657,6 +1720,16 @@ data:
16571720
return nil
16581721
end
16591722
1723+
-- Fast path: most tokens are issued by Synapse itself and embed the
1724+
-- localpart, so we can derive a stable per-user hash key with no network
1725+
-- call at all - this is what keeps a user's /sync traffic pinned to the
1726+
-- same httpd-user-hash worker regardless of how many devices they use.
1727+
local decoded_username = extract_username_from_access_token(token)
1728+
if decoded_username ~= nil then
1729+
log(request_handle, options, "warn", "decoded username from token " .. truncate_token(token, options) .. " -> " .. decoded_username)
1730+
return decoded_username
1731+
end
1732+
16601733
local cached_username = get_cached_username(token, options)
16611734
if cached_username ~= nil then
16621735
log(request_handle, options, "warn", "cache hit for token " .. truncate_token(token, options) .. " -> " .. cached_username)
@@ -1669,6 +1742,10 @@ data:
16691742
return username
16701743
end
16711744
1745+
-- Last resort: pin on the raw token. It's only stable per device/session
1746+
-- (not per user across devices), but that's still far better than
1747+
-- x-request-id - it keeps a given session's requests landing on the same
1748+
-- worker instead of scattering randomly on every single request.
16721749
log(request_handle, options, "warn", "whoami lookup failed, falling back to token-based routing")
16731750
return token
16741751
end

charts/synapse/tests/golden/fixtures/test-envoy-configmap-no-mas.golden.yaml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,69 @@ data:
15061506
return string.sub(user_id, 2, colon_index - 1)
15071507
end
15081508
1509+
-- Pure-Lua decoder for unpadded standard base64 (the alphabet and padding
1510+
-- behaviour produced by Synapse's `unpaddedbase64.encode_base64`). Used to
1511+
-- decode the localpart embedded directly in Synapse-issued access tokens -
1512+
-- see `extract_username_from_access_token` below.
1513+
local b64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
1514+
1515+
local function decode_unpadded_base64(data)
1516+
if string.find(data, "[^" .. b64_chars .. "=]") ~= nil then
1517+
return nil
1518+
end
1519+
1520+
local bits = (data:gsub(".", function(char)
1521+
if char == "=" then
1522+
return ""
1523+
end
1524+
1525+
local value = b64_chars:find(char, 1, true) - 1
1526+
local bit_string = ""
1527+
for i = 5, 0, -1 do
1528+
bit_string = bit_string .. (math.floor(value / 2 ^ i) % 2)
1529+
end
1530+
return bit_string
1531+
end))
1532+
1533+
return (bits:gsub("%d%d%d?%d?%d?%d?%d?%d?", function(byte_bits)
1534+
if string.len(byte_bits) ~= 8 then
1535+
return ""
1536+
end
1537+
1538+
local byte = 0
1539+
for i = 1, 8 do
1540+
byte = byte + (string.sub(byte_bits, i, i) == "1" and 2 ^ (8 - i) or 0)
1541+
end
1542+
return string.char(byte)
1543+
end))
1544+
end
1545+
1546+
-- Synapse-issued access tokens embed the user's localpart directly:
1547+
-- syt_<unpadded-base64(localpart)>_<random string>_<base62 crc check>
1548+
-- (see synapse/handlers/auth.py: AuthHandler.generate_access_token). Decoding
1549+
-- it locally gives us a stable per-user X-Hash-Key - identical across all of
1550+
-- a user's devices/sessions - without an internal whoami round-trip and its
1551+
-- failure modes (timeouts, transient errors). Tokens that don't match this
1552+
-- format (application-service tokens, delegated-auth/MSC3861 tokens, ...)
1553+
-- return nil here and fall through to the whoami lookup below.
1554+
local function extract_username_from_access_token(token)
1555+
if token == nil then
1556+
return nil
1557+
end
1558+
1559+
local _, _, b64local = string.find(token, "^syt_([A-Za-z0-9+/]+)_")
1560+
if b64local == nil then
1561+
return nil
1562+
end
1563+
1564+
local ok, localpart = pcall(decode_unpadded_base64, b64local)
1565+
if not ok or localpart == nil or string.len(localpart) == 0 then
1566+
return nil
1567+
end
1568+
1569+
return localpart
1570+
end
1571+
15091572
local function extract_user_id_from_whoami_body(body)
15101573
local _, _, user_id = string.find(body, '"user_id"%s*:%s*"([^"]+)"')
15111574
@@ -1596,6 +1659,16 @@ data:
15961659
return nil
15971660
end
15981661
1662+
-- Fast path: most tokens are issued by Synapse itself and embed the
1663+
-- localpart, so we can derive a stable per-user hash key with no network
1664+
-- call at all - this is what keeps a user's /sync traffic pinned to the
1665+
-- same httpd-user-hash worker regardless of how many devices they use.
1666+
local decoded_username = extract_username_from_access_token(token)
1667+
if decoded_username ~= nil then
1668+
log(request_handle, options, "warn", "decoded username from token " .. truncate_token(token, options) .. " -> " .. decoded_username)
1669+
return decoded_username
1670+
end
1671+
15991672
local cached_username = get_cached_username(token, options)
16001673
if cached_username ~= nil then
16011674
log(request_handle, options, "warn", "cache hit for token " .. truncate_token(token, options) .. " -> " .. cached_username)
@@ -1608,6 +1681,10 @@ data:
16081681
return username
16091682
end
16101683
1684+
-- Last resort: pin on the raw token. It's only stable per device/session
1685+
-- (not per user across devices), but that's still far better than
1686+
-- x-request-id - it keeps a given session's requests landing on the same
1687+
-- worker instead of scattering randomly on every single request.
16111688
log(request_handle, options, "warn", "whoami lookup failed, falling back to token-based routing")
16121689
return token
16131690
end

0 commit comments

Comments
 (0)