// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0
// The opening exchange: prelogin agrees on encryption, login7 hands over the credentials,
// and the token stream that comes back says whether the session started.
///|
/// What the client asks for in prelogin. `On` is the default this driver sends: the login
/// packet carries a password, and a protocol that lets it cross the wire in the clear is
/// one to ask better of.
priv enum Encryption {
Off
On
}
///|
/// One message the server sent about itself — an `ERROR` or an `INFO`.
priv struct ServerMessage {
number : Int32
severity : Int32
text : String
}
///|
/// Prepend a TDS packet header. Kept apart from the socket so the bytes can be asserted in
/// a test without a server.
fn packet(kind : Int32, payload : Bytes) -> Bytes {
let out = Buffer()
out.write_byte(kind.to_byte())
out.write_byte(b'\x01')
let length = payload.length() + 8
out.write_byte((length >> 8).to_byte())
out.write_byte((length & 0xFF).to_byte())
out.write_byte(b'\x00')
out.write_byte(b'\x00')
out.write_byte(b'\x01')
out.write_byte(b'\x00')
out.write_bytes(payload)
out.to_bytes()
}
///|
/// The prelogin message: a table of five options, then their values. The offsets count
/// from the first option's byte, which is why the header length is known before the values
/// are laid out.
fn build_prelogin(encryption : Encryption) -> Bytes {
let version = b"\x0f\x00\x07\xd0\x00\x00"
let items : Array[(Int32, Bytes)] = [
(0x00, version),
(0x01, if encryption is On { b"\x01" } else { b"\x00" }),
(0x02, b"\x00"),
(0x03, b"\x00\x00\x00\x00"),
(0x04, b"\x00"),
]
let header_len = 5 * items.length() + 1
let headers = Buffer()
let values = Buffer()
for item in items {
let (token, data) = item
headers.write_byte(token.to_byte())
let offset = header_len + values.to_bytes().length()
headers.write_byte((offset >> 8).to_byte())
headers.write_byte((offset & 0xFF).to_byte())
headers.write_byte((data.length() >> 8).to_byte())
headers.write_byte((data.length() & 0xFF).to_byte())
values.write_bytes(data)
}
let body = Buffer()
body.write_bytes(headers.to_bytes())
body.write_byte(b'\xff')
body.write_bytes(values.to_bytes())
packet(0x12, body.to_bytes())
}
///|
/// The encryption the server answered with: 0 off, 1 on, 2 not supported, 3 required.
/// "Required" and "on" both mean the handshake has to run; "not supported" means the
/// session runs in the clear, which is the server's decision to make.
fn parse_prelogin(payload : Bytes) -> Int32 {
let mut at = 0
let mut encryption = 2
while at + 5 <= payload.length() {
let token = payload[at].to_int()
if token == 0xFF {
break
}
let offset = be16(payload, at + 1)
let length = be16(payload, at + 3)
if token == 0x01 && length >= 1 && offset + 1 <= payload.length() {
encryption = payload[offset].to_int()
}
at += 5
}
encryption
}
///|
/// SQL Server's password obfuscation: each byte nibble-swapped, then XORed with `0xA5`.
/// It is not encryption and does not pretend to be — TLS is what protects the password;
/// this only keeps it out of the readable strings of a packet capture.
fn obfuscate(bytes : Bytes) -> Bytes {
let out = Buffer()
for i = 0; i < bytes.length(); i = i + 1 {
let b = bytes[i].to_int()
let swapped = ((b << 4) | (b >> 4)) & 0xFF
out.write_byte((swapped ^ 0xA5).to_byte())
}
out.to_bytes()
}
///|
/// The LOGIN7 message. Its fixed part is 94 bytes; the strings follow, and pairs of offsets
/// in the fixed part point at them. Every field is UTF-16LE with its length in characters.
///
/// TDS 7.4 adds an extension block: a four-byte pointer at `ibExtension` that points at a
/// feature block at the very end of the packet. A login without it is refused, which is why
/// the block and its terminator are built even when the only feature is "this client speaks
/// UTF-8".
fn build_login(
host : String,
user : String,
password : String,
database : String,
app : String,
version~ : Int32 = 0x74000004,
) -> Bytes {
let host_b = @utf16.encode(host)
let user_b = @utf16.encode(user)
let pass_b = obfuscate(@utf16.encode(password))
let app_b = @utf16.encode(app)
let lib_b = @utf16.encode("moonsqls")
let lang_b = @utf16.encode("us_english")
let db_b = @utf16.encode(database)
let before = Buffer()
for bytes in [host_b, user_b, pass_b, app_b, host_b] {
before.write_bytes(bytes)
}
let after = Buffer()
for bytes in [lib_b, lang_b, db_b] {
after.write_bytes(bytes)
}
let before_len = before.to_bytes().length()
let after_len = after.to_bytes().length()
// The pointer sits between the two runs of strings; the feature block it names comes
// after both.
let feature_offset = 94 + before_len + 4 + after_len
let body = Buffer()
body.write_bytes(before.to_bytes())
for i = 0; i < 4; i = i + 1 {
body.write_byte(((feature_offset >> (i * 8)) & 0xFF).to_byte())
}
body.write_bytes(after.to_bytes())
// UTF-8 support (feature 0x0A, one byte, on), then the terminator.
for b in [0x0a, 0x01, 0x00, 0x00, 0x00, 0x01, 0xff] {
body.write_byte(b.to_byte())
}
let body_bytes = body.to_bytes()
let total = 94 + body_bytes.length()
// Each pair is (length in characters, length in bytes). The extension pair is the odd
// one: four characters is really a four-byte pointer, not a string.
let offsets : Array[(Int32, Int32)] = []
let mut at = 94
for
pair in [
(host.length(), host_b.length()),
(user.length(), user_b.length()),
(password.length(), pass_b.length()),
(app.length(), app_b.length()),
(host.length(), host_b.length()),
(4, 4),
(8, lib_b.length()),
(10, lang_b.length()),
(database.length(), db_b.length()),
] {
let (chars, bytes) = pair
offsets.push((at, chars))
at += bytes
}
let head = Buffer()
for i = 0; i < 4; i = i + 1 {
head.write_byte(((total >> (i * 8)) & 0xFF).to_byte())
}
// The protocol version, then a 4096-byte packet, no client version, a pid, no
// connection id. 7.4 announces itself as 0x74000004, 8.0 as 0x08000000.
for i = 0; i < 4; i = i + 1 {
head.write_byte(((version >> (i * 8)) & 0xFF).to_byte())
}
for b in [0x00, 0x10, 0x00, 0x00] {
head.write_byte(b.to_byte())
}
for
b in [
0x00, 0x00, 0x00, 0x00, 0xd2, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
] {
head.write_byte(b.to_byte())
}
// OptionFlags1: dump/load off, "use db off", an initial database is fatal and a missing
// language only warns. OptionFlags2: nothing — an ordinary user without ODBC.
// TypeFlags: SQL default. OptionFlags3: the extension block is in use, and an unknown
// collation is not fatal. Time zone 0, LCID en-US.
for
b in [
0xf0, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x09, 0x04, 0x00, 0x00,
] {
head.write_byte(b.to_byte())
}
for pair in offsets {
let (offset, chars) = pair
head.write_byte((offset & 0xFF).to_byte())
head.write_byte(((offset >> 8) & 0xFF).to_byte())
head.write_byte((chars & 0xFF).to_byte())
head.write_byte(((chars >> 8) & 0xFF).to_byte())
}
// Client id (six bytes), then the SSPI, attach-db and change-password pairs, all empty
// and pointing at the end of the data, then the long SSPI length.
for b in [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] {
head.write_byte(b.to_byte())
}
for i = 0; i < 3; i = i + 1 {
head.write_byte((total & 0xFF).to_byte())
head.write_byte(((total >> 8) & 0xFF).to_byte())
head.write_byte(b'\x00')
head.write_byte(b'\x00')
}
for i = 0; i < 4; i = i + 1 {
head.write_byte(b'\x00')
}
let out = Buffer()
out.write_bytes(head.to_bytes())
out.write_bytes(body_bytes)
packet(0x10, out.to_bytes())
}