Add ALPN support for SslStream.#24389
Conversation
| { | ||
| GCHandle protocols = GCHandle.FromIntPtr(arg); | ||
| byte[] server = (byte[])protocols.Target; | ||
|
|
There was a problem hiding this comment.
Potential for Debug.Assert if protocols.Target isn't an allocated GC handle or Target is null? Maybe actually just a null check as throwing inside call back triggered native code is never pleasant.
| internal const int OPENSSL_NPN_NEGOTIATED = 1; | ||
| internal const int SSL_TLSEXT_ERR_OK = 0; | ||
| internal const int SSL_TLSEXT_ERR_NOACK = 3; | ||
|
|
There was a problem hiding this comment.
I can't seem to seen native asserts for these, should there not be something like this
static_assert(PAL_X509_V_ERR_CRL_SIGNATURE_FAILURE == X509_V_ERR_CRL_SIGNATURE_FAILURE, "");
To ensure that the native and managed code never get out of sync?
| int len; | ||
| SslGetAlpnSelected(ssl, out protocol, out len); | ||
|
|
||
| return len == 0 ? null : Marshal.PtrToStringAnsi(protocol, len); |
There was a problem hiding this comment.
This is a problem, ANSI != UTF8 and if the ALPN Protocol struct is doing UTF8 default conversion you can't round trip the data.
There was a problem hiding this comment.
This code doesn't exist, you're probably looking at an older commit.
| } | ||
|
|
||
| return buffer; | ||
| } |
There was a problem hiding this comment.
This function has similar encoding issues, we are now using ASCII encoding, but Protocols struct uses UTF8, and above we are decoding with PtrToAnsi?
There was a problem hiding this comment.
This code doesn't exist, you're probably looking at an older commit.
There was a problem hiding this comment.
Correct you updated it after the comment so GH marks it outdated so all good now 👍
There was a problem hiding this comment.
Nope, I had initially submitted the PR with this code, this code existed in the first commit, I had then changed this code after the api-proposal to use Readonlymemory in the later commits. You must have looked at this commit wise, instead of the file-changed wise.
| internal static byte[] AlpnStringListToByteArray(IList<string> protocols) | ||
| { | ||
| int protocolSize = 0; | ||
| foreach (string protocol in protocols) |
There was a problem hiding this comment.
Why are strings now being used as the backing class? This means a conversion every time ?
There was a problem hiding this comment.
This code doesn't exist, you're probably looking at an older commit.
|
I will take a further look at work :) Good to see ALPN getting closer |
| internal static partial class OpenSsl | ||
| { | ||
| private static Ssl.SslCtxSetVerifyCallback s_verifyClientCertificate = VerifyClientCertificate; | ||
| private static Ssl.SslCtxSetAplnCallback s_alpnServerCallback = AplnServerSelectCallback; |
There was a problem hiding this comment.
readonly for this and the above delegates?
| return OpenSslSuccess; | ||
| } | ||
|
|
||
| private static unsafe int AplnServerSelectCallback(IntPtr ssl, out IntPtr outp, out byte outlen, IntPtr inp, uint inlen, IntPtr arg) |
|
|
||
| return buffer; | ||
| } | ||
| } |
There was a problem hiding this comment.
There's a lot of allocation happening in these conversion routines: enumerators, byte arrays, etc. Not to block this PR, but it'd be good subsequently to see what kind of impact that has and whether there are ways to reduce it.
| byte[] result = new byte[ProtocolIdSize]; | ||
| Array.Copy(ProtocolId, result, ProtocolIdSize); | ||
|
|
||
| return result; |
There was a problem hiding this comment.
Why are we allocating and copying to a new array? Is the issue that ProtocolIdSize may actually be < ProtocolId.Length? If so, how about using spans?
[MarshalAs(...)]
private byte[] _protocolId;
public Span<byte> ProtocolId => new Span<byte>(_protocolId, 0, ProtocolIdSize);?
There was a problem hiding this comment.
Yeah the size can be less than 255, changed to span.
| public ApplicationProtocolNegotiationExt NegotiationExtension; | ||
| public byte ProtocolIdSize; | ||
| [MarshalAs(UnmanagedType.ByValArray, SizeConst = MAX_PROTOCOL_ID_SIZE)] | ||
| public byte[] ProtocolId; |
There was a problem hiding this comment.
How about doing this as a fixed buffer instead of marshaling it as a byvalarray and forcing the extra allocation?
| } | ||
|
|
||
| [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] | ||
| internal class SecPkgContext_ApplicationProtocol |
There was a problem hiding this comment.
struct instead of class? Or in current usage is it always going to be boxed anyway?
| public delegate bool RemoteCertificateValidationCallback(object sender, System.Security.Cryptography.X509Certificates.X509Certificate certificate, System.Security.Cryptography.X509Certificates.X509Chain chain, System.Net.Security.SslPolicyErrors sslPolicyErrors); | ||
| public class SslServerAuthenticationOptions | ||
| { | ||
| public bool AllowRenegotiation { get { throw null; } set { } } |
There was a problem hiding this comment.
Why include AllowRenegotiation now if you're not going to implement it yet?
There was a problem hiding this comment.
It is part of the approved api.
| { | ||
| if (Interop.Ssl.SslCtxSetAlpnProtos(innerContext, sslAuthenticationOptions.ApplicationProtocols) != 0) | ||
| { | ||
| throw CreateSslException(SR.net_alpn_notsupported); |
There was a problem hiding this comment.
The design was to fail silently when ALPN wasn't supported, correct? E.g. return a null result and let the caller decide what to do. This avoids error-prone platform light-up detection in application code.
There was a problem hiding this comment.
This situation doesn't seem to be "isn't supported", but rather "should be supported, but broke". So it seems exceptional to me.
There was a problem hiding this comment.
So the error message is misleading?
| public struct SslApplicationProtocol : IEquatable<SslApplicationProtocol> | ||
| { | ||
| private readonly byte[] _protocol; | ||
| private readonly ReadOnlyMemory<byte> _readOnlyProtocol; |
There was a problem hiding this comment.
Why store both representations? Is ReadOnlyMemory<byte> so hard to work with that you can't use it for Equals, GetHashCode, ToString, etc.?
There was a problem hiding this comment.
Equals on readonlymemory checks if the underlying array instance is the same, that's not the equality check that's needed here.
There was a problem hiding this comment.
I was asking why you can't implement Equals over top of ReadOnlyMemory<byte> rather than byte[]. I don't see why you need the byte[] field.
There was a problem hiding this comment.
I'm not aware how I can do that, I have to compare the values of the underlying byte[] for which i need to get the byte[] from readonlymemory, and looking at coreclr code, toarray() is expensive than just storing the byte[] here.
There was a problem hiding this comment.
Probably should be a SequenceEqual extension ReadOnlyMemory; meanwhile you can do
public bool Equals(SslApplicationProtocol other)
{
if (_readOnlyProtocol.Equals(other._readOnlyProtocol))
return true;
return _readOnlyProtocol.Span.SequenceEqual(other._protocol.Span);
/*
// Or instead of SequenceEqual
ReadOnlySpan<byte> protocol = _readOnlyProtocol.Span;
ReadOnlySpan<byte> otherProtocol = other._readOnlyProtocol.Span;
if (protocol.Length != otherProtocol.Length)
return false;
for (int i = 0; i < protocol.Length; i++)
{
if (protocol[i] != other._protocol[i])
return false;
}
return true;
*/
}There was a problem hiding this comment.
something like
if(_readonlyProtocol.Span.SequenceEquals(other._readonlyProtocol.Span))you could drop the readonly if you didn't need the array around as well.
thus
if(_protocol.Span.SequenceEquals(other._protocol.Span))There was a problem hiding this comment.
Both work, the bonus is you get to drop all the null checking because they are structs.....
| private readonly ReadOnlyMemory<byte> _readOnlyProtocol; | ||
| private static readonly Encoding s_utf8 = Encoding.GetEncoding(Encoding.UTF8.CodePage, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback); | ||
|
|
||
| // h2 |
There was a problem hiding this comment.
Doc comments? Preferably with the IANA link?
https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids
There was a problem hiding this comment.
We don't include IANA links in code. Can reference the RFC number.
There was a problem hiding this comment.
@Priya91 Maybe I've missed some memo somewhere, but I don't understand why the iana.org URI isn't a thing to include in comments. That does happen to (AFAIK) be the authoritative list. The RFCs all have things like "requesting IANA to register blah blah blah"
There was a problem hiding this comment.
We should include IANA information in comments. This makes the source code more understandable and maintainable.
| _readOnlyProtocol = new ReadOnlyMemory<byte>(_protocol); | ||
| } | ||
|
|
||
| public SslApplicationProtocol(byte[] protocol) : this(protocol, true) { } |
There was a problem hiding this comment.
named parameters when using constants?
|
|
||
| if (sslClientAuthenticationOptions.TargetHost.Length == 0) | ||
| { | ||
| sslClientAuthenticationOptions.TargetHost = "?" + Interlocked.Increment(ref s_uniqueNameInteger).ToString(NumberFormatInfo.InvariantInfo); |
There was a problem hiding this comment.
Any idea why this is here? I know it was in the old version, bit it's pretty strange.
There was a problem hiding this comment.
I'm not sure. @davidsh ?
I don't know. The code is very old. It's trying to prevent an empty string from being used as TargetHost, so it makes one up.
| public override int GetHashCode() | ||
| { | ||
| int hash = 0; | ||
| for (int i = 0; i < _protocol.Length; i++) |
| AuthenticateAsClient(options, CancellationToken.None); | ||
| } | ||
|
|
||
| private void AuthenticateAsClient(SslClientAuthenticationOptions sslClientAuthenticationOptions, CancellationToken cancellationToken) |
There was a problem hiding this comment.
Why does this take a CancellationToken? And why does this method exist if it's private?
There was a problem hiding this comment.
cancellationtoken is not required, but this method takes in options bag and all other methods call this.
There was a problem hiding this comment.
There's only one caller, correct? Why not collapse this method into the one above?
|
|
||
| internal static byte[] GetNegotiatedApplicationProtocol(SafeDeleteContext context) | ||
| { | ||
| // OSX SecureTransport does not export APIs to support ALPN, no-op. |
There was a problem hiding this comment.
It's available on newer versions, correct? Will there be a way to light that up?
There was a problem hiding this comment.
It should be possible to just P/Invoke into the function; though appropriate guards would need to be put in place for detecting suitability. I'd prefer it was shimmed, but sometimes needs must.
|
I don't get the native linux build failure locally, and the alpn tests pass as well. |
|
Might be worth checking the build is using the correct version of OpenSSL 1.02 because 1.01 is missing those methods that it is complaining about |
Do you get the same intermittent error, or a now-always error? If it's "always" it's possible that something in the SChannel stack doesn't work well with ephemeral private keys. If that's the case, the remaining option would be to strip the key names out of the PFX files and resubmit them, then the keys will always be named based on a GUID created at the time of import. If it's an intermittent problem even with ephemeral keys, then it's not key collision, but must be something else going on with SChannel. |
| if (remoteCertRequired) | ||
| if (sslAuthenticationOptions.IsServer && sslAuthenticationOptions.RemoteCertRequired) | ||
| { | ||
| Debug.Assert(isServer, "isServer flag should be true"); |
There was a problem hiding this comment.
What does it mean if RemoteCertRequired is true and IsServer is false? The assert here took care of identifying that as an invalid state.
There was a problem hiding this comment.
This state will not be hit with the current implementation. Previously, these internal APIs were passing the RemoteCertRequired=false for client and RemoteCertRequired=true for server. This is in direct contradiction with external meaning of RemoteCertRequired, where for client it is always true, and server is got from RemoteCertRequired parameter on the authenticate methods. Since we don't flip these values internally anymore, it doesn't make sense to have this assert.
There was a problem hiding this comment.
Oh, I get it. You've changed the client to be true for this instead of false. Really the client is N/A, and that's what the assert was guarding (that it made no sense for a client to change that value during the handshake).
This seems fine, now that I understand the change. There's a mild perf gain (ditching the &&) for having the client use false, but it's not something worth adding confusion over.
| { | ||
| if (Interop.Ssl.SslCtxSetAlpnProtos(innerContext, sslAuthenticationOptions.ApplicationProtocols) != 0) | ||
| { | ||
| throw CreateSslException(SR.net_alpn_notsupported); |
There was a problem hiding this comment.
This situation doesn't seem to be "isn't supported", but rather "should be supported, but broke". So it seems exceptional to me.
| foreach (SslApplicationProtocol protocol in applicationProtocols) | ||
| { | ||
| buffer[offset++] = (byte)(protocol.Protocol.Length); | ||
| Array.Copy(protocol.Protocol.ToArray(), 0, buffer, offset, protocol.Protocol.Length); |
There was a problem hiding this comment.
Why ToArray()? Using the Span property avoids the extra allocation and extra extra copy.
protocol.Protocol.Span.CopyTo(buffer.Slice(offset));| BadBinding = unchecked((int)0x80090346), | ||
| DowngradeDetected = unchecked((int)0x80090350) | ||
| DowngradeDetected = unchecked((int)0x80090350), | ||
| ApplicationProtocolMismatch = unchecked((int)0x80090367) |
There was a problem hiding this comment.
I don't know if we have a rule for (or, hopefully not, against) it, but I always end my enums with a , so that new members add one line of diff, not two.
| return SSL_CTX_set_alpn_protos(ctx, protos, protos_len); | ||
| } | ||
|
|
||
| extern "C" void CryptoNative_SslGet0AlpnSelected(SSL* ssl, const unsigned char** protocol, unsigned int* len) |
There was a problem hiding this comment.
These functions all need to use the correct type identifiers in their signatures.
char*is only used forstring.byte[]=>uint8_t*.int(C#) =>int32_t
So they might be
// If the void*'s here can't be void* then whatever type you think they are should be encoded. I'm guessing uint8_t.
extern "C" int32_t CryptoNative_SslSelectNextProto(void** out, uint8_t* outlen, const uint8_t* server, uint32_t server_len, const void* client, uint32_t client_len);
extern "C" int32_t CryptoNative_SslCtxSetAlpnProtos(SSL_CTX* ctx, const uint8_t* protos, uint32_t protos_len);
extern "C" void CryptoNative_SslGet0AlpnSelected(SSL* ssl, const uint8_t** protocol, uint32_t* len);| if (remoteCertRequired) | ||
| if (sslAuthenticationOptions.IsServer && sslAuthenticationOptions.RemoteCertRequired) | ||
| { | ||
| Debug.Assert(isServer, "remoteCertRequired should not be set for client-side handshakes"); |
There was a problem hiding this comment.
I asked this question in the OpenSSL version, but I'll ask it again: why are we removing the assert? If RemoteCertRequired can only be set when IsServer is true, then the assert is good (because we can remove the && operation). If there's a state where RemoteCertRequired can be true and IsServer is false, then what do we want that behavior to be?
There was a problem hiding this comment.
As explained previously.
| context, | ||
| Interop.SspiCli.ContextAttribute.SECPKG_ATTR_APPLICATION_PROTOCOL) as Interop.SecPkgContext_ApplicationProtocol; | ||
|
|
||
| if (alpnContext == null || alpnContext.NegotiationExtension != Interop.ApplicationProtocolNegotiationExt.ALPN || alpnContext.NegotiationStatus != Interop.ApplicationProtocolNegotiationStatus.Success) |
There was a problem hiding this comment.
Is there a case where NegotiationExtension isn't ALPN that isn't considered exceptional?
If I ask SChannel for the ALPN data and it gives me back a certificate, I'm certainly upset.
But maybe this is for the ALPN vs NPN thing? If so, a comment seems warranted.
| using (X509Certificate2 certificate = Configuration.Certificates.GetServerCertificate()) | ||
| { | ||
| SslApplicationProtocol LongLength = new SslApplicationProtocol( | ||
| "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + |
There was a problem hiding this comment.
new string('a', 256) seems clearer. (Or whatever number is accurate)
| Assert.True(left == right); | ||
| Assert.False(left != right); | ||
|
|
||
| if (!left.Protocol.IsEmpty && !right.Protocol.IsEmpty) |
There was a problem hiding this comment.
This if doesn't belong. if == is true then GetHashCode() should match. And GetHashCode() should never throw.
| Assert.True(left != right); | ||
| Assert.False(left == right); | ||
|
|
||
| if (!left.Protocol.IsEmpty && !right.Protocol.IsEmpty) |
Repro status of this error:
|
| serverOptions.ServerCertificate = certificate; | ||
| serverOptions.RemoteCertificateValidationCallback = AllowAnyServerCertificate; | ||
|
|
||
| Assert.ThrowsAsync<InvalidOperationException>(() => { return client.AuthenticateAsClientAsync(clientOptions, CancellationToken.None); }); |
There was a problem hiding this comment.
I'm not sure if this is common in CoreFX tests, but shouldn't you assert the exception message to make sure this is actually failing for the reason it is expected to fail?
|
|
||
| [Fact] | ||
| [PlatformSpecific(~TestPlatforms.OSX)] | ||
| public void SslStream_StreamToStream_Alpn_Fail() |
There was a problem hiding this comment.
Fail for what reason? That should be clearer in the test name.
| { | ||
| public class SslAuthenticationOptionsTests | ||
| { | ||
| private SslClientAuthenticationOptions _clientOptions = new SslClientAuthenticationOptions(); |
|
Update: I'm investigating the test failures, and working on updating the CI images to openssl 1.0.2. Please bear with no activity here till I sort those issues. Thanks! |
| { | ||
| get => _enabledSslProtocols; | ||
| set => _enabledSslProtocols = value; | ||
| } |
There was a problem hiding this comment.
ServerCertificate and EnabledSslProtocols could both be auto-properties now, if you wanted.
There was a problem hiding this comment.
enabledsslprotocols has a default, left that as if, changed the rest to auto properties.
| alpnContext.ProtoNegoExt != Interop.ApplicationProtocolNegotiationExt.ALPN || | ||
| alpnContext.ProtoNegoStatus != Interop.ApplicationProtocolNegotiationStatus.Success) | ||
| { | ||
| return null; |
There was a problem hiding this comment.
What is the situation where we expect ProtoNegoExt to not be ALPN, or ProtoNegoStatus to not be success? And should those cases be exceptions? (Comment seems to have been lost due to formatting fixes)
There was a problem hiding this comment.
Will add comment, NPN is an earlier version of alpn. Here, the code is querying the context for information about the alpn protocol negotiated, and the win32 api should return object with structure SecPkgContext_ApplicationProtocol, this is just checking if the object returned is indeed of this type and the context of the alpn operation was successful and of type alpn. If there was some internal error that resulted in a wrong object or object with incorrect context returned we'll just return null.
| { | ||
| fixed (byte* p = ProtocolId) | ||
| { | ||
| return new Span<byte>(p, ProtocolIdSize).ToArray(); |
There was a problem hiding this comment.
Why is this using fixed instead of just new Span<byte>(ProtocolId, 0, ProtocolIdSize).ToArray()?
| [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] | ||
| internal class SecPkgContext_ApplicationProtocol | ||
| [StructLayout(LayoutKind.Sequential)] | ||
| internal unsafe class SecPkgContext_ApplicationProtocol |
There was a problem hiding this comment.
Does this need unsafe at the class scope? A local unsafe block, or member-level, seems more appropriate...
| int index = 0; | ||
| for (int i = 0; i < applicationProtocols.Count; i++) | ||
| { | ||
| pBuffer[index++] = (byte)applicationProtocols[i].Protocol.Length; |
There was a problem hiding this comment.
Debug.Assert(applicationProtocols[i].Protocol.Length < 255)? (or <=, if appropriate)
There was a problem hiding this comment.
This check is done, in the code just before in the same function, so a debug assert is not required here.
| { | ||
| return SSL_select_next_proto(out, outlen, server, server_len, client, client_len); | ||
| } | ||
| else |
There was a problem hiding this comment.
Technically the else keyword and else braces are redundant, since the method either #defines out or hits an if+return.
But this is fine if you like it.
There was a problem hiding this comment.
This is the code pattern used elsewhere in the crypto shim.
| for (int i = 0; i < _readOnlyProtocol.Length; i++) | ||
| { | ||
| hash = ((hash << 5) + hash) ^ _protocol[i]; | ||
| hash1 = ((hash1 << 5) + hash1) ^ _readOnlyProtocol.Span[i]; |
There was a problem hiding this comment.
You're reading this property a lot. Should it be saved outside the loop?
| public ApplicationProtocolNegotiationExt ProtocolExtenstionType; | ||
| public short ProtocolListSize; | ||
|
|
||
| public static unsafe byte[] ToByteArray(List<SslApplicationProtocol> applicationProtocols) |
There was a problem hiding this comment.
@stephentoub, should we also have a method that takes in a span and puts the bytes into it, i.e. TryCopyTo(Span buffer)?
There was a problem hiding this comment.
This is internal. Is the equivalent exposed publicly somewhere?
There was a problem hiding this comment.
I don't think it's exposed, but: a) maybe we should expose it, b) maybe the internal code that uses it would be written with less copies/allocations if we had such internal API.
There was a problem hiding this comment.
For b), at that point it's an implementation detail and we should do whatever's most efficient, assuming it matters.
For a), what would it be used for?
| protocols.ProtocolExtenstionType = ApplicationProtocolNegotiationExt.ALPN; | ||
| protocols.ProtocolListSize = (short)protocolListSize; | ||
|
|
||
| Span<byte> pBuffer = new byte[protocolListSize]; |
There was a problem hiding this comment.
nit: pBuffer kind of implies that the variable is a pointer. it might be better to call it protocolsBuffer.
| Ssl.SslCtxSetVerify(innerContext, s_verifyClientCertificate); | ||
|
|
||
| //update the client CA list | ||
| UpdateCAListFromRootStore(innerContext); |
There was a problem hiding this comment.
This method call does not match the behavior on Windows.
Why does it exist in this manner? See also: https://github.com/dotnet/corefx/issues/23938
There was a problem hiding this comment.
@ayende This code is not related to the work done in this PR. Please file separate issue if there's a bug.
|
Documentation? (dotnet/docs#6469) |
|
@DylanFPS yeah, we should add docs - in the meantime, check out the API shape in #23177, it is fairly straightforward - use new |
Add ALPN support for SslStream. Commit migrated from dotnet/corefx@57b4664
cc @stephentoub @bartonjs @geoffkizer @wfurt @karelz @Drawaes @Tratcher
fixes #23177
In this PR, only ALPN support is added. This PR does not include:
I'll file issues separately for followup on above items at the time of merging this PR.