diff --git a/Tools.SAMLResponseDecryptor/Program.cs b/Tools.SAMLResponseDecryptor/Program.cs index 2a173a195f..eab8d98d6c 100644 --- a/Tools.SAMLResponseDecryptor/Program.cs +++ b/Tools.SAMLResponseDecryptor/Program.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text; using System.Xml; +using Spectre.Console; namespace Tools.SAMLResponseDecryptor { @@ -9,58 +10,170 @@ internal class Program { private static void Main() { - Console.WriteLine(@"Enter the path for the certificate file (eg C:\Temp\MyCertificate.pfx, __no quotes__):"); - var certificatePath = Console.ReadLine(); - if (!File.Exists(certificatePath)) + AnsiConsole.Write(new Rule("[bold deepskyblue1]SAML Response Decryptor[/]").RuleStyle("deepskyblue1")); + AnsiConsole.WriteLine(); + + string certPath; + while (true) { - Console.WriteLine("Certificate file not found."); - return; + certPath = AskPath("[deepskyblue1]Certificate file[/] [grey](PFX path, no quotes):[/]"); + if (File.Exists(certPath)) break; + AnsiConsole.MarkupLine("[red]File not found, try again.[/]"); } - Console.WriteLine("Enter the password for the certificate (from 1pwd):"); - var certificatePassword = Console.ReadLine() ?? string.Empty; - Console.WriteLine("Enter the SAML response symmetric key ():"); - var key = ReadValueOrFile(); - var decryptedSymmetricKey = SAMLDecryptor.DecryptSymmetricKey(key, certificatePath, certificatePassword); + var certPassword = AnsiConsole.Prompt( + new TextPrompt("[deepskyblue1]Certificate password[/] [grey](from 1Password):[/]") + .Secret()); - Console.WriteLine("Enter the SAML response assertion (), or a file path prefixed with 'file:':"); - Console.WriteLine(" (Tip: save the value to a .txt file and enter: file:C:\\Temp\\assertion.txt)"); - var data = ReadValueOrFile(); + AnsiConsole.WriteLine(); + AnsiConsole.Write( + new Panel( + "1. Open [bold]DevTools → Network[/] tab in your browser [grey](F12)[/] and start a new recording\n" + + "2. Trigger the KOMBIT SSO login flow for the KITOS application\n" + + "3. Locate the [bold]POST[/] request to [bold]Login.ashx[/] [grey](this is the SSO callback sent by KOMBIT/n2adgangsstyring back to your SP)[/]\n" + + "4. Open the [bold]Payload[/] tab and copy the [bold]SAMLResponse[/] form field value\n" + + "5. URL-decode the value, then Base64-decode it — save the resulting XML as a [bold].xml[/] file") + .Header("[bold yellow] How to capture the SAML response [/]") + .BorderColor(Color.Yellow) + .Padding(new Padding(1, 0, 1, 0))); + AnsiConsole.WriteLine(); - Console.WriteLine($"[DEBUG] Symmetric key : {decryptedSymmetricKey.Length} bytes ({decryptedSymmetricKey.Length * 8} bits)"); - Console.WriteLine($"[DEBUG] Assertion data : {Convert.FromBase64String(data).Length} bytes"); + var samlFilePath = AskPath("[deepskyblue1]SAML response XML file[/] [grey](path, or Enter for manual input):[/]"); - var decryptedAssertion = SAMLDecryptor.DecryptAssertion(data, decryptedSymmetricKey); - Console.WriteLine("---"); - Console.WriteLine($"Decrypted assertion: {decryptedAssertion}"); - Console.WriteLine("---"); - Console.WriteLine("Extracting privilege..."); - var xml = new XmlDocument(); - xml.LoadXml(decryptedAssertion); - var nsmgr = new XmlNamespaceManager(xml.NameTable); - nsmgr.AddNamespace("samlp", "urn:oasis:names:tc:SAML:2.0:protocol"); + string key, data, assertionAlgorithm = string.Empty; + + if (!string.IsNullOrEmpty(samlFilePath)) + { + if (!File.Exists(samlFilePath)) + { + AnsiConsole.MarkupLine($"[red]File not found:[/] {Markup.Escape(samlFilePath)}"); + return; + } + if (!ExtractCipherValuesFromSamlFile(samlFilePath, out key, out data, out assertionAlgorithm)) + return; + } + else + { + key = AskValue("[deepskyblue1]Encrypted symmetric key[/] [grey](, or file:path):[/]"); + data = AskValue("[deepskyblue1]Assertion cipher value[/] [grey](, or file:path):[/]"); + } + + byte[] decryptedSymmetricKey; + try + { + decryptedSymmetricKey = SAMLDecryptor.DecryptSymmetricKey(key, certPath, certPassword); + } + catch (System.Security.Cryptography.CryptographicException ex) + { + AnsiConsole.MarkupLine($"[red]Could not decrypt symmetric key:[/] {Markup.Escape(ex.Message)}"); + AnsiConsole.MarkupLine("[grey]Possible causes: wrong certificate, invalid PFX password, or all RSA padding modes failed.[/]"); + return; + } + + string decryptedAssertion; + try + { + decryptedAssertion = SAMLDecryptor.DecryptAssertion(data, decryptedSymmetricKey, + string.IsNullOrEmpty(assertionAlgorithm) ? null : assertionAlgorithm); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Could not decrypt assertion:[/] {Markup.Escape(ex.Message)}"); + return; + } + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[bold green]Decrypted Assertion[/]").RuleStyle("green")); + AnsiConsole.WriteLine(decryptedAssertion); + AnsiConsole.Write(new Rule().RuleStyle("green")); + AnsiConsole.WriteLine(); + + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(decryptedAssertion); + var nsmgr = new XmlNamespaceManager(xmlDoc.NameTable); nsmgr.AddNamespace("saml", "urn:oasis:names:tc:SAML:2.0:assertion"); - nsmgr.AddNamespace("xenc", "http://www.w3.org/2001/04/xmlenc#"); - nsmgr.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#"); - var node = xml.SelectSingleNode("//saml:Attribute[@Name='dk:gov:saml:attribute:Privileges_intermediate']/saml:AttributeValue", nsmgr); + + var node = xmlDoc.SelectSingleNode( + "//saml:Attribute[@Name='dk:gov:saml:attribute:Privileges_intermediate']/saml:AttributeValue", + nsmgr); + if (node == null) { - Console.WriteLine("No privileges found"); + AnsiConsole.MarkupLine("[yellow]No Privileges_intermediate attribute found in the assertion.[/]"); } else { - var attributeValue = node.InnerText; - var baseDecodedPrivilege = Convert.FromBase64String(attributeValue); - var decodedPrivilege = Encoding.UTF8.GetString(baseDecodedPrivilege); - Console.WriteLine($"Privilege={decodedPrivilege}"); + var decodedPrivilege = Encoding.UTF8.GetString(Convert.FromBase64String(node.InnerText)); + AnsiConsole.Write(new Rule("[bold green]Privileges[/]").RuleStyle("green")); + AnsiConsole.WriteLine(decodedPrivilege); + AnsiConsole.Write(new Rule().RuleStyle("green")); } + + AnsiConsole.WriteLine(); + AnsiConsole.Markup("[grey]Press Enter to exit.[/] "); Console.ReadLine(); } - private static string ReadValueOrFile() + private static bool ExtractCipherValuesFromSamlFile(string samlFilePath, + out string encryptedKeyCipherValue, out string assertionCipherValue, out string assertionEncryptionAlgorithm) + { + encryptedKeyCipherValue = string.Empty; + assertionCipherValue = string.Empty; + assertionEncryptionAlgorithm = string.Empty; + + var xml = new XmlDocument(); + xml.Load(samlFilePath); + + var ns = new XmlNamespaceManager(xml.NameTable); + ns.AddNamespace("xenc", "http://www.w3.org/2001/04/xmlenc#"); + + // First is inside — the RSA-encrypted symmetric key. + // Second is inside — the AES-encrypted assertion. + var encMethodNode = xml.SelectSingleNode("//xenc:EncryptedData/xenc:EncryptionMethod", ns); + assertionEncryptionAlgorithm = encMethodNode?.Attributes?["Algorithm"]?.Value ?? string.Empty; + + var cipherValues = xml.SelectNodes("//xenc:CipherData/xenc:CipherValue", ns); + if (cipherValues == null || cipherValues.Count < 2) + { + AnsiConsole.MarkupLine($"[red]Expected 2 CipherValue nodes, found {cipherValues?.Count ?? 0}.[/]"); + AnsiConsole.MarkupLine("[grey]Ensure the file contains a full EncryptedAssertion with both EncryptedKey and EncryptedData.[/]"); + return false; + } + + encryptedKeyCipherValue = cipherValues[0]!.InnerText.Trim(); + assertionCipherValue = cipherValues[1]!.InnerText.Trim(); + + var algoShort = assertionEncryptionAlgorithm.Contains("gcm", StringComparison.OrdinalIgnoreCase) ? "AES-256-GCM" : + assertionEncryptionAlgorithm.Contains("cbc", StringComparison.OrdinalIgnoreCase) ? "AES-256-CBC" : + assertionEncryptionAlgorithm; + + AnsiConsole.MarkupLine( + $"[grey]Algorithm:[/] [white]{Markup.Escape(algoShort)}[/] " + + $"[grey]key:[/] {encryptedKeyCipherValue.Length} chars " + + $"[grey]assertion:[/] {assertionCipherValue.Length} chars"); + return true; + } + + /// Prints a coloured label and reads the raw typed path (no file-content resolution). + private static string AskPath(string markupLabel) + { + AnsiConsole.Markup(markupLabel + " "); + return Console.ReadLine()?.Trim() ?? string.Empty; + } + + /// + /// Prints a coloured label and reads a value that may optionally be a file:-prefixed path + /// or a bare file path, in which case the file's text contents are returned instead. + /// + private static string AskValue(string markupLabel) { + AnsiConsole.Markup(markupLabel + " "); var input = Console.ReadLine()?.Trim() ?? string.Empty; + return ResolveFile(input); + } + private static string ResolveFile(string input) + { if (input.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) { var filePath = input["file:".Length..].Trim(); @@ -68,13 +181,8 @@ private static string ReadValueOrFile() throw new FileNotFoundException($"File not found: {filePath}"); return File.ReadAllText(filePath).Trim(); } - if (File.Exists(input)) - { - Console.WriteLine($"[INFO] Reading value from file: {input}"); return File.ReadAllText(input).Trim(); - } - return input; } } diff --git a/Tools.SAMLResponseDecryptor/SAMLDecryptor.cs b/Tools.SAMLResponseDecryptor/SAMLDecryptor.cs index ab196ef381..af93055f70 100644 --- a/Tools.SAMLResponseDecryptor/SAMLDecryptor.cs +++ b/Tools.SAMLResponseDecryptor/SAMLDecryptor.cs @@ -11,7 +11,7 @@ public static byte[] DecryptSymmetricKey(string encryptedKeyBase64, string priva var certificate = X509CertificateLoader.LoadPkcs12FromFile( privateKeyPath, certificatePassword, - X509KeyStorageFlags.Exportable); + X509KeyStorageFlags.EphemeralKeySet); // GetRSAPrivateKey() returns null if the certificate has no RSA private key (CS8602) var rsa = certificate.GetRSAPrivateKey() @@ -19,12 +19,12 @@ public static byte[] DecryptSymmetricKey(string encryptedKeyBase64, string priva byte[] encryptedKeyBytes = Convert.FromBase64String(encryptedKeyBase64); - var paddingModes = new[] - { - (RSAEncryptionPadding.OaepSHA256, "OAEP SHA-256"), - (RSAEncryptionPadding.OaepSHA1, "OAEP SHA-1"), - (RSAEncryptionPadding.Pkcs1, "PKCS#1 v1.5"), - }; + var paddingModes = new[] + { + (RSAEncryptionPadding.OaepSHA1, "OAEP SHA-1 (rsa-oaep-mgf1p + sha1)"), // matches EncryptionMethod rsa-oaep-mgf1p / DigestMethod sha1 + (RSAEncryptionPadding.OaepSHA256, "OAEP SHA-256"), + (RSAEncryptionPadding.Pkcs1, "PKCS#1 v1.5"), + }; foreach (var (padding, name) in paddingModes) { @@ -47,23 +47,26 @@ public static string DecryptAssertion(string encryptedAssertionBase64, byte[] sy { byte[] encryptedAssertionBytes = Convert.FromBase64String(encryptedAssertionBase64); - // AES-256-GCM (xmlenc11): layout is [nonce:12][ciphertext:n][tag:16] - const int nonceSize = 12; // GCM nonce: 96 bits - const int tagSize = 16; // GCM auth tag: 128 bits + // AES-256-CBC (xmlenc#aes256-cbc): layout is [iv:16][ciphertext:n] + const int ivSize = 16; // CBC IV: 128 bits - if (encryptedAssertionBytes.Length < nonceSize + tagSize) + if (encryptedAssertionBytes.Length < ivSize) throw new CryptographicException( - $"Assertion data too short ({encryptedAssertionBytes.Length} bytes) to contain a GCM nonce and tag."); + $"Assertion data too short ({encryptedAssertionBytes.Length} bytes) to contain a CBC IV."); + + byte[] iv = encryptedAssertionBytes[..ivSize]; + byte[] cipherText = encryptedAssertionBytes[ivSize..]; - byte[] nonce = encryptedAssertionBytes[..nonceSize]; - byte[] tag = encryptedAssertionBytes[^tagSize..]; - byte[] cipherText = encryptedAssertionBytes[nonceSize..^tagSize]; - byte[] plainText = new byte[cipherText.Length]; + Console.WriteLine($"[DEBUG] CBC iv={ivSize}B, ciphertext={cipherText.Length}B"); - Console.WriteLine($"[DEBUG] GCM nonce={nonceSize}B, ciphertext={cipherText.Length}B, tag={tagSize}B"); + using var aes = System.Security.Cryptography.Aes.Create(); + aes.Key = symmetricKey; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; - using var aesGcm = new AesGcm(symmetricKey, tagSize); - aesGcm.Decrypt(nonce, cipherText, tag, plainText); + using var decryptor = aes.CreateDecryptor(); + byte[] plainText = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length); return Encoding.UTF8.GetString(plainText); } diff --git a/Tools.SAMLResponseDecryptor/Tools.SAMLResponseDecryptor.csproj b/Tools.SAMLResponseDecryptor/Tools.SAMLResponseDecryptor.csproj index 1431afc831..59062db1e8 100644 --- a/Tools.SAMLResponseDecryptor/Tools.SAMLResponseDecryptor.csproj +++ b/Tools.SAMLResponseDecryptor/Tools.SAMLResponseDecryptor.csproj @@ -7,4 +7,7 @@ Tools.SAMLResponseDecryptor Tools.SAMLResponseDecryptor + + +