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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9ed49b9ca739655cee06b71573b4ef1c7f8c799e2a3692fb35e7dd0f1d80d212
|
|
4
|
+
data.tar.gz: a2a6f2e31558e35661eb5d882616c837d174359786a19f5e5ba85d03fb4326a6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d878db822fc637d3e9e9aa6a4c93b2b168c65a1432f4496b5dc013c1b16d4e7a0ee8acb946561091addc966e71479c00fd9467c8719e74454d3a46590c7ce456
|
|
7
|
+
data.tar.gz: 0c1ea6ad5a25c496b2bb43c871f5ce7355bd7143ae7cbcdb2c1bc33755952f8854ef6a4c1fa4d96bbf42ca40bcee13cf399c739b903f961460b3148d6e68cded
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 — 2026-10-20
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Complete SQRL wire protocol: `query`, `ident`, `disable`, `enable`, `remove`, every TIF
|
|
8
|
+
bit, `ask`/`btn` with a server-side accept-list, `sin`/`ins`/`pins`, previous identities
|
|
9
|
+
and re-keying, `x=` path extension, Alt-IDs, `can=`, Client Provided Session.
|
|
10
|
+
- Cryptography on OpenSSL only: EnHash, EnScrypt, AES-256-GCM, Ed25519, X25519.
|
|
11
|
+
- S4 identity files (type 1/2/3, binary and text), rescue codes, password changes,
|
|
12
|
+
re-keying, base56 textual identities; all published test vectors pass and GRC's reference
|
|
13
|
+
client request bodies are reproduced byte for byte.
|
|
14
|
+
- `SQRL::Session`: the client transaction state machine; `sqrl://` (HTTPS) only unless
|
|
15
|
+
`insecure: true` (loopback testing mode).
|
|
16
|
+
- `SQRL::Server::Handler` with `Config`, three store interfaces, in-memory and Redis store
|
|
17
|
+
sets, one-time CPS tokens, nut single use, `server=` echo verification, IP matching
|
|
18
|
+
(`require_ip_match`, on by default), `allowed_hosts`, `prune_every`, `before_issue` /
|
|
19
|
+
`before_handle` hooks and `SQRL::Server::RateLimited`.
|
|
20
|
+
- A shared store-contract spec for store implementations.
|
|
21
|
+
- Requires Ruby 3.1 or newer.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel P. Clark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# sqrl-core
|
|
2
|
+
|
|
3
|
+
A pure-Ruby implementation of [SQRL](https://www.grc.com/sqrl/sqrl.htm) (Secure Quick
|
|
4
|
+
Reliable Login): the cryptography, the identity key derivations and identity lock, S4
|
|
5
|
+
identity storage, the base56 textual identity, the client transaction state machine and a
|
|
6
|
+
server-side protocol handler with pluggable stores. Everything the other `sqrl-*` gems build
|
|
7
|
+
on. The only runtime dependency is Ruby's bundled `openssl` gem (3.0 or newer, which ships
|
|
8
|
+
with Ruby 3.1+).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem "sqrl-core"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
require "sqrl/core" # or require "sqrl"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## What it implements
|
|
21
|
+
|
|
22
|
+
- **Wire protocol**: `query`, `ident`, `disable`, `enable`, `remove`; `ids`/`pids`/`urs`
|
|
23
|
+
signatures over `client || server`; every TIF bit, with undefined bits aborting the
|
|
24
|
+
client; `ask`/`btn` (with a server-side accept-list); `sin`/`ins`/`pins`; previous
|
|
25
|
+
identities and re-keying; `sqrlonly`/`hardlock`/`cps`/`suk`/`noiptest`; the `x=` path
|
|
26
|
+
extension; Alt-IDs; `can=`; Client Provided Session (`url=`). The request bodies
|
|
27
|
+
reproduce GRC's reference client log byte for byte.
|
|
28
|
+
- **Cryptography**: EnHash, EnScrypt (iteration- and duration-based), AES-256-GCM,
|
|
29
|
+
Ed25519, X25519, all through OpenSSL.
|
|
30
|
+
- **Identities**: IMK/ILK/IUK, per-site keys, the identity lock (RLK/SUK/VUK/URSK),
|
|
31
|
+
indexed secrets; S4 identity files (type 1, 2 and 3 blocks, `sqrldata` binary and
|
|
32
|
+
`SQRLDATA` text), rescue codes, password changes, re-keying with up to four previous
|
|
33
|
+
identities; the base56 textual identity with check characters.
|
|
34
|
+
- **Server engine**: `SQRL::Server::Handler`, a port of the reference server whose state
|
|
35
|
+
sits behind three store interfaces, with nut single use, `server=` echo verification,
|
|
36
|
+
IP matching, identity binding, superseded handling, one-time CPS tokens, rate-limit and
|
|
37
|
+
prune hooks. In-memory and Redis store sets ship here; ActiveRecord and cache sets live
|
|
38
|
+
in `sqrl-rails`.
|
|
39
|
+
|
|
40
|
+
## Client: one transaction
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
identity = SQRL::UnlockedIdentity.from_iuk(SecureRandom.random_bytes(32))
|
|
44
|
+
session = SQRL::Session.new(identity, "sqrl://example.com/sqrl/cli?nut=…",
|
|
45
|
+
options: SQRL::ClientOptions.new(cps: true))
|
|
46
|
+
|
|
47
|
+
request = session.query # => Request(url:, body:, command:, client_text:)
|
|
48
|
+
session.feed(http_post(request)) # the server's base64url body
|
|
49
|
+
session.state # => :unknown | :known | :previous_known | :transient | :superseded | :failed
|
|
50
|
+
session.pending_ask # => SQRL::Ask or nil; answer with session.set_button(1)
|
|
51
|
+
|
|
52
|
+
request = session.ident # creates the account, logs in, or re-keys, as the answer dictates
|
|
53
|
+
session.feed(http_post(request))
|
|
54
|
+
session.cps_url # validated https URL to send the browser to (nil without cps)
|
|
55
|
+
|
|
56
|
+
session.disable # needs only ids
|
|
57
|
+
session.enable(iuk) # needs the IUK (rescue code) for urs
|
|
58
|
+
session.remove(iuk)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`SQRL::URL.parse` gives the site key (`site_key_bytes(alt_id)`), the POST URL and the
|
|
62
|
+
cancel URL. Only `sqrl://` (HTTPS) is accepted; `SQRL::Session.new(..., insecure: true)`
|
|
63
|
+
additionally accepts the draft-era `qrl://` for loopback hosts, which exists solely for the
|
|
64
|
+
browser extension's local test server.
|
|
65
|
+
|
|
66
|
+
## Server
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
config = SQRL::Server::Config.new(host: "example.com")
|
|
70
|
+
handler = SQRL::Server::Handler.new(config: config, stores: SQRL::Server::Memory.stores)
|
|
71
|
+
|
|
72
|
+
login = handler.new_login_url(ip: request_ip, return_to: "/account")
|
|
73
|
+
login.url # "sqrl://example.com/sqrl/cli?nut=…" for the page and the QR code
|
|
74
|
+
login.pending_login_id # keep in the browser session; never give it to the client
|
|
75
|
+
|
|
76
|
+
result = handler.handle(nut: params["nut"], body: raw_post_body, ip: request_ip)
|
|
77
|
+
result.body # base64url text to return with status 200
|
|
78
|
+
result.authenticated? # true after a successful ident/enable
|
|
79
|
+
result.cps_url # the one-time url= handed to the client, when it asked for cps
|
|
80
|
+
|
|
81
|
+
handler.status(pending_login_id) # {authenticated:, idk:} for the browser's poller
|
|
82
|
+
handler.claim_cps(token) # PendingLogin or nil; single use, expires after cps_token_ttl
|
|
83
|
+
handler.prune!
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Config options
|
|
87
|
+
|
|
88
|
+
| Option | Default | Meaning |
|
|
89
|
+
| --- | --- | --- |
|
|
90
|
+
| `host` | `nil` | public host (with port) placed in `sqrl://` URLs; identities derive from it, so set it in production |
|
|
91
|
+
| `path` | `/sqrl/cli` | the endpoint path, also used in `qry=` |
|
|
92
|
+
| `callback_path` | `/sqrl/callback` | where the default CPS `url=` points (plus `?t=TOKEN`) |
|
|
93
|
+
| `insecure_http` | `false` | loopback-only testing mode issuing `qrl://`; refused for other hosts, warns once |
|
|
94
|
+
| `path_extension` | `nil` | `x=N`: include the first N characters of the path in the site key |
|
|
95
|
+
| `nut_ttl` | `300` | seconds a login-page nut stays valid |
|
|
96
|
+
| `cps_token_ttl` | `60` | seconds a CPS token stays claimable |
|
|
97
|
+
| `require_ip_match` | `true` | refuse a client whose IP differs from the browser's unless it sends `noiptest` |
|
|
98
|
+
| `ask` | `nil` | `SQRL::Ask` (or a callable receiving a context hash) posed in the query reply; `accept:` limits the buttons that let `ident` proceed |
|
|
99
|
+
| `sin` | `nil` | secret-index challenge (string or callable); the client answers with `ins`/`pins` |
|
|
100
|
+
| `on_unknown_identity` | `:create` | `:create` an association, `:link` only when the pending login carries a `link_user_ref`, or `:refuse` |
|
|
101
|
+
| `on_authenticated` | `nil` | callable `(identity, pending_login, command)` after a successful command |
|
|
102
|
+
| `cps_url` | `nil` | callable `(token, pending_login) -> URL` replacing the default |
|
|
103
|
+
| `allowed_hosts` | `nil` | strings or regexps the request host must match when `host` is derived |
|
|
104
|
+
| `prune_every` | `1000` | call `prune!` opportunistically every N requests (`nil` disables) |
|
|
105
|
+
| `before_issue` / `before_handle` | `nil` | hooks that may raise `SQRL::Server::RateLimited` |
|
|
106
|
+
| `clock` / `logger` | `nil` | injectable clock (tests) and logger |
|
|
107
|
+
|
|
108
|
+
### Stores
|
|
109
|
+
|
|
110
|
+
The handler keeps every record behind three small interfaces, documented in
|
|
111
|
+
`SQRL::Server::NutStore`, `IdentityStore` and `PendingLoginStore`:
|
|
112
|
+
|
|
113
|
+
| Store | Records | Must be atomic |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| nuts | one per issued nut: pending login, expected `server=` echo, IP, bound identity, expiry | `consume` (exactly one caller wins) |
|
|
116
|
+
| identities | one per association: `idk`, `suk`, `vuk`, flags, `ins`, login count, `user_ref` | `rekey` |
|
|
117
|
+
| pending logins | one per login page view: authenticated identity, `ip_matched`, ask state, CPS token, `return_to` | `claim_cps_token` (single use) |
|
|
118
|
+
|
|
119
|
+
Implementations: `SQRL::Server::Memory.stores` (per process; development and tests),
|
|
120
|
+
`SQRL::Server::Redis.stores(redis: Redis.new)` (requires the `redis` gem; `GETDEL` and Lua
|
|
121
|
+
scripts for the single-use operations, TTLs for expiry), and in `sqrl-rails`
|
|
122
|
+
`SQRL::Rails::Stores::ActiveRecord` and `::Cache`. A shared "a SQRL store set" spec under
|
|
123
|
+
`spec/support/store_contract.rb` exercises any implementation, including concurrent
|
|
124
|
+
consume and claim races.
|
|
125
|
+
|
|
126
|
+
## Identities
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
file = SQRL::S4Identity.parse(File.binread("me.sqrl")) # binary "sqrldata" or "SQRLDATA" text
|
|
130
|
+
unlocked = file.unlock("correct horse") # EnScrypt over the type 1 block
|
|
131
|
+
iuk = file.unlock_iuk("1198-8748-7132-2838-8318-7570") # the rescue code frees the IUK
|
|
132
|
+
file.to_text # "SQRLDATA…" export
|
|
133
|
+
SQRL::Textual.encode(file.rescue_only.to_bytes) # base56 textual identity
|
|
134
|
+
|
|
135
|
+
new_file = SQRL::S4Identity.create(iuk, "password", SQRL::Storage.generate_rescue_code)
|
|
136
|
+
new_file.set_password(unlocked, "new password")
|
|
137
|
+
new_file.rekey(unlocked, iuk, new_iuk, "password", new_rescue_code)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
EnScrypt cost: one scrypt iteration (N=512, r=256) takes roughly 45 ms with OpenSSL 3, so
|
|
141
|
+
unlocking a real identity file takes a few seconds and creating one takes the configured
|
|
142
|
+
password and rescue durations. Tests and bots use `SQRL::UnlockedIdentity.from_iuk`
|
|
143
|
+
directly and skip EnScrypt.
|
|
144
|
+
|
|
145
|
+
## Test vectors
|
|
146
|
+
|
|
147
|
+
`SQRL::TestVectors` carries every published vector: the Spec-Vectors-Identity (password,
|
|
148
|
+
rescue code, textual identity, IMK, per-site keys), GRC's `SQRL.log` query and ident
|
|
149
|
+
bodies, the EnScrypt vectors from grc.com and the newsgroup, the base56 examples validated
|
|
150
|
+
against GRC's client, and the Android client's sample identity file. The specs assert the
|
|
151
|
+
request bodies byte for byte.
|
|
152
|
+
|
|
153
|
+
## Authority and credits
|
|
154
|
+
|
|
155
|
+
GRC's specification documents (*SQRL Explained*, *Operating Details* v1.01, *Cryptography*
|
|
156
|
+
v1.04, *On The Wire* v1.07) are the authority. The Rust implementation in
|
|
157
|
+
[SQRL-extension](https://github.com/danielpclark/SQRL-extension) is one careful reading of
|
|
158
|
+
them and the practical source for this port; where the two differ, the documents win. SQRL
|
|
159
|
+
was designed by Steve Gibson of Gibson Research Corporation, who placed the specification in
|
|
160
|
+
the public domain.
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT. See [LICENSE](LICENSE).
|
data/lib/sqrl/core.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entry point matching the gem name: `gem "sqrl-core"` is required as `require "sqrl/core"`
|
|
4
|
+
# (the dash maps to a directory, as for every extension-style gem name). It loads the whole
|
|
5
|
+
# library; `require "sqrl"` remains available and loads the same thing.
|
|
6
|
+
require_relative "../sqrl"
|
data/lib/sqrl/crypto.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module SQRL
|
|
6
|
+
# Cryptographic primitives used by SQRL.
|
|
7
|
+
#
|
|
8
|
+
# * EnHash: 16 chained iterations of SHA-256 whose outputs are XORed together.
|
|
9
|
+
# * EnScrypt: scrypt (N = 2**log_n, r = 256, p = 1, dkLen = 32) chained and XORed over
|
|
10
|
+
# many iterations, either for a fixed iteration count or for a fixed duration.
|
|
11
|
+
# * HMAC-SHA256, AES-256-GCM, Ed25519 and X25519 via OpenSSL.
|
|
12
|
+
#
|
|
13
|
+
# All byte arguments and results are binary-encoded Strings.
|
|
14
|
+
module Crypto
|
|
15
|
+
# scrypt +r+ parameter mandated by SQRL.
|
|
16
|
+
SCRYPT_R = 256
|
|
17
|
+
# scrypt +p+ parameter mandated by SQRL.
|
|
18
|
+
SCRYPT_P = 1
|
|
19
|
+
# Default scrypt log2(N) used by SQRL clients (N = 512).
|
|
20
|
+
DEFAULT_LOG_N = 9
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# SHA-256 digest.
|
|
25
|
+
# @param data [String] bytes
|
|
26
|
+
# @return [String] 32 binary bytes
|
|
27
|
+
def sha256(data)
|
|
28
|
+
OpenSSL::Digest.new("SHA256").digest(data)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# HMAC-SHA256.
|
|
32
|
+
# @param key [String] key bytes
|
|
33
|
+
# @param data [String] message bytes
|
|
34
|
+
# @return [String] 32 binary bytes
|
|
35
|
+
def hmac_sha256(key, data)
|
|
36
|
+
OpenSSL::HMAC.digest("SHA256", key, data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# EnHash: SHA-256 applied 16 times in a chain; all 16 outputs XORed together.
|
|
40
|
+
def enhash(input)
|
|
41
|
+
current = sha256(input)
|
|
42
|
+
acc = current.bytes
|
|
43
|
+
15.times do
|
|
44
|
+
current = sha256(current)
|
|
45
|
+
current.each_byte.with_index { |b, i| acc[i] ^= b }
|
|
46
|
+
end
|
|
47
|
+
acc.pack("C*")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# One raw scrypt invocation with SQRL's parameters (r = 256, p = 1, dkLen = 32).
|
|
51
|
+
def scrypt_once(password, salt, log_n = DEFAULT_LOG_N)
|
|
52
|
+
OpenSSL::KDF.scrypt(password.b, salt: salt.b, N: 1 << log_n, r: SCRYPT_R, p: SCRYPT_P, length: 32)
|
|
53
|
+
rescue OpenSSL::KDF::KDFError => e
|
|
54
|
+
raise CryptoError, "scrypt unavailable or failed (FIPS mode?): #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# EnScrypt with a fixed iteration count.
|
|
58
|
+
#
|
|
59
|
+
# The first iteration uses the caller-supplied salt; each subsequent iteration uses the
|
|
60
|
+
# previous iteration's output as its salt, and the result is the XOR of every
|
|
61
|
+
# iteration's output. The block, if given, is called after each iteration with the
|
|
62
|
+
# number of iterations completed.
|
|
63
|
+
def enscrypt_iterations(password, salt, log_n, iterations)
|
|
64
|
+
iterations = [iterations.to_i, 1].max
|
|
65
|
+
key = scrypt_once(password, salt, log_n)
|
|
66
|
+
acc = key.bytes
|
|
67
|
+
yield 1 if block_given?
|
|
68
|
+
(1...iterations).each do |i|
|
|
69
|
+
key = scrypt_once(password, key, log_n)
|
|
70
|
+
key.each_byte.with_index { |b, j| acc[j] ^= b }
|
|
71
|
+
yield i + 1 if block_given?
|
|
72
|
+
end
|
|
73
|
+
acc.pack("C*")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# EnScrypt for (at least) +duration_ms+ milliseconds. +now_ms+ is a callable returning
|
|
77
|
+
# a monotonic millisecond clock (injectable for tests). Returns +[key, iterations]+;
|
|
78
|
+
# the iteration count must be stored so the key can be re-derived with
|
|
79
|
+
# enscrypt_iterations. The block, if given, receives +(elapsed_ms, duration_ms)+.
|
|
80
|
+
def enscrypt_duration(password, salt, log_n, duration_ms, now_ms: method(:monotonic_ms))
|
|
81
|
+
start = now_ms.call
|
|
82
|
+
key = scrypt_once(password, salt, log_n)
|
|
83
|
+
acc = key.bytes
|
|
84
|
+
iterations = 1
|
|
85
|
+
elapsed = [now_ms.call - start, 0].max
|
|
86
|
+
yield elapsed, duration_ms if block_given?
|
|
87
|
+
while elapsed < duration_ms && iterations < 0xFFFF_FFFF
|
|
88
|
+
key = scrypt_once(password, key, log_n)
|
|
89
|
+
key.each_byte.with_index { |b, j| acc[j] ^= b }
|
|
90
|
+
iterations += 1
|
|
91
|
+
elapsed = [now_ms.call - start, 0].max
|
|
92
|
+
yield elapsed, duration_ms if block_given?
|
|
93
|
+
end
|
|
94
|
+
[acc.pack("C*"), iterations]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# @return [Integer] a monotonic clock in milliseconds (the default +now_ms+ for {enscrypt_duration})
|
|
98
|
+
def monotonic_ms
|
|
99
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# AES-256-GCM encryption. Returns ciphertext followed by the 16-byte tag.
|
|
103
|
+
def aes_gcm_encrypt(key, iv, aad, plaintext)
|
|
104
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
|
|
105
|
+
cipher.key = key
|
|
106
|
+
cipher.iv = iv
|
|
107
|
+
cipher.auth_data = aad
|
|
108
|
+
out = cipher.update(plaintext.b) + cipher.final
|
|
109
|
+
out + cipher.auth_tag(16)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# AES-256-GCM decryption of +ciphertext || tag+. Raises CryptoError when the tag does
|
|
113
|
+
# not verify.
|
|
114
|
+
def aes_gcm_decrypt(key, iv, aad, ciphertext_and_tag)
|
|
115
|
+
raise CryptoError, "ciphertext too short" if ciphertext_and_tag.bytesize < 16
|
|
116
|
+
|
|
117
|
+
ciphertext = ciphertext_and_tag.byteslice(0, ciphertext_and_tag.bytesize - 16)
|
|
118
|
+
tag = ciphertext_and_tag.byteslice(-16, 16)
|
|
119
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
|
|
120
|
+
cipher.key = key
|
|
121
|
+
cipher.iv = iv
|
|
122
|
+
cipher.auth_tag = tag
|
|
123
|
+
cipher.auth_data = aad
|
|
124
|
+
cipher.update(ciphertext) + cipher.final
|
|
125
|
+
rescue OpenSSL::Cipher::CipherError
|
|
126
|
+
raise CryptoError, "authentication tag mismatch"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Ed25519 signing key object from a 32-byte seed.
|
|
130
|
+
def ed25519_from_seed(seed)
|
|
131
|
+
raise CryptoError, "ed25519 seed must be 32 bytes" unless seed.bytesize == 32
|
|
132
|
+
|
|
133
|
+
OpenSSL::PKey.new_raw_private_key("ED25519", seed.b)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Public key bytes of the Ed25519 key derived from +seed+.
|
|
137
|
+
def ed25519_public(seed)
|
|
138
|
+
ed25519_from_seed(seed).raw_public_key
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Sign +msg+ with the Ed25519 key derived from +seed+; returns the 64-byte signature.
|
|
142
|
+
def ed25519_sign(seed, msg)
|
|
143
|
+
ed25519_from_seed(seed).sign(nil, msg.b)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Verify an Ed25519 signature. Returns true/false; never raises on bad input.
|
|
147
|
+
def ed25519_verify(public_key, msg, signature)
|
|
148
|
+
return false unless public_key.bytesize == 32 && signature.bytesize == 64
|
|
149
|
+
|
|
150
|
+
OpenSSL::PKey.new_raw_public_key("ED25519", public_key.b).verify(nil, signature.b, msg.b)
|
|
151
|
+
rescue OpenSSL::PKey::PKeyError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# X25519 public key for a scalar (scalar multiplication of the base point).
|
|
156
|
+
def x25519_base(scalar)
|
|
157
|
+
OpenSSL::PKey.new_raw_private_key("X25519", scalar.b).raw_public_key
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# X25519 Diffie-Hellman: +scalar * point+.
|
|
161
|
+
def x25519(scalar, point)
|
|
162
|
+
OpenSSL::PKey.new_raw_private_key("X25519", scalar.b)
|
|
163
|
+
.derive(OpenSSL::PKey.new_raw_public_key("X25519", point.b))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
data/lib/sqrl/errors.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# Base class for every error raised by the SQRL gems.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Input could not be parsed (bad base64, bad header, bad length...).
|
|
8
|
+
class FormatError < Error; end
|
|
9
|
+
|
|
10
|
+
# A cryptographic check failed (bad password, bad rescue code, bad signature...).
|
|
11
|
+
class CryptoError < Error; end
|
|
12
|
+
|
|
13
|
+
# The protocol state does not permit the requested operation.
|
|
14
|
+
class ProtocolError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQRL
|
|
4
|
+
# Key derivations that flow from the Identity Unlock Key, and the unlocked in-memory
|
|
5
|
+
# identity.
|
|
6
|
+
#
|
|
7
|
+
# * IUK - Identity Unlock Key: the 256-bit root secret, only available after the user
|
|
8
|
+
# enters the rescue code. Needed to enable/remove an account and to re-key.
|
|
9
|
+
# * IMK - Identity Master Key = EnHash(IUK). Derives every per-site key.
|
|
10
|
+
# * ILK - Identity Lock Key = X25519(IUK, basepoint). Given to sites via SUK/VUK.
|
|
11
|
+
# * Per-site seed = HMAC-SHA256(key = IMK, msg = site key string); the Ed25519 key pair
|
|
12
|
+
# from that seed is the site identity (idk / ids).
|
|
13
|
+
# * INK = EnHash(per-site seed); ins = HMAC-SHA256(INK, sin).
|
|
14
|
+
# * RLK/SUK/VUK - per-account identity lock values, see Identity.make_server_lock.
|
|
15
|
+
# * URSK - Unlock Request Signing Key = Ed25519 seed X25519(IUK, SUK).
|
|
16
|
+
module Identity
|
|
17
|
+
# The identity-lock values a client sends to a server when creating or re-keying an
|
|
18
|
+
# account.
|
|
19
|
+
ServerLock = Struct.new(:suk, :vuk, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Identity Master Key = EnHash(IUK).
|
|
24
|
+
# @param iuk [String] 32 bytes
|
|
25
|
+
# @return [String] 32 bytes
|
|
26
|
+
def imk_from_iuk(iuk)
|
|
27
|
+
Crypto.enhash(iuk)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Identity Lock Key = X25519(IUK, basepoint).
|
|
31
|
+
# @param iuk [String] 32 bytes
|
|
32
|
+
# @return [String] 32 bytes
|
|
33
|
+
def ilk_from_iuk(iuk)
|
|
34
|
+
Crypto.x25519_base(iuk)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Per-site Ed25519 seed = HMAC-SHA256(key = IMK, msg = site key).
|
|
38
|
+
# @param imk [String] 32 bytes
|
|
39
|
+
# @param site_key [String] see {SQRL::URL#site_key_bytes}
|
|
40
|
+
# @return [String] 32 bytes
|
|
41
|
+
def site_seed(imk, site_key)
|
|
42
|
+
Crypto.hmac_sha256(imk, site_key)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Indexed-secret response: ins = HMAC-SHA256(key = EnHash(site seed), msg = sin).
|
|
46
|
+
def indexed_secret(site_seed, sin)
|
|
47
|
+
Crypto.hmac_sha256(Crypto.enhash(site_seed), sin)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Generate SUK/VUK for an Identity Lock Key using a fresh Random Lock Key.
|
|
51
|
+
def make_server_lock(ilk, rlk = Util.random_bytes(32))
|
|
52
|
+
suk = Crypto.x25519_base(rlk)
|
|
53
|
+
dhk = Crypto.x25519(rlk, ilk)
|
|
54
|
+
vuk = Crypto.ed25519_public(dhk)
|
|
55
|
+
Util.wipe(dhk)
|
|
56
|
+
ServerLock.new(suk: suk, vuk: vuk)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Unlock Request Signing Key seed: X25519(IUK, SUK). Its Ed25519 public key equals the
|
|
60
|
+
# VUK the server stored, which lets the server verify a +urs+ signature.
|
|
61
|
+
def unlock_request_seed(iuk, suk)
|
|
62
|
+
Crypto.x25519(iuk, suk)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A decrypted identity held in memory. +previous_iuks+ are ordered most-recent first.
|
|
67
|
+
class UnlockedIdentity
|
|
68
|
+
attr_reader :imk, :ilk, :previous_iuks
|
|
69
|
+
|
|
70
|
+
def initialize(imk, ilk, previous_iuks = [])
|
|
71
|
+
raise ArgumentError, "imk must be 32 bytes" unless imk.bytesize == 32
|
|
72
|
+
raise ArgumentError, "ilk must be 32 bytes" unless ilk.bytesize == 32
|
|
73
|
+
|
|
74
|
+
@imk = imk.b.freeze
|
|
75
|
+
@ilk = ilk.b.freeze
|
|
76
|
+
@previous_iuks = previous_iuks.map { |k| k.b.freeze }.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build a brand-new identity from an Identity Unlock Key.
|
|
80
|
+
def self.from_iuk(iuk, previous_iuks = [])
|
|
81
|
+
new(Identity.imk_from_iuk(iuk), Identity.ilk_from_iuk(iuk), previous_iuks)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Per-site Ed25519 seed for the current identity.
|
|
85
|
+
# @param site_key [String]
|
|
86
|
+
# @return [String] 32 bytes
|
|
87
|
+
def site_seed(site_key)
|
|
88
|
+
Identity.site_seed(@imk, site_key)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Per-site Ed25519 public key (idk) for the current identity.
|
|
92
|
+
def site_public_key(site_key)
|
|
93
|
+
Crypto.ed25519_public(site_seed(site_key))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Integer] number of previous identities available for +pidk+
|
|
97
|
+
def previous_count
|
|
98
|
+
@previous_iuks.length
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Per-site Ed25519 seed for previous identity +index+ (0 = most recent), or nil.
|
|
102
|
+
def previous_site_seed(index, site_key)
|
|
103
|
+
iuk = @previous_iuks[index]
|
|
104
|
+
return nil unless iuk
|
|
105
|
+
|
|
106
|
+
Identity.site_seed(Identity.imk_from_iuk(iuk), site_key)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Per-site public key (pidk) for previous identity +index+, or nil.
|
|
110
|
+
def previous_site_public_key(index, site_key)
|
|
111
|
+
seed = previous_site_seed(index, site_key)
|
|
112
|
+
seed && Crypto.ed25519_public(seed)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Generate the SUK/VUK pair for a new account association on a server.
|
|
116
|
+
def make_lock
|
|
117
|
+
Identity.make_server_lock(@ilk)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Unlock request signing seed using a previous identity's IUK (re-keying an account
|
|
121
|
+
# still associated with that previous identity).
|
|
122
|
+
def previous_unlock_request_seed(index, suk)
|
|
123
|
+
iuk = @previous_iuks[index]
|
|
124
|
+
iuk && Identity.unlock_request_seed(iuk, suk)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether +iuk+ is the IUK this identity was derived from.
|
|
128
|
+
def matches_iuk?(iuk)
|
|
129
|
+
Util.secure_compare(Identity.imk_from_iuk(iuk), @imk)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# 16 hex characters identifying the identity without revealing key material.
|
|
133
|
+
def fingerprint
|
|
134
|
+
Util.hex_encode(Crypto.sha256("sqrl-fingerprint#{@imk}"))[0, 16]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [String] a short description with secret material redacted
|
|
138
|
+
def inspect
|
|
139
|
+
"#<SQRL::UnlockedIdentity fingerprint=#{fingerprint} previous=#{previous_count}>"
|
|
140
|
+
end
|
|
141
|
+
alias to_s inspect
|
|
142
|
+
end
|
|
143
|
+
end
|