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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# Test vectors published by GRC and by the SQRL developer community, shared by every
|
|
5
|
+
# gem's test-suite.
|
|
6
|
+
#
|
|
7
|
+
# Sources:
|
|
8
|
+
# * "SQRL Cryptography" v1.04 (December 2019), "SQRL Cryptography Test Vectors": the
|
|
9
|
+
# Spec-Vectors-Identity (password, rescue code, textual identity) and the client-side
|
|
10
|
+
# SQRL-PASS.log / SQRL.log excerpts.
|
|
11
|
+
# * https://www.grc.com/sqrl/storage.htm: the official EnScrypt vectors.
|
|
12
|
+
# * grc.sqrl.dev newsgroup, "SQRL Identity encryption" (Adam Comley, 2014): EnScrypt with
|
|
13
|
+
# a 16-byte salt, cross-validated against GRC's EnScrypt.exe.
|
|
14
|
+
# * grc.sqrl newsgroup, "Base56 Examples" (Paul Holder, March 2019): base56 textual
|
|
15
|
+
# encodings validated against the GRC SQRL client's export.
|
|
16
|
+
# * The open-source Android SQRL client's test code: a complete S4 identity file.
|
|
17
|
+
module TestVectors
|
|
18
|
+
# Password of GRC's Spec-Vectors-Identity.
|
|
19
|
+
SPEC_PASSWORD = "Zingo-Bingo-Slingo-Dingo"
|
|
20
|
+
# Rescue code of the Spec-Vectors-Identity.
|
|
21
|
+
SPEC_RESCUE_CODE = "1198-8748-7132-2838-8318-7570"
|
|
22
|
+
# The Spec-Vectors-Identity as a textual (base56) identity.
|
|
23
|
+
SPEC_TEXTUAL = <<~TEXT.chomp
|
|
24
|
+
KKcC 3BaX akxc Xwbf xki7
|
|
25
|
+
k7mF GHhg jQes gzWd 6TrK
|
|
26
|
+
vMsZ dBtB pZbC zsz8 cUWj
|
|
27
|
+
DtS2 ZK2s ZdAQ 8Yx3 iDyt
|
|
28
|
+
QuXt CkTC y6gc qG8n Xfj9
|
|
29
|
+
bHDA 422
|
|
30
|
+
TEXT
|
|
31
|
+
# Identity Master Key of the Spec-Vectors-Identity (hex).
|
|
32
|
+
SPEC_IMK = "2E471E106D758F3C7B242C8A4A0513A7192D8EDDB917BDD019F19E38ED292BA8"
|
|
33
|
+
# Per-site values for sqrl.grc.com from SQRL.log.
|
|
34
|
+
SPEC_SITE = "sqrl.grc.com"
|
|
35
|
+
# Per-site Ed25519 seed for sqrl.grc.com (hex).
|
|
36
|
+
SPEC_SITE_SEED = "7A550E0797EF9E617D0E544EBE27FDE749D9EC2472BF9618DEE4C063EEFD51B5"
|
|
37
|
+
# Per-site public key for sqrl.grc.com (hex).
|
|
38
|
+
SPEC_SITE_PUBLIC_KEY = "DA3801F9C47E1279588253DD193A0EB9C29823A083EAC52A7F893D32D92E897B"
|
|
39
|
+
# idk= (base64url) for sqrl.grc.com.
|
|
40
|
+
SPEC_SITE_IDK = "2jgB-cR-EnlYglPdGToOucKYI6CD6sUqf4k9MtkuiXs"
|
|
41
|
+
|
|
42
|
+
# SQRL.log transaction 1 and 2 (the query and ident against sqrl.grc.com).
|
|
43
|
+
SPEC_URL = "sqrl://sqrl.grc.com/cli.sqrl?nut=fXkb4MBToCm7&can=aHR0cHM6Ly9zcXJsLmdyYy5jb20vZGVtbw"
|
|
44
|
+
# The can= cancel URL from SQRL.log.
|
|
45
|
+
SPEC_CANCEL_URL = "https://sqrl.grc.com/demo"
|
|
46
|
+
# Decoded client= of the query request in SQRL.log.
|
|
47
|
+
SPEC_QUERY_CLIENT = "ver=1\r\ncmd=query\r\nidk=#{SPEC_SITE_IDK}\r\nopt=cps~suk\r\n".freeze
|
|
48
|
+
# Start of the base64url client= of the query request.
|
|
49
|
+
SPEC_QUERY_CLIENT_B64_PREFIX = "client=dmVyPTENCmNtZD1xdWVyeQ0KaWRrPTJqZ0ItY1ItRW5sWWdsUGRHVG9PdWNLWUk2Q0Q2c1VxZjRrOU10a3Vp"
|
|
50
|
+
# Fragment of the base64url server= of the query request.
|
|
51
|
+
SPEC_QUERY_SERVER_B64_FRAGMENT = "&server=c3FybDovL3NxcmwuZ3JjLmNvbS9jbGkuc3FybD9udXQ9ZlhrYjRNQlRvQ203JmNhbj1hSFIwY0hNNkx5OXpj"
|
|
52
|
+
# ids= signature of the query request (base64url).
|
|
53
|
+
SPEC_QUERY_IDS = "qXFikmg_jZQJ7GjKCA_zBwzras2QyQAwWw2s80ZcUHmLLBUN5_hPuFtB6ZoV5wUcNs5XmSrqg1FQwGA6RlVBAQ"
|
|
54
|
+
# Decoded first server reply from SQRL.log.
|
|
55
|
+
SPEC_REPLY1 = "ver=1\r\nnut=1WM9lfF1ST-z\r\ntif=5\r\nqry=/cli.sqrl?nut=1WM9lfF1ST-z\r\n" \
|
|
56
|
+
"suk=BMFDm7bPlsQojuJsoDTvlS1McnwgScvktF84vLjscGk\r\n"
|
|
57
|
+
# Start of the base64url first reply.
|
|
58
|
+
SPEC_REPLY1_B64_PREFIX = "dmVyPTENCm51dD0xV005bGZGMVNULXoNCnRpZj01DQpxcnk9L2NsaS5zcXJsP251dD0xV005bGZGMVNULXoN"
|
|
59
|
+
# suk= carried by the first reply.
|
|
60
|
+
SPEC_REPLY1_SUK = "BMFDm7bPlsQojuJsoDTvlS1McnwgScvktF84vLjscGk"
|
|
61
|
+
# POST URL of the ident request.
|
|
62
|
+
SPEC_IDENT_URL = "https://sqrl.grc.com/cli.sqrl?nut=1WM9lfF1ST-z"
|
|
63
|
+
# Decoded client= of the ident request in SQRL.log.
|
|
64
|
+
SPEC_IDENT_CLIENT = "ver=1\r\ncmd=ident\r\nidk=#{SPEC_SITE_IDK}\r\nopt=cps~suk\r\n".freeze
|
|
65
|
+
# Start of the base64url client= of the ident request.
|
|
66
|
+
SPEC_IDENT_CLIENT_B64_PREFIX = "client=dmVyPTENCmNtZD1pZGVudA0KaWRrPTJqZ0ItY1ItRW5sWWdsUGRHVG9PdWNLWUk2Q0Q2c1VxZjRrOU10a3Vp"
|
|
67
|
+
# ids= signature of the ident request (base64url).
|
|
68
|
+
SPEC_IDENT_IDS = "kQI05IpE_cu4u0mf7jymbap09hmS6ZCWjBGKUYnSNVRTXPbreTvDWHKNbSkUlQT2bx3wXwW3cCLBD0Qedd64AA"
|
|
69
|
+
# Decoded second server reply from SQRL.log.
|
|
70
|
+
SPEC_REPLY2 = "ver=1\r\nnut=BEFAI1tJYfBm\r\ntif=5\r\nqry=/cli.sqrl?nut=BEFAI1tJYfBm\r\n" \
|
|
71
|
+
"suk=BMFDm7bPlsQojuJsoDTvlS1McnwgScvktF84vLjscGk\r\n" \
|
|
72
|
+
"url=https://sqrl.grc.com/auth.test?4hVTe9LGHhMTuxz_mm6vRc_q\r\n"
|
|
73
|
+
# The CPS url= from SQRL.log.
|
|
74
|
+
SPEC_CPS_URL = "https://sqrl.grc.com/auth.test?4hVTe9LGHhMTuxz_mm6vRc_q"
|
|
75
|
+
|
|
76
|
+
# EnScrypt vectors: [password, salt hex, iterations, expected hex].
|
|
77
|
+
ENSCRYPT = [
|
|
78
|
+
# Official vectors from https://www.grc.com/sqrl/storage.htm
|
|
79
|
+
["", "", 1, "a8ea62a6e1bfd20e4275011595307aa302645c1801600ef5cd79bf9d884d911c"],
|
|
80
|
+
["", "", 100, "45a42a01709a0012a37b7b6874cf16623543409d19e7740ed96741d2e99aab67"],
|
|
81
|
+
["password", "", 123, "129d96d1e735618517259416a605be7094c2856a53c14ef7d4e4ba8e4ea36aeb"],
|
|
82
|
+
["password", "0000000000000000000000000000000000000000000000000000000000000000", 123,
|
|
83
|
+
"2f30b9d4e5c48056177ff90a6cc9da04b648a7e8451dfa60da56c148187f6a7d"],
|
|
84
|
+
# SQRL-PASS.log: password DeScryption, 179 iterations
|
|
85
|
+
[SPEC_PASSWORD, "F5019F96E9F64D32BEAE43145ACEF2CF", 179,
|
|
86
|
+
"bc151224fc6b72152021edbe3ade523f98be99c491334bba9fd8b587eb51e36d"],
|
|
87
|
+
# SQRL-PASS.log: QuickPass hint "Zing", 31 iterations
|
|
88
|
+
["Zing", "CD2E1EA56DE20FE036B6D2FA867DF50A", 31,
|
|
89
|
+
"ddc7f5d12caa26a869488ad83df8ed480072076dfd94eee5f6f1bd0dde02b9d9"],
|
|
90
|
+
# grc.sqrl.dev newsgroup, 16- and 32-byte salts
|
|
91
|
+
["Sqrl123Test", "0123456789abcdef0123456789abcdef", 63,
|
|
92
|
+
"83961678d0e808f29b5dcc045236ad7fd249d7dfd7284b461976b1f1b8a45df0"],
|
|
93
|
+
["Sqrl123Test", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 63,
|
|
94
|
+
"da06576fbe58fb20bd8c4f1204a87aa8fad21078473dd770bb7ee7faff8a2f4a"]
|
|
95
|
+
].map(&:freeze).freeze
|
|
96
|
+
|
|
97
|
+
# Base56 vectors validated against GRC's client: {hex input => raw encoding}.
|
|
98
|
+
BASE56_SHORT = {
|
|
99
|
+
"00" => "22Y", "01" => "32B", "0F" => "H2M", "55" => "X3v", "C5" => "X57", "FE" => "Y6e", "FF" => "Z6V",
|
|
100
|
+
"0000" => "222B", "0101" => "b628", "0F0F" => "rE3N", "1010" => "SK3T", "7F7F" => "rQCj",
|
|
101
|
+
"8080" => "SVC5", "FEFE" => "gpN7", "FFFF" => "HuNh",
|
|
102
|
+
"000000" => "22222z", "010101" => "tyN22r", "7F7F7F" => "9Tar2S", "808080" => "2Sxr2D",
|
|
103
|
+
"FEFEFE" => "GuAh3n", "FFFFFF" => "9tXh3W",
|
|
104
|
+
"0102030405060708090A0B0C0D0E0F10" => "tG5vDbjTXT2zfJ5cMfvg3A62d"
|
|
105
|
+
}.freeze
|
|
106
|
+
# Base56 example: 80 random bytes (hex).
|
|
107
|
+
BASE56_80_HEX = "0102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F2021222324252627" \
|
|
108
|
+
"28292A2B2C2D2E2F303132333435363738393A3B3C3D3E3F404142434445464748494A4B4C4D4E4F50"
|
|
109
|
+
# Base56 example: the 80 bytes as GRC's client exports them.
|
|
110
|
+
BASE56_80_TEXT = <<~TEXT.chomp
|
|
111
|
+
b6w8 RYMv S427 Gtt5 sXBj
|
|
112
|
+
UB5n vHZH PbHr uAM9 MgUR
|
|
113
|
+
fzT8 qs8f EEEp kUPV GH6R
|
|
114
|
+
ac4u Zdrk CwLJ Wxmy 8upX
|
|
115
|
+
rU9N Bn6P qcY9 HXKt ybEC
|
|
116
|
+
Sdh9 R22r SjAd 48i2 Y
|
|
117
|
+
TEXT
|
|
118
|
+
# Base56 example: 87 random bytes (hex).
|
|
119
|
+
BASE56_87_HEX = "18A2D975F61A3B6F1975FA2A9E3FBA2C7BE73732A0E34F006328E4BD938CFB6E292A50845C5A6B83F769" \
|
|
120
|
+
"7EAC8A49BF97221C2F06C1F4B251BDB865AF1E1E28E62D635959BEF7927CD658A7C2CA1AD5570DC6C066BA6EAF"
|
|
121
|
+
# Base56 example: the 87 bytes as GRC's client exports them.
|
|
122
|
+
BASE56_87_TEXT = <<~TEXT.chomp
|
|
123
|
+
sMrK xrbJ dHz5 s3mu EGWw
|
|
124
|
+
RjWM UbAV MMsq SwMM R7C6
|
|
125
|
+
theF MsSg PDJ4 BHVb jLau
|
|
126
|
+
KpW8 cbsU mTpd eueZ ZYMU
|
|
127
|
+
HgcB kffR Nviu pxb9 UvxN
|
|
128
|
+
fN54 3id9 CdRe L254 ZzEb
|
|
129
|
+
4LBd pNL
|
|
130
|
+
TEXT
|
|
131
|
+
# Sample textual identity (a single type 2 block) from the Android client's test harness.
|
|
132
|
+
ANDROID_TEXTUAL = "KB3Z 8My9 CVUX C34K B8NM bEXm jcUK RAq6 JT9p DaYW UstG K9hH xX98 KxHU weVa RvpV " \
|
|
133
|
+
"wJd5 JXbf eEDf 4cYy hzNL j6dW Ehq3 KXCV YSBf nRJd rAN"
|
|
134
|
+
|
|
135
|
+
# A real identity file (from the open-source Android SQRL client's test code) with the
|
|
136
|
+
# password +Testing1234+ and rescue code +7276-0587-2230-1119-8559-3839+.
|
|
137
|
+
ANDROID_IDENTITY_HEX = "7371726c646174617d0001002d00b51fd99559b887d106a8d877c70133bb20a12fa1a7c829b194db94f309c5" \
|
|
138
|
+
"000000f30104050f000d174cc6e7b70baa158aa4ce75e2f2b99a02a40e4beb2e5d16c2f03442bd3e932035" \
|
|
139
|
+
"419a63885a663125a600e5486c42b38f708c1094ced1ab0b0050137f6df449caf78581fec678408a804caf74" \
|
|
140
|
+
"f91c490002005528fc85e3e36866a85574146fe7776d09cf0000004a4e12277dd48366fc1f335dd37188bbcb" \
|
|
141
|
+
"a02bc32a12aef0188f5e83593665518483d638b80051c2b4b013491eb06835"
|
|
142
|
+
# Password of the Android client's test identity file.
|
|
143
|
+
ANDROID_PASSWORD = "Testing1234"
|
|
144
|
+
# Rescue code of the Android client's test identity file.
|
|
145
|
+
ANDROID_RESCUE_CODE = "7276-0587-2230-1119-8559-3839"
|
|
146
|
+
|
|
147
|
+
# RFC 7748 X25519 base-point vector.
|
|
148
|
+
X25519_ALICE_PRIVATE = "77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
|
|
149
|
+
# RFC 7748 X25519 test vector: Alice's public key (hex).
|
|
150
|
+
X25519_ALICE_PUBLIC = "8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a"
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/sqrl/textual.rb
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# The base56 "textual identity" format.
|
|
5
|
+
#
|
|
6
|
+
# The rescue (type 2) and previous-identity (type 3) blocks are treated as one
|
|
7
|
+
# little-endian number and written in base56 (digits 2-9, upper-case letters without I
|
|
8
|
+
# and O, lower-case letters without l and o). After every 19 characters a check
|
|
9
|
+
# character is inserted: SHA-256(19 chars || line number) interpreted as a little-endian
|
|
10
|
+
# number, modulo 56. For display the characters are grouped in fours, five groups per line.
|
|
11
|
+
module Textual
|
|
12
|
+
# The base56 alphabet.
|
|
13
|
+
ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz".b.freeze
|
|
14
|
+
CHARS_PER_LINE = 19
|
|
15
|
+
LOG2_56 = 5.807_354_922_057_604
|
|
16
|
+
INDEX = ALPHABET.each_byte.with_index.to_h.freeze
|
|
17
|
+
private_constant :CHARS_PER_LINE, :LOG2_56, :INDEX
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Index of a byte in the alphabet, or nil.
|
|
22
|
+
def alphabet_index(byte)
|
|
23
|
+
INDEX[byte]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Divide a little-endian big number (Array of bytes) in place by 56; returns the remainder.
|
|
27
|
+
def divmod56!(num)
|
|
28
|
+
rem = 0
|
|
29
|
+
(num.length - 1).downto(0) do |i|
|
|
30
|
+
cur = (rem << 8) | num[i]
|
|
31
|
+
num[i] = cur / 56
|
|
32
|
+
rem = cur % 56
|
|
33
|
+
end
|
|
34
|
+
rem
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Check character for one line of data characters.
|
|
38
|
+
def check_char(line_chars, line)
|
|
39
|
+
digest = Crypto.sha256(line_chars.pack("C*") + [line].pack("C"))
|
|
40
|
+
rem = 0
|
|
41
|
+
digest.bytes.reverse_each { |b| rem = ((rem << 8) | b) % 56 }
|
|
42
|
+
ALPHABET.getbyte(rem)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Encode bytes into the raw textual identity string (check characters included, no spacing).
|
|
46
|
+
def encode(data)
|
|
47
|
+
data = data.b
|
|
48
|
+
expected_len = ((data.bytesize * 8) / LOG2_56).ceil
|
|
49
|
+
num = data.bytes
|
|
50
|
+
out = []
|
|
51
|
+
line_chars = []
|
|
52
|
+
line = 0
|
|
53
|
+
expected_len.times do
|
|
54
|
+
if line_chars.length == CHARS_PER_LINE
|
|
55
|
+
out << check_char(line_chars, line)
|
|
56
|
+
line_chars = []
|
|
57
|
+
line = (line + 1) & 0xFF
|
|
58
|
+
end
|
|
59
|
+
c = num.all?(&:zero?) ? ALPHABET.getbyte(0) : ALPHABET.getbyte(divmod56!(num))
|
|
60
|
+
out << c
|
|
61
|
+
line_chars << c
|
|
62
|
+
end
|
|
63
|
+
out << check_char(line_chars, line)
|
|
64
|
+
out.pack("C*").force_encoding("UTF-8")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Format a raw textual identity for display: groups of four, five groups per line.
|
|
68
|
+
def format_for_display(raw)
|
|
69
|
+
out = +""
|
|
70
|
+
raw.each_char.with_index do |c, i|
|
|
71
|
+
if i.positive?
|
|
72
|
+
if (i % 20).zero?
|
|
73
|
+
out << "\n"
|
|
74
|
+
elsif (i % 4).zero?
|
|
75
|
+
out << " "
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
out << c
|
|
79
|
+
end
|
|
80
|
+
out
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Decode a textual identity (whitespace and dashes are ignored) into the block bytes.
|
|
84
|
+
# Raises FormatError naming the (1-based) line whose check character failed.
|
|
85
|
+
def decode(text)
|
|
86
|
+
cleaned = text.to_s.b.delete(" \t\r\n\f\v-").bytes
|
|
87
|
+
raise FormatError, "empty textual identity" if cleaned.empty?
|
|
88
|
+
|
|
89
|
+
cleaned.each do |c|
|
|
90
|
+
raise FormatError, "invalid character '#{c.chr}' in textual identity" unless alphabet_index(c)
|
|
91
|
+
end
|
|
92
|
+
total = cleaned.length
|
|
93
|
+
data_chars = []
|
|
94
|
+
line_chars = []
|
|
95
|
+
line = 0
|
|
96
|
+
cleaned.each_with_index do |c, i|
|
|
97
|
+
is_check = line_chars.length == CHARS_PER_LINE || i == total - 1
|
|
98
|
+
if is_check
|
|
99
|
+
raise FormatError, "check character mismatch on line #{line + 1}" unless c == check_char(line_chars, line)
|
|
100
|
+
|
|
101
|
+
line_chars = []
|
|
102
|
+
line = (line + 1) & 0xFF
|
|
103
|
+
else
|
|
104
|
+
data_chars << c
|
|
105
|
+
line_chars << c
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
expected_bytes = ((data_chars.length * LOG2_56) / 8.0).floor
|
|
109
|
+
num = Array.new(expected_bytes + 2, 0)
|
|
110
|
+
data_chars.reverse_each do |c|
|
|
111
|
+
carry = alphabet_index(c)
|
|
112
|
+
num.map! do |byte|
|
|
113
|
+
cur = (byte * 56) + carry
|
|
114
|
+
carry = cur >> 8
|
|
115
|
+
cur & 0xFF
|
|
116
|
+
end
|
|
117
|
+
raise FormatError, "textual identity value out of range" unless carry.zero?
|
|
118
|
+
end
|
|
119
|
+
raise FormatError, "textual identity value out of range" if num[expected_bytes..].any? { |b| b != 0 }
|
|
120
|
+
|
|
121
|
+
num.first(expected_bytes).pack("C*")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/sqrl/url.rb
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# A parsed +sqrl://+ URL.
|
|
5
|
+
#
|
|
6
|
+
# * The *site key* that all per-site keys are derived from is the lower-cased host name
|
|
7
|
+
# (user info and port stripped). When the URL carries an +x=N+ parameter, the first +N+
|
|
8
|
+
# characters of the path (including the leading +/+) are appended.
|
|
9
|
+
# * The *POST URL* is the same URL with the scheme replaced by +https+. +qrl://+, the
|
|
10
|
+
# plain-http scheme of GRC's early drafts, is not part of the final specification: it is
|
|
11
|
+
# parsed, accepted for loopback hosts only, and refused by {SQRL::Session} unless it was
|
|
12
|
+
# created with +insecure: true+ (a testing mode for the browser extension's test server).
|
|
13
|
+
# * +can=+ carries a base64url-encoded URL to send the browser to if the user cancels.
|
|
14
|
+
class URL
|
|
15
|
+
# The URL exactly as received (this is what the first +server=+ parameter carries).
|
|
16
|
+
attr_reader :original
|
|
17
|
+
# +sqrl+ (or +qrl+ in the loopback-only testing mode), lower-cased.
|
|
18
|
+
attr_reader :scheme
|
|
19
|
+
# +host[:port]+ without user info, as used for the POST URL.
|
|
20
|
+
attr_reader :authority
|
|
21
|
+
# Lower-cased host name.
|
|
22
|
+
attr_reader :host
|
|
23
|
+
# Explicit port, if any.
|
|
24
|
+
attr_reader :port
|
|
25
|
+
# The path (starts with +/+; +/+ if absent).
|
|
26
|
+
attr_reader :path
|
|
27
|
+
# The raw query string without the leading +?+.
|
|
28
|
+
attr_reader :query
|
|
29
|
+
# The +nut=+ value, if any.
|
|
30
|
+
attr_reader :nut
|
|
31
|
+
# Value of +x=+ (path characters included in the site key).
|
|
32
|
+
attr_reader :path_extension
|
|
33
|
+
# Decoded +can=+ cancel URL (relative URLs are resolved against the site).
|
|
34
|
+
attr_reader :cancel_url
|
|
35
|
+
# Site key string used for key derivation.
|
|
36
|
+
attr_reader :site_key
|
|
37
|
+
|
|
38
|
+
# Hosts for which the plain-http +qrl://+ testing scheme is tolerated at all.
|
|
39
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
40
|
+
|
|
41
|
+
# Parse a +sqrl://+ (or loopback-only +qrl://+) URL.
|
|
42
|
+
#
|
|
43
|
+
# @param input [String]
|
|
44
|
+
# @return [URL]
|
|
45
|
+
# @raise [FormatError]
|
|
46
|
+
def self.parse(input)
|
|
47
|
+
new(input)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Split a query string into +[key, value]+ pairs. Values are percent-decoded only for
|
|
51
|
+
# the characters SQRL servers may legitimately escape; the +nut+ is otherwise used
|
|
52
|
+
# verbatim.
|
|
53
|
+
#
|
|
54
|
+
# @param query [String]
|
|
55
|
+
# @return [Array<Array(String, String)>]
|
|
56
|
+
def self.query_pairs(query)
|
|
57
|
+
query.to_s.split("&").reject(&:empty?).map do |kv|
|
|
58
|
+
k, v = kv.split("=", 2)
|
|
59
|
+
[k, v ? percent_decode(v) : ""]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Percent-decode a string, leaving malformed escapes untouched.
|
|
64
|
+
def self.percent_decode(str)
|
|
65
|
+
return str unless str.include?("%")
|
|
66
|
+
|
|
67
|
+
str.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }
|
|
68
|
+
.force_encoding(Encoding::UTF_8)
|
|
69
|
+
.scrub
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether +host+ (lower-cased, IPv6 literals in brackets) is a loopback address:
|
|
73
|
+
# +localhost+ and +*.localhost+, +127.0.0.0/8+, +[::1]+ and the IPv4-mapped form of
|
|
74
|
+
# 127/8. Any other IPv6 literal is not loopback.
|
|
75
|
+
def self.loopback?(host)
|
|
76
|
+
h = host.to_s.downcase
|
|
77
|
+
return true if LOOPBACK_HOSTS.include?(h) || h.end_with?(".localhost")
|
|
78
|
+
return true if h.match?(/\A127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
79
|
+
|
|
80
|
+
bare = h.delete_prefix("[").delete_suffix("]")
|
|
81
|
+
bare == "::1" || bare == "0:0:0:0:0:0:0:1" || bare.match?(/\A::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Split +host[:port]+ handling IPv6 literals. Returns +[host, port_or_nil]+.
|
|
85
|
+
def self.split_host_port(hostport)
|
|
86
|
+
if hostport.start_with?("[")
|
|
87
|
+
close = hostport.index("]")
|
|
88
|
+
raise FormatError, "unterminated IPv6 literal" unless close
|
|
89
|
+
|
|
90
|
+
host = hostport[0..close]
|
|
91
|
+
after = hostport[(close + 1)..]
|
|
92
|
+
return [host, nil] if after.empty?
|
|
93
|
+
raise FormatError, "invalid port" unless after.start_with?(":") && after[1..].match?(/\A\d+\z/)
|
|
94
|
+
|
|
95
|
+
return [host, parse_port(after[1..])]
|
|
96
|
+
end
|
|
97
|
+
host, port = hostport.rpartition(":").then { |h, sep, p| sep.empty? ? [hostport, nil] : [h, p] }
|
|
98
|
+
return [host, nil] if port.nil?
|
|
99
|
+
raise FormatError, "invalid port" unless port.match?(/\A\d+\z/)
|
|
100
|
+
|
|
101
|
+
[host, parse_port(port)]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.parse_port(str)
|
|
105
|
+
port = Integer(str, 10)
|
|
106
|
+
raise FormatError, "invalid port" unless port.between?(0, 65_535)
|
|
107
|
+
|
|
108
|
+
port
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def initialize(input)
|
|
112
|
+
raise FormatError, "not a URL" unless input.is_a?(String)
|
|
113
|
+
|
|
114
|
+
@original = input.strip
|
|
115
|
+
scheme, rest = @original.split("://", 2)
|
|
116
|
+
raise FormatError, "not a URL" unless rest
|
|
117
|
+
|
|
118
|
+
@scheme = scheme.downcase
|
|
119
|
+
raise FormatError, "unsupported scheme '#{@scheme}'" unless %w[sqrl qrl].include?(@scheme)
|
|
120
|
+
|
|
121
|
+
rest = rest.split("#", 2).first || ""
|
|
122
|
+
end_of_authority = rest.index(%r{[/?]}) || rest.length
|
|
123
|
+
authority_raw = rest[0...end_of_authority]
|
|
124
|
+
path_and_query = rest[end_of_authority..] || ""
|
|
125
|
+
path, query = path_and_query.split("?", 2)
|
|
126
|
+
@path = path.nil? || path.empty? ? "/" : path
|
|
127
|
+
@query = query || ""
|
|
128
|
+
|
|
129
|
+
hostport = authority_raw.include?("@") ? authority_raw.rpartition("@").last : authority_raw
|
|
130
|
+
host, @port = self.class.split_host_port(hostport)
|
|
131
|
+
raise FormatError, "missing host" if host.empty?
|
|
132
|
+
|
|
133
|
+
@host = host.downcase
|
|
134
|
+
raise FormatError, "invalid host" if @host.match?(%r{[\s/\\]})
|
|
135
|
+
|
|
136
|
+
@authority = @port ? "#{@host}:#{@port}" : @host
|
|
137
|
+
if @scheme == "qrl" && !self.class.loopback?(@host)
|
|
138
|
+
raise FormatError, "plain-http qrl:// URLs are only allowed for localhost"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
@nut = nil
|
|
142
|
+
@path_extension = 0
|
|
143
|
+
cancel = nil
|
|
144
|
+
self.class.query_pairs(@query).each do |k, v|
|
|
145
|
+
case k
|
|
146
|
+
when "nut" then @nut = v
|
|
147
|
+
when "x", "X"
|
|
148
|
+
# GRC's client treats x= and X= identically and parses the value with an
|
|
149
|
+
# "intolerant" converter that stops at the first non-digit.
|
|
150
|
+
digits = v[/\A\d*/]
|
|
151
|
+
@path_extension = digits.empty? ? 0 : digits.to_i
|
|
152
|
+
when "can"
|
|
153
|
+
bytes = Util.b64u_decode(v)
|
|
154
|
+
cancel = bytes.force_encoding(Encoding::UTF_8)
|
|
155
|
+
raise FormatError, "can= is not UTF-8" unless cancel.valid_encoding?
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@site_key = @host.dup
|
|
160
|
+
if @path_extension.positive?
|
|
161
|
+
take = [@path_extension, @path.bytesize].min
|
|
162
|
+
# never cut in the middle of a multi-byte character
|
|
163
|
+
take -= 1 until @path.byteslice(0, take).force_encoding(Encoding::UTF_8).valid_encoding?
|
|
164
|
+
@site_key << @path.byteslice(0, take).force_encoding(Encoding::UTF_8)
|
|
165
|
+
end
|
|
166
|
+
@site_key.freeze
|
|
167
|
+
@cancel_url = cancel && resolve(cancel)
|
|
168
|
+
freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# +https+ (or +http+ for +qrl://+).
|
|
172
|
+
def http_scheme
|
|
173
|
+
@scheme == "qrl" ? "http" : "https"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# +https://authority+
|
|
177
|
+
def origin
|
|
178
|
+
"#{http_scheme}://#{@authority}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The URL to POST the first (query) request to.
|
|
182
|
+
def post_url
|
|
183
|
+
@query.empty? ? "#{origin}#{@path}" : "#{origin}#{@path}?#{@query}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The URL to POST a follow-up request to, from the server's +qry=+ value.
|
|
187
|
+
#
|
|
188
|
+
# +qry+ must be a path on the same host (it starts with +/+); absolute URLs pointing
|
|
189
|
+
# elsewhere are rejected to stop a compromised response redirecting signatures.
|
|
190
|
+
#
|
|
191
|
+
# @raise [ProtocolError]
|
|
192
|
+
def post_url_for_qry(qry)
|
|
193
|
+
qry = qry.to_s.strip
|
|
194
|
+
unless qry.start_with?("/") && !qry.start_with?("//")
|
|
195
|
+
raise ProtocolError, "qry must be a server-relative path, got '#{qry}'"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
"#{origin}#{qry}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Site key bytes, optionally extended with an Alt-ID (+site_key || 0x00 || alt_id+).
|
|
202
|
+
#
|
|
203
|
+
# @return [String] binary
|
|
204
|
+
def site_key_bytes(alt_id = nil)
|
|
205
|
+
bytes = @site_key.b
|
|
206
|
+
alt = alt_id.to_s.strip
|
|
207
|
+
bytes << "\0" << alt.b unless alt.empty?
|
|
208
|
+
bytes
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Resolve a possibly-relative URL (from +can=+ or +url=+) against this site.
|
|
212
|
+
def resolve(target)
|
|
213
|
+
t = target.to_s.strip
|
|
214
|
+
if t.start_with?("https://", "http://")
|
|
215
|
+
t
|
|
216
|
+
elsif t.start_with?("/")
|
|
217
|
+
"#{origin}#{t}"
|
|
218
|
+
else
|
|
219
|
+
"#{origin}/#{t}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Whether an absolute URL (e.g. the CPS +url=+) points at this site's host, using the
|
|
224
|
+
# site's own HTTP scheme (https for sqrl://, http only for loopback qrl:// sites), so
|
|
225
|
+
# a reply cannot downgrade the browser to plain http.
|
|
226
|
+
def same_host?(target)
|
|
227
|
+
t = target.to_s.strip
|
|
228
|
+
scheme, rest = t.split("://", 2)
|
|
229
|
+
return false unless rest && scheme.casecmp?(http_scheme)
|
|
230
|
+
|
|
231
|
+
stop = rest.index(%r{[/?#]}) || rest.length
|
|
232
|
+
hostport = rest[0...stop]
|
|
233
|
+
hostport = hostport.rpartition("@").last if hostport.include?("@")
|
|
234
|
+
host, = self.class.split_host_port(hostport)
|
|
235
|
+
host.casecmp?(@host)
|
|
236
|
+
rescue FormatError
|
|
237
|
+
false
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @return [String] the URL as received
|
|
241
|
+
def to_s
|
|
242
|
+
@original
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Boolean] whether +other+ carries the same values
|
|
246
|
+
def ==(other)
|
|
247
|
+
other.is_a?(URL) && other.original == @original
|
|
248
|
+
end
|
|
249
|
+
alias eql? ==
|
|
250
|
+
|
|
251
|
+
# @return [Integer] hash consistent with {#==}
|
|
252
|
+
def hash
|
|
253
|
+
@original.hash
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# @return [String] a short description with secret material redacted
|
|
257
|
+
def inspect
|
|
258
|
+
"#<SQRL::URL #{@original}>"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
data/lib/sqrl/util.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module SQRL
|
|
7
|
+
# Small helpers: base64url (no padding), hex, random bytes, little-endian integers,
|
|
8
|
+
# constant-time comparison and best-effort wiping of secret strings.
|
|
9
|
+
module Util
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Encode bytes as base64url without padding (the only base64 variant SQRL uses).
|
|
13
|
+
def b64u_encode(data)
|
|
14
|
+
[data].pack("m0").tr("+/", "-_").delete("=")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Decode base64url. Padding characters and ASCII whitespace are tolerated and ignored,
|
|
18
|
+
# as the SQRL storage spec requires for the textual +SQRLDATA+ form.
|
|
19
|
+
def b64u_decode(text)
|
|
20
|
+
raise FormatError, "invalid base64url: not a string" unless text.is_a?(String)
|
|
21
|
+
|
|
22
|
+
cleaned = text.delete(" \t\r\n\f\v=")
|
|
23
|
+
raise FormatError, "invalid base64url: bad characters" unless cleaned.match?(/\A[A-Za-z0-9_-]*\z/)
|
|
24
|
+
raise FormatError, "invalid base64url: bad length" if (cleaned.length % 4) == 1
|
|
25
|
+
|
|
26
|
+
padded = cleaned.tr("-_", "+/")
|
|
27
|
+
padded += "=" * ((4 - (padded.length % 4)) % 4)
|
|
28
|
+
padded.unpack1("m0").b
|
|
29
|
+
rescue ArgumentError => e
|
|
30
|
+
raise FormatError, "invalid base64url: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Decode base64url into exactly +size+ bytes.
|
|
34
|
+
def b64u_decode_fixed(text, size)
|
|
35
|
+
bytes = b64u_decode(text)
|
|
36
|
+
raise FormatError, "expected #{size} bytes, got #{bytes.bytesize}" unless bytes.bytesize == size
|
|
37
|
+
|
|
38
|
+
bytes
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Lower-case hex encoding.
|
|
42
|
+
def hex_encode(data)
|
|
43
|
+
data.unpack1("H*")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Hex decoding (case-insensitive, whitespace ignored).
|
|
47
|
+
def hex_decode(text)
|
|
48
|
+
cleaned = text.delete(" \t\r\n\f\v")
|
|
49
|
+
raise FormatError, "odd-length hex string" if cleaned.length.odd?
|
|
50
|
+
raise FormatError, "bad hex" unless cleaned.match?(/\A[0-9a-fA-F]*\z/)
|
|
51
|
+
|
|
52
|
+
[cleaned].pack("H*")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# +n+ cryptographically secure random bytes.
|
|
56
|
+
def random_bytes(n)
|
|
57
|
+
SecureRandom.random_bytes(n)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Read a little-endian unsigned 16-bit integer.
|
|
61
|
+
# @param bytes [String]
|
|
62
|
+
# @param offset [Integer]
|
|
63
|
+
# @return [Integer]
|
|
64
|
+
def le_u16(bytes, offset = 0)
|
|
65
|
+
bytes.byteslice(offset, 2).unpack1("v")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Read a little-endian unsigned 32-bit integer.
|
|
69
|
+
# @param bytes [String]
|
|
70
|
+
# @param offset [Integer]
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def le_u32(bytes, offset = 0)
|
|
73
|
+
bytes.byteslice(offset, 4).unpack1("V")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [String] +value+ as 2 little-endian bytes
|
|
77
|
+
def pack_le_u16(value)
|
|
78
|
+
[value].pack("v")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [String] +value+ as 4 little-endian bytes
|
|
82
|
+
def pack_le_u32(value)
|
|
83
|
+
[value].pack("V")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# XOR two equal-length byte strings, returning a new binary string.
|
|
87
|
+
def xor(a, b)
|
|
88
|
+
raise ArgumentError, "xor length mismatch" unless a.bytesize == b.bytesize
|
|
89
|
+
|
|
90
|
+
a.bytes.zip(b.bytes).map { |x, y| x ^ y }.pack("C*")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Constant-time equality for two byte strings (false when the lengths differ).
|
|
94
|
+
def secure_compare(a, b)
|
|
95
|
+
return false unless a.is_a?(String) && b.is_a?(String) && a.bytesize == b.bytesize
|
|
96
|
+
|
|
97
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Best-effort wipe of a mutable String holding secret material. Ruby cannot guarantee
|
|
101
|
+
# that no copies remain; this merely shortens the window for the buffer we own.
|
|
102
|
+
def wipe(str)
|
|
103
|
+
return str unless str.is_a?(String) && !str.frozen?
|
|
104
|
+
|
|
105
|
+
str.replace("\0" * str.bytesize)
|
|
106
|
+
str.clear
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Force a binary-encoded copy of a string without mutating the argument.
|
|
110
|
+
def binary(str)
|
|
111
|
+
str.b
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/sqrl/version.rb
ADDED
data/lib/sqrl.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sqrl/version"
|
|
4
|
+
require_relative "sqrl/errors"
|
|
5
|
+
require_relative "sqrl/util"
|
|
6
|
+
require_relative "sqrl/crypto"
|
|
7
|
+
require_relative "sqrl/identity"
|
|
8
|
+
|
|
9
|
+
# SQRL (Secure Quick Reliable Login) for Ruby.
|
|
10
|
+
module SQRL
|
|
11
|
+
autoload :Storage, "sqrl/storage"
|
|
12
|
+
autoload :S4Identity, "sqrl/storage"
|
|
13
|
+
autoload :Textual, "sqrl/textual"
|
|
14
|
+
autoload :URL, "sqrl/url"
|
|
15
|
+
autoload :Protocol, "sqrl/protocol"
|
|
16
|
+
autoload :Tif, "sqrl/protocol"
|
|
17
|
+
autoload :Command, "sqrl/protocol"
|
|
18
|
+
autoload :ClientOptions, "sqrl/protocol"
|
|
19
|
+
autoload :Ask, "sqrl/protocol"
|
|
20
|
+
autoload :ServerResponse, "sqrl/protocol"
|
|
21
|
+
autoload :Session, "sqrl/protocol/session"
|
|
22
|
+
autoload :Server, "sqrl/server" # SQRL::Server::Redis needs an explicit require "sqrl/server/redis"
|
|
23
|
+
autoload :TestVectors, "sqrl/test_vectors"
|
|
24
|
+
end
|