+ 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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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..];
|
||||
}
|
||||
|
||||
63
csplayready/remote/Context.cs
Normal file
63
csplayready/remote/Context.cs
Normal 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;
|
||||
123
csplayready/remote/RemoteCdm.cs
Normal file
123
csplayready/remote/RemoteCdm.cs
Normal 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
367
csplayready/remote/Serve.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
15
csplayready/serve.example.yml
Normal file
15
csplayready/serve.example.yml
Normal 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
|
||||
# ...
|
||||
@@ -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"])!)
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user