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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d5792e9877643c116649c834851fc097027b72fd01ab0f40920dd4e99d977a0d
4
- data.tar.gz: 6d035367f1b2f97b6f49306ef5ff6b77974a2825e73356740d66237e2577a1d1
3
+ metadata.gz: 4524df0a2c1434d82de3ae0e2202e408ce54a9ea2a0b2f892247e6623b739e3c
4
+ data.tar.gz: 0c1431641d973133fce8fb4e5fb965c7fbf3730a239250f1b3773cbcf1c5da51
5
5
  SHA512:
6
- metadata.gz: e2aec63bcdd3ef8d199d223c7de91343370dceaea32fd626b1cb085606d533c613909d3653e60a662e1874f0293ebc9c6c3633f931d39f1ea569970668867e63
7
- data.tar.gz: 3b4e1accd06da21f72da0148e3340bee4eeb1dbe3fbc98c51ce9b0d9891b6075a653c6386258582cadf8b3d49de8f596713e562213bd29c471c78c18865946e5
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.3/lib/skaidb.rb)
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.3.gem`.
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. The driver does not
167
- present a client certificate. Details: [docs/tls.md](https://github.com/porcupin26/skaidb-ruby/blob/main/docs/tls.md).
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). A non-row statement returns
278
- `nil` without yielding.
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.3
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"` | SCRAM user name. |
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| … } → nil` — see
53
- [streaming.md](streaming.md). Without a block returns an Enumerator.
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 `nil` after the last row.
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 `nil` without yielding.
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 SCRAM handshake, so 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. The
48
- driver does not present a client certificate; authentication is SCRAM.
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.3"
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 = OpenSSL::KDF.pbkdf2_hmac(
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
- def initialize(fields:, rows:, cmd_tuples: 0, result_sets: [])
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
- @sock = tls_wrap(@sock, tls_ca, tls_insecure, tls_server_name) if tls
670
- handshake(user.to_s, password.to_s)
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
- def tls_wrap(sock, ca_file, insecure, server_name)
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, 2
867
- return nil # not row-producing
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
- nil
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
- salt_hex = salt.unpack1("H*") # lowercase hex
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: "anonymous",
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
- tls = true if tls_ca || tls_insecure
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.3
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-20 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bigdecimal