Skip to content

Commit 496f01b

Browse files
Add passkey tests verifying WebAuthn conformance (#62441)
1 parent b965b67 commit 496f01b

16 files changed

+3505
-4
lines changed

src/Identity/Core/src/DefaultPasskeyHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ await VerifyClientDataAsync(
433433
// NOTE: We simply fail the ceremony in this case.
434434
if (authenticatorData.SignCount <= storedPasskey.SignCount)
435435
{
436-
throw PasskeyException.SignCountLessThanStoredSignCount();
436+
throw PasskeyException.SignCountLessThanOrEqualToStoredSignCount();
437437
}
438438
}
439439

src/Identity/Core/src/PasskeyExceptionExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public static PasskeyException ExpectedBackupIneligibleCredential()
8484
public static PasskeyException InvalidAssertionSignature()
8585
=> new("The assertion signature was invalid.");
8686

87-
public static PasskeyException SignCountLessThanStoredSignCount()
87+
public static PasskeyException SignCountLessThanOrEqualToStoredSignCount()
8888
=> new("The authenticator's signature counter is unexpectedly less than or equal to the stored signature counter.");
8989

9090
public static PasskeyException InvalidAttestationObject(Exception ex)

src/Identity/EntityFrameworkCore/test/EF.Test/VersionTestDbContext.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ public VersionTwoDbContext(DbContextOptions options)
2424
}
2525
}
2626

27+
public class VersionThreeDbContext : IdentityDbContext<IdentityUser, IdentityRole, string>
28+
{
29+
public VersionThreeDbContext(DbContextOptions options)
30+
: base(options)
31+
{
32+
}
33+
}
34+
2735
public class EmptyDbContext : IdentityDbContext<IdentityUser, IdentityRole, string>
2836
{
2937
public EmptyDbContext(DbContextOptions options)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Builder;
5+
using Microsoft.Data.Sqlite;
6+
using Microsoft.EntityFrameworkCore;
7+
using Microsoft.EntityFrameworkCore.Diagnostics;
8+
using Microsoft.Extensions.Configuration;
9+
using Microsoft.Extensions.DependencyInjection;
10+
11+
namespace Microsoft.AspNetCore.Identity.EntityFrameworkCore.Test;
12+
13+
public class VersionThreeSchemaTest : IClassFixture<ScratchDatabaseFixture>
14+
{
15+
private readonly ApplicationBuilder _builder;
16+
17+
public VersionThreeSchemaTest(ScratchDatabaseFixture fixture)
18+
{
19+
var services = new ServiceCollection();
20+
21+
services
22+
.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build())
23+
.AddDbContext<VersionThreeDbContext>(o =>
24+
o.UseSqlite(fixture.Connection)
25+
.ConfigureWarnings(b => b.Log(CoreEventId.ManyServiceProvidersCreatedWarning)))
26+
.AddIdentity<IdentityUser, IdentityRole>(o =>
27+
{
28+
// MaxKeyLength does not need to be set in version 3
29+
o.Stores.SchemaVersion = IdentitySchemaVersions.Version3;
30+
})
31+
.AddEntityFrameworkStores<VersionThreeDbContext>();
32+
33+
services.AddLogging();
34+
35+
_builder = new ApplicationBuilder(services.BuildServiceProvider());
36+
var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
37+
var db = scope.ServiceProvider.GetRequiredService<VersionThreeDbContext>();
38+
db.Database.EnsureCreated();
39+
}
40+
41+
[Fact]
42+
public void EnsureDefaultSchema()
43+
{
44+
using var scope = _builder.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope();
45+
var db = scope.ServiceProvider.GetRequiredService<VersionThreeDbContext>();
46+
VerifyVersion3Schema(db);
47+
}
48+
49+
internal static void VerifyVersion3Schema(DbContext dbContext)
50+
{
51+
using var sqlConn = (SqliteConnection)dbContext.Database.GetDbConnection();
52+
sqlConn.Open();
53+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUsers", "Id", "UserName", "Email", "PasswordHash", "SecurityStamp",
54+
"EmailConfirmed", "PhoneNumber", "PhoneNumberConfirmed", "TwoFactorEnabled", "LockoutEnabled",
55+
"LockoutEnd", "AccessFailedCount", "ConcurrencyStamp", "NormalizedUserName", "NormalizedEmail"));
56+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetRoles", "Id", "Name", "NormalizedName", "ConcurrencyStamp"));
57+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserRoles", "UserId", "RoleId"));
58+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserClaims", "Id", "UserId", "ClaimType", "ClaimValue"));
59+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserLogins", "UserId", "ProviderKey", "LoginProvider", "ProviderDisplayName"));
60+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserTokens", "UserId", "LoginProvider", "Name", "Value"));
61+
Assert.True(DbUtil.VerifyColumns(sqlConn, "AspNetUserPasskeys", "UserId", "CredentialId", "PublicKey", "Name", "CreatedAt",
62+
"SignCount", "Transports", "IsUserVerified", "IsBackupEligible", "IsBackedUp", "AttestationObject",
63+
"ClientDataJson"));
64+
65+
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUsers", 256, "UserName", "Email", "NormalizedUserName", "NormalizedEmail", "PhoneNumber"));
66+
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetRoles", 256, "Name", "NormalizedName"));
67+
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserLogins", 128, "LoginProvider", "ProviderKey"));
68+
Assert.True(DbUtil.VerifyMaxLength(dbContext, "AspNetUserTokens", 128, "LoginProvider", "Name"));
69+
70+
DbUtil.VerifyIndex(sqlConn, "AspNetRoles", "RoleNameIndex", isUnique: true);
71+
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "UserNameIndex", isUnique: true);
72+
DbUtil.VerifyIndex(sqlConn, "AspNetUsers", "EmailIndex");
73+
}
74+
}

src/Identity/Extensions.Core/src/UserManager.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,7 @@ public virtual Task<IList<UserPasskeyInfo>> GetPasskeysAsync(TUser user)
21882188
{
21892189
ThrowIfDisposed();
21902190
var passkeyStore = GetUserPasskeyStore();
2191+
ArgumentNullThrowHelper.ThrowIfNull(user);
21912192
ArgumentNullThrowHelper.ThrowIfNull(credentialId);
21922193

21932194
return passkeyStore.FindPasskeyAsync(user, credentialId, CancellationToken);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
#nullable enable
5+
6+
namespace Microsoft.AspNetCore.Identity.Test;
7+
8+
internal readonly struct AttestationObjectArgs
9+
{
10+
public required int? CborMapLength { get; init; }
11+
public required string? Format { get; init; }
12+
public required ReadOnlyMemory<byte>? AttestationStatement { get; init; }
13+
public required ReadOnlyMemory<byte>? AuthenticatorData { get; init; }
14+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Identity.Test;
5+
6+
internal readonly struct AttestedCredentialDataArgs
7+
{
8+
public required ReadOnlyMemory<byte> Aaguid { get; init; }
9+
public required ReadOnlyMemory<byte> CredentialId { get; init; }
10+
public required ReadOnlyMemory<byte> CredentialPublicKey { get; init; }
11+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Identity.Test;
5+
6+
internal readonly struct AuthenticatorDataArgs
7+
{
8+
public required AuthenticatorDataFlags Flags { get; init; }
9+
public required ReadOnlyMemory<byte> RpIdHash { get; init; }
10+
public required uint SignCount { get; init; }
11+
public ReadOnlyMemory<byte>? AttestedCredentialData { get; init; }
12+
public ReadOnlyMemory<byte>? Extensions { get; init; }
13+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Buffers.Binary;
5+
using System.Formats.Cbor;
6+
7+
namespace Microsoft.AspNetCore.Identity.Test;
8+
9+
internal static class CredentialHelpers
10+
{
11+
public static ReadOnlyMemory<byte> MakeAttestedCredentialData(in AttestedCredentialDataArgs args)
12+
{
13+
const int AaguidLength = 16;
14+
const int CredentialIdLengthLength = 2;
15+
var length = AaguidLength + CredentialIdLengthLength + args.CredentialId.Length + args.CredentialPublicKey.Length;
16+
var result = new byte[length];
17+
var offset = 0;
18+
19+
args.Aaguid.Span.CopyTo(result.AsSpan(offset, AaguidLength));
20+
offset += AaguidLength;
21+
22+
BinaryPrimitives.WriteUInt16BigEndian(result.AsSpan(offset, CredentialIdLengthLength), (ushort)args.CredentialId.Length);
23+
offset += CredentialIdLengthLength;
24+
25+
args.CredentialId.Span.CopyTo(result.AsSpan(offset));
26+
offset += args.CredentialId.Length;
27+
28+
args.CredentialPublicKey.Span.CopyTo(result.AsSpan(offset));
29+
offset += args.CredentialPublicKey.Length;
30+
31+
if (offset != result.Length)
32+
{
33+
throw new InvalidOperationException($"Expected attested credential data length '{length}', but got '{offset}'.");
34+
}
35+
36+
return result;
37+
}
38+
39+
public static ReadOnlyMemory<byte> MakeAuthenticatorData(in AuthenticatorDataArgs args)
40+
{
41+
const int RpIdHashLength = 32;
42+
const int AuthenticatorDataFlagsLength = 1;
43+
const int SignCountLength = 4;
44+
var length =
45+
RpIdHashLength +
46+
AuthenticatorDataFlagsLength +
47+
SignCountLength +
48+
(args.AttestedCredentialData?.Length ?? 0) +
49+
(args.Extensions?.Length ?? 0);
50+
var result = new byte[length];
51+
var offset = 0;
52+
53+
args.RpIdHash.Span.CopyTo(result.AsSpan(offset, RpIdHashLength));
54+
offset += RpIdHashLength;
55+
56+
result[offset] = (byte)args.Flags;
57+
offset += AuthenticatorDataFlagsLength;
58+
59+
BinaryPrimitives.WriteUInt32BigEndian(result.AsSpan(offset, SignCountLength), args.SignCount);
60+
offset += SignCountLength;
61+
62+
if (args.AttestedCredentialData is { } attestedCredentialData)
63+
{
64+
attestedCredentialData.Span.CopyTo(result.AsSpan(offset));
65+
offset += attestedCredentialData.Length;
66+
}
67+
68+
if (args.Extensions is { } extensions)
69+
{
70+
extensions.Span.CopyTo(result.AsSpan(offset));
71+
offset += extensions.Length;
72+
}
73+
74+
if (offset != result.Length)
75+
{
76+
throw new InvalidOperationException($"Expected authenticator data length '{length}', but got '{offset}'.");
77+
}
78+
79+
return result;
80+
}
81+
82+
public static ReadOnlyMemory<byte> MakeAttestationObject(in AttestationObjectArgs args)
83+
{
84+
var writer = new CborWriter(CborConformanceMode.Ctap2Canonical);
85+
writer.WriteStartMap(args.CborMapLength);
86+
if (args.Format is { } format)
87+
{
88+
writer.WriteTextString("fmt");
89+
writer.WriteTextString(format);
90+
}
91+
if (args.AttestationStatement is { } attestationStatement)
92+
{
93+
writer.WriteTextString("attStmt");
94+
writer.WriteEncodedValue(attestationStatement.Span);
95+
}
96+
if (args.AuthenticatorData is { } authenticatorData)
97+
{
98+
writer.WriteTextString("authData");
99+
writer.WriteByteString(authenticatorData.Span);
100+
}
101+
writer.WriteEndMap();
102+
return writer.Encode();
103+
}
104+
}

0 commit comments

Comments
 (0)