Skip to content

Commit

Permalink
Added Android-safetynet attestation format.
Browse files Browse the repository at this point in the history
  • Loading branch information
KristofferStrube committed Aug 11, 2024
1 parent 2fd2834 commit 065784e
Show file tree
Hide file tree
Showing 9 changed files with 339 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,35 +41,54 @@ public static Ok<byte[]> RegisterChallenge(string userName)
return TypedResults.Ok(challenge);
}

public static Ok<bool> Register(string userName, [FromBody] RegistrationResponseJSON registration)
public static Results<Ok, BadRequest<string>> Register(string userName, [FromBody] RegistrationResponseJSON registration)
{
CollectedClientData? clientData = System.Text.Json.JsonSerializer.Deserialize<CollectedClientData>(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<byte[]>? credentialList))
Expand All @@ -81,7 +100,7 @@ public static Ok<bool> 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<ValidateCredentials> ValidateChallenge(string userName)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishTrimmed>false</PublishTrimmed>
<UserSecretsId>5a4e5a29-3e55-4bf5-831a-e180c29758cc</UserSecretsId>
</PropertyGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

Expand All @@ -22,11 +23,23 @@
builder.Services.AddScoped<WebAuthenticationClient>();

// Configuring elmah.io
builder.Logging.AddElmahIo(options =>
if (builder.HostEnvironment.IsProduction())
{
options.ApiKey = "<API_KEY>";
options.LogId = new Guid("<LOG_ID>");
});
builder.Logging.AddElmahIo(options =>
{
options.ApiKey = "<API_KEY>";
options.LogId = new Guid("<LOG_ID>");
});
}
else
{
IConfiguration elmahIoOptions = builder.Configuration.GetSection("ElmahIo");
builder.Logging.AddElmahIo(options =>
{
options.ApiKey = elmahIoOptions.GetValue<string>("ApiKey");
options.LogId = new Guid(elmahIoOptions.GetValue<string>("LogId"));

Check warning on line 40 in samples/KristofferStrube.Blazor.WebAuthentication.WasmExample/Program.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 'g' in 'Guid.Guid(string g)'.
});
}

WebAssemblyHost app = builder.Build();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ public WebAuthenticationClient([FromKeyedServices(typeof(WebAuthenticationClient
return await httpClient.GetFromJsonAsync<byte[]>($"RegisterChallenge/{userName}");
}

public async Task<bool> 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<bool>();

if (!result.IsSuccessStatusCode)
throw new ArgumentException(await result.Content.ReadAsStringAsync());
}

public async Task<ValidateCredentials?> ValidateChallenge(string userName)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using System.Formats.Cbor;

namespace KristofferStrube.Blazor.WebAuthentication;

/// <summary>
/// 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
/// </summary>
/// <remarks><see href="https://www.w3.org/TR/webauthn-3/#sctn-android-safetynet-attestation">See the API definition here</see>.</remarks>
public class AndroidSafetyNetAttestationStatement : AttestationStatement
{
/// <summary>
/// The algorithm used to generate the attestation signature.
/// </summary>
public required string Version { get; set; }

/// <summary>
/// The UTF-8 encoded result of the <c>getJwsResult()</c> call of the SafetyNet API.
/// This value is a JWS object in Compact Serialization.
/// </summary>
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
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Formats.Cbor;

namespace KristofferStrube.Blazor.WebAuthentication;

/// <summary>
/// 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.
/// </summary>
/// <remarks><see href="https://www.w3.org/TR/webauthn-3/#sctn-defined-attestation-formats">See the API definition here</see>.</remarks>
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.")
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

namespace KristofferStrube.Blazor.WebAuthentication;

public class PackedAttestationFormat
/// <summary>
/// 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).
/// </summary>
/// <remarks><see href="https://www.w3.org/TR/webauthn-3/#sctn-packed-attestation">See the API definition here</see>.</remarks>
public class PackedAttestationStatement : AttestationStatement
{
/// <summary>
/// The algorithm used to generate the attestation signature.
Expand All @@ -20,54 +26,15 @@ public class PackedAttestationFormat
/// </summary>
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.");
Expand All @@ -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.");
Expand Down
Loading

0 comments on commit 065784e

Please sign in to comment.