Skip to content

Add TLS support (upgrade-after-INFO) - #57

Open
ChrisFrizza6969 wants to merge 6 commits into
thesis-php:0.4.xfrom
ChrisFrizza6969:feat/tls-support
Open

Add TLS support (upgrade-after-INFO)#57
ChrisFrizza6969 wants to merge 6 commits into
thesis-php:0.4.xfrom
ChrisFrizza6969:feat/tls-support

Conversation

@ChrisFrizza6969

Copy link
Copy Markdown

Add TLS support (upgrade-after-INFO)

Summary

Adds client TLS so the driver can connect to TLS-required servers such as
Synadia NGS (tls://connect.ngs.global). Currently
the client only speaks plaintext — it always sends CONNECT with
tls_required: false and never performs a TLS handshake — so any server that
advertises tls_required: true resets the connection.

Motivation

NGS (and any NATS server with tls: {...}) sends a plaintext INFO advertising
tls_required: true, then expects the client to run the TLS handshake before
CONNECT. Without TLS there is no way to use this driver against those servers.

What changed

  • Config: new optional ?Amp\Socket\ClientTlsContext $tls (default null
    = existing plaintext behavior, fully backward compatible). fromURI enables
    TLS from a tls:// / nats+tls:// / ssl:// scheme, defaulting the peer name
    to the host for SNI/verification; fromArray accepts a tls key.
  • SocketConnectionFactory: attaches the TLS context to the ConnectContext
    so the socket can be upgraded (it does not connect TLS-first).
  • Framer: when TLS is configured, the single reader fiber reads the
    plaintext INFO line, runs setupTls(), and then pushes INFO to the
    parser. Doing the upgrade inside the sole socket-reader fiber preserves the
    single-reader invariant (amphp forbids concurrent reads on a socket); pushing
    INFO only after the handshake completes prevents startup() (a different
    fiber) from writing CONNECT mid-handshake and corrupting the stream.
  • SocketConnection::startup() is unchanged (no TLS logic).

Compatibility

The non-TLS code path is byte-for-byte unchanged: when $tls is null,
$upgradeTls is false and the Framer behaves exactly as before. Existing
tests are unaffected.

Scope / limitation

Implements the standard NATS upgrade-after-INFO flow. Servers configured with
tls { handshake_first: true } (TLS handshake before INFO) are not covered by
this PR.

Tests

  • Added ConfigTest cases: tls:// / nats+tls:// / ssl:// produce a
    ClientTlsContext with the host as peer name; a non-TLS scheme leaves tls
    null; fromArray accepts a tls key.
  • Verified locally: php-cs-fixer (no diff), phpunit ConfigTest green,
    phpstan level max / strict clean on the changed files.
  • Manually verified end-to-end against real NGS: plaintext INFO
    setupTls()CONNECTPONG, plus JetStream object store list/get/put
    over the resulting TLS connection.

cgf-afk and others added 3 commits July 2, 2026 13:49
Adds client TLS so the driver can connect to TLS-required servers such as
Synadia NGS (tls://connect.ngs.global). Previously the client only spoke
plaintext (CONNECT always sent tls_required:false) and any TLS-required
server reset the connection.

Design:
- Config: new optional `?ClientTlsContext $tls` (null = current plaintext
  behavior, fully backward compatible). `fromURI` enables TLS from a
  tls:// / nats+tls:// / ssl:// scheme (peer name defaults to the host for
  SNI/verification); `fromArray` accepts a `tls` key.
- SocketConnectionFactory: attaches the TLS context to the ConnectContext so
  the socket can be upgraded (does NOT connect TLS-first).
- Framer: when TLS is configured, the single reader fiber reads the plaintext
  INFO line, runs setupTls(), THEN pushes INFO to the parser. Doing the
  upgrade inside the sole socket-reader fiber preserves the single-reader
  invariant (amphp forbids concurrent reads); pushing INFO only after the
  handshake completes prevents startup() from writing CONNECT mid-handshake.
- SocketConnection::startup() is unchanged (no TLS logic).

Scope/limitation: implements the standard NATS upgrade-after-INFO flow
(server sends plaintext INFO advertising tls_required, client then upgrades).
Servers configured with `tls { handshake_first: true }` are not covered.

Non-TLS code path is byte-for-byte unchanged; existing tests unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fromURI tls://, nats+tls://, ssl:// -> ClientTlsContext with host as peer name
- non-tls scheme leaves tls null (backward compat)
- fromArray accepts a tls key

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Config.php Outdated
$scheme = strtolower($components['scheme'] ?? '');
if (($scheme === 'tls' || $scheme === 'nats+tls' || $scheme === 'ssl') && ($components['host'] ?? '') !== '') {
$firstHost = explode(':', explode(',', $components['host'])[0])[0];
$tls = (new \Amp\Socket\ClientTlsContext($firstHost));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
$tls = (new \Amp\Socket\ClientTlsContext($firstHost));
$tls = new ClientTlsContext($firstHost);

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.

Done imported it. Also caught a third FQCN in the fromArray docblock.

Comment thread src/Config.php Outdated
// set, the connection upgrades to TLS after the server's plaintext INFO
// (the standard NATS tls_required flow, e.g. Synadia NGS). The upgrade is
// performed by the socket's single reader fiber — see Framer.
public ?\Amp\Socket\ClientTlsContext $tls = null,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
public ?\Amp\Socket\ClientTlsContext $tls = null,
public ?ClientTlsContext $tls = null,

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.

Imported

Comment thread src/Config.php
Comment thread src/JetStream/ObjectStore/Store.php Outdated
// SBC firmware fetcher) can't read an object whose digest we wrote
// unpadded. base64encode() strips padding (correct for subject/name
// segments), so pad the digest explicitly here.
$encodedDigest = strtr(base64_encode($digest->finish()), '+/', '-_');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These changes should be made in another PR.

*/
public function startup(): void
{
// The TLS upgrade (when configured) happens inside the Framer's reader

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Unnecessary AI comment, imho.

- import ClientTlsContext instead of using the FQCN inline
- drop the explanatory comment in SocketConnection::startup()

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ChrisFrizza6969 and others added 2 commits July 28, 2026 15:24
Co-authored-by: Vadim Zanfir <vadimzanfir@gmail.com>
The accepted suggestion was authored against the pre-import file and
added a second identical use statement, which is a fatal error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// below reads the encrypted stream exactly as normal.
if ($upgradeTls) {
$preamble = '';
while (!str_contains($preamble, "\r\n")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The read loop stops as soon as $preamble contains \r\n, but nothing guarantees a single read() ends exactly at the delimiter: TCP can hand us more than the INFO line. If any bytes land after the first \r\n, we push them to the parser as plaintext (which then tries to parse post-upgrade bytes as a NATS frame) and setupTls() never sees them. Should we guard this explicitly (fail if there's anything after the first \r\n) rather than rely on it silently? Or is there a reason the tail can't happen that I'm missing?

@kafkiansky

Copy link
Copy Markdown
Collaborator

@ChrisFrizza6969, run make fixer locally, please.

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.

3 participants