From 8b26e905c6f72e0bb0e8fb50053db67426e233ef Mon Sep 17 00:00:00 2001 From: titus Date: Thu, 6 Feb 2025 13:55:21 +0100 Subject: [PATCH] + Added RemoteCdm and Serving + Changed session ids from int to byte[] + Fixed BigInteger->byte[] conversion + Removed GetWrmHeaders() + Improved PSSH parsing --- README.md | 5 +- csplayready/Cdm.cs | 41 ++-- csplayready/Exceptions.cs | 2 + csplayready/Program.cs | 145 +++++++------ csplayready/Utils.cs | 21 +- csplayready/crypto/Crypto.cs | 12 +- csplayready/crypto/EccKey.cs | 6 +- csplayready/csplayready.csproj | 6 +- csplayready/license/XmlKey.cs | 2 +- csplayready/remote/Context.cs | 63 ++++++ csplayready/remote/RemoteCdm.cs | 123 +++++++++++ csplayready/remote/Serve.cs | 367 ++++++++++++++++++++++++++++++++ csplayready/serve.example.yml | 15 ++ csplayready/system/PSSH.cs | 66 ++++-- csplayready/system/Session.cs | 9 +- 15 files changed, 761 insertions(+), 122 deletions(-) create mode 100644 csplayready/remote/Context.cs create mode 100644 csplayready/remote/RemoteCdm.cs create mode 100644 csplayready/remote/Serve.cs create mode 100644 csplayready/serve.example.yml diff --git a/README.md b/README.md index 0433672..fe7bad3 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,7 @@ var pssh = new Pssh( "LgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMA" + "PgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="); -var wrmHeaders = pssh.GetWrmHeaders(); -var challenge = cdm.GetLicenseChallenge(sessionId, wrmHeaders.First()); +var challenge = cdm.GetLicenseChallenge(sessionId, pssh.WrmHeaders.First()); using HttpClient client = new HttpClient(); var content = new StringContent(challenge, Encoding.UTF8, "text/xml"); @@ -66,6 +65,8 @@ cdm.ParseLicense(sessionId, responseBody); foreach (var key in cdm.GetKeys(sessionId)) Console.WriteLine($"{key.KeyId.ToHex()}:{key.RawKey.ToHex()}"); + +cdm.Close(sessionId); ``` ## Disclaimer diff --git a/csplayready/Cdm.cs b/csplayready/Cdm.cs index 027ce03..a940856 100644 --- a/csplayready/Cdm.cs +++ b/csplayready/Cdm.cs @@ -25,7 +25,7 @@ public class Cdm private static readonly byte[] RgbMagicConstantZero = [0x7e, 0xe9, 0xed, 0x4a, 0xf7, 0x73, 0x22, 0x4f, 0x00, 0xb8, 0xea, 0x7e, 0xfb, 0x02, 0x7c, 0xbb]; private readonly ECPoint _wmrmEcc256PubKey; - private readonly Dictionary _sessions = []; + private readonly Dictionary _sessions = []; private readonly CertificateChain _certificateChain; private readonly EccKey _encryptionKey; @@ -49,21 +49,23 @@ public class Cdm return new Cdm(device.GroupCertificate!, device.EncryptionKey!, device.SigningKey!); } - public int Open() + public uint? GetSecurityLevel() => _certificateChain.GetSecurityLevel(); + + public byte[] Open() { if (_sessions.Count > MaxNumOfSessions) throw new TooManySessions($"Too many Sessions open ({MaxNumOfSessions})."); - var session = new Session(_sessions.Count + 1); - _sessions[session.Id] = session; + var session = new Session(); + _sessions[session.Id.ToHex()] = session; return session.Id; } - public void Close(int sessionId) + public void Close(byte[] sessionId) { - if (!_sessions.Remove(sessionId)) - throw new InvalidSession($"Session identifier {sessionId} is invalid."); + if (!_sessions.Remove(sessionId.ToHex())) + throw new InvalidSession($"Session identifier {sessionId.ToHex()} is invalid."); } private byte[] GetKeyData(Session session) @@ -130,22 +132,19 @@ public class Cdm ""; } - public string GetLicenseChallenge(int sessionId, string wrmHeader) + public string GetLicenseChallenge(byte[] sessionId, string wrmHeader) { - if (!_sessions.TryGetValue(sessionId, out Session? session)) - throw new InvalidSession($"Session identifier {sessionId} is invalid."); + if (!_sessions.TryGetValue(sessionId.ToHex(), out Session? session)) + throw new InvalidSession($"Session identifier {sessionId.ToHex()} is invalid."); session.SigningKey = _signingKey; session.EncryptionKey = _encryptionKey; SecureRandom secureRandom = new SecureRandom(); - - var randomBytes = new byte[16]; - secureRandom.NextBytes(randomBytes); - + var laContent = GetDigestContent( wrmHeader, - Convert.ToBase64String(randomBytes), + Convert.ToBase64String(Crypto.GetRandomBytes(16)), Convert.ToBase64String(GetKeyData(session)), Convert.ToBase64String(GetCipherData(session)) ); @@ -190,10 +189,10 @@ public class Cdm return encryptionKey.SequenceEqual(session.EncryptionKey!.PublicBytes()); } - public void ParseLicense(int sessionId, string xmrLicense) + public void ParseLicense(byte[] sessionId, string xmrLicense) { - if (!_sessions.TryGetValue(sessionId, out Session? session)) - throw new InvalidSession($"Session identifier {sessionId} is invalid"); + if (!_sessions.TryGetValue(sessionId.ToHex(), out Session? session)) + throw new InvalidSession($"Session identifier {sessionId.ToHex()} is invalid"); if (session.EncryptionKey == null || session.SigningKey == null) throw new InvalidSession("Cannot parse a license message without first making a license request"); @@ -265,10 +264,10 @@ public class Cdm } } - public List GetKeys(int sessionId) + public List GetKeys(byte[] sessionId) { - if (!_sessions.TryGetValue(sessionId, out Session? session)) - throw new InvalidSession($"Session identifier {sessionId} is invalid"); + if (!_sessions.TryGetValue(sessionId.ToHex(), out Session? session)) + throw new InvalidSession($"Session identifier {sessionId.ToHex()} is invalid"); return session.Keys; } diff --git a/csplayready/Exceptions.cs b/csplayready/Exceptions.cs index 1922e6f..5f06f6a 100644 --- a/csplayready/Exceptions.cs +++ b/csplayready/Exceptions.cs @@ -11,5 +11,7 @@ public class InvalidCertificate(string message) : CsPlayreadyException(message); public class InvalidCertificateChain(string message, Exception innerException) : CsPlayreadyException(message, innerException); public class TooManySessions(string message) : CsPlayreadyException(message); public class InvalidSession(string message) : CsPlayreadyException(message); +public class InvalidPssh(string message, Exception innerException) : CsPlayreadyException(message, innerException); public class InvalidLicense(string message) : CsPlayreadyException(message); public class OutdatedDevice(string message) : CsPlayreadyException(message); +public class DeviceMismatch(string message) : CsPlayreadyException(message); diff --git a/csplayready/Program.cs b/csplayready/Program.cs index 2549336..9107932 100644 --- a/csplayready/Program.cs +++ b/csplayready/Program.cs @@ -6,14 +6,17 @@ using csplayready.license; using System.CommandLine; using System.Net; using System.Text; +using csplayready.remote; using Microsoft.Extensions.Logging; using Org.BouncyCastle.Security; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; namespace csplayready; class Program { - private const string Version = "0.5.2"; + public const string Version = "0.5.5"; private static List? License(ILogger logger, Device device, Pssh pssh, string server) { @@ -22,14 +25,13 @@ class Program var sessionId = cdm.Open(); - var wrmHeaders = pssh.GetWrmHeaders(); - if (wrmHeaders.Length == 0) + if (pssh.WrmHeaders.Length == 0) { logger.LogError("PSSH does not contain any WRM headers"); return null; } - var challenge = cdm.GetLicenseChallenge(sessionId, wrmHeaders.First()); + var challenge = cdm.GetLicenseChallenge(sessionId, pssh.WrmHeaders.First()); logger.LogInformation("Created license challenge"); using HttpClient client = new HttpClient(); @@ -62,7 +64,7 @@ class Program options.TimestampFormat = "HH:mm:ss "; })); ILogger logger = factory.CreateLogger("csplayready"); - + // license var license = new Command("license", "Make a License Request to a server using a given PSSH"); @@ -75,39 +77,34 @@ class Program license.AddArgument(psshArg); license.AddArgument(serverArg); - license.SetHandler(context => + license.SetHandler((deviceName, pssh, server) => { - var device = Device.Load(context.ParseResult.GetValueForArgument(deviceNameArg).FullName); - var pssh = new Pssh(context.ParseResult.GetValueForArgument(psshArg)); - var server = context.ParseResult.GetValueForArgument(serverArg); + var device = Device.Load(deviceName.FullName); - var keys = License(logger, device, pssh, server); - if (keys == null) - return; + var keys = License(logger, device, new Pssh(pssh), server); + if (keys == null) return; foreach (var key in keys) { logger.LogInformation("{keyId}:{key}", key.KeyId.ToHex(), key.RawKey.ToHex()); } - }); + }, deviceNameArg, psshArg, serverArg); // test var test = new Command("test", "Test the CDM code by getting Content Keys for the Tears Of Steel demo on the Playready Test Server"); var deviceNameArg2 = new Argument(name: "prdFile", description: "Device path") { Arity = ArgumentArity.ExactlyOne }; - var encryptionType = new Option(["-c", "--ckt"], description: "Content key encryption type", getDefaultValue: () => "aesctr"); - encryptionType.FromAmong("aesctr", "aescbc"); - var securityLevel = new Option(["-sl", "--security_level"], description: "Minimum security level", getDefaultValue: () => "2000" ); - securityLevel.FromAmong("150", "2000", "3000"); + var encryptionTypeOption = new Option(["-c", "--ckt"], description: "Content key encryption type", getDefaultValue: () => "aesctr").FromAmong("aesctr", "aescbc"); + var securityLevelOption = new Option(["-sl", "--security_level"], description: "Minimum security level", getDefaultValue: () => "2000" ).FromAmong("150", "2000", "3000"); test.AddArgument(deviceNameArg2); - test.AddOption(encryptionType); - test.AddOption(securityLevel); + test.AddOption(encryptionTypeOption); + test.AddOption(securityLevelOption); - test.SetHandler(context => + test.SetHandler((deviceName, encryptionType, securityLevel) => { - var device = Device.Load(context.ParseResult.GetValueForArgument(deviceNameArg2).FullName); + var device = Device.Load(deviceName.FullName); var pssh = new Pssh( "AAADfHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1xcAwAAAQABAFIDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG" + "4AcwA9ACIAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAw" + @@ -122,29 +119,26 @@ class Program "AC4AMQAuADIAMwAwADQALgAzADEAPAAvAEkASQBTAF8ARABSAE0AXwBWAEUAUgBTAEkATwBOAD4APAAvAEMAVQBTAFQATwBNAEEAVA" + "BUAFIASQBCAFUAVABFAFMAPgA8AC8ARABBAFQAQQA+ADwALwBXAFIATQBIAEUAQQBEAEUAUgA+AA=="); - var encryptionTypeArg = context.ParseResult.GetValueForOption(encryptionType); - var securityLevelArg = context.ParseResult.GetValueForOption(securityLevel); - var server = $"https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:{securityLevelArg},ckt:{encryptionTypeArg})"; + var server = $"https://test.playready.microsoft.com/service/rightsmanager.asmx?cfg=(persist:false,sl:{securityLevel},ckt:{encryptionType})"; var keys = License(logger, device, pssh, server); - if (keys == null) - return; + if (keys == null) return; foreach (var key in keys) { logger.LogInformation("{keyId}:{key}", key.KeyId.ToHex(), key.RawKey.ToHex()); } - }); + }, deviceNameArg2, encryptionTypeOption, securityLevelOption); // create-device var createDevice = new Command("create-device", "Create a Playready Device (.prd) file from an ECC private group key (optionally encryption/signing key) and group certificate chain"); var groupKeyOption = new Option(["-k", "--group_key"], "Device ECC private group key") { IsRequired = true }; - var encryptionKeyOption = new Option(["-e", "--encryption_key"], "Optional Device ECC private encryption key"); - var signingKeyOption = new Option(["-s", "--signing_key"], "Optional Device ECC private signing key"); + var encryptionKeyOption = new Option(["-e", "--encryption_key"], "Optional Device ECC private encryption key"); + var signingKeyOption = new Option(["-s", "--signing_key"], "Optional Device ECC private signing key"); var groupCertOption = new Option(["-c", "--group_certificate"], "Device group certificate chain") { IsRequired = true }; - var outputOption = new Option(["-o", "--output"], "Output file name"); + var outputOption = new Option(["-o", "--output"], "Output file name"); createDevice.AddOption(groupKeyOption); createDevice.AddOption(encryptionKeyOption); @@ -152,17 +146,13 @@ class Program createDevice.AddOption(groupCertOption); createDevice.AddOption(outputOption); - createDevice.SetHandler(context => + createDevice.SetHandler((groupKeyName, encryptionKeyName, signingKeyName, groupCertName, output) => { - var groupKey = EccKey.Load(context.ParseResult.GetValueForOption(groupKeyOption)!.FullName); + var groupKey = EccKey.Load(groupKeyName.FullName); + var certificateChain = CertificateChain.Load(groupCertName.FullName); - var encryptionKeyArg = context.ParseResult.GetValueForOption(encryptionKeyOption); - var encryptionKey = encryptionKeyArg == null ? EccKey.Generate() : EccKey.Load(encryptionKeyArg.FullName); - - var signingKeyArg = context.ParseResult.GetValueForOption(signingKeyOption); - var signingKey = signingKeyArg == null ? EccKey.Generate() : EccKey.Load(signingKeyArg.FullName); - - var certificateChain = CertificateChain.Load(context.ParseResult.GetValueForOption(groupCertOption)!.FullName); + var encryptionKey = encryptionKeyName == null ? EccKey.Generate() : EccKey.Load(encryptionKeyName.FullName); + var signingKey = signingKeyName == null ? EccKey.Generate() : EccKey.Load(signingKeyName.FullName); if (!certificateChain.Get(0).GetIssuerKey()!.SequenceEqual(groupKey.PublicBytes())) { @@ -170,9 +160,8 @@ class Program return; } - var random = new SecureRandom(); - var certId = random.GenerateSeed(16); - var clientId = random.GenerateSeed(16); + var certId = Crypto.GetRandomBytes(16); + var clientId = Crypto.GetRandomBytes(16); var leafCert = Certificate.NewLeafCertificate(certId, (uint)certificateChain.GetSecurityLevel()!, clientId, signingKey, encryptionKey, groupKey, certificateChain); certificateChain.Prepend(leafCert); @@ -181,37 +170,32 @@ class Program var device = new Device(groupKey, encryptionKey, signingKey, certificateChain); - var outputArg = context.ParseResult.GetValueForOption(outputOption); - var saveName = outputArg == null ? $"{device.GetName()}.prd" : outputArg.FullName; + var saveName = output == null ? $"{device.GetName()}.prd" : output.FullName; logger.LogInformation("Saving to: {name}", saveName); device.Dump(saveName); - }); + }, groupKeyOption, encryptionKeyOption, signingKeyOption, groupCertOption, outputOption); // reprovision-device var reprovisionDevice = new Command("reprovision-device", "Reprovision a Playready Device (.prd) by creating a new leaf certificate and new encryption/signing keys"); var deviceNameArg3 = new Argument(name: "prdFile", description: "Device to reprovision") { Arity = ArgumentArity.ExactlyOne }; - var encryptionKeyOption2 = new Option(["-e", "--encryption_key"], "Optional Device ECC private encryption key"); - var signingKeyOption2 = new Option(["-s", "--signing_key"], "Optional Device ECC private signing key"); - var outputOption2 = new Option(["-o", "--output"], "Output file name"); + var encryptionKeyOption2 = new Option(["-e", "--encryption_key"], "Optional Device ECC private encryption key"); + var signingKeyOption2 = new Option(["-s", "--signing_key"], "Optional Device ECC private signing key"); + var outputOption2 = new Option(["-o", "--output"], "Output file name"); reprovisionDevice.AddArgument(deviceNameArg3); reprovisionDevice.AddOption(encryptionKeyOption2); reprovisionDevice.AddOption(signingKeyOption2); reprovisionDevice.AddOption(outputOption2); - reprovisionDevice.SetHandler(context => + reprovisionDevice.SetHandler((deviceName, encryptionKeyName, signingKeyName, output) => { - var deviceName = context.ParseResult.GetValueForArgument(deviceNameArg3); var device = Device.Load(deviceName.FullName); - var encryptionKeyArg = context.ParseResult.GetValueForOption(encryptionKeyOption2); - var encryptionKey = encryptionKeyArg == null ? EccKey.Generate() : EccKey.Load(encryptionKeyArg.FullName); - - var signingKeyArg = context.ParseResult.GetValueForOption(signingKeyOption2); - var signingKey = signingKeyArg == null ? EccKey.Generate() : EccKey.Load(signingKeyArg.FullName); + var encryptionKey = encryptionKeyName == null ? EccKey.Generate() : EccKey.Load(encryptionKeyName.FullName); + var signingKey = signingKeyName == null ? EccKey.Generate() : EccKey.Load(signingKeyName.FullName); if (device.GroupKey == null) { @@ -222,9 +206,8 @@ class Program device.EncryptionKey = encryptionKey; device.SigningKey = signingKey; - var random = new SecureRandom(); - var certId = random.GenerateSeed(16); - var clientId = random.GenerateSeed(16); + var certId = Crypto.GetRandomBytes(16); + var clientId = Crypto.GetRandomBytes(16); device.GroupCertificate!.Remove(0); @@ -233,31 +216,27 @@ class Program logger.LogInformation("Certificate validity: {validity}", device.GroupCertificate.Verify()); - var outputArg = context.ParseResult.GetValueForOption(outputOption2); - var saveName = outputArg ?? $"{device.GetName()}.prd"; + var saveName = output ?? $"{device.GetName()}.prd"; logger.LogInformation("Saving to: {name}", saveName); device.Dump(saveName); - }); + }, deviceNameArg3, encryptionKeyOption2, signingKeyOption2, outputOption2); // export-device var exportDevice = new Command("export-device", "Export a Playready Device (.prd) file to a group key and group certificate"); var deviceNameArg4 = new Argument(name: "prdFile", description: "Device to dump") { Arity = ArgumentArity.ExactlyOne }; - var outputDirOption2 = new Option(["-o", "--output"], "Output directory"); + var outputDirOption2 = new Option(["-o", "--output"], "Output directory"); exportDevice.AddArgument(deviceNameArg4); exportDevice.AddOption(outputDirOption2); - exportDevice.SetHandler(context => + exportDevice.SetHandler((deviceName, output) => { - var deviceName = context.ParseResult.GetValueForArgument(deviceNameArg4); var device = Device.Load(deviceName.FullName); - var outputDirArg = context.ParseResult.GetValueForOption(outputDirOption2); - - var outDir = outputDirArg ?? Path.GetFileNameWithoutExtension(deviceName.Name); + var outDir = output ?? Path.GetFileNameWithoutExtension(deviceName.Name); if (Directory.Exists(outDir)) { if (Directory.EnumerateFileSystemEntries(outDir).Any()) @@ -282,7 +261,32 @@ class Program device.GroupCertificate.Remove(0); device.GroupCertificate.Dump(Path.Combine(outDir, "bgroupcert.dat")); logger.LogInformation("Exported group certificate to bgroupcert.dat"); - }); + }, deviceNameArg4, outputDirOption2); + + // serve + + var serve = new Command("serve", "Serve your local CDM and Playready Devices remotely"); + + var configPathArg = new Argument(name: "configPath", description: "Serve config file path") { Arity = ArgumentArity.ExactlyOne }; + var hostOption = new Option(["-h", "--host"], description: "Host to serve from", getDefaultValue: () => "127.0.0.1"); + var portOption = new Option(["-p", "--port"], description: "Port to serve from", getDefaultValue: () => "6798"); + + serve.AddArgument(configPathArg); + serve.AddOption(hostOption); + serve.AddOption(portOption); + + serve.SetHandler((configPath, host, port) => + { + IDeserializer deserializer = new StaticDeserializerBuilder(new YamlContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + var yamlContent = File.ReadAllText(configPath.FullName); + var configData = deserializer.Deserialize(yamlContent); + + var server = new Serve(host, Convert.ToInt32(port), configData); + server.Run(); + }, configPathArg, hostOption, portOption); var rootCommand = new RootCommand($"csplayready (https://github.com/ready-dl/csplayready) version {Version} Copyright (c) 2025-{DateTime.Now.Year} DevLARLEY"); @@ -291,7 +295,12 @@ class Program rootCommand.AddCommand(license); rootCommand.AddCommand(reprovisionDevice); rootCommand.AddCommand(test); + rootCommand.AddCommand(serve); rootCommand.InvokeAsync(args).Wait(); + + // TODO: + // + add Enums + // + cli function for testing remote cdm? } } diff --git a/csplayready/Utils.cs b/csplayready/Utils.cs index 9b54be9..14e588d 100644 --- a/csplayready/Utils.cs +++ b/csplayready/Utils.cs @@ -1,4 +1,8 @@ -using Org.BouncyCastle.Asn1.X9; +using System.Collections; +using System.ComponentModel; +using System.Diagnostics; +using System.Dynamic; +using Org.BouncyCastle.Asn1.X9; using Org.BouncyCastle.Math; using Org.BouncyCastle.Math.EC; @@ -8,15 +12,20 @@ public static class Utils { private static readonly ECCurve Curve = ECNamedCurveTable.GetByName("secp256r1").Curve; - public static byte[] ToRawByteArray(this BigInteger value) { + public static byte[] ToFixedByteArray(this BigInteger value) { var bytes = value.ToByteArray(); - return bytes[0] == 0 ? bytes[1..] : bytes; + return bytes.Length switch + { + 31 => new[] { (byte)0 }.Concat(bytes).ToArray(), + 33 => bytes[1..], + _ => bytes + }; } public static byte[] ToBytes(this ECPoint point) { - return point.XCoord.ToBigInteger().ToRawByteArray() - .Concat(point.YCoord.ToBigInteger().ToRawByteArray()) + return point.XCoord.ToBigInteger().ToFixedByteArray() + .Concat(point.YCoord.ToBigInteger().ToFixedByteArray()) .ToArray(); } @@ -36,4 +45,6 @@ public static class Utils } public static string ToHex(this byte[] bytes) => string.Concat(bytes.Select(b => b.ToString("x2"))); + + public static byte[] FromHex(string hex) => Enumerable.Range(0, hex.Length).Where(x => x % 2 == 0).Select(x => Convert.ToByte(hex.Substring(x, 2), 16)).ToArray(); } \ No newline at end of file diff --git a/csplayready/crypto/Crypto.cs b/csplayready/crypto/Crypto.cs index d4c3982..f0efb66 100644 --- a/csplayready/crypto/Crypto.cs +++ b/csplayready/crypto/Crypto.cs @@ -45,8 +45,8 @@ public static class Crypto signer.Init(true, privateKeyParams); var signature = signer.GenerateSignature(hash); - return signature[0].ToRawByteArray() - .Concat(signature[1].ToRawByteArray()) + return signature[0].ToFixedByteArray() + .Concat(signature[1].ToFixedByteArray()) .ToArray(); } @@ -63,4 +63,12 @@ public static class Crypto return signer.VerifySignature(hash, r, s); } + + public static byte[] GetRandomBytes(int amount) + { + var secureRandom = new SecureRandom(); + var randomBytes = new byte[amount]; + secureRandom.NextBytes(randomBytes); + return randomBytes; + } } \ No newline at end of file diff --git a/csplayready/crypto/EccKey.cs b/csplayready/crypto/EccKey.cs index 10c3019..b885c6a 100644 --- a/csplayready/crypto/EccKey.cs +++ b/csplayready/crypto/EccKey.cs @@ -79,14 +79,14 @@ public class EccKey public void Dump(string path, bool privateOnly = false) => File.WriteAllBytes(path, Dumps(privateOnly)); - public byte[] PrivateBytes() => PrivateKey.ToRawByteArray(); + public byte[] PrivateBytes() => PrivateKey.ToFixedByteArray(); public byte[] PrivateSha256Digest() => SHA256.HashData(PrivateBytes()); public byte[] PublicBytes() { - return PublicKey.XCoord.ToBigInteger().ToRawByteArray() - .Concat(PublicKey.YCoord.ToBigInteger().ToRawByteArray()) + return PublicKey.XCoord.ToBigInteger().ToFixedByteArray() + .Concat(PublicKey.YCoord.ToBigInteger().ToFixedByteArray()) .ToArray(); } diff --git a/csplayready/csplayready.csproj b/csplayready/csplayready.csproj index 77301d5..707a91d 100644 --- a/csplayready/csplayready.csproj +++ b/csplayready/csplayready.csproj @@ -7,13 +7,14 @@ enable true true - 0.5.2 + 0.5.5 csplayready DevLARLEY C# implementation of Microsoft's Playready DRM CDM (Content Decryption Module) https://github.com/ready-dl/csplayready LICENSE.txt README.md + false @@ -24,6 +25,9 @@ + + + diff --git a/csplayready/license/XmlKey.cs b/csplayready/license/XmlKey.cs index a9628a7..c2ede8e 100644 --- a/csplayready/license/XmlKey.cs +++ b/csplayready/license/XmlKey.cs @@ -21,7 +21,7 @@ public class XmlKey _sharedX = sharedPoint.PublicKey.XCoord.ToBigInteger(); _sharedY = sharedPoint.PublicKey.YCoord.ToBigInteger(); - var sharedXBytes = _sharedX.ToRawByteArray(); + var sharedXBytes = _sharedX.ToFixedByteArray(); AesIv = sharedXBytes[..16]; AesKey = sharedXBytes[16..]; } diff --git a/csplayready/remote/Context.cs b/csplayready/remote/Context.cs new file mode 100644 index 0000000..8039540 --- /dev/null +++ b/csplayready/remote/Context.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Serialization; +using YamlDotNet.Serialization; + +namespace csplayready.remote; + +public class YamlConfig +{ + public class ConfigData + { + public List? devices { get; set; } + public Dictionary? users { get; set; } + } + + public class UserInfo + { + public string? username { get; set; } + public List? devices { get; set; } + } +} + +[YamlStaticContext] +[YamlSerializable(typeof(YamlConfig.ConfigData))] +[YamlSerializable(typeof(YamlConfig.UserInfo))] +public partial class YamlContext; + +public class Message +{ + public string? message { get; set; } + public DataMessage? data { get; set; } +} + +public class DataMessage +{ + public List? keys { get; set; } + public string? challenge { get; set; } + public string? session_id { get; set; } + public DeviceMessage? device { get; set; } +} + +public class KeyMessage +{ + public string? key_id { get; set; } + public string? key { get; set; } + public int? type { get; set; } + public int? cipher_type { get; set; } + public int? key_length { get; set; } +} + +public class DeviceMessage +{ + public uint? security_level { get; set; } +} + +public class RequestBody +{ + public string? session_id { get; set; } + public string? init_data { get; set; } + public string? license_message { get; set; } +} + +[JsonSerializable(typeof(Message))] +[JsonSerializable(typeof(RequestBody))] +public partial class JsonContext : JsonSerializerContext; diff --git a/csplayready/remote/RemoteCdm.cs b/csplayready/remote/RemoteCdm.cs new file mode 100644 index 0000000..1920a5d --- /dev/null +++ b/csplayready/remote/RemoteCdm.cs @@ -0,0 +1,123 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using csplayready.device; +using csplayready.license; + +namespace csplayready.remote; + +public class RemoteCdm +{ + private readonly int _securityLevel; + private readonly string _host; + private readonly string _deviceName; + + private readonly HttpClient _client; + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private static readonly JsonContext Context = new(Options); + + public RemoteCdm(int securityLevel, string host, string secret, string deviceName) + { + _securityLevel = securityLevel; + _host = host.EndsWith('/') ? host[..^1] : host; + _deviceName = deviceName; + + _client = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = true + }); + + _client.DefaultRequestHeaders.Add("X-Secret-Key", secret); + } + + public static RemoteCdm FromDevice(Device device) + { + throw new NotImplementedException("You cannot load a RemoteCdm from a local Device file."); + } + + public byte[] Open() + { + var response = _client.GetAsync($"{_host}/{_deviceName}/open").Result; + Message jsonBody = JsonSerializer.Deserialize(response.Content.ReadAsStringAsync().Result, Context.Message)!; + + if (response.StatusCode != HttpStatusCode.OK) + throw new Exception($"Cannot Open CDM Session, {jsonBody.message} [{response.StatusCode}]"); + + if (jsonBody.data!.device!.security_level != _securityLevel) + throw new DeviceMismatch("The Security Level specified does not match the one specified in the API response."); + + return Utils.FromHex(jsonBody.data!.session_id!); + } + + public void Close(byte[] sessionId) + { + var response = _client.GetAsync($"{_host}/{_deviceName}/close/{sessionId.ToHex()}").Result; + Message jsonBody = JsonSerializer.Deserialize(response.Content.ReadAsStringAsync().Result, Context.Message)!; + + if (response.StatusCode != HttpStatusCode.OK) + throw new Exception($"Cannot Close CDM Session, {jsonBody.message} [{response.StatusCode}]"); + } + + public string GetLicenseChallenge(byte[] sessionId, string wrmHeader) + { + var contentString = JsonSerializer.Serialize(new RequestBody + { + session_id = sessionId.ToHex(), + init_data = wrmHeader + }, Context.RequestBody); + + var content = new StringContent(contentString, Encoding.UTF8, "application/json"); + var response = _client.PostAsync($"{_host}/{_deviceName}/get_license_challenge", content).Result; + + Message jsonBody = JsonSerializer.Deserialize(response.Content.ReadAsStringAsync().Result, Context.Message)!; + + if (response.StatusCode != HttpStatusCode.OK) + throw new Exception($"Cannot get Challenge, {jsonBody.message} [{response.StatusCode}]"); + + return jsonBody.data!.challenge!; + } + + public void ParseLicense(byte[] sessionId, string xmrLicense) + { + var contentString = JsonSerializer.Serialize(new RequestBody + { + session_id = sessionId.ToHex(), + license_message = xmrLicense + }, Context.RequestBody); + + var content = new StringContent(contentString, Encoding.UTF8, "application/json"); + var response = _client.PostAsync($"{_host}/{_deviceName}/parse_license", content).Result; + + Message jsonBody = JsonSerializer.Deserialize(response.Content.ReadAsStringAsync().Result, Context.Message)!; + + if (response.StatusCode != HttpStatusCode.OK) + throw new Exception($"Cannot parse License, {jsonBody.message} [{response.StatusCode}]"); + } + + public List GetKeys(byte[] sessionId) + { + var contentString = JsonSerializer.Serialize(new RequestBody{ session_id = sessionId.ToHex() }, Context.RequestBody); + + var content = new StringContent(contentString, Encoding.UTF8, "application/json"); + var response = _client.PostAsync($"{_host}/{_deviceName}/get_keys", content).Result; + + Message jsonBody = JsonSerializer.Deserialize(response.Content.ReadAsStringAsync().Result, Context.Message)!; + + if (response.StatusCode != HttpStatusCode.OK) + throw new Exception($"Cannot get Keys, {jsonBody.message} [{response.StatusCode}]"); + + return jsonBody.data!.keys!.Select(key => new Key( + keyId: Convert.FromHexString(key.key_id!), + keyType: (Key.KeyTypes)(key.type ?? 0), + cipherType: (Key.CipherTypes)(key.cipher_type ?? 0), + rawKey: Convert.FromHexString(key.key!)) + ).ToList(); + } +} \ No newline at end of file diff --git a/csplayready/remote/Serve.cs b/csplayready/remote/Serve.cs new file mode 100644 index 0000000..b102801 --- /dev/null +++ b/csplayready/remote/Serve.cs @@ -0,0 +1,367 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using csplayready.device; +using csplayready.license; +using csplayready.system; +using Microsoft.Extensions.Logging; +using WatsonWebserver; +using WatsonWebserver.Core; +using HttpMethod = WatsonWebserver.Core.HttpMethod; + +namespace csplayready.remote; + +public class Serve +{ + private readonly string _host; + private readonly int _port; + private readonly YamlConfig.ConfigData _config; + + private WebserverSettings? _settings; + private Webserver? _server; + + private readonly ILogger _logger; + private readonly Dictionary<(string secretKey, string device), Cdm> _cdms = new(); + + private static readonly JsonSerializerOptions Options = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private static readonly JsonContext Context = new(Options); + + public Serve(string host, int port, YamlConfig.ConfigData config) + { + _host = host; + _port = port; + _config = config; + + using ILoggerFactory factory = LoggerFactory.Create(builder => + builder.SetMinimumLevel(LogLevel.Debug).AddSimpleConsole(options => + { + options.IncludeScopes = false; + options.SingleLine = true; + options.TimestampFormat = "HH:mm:ss "; + })); + _logger = factory.CreateLogger(); + } + + public void Run() + { + _settings = new WebserverSettings(_host, _port) + { + Debug = new WebserverSettings.DebugSettings + { + Requests = true + } + }; + + _server = new Webserver(_settings, DefaultRoute); + _server.Events.Logger += message => _logger.LogDebug(message); + + _server.Routes.AuthenticateRequest = AuthenticateRequest; + _server.Events.ExceptionEncountered += ExceptionEncountered; + + _server.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/{device}/open", OpenRoute); + _server.Routes.PostAuthentication.Parameter.Add(HttpMethod.GET, "/{device}/close/{session_id}", CloseRoute); + _server.Routes.PostAuthentication.Parameter.Add(HttpMethod.POST, "/{device}/get_license_challenge", GetLicenseChallengeRoute); + _server.Routes.PostAuthentication.Parameter.Add(HttpMethod.POST, "/{device}/parse_license", ParseLicenseRoute); + _server.Routes.PostAuthentication.Parameter.Add(HttpMethod.POST, "/{device}/get_keys", GetKeysRoute); + + _logger.LogInformation("Starting server on: {prefix}", _settings.Prefix); + _server.Start(); + + Console.CancelKeyPress += (_, @event) => + { + @event.Cancel = true; + _server.Dispose(); + Environment.Exit(0); + }; + + _logger.LogInformation("Running... Press CTRL+C to exit."); + Thread.Sleep(Timeout.Infinite); + } + + private async Task OpenRoute(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]!; + var deviceName = ctx.Request.Url.Parameters["device"]!; + var user = _config.users![secretKey]; + + var devices = _config.devices!.Where(path => Path.GetFileNameWithoutExtension(path) == deviceName).ToList(); + + if (devices.Count == 0) + { + await SendJsonResponse(ctx, 403, new Message{ message = $"Device '{deviceName}' is not found or you are not authorized to use it." }); + return; + } + + if (!_cdms.TryGetValue((secretKey, deviceName), out var cdm)) + { + var device = Device.Load(devices.First()); + cdm = _cdms[(secretKey, deviceName)] = Cdm.FromDevice(device); + } + + byte[] sessionId; + try + { + sessionId = cdm.Open(); + } + catch (TooManySessions e) + { + await SendJsonResponse(ctx, 400, new Message{ message = e.Message }); + return; + } + + await SendJsonResponse(ctx, 200, new Message + { + message = "Success", + data = new DataMessage + { + session_id = sessionId.ToHex(), + device = new DeviceMessage + { + security_level = cdm.GetSecurityLevel() + } + } + }); + } + + private async Task CloseRoute(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]!; + var deviceName = ctx.Request.Url.Parameters["device"]!; + + var sessionId = Utils.FromHex(ctx.Request.Url.Parameters["session_id"]!); + + if (!_cdms.TryGetValue((secretKey, deviceName), out var cdm)) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"No Cdm session for {deviceName} has been opened yet. No session to close." }); + return; + } + + try + { + cdm.Close(sessionId); + } + catch (InvalidSession) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"Invalid Session ID '{sessionId.ToHex()}', it may have expired." }); + return; + } + + await SendJsonResponse(ctx, 200, new Message{ message = $"Successfully closed Session '{sessionId.ToHex()}'." }); + } + + private async Task GetLicenseChallengeRoute(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]!; + var deviceName = ctx.Request.Url.Parameters["device"]!; + + RequestBody jsonBody = JsonSerializer.Deserialize(ctx.Request.DataAsString, Context.RequestBody)!; + + if (jsonBody.session_id is null) + { + await SendJsonResponse(ctx, 400, new Message{ message = "Missing required field 'session_id' in JSON body." }); + return; + } + + var sessionId = Utils.FromHex(jsonBody.session_id); + + if (string.IsNullOrEmpty(jsonBody.init_data)) + { + await SendJsonResponse(ctx, 400, new Message{ message = "Missing required field 'init_data' in JSON body." }); + return; + } + + if (!_cdms.TryGetValue((secretKey, deviceName), out var cdm)) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"No Cdm session for {deviceName} has been opened yet. No session to use." }); + return; + } + + var initData = jsonBody.init_data; + if (!initData.StartsWith(" 0) + initData = pssh.WrmHeaders.First(); + } + catch (InvalidPssh e) + { + await SendJsonResponse(ctx, 500, new Message{ message = $"Unable to parse base64 PSSH, {e}" }); + return; + } + } + + string licenseRequest; + try + { + licenseRequest = cdm.GetLicenseChallenge(sessionId, initData); + } + catch (InvalidSession) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"Invalid Session ID '{sessionId.ToHex()}', it may have expired." }); + return; + } + catch (Exception e) + { + await SendJsonResponse(ctx, 500, new Message{ message = $"Error, {e.Message}" }); + return; + } + + await SendJsonResponse(ctx, 200, new Message + { + message = "Success", + data = new DataMessage + { + challenge = licenseRequest + } + }); + } + + private async Task ParseLicenseRoute(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]!; + var deviceName = ctx.Request.Url.Parameters["device"]!; + + RequestBody jsonBody = JsonSerializer.Deserialize(ctx.Request.DataAsString, Context.RequestBody)!; + + if (jsonBody.session_id is null) + { + await SendJsonResponse(ctx, 400, new Message{ message = "Missing required field 'session_id' in JSON body." }); + return; + } + + var sessionId = Utils.FromHex(jsonBody.session_id); + + if (string.IsNullOrEmpty(jsonBody.license_message)) + { + await SendJsonResponse(ctx, 400, new Message{ message = "Missing required field 'license_message' in JSON body." }); + return; + } + + if (!_cdms.TryGetValue((secretKey, deviceName), out var cdm)) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"No Cdm session for {deviceName} has been opened yet. No session to use." }); + return; + } + + try + { + cdm.ParseLicense(sessionId, jsonBody.license_message); + } + catch (InvalidSession) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"Invalid Session ID '{sessionId.ToHex()}', it may have expired." }); + return; + } + catch (InvalidLicense e) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"Invalid License, {e}" }); + return; + } + catch (Exception e) + { + await SendJsonResponse(ctx, 500, new Message{ message = $"Error, {e.Message}" }); + return; + } + + await SendJsonResponse(ctx, 200, new Message{ message = "Successfully parsed and loaded the Keys from the License message." }); + } + + private async Task GetKeysRoute(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]!; + var deviceName = ctx.Request.Url.Parameters["device"]!; + + RequestBody jsonBody = JsonSerializer.Deserialize(ctx.Request.DataAsString, Context.RequestBody)!; + + if (jsonBody.session_id is null) + { + await SendJsonResponse(ctx, 400, new Message{ message = "Missing required field 'session_id' in JSON body." }); + return; + } + + var sessionId = Utils.FromHex(jsonBody.session_id); + + if (!_cdms.TryGetValue((secretKey, deviceName), out var cdm)) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"No Cdm session for {deviceName} has been opened yet. No session to use." }); + return; + } + + List keys; + try + { + keys = cdm.GetKeys(sessionId); + } + catch (InvalidSession) + { + await SendJsonResponse(ctx, 400, new Message{ message = $"Invalid Session ID '{sessionId.ToHex()}', it may have expired." }); + return; + } + catch (Exception e) + { + await SendJsonResponse(ctx, 500, new Message{ message = $"Error, {e.Message}" }); + return; + } + + await SendJsonResponse(ctx, 200, new Message + { + message = "Success", + data = new DataMessage + { + keys = keys.Select(key => new KeyMessage + { + key_id = key.KeyId.ToHex(), + key = key.RawKey.ToHex(), + type = (ushort)key.KeyType, + cipher_type = (ushort)key.CipherType, + key_length = key.RawKey.Length + }).ToList() + } + }); + } + + private static async Task DefaultRoute(HttpContextBase ctx) + { + await SendJsonResponse(ctx, 200, new Message{ message = "OK" }); + } + + private async Task AuthenticateRequest(HttpContextBase ctx) + { + var secretKey = ctx.Request.Headers["X-Secret-Key"]; + var path = ctx.Request.Url.RawWithoutQuery; + var requestIp = ctx.Request.Source.IpAddress; + + if (path != "/") + { + if (string.IsNullOrEmpty(secretKey)) + { + _logger.LogInformation("{requestIp} did not provide authorization.", requestIp); + await SendJsonResponse(ctx, 401, new Message{ message = "Secret Key is Empty." }); + } else if (!_config.users!.ContainsKey(secretKey)) + { + _logger.LogInformation("{requestIp} failed authentication with '{secretKey}'.", requestIp, secretKey); + await SendJsonResponse(ctx, 401, new Message{ message = "Secret Key is Invalid, the Key is case-sensitive." }); + } + } + + ctx.Response.Headers.Add("Server", $"https://github.com/ready-dl/csplayready serve v{Program.Version}"); + } + + private static async Task SendJsonResponse(HttpContextBase ctx, int statusCode, object data) + { + ctx.Response.StatusCode = statusCode; + ctx.Response.ContentType = "application/json"; + await ctx.Response.Send(JsonSerializer.Serialize(data, Context.Message)); + } + + private void ExceptionEncountered(object? sender, ExceptionEventArgs args) + { + _logger.LogError(args.Exception.ToString()); + } +} \ No newline at end of file diff --git a/csplayready/serve.example.yml b/csplayready/serve.example.yml new file mode 100644 index 0000000..4a2d577 --- /dev/null +++ b/csplayready/serve.example.yml @@ -0,0 +1,15 @@ +# This data serves as an example configuration file for the `serve` command. +# None of the sensitive data should be re-used. + +# List of Playready Device (.prd) file paths to use with serve. +# Note: Each individual user needs explicit permission to use a device listed. +devices: + test_device_001: 'C:\Users\ready-dl\Documents\PRDs\device.prd' + +# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API. +users: + fx206W0FaB2O34HzGsgb8rcDe9e3ijsf: # secret key, a-zA-Z-09{32} is recommended, case-sensitive + username: chloe # only for internal logging, user will not see this name + devices: # list of allowed devices + - test_device_001 + # ... \ No newline at end of file diff --git a/csplayready/system/PSSH.cs b/csplayready/system/PSSH.cs index 38f3ab1..c20c6d9 100644 --- a/csplayready/system/PSSH.cs +++ b/csplayready/system/PSSH.cs @@ -1,11 +1,12 @@ using BinaryStruct; using static BinaryStruct.ParserBuilder; +using Encoding = System.Text.Encoding; namespace csplayready.system; public class PsshStructs { - private static readonly Struct PlayreadyObject = new( + protected static readonly Struct PlayreadyObject = new( Int16ul("type"), Int16ul("length"), Switch("data", ctx => ctx["type"], i => i switch @@ -17,10 +18,10 @@ public class PsshStructs }) ); - private static readonly Struct PlayreadyHeader = new( + protected static readonly Struct PlayreadyHeader = new( Int32ul("length"), Int16ul("record_count"), - Array("records", Child("playreadyObject", PlayreadyObject), ctx => ctx["record_count"]) + Array("records", Child(string.Empty, PlayreadyObject), ctx => ctx["record_count"]) ); protected static readonly Struct PsshBox = new( @@ -29,28 +30,65 @@ public class PsshStructs Int32ub("fullbox"), Bytes("system_id", 16), Int32ub("data_length"), - Child("playreadyHeader", PlayreadyHeader) + Bytes("data", ctx => ctx["data_length"]) ); } public class Pssh : PsshStructs { - private readonly Dictionary _data; + public readonly string[] WrmHeaders; public Pssh(byte[] data) { - _data = PsshBox.Parse(data); - } - - public Pssh(string b64Data) - { - var data = Convert.FromBase64String(b64Data); - _data = PsshBox.Parse(data); + if (data[4..8].SequenceEqual("pssh"u8.ToArray())) + { + var psshBox = PsshBox.Parse(data); + var psshData = (byte[])psshBox["data"]; + + if (IsUtf16Le(psshData)) + { + WrmHeaders = [Encoding.Unicode.GetString(data)]; + } + else + { + var playreadyHeader = PlayreadyHeader.Parse(psshData); + WrmHeaders = ReadPlayreadyObjects(playreadyHeader); + } + } + else + { + if (BitConverter.ToInt16(data.AsSpan()[..2]) > 3) + { + var playreadyHeader = PlayreadyHeader.Parse(data); + WrmHeaders = ReadPlayreadyObjects(playreadyHeader); + } + else + { + var playreadyObject = PlayreadyObject.Parse(data); + WrmHeaders = [Encoding.Unicode.GetString((byte[])playreadyObject["data"])]; + } + } } - public string[] GetWrmHeaders() + public Pssh(string b64Data) : this(Convert.FromBase64String(b64Data)) { } + + private static bool IsUtf16Le(byte[] data) + { + if (data.Length % 2 != 0) + return false; + + try + { + return Encoding.Unicode.GetString(data).All(c => c >= 0x20 && c <= 0x7E); + } + catch (ArgumentException) + { + return false; + } + } + + private static string[] ReadPlayreadyObjects(Dictionary playreadyHeader) { - var playreadyHeader = (Dictionary)_data["playreadyHeader"]; var records = (List)playreadyHeader["records"]; return records.Where(dict => (ushort)((Dictionary)dict)["type"] == 1) .Select(dict => Convert.ToString(((Dictionary)dict)["data"])!) diff --git a/csplayready/system/Session.cs b/csplayready/system/Session.cs index acca5e9..8a4c1ff 100644 --- a/csplayready/system/Session.cs +++ b/csplayready/system/Session.cs @@ -3,12 +3,11 @@ using csplayready.license; namespace csplayready.system; -public class Session(int number) +public class Session { - public readonly int Number = number; - public readonly int Id = new Random().Next(1, int.MaxValue); - public readonly XmlKey XmlKey = new XmlKey(); + public readonly byte[] Id = Crypto.GetRandomBytes(16); + public readonly XmlKey XmlKey = new(); public EccKey? SigningKey = null; public EccKey? EncryptionKey = null; - public List Keys = []; + public readonly List Keys = []; }