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 +7 -0
- data/LICENSE +21 -0
- data/PROTOCOL.md +276 -0
- data/README.md +181 -0
- data/lib/sdkey/client.rb +301 -0
- data/lib/sdkey/crypto/canonical_json.rb +48 -0
- data/lib/sdkey/crypto/constants.rb +39 -0
- data/lib/sdkey/crypto/encoding.rb +22 -0
- data/lib/sdkey/crypto/seal.rb +92 -0
- data/lib/sdkey/errors.rb +17 -0
- data/lib/sdkey/types.rb +67 -0
- data/lib/sdkey/version.rb +5 -0
- data/lib/sdkey.rb +23 -0
- metadata +74 -0
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
|
data/lib/sdkey/client.rb
ADDED
|
@@ -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
|
data/lib/sdkey/errors.rb
ADDED
|
@@ -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
|
data/lib/sdkey/types.rb
ADDED
|
@@ -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
|
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: []
|