xcrypt 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ # lt~obsolete.m4 -- aclocal satisfying obsolete definitions. -*-Autoconf-*-
2
+ #
3
+ # Copyright (C) 2004-2005, 2007, 2009, 2011-2019, 2021-2024 Free
4
+ # Software Foundation, Inc.
5
+ # Written by Scott James Remnant, 2004.
6
+ #
7
+ # This file is free software; the Free Software Foundation gives
8
+ # unlimited permission to copy and/or distribute it, with or without
9
+ # modifications, as long as this notice is preserved.
10
+
11
+ # serial 5 lt~obsolete.m4
12
+
13
+ # These exist entirely to fool aclocal when bootstrapping libtool.
14
+ #
15
+ # In the past libtool.m4 has provided macros via AC_DEFUN (or AU_DEFUN),
16
+ # which have later been changed to m4_define as they aren't part of the
17
+ # exported API, or moved to Autoconf or Automake where they belong.
18
+ #
19
+ # The trouble is, aclocal is a bit thick. It'll see the old AC_DEFUN
20
+ # in /usr/share/aclocal/libtool.m4 and remember it, then when it sees us
21
+ # using a macro with the same name in our local m4/libtool.m4 it'll
22
+ # pull the old libtool.m4 in (it doesn't see our shiny new m4_define
23
+ # and doesn't know about Autoconf macros at all.)
24
+ #
25
+ # So we provide this file, which has a silly filename so it's always
26
+ # included after everything else. This provides aclocal with the
27
+ # AC_DEFUNs it wants, but when m4 processes it, it doesn't do anything
28
+ # because those macros already exist, or will be overwritten later.
29
+ # We use AC_DEFUN over AU_DEFUN for compatibility with aclocal-1.6.
30
+ #
31
+ # Anytime we withdraw an AC_DEFUN or AU_DEFUN, remember to add it here.
32
+ # Yes, that means every name once taken will need to remain here until
33
+ # we give up compatibility with versions before 1.7, at which point
34
+ # we need to keep only those names which we still refer to.
35
+
36
+ # This is to help aclocal find these macros, as it can't see m4_define.
37
+ AC_DEFUN([LTOBSOLETE_VERSION], [m4_if([1])])
38
+
39
+ m4_ifndef([AC_LIBTOOL_LINKER_OPTION], [AC_DEFUN([AC_LIBTOOL_LINKER_OPTION])])
40
+ m4_ifndef([AC_PROG_EGREP], [AC_DEFUN([AC_PROG_EGREP])])
41
+ m4_ifndef([_LT_AC_PROG_ECHO_BACKSLASH], [AC_DEFUN([_LT_AC_PROG_ECHO_BACKSLASH])])
42
+ m4_ifndef([_LT_AC_SHELL_INIT], [AC_DEFUN([_LT_AC_SHELL_INIT])])
43
+ m4_ifndef([_LT_AC_SYS_LIBPATH_AIX], [AC_DEFUN([_LT_AC_SYS_LIBPATH_AIX])])
44
+ m4_ifndef([_LT_PROG_LTMAIN], [AC_DEFUN([_LT_PROG_LTMAIN])])
45
+ m4_ifndef([_LT_AC_TAGVAR], [AC_DEFUN([_LT_AC_TAGVAR])])
46
+ m4_ifndef([AC_LTDL_ENABLE_INSTALL], [AC_DEFUN([AC_LTDL_ENABLE_INSTALL])])
47
+ m4_ifndef([AC_LTDL_PREOPEN], [AC_DEFUN([AC_LTDL_PREOPEN])])
48
+ m4_ifndef([_LT_AC_SYS_COMPILER], [AC_DEFUN([_LT_AC_SYS_COMPILER])])
49
+ m4_ifndef([_LT_AC_LOCK], [AC_DEFUN([_LT_AC_LOCK])])
50
+ m4_ifndef([AC_LIBTOOL_SYS_OLD_ARCHIVE], [AC_DEFUN([AC_LIBTOOL_SYS_OLD_ARCHIVE])])
51
+ m4_ifndef([_LT_AC_TRY_DLOPEN_SELF], [AC_DEFUN([_LT_AC_TRY_DLOPEN_SELF])])
52
+ m4_ifndef([AC_LIBTOOL_PROG_CC_C_O], [AC_DEFUN([AC_LIBTOOL_PROG_CC_C_O])])
53
+ m4_ifndef([AC_LIBTOOL_SYS_HARD_LINK_LOCKS], [AC_DEFUN([AC_LIBTOOL_SYS_HARD_LINK_LOCKS])])
54
+ m4_ifndef([AC_LIBTOOL_OBJDIR], [AC_DEFUN([AC_LIBTOOL_OBJDIR])])
55
+ m4_ifndef([AC_LTDL_OBJDIR], [AC_DEFUN([AC_LTDL_OBJDIR])])
56
+ m4_ifndef([AC_LIBTOOL_PROG_LD_HARDCODE_LIBPATH], [AC_DEFUN([AC_LIBTOOL_PROG_LD_HARDCODE_LIBPATH])])
57
+ m4_ifndef([AC_LIBTOOL_SYS_LIB_STRIP], [AC_DEFUN([AC_LIBTOOL_SYS_LIB_STRIP])])
58
+ m4_ifndef([AC_PATH_MAGIC], [AC_DEFUN([AC_PATH_MAGIC])])
59
+ m4_ifndef([AC_PROG_LD_GNU], [AC_DEFUN([AC_PROG_LD_GNU])])
60
+ m4_ifndef([AC_PROG_LD_RELOAD_FLAG], [AC_DEFUN([AC_PROG_LD_RELOAD_FLAG])])
61
+ m4_ifndef([AC_DEPLIBS_CHECK_METHOD], [AC_DEFUN([AC_DEPLIBS_CHECK_METHOD])])
62
+ m4_ifndef([AC_LIBTOOL_PROG_COMPILER_NO_RTTI], [AC_DEFUN([AC_LIBTOOL_PROG_COMPILER_NO_RTTI])])
63
+ m4_ifndef([AC_LIBTOOL_SYS_GLOBAL_SYMBOL_PIPE], [AC_DEFUN([AC_LIBTOOL_SYS_GLOBAL_SYMBOL_PIPE])])
64
+ m4_ifndef([AC_LIBTOOL_PROG_COMPILER_PIC], [AC_DEFUN([AC_LIBTOOL_PROG_COMPILER_PIC])])
65
+ m4_ifndef([AC_LIBTOOL_PROG_LD_SHLIBS], [AC_DEFUN([AC_LIBTOOL_PROG_LD_SHLIBS])])
66
+ m4_ifndef([AC_LIBTOOL_POSTDEP_PREDEP], [AC_DEFUN([AC_LIBTOOL_POSTDEP_PREDEP])])
67
+ m4_ifndef([LT_AC_PROG_EGREP], [AC_DEFUN([LT_AC_PROG_EGREP])])
68
+ m4_ifndef([LT_AC_PROG_SED], [AC_DEFUN([LT_AC_PROG_SED])])
69
+ m4_ifndef([_LT_CC_BASENAME], [AC_DEFUN([_LT_CC_BASENAME])])
70
+ m4_ifndef([_LT_COMPILER_BOILERPLATE], [AC_DEFUN([_LT_COMPILER_BOILERPLATE])])
71
+ m4_ifndef([_LT_LINKER_BOILERPLATE], [AC_DEFUN([_LT_LINKER_BOILERPLATE])])
72
+ m4_ifndef([_AC_PROG_LIBTOOL], [AC_DEFUN([_AC_PROG_LIBTOOL])])
73
+ m4_ifndef([AC_LIBTOOL_SETUP], [AC_DEFUN([AC_LIBTOOL_SETUP])])
74
+ m4_ifndef([_LT_AC_CHECK_DLFCN], [AC_DEFUN([_LT_AC_CHECK_DLFCN])])
75
+ m4_ifndef([AC_LIBTOOL_SYS_DYNAMIC_LINKER], [AC_DEFUN([AC_LIBTOOL_SYS_DYNAMIC_LINKER])])
76
+ m4_ifndef([_LT_AC_TAGCONFIG], [AC_DEFUN([_LT_AC_TAGCONFIG])])
77
+ m4_ifndef([AC_DISABLE_FAST_INSTALL], [AC_DEFUN([AC_DISABLE_FAST_INSTALL])])
78
+ m4_ifndef([_LT_AC_LANG_CXX], [AC_DEFUN([_LT_AC_LANG_CXX])])
79
+ m4_ifndef([_LT_AC_LANG_F77], [AC_DEFUN([_LT_AC_LANG_F77])])
80
+ m4_ifndef([_LT_AC_LANG_GCJ], [AC_DEFUN([_LT_AC_LANG_GCJ])])
81
+ m4_ifndef([AC_LIBTOOL_LANG_C_CONFIG], [AC_DEFUN([AC_LIBTOOL_LANG_C_CONFIG])])
82
+ m4_ifndef([_LT_AC_LANG_C_CONFIG], [AC_DEFUN([_LT_AC_LANG_C_CONFIG])])
83
+ m4_ifndef([AC_LIBTOOL_LANG_CXX_CONFIG], [AC_DEFUN([AC_LIBTOOL_LANG_CXX_CONFIG])])
84
+ m4_ifndef([_LT_AC_LANG_CXX_CONFIG], [AC_DEFUN([_LT_AC_LANG_CXX_CONFIG])])
85
+ m4_ifndef([AC_LIBTOOL_LANG_F77_CONFIG], [AC_DEFUN([AC_LIBTOOL_LANG_F77_CONFIG])])
86
+ m4_ifndef([_LT_AC_LANG_F77_CONFIG], [AC_DEFUN([_LT_AC_LANG_F77_CONFIG])])
87
+ m4_ifndef([AC_LIBTOOL_LANG_GCJ_CONFIG], [AC_DEFUN([AC_LIBTOOL_LANG_GCJ_CONFIG])])
88
+ m4_ifndef([_LT_AC_LANG_GCJ_CONFIG], [AC_DEFUN([_LT_AC_LANG_GCJ_CONFIG])])
89
+ m4_ifndef([AC_LIBTOOL_LANG_RC_CONFIG], [AC_DEFUN([AC_LIBTOOL_LANG_RC_CONFIG])])
90
+ m4_ifndef([_LT_AC_LANG_RC_CONFIG], [AC_DEFUN([_LT_AC_LANG_RC_CONFIG])])
91
+ m4_ifndef([AC_LIBTOOL_CONFIG], [AC_DEFUN([AC_LIBTOOL_CONFIG])])
92
+ m4_ifndef([_LT_AC_FILE_LTDLL_C], [AC_DEFUN([_LT_AC_FILE_LTDLL_C])])
93
+ m4_ifndef([_LT_REQUIRED_DARWIN_CHECKS], [AC_DEFUN([_LT_REQUIRED_DARWIN_CHECKS])])
94
+ m4_ifndef([_LT_AC_PROG_CXXCPP], [AC_DEFUN([_LT_AC_PROG_CXXCPP])])
95
+ m4_ifndef([_LT_PREPARE_SED_QUOTE_VARS], [AC_DEFUN([_LT_PREPARE_SED_QUOTE_VARS])])
96
+ m4_ifndef([_LT_PROG_ECHO_BACKSLASH], [AC_DEFUN([_LT_PROG_ECHO_BACKSLASH])])
97
+ m4_ifndef([_LT_PROG_F77], [AC_DEFUN([_LT_PROG_F77])])
98
+ m4_ifndef([_LT_PROG_FC], [AC_DEFUN([_LT_PROG_FC])])
99
+ m4_ifndef([_LT_PROG_CXX], [AC_DEFUN([_LT_PROG_CXX])])
data/lib/xcrypt/ffi.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ffi"
4
- require "ffi-compiler/loader"
5
4
 
6
5
  module XCrypt
7
6
  # Low-level FFI bindings for libxcrypt.
@@ -9,9 +8,9 @@ module XCrypt
9
8
  # Consumers should use the high-level {XCrypt} module methods instead of
10
9
  # calling into this module directly.
11
10
  #
12
- # The shared library loaded here is compiled from the libxcrypt submodule
13
- # (ext/libxcrypt) via ffi-compiler. Run <tt>bundle exec rake compile</tt>
14
- # to build it before loading this gem.
11
+ # The shared library loaded here is built from the libxcrypt submodule
12
+ # (ext/libxcrypt). Run <tt>bundle exec rake compile</tt> to build it
13
+ # before loading this gem.
15
14
  module FFI
16
15
  extend ::FFI::Library
17
16
 
@@ -32,11 +31,15 @@ module XCrypt
32
31
  CRYPT_SALT_METHOD_LEGACY = 3
33
32
  CRYPT_SALT_TOO_CHEAP = 4
34
33
 
35
- # Load the native extension compiled from the libxcrypt submodule.
36
- # FFI::Compiler::Loader searches for lib<arch>-<os>/libxcrypt.{bundle,so}
37
- # relative to this file's location.
34
+ # Load the shared library built from the libxcrypt submodule.
35
+ # It lives in a platform-specific subdirectory next to this file.
38
36
  begin
39
- ffi_lib ::FFI::Compiler::Loader.find("xcrypt")
37
+ _ext = ::FFI::Platform.mac? ? "bundle" : "so"
38
+ _lib = File.expand_path(
39
+ "../#{::FFI::Platform::ARCH}-#{::FFI::Platform::OS}/libxcrypt.#{_ext}",
40
+ __FILE__
41
+ )
42
+ ffi_lib _lib
40
43
  rescue LoadError
41
44
  raise LoadError,
42
45
  "XCrypt native extension not found. " \
@@ -70,6 +73,7 @@ module XCrypt
70
73
  # Returns the prefix string of the library's preferred (strongest)
71
74
  # hashing method.
72
75
  attach_function :crypt_preferred_method, [], :string
76
+
73
77
  end
74
78
 
75
79
  private_constant :FFI
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XCrypt
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module XCrypt
6
+ # Setting-string generation for the yescrypt and scrypt algorithms.
7
+ #
8
+ # Both algorithms share a base-64 alphabet and encoding scheme taken from
9
+ # libxcrypt's +alg-yescrypt-common.c+. The public methods produce setting
10
+ # strings that can be passed directly to {XCrypt.crypt}.
11
+ #
12
+ # @example Generate a $y$ yescrypt setting
13
+ # setting = XCrypt::Yescrypt.generate_setting(n: 16384, r: 8, p: 1)
14
+ # hash = XCrypt.crypt("hunter2", setting)
15
+ #
16
+ # @example Generate a $7$ scrypt setting
17
+ # setting = XCrypt::Yescrypt.generate_scrypt_setting(n: 16384, r: 32, p: 1)
18
+ # hash = XCrypt.crypt("hunter2", setting)
19
+ module Yescrypt
20
+ extend self
21
+
22
+ # -------------------------------------------------------------------------
23
+ # Flag constants — mirrors alg-yescrypt.h
24
+ #
25
+ # These may be OR'd together to form the +flags:+ argument of
26
+ # {generate_setting}, except that {WORM} stands alone (do not combine
27
+ # with {RW}).
28
+ # -------------------------------------------------------------------------
29
+
30
+ # Classic scrypt with minimal extensions (t parameter support only).
31
+ WORM = 0x001
32
+
33
+ # Full yescrypt mode — time-memory tradeoff resistant.
34
+ RW = 0x002
35
+
36
+ # Number of inner rounds.
37
+ ROUNDS_3 = 0x000
38
+ ROUNDS_6 = 0x004
39
+
40
+ # Memory-access gather width.
41
+ GATHER_1 = 0x000
42
+ GATHER_2 = 0x008
43
+ GATHER_4 = 0x010
44
+ GATHER_8 = 0x018
45
+
46
+ # Simple mix factor.
47
+ SIMPLE_1 = 0x000
48
+ SIMPLE_2 = 0x020
49
+ SIMPLE_4 = 0x040
50
+ SIMPLE_8 = 0x060
51
+
52
+ # S-box size.
53
+ SBOX_6K = 0x000
54
+ SBOX_12K = 0x080
55
+ SBOX_24K = 0x100
56
+ SBOX_48K = 0x180
57
+ SBOX_96K = 0x200
58
+ SBOX_192K = 0x280
59
+ SBOX_384K = 0x300
60
+ SBOX_768K = 0x380
61
+
62
+ # Recommended defaults: RW mode with 6 rounds, 4-wide gather, 2x simple
63
+ # mix, and a 12 KiB S-box.
64
+ DEFAULTS = RW | ROUNDS_6 | GATHER_4 | SIMPLE_2 | SBOX_12K
65
+
66
+ # -------------------------------------------------------------------------
67
+ # Public interface
68
+ # -------------------------------------------------------------------------
69
+
70
+ # Generates a +$y$+ yescrypt setting string from explicit parameters.
71
+ #
72
+ # Implements the encoding of libxcrypt's +yescrypt_encode_params_r+
73
+ # (alg-yescrypt-common.c) in pure Ruby, because that function is not
74
+ # exported on Linux.
75
+ #
76
+ # @param n [Integer] memory/CPU cost; must be a power of 2 greater than 1
77
+ # @param r [Integer] block size (default: 8)
78
+ # @param p [Integer] parallelism (default: 1)
79
+ # @param t [Integer] additional time cost (default: 0)
80
+ # @param flags [Integer] yescrypt mode flags; see +WORM+/+RW+/+ROUNDS_*+
81
+ # etc.; defaults to {DEFAULTS}
82
+ # @return [String] a +$y$+ setting string
83
+ # @raise [ArgumentError] if +n+ is not a valid power of 2, or if +flags+
84
+ # is an unsupported combination
85
+ def generate_setting(n:, r: 8, p: 1, t: 0, flags: DEFAULTS)
86
+ n_log2 = log2_of_power_of_2(n)
87
+
88
+ # Compute the "flavor" field exactly as yescrypt_encode_params_r does:
89
+ # flags < RW → flavor = flags (WORM / pure-scrypt modes)
90
+ # flags is valid RW → flavor = RW + (flags >> 2)
91
+ flavor =
92
+ if flags < RW
93
+ flags
94
+ elsif (flags & 0x3) == RW && flags <= (RW | 0x3fc)
95
+ RW + (flags >> 2)
96
+ else
97
+ raise ArgumentError, "invalid yescrypt flags: 0x#{flags.to_s(16)}"
98
+ end
99
+
100
+ # "have" bitmask indicates which optional fields follow r.
101
+ have = 0
102
+ have |= 1 if p != 1
103
+ have |= 2 if t != 0
104
+
105
+ setting = +"$y$"
106
+ setting << encode_varint(flavor, 0)
107
+ setting << encode_varint(n_log2, 1)
108
+ setting << encode_varint(r, 1)
109
+ if have != 0
110
+ setting << encode_varint(have, 1)
111
+ setting << encode_varint(p, 2) if p != 1
112
+ setting << encode_varint(t, 1) if t != 0
113
+ end
114
+ setting << "$"
115
+ setting << encode_bytes(SecureRandom.random_bytes(32))
116
+ setting
117
+ end
118
+
119
+ # Generates a +$7$+ scrypt setting string from explicit parameters.
120
+ #
121
+ # Encodes the setting using the same base-64 alphabet and field layout as
122
+ # libxcrypt's +gensalt_scrypt_rn+ (crypt-scrypt.c).
123
+ #
124
+ # @param n [Integer] memory/CPU cost; must be a power of 2 greater than 1
125
+ # @param r [Integer] block size (default: 32)
126
+ # @param p [Integer] parallelism (default: 1)
127
+ # @return [String] a +$7$+ setting string
128
+ # @raise [ArgumentError] if +n+ is not a valid power of 2
129
+ def generate_scrypt_setting(n:, r: 32, p: 1)
130
+ n_log2 = log2_of_power_of_2(n)
131
+
132
+ setting = +"$7$"
133
+ setting << B64[n_log2]
134
+ setting << encode_uint32(r, 30)
135
+ setting << encode_uint32(p, 30)
136
+ setting << encode_bytes(SecureRandom.random_bytes(32))
137
+ setting
138
+ end
139
+
140
+ private
141
+
142
+ # Base-64 alphabet shared by yescrypt and scrypt (crypt-style, not RFC 4648).
143
+ B64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
144
+
145
+ # Returns log2(n) after validating that n is a power of 2 greater than 1.
146
+ def log2_of_power_of_2(n)
147
+ n_log2 = Integer(Math.log2(n).round)
148
+ raise ArgumentError, "n must be a power of 2 greater than 1 (got #{n})" unless (1 << n_log2) == n && n > 1
149
+ n_log2
150
+ end
151
+
152
+ # Variable-length base-64 encoding for yescrypt parameter fields
153
+ # (encode64_uint32 in alg-yescrypt-common.c, last argument = min).
154
+ #
155
+ # The first character of the output encodes both the number of subsequent
156
+ # characters and the most-significant bits. The character ranges used are:
157
+ # 1 char : indices 0..47 (48 distinct values)
158
+ # 2 chars: indices 48..56 (9 × 64 = 576 additional values)
159
+ # 3 chars: indices 57..60 (4 × 64² = 16 384 additional values), …
160
+ def encode_varint(src, min)
161
+ raise ArgumentError, "value #{src} is below minimum #{min}" if src < min
162
+
163
+ src -= min
164
+ start = 0
165
+ endv = 47
166
+ chars = 1
167
+ bits = 0
168
+
169
+ loop do
170
+ count = (endv + 1 - start) << bits
171
+ break if src < count
172
+ raise ArgumentError, "value too large for yescrypt varint encoding" if start >= 63
173
+
174
+ start = endv + 1
175
+ endv = start + (62 - endv) / 2
176
+ src -= count
177
+ chars += 1
178
+ bits += 6
179
+ end
180
+
181
+ result = +B64[start + (src >> bits)]
182
+ (chars - 1).times { bits -= 6; result << B64[(src >> bits) & 0x3f] }
183
+ result
184
+ end
185
+
186
+ # Fixed-width base-64 encoding of a 32-bit value using +srcbits+ bits,
187
+ # LSB first (ceil(srcbits/6) output characters).
188
+ # Used for scrypt's r and p fields (encode64_uint32 in crypt-scrypt.c).
189
+ def encode_uint32(value, srcbits)
190
+ out = +""
191
+ bits = 0
192
+ while bits < srcbits
193
+ out << B64[value & 0x3f]
194
+ value >>= 6
195
+ bits += 6
196
+ end
197
+ out
198
+ end
199
+
200
+ # Encodes a binary string using the fixed-width base-64 scheme, processing
201
+ # 3 bytes (24 bits) into 4 characters at a time (encode64 in both
202
+ # alg-yescrypt-common.c and crypt-scrypt.c).
203
+ def encode_bytes(bytes)
204
+ out = +""
205
+ i = 0
206
+ while i < bytes.bytesize
207
+ value = 0
208
+ bits = 0
209
+ while bits < 24 && i < bytes.bytesize
210
+ value |= bytes.getbyte(i) << bits
211
+ bits += 8
212
+ i += 1
213
+ end
214
+ out << encode_uint32(value, bits)
215
+ end
216
+ out
217
+ end
218
+ end
219
+ end
data/lib/xcrypt.rb CHANGED
@@ -1,11 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Top-level module providing a high-level Ruby interface to libxcrypt, a
4
+ # modern library for one-way hashing of passwords.
5
+ #
6
+ # All public methods are available directly on the module.
7
+ # The most common entry points are the algorithm-specific convenience methods
8
+ # ({yescrypt}, {bcrypt}, {sha512}, etc.) and {verify}.
9
+ #
10
+ # @example Hash a password with yescrypt (the strongest supported algorithm)
11
+ # hash = XCrypt.yescrypt("correct horse battery staple")
12
+ # XCrypt.verify("correct horse battery staple", hash) #=> true
13
+ #
14
+ # @example Hash with an explicit cost factor
15
+ # hash = XCrypt.bcrypt("hunter2", cost: 12)
16
+ #
17
+ # @example Use the generic interface
18
+ # hash = XCrypt.crypt("hunter2", algorithm: :sha512)
19
+ #
20
+ # @example Generate a yescrypt setting with explicit N, r, p, t, and flags
21
+ # setting = XCrypt.generate_setting(:yescrypt, n: 16384, r: 8, p: 1, t: 0,
22
+ # flags: XCrypt::YESCRYPT_DEFAULTS)
23
+ # hash = XCrypt.crypt("hunter2", setting)
24
+ #
25
+ # @example Generate a scrypt ($7$) setting with explicit N, r, p
26
+ # setting = XCrypt.generate_setting(:scrypt, n: 16384, r: 32, p: 1)
27
+ # hash = XCrypt.crypt("hunter2", setting)
3
28
  module XCrypt
4
29
  require "xcrypt/version"
5
30
  require "xcrypt/ffi"
31
+ require "xcrypt/yescrypt"
6
32
 
33
+ # Raised when hashing or salt generation fails. Common causes include an
34
+ # unsupported algorithm, a malformed setting string, or a passphrase that
35
+ # exceeds {FFI::CRYPT_MAX_PASSPHRASE_SIZE} bytes.
7
36
  Error ||= Class.new(StandardError)
8
37
 
38
+ # Maps each supported algorithm name to its setting-string prefix.
39
+ #
40
+ # The prefix is the leading characters of any hash produced by that
41
+ # algorithm and is used to identify the algorithm from an existing hash.
42
+ #
43
+ # @return [Hash{Symbol => String}]
9
44
  ALGORITHMS = {
10
45
  yescrypt: "$y$",
11
46
  gost_yescrypt: "$gy$",
@@ -25,25 +60,131 @@ module XCrypt
25
60
 
26
61
  extend self
27
62
 
63
+ # @!method yescrypt(phrase, setting = nil, cost: nil)
64
+ # Hash +phrase+ using yescrypt, the strongest supported algorithm.
65
+ # @param phrase [String] the password to hash
66
+ # @param setting [String, nil] an existing hash or salt string to use as
67
+ # the setting; a new setting is generated automatically when +nil+
68
+ # @param cost [Integer, nil] work-factor override; uses the library
69
+ # default when +nil+
70
+ # @return [String] the hashed password
71
+ # @raise [ArgumentError] if +setting+ belongs to a different algorithm
72
+ # @raise [Error] if hashing fails
73
+
74
+ # @!method gost_yescrypt(phrase, setting = nil, cost: nil)
75
+ # Hash +phrase+ using GOST R 34.11-2012 combined with yescrypt.
76
+ # @param (see #yescrypt)
77
+ # @return (see #yescrypt)
78
+ # @raise (see #yescrypt)
79
+
80
+ # @!method scrypt(phrase, setting = nil, cost: nil)
81
+ # Hash +phrase+ using scrypt.
82
+ # @param (see #yescrypt)
83
+ # @return (see #yescrypt)
84
+ # @raise (see #yescrypt)
85
+
86
+ # @!method bcrypt(phrase, setting = nil, cost: nil)
87
+ # Hash +phrase+ using bcrypt (Blowfish-based password hashing).
88
+ # @param (see #yescrypt)
89
+ # @return (see #yescrypt)
90
+ # @raise (see #yescrypt)
91
+
92
+ # @!method sha512(phrase, setting = nil, cost: nil)
93
+ # Hash +phrase+ using SHA-512 crypt.
94
+ # @param (see #yescrypt)
95
+ # @return (see #yescrypt)
96
+ # @raise (see #yescrypt)
97
+
98
+ # @!method sha256(phrase, setting = nil, cost: nil)
99
+ # Hash +phrase+ using SHA-256 crypt.
100
+ # @param (see #yescrypt)
101
+ # @return (see #yescrypt)
102
+ # @raise (see #yescrypt)
103
+
104
+ # @!method sha1(phrase, setting = nil, cost: nil)
105
+ # Hash +phrase+ using HMAC-SHA1 NetBSD crypt.
106
+ # @param (see #yescrypt)
107
+ # @return (see #yescrypt)
108
+ # @raise (see #yescrypt)
109
+
110
+ # @!method sun_md5(phrase, setting = nil, cost: nil)
111
+ # Hash +phrase+ using SunMD5 (Solaris MD5 crypt).
112
+ # @param (see #yescrypt)
113
+ # @return (see #yescrypt)
114
+ # @raise (see #yescrypt)
115
+
116
+ # @!method md5(phrase, setting = nil, cost: nil)
117
+ # Hash +phrase+ using MD5 crypt.
118
+ # @param (see #yescrypt)
119
+ # @return (see #yescrypt)
120
+ # @raise (see #yescrypt)
121
+
122
+ # @!method bsdi_des(phrase, setting = nil, cost: nil)
123
+ # Hash +phrase+ using BSDi extended DES crypt.
124
+ # @param (see #yescrypt)
125
+ # @return (see #yescrypt)
126
+ # @raise (see #yescrypt)
127
+
128
+ # @!method des(phrase, setting = nil, cost: nil)
129
+ # Hash +phrase+ using traditional DES crypt.
130
+ # @param (see #yescrypt)
131
+ # @return (see #yescrypt)
132
+ # @raise (see #yescrypt)
133
+
28
134
  ALGORITHMS.each_key do |algorithm|
29
- define_method(algorithm) do |phrase, setting = nil, cost: nil|
135
+ define_method(algorithm) do |phrase, setting = nil, cost: nil, n: nil, r: nil, p: nil, t: nil, flags: nil|
30
136
  if setting
31
137
  setting_algorithm = detect_algorithm(setting)
32
138
  if setting_algorithm != algorithm
33
139
  raise ArgumentError, "setting algorithm #{setting_algorithm.inspect} does not match expected #{algorithm.inspect}"
34
140
  end
35
141
  end
36
- crypt(phrase, setting, algorithm:, cost:)
142
+ crypt(phrase, setting, algorithm:, cost:, n:, r:, p:, t:, flags:)
37
143
  end
38
144
  end
39
145
 
146
+ # Returns the names of all supported algorithms.
147
+ #
148
+ # @return [Array<Symbol>] algorithm names in order from strongest to weakest
40
149
  def algorithms = ALGORITHMS.keys
41
150
 
151
+ # Detects which algorithm produced a given setting or hash string by
152
+ # matching its leading prefix against {ALGORITHMS}.
153
+ #
154
+ # @param setting [String] a setting string or an existing password hash
155
+ # @return [Symbol, nil] the algorithm name, or +nil+ if the prefix is
156
+ # unrecognized
42
157
  def detect_algorithm(setting) = PREFIXES[setting[/\A\$\w+\$?|_/].to_s]
43
158
 
44
- def crypt(phrase, setting = nil, algorithm: nil, cost: nil)
159
+ # Hashes +phrase+ using libxcrypt's +crypt_rn+ function.
160
+ #
161
+ # When both +setting+ and +algorithm+ are omitted, a fresh setting is
162
+ # generated with the library's default algorithm. The result is always a
163
+ # self-describing string whose leading prefix identifies the algorithm and
164
+ # encodes the salt, making it safe to store directly.
165
+ #
166
+ # @param phrase [String] the password to hash
167
+ # @param setting [String, Symbol, nil] an existing hash or salt string, or
168
+ # an algorithm +Symbol+ as shorthand for passing only +algorithm:+;
169
+ # generates a fresh setting when +nil+
170
+ # @param algorithm [Symbol, nil] algorithm to use when generating a new
171
+ # setting; ignored when +setting+ is already a String
172
+ # @param cost [Integer, nil] work-factor override passed to
173
+ # {generate_setting}; uses the library default when +nil+
174
+ # @param n [Integer, nil] explicit N parameter for yescrypt/scrypt; passed
175
+ # to {generate_setting} when no +setting+ is provided
176
+ # @param r [Integer, nil] explicit r parameter; passed to {generate_setting}
177
+ # @param p [Integer, nil] explicit p parameter; passed to {generate_setting}
178
+ # @param t [Integer, nil] explicit t parameter (yescrypt only); passed to
179
+ # {generate_setting}
180
+ # @param flags [Integer, nil] explicit yescrypt flags; passed to
181
+ # {generate_setting}
182
+ # @return [String] the hashed password
183
+ # @raise [Error] if +crypt_rn+ returns +NULL+, indicating an invalid
184
+ # setting or an unsupported algorithm
185
+ def crypt(phrase, setting = nil, algorithm: nil, cost: nil, n: nil, r: nil, p: nil, t: nil, flags: nil)
45
186
  setting, algorithm = nil, setting if setting.is_a? Symbol
46
- setting ||= generate_setting(algorithm, cost:)
187
+ setting ||= generate_setting(algorithm, cost:, n:, r:, p:, t:, flags:)
47
188
  data = ::FFI::MemoryPointer.new(:uint8, FFI::CRYPT_DATA_SIZE)
48
189
  result_ptr = FFI.crypt_rn(phrase, setting, data, FFI::CRYPT_DATA_SIZE)
49
190
  raise Error, "crypt failed: invalid setting or unsupported algorithm" if result_ptr.null?
@@ -52,6 +193,17 @@ module XCrypt
52
193
  data&.clear
53
194
  end
54
195
 
196
+ # Verifies that +phrase+ matches a previously computed +hash+.
197
+ #
198
+ # Returns +false+ immediately for any hash value that would cause
199
+ # libxcrypt to return a magic failure token (strings beginning with +"*"+),
200
+ # or for empty or +nil+ input, guarding against invalid-hash oracle attacks.
201
+ # The final comparison is performed in constant time to prevent timing
202
+ # attacks.
203
+ #
204
+ # @param phrase [String] the candidate password
205
+ # @param hash [String, nil] the stored password hash to verify against
206
+ # @return [Boolean] +true+ if +phrase+ matches +hash+, +false+ otherwise
55
207
  def verify(phrase, hash)
56
208
  return false if hash.nil? || hash.empty? || hash.start_with?("*")
57
209
  result = crypt(phrase, hash)
@@ -60,8 +212,60 @@ module XCrypt
60
212
  false
61
213
  end
62
214
 
63
- def generate_setting(algorithm = nil, cost: nil)
64
- prefix = ALGORITHMS.fetch(algorithm) { raise ArgumentError, "unknown algorithm: #{algorithm.inspect}" } if algorithm
215
+ # Generates a fresh setting string suitable for passing to {crypt}.
216
+ #
217
+ # When only +algorithm+ and optionally +cost+ are given, delegates to
218
+ # libxcrypt's +crypt_gensalt_rn+, which draws entropy from the OS.
219
+ #
220
+ # When +n+, +r+, +p+, +t+, or +flags+ are supplied the method generates the
221
+ # setting directly from those parameters instead, delegating to
222
+ # {XCrypt::Yescrypt}:
223
+ #
224
+ # * For +:yescrypt+ (and +:gost_yescrypt+): delegates to
225
+ # {XCrypt::Yescrypt.generate_setting}, producing a +$y$+ setting.
226
+ # +n+ must be a power of 2 greater than 1; +r+, +p+, +t+, and +flags+
227
+ # default to 8, 1, 0, and {XCrypt::Yescrypt::DEFAULTS} respectively.
228
+ #
229
+ # * For +:scrypt+: delegates to {XCrypt::Yescrypt.generate_scrypt_setting},
230
+ # producing a +$7$+ setting. +n+ must be a power of 2 (2..2^63); +r+
231
+ # and +p+ default to 32 and 1. +t+ and +flags+ are not used for scrypt.
232
+ #
233
+ # When +algorithm+ is +nil+, the library selects its preferred algorithm.
234
+ #
235
+ # @param algorithm [Symbol, nil] the desired algorithm; uses the library
236
+ # default when +nil+
237
+ # @param cost [Integer, nil] work-factor for the generated setting; a value
238
+ # of +0+ selects the library's own default cost; ignored when +n:+ is set
239
+ # @param n [Integer, nil] explicit N (memory/CPU cost, must be a power of 2
240
+ # greater than 1); yescrypt and scrypt only
241
+ # @param r [Integer, nil] block size parameter; yescrypt and scrypt only
242
+ # @param p [Integer, nil] parallelism parameter; yescrypt and scrypt only
243
+ # @param t [Integer, nil] additional time cost; yescrypt only
244
+ # @param flags [Integer, nil] yescrypt mode flags; see {XCrypt::Yescrypt}
245
+ # constants; yescrypt only; defaults to {XCrypt::Yescrypt::DEFAULTS}
246
+ # @return [String] a setting string beginning with the algorithm prefix
247
+ # @raise [ArgumentError] if +algorithm+ is not a key in {ALGORITHMS}, or if
248
+ # +n+ is not a power of 2 greater than 1
249
+ # @raise [Error] if the underlying C call returns +NULL+
250
+ def generate_setting(algorithm = nil, cost: nil, n: nil, r: nil, p: nil, t: nil, flags: nil)
251
+ if algorithm
252
+ ALGORITHMS.key?(algorithm) or raise ArgumentError, "unknown algorithm: #{algorithm.inspect}"
253
+ end
254
+
255
+ if n || r || p || t || flags
256
+ case algorithm
257
+ when :yescrypt, :gost_yescrypt, nil
258
+ return Yescrypt.generate_setting(n: n || 4096, r: r || 8, p: p || 1,
259
+ t: t || 0, flags: flags || Yescrypt::DEFAULTS)
260
+ when :scrypt
261
+ return Yescrypt.generate_scrypt_setting(n: n || 16384, r: r || 32, p: p || 1)
262
+ else
263
+ raise ArgumentError,
264
+ "n/r/p/t/flags parameters are only supported for :yescrypt and :scrypt, got #{algorithm.inspect}"
265
+ end
266
+ end
267
+
268
+ prefix = ALGORITHMS[algorithm]
65
269
  cost ||= 0
66
270
 
67
271
  output = ::FFI::MemoryPointer.new(:char, FFI::CRYPT_GENSALT_OUTPUT_SIZE)
@@ -73,6 +277,19 @@ module XCrypt
73
277
 
74
278
  private
75
279
 
280
+ # Compares two strings in constant time to prevent timing attacks.
281
+ #
282
+ # Pads or truncates +trusted+ to match +untrusted+'s byte length before
283
+ # comparing so that the number of loop iterations is always the same
284
+ # regardless of content. A separate length check at the end ensures that a
285
+ # length-padded match is still rejected.
286
+ #
287
+ # Uses {OpenSSL.fixed_length_secure_compare} when available (Ruby >= 2.7
288
+ # with openssl >= 2.2); otherwise falls back to a pure-Ruby XOR loop.
289
+ #
290
+ # @param trusted [String] the known-good value (e.g., the output of {crypt})
291
+ # @param untrusted [String] the value supplied by the caller
292
+ # @return [Boolean] +true+ only when both strings are identical
76
293
  def secure_compare(trusted, untrusted)
77
294
  return false unless trusted.respond_to? :to_str and trusted = trusted.to_str.b
78
295
  return false unless untrusted.respond_to? :to_str and untrusted = untrusted.to_str.b