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.
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQRL
4
+ # Version shared by every gem in the family.
5
+ VERSION = "0.1.0"
6
+ end
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