From 092b9e37a88d333218416c2509e27dfe395f1de1 Mon Sep 17 00:00:00 2001 From: cs01 Date: Tue, 21 Apr 2026 07:37:20 -0700 Subject: [PATCH] [stdlib/pg] add scram-sha-256 auth support --- .github/workflows/ci.yml | 24 ++- c_bridges/scram-bridge.c | 283 ++++++++++++++++++++++++++++++ lib/pg.ts | 217 ++++++++++++++++++++++- scripts/build-target-sdk.sh | 2 +- scripts/build-vendor.sh | 17 ++ src/compiler.ts | 3 +- src/native-compiler-lib.ts | 3 + tests/fixtures/stdlib/pg-scram.ts | 40 +++++ 8 files changed, 576 insertions(+), 13 deletions(-) create mode 100644 c_bridges/scram-bridge.c create mode 100644 tests/fixtures/stdlib/pg-scram.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e904758..dcf73c45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -200,19 +200,24 @@ jobs: env: PG_TESTS_ENABLED: 1 + PG_SCRAM_TESTS_ENABLED: 1 PGPASSWORD: test steps: - uses: actions/checkout@v4 - - name: Force pg password to md5 encryption + - name: Force pg password to md5 encryption (primary postgres user) # postgres:16 image defaults to password_encryption=scram-sha-256, so - # the `postgres` user is scram-hashed. Our pure-TS pg driver implements - # md5 auth (SCRAM is follow-up) — re-hash password as md5. + # the `postgres` user is scram-hashed by default. Our pure-TS pg driver + # implements both md5 AND SCRAM-SHA-256; we re-hash the default user as + # md5 so the md5 fixtures exercise that code path, and create a second + # `scramuser` (scram-sha-256 hashed) for the SCRAM fixture. run: | sudo apt-get install -y postgresql-client psql -h localhost -p 5432 -U postgres -d chadtest \ -c "SET password_encryption='md5'; ALTER USER postgres PASSWORD 'test';" + psql -h localhost -p 5432 -U postgres -d chadtest \ + -c "SET password_encryption='scram-sha-256'; CREATE USER scramuser SUPERUSER PASSWORD 'test';" - uses: dorny/paths-filter@v3 id: changes @@ -271,7 +276,7 @@ jobs: - name: Verify vendor libraries run: | fail=0 - for lib in vendor/bdwgc/libgc.a vendor/yyjson/libyyjson.a vendor/libuv/build/libuv.a vendor/picohttpparser/picohttpparser.o vendor/rure/librure.a c_bridges/lws-bridge.o c_bridges/multipart-bridge.o c_bridges/regex-bridge.o c_bridges/child-process-bridge.o c_bridges/child-process-spawn.o c_bridges/trampoline-bridge.o c_bridges/os-bridge.o c_bridges/strlen-cache.o c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o c_bridges/dotenv-bridge.o c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o; do + for lib in vendor/bdwgc/libgc.a vendor/yyjson/libyyjson.a vendor/libuv/build/libuv.a vendor/picohttpparser/picohttpparser.o vendor/rure/librure.a c_bridges/lws-bridge.o c_bridges/multipart-bridge.o c_bridges/regex-bridge.o c_bridges/child-process-bridge.o c_bridges/child-process-spawn.o c_bridges/trampoline-bridge.o c_bridges/os-bridge.o c_bridges/strlen-cache.o c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o c_bridges/dotenv-bridge.o c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/scram-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o; do if [ ! -f "$lib" ]; then echo "MISSING: $lib" fail=1 @@ -338,6 +343,7 @@ jobs: cp c_bridges/multipart-bridge.o release/lib/ cp c_bridges/regex-bridge.o release/lib/ cp c_bridges/net-bridge.o release/lib/ + cp c_bridges/scram-bridge.o release/lib/ cp vendor/rure/librure.a release/lib/ cp c_bridges/child-process-bridge.o release/lib/ cp c_bridges/child-process-spawn.o release/lib/ @@ -345,7 +351,7 @@ jobs: cp c_bridges/os-bridge.o c_bridges/strlen-cache.o release/lib/ cp c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o release/lib/ cp c_bridges/dotenv-bridge.o release/lib/ - cp c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o release/lib/ + cp c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/scram-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o release/lib/ tar -czf chadscript-linux-x64.tar.gz -C release chad lib - name: Upload artifact @@ -365,6 +371,7 @@ jobs: env: PG_TESTS_ENABLED: 1 + PG_SCRAM_TESTS_ENABLED: 1 steps: - uses: actions/checkout@v4 @@ -416,6 +423,7 @@ jobs: psql postgres -c "SET password_encryption='md5'; CREATE USER postgres SUPERUSER PASSWORD 'test';" || echo "postgres user already exists" psql postgres -c "SET password_encryption='md5'; ALTER USER postgres WITH PASSWORD 'test';" psql postgres -c "CREATE DATABASE chadtest OWNER postgres;" || echo "chadtest db already exists" + psql postgres -c "SET password_encryption='scram-sha-256'; CREATE USER scramuser SUPERUSER PASSWORD 'test';" || echo "scramuser already exists" psql -h localhost -p 5432 -U postgres -d chadtest -c "SELECT 1;" postgresql://postgres:test@localhost:5432/chadtest - name: Install npm dependencies @@ -438,7 +446,7 @@ jobs: - name: Verify vendor libraries run: | fail=0 - for lib in vendor/bdwgc/libgc.a vendor/yyjson/libyyjson.a vendor/libuv/build/libuv.a vendor/picohttpparser/picohttpparser.o vendor/rure/librure.a c_bridges/lws-bridge.o c_bridges/multipart-bridge.o c_bridges/regex-bridge.o c_bridges/child-process-bridge.o c_bridges/child-process-spawn.o c_bridges/trampoline-bridge.o c_bridges/os-bridge.o c_bridges/strlen-cache.o c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o c_bridges/dotenv-bridge.o c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o; do + for lib in vendor/bdwgc/libgc.a vendor/yyjson/libyyjson.a vendor/libuv/build/libuv.a vendor/picohttpparser/picohttpparser.o vendor/rure/librure.a c_bridges/lws-bridge.o c_bridges/multipart-bridge.o c_bridges/regex-bridge.o c_bridges/child-process-bridge.o c_bridges/child-process-spawn.o c_bridges/trampoline-bridge.o c_bridges/os-bridge.o c_bridges/strlen-cache.o c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o c_bridges/dotenv-bridge.o c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/scram-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o; do if [ ! -f "$lib" ]; then echo "MISSING: $lib" fail=1 @@ -509,6 +517,7 @@ jobs: cp c_bridges/multipart-bridge.o release/lib/ cp c_bridges/regex-bridge.o release/lib/ cp c_bridges/net-bridge.o release/lib/ + cp c_bridges/scram-bridge.o release/lib/ cp vendor/rure/librure.a release/lib/ cp c_bridges/child-process-bridge.o release/lib/ cp c_bridges/child-process-spawn.o release/lib/ @@ -516,7 +525,7 @@ jobs: cp c_bridges/os-bridge.o c_bridges/strlen-cache.o release/lib/ cp c_bridges/time-bridge.o c_bridges/base64-bridge.o c_bridges/url-bridge.o c_bridges/uri-bridge.o release/lib/ cp c_bridges/dotenv-bridge.o release/lib/ - cp c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o release/lib/ + cp c_bridges/watch-bridge.o c_bridges/arena-bridge.o c_bridges/curl-bridge.o c_bridges/pg-bridge.o c_bridges/scram-bridge.o c_bridges/compress-bridge.o c_bridges/yaml-bridge.o c_bridges/string-ops-bridge.o c_bridges/llvm-bridge.o c_bridges/llvm-builder-bridge.o c_bridges/lld-bridge.o release/lib/ tar -czf chadscript-macos-arm64.tar.gz -C release chad lib - name: Upload artifact @@ -588,6 +597,7 @@ jobs: multipart-bridge.o \ regex-bridge.o \ net-bridge.o \ + scram-bridge.o \ librure.a \ child-process-bridge.o \ child-process-spawn.o \ diff --git a/c_bridges/scram-bridge.c b/c_bridges/scram-bridge.c new file mode 100644 index 00000000..6794a9f1 --- /dev/null +++ b/c_bridges/scram-bridge.c @@ -0,0 +1,283 @@ +// scram-bridge.c — SCRAM-SHA-256 primitives for the ChadScript pg driver. +// +// Exposes four entry points callable from ChadScript via `declare function`: +// +// cs_scram_random_nonce_b64() -> string +// cs_scram_client_first_bare(user, nonce_b64) -> string ("n=,r=") +// cs_scram_client_final(password, client_first_bare, server_first) -> string +// returns "\x01"; caller splits on \x01 +// cs_scram_is_valid_server_final(server_final, expected_sig_b64) -> int (1/0) +// +// All strings are null-terminated, GC_malloc_atomic-allocated (Boehm GC). +// Base64 encoding follows RFC 4648 (standard alphabet, '=' padding) which is +// what RFC 5802 §3 specifies for SCRAM attributes. + +#include +#include +#include +#include +#include +#include +#include +#include + +extern void* GC_malloc_atomic(size_t sz); +extern void* GC_malloc(size_t sz); + +// ---- base64 (standard, padded) --------------------------------------------- + +static const char B64_ENC[65] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +static char* b64_encode(const unsigned char* in, size_t in_len) { + size_t out_len = ((in_len + 2) / 3) * 4; + char* out = (char*)GC_malloc_atomic(out_len + 1); + size_t i = 0, j = 0; + while (i < in_len) { + unsigned int a = in[i++]; + int has_b = (i < in_len); + unsigned int b = has_b ? in[i++] : 0; + int has_c = (i < in_len); + unsigned int c = has_c ? in[i++] : 0; + unsigned int triple = (a << 16) | (b << 8) | c; + out[j++] = B64_ENC[(triple >> 18) & 0x3F]; + out[j++] = B64_ENC[(triple >> 12) & 0x3F]; + out[j++] = has_b ? B64_ENC[(triple >> 6) & 0x3F] : '='; + out[j++] = has_c ? B64_ENC[triple & 0x3F] : '='; + } + out[j] = '\0'; + return out; +} + +static const signed char B64_DEC[256] = { + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,62,-1,-1,-1,63, + 52,53,54,55,56,57,58,59,60,61,-1,-1,-1, 0,-1,-1, + -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10,11,12,13,14, + 15,16,17,18,19,20,21,22,23,24,25,-1,-1,-1,-1,-1, + -1,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40, + 41,42,43,44,45,46,47,48,49,50,51,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1, + -1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1 +}; + +// Returns bytes decoded into out (caller allocates); returns byte length written. +static size_t b64_decode_into(const char* in, unsigned char* out) { + size_t in_len = strlen(in); + size_t out_pos = 0; + int buf = 0, bits = 0; + for (size_t i = 0; i < in_len; i++) { + unsigned char ch = (unsigned char)in[i]; + if (ch == '=') break; + signed char v = B64_DEC[ch]; + if (v < 0) continue; + buf = (buf << 6) | v; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[out_pos++] = (unsigned char)((buf >> bits) & 0xFF); + buf &= (1 << bits) - 1; + } + } + return out_pos; +} + +// ---- helpers ---------------------------------------------------------------- + +static char* escape_saslname(const char* user) { + size_t in_len = strlen(user); + // Worst case every char becomes 3 bytes. + char* out = (char*)GC_malloc_atomic(in_len * 3 + 1); + size_t j = 0; + for (size_t i = 0; i < in_len; i++) { + char c = user[i]; + if (c == '=') { out[j++]='='; out[j++]='3'; out[j++]='D'; } + else if (c == ',') { out[j++]='='; out[j++]='2'; out[j++]='C'; } + else { out[j++] = c; } + } + out[j] = '\0'; + return out; +} + +// Parse a SCRAM attribute list "k1=v1,k2=v2,...". Returns malloc'd value for +// the first matching key, or NULL. Uses stdlib malloc then caller free — but +// for our single-use call sites we copy into GC memory. +static int find_attr(const char* s, char key, char* out, size_t out_sz) { + size_t len = strlen(s); + size_t start = 0; + for (size_t i = 0; i <= len; i++) { + if (i == len || s[i] == ',') { + // segment [start, i) + if (i > start + 1 && s[start] == key && s[start + 1] == '=') { + size_t vlen = i - (start + 2); + if (vlen >= out_sz) vlen = out_sz - 1; + memcpy(out, s + start + 2, vlen); + out[vlen] = '\0'; + return 1; + } + start = i + 1; + } + } + return 0; +} + +// ---- public API ------------------------------------------------------------- + +// 18 random bytes → base64. Matches libpq's client nonce size. +const char* cs_scram_random_nonce_b64(void) { + unsigned char raw[18]; + if (RAND_bytes(raw, 18) != 1) { + // Fallback: fill with zeros (extremely unlikely path). Still produces + // a valid-looking string; the server will reject on signature anyway. + memset(raw, 0, 18); + } + return b64_encode(raw, 18); +} + +// client-first-bare = "n=,r=" +const char* cs_scram_client_first_bare(const char* user, const char* nonce_b64) { + if (!user) user = ""; + if (!nonce_b64) nonce_b64 = ""; + char* escaped = escape_saslname(user); + size_t e_len = strlen(escaped); + size_t n_len = strlen(nonce_b64); + size_t total = 2 + e_len + 3 + n_len; + char* out = (char*)GC_malloc_atomic(total + 1); + size_t j = 0; + out[j++] = 'n'; out[j++] = '='; + memcpy(out + j, escaped, e_len); j += e_len; + out[j++] = ','; out[j++] = 'r'; out[j++] = '='; + memcpy(out + j, nonce_b64, n_len); j += n_len; + out[j] = '\0'; + return out; +} + +// Derive client-final-message and server-signature. Returns a single string +// of the form: \x01 +// The caller splits on byte 0x01. Using an in-band separator keeps the FFI +// a single string (ChadScript's `declare function` signature). +const char* cs_scram_client_final( + const char* password, + const char* client_first_bare, + const char* server_first +) { + if (!password) password = ""; + if (!client_first_bare) client_first_bare = ""; + if (!server_first) server_first = ""; + + char salt_b64[512]; + char iter_str[32]; + char combined_nonce[512]; + if (!find_attr(server_first, 'r', combined_nonce, sizeof(combined_nonce)) || + !find_attr(server_first, 's', salt_b64, sizeof(salt_b64)) || + !find_attr(server_first, 'i', iter_str, sizeof(iter_str))) { + char* err = (char*)GC_malloc_atomic(8); + strcpy(err, "ERR"); + return err; + } + int iter = atoi(iter_str); + if (iter < 1) { + char* err = (char*)GC_malloc_atomic(8); + strcpy(err, "ERR"); + return err; + } + + // Decode salt. + unsigned char salt[512]; + size_t salt_len = b64_decode_into(salt_b64, salt); + + // SaltedPassword = PBKDF2(HMAC-SHA-256, password, salt, iter, 32) + unsigned char salted[32]; + if (PKCS5_PBKDF2_HMAC(password, (int)strlen(password), + salt, (int)salt_len, iter, + EVP_sha256(), 32, salted) != 1) { + char* err = (char*)GC_malloc_atomic(8); + strcpy(err, "ERR"); + return err; + } + + // ClientKey = HMAC-SHA-256(SaltedPassword, "Client Key") + unsigned char client_key[32]; + unsigned int ck_len = 32; + HMAC(EVP_sha256(), salted, 32, + (const unsigned char*)"Client Key", 10, client_key, &ck_len); + + // StoredKey = SHA-256(ClientKey) + unsigned char stored_key[32]; + SHA256(client_key, 32, stored_key); + + // AuthMessage = client_first_bare + "," + server_first + "," + client_final_no_proof + // where client_final_no_proof = "c=biws,r=" + combined_nonce + size_t cfb_len = strlen(client_first_bare); + size_t sf_len = strlen(server_first); + size_t cn_len = strlen(combined_nonce); + const char* cfwp_prefix = "c=biws,r="; + size_t cfwp_prefix_len = 9; + size_t cfwp_len = cfwp_prefix_len + cn_len; + size_t auth_len = cfb_len + 1 + sf_len + 1 + cfwp_len; + unsigned char* auth = (unsigned char*)GC_malloc_atomic(auth_len); + size_t p = 0; + memcpy(auth + p, client_first_bare, cfb_len); p += cfb_len; + auth[p++] = ','; + memcpy(auth + p, server_first, sf_len); p += sf_len; + auth[p++] = ','; + memcpy(auth + p, cfwp_prefix, cfwp_prefix_len); p += cfwp_prefix_len; + memcpy(auth + p, combined_nonce, cn_len); p += cn_len; + + // ClientSignature = HMAC-SHA-256(StoredKey, AuthMessage) + unsigned char client_sig[32]; + unsigned int cs_len = 32; + HMAC(EVP_sha256(), stored_key, 32, auth, auth_len, client_sig, &cs_len); + + // ClientProof = ClientKey XOR ClientSignature + unsigned char proof[32]; + for (int i = 0; i < 32; i++) proof[i] = client_key[i] ^ client_sig[i]; + char* proof_b64 = b64_encode(proof, 32); + + // ServerKey = HMAC-SHA-256(SaltedPassword, "Server Key") + unsigned char server_key[32]; + unsigned int sk_len = 32; + HMAC(EVP_sha256(), salted, 32, + (const unsigned char*)"Server Key", 10, server_key, &sk_len); + + // ServerSignature = HMAC-SHA-256(ServerKey, AuthMessage) + unsigned char server_sig[32]; + unsigned int ss_len = 32; + HMAC(EVP_sha256(), server_key, 32, auth, auth_len, server_sig, &ss_len); + char* server_sig_b64 = b64_encode(server_sig, 32); + + // Assemble: "c=biws,r=,p=" + '\x01' + server_sig_b64 + size_t proof_len = strlen(proof_b64); + size_t ss_b64_len = strlen(server_sig_b64); + size_t final_msg_len = cfwp_len + 3 + proof_len; + size_t total = final_msg_len + 1 + ss_b64_len; + char* out = (char*)GC_malloc_atomic(total + 1); + size_t j = 0; + memcpy(out + j, cfwp_prefix, cfwp_prefix_len); j += cfwp_prefix_len; + memcpy(out + j, combined_nonce, cn_len); j += cn_len; + out[j++] = ','; out[j++] = 'p'; out[j++] = '='; + memcpy(out + j, proof_b64, proof_len); j += proof_len; + out[j++] = '\x01'; + memcpy(out + j, server_sig_b64, ss_b64_len); j += ss_b64_len; + out[j] = '\0'; + return out; +} + +// Verify the server-final-message "v=..." against the expected signature we +// computed during client-final. Returns 1 on match, 0 on mismatch or error. +double cs_scram_verify_server_final(const char* server_final, const char* expected_sig_b64) { + if (!server_final || !expected_sig_b64) return 0; + char verifier[128]; + if (!find_attr(server_final, 'v', verifier, sizeof(verifier))) { + return 0; + } + if (strcmp(verifier, expected_sig_b64) == 0) return 1; + return 0; +} diff --git a/lib/pg.ts b/lib/pg.ts index 7f99b2d0..7a333321 100644 --- a/lib/pg.ts +++ b/lib/pg.ts @@ -7,6 +7,18 @@ import { createConnection, Socket } from "chadscript/net"; +// SCRAM-SHA-256 helpers implemented in c_bridges/scram-bridge.c (OpenSSL). +// cs_scram_client_final packs two strings joined by \x01 (SOH): +// "\x01". +declare function cs_scram_random_nonce_b64(): string; +declare function cs_scram_client_first_bare(user: string, nonce: string): string; +declare function cs_scram_client_final( + password: string, + clientFirstBare: string, + serverFirst: string, +): string; +declare function cs_scram_verify_server_final(serverFinal: string, expectedSigB64: string): number; + const RX_CAP = 65536; const TX_CAP = 65536; @@ -41,6 +53,8 @@ const AUTH_OK: number = 0; const AUTH_CLEARTEXT: number = 3; const AUTH_MD5: number = 5; const AUTH_SASL: number = 10; +const AUTH_SASL_CONTINUE: number = 11; +const AUTH_SASL_FINAL: number = 12; export class Client { private opts: ClientOpts; @@ -157,10 +171,10 @@ export class Client { this._sendMd5Password(); this._consumeCurrentFrame(); } else if (sub === AUTH_SASL) { - this._lastError = - "SCRAM-SHA-256 not yet supported — use password_encryption=md5 or trust on the server"; - this._consumeCurrentFrame(); - return false; + if (!this._doScram(deadline)) { + return false; + } + continue; } else { this._lastError = "unsupported auth sub-type " + sub; this._consumeCurrentFrame(); @@ -435,6 +449,201 @@ export class Client { this.sock.writeBytes(tx, 1 + len); } + // Drive a SCRAM-SHA-256 exchange from AuthenticationSASL → AuthenticationOK. + // Assumes the current frame buffer holds the AuthenticationSASL message; + // consumes it, runs the three-step handshake, and returns with the frame + // buffer drained up to (not including) the final AuthenticationOK — which + // the outer connect() loop will then see and accept. + private _doScram(deadline: number): boolean { + const rx = this.rx; + const po = this._framePayloadOff; + const pe = this._framePayloadEnd; + + // Mechanism list starts at po+4; null-terminated strings, list ends with + // an empty string (double NUL). Check that SCRAM-SHA-256 is offered. + let off = po + 4; + let found = 0; + const want = "SCRAM-SHA-256"; + const wantLen = want.length; + while (off < pe && rx[off] !== 0) { + let j = off; + while (j < pe && rx[j] !== 0) { + j = j + 1; + } + const mechLen = j - off; + if (mechLen === wantLen) { + let eq = 1; + let k = 0; + while (k < wantLen) { + if (rx[off + k] !== (want.charCodeAt(k) & 0xff)) { + eq = 0; + k = wantLen; + } else { + k = k + 1; + } + } + if (eq === 1) { + found = 1; + } + } + off = j + 1; + } + if (found === 0) { + this._lastError = "server did not offer SCRAM-SHA-256"; + this._consumeCurrentFrame(); + return false; + } + this._consumeCurrentFrame(); + + // ---- Step 1: send SASLInitialResponse ---- + const clientNonce = cs_scram_random_nonce_b64(); + const user = this.opts.user; + const clientFirstBare = cs_scram_client_first_bare(user, clientNonce); + // client-first-message = "n,," + clientFirstBare (gs2-header "n,," = no channel binding). + const clientFirst = "n,," + clientFirstBare; + const cfLen = clientFirst.length; + const mech = "SCRAM-SHA-256"; + const mechLen2 = mech.length; + // PasswordMessage body: mechanism\0 + int32 len + client-first bytes + const bodyLen = mechLen2 + 1 + 4 + cfLen; + const totalLen = 4 + bodyLen; + const tx = this.tx; + tx[0] = 112; // 'p' + tx[1] = (totalLen >> 24) & 0xff; + tx[2] = (totalLen >> 16) & 0xff; + tx[3] = (totalLen >> 8) & 0xff; + tx[4] = totalLen & 0xff; + let oi = 5; + let mi = 0; + while (mi < mechLen2) { + tx[oi + mi] = mech.charCodeAt(mi) & 0xff; + mi = mi + 1; + } + tx[oi + mechLen2] = 0; + oi = oi + mechLen2 + 1; + tx[oi] = (cfLen >> 24) & 0xff; + tx[oi + 1] = (cfLen >> 16) & 0xff; + tx[oi + 2] = (cfLen >> 8) & 0xff; + tx[oi + 3] = cfLen & 0xff; + oi = oi + 4; + let ci = 0; + while (ci < cfLen) { + tx[oi + ci] = clientFirst.charCodeAt(ci) & 0xff; + ci = ci + 1; + } + oi = oi + cfLen; + this.sock.writeBytes(tx, oi); + + // ---- Step 2: expect AuthenticationSASLContinue ---- + const t1 = this._pumpOneFrame(deadline); + if (t1 === 0) { + this._lastError = "scram: timeout waiting for SASLContinue"; + return false; + } + if (t1 === T_ERROR) { + this._consumeCurrentFrame(); + return false; + } + if (t1 !== T_AUTH) { + this._lastError = "scram: expected Authentication frame, got " + t1; + this._consumeCurrentFrame(); + return false; + } + const rx2 = this.rx; + const po2 = this._framePayloadOff; + const pe2 = this._framePayloadEnd; + const sub2 = (rx2[po2] << 24) | (rx2[po2 + 1] << 16) | (rx2[po2 + 2] << 8) | rx2[po2 + 3]; + if (sub2 !== AUTH_SASL_CONTINUE) { + this._lastError = "scram: expected SASLContinue (11), got " + sub2; + this._consumeCurrentFrame(); + return false; + } + // server-first-message = payload bytes after the 4-byte sub-code. + let serverFirst = ""; + let sfi = po2 + 4; + while (sfi < pe2) { + serverFirst = serverFirst + String.fromCharCode(rx2[sfi]); + sfi = sfi + 1; + } + this._consumeCurrentFrame(); + + // ---- Step 3: derive + send SASLResponse (client-final-message) ---- + const packed = cs_scram_client_final(this.opts.password, clientFirstBare, serverFirst); + if (packed === "ERR") { + this._lastError = "scram: failed to derive client proof"; + return false; + } + // The bridge returns "\x01"; split on SOH. + const packedLen = packed.length; + let sepIdx = -1; + let si2 = 0; + while (si2 < packedLen) { + if (packed.charCodeAt(si2) === 1) { + sepIdx = si2; + si2 = packedLen; + } else { + si2 = si2 + 1; + } + } + if (sepIdx < 0) { + this._lastError = "scram: malformed bridge result"; + return false; + } + const clientFinalLen = sepIdx; + const serverSigB64 = packed.substring(sepIdx + 1, packedLen); + + const fLen = 4 + clientFinalLen; + tx[0] = 112; // 'p' + tx[1] = (fLen >> 24) & 0xff; + tx[2] = (fLen >> 16) & 0xff; + tx[3] = (fLen >> 8) & 0xff; + tx[4] = fLen & 0xff; + let fi = 0; + while (fi < clientFinalLen) { + tx[5 + fi] = packed.charCodeAt(fi) & 0xff; + fi = fi + 1; + } + this.sock.writeBytes(tx, 1 + fLen); + + // ---- Step 4: expect AuthenticationSASLFinal and verify signature ---- + const t2 = this._pumpOneFrame(deadline); + if (t2 === 0) { + this._lastError = "scram: timeout waiting for SASLFinal"; + return false; + } + if (t2 === T_ERROR) { + this._consumeCurrentFrame(); + return false; + } + if (t2 !== T_AUTH) { + this._lastError = "scram: expected Authentication frame, got " + t2; + this._consumeCurrentFrame(); + return false; + } + const rx3 = this.rx; + const po3 = this._framePayloadOff; + const pe3 = this._framePayloadEnd; + const sub3 = (rx3[po3] << 24) | (rx3[po3 + 1] << 16) | (rx3[po3 + 2] << 8) | rx3[po3 + 3]; + if (sub3 !== AUTH_SASL_FINAL) { + this._lastError = "scram: expected SASLFinal (12), got " + sub3; + this._consumeCurrentFrame(); + return false; + } + let serverFinal = ""; + let sfi2 = po3 + 4; + while (sfi2 < pe3) { + serverFinal = serverFinal + String.fromCharCode(rx3[sfi2]); + sfi2 = sfi2 + 1; + } + this._consumeCurrentFrame(); + + if (cs_scram_verify_server_final(serverFinal, serverSigB64) !== 1) { + this._lastError = "scram: server signature mismatch"; + return false; + } + return true; + } + end(): void { if (this.connected === 1) { const tx = this.tx; diff --git a/scripts/build-target-sdk.sh b/scripts/build-target-sdk.sh index 75f12b56..789ce2db 100755 --- a/scripts/build-target-sdk.sh +++ b/scripts/build-target-sdk.sh @@ -72,7 +72,7 @@ fi # Copy C bridge object files echo " Copying bridge objects..." -for bridge in child-process-bridge.o os-bridge.o strlen-cache.o time-bridge.o base64-bridge.o url-bridge.o uri-bridge.o regex-bridge.o dotenv-bridge.o watch-bridge.o lws-bridge.o multipart-bridge.o child-process-spawn.o trampoline-bridge.o arena-bridge.o curl-bridge.o pg-bridge.o net-bridge.o compress-bridge.o yaml-bridge.o string-ops-bridge.o llvm-bridge.o llvm-builder-bridge.o lld-bridge.o; do +for bridge in child-process-bridge.o os-bridge.o strlen-cache.o time-bridge.o base64-bridge.o url-bridge.o uri-bridge.o regex-bridge.o dotenv-bridge.o watch-bridge.o lws-bridge.o multipart-bridge.o child-process-spawn.o trampoline-bridge.o arena-bridge.o curl-bridge.o pg-bridge.o net-bridge.o scram-bridge.o compress-bridge.o yaml-bridge.o string-ops-bridge.o llvm-bridge.o llvm-builder-bridge.o lld-bridge.o; do if [ -f "$C_BRIDGES_DIR/$bridge" ]; then cp "$C_BRIDGES_DIR/$bridge" "$SDK_DIR/bridges/" fi diff --git a/scripts/build-vendor.sh b/scripts/build-vendor.sh index 835b5f18..d3f2f523 100755 --- a/scripts/build-vendor.sh +++ b/scripts/build-vendor.sh @@ -626,6 +626,23 @@ else echo "==> net-bridge already built, skipping" fi +# --- scram-bridge (SCRAM-SHA-256 helpers for pg driver) --- +SCRAM_BRIDGE_SRC="$C_BRIDGES_DIR/scram-bridge.c" +SCRAM_BRIDGE_OBJ="$C_BRIDGES_DIR/scram-bridge.o" +if [ ! -f "$SCRAM_BRIDGE_OBJ" ] || [ "$SCRAM_BRIDGE_SRC" -nt "$SCRAM_BRIDGE_OBJ" ]; then + echo "==> Building scram-bridge..." + OSSL_INC="" + if [ -d "/opt/homebrew/opt/openssl/include" ]; then + OSSL_INC="-I/opt/homebrew/opt/openssl/include" + elif [ -d "/usr/local/opt/openssl/include" ]; then + OSSL_INC="-I/usr/local/opt/openssl/include" + fi + cc -c -O2 -fPIC $OSSL_INC "$SCRAM_BRIDGE_SRC" -o "$SCRAM_BRIDGE_OBJ" + echo " -> $SCRAM_BRIDGE_OBJ" +else + echo "==> scram-bridge already built, skipping" +fi + # --- trampoline-bridge (C-ABI closure slot table) --- TRAMP_SRC="$C_BRIDGES_DIR/trampoline-bridge.c" TRAMP_OBJ="$C_BRIDGES_DIR/trampoline-bridge.o" diff --git a/src/compiler.ts b/src/compiler.ts index 15929f76..b7b7b038 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -573,6 +573,7 @@ export function compile( const curlBridgeObj = generator.usesCurl ? `${bridgePath}/curl-bridge.o` : ""; const pgBridgeObj = usesPostgres ? `${bridgePath}/pg-bridge.o` : ""; const netBridgeObj = usesNet ? `${bridgePath}/net-bridge.o` : ""; + const scramBridgeObj = usesNet ? `${bridgePath}/scram-bridge.o` : ""; const compressBridgeObj = generator.usesCompression ? `${bridgePath}/compress-bridge.o` : ""; const yamlBridgeObj = generator.usesYaml ? `${bridgePath}/yaml-bridge.o` : ""; let extraObjs = ""; @@ -750,7 +751,7 @@ export function compile( const userObjs = extraLinkObjs.length > 0 ? " " + extraLinkObjs.join(" ") : ""; const userPaths = extraLinkPaths.map((p) => ` -L${p}`).join(""); const userLibs = extraLinkLibs.map((l) => ` -l${l}`).join(""); - const linkCmd = `${linker} ${objFile} ${lwsBridgeObj} ${regexBridgeObj} ${cpBridgeObj} ${osBridgeObj} ${strlenCacheObj} ${timeBridgeObj} ${base64BridgeObj} ${urlBridgeObj} ${uriBridgeObj} ${dotenvBridgeObj} ${watchBridgeObj} ${arenaBridgeObj} ${trampBridgeObj} ${cpSpawnObj} ${curlBridgeObj} ${pgBridgeObj} ${netBridgeObj} ${compressBridgeObj} ${yamlBridgeObj} ${stringOpsBridgeObj}${extraObjs}${userObjs} -o ${outputFile}${noPie}${debugFlag}${stripFlag}${staticFlag}${crossTarget}${crossLinker}${suppressLdWarnings}${sanitizeFlags} ${linkLibs}${userPaths}${userLibs}`; + const linkCmd = `${linker} ${objFile} ${lwsBridgeObj} ${regexBridgeObj} ${cpBridgeObj} ${osBridgeObj} ${strlenCacheObj} ${timeBridgeObj} ${base64BridgeObj} ${urlBridgeObj} ${uriBridgeObj} ${dotenvBridgeObj} ${watchBridgeObj} ${arenaBridgeObj} ${trampBridgeObj} ${cpSpawnObj} ${curlBridgeObj} ${pgBridgeObj} ${netBridgeObj} ${scramBridgeObj} ${compressBridgeObj} ${yamlBridgeObj} ${stringOpsBridgeObj}${extraObjs}${userObjs} -o ${outputFile}${noPie}${debugFlag}${stripFlag}${staticFlag}${crossTarget}${crossLinker}${suppressLdWarnings}${sanitizeFlags} ${linkLibs}${userPaths}${userLibs}`; logger.info(` ${linkCmd}`); const linkStdio = logger.getLevel() >= LogLevel_Verbose ? "inherit" : "pipe"; execSync(linkCmd, { stdio: linkStdio }); diff --git a/src/native-compiler-lib.ts b/src/native-compiler-lib.ts index 41585f1a..caf21e5b 100644 --- a/src/native-compiler-lib.ts +++ b/src/native-compiler-lib.ts @@ -745,6 +745,7 @@ export function compileNative(inputFile: string, outputFile: string): void { const curlBridgeObj = generator.getUsesCurl() ? effectiveBridgePath + "/curl-bridge.o" : ""; const pgBridgeObj = usesPostgres ? effectiveBridgePath + "/pg-bridge.o" : ""; const netBridgeObj = usesNet ? effectiveBridgePath + "/net-bridge.o" : ""; + const scramBridgeObj = usesNet ? effectiveBridgePath + "/scram-bridge.o" : ""; const compressBridgeObj = generator.getUsesCompression() ? effectiveBridgePath + "/compress-bridge.o" : ""; @@ -814,6 +815,8 @@ export function compileNative(inputFile: string, outputFile: string): void { " " + netBridgeObj + " " + + scramBridgeObj + + " " + compressBridgeObj + " " + yamlBridgeObj + diff --git a/tests/fixtures/stdlib/pg-scram.ts b/tests/fixtures/stdlib/pg-scram.ts new file mode 100644 index 00000000..a3533179 --- /dev/null +++ b/tests/fixtures/stdlib/pg-scram.ts @@ -0,0 +1,40 @@ +// @test-requires-env: PG_SCRAM_TESTS_ENABLED +// SCRAM-SHA-256 auth handshake against a Postgres server configured with +// password_encryption=scram-sha-256. Kept behind PG_SCRAM_TESTS_ENABLED so +// the default PG_TESTS_ENABLED runs (which expect md5) stay green — the +// scram CI matrix flips the gate once postgres is reconfigured. + +import { Client } from "chadscript/pg"; + +function main(): void { + // CI provisions `scramuser` (scram-sha-256 hashed) alongside the default + // `postgres` user (md5-hashed). Local runs can override via PG_SCRAM_USER. + const user = process.env.PG_SCRAM_USER ?? "scramuser"; + const db = process.env.PGDATABASE ?? "chadtest"; + const pw = process.env.PG_SCRAM_PASSWORD ?? "test"; + const c = new Client({ + host: "127.0.0.1", + port: 5432, + user: user, + database: db, + password: pw, + }); + if (!c.connect()) { + console.log("FAIL connect: " + c.lastError()); + return; + } + const r = c.query("SELECT 1 AS x, 'hello'::text AS msg"); + if (r.rowCount !== 1) { + console.log("FAIL rowCount " + r.rowCount); + return; + } + const row = r.rows[0] as string[]; + if (row[0] !== "1" || row[1] !== "hello") { + console.log("FAIL row " + row[0] + "|" + row[1]); + return; + } + c.end(); + console.log("TEST_PASSED"); +} + +main();