Skip to content

Commit 988809f

Browse files
committed
Replace scattered state guards with a trait-based state machine
The SSL class checked `_ssl.is_null()` and compared `_state` at the top of every method, with each site making its own decisions about which states allowed which operations. The scattered guards had already diverged: `read()` and `alpn_selected()` on an `SSLError` session called into OpenSSL when they should have returned `None`, matching how `SSLAuthFail` already behaved. Seven internal state classes now implement a `_SSLSessionState` trait. The SSL class delegates every operation to the current state, and Pony's type system enforces that every state handles every operation. Closes #137
1 parent 37b2757 commit 988809f

4 files changed

Lines changed: 599 additions & 141 deletions

File tree

.release-notes/next-release.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,11 @@ match ssl.state()
2828
| SSLDisposed => // ...
2929
end
3030
```
31+
32+
## Fix alpn_selected() returning a protocol from a failed session
33+
34+
`alpn_selected()` on a session in `SSLError` returned the ALPN protocol that was negotiated before the session failed, instead of `None`. A session in `SSLAuthFail` already returned `None`; the `SSLError` path was missing the same guard.
35+
36+
## Fix read() calling into OpenSSL on a failed session
37+
38+
`read()` on a session in `SSLError` called `SSL_read` and cleared the thread's error queue before returning `None`. A session in `SSLAuthFail` already returned `None` without touching OpenSSL; the `SSLError` path was missing the same guard.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to this project will be documented in this file. This projec
66

77
### Fixed
88

9+
- Fix alpn_selected() returning a protocol from a failed session ([PR #149](https://github.com/ponylang/ssl/pull/149))
10+
- Fix read() calling into OpenSSL on a failed session ([PR #149](https://github.com/ponylang/ssl/pull/149))
911

1012
### Added
1113

ssl/net/_test.pony

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ actor \nodoc\ Main is TestList
4141
test(_TestSSLReadPendingExceedsExpect)
4242
test(_TestSSLReadOnAuthFail)
4343
test(_TestSSLALPNSelectedOnAuthFail)
44+
test(_TestSSLALPNSelectedOnError)
4445
test(_TestSSLReceiveOnAuthFail)
4546
test(_TestSSLReceiveOnError)
4647
test(_TestSSLCloseFromReady)
@@ -52,6 +53,9 @@ actor \nodoc\ Main is TestList
5253
test(_TestSSLReceiveInClosed)
5354
test(_TestSSLWriteBlockedInClosed)
5455
test(_TestSSLBidirectionalCloseRoundtrip)
56+
test(_TestSSLCorruptRecordInClosed)
57+
test(_TestSSLCanSendOnFailedSession)
58+
test(_TestSSLWriteBlockedInClosing)
5559
test(_TestSSLDisposeBeforeHandshake)
5660
test(_TestSSLDisposeTwice)
5761
test(_TestSSLReadAfterDispose)
@@ -2515,6 +2519,82 @@ class \nodoc\ iso _TestSSLALPNSelectedOnAuthFail is UnitTest
25152519
client.dispose()
25162520
server.dispose()
25172521

2522+
class \nodoc\ iso _TestSSLALPNSelectedOnError is UnitTest
2523+
"""
2524+
`alpn_selected` on a session in `SSLError` returns `None`, even though
2525+
the session negotiated a protocol before it failed.
2526+
"""
2527+
fun name(): String => "net/ssl/SSL.alpn_selected/on_error"
2528+
2529+
fun apply(h: TestHelper) =>
2530+
let auth = FileAuth(h.env.root)
2531+
let client_ctx =
2532+
try
2533+
recover val
2534+
SSLContext
2535+
.> set_cert(
2536+
FilePath(auth, "assets/cert.pem"),
2537+
FilePath(auth, "assets/key.pem"))?
2538+
.> set_authority(FilePath(auth, "assets/cert.pem"))?
2539+
.> alpn_set_client_protocols(["h2"])
2540+
end
2541+
else
2542+
h.fail("client ssl context setup failed")
2543+
return
2544+
end
2545+
let server_ctx =
2546+
try
2547+
recover val
2548+
SSLContext
2549+
.> set_cert(
2550+
FilePath(auth, "assets/cert.pem"),
2551+
FilePath(auth, "assets/key.pem"))?
2552+
.> set_authority(FilePath(auth, "assets/cert.pem"))?
2553+
.> alpn_set_resolver(ALPNStandardProtocolResolver(["h2"]))
2554+
end
2555+
else
2556+
h.fail("server ssl context setup failed")
2557+
return
2558+
end
2559+
2560+
(let client, let server) =
2561+
try
2562+
_TestSSLSessionPair.attempt(h, client_ctx, server_ctx)?
2563+
else
2564+
h.fail("could not create an SSL session pair")
2565+
return
2566+
end
2567+
2568+
h.assert_true(
2569+
client.state() is SSLReady,
2570+
"the client should be in SSLReady after a successful handshake")
2571+
2572+
h.assert_true(
2573+
try (server.alpn_selected() as String) == "h2" else false end,
2574+
"the server should have negotiated h2 before the failure")
2575+
2576+
try
2577+
_TestSSLCorruptRecord(client, server, "hello")?
2578+
else
2579+
h.fail("could not send a corrupted record")
2580+
client.dispose()
2581+
server.dispose()
2582+
return
2583+
end
2584+
2585+
server.read()
2586+
2587+
h.assert_true(
2588+
server.state() is SSLError,
2589+
"a corrupted record should put the server in SSLError")
2590+
2591+
h.assert_true(
2592+
server.alpn_selected() is None,
2593+
"alpn_selected() on an SSLError session should return None")
2594+
2595+
client.dispose()
2596+
server.dispose()
2597+
25182598
class \nodoc\ iso _TestSSLReceiveOnAuthFail is UnitTest
25192599
"""
25202600
`receive` on a session in `SSLAuthFail` does nothing.
@@ -4479,6 +4559,178 @@ class \nodoc\ iso _TestSSLBidirectionalCloseRoundtrip is UnitTest
44794559
client.dispose()
44804560
server.dispose()
44814561

4562+
class \nodoc\ iso _TestSSLCorruptRecordInClosed is UnitTest
4563+
"""
4564+
A corrupted record received while in `_Closed` transitions to `SSLError`.
4565+
4566+
The session has already sent its own `close_notify` and is in `_Closed`.
4567+
A corrupted record arriving now is a genuine error. `_Closed.read`
4568+
preserves `_Closed` on a clean `zero_return` but lets `_Errored`
4569+
through.
4570+
"""
4571+
fun name(): String => "net/ssl/SSL.read/corrupt_record_in_closed"
4572+
4573+
fun apply(h: TestHelper) =>
4574+
(let client, let server) =
4575+
try
4576+
_TestSSLSessionPair(h)?
4577+
else
4578+
h.fail("could not establish an SSL session pair")
4579+
return
4580+
end
4581+
4582+
// Encrypt a record from the server before the close flow starts.
4583+
// This record belongs to the same TLS session, so corrupting it triggers
4584+
// a MAC failure the client's session can detect.
4585+
var saved_record: Array[U8] iso = recover iso Array[U8] end
4586+
try
4587+
server.write("payload")?
4588+
saved_record = server.send()?
4589+
else
4590+
h.fail("could not produce a record to corrupt")
4591+
client.dispose()
4592+
server.dispose()
4593+
return
4594+
end
4595+
4596+
// Get the client into _Closed by calling close()
4597+
client.close()
4598+
4599+
h.assert_true(
4600+
client.state() is SSLClosed,
4601+
"client should be SSLClosed after close()")
4602+
4603+
// Feed the corrupted record — same session, so the MAC failure is real
4604+
try
4605+
let last = saved_record.size() - 1
4606+
saved_record(last)? = saved_record(last)? xor 0xFF
4607+
client.receive(consume saved_record)
4608+
else
4609+
h.fail("could not corrupt the saved record")
4610+
client.dispose()
4611+
server.dispose()
4612+
return
4613+
end
4614+
4615+
client.read()
4616+
4617+
h.assert_true(
4618+
client.state() is SSLError,
4619+
"a corrupted record in _Closed should transition to SSLError")
4620+
4621+
client.dispose()
4622+
server.dispose()
4623+
4624+
class \nodoc\ iso _TestSSLCanSendOnFailedSession is UnitTest
4625+
"""
4626+
`can_send` and `send` work on a failed session, allowing retrieval of
4627+
alert bytes that OpenSSL queued in the output BIO before the failure.
4628+
4629+
A handshake failure from non-TLS input produces an alert. `can_send` returns
4630+
`true` while those bytes are in the BIO, and `send` retrieves them.
4631+
"""
4632+
fun name(): String => "net/ssl/SSL.can_send/on_failed_session"
4633+
4634+
fun apply(h: TestHelper) =>
4635+
(let client, let server) =
4636+
try
4637+
_TestSSLSessionPair.fresh(h)?
4638+
else
4639+
h.fail("could not create a fresh SSL session pair")
4640+
return
4641+
end
4642+
4643+
// Drive the handshake far enough that the server has state, then corrupt it
4644+
try
4645+
_TestSSLTransfer(client, server)?
4646+
_TestSSLTransfer(server, client)?
4647+
else
4648+
h.fail("could not drive initial handshake exchange")
4649+
client.dispose()
4650+
server.dispose()
4651+
return
4652+
end
4653+
4654+
// Feed non-TLS bytes to the server to trigger an error with an alert
4655+
server.receive("NOT TLS DATA\r\n")
4656+
server.read()
4657+
4658+
h.assert_true(
4659+
server.state() is SSLError,
4660+
"server should be in SSLError after receiving non-TLS bytes")
4661+
4662+
h.assert_true(
4663+
server.can_send(),
4664+
"failed session should have alert bytes to send")
4665+
4666+
try
4667+
let alert = server.send()?
4668+
h.assert_true(
4669+
alert.size() > 0,
4670+
"alert bytes from a failed session should not be empty")
4671+
else
4672+
h.fail("send() on a failed session with queued alert raised an error")
4673+
end
4674+
4675+
h.assert_false(
4676+
server.can_send(),
4677+
"can_send should be false after sending the alert")
4678+
4679+
client.dispose()
4680+
server.dispose()
4681+
4682+
class \nodoc\ iso _TestSSLWriteBlockedInClosing is UnitTest
4683+
"""
4684+
`write` raises an error in `_Closing`.
4685+
4686+
The peer has sent `close_notify`, and we have not responded yet. Writing
4687+
application data is not allowed — the only valid next step is `close` to
4688+
send our own `close_notify`.
4689+
"""
4690+
fun name(): String => "net/ssl/SSL.write/blocked_in_closing"
4691+
4692+
fun apply(h: TestHelper) =>
4693+
(let client, let server) =
4694+
try
4695+
_TestSSLSessionPair(h)?
4696+
else
4697+
h.fail("could not establish an SSL session pair")
4698+
return
4699+
end
4700+
4701+
// Get server into _Closing: client sends close_notify, server reads it
4702+
client.close()
4703+
4704+
try
4705+
_TestSSLTransfer(client, server)?
4706+
else
4707+
h.fail("could not transfer close_notify")
4708+
client.dispose()
4709+
server.dispose()
4710+
return
4711+
end
4712+
4713+
server.read()
4714+
4715+
h.assert_true(
4716+
server.state() is SSLClosed,
4717+
"server should report SSLClosed after receiving peer's close_notify")
4718+
4719+
let write_succeeded =
4720+
try
4721+
server.write("should fail")?
4722+
true
4723+
else
4724+
false
4725+
end
4726+
4727+
h.assert_false(
4728+
write_succeeded,
4729+
"write should raise an error in _Closing")
4730+
4731+
client.dispose()
4732+
server.dispose()
4733+
44824734
class \nodoc\ iso _TestMatchNameEmptyName is UnitTest
44834735
fun name(): String => "net/ssl/X509._match_name/empty_name"
44844736

0 commit comments

Comments
 (0)