diff --git a/samples/KristofferStrube.Blazor.WebAuthentication.API/WebAuthenticationAPI.cs b/samples/KristofferStrube.Blazor.WebAuthentication.API/WebAuthenticationAPI.cs index d120b1c..35413ed 100644 --- a/samples/KristofferStrube.Blazor.WebAuthentication.API/WebAuthenticationAPI.cs +++ b/samples/KristofferStrube.Blazor.WebAuthentication.API/WebAuthenticationAPI.cs @@ -41,35 +41,54 @@ public static Ok RegisterChallenge(string userName) return TypedResults.Ok(challenge); } - public static Ok Register(string userName, [FromBody] RegistrationResponseJSON registration) + public static Results> Register(string userName, [FromBody] RegistrationResponseJSON registration) { CollectedClientData? clientData = System.Text.Json.JsonSerializer.Deserialize(Convert.FromBase64String(registration.Response.ClientDataJSON)); if (clientData is null) { - return TypedResults.Ok(false); + return TypedResults.BadRequest("Client data was not present."); } - if (!Challenges.TryGetValue(userName, out byte[]? originalChallenge) - || !originalChallenge.SequenceEqual(WebEncoders.Base64UrlDecode(clientData.Challenge))) + if (!Challenges.TryGetValue(userName, out byte[]? originalChallenge)) { - return TypedResults.Ok(false); + return TypedResults.BadRequest("Challenge did not exist."); } - if (registration.Response.PublicKey is null) + if (!originalChallenge.SequenceEqual(WebEncoders.Base64UrlDecode(clientData.Challenge))) { - return TypedResults.Ok(false); + return TypedResults.BadRequest("Challenge did not match server side challenge."); } - var attestationStatement = PackedAttestationFormat.ReadFromBase64EncodedAttestationStatement(registration.Response.AttestationObject); + if (registration.Response.PublicKey is null) + { + return TypedResults.BadRequest("Response did not have a public key."); + } - if (attestationStatement.Algorithm != (COSEAlgorithm)registration.Response.PublicKeyAlgorithm) + AttestationStatement? attestationStatement; + try { - return TypedResults.Ok(false); + attestationStatement = AttestationStatement.ReadFromBase64EncodedAttestationStatement(registration.Response.AttestationObject); + } + catch (Exception e) + { + return TypedResults.BadRequest($"Could not parse attestation statement: \"{e.Message}\""); } - if (!VerifySignature(attestationStatement.Algorithm, Convert.FromBase64String(registration.Response.PublicKey), registration.Response.AuthenticatorData, registration.Response.ClientDataJSON, attestationStatement.Signature)) + switch (attestationStatement) { - return TypedResults.Ok(false); + case PackedAttestationStatement packed: + if (packed.Algorithm != (COSEAlgorithm)registration.Response.PublicKeyAlgorithm) + { + return TypedResults.BadRequest("The algorithm specified int the packed attestation format did not match the algorithm specified in the response."); + } + + if (!VerifySignature(packed.Algorithm, Convert.FromBase64String(registration.Response.PublicKey), registration.Response.AuthenticatorData, registration.Response.ClientDataJSON, packed.Signature)) + { + return TypedResults.BadRequest("Signature was not valid."); + } + break; + default: + return TypedResults.BadRequest($"Verification of signature was not implemented for type {attestationStatement?.GetType().Name}"); } if (Credentials.TryGetValue(userName, out List? credentialList)) @@ -81,7 +100,7 @@ public static Ok Register(string userName, [FromBody] RegistrationResponse Credentials[userName] = [Convert.FromBase64String(registration.RawId)]; } PublicKeys[registration.RawId] = ((COSEAlgorithm)registration.Response.PublicKeyAlgorithm, Convert.FromBase64String(registration.Response.PublicKey)); - return TypedResults.Ok(true); + return TypedResults.Ok(); } public static Ok ValidateChallenge(string userName) diff --git a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/KristofferStrube.Blazor.WebAuthentication.WasmExample.csproj b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/KristofferStrube.Blazor.WebAuthentication.WasmExample.csproj index f9631ac..56e2f7e 100644 --- a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/KristofferStrube.Blazor.WebAuthentication.WasmExample.csproj +++ b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/KristofferStrube.Blazor.WebAuthentication.WasmExample.csproj @@ -1,10 +1,11 @@ - + net8.0 enable enable false + 5a4e5a29-3e55-4bf5-831a-e180c29758cc diff --git a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Pages/Index.razor.cs b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Pages/Index.razor.cs index 1bf0421..4f32f1a 100644 --- a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Pages/Index.razor.cs +++ b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Pages/Index.razor.cs @@ -99,14 +99,14 @@ private async Task CreateCredential() PublicKeyCredentialJSON registrationResponse = await credential.ToJSONAsync(); if (registrationResponse is RegistrationResponseJSON { } registration) { - bool succesfullyRegistered = await WebAuthenticationClient.Register(username, registration); - if (succesfullyRegistered) + try { + await WebAuthenticationClient.Register(username, registration); publicKey = registration.Response.PublicKey is not null ? Convert.FromBase64String(registration.Response.PublicKey) : null; } - else + catch (Exception e) { - errorMessage = "Was not successfull in registering the credentials"; + errorMessage = $"Was not successfull in registering the credentials. {e.Message}"; credential = null; return; } diff --git a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Program.cs b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Program.cs index e0a8c1c..7800459 100644 --- a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Program.cs +++ b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Program.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; var builder = WebAssemblyHostBuilder.CreateDefault(args); + builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); @@ -22,11 +23,23 @@ builder.Services.AddScoped(); // Configuring elmah.io -builder.Logging.AddElmahIo(options => +if (builder.HostEnvironment.IsProduction()) { - options.ApiKey = ""; - options.LogId = new Guid(""); -}); + builder.Logging.AddElmahIo(options => + { + options.ApiKey = ""; + options.LogId = new Guid(""); + }); +} +else +{ + IConfiguration elmahIoOptions = builder.Configuration.GetSection("ElmahIo"); + builder.Logging.AddElmahIo(options => + { + options.ApiKey = elmahIoOptions.GetValue("ApiKey"); + options.LogId = new Guid(elmahIoOptions.GetValue("LogId")); + }); +} WebAssemblyHost app = builder.Build(); diff --git a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/WebAuthenticationClient.cs b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/WebAuthenticationClient.cs index bc0e41b..922e48e 100644 --- a/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/WebAuthenticationClient.cs +++ b/samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/WebAuthenticationClient.cs @@ -17,10 +17,12 @@ public WebAuthenticationClient([FromKeyedServices(typeof(WebAuthenticationClient return await httpClient.GetFromJsonAsync($"RegisterChallenge/{userName}"); } - public async Task Register(string userName, RegistrationResponseJSON registrationResponse) + public async Task Register(string userName, RegistrationResponseJSON registrationResponse) { HttpResponseMessage result = await httpClient.PostAsJsonAsync($"Register/{userName}", registrationResponse); - return await result.Content.ReadFromJsonAsync(); + + if (!result.IsSuccessStatusCode) + throw new ArgumentException(await result.Content.ReadAsStringAsync()); } public async Task ValidateChallenge(string userName) diff --git a/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AndroidSafetyNetAttestationStatement.cs b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AndroidSafetyNetAttestationStatement.cs new file mode 100644 index 0000000..867b233 --- /dev/null +++ b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AndroidSafetyNetAttestationStatement.cs @@ -0,0 +1,96 @@ +using System.Formats.Cbor; + +namespace KristofferStrube.Blazor.WebAuthentication; + +/// +/// When the authenticator is a platform authenticator on certain Android platforms, the attestation statement may be based on the SafetyNet API. +/// In this case the authenticator data is completely controlled by the caller of the SafetyNet API (typically an application running on the Android platform) +/// and the attestation statement provides some statements about the health of the platform and the identity of the calling application +/// +/// See the API definition here. +public class AndroidSafetyNetAttestationStatement : AttestationStatement +{ + /// + /// The algorithm used to generate the attestation signature. + /// + public required string Version { get; set; } + + /// + /// The UTF-8 encoded result of the getJwsResult() call of the SafetyNet API. + /// This value is a JWS object in Compact Serialization. + /// + public required byte[] Response { get; set; } + + internal static AndroidSafetyNetAttestationStatement ReadAttestationStatement(CborReader cborReader) + { + CborReaderState state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's second key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + string label = cborReader.ReadTextString(); + if (label is not "attStmt") + { + throw new FormatException($"Attestation Statement's second key was '{label}' but 'attStmt' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.StartMap) + { + throw new FormatException($"Attestation Statement's 'attStmt' was of type '{state}' but '{CborReaderState.StartMap}' was expected."); + } + + int? mapSize = cborReader.ReadStartMap(); + if (mapSize is not 2) + { + throw new FormatException($"Attestation Statement's packed format had '{mapSize}' entries but '2' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's packed format's first key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + label = cborReader.ReadTextString(); + if (label is not "ver") + { + throw new FormatException($"Attestation Statement's packed format's first key was '{label}' but 'ver' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.NegativeInteger) + { + throw new FormatException($"Attestation Statement's packed format's 'ver' was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + string version = cborReader.ReadTextString(); + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's packed format's second key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + label = cborReader.ReadTextString(); + if (label is not "response") + { + throw new FormatException($"Attestation Statement's packed format's second key was '{label}' but 'response' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.ByteString) + { + throw new FormatException($"Attestation Statement's packed format's 'response' was of type '{state}' but '{CborReaderState.ByteString}' was expected."); + } + + byte[] response = cborReader.ReadByteString(); + + return new() + { + Version = version, + Response = response + }; + } +} diff --git a/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AttestationStatement.cs b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AttestationStatement.cs new file mode 100644 index 0000000..b6a8c5d --- /dev/null +++ b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/AttestationStatement.cs @@ -0,0 +1,56 @@ +using System.Formats.Cbor; + +namespace KristofferStrube.Blazor.WebAuthentication; + +/// +/// WebAuthn supports pluggable attestation statement formats. +/// Attestation statement formats are identified by a string, called an attestation statement format identifier, chosen by the author of the attestation statement format. +/// +/// See the API definition here. +public abstract class AttestationStatement +{ + public static AttestationStatement ReadFromBase64EncodedAttestationStatement(string input) + { + CborReader cborReader = new(Convert.FromBase64String(input)); + + CborReaderState state = cborReader.PeekState(); + + if (state is not CborReaderState.StartMap) + { + throw new FormatException("Attestation Statement did not start with a map."); + } + + int? mapSize = cborReader.ReadStartMap(); + if (mapSize is not 3) + { + throw new FormatException($"Attestation Statement had '{mapSize}' entries in its first map but '3' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's first key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + string label = cborReader.ReadTextString(); + if (label is not "fmt") + { + throw new FormatException($"Attestation Statement's first key was '{label}' but 'fmt' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's first value was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + string fmt = cborReader.ReadTextString(); + + return fmt switch + { + "packed" => PackedAttestationStatement.ReadAttestationStatement(cborReader), + "android-safetynet" => AndroidSafetyNetAttestationStatement.ReadAttestationStatement(cborReader), + _ => throw new FormatException($"Attestation Statement had format '{fmt}' which was not supported.") + }; + } +} diff --git a/src/KristofferStrube.Blazor.WebAuthentication/AttestationFormats/PackedAttestationFormat.cs b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/PackedAttestationStatement.cs similarity index 70% rename from src/KristofferStrube.Blazor.WebAuthentication/AttestationFormats/PackedAttestationFormat.cs rename to src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/PackedAttestationStatement.cs index 8670928..583dfa1 100644 --- a/src/KristofferStrube.Blazor.WebAuthentication/AttestationFormats/PackedAttestationFormat.cs +++ b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/PackedAttestationStatement.cs @@ -2,7 +2,13 @@ namespace KristofferStrube.Blazor.WebAuthentication; -public class PackedAttestationFormat +/// +/// This is a WebAuthn optimized attestation statement format. +/// It uses a very compact but still extensible encoding method. +/// It is implementable by authenticators with limited resources (e.g., secure elements). +/// +/// See the API definition here. +public class PackedAttestationStatement : AttestationStatement { /// /// The algorithm used to generate the attestation signature. @@ -20,54 +26,15 @@ public class PackedAttestationFormat /// public byte[][]? X5c { get; set; } - public static PackedAttestationFormat ReadFromBase64EncodedAttestationStatement(string input) + internal static PackedAttestationStatement ReadAttestationStatement(CborReader cborReader) { - CborReader cborReader = new(Convert.FromBase64String(input)); - CborReaderState state = cborReader.PeekState(); - - if (state is not CborReaderState.StartMap) - { - throw new FormatException("Attestation Statement did not start with a map."); - } - - int? mapSize = cborReader.ReadStartMap(); - if (mapSize is not 3) - { - throw new FormatException($"Attestation Statement had '{mapSize}' entries in its first map but '3' was expected."); - } - - state = cborReader.PeekState(); - if (state is not CborReaderState.TextString) - { - throw new FormatException($"Attestation Statement's first key was of type '{state}' but '{CborReaderState.TextString}' was expected."); - } - - string label = cborReader.ReadTextString(); - if (label is not "fmt") - { - throw new FormatException($"Attestation Statement's first key was '{label}' but 'fmt' was expected."); - } - - state = cborReader.PeekState(); - if (state is not CborReaderState.TextString) - { - throw new FormatException($"Attestation Statement's first value was of type '{state}' but '{CborReaderState.TextString}' was expected."); - } - - string fmt = cborReader.ReadTextString(); - if (fmt is not "packed") - { - throw new FormatException($"Attestation Statement had format '{fmt}' but 'packed' was expected."); - } - - state = cborReader.PeekState(); if (state is not CborReaderState.TextString) { throw new FormatException($"Attestation Statement's second key was of type '{state}' but '{CborReaderState.TextString}' was expected."); } - label = cborReader.ReadTextString(); + string label = cborReader.ReadTextString(); if (label is not "attStmt") { throw new FormatException($"Attestation Statement's second key was '{label}' but 'attStmt' was expected."); @@ -79,7 +46,7 @@ public static PackedAttestationFormat ReadFromBase64EncodedAttestationStatement( throw new FormatException($"Attestation Statement's 'attStmt' was of type '{state}' but '{CborReaderState.StartMap}' was expected."); } - mapSize = cborReader.ReadStartMap(); + int? mapSize = cborReader.ReadStartMap(); if (mapSize is not 2 or 3) { throw new FormatException($"Attestation Statement's packed format had '{mapSize}' entries but '2' or '3' was expected."); diff --git a/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/TPMAttestationStatement.cs b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/TPMAttestationStatement.cs new file mode 100644 index 0000000..5d0e1e8 --- /dev/null +++ b/src/KristofferStrube.Blazor.WebAuthentication/AttestationStatements/TPMAttestationStatement.cs @@ -0,0 +1,118 @@ +using System.Formats.Cbor; + +namespace KristofferStrube.Blazor.WebAuthentication; + +/// +/// This attestation statement format is generally used by authenticators that use a Trusted Platform Module as their cryptographic engine. +/// +/// See the API definition here. +public class TPMAttestationStatement : AttestationStatement +{ + /// + /// The version of the TPM specification to which the signature conforms. + /// + public required string Version { get; set; } + + /// + /// The algorithm used to generate the attestation signature. + /// + public required COSEAlgorithm Algorithm { get; set; } + + /// + /// The AIK certificate used for the attestation, in X.509 encoding. Followed by its certificate chain, in X.509 encoding. + /// + public byte[][]? X5c { get; set; } + + /// + /// The attestation signature, in the form of a TPMT_SIGNATURE structure as specified in TPMv2-Part2 section 11.3.4. + /// + public required byte[] Signature { get; set; } + + /// + /// The TPMS_ATTEST structure over which the above signature was computed, as specified in TPMv2-Part2 section 10.12.8. + /// + public required byte[] CertificateInformation { get; set; } + + /// + /// The TPMT_PUBLIC structure (see TPMv2-Part2 section 12.2.4) used by the TPM to represent the credential public key. + /// + public required byte[] PublicArea { get; set; } + + internal static TPMAttestationStatement ReadAttestationStatement(CborReader cborReader) + { + CborReaderState state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's second key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + string label = cborReader.ReadTextString(); + if (label is not "attStmt") + { + throw new FormatException($"Attestation Statement's second key was '{label}' but 'attStmt' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.StartMap) + { + throw new FormatException($"Attestation Statement's 'attStmt' was of type '{state}' but '{CborReaderState.StartMap}' was expected."); + } + + int? mapSize = cborReader.ReadStartMap(); + if (mapSize is not 2 or 3) + { + throw new FormatException($"Attestation Statement's packed format had '{mapSize}' entries but '2' or '3' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's packed format's first key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + label = cborReader.ReadTextString(); + if (label is not "alg") + { + throw new FormatException($"Attestation Statement's packed format's first key was '{label}' but 'alg' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.NegativeInteger) + { + throw new FormatException($"Attestation Statement's packed format's 'alg' was of type '{state}' but '{CborReaderState.NegativeInteger}' was expected."); + } + + ulong negativeAlg = cborReader.ReadCborNegativeIntegerRepresentation(); + + state = cborReader.PeekState(); + if (state is not CborReaderState.TextString) + { + throw new FormatException($"Attestation Statement's packed format's second key was of type '{state}' but '{CborReaderState.TextString}' was expected."); + } + + label = cborReader.ReadTextString(); + if (label is not "sig") + { + throw new FormatException($"Attestation Statement's packed format's second key was '{label}' but 'sig' was expected."); + } + + state = cborReader.PeekState(); + if (state is not CborReaderState.ByteString) + { + throw new FormatException($"Attestation Statement's packed format's 'sig' was of type '{state}' but '{CborReaderState.ByteString}' was expected."); + } + + byte[] signature = cborReader.ReadByteString(); + + //if (mapSize is 2) + //{ + // return new() + // { + // Algorithm = (COSEAlgorithm)(-(long)negativeAlg - 1), + // Signature = signature, + // }; + //} + + throw new NotSupportedException("Reading x5c is not yet supported."); + } +}