Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
- Async byte data crossing awaits should use `Memory<byte>`, `ReadOnlyMemory<byte>`, or `IMemoryOwner<byte>`, not `Span<byte>`.
- Avoid `.ToArray()` and LINQ on byte data unless data must escape the current scope.
- Always zero PINs, PUKs, keys, SCP material, and secret-derived buffers with `CryptographicOperations.ZeroMemory()`.
- Classify YubiKey-returned data by semantics: device metadata/status does not need special zeroing; authentication material, token material, decrypted plaintext, and secret-derived output does.
- Use `CryptographicOperations.FixedTimeEquals` for secret-derived comparisons; do not use `SequenceEqual` on secrets.
- Never store a privately cloned sensitive `byte[]` in a struct; struct copies keep references you cannot reliably zero. Use a sealed disposable class for owned sensitive buffers.
- Logging is static via `YubiKitLogging.CreateLogger<T>()`; do not add SDK constructors that require `ILogger`/`ILoggerFactory`.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ These are the always-loaded mandates. Each section ends with a JIT pointer to de

**Security:**
- ✅ ALWAYS zero sensitive data: `CryptographicOperations.ZeroMemory()`
- ✅ Classify byte data by semantic meaning, not by direction. YubiKey-returned device metadata/status (device info TLVs, firmware version, capabilities, form factor, public status) does not require special zeroing beyond normal ownership/disposal. YubiKey-returned authentication material, token material, decrypted plaintext, or secret-derived output does.
- ✅ ALWAYS dispose crypto objects: `using var aes = Aes.Create()`
- ✅ ALWAYS use `CryptographicOperations.FixedTimeEquals` for secret-derived comparisons
- ❌ NEVER log PINs, keys, or sensitive payloads
Expand Down
43 changes: 34 additions & 9 deletions src/Core/src/Devices/DeviceInfoReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,47 @@ public static async Task<DeviceInfo> ReadAsync(
byte page = 0;
var allPagesTlvs = new List<Tlv>();

var hasMoreData = true;
while (hasMoreData)
var remainingPages = 1;
while (remainingPages > 0)
{
--remainingPages;
var pageTlvs = await ReadPageAsync(protocol, page, cancellationToken).ConfigureAwait(false);
allPagesTlvs.AddRange(pageTlvs);

var moreData = pageTlvs.SingleOrDefault(t => t.Tag == TagMoreDeviceInfo);
if (moreData is null)
break;
Tlv? moreData = null;
foreach (var tlv in pageTlvs)
{
if (tlv.Tag != TagMoreDeviceInfo)
{
continue;
}

if (moreData is not null)
{
throw new BadResponseException($"Duplicate more-data tags in device info page {page}.");
}

moreData = tlv;
}

if (moreData is not null && moreData.Length > 0)
{
remainingPages = moreData.Value.Span[^1];
}

foreach (var tlv in pageTlvs)
{
if (tlv.Tag == TagMoreDeviceInfo)
{
continue;
}

allPagesTlvs.Add(tlv);
}

hasMoreData = moreData.Length == 1 && moreData.Value.Span[0] == 1;
++page;
}

using var allTlvs = new DisposableTlvList(allPagesTlvs);
return DeviceInfo.CreateFromTlvs([.. allTlvs], defaultVersion);
return DeviceInfo.CreateFromTlvs(allPagesTlvs, defaultVersion);
}

private static async Task<DisposableTlvList> ReadPageAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public async Task ReadAsync_SmartCardSinglePage_ParsesDeviceInfo()

Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
Assert.Equal([0x2A], info.ChallengeResponseTimeout.ToArray());
Assert.Equal([0], protocol.RequestedPages);
}

Expand All @@ -37,6 +38,50 @@ public async Task ReadAsync_SmartCardMoreData_ReadsNextPage()
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
public async Task ReadAsync_SmartCardMoreDataCount_ReadsRemainingPages()
{
var protocol = new FakeSmartCardProtocol(
BuildPage(new Tlv(0x10, [0x02])),
BuildPage(new Tlv(0x10, [0x01])),
BuildPage(CreateRequiredDeviceInfoTlvs()));

var info = await DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken);

Assert.Equal([0, 1, 2], protocol.RequestedPages);
Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
public async Task ReadAsync_SmartCardMoreDataCountOnFirstPage_ReadsRemainingPagesWithoutRepeatedMoreDataTag()
{
var protocol = new FakeSmartCardProtocol(
BuildPage(new Tlv(0x10, [0x02])),
BuildPage(new Tlv(0x1F, [0x00])),
BuildPage(CreateRequiredDeviceInfoTlvs()));

var info = await DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken);

Assert.Equal([0, 1, 2], protocol.RequestedPages);
Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
public async Task ReadAsync_SmartCardMoreDataCountZero_StopsAfterCurrentPage()
{
var protocol = new FakeSmartCardProtocol(
BuildPage([.. CreateRequiredDeviceInfoTlvs(), new Tlv(0x10, [0x00])]),
BuildPage(new Tlv(0x0A, [0x00])));

var info = await DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken);

Assert.Equal([0], protocol.RequestedPages);
Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
public async Task ReadAsync_SmartCardInvalidPageLength_ThrowsPageAwareBadResponse()
{
Expand All @@ -50,6 +95,33 @@ public async Task ReadAsync_SmartCardInvalidPageLength_ThrowsPageAwareBadRespons
Assert.Contains("actual 1", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ReadAsync_SmartCardDuplicateMoreDataTags_ThrowsPageAwareBadResponse()
{
var protocol = new FakeSmartCardProtocol(BuildPage(new Tlv(0x10, [0x01]), new Tlv(0x10, [0x01])));

var ex = await Assert.ThrowsAsync<BadResponseException>(
() => DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken));

Assert.Contains("Duplicate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("page 0", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ReadAsync_SmartCardDuplicateMoreDataTagsAfterAccumulatingTlvs_ThrowsPageAwareBadResponse()
{
var protocol = new FakeSmartCardProtocol(
BuildPage(new Tlv(0x1F, [0x00]), new Tlv(0x10, [0x01])),
BuildPage(new Tlv(0x10, [0x01]), new Tlv(0x10, [0x01])));

var ex = await Assert.ThrowsAsync<BadResponseException>(
() => DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken));

Assert.Equal([0, 1], protocol.RequestedPages);
Assert.Contains("Duplicate", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("page 1", ex.Message, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ReadAsync_FidoSinglePage_ParsesDeviceInfo()
{
Expand All @@ -62,6 +134,21 @@ public async Task ReadAsync_FidoSinglePage_ParsesDeviceInfo()
Assert.Equal([0], protocol.RequestedPages);
}

[Fact]
public async Task ReadAsync_FidoMoreDataCount_ReadsRemainingPages()
{
var protocol = new FakeFidoHidProtocol(
BuildPage(new Tlv(0x10, [0x02])),
BuildPage(new Tlv(0x1F, [0x00])),
BuildPage(CreateRequiredDeviceInfoTlvs()));

var info = await DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken);

Assert.Equal([0, 1, 2], protocol.RequestedPages);
Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
public async Task ReadAsync_OtpSinglePage_ParsesDeviceInfo()
{
Expand All @@ -72,6 +159,22 @@ public async Task ReadAsync_OtpSinglePage_ParsesDeviceInfo()

Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
Assert.Equal([0], protocol.RequestedPages);
}

[Fact]
public async Task ReadAsync_OtpMoreDataCount_ReadsRemainingPages()
{
var protocol = new FakeOtpHidProtocol(
BuildOtpFrame(BuildPage(new Tlv(0x10, [0x02]))),
BuildOtpFrame(BuildPage(new Tlv(0x1F, [0x00]))),
BuildOtpFrame(BuildPage(CreateRequiredDeviceInfoTlvs())));

var info = await DeviceInfoReader.ReadAsync(protocol, null, TestContext.Current.CancellationToken);

Assert.Equal([0, 1, 2], protocol.RequestedPages);
Assert.Equal(0x01020304, info.SerialNumber);
Assert.Equal(new FirmwareVersion(5, 7, 2), info.FirmwareVersion);
}

[Fact]
Expand Down Expand Up @@ -147,7 +250,7 @@ private static Tlv[] CreateRequiredDeviceInfoTlvs() =>
new(0x14, [0x00]),
new(0x15, [0x00]),
new(0x06, [0x00, 0x00]),
new(0x07, [0x00]),
new(0x07, [0x2A]),
new(0x08, [0x00]),
new(0x05, [0x05, 0x07, 0x02]),
new(0x02, [0x01, 0x02, 0x03, 0x04])
Expand Down Expand Up @@ -217,13 +320,18 @@ private sealed class FakeOtpHidProtocol(params byte[][] frames) : IOtpHidProtoco
{
private readonly Queue<byte[]> _frames = new(frames);

public List<byte> RequestedPages { get; } = [];

public FirmwareVersion? FirmwareVersion => null;

public Task<ReadOnlyMemory<byte>> SendAndReceiveAsync(
byte slot,
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken = default) =>
Task.FromResult<ReadOnlyMemory<byte>>(_frames.Dequeue());
CancellationToken cancellationToken = default)
{
RequestedPages.Add(data.Length == 0 ? (byte)0 : data.Span[0]);
return Task.FromResult<ReadOnlyMemory<byte>>(_frames.Dequeue());
}

public Task<ReadOnlyMemory<byte>> ReadStatusAsync(CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
Expand Down
Loading