diff --git a/src/LibHac/FsSystem/AesCtrCounterExtendedStorage.cs b/src/LibHac/FsSystem/AesCtrCounterExtendedStorage.cs
new file mode 100644
index 00000000..578ddd35
--- /dev/null
+++ b/src/LibHac/FsSystem/AesCtrCounterExtendedStorage.cs
@@ -0,0 +1,476 @@
+using System;
+using System.Buffers.Binary;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using LibHac.Common;
+using LibHac.Common.FixedArrays;
+using LibHac.Crypto;
+using LibHac.Diag;
+using LibHac.Fs;
+using LibHac.Util;
+
+namespace LibHac.FsSystem;
+
+///
+/// Reads from an encrypted with AES-CTR-128 using a table of counters.
+///
+/// The base data used for this storage comes with a table of ranges and counter values that are used
+/// to decrypt each range. This encryption scheme is used for encrypting content updates so that no counter values
+/// are ever reused.
+/// Based on FS 13.1.0 (nnSdk 13.4.0)
+public class AesCtrCounterExtendedStorage : IStorage
+{
+ public delegate Result DecryptFunction(Span destination, int index, ReadOnlySpan encryptedKey,
+ ReadOnlySpan iv, ReadOnlySpan source);
+
+ public interface IDecryptor : IDisposable
+ {
+ Result Decrypt(Span destination, ReadOnlySpan encryptedKey, ReadOnlySpan iv);
+ bool HasExternalDecryptionKey();
+ }
+
+ public struct Entry
+ {
+ public Array8 Offset;
+ public int Reserved;
+ public int Generation;
+
+ public void SetOffset(long value)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(Offset.Items, value);
+ }
+
+ public readonly long GetOffset()
+ {
+ return BinaryPrimitives.ReadInt64LittleEndian(Offset.ItemsRo);
+ }
+ }
+
+ public static readonly int BlockSize = Aes.BlockSize;
+ public static readonly int KeySize = Aes.KeySize128;
+ public static readonly int IvSize = Aes.BlockSize;
+ public static readonly int NodeSize = 1024 * 16;
+
+ private BucketTree _table;
+ private ValueSubStorage _dataStorage;
+ private Array16 _key;
+ private uint _secureValue;
+ private long _counterOffset;
+ private UniqueRef _decryptor;
+
+ public static long QueryHeaderStorageSize()
+ {
+ return BucketTree.QueryHeaderStorageSize();
+ }
+
+ public static long QueryNodeStorageSize(int entryCount)
+ {
+ return BucketTree.QueryNodeStorageSize(NodeSize, Unsafe.SizeOf(), entryCount);
+ }
+
+ public static long QueryEntryStorageSize(int entryCount)
+ {
+ return BucketTree.QueryEntryStorageSize(NodeSize, Unsafe.SizeOf(), entryCount);
+ }
+
+ public static Result CreateExternalDecryptor(ref UniqueRef outDecryptor,
+ DecryptFunction decryptFunction, int keyIndex)
+ {
+ using var decryptor = new UniqueRef(new ExternalDecryptor(decryptFunction, keyIndex));
+
+ if (!decryptor.HasValue)
+ return ResultFs.AllocationMemoryFailedInAesCtrCounterExtendedStorageA.Log();
+
+ outDecryptor.Set(ref decryptor.Ref());
+ return Result.Success;
+ }
+
+ public static Result CreateSoftwareDecryptor(ref UniqueRef outDecryptor)
+ {
+ using var decryptor = new UniqueRef(new SoftwareDecryptor());
+
+ if (!decryptor.HasValue)
+ return ResultFs.AllocationMemoryFailedInAesCtrCounterExtendedStorageA.Log();
+
+ outDecryptor.Set(ref decryptor.Ref());
+ return Result.Success;
+ }
+
+ public AesCtrCounterExtendedStorage()
+ {
+ _table = new BucketTree();
+ }
+
+ public override void Dispose()
+ {
+ FinalizeObject();
+
+ _decryptor.Destroy();
+ _dataStorage.Dispose();
+ _table.Dispose();
+
+ base.Dispose();
+ }
+
+ public bool IsInitialized()
+ {
+ return _table.IsInitialized();
+ }
+
+ // ReSharper disable once UnusedMember.Local
+ private Result Initialize(MemoryResource allocator, ReadOnlySpan key, uint secureValue,
+ in ValueSubStorage dataStorage, in ValueSubStorage tableStorage)
+ {
+ Unsafe.SkipInit(out BucketTree.Header header);
+
+ Result rc = tableStorage.Read(0, SpanHelpers.AsByteSpan(ref header));
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = header.Verify();
+ if (rc.IsFailure()) return rc.Miss();
+
+ long nodeStorageSize = QueryNodeStorageSize(header.EntryCount);
+ long entryStorageSize = QueryEntryStorageSize(header.EntryCount);
+ long nodeStorageOffset = QueryHeaderStorageSize();
+ long entryStorageOffset = nodeStorageOffset + nodeStorageSize;
+
+ using var decryptor = new UniqueRef();
+ rc = CreateSoftwareDecryptor(ref decryptor.Ref());
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = tableStorage.GetSize(out long storageSize);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (nodeStorageOffset + nodeStorageSize + entryStorageSize > storageSize)
+ return ResultFs.InvalidAesCtrCounterExtendedMetaStorageSize.Log();
+
+ using var entryStorage = new ValueSubStorage(in tableStorage, entryStorageOffset, entryStorageSize);
+ using var nodeStorage = new ValueSubStorage(in tableStorage, nodeStorageOffset, nodeStorageSize);
+
+ return Initialize(allocator, key, secureValue, counterOffset: 0, in dataStorage, in nodeStorage,
+ in entryStorage, header.EntryCount, ref decryptor.Ref());
+ }
+
+ public Result Initialize(MemoryResource allocator, ReadOnlySpan key, uint secureValue, long counterOffset,
+ in ValueSubStorage dataStorage, in ValueSubStorage nodeStorage, in ValueSubStorage entryStorage, int entryCount,
+ ref UniqueRef decryptor)
+ {
+ Assert.SdkRequiresEqual(key.Length, KeySize);
+ Assert.SdkRequiresGreaterEqual(counterOffset, 0);
+ Assert.SdkRequiresNotNull(in decryptor);
+
+ Result rc = _table.Initialize(allocator, in nodeStorage, in entryStorage, NodeSize, Unsafe.SizeOf(),
+ entryCount);
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = dataStorage.GetSize(out long dataStorageSize);
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = _table.GetOffsets(out BucketTree.Offsets offsets);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (offsets.EndOffset > dataStorageSize)
+ return ResultFs.InvalidAesCtrCounterExtendedDataStorageSize.Log();
+
+ _dataStorage.Set(in dataStorage);
+ key.CopyTo(_key.Items);
+ _secureValue = secureValue;
+ _counterOffset = counterOffset;
+ _decryptor.Set(ref decryptor);
+
+ return Result.Success;
+ }
+
+ public void FinalizeObject()
+ {
+ if (IsInitialized())
+ {
+ _table.FinalizeObject();
+
+ using var emptyStorage = new ValueSubStorage();
+ _dataStorage.Set(in emptyStorage);
+ }
+ }
+
+ public override Result Read(long offset, Span destination)
+ {
+ Assert.SdkRequiresLessEqual(0, offset);
+ Assert.SdkRequires(IsInitialized());
+
+ if (destination.Length == 0)
+ return Result.Success;
+
+ // Reads cannot contain any partial blocks.
+ if (!Alignment.IsAlignedPow2(offset, (uint)BlockSize))
+ return ResultFs.InvalidOffset.Log();
+
+ if (!Alignment.IsAlignedPow2(destination.Length, (uint)BlockSize))
+ return ResultFs.InvalidSize.Log();
+
+ // Ensure the the requested range is within the bounds of the table.
+ Result rc = _table.GetOffsets(out BucketTree.Offsets offsets);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (!offsets.IsInclude(offset, destination.Length))
+ return ResultFs.OutOfRange.Log();
+
+ // Fill the destination buffer with the encrypted data.
+ rc = _dataStorage.Read(offset, destination);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Temporarily increase our thread priority.
+ using var changePriority = new ScopedThreadPriorityChanger(1, ScopedThreadPriorityChanger.Mode.Relative);
+
+ // Find the entry in the table that contains our current offset.
+ using var visitor = new BucketTree.Visitor();
+ rc = _table.Find(ref visitor.Ref, offset);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Verify that the entry's offset is aligned to an AES block and within the bounds of the table.
+ long entryOffset = visitor.Get().GetOffset();
+ if (!Alignment.IsAlignedPow2(entryOffset, (uint)BlockSize) || entryOffset < 0 ||
+ !offsets.IsInclude(entryOffset))
+ {
+ return ResultFs.InvalidAesCtrCounterExtendedEntryOffset.Log();
+ }
+
+ Span currentData = destination;
+ long currentOffset = offset;
+ long endOffset = offset + destination.Length;
+
+ while (currentOffset < endOffset)
+ {
+ // Get the current entry and validate its offset.
+ // No need to check its alignment since it was already checked elsewhere.
+ var entry = visitor.Get();
+
+ long entryStartOffset = entry.GetOffset();
+ if (entryStartOffset > currentOffset)
+ return ResultFs.InvalidAesCtrCounterExtendedEntryOffset.Log();
+
+ // Get current entry's end offset.
+ long entryEndOffset;
+ if (visitor.CanMoveNext())
+ {
+ // Advance to the next entry so we know where our current entry ends.
+ // The current entry's end offset is the next entry's start offset.
+ rc = visitor.MoveNext();
+ if (rc.IsFailure()) return rc.Miss();
+
+ entryEndOffset = visitor.Get().GetOffset();
+ if (!offsets.IsInclude(entryEndOffset))
+ return ResultFs.InvalidAesCtrCounterExtendedEntryOffset.Log();
+ }
+ else
+ {
+ // If this is the last entry its end offset is the table's end offset.
+ entryEndOffset = offsets.EndOffset;
+ }
+
+ if (!Alignment.IsAlignedPow2((ulong)entryEndOffset, (uint)BlockSize) || currentOffset >= entryEndOffset)
+ return ResultFs.InvalidAesCtrCounterExtendedEntryOffset.Log();
+
+ // Get the part of the entry that contains the data we read.
+ long dataOffset = currentOffset - entryStartOffset;
+ long dataSize = entryEndOffset - currentOffset;
+ Assert.SdkLess(0, dataSize);
+
+ long remainingSize = endOffset - currentOffset;
+ long readSize = Math.Min(remainingSize, dataSize);
+ Assert.SdkLessEqual(readSize, destination.Length);
+
+ // Create the counter for the first data block we're decrypting.
+ long counterOffset = _counterOffset + entryStartOffset + dataOffset;
+ var upperIv = new NcaAesCtrUpperIv
+ {
+ Generation = (uint)entry.Generation,
+ SecureValue = _secureValue
+ };
+
+ Unsafe.SkipInit(out Array16 counter);
+ AesCtrStorage.MakeIv(counter.Items, upperIv.Value, counterOffset);
+
+ // Decrypt the data from the current entry.
+ rc = _decryptor.Get.Decrypt(currentData.Slice(0, (int)dataSize), _key, counter);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Advance the current offsets.
+ currentData = currentData.Slice((int)dataSize);
+ currentOffset -= dataSize;
+ }
+
+ return Result.Success;
+ }
+
+ public override Result Write(long offset, ReadOnlySpan source)
+ {
+ return ResultFs.UnsupportedWriteForAesCtrCounterExtendedStorage.Log();
+ }
+
+ public override Result Flush()
+ {
+ return Result.Success;
+ }
+
+ public override Result GetSize(out long size)
+ {
+ UnsafeHelpers.SkipParamInit(out size);
+
+ Result rc = _table.GetOffsets(out BucketTree.Offsets offsets);
+ if (rc.IsFailure()) return rc.Miss();
+
+ size = offsets.EndOffset;
+ return Result.Success;
+ }
+
+ public override Result SetSize(long size)
+ {
+ return ResultFs.UnsupportedSetSizeForAesCtrCounterExtendedStorage.Log();
+ }
+
+ public override Result OperateRange(Span outBuffer, OperationId operationId, long offset, long size,
+ ReadOnlySpan inBuffer)
+ {
+ switch (operationId)
+ {
+ case OperationId.InvalidateCache:
+ {
+ Assert.SdkRequires(IsInitialized());
+
+ // Invalidate the table's cache.
+ Result rc = _table.InvalidateCache();
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Invalidate the data storage's cache.
+ rc = _dataStorage.OperateRange(OperationId.InvalidateCache, offset: 0, size: long.MaxValue);
+ if (rc.IsFailure()) return rc.Miss();
+
+ return Result.Success;
+ }
+ case OperationId.QueryRange:
+ {
+ Assert.SdkRequiresLessEqual(0, offset);
+ Assert.SdkRequires(IsInitialized());
+
+ if (outBuffer.Length != Unsafe.SizeOf())
+ return ResultFs.InvalidSize.Log();
+
+ ref QueryRangeInfo outInfo =
+ ref Unsafe.As(ref MemoryMarshal.GetReference(outBuffer));
+
+ if (size == 0)
+ {
+ outInfo.Clear();
+ return Result.Success;
+ }
+
+ if (!Alignment.IsAlignedPow2(offset, (uint)BlockSize))
+ return ResultFs.InvalidOffset.Log();
+
+ if (!Alignment.IsAlignedPow2(size, (uint)BlockSize))
+ return ResultFs.InvalidSize.Log();
+
+ // Ensure the storage contains the provided offset and size.
+ Result rc = _table.GetOffsets(out BucketTree.Offsets offsets);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (!offsets.IsInclude(offset, size))
+ return ResultFs.OutOfRange.Log();
+
+ // Get the QueryRangeInfo of the underlying data storage.
+ rc = _dataStorage.OperateRange(outBuffer, operationId, offset, size, inBuffer);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Set the key type in the info and merge it with the output info.
+ Unsafe.SkipInit(out QueryRangeInfo info);
+ info.Clear();
+ info.AesCtrKeyType = (int)(_decryptor.Get.HasExternalDecryptionKey()
+ ? QueryRangeInfo.AesCtrKeyTypeFlag.ExternalKeyForHardwareAes
+ : QueryRangeInfo.AesCtrKeyTypeFlag.InternalKeyForHardwareAes);
+
+ outInfo.Merge(in info);
+
+ return Result.Success;
+ }
+
+ default:
+ return ResultFs.UnsupportedOperateRangeForAesCtrCounterExtendedStorage.Log();
+ }
+ }
+
+ private class ExternalDecryptor : IDecryptor
+ {
+ private DecryptFunction _decryptFunction;
+ private int _keyIndex;
+
+ public ExternalDecryptor(DecryptFunction decryptFunction, int keyIndex)
+ {
+ Assert.SdkRequiresNotNull(decryptFunction);
+
+ _decryptFunction = decryptFunction;
+ _keyIndex = keyIndex;
+ }
+
+ public void Dispose() { }
+
+ public Result Decrypt(Span destination, ReadOnlySpan encryptedKey, ReadOnlySpan iv)
+ {
+ Assert.SdkRequiresEqual(encryptedKey.Length, KeySize);
+ Assert.SdkRequiresEqual(iv.Length, IvSize);
+
+ Unsafe.SkipInit(out Array16 counter);
+ iv.CopyTo(counter.Items);
+
+ int remainingSize = destination.Length;
+ int currentOffset = 0;
+
+ // Todo: Align the buffer to the block size
+ using var pooledBuffer = new PooledBuffer();
+ pooledBuffer.AllocateParticularlyLarge(destination.Length, BlockSize);
+ Assert.SdkAssert(pooledBuffer.GetSize() > 0);
+
+ while (remainingSize > 0)
+ {
+ int currentSize = Math.Min(pooledBuffer.GetSize(), remainingSize);
+ Span dstBuffer = destination.Slice(currentOffset, currentSize);
+ Span workBuffer = pooledBuffer.GetBuffer().Slice(0, currentSize);
+
+ Result rc = _decryptFunction(workBuffer, _keyIndex, encryptedKey, counter, dstBuffer);
+ if (rc.IsFailure()) return rc.Miss();
+
+ workBuffer.CopyTo(dstBuffer);
+
+ currentOffset += currentSize;
+ remainingSize -= currentSize;
+
+ if (remainingSize > 0)
+ {
+ Utility.AddCounter(counter.Items, (uint)currentSize / (uint)BlockSize);
+ }
+ }
+
+ return Result.Success;
+ }
+
+ public bool HasExternalDecryptionKey()
+ {
+ return _keyIndex < 0;
+ }
+ }
+
+ private class SoftwareDecryptor : IDecryptor
+ {
+ public void Dispose() { }
+
+ public Result Decrypt(Span destination, ReadOnlySpan key, ReadOnlySpan iv)
+ {
+ Aes.DecryptCtr128(destination, destination, key, iv);
+ return Result.Success;
+ }
+
+ public bool HasExternalDecryptionKey()
+ {
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/LibHac.Tests/FsSystem/TypeLayoutTests.cs b/tests/LibHac.Tests/FsSystem/TypeLayoutTests.cs
index 5d0746ea..092c1986 100644
--- a/tests/LibHac.Tests/FsSystem/TypeLayoutTests.cs
+++ b/tests/LibHac.Tests/FsSystem/TypeLayoutTests.cs
@@ -247,4 +247,16 @@ public class TypeLayoutTests
Assert.Equal(NcaCryptoConfiguration.KeyAreaEncryptionKeyCount + 3, (int)KeyType.SaveDataSeedUniqueMac);
Assert.Equal(NcaCryptoConfiguration.KeyAreaEncryptionKeyCount + 4, (int)KeyType.SaveDataTransferMac);
}
+
+ [Fact]
+ public static void AesCtrCounterExtendedStorage_Entry_Layout()
+ {
+ AesCtrCounterExtendedStorage.Entry s = default;
+
+ Assert.Equal(0x10, Unsafe.SizeOf());
+
+ Assert.Equal(0x0, GetOffset(in s, in s.Offset));
+ Assert.Equal(0x8, GetOffset(in s, in s.Reserved));
+ Assert.Equal(0xC, GetOffset(in s, in s.Generation));
+ }
}
\ No newline at end of file