MobileAttest 1.0.0
dotnet add package MobileAttest --version 1.0.0
NuGet\Install-Package MobileAttest -Version 1.0.0
<PackageReference Include="MobileAttest" Version="1.0.0" />
<PackageVersion Include="MobileAttest" Version="1.0.0" />
<PackageReference Include="MobileAttest" />
paket add MobileAttest --version 1.0.0
#r "nuget: MobileAttest, 1.0.0"
#:package MobileAttest@1.0.0
#addin nuget:?package=MobileAttest&version=1.0.0
#tool nuget:?package=MobileAttest&version=1.0.0
MobileAttest
Server-side verification of mobile device attestation. Apple App Attest and Android key attestation, two mechanisms behind one result type.
dotnet add package MobileAttest
Targets net8.0. Two dependencies: BouncyCastle for cryptography and path validation,
System.Formats.Cbor for the Apple objects.
What it answers
At enrollment, once: was this key created in real hardware, by this application, on a device that booted the way it says it did.
On every request after that: did that same key sign these bytes.
Configure once
Everything the check compares against is yours to supply. The library ships no trust root and downloads none, so an unconfigured anchor is a rejection rather than a pass.
var apple = new AppleAttestOptions
{
TeamId = "A1B2C3D4E5",
BundleIdAllowlist = new[] { "com.example.app" },
RequireProduction = true,
PinnedRootCertificates = PemRootStore.LoadFromPem(File.ReadAllText(appleRootPath)),
};
apple.Validate(); // empty allow list or no root throws here, not on first request
var android = new AndroidAttestOptions
{
AllowedSignatureDigests = new[] { "44D6FB97…" }, // SHA-256 of the APK signing cert
RequireStrongBox = false,
PinnedRootCertificates = PemRootStore.LoadFromPem(File.ReadAllText(googleRootsPath)),
RevocationPolicy = RevocationPolicy.Skip, // no default: you must choose
StatusCacheTtl = TimeSpan.FromHours(24),
};
android.Validate();
Google publishes two active roots and a deployment needs both; Apple publishes one. Pin them by fingerprint, and refresh at deployment time rather than at runtime.
Serving more than one application
An options object describes exactly one application: one team, one bundle allow list, one signing digest. A service that verifies for several applications keeps one set per application and picks it before verifying.
// Built once at startup, from your own registry of onboarded applications.
var verifiers = registry.ToDictionary(
app => app.ClientId,
app => new AndroidKeyAttestationVerifier(
new AndroidAttestOptions
{
AllowedSignatureDigests = new[] { app.SigningDigest }, // theirs, not yours
RequireStrongBox = app.RequireStrongBox,
PinnedRootCertificates = googleRoots, // shared: platform-wide
RevocationPolicy = RevocationPolicy.Skip,
},
TimeProvider.System,
statusSource));
// Per request: identify the application first, verify second.
if (!verifiers.TryGetValue(clientId, out var verifier))
{
return Unauthorized();
}
AttestationResult result = await verifier.VerifyAsync(request);
Two things are worth stating plainly about that order.
Identify the caller before verifying, never from the attestation. Apple does not expose the application identity in the attestation at all — only a hash to compare against. Android does expose the package name, but selecting a configuration from it would be circular: the device would be choosing the rules it is judged by.
The client identifier need not be a secret. If it is taken and replayed by someone else, their application is checked against the registered team, bundle and signing digest — and refused, because those are what actually authenticate. Getting the identifier wrong fails closed.
Trust roots are the exception to per-application configuration: Apple's root is the same for every Apple device and Google's for every Android device, so they are shared.
Verify an enrollment
var verifier = new AppleAppAttestVerifier(apple);
AttestationResult result = await verifier.VerifyAsync(
new AppleAttestationRequest(keyId, attestationObject, challengeYouIssued),
receivedAt); // the instant to judge certificates at, from your own record
if (!result.IsValid)
{
return Unauthorized(); // result.Reason names which check refused it
}
Store(result.KeyId, result.PublicKeyDer, result.AppId, result.SignCount);
Android is the same call with AndroidKeyAttestationVerifier and an
AndroidAttestationRequest carrying the certificate chain. Both return the same
AttestationResult, so one storage path serves both platforms.
Verify what the key signs afterwards
SignatureVerificationResult ok = DeviceSignature.Verify(
signature: popFromTheDevice, // DER ECDSA, as SHA256withECDSA produces
signedData: nonceYouIssued, // whatever you decided to bind
publicKeyDer: record.PublicKeyDer); // what enrollment returned, from your store
Apple App Attest assertions are a CBOR envelope rather than a bare signature, and carry a counter that must strictly advance:
AssertionResult op = await new AppleAssertionVerifier(record.AppId).VerifyAsync(
assertionObject, clientDataHash, record.PublicKeyDer, record.SignCount);
What it deliberately does not do
- No trust roots, no network of its own. Roots, allow lists and any status endpoint are
configuration. Where a status list is consulted, the
HttpClientand the address come from you. - No storage. Not a key, not a counter, not a challenge. Issuing nonces and refusing a second use stays with the caller — and without it, a verified signature only proves possession, never freshness.
- No position it can avoid. Whether an unverifiable key is acceptable has no default; a configuration that never chose is refused at startup.
- No opinion about your protocol.
DeviceSignaturecompares bytes. Binding those bytes to an operation is yours;AnchorSignatureContextis offered for it and is optional.
Certificate validity is asked about an instant you name rather than the current clock, so a backend can ask whether a certificate was valid when the attestation actually arrived. That instant must come from your own record, never from the device.
Documentation
- Full usage reference — every option, every failure reason, the order the checks run in and why that order is itself a security property
- Package on NuGet
Tests
Device vectors are deliberately not in this repository: they carry a real device key and a real application identity. Tests locate them at run time, and a test that needs one is skipped by name, stating the path it looked for, rather than passing quietly.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- BouncyCastle.Cryptography (>= 2.7.0)
- System.Formats.Cbor (>= 10.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.