skaidb 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +49 -8
- data/docs/api.md +13 -3
- data/docs/streaming.md +5 -2
- data/docs/tls.md +31 -3
- data/lib/skaidb.rb +140 -23
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4524df0a2c1434d82de3ae0e2202e408ce54a9ea2a0b2f892247e6623b739e3c
|
|
4
|
+
data.tar.gz: 0c1431641d973133fce8fb4e5fb965c7fbf3730a239250f1b3773cbcf1c5da51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f4f4b9390ed85e9504f325b14b8ae1a9f8f4b54edfd9622e2bc514b0d47b208e763f46887016388ffc6db20625e14ea6a9dda26e5d53daa6fa86f5addbd6b992
|
|
7
|
+
data.tar.gz: 8afeaab18359327ad49ff27d07227068e6243b1c2ae4df6da6ed0ee44d87233cebb12d00601c0f930fdbe715f0fbe467ba2034bb70f92e8880fa44dbc7ebe3dd
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ All notable changes to the skaidb Ruby driver. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-09-26
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Certificate login (wire mechanism EXTERNAL): `auth_mechanism: :certificate`
|
|
11
|
+
with `tls_client_cert:` / `tls_client_key:` (PEM files). AuthStart carries
|
|
12
|
+
the optional user, an empty client nonce and mechanism byte 2; the
|
|
13
|
+
outcome's signature is not verified, since TLS authenticated the server.
|
|
14
|
+
`user:` is optional under it and, when given, must equal the certificate's
|
|
15
|
+
Common Name. `Skaidb::AuthMechanism` resolves the option.
|
|
16
|
+
- `tls_client_cert:` / `tls_client_key:` also present a client certificate
|
|
17
|
+
under SCRAM, for servers that verify client certificates; either turns TLS
|
|
18
|
+
on, and they must be given together.
|
|
19
|
+
- The shared wire-protocol conformance suite: `conformance/vectors.json` and
|
|
20
|
+
its contract, vendored byte-identical, run by `test/conformance_test.rb`
|
|
21
|
+
(values, SCRAM, auth outcomes, every case through the public API; nothing
|
|
22
|
+
skipped). CI fails when the vendored vectors differ from
|
|
23
|
+
https://skaidb.org/conformance/vectors.json.
|
|
24
|
+
- `Result#kind` (`:rows`, `:mutation`, `:ddl`, `:stream`): a DDL
|
|
25
|
+
acknowledgement was indistinguishable from "0 rows affected".
|
|
26
|
+
- `Skaidb.salted_password` and `Skaidb.auth_message`, the SCRAM pieces the
|
|
27
|
+
handshake uses, so they can be checked against the reference vectors.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- `Connection#stream` with a block returns a `Result` instead of `nil`. Found
|
|
31
|
+
by the conformance suite: a stream with no rows had no way to report its
|
|
32
|
+
column names, and a non-row statement sent over the streaming opcode
|
|
33
|
+
dropped its affected-row count. A row stream now returns `kind: :stream`
|
|
34
|
+
with the column names in `fields`; a non-row statement returns its
|
|
35
|
+
`:mutation` (with `cmd_tuples`) or `:ddl` Result.
|
|
36
|
+
- `Skaidb.connect`'s `user:` defaults to `nil`, which still means
|
|
37
|
+
`"anonymous"` under SCRAM and an empty user under certificate login.
|
|
38
|
+
|
|
7
39
|
## [1.0.3] - 2026-09-20
|
|
8
40
|
|
|
9
41
|
Release automation: published from GitHub Actions. No code change.
|
data/README.md
CHANGED
|
@@ -54,13 +54,13 @@ Xcode command-line tools on macOS). `gem install skaidb` and vendoring reuse
|
|
|
54
54
|
the `bigdecimal` already installed.
|
|
55
55
|
|
|
56
56
|
Or vendor the single file: copy
|
|
57
|
-
[`lib/skaidb.rb`](https://github.com/porcupin26/skaidb-ruby/blob/v1.0
|
|
57
|
+
[`lib/skaidb.rb`](https://github.com/porcupin26/skaidb-ruby/blob/v1.1.0/lib/skaidb.rb)
|
|
58
58
|
into your project and `require_relative` it. It has no dependencies beyond
|
|
59
59
|
Ruby's standard library.
|
|
60
60
|
|
|
61
61
|
Every release is also attached as a `.gem` to its
|
|
62
62
|
[GitHub release](https://github.com/porcupin26/skaidb-ruby/releases) for
|
|
63
|
-
installs without registry access: `gem install ./skaidb-1.0.
|
|
63
|
+
installs without registry access: `gem install ./skaidb-1.1.0.gem`.
|
|
64
64
|
|
|
65
65
|
## Quick start
|
|
66
66
|
|
|
@@ -117,7 +117,9 @@ Skaidb.connect(
|
|
|
117
117
|
timeout: 10.0, # TCP connect timeout in seconds; nil = none
|
|
118
118
|
database: nil, # run USE <database> as part of connecting
|
|
119
119
|
seeds: nil, # ["db1", "db2:7000", …] tried in random order
|
|
120
|
-
tls: false, tls_ca: nil, tls_insecure: false, tls_server_name: "skaidb"
|
|
120
|
+
tls: false, tls_ca: nil, tls_insecure: false, tls_server_name: "skaidb",
|
|
121
|
+
tls_client_cert: nil, tls_client_key: nil, # PEM files: present a client certificate
|
|
122
|
+
auth_mechanism: :scram # or :certificate: the client certificate is the login
|
|
121
123
|
) # => Skaidb::Connection (or the block's value when a block is given)
|
|
122
124
|
```
|
|
123
125
|
|
|
@@ -163,8 +165,19 @@ conn = Skaidb.connect(host: "db1", tls_insecure: true) # dev only: no
|
|
|
163
165
|
|
|
164
166
|
Any of the three enables TLS. The server name sent as SNI and checked against
|
|
165
167
|
the certificate is `tls_server_name` (default `"skaidb"`, the SAN skaidb's own
|
|
166
|
-
certificates carry). SCRAM runs inside the TLS session.
|
|
167
|
-
|
|
168
|
+
certificates carry). SCRAM runs inside the TLS session.
|
|
169
|
+
|
|
170
|
+
`tls_client_cert:` / `tls_client_key:` present a client certificate; with
|
|
171
|
+
`auth_mechanism: :certificate` that certificate is the login (its Common Name
|
|
172
|
+
is the user, no password is sent):
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
conn = Skaidb.connect(host: "db1", tls_ca: "/etc/skaidb/ca.crt",
|
|
176
|
+
tls_client_cert: "app.crt", tls_client_key: "app.key",
|
|
177
|
+
auth_mechanism: :certificate)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Details: [docs/tls.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/tls.md).
|
|
168
181
|
|
|
169
182
|
### Consistency
|
|
170
183
|
|
|
@@ -255,6 +268,8 @@ shaped like `PG::Result`:
|
|
|
255
268
|
cell by index or column name.
|
|
256
269
|
- `cmd_tuples`: rows affected by `INSERT`/`UPDATE`/`DELETE`; `0` for a
|
|
257
270
|
`SELECT` and for DDL.
|
|
271
|
+
- `kind`: `:rows`, `:mutation` (a count in `cmd_tuples`) or `:ddl` (a bare
|
|
272
|
+
acknowledgement), which tells "DDL succeeded" from "0 rows affected".
|
|
258
273
|
- `result_sets`: for a `CALL` whose procedure `EMIT`s several result sets,
|
|
259
274
|
every set in order (each a `Result`); the last set is the result's own
|
|
260
275
|
`rows`/`fields`. Empty for an ordinary reply.
|
|
@@ -274,8 +289,10 @@ end
|
|
|
274
289
|
first = conn.stream("SELECT id FROM big ORDER BY id").first(10) # Enumerator form
|
|
275
290
|
```
|
|
276
291
|
|
|
277
|
-
`stream` takes SQL text only (no parameters).
|
|
278
|
-
`
|
|
292
|
+
`stream` takes SQL text only (no parameters). With a block it returns a
|
|
293
|
+
`Result` whose `kind` is `:stream` and whose `fields` are the column names
|
|
294
|
+
(the rows went to the block); a non-row statement returns its `:mutation` /
|
|
295
|
+
`:ddl` Result without yielding.
|
|
279
296
|
|
|
280
297
|
**The abandon/drain rule.** The connection is busy for the whole stream:
|
|
281
298
|
a statement from another thread waits for it. Leaving the block early —
|
|
@@ -404,7 +421,7 @@ frame after every handshake. It shows up as `client_name = 'ruby'` /
|
|
|
404
421
|
|
|
405
422
|
```sql
|
|
406
423
|
SELECT client_name, client_version FROM drivers;
|
|
407
|
-
-- ruby | 1.0
|
|
424
|
+
-- ruby | 1.1.0
|
|
408
425
|
```
|
|
409
426
|
|
|
410
427
|
The server records the row asynchronously, so a `SELECT` immediately after
|
|
@@ -424,6 +441,30 @@ know the opcode answers with an error the driver ignores.
|
|
|
424
441
|
binary protocol on port 7000; the server's REST/JSON gateway (port 7080)
|
|
425
442
|
is a dependency-free alternative for other clients.
|
|
426
443
|
|
|
444
|
+
## Conformance
|
|
445
|
+
|
|
446
|
+
`test/conformance_test.rb` runs skaidb's shared wire-protocol conformance
|
|
447
|
+
suite, vendored byte-identical from the server repository in
|
|
448
|
+
[`conformance/`](https://github.com/porcupin26/skaidb-ruby/tree/main/conformance)
|
|
449
|
+
(`vectors.json` plus the contract in its `README.md`), as part of `rake test`:
|
|
450
|
+
|
|
451
|
+
- every value vector decodes to the expected Ruby value and encodes back to
|
|
452
|
+
the reference bytes (a Uuid cell is compared as its canonical String, the
|
|
453
|
+
form the driver returns);
|
|
454
|
+
- every SCRAM vector (auth message, salted password, client proof, server
|
|
455
|
+
signature) through the driver's own SCRAM code;
|
|
456
|
+
- the auth outcomes against a fake server that verifies the client proof
|
|
457
|
+
independently: `ok` connects, a server signature that does not verify
|
|
458
|
+
fails the connect, `denied` fails with the server's reason;
|
|
459
|
+
- every case through the public API (`exec`, `stream`, `exec_params`,
|
|
460
|
+
`exec_batch`, and sequences of them on one connection), checking the exact
|
|
461
|
+
request bytes and the decoded result. The vectors' `?` placeholders are
|
|
462
|
+
passed as this driver's `$1`, `$2`, ….
|
|
463
|
+
|
|
464
|
+
Nothing is skipped: the test prints `skipped call methods: none`. CI also
|
|
465
|
+
fails when the vendored `vectors.json` differs from the published
|
|
466
|
+
<https://skaidb.org/conformance/vectors.json>.
|
|
467
|
+
|
|
427
468
|
## Examples
|
|
428
469
|
|
|
429
470
|
[`examples/`](https://github.com/porcupin26/skaidb-ruby/tree/main/examples)
|
data/docs/api.md
CHANGED
|
@@ -8,7 +8,7 @@ Everything lives under the `Skaidb` module in `lib/skaidb.rb`.
|
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `host` | `"localhost"` | Node to dial (ignored when `seeds` is given). |
|
|
10
10
|
| `port` | `7000` | Binary-protocol port; also the port for a seed without one. |
|
|
11
|
-
| `user` | `"anonymous"` |
|
|
11
|
+
| `user` | `"anonymous"` (SCRAM), `""` (certificate) | User name. Under `auth_mechanism: :certificate` it is optional and, when given, must equal the certificate's Common Name. |
|
|
12
12
|
| `password` | `""` | SCRAM password. Empty skips server-signature verification (anonymous connect). |
|
|
13
13
|
| `consistency` | `:quorum` | Default level for every statement: `:one`, `:quorum`, `:all`, `"ONE"`…, or `0`/`1`/`2`. |
|
|
14
14
|
| `timeout` | `10.0` | TCP connect timeout in seconds; `nil` for none. Reads are not bounded. |
|
|
@@ -18,12 +18,18 @@ Everything lives under the `Skaidb` module in `lib/skaidb.rb`.
|
|
|
18
18
|
| `tls_ca` | `nil` | PEM file to trust instead of the system store; implies `tls`. |
|
|
19
19
|
| `tls_insecure` | `false` | TLS without any certificate verification; implies `tls`. Development only. |
|
|
20
20
|
| `tls_server_name` | `"skaidb"` | SNI name, also checked against the certificate's SANs. |
|
|
21
|
+
| `tls_client_cert` | `nil` | PEM file of a client certificate to present in the TLS handshake; implies `tls`. Needs `tls_client_key`. |
|
|
22
|
+
| `tls_client_key` | `nil` | PEM file of that certificate's private key. |
|
|
23
|
+
| `auth_mechanism` | `:scram` | `:scram` (user + password) or `:certificate` (the client certificate is the login, wire mechanism EXTERNAL; needs `tls_client_cert`/`tls_client_key`). Strings and the aliases `"password"`, `"external"`, `"x509"` are accepted. |
|
|
21
24
|
|
|
22
25
|
With a block, yields the connection and closes it when the block returns
|
|
23
26
|
(the block's value is returned). Raises `Skaidb::ConnectionError` when no
|
|
24
27
|
endpoint accepts the handshake (`no reachable endpoint in …: <last error>`),
|
|
25
28
|
`authentication denied: …` on a wrong password, and
|
|
26
29
|
`server signature mismatch` when the server fails mutual authentication.
|
|
30
|
+
Raises `Skaidb::Error` before dialing for an unknown `auth_mechanism`, a
|
|
31
|
+
`tls_client_cert` without its `tls_client_key` (or the reverse), and
|
|
32
|
+
`:certificate` without a client certificate.
|
|
27
33
|
|
|
28
34
|
## `Skaidb::Connection`
|
|
29
35
|
|
|
@@ -49,8 +55,11 @@ endpoint accepts the handshake (`no reachable endpoint in …: <last error>`),
|
|
|
49
55
|
- `prepare_server(sql) → [id, nparams] | nil` — prepare a statement with `?`
|
|
50
56
|
placeholders on the server; `nil` when the server refuses. Cached per
|
|
51
57
|
connection (240 entries); the cache is cleared on re-dial.
|
|
52
|
-
- `stream(sql, consistency: nil) { |row| … } →
|
|
53
|
-
[streaming.md](streaming.md).
|
|
58
|
+
- `stream(sql, consistency: nil) { |row| … } → Result` — see
|
|
59
|
+
[streaming.md](streaming.md). For a row stream the returned Result has
|
|
60
|
+
`kind == :stream`, the column names in `fields` and no `rows` (they went to
|
|
61
|
+
the block); a statement without rows returns its `:mutation` / `:ddl`
|
|
62
|
+
Result without yielding. Without a block returns an Enumerator.
|
|
54
63
|
- `subscribe(stream_name, after: nil, poll: 0.5) { |event| … }` — yields a
|
|
55
64
|
`CREATE STREAM` log's events forever, each a Hash with `"id"`, `"op"`,
|
|
56
65
|
`"k"`, `"ts"`, `"doc"`. Polls `_stream_<name>` 500 rows at a time with a
|
|
@@ -91,6 +100,7 @@ Shaped like `PG::Result`; `Enumerable` over row Hashes.
|
|
|
91
100
|
| `[i]` | Row `i` as a Hash, or `nil`. |
|
|
92
101
|
| `getvalue(row, col)` | One cell; `col` is an index or a column name. |
|
|
93
102
|
| `cmd_tuples` | Rows affected by `INSERT`/`UPDATE`/`DELETE`; `0` for `SELECT` and DDL. |
|
|
103
|
+
| `kind` | What the server answered: `:rows` (a result set, or several in `result_sets`), `:mutation` (a count in `cmd_tuples`), `:ddl` (a bare acknowledgement — no rows and no count), `:stream` (the summary `stream` returns). |
|
|
94
104
|
| `result_sets` | For a `CALL` whose body `EMIT`s, every result set in order (each a `Result`); the last one is this result's own `rows`/`fields`. `[]` otherwise. |
|
|
95
105
|
|
|
96
106
|
## `Skaidb::Pool`
|
data/docs/streaming.md
CHANGED
|
@@ -13,11 +13,14 @@ end
|
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
- `stream(sql, consistency: nil)` takes SQL text only — no parameters.
|
|
16
|
-
- With a block it returns `
|
|
16
|
+
- With a block it returns a `Skaidb::Result` after the last row: `kind` is
|
|
17
|
+
`:stream`, `fields` holds the column names (known even when no row
|
|
18
|
+
arrived), and `rows` is empty because the rows went to the block.
|
|
17
19
|
- Without a block it returns an Enumerator: `conn.stream(sql).first(10)`,
|
|
18
20
|
`.each_slice(1000)`, `.lazy.map { … }`, and the rest of Enumerable work.
|
|
19
21
|
- A non-row statement (an `INSERT`, DDL, `USE`) answers with its ordinary
|
|
20
|
-
result over the same opcode; `stream` returns `
|
|
22
|
+
result over the same opcode; `stream` returns that Result (`kind`
|
|
23
|
+
`:mutation` with `cmd_tuples`, or `:ddl`) without yielding.
|
|
21
24
|
- A server without the streaming opcode raises `Skaidb::QueryError`
|
|
22
25
|
(`server does not support streaming: …`).
|
|
23
26
|
|
data/docs/tls.md
CHANGED
|
@@ -42,16 +42,44 @@ OpenSSL's message.
|
|
|
42
42
|
|
|
43
43
|
## What runs inside the session
|
|
44
44
|
|
|
45
|
-
The TCP connection is upgraded to TLS *before* the
|
|
45
|
+
The TCP connection is upgraded to TLS *before* the login handshake, so the
|
|
46
46
|
user name, nonces and proof all travel encrypted; with `seeds:`, each
|
|
47
|
-
endpoint is upgraded and authenticated in turn until one succeeds.
|
|
48
|
-
|
|
47
|
+
endpoint is upgraded and authenticated in turn until one succeeds.
|
|
48
|
+
|
|
49
|
+
## Client certificates
|
|
50
|
+
|
|
51
|
+
`tls_client_cert:` / `tls_client_key:` (PEM files) make the driver present a
|
|
52
|
+
client certificate in the TLS handshake, which a server with
|
|
53
|
+
`client_tls = "required"` and client verification needs. Either one turns TLS
|
|
54
|
+
on, and they go together. On its own the certificate only opens the TLS
|
|
55
|
+
session; the login is still the SCRAM user.
|
|
56
|
+
|
|
57
|
+
With `auth_mechanism: :certificate` the certificate IS the login (wire
|
|
58
|
+
mechanism EXTERNAL): the server maps its Common Name to a role and no
|
|
59
|
+
password is sent. The server needs `auth.x509_enabled`, and its client CA
|
|
60
|
+
must have signed the certificate.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
conn = Skaidb.connect(host: "db1", tls_ca: "/etc/skaidb/ca.crt",
|
|
64
|
+
tls_client_cert: "/etc/app/app.crt",
|
|
65
|
+
tls_client_key: "/etc/app/app.key",
|
|
66
|
+
auth_mechanism: :certificate, database: "app")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Pass `user:` only to assert the expected identity: a certificate mapped to a
|
|
70
|
+
different role fails the connect. The server's answer carries no signature
|
|
71
|
+
to check — TLS already authenticated the server, so keep `tls_ca:` (or the
|
|
72
|
+
system store) and do not combine a certificate login with `tls_insecure`.
|
|
73
|
+
`Skaidb::Pool` passes these keywords through like the others.
|
|
49
74
|
|
|
50
75
|
## Failures
|
|
51
76
|
|
|
52
77
|
- Wrong CA, expired certificate, name mismatch: `Skaidb::ConnectionError`
|
|
53
78
|
with the OpenSSL message, from `connect` (or from the statement that
|
|
54
79
|
triggered a re-dial).
|
|
80
|
+
- A certificate login the server refuses (no role for the CN, `user:` not
|
|
81
|
+
matching it, x509 login disabled): `Skaidb::ConnectionError` with
|
|
82
|
+
`authentication denied: <reason>`.
|
|
55
83
|
- Plain TCP against a `client_tls = required` server: the handshake cannot
|
|
56
84
|
complete, so `connect` raises `Skaidb::ConnectionError`
|
|
57
85
|
(`no reachable endpoint in …`) naming the underlying failure.
|
data/lib/skaidb.rb
CHANGED
|
@@ -34,7 +34,7 @@ require "bigdecimal"
|
|
|
34
34
|
module Skaidb
|
|
35
35
|
# The package version — the single source of truth. The gemspec reads it,
|
|
36
36
|
# and it is what the driver reports to the server in the Hello frame.
|
|
37
|
-
VERSION = "1.0
|
|
37
|
+
VERSION = "1.1.0"
|
|
38
38
|
|
|
39
39
|
# Base class for every error raised by this driver.
|
|
40
40
|
class Error < StandardError; end
|
|
@@ -73,6 +73,29 @@ module Skaidb
|
|
|
73
73
|
end
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Login mechanisms (the optional last byte of AuthStart, PROTOCOL.md §2.4).
|
|
77
|
+
module AuthMechanism
|
|
78
|
+
# SCRAM-SHA-256 with +user+ / +password+ (the default).
|
|
79
|
+
SCRAM = :scram
|
|
80
|
+
# EXTERNAL: the TLS client certificate is the credential.
|
|
81
|
+
CERTIFICATE = :certificate
|
|
82
|
+
|
|
83
|
+
WIRE = { SCRAM => 0, CERTIFICATE => 2 }.freeze
|
|
84
|
+
ALIASES = {
|
|
85
|
+
"scram" => SCRAM, "password" => SCRAM,
|
|
86
|
+
"certificate" => CERTIFICATE, "external" => CERTIFICATE, "x509" => CERTIFICATE
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# Resolve +:scram+ / +:certificate+ (or a String, or an alias:
|
|
90
|
+
# "password", "external", "x509") into +:scram+ or +:certificate+.
|
|
91
|
+
def self.resolve(value)
|
|
92
|
+
m = ALIASES[value.to_s.downcase] if value.is_a?(String) || value.is_a?(Symbol)
|
|
93
|
+
return m if m
|
|
94
|
+
|
|
95
|
+
raise Error, "unknown auth_mechanism #{value.inspect} (use :scram or :certificate)"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
76
99
|
# Value type tags (§4 of PROTOCOL.md).
|
|
77
100
|
module Tags
|
|
78
101
|
NULL = 0
|
|
@@ -490,12 +513,25 @@ module Skaidb
|
|
|
490
513
|
|
|
491
514
|
# ---- SCRAM-SHA-256 (§2) --------------------------------------------------
|
|
492
515
|
|
|
516
|
+
# The SaltedPassword: PBKDF2-HMAC-SHA-256 of the UTF-8 password, 32 bytes.
|
|
517
|
+
def self.salted_password(password, salt, iterations)
|
|
518
|
+
OpenSSL::KDF.pbkdf2_hmac(
|
|
519
|
+
password.to_s.dup.force_encoding("UTF-8"), salt: salt, iterations: iterations, length: 32, hash: "sha256"
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# The AuthMessage both sides sign: username, client nonce, server nonce,
|
|
524
|
+
# lowercase-hex salt and decimal iteration count, joined with NUL bytes.
|
|
525
|
+
def self.auth_message(username, client_nonce, server_nonce, salt, iterations)
|
|
526
|
+
[username, client_nonce, server_nonce, salt.unpack1("H*"), iterations.to_s]
|
|
527
|
+
.map { |part| part.to_s.dup.force_encoding("UTF-8") }
|
|
528
|
+
.join("\0")
|
|
529
|
+
end
|
|
530
|
+
|
|
493
531
|
# Compute the client proof and the expected server signature.
|
|
494
532
|
def self.scram(password, salt, iterations, auth_message)
|
|
495
533
|
digest = OpenSSL::Digest::SHA256
|
|
496
|
-
salted =
|
|
497
|
-
password, salt: salt, iterations: iterations, length: 32, hash: "sha256"
|
|
498
|
-
)
|
|
534
|
+
salted = salted_password(password, salt, iterations)
|
|
499
535
|
client_key = OpenSSL::HMAC.digest(digest.new, salted, "Client Key")
|
|
500
536
|
stored_key = OpenSSL::Digest::SHA256.digest(client_key)
|
|
501
537
|
client_sig = OpenSSL::HMAC.digest(digest.new, stored_key, auth_message)
|
|
@@ -525,6 +561,23 @@ module Skaidb
|
|
|
525
561
|
[bytes].pack("V") + b.b
|
|
526
562
|
end
|
|
527
563
|
|
|
564
|
+
# AuthStart for the certificate login (mechanism EXTERNAL): username (may
|
|
565
|
+
# be empty), an empty client nonce, mechanism byte 2.
|
|
566
|
+
def self.certificate_auth_start(user)
|
|
567
|
+
[10].pack("C") + enc_str(user) + enc_str("") + [AuthMechanism::WIRE[AuthMechanism::CERTIFICATE]].pack("C")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Check the AuthOutcome that answers a certificate AuthStart: returns on
|
|
571
|
+
# Ok (the signature is deliberately not checked), raises ConnectionError
|
|
572
|
+
# with the server's reason on Denied.
|
|
573
|
+
def self.parse_certificate_outcome(payload)
|
|
574
|
+
r = Reader.new(payload)
|
|
575
|
+
raise ConnectionError, "bad handshake outcome" unless r.u8 == 13
|
|
576
|
+
raise ConnectionError, "authentication denied: #{r.text}" unless r.u8 == 1
|
|
577
|
+
|
|
578
|
+
nil
|
|
579
|
+
end
|
|
580
|
+
|
|
528
581
|
# ---- Result --------------------------------------------------------------
|
|
529
582
|
|
|
530
583
|
# A query result, shaped like +PG::Result+. Enumerable over row Hashes
|
|
@@ -543,8 +596,15 @@ module Skaidb
|
|
|
543
596
|
# whose body EMITted), in order; this Result is the LAST of them.
|
|
544
597
|
# Empty for an ordinary single-set reply.
|
|
545
598
|
attr_reader :result_sets
|
|
546
|
-
|
|
547
|
-
|
|
599
|
+
# @return [Symbol] what the server answered: +:rows+ (a result set, or
|
|
600
|
+
# several — see +result_sets+), +:mutation+ (an affected-row count in
|
|
601
|
+
# +cmd_tuples+), +:ddl+ (a bare acknowledgement: no rows, no count), or
|
|
602
|
+
# +:stream+ (returned by Connection#stream once every row was yielded:
|
|
603
|
+
# +fields+ holds the column names, +rows+ is empty).
|
|
604
|
+
attr_reader :kind
|
|
605
|
+
|
|
606
|
+
def initialize(fields:, rows:, cmd_tuples: 0, result_sets: [], kind: :rows)
|
|
607
|
+
@kind = kind
|
|
548
608
|
@result_sets = result_sets
|
|
549
609
|
@fields = fields
|
|
550
610
|
@rows = rows
|
|
@@ -620,8 +680,17 @@ module Skaidb
|
|
|
620
680
|
|
|
621
681
|
def initialize(host:, port:, user:, password:, consistency:, timeout:,
|
|
622
682
|
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
623
|
-
tls_server_name: "skaidb", seeds: nil
|
|
683
|
+
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
684
|
+
tls_client_key: nil, auth_mechanism: :scram)
|
|
624
685
|
@consistency = Consistency.resolve(consistency)
|
|
686
|
+
@auth_mechanism = AuthMechanism.resolve(auth_mechanism)
|
|
687
|
+
if tls_client_cert.to_s.empty? != tls_client_key.to_s.empty?
|
|
688
|
+
raise Error, "tls_client_cert and tls_client_key go together"
|
|
689
|
+
end
|
|
690
|
+
if @auth_mechanism == AuthMechanism::CERTIFICATE && (!tls || tls_client_cert.to_s.empty?)
|
|
691
|
+
raise Error, "certificate authentication needs TLS with a client certificate " \
|
|
692
|
+
"(tls_client_cert: and tls_client_key:)"
|
|
693
|
+
end
|
|
625
694
|
@mutex = Mutex.new
|
|
626
695
|
@closed = false
|
|
627
696
|
# Transport died; the next statement re-dials (see ensure_live!).
|
|
@@ -634,7 +703,8 @@ module Skaidb
|
|
|
634
703
|
@dial_args = { host: host, port: port, user: user, password: password,
|
|
635
704
|
timeout: timeout, database: database, tls: tls, tls_ca: tls_ca,
|
|
636
705
|
tls_insecure: tls_insecure, tls_server_name: tls_server_name,
|
|
637
|
-
seeds: seeds
|
|
706
|
+
seeds: seeds, tls_client_cert: tls_client_cert,
|
|
707
|
+
tls_client_key: tls_client_key }
|
|
638
708
|
dial!
|
|
639
709
|
end
|
|
640
710
|
|
|
@@ -666,8 +736,15 @@ module Skaidb
|
|
|
666
736
|
begin
|
|
667
737
|
@sock = Socket.tcp(h, prt, connect_timeout: timeout)
|
|
668
738
|
@sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
669
|
-
|
|
670
|
-
|
|
739
|
+
if tls
|
|
740
|
+
@sock = tls_wrap(@sock, tls_ca, tls_insecure, tls_server_name,
|
|
741
|
+
@dial_args[:tls_client_cert], @dial_args[:tls_client_key])
|
|
742
|
+
end
|
|
743
|
+
if @auth_mechanism == AuthMechanism::CERTIFICATE
|
|
744
|
+
handshake_certificate(user.to_s)
|
|
745
|
+
else
|
|
746
|
+
handshake(user.to_s, password.to_s)
|
|
747
|
+
end
|
|
671
748
|
last = nil
|
|
672
749
|
break
|
|
673
750
|
rescue StandardError => e
|
|
@@ -710,8 +787,16 @@ module Skaidb
|
|
|
710
787
|
# unreachable. +tls_server_name+ must match a SAN on the server
|
|
711
788
|
# certificate — skaidb's own certs carry DNS:skaidb, which is usually NOT
|
|
712
789
|
# the address dialled.
|
|
713
|
-
|
|
790
|
+
#
|
|
791
|
+
# +client_cert+ / +client_key+ (PEM files) present a client certificate in
|
|
792
|
+
# the TLS handshake: the credential of the certificate login, and what a
|
|
793
|
+
# server that verifies client certificates asks for.
|
|
794
|
+
def tls_wrap(sock, ca_file, insecure, server_name, client_cert = nil, client_key = nil)
|
|
714
795
|
ctx = OpenSSL::SSL::SSLContext.new
|
|
796
|
+
if client_cert && !client_cert.to_s.empty?
|
|
797
|
+
ctx.cert = OpenSSL::X509::Certificate.new(File.read(client_cert))
|
|
798
|
+
ctx.key = OpenSSL::PKey.read(File.read(client_key))
|
|
799
|
+
end
|
|
715
800
|
if insecure
|
|
716
801
|
# Encrypts, but authenticates nothing: a man in the middle can present
|
|
717
802
|
# any certificate. Development only.
|
|
@@ -845,6 +930,12 @@ module Skaidb
|
|
|
845
930
|
# the whole stream), which is what keeps it out of the pool. Iterate with
|
|
846
931
|
# a block or +each+ if you mean to reuse the connection.
|
|
847
932
|
#
|
|
933
|
+
# With a block it returns a Result once the stream ends: for a row stream
|
|
934
|
+
# +kind+ is +:stream+ and +fields+ holds the column names (known even when
|
|
935
|
+
# no row arrived) while +rows+ stays empty — the rows went to the block;
|
|
936
|
+
# a statement that produces no rows returns its ordinary +:mutation+ or
|
|
937
|
+
# +:ddl+ Result without yielding.
|
|
938
|
+
#
|
|
848
939
|
# Takes no parameters — the streaming opcode carries SQL text.
|
|
849
940
|
def stream(sql, consistency: nil)
|
|
850
941
|
return enum_for(:stream, sql, consistency: consistency) unless block_given?
|
|
@@ -855,6 +946,7 @@ module Skaidb
|
|
|
855
946
|
sql_bytes = sql.dup.force_encoding("UTF-8").b
|
|
856
947
|
req = [5, level].pack("CC") + [sql_bytes.bytesize].pack("V") + sql_bytes
|
|
857
948
|
live = false
|
|
949
|
+
summary = nil
|
|
858
950
|
@mutex.synchronize do
|
|
859
951
|
write_frame(req)
|
|
860
952
|
r = Reader.new(read_frame)
|
|
@@ -863,8 +955,10 @@ module Skaidb
|
|
|
863
955
|
when 3
|
|
864
956
|
msg = r.text
|
|
865
957
|
raise QueryError, msg.include?("unknown opcode") ? "server does not support streaming: #{msg}" : msg
|
|
866
|
-
when 1
|
|
867
|
-
return
|
|
958
|
+
when 1
|
|
959
|
+
return Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation) # not row-producing
|
|
960
|
+
when 2
|
|
961
|
+
return Result.new(fields: [], rows: [], cmd_tuples: 0, kind: :ddl)
|
|
868
962
|
when 5
|
|
869
963
|
# The server has already committed to a row stream, so from here
|
|
870
964
|
# the socket carries frames this call owns — mark it BEFORE
|
|
@@ -879,6 +973,7 @@ module Skaidb
|
|
|
879
973
|
live = true
|
|
880
974
|
@streaming = true
|
|
881
975
|
cols = Array.new(r.u32) { r.text }
|
|
976
|
+
summary = Result.new(fields: cols, rows: [], cmd_tuples: 0, kind: :stream)
|
|
882
977
|
begin
|
|
883
978
|
while live
|
|
884
979
|
fr = Reader.new(read_frame)
|
|
@@ -914,7 +1009,7 @@ module Skaidb
|
|
|
914
1009
|
raise QueryError, "unexpected response tag #{tag} to stream request"
|
|
915
1010
|
end
|
|
916
1011
|
end
|
|
917
|
-
|
|
1012
|
+
summary
|
|
918
1013
|
end
|
|
919
1014
|
|
|
920
1015
|
# Prepare +sql+ on the SERVER, returning [id, nparams], or nil when the
|
|
@@ -1143,9 +1238,7 @@ module Skaidb
|
|
|
1143
1238
|
iterations = r.u32
|
|
1144
1239
|
server_nonce = r.text
|
|
1145
1240
|
|
|
1146
|
-
|
|
1147
|
-
auth_message = [user, client_nonce, server_nonce, salt_hex, iterations.to_s]
|
|
1148
|
-
.join("\0").dup.force_encoding("UTF-8")
|
|
1241
|
+
auth_message = Skaidb.auth_message(user, client_nonce, server_nonce, salt, iterations)
|
|
1149
1242
|
proof, expected_sig = Skaidb.scram(password, salt, iterations, auth_message)
|
|
1150
1243
|
|
|
1151
1244
|
write_frame([12].pack("C") + proof) # 32 raw bytes, not length-prefixed
|
|
@@ -1163,6 +1256,17 @@ module Skaidb
|
|
|
1163
1256
|
end
|
|
1164
1257
|
end
|
|
1165
1258
|
|
|
1259
|
+
# EXTERNAL (PROTOCOL.md §2.4): the TLS client certificate is the
|
|
1260
|
+
# credential, its Common Name the username; +user+ may be empty or must
|
|
1261
|
+
# equal it. AuthStart carries an empty client nonce and mechanism byte 2,
|
|
1262
|
+
# and the server answers AuthOutcome at once. The outcome's 32-byte
|
|
1263
|
+
# signature is NOT verified — there is no shared secret to verify it
|
|
1264
|
+
# with; TLS already authenticated the server.
|
|
1265
|
+
def handshake_certificate(user)
|
|
1266
|
+
write_frame(Skaidb.certificate_auth_start(user))
|
|
1267
|
+
Skaidb.parse_certificate_outcome(read_frame)
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1166
1270
|
# -- query --
|
|
1167
1271
|
|
|
1168
1272
|
def run(sql, consistency)
|
|
@@ -1205,9 +1309,9 @@ module Skaidb
|
|
|
1205
1309
|
last = sets.last || Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1206
1310
|
Result.new(fields: last.fields, rows: last.rows, cmd_tuples: 0, result_sets: sets)
|
|
1207
1311
|
when 1 # Mutation
|
|
1208
|
-
Result.new(fields: [], rows: [], cmd_tuples: r.u64)
|
|
1312
|
+
Result.new(fields: [], rows: [], cmd_tuples: r.u64, kind: :mutation)
|
|
1209
1313
|
when 2 # Ddl
|
|
1210
|
-
Result.new(fields: [], rows: [], cmd_tuples: 0)
|
|
1314
|
+
Result.new(fields: [], rows: [], cmd_tuples: 0, kind: :ddl)
|
|
1211
1315
|
when 3 # Error — a statement error, connection stays usable
|
|
1212
1316
|
raise QueryError, r.text
|
|
1213
1317
|
else
|
|
@@ -1226,18 +1330,31 @@ module Skaidb
|
|
|
1226
1330
|
# @param password [String]
|
|
1227
1331
|
# @param consistency [Symbol, String, Integer] :one / :quorum / :all (or 0/1/2)
|
|
1228
1332
|
# @param timeout [Numeric, nil] connect/IO timeout in seconds
|
|
1333
|
+
# @param tls_client_cert [String, nil] PEM file of a client certificate to
|
|
1334
|
+
# present over TLS (with +tls_client_key+); turns TLS on
|
|
1335
|
+
# @param tls_client_key [String, nil] PEM file of its private key
|
|
1336
|
+
# @param auth_mechanism [Symbol, String] +:scram+ (user + password, the
|
|
1337
|
+
# default) or +:certificate+ (the client certificate is the login; +user+
|
|
1338
|
+
# is optional and, when given, must equal the certificate's Common Name)
|
|
1229
1339
|
# @yield [conn] optional block; the connection is closed when it returns
|
|
1230
1340
|
# @return [Connection] (or the block's value when a block is given)
|
|
1231
|
-
def self.connect(host: "localhost", port: 7000, user:
|
|
1341
|
+
def self.connect(host: "localhost", port: 7000, user: nil,
|
|
1232
1342
|
password: "", consistency: :quorum, timeout: 10.0,
|
|
1233
1343
|
database: nil, tls: false, tls_ca: nil, tls_insecure: false,
|
|
1234
|
-
tls_server_name: "skaidb", seeds: nil
|
|
1235
|
-
|
|
1344
|
+
tls_server_name: "skaidb", seeds: nil, tls_client_cert: nil,
|
|
1345
|
+
tls_client_key: nil, auth_mechanism: :scram)
|
|
1346
|
+
tls = true if tls_ca || tls_insecure || tls_client_cert
|
|
1347
|
+
# A SCRAM login with no user is "anonymous"; a certificate login with no
|
|
1348
|
+
# user sends an empty one and lets the certificate's CN decide.
|
|
1349
|
+
user = "anonymous" if user.nil? && AuthMechanism.resolve(auth_mechanism) == AuthMechanism::SCRAM
|
|
1236
1350
|
conn = Connection.new(host: host, port: port, user: user,
|
|
1237
1351
|
password: password, consistency: consistency,
|
|
1238
1352
|
timeout: timeout, database: database, tls: tls,
|
|
1239
1353
|
tls_ca: tls_ca, tls_insecure: tls_insecure,
|
|
1240
|
-
tls_server_name: tls_server_name, seeds: seeds
|
|
1354
|
+
tls_server_name: tls_server_name, seeds: seeds,
|
|
1355
|
+
tls_client_cert: tls_client_cert,
|
|
1356
|
+
tls_client_key: tls_client_key,
|
|
1357
|
+
auth_mechanism: auth_mechanism)
|
|
1241
1358
|
return conn unless block_given?
|
|
1242
1359
|
|
|
1243
1360
|
begin
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: skaidb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- skaidb contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|