Skip to content

Add SCRAM-SHA-256 authentication#4

Open
thierrymarianne wants to merge 12 commits into
aarroyoc:mainfrom
thierrymarianne:scram-sha-256-auth-upstream
Open

Add SCRAM-SHA-256 authentication#4
thierrymarianne wants to merge 12 commits into
aarroyoc:mainfrom
thierrymarianne:scram-sha-256-auth-upstream

Conversation

@thierrymarianne
Copy link
Copy Markdown

@thierrymarianne thierrymarianne commented May 22, 2026

Hello @aarroyoc 👋🏼,

I needed to connect to a PostgreSQL instance via SCRAM-SHA-256 authentication.

I thought you might be interested in this implementation.

I've noticed that Logtalk suite didn't work anymore with the most recent version of Scryer Prolog so I've tried to keep the suite separate.

Please let me know if you'd like to see something done differently.

Summary

  • Implements SCRAM-SHA-256 SASL auth (RFC 5802 + RFC 7677) so the library can connect to default-configured PostgreSQL 10+ servers and managed providers that no longer accept cleartext password auth.
  • messages.pl: decode AuthenticationRequest into a tagged term (ok / password / md5/1 / sasl/1 / sasl_continue/1 / sasl_final/1); add SASL response builders.
  • scram.pl (new): SCRAM-SHA-256 client with PBKDF2-HMAC-SHA-256 implemented on top of library(crypto). No new external dependencies.
  • connect/6 reads the first auth response and dispatches: ok → done, password → existing cleartext path (unchanged), sasl(_) → SCRAM-SHA-256 when offered. The existing cleartext flow is preserved; backward compatibility is decided at runtime by the server's auth-method byte.

Test plan

  • Local: just psql-up boots both postgres:16-alpine (password) and postgres:17-alpine (scram-sha-256);
    just test-scram runs the round-trip against the SCRAM instance on port 5433.
  • CI: existing Logtalk suite continues to run against the postgres:16-alpine password service; a second postgres:17-alpine scram service runs the SCRAM smoke test side by side.

Implements SCRAM-SHA-256 SASL authentication so the library can connect
to default-configured PostgreSQL 10+ instances and managed providers
that no longer accept cleartext password auth.

What changes:

- messages.pl: add auth_method/2 to decode any R(N) AuthenticationRequest
  into a tagged term (ok, password, md5/1, sasl/1, sasl_continue/1,
  sasl_final/1); add sasl_initial_response_message/3 and
  sasl_response_message/2 builders.
- scram.pl (new): SCRAM-SHA-256 client per RFC 5802 + RFC 7677.
  PBKDF2-HMAC-SHA-256 is implemented as HMAC iteration on top of
  library(crypto)'s crypto_data_hash/3 with the hmac/1 option. No new
  external dependencies.
- postgresql.pl: connect/6 now reads the first auth response, decodes
  it via auth_method/2, and dispatches:
    * ok           -> done
    * password     -> existing cleartext path (unchanged)
    * sasl(_)      -> SCRAM-SHA-256 if offered, otherwise throws
  Drive-by: accept the Host argument as either an atom or chars
  (current Scryer Prolog's socket_client_open/3 requires an atom).

Verified end-to-end against postgres:17-alpine configured with
POSTGRES_HOST_AUTH_METHOD=scram-sha-256 (no cleartext fallback). The
existing cleartext flow is preserved; backward compatibility is decided
at runtime by the server's auth-method byte.

Out of scope (deferred): SASLprep (RFC 4013) -- ASCII passwords only;
channel binding (SCRAM-SHA-256-PLUS); TLS; MD5; GSS/SSPI.

Test:
  just psql-up
  just test-scram
Comment thread scram.pl Outdated
]).

% Reads bytes from Stream the same way postgresql.pl get_bytes/2 does.
:- use_module('types', [int32/2]).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to quote atoms like types!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, the unnecessary quotes have been removed.

Comment thread scram.pl Outdated
append("n=", User, NUser0),
append(NUser0, ",r=", NUser1),
append(NUser1, ClientNonce, ClientFirstBare),
append("n,,", ClientFirstBare, ClientFirst),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this something like phrase(("n=",seq(User),",r=",seq(ClientNonce),"n,,",seq(ClientFirstBare),...", Bytes)?

Then maybe using phrase/2 and DCGs could make the construction more readable.

@thierrymarianne thierrymarianne force-pushed the scram-sha-256-auth-upstream branch 2 times, most recently from a719463 to b20b14a Compare May 22, 2026 17:02
CI now boots two Postgres services side by side:

  - postgres (16-alpine, POSTGRES_HOST_AUTH_METHOD=password, port 5432)
    used by the existing Logtalk test suite.
  - postgres-scram (17-alpine, scram-sha-256, port 5433)
    used by the new SCRAM-SHA-256 round-trip smoke test.

Local docker-compose.yml mirrors this split.

logtalk_tester now runs in verbose mode (-o verbose) so subsequent
"broken" failures surface the actual load error in the CI log.

scram_test.pl defaults to DATABASE_PORT=5433 so it targets the scram
container by default.
The existing Logtalk-driven test set fails to load on CI for reasons
that don't reproduce with plain scryer-prolog, and chasing that
diagnostic isn't on the critical path for the SCRAM-SHA-256
contribution. The SCRAM round-trip step alone gives CI a meaningful
green/red signal for this branch's purpose.

The postgres password service is left in services{} for re-enabling
the Logtalk suite once the load issue is understood.
The Logtalk suite needs scryer-prolog pinned to v0.9.4 (last release the
Logtalk 3.70.0 scryer adapter compiles against), while the SCRAM round-trip
needs scryer master for its newer crypto APIs. Running both off one scryer
build forces a single version that satisfies neither; splitting lets each
job pin its own. The new workflow caches the built v0.9.4 binary so the
~5 min cargo build only happens on cache miss, and uploads the
logtalk_tester_logs/ directory on failure (the previous CI showed only
"broken / exit 5" because the per-tester .results file was never surfaced).

Also bumps every JS action to a Node 24 LTS runtime and replaces the
node16-based outliers: actions-rs/toolchain (archived org) -> dtolnay
rust-toolchain (composite, no Node), and logtalk-actions/setup-logtalk
(node16, no tagged releases) -> inline install.sh from the upstream tarball.
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
Signed-off-by: Thierry Marianne <thierry@marianne.io>
types.pl already worked around the bug by switching int32/2 and int16/2
from `<<` to `*`. tests/indexing_regression.pl now reproduces it: it
exercises the same clause shape as auth_method_/3 with both formulations
and halts non-zero when any probe mismatches its expected method.

The script runs in both CI workflows. The SCRAM job (v0.10.0) requires
it to pass, asserting the regression is fixed upstream. The Logtalk job
(v0.9.4) marks it continue-on-error so the failure stays visible in CI
without breaking the build. Two justfile targets cover the same versions
for local runs.

Signed-off-by: Thierry Marianne <thierry@marianne.io>
@thierrymarianne thierrymarianne force-pushed the scram-sha-256-auth-upstream branch from cb53df3 to 7580437 Compare May 23, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants