From 66b772d9db16bf1784d8b5b10e9bf32290d85649 Mon Sep 17 00:00:00 2001 From: "J.L.M" Date: Mon, 13 Apr 2026 18:32:35 +0200 Subject: [PATCH 1/3] 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 From 3ef5bfc3233b95b51399769dfb77e9343287681b Mon Sep 17 00:00:00 2001 From: "J.L.M" Date: Mon, 13 Apr 2026 19:37:24 +0200 Subject: [PATCH 2/3] Auto-create FIDO2 credential in Protect-YubiKeyFIDO2File when none is supplied Change the default parameter set to "AutoCreate" so that running Protect-YubiKeyFIDO2File without -Credential or -RelyingPartyID automatically creates a synthetic credential (RP and username both set to "prf-encryption") via New-YubiKeyFIDO2Credential. Add a -Force switch to suppress the confirmation prompt during auto-creation. Update XML doc examples to cover the new default flow. --- .../Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs b/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs index 3d36657..5723a56 100644 --- a/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs +++ b/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs @@ -1,16 +1,28 @@ /// /// Encrypts a file using FIDO2 PRF (hmac-secret) extension on a YubiKey. /// Uses HKDF-SHA256 for key derivation and AES-256-GCM for authenticated encryption. -/// Requires a YubiKey with FIDO2 hmac-secret support and administrator privileges on Windows. +/// Requires a YubiKey with FIDO2 hmac-secret support, a FIDO2 PIN, and administrator +/// privileges on Windows. +/// When no credential or relying party is provided, the cmdlet automatically creates a +/// synthetic FIDO2 credential (RP and username both set to prf-encryption) and +/// uses it for encryption. +/// +/// .EXAMPLE +/// Protect-YubiKeyFIDO2File -Path .\secret.txt +/// Encrypts secret.txt, automatically creating a FIDO2 credential (prf-encryption) for file encryption. +/// +/// .EXAMPLE +/// Protect-YubiKeyFIDO2File -Path .\secret.txt -Force +/// Same as above but skips the credential-creation confirmation prompt. /// /// .EXAMPLE /// $cred = Get-YubiKeyFIDO2Credential | Where-Object { $_.RelyingParty.Id -eq "demo.yubico.com" } /// Protect-YubiKeyFIDO2File -Path .\secret.txt -Credential $cred -/// Encrypts secret.txt using the specified FIDO2 credential +/// Encrypts secret.txt using the specified FIDO2 credential. /// /// .EXAMPLE /// Get-Item .\secret.txt | Protect-YubiKeyFIDO2File -Credential $cred -/// Encrypts a file via pipeline input +/// Encrypts a file via pipeline input. /// /// .EXAMPLE /// Protect-YubiKeyFIDO2File -Path .\secret.txt -RelyingPartyID "demo.yubico.com" @@ -35,7 +47,7 @@ namespace powershellYK.Cmdlets.Fido { - [Cmdlet(VerbsSecurity.Protect, "YubiKeyFIDO2File", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, DefaultParameterSetName = "WithCredential")] + [Cmdlet(VerbsSecurity.Protect, "YubiKeyFIDO2File", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, DefaultParameterSetName = "AutoCreate")] public class ProtectYubiKeyFIDO2FileCmdlet : PSCmdlet { // Parameters for file input/output @@ -68,6 +80,12 @@ public class ProtectYubiKeyFIDO2FileCmdlet : PSCmdlet [ValidateNotNullOrEmpty] public string? RelyingPartyID { get; set; } + [Parameter(Mandatory = false, HelpMessage = "Suppress the confirmation prompt when auto-creating a credential.")] + public SwitchParameter Force { get; set; } + + private const string AutoCreateRpId = "prf-encryption"; + private const string AutoCreateUsername = "prf-encryption"; + // HKDF domain separation info string for key derivation private static readonly byte[] HkdfInfo = "powershellYK/fido2prf/v1"u8.ToArray(); @@ -131,7 +149,35 @@ protected override void ProcessRecord() byte[] credIdBytes; string rpId; - if (ParameterSetName == "WithCredential") + if (ParameterSetName == "AutoCreate") + { + string username = AutoCreateUsername; + rpId = AutoCreateRpId; + + if (!Force.IsPresent && !ShouldContinue( + $"No credential or relying party was provided. A new FIDO2 credential will be created on RP '{rpId}' for user '{username}'. Continue?", + "Create FIDO2 credential")) + { + return; + } + + WriteDebug($"AutoCreate: invoking New-YubiKeyFIDO2Credential for RP '{rpId}', user '{username}'..."); + var ps = PowerShell.Create(RunspaceMode.CurrentRunspace) + .AddCommand("New-YubiKeyFIDO2Credential") + .AddParameter("RelyingPartyID", rpId) + .AddParameter("Username", username) + .AddParameter("Confirm", false); + if (this.MyInvocation.BoundParameters.ContainsKey("InformationAction")) + ps.AddParameter("InformationAction", this.MyInvocation.BoundParameters["InformationAction"]); + + var results = ps.Invoke(); + if (results.Count == 0 || results[0].BaseObject is not CredentialData credData) + throw new InvalidOperationException("Failed to create a FIDO2 credential for file encryption."); + + credIdBytes = credData.CredentialId!.Value.ToArray(); + WriteDebug($"AutoCreate: credential created, ID {Convert.ToHexString(credIdBytes).ToLowerInvariant()}"); + } + else if (ParameterSetName == "WithCredential") { credIdBytes = Credential!.CredentialID.ToByte(); rpId = Credential.RelyingParty.Id!; From a08eaeb2d9cf4da5b0d0b204bc2ccc216ca66ed1 Mon Sep 17 00:00:00 2001 From: "J.L.M" Date: Tue, 14 Apr 2026 18:27:28 +0200 Subject: [PATCH 3/3] Add default parameter sets for FIDO2 blob and PRF cmdlets to auto-create supporting FIDO2 credential. --- .../Cmdlets/FIDO2/ExportYubiKeyFIDO2Blob.cs | 68 ++- .../Cmdlets/FIDO2/Import-YubiKeyFIDO2Blob.cs | 482 +++++++++++------- .../Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs | 77 ++- 3 files changed, 395 insertions(+), 232 deletions(-) diff --git a/Module/Cmdlets/FIDO2/ExportYubiKeyFIDO2Blob.cs b/Module/Cmdlets/FIDO2/ExportYubiKeyFIDO2Blob.cs index 329a36c..103c029 100644 --- a/Module/Cmdlets/FIDO2/ExportYubiKeyFIDO2Blob.cs +++ b/Module/Cmdlets/FIDO2/ExportYubiKeyFIDO2Blob.cs @@ -1,6 +1,11 @@ /// /// Allows the return of a large blob associated with a FIDO2 credential, which may contain additional metadata or state information for that credential. /// Requires a YubiKey with FIDO2 support and administrator privileges on Windows. +/// When no credential or relying party is provided, the cmdlet automatically looks up the "blob-storage" credential. +/// +/// .EXAMPLE +/// Export-YubiKeyFIDO2Blob -OutFile fileName.txt +/// Exports the large blob stored under the "blob-storage" credential. /// /// .EXAMPLE /// Export-YubiKeyFIDO2Blob -RelyingPartyID "demo.yubico.com" -OutFile fileName.txt @@ -25,7 +30,7 @@ namespace powershellYK.Cmdlets.Fido { - [Cmdlet(VerbsData.Export, "YubiKeyFIDO2Blob")] + [Cmdlet(VerbsData.Export, "YubiKeyFIDO2Blob", DefaultParameterSetName = "AutoLookup")] public class ExportYubikeyFIDO2BlobCmdlet : PSCmdlet { [Parameter( @@ -46,6 +51,12 @@ public class ExportYubikeyFIDO2BlobCmdlet : PSCmdlet [ValidateNotNullOrEmpty] public string? RelyingPartyID { get; set; } + [Parameter( + Mandatory = true, + ParameterSetName = "AutoLookup", + ValueFromPipeline = false, + HelpMessage = "Output file path for the exported large blob" + )] [Parameter( Mandatory = true, ParameterSetName = "Export LargeBlob", @@ -62,6 +73,8 @@ public class ExportYubikeyFIDO2BlobCmdlet : PSCmdlet [ValidatePath(fileMustExist: false, fileMustNotExist: true)] public required System.IO.FileInfo OutFile { get; set; } + private const string AutoCreateRpId = "blob-storage"; + // Initialize processing and verify requirements protected override void BeginProcessing() { @@ -84,22 +97,19 @@ protected override void BeginProcessing() WriteDebug($"Successfully connected"); } - // Connect to FIDO2 if exporting large blob - if (ParameterSetName == "Export LargeBlob" || ParameterSetName == "Export LargeBlob by RelyingPartyID") + // Connect to FIDO2 if not already authenticated + if (YubiKeyModule._fido2PIN is null) { + WriteDebug("No FIDO2 session has been authenticated, calling Connect-YubikeyFIDO2..."); + var myPowersShellInstance = PowerShell.Create(RunspaceMode.CurrentRunspace).AddCommand("Connect-YubikeyFIDO2"); + if (this.MyInvocation.BoundParameters.ContainsKey("InformationAction")) + { + myPowersShellInstance = myPowersShellInstance.AddParameter("InformationAction", this.MyInvocation.BoundParameters["InformationAction"]); + } + myPowersShellInstance.Invoke(); if (YubiKeyModule._fido2PIN is null) { - WriteDebug("No FIDO2 session has been authenticated, calling Connect-YubikeyFIDO2..."); - var myPowersShellInstance = PowerShell.Create(RunspaceMode.CurrentRunspace).AddCommand("Connect-YubikeyFIDO2"); - if (this.MyInvocation.BoundParameters.ContainsKey("InformationAction")) - { - myPowersShellInstance = myPowersShellInstance.AddParameter("InformationAction", this.MyInvocation.BoundParameters["InformationAction"]); - } - myPowersShellInstance.Invoke(); - if (YubiKeyModule._fido2PIN is null) - { - throw new Exception("Connect-YubikeyFIDO2 failed to connect to the FIDO2 applet!"); - } + throw new Exception("Connect-YubikeyFIDO2 failed to connect to the FIDO2 applet!"); } } } @@ -122,7 +132,35 @@ protected override void ProcessRecord() RelyingParty? credentialRelyingParty = null; var relyingParties = fido2Session.EnumerateRelyingParties(); powershellYK.FIDO2.CredentialID selectedCredentialId; - if (ParameterSetName == "Export LargeBlob by RelyingPartyID") + if (ParameterSetName == "AutoLookup") + { + var match = relyingParties.FirstOrDefault(rp => + string.Equals(rp.Id, AutoCreateRpId, StringComparison.OrdinalIgnoreCase)); + if (match is null) + { + throw new InvalidOperationException( + $"No '{AutoCreateRpId}' credential found on this YubiKey. " + + "Use Import-YubiKeyFIDO2Blob to store a blob first, or specify -CredentialId / -RelyingPartyID."); + } + + try + { + var creds = fido2Session.EnumerateCredentialsForRelyingParty(match); + if (creds.Count == 0) + { + throw new InvalidOperationException( + $"No credentials found for relying party '{match.Id}'."); + } + credentialRelyingParty = match; + selectedCredentialId = (powershellYK.FIDO2.CredentialID)creds[0].CredentialId; + } + catch (NotSupportedException) + { + throw new InvalidOperationException( + $"Unable to enumerate credentials for relying party '{match.Id}' due to unsupported algorithm."); + } + } + else if (ParameterSetName == "Export LargeBlob by RelyingPartyID") { if (string.IsNullOrWhiteSpace(RelyingPartyID)) { diff --git a/Module/Cmdlets/FIDO2/Import-YubiKeyFIDO2Blob.cs b/Module/Cmdlets/FIDO2/Import-YubiKeyFIDO2Blob.cs index 3b4125b..ba868d2 100644 --- a/Module/Cmdlets/FIDO2/Import-YubiKeyFIDO2Blob.cs +++ b/Module/Cmdlets/FIDO2/Import-YubiKeyFIDO2Blob.cs @@ -1,17 +1,27 @@ /// /// Allows uploading of large blobs to the YubiKey FIDO2 applet, associated with a specific credential ID or relying party. /// Requires a YubiKey with FIDO2 support and administrator privileges on Windows. +/// When no credential or relying party is provided, the cmdlet automatically creates or reuses +/// a synthetic FIDO2 credential (RP and username both set to "blob-storage") and uses it for storage. /// /// .EXAMPLE -/// Import-YubiKeyFIDO2Blob -LargeBlob test.txt -RelyingPartyID "demo.yubico.com" +/// Import-YubiKeyFIDO2Blob -LargeBlob .\test.txt +/// Imports a file as a large blob, automatically creating or reusing a "blob-storage" credential. +/// +/// .EXAMPLE +/// Import-YubiKeyFIDO2Blob -LargeBlob .\test.txt -Force +/// Same as above but skips the credential-creation confirmation prompt. +/// +/// .EXAMPLE +/// Import-YubiKeyFIDO2Blob -LargeBlob .\test.txt -RelyingPartyID "demo.yubico.com" /// Imports a file as a large blob when there is no more than one credential for the Relying Party on the YubiKey. /// /// .EXAMPLE -/// Import-YubiKeyFIDO2Blob -LargeBlob test.txt -CredentialId "19448fe...67ab9207071e" +/// Import-YubiKeyFIDO2Blob -LargeBlob .\test.txt -CredentialId "19448fe...67ab9207071e" /// Imports a file as a large blob for a specified FIDO2 credential by ID (use when the RP has multiple credentials). /// /// .EXAMPLE -/// Import-YubiKeyFIDO2Blob -LargeBlob test.txt -CredentialId "19448fe...67ab9207071e" -Force +/// Import-YubiKeyFIDO2Blob -LargeBlob .\test.txt -CredentialId "19448fe...67ab9207071e" -Force /// Imports a file as a large blob and overwrites any existing blob entry for that credential without prompting. /// @@ -30,10 +40,16 @@ namespace powershellYK.Cmdlets.Fido { - [Cmdlet(VerbsData.Import, "YubiKeyFIDO2Blob")] + [Cmdlet(VerbsData.Import, "YubiKeyFIDO2Blob", SupportsShouldProcess = true, ConfirmImpact = ConfirmImpact.High, DefaultParameterSetName = "AutoCreate")] public class ImportYubikeyFIDO2BlobCmdlet : PSCmdlet { // Parameters for large blob import + [Parameter( + Mandatory = true, + ParameterSetName = "AutoCreate", + ValueFromPipeline = false, + HelpMessage = "File to import as large blob" + )] [Parameter( Mandatory = true, ParameterSetName = "Set LargeBlob", @@ -68,6 +84,12 @@ public class ImportYubikeyFIDO2BlobCmdlet : PSCmdlet [ValidateNotNullOrEmpty] public string? RelyingPartyID { get; set; } + [Parameter( + Mandatory = false, + ParameterSetName = "AutoCreate", + ValueFromPipeline = false, + HelpMessage = "Suppress confirmation prompts (credential creation and blob overwrite)." + )] [Parameter( Mandatory = false, ParameterSetName = "Set LargeBlob", @@ -82,6 +104,9 @@ public class ImportYubikeyFIDO2BlobCmdlet : PSCmdlet )] public SwitchParameter Force { get; set; } + private const string AutoCreateRpId = "blob-storage"; + private const string AutoCreateUsername = "blob-storage"; + // Initialize processing and verify requirements protected override void BeginProcessing() { @@ -111,230 +136,301 @@ protected override void BeginProcessing() // Process the main cmdlet logic protected override void ProcessRecord() { - using (var fido2Session = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + if (LargeBlob is null) { - fido2Session.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; + throw new ArgumentException("You must enter a valid file path.", nameof(LargeBlob)); + } - switch (ParameterSetName) + // Resolve and read the input file + string resolvedPath = GetUnresolvedProviderPathFromPSPath(LargeBlob.FullName); + byte[] blobData; + try + { + blobData = System.IO.File.ReadAllBytes(resolvedPath); + WriteDebug($"Step 1: Input file loaded from '{LargeBlob.FullName}' ({blobData.Length} bytes)."); + } + catch (Exception ex) + { + throw new IOException($"Failed to read large blob data from file '{LargeBlob}'.", ex); + } + + // ── Phase 1: Resolve credential ID and RP in isolated sessions ── + powershellYK.FIDO2.CredentialID selectedCredentialId; + string? resolvedRpId = null; + + if (ParameterSetName == "AutoCreate") + { + resolvedRpId = AutoCreateRpId; + byte[]? credIdBytes = null; + + using (var lookupSession = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) { - case "Set LargeBlob": - case "Set LargeBlob by RelyingPartyID": - // Verify the YubiKey supports large blobs - if (fido2Session.AuthenticatorInfo.MaximumSerializedLargeBlobArray is null) + lookupSession.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; + + if (lookupSession.AuthenticatorInfo.MaximumSerializedLargeBlobArray is null) + { + throw new NotSupportedException("This YubiKey does not support FIDO2 large blobs."); + } + + var rps = lookupSession.EnumerateRelyingParties(); + var match = rps.FirstOrDefault(rp => + string.Equals(rp.Id, AutoCreateRpId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + var creds = lookupSession.EnumerateCredentialsForRelyingParty(match); + if (creds.Count > 0) { - throw new NotSupportedException("This YubiKey does not support FIDO2 large blobs."); + credIdBytes = creds[0].CredentialId.Id.ToArray(); + WriteDebug($"AutoCreate: reusing existing credential {Convert.ToHexString(credIdBytes).ToLowerInvariant()} for RP '{AutoCreateRpId}'."); } - WriteDebug($"Step 1: Large blob support verified (max {fido2Session.AuthenticatorInfo.MaximumSerializedLargeBlobArray.Value} bytes)."); + } + } - if (LargeBlob is null) - { - throw new ArgumentException("You must enter a valid file path.", nameof(LargeBlob)); - } + if (credIdBytes is null) + { + if (!Force.IsPresent && !ShouldContinue( + $"No credential or relying party was provided. A new FIDO2 credential will be created on RP '{AutoCreateRpId}'. Continue?", + "Create FIDO2 credential")) + { + return; + } + + WriteDebug($"AutoCreate: invoking New-YubiKeyFIDO2Credential for RP '{AutoCreateRpId}', user '{AutoCreateUsername}'..."); + var ps = PowerShell.Create(RunspaceMode.CurrentRunspace) + .AddCommand("New-YubiKeyFIDO2Credential") + .AddParameter("RelyingPartyID", AutoCreateRpId) + .AddParameter("Username", AutoCreateUsername) + .AddParameter("Confirm", false); + if (this.MyInvocation.BoundParameters.ContainsKey("InformationAction")) + ps.AddParameter("InformationAction", this.MyInvocation.BoundParameters["InformationAction"]); + + var results = ps.Invoke(); + if (results.Count == 0 || results[0].BaseObject is not CredentialData credData) + throw new InvalidOperationException("Failed to create a FIDO2 credential for blob storage."); + + credIdBytes = credData.CredentialId!.Value.ToArray(); + WriteDebug($"AutoCreate: credential created, ID {Convert.ToHexString(credIdBytes).ToLowerInvariant()}"); + } - // Resolve and read the input file - string resolvedPath = GetUnresolvedProviderPathFromPSPath(LargeBlob.FullName); - byte[] blobData; - try + selectedCredentialId = new powershellYK.FIDO2.CredentialID(credIdBytes); + } + else if (ParameterSetName == "Set LargeBlob by RelyingPartyID") + { + if (string.IsNullOrWhiteSpace(RelyingPartyID)) + { + throw new ArgumentNullException(nameof(RelyingPartyID), "A relying party ID/name must be provided when setting a large blob by RelyingPartyID."); + } + + using (var resolveSession = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + { + resolveSession.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; + + var relyingParties = resolveSession.EnumerateRelyingParties(); + var matchingRps = relyingParties.Where(rpMatch => + string.Equals(rpMatch.Id, RelyingPartyID, StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(rpMatch.Name) && string.Equals(rpMatch.Name, RelyingPartyID, StringComparison.OrdinalIgnoreCase))) + .ToList(); + + if (matchingRps.Count == 0) + { + throw new ArgumentException($"No relying party found matching '{RelyingPartyID}' on this YubiKey.", nameof(RelyingPartyID)); + } + if (matchingRps.Count > 1) + { + string rpCandidates = string.Join(", ", matchingRps.Select(rpMatch => $"'{rpMatch.Id}'")); + throw new InvalidOperationException( + $"Multiple relying parties matched '{RelyingPartyID}': {rpCandidates}. " + + "Use a specific RP ID with -RelyingPartyID, or specify -CredentialId directly."); + } + + RelyingParty credentialRelyingParty = matchingRps[0]; + try + { + var credentialsForOrigin = resolveSession.EnumerateCredentialsForRelyingParty(credentialRelyingParty); + if (credentialsForOrigin.Count == 0) { - blobData = System.IO.File.ReadAllBytes(resolvedPath); - WriteDebug($"Step 2: Input file loaded from '{LargeBlob.FullName}' ({blobData.Length} bytes)."); + throw new InvalidOperationException($"No credentials found for relying party '{credentialRelyingParty.Id}'."); } - catch (Exception ex) + if (credentialsForOrigin.Count > 1) { - throw new IOException($"Failed to read large blob data from file '{LargeBlob}'.", ex); + string candidateCredentialIds = string.Join(", ", + credentialsForOrigin.Select(c => Convert.ToHexString(c.CredentialId.Id.ToArray()).ToLowerInvariant())); + throw new InvalidOperationException( + $"Relying party '{credentialRelyingParty.Id}' has multiple credentials ({credentialsForOrigin.Count}). " + + $"Use Get-YubiKeyFIDO2Credential -RelyingPartyID {credentialRelyingParty.Id} to list credentials, then use -CredentialId to choose which credential to use."); } - // Resolve target credential and corresponding relying party. - RelyingParty? credentialRelyingParty = null; - var relyingParties = fido2Session.EnumerateRelyingParties(); - powershellYK.FIDO2.CredentialID selectedCredentialId; - if (ParameterSetName == "Set LargeBlob by RelyingPartyID") - { - if (string.IsNullOrWhiteSpace(RelyingPartyID)) - { - throw new ArgumentNullException(nameof(RelyingPartyID), "A relying party ID/name must be provided when setting a large blob by RelyingPartyID."); - } + selectedCredentialId = (powershellYK.FIDO2.CredentialID)credentialsForOrigin[0].CredentialId; + resolvedRpId = credentialRelyingParty.Id; + } + catch (NotSupportedException) + { + throw new InvalidOperationException( + $"Unable to enumerate credentials for relying party '{credentialRelyingParty.Id}' due to unsupported algorithm."); + } + } + } + else + { + // "Set LargeBlob" — CredentialId provided directly + if (CredentialId is null) + { + throw new ArgumentNullException(nameof(CredentialId), "A FIDO2 credential ID must be provided when setting a large blob."); + } + selectedCredentialId = CredentialId.Value; + // resolvedRpId stays null; Phase 2 will scan for it + } - var matchingRps = relyingParties.Where(rpMatch => - string.Equals(rpMatch.Id, RelyingPartyID, StringComparison.OrdinalIgnoreCase) || - (!string.IsNullOrWhiteSpace(rpMatch.Name) && string.Equals(rpMatch.Name, RelyingPartyID, StringComparison.OrdinalIgnoreCase))) - .ToList(); + // ── Phase 2: Fresh session for assertion + blob operations ── + using (var fido2Session = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + { + fido2Session.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; - if (matchingRps.Count == 0) - { - throw new ArgumentException($"No relying party found matching '{RelyingPartyID}' on this YubiKey.", nameof(RelyingPartyID)); - } - if (matchingRps.Count > 1) - { - string rpCandidates = string.Join(", ", matchingRps.Select(rpMatch => $"'{rpMatch.Id}'")); - throw new InvalidOperationException( - $"Multiple relying parties matched '{RelyingPartyID}': {rpCandidates}. " + - "Use a specific RP ID with -RelyingPartyID, or specify -CredentialId directly."); - } + // Verify the YubiKey supports large blobs + if (fido2Session.AuthenticatorInfo.MaximumSerializedLargeBlobArray is null) + { + throw new NotSupportedException("This YubiKey does not support FIDO2 large blobs."); + } + WriteDebug($"Step 2: Large blob support verified (max {fido2Session.AuthenticatorInfo.MaximumSerializedLargeBlobArray.Value} bytes)."); - credentialRelyingParty = matchingRps[0]; - try - { - var credentialsForOrigin = fido2Session.EnumerateCredentialsForRelyingParty(credentialRelyingParty); - if (credentialsForOrigin.Count == 0) - { - throw new InvalidOperationException($"No credentials found for relying party '{credentialRelyingParty.Id}'."); - } - if (credentialsForOrigin.Count > 1) - { - string candidateCredentialIds = string.Join(", ", - credentialsForOrigin.Select(c => Convert.ToHexString(c.CredentialId.Id.ToArray()).ToLowerInvariant())); - throw new InvalidOperationException( - $"Relying party '{credentialRelyingParty.Id}' has multiple credentials ({credentialsForOrigin.Count}). " + - $"Use Get-YubiKeyFIDO2Credential -RelyingPartyID {credentialRelyingParty.Id} to list credentials, then use -CredentialId to choose which credential to use."); - } + // Resolve the RelyingParty object for the assertion + RelyingParty credentialRelyingParty; + if (resolvedRpId is not null) + { + credentialRelyingParty = new RelyingParty(resolvedRpId); + } + else + { + // CredentialId-only: scan all RPs to find the matching one + RelyingParty? foundRp = null; + byte[] credentialIdBytes = selectedCredentialId.ToByte(); + var relyingParties = fido2Session.EnumerateRelyingParties(); - selectedCredentialId = (powershellYK.FIDO2.CredentialID)credentialsForOrigin[0].CredentialId; - } - catch (NotSupportedException) - { - throw new InvalidOperationException( - $"Unable to enumerate credentials for relying party '{credentialRelyingParty.Id}' due to unsupported algorithm."); - } - } - else + foreach (RelyingParty currentRp in relyingParties) + { + try { - // Ensure a credential ID was supplied - if (CredentialId is null) - { - throw new ArgumentNullException(nameof(CredentialId), "A FIDO2 credential ID must be provided when setting a large blob."); - } - - selectedCredentialId = CredentialId.Value; - byte[] credentialIdBytes = selectedCredentialId.ToByte(); - - foreach (RelyingParty currentRp in relyingParties) + var credentials = fido2Session.EnumerateCredentialsForRelyingParty(currentRp); + foreach (var credInfo in credentials) { - try + if (credInfo.CredentialId.Id.ToArray().SequenceEqual(credentialIdBytes)) { - var credentials = fido2Session.EnumerateCredentialsForRelyingParty(currentRp); - foreach (var credInfo in credentials) - { - if (credInfo.CredentialId.Id.ToArray().SequenceEqual(credentialIdBytes)) - { - credentialRelyingParty = currentRp; - break; - } - } - if (credentialRelyingParty is not null) - { - break; - } - } - catch (NotSupportedException) - { - // Skip relying parties with unsupported algorithms - continue; + foundRp = currentRp; + break; } } - - if (credentialRelyingParty is null) + if (foundRp is not null) { - throw new ArgumentException($"Credential with ID '{selectedCredentialId}' not found on this YubiKey.", nameof(CredentialId)); + break; } } - WriteDebug($"Step 3: Target resolved to RP '{credentialRelyingParty.Id}' and credential '{selectedCredentialId}'."); - - // Create client data hash for GetAssertion - byte[] challengeBytes = new byte[32]; - RandomNumberGenerator.Fill(challengeBytes); - var clientData = new - { - type = "webauthn.get", - origin = $"https://{credentialRelyingParty.Id}", - challenge = Convert.ToBase64String(challengeBytes) - }; - var clientDataJSON = JsonConvert.SerializeObject(clientData); - var clientDataBytes = System.Text.Encoding.UTF8.GetBytes(clientDataJSON); - var digester = CryptographyProviders.Sha256Creator(); - _ = digester.TransformFinalBlock(clientDataBytes, 0, clientDataBytes.Length); - ReadOnlyMemory clientDataHash = digester.Hash!.AsMemory(); - WriteDebug($"Step 4: Client data hash created for origin '{clientData.origin}'."); - - // Perform GetAssertion to retrieve the largeBlobKey - var gaParams = new GetAssertionParameters(credentialRelyingParty, clientDataHash); - - // Add the credential ID to the allow list (for non-resident keys) - gaParams.AllowCredential(selectedCredentialId.ToYubicoFIDO2CredentialID()); - - // Request the largeBlobKey extension - gaParams.AddExtension(Extensions.LargeBlobKey, new byte[] { 0xF5 }); - - // Execute assertion ceremony - Console.WriteLine("Touch the YubiKey..."); - var assertions = fido2Session.GetAssertions(gaParams); - if (assertions.Count == 0) + catch (NotSupportedException) { - throw new InvalidOperationException("GetAssertion returned no assertions."); + continue; } + } - // Retrieve the per-credential largeBlobKey - var retrievedKey = assertions[0].LargeBlobKey; - if (retrievedKey is null) - { - throw new NotSupportedException("The credential does not support large blob keys. The credential may need to be recreated with the largeBlobKey extension."); - } - WriteDebug($"Step 5: Assertion completed and largeBlobKey retrieved ({assertions.Count} assertion(s))."); + if (foundRp is null) + { + throw new ArgumentException($"Credential with ID '{selectedCredentialId}' not found on this YubiKey.", nameof(CredentialId)); + } + credentialRelyingParty = foundRp; + } + WriteDebug($"Step 3: Target resolved to RP '{credentialRelyingParty.Id}' and credential '{selectedCredentialId}'."); - // Get the current serialized Large Blob array from the authenticator - var blobArray = fido2Session.GetSerializedLargeBlobArray(); - WriteDebug($"Step 6: Current large blob array loaded ({blobArray.Entries.Count} entries)."); + // Create client data hash for GetAssertion + byte[] challengeBytes = new byte[32]; + RandomNumberGenerator.Fill(challengeBytes); + var clientData = new + { + type = "webauthn.get", + origin = $"https://{credentialRelyingParty.Id}", + challenge = Convert.ToBase64String(challengeBytes) + }; + var clientDataJSON = JsonConvert.SerializeObject(clientData); + var clientDataBytes = System.Text.Encoding.UTF8.GetBytes(clientDataJSON); + var digester = CryptographyProviders.Sha256Creator(); + _ = digester.TransformFinalBlock(clientDataBytes, 0, clientDataBytes.Length); + ReadOnlyMemory clientDataHash = digester.Hash!.AsMemory(); + WriteDebug($"Step 4: Client data hash created for origin '{clientData.origin}'."); + + // Perform GetAssertion to retrieve the largeBlobKey + var gaParams = new GetAssertionParameters(credentialRelyingParty, clientDataHash); + + // Add the credential ID to the allow list (for non-resident keys) + gaParams.AllowCredential(selectedCredentialId.ToYubicoFIDO2CredentialID()); + + // Request the largeBlobKey extension + gaParams.AddExtension(Extensions.LargeBlobKey, new byte[] { 0xF5 }); + + // Execute assertion ceremony + Console.WriteLine("Touch the YubiKey..."); + var assertions = fido2Session.GetAssertions(gaParams); + if (assertions.Count == 0) + { + throw new InvalidOperationException("GetAssertion returned no assertions."); + } - // Enforce one entry per credential key by detecting existing decryptable entries. - var matchingEntryIndexes = new List(); - for (int i = 0; i < blobArray.Entries.Count; i++) - { - if (blobArray.Entries[i].TryDecrypt(retrievedKey.Value, out _)) - { - matchingEntryIndexes.Add(i); - } - } + // Retrieve the per-credential largeBlobKey + var retrievedKey = assertions[0].LargeBlobKey; + if (retrievedKey is null) + { + throw new NotSupportedException("The credential does not support large blob keys. The credential may need to be recreated with the largeBlobKey extension."); + } + WriteDebug($"Step 5: Assertion completed and largeBlobKey retrieved ({assertions.Count} assertion(s))."); - if (matchingEntryIndexes.Count > 0) - { - string existingMsg = - $"Found {matchingEntryIndexes.Count} existing large blob entr{(matchingEntryIndexes.Count == 1 ? "y" : "ies")} " + - $"for relying party '{credentialRelyingParty.Id}'."; - WriteWarning(existingMsg); + // Get the current serialized Large Blob array from the authenticator + var blobArray = fido2Session.GetSerializedLargeBlobArray(); + WriteDebug($"Step 6: Current large blob array loaded ({blobArray.Entries.Count} entries)."); - bool overwriteExisting = Force.IsPresent; - if (!overwriteExisting) - { - overwriteExisting = ShouldContinue( - $"{existingMsg} Overwrite existing entr{(matchingEntryIndexes.Count == 1 ? "y" : "ies")}?", - "Large blob entry already exists"); - } - - if (!overwriteExisting) - { - WriteWarning("Operation cancelled by user. Existing large blob entries were left unchanged."); - return; - } + // Enforce one entry per credential key by detecting existing decryptable entries. + var matchingEntryIndexes = new List(); + for (int i = 0; i < blobArray.Entries.Count; i++) + { + if (blobArray.Entries[i].TryDecrypt(retrievedKey.Value, out _)) + { + matchingEntryIndexes.Add(i); + } + } - for (int i = matchingEntryIndexes.Count - 1; i >= 0; i--) - { - blobArray.RemoveEntry(matchingEntryIndexes[i]); - } - } + if (matchingEntryIndexes.Count > 0) + { + string existingMsg = + $"Found {matchingEntryIndexes.Count} existing large blob entr{(matchingEntryIndexes.Count == 1 ? "y" : "ies")} " + + $"for relying party '{credentialRelyingParty.Id}'."; + WriteWarning(existingMsg); + + bool overwriteExisting = Force.IsPresent; + if (!overwriteExisting) + { + overwriteExisting = ShouldContinue( + $"{existingMsg} Overwrite existing entr{(matchingEntryIndexes.Count == 1 ? "y" : "ies")}?", + "Large blob entry already exists"); + } + + if (!overwriteExisting) + { + WriteWarning("Operation cancelled by user. Existing large blob entries were left unchanged."); + return; + } + + for (int i = matchingEntryIndexes.Count - 1; i >= 0; i--) + { + blobArray.RemoveEntry(matchingEntryIndexes[i]); + } + } - WriteDebug($"Step 7: Adding blob entry ({blobData.Length} bytes)."); - // Add a new encrypted entry, binding the data to the retrieved largeBlobKey - blobArray.AddEntry(blobData, retrievedKey.Value); + WriteDebug($"Step 7: Adding blob entry ({blobData.Length} bytes)."); + // Add a new encrypted entry, binding the data to the retrieved largeBlobKey + blobArray.AddEntry(blobData, retrievedKey.Value); - WriteDebug("Step 8: Writing updated large blob array to YubiKey..."); - // Write the updated Large Blob array back to the authenticator - fido2Session.SetSerializedLargeBlobArray(blobArray); + WriteDebug("Step 8: Writing updated large blob array to YubiKey..."); + // Write the updated Large Blob array back to the authenticator + fido2Session.SetSerializedLargeBlobArray(blobArray); - WriteInformation( - $"FIDO2 large blob entry added successfully for Relying Party (Origin): '{credentialRelyingParty.Id}'.", - new[] { "FIDO2", "LargeBlob" }); - break; - } + WriteInformation( + $"FIDO2 large blob entry added successfully for Relying Party (Origin): '{credentialRelyingParty.Id}'.", + new[] { "FIDO2", "LargeBlob" }); } } } diff --git a/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs b/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs index 5723a56..3803478 100644 --- a/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs +++ b/Module/Cmdlets/FIDO2/ProtectYubiKeyFIDO2File.cs @@ -4,12 +4,12 @@ /// Requires a YubiKey with FIDO2 hmac-secret support, a FIDO2 PIN, and administrator /// privileges on Windows. /// When no credential or relying party is provided, the cmdlet automatically creates a -/// synthetic FIDO2 credential (RP and username both set to prf-encryption) and +/// synthetic FIDO2 credential (RP and username both set to "prf-encryption" and /// uses it for encryption. /// /// .EXAMPLE /// Protect-YubiKeyFIDO2File -Path .\secret.txt -/// Encrypts secret.txt, automatically creating a FIDO2 credential (prf-encryption) for file encryption. +/// Encrypts secret.txt, automatically creating or reusing a "prf-encryption" credential. /// /// .EXAMPLE /// Protect-YubiKeyFIDO2File -Path .\secret.txt -Force @@ -142,20 +142,38 @@ protected override void ProcessRecord() byte[] salt = RandomNumberGenerator.GetBytes(PRFEncryptedFile.SaltLength); - using (var fido2Session = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + byte[] credIdBytes; + string rpId; + + if (ParameterSetName == "AutoCreate") { - fido2Session.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; + rpId = AutoCreateRpId; + credIdBytes = null!; - byte[] credIdBytes; - string rpId; + using (var lookupSession = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + { + lookupSession.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; - if (ParameterSetName == "AutoCreate") + var rps = lookupSession.EnumerateRelyingParties(); + var match = rps.FirstOrDefault(rp => + string.Equals(rp.Id, rpId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + { + var creds = lookupSession.EnumerateCredentialsForRelyingParty(match); + if (creds.Count > 0) + { + credIdBytes = creds[0].CredentialId.Id.ToArray(); + WriteDebug($"AutoCreate: reusing existing credential {Convert.ToHexString(credIdBytes).ToLowerInvariant()} for RP '{rpId}'."); + } + } + } + + if (credIdBytes is null) { string username = AutoCreateUsername; - rpId = AutoCreateRpId; if (!Force.IsPresent && !ShouldContinue( - $"No credential or relying party was provided. A new FIDO2 credential will be created on RP '{rpId}' for user '{username}'. Continue?", + $"No credential or relying party was provided. A new FIDO2 credential will be created on RP '{rpId}'. Continue?", "Create FIDO2 credential")) { return; @@ -177,19 +195,24 @@ protected override void ProcessRecord() credIdBytes = credData.CredentialId!.Value.ToArray(); WriteDebug($"AutoCreate: credential created, ID {Convert.ToHexString(credIdBytes).ToLowerInvariant()}"); } - else if (ParameterSetName == "WithCredential") + } + else if (ParameterSetName == "WithCredential") + { + credIdBytes = Credential!.CredentialID.ToByte(); + rpId = Credential.RelyingParty.Id!; + } + else if (ParameterSetName == "ByRelyingPartyID") + { + if (string.IsNullOrWhiteSpace(RelyingPartyID)) { - credIdBytes = Credential!.CredentialID.ToByte(); - rpId = Credential.RelyingParty.Id!; + throw new ArgumentNullException(nameof(RelyingPartyID), "A relying party ID or name must be provided."); } - else if (ParameterSetName == "ByRelyingPartyID") + + using (var resolveSession = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) { - if (string.IsNullOrWhiteSpace(RelyingPartyID)) - { - throw new ArgumentNullException(nameof(RelyingPartyID), "A relying party ID or name must be provided."); - } + resolveSession.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; - var relyingParties = fido2Session.EnumerateRelyingParties(); + var relyingParties = resolveSession.EnumerateRelyingParties(); var matchingRps = relyingParties.Where(rpMatch => string.Equals(rpMatch.Id, RelyingPartyID, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(rpMatch.Name) && string.Equals(rpMatch.Name, RelyingPartyID, StringComparison.OrdinalIgnoreCase))) @@ -211,7 +234,7 @@ protected override void ProcessRecord() RelyingParty credentialRelyingParty = matchingRps[0]; try { - var credentialsForOrigin = fido2Session.EnumerateCredentialsForRelyingParty(credentialRelyingParty); + var credentialsForOrigin = resolveSession.EnumerateCredentialsForRelyingParty(credentialRelyingParty); if (credentialsForOrigin.Count == 0) { throw new InvalidOperationException($"No credentials found for relying party '{credentialRelyingParty.Id}'."); @@ -235,11 +258,17 @@ protected override void ProcessRecord() $"Unable to enumerate credentials for relying party '{credentialRelyingParty.Id}' due to unsupported algorithm."); } } - else - { - credIdBytes = CredentialID.ToByte(); - rpId = RelyingPartyID!; - } + } + else + { + credIdBytes = CredentialID.ToByte(); + rpId = RelyingPartyID!; + } + + // Fresh session for the assertion -- not contaminated by credential creation + using (var fido2Session = new Fido2Session((YubiKeyDevice)YubiKeyModule._yubikey!)) + { + fido2Session.KeyCollector = YubiKeyModule._KeyCollector.YKKeyCollectorDelegate; // Build client data hash for the assertion var relyingParty = new RelyingParty(rpId);