Skip to content

[RDMW-21965] Add a .NET wrapper for the WebAuthn authenticator - #131

Merged
Bea (B-E-A-F) merged 4 commits into
masterfrom
feat/dotnet-wrapper
Jul 31, 2026
Merged

[RDMW-21965] Add a .NET wrapper for the WebAuthn authenticator#131
Bea (B-E-A-F) merged 4 commits into
masterfrom
feat/dotnet-wrapper

Conversation

@B-E-A-F

Copy link
Copy Markdown
Contributor

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 in slauth.h existed on any
other platform. The ABI is platform-neutral, so it is gated on the native-bindings feature instead,
matching oath and u2f. The not(feature = "android") half is required rather than cosmetic: the
android 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_free reclaims the char* returns — they are
CString::into_raw allocations, so the caller's free is the wrong allocator, which is what the swift
wrapper currently does; the private key string is wiped before release. request_response_free releases
AuthenticatorRequestResponse, which had no free function at all, so every assertion leaked. And
response_free now null-checks.

The wrapper exposes no raw pointers: SafeHandles own the handles, strings go back through
slauth_string_free, and because Buffer borrows the response's storage every byte array handed out is an
owned copy. build.ps1 cross-compiles, stages natives per RID, tests and packs. rust.yml gains a
build-dotnet job and publish.yml a nuget input. Tests run against the real ABI: all three algorithms,
assertion signing, a PKCS#8 round trip, and a create/free loop.

…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread wrappers/dotnet/Devolutions.Slauth.Tests/WebAuthnTests.cs Outdated
Comment thread src/webauthn/authenticator/native.rs Outdated
Comment thread src/webauthn/authenticator/native.rs
Comment thread wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs
… 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 native AuthenticatorData::to_vec implementation 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 None for attested data and serializes no extensions, so either flag yields malformed authenticator data while IsSuccess remains true. Reject both unsupported structural flags before calling native code.
        IntPtr rpIdPointer = Utf8.Allocate(rpId);

wrappers/dotnet/Devolutions.Slauth/WebAuthn.cs:213

  • WebAuthn's clientDataHash is always a 32-byte SHA-256 digest, but the native signer accepts any length and still reports success. A caller can therefore receive IsSuccess == true for 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

  • credentialId is unbounded here, but the native serializer writes its length as credential_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>
@B-E-A-F
Bea (B-E-A-F) merged commit 3fc7328 into master Jul 31, 2026
6 checks passed
@B-E-A-F
Bea (B-E-A-F) deleted the feat/dotnet-wrapper branch July 31, 2026 20:37
Bea (B-E-A-F) added a commit that referenced this pull request Jul 31, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants