+ Added RemoteCdm and Serving

+ Changed session ids from int to byte[]
+ Fixed BigInteger->byte[] conversion
+ Removed GetWrmHeaders()
+ Improved PSSH parsing
This commit is contained in:
titus
2025-02-06 13:55:21 +01:00
parent 0384482a0f
commit 8b26e905c6
15 changed files with 761 additions and 122 deletions

View File

@@ -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

View File

@@ -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<int, Session> _sessions = [];
private readonly Dictionary<string, Session> _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
"</SignedInfo>";
}
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<Key> GetKeys(int sessionId)
public List<Key> 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;
}

View File

@@ -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);

View File

@@ -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<Key>? 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<FileInfo>(name: "prdFile", description: "Device path") { Arity = ArgumentArity.ExactlyOne };
var encryptionType = new Option<string>(["-c", "--ckt"], description: "Content key encryption type", getDefaultValue: () => "aesctr");
encryptionType.FromAmong("aesctr", "aescbc");
var securityLevel = new Option<string>(["-sl", "--security_level"], description: "Minimum security level", getDefaultValue: () => "2000" );
securityLevel.FromAmong("150", "2000", "3000");
var encryptionTypeOption = new Option<string>(["-c", "--ckt"], description: "Content key encryption type", getDefaultValue: () => "aesctr").FromAmong("aesctr", "aescbc");
var securityLevelOption = new Option<string>(["-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<FileInfo>(["-k", "--group_key"], "Device ECC private group key") { IsRequired = true };
var encryptionKeyOption = new Option<FileInfo>(["-e", "--encryption_key"], "Optional Device ECC private encryption key");
var signingKeyOption = new Option<FileInfo>(["-s", "--signing_key"], "Optional Device ECC private signing key");
var encryptionKeyOption = new Option<FileInfo?>(["-e", "--encryption_key"], "Optional Device ECC private encryption key");
var signingKeyOption = new Option<FileInfo?>(["-s", "--signing_key"], "Optional Device ECC private signing key");
var groupCertOption = new Option<FileInfo>(["-c", "--group_certificate"], "Device group certificate chain") { IsRequired = true };
var outputOption = new Option<FileInfo>(["-o", "--output"], "Output file name");
var outputOption = new Option<FileInfo?>(["-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<FileInfo>(name: "prdFile", description: "Device to reprovision") { Arity = ArgumentArity.ExactlyOne };
var encryptionKeyOption2 = new Option<FileInfo>(["-e", "--encryption_key"], "Optional Device ECC private encryption key");
var signingKeyOption2 = new Option<FileInfo>(["-s", "--signing_key"], "Optional Device ECC private signing key");
var outputOption2 = new Option<string>(["-o", "--output"], "Output file name");
var encryptionKeyOption2 = new Option<FileInfo?>(["-e", "--encryption_key"], "Optional Device ECC private encryption key");
var signingKeyOption2 = new Option<FileInfo?>(["-s", "--signing_key"], "Optional Device ECC private signing key");
var outputOption2 = new Option<string?>(["-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<FileInfo>(name: "prdFile", description: "Device to dump") { Arity = ArgumentArity.ExactlyOne };
var outputDirOption2 = new Option<string>(["-o", "--output"], "Output directory");
var outputDirOption2 = new Option<string?>(["-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<FileInfo>(name: "configPath", description: "Serve config file path") { Arity = ArgumentArity.ExactlyOne };
var hostOption = new Option<string>(["-h", "--host"], description: "Host to serve from", getDefaultValue: () => "127.0.0.1");
var portOption = new Option<string>(["-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<YamlConfig.ConfigData>(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?
}
}

View File

@@ -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();
}

View File

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

View File

@@ -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();
}

View File

@@ -7,13 +7,14 @@
<Nullable>enable</Nullable>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<Version>0.5.2</Version>
<Version>0.5.5</Version>
<PackageId>csplayready</PackageId>
<Authors>DevLARLEY</Authors>
<Description>C# implementation of Microsoft's Playready DRM CDM (Content Decryption Module)</Description>
<RepositoryUrl>https://github.com/ready-dl/csplayready</RepositoryUrl>
<PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
<ItemGroup>
@@ -24,6 +25,9 @@
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<None Include="..\README.md" Pack="true" PackagePath="\" />
<None Include="..\LICENSE.txt" Pack="true" PackagePath=""/>
<PackageReference Include="Vecc.YamlDotNet.Analyzers.StaticGenerator" Version="16.3.0" />
<PackageReference Include="Watson" Version="6.3.5" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>

View File

@@ -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..];
}

View File

@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
using YamlDotNet.Serialization;
namespace csplayready.remote;
public class YamlConfig
{
public class ConfigData
{
public List<string>? devices { get; set; }
public Dictionary<string, UserInfo>? users { get; set; }
}
public class UserInfo
{
public string? username { get; set; }
public List<string>? 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<KeyMessage>? 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;

View File

@@ -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<Key> 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();
}
}

367
csplayready/remote/Serve.cs Normal file
View File

@@ -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<Serve>();
}
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("<WRMHEADER"))
{
try
{
var pssh = new Pssh(initData);
if (pssh.WrmHeaders.Length > 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<Key> 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());
}
}

View File

@@ -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
# ...

View File

@@ -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<string, object> _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<string, object> playreadyHeader)
{
var playreadyHeader = (Dictionary<string, object>)_data["playreadyHeader"];
var records = (List<object>)playreadyHeader["records"];
return records.Where(dict => (ushort)((Dictionary<string, object>)dict)["type"] == 1)
.Select(dict => Convert.ToString(((Dictionary<string, object>)dict)["data"])!)

View File

@@ -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<Key> Keys = [];
public readonly List<Key> Keys = [];
}