Skip to content

Commit 264c634

Browse files
committed
Add SASLprep. Code generated & tested with RFC3454
RFC-4422 recommends that mechanisms SHOULD prepare simple usernames and passwords with SASLprep. And SASLprep is required by the `SCRAM-*` mechanisms, which will be added in a future PR. SASLprep is also recommended for the `PLAIN` SASL mechanism and for the `ACL` IMAP extension but—in both cases—string preparation is done by the *server*, at its own discretion. SASLprep has been officially obsoleted by PRECIS. I don't believe any IMAP RFCs have allowed replacing SASLprep with PRECIS yet. In contrast, RFC-7622 updates XMPP to require PRECIS. (See RFC-8265 for more info on PRECIS, and Section 6 for migration considerations.) Rather than create a fully generic StringPrep superclass or function, the SASLprep profile is optimized. Just enough of the generic StringPrep algorithm has been implemented to provide more detailed errors for prohibited strings. Future PRs can expand it as needed, to implement other profiles. In particular, the `trace` StringPrep profile is a requirement for clients using the `ANONYMOUS` mechanism. Many other StringPrep implementations store the tables as an array of ranges and loop over every character in the input. But using Regexp is simpler and much faster, especially where the tables closely match Unicode character classes (benchmarks are included). Some StringPrep tables use Regexps that are generated from the RFC-3454 appendices. Manually written regular expressions are used in cases where there is a close match between Unicode character classes and the SASLprep tables. All regexps are tested against the RFC tables with every valid codepoint, to verify they aren't broken if their character classes are changed by new versions of Unicode. Additionally: * Added `rake rfcs` to download many IMAP-related RFCs, for convenience. * The new code is namespaced under `Net::IMAP::SASL`. We could move the authenticators there too. If SASL funcionality is ever extracted to another gem, we can use: `Net::IMAP::SASL = Net::SASL` for backward compatibility.
1 parent 1e80875 commit 264c634

15 files changed

+1258
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
/coverage/
55
/doc/
66
/pkg/
7+
/rfcs
78
/spec/reports/
89
/tmp/
910
/Gemfile.lock

Rakefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
# frozen_string_literal: true
2+
13
require "bundler/gem_tasks"
24
require "rake/testtask"
5+
require "rake/clean"
36

47
Rake::TestTask.new(:test) do |t|
58
t.libs << "test/lib"

benchmarks/stringprep.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
prelude: |
3+
begin
4+
require "mongo" # gem install mongo
5+
require "idn" # gem install idn-ruby
6+
rescue LoadError
7+
warn "You must 'gem install mongo idn-ruby' for this benchmark."
8+
raise
9+
end
10+
11+
MStrPrep = Mongo::Auth::StringPrep
12+
13+
# this indirection will slow it down a little bit
14+
def mongo_saslprep(string)
15+
MStrPrep.prepare(string,
16+
MStrPrep::Profiles::SASL::MAPPINGS,
17+
MStrPrep::Profiles::SASL::PROHIBITED,
18+
normalize: true,
19+
bidi: true)
20+
rescue Mongo::Error::FailedStringPrepValidation
21+
nil
22+
end
23+
24+
$LOAD_PATH.unshift "./lib"
25+
require "net/imap"
26+
def net_imap_saslprep(string)
27+
Net::IMAP::SASL::SASLprep.saslprep string, exception: false
28+
end
29+
30+
def libidn_saslprep(string)
31+
IDN::Stringprep.with_profile(string, "SASLprep")
32+
rescue IDN::Stringprep::StringprepError
33+
nil
34+
end
35+
36+
benchmark:
37+
- net_imap_saslprep "I\u00ADX" # RFC example 1. IX
38+
- net_imap_saslprep "user" # RFC example 2. user
39+
- net_imap_saslprep "USER" # RFC example 3. user
40+
- net_imap_saslprep "\u00aa" # RFC example 4. a
41+
- net_imap_saslprep "\u2168" # RFC example 5. IX
42+
- net_imap_saslprep "\u0007" # RFC example 6. Error - prohibited character
43+
- net_imap_saslprep "\u0627\u0031" # RFC example 7. Error - bidirectional check
44+
- net_imap_saslprep "I\u2000X" # map to space: I X
45+
- net_imap_saslprep "a longer string, e.g. a password"
46+
47+
- libidn_saslprep "I\u00ADX" # RFC example 1. IX
48+
- libidn_saslprep "user" # RFC example 2. user
49+
- libidn_saslprep "USER" # RFC example 3. user
50+
- libidn_saslprep "\u00aa" # RFC example 4. a
51+
- libidn_saslprep "\u2168" # RFC example 5. IX
52+
- libidn_saslprep "\u0007" # RFC example 6. Error - prohibited character
53+
- libidn_saslprep "\u0627\u0031" # RFC example 7. Error - bidirectional check
54+
- libidn_saslprep "I\u2000X" # map to space: I X
55+
- libidn_saslprep "a longer string, e.g. a password"
56+
57+
- mongo_saslprep "I\u00ADX" # RFC example 1. IX
58+
- mongo_saslprep "user" # RFC example 2. user
59+
- mongo_saslprep "USER" # RFC example 3. user
60+
- mongo_saslprep "\u00aa" # RFC example 4. a
61+
- mongo_saslprep "\u2168" # RFC example 5. IX
62+
- mongo_saslprep "\u0007" # RFC example 6. Error - prohibited character
63+
- mongo_saslprep "\u0627\u0031" # RFC example 7. Error - bidirectional check
64+
- mongo_saslprep "I\u2000X" # map to space: I X
65+
- mongo_saslprep "a longer string, e.g. a password"

benchmarks/table-regexps.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
prelude: |
2+
require "json"
3+
require "set"
4+
5+
all_codepoints = (0..0x10ffff).map{_1.chr("UTF-8") rescue nil}.compact
6+
7+
rfc3454_tables = Dir["rfcs/rfc3454*.json"]
8+
.first
9+
.then{File.read _1}
10+
.then{JSON.parse _1}
11+
titles = rfc3454_tables.delete("titles")
12+
13+
sets = rfc3454_tables
14+
.transform_values{|t|t.keys rescue t}
15+
.transform_values{|table|
16+
table
17+
.map{_1.split(?-).map{|i|Integer i, 16}}
18+
.flat_map{_2 ? (_1.._2).to_a : _1}
19+
.to_set
20+
}
21+
22+
TABLE_A1_SET = sets.fetch "A.1"
23+
ASSIGNED_3_2 = /\p{AGE=3.2}/
24+
UNASSIGNED_3_2 = /\P{AGE=3.2}/
25+
TABLE_A1_REGEX = /(?-mix:[\u{0000}-\u{001f}\u{007f}-\u{00a0}\u{0340}-\u{0341}\u{06dd}\u{070f}\u{1680}\u{180e}\u{2000}-\u{200f}\u{2028}-\u{202f}\u{205f}-\u{2063}\u{206a}-\u{206f}\u{2ff0}-\u{2ffb}\u{3000}\u{e000}-\u{f8ff}\u{fdd0}-\u{fdef}\u{feff}\u{fff9}-\u{ffff}\u{1d173}-\u{1d17a}\u{1fffe}-\u{1ffff}\u{2fffe}-\u{2ffff}\u{3fffe}-\u{3ffff}\u{4fffe}-\u{4ffff}\u{5fffe}-\u{5ffff}\u{6fffe}-\u{6ffff}\u{7fffe}-\u{7ffff}\u{8fffe}-\u{8ffff}\u{9fffe}-\u{9ffff}\u{afffe}-\u{affff}\u{bfffe}-\u{bffff}\u{cfffe}-\u{cffff}\u{dfffe}-\u{dffff}\u{e0001}\u{e0020}-\u{e007f}\u{efffe}-\u{10ffff}])|(?-mix:\p{Cs})/.freeze
26+
27+
benchmark:
28+
29+
# matches A.1
30+
- script: "all_codepoints.grep(TABLE_A1_SET)"
31+
- script: "all_codepoints.grep(TABLE_A1_REGEX)"
32+
- script: "all_codepoints.grep(UNASSIGNED_3_2)"
33+
- script: "all_codepoints.grep_v(ASSIGNED_3_2)"
34+
35+
# doesn't match A.1
36+
- script: "all_codepoints.grep_v(TABLE_A1_SET)"
37+
- script: "all_codepoints.grep_v(TABLE_A1_REGEX)"
38+
- script: "all_codepoints.grep_v(UNASSIGNED_3_2)"
39+
- script: "all_codepoints.grep(ASSIGNED_3_2)"

lib/net/imap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,3 +1528,4 @@ def start_tls_session(params = {})
15281528
require_relative "imap/response_data"
15291529
require_relative "imap/response_parser"
15301530
require_relative "imap/authenticators"
1531+
require_relative "imap/sasl"

lib/net/imap/sasl.rb

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
module Net
4+
class IMAP
5+
6+
# Pluggable authentication mechanisms for protocols which support SASL
7+
# (Simple Authentication and Security Layer), such as IMAP4, SMTP, LDAP, and
8+
# XMPP. {RFC-4422}[https://tools.ietf.org/html/rfc4422] specifies the
9+
# common SASL framework and the +EXTERNAL+ mechanism, and the
10+
# {SASL mechanism registry}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]
11+
# lists the specification for others.
12+
#
13+
# "SASL is conceptually a framework that provides an abstraction layer
14+
# between protocols and mechanisms as illustrated in the following diagram."
15+
#
16+
# SMTP LDAP XMPP Other protocols ...
17+
# \ | | /
18+
# \ | | /
19+
# SASL abstraction layer
20+
# / | | \
21+
# / | | \
22+
# EXTERNAL GSSAPI PLAIN Other mechanisms ...
23+
#
24+
module SASL
25+
26+
# autoloading to avoid loading all of the regexps when they aren't used.
27+
28+
autoload :StringPrep, File.expand_path("sasl/stringprep", __dir__)
29+
autoload :SASLprep, File.expand_path("#{__dir__}/sasl/saslprep", __dir__)
30+
31+
# ArgumentError raised when +string+ is invalid for the stringprep
32+
# +profile+.
33+
class StringPrepError < ArgumentError
34+
attr_reader :string, :profile
35+
36+
def initialize(*args, string: nil, profile: nil)
37+
@string = -string.to_str unless string.nil?
38+
@profile = -profile.to_str unless profile.nil?
39+
super(*args)
40+
end
41+
end
42+
43+
# StringPrepError raised when +string+ contains a codepoint prohibited by
44+
# +table+.
45+
class ProhibitedCodepoint < StringPrepError
46+
attr_reader :table
47+
48+
def initialize(table, *args, **kwargs)
49+
@table = -table.to_str
50+
details = (title = StringPrep::TABLE_TITLES[table]) ?
51+
"%s [%s]" % [title, table] : table
52+
message = "String contains a prohibited codepoint: %s" % [details]
53+
super(message, *args, **kwargs)
54+
end
55+
end
56+
57+
# StringPrepError raised when +string+ contains bidirectional characters
58+
# which violate the StringPrep requirements.
59+
class BidiStringError < StringPrepError
60+
end
61+
62+
#--
63+
# We could just extend SASLprep module directly. It's done this way so
64+
# SASLprep can be lazily autoloaded. Most users won't need it.
65+
#++
66+
extend self
67+
68+
# See SASLprep#saslprep.
69+
def saslprep(string, **opts)
70+
SASLprep.saslprep(string, **opts)
71+
end
72+
73+
end
74+
end
75+
76+
end
77+
78+
Net::IMAP.extend Net::IMAP::SASL

lib/net/imap/sasl/saslprep.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "saslprep_tables"
4+
5+
module Net::IMAP::SASL
6+
7+
# SASLprep#saslprep can be used to prepare a string according to [RFC4013].
8+
#
9+
# \SASLprep maps characters three ways: to nothing, to space, and Unicode
10+
# normalization form KC. \SASLprep prohibits codepoints from nearly all
11+
# standard StringPrep tables (RFC3454, Appendix "C"), and uses \StringPrep's
12+
# standard bidirectional characters requirements (Appendix "D"). \SASLprep
13+
# also uses \StringPrep's definition of "Unassigned" codepoints (Appendix "A").
14+
module SASLprep
15+
16+
# Used to short-circuit strings that don't need preparation.
17+
ASCII_NO_CTRLS = /\A[\x20-\x7e]*\z/u.freeze
18+
19+
module_function
20+
21+
# Prepares a UTF-8 +string+ for comparison, using the \SASLprep profile
22+
# RFC4013 of the StringPrep algorithm RFC3454.
23+
#
24+
# By default, prohibited strings will return +nil+. When +exception+ is
25+
# +true+, a StringPrepError describing the violation will be raised.
26+
#
27+
# When +stored+ is +true+, "unassigned" codepoints will be prohibited. For
28+
# \StringPrep and the \SASLprep profile, "unassigned" refers to Unicode 3.2,
29+
# and not later versions. See RFC3454 §7 for more information.
30+
#
31+
def saslprep(str, stored: false, exception: false)
32+
return str if ASCII_NO_CTRLS.match?(str) # raises on incompatible encoding
33+
str = str.encode("UTF-8") # also dups (and raises for invalid encoding)
34+
str.gsub!(MAP_TO_SPACE, " ")
35+
str.gsub!(MAP_TO_NOTHING, "")
36+
str.unicode_normalize!(:nfkc)
37+
# These regexps combine the prohibited and bidirectional checks
38+
return str unless str.match?(stored ? PROHIBITED_STORED : PROHIBITED)
39+
return nil unless exception
40+
# raise helpful errors to indicate *why* it failed:
41+
tables = stored ? TABLES_PROHIBITED_STORED : TABLES_PROHIBITED
42+
StringPrep.check_prohibited! str, *tables, bidi: true, profile: "SASLprep"
43+
raise StringPrep::InvalidStringError.new(
44+
"unknown error", string: string, profile: "SASLprep"
45+
)
46+
rescue ArgumentError, Encoding::CompatibilityError => ex
47+
if /invalid byte sequence|incompatible encoding/.match? ex.message
48+
return nil unless exception
49+
raise StringPrepError.new(ex.message, string: str, profile: "saslprep")
50+
end
51+
raise ex
52+
end
53+
54+
end
55+
end

0 commit comments

Comments
 (0)