Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 146 additions & 38 deletions Tools.SAMLResponseDecryptor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,79 +2,187 @@
using System.IO;
using System.Text;
using System.Xml;
using Spectre.Console;

namespace Tools.SAMLResponseDecryptor
{
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 (<e:CipherValue>):");
var key = ReadValueOrFile();
var decryptedSymmetricKey = SAMLDecryptor.DecryptSymmetricKey(key, certificatePath, certificatePassword);
var certPassword = AnsiConsole.Prompt(
new TextPrompt<string>("[deepskyblue1]Certificate password[/] [grey](from 1Password):[/]")
.Secret());

Console.WriteLine("Enter the SAML response assertion (<xenc:CipherValue>), 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](<e:CipherValue>, or file:path):[/]");
data = AskValue("[deepskyblue1]Assertion cipher value[/] [grey](<xenc:CipherValue>, 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);
Comment on lines +91 to +93
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);

Comment on lines +124 to +126
var ns = new XmlNamespaceManager(xml.NameTable);
ns.AddNamespace("xenc", "http://www.w3.org/2001/04/xmlenc#");

// First <xenc:CipherValue> is inside <e:EncryptedKey> — the RSA-encrypted symmetric key.
// Second <xenc:CipherValue> is inside <xenc:EncryptedData> — 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();
Comment on lines +135 to +144

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;
}

/// <summary>Prints a coloured label and reads the raw typed path (no file-content resolution).</summary>
private static string AskPath(string markupLabel)
{
AnsiConsole.Markup(markupLabel + " ");
return Console.ReadLine()?.Trim() ?? string.Empty;
}

/// <summary>
/// Prints a coloured label and reads a value that may optionally be a <c>file:</c>-prefixed path
/// or a bare file path, in which case the file's text contents are returned instead.
/// </summary>
private static string AskValue(string markupLabel)
{
AnsiConsole.Markup(markupLabel + " ");
var input = Console.ReadLine()?.Trim() ?? string.Empty;
return ResolveFile(input);
}
Comment on lines +168 to +173

private static string ResolveFile(string input)
{
if (input.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
var filePath = input["file:".Length..].Trim();
if (!File.Exists(filePath))
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;
}
}
Expand Down
41 changes: 22 additions & 19 deletions Tools.SAMLResponseDecryptor/SAMLDecryptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ 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()
?? throw new CryptographicException("Certificate does not contain an RSA private key.");

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)
{
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
<AssemblyName>Tools.SAMLResponseDecryptor</AssemblyName>
<RootNamespace>Tools.SAMLResponseDecryptor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Spectre.Console" Version="0.55.2" />
</ItemGroup>
</Project>
Loading