sqrl-core 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +164 -0
- data/lib/sqrl/core.rb +6 -0
- data/lib/sqrl/crypto.rb +166 -0
- data/lib/sqrl/errors.rb +15 -0
- data/lib/sqrl/identity.rb +143 -0
- data/lib/sqrl/protocol/session.rb +396 -0
- data/lib/sqrl/protocol.rb +433 -0
- data/lib/sqrl/server/config.rb +255 -0
- data/lib/sqrl/server/memory.rb +187 -0
- data/lib/sqrl/server/redis.rb +420 -0
- data/lib/sqrl/server/stores.rb +182 -0
- data/lib/sqrl/server.rb +487 -0
- data/lib/sqrl/storage.rb +624 -0
- data/lib/sqrl/test_vectors.rb +152 -0
- data/lib/sqrl/textual.rb +124 -0
- data/lib/sqrl/url.rb +261 -0
- data/lib/sqrl/util.rb +114 -0
- data/lib/sqrl/version.rb +6 -0
- data/lib/sqrl.rb +24 -0
- metadata +70 -0
data/lib/sqrl/storage.rb
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# The S4 ("SQRL Secure Storage System") identity file format.
|
|
5
|
+
#
|
|
6
|
+
# An identity file starts with the 8 byte header +sqrldata+ (binary) or +SQRLDATA+
|
|
7
|
+
# followed by base64url text, and then a sequence of length-prefixed blocks:
|
|
8
|
+
#
|
|
9
|
+
# * Type 1 (125 bytes): the password-protected IMK and ILK plus the user's settings.
|
|
10
|
+
# AES-256-GCM, key = EnScrypt(password, salt, logN, iterations), IV from the block,
|
|
11
|
+
# AAD = the 45 plaintext bytes of the block.
|
|
12
|
+
# * Type 2 (73 bytes): the rescue-code-protected IUK. AES-256-GCM, key =
|
|
13
|
+
# EnScrypt(rescue code digits, salt, logN, iterations), IV = 12 zero bytes,
|
|
14
|
+
# AAD = the 25 plaintext bytes of the block.
|
|
15
|
+
# * Type 3 (54/86/118/150 bytes): up to four previous IUKs. AES-256-GCM, key = IMK,
|
|
16
|
+
# IV = 12 zero bytes, AAD = the 6 plaintext bytes of the block.
|
|
17
|
+
#
|
|
18
|
+
# Unknown block types are preserved verbatim so that files written by other clients
|
|
19
|
+
# survive a round trip.
|
|
20
|
+
module Storage
|
|
21
|
+
# Binary file header.
|
|
22
|
+
HEADER_BINARY = "sqrldata".b.freeze
|
|
23
|
+
# Header of the base64url textual representation.
|
|
24
|
+
HEADER_TEXT = "SQRLDATA".b.freeze
|
|
25
|
+
|
|
26
|
+
# Type 1 option flag: the client should check for updates.
|
|
27
|
+
FLAG_CHECK_FOR_UPDATES = 0x0001
|
|
28
|
+
# Type 1 option flag: the client may update itself automatically.
|
|
29
|
+
FLAG_UPDATE_AUTOMATICALLY = 0x0002
|
|
30
|
+
# Type 1 option flag: request that sites disable non-SQRL authentication (+sqrlonly+).
|
|
31
|
+
FLAG_SQRL_ONLY = 0x0004
|
|
32
|
+
# Type 1 option flag: request that sites disable account recovery by other means (+hardlock+).
|
|
33
|
+
FLAG_NO_BYPASS = 0x0008
|
|
34
|
+
# Type 1 option flag: warn of a possible MITM attack when the server reports that the
|
|
35
|
+
# query's IP does not match the IP that requested the SQRL URL.
|
|
36
|
+
FLAG_WARN_MITM = 0x0010
|
|
37
|
+
# Type 1 option flag: discard the QuickPass when the system blanks, sleeps or suspends.
|
|
38
|
+
FLAG_CLEAR_QUICKPASS_ON_SLEEP = 0x0020
|
|
39
|
+
# Type 1 option flag: discard the QuickPass when the user is switched.
|
|
40
|
+
FLAG_CLEAR_QUICKPASS_ON_SWITCH = 0x0040
|
|
41
|
+
# Type 1 option flag: discard the QuickPass after the idle timeout (minutes) elapses.
|
|
42
|
+
FLAG_CLEAR_QUICKPASS_ON_IDLE = 0x0080
|
|
43
|
+
# Type 1 option flag: warn whenever a non-CPS authentication is attempted.
|
|
44
|
+
FLAG_WARN_NON_CPS = 0x0100
|
|
45
|
+
# Default option flags for a newly created identity (0x01F3, per the S4 specification).
|
|
46
|
+
DEFAULT_OPTION_FLAGS = FLAG_CHECK_FOR_UPDATES |
|
|
47
|
+
FLAG_UPDATE_AUTOMATICALLY |
|
|
48
|
+
FLAG_WARN_MITM |
|
|
49
|
+
FLAG_CLEAR_QUICKPASS_ON_SLEEP |
|
|
50
|
+
FLAG_CLEAR_QUICKPASS_ON_SWITCH |
|
|
51
|
+
FLAG_CLEAR_QUICKPASS_ON_IDLE |
|
|
52
|
+
FLAG_WARN_NON_CPS
|
|
53
|
+
|
|
54
|
+
# Maximum number of previous IUKs stored in a type 3 block.
|
|
55
|
+
MAX_PREVIOUS_KEYS = 4
|
|
56
|
+
|
|
57
|
+
ZERO_IV = ("\0" * 12).b.freeze
|
|
58
|
+
TYPE1_LEN = 125
|
|
59
|
+
TYPE1_PT_LEN = 45
|
|
60
|
+
TYPE2_LEN = 73
|
|
61
|
+
TYPE2_PT_LEN = 25
|
|
62
|
+
TYPE3_PT_LEN = 6
|
|
63
|
+
private_constant :ZERO_IV, :TYPE1_LEN, :TYPE1_PT_LEN, :TYPE2_LEN, :TYPE2_PT_LEN, :TYPE3_PT_LEN
|
|
64
|
+
|
|
65
|
+
# Normalize user-typed secret text the way all SQRL clients must before EnScrypt:
|
|
66
|
+
# Unicode NFKC composition, then UTF-8 (SQRL Cryptography, "Processing User Input").
|
|
67
|
+
def self.normalize_password(password)
|
|
68
|
+
password.to_s.encode("UTF-8").unicode_normalize(:nfkc).b
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generate a new 24-digit rescue code from 256 bits of entropy.
|
|
72
|
+
def self.generate_rescue_code
|
|
73
|
+
rescue_code_from_entropy(Util.random_bytes(32))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deterministic core of .generate_rescue_code: digits are extracted by repeated long
|
|
77
|
+
# division of the 256-bit big-endian number by 10 (the code is +entropy mod 10**24+,
|
|
78
|
+
# which is uniform to within 2**-176).
|
|
79
|
+
def self.rescue_code_from_entropy(entropy)
|
|
80
|
+
raise ArgumentError, "entropy must be 32 bytes" unless entropy.bytesize == 32
|
|
81
|
+
|
|
82
|
+
num = entropy.bytes
|
|
83
|
+
digits = Array.new(24) do
|
|
84
|
+
rem = 0
|
|
85
|
+
num.map! do |byte|
|
|
86
|
+
cur = (rem << 8) | byte
|
|
87
|
+
rem = cur % 10
|
|
88
|
+
cur / 10
|
|
89
|
+
end
|
|
90
|
+
rem
|
|
91
|
+
end
|
|
92
|
+
digits.join
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Strip separators and whitespace from a rescue code and verify it has 24 digits.
|
|
96
|
+
def self.normalize_rescue_code(code)
|
|
97
|
+
code = code.to_s
|
|
98
|
+
digits = code.delete("^0-9")
|
|
99
|
+
non_digits = code.match?(/[^0-9\s-]/)
|
|
100
|
+
raise FormatError, "a rescue code is 24 decimal digits" if digits.length != 24 || non_digits
|
|
101
|
+
|
|
102
|
+
digits
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Format a rescue code as six groups of four digits separated by dashes.
|
|
106
|
+
def self.format_rescue_code(code)
|
|
107
|
+
code.to_s.scan(/.{1,4}/).join("-")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Strip leading and trailing ASCII whitespace from a binary string.
|
|
111
|
+
def self.trim_ascii(bytes)
|
|
112
|
+
bytes.b.sub(/\A[ \t\r\n\f\v]+/, "").sub(/[ \t\r\n\f\v]+\z/, "")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# User-adjustable settings stored in the plaintext part of the type 1 block.
|
|
116
|
+
IdentitySettings = Struct.new(:option_flags, :hint_length, :pw_verify_seconds,
|
|
117
|
+
:idle_timeout_minutes, :log_n, keyword_init: true) do
|
|
118
|
+
def initialize(option_flags: DEFAULT_OPTION_FLAGS, hint_length: 4, pw_verify_seconds: 5,
|
|
119
|
+
idle_timeout_minutes: 15, log_n: Crypto::DEFAULT_LOG_N)
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Type 1 block: password-encrypted IMK and ILK.
|
|
125
|
+
class Type1Block
|
|
126
|
+
attr_accessor :iv, :salt, :settings, :iterations, :enc_imk, :enc_ilk, :tag
|
|
127
|
+
|
|
128
|
+
def initialize(iv:, salt:, settings:, iterations:, enc_imk:, enc_ilk:, tag:)
|
|
129
|
+
@iv = iv.b
|
|
130
|
+
@salt = salt.b
|
|
131
|
+
@settings = settings
|
|
132
|
+
@iterations = iterations
|
|
133
|
+
@enc_imk = enc_imk.b
|
|
134
|
+
@enc_ilk = enc_ilk.b
|
|
135
|
+
@tag = tag.b
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The 45 plaintext bytes (which double as the AES-GCM additional authenticated data).
|
|
139
|
+
def plaintext
|
|
140
|
+
[TYPE1_LEN, 1, TYPE1_PT_LEN].pack("vvv") + @iv + @salt +
|
|
141
|
+
[@settings.log_n, @iterations, @settings.option_flags, @settings.hint_length,
|
|
142
|
+
@settings.pw_verify_seconds, @settings.idle_timeout_minutes].pack("CVvCCv")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Serialize the full 125-byte block.
|
|
146
|
+
def to_bytes
|
|
147
|
+
plaintext + @enc_imk + @enc_ilk + @tag
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def self.parse(b)
|
|
151
|
+
raise FormatError, "type 1 block must be #{TYPE1_LEN} bytes, got #{b.bytesize}" unless b.bytesize == TYPE1_LEN
|
|
152
|
+
|
|
153
|
+
pt_len = Util.le_u16(b, 4)
|
|
154
|
+
raise FormatError, "unsupported type 1 plaintext length #{pt_len}" unless pt_len == TYPE1_PT_LEN
|
|
155
|
+
|
|
156
|
+
new(
|
|
157
|
+
iv: b.byteslice(6, 12),
|
|
158
|
+
salt: b.byteslice(18, 16),
|
|
159
|
+
settings: IdentitySettings.new(
|
|
160
|
+
log_n: b.getbyte(34),
|
|
161
|
+
option_flags: Util.le_u16(b, 39),
|
|
162
|
+
hint_length: b.getbyte(41),
|
|
163
|
+
pw_verify_seconds: b.getbyte(42),
|
|
164
|
+
idle_timeout_minutes: Util.le_u16(b, 43)
|
|
165
|
+
),
|
|
166
|
+
iterations: Util.le_u32(b, 35),
|
|
167
|
+
enc_imk: b.byteslice(45, 32),
|
|
168
|
+
enc_ilk: b.byteslice(77, 32),
|
|
169
|
+
tag: b.byteslice(109, 16)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Derive the AES key from the password (slow: runs EnScrypt for +iterations+). The
|
|
174
|
+
# block, if given, receives the number of completed iterations.
|
|
175
|
+
def derive_key(password, &)
|
|
176
|
+
pw = Storage.normalize_password(password)
|
|
177
|
+
key = Crypto.enscrypt_iterations(pw, @salt, @settings.log_n, @iterations, &)
|
|
178
|
+
Util.wipe(pw)
|
|
179
|
+
key
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Decrypt IMK and ILK with an already-derived key. Returns +[imk, ilk]+.
|
|
183
|
+
def decrypt_with_key(key)
|
|
184
|
+
pt = Crypto.aes_gcm_decrypt(key, @iv, plaintext, @enc_imk + @enc_ilk + @tag)
|
|
185
|
+
[pt.byteslice(0, 32), pt.byteslice(32, 32)]
|
|
186
|
+
rescue CryptoError
|
|
187
|
+
raise CryptoError, "incorrect password"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Build a fresh block encrypting +imk+/+ilk+ under +password+, running EnScrypt for
|
|
191
|
+
# +settings.pw_verify_seconds+ (as measured by +now_ms+). The block, if given,
|
|
192
|
+
# receives +(elapsed_ms, duration_ms)+.
|
|
193
|
+
def self.encrypt(imk, ilk, password, settings, now_ms: Crypto.method(:monotonic_ms), &progress)
|
|
194
|
+
salt = Util.random_bytes(16)
|
|
195
|
+
iv = Util.random_bytes(12)
|
|
196
|
+
duration_ms = [settings.pw_verify_seconds, 1].max * 1000
|
|
197
|
+
pw = Storage.normalize_password(password)
|
|
198
|
+
key, iterations = Crypto.enscrypt_duration(pw, salt, settings.log_n, duration_ms, now_ms: now_ms, &progress)
|
|
199
|
+
Util.wipe(pw)
|
|
200
|
+
block = encrypt_with_key(imk, ilk, key, salt, iv, iterations, settings)
|
|
201
|
+
Util.wipe(key)
|
|
202
|
+
block
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Build a block with a pre-derived key (used by tests and for deterministic rebuilds).
|
|
206
|
+
def self.encrypt_with_key(imk, ilk, key, salt, iv, iterations, settings)
|
|
207
|
+
block = new(iv: iv, salt: salt, settings: settings, iterations: iterations,
|
|
208
|
+
enc_imk: "\0" * 32, enc_ilk: "\0" * 32, tag: "\0" * 16)
|
|
209
|
+
ct = Crypto.aes_gcm_encrypt(key, block.iv, block.plaintext, imk.b + ilk.b)
|
|
210
|
+
block.enc_imk = ct.byteslice(0, 32)
|
|
211
|
+
block.enc_ilk = ct.byteslice(32, 32)
|
|
212
|
+
block.tag = ct.byteslice(64, 16)
|
|
213
|
+
block
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
217
|
+
def ==(other)
|
|
218
|
+
other.is_a?(Type1Block) && to_bytes == other.to_bytes
|
|
219
|
+
end
|
|
220
|
+
alias eql? ==
|
|
221
|
+
|
|
222
|
+
# @return [Integer] hash consistent with {#==}
|
|
223
|
+
def hash
|
|
224
|
+
to_bytes.hash
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Type 2 block: rescue-code-encrypted IUK.
|
|
229
|
+
class Type2Block
|
|
230
|
+
attr_accessor :salt, :log_n, :iterations, :enc_iuk, :tag
|
|
231
|
+
|
|
232
|
+
def initialize(salt:, log_n:, iterations:, enc_iuk:, tag:)
|
|
233
|
+
@salt = salt.b
|
|
234
|
+
@log_n = log_n
|
|
235
|
+
@iterations = iterations
|
|
236
|
+
@enc_iuk = enc_iuk.b
|
|
237
|
+
@tag = tag.b
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The 25 plaintext bytes (also the AAD).
|
|
241
|
+
def plaintext
|
|
242
|
+
[TYPE2_LEN, 2].pack("vv") + @salt + [@log_n, @iterations].pack("CV")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Serialize the full 73-byte block.
|
|
246
|
+
def to_bytes
|
|
247
|
+
plaintext + @enc_iuk + @tag
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def self.parse(b)
|
|
251
|
+
raise FormatError, "type 2 block must be #{TYPE2_LEN} bytes, got #{b.bytesize}" unless b.bytesize == TYPE2_LEN
|
|
252
|
+
|
|
253
|
+
new(salt: b.byteslice(4, 16), log_n: b.getbyte(20), iterations: Util.le_u32(b, 21),
|
|
254
|
+
enc_iuk: b.byteslice(25, 32), tag: b.byteslice(57, 16))
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Decrypt the IUK with the rescue code (slow: runs EnScrypt for +iterations+).
|
|
258
|
+
def decrypt(rescue_code, &)
|
|
259
|
+
digits = Storage.normalize_rescue_code(rescue_code)
|
|
260
|
+
key = Crypto.enscrypt_iterations(digits, @salt, @log_n, @iterations, &)
|
|
261
|
+
begin
|
|
262
|
+
Crypto.aes_gcm_decrypt(key, ZERO_IV, plaintext, @enc_iuk + @tag)
|
|
263
|
+
rescue CryptoError
|
|
264
|
+
raise CryptoError, "incorrect rescue code"
|
|
265
|
+
ensure
|
|
266
|
+
Util.wipe(key)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Encrypt an IUK under a rescue code, running EnScrypt for +duration_ms+.
|
|
271
|
+
def self.encrypt(iuk, rescue_code, log_n, duration_ms, now_ms: Crypto.method(:monotonic_ms), &progress)
|
|
272
|
+
digits = Storage.normalize_rescue_code(rescue_code)
|
|
273
|
+
salt = Util.random_bytes(16)
|
|
274
|
+
key, iterations = Crypto.enscrypt_duration(digits, salt, log_n, duration_ms, now_ms: now_ms, &progress)
|
|
275
|
+
block = encrypt_with_key(iuk, key, salt, log_n, iterations)
|
|
276
|
+
Util.wipe(key)
|
|
277
|
+
block
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Encrypt with a pre-derived key.
|
|
281
|
+
def self.encrypt_with_key(iuk, key, salt, log_n, iterations)
|
|
282
|
+
block = new(salt: salt, log_n: log_n, iterations: iterations, enc_iuk: "\0" * 32, tag: "\0" * 16)
|
|
283
|
+
ct = Crypto.aes_gcm_encrypt(key, ZERO_IV, block.plaintext, iuk.b)
|
|
284
|
+
block.enc_iuk = ct.byteslice(0, 32)
|
|
285
|
+
block.tag = ct.byteslice(32, 16)
|
|
286
|
+
block
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
290
|
+
def ==(other)
|
|
291
|
+
other.is_a?(Type2Block) && to_bytes == other.to_bytes
|
|
292
|
+
end
|
|
293
|
+
alias eql? ==
|
|
294
|
+
|
|
295
|
+
# @return [Integer] hash consistent with {#==}
|
|
296
|
+
def hash
|
|
297
|
+
to_bytes.hash
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Type 3 block: previous IUKs encrypted under the current IMK.
|
|
302
|
+
class Type3Block
|
|
303
|
+
# The identity's "edition": a counter incremented once per re-keying. It is part of the
|
|
304
|
+
# authenticated plaintext, so it can be shown without the password. The number of
|
|
305
|
+
# stored keys is given by the block length, not by this field.
|
|
306
|
+
attr_accessor :edition
|
|
307
|
+
# Encrypted previous IUKs, most recent first (1 to 4 entries).
|
|
308
|
+
attr_accessor :enc_keys
|
|
309
|
+
attr_accessor :tag
|
|
310
|
+
|
|
311
|
+
def initialize(edition:, enc_keys:, tag:)
|
|
312
|
+
@edition = edition
|
|
313
|
+
@enc_keys = enc_keys.map(&:b)
|
|
314
|
+
@tag = tag.b
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Total block length for this many keys.
|
|
318
|
+
def length
|
|
319
|
+
TYPE3_PT_LEN + (32 * @enc_keys.length) + 16
|
|
320
|
+
end
|
|
321
|
+
alias len length
|
|
322
|
+
|
|
323
|
+
# Whether the block holds no keys (never true for a well-formed block).
|
|
324
|
+
def empty?
|
|
325
|
+
@enc_keys.empty?
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# The 6 plaintext bytes (also the AAD).
|
|
329
|
+
def plaintext
|
|
330
|
+
[length, 3, @edition].pack("vvv")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @return [String] the block serialised, header included
|
|
334
|
+
def to_bytes
|
|
335
|
+
plaintext + @enc_keys.join + @tag
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self.parse(b)
|
|
339
|
+
raise FormatError, "type 3 block too short" if b.bytesize < TYPE3_PT_LEN + 32 + 16
|
|
340
|
+
|
|
341
|
+
edition = Util.le_u16(b, 4)
|
|
342
|
+
raise FormatError, "type 3 block has edition 0" if edition.zero?
|
|
343
|
+
|
|
344
|
+
body = b.bytesize - TYPE3_PT_LEN - 16
|
|
345
|
+
if (body % 32) != 0 || body.zero? || body / 32 > MAX_PREVIOUS_KEYS
|
|
346
|
+
raise FormatError, "type 3 block length #{b.bytesize} is not 54, 86, 118 or 150"
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
count = body / 32
|
|
350
|
+
enc_keys = Array.new(count) { |i| b.byteslice(TYPE3_PT_LEN + (32 * i), 32) }
|
|
351
|
+
new(edition: edition, enc_keys: enc_keys, tag: b.byteslice(-16, 16))
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Decrypt the previous IUKs with the current IMK.
|
|
355
|
+
def decrypt(imk)
|
|
356
|
+
pt = Crypto.aes_gcm_decrypt(imk, ZERO_IV, plaintext, @enc_keys.join + @tag)
|
|
357
|
+
pt.bytes.each_slice(32).map { |c| c.pack("C*") }
|
|
358
|
+
rescue CryptoError
|
|
359
|
+
raise CryptoError, "previous identity block does not verify"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Encrypt previous IUKs (most recent first; at most four are kept) under +imk+ with the
|
|
363
|
+
# given edition number. Returns nil when there are no keys.
|
|
364
|
+
def self.encrypt(previous_iuks, imk, edition)
|
|
365
|
+
keys = previous_iuks.first(MAX_PREVIOUS_KEYS)
|
|
366
|
+
return nil if keys.empty?
|
|
367
|
+
|
|
368
|
+
block = new(edition: [edition, 1].max, enc_keys: Array.new(keys.length) { "\0" * 32 }, tag: "\0" * 16)
|
|
369
|
+
ct = Crypto.aes_gcm_encrypt(imk, ZERO_IV, block.plaintext, keys.map(&:b).join)
|
|
370
|
+
block.enc_keys = Array.new(keys.length) { |i| ct.byteslice(32 * i, 32) }
|
|
371
|
+
block.tag = ct.byteslice(32 * keys.length, 16)
|
|
372
|
+
block
|
|
373
|
+
end
|
|
374
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
375
|
+
|
|
376
|
+
def ==(other)
|
|
377
|
+
other.is_a?(Type3Block) && to_bytes == other.to_bytes
|
|
378
|
+
end
|
|
379
|
+
alias eql? ==
|
|
380
|
+
# @return [Integer] hash consistent with {#==}
|
|
381
|
+
|
|
382
|
+
def hash
|
|
383
|
+
to_bytes.hash
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# A parsed S4 identity file.
|
|
389
|
+
class S4Identity
|
|
390
|
+
include Storage
|
|
391
|
+
|
|
392
|
+
# Password block (Storage::Type1Block or nil).
|
|
393
|
+
attr_accessor :type1
|
|
394
|
+
# Rescue code block (Storage::Type2Block or nil).
|
|
395
|
+
attr_accessor :type2
|
|
396
|
+
# Previous identities block (Storage::Type3Block or nil).
|
|
397
|
+
attr_accessor :type3
|
|
398
|
+
# Blocks of unknown type, preserved verbatim (each including its length/type prefix).
|
|
399
|
+
attr_accessor :unknown
|
|
400
|
+
|
|
401
|
+
def initialize(type1: nil, type2: nil, type3: nil, unknown: [])
|
|
402
|
+
@type1 = type1
|
|
403
|
+
@type2 = type2
|
|
404
|
+
@type3 = type3
|
|
405
|
+
@unknown = unknown.map(&:b)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Parse either the binary (+sqrldata+) or the textual base64url (+SQRLDATA+) form.
|
|
409
|
+
def self.parse(input)
|
|
410
|
+
trimmed = Storage.trim_ascii(input)
|
|
411
|
+
raise FormatError, "identity data too short" if trimmed.bytesize < 8
|
|
412
|
+
|
|
413
|
+
header = trimmed.byteslice(0, 8)
|
|
414
|
+
if header == HEADER_TEXT
|
|
415
|
+
text = trimmed.byteslice(8..)
|
|
416
|
+
raise FormatError, "invalid SQRLDATA text" unless text.force_encoding("UTF-8").valid_encoding?
|
|
417
|
+
|
|
418
|
+
return parse_blocks(Util.b64u_decode(text))
|
|
419
|
+
end
|
|
420
|
+
return parse_blocks(trimmed.byteslice(8..)) if header == HEADER_BINARY
|
|
421
|
+
|
|
422
|
+
raise FormatError, "missing sqrldata header"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Parse a sequence of blocks without the file header (used for the textual identity too).
|
|
426
|
+
def self.parse_blocks(data)
|
|
427
|
+
data = data.b
|
|
428
|
+
id = new
|
|
429
|
+
while data.bytesize >= 4
|
|
430
|
+
len = Util.le_u16(data, 0)
|
|
431
|
+
typ = Util.le_u16(data, 2)
|
|
432
|
+
if len.zero?
|
|
433
|
+
# zero padding (textual identities may carry trailing zero bytes)
|
|
434
|
+
break if data.each_byte.all?(&:zero?)
|
|
435
|
+
|
|
436
|
+
raise FormatError, "zero-length block"
|
|
437
|
+
end
|
|
438
|
+
if len < 4 || len > data.bytesize
|
|
439
|
+
raise FormatError, "block of type #{typ} claims #{len} bytes but only #{data.bytesize} remain"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
block = data.byteslice(0, len)
|
|
443
|
+
# "Any client encountering an identity containing more than one of either type
|
|
444
|
+
# should reject the entire identity as suspicious and invalid."
|
|
445
|
+
duplicate = case typ
|
|
446
|
+
when 1 then !id.type1.nil?
|
|
447
|
+
when 2 then !id.type2.nil?
|
|
448
|
+
when 3 then !id.type3.nil?
|
|
449
|
+
else false
|
|
450
|
+
end
|
|
451
|
+
raise FormatError, "identity contains more than one type #{typ} block" if duplicate
|
|
452
|
+
|
|
453
|
+
case typ
|
|
454
|
+
when 1 then id.type1 = Type1Block.parse(block)
|
|
455
|
+
when 2 then id.type2 = Type2Block.parse(block)
|
|
456
|
+
when 3 then id.type3 = Type3Block.parse(block)
|
|
457
|
+
else id.unknown << block
|
|
458
|
+
end
|
|
459
|
+
data = data.byteslice(len..)
|
|
460
|
+
end
|
|
461
|
+
raise FormatError, "trailing garbage after last block" if data.each_byte.any? { |b| b != 0 }
|
|
462
|
+
if id.type1.nil? && id.type2.nil?
|
|
463
|
+
raise FormatError, "identity contains neither a password nor a rescue block"
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
id
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Serialize the blocks (type 1, 2, 3, then unknown) without a header.
|
|
470
|
+
def blocks_to_bytes
|
|
471
|
+
out = +"".b
|
|
472
|
+
out << @type1.to_bytes if @type1
|
|
473
|
+
out << @type2.to_bytes if @type2
|
|
474
|
+
out << @type3.to_bytes if @type3
|
|
475
|
+
@unknown.each { |u| out << u }
|
|
476
|
+
out
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# The type 2 + type 3 blocks only: what the textual identity and the rescue-only export
|
|
480
|
+
# contain. (Type 1 is omitted because it is tied to the password of this installation.)
|
|
481
|
+
def rescue_blocks_to_bytes
|
|
482
|
+
out = +"".b
|
|
483
|
+
out << @type2.to_bytes if @type2
|
|
484
|
+
out << @type3.to_bytes if @type3
|
|
485
|
+
out
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Binary +sqrldata+ file.
|
|
489
|
+
def to_bytes
|
|
490
|
+
HEADER_BINARY + blocks_to_bytes
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Textual +SQRLDATA+ + base64url representation.
|
|
494
|
+
def to_text
|
|
495
|
+
"SQRLDATA#{Util.b64u_encode(blocks_to_bytes)}"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# A copy containing only the rescue (type 2) and previous (type 3) blocks.
|
|
499
|
+
def rescue_only
|
|
500
|
+
S4Identity.new(type2: @type2, type3: @type3)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Settings from the type 1 block, or defaults.
|
|
504
|
+
def settings
|
|
505
|
+
@type1 ? @type1.settings.dup : IdentitySettings.new
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# The identity's edition: 0 for a never re-keyed identity, else the type 3 counter.
|
|
509
|
+
def edition
|
|
510
|
+
@type3 ? @type3.edition : 0
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def password_block?
|
|
514
|
+
!@type1.nil?
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def rescue_block?
|
|
518
|
+
!@type2.nil?
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Derive the password key (slow). The block receives completed EnScrypt iterations out
|
|
522
|
+
# of +type1.iterations+.
|
|
523
|
+
def derive_password_key(password, &)
|
|
524
|
+
raise FormatError, "identity has no password block" unless @type1
|
|
525
|
+
|
|
526
|
+
@type1.derive_key(password, &)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Unlock the identity with an already-derived password key.
|
|
530
|
+
def unlock_with_key(key)
|
|
531
|
+
raise FormatError, "identity has no password block" unless @type1
|
|
532
|
+
|
|
533
|
+
imk, ilk = @type1.decrypt_with_key(key)
|
|
534
|
+
previous = @type3 ? @type3.decrypt(imk) : []
|
|
535
|
+
UnlockedIdentity.new(imk, ilk, previous)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Unlock the identity with the password (slow).
|
|
539
|
+
def unlock(password, &)
|
|
540
|
+
key = derive_password_key(password, &)
|
|
541
|
+
unlock_with_key(key)
|
|
542
|
+
ensure
|
|
543
|
+
Util.wipe(key) if key
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Decrypt the IUK with the rescue code (slow).
|
|
547
|
+
def unlock_iuk(rescue_code, &)
|
|
548
|
+
raise FormatError, "identity has no rescue code block" unless @type2
|
|
549
|
+
|
|
550
|
+
@type2.decrypt(rescue_code, &)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# Create a complete new identity file from an IUK.
|
|
554
|
+
#
|
|
555
|
+
# +rescue_duration_ms+ controls how long EnScrypt runs for the rescue block (the
|
|
556
|
+
# reference client uses a long time because the rescue code is only 24 digits). The
|
|
557
|
+
# block, if given, receives +(stage, elapsed_ms, duration_ms)+ with stage "password" or
|
|
558
|
+
# "rescue".
|
|
559
|
+
def self.create(iuk, password, rescue_code, settings = IdentitySettings.new, rescue_duration_ms = 5000,
|
|
560
|
+
now_ms: Crypto.method(:monotonic_ms), &progress)
|
|
561
|
+
imk = Identity.imk_from_iuk(iuk)
|
|
562
|
+
ilk = Identity.ilk_from_iuk(iuk)
|
|
563
|
+
type1 = Type1Block.encrypt(imk, ilk, password, settings, now_ms: now_ms) do |a, b|
|
|
564
|
+
progress&.call("password", a, b)
|
|
565
|
+
end
|
|
566
|
+
type2 = Type2Block.encrypt(iuk, rescue_code, settings.log_n, rescue_duration_ms, now_ms: now_ms) do |a, b|
|
|
567
|
+
progress&.call("rescue", a, b)
|
|
568
|
+
end
|
|
569
|
+
Util.wipe(imk)
|
|
570
|
+
Util.wipe(ilk)
|
|
571
|
+
new(type1: type1, type2: type2)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Re-encrypt the type 1 (and type 3) blocks for an unlocked identity under a new
|
|
575
|
+
# password / settings. Used for password changes, settings changes and after importing a
|
|
576
|
+
# rescue-only identity. The block receives +(elapsed_ms, duration_ms)+.
|
|
577
|
+
def set_password(identity, password, settings = self.settings, now_ms: Crypto.method(:monotonic_ms), &progress)
|
|
578
|
+
@type1 = Type1Block.encrypt(identity.imk, identity.ilk, password, settings, now_ms: now_ms, &progress)
|
|
579
|
+
edition = [self.edition, identity.previous_iuks.length].max
|
|
580
|
+
@type3 = Type3Block.encrypt(identity.previous_iuks, identity.imk, edition)
|
|
581
|
+
self
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Re-key the identity: a new IUK is supplied, the old IUK (recovered with the old rescue
|
|
585
|
+
# code) moves to the front of the previous-identity list, and new type 1 / 2 / 3 blocks
|
|
586
|
+
# are produced for +new_password+ and +new_rescue_code+.
|
|
587
|
+
#
|
|
588
|
+
# Returns the new unlocked identity (with the previous IUKs).
|
|
589
|
+
def rekey(old_identity, old_iuk, new_iuk, new_password, new_rescue_code, settings = self.settings,
|
|
590
|
+
rescue_duration_ms = 5000, now_ms: Crypto.method(:monotonic_ms), &progress)
|
|
591
|
+
previous = [old_iuk.b] + old_identity.previous_iuks.first(MAX_PREVIOUS_KEYS - 1)
|
|
592
|
+
new_identity = UnlockedIdentity.from_iuk(new_iuk, previous)
|
|
593
|
+
type1 = Type1Block.encrypt(new_identity.imk, new_identity.ilk, new_password, settings, now_ms: now_ms) do |a, b|
|
|
594
|
+
progress&.call("password", a, b)
|
|
595
|
+
end
|
|
596
|
+
type2 = Type2Block.encrypt(new_iuk, new_rescue_code, settings.log_n, rescue_duration_ms, now_ms: now_ms) do |a, b|
|
|
597
|
+
progress&.call("rescue", a, b)
|
|
598
|
+
end
|
|
599
|
+
edition = [self.edition + 1, 0xFFFF].min
|
|
600
|
+
@type1 = type1
|
|
601
|
+
@type2 = type2
|
|
602
|
+
@type3 = Type3Block.encrypt(new_identity.previous_iuks, new_identity.imk, edition)
|
|
603
|
+
new_identity
|
|
604
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
608
|
+
def ==(other)
|
|
609
|
+
other.is_a?(S4Identity) && to_bytes == other.to_bytes
|
|
610
|
+
end
|
|
611
|
+
# @return [Integer] hash consistent with {#==}
|
|
612
|
+
alias eql? ==
|
|
613
|
+
|
|
614
|
+
# @return [Integer] hash consistent with {#==}
|
|
615
|
+
def hash
|
|
616
|
+
to_bytes.hash
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# @return [String] a short description with secret material redacted
|
|
620
|
+
def inspect
|
|
621
|
+
"#<SQRL::S4Identity type1=#{!@type1.nil?} type2=#{!@type2.nil?} edition=#{edition} unknown=#{@unknown.length}>"
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
end
|