Skip to content

Commit 300ece3

Browse files
authored
FIX: Add OAEP padding option for user API key encryption (#36592)
Users/apps running OpenSSL in FIPS 140-3 mode cannot decrypt user API key responses because FIPS forbids the legacy PKCS1_PADDING scheme that Ruby/Discourse uses by default. Add a `padding` parameter to the user API key endpoints that accepts "oaep" to use PKCS1_OAEP_PADDING instead. The default remains PKCS1 for backwards compatibility with existing clients. Usage: POST /user-api-key.json?padding=oaep Internal ref - t/170427
1 parent f27b9ca commit 300ece3

File tree

2 files changed

+90
-5
lines changed

2 files changed

+90
-5
lines changed

app/controllers/user_api_keys_controller.rb

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class UserApiKeysController < ApplicationController
1111

1212
AUTH_API_VERSION = 4
1313

14+
ALLOWED_PADDING_MODES = %w[pkcs1 oaep].freeze
15+
1416
def new
1517
if request.head?
1618
head :ok, auth_api_version: AUTH_API_VERSION
@@ -90,10 +92,8 @@ def create
9092
public_key_str = (@client.public_key.presence || params[:public_key])
9193
public_key = OpenSSL::PKey::RSA.new(public_key_str)
9294

93-
# by default, Ruby uses `PKCS1_PADDING` here
94-
# see https://docs.ruby-lang.org/en/3.2/OpenSSL/PKey/RSA.html#method-i-public_encrypt
95-
# make sure that Node/OpenSSL can use the same padding in your implementation
96-
@payload = Base64.encode64(public_key.public_encrypt(@payload))
95+
validate_payload_size_for_oaep!(@payload, public_key)
96+
@payload = Base64.encode64(rsa_encrypt(public_key, @payload))
9797

9898
if scopes.include?("one_time_password")
9999
# encrypt one_time_password separately to bypass 128 chars encryption limit
@@ -218,6 +218,30 @@ def one_time_password(public_key, username)
218218
otp = SecureRandom.hex
219219
Discourse.redis.setex "otp_#{otp}", 10.minutes, username
220220

221-
Base64.encode64(public_key.public_encrypt(otp))
221+
Base64.encode64(rsa_encrypt(public_key, otp))
222+
end
223+
224+
def rsa_encrypt(public_key, data)
225+
# OAEP padding is recommended for new applications and required for FIPS 140-3 compliance.
226+
# PKCS1 padding is kept as default for backwards compatibility with existing clients.
227+
padding_mode = params[:padding] == "oaep" ? "oaep" : "pkcs1"
228+
public_key.encrypt(data, { "rsa_padding_mode" => padding_mode })
229+
end
230+
231+
def validate_payload_size_for_oaep!(payload, public_key)
232+
return unless params[:padding] == "oaep"
233+
234+
# RSA-OAEP max payload = key_size_bytes - 2*hash_size_bytes - 2
235+
# OpenSSL uses SHA-1 (20 bytes) by default for OAEP
236+
key_size_bytes = public_key.n.num_bytes
237+
max_payload_size = key_size_bytes - 2 * 20 - 2
238+
239+
if payload.bytesize > max_payload_size
240+
raise Discourse::InvalidParameters.new(
241+
"Payload too large for OAEP encryption with this key size. " \
242+
"Maximum: #{max_payload_size} bytes, got: #{payload.bytesize} bytes. " \
243+
"Try using a shorter nonce or a larger RSA key (minimum 2048-bit recommended).",
244+
)
245+
end
222246
end
223247
end

spec/requests/user_api_keys_controller_spec.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,44 @@
268268
expect(api_key.user_id).to eq(user.id)
269269
end
270270

271+
it "encrypts payload with OAEP padding when requested" do
272+
# OAEP padding has larger overhead (42 bytes vs 11 for PKCS1), requiring a larger key
273+
oaep_private_key = OpenSSL::PKey::RSA.new(2048)
274+
oaep_public_key = oaep_private_key.public_key.to_pem
275+
276+
user = Fabricate(:user, trust_level: TrustLevel[0])
277+
sign_in(user)
278+
279+
oaep_args = args.except(:auth_redirect).merge(padding: "oaep", public_key: oaep_public_key)
280+
281+
SiteSetting.user_api_key_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
282+
post "/user-api-key.json", params: oaep_args
283+
expect(response.status).not_to eq(302)
284+
payload = response.parsed_body["payload"]
285+
encrypted = Base64.decode64(payload)
286+
parsed =
287+
JSON.parse(
288+
oaep_private_key.private_decrypt(encrypted, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING),
289+
)
290+
api_key = UserApiKey.with_key(parsed["key"]).first
291+
expect(api_key.user_id).to eq(user.id)
292+
end
293+
294+
it "rejects OAEP requests when payload exceeds maximum size for the key" do
295+
SiteSetting.user_api_key_allowed_groups = Group::AUTO_GROUPS[:trust_level_0]
296+
297+
sign_in Fabricate(:user, trust_level: TrustLevel[0])
298+
299+
public_key = OpenSSL::PKey::RSA.new(2048).public_key.to_pem
300+
nonce = "x" * 150
301+
params = args.except(:auth_redirect).merge(padding: "oaep", public_key:, nonce:)
302+
303+
post "/user-api-key.json", params: params
304+
305+
expect(response.status).to eq(400)
306+
expect(response.parsed_body["errors"].first).to include("Payload too large for OAEP")
307+
end
308+
271309
it "will allow redirect to wildcard urls" do
272310
SiteSetting.allowed_user_api_auth_redirects = args[:auth_redirect] + "/*"
273311
args[:auth_redirect] = args[:auth_redirect] + "/bluebirds/fly"
@@ -409,5 +447,28 @@
409447

410448
expect(Discourse.redis.get("otp_#{parsed}")).to eq(user.username)
411449
end
450+
451+
it "encrypts one-time-password with OAEP padding when requested" do
452+
# OAEP padding has larger overhead, use a larger key for safety
453+
oaep_private_key = OpenSSL::PKey::RSA.new(2048)
454+
oaep_public_key = oaep_private_key.public_key.to_pem
455+
456+
SiteSetting.allowed_user_api_auth_redirects = otp_args[:auth_redirect]
457+
user = Fabricate(:user, refresh_auto_groups: true)
458+
sign_in(user)
459+
460+
post "/user-api-key/otp", params: otp_args.merge(padding: "oaep", public_key: oaep_public_key)
461+
expect(response.status).to eq(302)
462+
463+
uri = URI.parse(response.redirect_url)
464+
465+
query = uri.query
466+
payload = query.split("oneTimePassword=")[1]
467+
encrypted = Base64.decode64(CGI.unescape(payload))
468+
469+
parsed = oaep_private_key.private_decrypt(encrypted, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
470+
471+
expect(Discourse.redis.get("otp_#{parsed}")).to eq(user.username)
472+
end
412473
end
413474
end

0 commit comments

Comments
 (0)