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 +4 -4
- data/PROTOCOL.md +116 -17
- data/README.md +34 -13
- data/lib/sdkey/client.rb +77 -78
- data/lib/sdkey/types.rb +1 -1
- data/lib/sdkey/version.rb +1 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ba734c87928d03dc312c980d12c99f297fbcd08669ee4a2edc9619c043682644
|
|
4
|
+
data.tar.gz: fdc9bacd2081741881c91f6ab348a2cc4a46c1db63b136b95d65057b371f2f64
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
169
|
+
## Sealed client auth (register / login / upgrade)
|
|
170
170
|
|
|
171
|
-
|
|
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
|
-
"
|
|
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).
|
|
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
|
-
"
|
|
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
|
-
"
|
|
254
|
+
"nonce": "<base64 16 bytes>",
|
|
255
|
+
"timestamp": 1720000001,
|
|
256
|
+
"v": 1
|
|
212
257
|
}
|
|
213
258
|
```
|
|
214
259
|
|
|
215
|
-
###
|
|
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
|
|
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
|
-
###
|
|
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
|
-
|
|
347
|
+
Sealed failure plaintext — customizable text is in **`message`** (not top-level `error`):
|
|
258
348
|
|
|
259
349
|
```json
|
|
260
|
-
{
|
|
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
|
|
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.
|
|
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.
|
|
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`
|
|
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 (
|
|
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
|
-
|
|
|
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
|
-
**
|
|
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
|
-
"
|
|
131
|
-
"
|
|
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(...)` —
|
|
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
|
|
16
|
+
# SDKey license client (sealed session protocol).
|
|
17
17
|
#
|
|
18
|
-
# Flow: +init+ (session handshake) → +validate
|
|
19
|
-
#
|
|
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
|
|
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
|
|
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", "
|
|
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"] ||
|
|
141
|
-
(envelope["error"] || "invalid
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
"
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
data/lib/sdkey/version.rb
CHANGED
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.
|
|
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-
|
|
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)
|
|
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: []
|