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 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"
@@ -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
@@ -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