[RDMW-21965] Add a .NET wrapper for the WebAuthn authenticator - #131
Conversation
…ions The authenticator FFI was gated on `target_os = "ios"`, so the C ABI declared in slauth.h did not exist on any other platform. The ABI itself is platform-neutral, so gate it on the `native-bindings` feature instead — matching how oath and u2f expose their bindings — which makes it available to a .NET wrapper and any other cdylib consumer. The android module is a second C ABI over the same authenticator, shaped for Android's JSON-oriented Credential Manager, and it exports four of the same symbol names with different signatures, so the two modules are kept mutually exclusive to avoid colliding `#[no_mangle]` symbols. Also closes the leaks in that ABI: - `slauth_string_free` reclaims the `char*` returns. They are all `CString::into_raw` allocations and cannot be released by the caller's `free`, so `get_private_key_from_response`, `private_key_to_pkcs8_der`, `pkcs8_to_custom_private_key` and `get_error_message` all leaked. The buffer is wiped before release because the first of those carries private key material. - `request_response_free` releases `AuthenticatorRequestResponse`; only the creation response had a free function, so every assertion leaked the whole response. - `response_free` now null-checks instead of calling `Box::from_raw` on a null pointer. Note that `Buffer` borrows the response's storage, so callers must copy before freeing the response.
Adds wrappers/dotnet alongside the existing swift/android/wasm wrappers, so the authenticator C ABI is
consumable from .NET. The package ships the native library per RID under runtimes/<rid>/native, which is
what lets the .NET host resolve DllImport("slauth") with no setup in the consumer.
The wrapper deliberately does not expose raw pointers, because the C ABI has three ownership rules that
are easy to get wrong:
- Response pointers are held by SafeHandles, so they are always released.
- char* returns go back through slauth_string_free rather than the caller's free; they are Rust
CString::into_raw allocations, so freeing them with the C allocator is wrong. The swift wrapper
currently does exactly that.
- A returned Buffer borrows the response's storage, so every byte array handed to a caller is an owned
managed copy taken while the handle is still alive.
Strings are marshalled as UTF-8 by hand rather than with the platform default, which would corrupt a
non-ASCII relying party id.
build.ps1 cross-compiles each target, stages the natives per RID, runs the tests and packs. Adding a
platform is one entry in its target-to-RID map. Both Windows targets are wired into CI: rust.yml gains a
build-dotnet job for pull requests, and publish.yml gains a nuget input plus a job that packs and pushes
through devolutions/actions/jfrog-nuget-publish.
Tests run against the real ABI rather than a mock: they create credentials for ES256, EdDSA and RS256,
sign assertions, check the rpIdHash, round-trip a key through PKCS#8 and confirm it still signs, and loop
the create/free cycle so a bad free surfaces as a crash instead of a slow leak.
There was a problem hiding this comment.
Pull request overview
Adds managed .NET WebAuthn bindings with native-library packaging, lifecycle management, tests, and CI publishing support.
Changes:
- Adds the .NET API, native interop layer, tests, and build tooling.
- Makes the authenticator C ABI platform-neutral and adds deallocation functions.
- Adds NuGet build and publication workflows.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
wrappers/dotnet/README.md |
Documents usage, ownership, building, and tests. |
wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs |
Implements the public managed API. |
wrappers/dotnet/Devolutions.Slauth/Native.cs |
Defines P/Invoke bindings and safe handles. |
wrappers/dotnet/Devolutions.Slauth/Devolutions.Slauth.csproj |
Configures the NuGet library package. |
wrappers/dotnet/Devolutions.Slauth.Tests/WebAuthnTests.cs |
Adds native ABI integration tests. |
wrappers/dotnet/Devolutions.Slauth.Tests/Devolutions.Slauth.Tests.csproj |
Configures the managed test project. |
wrappers/dotnet/build.ps1 |
Builds, tests, stages, and packs native assets. |
src/webauthn/authenticator/native.rs |
Broadens ABI availability and adds free functions. |
slauth.h |
Declares the new deallocation APIs. |
.gitignore |
Excludes .NET outputs and staged native libraries. |
.github/workflows/rust.yml |
Adds .NET CI validation. |
.github/workflows/publish.yml |
Adds NuGet packaging and publication. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… the AT flag slauth_string_free moves from the webauthn authenticator module to the crate-wide `strings` module in lib.rs, next to `string_to_c_char` which is what allocates every `char*` the crate returns. That is the correct home — oath, u2f and webauthn all funnel through it — and it fixes a hole in the original placement: because the authenticator module is gated `not(feature = "android")`, android builds got no string free function at all despite their bindings returning the same into_raw pointers. All 14 libc `free` calls in the Swift wrapper now use it. Every one of them frees a pointer returned straight from a slauth entry point (hotp/totp uri and code, u2f origin, key handle and json, the webauthn private key and error message, and both pkcs8 converters), so all 14 had the allocator mismatch, not just the webauthn ones. WebAuthnRequestResponse also gains the missing `deinit` that calls request_response_free; without it every Swift assertion leaked its boxed response. The .NET wrapper now rejects creation flags without AttestedCredentialDataIncluded. slauth omits the attested credential data and still reports success in that case, producing an attestation object no relying party can use. The assertion tests now verify signatures cryptographically against the public key in the attestation object, for ES256, RS256 and EdDSA, instead of only asserting the bytes are non-empty. A companion test verifies a wrong challenge fails, so the helper cannot pass vacuously. Test-only dependencies on System.Formats.Cbor and BouncyCastle were added; .NET 10 has no Ed25519 in the BCL. Writing those tests surfaced a conformance bug that is left alone here: the OKP serialization in raw_message.rs is a copy of the EC2 branch and emits a `-3` member on Ed25519 keys, which RFC 8152 does not define for OKP. `from_value` also requires `-3` when parsing OKP, so correcting it means adding an x-only coords representation and changing the parser contract, which affects the server verification path and the three shipped platforms. The test parser ignores `-3` unless the key type is EC2.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (4)
wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs:120
- This validation still permits
ExtensionDataIncluded, although the nativeAuthenticatorData::to_vecimplementation never appends extension CBOR (src/webauthn/proto/raw_message.rs:189-202). Setting ED therefore produces registration data whose flags claim an extension map follows when none does. Reject ED until the ABI accepts and serializes extension data.
if ((attestationFlags & AttestationFlags.AttestedCredentialDataIncluded) == 0)
{
throw new ArgumentException(
"AttestedCredentialDataIncluded is required when creating a credential.",
nameof(attestationFlags));
}
wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs:215
- Assertions must not set AT without attested credential data, and ED must not be set without extension CBOR. This native path always passes
Nonefor attested data and serializes no extensions, so either flag yields malformed authenticator data whileIsSuccessremains true. Reject both unsupported structural flags before calling native code.
IntPtr rpIdPointer = Utf8.Allocate(rpId);
wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs:213
- WebAuthn's
clientDataHashis always a 32-byte SHA-256 digest, but the native signer accepts any length and still reports success. A caller can therefore receiveIsSuccess == truefor an assertion that every relying party will reject because it signs the wrong byte sequence. Validate the fixed hash length before invoking the ABI.
if (clientDataHash is null)
{
throw new ArgumentNullException(nameof(clientDataHash));
}
wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs:101
credentialIdis unbounded here, but the native serializer writes its length ascredential_id.len() as u16(src/webauthn/proto/raw_message.rs:197). For arrays larger than 65,535 bytes the length wraps while all bytes are still appended, so the relying party parses the remaining ID bytes as the COSE key and receives a malformed attestation. Reject IDs that cannot fit in the encoded length field before crossing the ABI.
This issue also appears in the following locations of the same file:
- line 115
- line 215
if (credentialId is null)
{
throw new ArgumentNullException(nameof(credentialId));
}
slauth signs whatever it is handed and reports success, so three classes of bad input reached the relying party as an unparseable assertion rather than as an error at the call site. Validate them at the ABI boundary instead. The structural flags now mirror each other: registration requires AT, an assertion rejects it because the assertion ABI always passes slauth None for attested credential data, and both reject ED since AuthenticatorData::to_vec never serializes an extension map. Either flag previously set a bit advertising bytes that were never appended, with IsSuccess still true. clientDataHash must be the 32-byte SHA-256 digest WebAuthn defines; slauth would otherwise sign a wrong-sized buffer and succeed. credentialId is bounded by the two-byte length field in the attested credential data, which slauth casts to u16 unchecked -- a longer id wrapped while every byte was still written, so the relying party read the overflow as the start of the COSE key. Tests cover each rejection plus the 65535-byte boundary, which must still encode exactly rather than wrap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nuget publish job added in #131 never ran. It failed at "Prepare all required actions", before any step: Devolutions/actions is an internal repository and GitHub does not let a public repository resolve `uses:` against a private or internal one, so the runner's repo-scoped token cannot see it at all -- hence not-found rather than a permissions error. The @v1 ref was never the problem; it exists as a branch. Adopt the pattern devolutions-gateway already uses in jetify.yml to reach these same shared actions from a public repository: check the repository out with actions/checkout and an explicit token, which does carry org access, then reference the actions by local path. Switch from jfrog-nuget-publish to nuget-artifactory-setup plus dotnet-push to match. That also drops a second latent failure, since jfrog-nuget-publish shells out to `jfrog rt upload` without installing the CLI and the Windows runners do not ship it, whereas dotnet-push uses the .NET SDK the job already sets up. Also terminate rust.yml with a newline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The RDM Desktop Extension acts as a Windows WebAuthn plugin and currently reimplements slauth's credential
envelope, COSE encoding and assertion signing by hand in C#, which risks drifting from the format Hub
writes through
slauth-web. This adds a .NET wrapper so it can call slauth instead.The authenticator C ABI was gated on
target_os = "ios", so nothing declared inslauth.hexisted on anyother platform. The ABI is platform-neutral, so it is gated on the
native-bindingsfeature instead,matching
oathandu2f. Thenot(feature = "android")half is required rather than cosmetic: theandroid module exports four of the same symbol names with different signatures and they would collide at
link time. iOS is unaffected.
Three leaks in that ABI are also fixed.
slauth_string_freereclaims thechar*returns — they areCString::into_rawallocations, so the caller'sfreeis the wrong allocator, which is what the swiftwrapper currently does; the private key string is wiped before release.
request_response_freereleasesAuthenticatorRequestResponse, which had no free function at all, so every assertion leaked. Andresponse_freenow null-checks.The wrapper exposes no raw pointers:
SafeHandles own the handles, strings go back throughslauth_string_free, and becauseBufferborrows the response's storage every byte array handed out is anowned copy.
build.ps1cross-compiles, stages natives per RID, tests and packs.rust.ymlgains abuild-dotnetjob andpublish.ymlanugetinput. Tests run against the real ABI: all three algorithms,assertion signing, a PKCS#8 round trip, and a create/free loop.