sdkey 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8d89ff1ef1aa3fb22572fa04b869e0bbc3d43874a8fc26609e8b14304484a66
4
- data.tar.gz: b93267523e955d18224b0663a97157e7957a19115eab13f18f37a74ee1793d27
3
+ metadata.gz: ba734c87928d03dc312c980d12c99f297fbcd08669ee4a2edc9619c043682644
4
+ data.tar.gz: fdc9bacd2081741881c91f6ab348a2cc4a46c1db63b136b95d65057b371f2f64
5
5
  SHA512:
6
- metadata.gz: 88436c0ae334d358ac3a27f570180d5ba8ec63ace13f9a7a9d9aede0fbdad5994ad68291900435c70fb793b23a8403c42c03d54b3197482ca2726f1ff7af912f
7
- data.tar.gz: 2e256ba0ee1a0a83c53086ec54c303fa75ad46403d03eeebc9d5141efa4f0e409b966214253ea32e6186beb8a8ba8d5e3bf011faf275f1dbaa8b9b013c01f7cf
6
+ metadata.gz: 5869f2b9fa8ea8b67df19b28c812bfbdc530fd35fb92070b64d8892eb70bef6819f228f549192cf6afc2e23cd2f27c774ad6057aa8edff04a58d073453be7d7b
7
+ data.tar.gz: 3943b769039107de69655837c68164b57bd7c938b646f2a76227445be35b596511223dd265eb3f6bf187571ac2ee3af7939dd5cd1581544cefd5435415aa42f2
data/PROTOCOL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SDKey Client Protocol (C++ / C# / TypeScript implementer guide)
2
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.
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 sealed client auth (register / login / upgrade).
4
4
 
5
5
  ## Embed at build time
6
6
 
@@ -166,35 +166,77 @@ Plaintext validate (integration / when `CRYPTO_ENFORCE` is off): success has top
166
166
 
167
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
168
 
169
- ## Public client auth (plaintext JSON)
169
+ ## Sealed client auth (register / login / upgrade)
170
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`).
171
+ Client register, login, and upgrade use the **same sealed-session wire model as validate**: `POST /session/init` first, then outer `sealedEnvelopeSchema`, inner nonce / timestamp / `v`, replay + clock-skew checks, and **sign then seal** responses. Application binding (`applicationId`) and version gating come from the crypto session do **not** put `appId` or `clientVersion` in the inner body.
172
+
173
+ **`CRYPTO_ENFORCE`:** sealed envelopes are always accepted. Plaintext JSON client-auth bodies (with `appId` + `clientVersion`) are only accepted when `CRYPTO_ENFORCE=false` (local / integration). Production with `CRYPTO_ENFORCE=true` rejects plaintext on these paths with `400 CRYPTO_REQUIRED`.
174
+
175
+ Optional `hwid` follows the same skip-when-omitted rules as validate. Rate limit: 30 / min / IP (`clientAuth`). Opaque `sessionToken` TTL: 7 days (`USER_SESSION_TTL_SECONDS`).
176
+
177
+ Outer request envelope (HTTPS JSON) — identical to validate:
178
+
179
+ ```json
180
+ {
181
+ "sessionId": "...",
182
+ "ivB64": "...",
183
+ "ciphertextB64": "...",
184
+ "tagB64": "..."
185
+ }
186
+ ```
187
+
188
+ Response envelope (adds `signatureB64`) — identical to validate:
189
+
190
+ ```json
191
+ {
192
+ "sessionId": "...",
193
+ "ivB64": "...",
194
+ "ciphertextB64": "...",
195
+ "tagB64": "...",
196
+ "signatureB64": "..."
197
+ }
198
+ ```
199
+
200
+ **Client order of operations (mandatory)** — same as validate:
201
+
202
+ 1. AES-GCM open → plaintext JSON
203
+ 2. Ed25519 verify(`APP_PUBLIC_KEY`, `canonicalJson(plaintext)`, `signature`)
204
+ 3. Check `timestamp` skew (±60s) and `sessionId` match
205
+ 4. Only then honor `success` / `sessionToken`
206
+
207
+ Sealed business failures (and crypto protocol failures after a valid session) typically return HTTP **200** with a sealed body so clients always take the decrypt/verify path.
172
208
 
173
209
  ### `POST /api/v1/client/register`
174
210
 
211
+ Inner plaintext (before AES-GCM seal):
212
+
175
213
  ```json
176
214
  {
177
- "appId": "<uuid>",
178
215
  "username": "player1",
179
216
  "password": "••••••••",
180
217
  "email": "optional@example.com",
181
218
  "licenseKey": "SDKY-....",
182
219
  "hwid": "...",
183
- "clientVersion": "1.0.0"
220
+ "nonce": "<base64 16 bytes>",
221
+ "timestamp": 1720000001,
222
+ "v": 1
184
223
  }
185
224
  ```
186
225
 
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.
226
+ Username: 3–64 chars, `[a-zA-Z0-9._-]`. Password: 8–128. `email`, `licenseKey`, and `hwid` are optional on the wire. `licenseKey` may be required by app setting `requireLicenseToRegister` (default true) → `LICENSE_REQUIRED` when missing. Success returns an opaque user session inside the sealed plaintext (see below).
188
227
 
189
228
  ### `POST /api/v1/client/login`
190
229
 
230
+ Inner plaintext:
231
+
191
232
  ```json
192
233
  {
193
- "appId": "<uuid>",
194
234
  "username": "player1",
195
235
  "password": "••••••••",
196
236
  "hwid": "...",
197
- "clientVersion": "1.0.0"
237
+ "nonce": "<base64 16 bytes>",
238
+ "timestamp": 1720000001,
239
+ "v": 1
198
240
  }
199
241
  ```
200
242
 
@@ -202,23 +244,43 @@ Username: 3–64 chars, `[a-zA-Z0-9._-]`. Password: 8–128. `licenseKey` may be
202
244
 
203
245
  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
246
 
247
+ Inner plaintext:
248
+
205
249
  ```json
206
250
  {
207
- "appId": "<uuid>",
208
251
  "username": "player1",
209
252
  "licenseKey": "SDKY-....",
210
253
  "hwid": "...",
211
- "clientVersion": "1.0.0"
254
+ "nonce": "<base64 16 bytes>",
255
+ "timestamp": 1720000001,
256
+ "v": 1
212
257
  }
213
258
  ```
214
259
 
215
- ### Auth success shape
260
+ ### Sealed auth response plaintext
261
+
262
+ After AES-GCM open, **both success and failure plaintext include `message`** (not a top-level `error`). Signed fields:
263
+
264
+ | Field | Notes |
265
+ |---|---|
266
+ | `success` | boolean |
267
+ | `code` | stable machine code |
268
+ | `message` | human-readable (editable per app for many codes) |
269
+ | `sessionId` | must match request session |
270
+ | `timestamp` | unix seconds |
271
+ | `v` | protocol version (`1`) |
272
+ | `sessionToken`, `expiresAt`, `user`, `license`, `session` | present on success |
216
273
 
217
- Success has **no** customizable `message` field:
274
+ Success example:
218
275
 
219
276
  ```json
220
277
  {
221
278
  "success": true,
279
+ "code": "OK",
280
+ "message": "ok",
281
+ "sessionId": "...",
282
+ "timestamp": 1720000001,
283
+ "v": 1,
222
284
  "sessionToken": "<opaque>",
223
285
  "expiresAt": "2026-01-01T00:00:00.000Z",
224
286
  "user": {
@@ -240,8 +302,36 @@ Success has **no** customizable `message` field:
240
302
  }
241
303
  ```
242
304
 
305
+ Sealed failure plaintext example:
306
+
307
+ ```json
308
+ {
309
+ "success": false,
310
+ "code": "INVALID_CREDENTIALS",
311
+ "message": "Invalid username or password",
312
+ "sessionId": "...",
313
+ "timestamp": 1720000001,
314
+ "v": 1
315
+ }
316
+ ```
317
+
243
318
  `license` may be `null` when the user has no linked license. Never send or echo passwords in responses.
244
319
 
320
+ Plaintext client-auth bodies (when `CRYPTO_ENFORCE=false` only) still use top-level `error` on failure and omit the sealed `message` / `sessionId` / `timestamp` / `v` envelope fields — local integration only.
321
+
322
+ ## Sealed operations checklist
323
+
324
+ Every path below accepts a sealed envelope (and **requires** it when `CRYPTO_ENFORCE=true`). Paths are relative to `/api/v1/`.
325
+
326
+ | Operation | Method + path | Inner body (sealed) | Notes |
327
+ |---|---|---|---|
328
+ | Validate | `POST /licenses/validate` | `licenseKey`, optional `hwid`, `nonce`, `timestamp`, `v` | Sign-then-seal response; verify order above |
329
+ | Register | `POST /client/register` | `username`, `password`, optional `email` / `licenseKey` / `hwid`, `nonce`, `timestamp`, `v` | `appId` / `clientVersion` from session |
330
+ | Login | `POST /client/login` | `username`, `password`, optional `hwid`, `nonce`, `timestamp`, `v` | Same outer envelope + verify order |
331
+ | Upgrade | `POST /client/upgrade` | `username`, `licenseKey`, optional `hwid`, `nonce`, `timestamp`, `v` | No password; tier must increase |
332
+
333
+ Registry source of truth in-repo: `SEALED_CLIENT_OPERATIONS` in `@sdkey/shared`.
334
+
245
335
  ## Failure codes
246
336
 
247
337
  ### Sealed validate / crypto session
@@ -250,16 +340,25 @@ Success has **no** customizable `message` field:
250
340
 
251
341
  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
342
 
253
- ### Client auth (plaintext JSON error body)
343
+ ### Sealed client auth
254
344
 
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.)
345
+ 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`, `SESSION_EXPIRED`, `CLOCK_SKEW`, `REPLAY`, `DECRYPT_FAIL`. (`REGISTER_DISABLED` is a reserved editable message key.)
256
346
 
257
- Error body — customizable text is in **`error`** (not `message`):
347
+ Sealed failure plaintext — customizable text is in **`message`** (not top-level `error`):
258
348
 
259
349
  ```json
260
- { "success": false, "error": "License tier must be higher than the current tier", "code": "TIER_NOT_HIGHER" }
350
+ {
351
+ "success": false,
352
+ "code": "TIER_NOT_HIGHER",
353
+ "message": "License tier must be higher than the current tier",
354
+ "sessionId": "...",
355
+ "timestamp": 1720000001,
356
+ "v": 1
357
+ }
261
358
  ```
262
359
 
360
+ Plaintext client-auth failures (`CRYPTO_ENFORCE=false` only) still use `{ "success": false, "error": "...", "code": "..." }`.
361
+
263
362
  ## Threat model notes
264
363
 
265
364
  - 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.
@@ -273,4 +372,4 @@ Future hash-only validate (anti-replay + app binding without presenting plaintex
273
372
 
274
373
  ## Developer tooling (not part of sealed protocol)
275
374
 
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).
375
+ Cookie or `Authorization: Bearer sdk_live_…` covers dashboard-equivalent management: `PATCH /api/v1/apps/:id/settings`, `DELETE /api/v1/apps/:id` (cascades licenses, users, bans, logs), ban list/delete, `POST /api/v1/licenses/:id/ban` with scopes, create licenses with `subscriptionTier`, `POST /api/v1/licenses/:id/tier`, `POST /api/v1/licenses/:id/reset-hwid`, `POST /api/v1/apps/:id/users` (create end-user; optional `licenseKey`, respects `requireLicenseToRegister`), `POST /api/v1/apps/:id/users/:userId/reset-hwid`, and `GET /api/v1/apps/:id/users`. See the web docs (API keys + API reference).
data/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Official Ruby client for [SDKey](https://docs.sdkey.dev) license authentication.
4
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).
5
+ Implements the sealed session protocol: Ed25519-verified handshake, HKDF session keys, and AES-256-GCM envelopes for validate and client auth (`register` / `login` / `upgrade`). See [PROTOCOL.md](./PROTOCOL.md).
6
6
 
7
- **Version:** `0.2.0` (RubyGems / git tag `v0.2.0`)
7
+ **Version:** `0.3.0` (RubyGems / git tag `v0.3.0`)
8
8
 
9
9
  ## Install
10
10
 
@@ -15,7 +15,7 @@ gem install sdkey
15
15
  Or in a Gemfile:
16
16
 
17
17
  ```ruby
18
- gem "sdkey", "~> 0.2.0"
18
+ gem "sdkey", "~> 0.3.0"
19
19
  ```
20
20
 
21
21
  Requires Ruby 3.1+ with OpenSSL (Ed25519, AES-GCM, HKDF).
@@ -50,9 +50,11 @@ rescue Sdkey::Error => err
50
50
  end
51
51
  ```
52
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.
53
+ `validate`, `register`, `login`, and `upgrade` call `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. An active session is reused across sealed operations (no second `session/init`).
54
54
 
55
- ### Client auth (plaintext JSON)
55
+ ### Client auth (sealed session)
56
+
57
+ Register / login / upgrade use the same sealed envelope as validate. Application binding and version gating come from the crypto session — do **not** send `appId` or `clientVersion` in the auth body. With `CRYPTO_ENFORCE=true`, plaintext client-auth bodies are rejected (`CRYPTO_REQUIRED`).
56
58
 
57
59
  ```ruby
58
60
  reg = client.register(
@@ -64,6 +66,7 @@ reg = client.register(
64
66
  if reg.success
65
67
  puts [reg.session_token, reg.user, reg.license].inspect
66
68
  else
69
+ # Sealed failures surface server `message` on ClientAuthResult#error
67
70
  puts "#{reg.code} #{reg.error}"
68
71
  end
69
72
 
@@ -81,7 +84,7 @@ Per-app `responseMessages` may customize many strings. The SDK surfaces whatever
81
84
  |---|---|---|
82
85
  | Session init | *(none)* | `error` (raised as `Sdkey::Error#message`) |
83
86
  | Sealed validate | `message` | `message` |
84
- | Client register / login / upgrade | *(none)* | `error` on `ClientAuthResult` |
87
+ | Sealed register / login / upgrade | `message` | `message` (mapped to `ClientAuthResult#error`) |
85
88
 
86
89
  ### Example JSON shapes
87
90
 
@@ -122,13 +125,27 @@ Per-app `responseMessages` may customize many strings. The SDK surfaces whatever
122
125
  }
123
126
  ```
124
127
 
125
- **Client auth failure** (`error`):
128
+ **Sealed client-auth outer request** (identical to validate):
129
+
130
+ ```json
131
+ {
132
+ "sessionId": "...",
133
+ "ivB64": "...",
134
+ "ciphertextB64": "...",
135
+ "tagB64": "..."
136
+ }
137
+ ```
138
+
139
+ **Sealed client-auth failure** (`message` inside the opened plaintext):
126
140
 
127
141
  ```json
128
142
  {
129
143
  "success": false,
130
- "error": "License tier must be higher than the current tier",
131
- "code": "TIER_NOT_HIGHER"
144
+ "code": "TIER_NOT_HIGHER",
145
+ "message": "License tier must be higher than the current tier",
146
+ "sessionId": "...",
147
+ "timestamp": 1720000001,
148
+ "v": 1
132
149
  }
133
150
  ```
134
151
 
@@ -140,7 +157,7 @@ Per-app `responseMessages` may customize many strings. The SDK surfaces whatever
140
157
  |---|---|---|
141
158
  | `api_base_url` | `String` | API origin (no trailing slash) |
142
159
  | `app_id` | `String` | Application UUID |
143
- | `app_version` | `String` | Exact app version → sent as `clientVersion` |
160
+ | `app_version` | `String` | Exact app version → sent as `clientVersion` on session init |
144
161
  | `app_public_key_b64` | `String` | Raw Ed25519 public key (32 bytes), base64 |
145
162
  | `http_post` | callable | Optional HTTP POST override (tests / custom transport) |
146
163
 
@@ -148,19 +165,23 @@ Per-app `responseMessages` may customize many strings. The SDK surfaces whatever
148
165
 
149
166
  - `init` — challenge handshake; verifies the signed hello; derives the AES session key; sends `clientVersion`
150
167
  - `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/*`
168
+ - `register(...)` / `login(...)` / `upgrade(...)` — sealed `POST /api/v1/client/*` (same outer envelope + verify order as validate)
152
169
  - `session` / `clear_session` — inspect or drop the local session
153
170
 
154
171
  ### Errors
155
172
 
156
173
  Protocol / transport failures raise `Sdkey::Error` with a `code` and `message` (server `error` text when the API provides one):
157
174
 
158
- `INIT_FAILED` · `APP_OUTDATED` · `HELLO_SIGNATURE_INVALID` · `VALIDATE_RESPONSE_INVALID` · `RESPONSE_SIGNATURE_INVALID` · `SESSION_MISMATCH` · `CLOCK_SKEW` · `NETWORK`
175
+ `INIT_FAILED` · `APP_OUTDATED` · `HELLO_SIGNATURE_INVALID` · `VALIDATE_RESPONSE_INVALID` · `SEALED_RESPONSE_INVALID` · `RESPONSE_SIGNATURE_INVALID` · `SESSION_MISMATCH` · `CLOCK_SKEW` · `NETWORK`
159
176
 
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`.
177
+ 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` (from sealed `message`).
161
178
 
162
179
  This package does **not** include developer tooling / Bearer (`sdk_live_…`) management APIs.
163
180
 
181
+ ## Breaking changes (0.3.0)
182
+
183
+ Client auth is sealed. Plaintext `POST /api/v1/client/{register,login,upgrade}` bodies with `appId` / `clientVersion` no longer work when the API has `CRYPTO_ENFORCE=true` (`CRYPTO_REQUIRED`). Public method signatures are unchanged.
184
+
164
185
  ## Security notes
165
186
 
166
187
  - Never ship app **private** keys in a client.
data/lib/sdkey/client.rb CHANGED
@@ -13,12 +13,10 @@ require_relative "errors"
13
13
  require_relative "types"
14
14
 
15
15
  module Sdkey
16
- # SDKey license client (sealed session protocol + plaintext client auth).
16
+ # SDKey license client (sealed session protocol).
17
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).
18
+ # Flow: +init+ (session handshake) → sealed +validate+ / +register+ / +login+ / +upgrade+.
19
+ # Sealed RPCs call +init+ automatically when no session exists.
22
20
  class Client
23
21
  def initialize(api_base_url:, app_id:, app_version:, app_public_key_b64:, http_post: nil)
24
22
  @api_base_url = api_base_url.to_s.sub(%r{/+\z}, "")
@@ -36,7 +34,7 @@ module Sdkey
36
34
  end
37
35
  alias get_session session
38
36
 
39
- # Drop the current session (next +validate+ will re-init).
37
+ # Drop the current session (next sealed RPC will re-init).
40
38
  def clear_session
41
39
  @session = nil
42
40
  end
@@ -95,10 +93,6 @@ module Sdkey
95
93
  end
96
94
 
97
95
  def validate(license_key, hwid = nil)
98
- init if @session.nil? || @public_key.nil?
99
- session = @session
100
- public_key = @public_key
101
-
102
96
  inner = {
103
97
  "licenseKey" => license_key,
104
98
  "nonce" => Crypto::Encoding.bytes_to_base64(
@@ -118,6 +112,61 @@ module Sdkey
118
112
  }
119
113
  end
120
114
 
115
+ plaintext = sealed_rpc("licenses/validate", inner)
116
+
117
+ clear_session if plaintext["code"] == "SESSION_EXPIRED"
118
+
119
+ tier_raw = plaintext["subscriptionTier"]
120
+ subscription_tier = tier_raw.nil? ? nil : tier_raw.to_i
121
+
122
+ ValidateResult.new(
123
+ success: !!plaintext["success"],
124
+ code: plaintext["code"].to_s,
125
+ message: plaintext["message"].to_s,
126
+ status: plaintext["status"],
127
+ expires_at: plaintext["expiresAt"],
128
+ subscription_tier: subscription_tier,
129
+ timestamp: plaintext["timestamp"].to_i
130
+ )
131
+ end
132
+
133
+ def register(username:, password:, email: nil, license_key: nil, hwid: nil)
134
+ fields = {
135
+ "username" => username,
136
+ "password" => password
137
+ }
138
+ fields["email"] = email unless email.nil?
139
+ fields["licenseKey"] = license_key unless license_key.nil?
140
+ fields["hwid"] = hwid unless hwid.nil?
141
+ client_auth("register", fields)
142
+ end
143
+
144
+ def login(username:, password:, hwid: nil)
145
+ fields = {
146
+ "username" => username,
147
+ "password" => password
148
+ }
149
+ fields["hwid"] = hwid unless hwid.nil?
150
+ client_auth("login", fields)
151
+ end
152
+
153
+ def upgrade(username:, license_key:, hwid: nil)
154
+ fields = {
155
+ "username" => username,
156
+ "licenseKey" => license_key
157
+ }
158
+ fields["hwid"] = hwid unless hwid.nil?
159
+ client_auth("upgrade", fields)
160
+ end
161
+
162
+ private
163
+
164
+ # Seal +inner+, POST to +/api/v1/{path}+, then open → verify → sessionId + skew.
165
+ def sealed_rpc(path, inner)
166
+ init if @session.nil? || @public_key.nil?
167
+ session = @session
168
+ public_key = @public_key
169
+
121
170
  sealed = Crypto::Seal.seal_aes_gcm(
122
171
  session.aes_key,
123
172
  JSON.generate(inner)
@@ -125,20 +174,21 @@ module Sdkey
125
174
 
126
175
  begin
127
176
  _status, envelope = @http_post.call(
128
- "#{@api_base_url}/api/v1/licenses/validate",
177
+ "#{@api_base_url}/api/v1/#{path}",
129
178
  {
130
179
  "sessionId" => session.session_id
131
180
  }.merge(sealed.as_wire)
132
181
  )
133
182
  rescue StandardError => e
134
- raise Error.new("NETWORK", "validate request failed", e)
183
+ raise Error.new("NETWORK", "#{path} request failed", e)
135
184
  end
136
185
 
137
186
  unless envelope["ivB64"] && envelope["ciphertextB64"] && envelope["tagB64"] && envelope["signatureB64"]
138
187
  clear_session if envelope["code"] == "SESSION_EXPIRED"
188
+ default_code = path == "licenses/validate" ? "VALIDATE_RESPONSE_INVALID" : "SEALED_RESPONSE_INVALID"
139
189
  raise Error.new(
140
- (envelope["code"] || "VALIDATE_RESPONSE_INVALID").to_s,
141
- (envelope["error"] || "invalid validate response").to_s
190
+ (envelope["code"] || default_code).to_s,
191
+ (envelope["error"] || "invalid #{path} response").to_s
142
192
  )
143
193
  end
144
194
 
@@ -161,72 +211,21 @@ module Sdkey
161
211
  raise Error.new("CLOCK_SKEW", "response clock skew")
162
212
  end
163
213
 
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
- )
214
+ plaintext
178
215
  end
179
216
 
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)
217
+ def client_auth(action, fields)
218
+ inner = fields.merge(
219
+ "nonce" => Crypto::Encoding.bytes_to_base64(
220
+ SecureRandom.random_bytes(Crypto::Constants::VALIDATE_NONCE_BYTES)
221
+ ),
222
+ "timestamp" => Time.now.to_i,
223
+ "v" => Crypto::Constants::PROTOCOL_VERSION
224
+ )
228
225
 
229
- parse_auth_result(response)
226
+ plaintext = sealed_rpc("client/#{action}", inner)
227
+ clear_session if plaintext["code"] == "SESSION_EXPIRED"
228
+ parse_auth_result(plaintext)
230
229
  end
231
230
 
232
231
  def parse_auth_result(body)
@@ -234,7 +233,7 @@ module Sdkey
234
233
  return ClientAuthResult.new(
235
234
  success: false,
236
235
  code: body["code"]&.to_s,
237
- error: body["error"]&.to_s
236
+ error: (body["message"] || body["error"])&.to_s
238
237
  )
239
238
  end
240
239
 
data/lib/sdkey/types.rb CHANGED
@@ -52,7 +52,7 @@ module Sdkey
52
52
  keyword_init: true
53
53
  )
54
54
 
55
- # Result of register / login / upgrade (plaintext client auth).
55
+ # Result of register / login / upgrade (sealed client auth).
56
56
  ClientAuthResult = Struct.new(
57
57
  :success,
58
58
  :code,
data/lib/sdkey/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sdkey
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sdkey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - SDKeyDev
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-22 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -24,8 +24,8 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0.2'
27
- description: Sealed session protocol (Ed25519, HKDF, AES-256-GCM) plus plaintext client
28
- auth.
27
+ description: Sealed session protocol (Ed25519, HKDF, AES-256-GCM) for validate and
28
+ client auth.
29
29
  email:
30
30
  - support@sdkey.dev
31
31
  executables: []