Open badeend opened 5 months ago
I made an informal proposal at today's WASI meeting today: https://docs.google.com/presentation/d/1C55ph_fSTRhb4A4Nlpwvp9JGy8HBL6A1MvgY2jrapyQ/edit?usp=sharing
In a nutshell: I'd propose we translate the API of Rust's native-tls library to WIT as a starting point.
As @badeend pointed out, we could start even simpler than that and add an e.g. wasi:sockets/easy-client-tls
interface with a single function, e.g. wrap-tls: func(input: input-stream, output: output-stream, server-name: string) -> tuple<input-stream, output-stream>
, which would likely cover 80% of use cases.
In a nutshell: I'd propose we translate the API of Rust's native-tls library to WIT as a starting point.
If taking the native-tls API, the initial WIT version should aim to fill some of its current gaps:
PQC support in TLS is a hot topic at the moment and ensuring that the wasi-tls design allows the selection of TLSv1.3 and specific ciphersuites is important to ensuring that the design is flexible enough to support PQC related features that are coming.
I drafted up an interface at: https://github.com/WebAssembly/wasi-sockets/pull/104
I don't think a distinct easy-client-tls
interface as mentioned above is needed anymore. Even "fully fledged" interface is pretty straight forward to set up.
Below is an example of a TCP/TLS client in pseudo code. If you ignore the setup boilerplate, you'll see that the actual TLS client surface area is only two lines long: /* A */
& /* B */
let ip = resolve_addresses("example.com").await?[0];
let tcp_client = TcpSocket::new();
let (tcp_input, tcp_output) = tcp_client.connect(ip, 443).await;
let tls_client = TlsClient::new("example.com", SuspensionPoints::none()); /* A */
let tls_streams = tls_client.streams()?;
forward(tcp_input, tls_streams.public_output);
forward(tls_streams.public_input, tcp_output);
tls_client.resume(); /* B */
tls_streams.private_output.blocking_write_and_flush("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n");
let response = tls_streams.private_input.blocking_read();
println!(response);
/// Pump all data from `src` to `dest` on a background task
fn forward(src: InputStream, dest: OutputStream) -> JoinHandle {
spawn_blocking(|| {
loop {
dest.blocking_splice(src).await?;
}
})
}
If you're wondering what the SuspensionPoints
& resume
stuff is about, I invite you to read the docs on the tls-client
resource in the PR. TLDR; it is the least bad solution I could come up with to handle callbacks, which TLS libraries love to use but the CM doesn't support.
Below is a more detailed showcase of these suspensions, this time from the POV of a server:
let id1 = PrivateIdentity::parse(
fs::read("private1.key"),
fs::read("public1.crt"),
)?;
let id2 = PrivateIdentity::parse(
fs::read("private2.key"),
fs::read("public2.crt"),
)?;
let tcp_server = TcpSocket::new();
tcp_server.bind(443);
tcp_server.listen();
loop {
let (tcp_client, tcp_input, tcp_output) = tcp_server.accept().await;
let tls_server = TlsServer::new(SuspensionPoints::ClientHello | SuspensionPoints::Accepted);
let tls_streams = tls_server.streams()?;
{
tls_client.configure_alpn_ids(["h2"]);
forward(tcp_input, tls_streams.public_output);
forward(tls_streams.public_input, tcp_output);
tls_server.resume(); // Accept initiate handshake
}
{
let suspension = wait_suspend(tls_server).await; // Wait for client hello
assert!(suspension.at() == SuspensionPoints::ClientHello);
// Select certificate based on SNI:
match suspension.requested_server_name()? {
Some("example.com") => {
tls_server.configure_identities([id1]);
}
_ => tls_server.configure_identities([id2]);
}
tls_server.resume(); // Continue handshake
}
{
let suspension = wait_suspend(tls_server).await; // Wait for handshake to complete
assert!(suspension.at() == SuspensionPoints::Accepted);
println!("Fully connected!");
tls_server.resume();
}
{
let request = tls_streams.private_input.blocking_read();
println!(request);
}
}
async fn wait_suspend(tls: TlsServer) {
loop {
let result = tls.suspend();
if result == Err(NotReady) {
tls.subscribe().block();
} else {
return result;
}
}
}
Here is SslStream Platform Abstraction Layer (PAL) for dotnet running on Android (consuming Java API). https://github.com/dotnet/runtime/blob/main/src/native/libs/System.Security.Cryptography.Native.Android/pal_sslstream.c https://github.com/dotnet/runtime/blob/main/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamPal.Android.cs
I guess this is closest to what we need to do for WASI TLS on dotnet. TLSv1.3 only I think. It seems managing and validating certificates is complex feature.
The other important thing to notice about .NET's SSLStream
is that it can wrap an arbitrary Stream
implementation, e.g. a MemoryStream
, a FileStream
, a SocketStream
, etc. So we need to make sure that this interface can support such an abstraction.
If I'm reading @badeend's draft interface correctly, it looks like we could do that by piping the public-input
and public-output
streams to the wrapped .NET Stream
, possibly optimizing the the FileStream
and SocketStream
cases by using splice
, and falling back to using read
and write
for e.g. MemoryStream
and others.
I guess WASI stream will be Pollable
(or Promise in the future).
While we implement it in single-thread we can only support it for async APIs of C# Stream
We can't be blocked by Pollable
and so synchronous APIs of Stream can't be implemented.
That's fine, we already have the same for HTTP response stream when running in a browser.
Right, I was assuming we'd only support e.g. ReadAsync
and WriteAsync
.
For reference, here are the thin wrappers I built around wasi:io/streams/input-stream
and wasi:io/streams/output-stream
, respectively:
https://github.com/dicej/spin-dotnet-sdk/blob/main/src/InputStream.cs https://github.com/dicej/spin-dotnet-sdk/blob/main/src/OutputStream.cs
The other important thing to notice about .NET's
SSLStream
is that it can wrap an arbitraryStream
implementation, e.g. aMemoryStream
, aFileStream
, aSocketStream
, etc. So we need to make sure that this interface can support such an abstraction.If I'm reading @badeend's draft interface correctly, it looks like we could do that by piping the
public-input
andpublic-output
streams to the wrapped .NETStream
, possibly optimizing the theFileStream
andSocketStream
cases by usingsplice
, and falling back to usingread
andwrite
for e.g.MemoryStream
and others.
Pretty much, yes. 👍 The tls-client/server
doesn't care where its TLS data comes from and goes to.
Here is SslStream Platform Abstraction Layer (PAL) for dotnet running on Android (consuming Java API). [...] I guess this is closest to what we need to do for WASI TLS on dotnet.
Thanks for the links. Good to know what the dotnet team has already encountered and how they solved the various differences.
Looking at the source for the Android backend and similarly for the OpenSSL backend; there's a lot of "intricate" code. Don't know if this is what you were suggesting, but; I don't we should attempt to emulate one of these existing backend interfaces with all its warts. Rather, I think we should start at the user-facing interface and work our way back from there.
We can't be blocked by Pollable and so synchronous APIs of Stream can't be implemented.
Interesting, why's that? Can't you use blocking_read
etc?
We can't be blocked by Pollable and so synchronous APIs of Stream can't be implemented.
Interesting, why's that? Can't you use
blocking_read
etc?
Perhaps he meant "we can implement them, but using them means no concurrency" (which might be fine for simple use cases).
I did some exploratory investigation on how much of the .NET interface is theoretically supported by the draft. The results are now included in the PR. Overall; not bad.
What sticks out is that about half of the "Not supported"s are to do with lower-level primitives (cipher suites, hashing algorithms, their parameters, etc) i.e. the potential footguns. So we'll need to carefully consider if/how to expose them.
We can't be blocked by Pollable and so synchronous APIs of Stream can't be implemented.
Interesting, why's that? Can't you use
blocking_read
etc?Perhaps he meant "we can implement them, but using them means no concurrency" (which might be fine for simple use cases).
Yes, we can implement it, but we would not do that for ST build, because
I think we are not "simple use case" with dotnet, but it may make sense for others.
Looking at the source for the Android backend and similarly for the OpenSSL backend; there's a lot of "intricate" code. Don't know if this is what you were suggesting, but; I don't we should attempt to emulate one of these existing backend interfaces with all its warts.
Agreed, we don't want/need to implement full set of those features. I only shared it to broaden our design perspective. The actual scope should be limited initially.
After we enable the initial experience, we will learn more. About the use cases which are not possible without those missing features. We can add more incrementally, hopefully without too many breaking changes.
I did some exploratory investigation on how much of the .NET interface is theoretically supported by the draft. The results are now included in the PR. Overall; not bad.
This is great, thanks!
cc @ManickaP @karelz @wfurt could you guys please provide feedback about the proposed design of
WASI interface definition for TLS stream https://github.com/badeend/wasi-sockets/blob/tls/wit/tls.wit
And proposed scope for C# SslStream ? https://github.com/badeend/wasi-sockets/blob/tls/TLS.md
What would be major existing use-cases which would not work ?
Is that enough for Microsoft.Data.SqlClient
and similar use cases ?
Adding @rzikm
I did some exploratory investigation on how much of the .NET interface is theoretically supported by the draft. The results are now included in the PR. Overall; not bad.
And it now also includes the same thing for Node.JS.
Sorry for longer text, the topics/ideas kept accumulating as I read the two linked files.
most of the "Not supported" members are informative only and *Strength
and *Algorithm
members will be obsoleted in .NET 10, what remains is
I assume "not supported" stands for lack of configuration support, not for lack of feature in general. lack of customizability of some features should not be a problem as long as the behavior is sane.
true
)true
on clients and false
on servers. TLS 1.3 does not have a concept of renegotiation.
- ClientCertificateRequired - This one is slightly concerning, lack of customization here likely means that mutual TLS scenarios are going to be blocked (clients can't send certs by themselves, server must request them explicitly)I see that the WIT file has
/// The combination of a private key with its public certificate(s).
/// The private key data can not be exported.
resource private-identity {
/// TODO: find a way to "preopen" these private-identity resources, so that the sensitive private key data never has to flow through the guest.
parse: static func(private-key: list<u8>, x509-chain: list<list<u8>>) -> result<private-identity>;
public-identity: func() -> public-identity;
}
The 'private-identity' is more or less equivalent to the SslStreamCertificateContext, it is a certificate + intermediates + some extra info, so you can consider it supported.
While on the topic of certificates, there is an important point to consider when designing APIs. Windows is very specific in a way how it works with certificates, basically, the sensitive operations with private keys are performed by a separate process (lsass) and application communicates with lsass over IPC. Applications refer to a certificate using a handle.
This becomes somewhat problematic when attempting to send certificate chains when intermediates are not stored in the OS certificate store. The relevant SChannel API takes only a single certificate handle parameter, and attempts to build the certificate chain internally. Since the certificate chain is bulit by separate process and the intermediates are not in a store, lsass/Schannel can't find the intermediates, which can result in only the leaf certificate being sent over the wire, which in the end means connection errors because the remote peer may not be able to verify the certificate. In .NET, we workaround this fairly common case by inserting the intermediates to the cert stores when building the SslStreamCertificateContext.
There is some discussion about the topic at https://github.com/dotnet/runtime/issues/26323
The other reason I am bringing this up is that on Windows, Having an access to certificate handle does not mean you have access to a certificate private key. You can lookup certs in a store by Thumbprint and get handle back, but by configuration the application may not be able to export private key to be able to pass it to your API, so consider adding more options of creating private-identity
Also, note that there are multiple binary formats for X509 certificates (DER, PKCS12, ...) so you probably want to reflect that in the parse
function.
I suggest adding following
type tls-cipher-suite = u16; // wire identifier of the negotiated cipher suite. e.g. 0x1301 for TLS_AES_128_GCM_SHA256
resource client {
negotiated-cipher-suite: func() -> option<tls-cipher-suite>
}
resource server {
negotiated-cipher-suite: func() -> option<tls-cipher-suite>
/// enables mutual auth, should be set during client-hello suspension point at the latest
configure-require-client-cert: func(bool) -> result;
}
otherwise the proposed API seems reasonable to be able to cover most of the use cases.
cc @wfurt in case I forgot something.
TODO
https://github.com/Mbed-TLS/mbedtls https://github.com/enarx-archive/tlssock