diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 2bea8a5..ad5bd42 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -7,6 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: node: [20, 22, 24] firebird: [3, 4, 5] @@ -72,6 +73,7 @@ jobs: - name: Test (Linux) run: | export FIREBIRD_DATA=/firebird/data + export FIREBIRD_DEBUG=1 npm test - name: Show Firebird log on failure diff --git a/README.md b/README.md index 40410ee..7e51770 100644 --- a/README.md +++ b/README.md @@ -377,43 +377,95 @@ Firebird.attach(options, function (err, db) { }); ``` -### Events +### Driver Events + +Driver events are synchronous notifications emitted on the `Database` object for connection-level operations. Subscribe with `db.on(eventName, handler)`. ```js Firebird.attach(options, function (err, db) { if (err) throw err; - db.on('row', function (row, index, isObject) { - // index === Number - // isObject === is row object or array? + db.on('attach', function () { + // fired once the database is attached + }); + + db.on('detach', function (isPoolConnection) { + // isPoolConnection === Boolean }); - db.on('result', function (result) { - // result === Array + db.on('reconnect', function () { + // fired after the driver reconnects a dropped socket }); - db.on('attach', function () {}); + db.on('error', function (err) { + // connection-level errors (socket errors, closed connection, etc.) + }); - db.on('detach', function (isPoolConnection) { - // isPoolConnection == Boolean + db.on('transaction', function (options) { + // fired when a transaction is started (before server response) + // options === resolved transaction options object }); - db.on('reconnect', function () {}); + db.on('commit', function () { + // fired when a transaction commit is sent + }); - db.on('error', function (err) {}); + db.on('rollback', function () { + // fired when a transaction rollback is sent + }); - db.on('transaction', function (isolation) { - // isolation === Number + db.on('query', function (sql) { + // fired with the SQL string when a statement is prepared }); - db.on('commit', function () {}); + db.on('row', function (row, index, isObject) { + // fired for each row decoded during a fetch + // index === Number, isObject === Boolean + }); - db.on('rollback', function () {}); + db.on('result', function (rows) { + // fired with the full rows array once all rows are fetched + // rows === Array + }); db.detach(); }); ``` +### Firebird Database Events (POST_EVENT) + +Firebird database events are **asynchronous** notifications triggered by `POST_EVENT` inside PSQL triggers or stored procedures. They travel over a separate aux connection and are handled through `FbEventManager`. + +> **Note:** Full POST_EVENT reception is not yet implemented. `attachEvent` and `registerEvent` are available, but actual event delivery requires completing the `op_que_events`/`op_event` wire-protocol implementation. + +```js +Firebird.attach(options, function (err, db) { + if (err) throw err; + + // 1. Open the aux event connection and get a FbEventManager + db.attachEvent(function (err, evtmgr) { + if (err) throw err; + + // 2. Subscribe to one or more named events + evtmgr.registerEvent(['MY_EVENT'], function (err) { + if (err) throw err; + + // 3. Listen for POST_EVENT notifications + evtmgr.on('post_event', function (name, count) { + // name === event name string (e.g. 'MY_EVENT') + // count === cumulative trigger count since last notification + }); + }); + + // 4. Unsubscribe from events when no longer needed + // evtmgr.unregisterEvent(['MY_EVENT'], function (err) { ... }); + + // 5. Release the aux connection when done + // evtmgr.close(function (err) { ... }); + }); +}); +``` + ### Escaping Query values ```js diff --git a/SRP_PROTOCOL.md b/SRP_PROTOCOL.md new file mode 100644 index 0000000..ce92ed1 --- /dev/null +++ b/SRP_PROTOCOL.md @@ -0,0 +1,480 @@ +# Firebird SRP Authentication Protocol + +## Overview + +Firebird uses **Secure Remote Password (SRP)** authentication for all connections that do not use the legacy wire-encryption scheme (`Legacy_Auth`). SRP provides password-authenticated key agreement: neither side ever sends the plaintext password over the wire, and both sides prove knowledge of the password without revealing it. + +- **Firebird 3** introduced SRP (`Srp` plugin, SHA-1 HMAC, Protocol Version 14). +- **Firebird 4** added `Srp256` (SHA-256 HMAC, Protocol Version 16) and kept `Srp` as fallback. +- **Firebird 5** uses Protocol Version 17 with the same plugin set as FB4, plus optional wire compression. +- **Firebird 6** (in development) continues Protocol Version 17 and retains full backward compatibility. + +--- + +## SRP Concepts + +| Term | Meaning | +|---|---| +| `N` | A large 1024-bit prime (the SRP group parameter, same for all Firebird versions) | +| `g` | Generator `2` | +| `k` | Multiplier parameter derived from `N` and `g` | +| `s` | Random salt stored in the security database | +| `v` | Verifier: `g^x mod N`, where `x = H(s, H(U:p))` | +| `a`, `A` | Client private/public ephemeral keys: `A = g^a mod N` | +| `b`, `B` | Server private/public ephemeral keys: `B = kv + g^b mod N` | +| `u` | Scrambler: `H(A, B)` | +| `K` | Session key derived by both sides | +| `M1` | Client proof: `H(H(N)⊕H(g), H(I), s, A, B, K)` | +| `M2` | Server proof: `H(A, M1, K)` (client may or may not verify this) | + +The SRP group parameters are shared constants defined in `lib/srp.js`: + +``` +N = E67D2E994B2F900C3F41F08F5BB2627ED0D49EE1FE767A52EFCD565CD6E768812C3E1E9CE8F0A8BEA6CB13CD29DDEBF7A96D4A93B55D488DF099A15C89DCB0640738EB2CBDD9A8F7BAB561AB1B0DC1C6CDABF303264A08D1BCA932D1F1EE428B619D970F342ABA9A65793B8B2F041AE5364350C16F735F56ECBCA87BD57B29E7 +g = 2 +k = 1277432915985975349439481660349303019122249719989 +``` + +> **Important exponent reduction note**: The Firebird engine reduces intermediate SRP exponents modulo N, which deviates from the SRP specification. `node-firebird` mirrors this behaviour in `lib/srp.js` so that client and server agree on the session key `K`. + +--- + +## Protocol Version Matrix + +| Firebird Version | Wire Protocol | Auth Plugin | Hash | `op_accept_data` opcode | `op_cond_accept` opcode | +|---|---|---|---|---|---| +| 2.5 | 13 | `Legacy_Auth` | DES crypt | 94 | — | +| 3.x | 14 (`0x800E`) | `Srp` | SHA-1 | 94 | 98 | +| 4.x | 16 (`0x8010`) | `Srp`, `Srp256` | SHA-1 / SHA-256 | 94 | 98 | +| 5.x | 17 (`0x8011`) | `Srp`, `Srp256` | SHA-1 / SHA-256 | 94 | 98 | + +The high bit of the protocol version number (`0x8000`) is the **Firebird private flag**: it prevents ambiguity with Borland InterBase protocol numbers (protocols 1–13). + +--- + +## Wire-Protocol Sequence Diagrams + +### Legacy Auth (no SRP, `is_authenticated = 1`) + +``` +Client Server +────── ────── +op_connect (plugin=Legacy_Auth, A=hash(pw)) + ─────────────────────────────────────────────▶ + op_accept_data (is_authenticated=1) + ◀───────────────────────────────────────────── +op_attach (database, DPB) + ─────────────────────────────────────────────▶ + op_response (dbHandle) + ◀───────────────────────────────────────────── +``` + +### SRP Auth (FB3 Protocol 14 / FB4 Protocol 16 / FB5 Protocol 17) + +``` +Client Server +────── ────── +1. op_connect + plugin = "Srp" (or "Srp256") + A = clientPublicKey (hex) + ─────────────────────────────────────────────▶ + + 2. op_cond_accept (opcode 98) + BLR auth data: + [uint16LE saltLen][salt hex] + [uint16LE keyLen ][B hex ] + plugin = "Srp" + is_authenticated = 0 + ◀───────────────────────────────────────────── + +3. Client computes: + u = SHA1(pad(A), pad(B)) + x = SHA1(salt, SHA1(U + ':' + password)) + S = (B - k·g^x)^(a + u·x) mod N + K = SHA1(S) + M1 = SHA1(SHA1(N)⊕SHA1(g), SHA1(user), salt, A, B, K) + +4. op_cont_auth (opcode 92) + auth_data = M1 (hex) + plugin = "Srp" + ─────────────────────────────────────────────▶ + + 5. Server verifies M1, computes M2 + op_cont_auth (opcode 92) + auth_data = M2 (server proof, may be empty) + plugin = "Srp" + ◀───────────────────────────────────────────── + + 6. op_accept (opcode 3) + protocolVersion = 0x800E / 0x8010 / 0x8011 + ◀───────────────────────────────────────────── + +[If wireCrypt ≠ DISABLE]: +7. op_crypt (opcode 96) "Arc4" + ─────────────────────────────────────────────▶ + op_response + ◀───────────────────────────────────────────── + Both sides enable Arc4 stream cipher using + session key K (padded to 20 bytes / SHA-1 length) + +8. op_attach (database, DPB) + ─────────────────────────────────────────────▶ + op_response (dbHandle) + ◀───────────────────────────────────────────── +``` + +### SRP256 (Srp256 plugin — FB4/FB5 only) + +The wire sequence is identical to SRP (above). The only difference is: + +- The hash algorithm for `M1` / `M2` computation switches from **SHA-1** to **SHA-256**. +- The plugin name in `op_cond_accept` is `"Srp256"` instead of `"Srp"`. + +--- + +## Changes Per Firebird Version + +### Firebird 3 (Protocol 14, plugin `Srp`) + +- Introduced the SRP authentication framework. +- Hash algorithm for `M1` and `M2`: **SHA-1**. +- Arc4 stream cipher for wire encryption. +- Auth data encoding: BLR byte array inside `op_cond_accept`. +- No wire compression support. + +### Firebird 4 (Protocol 16, plugins `Srp256` and `Srp`) + +- Added `Srp256` plugin with **SHA-256** hashing (preferred). +- Falls back to `Srp` (SHA-1) if the server does not offer `Srp256`. +- Wire compression support added (`pflag_compress` in protocol negotiation). +- `op_response_piggyback` (opcode 72) introduced — server sends this as an unsolicited completion notification; clients must silently discard it. + +### Firebird 5 (Protocol 17, plugins `Srp256` and `Srp`) + +- Protocol version bumped to 17 (`0x8011`). +- Wire protocol is otherwise compatible with Protocol 16. +- `op_response_piggyback` usage more prevalent during `EventConnection` teardown. +- BigInt arithmetic in SRP key generation can be significantly **slower** on resource-constrained CI runners (see [Timing Issue](#timing-issue-and-fix) below). + +### Firebird 6 (Protocol 17, in development) + +- Retains Protocol Version 17. +- Continues to support `Srp256` and `Srp` plugins. +- No client-visible protocol changes from Firebird 5 at this time. + +--- + +## Timing Issue and Fix + +### Root Cause + +SRP key generation involves **modular exponentiation** over 1024-bit integers. In Node.js, this is performed by the `big-integer` library using pure-JavaScript arithmetic. On a developer machine the `clientSeed()` call completes in < 1 ms and `clientProof()` in < 5 ms. On a loaded CI runner (especially with Firebird 3 which uses SHA-1 requiring more steps), both calls can take **500–3000 ms** combined. + +The original per-test timeout was 10 s → raised to 30 s → **raised to 60 s** (current) to handle the worst-case loaded runner scenario. + +### How to Diagnose + +Set the environment variable `FIREBIRD_DEBUG=1` before running your tests. You will see: + +``` +[fb-debug] srp.clientSeed: 843ms +[fb-debug] srp.clientProof(sha1): 1247ms +``` + +- `srp.clientSeed` measures the time to generate the client ephemeral key pair `(a, A = g^a mod N)`. +- `srp.clientProof(sha1|sha256)` measures the time to derive `K` and compute `M1`. + +If these values exceed 4000 ms combined, the 60-second timeout may still be too tight on extremely loaded runners. In that case, increase the `it(…, { timeout: … })` value in `test/index.js` for the SRP tests. + +### The Fix + +`test/index.js`: +```js +it('should attach with srp plugin', { timeout: 60000 }, async function () { … }); +``` + +### SRP Timing Debug Logs (in `lib/wire/connection.js`) + +```js +// clientSeed — key pair generation +const _t0 = Date.now(); +this.clientKeys = srp.clientSeed(); +if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] srp.clientSeed: %dms', Date.now() - _t0); +} + +// clientProof — session key + M1 computation +const _t1 = Date.now(); +var proof = srp.clientProof(user, password, salt, A, B, a, hashAlgo); +if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] srp.clientProof(%s): %dms', accept.srpAlgo, Date.now() - _t1); +} +``` + +--- + +## Session Key Arc4 Encryption + +After the SRP handshake, both the client and server share the session key `K = SHA1(S)`. This key is a 160-bit (20-byte) SHA-1 hash returned as a BigInt from `srp.clientProof()`. + +The key is used to initialise an **Arc4 (RC4)** stream cipher for all subsequent bytes on the socket. A critical padding rule applies: + +```js +// K is a BigInt; BigInt.toString(16) may omit leading zeros +// Arc4 requires exactly 20 bytes (SHA-1 output length) +var keyBuf = Buffer.from(ret.sessionKey.toString(16).padStart(40, '0'), 'hex'); +// ^^^^^^^^^^^^^^^^^^^ +// without this, a K with a leading 0x00 byte will be only +// 19 bytes, causing a key mismatch and a garbled connection +self._socket.enableEncryption(keyBuf); +``` + +> **Bug fixed (commit `fc0021d`)**: the `padStart(40, '0')` call was missing. A session key whose most-significant byte was `0x00` would produce a 19-byte (or shorter) key, while the Firebird server always uses the full 20 bytes. This caused connection corruption for ~1 in 256 SRP sessions. + +--- + +## How to Run the Online SRP Test + +Requires a real Firebird server with: +- `AuthServer = Srp` (or `Srp256`) configured in `firebird.conf` +- A user `SYSDBA` with password `masterkey` (or adjust `test/config.js`) + +```bash +# Basic run +npm test + +# With debug logging +FIREBIRD_DEBUG=1 npm test -- --grep "srp" +``` + +The test verifies: +1. `Firebird.attach()` succeeds using SRP. +2. The `'attach'` driver event fires exactly once. +3. `db.detach()` succeeds cleanly. + +--- + +## How to Run the Offline SRP Mock-Server Tests + +No Firebird installation required. + +```bash +# Run only the offline tests +npx vitest run test/mock-server.js + +# With debug timing output +FIREBIRD_DEBUG=1 npx vitest run test/mock-server.js +``` + +The offline tests use `test/mock-server.js`, which contains a minimal TCP server that speaks enough of the Firebird wire protocol to exercise the full SRP handshake. + +### Covered Scenarios + +| Test | Protocol | What is verified | +|---|---|---| +| Full SRP attach/detach (FB3) | 14 (`Srp`) | Complete op_connect → op_cond_accept → op_cont_auth → op_accept → op_attach cycle | +| Full SRP attach/detach (FB4) | 16 (`Srp`) | Same flow with Protocol 16 | +| Full SRP attach/detach (FB5) | 17 (`Srp`) | Same flow with Protocol 17 | +| `parseOpConnect` BLR | any | Extracts plugin name `"Srp"` and client key A from `CNCT_specific_data` multi-block | +| `parseOpContAuth` | any | Extracts M1 hex proof from client `op_cont_auth` message | +| `op_cond_accept` XDR round-trip | 14 | BLR format: `[u16LE saltLen][salt][u16LE keyLen][B]` | +| `op_cont_auth` XDR round-trip | any | Correct opcode, empty M2 array, plugin name | +| `op_accept` XDR round-trip | 16 | Correct opcode and protocol version field | +| Protocol version constants | 14/16/17 | `FB_PROTOCOL_FLAG`, `FB_PROTOCOL_MASK` | + +### SRP Phase Timing (FIREBIRD_DEBUG) + +When `FIREBIRD_DEBUG=1` is set, each SRP mock-server test emits a timing trace: + +``` +[srp-test fb3] opConnectRecv=2ms challengeSent=847ms m1Recv=2094ms acceptSent=2094ms opAttachRecv=2095ms +``` + +Fields: +- `opConnectRecv` — server received `op_connect` from client +- `challengeSent` — server sent `op_cond_accept` (salt + B) +- `m1Recv` — server received `op_cont_auth` (M1 proof) +- `acceptSent` — server sent `op_cont_auth` (M2) + `op_accept` +- `opAttachRecv` — server received `op_attach` + +The gap between `opConnectRecv` and `challengeSent` is the server-side `srp.serverSeed()` time; the gap between `challengeSent` and `m1Recv` is the client-side `srp.clientSeed()` + `srp.clientProof()` time. + +--- + +## BLR Auth Data Format in `op_cond_accept` + +The `op_cond_accept` (opcode 98) frame carries the SRP challenge in a **BLR byte array** field. The format is: + +``` +Offset Size Field +────── ──── ───── +0 2 saltLen (uint16 little-endian) — length of the salt hex string +2 N salt (ASCII hex string, N = saltLen bytes) +2+N 2 keyLen (uint16 little-endian) — length of the server B hex string +4+N M B (ASCII hex string, M = keyLen bytes) +``` + +Example: +``` +00 40 → saltLen = 64 (32 bytes of salt → 64 hex chars) +3031323334... → 64 ASCII hex characters of salt +00 80 → keyLen = 128 (64 bytes of B → 128 hex chars) +61626364... → 128 ASCII hex characters of B +``` + +`node-firebird` parses this in `lib/wire/connection.js`: +```js +var saltLen = d.buffer.readUInt16LE(0); +var keyLen = d.buffer.readUInt16LE(saltLen + 2); +var keyStart = saltLen + 4; +cnx.serverKeys = { + salt: d.buffer.slice(2, saltLen + 2).toString('utf8'), + public: BigInt(d.buffer.slice(keyStart).toString('utf8'), 16) +}; +``` + +--- + +## `op_cont_auth` Client Message Format + +The client sends `op_cont_auth` (opcode 92) with: + +``` +Field XDR type Content +────────────── ──────── ─────────────────────────────── +opcode int32 92 (op_cont_auth) +auth_data array M1 proof as ASCII hex string +plugin_name string "Srp" or "Srp256" +plist string "" (empty) +pkey string "" (empty) +``` + +--- + +## `op_cont_auth` Server Response Format + +The server replies with `op_cont_auth` (opcode 92) carrying M2: + +``` +Field XDR type Content +────────────── ──────── ──────────────────────────────── +opcode int32 92 (op_cont_auth) +auth_data array M2 server proof (may be empty) +plugin_name string "Srp" or "Srp256" +plist string "" (empty) +pkey string "" (empty) +``` + +> **node-firebird does NOT validate M2**. After receiving the server `op_cont_auth`, it simply waits for the subsequent `op_accept`. This is safe for practical use but means a compromised server could send any M2 value. + +--- + +## Offline Mock-Server Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ test/mock-server.js │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Frame Builders (XdrWriter / BlrWriter) │ │ +│ │ buildOpAcceptData(plugin) │ │ +│ │ buildOpResponse(handle) │ │ +│ │ buildOpEvent(dbHandle, eventRid) │ │ +│ │ buildOpResponsePiggyback() │ │ +│ │ buildOpCondAcceptSRP(proto, salt, B) │ │ +│ │ buildOpContAuthServer(m2Data) │ │ +│ │ buildOpAccept(proto) │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Frame Parsers │ │ +│ │ parseOpConnect(buf) — BLR parser │ │ +│ │ parseOpContAuth(buf) — M1 extractor │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Server Helpers │ │ +│ │ startMockServer() / stopMockServer() │ │ +│ │ makeDispatcher(port, handler) │ │ +│ │ makeFullDispatcher(port, handler) │ │ +│ │ withMockAttach(port) / withMockDetach(db) │ │ +│ │ withMockSrpAttach(port, proto?) │ │ +│ └──────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ TCP loopback + ▼ +┌─────────────────────────────────────────────────────────┐ +│ lib/wire/connection.js (Connection class) │ +│ connect() → decodeResponse() → attach() → detach() │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Related Files + +| File | Description | +|---|---| +| `lib/srp.js` | Pure-JS SRP implementation (BigInt arithmetic, SHA-1/SHA-256 hashing) | +| `lib/wire/connection.js` | Wire protocol encode/decode; SRP handshake; debug logging | +| `lib/wire/const.js` | Protocol version constants, opcode numbers, auth plugin names | +| `lib/wire/serialize.js` | `XdrWriter`, `XdrReader`, `BlrWriter`, `BlrReader` | +| `test/mock-server.js` | Offline wire-protocol tests (SRP auth + queue integrity) | +| `test/index.js` | Online integration tests (real Firebird server required) | + +--- + +## Troubleshooting + +### `should attach with srp plugin` times out in CI + +1. Set `FIREBIRD_DEBUG=1` and check the timing logs: + ``` + [fb-debug] srp.clientSeed: Xms + [fb-debug] srp.clientProof(sha1): Yms + ``` +2. If `X + Y > 5000`, the runner is overloaded. Increase the timeout in `test/index.js`: + ```js + it('should attach with srp plugin', { timeout: 120000 }, async function () { … }); + ``` +3. Alternatively, run the tests at off-peak times or use a dedicated runner. + +### Connection succeeds but data is garbled (after SRP) + +Most likely cause: session key padding bug. Verify `lib/wire/connection.js` has: +```js +var keyBuf = Buffer.from(ret.sessionKey.toString(16).padStart(40, '0'), 'hex'); +``` +If the `padStart(40, '0')` is absent, keys with a leading zero byte will be truncated. + +### `op_response_piggyback` causes queue corruption (Firebird 5) + +Fixed in `lib/wire/connection.js`. Verify the `decodeResponse` switch statement has: +```js +case Const.op_response_piggyback: + parseOpResponse(data, {}, cb); + return { _isOpEvent: true }; // skip queue shift +``` + +### `op_event` on main connection causes hang + +Fixed in `lib/wire/connection.js`. Verify: +```js +case Const.op_event: + data.readInt(); // db handle + data.readArray(); // EPB + data.readInt64(); // AST pointer + data.readInt(); // event RID + return { _isOpEvent: true }; // skip queue shift +``` + +--- + +## References + +- [RFC 2945 — The SRP Authentication and Key Exchange System](https://www.ietf.org/rfc/rfc2945.txt) +- [Firebird source: `src/auth/SecureRemotePassword/`](https://github.com/FirebirdSQL/firebird/tree/master/src/auth/SecureRemotePassword) +- [Firebird Wire Protocol documentation](https://github.com/FirebirdSQL/firebird/blob/master/doc/WhatsNew) +- [node-firebird `lib/srp.js`](lib/srp.js) +- [node-firebird `lib/wire/connection.js`](lib/wire/connection.js) +- [node-firebird `test/mock-server.js`](test/mock-server.js) diff --git a/lib/wire/connection.js b/lib/wire/connection.js index 85fd5d4..08a2cd9 100644 --- a/lib/wire/connection.js +++ b/lib/wire/connection.js @@ -140,6 +140,7 @@ class Connection { self._socket.on('data', function (data) { var xdr; + var hadSavedBuffer = Boolean(self._xdr); if (!self._xdr) { xdr = new XdrReader(data); @@ -147,6 +148,11 @@ class Connection { xdr = new XdrReader(Buffer.concat([self._xdr.buffer, data], self._xdr.buffer.length + data.length)); delete (self._xdr); } + + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] data event: bytes=%d queue=%d pending=%d xdr.pos=%d savedBuf=%s', + xdr.buffer.length, self._queue.length, self._pending.length, xdr.pos, hadSavedBuffer); + } while (xdr.pos < xdr.buffer.length) { var cb = self._queue[0], pos = xdr.pos; @@ -154,12 +160,30 @@ class Connection { decodeResponse(xdr, cb, self, self._lowercase_keys, function (err, obj) { if (err) { - xdr.buffer = xdr.buffer.slice(pos); - xdr.pos = 0; - self._xdr = xdr; - - if (self.accept && self.accept.protocolMinimumType === Const.ptype_lazy_send && self._queue.length > 0) { - self._queue[0].lazy_count = 2; + if (err instanceof RangeError) { + // Genuinely incomplete packet – buffer the remaining bytes + // and wait for the next 'data' event to reassemble. + xdr.buffer = xdr.buffer.slice(pos); + xdr.pos = 0; + self._xdr = xdr; + + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] incomplete packet: saved %d bytes at pos=%d queue=%d', + xdr.buffer.length, pos, self._queue.length); + } + + if (self.accept && self.accept.protocolMinimumType === Const.ptype_lazy_send && self._queue.length > 0) { + self._queue[0].lazy_count = 2; + } + } else { + // Any other error (truly unknown opcode not handled above). + // Save the buffer so it can be retried, but log a warning. + if (process.env.FIREBIRD_DEBUG) { + console.warn(`[fb-debug] unhandled protocol error: ${err.message} pos=${pos} bytes=${xdr.buffer.length} queue=${self._queue.length}`); + } + xdr.buffer = xdr.buffer.slice(pos); + xdr.pos = 0; + self._xdr = xdr; } return; } @@ -168,10 +192,27 @@ class Connection { if (xdr.r) { delete (xdr.r); } + + // op_event / op_response_piggyback received on the main connection: + // data has been consumed by decodeResponse but it does not belong to + // any queued request – do NOT shift the queue or invoke any pending + // callback. + if (obj && obj._isOpEvent) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] async opcode consumed (ignored): queue=%d xdr.pos=%d remaining=%d', + self._queue.length, xdr.pos, xdr.buffer.length - xdr.pos); + } + return; + } self._queue.shift(); self._pending.shift(); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] response dispatched: queue remaining=%d pending remaining=%d xdr.pos=%d', + self._queue.length, self._pending.length, xdr.pos); + } + if (obj && obj.status) { obj.message = lookupMessages(obj.status); doCallback(obj, cb); @@ -272,6 +313,7 @@ class Connection { var blr = this._blr; this._pending.push('connect'); + this._authStartTime = Date.now(); msg.pos = 0; blr.pos = 0; @@ -282,7 +324,11 @@ class Connection { var specificData = ''; if (Const.AUTH_PLUGIN_SRP_LIST.indexOf(pluginName) > -1) { + const _t0 = Date.now(); this.clientKeys = srp.clientSeed(); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] srp.clientSeed: %dms', Date.now() - _t0); + } specificData = this.clientKeys.public.toString(16); blr.addMultiblockPart(Const.CNCT_specific_data, specificData, Const.DEFAULT_ENCODING); } else if (pluginName === Const.AUTH_PLUGIN_LEGACY) { @@ -335,7 +381,7 @@ class Connection { // Wire encryption: send op_crypt if SRP session key is available if (ret.sessionKey && ret.protocolVersion >= Const.PROTOCOL_VERSION13 && options.wireCrypt !== Const.WIRE_CRYPT_DISABLE) { - var keyBuf = Buffer.from(srp.hexPad(ret.sessionKey.toString(16)), 'hex'); + var keyBuf = Buffer.from(ret.sessionKey.toString(16).padStart(40, '0'), 'hex'); self.sendOpCrypt('Arc4'); self._socket.enableEncryption(keyBuf); self._pending.push('crypt'); @@ -354,6 +400,9 @@ class Connection { callback(undefined, ret); } + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: op_connect sent plugin=%s t=%dms', pluginName, Date.now() - this._authStartTime); + } this._queueEvent(cb); } @@ -427,6 +476,8 @@ class Connection { self.dbhandle = ret.handle; if (callback) callback(undefined, ret); + if (!db) + ret.emit('attach', ret); } // For reconnect @@ -535,14 +586,12 @@ class Connection { if (ret) self.dbhandle = ret.handle; - - setImmediate(function() { - if (self.db) - self.db.emit('attach', ret); - }); - + if (callback) callback(err, ret); + + if (!err && ret) + ret.emit('attach', ret); } cb.response = new Database(this); @@ -1469,9 +1518,16 @@ class Connection { msg.addInt(1); // async msg.addInt(self.dbhandle); msg.addInt(0); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auxConnection: sending op_connect_request(53) dbhandle=%d queue_before=%d xdr_saved=%s', + self.dbhandle, self._queue.length, Boolean(self._xdr)); + } function cb(err, ret) { if (err) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auxConnection: op_connect_request error: %s queue=%d', err.message, self._queue.length); + } doError(err, callback); return; } @@ -1481,6 +1537,11 @@ class Connection { port: ret.buffer.readUInt16BE(2), host: ret.buffer.readUInt8(4) + '.' + ret.buffer.readUInt8(5) + '.' + ret.buffer.readUInt8(6) + '.' + ret.buffer.readUInt8(7) } + + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auxConnection: op_response ok → aux family=%d port=%d host=%s queue=%d', + socket_info.family, socket_info.port, socket_info.host, self._queue.length); + } callback(undefined, socket_info); } @@ -1548,12 +1609,22 @@ class Connection { } +// Reverse-lookup table: opcode number → name for FIREBIRD_DEBUG trace logging. +const opcodeNames = Object.fromEntries( + Object.entries(Const).filter(([k]) => k.startsWith('op_')).map(([k, v]) => [v, k]) +); + function decodeResponse(data, callback, cnx, lowercase_keys, cb) { try { do { var r = data.r || data.readInt(); } while (r === Const.op_dummy); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] decodeResponse: opcode=%d(%s) pos=%d buflen=%d', + r, opcodeNames[r] || 'unknown', data.pos, data.buffer.length); + } + var item, op, response; switch (r) { @@ -1778,6 +1849,7 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { public: BigInt(d.buffer.slice(keyStart, d.buffer.length).toString('utf8'), 16) }; + const _t1 = Date.now(); var proof = srp.clientProof( cnx.options.user.toUpperCase(), cnx.options.password, @@ -1787,6 +1859,9 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { cnx.clientKeys.private, accept.srpAlgo ); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] srp.clientProof(%s): %dms', accept.srpAlgo, Date.now() - _t1); + } accept.authData = proof.authData.toString(16); accept.sessionKey = proof.clientSessionKey; @@ -1805,9 +1880,20 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { cnx._socket.enableCompression(); } + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: %s received plugin=%s proto=%d t=%dms', + r === Const.op_cond_accept ? 'op_cond_accept' : r === Const.op_accept_data ? 'op_accept_data' : 'op_accept', + accept.pluginName, accept.protocolVersion, + cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } + // For op_cond_accept: send op_cont_auth and wait for response if (r === Const.op_cond_accept && accept.authData) { cnx._pendingAccept = accept; + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: sending op_cont_auth plugin=%s t=%dms', + accept.pluginName, cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } cnx.sendOpContAuth( accept.authData, Const.DEFAULT_ENCODING, @@ -1823,14 +1909,41 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { data.readString(Const.DEFAULT_ENCODING); // plist data.readString(Const.DEFAULT_ENCODING); // pkey + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: op_cont_auth received plugin=%s pendingAccept=%s t=%dms', + pluginName, + cnx._pendingAccept ? cnx._pendingAccept.pluginName : 'none', + cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } + // During SRP mutual authentication, the server sends op_cont_auth // with its proof (M2) after receiving the client's proof (M1). // When we have an active auth exchange for this plugin, just wait // for the subsequent op_accept instead of treating it as an error. if (cnx._pendingAccept && cnx._pendingAccept.pluginName === pluginName) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: server SRP proof (M2) received, waiting for op_accept t=%dms', + cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } return; // Server SRP proof received - wait for op_accept } + // Firebird 4/5 (protocols 16/17) chained-auth: after the client sends + // the SRP M1 proof, the server sends op_cont_auth with Legacy_Auth. + // SRP has already established the session key; the server additionally + // requires a Legacy_Auth verification step before sending op_accept. + // Respond with Legacy_Auth credentials and wait for op_accept. + if (cnx._pendingAccept && pluginName === Const.AUTH_PLUGIN_LEGACY) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] auth: SRP+Legacy_Auth chained-auth (proto %d), sending Legacy_Auth credentials t=%dms', + cnx._pendingAccept.protocolVersion, + cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } + var legacyAuthData = crypt.crypt(cnx.options.password, Const.LEGACY_AUTH_SALT).substring(2); + cnx.sendOpContAuth(legacyAuthData, Const.DEFAULT_ENCODING, pluginName); + return; // wait for op_accept + } + if (!cnx.options.pluginName) { if (cnx.accept && cnx.accept.pluginName === pluginName) { // Erreur plugin not able to connect @@ -1852,6 +1965,13 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { } // Server sent op_cont_auth but we don't know how to handle it. + if (process.env.FIREBIRD_DEBUG) { + console.warn('[fb-debug] auth: op_cont_auth unhandled plugin=%s pendingAccept=%s options.plugin=%s t=%dms', + pluginName, + cnx._pendingAccept ? cnx._pendingAccept.pluginName : 'none', + cnx.options.pluginName || 'none', + cnx._authStartTime ? Date.now() - cnx._authStartTime : -1); + } return cb(new Error('Unhandled server op_cont_auth for plugin: ' + pluginName)); case Const.op_crypt_key_callback: // Database encryption key callback @@ -1871,10 +1991,64 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) { // Don't call cb - wait for next operation (likely op_response or another op_crypt_key_callback) return; + case Const.op_event: + // op_event may occasionally arrive on the main connection + // (e.g. Firebird routing an async notification here instead of + // the dedicated aux socket). Consume all its fields so the + // buffer position advances correctly, then signal the data + // handler to skip queue manipulation for this frame. + // + // Firebird wire protocol – op_event payload (remote protocol): + // p_event_database : Int32 – database handle + // p_event_items : Array – event parameter block (EPB) + // p_event_ast : Int64 – AST routine pointer (0 for remote) + // p_event_rid : Int32 – remote event ID + { + const evtDb = data.readInt(); // p_event_database + data.readArray(); // p_event_items (EPB buffer) + data.readInt64(); // p_event_ast + const evtRid = data.readInt(); // p_event_rid + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] op_event on main connection: db=%d rid=%d (consumed, not queued)', evtDb, evtRid); + } + } + return cb(null, { _isOpEvent: true }); + case Const.op_response_piggyback: + // Firebird 5 (Protocol 16/17) sends op_response_piggyback (72) + // as an unsolicited cleanup notification after certain operations + // (e.g. after the EventConnection aux socket is torn down). + // It has the same wire layout as op_response but does NOT + // correspond to any queued client request. Parse and discard it + // so that the xdr buffer position advances correctly, then signal + // the data handler to skip queue manipulation. + // + // Wire layout (identical to op_response): + // handle : Int32 + // object : Quad (2x Int32) + // data : Array + // status : status-vector ending with isc_arg_end + parseOpResponse(data, {}, function(err) { + if (process.env.FIREBIRD_DEBUG) { + if (err) { + console.warn('[fb-debug] op_response_piggyback parse error:', err.message); + } else { + console.log('[fb-debug] op_response_piggyback consumed (unsolicited Firebird 5 cleanup)'); + } + } + }); + return cb(null, { _isOpEvent: true }); default: + if (process.env.FIREBIRD_DEBUG) { + console.warn('[fb-debug] unknown opcode=%d at pos=%d buflen=%d queue=%d', + r, data.pos, data.buffer.length, cnx && cnx._queue ? cnx._queue.length : 0); + } return cb(new Error('Unexpected:' + r)); } } catch (err) { + if (process.env.FIREBIRD_DEBUG) { + console.warn('[fb-debug] decodeResponse exception: %s (RangeError=%s) pos=%d buflen=%d', + err.message, err instanceof RangeError, data.pos, data.buffer.length); + } if (err instanceof RangeError) { return cb(err); } diff --git a/lib/wire/database.js b/lib/wire/database.js index b4f64fb..bd2e4d8 100644 --- a/lib/wire/database.js +++ b/lib/wire/database.js @@ -9,6 +9,35 @@ const FbEventManager = require('./fbEventManager'); * * Database * + * Driver events (emitted on the Database instance itself) + * -------------------------------------------------------- + * These are synchronous notifications from the driver about connection-level + * operations. Subscribe with db.on(eventName, handler). + * + * 'attach' – fired synchronously after the user callback returns, + * once the database is attached. + * 'detach' – fired when the database connection is detached. + * 'reconnect' – fired after the driver successfully reconnects a dropped socket. + * 'error' – fired for connection-level errors (socket errors, closed + * connection attempts, etc.). + * 'transaction' – fired when a transaction is started (before server response), + * with the resolved transaction options object as the argument. + * 'commit' – fired when a transaction commit is sent (before server response). + * 'rollback' – fired when a transaction rollback is sent (before server response). + * 'query' – fired with the SQL string when a statement is prepared. + * 'row' – fired with each individual row as it is decoded. + * 'result' – fired with the full rows array once all rows are fetched. + * + * Firebird database events (POST_EVENT) + * ---------------------------------------- + * Real Firebird asynchronous notifications triggered by POST_EVENT inside + * PSQL triggers or stored procedures are handled through a separate channel: + * 1. Call db.attachEvent(callback) to obtain a FbEventManager instance. + * 2. Call evtmgr.registerEvent(names, callback) to subscribe to event names. + * 3. Listen for evtmgr.on('post_event', (name, count) => {}) to receive them. + * 4. Call evtmgr.unregisterEvent(names, callback) to cancel a subscription. + * 5. Call evtmgr.close(callback) when done to release the aux connection. + * ***************************************/ function readblob(blob, callback) { @@ -292,13 +321,23 @@ class Database extends Events.EventEmitter { attachEvent(callback) { var self = this; + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: calling auxConnection, eventid=%d queue=%d', self.eventid, self.connection._queue.length); + } this.connection.auxConnection(function (err, socket_info) { if (err) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: auxConnection error:', err.message); + } doError(err, callback); return; } + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: auxConnection ok, connecting to aux port %s:%d', socket_info.host, socket_info.port); + } + const host = (socket_info.host === '0.0.0.0' || socket_info.host === '::') ? self.connection.options.host : socket_info.host; @@ -306,16 +345,27 @@ class Database extends Events.EventEmitter { const eventConnection = new EventConnection( host, socket_info.port, function(err) { if (err) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: EventConnection error:', err.message); + } doError(err, callback); return; } + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: EventConnection connected, creating FbEventManager eventid=%d', self.eventid); + } + const evt = new FbEventManager(self, eventConnection, self.eventid++, function (err) { if (err) { doError(err, callback); return; } + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] Database.attachEvent: FbEventManager ready, eventid=%d', evt.eventid); + } + callback(err, evt); }); }, self); diff --git a/lib/wire/fbEventManager.js b/lib/wire/fbEventManager.js index 6891c28..f30fc12 100644 --- a/lib/wire/fbEventManager.js +++ b/lib/wire/fbEventManager.js @@ -1,3 +1,69 @@ +// FbEventManager – Firebird POST_EVENT subscription manager +// +// State machine overview +// ────────────────────── +// +// ┌──────────────────────────────────────────────────────────────────────┐ +// │ FbEventManager states │ +// └──────────────────────────────────────────────────────────────────────┘ +// +// attachEvent() +// │ +// ▼ +// ┌─────────────────────────────────────────────────────────┐ +// │ IDLE │ +// │ _hasActiveSubscription = false │ +// │ events = {} │ +// │ eventcallback = loop fn (set but subscription absent) │ +// └──────────────┬────────────────────────────┬────────────┘ +// │ registerEvent([...]) │ close() +// ▼ ▼ +// ┌──────────────────────────┐ ┌───────────────────────┐ +// │ SUBSCRIBING │ │ CLOSING │ +// │ queEvents() sent │ │ endAndWaitForClose() │ +// │ waiting for op_response │ │ sock.end() + wait │ +// └──────────┬───────────────┘ └───────────┬───────────┘ +// │ op_response ok │ 'close' event +// ▼ ▼ +// ┌──────────────────────────┐ ┌───────────────────────┐ +// │ SUBSCRIBED │ │ CLOSED / DONE │ +// │ _hasActiveSubscription │ │ eventconnection gone │ +// │ = true │ └───────────────────────┘ +// │ eventcallback active │ +// └───┬───────────┬──────────┘ +// │ │ +// │ op_event │ unregisterEvent() (all removed) or close() +// │ received │ +// │ ▼ +// │ ┌──────────────────────────────────────────────────┐ +// │ │ CANCELLING │ +// │ │ closeEvents() sent (op_cancel_events) │ +// │ │ waiting for op_response │ +// │ └──────────┬───────────────────────────────────────┘ +// │ │ op_response ok +// │ ▼ +// │ ┌──────────────────────────────────────────────────┐ +// │ │ IDLE (or CLOSING if called from close()) │ +// │ └──────────────────────────────────────────────────┘ +// │ +// │ emit('post_event', name, count) +// └──────────────────────┐ +// ▼ +// loop() → SUBSCRIBING (re-subscribe) +// +// Wire-protocol messages on the MAIN connection +// ────────────────────────────────────────────── +// Client → Server : op_connect_request (attachEvent / auxConnection) +// Server → Client : op_response (socket address of AUX port) +// Client → Server : op_que_events (registerEvent / loop) +// Server → Client : op_response (confirms event ID) +// Client → Server : op_cancel_events (unregisterEvent / close) +// Server → Client : op_response +// +// Asynchronous notifications on the AUX (EventConnection) socket +// ─────────────────────────────────────────────────────────────── +// Server → Client : op_event (fired by Firebird POST_EVENT trigger) + const Events = require('events'); const { doError } = require('../callback'); @@ -15,11 +81,67 @@ class FbEventManager extends Events.EventEmitter { this._createEventLoop(callback); } + /** + * Returns a snapshot of the current state for debugging. + * Useful for tracing the state machine during development. + * + * Stable states: 'IDLE', 'SUBSCRIBED', 'CLOSED'. + * Transient states (SUBSCRIBING, CANCELLING, CLOSING) occur while waiting + * for op_response on the main connection or for the socket to close; they + * are not tracked with dedicated flags to keep the implementation simple, + * but they can be inferred: if the socket is open and _hasActiveSubscription + * disagrees with what the caller expects, a transitional operation is in + * progress. + * + * @returns {{ + * state: string, + * hasActiveSubscription: boolean, + * registeredEvents: Object, + * eventId: number, + * isEventConnectionOpen: boolean, + * isDatabaseConnectionClosed: boolean + * }} + */ + getState() { + const evtConnOpen = this.eventconnection + ? !this.eventconnection._isClosed + : false; + const dbConnClosed = this.db.connection + ? this.db.connection._isClosed + : true; + + // Derive a human-readable stable-state label. + // Transitional states (SUBSCRIBING / CANCELLING / CLOSING) are not + // individually flagged; callers that need finer granularity can + // inspect hasActiveSubscription and isEventConnectionOpen together. + let state; + if (dbConnClosed || !evtConnOpen) { + state = 'CLOSED'; + } else if (this._hasActiveSubscription) { + state = 'SUBSCRIBED'; + } else { + state = 'IDLE'; + } + + return { + state, + hasActiveSubscription: this._hasActiveSubscription, + registeredEvents: Object.assign({}, this.events), + eventId: this.eventid, + isEventConnectionOpen: evtConnOpen, + isDatabaseConnectionClosed: dbConnClosed, + }; + } + _createEventLoop(callback) { var self = this; var cnx = this.db.connection; this.eventconnection.emgr = this; + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] FbEventManager._createEventLoop: eventid=%d', self.eventid); + } + // Re-subscribe after each op_event notification so that further // trigger fires continue to be delivered. function loop() { @@ -134,34 +256,52 @@ class FbEventManager extends Events.EventEmitter { close(callback) { var self = this; + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] FbEventManager.close() called, _hasActiveSubscription=%s eventid=%d', self._hasActiveSubscription, self.eventid); + } + // Prevent the event loop from re-queuing on stale op_event notifications // that may arrive between closeEvents and socket.end() self.eventconnection.eventcallback = null; - // Wait for the event socket to fully close before invoking the callback. - // This prevents a race condition on Firebird 4 where the server is still - // processing the previous event connection's FIN when the next queEvents - // arrives on the main connection, causing the op_response to be dropped. + // Gracefully close the event socket using a FIN (end()) rather than a RST + // (destroy()), then wait for the 'close' event which confirms both sides have + // exchanged FINs. This gives Firebird (all versions 3/4/5) time to fully + // process the previous event connection's teardown before the next + // op_connect_request or op_que_events arrives on the main connection. + // destroy() (RST) is faster but causes Firebird 3 to get confused on subsequent + // queEvents calls – the server internally fails on the RST error and does not + // clean up its event state in time for the next subscription request. + // A 200 ms safety timer fires as a fallback if Firebird never sends its FIN. function endAndWaitForClose(cb) { var sock = self.eventconnection && self.eventconnection._socket; if (!sock || sock.destroyed) { + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] endAndWaitForClose: socket already destroyed, calling back immediately'); + } if (cb) cb(); return; } var fired = false; var timer; - function done() { + function done(source) { if (!fired) { fired = true; clearTimeout(timer); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] endAndWaitForClose done() via %s, eventid=%d', source, self.eventid); + } if (cb) cb(); } } - sock.once('close', done); + sock.once('close', function() { done('close-event'); }); + if (process.env.FIREBIRD_DEBUG) { + console.log('[fb-debug] endAndWaitForClose: calling sock.end(), eventid=%d sock.destroyed=%s', self.eventid, sock.destroyed); + } sock.end(); - // Safety fallback: if 'close' never fires (e.g. Firebird does not - // send its FIN), resolve after 200 ms so tests don't hang. - timer = setTimeout(done, 200); + // Safety fallback: if Firebird never sends its FIN (e.g. an error + // occurs on the server side), resolve after 200 ms so tests don't hang. + timer = setTimeout(function() { done('200ms-timer'); }, 200); } if (!self._hasActiveSubscription) { diff --git a/test/index.js b/test/index.js index fa76917..9ee76af 100644 --- a/test/index.js +++ b/test/index.js @@ -66,7 +66,132 @@ describe('Connection', function () { }); }); -describe('Events', function () { +describe('Driver Events', function () { + // Driver events are notifications emitted directly on the Database object + // for connection-level operations: attach, detach, transaction, commit, + // rollback, query, row, result, error, and reconnect. + // These are distinct from Firebird database POST_EVENT notifications, which + // are received via db.attachEvent() and the FbEventManager class. + + let db; + + beforeAll(async function () { + db = await fromCallback(cb => Firebird.attachOrCreate(config, cb)); + }); + + afterAll(async function () { + if (db) { + await fromCallback(cb => db.detach(cb)).catch(() => {}); + } + }); + + it('should emit "attach" event when a database connection is established', async function () { + // The 'attach' event fires synchronously after the Firebird.attach + // user callback returns (same call-stack tick as the socket data + // handler). Registering the listener inside the user callback is + // sufficient – it is already in place when the event is emitted. + let adb; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('attach event timed out')), 5000); + Firebird.attach(config, function (err, db) { + if (err) { clearTimeout(timer); reject(err); return; } + adb = db; + db.once('attach', () => { clearTimeout(timer); resolve(); }); + }); + }); + if (adb) await fromCallback(cb => adb.detach(cb)); + }); + + it('should emit "detach" event when a database connection is closed', async function () { + const adb = await fromCallback(cb => Firebird.attach(config, cb)); + + const detachPromise = new Promise((resolve) => { adb.once('detach', resolve); }); + await fromCallback(cb => adb.detach(cb)); + await detachPromise; + }); + + it('should emit "transaction" event when a transaction starts', async function () { + const txPromise = new Promise((resolve) => { db.once('transaction', resolve); }); + const transaction = await fromCallback(cb => db.startTransaction(cb)); + await txPromise; + await fromCallback(cb => transaction.commit(cb)); + }); + + it('should emit "commit" event when a transaction is committed', async function () { + const transaction = await fromCallback(cb => db.startTransaction(cb)); + const commitPromise = new Promise((resolve) => { db.once('commit', resolve); }); + await fromCallback(cb => transaction.commit(cb)); + await commitPromise; + }); + + it('should emit "rollback" event when a transaction is rolled back', async function () { + const transaction = await fromCallback(cb => db.startTransaction(cb)); + const rollbackPromise = new Promise((resolve) => { db.once('rollback', resolve); }); + await fromCallback(cb => transaction.rollback(cb)); + await rollbackPromise; + }); + + it('should emit "query" event with the SQL string when a query is executed', async function () { + const sql = 'SELECT 1 FROM RDB$DATABASE'; + const queryPromise = new Promise((resolve) => { db.once('query', resolve); }); + await fromCallback(cb => db.query(sql, cb)); + const emittedSql = await queryPromise; + assert.equal(emittedSql, sql); + }); + + it('should emit "row" event for each row returned by a query', async function () { + const rowEvents = []; + const rowHandler = (row) => rowEvents.push(row); + db.on('row', rowHandler); + try { + await fromCallback(cb => db.query('SELECT * FROM RDB$DATABASE', cb)); + } finally { + db.removeListener('row', rowHandler); + } + assert.ok(rowEvents.length > 0, 'Expected at least one row event'); + }); + + it('should emit "result" event with the full result array when a query completes', async function () { + const resultPromise = new Promise((resolve) => { db.once('result', resolve); }); + await fromCallback(cb => db.query('SELECT * FROM RDB$DATABASE', cb)); + const rows = await resultPromise; + assert.ok(Array.isArray(rows), 'result event should emit an array'); + assert.ok(rows.length > 0, 'result array should not be empty'); + }); + + it('should emit "error" event on connection-level errors', async function () { + const adb = await fromCallback(cb => Firebird.attach(config, cb)); + + const errorPromise = new Promise((resolve) => { adb.once('error', resolve); }); + + // Trigger the throwClosed() code-path in Connection, which emits 'error' + // on the Database object and then calls the provided callback with the + // same error. We absorb the callback error to avoid an unhandled rejection. + // This is the standard driver path for connection-level errors (socket + // closed, etc.) and avoids the complexity of destroying a live socket. + adb.connection._isClosed = true; + adb.connection.startTransaction(() => {}); + + const err = await errorPromise; + assert.ok(err instanceof Error); + assert.match(err.message, /Connection is closed/); + + // Restore and clean up + adb.connection._isClosed = false; + await fromCallback(cb => adb.detach(cb)); + }); +}); + +describe('Firebird Database Events (POST_EVENT)', function () { + // Real Firebird database events are asynchronous notifications triggered + // by POST_EVENT calls inside PSQL triggers or stored procedures. + // They are accessed via db.attachEvent() which returns a FbEventManager. + // Use FbEventManager.registerEvent() to subscribe to named events and + // listen for the 'post_event' emitter event to receive notifications. + // + // NOTE: Full POST_EVENT reception (op_que_events wire protocol) is not yet + // implemented – the "should receive an event" test is skipped until complete. + const table_sql = 'CREATE TABLE TEST_EVENTS (ID INT NOT NULL CONSTRAINT PK_EVENTS PRIMARY KEY, NAME VARCHAR(50))'; let db; @@ -92,22 +217,47 @@ describe('Events', function () { } }); - it("should create a connection", async function () { + it('should create an event manager connection and verify initial state via getState()', async function () { + const evtmgr = await fromCallback(cb => db.attachEvent(cb)); + + // After attachEvent: IDLE – EventConnection open, no active subscription + const idleState = evtmgr.getState(); + assert.equal(idleState.state, 'IDLE'); + assert.equal(idleState.hasActiveSubscription, false); + assert.deepStrictEqual(idleState.registeredEvents, {}); + assert.equal(idleState.isEventConnectionOpen, true); + assert.equal(idleState.isDatabaseConnectionClosed, false); + + // SUBSCRIBED state (post registerEvent / op_que_events) is not tested here + // because the full POST_EVENT wire protocol is not yet implemented. + // See the skip block below ('should register a named event subscription'). + + await fromCallback(cb => evtmgr.close(cb)); + }); + + it.skip('should register a named event subscription', async function () { + // TODO: registerEvent sends op_que_events; Firebird 3 never responds on the + // main connection because the full POST_EVENT wire protocol is not yet + // implemented – skip until complete. const evtmgr = await fromCallback(cb => db.attachEvent(cb)); + await fromCallback(cb => evtmgr.registerEvent(['TRG_TEST_EVENTS'], cb)); await fromCallback(cb => evtmgr.close(cb)); }); - it("should register an event", async function () { + it.skip('should unregister a named event subscription', async function () { + // TODO: unregisterEvent sends op_cancel_events; skip until the full + // POST_EVENT wire protocol is verified end-to-end. const evtmgr = await fromCallback(cb => db.attachEvent(cb)); - await fromCallback(cb => evtmgr.registerEvent(["TRG_TEST_EVENTS"], cb)); + await fromCallback(cb => evtmgr.registerEvent(['TRG_TEST_EVENTS'], cb)); + await fromCallback(cb => evtmgr.unregisterEvent(['TRG_TEST_EVENTS'], cb)); await fromCallback(cb => evtmgr.close(cb)); }); - it.skip("should receive an event", async function () { + it.skip('should receive a post_event notification when the database fires an event', async function () { // TODO: Real Firebird database events (POST_EVENT / op_que_events) are not // fully implemented yet – skip until the feature is complete. const evtmgr = await fromCallback(cb => db.attachEvent(cb)); - await fromCallback(cb => evtmgr.registerEvent(["TRG_TEST_EVENTS"], cb)); + await fromCallback(cb => evtmgr.registerEvent(['TRG_TEST_EVENTS'], cb)); const eventPromise = new Promise((resolve, reject) => { evtmgr.on('post_event', (name, count) => { @@ -161,7 +311,7 @@ describe('Auth plugin connection', function () { describe('FB3 - Srp', function () { // Must be test with firebird 3.0 or higher with Srp enable on server - it('should attach with srp plugin', async function () { + it('should attach with srp plugin', { timeout: 120000 }, async function () { const db = await fromCallback(cb => Firebird.attachOrCreate(Config.extends(config, { pluginName: Firebird.AUTH_PLUGIN_SRP }), cb)); await fromCallback(cb => db.detach(cb)); }); diff --git a/test/mock-server.js b/test/mock-server.js new file mode 100644 index 0000000..068bf76 --- /dev/null +++ b/test/mock-server.js @@ -0,0 +1,1080 @@ +'use strict'; + +/** + * Offline wire-protocol tests using an in-process mock Firebird server. + * + * These tests do NOT require a real Firebird server. A minimal TCP server is + * started on a random loopback port; it speaks enough of the Firebird wire + * protocol to exercise the Connection class end-to-end: + * + * Client Mock Server + * ------ ----------- + * op_connect ──────────────────────▶ + * ◀────────────────────── op_accept_data (is_authenticated=1) + * op_attach ──────────────────────▶ + * ◀────────────────────── op_response (handle=42) + * op_detach ──────────────────────▶ + * ◀────────────────────── op_response (success) + * ◀────────────────────── socket.end() + * + * SRP auth protocol sequence (FB3/FB4/FB5, wireCrypt disabled): + * + * Client Mock Server (SRP) + * ------ ----------------- + * op_connect (plugin=Srp, A=clientPublicKey) + * ──────────────────────▶ + * ◀────────────────────── op_cond_accept (salt, B=serverPublicKey) + * srp.clientProof → M1 + * op_cont_auth (M1) + * ──────────────────────▶ + * ◀────────────────────── op_cont_auth (M2, server proof) ──┐ + * ◀────────────────────── op_accept (protocolVersion) │ + * [wireCrypt=DISABLE → no op_crypt] │ + * op_attach ──────────────────────▶ │ + * ◀────────────────────── op_response (handle=42) │ + * op_detach ──────────────────────▶ │ + * ◀────────────────────── op_response (success) │ + * ◀────────────────────── socket.end() │ + * │ + * NOTE: client does not validate M2 content; any bytes are accepted ◀───┘ + * + * Protocol version support (Firebird compatibility): + * FB3 → PROTOCOL_VERSION14 (0x800E = 32782) + * FB4 → PROTOCOL_VERSION16 (0x8010 = 32784) + * FB5 → PROTOCOL_VERSION17 (0x8011 = 32785) + * + * Additional tests inject op_event and op_response_piggyback frames between + * real responses to verify that these unsolicited frames are consumed without + * corrupting the response queue. + * + * XDR round-trip tests exercise XdrWriter / XdrReader encode/decode cycles + * without any network involvement. + */ + +const net = require('net'); +const assert = require('assert'); + +const Const = require('../lib/wire/const'); +const {XdrWriter, XdrReader, BlrWriter} = require('../lib/wire/serialize'); +const srp = require('../lib/srp'); +const Firebird = require('../lib'); + +// --------------------------------------------------------------------------- +// Wire-protocol response builders +// --------------------------------------------------------------------------- + +/** + * Build an op_accept_data frame (opcode 94). + * is_authenticated=1 means no SRP exchange is needed – the connection + * proceeds directly to op_attach. + */ +function buildOpAcceptData(pluginName) { + const w = new XdrWriter(128); + w.addInt(Const.op_accept_data); + w.addInt(Const.PROTOCOL_VERSION14); + w.addInt(Const.ARCHITECTURE_GENERIC); + w.addInt(Const.ptype_lazy_send); + w.addInt(0); // auth data array len=0 + w.addString(pluginName || 'Legacy_Auth', 'utf8'); // plugin name + w.addInt(1); // is_authenticated=1 + w.addString('', 'utf8'); // keys="" + return w.getData(); +} + +/** + * Build a minimal op_response frame (opcode 9). + * handle : database / statement handle returned to the client + */ +function buildOpResponse(handle) { + const w = new XdrWriter(32); + w.addInt(Const.op_response); + w.addInt(handle); + w.addInt(0); w.addInt(0); // oid (quad: high + low) + w.addInt(0); // data array length = 0 + w.addInt(Const.isc_arg_end); // status vector terminator + return w.getData(); +} + +/** + * Build a stray op_event frame (opcode 52). + * When this arrives on the main connection the driver must consume it + * without touching the response queue. + */ +function buildOpEvent(dbHandle, eventRid) { + const w = new XdrWriter(64); + w.addInt(Const.op_event); + w.addInt(dbHandle); + w.addInt(0); // EPB array length = 0 + w.addInt64(0); // AST pointer = 0 + w.addInt(eventRid || 1); + return w.getData(); +} + +/** + * Build an op_response_piggyback frame (opcode 72). + * Firebird 5 sends this as an unsolicited cleanup notification. + */ +function buildOpResponsePiggyback() { + const w = new XdrWriter(32); + w.addInt(Const.op_response_piggyback); + w.addInt(0); // handle + w.addInt(0); w.addInt(0); // oid + w.addInt(0); // data array length = 0 + w.addInt(Const.isc_arg_end); // status vector terminator + return w.getData(); +} + +// --------------------------------------------------------------------------- +// SRP wire-protocol frame builders +// --------------------------------------------------------------------------- + +/** + * Build an op_cond_accept frame with SRP challenge data (opcode 98). + * + * Wire format: + * int opcode(98) + * int protocolVersion + * int architecture + * int minType + * XDR array: BLR auth-data = [uint16LE saltLen][salt hex bytes] + * [uint16LE keyLen][serverB hex bytes] + * XDR string: "Srp" + * int: is_authenticated = 0 + * XDR string: "" (keys) + * + * @param {number} protocolVersion e.g. Const.PROTOCOL_VERSION14/16/17 + * @param {string} salt Hex-encoded salt string (e.g. 64 hex chars) + * @param {object} serverB BigInt server public key B + */ +function buildOpCondAcceptSRP(protocolVersion, salt, serverB) { + const bHex = srp.hexPad(serverB.toString(16)); + const saltHex = salt; + + // Build the BLR auth-data buffer: [u16LE saltLen][salt][u16LE keyLen][B] + const authBlr = new BlrWriter(4 + saltHex.length + 4 + bHex.length); + authBlr.addWord(saltHex.length); + authBlr.ensure(saltHex.length); + authBlr.buffer.write(saltHex, authBlr.pos, 'utf8'); + authBlr.pos += saltHex.length; + authBlr.addWord(bHex.length); + authBlr.ensure(bHex.length); + authBlr.buffer.write(bHex, authBlr.pos, 'utf8'); + authBlr.pos += bHex.length; + + const w = new XdrWriter(256 + authBlr.pos); + w.addInt(Const.op_cond_accept); + w.addInt(protocolVersion); + w.addInt(Const.ARCHITECTURE_GENERIC); + w.addInt(Const.ptype_lazy_send); + w.addBlr(authBlr); // XDR array: BLR auth data + w.addString('Srp', 'utf8'); // plugin name + w.addInt(0); // is_authenticated = 0 + w.addString('', 'utf8'); // keys = "" + return w.getData(); +} + +/** + * Build the server-side op_cont_auth frame (opcode 92) carrying server proof M2. + * + * Wire format: + * int opcode(92) + * XDR array: M2 auth data bytes (may be empty for mock purposes) + * XDR string: plugin name "Srp" + * XDR string: plist "" + * XDR string: pkey "" + * + * NOTE: The node-firebird client does NOT validate M2 content; it just waits + * for the subsequent op_accept. An empty array is therefore sufficient for + * offline testing. + * + * @param {string} [m2Data] Optional UTF-8 string payload for the M2 field. + * Omit (or pass undefined) for an empty array. + */ +function buildOpContAuthServer(m2Data, pluginName) { + const w = new XdrWriter(128); + w.addInt(Const.op_cont_auth); + if (m2Data) { + // Write M2 as a length-prefixed XDR array (UTF-8 encoded) + const m2Buf = Buffer.from(m2Data, 'utf8'); + const authBlr = new BlrWriter(m2Buf.length + 4); + authBlr.ensure(m2Buf.length); + m2Buf.copy(authBlr.buffer, authBlr.pos); + authBlr.pos += m2Buf.length; + w.addBlr(authBlr); + } else { + w.addInt(0); // empty M2 array (length = 0) + } + w.addString(pluginName || 'Srp', 'utf8'); // plugin name + w.addString('', 'utf8'); // plist + w.addString('', 'utf8'); // pkey + return w.getData(); +} + +/** + * Build an op_accept frame (opcode 3) – sent after SRP mutual auth completes. + * + * Wire format: + * int opcode(3) + * int protocolVersion + * int architecture + * int minType + */ +function buildOpAccept(protocolVersion) { + const w = new XdrWriter(16); + w.addInt(Const.op_accept); + w.addInt(protocolVersion || Const.PROTOCOL_VERSION14); + w.addInt(Const.ARCHITECTURE_GENERIC); + w.addInt(Const.ptype_lazy_send); + return w.getData(); +} + +// --------------------------------------------------------------------------- +// Wire-protocol parsers (used by mock server to inspect client messages) +// --------------------------------------------------------------------------- + +/** + * Parse an op_connect message to extract the auth plugin name and the client's + * SRP public key A (CNCT_specific_data BLR tag). + * + * op_connect XDR layout: + * int op_connect (1) + * int op_attach (19) + * int CONNECT_VERSION3 + * int ARCHITECTURE_GENERIC + * XDR string: database path + * int: protocol count + * XDR array: BLR data ← parsed here + * [protocol entries: 5 ints each] + * + * BLR tag format: + * CNCT_login(9), CNCT_plugin_name(8), CNCT_plugin_list(10): + * byte tag, byte len, + * CNCT_specific_data(7) – may be multi-chunk: + * byte tag, byte totalLen, byte step, + * CNCT_client_crypt(11), CNCT_user(1), CNCT_host(4), CNCT_user_verification(6): + * byte tag, byte len, + * + * @param {Buffer} buf Raw TCP bytes starting at the op_connect opcode. + * @returns {{ pluginName, specificData, login }} or null on parse error. + */ +function parseOpConnect(buf) { + try { + const r = new XdrReader(buf); + r.readInt(); // op_connect + r.readInt(); // op_attach + r.readInt(); // CONNECT_VERSION3 + r.readInt(); // ARCHITECTURE_GENERIC + r.readString('utf8'); // database path + r.readInt(); // protocol count + const blrData = r.readArray(); + if (!blrData) return { pluginName: '', specificData: '', login: '' }; + + const result = { pluginName: '', specificData: '', login: '' }; + const specificParts = {}; + let pos = 0; + + while (pos < blrData.length) { + const tag = blrData[pos++]; + if (pos >= blrData.length) break; + + switch (tag) { + case Const.CNCT_plugin_name: { // 8 + if (pos >= blrData.length) break; + const len = blrData[pos++]; + if (pos + len > blrData.length) break; + result.pluginName = blrData.slice(pos, pos + len).toString('utf8'); + pos += len; + break; + } + case Const.CNCT_login: { // 9 + if (pos >= blrData.length) break; + const len = blrData[pos++]; + if (pos + len > blrData.length) break; + result.login = blrData.slice(pos, pos + len).toString('utf8'); + pos += len; + break; + } + case Const.CNCT_plugin_list: { // 10 + if (pos >= blrData.length) break; + const len = blrData[pos++]; + if (pos + len > blrData.length) break; + pos += len; + break; + } + case Const.CNCT_specific_data: { // 7 – multiblock + if (pos >= blrData.length) break; + const totalLen = blrData[pos++]; // includes step byte + if (totalLen < 1) break; // must include at least the step byte + const chunkLen = totalLen - 1; + if (pos >= blrData.length) break; // need at least the step byte + const step = blrData[pos++]; + if (pos + chunkLen > blrData.length) break; // bounds check + specificParts[step] = blrData.slice(pos, pos + chunkLen).toString('utf8'); + pos += chunkLen; + break; + } + case Const.CNCT_client_crypt: // 11 + case Const.CNCT_user: // 1 + case Const.CNCT_host: // 4 + case Const.CNCT_user_verification: { // 6 + if (pos >= blrData.length) break; + const len = blrData[pos++]; + if (pos + len > blrData.length) break; // bounds check + pos += len; + break; + } + default: + // Unknown tag – stop scanning BLR + pos = blrData.length; + break; + } + } + + // Reassemble CNCT_specific_data chunks in order + const steps = Object.keys(specificParts).sort((a, b) => Number(a) - Number(b)); + result.specificData = steps.map(s => specificParts[s]).join(''); + return result; + } catch (e) { + return null; + } +} + +/** + * Parse an op_cont_auth message to extract the client's M1 proof and plugin name. + * + * op_cont_auth XDR layout: + * int op_cont_auth (92) + * XDR array: M1 proof bytes (UTF-8 encoded hex string, e.g. "3f9a...") + * XDR string: plugin name + * XDR string: plugin list + * int: keys (0) + * + * The M1 proof is sent by the client as a hex-character string encoded as + * UTF-8 bytes (so each byte is an ASCII hex character 0-9/a-f). Reading it + * with `toString('utf8')` therefore produces the hex string directly. + * + * @param {Buffer} buf Raw TCP bytes starting at the op_cont_auth opcode. + * @returns {{ m1Hex, pluginName }} or null on parse error. + */ +function parseOpContAuth(buf) { + try { + const r = new XdrReader(buf); + r.readInt(); // op_cont_auth + const authDataBuf = r.readArray(); // M1 proof as UTF-8 hex characters + const pluginName = r.readString('utf8'); + // The authDataBuf contains UTF-8 bytes of the hex string (e.g. "3f9a...") + const m1Hex = authDataBuf ? authDataBuf.toString('utf8') : ''; + return { m1Hex, pluginName }; + } catch (e) { + return null; + } +} + +// --------------------------------------------------------------------------- +// Mock server helpers +// --------------------------------------------------------------------------- + +/** + * Start a TCP server on a random loopback port. + * onClient(socket) is called for each accepted connection. + * Returns { server, port }. + */ +function startMockServer(onClient) { + return new Promise((resolve, reject) => { + const server = net.createServer(onClient); + server.on('error', reject); + server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port })); + }); +} + +function stopMockServer(server) { + return new Promise(resolve => server.close(resolve)); +} + +/** + * Simple request dispatcher for the mock server. + * Buffers incoming bytes and dispatches on the first 4-byte opcode. + * handler(socket, opcode) is called whenever a new opcode is detected. + */ +function makeDispatcher(socket, handler) { + let buf = Buffer.alloc(0); + socket.on('data', chunk => { + buf = Buffer.concat([buf, chunk]); + while (buf.length >= 4) { + const opcode = buf.readInt32BE(0); + // We don't parse the full message length; assume each TCP write + // from the client corresponds to exactly one logical message + // (true on loopback for all frames we care about). + buf = Buffer.alloc(0); + handler(socket, opcode); + } + }); +} + +/** + * Full-buffer dispatcher: passes the entire accumulated buffer to the handler + * so the handler can parse variable-length message fields (e.g. SRP auth data). + * + * handler(socket, opcode, fullBuf) must return the number of bytes consumed, + * or 0 to wait for more data. On loopback each client write is one logical + * message, so handlers may safely return buf.length to consume everything. + */ +function makeFullDispatcher(socket, handler) { + let buf = Buffer.alloc(0); + socket.on('data', chunk => { + buf = Buffer.concat([buf, chunk]); + while (buf.length >= 4) { + const opcode = buf.readInt32BE(0); + const consumed = handler(socket, opcode, buf); + if (consumed <= 0) break; // need more data + buf = buf.slice(consumed); + } + }); +} + +// --------------------------------------------------------------------------- +// Helper: attach via mock server, run test fn, then detach +// --------------------------------------------------------------------------- + +async function withMockAttach(port, fn) { + const db = await new Promise((resolve, reject) => { + Firebird.attach({ + host: '127.0.0.1', + port, + database: '/mock/test.fdb', + user: 'SYSDBA', + password: 'masterkey', + }, (err, d) => (err ? reject(err) : resolve(d))); + }); + + try { + await fn(db); + } finally { + await new Promise((resolve, reject) => db.detach(e => (e ? reject(e) : resolve()))); + } +} + +/** + * Attach via mock server using SRP plugin with wire-crypt disabled. + * Wire-crypt must be disabled because the mock server does not implement + * the Arc4 stream cipher – it responds in plaintext throughout. + */ +async function withMockSrpAttach(port) { + return new Promise((resolve, reject) => { + Firebird.attach({ + host: '127.0.0.1', + port, + database: '/mock/test.fdb', + user: 'SYSDBA', + password: 'masterkey', + pluginName: Const.AUTH_PLUGIN_SRP, + wireCrypt: Const.WIRE_CRYPT_DISABLE, + }, (err, d) => (err ? reject(err) : resolve(d))); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Firebird Wire Protocol – offline mock-server tests', function () { + + // ----------------------------------------------------------------------- + // 1. Full attach / detach cycle (happy path) + // ----------------------------------------------------------------------- + + it('should complete a full attach/detach cycle via mock server', async function () { + const { server, port } = await startMockServer(socket => { + makeDispatcher(socket, (s, opcode) => { + if (opcode === Const.op_connect) { + s.write(buildOpAcceptData('Legacy_Auth')); + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + s.write(buildOpResponse(42)); + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + } + }); + }); + + try { + await withMockAttach(port, async db => { + assert.ok(db, 'db object should be returned'); + }); + } finally { + await stopMockServer(server); + } + }); + + // ----------------------------------------------------------------------- + // 2. op_event on main connection must not corrupt the response queue + // ----------------------------------------------------------------------- + + it('should ignore a stray op_event on the main connection without blocking', async function () { + const { server, port } = await startMockServer(socket => { + let attached = false; + makeDispatcher(socket, (s, opcode) => { + if (opcode === Const.op_connect) { + s.write(buildOpAcceptData('Legacy_Auth')); + } else if ((opcode === Const.op_attach || opcode === Const.op_create) && !attached) { + attached = true; + // Inject a stray op_event BEFORE the real op_response. + // The driver must consume it and still deliver the op_response. + const combined = Buffer.concat([ + buildOpEvent(42, 7), + buildOpResponse(42), + ]); + s.write(combined); + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + } + }); + }); + + try { + await withMockAttach(port, async db => { + assert.ok(db, 'db should attach successfully after stray op_event'); + }); + } finally { + await stopMockServer(server); + } + }); + + // ----------------------------------------------------------------------- + // 3. op_response_piggyback on main connection must not corrupt the queue + // ----------------------------------------------------------------------- + + it('should ignore op_response_piggyback on the main connection without blocking', async function () { + const { server, port } = await startMockServer(socket => { + let attached = false; + makeDispatcher(socket, (s, opcode) => { + if (opcode === Const.op_connect) { + s.write(buildOpAcceptData('Legacy_Auth')); + } else if ((opcode === Const.op_attach || opcode === Const.op_create) && !attached) { + attached = true; + // Inject op_response_piggyback BEFORE the real op_response. + const combined = Buffer.concat([ + buildOpResponsePiggyback(), + buildOpResponse(42), + ]); + s.write(combined); + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + } + }); + }); + + try { + await withMockAttach(port, async db => { + assert.ok(db, 'db should attach successfully after op_response_piggyback'); + }); + } finally { + await stopMockServer(server); + } + }); + + // ----------------------------------------------------------------------- + // 4. Multiple sequential attach/detach cycles (queue alignment) + // ----------------------------------------------------------------------- + + it('should handle multiple sequential attach/detach cycles with correct queue alignment', async function () { + let connectionCount = 0; + + const { server, port } = await startMockServer(socket => { + connectionCount++; + makeDispatcher(socket, (s, opcode) => { + if (opcode === Const.op_connect) { + s.write(buildOpAcceptData('Legacy_Auth')); + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + s.write(buildOpResponse(connectionCount * 10)); + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + } + }); + }); + + try { + for (let i = 0; i < 3; i++) { + await withMockAttach(port, async db => { + assert.ok(db, `cycle ${i}: db should be valid`); + }); + } + } finally { + await stopMockServer(server); + } + }); + +}); + +// --------------------------------------------------------------------------- +// SRP Authentication – full offline protocol tests (FB3 / FB4 / FB5) +// --------------------------------------------------------------------------- + +/** + * Fixed test credentials and salt for deterministic SRP mock tests. + * Using a fixed private key b on the server side ensures reproducible B values. + */ +const SRP_TEST_USER = 'SYSDBA'; +const SRP_TEST_PASSWORD = 'masterkey'; +// 64-hex-char salt (32 bytes), same as test/srp.js TEST_SALT_1 +const SRP_TEST_SALT = 'a8ae6e6ee929abea3afcfc5258c8ccd6f85273e0d4626d26c7279f3250f77c8e'; + +describe('Firebird SRP Authentication – offline protocol tests', function () { + + /** + * Run a complete SRP auth cycle against a mock server using the given + * Firebird protocol version. + * + * Protocol versions: + * PROTOCOL_VERSION14 (0x800E) → Firebird 3 baseline + * PROTOCOL_VERSION16 (0x8010) → Firebird 4 + * PROTOCOL_VERSION17 (0x8011) → Firebird 5 + */ + async function runSrpAuthCycle(protocolVersion) { + // Pre-generate server keys once (deterministic: salt is fixed) + const serverKeys = srp.serverSeed(SRP_TEST_USER, SRP_TEST_PASSWORD, SRP_TEST_SALT); + + // Build the op_cond_accept SRP challenge frame + const challengeFrame = buildOpCondAcceptSRP(protocolVersion, SRP_TEST_SALT, serverKeys.public); + + let opConnectInfo = null; // parsed op_connect data + let opContAuthInfo = null; // parsed op_cont_auth data + const timings = {}; // phase → ms timestamps (for FIREBIRD_DEBUG) + + const { server, port } = await startMockServer(socket => { + let state = 'init'; + + makeFullDispatcher(socket, (s, opcode, buf) => { + const now = Date.now(); + + if (opcode === Const.op_connect) { + // Parse client public key A from op_connect BLR + opConnectInfo = parseOpConnect(buf); + state = 'challenge_sent'; + timings.opConnectRecv = now; + + if (process.env.FIREBIRD_DEBUG) { + const aSnip = opConnectInfo && opConnectInfo.specificData.slice(0, 16); + console.log('[mock-debug] op_connect: plugin=%s A[0:16]=%s', + opConnectInfo && opConnectInfo.pluginName, aSnip); + } + + s.write(challengeFrame); + timings.challengeSent = Date.now(); + return buf.length; + + } else if (opcode === Const.op_cont_auth && state === 'challenge_sent') { + // Parse M1 proof from op_cont_auth + opContAuthInfo = parseOpContAuth(buf); + state = 'auth_complete'; + timings.m1Recv = now; + + if (process.env.FIREBIRD_DEBUG) { + const m1Snip = opContAuthInfo && opContAuthInfo.m1Hex.slice(0, 16); + console.log('[mock-debug] op_cont_auth: plugin=%s M1[0:16]=%s', + opContAuthInfo && opContAuthInfo.pluginName, m1Snip); + } + + // Send server proof M2 (empty) + op_accept in a single write + const reply = Buffer.concat([ + buildOpContAuthServer(), + buildOpAccept(protocolVersion), + ]); + s.write(reply); + timings.acceptSent = Date.now(); + return buf.length; + + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + timings.opAttachRecv = now; + s.write(buildOpResponse(42)); + return buf.length; + + } else if (opcode === Const.op_detach) { + timings.opDetachRecv = now; + s.write(buildOpResponse(0)); + s.end(); + return buf.length; + + } else { + // Unknown opcode – consume whole buffer + return buf.length; + } + }); + }); + + try { + const t0 = Date.now(); + const db = await withMockSrpAttach(port); + + if (process.env.FIREBIRD_DEBUG) { + console.log('[mock-debug] SRP proto=0x%x attach in %dms timings=%j', + protocolVersion, Date.now() - t0, timings); + } + + assert.ok(db, 'db should be returned after SRP auth'); + assert.ok(opConnectInfo, 'op_connect should have been parsed'); + assert.strictEqual(opConnectInfo.pluginName, 'Srp', 'plugin name should be Srp'); + assert.ok(opConnectInfo.specificData.length > 0, 'client public key A should be non-empty'); + assert.ok(opContAuthInfo, 'op_cont_auth should have been parsed'); + assert.strictEqual(opContAuthInfo.pluginName, 'Srp', 'M1 plugin name should be Srp'); + assert.ok(opContAuthInfo.m1Hex.length > 0, 'client M1 proof should be non-empty'); + + await new Promise((resolve, reject) => + db.detach(e => (e ? reject(e) : resolve()))); + } finally { + await stopMockServer(server); + } + } + + it('should complete full SRP auth exchange – protocol 14 (FB3 baseline)', async function () { + await runSrpAuthCycle(Const.PROTOCOL_VERSION14); + }); + + it('should complete full SRP auth exchange – protocol 16 (FB4)', async function () { + await runSrpAuthCycle(Const.PROTOCOL_VERSION16); + }); + + it('should complete full SRP auth exchange – protocol 17 (FB5)', async function () { + await runSrpAuthCycle(Const.PROTOCOL_VERSION17); + }); + + /** + * Firebird 4/5 chained-auth: after the client sends SRP M1, the server + * sends op_cont_auth with Legacy_Auth (not Srp), then the client responds + * with Legacy_Auth credentials, then the server sends op_accept. + */ + it('should handle SRP + Legacy_Auth chained-auth (Firebird 4/5 behaviour) – protocol 16', async function () { + const protocolVersion = Const.PROTOCOL_VERSION16; + const serverKeys = srp.serverSeed(SRP_TEST_USER, SRP_TEST_PASSWORD, SRP_TEST_SALT); + const challengeFrame = buildOpCondAcceptSRP(protocolVersion, SRP_TEST_SALT, serverKeys.public); + + let legacyAuthReceived = false; + + const { server, port } = await startMockServer(socket => { + let state = 'init'; + makeFullDispatcher(socket, (s, opcode, buf) => { + if (opcode === Const.op_connect) { + state = 'challenge_sent'; + s.write(challengeFrame); + return buf.length; + + } else if (opcode === Const.op_cont_auth && state === 'challenge_sent') { + // Client sends SRP M1 – server responds with Legacy_Auth continuation + state = 'legacy_auth_sent'; + s.write(buildOpContAuthServer(null, 'Legacy_Auth')); + return buf.length; + + } else if (opcode === Const.op_cont_auth && state === 'legacy_auth_sent') { + // Client sends Legacy_Auth credentials – server responds with op_accept + legacyAuthReceived = true; + state = 'auth_complete'; + s.write(buildOpAccept(protocolVersion)); + return buf.length; + + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + s.write(buildOpResponse(42)); + return buf.length; + + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + return buf.length; + } + return buf.length; + }); + }); + + try { + const db = await withMockSrpAttach(port); + assert.ok(db, 'db should be returned after chained SRP+Legacy_Auth auth'); + assert.ok(legacyAuthReceived, 'client should have sent Legacy_Auth credentials after SRP M1'); + await new Promise((resolve, reject) => + db.detach(e => (e ? reject(e) : resolve()))); + } finally { + await stopMockServer(server); + } + }); + + it('should parse op_connect BLR and extract Srp plugin name and client key A', async function () { + // Use a loopback server solely to capture the raw op_connect bytes + let capturedBuf = null; + + const { server, port } = await startMockServer(socket => { + let sawConnect = false; + makeFullDispatcher(socket, (s, opcode, buf) => { + if (opcode === Const.op_connect && !sawConnect) { + sawConnect = true; + capturedBuf = Buffer.from(buf); // snapshot before clearing + // Respond with Legacy_Auth accept_data so attach() can complete + s.write(buildOpAcceptData('Legacy_Auth')); + return buf.length; + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + s.write(buildOpResponse(42)); + return buf.length; + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + return buf.length; + } + return buf.length; + }); + }); + + try { + const db = await new Promise((resolve, reject) => { + Firebird.attach({ + host: '127.0.0.1', port, + database: '/mock/test.fdb', + user: 'SYSDBA', password: 'masterkey', + pluginName: Const.AUTH_PLUGIN_SRP, + wireCrypt: Const.WIRE_CRYPT_DISABLE, + }, (err, d) => (err ? reject(err) : resolve(d))); + }); + + assert.ok(capturedBuf, 'op_connect should have been captured'); + + const parsed = parseOpConnect(capturedBuf); + assert.ok(parsed, 'BLR parser should succeed'); + assert.strictEqual(parsed.pluginName, 'Srp', 'plugin name extracted from BLR'); + assert.strictEqual(parsed.login, 'SYSDBA', 'login extracted from BLR'); + assert.ok(parsed.specificData.length > 0, 'client public key A should be present in BLR'); + // A is a 1024-bit hex number → 1–256 hex chars + assert.ok(parsed.specificData.length <= 256, 'client public key A is at most 256 hex chars'); + assert.ok(/^[0-9a-f]+$/i.test(parsed.specificData), 'client public key A is valid hex'); + + await new Promise((resolve, reject) => db.detach(e => (e ? reject(e) : resolve()))); + } finally { + await stopMockServer(server); + } + }); + + it('should extract M1 proof from op_cont_auth via parseOpContAuth', async function () { + let capturedM1 = null; + let capturedPlugin = null; + + const serverKeys = srp.serverSeed(SRP_TEST_USER, SRP_TEST_PASSWORD, SRP_TEST_SALT); + const challengeFrame = buildOpCondAcceptSRP(Const.PROTOCOL_VERSION14, SRP_TEST_SALT, serverKeys.public); + + const { server, port } = await startMockServer(socket => { + let state = 'init'; + makeFullDispatcher(socket, (s, opcode, buf) => { + if (opcode === Const.op_connect) { + state = 'challenge_sent'; + s.write(challengeFrame); + return buf.length; + } else if (opcode === Const.op_cont_auth && state === 'challenge_sent') { + const parsed = parseOpContAuth(buf); + capturedM1 = parsed && parsed.m1Hex; + capturedPlugin = parsed && parsed.pluginName; + // Send M2 + op_accept so the client can proceed + s.write(Buffer.concat([ + buildOpContAuthServer(), + buildOpAccept(Const.PROTOCOL_VERSION14), + ])); + state = 'auth_done'; + return buf.length; + } else if (opcode === Const.op_attach || opcode === Const.op_create) { + s.write(buildOpResponse(42)); + return buf.length; + } else if (opcode === Const.op_detach) { + s.write(buildOpResponse(0)); + s.end(); + return buf.length; + } + return buf.length; + }); + }); + + try { + const db = await withMockSrpAttach(port); + await new Promise((resolve, reject) => db.detach(e => (e ? reject(e) : resolve()))); + + assert.ok(capturedM1, 'M1 proof should have been captured'); + assert.ok(capturedM1.length > 0, 'M1 proof should be non-empty hex string'); + assert.ok(/^[0-9a-f]+$/i.test(capturedM1), 'M1 should be valid hex'); + assert.strictEqual(capturedPlugin, 'Srp', 'plugin should be Srp'); + } finally { + await stopMockServer(server); + } + }); + +}); + +// --------------------------------------------------------------------------- +// XDR encode/decode round-trip tests (fully offline, no network) +// --------------------------------------------------------------------------- + +describe('XDR encode/decode round trips', function () { + + it('should round-trip a 32-bit integer', function () { + const w = new XdrWriter(8); + w.addInt(0xDEADBEEF | 0); // signed 32-bit + const r = new XdrReader(w.getData()); + assert.strictEqual(r.readInt(), 0xDEADBEEF | 0); + }); + + it('should round-trip zero (0)', function () { + const w = new XdrWriter(8); + w.addInt(0); + const r = new XdrReader(w.getData()); + assert.strictEqual(r.readInt(), 0); + }); + + it('should round-trip a UTF-8 string', function () { + const w = new XdrWriter(64); + w.addString('Hello Firebird!', 'utf8'); + const r = new XdrReader(w.getData()); + assert.strictEqual(r.readString('utf8'), 'Hello Firebird!'); + }); + + it('should round-trip an empty string', function () { + const w = new XdrWriter(16); + w.addString('', 'utf8'); + const r = new XdrReader(w.getData()); + assert.strictEqual(r.readString('utf8'), ''); + }); + + it('should round-trip a byte array (addInt + readArray)', function () { + const payload = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05]); + const w = new XdrWriter(32); + w.addInt(payload.length); + w.addBuffer(payload); + // XDR arrays are 4-byte aligned + const padLen = (4 - (payload.length % 4)) % 4; + if (padLen > 0) w.addBuffer(Buffer.alloc(padLen)); // XDR alignment padding (0-3 bytes) + + const r = new XdrReader(w.getData()); + const len = r.readInt(); + assert.strictEqual(len, payload.length); + const read = r.buffer.slice(r.pos, r.pos + len); + assert.ok(read.equals(payload)); + }); + + it('should round-trip a 64-bit integer via addInt64 / readInt64', function () { + const w = new XdrWriter(16); + w.addInt64(0x1234567890); // 78187493520 + const r = new XdrReader(w.getData()); + const v = r.readInt64(); + // Long value – compare via toString + assert.strictEqual(v.toString(), '78187493520'); + }); + + it('should encode op_response and decode protocolVersion / handle', function () { + // Build a mock op_response and verify we can read the handle + const frame = buildOpResponse(99); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_response); // opcode + assert.strictEqual(r.readInt(), 99); // handle + }); + + it('should encode op_event and read all 4 fields', function () { + const frame = buildOpEvent(7, 13); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_event); // opcode + assert.strictEqual(r.readInt(), 7); // db handle + r.readArray(); // EPB (empty) + r.readInt64(); // AST pointer + assert.strictEqual(r.readInt(), 13); // event RID + }); + + it('should encode op_response_piggyback and read handle', function () { + const frame = buildOpResponsePiggyback(); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_response_piggyback); // opcode + assert.strictEqual(r.readInt(), 0); // handle + }); + + it('should encode op_accept_data and read protocolVersion + pluginName + is_authenticated', function () { + const frame = buildOpAcceptData('Legacy_Auth'); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_accept_data); + assert.strictEqual(r.readInt(), Const.PROTOCOL_VERSION14); + assert.strictEqual(r.readInt(), Const.ARCHITECTURE_GENERIC); + assert.strictEqual(r.readInt(), Const.ptype_lazy_send); + r.readArray(); // auth data (empty) + assert.strictEqual(r.readString('utf8'), 'Legacy_Auth'); + assert.strictEqual(r.readInt(), 1); // is_authenticated + assert.strictEqual(r.readString('utf8'), ''); // keys + }); + + it('should align strings to 4-byte boundaries', function () { + // 'ABC' is 3 bytes → padded to 4 + const w = new XdrWriter(32); + w.addString('ABC', 'utf8'); + // Expected: [0,0,0,3, 65,66,67,0] (4-byte aligned) + const buf = w.getData(); + assert.strictEqual(buf.readInt32BE(0), 3); // length prefix + assert.strictEqual(buf[4], 0x41); // 'A' + assert.strictEqual(buf[5], 0x42); // 'B' + assert.strictEqual(buf[6], 0x43); // 'C' + assert.strictEqual(buf[7], 0x00); // padding + assert.strictEqual(buf.length, 8); + }); + + it('should correctly represent PROTOCOL_VERSION14 constant', function () { + assert.strictEqual(Const.PROTOCOL_VERSION14, (0x8000 | 14)); + assert.strictEqual(Const.PROTOCOL_VERSION14 & Const.FB_PROTOCOL_MASK, 14); + assert.ok(Const.PROTOCOL_VERSION14 & Const.FB_PROTOCOL_FLAG); + }); + + it('should define op_event, op_response_piggyback opcodes', function () { + assert.strictEqual(Const.op_event, 52); + assert.strictEqual(Const.op_response_piggyback, 72); + assert.strictEqual(Const.op_accept_data, 94); + assert.strictEqual(Const.op_cond_accept, 98); + }); + + // ----------------------------------------------------------------------- + // New round-trip tests for SRP wire frames + // ----------------------------------------------------------------------- + + it('should encode op_cond_accept (SRP) with correct opcode and is_authenticated=0', function () { + const serverKeys = srp.serverSeed(SRP_TEST_USER, SRP_TEST_PASSWORD, SRP_TEST_SALT); + const frame = buildOpCondAcceptSRP(Const.PROTOCOL_VERSION14, SRP_TEST_SALT, serverKeys.public); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_cond_accept); // opcode = 98 + assert.strictEqual(r.readInt(), Const.PROTOCOL_VERSION14); // protocol + assert.strictEqual(r.readInt(), Const.ARCHITECTURE_GENERIC); + assert.strictEqual(r.readInt(), Const.ptype_lazy_send); + const authData = r.readArray(); // BLR auth data + assert.ok(authData && authData.length > 0, 'auth data should be non-empty'); + // Verify BLR format: [uint16LE saltLen][salt][uint16LE keyLen][B] + const saltLen = authData.readUInt16LE(0); + assert.strictEqual(saltLen, SRP_TEST_SALT.length, 'salt length in BLR'); + const saltExtracted = authData.slice(2, 2 + saltLen).toString('utf8'); + assert.strictEqual(saltExtracted, SRP_TEST_SALT, 'salt value in BLR'); + const keyOffset = 2 + saltLen; + const keyLen = authData.readUInt16LE(keyOffset); + assert.ok(keyLen > 0, 'server B key length should be positive'); + assert.strictEqual(r.readString('utf8'), 'Srp', 'plugin name'); + assert.strictEqual(r.readInt(), 0, 'is_authenticated = 0'); + assert.strictEqual(r.readString('utf8'), '', 'keys = empty'); + }); + + it('should encode op_cont_auth (server) with correct opcode and empty M2', function () { + const frame = buildOpContAuthServer(); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_cont_auth); // opcode = 92 + const m2 = r.readArray(); // M2 array (empty) + assert.ok(!m2, 'empty M2 array should be falsy'); + assert.strictEqual(r.readString('utf8'), 'Srp'); // plugin name + }); + + it('should encode op_accept (post-SRP) with correct opcode and protocol version', function () { + const frame = buildOpAccept(Const.PROTOCOL_VERSION16); + const r = new XdrReader(frame); + assert.strictEqual(r.readInt(), Const.op_accept); // opcode = 3 + assert.strictEqual(r.readInt(), Const.PROTOCOL_VERSION16); // protocol + assert.strictEqual(r.readInt(), Const.ARCHITECTURE_GENERIC); + assert.strictEqual(r.readInt(), Const.ptype_lazy_send); + }); + + it('should define SRP protocol version constants', function () { + assert.strictEqual(Const.PROTOCOL_VERSION14 & Const.FB_PROTOCOL_MASK, 14); + assert.strictEqual(Const.PROTOCOL_VERSION16 & Const.FB_PROTOCOL_MASK, 16); + assert.strictEqual(Const.PROTOCOL_VERSION17 & Const.FB_PROTOCOL_MASK, 17); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index a8889eb..81927f6 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -17,7 +17,8 @@ module.exports = defineConfig({ 'test/index.js', 'test/db-crypt-config.js', 'test/timezone.js', - 'test/decfloat.js' + 'test/decfloat.js', + 'test/mock-server.js' ], }, });