From 66b772d9db16bf1784d8b5b10e9bf32290d85649 Mon Sep 17 00:00:00 2001 From: "J.L.M" Date: Mon, 13 Apr 2026 18:32:35 +0200 Subject: [PATCH] Add "synthetic"/ "mock" FIDO2 credential creation. --- Module/Cmdlets/FIDO2/NewFIDO2Credential.cs | 63 ++++++++++++++----- .../FIDO2/SyntheticCredentialHelper.cs | 24 +++++++ Module/types/FIDO2/Challenge.cs | 37 +++++------ Module/types/FIDO2/CredentialData.cs | 3 + Pester/310-FIDO2.tests.ps1 | 9 +++ 5 files changed, 101 insertions(+), 35 deletions(-) create mode 100644 Module/support/FIDO2/SyntheticCredentialHelper.cs diff --git a/Module/Cmdlets/FIDO2/NewFIDO2Credential.cs b/Module/Cmdlets/FIDO2/NewFIDO2Credential.cs index 0a6015a..addf433 100644 --- a/Module/Cmdlets/FIDO2/NewFIDO2Credential.cs +++ b/Module/Cmdlets/FIDO2/NewFIDO2Credential.cs @@ -1,19 +1,30 @@ /// -/// Creates a new FIDO2 credential on a YubiKey. -/// Supports creating credentials with various parameters including relying party information, -/// user data, and authentication options. Requires a YubiKey with FIDO2 support and -/// administrator privileges on Windows. +/// Creates a new FIDO2 discoverable credential on a YubiKey. +/// Supports creating credentials with various parameters including Relying Party (RP) +/// information, user data, and authentication options. Requires a YubiKey with FIDO2 support +/// and administrator privileges on Windows. When used with only -RelyingPartyID and -Username +/// (Synthetic parameter set), the cmdlet auto-generates a cryptographic challenge and +/// random user ID so no external IdP is needed. /// /// .EXAMPLE -/// $challenge = New-YubiKeyFIDO2Challenge -/// New-YubiKeyFIDO2Credential -RelyingPartyID "example.com" -Username "user@example.com" -Challenge $challenge -/// Creates a new FIDO2 credential for example.com with the specified username +/// New-YubiKeyFIDO2Credential -RelyingPartyID "example.local" -Username "alice@example.local" +/// Creates a synthetic credential (without an actual IdP) with a default display name. +/// +/// .EXAMPLE +/// New-YubiKeyFIDO2Credential -RelyingPartyID "example.local" -Username "alice@example.local" -UserDisplayName "Alice Smith" +/// Creates a synthetic credential (without an actual IdP) with a custom display name. +/// +/// .EXAMPLE +/// $challengeB64Url = "<challenge from relying party registerBegin response>" +/// $challenge = [powershellYK.FIDO2.Challenge]::FromBase64URLEncoded($challengeB64Url) +/// New-YubiKeyFIDO2Credential -RelyingPartyID "example.com" -RelyingPartyName "Example" -Username "user@example.com" -UserID ([byte[]](0x01)) -Challenge $challenge +/// Creates a credential using the challenge issued by the relying party during registration. /// /// .EXAMPLE /// $rp = Get-YubiKeyFIDO2Credential | Select-Object -First 1 -ExpandProperty RelyingParty -/// $challenge = New-YubiKeyFIDO2Challenge -/// New-YubiKeyFIDO2Credential -RelyingParty $rp -Username "user@example.com" -Challenge $challenge -/// Creates a new FIDO2 credential using an existing relying party object +/// $challenge = [powershellYK.FIDO2.Challenge]::CreateSyntheticChallenge($rp.Id) +/// New-YubiKeyFIDO2Credential -RelyingParty $rp -Username "user@example.com" -UserID ([byte[]](0x01)) -Challenge $challenge +/// Creates a credential reusing a relying party from an existing credential with a locally generated challenge. /// // Imports @@ -23,6 +34,7 @@ using powershellYK.support; using Yubico.YubiKey.Cryptography; using powershellYK.FIDO2; +using powershellYK.support.FIDO2; namespace powershellYK.Cmdlets.Fido { @@ -32,10 +44,12 @@ public class NewYubikeyFIDO2CredentialCmdlet : PSCmdlet // Parameters for relying party information [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Specify which relayingParty (site) this credential is regards to.", ParameterSetName = "UserData-HostData")] [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Specify which relayingParty (site) this credential is regards to.", ParameterSetName = "UserEntity-HostData")] + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Relying party ID (domain) for the credential.", ParameterSetName = "Synthetic")] public required string RelyingPartyID { private get; set; } [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "Friendlyname for the relayingParty.", ParameterSetName = "UserData-HostData")] [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "Friendlyname for the relayingParty.", ParameterSetName = "UserEntity-HostData")] + [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "Friendly name for the relying party. Defaults to RelyingPartyID.", ParameterSetName = "Synthetic")] public required string RelyingPartyName { private get; set; } [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "RelaingParty object.", ParameterSetName = "UserData-RelyingParty")] @@ -45,10 +59,12 @@ public class NewYubikeyFIDO2CredentialCmdlet : PSCmdlet // Parameters for user information [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Username to create credental for.", ParameterSetName = "UserData-RelyingParty")] [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Username to create credental for.", ParameterSetName = "UserData-HostData")] + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Username for the credential.", ParameterSetName = "Synthetic")] public required string Username { private get; set; } [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "UserDisplayName to create credental for.", ParameterSetName = "UserData-RelyingParty")] [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "UserDisplayName to create credental for.", ParameterSetName = "UserData-HostData")] + [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "Display name for the user. Defaults to Username.", ParameterSetName = "Synthetic")] public string? UserDisplayName { private get; set; } [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "UserID.", ParameterSetName = "UserData-RelyingParty")] @@ -56,8 +72,11 @@ public class NewYubikeyFIDO2CredentialCmdlet : PSCmdlet public byte[]? UserID { private get; set; } // Parameters for credential configuration - [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Challange.")] - public required Challenge Challenge { private get; set; } + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Challenge for credential registration.", ParameterSetName = "UserData-HostData")] + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Challenge for credential registration.", ParameterSetName = "UserData-RelyingParty")] + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Challenge for credential registration.", ParameterSetName = "UserEntity-HostData")] + [Parameter(Mandatory = true, ValueFromPipeline = false, HelpMessage = "Challenge for credential registration.", ParameterSetName = "UserEntity-RelyingParty")] + public Challenge? Challenge { private get; set; } [Parameter(Mandatory = false, ValueFromPipeline = false, HelpMessage = "Should this credential be discoverable.")] public bool Discoverable { private get; set; } = true; @@ -103,6 +122,20 @@ protected override void ProcessRecord() // Set up key collector for PIN operations fido2Session.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; + if (ParameterSetName == "Synthetic") + { + WriteDebug("Synthetic mode: generating Challenge and UserID automatically."); + Challenge = FIDO2.Challenge.CreateSyntheticChallenge(RelyingPartyID); + RelyingParty = new RelyingParty(RelyingPartyID) { Name = RelyingPartyName ?? RelyingPartyID }; + byte[] syntheticUserId = SyntheticCredentialHelper.GenerateUserID(); + WriteDebug($"Generated synthetic UserID: {Converter.ByteArrayToString(syntheticUserId)}"); + UserEntity = new UserEntity(syntheticUserId.AsMemory()) + { + Name = Username, + DisplayName = UserDisplayName ?? Username, + }; + } + // Configure relying party information if (RelyingParty is null) { @@ -142,7 +175,7 @@ protected override void ProcessRecord() { type = "webauthn.create", origin = $"https://{RelyingParty.Id}", - challenge = Challenge.Base64URLEncode(), + challenge = Challenge!.Base64URLEncode(), }; var clientDataJSON = System.Text.Json.JsonSerializer.Serialize(clientData); @@ -160,10 +193,12 @@ protected override void ProcessRecord() } // Create and return the credential - WriteDebug($"Sending new credential data into SDK"); + WriteDebug($"Promting for touch to complete the credential creation..."); + Console.WriteLine("Touch the YubiKey..."); MakeCredentialData returnvalue = fido2Session.MakeCredential(make); var credData = new CredentialData(returnvalue, clientDataJSON, UserEntity!, RelyingParty); + WriteInformation($"Credential created for {UserEntity!.DisplayName ?? UserEntity.Name} using RP: {RelyingParty.Id}.", new string[] { "FIDO2", "Info" }); WriteObject(credData); } } diff --git a/Module/support/FIDO2/SyntheticCredentialHelper.cs b/Module/support/FIDO2/SyntheticCredentialHelper.cs new file mode 100644 index 0000000..8cbacb9 --- /dev/null +++ b/Module/support/FIDO2/SyntheticCredentialHelper.cs @@ -0,0 +1,24 @@ +/// +/// Helpers for creating FIDO2 synthetic credentials without an IdP. +/// GenerateUserID returns 32 cryptographically random bytes suitable for +/// WebAuthn user.id (spec allows 1-64 bytes; value must not contain PII). +/// + +// Imports +using Yubico.YubiKey.Cryptography; + +namespace powershellYK.support.FIDO2 +{ + // Helpers for creating FIDO2 synthetic credentials without an IdP + public static class SyntheticCredentialHelper + { + // Generates a 32-byte cryptographically random user ID + public static byte[] GenerateUserID() + { + byte[] userId = new byte[32]; + var rng = CryptographyProviders.RngCreator(); + rng.GetBytes(userId); + return userId; + } + } +} diff --git a/Module/types/FIDO2/Challenge.cs b/Module/types/FIDO2/Challenge.cs index dab0ff2..bda90ff 100644 --- a/Module/types/FIDO2/Challenge.cs +++ b/Module/types/FIDO2/Challenge.cs @@ -3,13 +3,13 @@ /// Handles challenge generation, encoding, and conversion between formats. /// /// .EXAMPLE -/// # Create a challenge from a base64 string +/// # Create a challenge from a base64 string (from an IdP registerBegin response) /// $challenge = [powershellYK.FIDO2.Challenge]::new("SGVsbG8gV29ybGQ=") /// Write-Host $challenge.ToString() /// /// .EXAMPLE -/// # Create a fake challenge for testing -/// $challenge = [powershellYK.FIDO2.Challenge]::FakeChallange("example.com") +/// # Create a synthetic challenge locally (without an actual IdP) +/// $challenge = [powershellYK.FIDO2.Challenge]::CreateSyntheticChallenge("example.local") /// Write-Host $challenge.Base64URLEncode() /// @@ -37,10 +37,16 @@ public Challenge(byte[] value) this._challenge = value; } - // Generates a fake challenge for testing purposes + public static Challenge CreateSyntheticChallenge(string relyingPartyID) + { + return new Challenge(BuildSyntheticChallengeBytes(32)); + } + + // Kept for backward compatibility; Pester tests and earlier scripts reference this name. + [System.Obsolete("Use CreateSyntheticChallenge instead.")] public static Challenge FakeChallange(string relyingPartyID) { - return new Challenge(BuildFakeClientDataHash(relyingPartyID)); + return CreateSyntheticChallenge(relyingPartyID); } // Converts the challenge to a string representation @@ -111,23 +117,12 @@ private static string AddMissingPadding(string base64) return base64; } - // Builds a fake client data hash for testing - private static byte[] BuildFakeClientDataHash(string relyingPartyId) + private static byte[] BuildSyntheticChallengeBytes(int length = 32) { - // Convert relying party ID to bytes - byte[] idBytes = System.Text.Encoding.Unicode.GetBytes(relyingPartyId); - - // Generate random challenge bytes - var randomObject = CryptographyProviders.RngCreator(); - byte[] randomBytes = new byte[16]; - randomObject.GetBytes(randomBytes); - - // Create hash of random bytes and relying party ID - var digester = CryptographyProviders.Sha256Creator(); - _ = digester.TransformBlock(randomBytes, 0, randomBytes.Length, null, 0); - _ = digester.TransformFinalBlock(idBytes, 0, idBytes.Length); - - return digester.Hash!; + byte[] randomBytes = new byte[length]; + var rng = CryptographyProviders.RngCreator(); + rng.GetBytes(randomBytes); + return randomBytes; } #endregion // Support Methods diff --git a/Module/types/FIDO2/CredentialData.cs b/Module/types/FIDO2/CredentialData.cs index 647d841..94b1a67 100644 --- a/Module/types/FIDO2/CredentialData.cs +++ b/Module/types/FIDO2/CredentialData.cs @@ -19,6 +19,7 @@ using System.Runtime.CompilerServices; using System.Text; using Yubico.YubiKey.Fido2; +using Yubico.YubiKey.Fido2.Cose; namespace powershellYK.FIDO2 { @@ -28,6 +29,8 @@ public class CredentialData // Properties for accessing credential data public MakeCredentialData MakeCredentialData { get { return this._makeCredentialData; } } public string ClientDataJSON { get { return this._clientDataJSON; } } + public CoseKey? PublicKey => _makeCredentialData.AuthenticatorData.CredentialPublicKey; + public ReadOnlyMemory? CredentialId => _makeCredentialData.AuthenticatorData.CredentialId?.Id; // Internal storage for credential components private readonly string _clientDataJSON; diff --git a/Pester/310-FIDO2.tests.ps1 b/Pester/310-FIDO2.tests.ps1 index 39e9558..848e4ff 100644 --- a/Pester/310-FIDO2.tests.ps1 +++ b/Pester/310-FIDO2.tests.ps1 @@ -10,6 +10,15 @@ Describe "FIDO2 Tests" -Tag @("FIDO2") { {Connect-YubikeyFIDO2 -PIN (ConvertTo-SecureString -String "654321" -AsPlainText -Force)} | Should -Not -Throw {Set-YubikeyFIDO2PIN -OldPIN (ConvertTo-SecureString -String "654321" -AsPlainText -Force) -NewPIN (ConvertTo-SecureString -String "123456" -AsPlainText -Force)} | Should -Not -Throw } + It -Name "Create synthetic credential (no IdP)" -Test { + {New-YubiKeyFIDO2Credential -RelyingPartyID 'powershellYK-synthetic' -Username 'syntheticUser'} | Should -Not -Throw + (Get-YubiKeyFIDO2Credential | Where-Object { $_.RPId -eq 'powershellYK-synthetic' }).UserName | Should -Be 'syntheticUser' + {Get-YubiKeyFIDO2Credential | Where-Object { $_.RPId -eq 'powershellYK-synthetic' } | ForEach-Object { Remove-YubikeyFIDO2Credential -CredentialId $_.CredentialID -Confirm:$false }} | Should -Not -Throw + } + It -Name "Create synthetic credential with display name" -Test { + {New-YubiKeyFIDO2Credential -RelyingPartyID 'powershellYK-synthetic2' -Username 'synUser2' -UserDisplayName 'Synthetic User Two'} | Should -Not -Throw + {Get-YubiKeyFIDO2Credential | Where-Object { $_.RPId -eq 'powershellYK-synthetic2' } | ForEach-Object { Remove-YubikeyFIDO2Credential -CredentialId $_.CredentialID -Confirm:$false }} | Should -Not -Throw + } It -Name "Clear all credentials" -Test { {Get-YubiKeyFIDO2Credential|%{Remove-YubikeyFIDO2Credential -CredentialId $_.CredentialID -Confirm:$false}} | Should -Not -Throw (Get-YubiKeyFIDO2Credential).Count | Should -Be 0