WebAssembly / wasi-sockets

WASI API proposal for managing sockets
233 stars 21 forks source link

TLS (Transport Layer Security) #100

Open badeend opened 5 months ago

badeend commented 5 months ago

TODO

https://github.com/Mbed-TLS/mbedtls https://github.com/enarx-archive/tlssock

dicej commented 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.

stevedoyle commented 5 months ago

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.

badeend commented 3 weeks ago

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;
        }
    }
}
pavelsavara commented 3 weeks ago

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.

dicej commented 3 weeks ago

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.

pavelsavara commented 3 weeks ago

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.

dicej commented 3 weeks ago

Right, I was assuming we'd only support e.g. ReadAsync and WriteAsync.

dicej commented 3 weeks ago

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

badeend commented 3 weeks ago

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.

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?

dicej commented 3 weeks ago

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).

badeend commented 3 weeks ago

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.

pavelsavara commented 3 weeks ago

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.

pavelsavara commented 3 weeks ago

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!

pavelsavara commented 3 weeks ago

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 ?

karelz commented 3 weeks ago

Adding @rzikm

badeend commented 3 weeks ago

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.

rzikm commented 3 weeks ago

Sorry for longer text, the topics/ideas kept accumulating as I read the two linked files.

Re SslStream members

most of the "Not supported" members are informative only and *Strength and *Algorithm members will be obsoleted in .NET 10, what remains is

Ssl(Client/Server)AuthenticationOptions

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.

Re (Client/Server)CertificateContext and private-identity

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.

https://github.com/dotnet/runtime/blob/4bbde33ac01496375ee8902c886c9f0c3c7c709c/src/libraries/System.Net.Security/src/System/Net/Security/SslStreamCertificateContext.Windows.cs#L43-L101

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.

suggested additions

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.

Questions/other

cc @wfurt in case I forgot something.