sdkey 0.2.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: a8d89ff1ef1aa3fb22572fa04b869e0bbc3d43874a8fc26609e8b14304484a66
4
+ data.tar.gz: b93267523e955d18224b0663a97157e7957a19115eab13f18f37a74ee1793d27
5
+ SHA512:
6
+ metadata.gz: 88436c0ae334d358ac3a27f570180d5ba8ec63ace13f9a7a9d9aede0fbdad5994ad68291900435c70fb793b23a8403c42c03d54b3197482ca2726f1ff7af912f
7
+ data.tar.gz: 2e256ba0ee1a0a83c53086ec54c303fa75ad46403d03eeebc9d5141efa4f0e409b966214253ea32e6186beb8a8ba8d5e3bf011faf275f1dbaa8b9b013c01f7cf
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SDKeyDev
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/PROTOCOL.md ADDED
@@ -0,0 +1,276 @@
1
+ # SDKey Client Protocol (C++ / C# / TypeScript implementer guide)
2
+
3
+ This document describes the SDKey client wire protocol. Native clients must implement the same byte layouts and verification order. An in-repo TypeScript helper (`SdkeyClient` in `@sdkey/sdk`) implements this protocol for session init, sealed validate, and client auth.
4
+
5
+ ## Embed at build time
6
+
7
+ | Constant | Description |
8
+ |---|---|
9
+ | `API_BASE_URL` | e.g. `https://api.sdkey.dev` (no trailing slash) |
10
+ | `APP_ID` | Application UUID |
11
+ | `APP_VERSION` | Exact app version string (must match `applications.version`) |
12
+ | `APP_PUBLIC_KEY` | Ed25519 public key, 32 raw bytes (or base64 of those bytes) |
13
+
14
+ ## Algorithms
15
+
16
+ | Primitive | Spec |
17
+ |---|---|
18
+ | App identity | Ed25519 sign / verify |
19
+ | Session key | HKDF-SHA256 → 32-byte AES key |
20
+ | Payload seal | AES-256-GCM, 12-byte IV, 128-bit tag |
21
+ | Canonical JSON | Object keys sorted lexicographically, no insignificant whitespace |
22
+ | Clock skew | Reject if `\|now - timestamp\| > 60` seconds |
23
+
24
+ ## Session key derivation
25
+
26
+ ```
27
+ IKM = clientNonce (32) || serverNonce (32)
28
+ salt = SESSION_HKDF_SALT (from signed hello as hkdfSaltB64)
29
+ info = UTF8("sdkey-session-v1" || appId)
30
+ OKM = HKDF-SHA256(IKM, salt, info, length=32)
31
+ ```
32
+
33
+ ## Flow
34
+
35
+ ### 1. `POST /api/v1/session/init`
36
+
37
+ Request:
38
+
39
+ ```json
40
+ {
41
+ "appId": "<uuid>",
42
+ "clientNonceB64": "<base64 32 bytes>",
43
+ "clientVersion": "<exact app version>"
44
+ }
45
+ ```
46
+
47
+ `clientVersion` must exactly match the application's configured version. Mismatch → `APP_OUTDATED`. Banned client IP → `IP_BANNED`. Disabled app → `APP_DISABLED`.
48
+
49
+ Success response (no `message` field):
50
+
51
+ ```json
52
+ {
53
+ "success": true,
54
+ "sessionId": "<uuid>",
55
+ "serverNonceB64": "...",
56
+ "hkdfSaltB64": "...",
57
+ "timestamp": 1720000000,
58
+ "signatureB64": "...",
59
+ "v": 1
60
+ }
61
+ ```
62
+
63
+ Failures are plaintext JSON. Customizable text for `APP_DISABLED` / `APP_OUTDATED` / `IP_BANNED` is in `error` (from app `responseMessages`):
64
+
65
+ ```json
66
+ {
67
+ "success": false,
68
+ "error": "Client version outdated",
69
+ "code": "APP_OUTDATED"
70
+ }
71
+ ```
72
+
73
+ **Client must** verify Ed25519 signature over canonical JSON of:
74
+
75
+ ```json
76
+ {
77
+ "appId": "...",
78
+ "hkdfSaltB64": "...",
79
+ "serverNonceB64": "...",
80
+ "sessionId": "...",
81
+ "timestamp": 1720000000,
82
+ "v": 1
83
+ }
84
+ ```
85
+
86
+ Then derive the AES session key.
87
+
88
+ ### 2. `POST /api/v1/licenses/validate`
89
+
90
+ Outer request envelope (HTTPS JSON):
91
+
92
+ ```json
93
+ {
94
+ "sessionId": "...",
95
+ "ivB64": "...",
96
+ "ciphertextB64": "...",
97
+ "tagB64": "..."
98
+ }
99
+ ```
100
+
101
+ Inner plaintext (before AES-GCM seal):
102
+
103
+ ```json
104
+ {
105
+ "hwid": "...",
106
+ "licenseKey": "SDKY-....",
107
+ "nonce": "<base64 16 bytes>",
108
+ "timestamp": 1720000001,
109
+ "v": 1
110
+ }
111
+ ```
112
+
113
+ `hwid` is **optional**. When omitted, the server skips HWID lock, HWID mismatch, and HWID-ban checks. When present and the app has `hwidLockEnabled` (default true), first-use binding and mismatch rules apply. IP bans apply regardless of HWID. Message strings for many codes are editable per app (`responseMessages` on `PATCH /api/v1/apps/:id/settings`).
114
+
115
+ Response envelope:
116
+
117
+ ```json
118
+ {
119
+ "sessionId": "...",
120
+ "ivB64": "...",
121
+ "ciphertextB64": "...",
122
+ "tagB64": "...",
123
+ "signatureB64": "..."
124
+ }
125
+ ```
126
+
127
+ After AES-GCM open, **both success and failure plaintext include `message`** (not a top-level `error`). Success uses the app's `OK` response message. Success also includes integer `subscriptionTier` (≥ 0; default `0` when the license was created without a tier):
128
+
129
+ ```json
130
+ {
131
+ "success": true,
132
+ "code": "OK",
133
+ "message": "validated",
134
+ "status": "active",
135
+ "expiresAt": "2026-01-01T00:00:00.000Z",
136
+ "subscriptionTier": 0,
137
+ "sessionId": "...",
138
+ "timestamp": 1720000001,
139
+ "v": 1
140
+ }
141
+ ```
142
+
143
+ Sealed failure plaintext example:
144
+
145
+ ```json
146
+ {
147
+ "success": false,
148
+ "code": "HWID_MISMATCH",
149
+ "message": "Hardware ID mismatch",
150
+ "status": null,
151
+ "expiresAt": null,
152
+ "sessionId": "...",
153
+ "timestamp": 1720000001,
154
+ "v": 1
155
+ }
156
+ ```
157
+
158
+ Plaintext validate (integration / when `CRYPTO_ENFORCE` is off): success has top-level `message`; failures use `{ success: false, error, code }`.
159
+
160
+ **Client order of operations (mandatory):**
161
+
162
+ 1. AES-GCM open → plaintext JSON
163
+ 2. Ed25519 verify(`APP_PUBLIC_KEY`, `canonicalJson(plaintext)`, `signature`)
164
+ 3. Check `timestamp` skew and `sessionId` match
165
+ 4. Only then honor `success`
166
+
167
+ Skipping step 2 defeats anti-spoof protection. A patched binary that skips verify can still be attacked offline — document this honestly to customers.
168
+
169
+ ## Public client auth (plaintext JSON)
170
+
171
+ These endpoints are **not** sealed. They still require `appId` + `clientVersion` (exact match) and are subject to IP bans. Optional `hwid` follows the same skip-when-omitted rules as validate (HWID ban only checked when `hwid` is present). Rate limit: 30 / min / IP (`clientAuth`). Opaque `sessionToken` TTL: 7 days (`USER_SESSION_TTL_SECONDS`).
172
+
173
+ ### `POST /api/v1/client/register`
174
+
175
+ ```json
176
+ {
177
+ "appId": "<uuid>",
178
+ "username": "player1",
179
+ "password": "••••••••",
180
+ "email": "optional@example.com",
181
+ "licenseKey": "SDKY-....",
182
+ "hwid": "...",
183
+ "clientVersion": "1.0.0"
184
+ }
185
+ ```
186
+
187
+ Username: 3–64 chars, `[a-zA-Z0-9._-]`. Password: 8–128. `licenseKey` may be required by app setting `requireLicenseToRegister` (default true) → `LICENSE_REQUIRED` when missing. Success returns an opaque user session (see below). HTTP `201` on success.
188
+
189
+ ### `POST /api/v1/client/login`
190
+
191
+ ```json
192
+ {
193
+ "appId": "<uuid>",
194
+ "username": "player1",
195
+ "password": "••••••••",
196
+ "hwid": "...",
197
+ "clientVersion": "1.0.0"
198
+ }
199
+ ```
200
+
201
+ ### `POST /api/v1/client/upgrade`
202
+
203
+ Upgrade the user's linked license with a higher-tier key. **No password** — username + new key only. New key's `subscriptionTier` must be **greater than** the user's current tier (no linked license → current = `0`).
204
+
205
+ ```json
206
+ {
207
+ "appId": "<uuid>",
208
+ "username": "player1",
209
+ "licenseKey": "SDKY-....",
210
+ "hwid": "...",
211
+ "clientVersion": "1.0.0"
212
+ }
213
+ ```
214
+
215
+ ### Auth success shape
216
+
217
+ Success has **no** customizable `message` field:
218
+
219
+ ```json
220
+ {
221
+ "success": true,
222
+ "sessionToken": "<opaque>",
223
+ "expiresAt": "2026-01-01T00:00:00.000Z",
224
+ "user": {
225
+ "id": "<uuid>",
226
+ "username": "player1",
227
+ "email": null,
228
+ "applicationId": "<uuid>"
229
+ },
230
+ "license": {
231
+ "id": "<uuid>",
232
+ "status": "active",
233
+ "expiresAt": null,
234
+ "subscriptionTier": 1
235
+ },
236
+ "session": {
237
+ "ip": "203.0.113.1",
238
+ "hwid": "..."
239
+ }
240
+ }
241
+ ```
242
+
243
+ `license` may be `null` when the user has no linked license. Never send or echo passwords in responses.
244
+
245
+ ## Failure codes
246
+
247
+ ### Sealed validate / crypto session
248
+
249
+ `SESSION_EXPIRED`, `CLOCK_SKEW`, `REPLAY`, `LICENSE_NOT_FOUND`, `APP_MISMATCH`, `BANNED`, `EXPIRED`, `HWID_MISMATCH`, `DECRYPT_FAIL`, `APP_DISABLED`, `APP_OUTDATED`, `HWID_BANNED`, `IP_BANNED`
250
+
251
+ Cryptographic protocol failures after a valid session typically return HTTP **200** with a sealed body so clients always take the decrypt/verify path. Session init failures (version / IP / disabled) return plaintext JSON errors.
252
+
253
+ ### Client auth (plaintext JSON error body)
254
+
255
+ Also: `LICENSE_REQUIRED`, `INVALID_CREDENTIALS`, `USERNAME_TAKEN`, `USER_NOT_FOUND`, `TIER_NOT_HIGHER`, plus shared codes such as `APP_OUTDATED`, `APP_DISABLED`, `IP_BANNED`, `HWID_BANNED`, `LICENSE_NOT_FOUND`, `BANNED`, `EXPIRED`, `APP_MISMATCH`. (`REGISTER_DISABLED` is a reserved editable message key.)
256
+
257
+ Error body — customizable text is in **`error`** (not `message`):
258
+
259
+ ```json
260
+ { "success": false, "error": "License tier must be higher than the current tier", "code": "TIER_NOT_HIGHER" }
261
+ ```
262
+
263
+ ## Threat model notes
264
+
265
+ - TLS alone is insufficient: an attacker who injects at the process boundary after decrypt can feed `success: true` unless Ed25519 verify binds the response to the app public key.
266
+ - Debugger skip-verify remains a residual bypass class for determined attackers.
267
+
268
+ ## Account modes vs validate wire format
269
+
270
+ **Dashboard Private mode does not change this validate protocol.** Sealed validate still sends plaintext `licenseKey` inside the AES-GCM inner payload. Private mode only changes how developers mint and store inventory (client-side mint → server stores hash/prefix only).
271
+
272
+ Future hash-only validate (anti-replay + app binding without presenting plaintext) is explicitly out of scope for this release.
273
+
274
+ ## Developer tooling (not part of sealed protocol)
275
+
276
+ Cookie or `Authorization: Bearer sdk_live_…` covers dashboard-equivalent management: `PATCH /api/v1/apps/:id/settings`, ban list/delete, `POST /api/v1/licenses/:id/ban` with scopes, create licenses with `subscriptionTier`, `POST /api/v1/licenses/:id/tier`, and `GET /api/v1/apps/:id/users`. See the web docs (API keys + API reference).
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # sdkey
2
+
3
+ Official Ruby client for [SDKey](https://docs.sdkey.dev) license authentication.
4
+
5
+ Implements the sealed session protocol: Ed25519-verified handshake, HKDF session keys, and AES-256-GCM validate envelopes, plus plaintext client auth (`register` / `login` / `upgrade`). See [PROTOCOL.md](./PROTOCOL.md).
6
+
7
+ **Version:** `0.2.0` (RubyGems / git tag `v0.2.0`)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ gem install sdkey
13
+ ```
14
+
15
+ Or in a Gemfile:
16
+
17
+ ```ruby
18
+ gem "sdkey", "~> 0.2.0"
19
+ ```
20
+
21
+ Requires Ruby 3.1+ with OpenSSL (Ed25519, AES-GCM, HKDF).
22
+
23
+ ## Quick start
24
+
25
+ Embed these values from the SDKey dashboard when you ship your app. `app_version` must **exactly match** the application version configured on the server (`clientVersion`); mismatch returns `APP_OUTDATED`.
26
+
27
+ ```ruby
28
+ require "sdkey"
29
+
30
+ client = Sdkey::Client.new(
31
+ api_base_url: "https://api.sdkey.dev",
32
+ app_id: "YOUR_APP_ID",
33
+ app_version: "1.0.0",
34
+ app_public_key_b64: "YOUR_APP_PUBLIC_KEY_BASE64"
35
+ )
36
+
37
+ begin
38
+ # hwid is optional (omit for web clients — server skips HWID checks)
39
+ result = client.validate("SDKY-XXXX-XXXX-XXXX-XXXX", "machine-hwid")
40
+ if result.success
41
+ puts "licensed #{result.status} #{result.expires_at} tier=#{result.subscription_tier}"
42
+ puts "message #{result.message}"
43
+ else
44
+ puts "denied #{result.code} #{result.message}"
45
+ end
46
+ rescue Sdkey::Error => err
47
+ # Init / transport failures use `error` text from the server when present
48
+ puts "#{err.code} #{err.message}"
49
+ raise
50
+ end
51
+ ```
52
+
53
+ `validate` calls `init` automatically when no session exists. Sessions last ~15 minutes server-side; on `SESSION_EXPIRED` the client clears local state so the next call re-handshakes.
54
+
55
+ ### Client auth (plaintext JSON)
56
+
57
+ ```ruby
58
+ reg = client.register(
59
+ username: "player1",
60
+ password: "••••••••",
61
+ license_key: "SDKY-XXXX-XXXX-XXXX-XXXX",
62
+ hwid: "machine-hwid" # optional
63
+ )
64
+ if reg.success
65
+ puts [reg.session_token, reg.user, reg.license].inspect
66
+ else
67
+ puts "#{reg.code} #{reg.error}"
68
+ end
69
+
70
+ login = client.login(username: "player1", password: "••••••••")
71
+ upgrade = client.upgrade(username: "player1", license_key: "SDKY-HIGHER-TIER-KEY")
72
+ ```
73
+
74
+ `upgrade` takes **username + license key only** (no password). The new key’s `subscriptionTier` must be strictly greater than the user’s current tier.
75
+
76
+ ## Where `message` vs `error` appears
77
+
78
+ Per-app `responseMessages` may customize many strings. The SDK surfaces whatever the server returns.
79
+
80
+ | Surface | Success text field | Failure text field |
81
+ |---|---|---|
82
+ | Session init | *(none)* | `error` (raised as `Sdkey::Error#message`) |
83
+ | Sealed validate | `message` | `message` |
84
+ | Client register / login / upgrade | *(none)* | `error` on `ClientAuthResult` |
85
+
86
+ ### Example JSON shapes
87
+
88
+ **Init failure** (plaintext):
89
+
90
+ ```json
91
+ { "success": false, "error": "Client version outdated", "code": "APP_OUTDATED" }
92
+ ```
93
+
94
+ **Sealed validate success** (`message`):
95
+
96
+ ```json
97
+ {
98
+ "success": true,
99
+ "code": "OK",
100
+ "message": "validated",
101
+ "status": "active",
102
+ "expiresAt": "2026-01-01T00:00:00.000Z",
103
+ "subscriptionTier": 0,
104
+ "sessionId": "...",
105
+ "timestamp": 1720000001,
106
+ "v": 1
107
+ }
108
+ ```
109
+
110
+ **Sealed validate failure** (still `message`, not `error`):
111
+
112
+ ```json
113
+ {
114
+ "success": false,
115
+ "code": "HWID_MISMATCH",
116
+ "message": "Hardware ID mismatch",
117
+ "status": null,
118
+ "expiresAt": null,
119
+ "sessionId": "...",
120
+ "timestamp": 1720000001,
121
+ "v": 1
122
+ }
123
+ ```
124
+
125
+ **Client auth failure** (`error`):
126
+
127
+ ```json
128
+ {
129
+ "success": false,
130
+ "error": "License tier must be higher than the current tier",
131
+ "code": "TIER_NOT_HIGHER"
132
+ }
133
+ ```
134
+
135
+ ## API
136
+
137
+ ### `Sdkey::Client.new(...)`
138
+
139
+ | Option | Type | Description |
140
+ |---|---|---|
141
+ | `api_base_url` | `String` | API origin (no trailing slash) |
142
+ | `app_id` | `String` | Application UUID |
143
+ | `app_version` | `String` | Exact app version → sent as `clientVersion` |
144
+ | `app_public_key_b64` | `String` | Raw Ed25519 public key (32 bytes), base64 |
145
+ | `http_post` | callable | Optional HTTP POST override (tests / custom transport) |
146
+
147
+ ### Methods
148
+
149
+ - `init` — challenge handshake; verifies the signed hello; derives the AES session key; sends `clientVersion`
150
+ - `validate(license_key, hwid = nil)` — sealed validate; omits `hwid` JSON key when not provided; **always** decrypts then verifies the Ed25519 signature before trusting `success`
151
+ - `register(...)` / `login(...)` / `upgrade(...)` — plaintext `POST /api/v1/client/*`
152
+ - `session` / `clear_session` — inspect or drop the local session
153
+
154
+ ### Errors
155
+
156
+ Protocol / transport failures raise `Sdkey::Error` with a `code` and `message` (server `error` text when the API provides one):
157
+
158
+ `INIT_FAILED` · `APP_OUTDATED` · `HELLO_SIGNATURE_INVALID` · `VALIDATE_RESPONSE_INVALID` · `RESPONSE_SIGNATURE_INVALID` · `SESSION_MISMATCH` · `CLOCK_SKEW` · `NETWORK`
159
+
160
+ License denials (banned, HWID mismatch, etc.) return a normal `ValidateResult` with `success: false` — they are not raised. Auth denials return `ClientAuthResult` with `success: false`, `code`, and `error`.
161
+
162
+ This package does **not** include developer tooling / Bearer (`sdk_live_…`) management APIs.
163
+
164
+ ## Security notes
165
+
166
+ - Never ship app **private** keys in a client.
167
+ - Do not skip signature verification — that is the anti-spoof binding.
168
+ - This package is open source; the SDKey server remains a separate product.
169
+
170
+ ## Development
171
+
172
+ ```bash
173
+ bundle install
174
+ bundle exec rake test
175
+ ```
176
+
177
+ Repository: https://github.com/SDKeyDev/sdkey-ruby
178
+
179
+ ## License
180
+
181
+ MIT
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "time"
7
+ require "uri"
8
+
9
+ require_relative "crypto/constants"
10
+ require_relative "crypto/encoding"
11
+ require_relative "crypto/seal"
12
+ require_relative "errors"
13
+ require_relative "types"
14
+
15
+ module Sdkey
16
+ # SDKey license client (sealed session protocol + plaintext client auth).
17
+ #
18
+ # Flow: +init+ (session handshake) → +validate(license_key, hwid=nil)+ (sealed).
19
+ # +validate+ calls +init+ automatically when no session exists.
20
+ #
21
+ # Plaintext client auth: +register+ / +login+ / +upgrade+ (no sealed session required).
22
+ class Client
23
+ def initialize(api_base_url:, app_id:, app_version:, app_public_key_b64:, http_post: nil)
24
+ @api_base_url = api_base_url.to_s.sub(%r{/+\z}, "")
25
+ @app_id = app_id
26
+ @app_version = app_version
27
+ @app_public_key_b64 = app_public_key_b64
28
+ @http_post = http_post || method(:default_http_post)
29
+ @public_key = nil
30
+ @session = nil
31
+ end
32
+
33
+ # Active session, if any.
34
+ def session
35
+ @session
36
+ end
37
+ alias get_session session
38
+
39
+ # Drop the current session (next +validate+ will re-init).
40
+ def clear_session
41
+ @session = nil
42
+ end
43
+
44
+ def init
45
+ @public_key = Crypto::Seal.import_public_key(@app_public_key_b64)
46
+ client_nonce = SecureRandom.random_bytes(Crypto::Constants::CLIENT_NONCE_BYTES)
47
+
48
+ begin
49
+ status, body = @http_post.call(
50
+ "#{@api_base_url}/api/v1/session/init",
51
+ {
52
+ "appId" => @app_id,
53
+ "clientNonceB64" => Crypto::Encoding.bytes_to_base64(client_nonce),
54
+ "clientVersion" => @app_version
55
+ }
56
+ )
57
+ rescue StandardError => e
58
+ raise Error.new("NETWORK", "session init request failed", e)
59
+ end
60
+
61
+ if status < 200 || status >= 300 || !body["success"]
62
+ raise Error.new(
63
+ (body["code"] || "INIT_FAILED").to_s,
64
+ (body["error"] || "session init failed").to_s
65
+ )
66
+ end
67
+
68
+ hello = {
69
+ "appId" => @app_id,
70
+ "hkdfSaltB64" => body["hkdfSaltB64"],
71
+ "serverNonceB64" => body["serverNonceB64"],
72
+ "sessionId" => body["sessionId"],
73
+ "timestamp" => body["timestamp"],
74
+ "v" => Crypto::Constants::PROTOCOL_VERSION
75
+ }
76
+
77
+ unless Crypto::Seal.verify_signature(@public_key, hello, body["signatureB64"])
78
+ raise Error.new("HELLO_SIGNATURE_INVALID", "hello signature verification failed")
79
+ end
80
+
81
+ aes_key = Crypto::Seal.derive_session_aes_key(
82
+ client_nonce: client_nonce,
83
+ server_nonce: Crypto::Encoding.base64_to_bytes(body["serverNonceB64"]),
84
+ salt_b64: body["hkdfSaltB64"],
85
+ app_id: @app_id
86
+ )
87
+
88
+ @session = SessionState.new(
89
+ session_id: body["sessionId"],
90
+ aes_key: aes_key,
91
+ server_nonce_b64: body["serverNonceB64"],
92
+ hkdf_salt_b64: body["hkdfSaltB64"]
93
+ )
94
+ @session
95
+ end
96
+
97
+ def validate(license_key, hwid = nil)
98
+ init if @session.nil? || @public_key.nil?
99
+ session = @session
100
+ public_key = @public_key
101
+
102
+ inner = {
103
+ "licenseKey" => license_key,
104
+ "nonce" => Crypto::Encoding.bytes_to_base64(
105
+ SecureRandom.random_bytes(Crypto::Constants::VALIDATE_NONCE_BYTES)
106
+ ),
107
+ "timestamp" => Time.now.to_i,
108
+ "v" => Crypto::Constants::PROTOCOL_VERSION
109
+ }
110
+ if hwid
111
+ # Keep lexicographic key order for the sealed inner JSON.
112
+ inner = {
113
+ "hwid" => hwid,
114
+ "licenseKey" => inner["licenseKey"],
115
+ "nonce" => inner["nonce"],
116
+ "timestamp" => inner["timestamp"],
117
+ "v" => inner["v"]
118
+ }
119
+ end
120
+
121
+ sealed = Crypto::Seal.seal_aes_gcm(
122
+ session.aes_key,
123
+ JSON.generate(inner)
124
+ )
125
+
126
+ begin
127
+ _status, envelope = @http_post.call(
128
+ "#{@api_base_url}/api/v1/licenses/validate",
129
+ {
130
+ "sessionId" => session.session_id
131
+ }.merge(sealed.as_wire)
132
+ )
133
+ rescue StandardError => e
134
+ raise Error.new("NETWORK", "validate request failed", e)
135
+ end
136
+
137
+ unless envelope["ivB64"] && envelope["ciphertextB64"] && envelope["tagB64"] && envelope["signatureB64"]
138
+ clear_session if envelope["code"] == "SESSION_EXPIRED"
139
+ raise Error.new(
140
+ (envelope["code"] || "VALIDATE_RESPONSE_INVALID").to_s,
141
+ (envelope["error"] || "invalid validate response").to_s
142
+ )
143
+ end
144
+
145
+ plain_bytes = Crypto::Seal.open_aes_gcm(
146
+ session.aes_key,
147
+ {
148
+ "ivB64" => envelope["ivB64"],
149
+ "ciphertextB64" => envelope["ciphertextB64"],
150
+ "tagB64" => envelope["tagB64"]
151
+ }
152
+ )
153
+ plaintext = JSON.parse(plain_bytes)
154
+
155
+ unless Crypto::Seal.verify_signature(public_key, plaintext, envelope["signatureB64"])
156
+ raise Error.new("RESPONSE_SIGNATURE_INVALID", "response signature verification failed")
157
+ end
158
+
159
+ raise Error.new("SESSION_MISMATCH", "sessionId mismatch") if plaintext["sessionId"] != session.session_id
160
+ if (Time.now.to_i - plaintext["timestamp"].to_i).abs > Crypto::Constants::CLOCK_SKEW_SECONDS
161
+ raise Error.new("CLOCK_SKEW", "response clock skew")
162
+ end
163
+
164
+ clear_session if plaintext["code"] == "SESSION_EXPIRED"
165
+
166
+ tier_raw = plaintext["subscriptionTier"]
167
+ subscription_tier = tier_raw.nil? ? nil : tier_raw.to_i
168
+
169
+ ValidateResult.new(
170
+ success: !!plaintext["success"],
171
+ code: plaintext["code"].to_s,
172
+ message: plaintext["message"].to_s,
173
+ status: plaintext["status"],
174
+ expires_at: plaintext["expiresAt"],
175
+ subscription_tier: subscription_tier,
176
+ timestamp: plaintext["timestamp"].to_i
177
+ )
178
+ end
179
+
180
+ def register(username:, password:, email: nil, license_key: nil, hwid: nil)
181
+ body = {
182
+ "appId" => @app_id,
183
+ "username" => username,
184
+ "password" => password,
185
+ "clientVersion" => @app_version
186
+ }
187
+ body["email"] = email unless email.nil?
188
+ body["licenseKey"] = license_key unless license_key.nil?
189
+ body["hwid"] = hwid unless hwid.nil?
190
+ client_auth("register", body)
191
+ end
192
+
193
+ def login(username:, password:, hwid: nil)
194
+ body = {
195
+ "appId" => @app_id,
196
+ "username" => username,
197
+ "password" => password,
198
+ "clientVersion" => @app_version
199
+ }
200
+ body["hwid"] = hwid unless hwid.nil?
201
+ client_auth("login", body)
202
+ end
203
+
204
+ def upgrade(username:, license_key:, hwid: nil)
205
+ body = {
206
+ "appId" => @app_id,
207
+ "username" => username,
208
+ "licenseKey" => license_key,
209
+ "clientVersion" => @app_version
210
+ }
211
+ body["hwid"] = hwid unless hwid.nil?
212
+ client_auth("upgrade", body)
213
+ end
214
+
215
+ private
216
+
217
+ def client_auth(action, body)
218
+ begin
219
+ _status, response = @http_post.call(
220
+ "#{@api_base_url}/api/v1/client/#{action}",
221
+ body
222
+ )
223
+ rescue StandardError => e
224
+ raise Error.new("NETWORK", "#{action} request failed", e)
225
+ end
226
+
227
+ raise Error.new("UNKNOWN", "invalid #{action} response") unless response.is_a?(Hash)
228
+
229
+ parse_auth_result(response)
230
+ end
231
+
232
+ def parse_auth_result(body)
233
+ unless body["success"]
234
+ return ClientAuthResult.new(
235
+ success: false,
236
+ code: body["code"]&.to_s,
237
+ error: body["error"]&.to_s
238
+ )
239
+ end
240
+
241
+ user_raw = body["user"]
242
+ license_raw = body["license"]
243
+ session_raw = body["session"]
244
+
245
+ user = nil
246
+ if user_raw.is_a?(Hash)
247
+ user = ClientAuthUser.new(
248
+ id: user_raw["id"].to_s,
249
+ username: user_raw["username"].to_s,
250
+ email: user_raw["email"],
251
+ application_id: user_raw["applicationId"].to_s
252
+ )
253
+ end
254
+
255
+ license = nil
256
+ if license_raw.is_a?(Hash)
257
+ license = ClientAuthLicense.new(
258
+ id: license_raw["id"].to_s,
259
+ status: license_raw["status"].to_s,
260
+ expires_at: license_raw["expiresAt"],
261
+ subscription_tier: (license_raw["subscriptionTier"] || 0).to_i
262
+ )
263
+ end
264
+
265
+ session = nil
266
+ if session_raw.is_a?(Hash)
267
+ session = ClientAuthSessionInfo.new(
268
+ ip: session_raw["ip"].to_s,
269
+ hwid: session_raw["hwid"]
270
+ )
271
+ end
272
+
273
+ ClientAuthResult.new(
274
+ success: true,
275
+ session_token: body["sessionToken"]&.to_s,
276
+ expires_at: body["expiresAt"],
277
+ user: user,
278
+ license: license,
279
+ session: session
280
+ )
281
+ end
282
+
283
+ def default_http_post(url, body)
284
+ uri = URI(url)
285
+ http = Net::HTTP.new(uri.host, uri.port)
286
+ http.use_ssl = uri.scheme == "https"
287
+ request = Net::HTTP::Post.new(uri)
288
+ request["Content-Type"] = "application/json"
289
+ request.body = JSON.generate(body)
290
+ response = http.request(request)
291
+ parsed = response.body.to_s.empty? ? {} : JSON.parse(response.body)
292
+ parsed = {} unless parsed.is_a?(Hash)
293
+ [response.code.to_i, parsed]
294
+ rescue JSON::ParserError
295
+ [response.code.to_i, {}]
296
+ end
297
+ end
298
+
299
+ # Alias matching Python naming.
300
+ SdkeyClient = Client
301
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Sdkey
6
+ module Crypto
7
+ # Deterministic JSON encoding for Ed25519 signing.
8
+ # Object keys sorted lexicographically, no insignificant whitespace.
9
+ module CanonicalJson
10
+ module_function
11
+
12
+ def canonical_json(value)
13
+ canonicalize(value).encode("UTF-8")
14
+ end
15
+
16
+ def canonicalize(value)
17
+ case value
18
+ when nil
19
+ "null"
20
+ when true
21
+ "true"
22
+ when false
23
+ "false"
24
+ when Integer
25
+ value.to_s
26
+ when Float
27
+ raise ArgumentError, "canonicalJson: non-finite numbers are not allowed" unless value.finite?
28
+
29
+ JSON.generate(value)
30
+ when String
31
+ JSON.generate(value)
32
+ when Array
33
+ "[#{value.map { |item| canonicalize(item) }.join(",")}]"
34
+ when Hash
35
+ stringified = {}
36
+ value.each { |k, v| stringified[k.to_s] = v }
37
+ keys = stringified.keys.sort
38
+ body = keys.map do |key|
39
+ "#{JSON.generate(key)}:#{canonicalize(stringified[key])}"
40
+ end.join(",")
41
+ "{#{body}}"
42
+ else
43
+ raise TypeError, "canonicalJson: unsupported type #{value.class}"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sdkey
4
+ module Crypto
5
+ # Wire-protocol constants (protocol v1).
6
+ module Constants
7
+ PROTOCOL_VERSION = 1
8
+
9
+ CLOCK_SKEW_SECONDS = 60
10
+
11
+ CLIENT_NONCE_BYTES = 32
12
+ SERVER_NONCE_BYTES = 32
13
+ VALIDATE_NONCE_BYTES = 16
14
+
15
+ AES_GCM_IV_BYTES = 12
16
+ AES_GCM_TAG_BITS = 128
17
+ AES_GCM_TAG_BYTES = 16
18
+ SESSION_AES_KEY_BYTES = 32
19
+
20
+ SESSION_HKDF_INFO_PREFIX = "sdkey-session-v1"
21
+
22
+ VALIDATE_FAILURE_CODES = %w[
23
+ SESSION_EXPIRED
24
+ CLOCK_SKEW
25
+ REPLAY
26
+ LICENSE_NOT_FOUND
27
+ APP_MISMATCH
28
+ BANNED
29
+ EXPIRED
30
+ HWID_MISMATCH
31
+ DECRYPT_FAIL
32
+ APP_DISABLED
33
+ APP_OUTDATED
34
+ HWID_BANNED
35
+ IP_BANNED
36
+ ].freeze
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Sdkey
6
+ module Crypto
7
+ # Base64 helpers (standard and URL-safe).
8
+ module Encoding
9
+ module_function
10
+
11
+ def bytes_to_base64(data)
12
+ Base64.strict_encode64(data.b)
13
+ end
14
+
15
+ def base64_to_bytes(b64)
16
+ normalized = b64.tr("-_", "+/")
17
+ pad = (4 - (normalized.length % 4)) % 4
18
+ Base64.decode64(normalized + ("=" * pad)).b
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ require_relative "canonical_json"
7
+ require_relative "constants"
8
+ require_relative "encoding"
9
+
10
+ module Sdkey
11
+ module Crypto
12
+ # Ed25519, HKDF session keys, and AES-GCM seal helpers.
13
+ module Seal
14
+ SealedEnvelope = Struct.new(:iv_b64, :ciphertext_b64, :tag_b64, keyword_init: true) do
15
+ def as_wire
16
+ {
17
+ "ivB64" => iv_b64,
18
+ "ciphertextB64" => ciphertext_b64,
19
+ "tagB64" => tag_b64
20
+ }
21
+ end
22
+ end
23
+
24
+ module_function
25
+
26
+ def import_public_key(public_key_b64)
27
+ raw = Encoding.base64_to_bytes(public_key_b64)
28
+ OpenSSL::PKey.new_raw_public_key("ED25519", raw)
29
+ end
30
+
31
+ def verify_signature(public_key, payload, signature_b64)
32
+ signature = Encoding.base64_to_bytes(signature_b64)
33
+ message = CanonicalJson.canonical_json(payload)
34
+ public_key.verify(nil, signature, message)
35
+ rescue OpenSSL::PKey::PKeyError, OpenSSL::OpenSSLError
36
+ false
37
+ end
38
+
39
+ def derive_session_aes_key(client_nonce:, server_nonce:, salt_b64:, app_id:)
40
+ ikm = client_nonce.b + server_nonce.b
41
+ salt = Encoding.base64_to_bytes(salt_b64)
42
+ info = "#{Constants::SESSION_HKDF_INFO_PREFIX}#{app_id}".b
43
+ OpenSSL::KDF.hkdf(
44
+ ikm,
45
+ salt: salt,
46
+ info: info,
47
+ length: Constants::SESSION_AES_KEY_BYTES,
48
+ hash: "SHA256"
49
+ )
50
+ end
51
+
52
+ def seal_aes_gcm(aes_key, plaintext)
53
+ iv = SecureRandom.random_bytes(Constants::AES_GCM_IV_BYTES)
54
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
55
+ cipher.encrypt
56
+ cipher.key = aes_key
57
+ cipher.iv = iv
58
+ ciphertext = cipher.update(plaintext.b) + cipher.final
59
+ tag = cipher.auth_tag(Constants::AES_GCM_TAG_BYTES)
60
+ SealedEnvelope.new(
61
+ iv_b64: Encoding.bytes_to_base64(iv),
62
+ ciphertext_b64: Encoding.bytes_to_base64(ciphertext),
63
+ tag_b64: Encoding.bytes_to_base64(tag)
64
+ )
65
+ end
66
+
67
+ def open_aes_gcm(aes_key, envelope)
68
+ if envelope.is_a?(SealedEnvelope)
69
+ iv_b64 = envelope.iv_b64
70
+ ciphertext_b64 = envelope.ciphertext_b64
71
+ tag_b64 = envelope.tag_b64
72
+ else
73
+ iv_b64 = envelope["ivB64"] || envelope[:ivB64] || envelope["iv_b64"] || envelope[:iv_b64]
74
+ ciphertext_b64 = envelope["ciphertextB64"] || envelope[:ciphertextB64] ||
75
+ envelope["ciphertext_b64"] || envelope[:ciphertext_b64]
76
+ tag_b64 = envelope["tagB64"] || envelope[:tagB64] || envelope["tag_b64"] || envelope[:tag_b64]
77
+ end
78
+
79
+ iv = Encoding.base64_to_bytes(iv_b64)
80
+ ciphertext = Encoding.base64_to_bytes(ciphertext_b64)
81
+ tag = Encoding.base64_to_bytes(tag_b64)
82
+
83
+ cipher = OpenSSL::Cipher.new("aes-256-gcm")
84
+ cipher.decrypt
85
+ cipher.key = aes_key
86
+ cipher.iv = iv
87
+ cipher.auth_tag = tag
88
+ cipher.update(ciphertext) + cipher.final
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sdkey
4
+ # Protocol and transport errors for the SDKey client.
5
+ class Error < StandardError
6
+ attr_reader :code, :cause
7
+
8
+ def initialize(code, message, cause = nil)
9
+ super(message)
10
+ @code = code
11
+ @cause = cause
12
+ end
13
+ end
14
+
15
+ # Alias matching Python/TS naming in docs.
16
+ SdkeyError = Error
17
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sdkey
4
+ # Constructor options for {Client}.
5
+ SdkeyClientOptions = Struct.new(
6
+ :api_base_url,
7
+ :app_id,
8
+ :app_version,
9
+ :app_public_key_b64,
10
+ :http_post,
11
+ keyword_init: true
12
+ )
13
+
14
+ SessionState = Struct.new(
15
+ :session_id,
16
+ :aes_key,
17
+ :server_nonce_b64,
18
+ :hkdf_salt_b64,
19
+ keyword_init: true
20
+ )
21
+
22
+ ValidateResult = Struct.new(
23
+ :success,
24
+ :code,
25
+ :message,
26
+ :status,
27
+ :expires_at,
28
+ :subscription_tier,
29
+ :timestamp,
30
+ keyword_init: true
31
+ )
32
+
33
+ ClientAuthUser = Struct.new(
34
+ :id,
35
+ :username,
36
+ :email,
37
+ :application_id,
38
+ keyword_init: true
39
+ )
40
+
41
+ ClientAuthLicense = Struct.new(
42
+ :id,
43
+ :status,
44
+ :expires_at,
45
+ :subscription_tier,
46
+ keyword_init: true
47
+ )
48
+
49
+ ClientAuthSessionInfo = Struct.new(
50
+ :ip,
51
+ :hwid,
52
+ keyword_init: true
53
+ )
54
+
55
+ # Result of register / login / upgrade (plaintext client auth).
56
+ ClientAuthResult = Struct.new(
57
+ :success,
58
+ :code,
59
+ :error,
60
+ :session_token,
61
+ :expires_at,
62
+ :user,
63
+ :license,
64
+ :session,
65
+ keyword_init: true
66
+ )
67
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sdkey
4
+ VERSION = "0.2.0"
5
+ end
data/lib/sdkey.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sdkey/version"
4
+ require_relative "sdkey/errors"
5
+ require_relative "sdkey/types"
6
+ require_relative "sdkey/crypto/constants"
7
+ require_relative "sdkey/crypto/encoding"
8
+ require_relative "sdkey/crypto/canonical_json"
9
+ require_relative "sdkey/crypto/seal"
10
+ require_relative "sdkey/client"
11
+
12
+ # Official Ruby client for the SDKey license authentication protocol.
13
+ module Sdkey
14
+ PROTOCOL_VERSION = Crypto::Constants::PROTOCOL_VERSION
15
+ CLOCK_SKEW_SECONDS = Crypto::Constants::CLOCK_SKEW_SECONDS
16
+ CLIENT_NONCE_BYTES = Crypto::Constants::CLIENT_NONCE_BYTES
17
+ SERVER_NONCE_BYTES = Crypto::Constants::SERVER_NONCE_BYTES
18
+ VALIDATE_NONCE_BYTES = Crypto::Constants::VALIDATE_NONCE_BYTES
19
+ AES_GCM_IV_BYTES = Crypto::Constants::AES_GCM_IV_BYTES
20
+ SESSION_AES_KEY_BYTES = Crypto::Constants::SESSION_AES_KEY_BYTES
21
+ SESSION_HKDF_INFO_PREFIX = Crypto::Constants::SESSION_HKDF_INFO_PREFIX
22
+ VALIDATE_FAILURE_CODES = Crypto::Constants::VALIDATE_FAILURE_CODES
23
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sdkey
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - SDKeyDev
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.2'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.2'
27
+ description: Sealed session protocol (Ed25519, HKDF, AES-256-GCM) plus plaintext client
28
+ auth.
29
+ email:
30
+ - support@sdkey.dev
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - LICENSE
36
+ - PROTOCOL.md
37
+ - README.md
38
+ - lib/sdkey.rb
39
+ - lib/sdkey/client.rb
40
+ - lib/sdkey/crypto/canonical_json.rb
41
+ - lib/sdkey/crypto/constants.rb
42
+ - lib/sdkey/crypto/encoding.rb
43
+ - lib/sdkey/crypto/seal.rb
44
+ - lib/sdkey/errors.rb
45
+ - lib/sdkey/types.rb
46
+ - lib/sdkey/version.rb
47
+ homepage: https://github.com/SDKeyDev/sdkey-ruby
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/SDKeyDev/sdkey-ruby
52
+ source_code_uri: https://github.com/SDKeyDev/sdkey-ruby
53
+ changelog_uri: https://github.com/SDKeyDev/sdkey-ruby
54
+ rubygems_mfa_required: 'true'
55
+ post_install_message:
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.1.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 3.5.22
71
+ signing_key:
72
+ specification_version: 4
73
+ summary: Official Ruby client for the SDKey license authentication protocol
74
+ test_files: []