diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 56dcd4e..9523c70 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - dotnet-version: [ '9.0.x' ] + dotnet-version: [ '10.0.x' ] rid: ['win-x64', 'win-x86', 'linux-x64', 'linux-arm64', 'osx-x64', 'osx-arm64'] steps: diff --git a/LibXboxOne.Tests/LibXboxOne.Tests.csproj b/LibXboxOne.Tests/LibXboxOne.Tests.csproj index d0d252a..6e093e9 100644 --- a/LibXboxOne.Tests/LibXboxOne.Tests.csproj +++ b/LibXboxOne.Tests/LibXboxOne.Tests.csproj @@ -1,19 +1,19 @@ - net9.0 + net10.0 true false false - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/LibXboxOne/LibXboxOne.csproj b/LibXboxOne/LibXboxOne.csproj index 7a7903f..48fd92d 100644 --- a/LibXboxOne/LibXboxOne.csproj +++ b/LibXboxOne/LibXboxOne.csproj @@ -1,7 +1,8 @@ - net9.0 + net10.0 + True @@ -10,12 +11,12 @@ - + + + + - - - diff --git a/LibXboxOne/XVC2/BoxIndex.cs b/LibXboxOne/XVC2/BoxIndex.cs new file mode 100644 index 0000000..839b086 --- /dev/null +++ b/LibXboxOne/XVC2/BoxIndex.cs @@ -0,0 +1,16 @@ +using System.Formats.Cbor; +using LibXboxOne.XVC2.SerializedModel; + +namespace LibXboxOne.XVC2; + +public readonly record struct BoxIndex(int Value) : ISerialize +{ + public override string ToString() => $"box:{Value}"; + + public void Serialize(CborWriter writer) + { + writer.WriteInt32(Value); + } + + public static BoxIndex Deserialize(CborReader reader) => new(reader.ReadInt32()); +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/CborExtensions.cs b/LibXboxOne/XVC2/CborExtensions.cs new file mode 100644 index 0000000..8c472df --- /dev/null +++ b/LibXboxOne/XVC2/CborExtensions.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; +using System.IO; +using LibXboxOne.XVC2.SerializedModel; + +namespace LibXboxOne.XVC2; + +public static class CborExtensions +{ + extension(CborWriter writer) + { + public void WriteTagEx(CborTagEx tag) + => writer.WriteTag((CborTag)tag); + + public void WriteSelfDescribeTag(CborTagEx tag) + { + writer.WriteTag(CborTag.SelfDescribeCbor); + writer.WriteTagEx(tag); + } + + public void WriteHash(PackagingHash hash) + { + var tag = hash.Algorithm switch + { + PackagingHashAlgorithm.SHA256 => CborTagEx.SHA256, + PackagingHashAlgorithm.SHA384 => CborTagEx.SHA384, + PackagingHashAlgorithm.SHA512 => CborTagEx.SHA512, + _ => throw new UnreachableException() + }; + + writer.WriteTagEx(tag); + writer.WriteByteString(hash.Hash); + } + + public void WriteMap(Dictionary map) + { + writer.WriteStartMap(map.Count); + + foreach (var (key, value) in map) + { + writer.WriteInt32((int)(object)key); + + switch (value) + { + case string str: + writer.WriteTextString(str); + break; + case byte[] bytes: + writer.WriteByteString(bytes); + break; + case int i32: + writer.WriteInt32(i32); + break; + case long i64: + writer.WriteInt64(i64); + break; + case uint u32: + writer.WriteUInt32(u32); + break; + case ulong u64: + writer.WriteUInt64(u64); + break; + case bool boolean: + writer.WriteBoolean(boolean); + break; + default: + Debug.Assert(false); + break; + } + } + + writer.WriteEndMap(); + } + + public void WriteGuid(Guid value) + => writer.WriteTextString(value.ToString()); + + public void WriteEnum(T value) where T : Enum + => writer.WriteInt32((int)(object)value); + + public void WriteLabel(SerializedLabel label) + => writer.WriteInt32((int)label); + } + + extension(CborReader reader) + { + public CborTagEx ReadTagEx() + => (CborTagEx)reader.ReadTag(); + + public void ReadSelfDescribeTag(CborTagEx tag) + { + var tag0 = reader.ReadTag(); + if (tag0 != CborTag.SelfDescribeCbor) + throw new InvalidDataException(); + + var tag1 = reader.ReadTagEx(); + if (tag1 != tag) + throw new InvalidDataException(); + } + + public PackagingHash ReadHash() + { + var tagType = reader.ReadTagEx(); + var type = tagType switch + { + CborTagEx.SHA256 => PackagingHashAlgorithm.SHA256, + CborTagEx.SHA384 => PackagingHashAlgorithm.SHA384, + CborTagEx.SHA512 => PackagingHashAlgorithm.SHA512, + _ => throw new UnreachableException() + }; + + var hash = reader.ReadByteString(); + return new PackagingHash(type, hash); + } + + public Dictionary ReadMap() + { + var count = reader.ReadStartMap(); + + var dict = new Dictionary(); + while (count-- != 0) + { + var key = (TKey)(object)reader.ReadInt32(); + dict[key] = reader.PeekState() switch + { + CborReaderState.TextString => reader.ReadTextString(), + CborReaderState.ByteString => reader.ReadByteString(), + CborReaderState.UnsignedInteger => reader.ReadUInt64(), + CborReaderState.NegativeInteger => reader.ReadInt64(), + CborReaderState.Boolean => reader.ReadBoolean(), + _ => throw new UnreachableException() + }; + } + + reader.ReadEndMap(); + + return dict; + } + + public Guid ReadGuid() + => Guid.Parse(reader.ReadTextString()); + + public T ReadEnum() where T : Enum + => (T)(object)reader.ReadInt32(); + + public void AssertInvalidValue() + { + Debug.Assert(false); + reader.SkipValue(); + } + + public SerializedLabel ReadLabel() + => (SerializedLabel)reader.ReadInt32(); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/CborTagEx.cs b/LibXboxOne/XVC2/CborTagEx.cs new file mode 100644 index 0000000..6fe543f --- /dev/null +++ b/LibXboxOne/XVC2/CborTagEx.cs @@ -0,0 +1,28 @@ +namespace LibXboxOne.XVC2; + +public enum CborTagEx +{ + SHA512 = 18512, + SHA384 = 18513, + SHA256 = 18540, + Enum0 = 121, + Enum1 = 122, + Enum2 = 123, + Enum3 = 124, + Enum4 = 125, + Enum5 = 126, + Enum6 = 127, + LogicalNone = 32870, + LogicalAny = 32871, + LogicalAll = 32872, + XVC2 = 1482048306, + XVCB = 1482048322, // Box + XVCC = 1482048323, // Chunk + XVCD = 1482048324, + XVCE = 1482048325, + XVCF = 1482048326, + XVCI = 1482048329, + XVCP = 1482048336, // Package + XVCS = 1482048339, + XVCZ = 1482048346, // Seal +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Msixvc2File.Content.cs b/LibXboxOne/XVC2/Msixvc2File.Content.cs new file mode 100644 index 0000000..c1c1a52 --- /dev/null +++ b/LibXboxOne/XVC2/Msixvc2File.Content.cs @@ -0,0 +1,148 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using LibXboxOne.XVC2.SerializedModel; + +namespace LibXboxOne.XVC2; + +public partial class Msixvc2File +{ + private readonly Dictionary _cachedBoxEntryStreams = []; + + public byte[] GetFileContent(string filePath) + { + var file = _files[filePath]; + var chunk = _chunks[file.ChunkId]; + + var fileContent = new byte[file.Length]; + var currentOffset = 0; + + if (file.Segments != null) + { + foreach (var segment in file.Segments) + { + ReadSegmentContent(segment, chunk.KeyIndex, fileContent.AsSpan(currentOffset, segment.Length), PackagingKeyPurpose.Content); + currentOffset += segment.Length; + } + } + + if (!ValidateHash(fileContent, file.Hash)) + throw new InvalidDataException("Failed to validate file hash"); + + return fileContent; + } + + public byte[] GetSegmentContent(SegmentReference segment, int keyId, PackagingKeyPurpose purpose) + { + var content = new byte[segment.Length]; + ReadSegmentContent(segment, keyId, content, purpose); + return content; + } + + public void ReadSegmentContent(SegmentReference segment, int keyId, Span content, + PackagingKeyPurpose purpose = PackagingKeyPurpose.Content) + { + var boxContent = new byte[segment.BoxLength]; + + ReadBoxContent(segment.BoxIndex, segment.BoxOffset, boxContent); + + if (!ValidateHash(boxContent, segment.BoxHash)) + throw new InvalidDataException("Failed to verify box content hash"); + + if (segment.EncryptionKey != null || segment.WrappedKey != null) + { + var decrypted = new byte[segment.BoxLength]; + + DecryptContent(boxContent, segment.Hash.Hash, decrypted, keyId, segment.EncryptionKey, segment.WrappedKey, + segment.WrapIV, purpose); + + boxContent = decrypted; + } + + if (segment.Compression != PackagingCompression.None) + { + var compressed = boxContent.AsSpan(0, segment.CompressedLength); + DecompressContent(compressed, content, segment.Compression); + } + else + { + boxContent.AsSpan(0, segment.Length).CopyTo(content); + } + + if (!ValidateHash(content, segment.Hash)) + throw new InvalidDataException("Failed to verify decompressed content hash"); + } + + private static void DecompressContent(ReadOnlySpan compressed, Span decompressed, + PackagingCompression compression) + { + switch (compression) + { + case PackagingCompression.Deflate: + { + unsafe + { + fixed (byte* compressedPtr = compressed, decompressedPtr = decompressed) + { + using var input = new UnmanagedMemoryStream(compressedPtr, compressed.Length); + using var deflateStream = new DeflateStream(input, CompressionMode.Decompress); + using var output = new UnmanagedMemoryStream(decompressedPtr, decompressed.Length); + deflateStream.CopyTo(output); + } + } + break; + } + case PackagingCompression.Brotli: + if (!BrotliDecoder.TryDecompress(compressed, decompressed, out _)) + throw new InvalidDataException("Brotli decompression failed"); + + break; + default: + throw new UnreachableException(); + } + } + + private static bool ValidateHash(ReadOnlySpan content, PackagingHash hash) + { + switch (hash.Algorithm) + { + case PackagingHashAlgorithm.None: + return true; + case PackagingHashAlgorithm.SHA256: + return SHA256.HashData(content).SequenceEqual(hash.Hash); + case PackagingHashAlgorithm.SHA384: + return SHA384.HashData(content).SequenceEqual(hash.Hash); + case PackagingHashAlgorithm.SHA512: + return SHA512.HashData(content).SequenceEqual(hash.Hash); + default: + Debug.Assert(false); + return false; + } + } + + private void ReadBoxContent(BoxIndex boxIndex, int offset, Span content) + { + var stream = GetBoxEntryStream(boxIndex); + stream.Seek(offset, SeekOrigin.Begin); + stream.ReadExactly(content); + } + + private Stream GetBoxEntryStream(BoxIndex boxIndex) + { + if (_cachedBoxEntryStreams.TryGetValue(boxIndex, out var cachedStream)) + return cachedStream; + + var boxName = _package.Boxes[boxIndex.Value].Name; + var boxPath = $"Boxes/{boxName}"; + + var content = GetEntryContent(boxPath); + var ms = new MemoryStream(content); + + _cachedBoxEntryStreams[boxIndex] = ms; + return ms; + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Msixvc2File.Crypto.cs b/LibXboxOne/XVC2/Msixvc2File.Crypto.cs new file mode 100644 index 0000000..a9ce42d --- /dev/null +++ b/LibXboxOne/XVC2/Msixvc2File.Crypto.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using LibXboxOne.XVC2.SerializedModel; + +namespace LibXboxOne.XVC2; + +public partial class Msixvc2File +{ + private readonly Dictionary _storedKeyMaterial = []; + + public void SubmitKeyMaterial(Guid keyId, byte[] keyMaterial) + { + _storedKeyMaterial[keyId] = keyMaterial; + } + + public void SubmitKeys(byte[]? contentKey, byte[]? versionKey) + { + if (_package.Keys.Count == 0) + return; + + var sources = _package.Keys[0].Sources; + + if (contentKey != null) + { + var contentKeySource = sources.First(x => x.SourcePurpose == PackagingKeyPurpose.Content); + SubmitKeyMaterial(contentKeySource.SourceKeyId, contentKey); + } + + if (versionKey != null) + { + var versionKeySource = sources.First(x => x.SourcePurpose == PackagingKeyPurpose.Version); + SubmitKeyMaterial(versionKeySource.SourceKeyId, versionKey); + } + } + + private void DecryptContent(ReadOnlySpan encrypted, ReadOnlySpan iv, Span decrypted, int keyId, + byte[]? encryptionKeyMaterial, byte[]? wrappedKey, PackagingIV? wrapIV, PackagingKeyPurpose purpose) + { + var key = _package.Keys[keyId]; + var keySource = key.Sources.First(x => x.SourcePurpose == purpose); + + if (wrappedKey != null) + { + var wrapKey = DeriveWrappingKey(keySource, keySource.WrapAlgorithm); + encryptionKeyMaterial = wrapKey.UnwrapKeyMaterial(wrappedKey, wrapIV); + } + + if (encryptionKeyMaterial != null) + { + var encryptionKey = PackagingEncryptionKey.Create(keySource.Algorithm, encryptionKeyMaterial); + encryptionKey.Decrypt(encrypted, PackagingIV.FromBytes(iv[..PackagingIV.Size]), decrypted); + } + } + + private IPackagingEncryptionKey DeriveWrappingKey(PackageKeySource keySource, PackagingEncryptionAlgorithm algorithm) + { + // Is this really how it's supposed to be done? since .PackageData is only used here + + var wrapKeyMaterial = _storedKeyMaterial[keySource.SourceKeyId]; + var derivationKey = PackagingDerivationKey.Create(keySource.DerivationAlgorithm, wrapKeyMaterial); + var derivedKey = derivationKey.DeriveKey(PackagingKeyPurpose.PackageData, keySource.WrapAlgorithm, keySource.KdfContext); + + var wrappingKey = derivedKey.UnwrapKeyMaterial(keySource.WrappedKey, keySource.WrapIV); + return PackagingEncryptionKey.Create(algorithm, wrappingKey); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Msixvc2File.Streaming.cs b/LibXboxOne/XVC2/Msixvc2File.Streaming.cs new file mode 100644 index 0000000..62d3585 --- /dev/null +++ b/LibXboxOne/XVC2/Msixvc2File.Streaming.cs @@ -0,0 +1,151 @@ +using LibXboxOne.XVC2.SerializedModel; +using System; +using System.IO; +using System.Linq; +using System.Text; + +namespace LibXboxOne.XVC2; + +public partial class Msixvc2File +{ + // TODO: Make this look nice + optimized + + public string PrintInfo(bool showAllFiles) + { + var sb = new StringBuilder(); + + sb.AppendLine("MSIXVC2 Package"); + sb.AppendLine($" ContentId: {_package.ContentId}"); + sb.AppendLine($" FulfillmentContentId: {_package.FulfillmentContentId}"); + sb.AppendLine($" Version: {_package.Version}"); + sb.AppendLine($" StoreId: {_package.StoreId}"); + sb.AppendLine($" ProductId: {_package.ProductId}"); + sb.AppendLine($" MinimumSystemVersion: {_package.MinimumSystemVersion}"); + sb.AppendLine($" SupportedPlatforms: {_package.SupportedPlatforms}"); + sb.AppendLine(); + + sb.AppendLine("Segmentation:"); + sb.AppendLine($" Algorithm: {_package.Segmentation.Algorithm}"); + sb.AppendLine($" Hash algorithm: 0x{_package.Segmentation.HashAlgorithm:x}"); + sb.AppendLine("Options:"); + foreach (var (key, value) in _package.Segmentation.Options) + { + sb.AppendLine($" {key}: {value}"); + } + sb.AppendLine(); + + sb.AppendLine("Boxes:"); + foreach (var box in _package.Boxes) + { + sb.AppendLine($" Name: {box.Name}"); + } + sb.AppendLine(); + + if (_package.Keys.Count > 0) + { + sb.AppendLine("Encrypted: true"); + sb.AppendLine($"Initial IV: {_package.InitialIV}"); + + sb.AppendLine("Keys:"); + for (var i = 0; i < _package.Keys.Count; i++) + { + sb.AppendLine($" Key #{i} - Sources:"); + var key = _package.Keys[i]; + foreach (var source in key.Sources) + { + sb.AppendLine($" Key ID: {source.SourceKeyId}"); + sb.AppendLine($" Purpose: {source.SourcePurpose}"); + sb.AppendLine($" Derivation: {source.DerivationAlgorithm}"); + sb.AppendLine($" KDF Context: {Convert.ToHexString(source.KdfContext)}"); + sb.AppendLine($" Wrap: {source.WrapAlgorithm}"); + sb.AppendLine($" Wrapping IV: {source.WrapIV}"); + sb.AppendLine($" Wrapped Key: {Convert.ToHexString(source.WrappedKey)}"); + sb.AppendLine($" Encryption: {source.Algorithm}"); + sb.AppendLine(); + } + + sb.AppendLine(); + } + + if (!_loadedFileNames) + { + var remainingFilesToShow = 4096; + foreach (var chunk in _chunkDetails) + { + foreach (var file in chunk.Value.Files) + { + sb.AppendLine($" File {file.Id}:"); + sb.AppendLine($" ChunkId: 0x{chunk.Key:x}"); + sb.AppendLine($" Length: 0x{file.Length:x}"); + sb.AppendLine($" Hash: {file.Hash}"); + sb.AppendLine($" ReadProtected: {file.ReadProtected}"); + sb.AppendLine(); + + if (!showAllFiles && remainingFilesToShow-- == 0) + break; + } + + if (remainingFilesToShow == 0) + break; + } + } + else + { + sb.AppendLine(" Files in package:"); + foreach (var (name, file) in showAllFiles ? _files : _files.Take(Math.Min(4096, _files.Count))) + { + sb.AppendLine($" File {name}:"); + sb.AppendLine($" ChunkId: 0x{file.ChunkId:x}"); + sb.AppendLine($" Length: 0x{file.Length:x}"); + sb.AppendLine($" Hash: {file.Hash}"); + sb.AppendLine($" ReadProtected: {file.ReadProtected}"); + sb.AppendLine(); + } + } + + sb.AppendLine(); + } + + return sb.ToString(); + } + + public void ExtractFiles(string outputDirectory) + { + if (!_loadedFileNames) + { + throw new InvalidOperationException("Cannot extract files without filenames being loaded"); + } + + foreach (var path in _files.Keys) + { + var outputPath = Path.Join(outputDirectory, path); + var dir = Path.GetDirectoryName(outputPath); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + } + + foreach (var (name, file) in _files) + { + Console.WriteLine($"Extracting {name}"); + + var chunk = _chunks[file.ChunkId]; + + var outputPath = Path.Join(outputDirectory, name); + using var output = System.IO.File.OpenWrite(outputPath); + + if (file.Segments == null) + continue; + + var buffer = new byte[file.Segments.Select(x => x.Length).Max()].AsSpan(); + + foreach (var segment in file.Segments) + { + var currentSegmentBuffer = buffer[..segment.Length]; + ReadSegmentContent(segment, chunk.KeyIndex, currentSegmentBuffer); + output.Write(currentSegmentBuffer); + } + } + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Msixvc2File.cs b/LibXboxOne/XVC2/Msixvc2File.cs new file mode 100644 index 0000000..0814f28 --- /dev/null +++ b/LibXboxOne/XVC2/Msixvc2File.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Formats.Cbor; +using System.IO; +using System.IO.Compression; +using System.Runtime.InteropServices; +using LibXboxOne.XVC2.SerializedModel; +using File = System.IO.File; + +namespace LibXboxOne.XVC2; + +public sealed partial class Msixvc2File : IDisposable +{ + private readonly ZipArchive _archive; + private readonly Package _package; + + private readonly Dictionary _chunks = []; + private readonly Dictionary _chunkDetails = []; + private readonly Dictionary _chunkSecrets = []; + private readonly Dictionary _files = []; + private readonly Dictionary _boxes = []; + + private bool _loadedFileNames; + + public static Msixvc2File FromPath(string path) => new(File.OpenRead(path)); + + public Msixvc2File(Stream stream, bool leaveOpen = false) + { + _archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen); + _package = ReadPackageMetadata(); + + foreach (var chunk in _package.Chunks) + { + _chunks[chunk.Id] = chunk; + _chunkDetails[chunk.Id] = ReadChunkDetails(chunk); + } + + for (int i = 0; i < _package.Boxes.Count; i++) + { + var boxIndex = new BoxIndex(i); + _boxes[boxIndex] = ReadBoxManifest(boxIndex); + } + } + + public void LoadFileNames() + { + foreach (var chunk in _chunks.Values) + { + var chunkSecretContent = + GetSegmentContent(chunk.SecretReference, chunk.KeyIndex, PackagingKeyPurpose.Content); + + var reader = new CborReader(chunkSecretContent); + _chunkSecrets[chunk.Id] = ChunkDetailsSecret.Deserialize(reader); + + for (int i = 0; i < _chunkDetails[chunk.Id].Files.Count; i++) + { + var file = _chunkDetails[chunk.Id].Files[i]; + var fileName = _chunkSecrets[chunk.Id].Files[i].FileName; + _files[fileName] = file with { ChunkId = chunk.Id }; + } + } + + _loadedFileNames = true; + } + + private Box ReadBoxManifest(BoxIndex index) + { + var entry = GetBoxEntryStream(index); + + entry.Position = 0; + var header = (stackalloc byte[8]); + entry.ReadExactly(header); + + if (!header.SequenceEqual("XBOXBOX\0"u8)) + throw new InvalidDataException("Invalid box header"); + + entry.Position = entry.Length - 8 - 4; + + var manifestOffset = 0uL; + var manifestLength = 0u; + + entry.ReadExactly(MemoryMarshal.AsBytes(new Span(ref manifestOffset))); + entry.ReadExactly(MemoryMarshal.AsBytes(new Span(ref manifestLength))); + + var manifestEndOffset = long.CreateChecked(manifestLength + manifestOffset); + if (manifestEndOffset > entry.Length) + throw new InvalidDataException("Invalid box metadata offset or length"); + + entry.Position = long.CreateChecked(manifestOffset); + var manifestContent = new byte[manifestLength]; + entry.ReadExactly(manifestContent); + + var sealLength = entry.Length - (manifestEndOffset + 8 + 4); + var sealContent = new byte[sealLength]; + entry.ReadExactly(sealContent); + + var sealReader = new CborReader(sealContent); + var seal = Seal.Deserialize(sealReader); + if (seal.Target != CborTagEx.XVCB) + throw new InvalidDataException("Seal did not seal box manifest"); + + if (!ValidateHash(manifestContent, seal.Hash)) + throw new InvalidDataException("Failed to validate box manifest hash"); + + var reader = new CborReader(manifestContent); + var box = Box.Deserialize(reader); + + return box; + } + + private Package ReadPackageMetadata() + { + var metadataCbor = GetEntryContent("XboxPackage.cbor"); + var reader = new CborReader(metadataCbor); + return Package.Deserialize(reader); + } + + private ChunkDetails ReadChunkDetails(Chunk chunk) + { + var path = $"Chunks/{chunk.Id}.cbor"; + var cbor = GetEntryContent(path); + var reader = new CborReader(cbor); + return ChunkDetails.Deserialize(reader); + } + + private byte[] GetEntryContent(string entryPath) + { + var packageEntry = _archive.GetEntry(entryPath); + if (packageEntry == null) + throw new InvalidOperationException($"Failed to find entry {entryPath} in MSIXVC2"); + + using var packageDataStream = packageEntry.Open(); + + var buffer = new byte[packageEntry.Length]; + using var ms = new MemoryStream(buffer); + packageDataStream.CopyTo(ms); + + return buffer; + } + + public void Dispose() + { + _archive.Dispose(); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingCompression.cs b/LibXboxOne/XVC2/PackagingCompression.cs new file mode 100644 index 0000000..c9e71e6 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingCompression.cs @@ -0,0 +1,8 @@ +namespace LibXboxOne.XVC2; + +public enum PackagingCompression +{ + None = 0, + Deflate = 1, + Brotli = 2 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingDerivationAlgorithm.cs b/LibXboxOne/XVC2/PackagingDerivationAlgorithm.cs new file mode 100644 index 0000000..c228db8 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingDerivationAlgorithm.cs @@ -0,0 +1,7 @@ +namespace LibXboxOne.XVC2; + +public enum PackagingDerivationAlgorithm +{ + None = 0, + SP800_108_HMAC_SHA256 = 1 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingDerivationKey.cs b/LibXboxOne/XVC2/PackagingDerivationKey.cs new file mode 100644 index 0000000..81acb97 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingDerivationKey.cs @@ -0,0 +1,67 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; + +namespace LibXboxOne.XVC2; + +public interface IPackagingDerivationKey +{ + IPackagingEncryptionKey DeriveKey(PackagingKeyPurpose purpose, + PackagingEncryptionAlgorithm algorithm, ReadOnlySpan context); +} + +public static class PackagingDerivationKey +{ + public static IPackagingDerivationKey Create(PackagingDerivationAlgorithm algorithm, ReadOnlySpan key) + { + return algorithm switch + { + PackagingDerivationAlgorithm.None => new PackagingDerivationKeyNone(), + PackagingDerivationAlgorithm.SP800_108_HMAC_SHA256 => new PackagingDerivationKeySp800108(key.ToArray()), + _ => throw new UnreachableException() + }; + } +} + +public class PackagingDerivationKeyNone : IPackagingDerivationKey +{ + public IPackagingEncryptionKey DeriveKey(PackagingKeyPurpose purpose, PackagingEncryptionAlgorithm algorithm, + ReadOnlySpan context) + { + throw new NotImplementedException(); + } +} + +public class PackagingDerivationKeySp800108(byte[] key) : IPackagingDerivationKey +{ + private readonly byte[] _key = key; + + public IPackagingEncryptionKey DeriveKey(PackagingKeyPurpose purpose, + PackagingEncryptionAlgorithm algorithm, ReadOnlySpan context) + { + var label = GetLabel(purpose, algorithm); + var keySize = GetKeySize(algorithm); + + var derivedKey = SP800108HmacCounterKdf.DeriveBytes(_key, HashAlgorithmName.SHA256, label, context, keySize); + return PackagingEncryptionKey.Create(algorithm, derivedKey); + } + + private static int GetKeySize(PackagingEncryptionAlgorithm encryptionAlgorithm) + { + if (encryptionAlgorithm == PackagingEncryptionAlgorithm.AES_256_CBC) + return 32; + + throw new InvalidOperationException($"No key size defined for {encryptionAlgorithm}"); + } + + private static ReadOnlySpan GetLabel(PackagingKeyPurpose keyPurpose, PackagingEncryptionAlgorithm encryptionAlgorithm) + { + if (keyPurpose == PackagingKeyPurpose.PackageData && + encryptionAlgorithm == PackagingEncryptionAlgorithm.AES_256_CBC) + { + return "MSIXVC2:PackageData:AES_256_CBC"u8; + } + + throw new InvalidOperationException($"No label defined for {keyPurpose} in {encryptionAlgorithm}"); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingEncryptionAlgorithm.cs b/LibXboxOne/XVC2/PackagingEncryptionAlgorithm.cs new file mode 100644 index 0000000..48dbad8 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingEncryptionAlgorithm.cs @@ -0,0 +1,9 @@ +namespace LibXboxOne.XVC2; + +public enum PackagingEncryptionAlgorithm +{ + None = 0, + Automatic = 1, + AES_256_CBC = 2, + AES_256_KW = 3, +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingEncryptionKey.cs b/LibXboxOne/XVC2/PackagingEncryptionKey.cs new file mode 100644 index 0000000..eb7c2b7 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingEncryptionKey.cs @@ -0,0 +1,104 @@ +using System; +using System.Diagnostics; +using System.Security.Cryptography; + +namespace LibXboxOne.XVC2; + +public interface IPackagingEncryptionKey +{ + void Encrypt(ReadOnlySpan input, PackagingIV iv, Span output); + void Decrypt(ReadOnlySpan input, PackagingIV iv, Span output); + byte[] WrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv); + byte[] UnwrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv); +} + +public static class PackagingEncryptionKey +{ + public static IPackagingEncryptionKey Create(PackagingEncryptionAlgorithm algorithm, ReadOnlySpan key) + { + return algorithm switch + { + PackagingEncryptionAlgorithm.Automatic => new PackagingEncryptionAesCbc(key), + PackagingEncryptionAlgorithm.AES_256_CBC => new PackagingEncryptionAesCbc(key), + PackagingEncryptionAlgorithm.AES_256_KW => new PackagingEncryptionAesKw(key), + _ => throw new UnreachableException() + }; + } +} + +public class PackagingEncryptionAesCbc : IPackagingEncryptionKey +{ + private readonly Aes _aes; + + public PackagingEncryptionAesCbc(ReadOnlySpan key) + { + _aes = Aes.Create(); + _aes.Key = key.ToArray(); + } + + public void Encrypt(ReadOnlySpan input, PackagingIV iv, Span output) + { + _aes.EncryptCbc(input, iv.ToArray(), output, PaddingMode.None); + } + + public void Decrypt(ReadOnlySpan input, PackagingIV iv, Span output) + { + _aes.DecryptCbc(input, iv.ToArray(), output, PaddingMode.None); + } + + public byte[] WrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv) + { + if (iv == null) + throw new InvalidOperationException("IV required for CBC key wrapping"); + + var output = new byte[input.Length]; + Encrypt(input, iv.Value, output); + return output; + } + + public byte[] UnwrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv) + { + if (iv == null) + throw new InvalidOperationException("IV required for CBC key unwrapping"); + + var output = new byte[input.Length]; + Decrypt(input, iv.Value, output); + return output; + } +} + +public class PackagingEncryptionAesKw : IPackagingEncryptionKey +{ + private readonly Aes _aes; + + public PackagingEncryptionAesKw(ReadOnlySpan key) + { + _aes = Aes.Create(); + _aes.Key = key.ToArray(); + } + public void Encrypt(ReadOnlySpan input, PackagingIV iv, Span output) + { + throw new NotImplementedException(); + } + + public void Decrypt(ReadOnlySpan input, PackagingIV iv, Span output) + { + throw new NotImplementedException(); + } + + public byte[] WrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv) + { + if (iv != null) + throw new InvalidOperationException("No IV needed for AES-KW"); + + return _aes.EncryptKeyWrapPadded(input); + } + + public byte[] UnwrapKeyMaterial(ReadOnlySpan input, PackagingIV? iv) + { + if (iv != null) + throw new InvalidOperationException("No IV needed for AES-KW"); + + return _aes.DecryptKeyWrapPadded(input); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingHash.cs b/LibXboxOne/XVC2/PackagingHash.cs new file mode 100644 index 0000000..ae93856 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingHash.cs @@ -0,0 +1,8 @@ +using System.Buffers.Text; + +namespace LibXboxOne.XVC2; + +public readonly record struct PackagingHash(PackagingHashAlgorithm Algorithm, byte[] Hash) +{ + public override string ToString() => $"{Algorithm.ToString().ToLower()}:{Base64Url.EncodeToString(Hash)}"; +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingHashAlgorithm.cs b/LibXboxOne/XVC2/PackagingHashAlgorithm.cs new file mode 100644 index 0000000..41b997a --- /dev/null +++ b/LibXboxOne/XVC2/PackagingHashAlgorithm.cs @@ -0,0 +1,9 @@ +namespace LibXboxOne.XVC2; + +public enum PackagingHashAlgorithm +{ + None = 0, + SHA256 = 1, + SHA384 = 2, + SHA512 = 3 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingIV.cs b/LibXboxOne/XVC2/PackagingIV.cs new file mode 100644 index 0000000..923caf7 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingIV.cs @@ -0,0 +1,64 @@ +using System; +using System.Buffers.Binary; +using System.Buffers.Text; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2; + +public struct PackagingIV +{ + public const int Size = 16; + + private ulong _counter0; + private ulong _counter1; + + public PackagingIV Increment() + { + var copy = new PackagingIV + { + _counter0 = _counter0, + _counter1 = _counter1 + }; + + var prev = copy._counter0++; + if (prev + 1 < copy._counter0) + copy._counter1++; + + return copy; + } + + public byte[] ToArray() + { + var array = new byte[16]; + BinaryPrimitives.WriteUInt64BigEndian(array, _counter1); + BinaryPrimitives.WriteUInt64BigEndian(array.AsSpan(8), _counter0); + return array; + } + + public override string ToString() => Base64Url.EncodeToString(ToArray()); + + public static PackagingIV FromBase64UrlString(string value) + { + var bytes = Base64Url.DecodeFromChars(value); + return FromBytes(bytes); + } + + public static PackagingIV FromBytes(ReadOnlySpan value) + { + return new PackagingIV + { + _counter1 = BinaryPrimitives.ReadUInt64BigEndian(value), + _counter0 = BinaryPrimitives.ReadUInt64BigEndian(value[8..]) + }; + } + + public void Serialize(CborWriter writer) + { + writer.WriteByteString(ToArray()); + } + + public static PackagingIV Deserialize(CborReader reader) + { + return FromBytes(reader.ReadByteString()); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/PackagingKeyPurpose.cs b/LibXboxOne/XVC2/PackagingKeyPurpose.cs new file mode 100644 index 0000000..5d9eb60 --- /dev/null +++ b/LibXboxOne/XVC2/PackagingKeyPurpose.cs @@ -0,0 +1,8 @@ +namespace LibXboxOne.XVC2; + +public enum PackagingKeyPurpose +{ + Content = 0, + Version = 1, + PackageData = 2 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/README.md b/LibXboxOne/XVC2/README.md new file mode 100644 index 0000000..c20626a --- /dev/null +++ b/LibXboxOne/XVC2/README.md @@ -0,0 +1 @@ +This directory contains information about the new MSIXVC2 format, introduced in the April 2026 GDK. \ No newline at end of file diff --git a/LibXboxOne/XVC2/SegmentationAlgorithm.cs b/LibXboxOne/XVC2/SegmentationAlgorithm.cs new file mode 100644 index 0000000..23a2186 --- /dev/null +++ b/LibXboxOne/XVC2/SegmentationAlgorithm.cs @@ -0,0 +1,8 @@ +namespace LibXboxOne.XVC2; + +public enum SegmentationAlgorithm +{ + None = 0, + FastCDC = 1, + Fixed = 2 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Box.cs b/LibXboxOne/XVC2/SerializedModel/Box.cs new file mode 100644 index 0000000..9f78d47 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Box.cs @@ -0,0 +1,74 @@ +#nullable enable +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record Box(FileFormat FileFormat, string Name, List Segments) : ISerialize +{ + public void Serialize(CborWriter writer) + { + writer.WriteSelfDescribeTag(CborTagEx.XVCB); + + writer.WriteStartMap(3); + + writer.WriteLabel(SerializedLabel.FileFormat); + FileFormat.Serialize(writer); + + writer.WriteLabel(SerializedLabel.Name); + writer.WriteTextString(Name); + + writer.WriteLabel(SerializedLabel.Segments); + writer.WriteStartArray(Segments.Count); + foreach (var segment in Segments) + { + segment.Serialize(writer); + } + writer.WriteEndArray(); + + writer.WriteEndMap(); + } + + public static Box Deserialize(CborReader reader) + { + FileFormat? fileFormat = default; + string? name = default; + List? segments = default; + + reader.ReadSelfDescribeTag(CborTagEx.XVCB); + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.FileFormat: + fileFormat = FileFormat.Deserialize(reader); + break; + case SerializedLabel.Name: + name = reader.ReadTextString(); + break; + case SerializedLabel.Segments: + segments = []; + var segmentCount = reader.ReadStartArray(); + PackagingIV? iv = default; + while (segmentCount-- != 0) + { + segments.Add(SegmentReference.Deserialize(reader, ref iv)); + } + reader.ReadEndArray(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + Debug.Assert(fileFormat != null && name != null && segments != null); + return new Box(fileFormat, name, segments); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/BoxReference.cs b/LibXboxOne/XVC2/SerializedModel/BoxReference.cs new file mode 100644 index 0000000..df1facc --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/BoxReference.cs @@ -0,0 +1,40 @@ +#nullable enable +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record BoxReference(string Name) : ISerialize +{ + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(1); + writer.WriteLabel(SerializedLabel.Name); + writer.WriteTextString(Name); + writer.WriteEndMap(); + } + + public static BoxReference Deserialize(CborReader reader) + { + string? name = null; + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Name: + name = reader.ReadTextString(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + reader.ReadEndMap(); + + Debug.Assert(name != null); + return new BoxReference(name); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Chunk.cs b/LibXboxOne/XVC2/SerializedModel/Chunk.cs new file mode 100644 index 0000000..c1822f2 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Chunk.cs @@ -0,0 +1,212 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; +using LibXboxOne.XVC2.Specifiers; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record Chunk( + IPackagingSpecifier? Tags, + IPackagingSpecifier? Languages, + IPackagingSpecifier? Devices, + int Id, + long Length, + bool OnDemand, + bool RequiredToLaunch, + int KeyIndex, + int BoxLength, + SegmentReference SecretReference +) : ISerialize +{ + private static void SerializeSpecifier(CborWriter writer, IPackagingSpecifier specifier) + { + if (specifier is PackagingSpecifierValue packagingSpecifierValue) + { + writer.WriteTextString(packagingSpecifierValue.Value); + return; + } + + if (specifier is PackagingLogicalSpecifier logicalSpecifierValue) + { + writer.WriteTagEx(logicalSpecifierValue.Type switch + { + LogicalSpecifierType.Any => CborTagEx.LogicalAny, + LogicalSpecifierType.All => CborTagEx.LogicalAll, + _ => throw new UnreachableException() + }); + + writer.WriteStartArray(logicalSpecifierValue.Specifiers.Count); + foreach (var subSpecifier in logicalSpecifierValue.Specifiers) + { + SerializeSpecifier(writer, subSpecifier); + } + writer.WriteEndArray(); + return; + } + + Debug.Assert(false); + } + + private static IPackagingSpecifier DeserializeSpecifier(CborReader reader) + { + var nextTag = reader.PeekState(); + if (nextTag == CborReaderState.TextString) + { + return new PackagingSpecifierValue(reader.ReadTextString()); + } + + if (nextTag == CborReaderState.Tag) + { + var tag = reader.ReadTagEx(); + if (tag is CborTagEx.LogicalAny or CborTagEx.LogicalAll) + { + var logicalSpecifier = tag switch + { + CborTagEx.LogicalAny => LogicalSpecifierType.Any, + CborTagEx.LogicalAll => LogicalSpecifierType.All, + _ => throw new UnreachableException() + }; + + var subSpecifiers = new List(); + + var count = reader.ReadStartArray(); + while (count-- != 0) + { + subSpecifiers.Add(DeserializeSpecifier(reader)); + } + reader.ReadEndArray(); + + return new PackagingLogicalSpecifier(logicalSpecifier, subSpecifiers); + } + } + + throw new InvalidOperationException("Invalid packaging specifier"); + } + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(3 + + (Tags != null ? 1 : 0) + + (Languages != null ? 1 : 0) + + (Devices != null ? 1 : 0) + + (OnDemand ? 1 : 0) + + (RequiredToLaunch ? 1 : 0) + + (KeyIndex != 0 ? 1 : 0) + + (BoxLength != 0 ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.Id); + writer.WriteInt32(Id); + + if (OnDemand) + { + writer.WriteLabel(SerializedLabel.OnDemand); + writer.WriteBoolean(OnDemand); + } + + if (Tags != null) + { + writer.WriteLabel(SerializedLabel.Tags); + SerializeSpecifier(writer, Tags); + } + + if (Languages != null) + { + writer.WriteLabel(SerializedLabel.Languages); + SerializeSpecifier(writer, Languages); + } + + if (Devices != null) + { + writer.WriteLabel(SerializedLabel.Devices); + SerializeSpecifier(writer, Devices); + } + + if (RequiredToLaunch) + { + writer.WriteLabel(SerializedLabel.RequiredToLaunch); + writer.WriteBoolean(RequiredToLaunch); + } + + if (KeyIndex != 0) + { + writer.WriteLabel(SerializedLabel.KeyIndex); + writer.WriteInt32(KeyIndex); + } + + writer.WriteLabel(SerializedLabel.Length); + writer.WriteInt64(Length); + + if (BoxLength != 0) + { + writer.WriteLabel(SerializedLabel.BoxLength); + writer.WriteInt32(BoxLength); + } + + writer.WriteLabel(SerializedLabel.SecretReference); + SecretReference.Serialize(writer); + + writer.WriteEndMap(); + } + + public static Chunk Deserialize(CborReader reader, ref PackagingIV? rollingIV) + { + IPackagingSpecifier? tags = default; + IPackagingSpecifier? languages = default; + IPackagingSpecifier? devices = default; + bool onDemand = default; + bool requiredToLaunch = default; + int id = default; + int keyIndex = default; + long length = default; + int boxLength = default; + SegmentReference? secretReference = default; + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Id: + id = reader.ReadInt32(); + break; + case SerializedLabel.OnDemand: + onDemand = reader.ReadBoolean(); + break; + case SerializedLabel.Tags: + tags = DeserializeSpecifier(reader); + break; + case SerializedLabel.Languages: + languages = DeserializeSpecifier(reader); + break; + case SerializedLabel.Devices: + devices = DeserializeSpecifier(reader); + break; + case SerializedLabel.RequiredToLaunch: + requiredToLaunch = reader.ReadBoolean(); + break; + case SerializedLabel.KeyIndex: + keyIndex = reader.ReadInt32(); + break; + case SerializedLabel.Length: + length = reader.ReadInt64(); + break; + case SerializedLabel.BoxLength: + boxLength = reader.ReadInt32(); + break; + case SerializedLabel.SecretReference: + secretReference = SegmentReference.Deserialize(reader, ref rollingIV); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + reader.ReadEndMap(); + + Debug.Assert(secretReference != null); + return new Chunk(tags, languages, devices, id, length, onDemand, requiredToLaunch, keyIndex, boxLength, secretReference); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/ChunkDetails.cs b/LibXboxOne/XVC2/SerializedModel/ChunkDetails.cs new file mode 100644 index 0000000..d7cb8db --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/ChunkDetails.cs @@ -0,0 +1,82 @@ +#nullable enable +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record ChunkDetails(List Files, int Id, PackagingIV? IV) : IRootSerialize +{ + public string OpcPath => $"/Chunks/{Id}.cbor"; + public string OpcRelationship => "http://xbox.com/MSIXVC2/Chunk"; + + public void Serialize(CborWriter writer) + { + writer.WriteSelfDescribeTag(CborTagEx.XVCC); + + writer.WriteStartMap(2 + (IV.HasValue ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.Id); + writer.WriteInt32(Id); + + if (IV is { } initialIV) + { + writer.WriteLabel(SerializedLabel.InitialIV); + initialIV.Serialize(writer); + } + + writer.WriteLabel(SerializedLabel.Files); + writer.WriteStartArray(Files.Count); + + var iv = IV; + foreach (var file in Files) + { + file.Serialize(writer, ref iv, false); + } + writer.WriteEndArray(); + + writer.WriteEndMap(); + } + + public static ChunkDetails Deserialize(CborReader reader) + { + int id = default; + PackagingIV? iv = default; + List? files = default; + + reader.ReadSelfDescribeTag(CborTagEx.XVCC); + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Id: + id = reader.ReadInt32(); + break; + case SerializedLabel.InitialIV: + iv = PackagingIV.Deserialize(reader); + break; + case SerializedLabel.Files: + var fileCount = reader.ReadStartArray(); + files = []; + while (fileCount-- != 0) + { + files.Add(File.Deserialize(reader, ref iv)); + } + reader.ReadEndArray(); + + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + Debug.Assert(files != null); + return new ChunkDetails(files, id, iv); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/ChunkDetailsSecret.cs b/LibXboxOne/XVC2/SerializedModel/ChunkDetailsSecret.cs new file mode 100644 index 0000000..7b3d86c --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/ChunkDetailsSecret.cs @@ -0,0 +1,71 @@ +#nullable enable +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record ChunkDetailsSecret(int Id, List Files) : IRootSerialize +{ + public string OpcPath => $"/Chunks/{Id}-secret.cbor"; + public string OpcRelationship => "http://xbox.com/MSIXVC2/ChunkSecret"; + + public void Serialize(CborWriter writer) + { + writer.WriteSelfDescribeTag(CborTagEx.XVCC); + + writer.WriteStartMap(2); + + writer.WriteLabel(SerializedLabel.Id); + writer.WriteInt32(Id); + + writer.WriteLabel(SerializedLabel.Files); + writer.WriteStartArray(Files.Count); + foreach (var file in Files) + { + file.Serialize(writer); + } + writer.WriteEndArray(); + + writer.WriteEndMap(); + } + + public static ChunkDetailsSecret Deserialize(CborReader reader) + { + reader.ReadSelfDescribeTag(CborTagEx.XVCC); + + int id = default; + List? files = default; + + var count = reader.ReadStartMap(); + + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Id: + id = reader.ReadInt32(); + break; + case SerializedLabel.Files: + files = []; + var fileCount = reader.ReadStartArray(); + while (fileCount-- != 0) + { + files.Add(FileSecret.Deserialize(reader)); + } + reader.ReadEndArray(); + + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + Debug.Assert(files != null); + return new ChunkDetailsSecret(id, files); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/File.cs b/LibXboxOne/XVC2/SerializedModel/File.cs new file mode 100644 index 0000000..e8c37a1 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/File.cs @@ -0,0 +1,132 @@ +#nullable enable +using System.Collections.Generic; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record File( + int Id, + int ChunkId, + PackagingIV? IV, + long Length, + PackagingHash Hash, + bool ReadProtected, + List? Segments +) : IRootSerialize +{ + public string OpcPath => $"/Files/{Id}.cbor"; + public string OpcRelationship => "http://xbox.com/MSIXVC2/File"; + + public void Serialize(CborWriter writer) + { + PackagingIV? iv = default; + Serialize(writer, ref iv, true); + } + + public void Serialize(CborWriter writer, ref PackagingIV? rollingIV, bool isStandaloneSerialize) + { + writer.WriteStartMap(3 + + (isStandaloneSerialize && ChunkId != 0 ? 1 : 0) + + (IV.HasValue ? 1 : 0) + + (ReadProtected ? 1 : 0) + + (Segments != null ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.Id); + writer.WriteInt32(Id); + + if (isStandaloneSerialize && ChunkId != 0) + { + writer.WriteLabel(SerializedLabel.ChunkId); + writer.WriteInt32(ChunkId); + } + + if (IV is {} iv) + { + writer.WriteLabel(SerializedLabel.InitialIV); + iv.Serialize(writer); + } + + writer.WriteLabel(SerializedLabel.Length); + writer.WriteInt64(Length); + + writer.WriteLabel(SerializedLabel.Hash); + writer.WriteHash(Hash); + + writer.WriteLabel(SerializedLabel.ReadProtected); + writer.WriteBoolean(ReadProtected); + + if (Segments != null) + { + writer.WriteLabel(SerializedLabel.Segments); + writer.WriteStartArray(Segments.Count); + foreach (var segment in Segments) + { + segment.Serialize(writer); + } + writer.WriteEndArray(); + } + + writer.WriteEndMap(); + } + + public static File Deserialize(CborReader reader, ref PackagingIV? rollingIV) + { + int id = default; + int chunkId = default; + var iv = rollingIV; + long length = default; + PackagingHash hash = default; + bool readProtected = default; + List? segments = default; + + var count = reader.ReadStartMap(); + + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Id: + id = reader.ReadInt32(); + break; + case SerializedLabel.ChunkId: + chunkId = reader.ReadInt32(); + break; + case SerializedLabel.InitialIV: + iv = PackagingIV.Deserialize(reader); + break; + case SerializedLabel.Length: + length = reader.ReadInt64(); + break; + case SerializedLabel.Hash: + hash = reader.ReadHash(); + break; + case SerializedLabel.ReadProtected: + readProtected = reader.ReadBoolean(); + break; + case SerializedLabel.Segments: + segments = []; + var segmentCount = reader.ReadStartArray(); + while (segmentCount-- != 0) + { + segments.Add(SegmentReference.Deserialize(reader, ref iv)); + } + reader.ReadEndArray(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + if (rollingIV != null) + { + rollingIV = iv; + } + + return new File(id, chunkId, iv, length, hash, readProtected, segments); + + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/FileFormat.cs b/LibXboxOne/XVC2/SerializedModel/FileFormat.cs new file mode 100644 index 0000000..dac49f9 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/FileFormat.cs @@ -0,0 +1,87 @@ +#nullable enable +using System.Collections.Generic; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public record FileFormat(List WrittenBy, int MajorVersion, int MinorVersion, int Build) : ISerialize +{ + public System.Version Version => new(MajorVersion, MinorVersion); + + public override string ToString() + => $"{MajorVersion}.{MinorVersion}.{Build} ({WrittenBy})"; + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(2 + + (Build > 0 ? 1 : 0) + + (WrittenBy.Count > 0 ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.MajorVersion); + writer.WriteInt32(MajorVersion); + + writer.WriteLabel(SerializedLabel.MinorVersion); + writer.WriteInt32(MinorVersion); + + if (Build > 0) + { + writer.WriteLabel(SerializedLabel.Build); + writer.WriteInt32(Build); + } + + if (WrittenBy.Count > 0) + { + writer.WriteLabel(SerializedLabel.WrittenBy); + writer.WriteStartArray(WrittenBy.Count); + foreach (var writtenBy in WrittenBy) + { + writer.WriteTextString(writtenBy); + } + writer.WriteEndArray(); + } + + writer.WriteEndMap(); + } + + public static FileFormat Deserialize(CborReader reader) + { + int major = default; + int minor = default; + int build = default; + List writtenBy = []; + + var remaining = reader.ReadStartMap(); + while (remaining-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.MajorVersion: + major = reader.ReadInt32(); + break; + case SerializedLabel.MinorVersion: + minor = reader.ReadInt32(); + break; + case SerializedLabel.Build: + build = reader.ReadInt32(); + break; + case SerializedLabel.WrittenBy: + writtenBy = []; + var count = reader.ReadStartArray(); + while (count-- != 0) + { + writtenBy.Add(reader.ReadTextString()); + } + reader.ReadEndArray(); + break; + default: + reader.SkipValue(); + break; + } + } + + reader.ReadEndMap(); + + return new FileFormat(writtenBy, major, minor, build); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/FileSecret.cs b/LibXboxOne/XVC2/SerializedModel/FileSecret.cs new file mode 100644 index 0000000..f48d149 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/FileSecret.cs @@ -0,0 +1,45 @@ +#nullable enable +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record FileSecret(string FileName) : ISerialize +{ + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(1); + + // I think this is a typo, and they meant to use SerializedLabel.FileName here + writer.WriteLabel(SerializedLabel.Name); + writer.WriteTextString(FileName); + + writer.WriteEndMap(); + } + + public static FileSecret Deserialize(CborReader reader) + { + string? name = default; + + var count = reader.ReadStartMap(); + + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Name: + name = reader.ReadTextString(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + Debug.Assert(name != null); + return new FileSecret(name); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/IRootSerialize.cs b/LibXboxOne/XVC2/SerializedModel/IRootSerialize.cs new file mode 100644 index 0000000..42070b5 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/IRootSerialize.cs @@ -0,0 +1,7 @@ +namespace LibXboxOne.XVC2.SerializedModel; + +public interface IRootSerialize : ISerialize +{ + string OpcPath { get; } + string OpcRelationship { get; } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/ISerialize.cs b/LibXboxOne/XVC2/SerializedModel/ISerialize.cs new file mode 100644 index 0000000..fd154e3 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/ISerialize.cs @@ -0,0 +1,8 @@ +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public interface ISerialize +{ + void Serialize(CborWriter writer); +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Package.cs b/LibXboxOne/XVC2/SerializedModel/Package.cs new file mode 100644 index 0000000..a080f53 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Package.cs @@ -0,0 +1,190 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public record Package( + FileFormat FileFormat, + Guid ContentId, + Version Version, + PackagingIV? InitialIV, + List Keys, + Segmentation Segmentation, + List Boxes, + List Chunks, + Guid FulfillmentContentId, + Guid ProductId, + Version MinimumSystemVersion, + string StoreId, + SerializedPlatform SupportedPlatforms +) : IRootSerialize +{ + public string OpcPath => "/XbocPackage.cbor"; + public string OpcRelationship => "http://xbox.com/MSIXVC2/Package"; + + public void Serialize(CborWriter writer) + { + writer.WriteSelfDescribeTag(CborTagEx.XVCP); + + writer.WriteStartMap(11 + (InitialIV != null ? 1 : 0) + (Keys.Count > 0 ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.FileFormat); + FileFormat.Serialize(writer); + + if (InitialIV is {} initialIV) + { + writer.WriteLabel(SerializedLabel.InitialIV); + initialIV.Serialize(writer); + } + + writer.WriteLabel(SerializedLabel.ContentId); + writer.WriteGuid(ContentId); + + writer.WriteLabel(SerializedLabel.FulfillmentContentId); + writer.WriteGuid(FulfillmentContentId); + + writer.WriteLabel(SerializedLabel.ProductId); + writer.WriteGuid(ProductId); + + writer.WriteLabel(SerializedLabel.Version); + Version.Serialize(writer); + + writer.WriteLabel(SerializedLabel.MinimumSystemVersion); + MinimumSystemVersion.Serialize(writer); + + writer.WriteLabel(SerializedLabel.StoreId); + writer.WriteTextString(StoreId); + + writer.WriteLabel(SerializedLabel.SupportedPlatforms); + writer.WriteEnum(SupportedPlatforms); + + writer.WriteLabel(SerializedLabel.Segmentation); + Segmentation.Serialize(writer); + + if (Keys.Count > 0) + { + writer.WriteLabel(SerializedLabel.Keys); + writer.WriteStartArray(Keys.Count); + foreach (var key in Keys) + { + key.Serialize(writer); + } + writer.WriteEndArray(); + } + + writer.WriteLabel(SerializedLabel.Boxes); + writer.WriteStartArray(Boxes.Count); + foreach (var box in Boxes) + { + box.Serialize(writer); + } + writer.WriteEndArray(); + + writer.WriteLabel(SerializedLabel.Chunks); + writer.WriteStartArray(Chunks.Count); + foreach (var chunk in Chunks) + { + chunk.Serialize(writer); + } + writer.WriteEndArray(); + + writer.WriteEndMap(); + } + + public static Package Deserialize(CborReader reader) + { + FileFormat? fileFormat = default; + Guid contentId = default; + Version version = default; + PackagingIV? initialIV = default; + List keys = []; + Segmentation? segmentation = default; + List? boxes = default; + List? chunks = default; + Guid fulfillmentContentId = default; + Guid productId = default; + Version minimumSystemVersion = default; + string? storeId = default; + SerializedPlatform supportedPlatforms = default; + + reader.ReadSelfDescribeTag(CborTagEx.XVCP); + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.FileFormat: + fileFormat = FileFormat.Deserialize(reader); + break; + case SerializedLabel.InitialIV: + initialIV = PackagingIV.FromBytes(reader.ReadByteString()); + break; + case SerializedLabel.ContentId: + contentId = reader.ReadGuid(); + break; + case SerializedLabel.FulfillmentContentId: + fulfillmentContentId = reader.ReadGuid(); + break; + case SerializedLabel.ProductId: + productId = reader.ReadGuid(); + break; + case SerializedLabel.Version: + version = Version.Deserialize(reader); + break; + case SerializedLabel.MinimumSystemVersion: + minimumSystemVersion = Version.Deserialize(reader); + break; + case SerializedLabel.StoreId: + storeId = reader.ReadTextString(); + break; + case SerializedLabel.SupportedPlatforms: + supportedPlatforms = (SerializedPlatform)reader.ReadUInt32(); + break; + case SerializedLabel.Segmentation: + segmentation = Segmentation.Deserialize(reader); + break; + case SerializedLabel.Keys: + var keyCount = reader.ReadStartArray(); + keys = new List(keyCount ?? 0); + while (keyCount-- != 0) + { + keys.Add(PackageKey.Deserialize(reader)); + } + reader.ReadEndArray(); + break; + case SerializedLabel.Boxes: + var boxCount = reader.ReadStartArray(); + boxes = new List(boxCount ?? 0); + while (boxCount-- != 0) + { + boxes.Add(BoxReference.Deserialize(reader)); + } + reader.ReadEndArray(); + break; + case SerializedLabel.Chunks: + var chunkCount = reader.ReadStartArray(); + chunks = new List(chunkCount ?? 0); + while (chunkCount-- != 0) + { + chunks.Add(Chunk.Deserialize(reader, ref initialIV)); + } + reader.ReadEndArray(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + reader.ReadEndMap(); + + Debug.Assert(fileFormat != null && segmentation != null && boxes != null && chunks != null && storeId != null); + return new Package(fileFormat, contentId, version, initialIV, keys, segmentation, boxes, chunks, + fulfillmentContentId, productId, minimumSystemVersion, storeId, supportedPlatforms); + + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/PackageKey.cs b/LibXboxOne/XVC2/SerializedModel/PackageKey.cs new file mode 100644 index 0000000..38f494b --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/PackageKey.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record PackageKey(List Sources) : ISerialize +{ + public void Serialize(CborWriter writer) + { + writer.WriteStartArray(Sources.Count); + foreach (var source in Sources) + { + source.Serialize(writer); + } + writer.WriteEndArray(); + } + + public static PackageKey Deserialize(CborReader reader) + { + var count = reader.ReadStartArray(); + var sources = new List(count ?? 0); + while (count-- != 0) + { + sources.Add(PackageKeySource.Deserialize(reader)); + } + reader.ReadEndArray(); + + return new PackageKey(sources); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/PackageKeySource.cs b/LibXboxOne/XVC2/SerializedModel/PackageKeySource.cs new file mode 100644 index 0000000..60f8ae9 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/PackageKeySource.cs @@ -0,0 +1,152 @@ +#nullable enable +using System; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record PackageKeySource( + Guid SourceKeyId, + PackagingKeyPurpose SourcePurpose, + PackagingDerivationAlgorithm DerivationAlgorithm, + byte[] KdfContext, + PackagingEncryptionAlgorithm WrapAlgorithm, + PackagingIV? WrapIV, // unverified + byte[] WrappedKey, + PackagingEncryptionAlgorithm Algorithm + +) : ISerialize +{ + private static PackagingDerivationAlgorithm ToDerivationAlgorithm(SerializedAlgorithm algorithm) => algorithm switch + { + SerializedAlgorithm.None => PackagingDerivationAlgorithm.None, + SerializedAlgorithm.SP800_108_HMAC_SHA256 => PackagingDerivationAlgorithm.SP800_108_HMAC_SHA256, + _ => throw new UnreachableException() + }; + + private static PackagingEncryptionAlgorithm ToEncryptionAlgorithm(SerializedAlgorithm algorithm) => algorithm switch + { + SerializedAlgorithm.None => PackagingEncryptionAlgorithm.None, + SerializedAlgorithm.AES_256_CBC => PackagingEncryptionAlgorithm.AES_256_CBC, + SerializedAlgorithm.AES_256_KW => PackagingEncryptionAlgorithm.AES_256_KW, + _ => throw new UnreachableException() + }; + + private static PackagingKeyPurpose ToKeyPurpose(SerializedKeyPurpose keyPurpose) => keyPurpose switch + { + SerializedKeyPurpose.Content => PackagingKeyPurpose.Content, + SerializedKeyPurpose.Version => PackagingKeyPurpose.Version, + SerializedKeyPurpose.PackageData => PackagingKeyPurpose.PackageData, + _ => throw new UnreachableException() + }; + + private static SerializedAlgorithm ToSerialized(PackagingDerivationAlgorithm algorithm) => algorithm switch + { + PackagingDerivationAlgorithm.None => SerializedAlgorithm.None, + PackagingDerivationAlgorithm.SP800_108_HMAC_SHA256 => SerializedAlgorithm.SP800_108_HMAC_SHA256, + _ => throw new UnreachableException() + }; + + private static SerializedAlgorithm ToSerialized(PackagingEncryptionAlgorithm algorithm) => algorithm switch + { + PackagingEncryptionAlgorithm.None => SerializedAlgorithm.None, + PackagingEncryptionAlgorithm.AES_256_CBC => SerializedAlgorithm.AES_256_CBC, + PackagingEncryptionAlgorithm.AES_256_KW => SerializedAlgorithm.AES_256_KW, + _ => throw new UnreachableException() + }; + + private static SerializedKeyPurpose ToSerialized(PackagingKeyPurpose keyPurpose) => keyPurpose switch + { + PackagingKeyPurpose.Content => SerializedKeyPurpose.Content, + PackagingKeyPurpose.Version => SerializedKeyPurpose.Version, + PackagingKeyPurpose.PackageData => SerializedKeyPurpose.PackageData, + _ => throw new UnreachableException() + }; + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(7 + (WrapIV != null ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.SourcePurpose); + writer.WriteEnum(ToSerialized(SourcePurpose)); + + writer.WriteLabel(SerializedLabel.SourceKeyId); + writer.WriteGuid(SourceKeyId); + + writer.WriteLabel(SerializedLabel.DerivationAlgorithm); + writer.WriteEnum(ToSerialized(DerivationAlgorithm)); + + writer.WriteLabel(SerializedLabel.KdfContext); + writer.WriteByteString(KdfContext); + + writer.WriteLabel(SerializedLabel.WrapAlgorithm); + writer.WriteEnum(ToSerialized(WrapAlgorithm)); + + if (WrapIV is {} wrapIV) + { + writer.WriteLabel(SerializedLabel.WrapIV); + wrapIV.Serialize(writer); + } + + writer.WriteLabel(SerializedLabel.WrappedKey); + writer.WriteByteString(WrappedKey); + + writer.WriteLabel(SerializedLabel.Algorithm); + writer.WriteEnum(ToSerialized(Algorithm)); + + writer.WriteEndMap(); + } + + public static PackageKeySource Deserialize(CborReader reader) + { + Guid sourceKeyId = default; + PackagingKeyPurpose keyPurpose = default; + PackagingDerivationAlgorithm derivationAlgorithm = default; + PackagingEncryptionAlgorithm wrapAlgorithm = default; + PackagingEncryptionAlgorithm algorithm = default; + byte[]? kdfContext = default; + PackagingIV? wrapIV = default; + byte[]? wrappedKey = default; + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.SourcePurpose: + keyPurpose = ToKeyPurpose(reader.ReadEnum()); + break; + case SerializedLabel.SourceKeyId: + sourceKeyId = reader.ReadGuid(); + break; + case SerializedLabel.DerivationAlgorithm: + derivationAlgorithm = ToDerivationAlgorithm(reader.ReadEnum()); + break; + case SerializedLabel.KdfContext: + kdfContext = reader.ReadByteString(); + break; + case SerializedLabel.WrapAlgorithm: + wrapAlgorithm = ToEncryptionAlgorithm(reader.ReadEnum()); + break; + case SerializedLabel.WrapIV: + wrapIV = PackagingIV.Deserialize(reader); + break; + case SerializedLabel.WrappedKey: + wrappedKey = reader.ReadByteString(); + break; + case SerializedLabel.Algorithm: + algorithm = ToEncryptionAlgorithm(reader.ReadEnum()); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + reader.ReadEndMap(); + + Debug.Assert(kdfContext != null && wrappedKey != null); + return new PackageKeySource(sourceKeyId, keyPurpose, derivationAlgorithm, kdfContext, wrapAlgorithm, + wrapIV, wrappedKey, algorithm); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Seal.cs b/LibXboxOne/XVC2/SerializedModel/Seal.cs new file mode 100644 index 0000000..a3efb8d --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Seal.cs @@ -0,0 +1,50 @@ +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record Seal(CborTagEx Target, PackagingHash Hash) : ISerialize +{ + public void Serialize(CborWriter writer) + { + writer.WriteSelfDescribeTag(CborTagEx.XVCZ); + + writer.WriteStartMap(2); + + writer.WriteLabel(SerializedLabel.Target); + writer.WriteEnum(Target); + + writer.WriteLabel(SerializedLabel.Hash); + writer.WriteHash(Hash); + + writer.WriteEndMap(); + } + + public static Seal Deserialize(CborReader reader) + { + CborTagEx target = default; + PackagingHash hash = default; + + reader.ReadSelfDescribeTag(CborTagEx.XVCZ); + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Target: + target = reader.ReadEnum(); + break; + case SerializedLabel.Hash: + hash = reader.ReadHash(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + return new Seal(target, hash); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/SegmentReference.cs b/LibXboxOne/XVC2/SerializedModel/SegmentReference.cs new file mode 100644 index 0000000..8a2ea63 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/SegmentReference.cs @@ -0,0 +1,184 @@ +#nullable enable +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record SegmentReference( + PackagingHash Hash, + int Length, + PackagingCompression Compression, + int CompressedLength, + byte[]? EncryptionKey, + byte[]? WrappedKey, + PackagingIV? WrapIV, + PackagingHash BoxHash, + BoxIndex BoxIndex, + int BoxOffset, + int BoxLength, + bool Secondary +) +{ + private static SerializedAlgorithm ToSerialized(PackagingCompression compression) => compression switch + { + PackagingCompression.None => SerializedAlgorithm.None, + PackagingCompression.Deflate => SerializedAlgorithm.Deflate, + PackagingCompression.Brotli => SerializedAlgorithm.Brotli, + _ => throw new UnreachableException() + }; + + private static PackagingCompression ToCompression(SerializedAlgorithm algorithm) => algorithm switch + { + SerializedAlgorithm.None => PackagingCompression.None, + SerializedAlgorithm.Deflate => PackagingCompression.Deflate, + SerializedAlgorithm.Brotli => PackagingCompression.Brotli, + _ => throw new UnreachableException() + }; + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(2 + + (Compression != 0 ? 2 : 0) + + (EncryptionKey != null ? 1 : 0) + + (WrappedKey != null ? 1 : 0) + + (BoxHash != default ? 1 : 0) + + (BoxIndex != default ? 1 : 0) + + (BoxOffset != 0 ? 1 : 0) + + (BoxLength != 0 ? 1 : 0) + + (Secondary ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.Hash); + writer.WriteHash(Hash); + + writer.WriteLabel(SerializedLabel.Length); + writer.WriteInt32(Length); + + if (Compression != 0) + { + writer.WriteLabel(SerializedLabel.Compression); + writer.WriteEnum(ToSerialized(Compression)); + + writer.WriteLabel(SerializedLabel.CompressedLength); + writer.WriteInt32(CompressedLength); + } + + if (EncryptionKey != null) + { + writer.WriteLabel(SerializedLabel.EncryptionKey); + writer.WriteByteString(EncryptionKey); + } + + if (WrappedKey != null) + { + writer.WriteLabel(SerializedLabel.WrappedKey); + writer.WriteByteString(WrappedKey); + } + + // 7 is probably WrapIV? is this used? + + if (BoxHash != default) + { + writer.WriteLabel(SerializedLabel.BoxHash); + writer.WriteHash(BoxHash); + } + + if (BoxIndex != default) + { + writer.WriteLabel(SerializedLabel.BoxIndex); + BoxIndex.Serialize(writer); + } + + if (BoxOffset != 0) + { + writer.WriteLabel(SerializedLabel.BoxOffset); + writer.WriteInt32(BoxOffset); + } + + if (BoxLength != 0) + { + writer.WriteLabel(SerializedLabel.BoxLength); + writer.WriteInt32(BoxLength); + } + + if (Secondary) + { + writer.WriteLabel(SerializedLabel.Secondary); + writer.WriteBoolean(Secondary); + } + + writer.WriteEndMap(); + } + + public static SegmentReference Deserialize(CborReader reader, ref PackagingIV? rollingIV) + { + PackagingHash hash = default; + int length = default; + PackagingCompression compression = default; + int compressedLength = default; + byte[]? encryptionKey = default; + byte[]? wrappedKey = default; + PackagingIV? wrapIV = default; + PackagingHash boxHash = default; + BoxIndex boxIndex = default; + int boxOffset = default; + int boxLength = default; + bool secondary = default; + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Hash: + hash = reader.ReadHash(); + break; + case SerializedLabel.Length: + length = reader.ReadInt32(); + break; + case SerializedLabel.Compression: + compression = ToCompression(reader.ReadEnum()); + break; + case SerializedLabel.CompressedLength: + compressedLength = reader.ReadInt32(); + break; + case SerializedLabel.EncryptionKey: + encryptionKey = reader.ReadByteString(); + break; + case SerializedLabel.WrappedKey: + wrappedKey = reader.ReadByteString(); + + Debug.Assert(rollingIV.HasValue); + wrapIV = rollingIV; + rollingIV = wrapIV.Value.Increment(); + break; + //case 7: + // // This is not set in normal references - the iv is gotten from the initial iv + // wrapIV = PackagingIV.FromBytes(reader.ReadByteString()); + // break; + case SerializedLabel.BoxHash: + boxHash = reader.ReadHash(); + break; + case SerializedLabel.BoxIndex: + boxIndex = BoxIndex.Deserialize(reader); + break; + case SerializedLabel.BoxOffset: + boxOffset = reader.ReadInt32(); + break; + case SerializedLabel.BoxLength: + boxLength = reader.ReadInt32(); + break; + case SerializedLabel.Secondary: + secondary = reader.ReadBoolean(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + return new SegmentReference(hash, length, compression, compressedLength, encryptionKey, wrappedKey, wrapIV, + boxHash, boxIndex, boxOffset, boxLength, secondary); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Segmentation.cs b/LibXboxOne/XVC2/SerializedModel/Segmentation.cs new file mode 100644 index 0000000..74dde12 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Segmentation.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public sealed record Segmentation(SegmentationAlgorithm Algorithm, Dictionary Options, int HashAlgorithm) : ISerialize +{ + private static SerializedAlgorithm ToSerialized(SegmentationAlgorithm algorithm) => algorithm switch + { + SegmentationAlgorithm.FastCDC => SerializedAlgorithm.FastCDC, + SegmentationAlgorithm.Fixed => SerializedAlgorithm.Fixed, + _ => throw new UnreachableException() + }; + + private static SegmentationAlgorithm ToSegmentationAlgorithm(SerializedAlgorithm algorithm) => algorithm switch + { + SerializedAlgorithm.FastCDC => SegmentationAlgorithm.FastCDC, + SerializedAlgorithm.Fixed => SegmentationAlgorithm.Fixed, + _ => throw new UnreachableException() + }; + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(2 + (HashAlgorithm != 0x301 ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.Algorithm); + writer.WriteEnum(ToSerialized(Algorithm)); + + writer.WriteLabel(SerializedLabel.Options); + writer.WriteMap(Options); + + if (HashAlgorithm != 0x301) + { + writer.WriteLabel(SerializedLabel.HashAlgorithm); + writer.WriteInt32(HashAlgorithm); + } + + writer.WriteEndMap(); + } + + public static Segmentation Deserialize(CborReader reader) + { + SegmentationAlgorithm algorithm = 0; + var options = new Dictionary(); + var hashAlgorithm = 0x301; + + var count = reader.ReadStartMap(); + while (count-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.Algorithm: + algorithm = ToSegmentationAlgorithm(reader.ReadEnum()); + break; + case SerializedLabel.Options: + options = reader.ReadMap(); + break; + case SerializedLabel.HashAlgorithm: + hashAlgorithm = reader.ReadInt32(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + return new Segmentation(algorithm, options, hashAlgorithm); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/SerializedAlgorithm.cs b/LibXboxOne/XVC2/SerializedModel/SerializedAlgorithm.cs new file mode 100644 index 0000000..f0b9d0c --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/SerializedAlgorithm.cs @@ -0,0 +1,16 @@ +namespace LibXboxOne.XVC2.SerializedModel; + +public enum SerializedAlgorithm +{ + None = 0, + Deflate = 1, + Brotli = 2, + AES_256_CBC = 256, + AES_256_KW = 257, + FastCDC = 512, + Fixed = 513, + SHA256 = 768, + SHA384 = 769, + SHA512 = 770, + SP800_108_HMAC_SHA256 = 1024, +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/SerializedKeyPurpose.cs b/LibXboxOne/XVC2/SerializedModel/SerializedKeyPurpose.cs new file mode 100644 index 0000000..7d1b6e0 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/SerializedKeyPurpose.cs @@ -0,0 +1,8 @@ +namespace LibXboxOne.XVC2.SerializedModel; + +public enum SerializedKeyPurpose +{ + Content = 0, + Version = 1, + PackageData = 2, +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/SerializedLabel.cs b/LibXboxOne/XVC2/SerializedModel/SerializedLabel.cs new file mode 100644 index 0000000..54a89b9 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/SerializedLabel.cs @@ -0,0 +1,69 @@ +namespace LibXboxOne.XVC2.SerializedModel; + +public enum SerializedLabel +{ + Unknown = 0, + Hash = 1, + Length = 2, + Compression = 3, + CompressedLength = 4, + EncryptionKey = 5, + WrappedKey = 6, + WrapIV = 7, + BoxHash = 8, + BoxIndex = 9, + BoxOffset = 10, + BoxLength = 11, + Secondary = 12, + Id = 24, + Name = 25, + SecretReference = 26, + InitialIV = 27, + Files = 28, + Segments = 29, + Tags = 30, + Languages = 31, + Devices = 32, + RequiredToLaunch = 33, + KeyIndex = 34, + ChunkId = 35, + OnDemand = 36, + ReadProtected = 37, + FileFormat = 256, + MajorVersion = 257, + MinorVersion = 258, + Algorithm = 259, + ContentId = 260, + Version = 261, + Keys = 262, + Segmentation = 263, + Boxes = 264, + Chunks = 265, + Options = 266, + Build = 267, + Revision = 268, + BuildId = 269, + ContentKeyId = 270, + VersionKeyId = 271, + WrappedPackageKey = 272, + Min = 273, + Avg = 274, + Max = 275, + PackageUri = 276, + FileName = 277, + UserDataName = 278, + FulfillmentContentId = 279, + ProductId = 280, + MinimumSystemVersion = 281, + StoreId = 282, + SupportedPlatforms = 283, + DerivationAlgorithm = 284, + WrapAlgorithm = 285, + KdfContext = 286, + SourcePurpose = 287, + SourceKeyId = 288, + Target = 289, + WrittenBy = 290, + HashAlgorithm = 291, + OriginalBuildId = 292, +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/SerializedPlatform.cs b/LibXboxOne/XVC2/SerializedModel/SerializedPlatform.cs new file mode 100644 index 0000000..a496d75 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/SerializedPlatform.cs @@ -0,0 +1,9 @@ +namespace LibXboxOne.XVC2.SerializedModel; + +public enum SerializedPlatform : uint +{ + None = 0, + PC = 1, + ConsoleGen8 = 2, + ConsoleGen9 = 3 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/SerializedModel/Version.cs b/LibXboxOne/XVC2/SerializedModel/Version.cs new file mode 100644 index 0000000..5fb9965 --- /dev/null +++ b/LibXboxOne/XVC2/SerializedModel/Version.cs @@ -0,0 +1,88 @@ +using System; +using System.Formats.Cbor; + +namespace LibXboxOne.XVC2.SerializedModel; + +public record struct Version( + ushort Major, + ushort Minor, + ushort Build, + ushort Revision, + Guid BuildId, + Guid? OriginalBuildId +) : ISerialize +{ + public override string ToString() => $"{Major}.{Minor}.{Build}.{Revision}.{BuildId}"; + + public void Serialize(CborWriter writer) + { + writer.WriteStartMap(5 + (OriginalBuildId.HasValue ? 1 : 0)); + + writer.WriteLabel(SerializedLabel.MajorVersion); + writer.WriteUInt32(Major); + + writer.WriteLabel(SerializedLabel.MinorVersion); + writer.WriteUInt32(Minor); + + writer.WriteLabel(SerializedLabel.Build); + writer.WriteUInt32(Build); + + writer.WriteLabel(SerializedLabel.Revision); + writer.WriteUInt32(Revision); + + writer.WriteLabel(SerializedLabel.BuildId); + writer.WriteGuid(BuildId); + + if (OriginalBuildId is { } value) + { + writer.WriteLabel(SerializedLabel.OriginalBuildId); + writer.WriteGuid(value); + } + + writer.WriteEndMap(); + } + + public static Version Deserialize(CborReader reader) + { + ushort major = default; + ushort minor = default; + ushort build = default; + ushort revision = default; + Guid buildId = default; + Guid? originalBuildId = default; + + var remaining = reader.ReadStartMap(); + while (remaining-- != 0) + { + var key = reader.ReadLabel(); + switch (key) + { + case SerializedLabel.MajorVersion: + major = (ushort)reader.ReadUInt32(); + break; + case SerializedLabel.MinorVersion: + minor = (ushort)reader.ReadUInt32(); + break; + case SerializedLabel.Build: + build = (ushort)reader.ReadUInt32(); + break; + case SerializedLabel.Revision: + revision = (ushort)reader.ReadUInt32(); + break; + case SerializedLabel.BuildId: + buildId = reader.ReadGuid(); + break; + case SerializedLabel.OriginalBuildId: + originalBuildId = reader.ReadGuid(); + break; + default: + reader.AssertInvalidValue(); + break; + } + } + + reader.ReadEndMap(); + + return new Version(major, minor, build, revision, buildId, originalBuildId); + } +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Specifiers/IPackagingSpecifier.cs b/LibXboxOne/XVC2/Specifiers/IPackagingSpecifier.cs new file mode 100644 index 0000000..ef76ff7 --- /dev/null +++ b/LibXboxOne/XVC2/Specifiers/IPackagingSpecifier.cs @@ -0,0 +1,3 @@ +namespace LibXboxOne.XVC2.Specifiers; + +public interface IPackagingSpecifier; \ No newline at end of file diff --git a/LibXboxOne/XVC2/Specifiers/LogicalSpecifierType.cs b/LibXboxOne/XVC2/Specifiers/LogicalSpecifierType.cs new file mode 100644 index 0000000..6493742 --- /dev/null +++ b/LibXboxOne/XVC2/Specifiers/LogicalSpecifierType.cs @@ -0,0 +1,7 @@ +namespace LibXboxOne.XVC2.Specifiers; + +public enum LogicalSpecifierType +{ + Any = 0, + All = 1 +} \ No newline at end of file diff --git a/LibXboxOne/XVC2/Specifiers/PackagingLogicalSpecifier.cs b/LibXboxOne/XVC2/Specifiers/PackagingLogicalSpecifier.cs new file mode 100644 index 0000000..274f109 --- /dev/null +++ b/LibXboxOne/XVC2/Specifiers/PackagingLogicalSpecifier.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace LibXboxOne.XVC2.Specifiers; + +public sealed record PackagingLogicalSpecifier(LogicalSpecifierType Type, List Specifiers) : IPackagingSpecifier; \ No newline at end of file diff --git a/LibXboxOne/XVC2/Specifiers/PackagingSpecifierValue.cs b/LibXboxOne/XVC2/Specifiers/PackagingSpecifierValue.cs new file mode 100644 index 0000000..6c5f790 --- /dev/null +++ b/LibXboxOne/XVC2/Specifiers/PackagingSpecifierValue.cs @@ -0,0 +1,3 @@ +namespace LibXboxOne.XVC2.Specifiers; + +public sealed record PackagingSpecifierValue(string Value) : IPackagingSpecifier; \ No newline at end of file diff --git a/LibXboxOne/XVD/XvdFilesystem.cs b/LibXboxOne/XVD/XvdFilesystem.cs index adeb086..9ccaf8c 100644 --- a/LibXboxOne/XVD/XvdFilesystem.cs +++ b/LibXboxOne/XVD/XvdFilesystem.cs @@ -189,7 +189,7 @@ public bool ExtractFilesystemImage(string targetFile, bool createVhd) for (long offset = 0; offset < _fs.Length; offset += XvdFile.PAGE_SIZE) { - _fs.Read(buffer, 0, buffer.Length); + _fs.ReadExactly(buffer, 0, buffer.Length); destFs.Write(buffer, 0, buffer.Length); } } diff --git a/XvdTool.Streaming/Commands/CryptoCommand.cs b/XvdTool.Streaming/Commands/CryptoCommand.cs index 29db082..53cc347 100644 --- a/XvdTool.Streaming/Commands/CryptoCommand.cs +++ b/XvdTool.Streaming/Commands/CryptoCommand.cs @@ -46,7 +46,7 @@ protected bool Initialize(CryptoCommandSettings settings, out KeyEntry entry) return true; } - public override ValidationResult Validate(CommandContext context, T settings) + protected override ValidationResult Validate(CommandContext context, T settings) { var result = base.Validate(context, settings); diff --git a/XvdTool.Streaming/Commands/DecryptCommand.cs b/XvdTool.Streaming/Commands/DecryptCommand.cs index 090f6cb..f9ca934 100644 --- a/XvdTool.Streaming/Commands/DecryptCommand.cs +++ b/XvdTool.Streaming/Commands/DecryptCommand.cs @@ -14,7 +14,7 @@ public sealed class Settings : CryptoCommandSettings public bool SkipHashCalculation { get; init; } } - public override int Execute(CommandContext context, Settings settings) + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { if (!Initialize(settings, out var keyEntry)) { @@ -29,7 +29,7 @@ public override int Execute(CommandContext context, Settings settings) return 0; } - public override ValidationResult Validate(CommandContext context, Settings settings) + protected override ValidationResult Validate(CommandContext context, Settings settings) { base.Validate(context, settings); diff --git a/XvdTool.Streaming/Commands/ExtractCommand.cs b/XvdTool.Streaming/Commands/ExtractCommand.cs index 6827ef5..f86a90e 100644 --- a/XvdTool.Streaming/Commands/ExtractCommand.cs +++ b/XvdTool.Streaming/Commands/ExtractCommand.cs @@ -27,7 +27,7 @@ public sealed class Settings : CryptoCommandSettings public bool SkipHashCheck { get; init; } } - public override int Execute(CommandContext context, Settings settings) + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { Debug.Assert(settings.OutputDirectory != null, "settings.OutputDirectory != null"); @@ -52,7 +52,7 @@ public override int Execute(CommandContext context, Settings settings) return 0; } - public override ValidationResult Validate(CommandContext context, Settings settings) + protected override ValidationResult Validate(CommandContext context, Settings settings) { if (settings is { DownloadRegions: not null, SkipRegions: not null }) return ValidationResult.Error("'--skip-region' and '--download-region' cannot be used together."); diff --git a/XvdTool.Streaming/Commands/ExtractEmbeddedXvdCommand.cs b/XvdTool.Streaming/Commands/ExtractEmbeddedXvdCommand.cs index 7851a18..9496d6b 100644 --- a/XvdTool.Streaming/Commands/ExtractEmbeddedXvdCommand.cs +++ b/XvdTool.Streaming/Commands/ExtractEmbeddedXvdCommand.cs @@ -15,7 +15,7 @@ internal sealed class Settings : XvdCommandSettings public string EmbeddedXvdOutputPath { get; set; } = null!; } - public override int Execute(CommandContext context, Settings settings) + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { Initialize(settings, requiresWriting: false); @@ -33,7 +33,7 @@ public override int Execute(CommandContext context, Settings settings) return 0; } - public override ValidationResult Validate(CommandContext context, Settings settings) + protected override ValidationResult Validate(CommandContext context, Settings settings) { if (Directory.Exists(settings.EmbeddedXvdOutputPath)) return ValidationResult.Error("The embedded XVD output path is a directory."); diff --git a/XvdTool.Streaming/Commands/InfoCommand.cs b/XvdTool.Streaming/Commands/InfoCommand.cs index 0745616..b8714aa 100644 --- a/XvdTool.Streaming/Commands/InfoCommand.cs +++ b/XvdTool.Streaming/Commands/InfoCommand.cs @@ -17,7 +17,7 @@ public sealed class Settings : XvdCommandSettings public bool ShowAllFiles { get; set; } } - public override int Execute(CommandContext context, Settings settings) + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { Initialize(settings, requiresWriting: false); diff --git a/XvdTool.Streaming/Commands/Msixvc2/ExtractCommand.cs b/XvdTool.Streaming/Commands/Msixvc2/ExtractCommand.cs new file mode 100644 index 0000000..c88dc08 --- /dev/null +++ b/XvdTool.Streaming/Commands/Msixvc2/ExtractCommand.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using System.Diagnostics; +using Spectre.Console.Cli; + +namespace XvdTool.Streaming.Commands.Msixvc2; + +internal sealed class ExtractCommand : Msixvc2Command +{ + public sealed class Settings : Msixvc2CommandSettings + { + [Description("File path to save the output into.")] + [CommandOption("-o|--output")] + public string? OutputPath { get; init; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + Initialize(settings); + + Debug.Assert(Msixvc2 != null); + + using (Msixvc2) + { + Msixvc2.ExtractFiles(settings.OutputPath); + } + + return 0; + } +} \ No newline at end of file diff --git a/XvdTool.Streaming/Commands/Msixvc2/InfoCommand.cs b/XvdTool.Streaming/Commands/Msixvc2/InfoCommand.cs new file mode 100644 index 0000000..bd19556 --- /dev/null +++ b/XvdTool.Streaming/Commands/Msixvc2/InfoCommand.cs @@ -0,0 +1,41 @@ +using System.ComponentModel; +using System.Diagnostics; +using Spectre.Console.Cli; + +namespace XvdTool.Streaming.Commands.Msixvc2; + +internal sealed class InfoCommand : Msixvc2Command +{ + public sealed class Settings : Msixvc2CommandSettings + { + [Description("File path to save the output into.")] + [CommandOption("-o|--output")] + public string? OutputPath { get; init; } + + [Description("If all files should be printed.\nIf unset, only the first 4096 files will be printed.")] + [CommandOption("-a|--show-all-files")] + public bool ShowAllFiles { get; set; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) + { + Initialize(settings); + + Debug.Assert(Msixvc2 != null); + + using (Msixvc2) + { + var infoOutput = Msixvc2.PrintInfo(settings.ShowAllFiles); + if (settings.OutputPath != null) + { + var directory = Path.GetDirectoryName(settings.OutputPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + File.WriteAllText(settings.OutputPath, infoOutput); + } + } + + return 0; + } +} \ No newline at end of file diff --git a/XvdTool.Streaming/Commands/Msixvc2/Msixvc2Command.cs b/XvdTool.Streaming/Commands/Msixvc2/Msixvc2Command.cs new file mode 100644 index 0000000..76f4e75 --- /dev/null +++ b/XvdTool.Streaming/Commands/Msixvc2/Msixvc2Command.cs @@ -0,0 +1,59 @@ +using System.Diagnostics; +using LibXboxOne.XVC2; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace XvdTool.Streaming.Commands.Msixvc2; + +internal abstract class Msixvc2Command : Command where T : Msixvc2CommandSettings +{ + protected Msixvc2File Msixvc2 = default!; + + private static Stream OpenStream(string path) + => path.StartsWith("http") + ? HttpFileStream.Open(path) + : File.OpenRead(path); + + private static bool CheckIfMsixvc2(string path) + { + using var stream = OpenStream(path); + var header = (stackalloc byte[4]); + stream.ReadExactly(header); + + return header.SequenceEqual("PK\x03\x04"u8); + } + + protected void Initialize(Msixvc2CommandSettings settings) + { + Debug.Assert(settings.XvcPath != null); + + var path = settings.XvcPath; + if (!CheckIfMsixvc2(path)) + { + throw new NotSupportedException("File is not a MSIXVC2."); + } + + Msixvc2 = new Msixvc2File(OpenStream(path)); + + var contentKey = settings.ContentKey != null ? Convert.FromBase64String(settings.ContentKey) : null; + var versionKey = settings.VersionKey != null ? Convert.FromBase64String(settings.VersionKey) : null; + Msixvc2.SubmitKeys(contentKey, versionKey); + + try + { + Msixvc2.LoadFileNames(); + } + catch (Exception) + { + // Attempt to load file names, the info command can be used without this. + } + } + + protected override ValidationResult Validate(CommandContext context, T settings) + { + if (settings.XvcPath != null && !settings.XvcPath.StartsWith("http") && !File.Exists(settings.XvcPath)) + return ValidationResult.Error("Provided file does not exist."); + + return ValidationResult.Success(); + } +} \ No newline at end of file diff --git a/XvdTool.Streaming/Commands/Msixvc2/Msixvc2CommandSettings.cs b/XvdTool.Streaming/Commands/Msixvc2/Msixvc2CommandSettings.cs new file mode 100644 index 0000000..4b91ef9 --- /dev/null +++ b/XvdTool.Streaming/Commands/Msixvc2/Msixvc2CommandSettings.cs @@ -0,0 +1,19 @@ +using Spectre.Console.Cli; +using System.ComponentModel; + +namespace XvdTool.Streaming.Commands.Msixvc2; + +internal abstract class Msixvc2CommandSettings : CommandSettings +{ + [Description("File Path / URL to the MSIXVC.")] + [CommandArgument(0, "")] + public string? XvcPath { get; init; } + + [Description("(Encrypted packages only) Base64-encoded content key")] + [CommandOption("-c|--content-key")] + public string? ContentKey { get; init; } + + [Description("(Encrypted packages only, currently unused) Base64-encoded version key")] + [CommandOption("-v|--version-key")] + public string? VersionKey { get; init; } +} \ No newline at end of file diff --git a/XvdTool.Streaming/Commands/VerifyCommand.cs b/XvdTool.Streaming/Commands/VerifyCommand.cs index 1b15dbd..37b140f 100644 --- a/XvdTool.Streaming/Commands/VerifyCommand.cs +++ b/XvdTool.Streaming/Commands/VerifyCommand.cs @@ -8,7 +8,7 @@ internal sealed class VerifyCommand : XvdCommand { public sealed class Settings : XvdCommandSettings; - public override int Execute(CommandContext context, Settings settings) + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellationToken) { Initialize(settings, requiresWriting: false); @@ -26,7 +26,7 @@ public override int Execute(CommandContext context, Settings settings) return 0; } - public override ValidationResult Validate(CommandContext context, Settings settings) + protected override ValidationResult Validate(CommandContext context, Settings settings) { base.Validate(context, settings); diff --git a/XvdTool.Streaming/Commands/XvdCommand.cs b/XvdTool.Streaming/Commands/XvdCommand.cs index dd8547d..8aa11db 100644 --- a/XvdTool.Streaming/Commands/XvdCommand.cs +++ b/XvdTool.Streaming/Commands/XvdCommand.cs @@ -21,7 +21,7 @@ protected void Initialize(XvdCommandSettings settings, bool requiresWriting) XvdFile.Parse(); } - public override ValidationResult Validate(CommandContext context, T settings) + protected override ValidationResult Validate(CommandContext context, T settings) { if (settings.XvcPath != null && !settings.XvcPath.StartsWith("http") && !File.Exists(settings.XvcPath)) return ValidationResult.Error("Provided file does not exist."); diff --git a/XvdTool.Streaming/Program.cs b/XvdTool.Streaming/Program.cs index 67f1bd2..aaabd51 100644 --- a/XvdTool.Streaming/Program.cs +++ b/XvdTool.Streaming/Program.cs @@ -36,6 +36,21 @@ static void Main(string[] args) .WithDescription("Extracts an embedded XVD from a given file.") .WithExample("extract-embedded-xvd", "c:/file.xvc") .WithExample("extract-embedded-xvd", "https://assets1.xboxlive.com/..."); + + config.AddBranch("msixvc2", msixvc2 => + { + msixvc2.AddCommand("info") + .WithDescription("Prints information about a given MSIXVC2.") + .WithExample("info", "c:/file.msixvc") + .WithExample("info", "c:/file.msixvc", "-o log.txt") + .WithExample("info", "https://assets1.xboxlive.com/..."); + + msixvc2.AddCommand("extract") + .WithDescription("Decrypts and extracts the files contained in a given MSIXVC2.") + .WithExample("extract", "c:/file.msixvc") + .WithExample("extract", "c:/file.msixvc", "-o c:/output") + .WithExample("extract", "https://assets1.xboxlive.com/..."); + }); }); app.Run(args); diff --git a/XvdTool.Streaming/StreamedXvdFile.cs b/XvdTool.Streaming/StreamedXvdFile.cs index 36bbb17..a3a3075 100644 --- a/XvdTool.Streaming/StreamedXvdFile.cs +++ b/XvdTool.Streaming/StreamedXvdFile.cs @@ -277,7 +277,7 @@ private void ParseNtfsPartition() if (partitionTable == null) { - ConsoleLogger.WriteErrLine("Failed to drive contents as either GPT or MBR."); + ConsoleLogger.WriteErrLine("Failed to parse drive contents as either GPT or MBR."); return; } diff --git a/XvdTool.Streaming/XvdTool.Streaming.csproj b/XvdTool.Streaming/XvdTool.Streaming.csproj index 3c710d5..8d7cd91 100644 --- a/XvdTool.Streaming/XvdTool.Streaming.csproj +++ b/XvdTool.Streaming/XvdTool.Streaming.csproj @@ -2,16 +2,16 @@ Exe - net9.0 + net10.0 enable enable preview - - - + + +