Skip to content

security(core,fido2,piv): fix sensitive data handling#447

Merged
DennisDyallo merged 26 commits intoyubikit-appletsfrom
worktree-security-remediation
Apr 10, 2026
Merged

security(core,fido2,piv): fix sensitive data handling#447
DennisDyallo merged 26 commits intoyubikit-appletsfrom
worktree-security-remediation

Conversation

@DennisDyallo
Copy link
Copy Markdown
Collaborator

@DennisDyallo DennisDyallo commented Apr 3, 2026

Summary

  • Remove 38 `Console.WriteLine` statements from SCP implementation that unconditionally dumped session encryption keys (S-ENC, S-MAC, S-RMAC), cryptograms, MAC chains, and plaintext APDU data to stdout
  • Zero all sensitive buffers in FIDO2 ClientPin, PinUvAuthProtocol V1/V2, PivSession.Authentication, and PivSession.Crypto via try/finally with `CryptographicOperations.ZeroMemory()`
  • Implement IDisposable on ScpState with full disposal chain (PcscProtocolScp → ScpProcessor → ScpState) to zero MAC chain and dispose SessionKeys
  • Reduce OTP HID protocol logging from hex-dumping payloads/reports to byte-count-only metadata
  • Convert `ApduCommand` from `readonly record struct` to `sealed class` with `IDisposable` — closes the T1 API limitation where the constructor's internal `.ToArray()` clone was unreachable by callers; sensitive APDUs (cryptograms, PIN hashes, key material) can now use `using var` or `ZeroData()` to zero the internal copy immediately after transmission

Post-Review Fixes (commits 2d6f2b4c0429bb)

T10 — IDisposable not disposed on exception/failure paths

  • ScpInitializer.InitScp03Async: Added inner try/catch — if EXTERNAL AUTHENTICATE throws or returns non-success SW, `ScpProcessor.Dispose()` is called before rethrowing. Prevents session key leak to GC finalizer on auth failure.

T11 — Conditional buffer allocation not covered by ZeroMemory

  • ScpProcessor.TransmitAsync: Moved `encryptedData` declaration before the try block so the finally can zero it. Previously declared inside `if (encrypt)` — encrypted command copy was never zeroed.

T12 — Dispose() zeros caller-provided buffer (ownership violation)

  • CredentialManagement.Dispose(): Removed `MemoryMarshal.TryGetArray` + `ZeroMemory` entirely. `_pinUvAuthToken` is caller-provided; zeroing it in `Dispose()` silently corrupts the caller's buffer. Ownership comment added.

T1 — preparedData and ApduCommand internal clones

  • PivSession.Crypto.SignOrDecryptCoreAsync: Added `ZeroMemory(preparedData)` in the finally block — the source buffer for the APDU payload was not zeroed after encoding.
  • ApduCommand struct → class (architectural fix): Converted `ApduCommand` from `readonly record struct` to `sealed class : IDisposable`. The constructor's `data?.ToArray()` now stores into a `private byte[] _dataBytes` field that `ZeroData()`/`Dispose()` can clear. Callers pass sensitive APDUs with `using var command = new ApduCommand(...)` and the internal copy is zeroed on scope exit. `ScpProcessor` calls `scpCommand?.ZeroData()` and `finalCommand?.ZeroData()` in its finally block; `ScpInitializer` uses `using var authCommand`. Migration: `ApduCommand?` parameters no longer use `.Value` (was Nullable, now nullable reference).

Documentation and acknowledgements

  • `DisposableBufferHandle`: XML ownership contract — this type takes ownership of the provided buffer and zeros it on Dispose
  • `IOpenPgpSession.VerifyPinAsync` / `IOathSession.DeriveKey`: `` breaking-change notes for `string → ReadOnlyMemory`
  • `CLAUDE.md`: Added architectural rule — types carrying heap-allocated sensitive byte payloads must be `sealed class` with `IDisposable`, not `struct` (struct copies cannot be comprehensively zeroed)

Additional Copilot review fixes

  • Removed `.ToArray()` PIN copies in FIDO2 examples (13 sites across 6 files)
  • `FidoPinHelper`/`OpenPgpCommand`: direct `Encoding.UTF8.GetBytes(string, Span)` to pooled buffer
  • `OtpHidProtocol`: all `Convert.ToHexString(...)` log calls replaced with byte-count metadata

Security Impact

Finding Severity Status
Console.WriteLine dumps SCP session keys to stdout CRITICAL ✅ Fixed
LogTrace dumps plaintext APDU data as hex CRITICAL ✅ Fixed
FIDO2 PIN bytes never zeroed after use HIGH ✅ Fixed
.ToArray() creates untracked key copies (5 files) HIGH ✅ Fixed
ScpState holds SessionKeys without IDisposable HIGH ✅ Fixed
ScpProcessor not disposed on auth failure (T10) HIGH ✅ Fixed
encryptedData not zeroed on encrypt path (T11) HIGH ✅ Fixed
preparedData byte[] not zeroed in SignOrDecrypt (T1) HIGH ✅ Fixed
ApduCommand internal .ToArray() clone unreachable by callers (T1 API) HIGH ✅ Fixed — struct→class, ZeroData()/IDisposable
CredentialManagement.Dispose() zeroes caller-provided token (T12) MEDIUM ✅ Fixed
.ToArray() PIN copies in FIDO2 examples (13 sites) MEDIUM ✅ Fixed
FidoPinHelper/OpenPgpCommand: untracked UTF8.GetBytes temp byte[] MEDIUM ✅ Fixed
OTP HID hex-dump logs LOW ✅ Fixed

Files Changed

File Change
`src/Core/src/SmartCard/ApduCommand.cs` `readonly record struct` → `sealed class : IDisposable`; `ZeroData()`; `Data` backed by `private byte[]`
`src/Core/src/SmartCard/ApduException.cs` Remove `.Value` nullable-struct dereference (now reference type)
`src/Core/src/SmartCard/ChainedApduTransmitter.cs` Replace `record with` expression with explicit constructor
`src/Core/src/SmartCard/Scp/ScpState.Scp03.cs` Remove Console.WriteLine; dispose sessionKeys on failure; remove keys from exception message
`src/Core/src/SmartCard/Scp/ScpState.cs` Remove Console.WriteLine; add IDisposable; zero mac chain
`src/Core/src/SmartCard/Scp/ScpProcessor.cs` Remove Console.WriteLine; add IDisposable; zero encryptedData (T11); ZeroData() on intermediate ApduCommands
`src/Core/src/SmartCard/Scp/ScpInitializer.cs` Dispose ScpProcessor on auth failure (T10); `using var authCommand`
`src/Core/src/SmartCard/Scp/StaticKeys.cs` Remove Console.WriteLine
`src/Core/src/SmartCard/Scp/PcscProtocolScp.cs` Dispose ScpProcessor in disposal chain
`src/Core/src/Hid/Otp/OtpHidProtocol.cs` Replace hex-dump logs with byte-count metadata
`src/Core/src/Utils/DisposableBufferHandle.cs` XML ownership contract documentation
`src/Fido2/src/Pin/ClientPin.cs` Zero PIN bytes and intermediates in finally blocks
`src/Fido2/src/Pin/PinUvAuthProtocolV1.cs` Zero .ToArray() key/plaintext copies
`src/Fido2/src/Pin/PinUvAuthProtocolV2.cs` Zero iv and ciphertext temp arrays
`src/Fido2/src/CredentialManagement/CredentialManagement.cs` Remove Dispose() ZeroMemory (T12 fix)
`src/Piv/src/PivSession.Authentication.cs` Zero .ToArray() AES key copies
`src/Piv/src/PivSession.Crypto.cs` Zero preparedData in finally (T1 fix)
`src/OpenPgp/src/IOpenPgpSession.cs` Add breaking-change `` for PIN parameter change
`src/Oath/src/IOathSession.cs` Add breaking-change `` for password parameter change
`CLAUDE.md` Add architectural rule: sensitive payload types must be class+IDisposable, not struct
`src/Fido2/examples/FidoTool/` (6 files) Remove .ToArray() PIN copies (13 sites)
`src/Fido2/examples/FidoTool/Cli/Prompts/FidoPinHelper.cs` Direct encode to pooled buffer
`src/OpenPgp/examples/OpenPgpTool/Cli/Commands/OpenPgpCommand.cs` Direct encode to pooled buffer

Test plan

  • `dotnet build.cs build` — 0 errors
  • `dotnet build.cs test` — all unit test projects passing
  • `./scripts/security-audit.sh` — exit code 0, all 9 mechanical taxonomy checks clean
  • All 31 Copilot review threads resolved
  • Integration test with SCP03 on YubiKey hardware (requires device)

🤖 Generated with Claude Code

…, and key operations

Remove 38 Console.WriteLine statements from SCP implementation that unconditionally
dumped session encryption keys (S-ENC, S-MAC, S-RMAC), cryptograms, MAC chains, and
plaintext APDU data to stdout. Replace LogTrace plaintext hex dumps with length-only
logging. Zero PIN bytes in FIDO2 ClientPin PadPin/ComputePinHash after use. Zero all
intermediate buffers (pinHashEnc, newPinEnc, message, pinHash) in ClientPin async
method finally blocks. Capture and zero .ToArray() key copies in PinUvAuthProtocol
V1/V2 Encrypt/Decrypt and PivSession.Authentication DecryptBlock/EncryptBlock.
Implement IDisposable on ScpState to zero MAC chain and dispose SessionKeys, with
disposal chain through ScpProcessor and PcscProtocolScp. Reduce OTP HID protocol
logger hex dumps to length-only metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens sensitive-data handling across Core (SCP + OTP HID), FIDO2 PIN flows, and PIV authentication by removing key/plaintext logging and ensuring key material and transient buffers are zeroed/disposed.

Changes:

  • Removes insecure debug output (notably SCP session keys / APDUs) and reduces OTP HID logging verbosity.
  • Adds/extends zeroization patterns (try/finally + CryptographicOperations.ZeroMemory) to reduce lingering key/plaintext copies created via .ToArray().
  • Introduces disposal plumbing for SCP state (ScpState/ScpProcessor and protocol wrapper) to clear session keys and MAC chain on teardown.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
src/Core/src/SmartCard/Scp/ScpState.Scp03.cs Removes SCP03 debug prints; retains SCP03 init/verification logic.
src/Core/src/SmartCard/Scp/ScpState.cs Adds IDisposable, removes plaintext/APDU logging, and zeroes temporary AES key arrays.
src/Core/src/SmartCard/Scp/ScpProcessor.cs Removes debug prints and disposes SCP state via IDisposable.
src/Core/src/SmartCard/Scp/StaticKeys.cs Removes key-derivation debug output.
src/Core/src/SmartCard/Scp/PcscProtocolScp.cs Disposes SCP processor during protocol disposal.
src/Core/src/Hid/Otp/OtpHidProtocol.cs Replaces some hex-dump logs with byte-count metadata.
src/Fido2/src/Pin/ClientPin.cs Zeroes PIN bytes, PIN hashes, encrypted PIN buffers, and related intermediates.
src/Fido2/src/Pin/PinUvAuthProtocolV1.cs Zeroes temporary key/plaintext copies created for AES operations.
src/Fido2/src/Pin/PinUvAuthProtocolV2.cs Same as V1 plus key/IV handling for IV-prefixed ciphertext format.
src/Piv/src/PivSession.Authentication.cs Zeroes temporary AES key arrays created via .ToArray() during block operations.

Comment thread src/Core/src/SmartCard/Scp/ScpState.cs Outdated
Comment thread src/Core/src/SmartCard/Scp/ScpState.Scp03.cs Outdated
Comment thread src/Fido2/src/Pin/ClientPin.cs
Comment thread src/Fido2/src/Pin/ClientPin.cs
Comment thread src/Fido2/src/Pin/PinUvAuthProtocolV2.cs
Comment thread src/Core/src/Hid/Otp/OtpHidProtocol.cs
Comment thread src/Core/src/SmartCard/Scp/ScpState.Scp03.cs
Comment thread src/Core/src/SmartCard/Scp/PcscProtocolScp.cs
DennisDyallo and others added 7 commits April 9, 2026 10:54
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…tterns

Address Copilot PR review findings plus 3 additional issues from codebase scan:
- Zero pinUvAuthParam after use in ClientPin SetPin/ChangePin
- Zero IV and ciphertext buffers in PinUvAuthProtocolV2.Encrypt
- Replace hex-dump logging with byte-count metadata in OtpHidProtocol
- Dispose sessionKeys on SCP03 cryptogram verification failure
- Zero SCP command data, MAC arrays in ScpProcessor after transmission
- Replace List<byte> with direct byte[] + ZeroMemory for PIV management key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…erns

- Replace List<byte> with pre-allocated byte[] + ZeroMemory in PIV crypto/key operations
- Add ZeroMemory for OATH _salt/_challenge fields on reassignment and disposal
- Implement IDisposable on CredentialManagement to zero pinUvAuthToken
- Zero hostCryptogram in ScpInitializer after EXTERNAL AUTHENTICATE

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eadOnlyMemory<byte>

Eliminate immutable string parameters for sensitive credentials across all modules:
- FIDO2 ClientPin: SetPin, ChangePin, GetPinToken accept ReadOnlyMemory<byte>
- OpenPGP: all PIN/admin/reset methods accept ReadOnlyMemory<byte>
- OpenPGP KDF: Process accepts ReadOnlySpan<byte>, removes Encoding.UTF8.GetBytes
- OATH: DeriveKey accepts ReadOnlyMemory<byte>
- Update all examples and tests to pass byte arrays

Strings are immutable in .NET and cannot be securely wiped from memory.
ReadOnlyMemory<byte> allows callers to zero buffers after use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…assword input

Replace string-based PIN/password prompts with ISecureCredentialReader pattern:
- FIDO2: new FidoPinHelper using ConsoleCredentialReader, all menus updated
- OpenPGP: base command GetCredential returns IMemoryOwner<byte>, 10 commands updated
- OATH: SessionHelper accepts IMemoryOwner<byte>, AccessCommand uses secure prompts
- Core: add CredentialReaderOptions factory methods for FIDO2, OpenPGP, OATH

Interactive input never creates managed strings. CLI args wrapped in
DisposableArrayPoolBuffer for consistent IMemoryOwner<byte> lifecycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…o each module

Core should only contain generic credential options (ForPin, ForPuk,
ForPassphrase, ForHexKey). Module-specific presets belong in the modules
that own the domain knowledge:

- FidoCredentialOptions.ForFido2Pin() in Yubico.YubiKit.Fido2
- OpenPgpCredentialOptions.ForOpenPgpPin/AdminPin/ResetCode() in OpenPgp
- OathCredentialOptions.ForOathPassword() in Oath

This keeps Core free of FIDO2/OpenPGP/OATH domain knowledge and follows
the existing dependency direction (module → Core, never reverse).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 67 out of 67 changed files in this pull request and generated 15 comments.

Comments suppressed due to low confidence (1)

src/Fido2/examples/FidoTool/FidoExamples/CredentialManagement.cs:116

  • CredentialManagement now implements IDisposable, but instances here are not disposed. Even though pinToken is zeroed separately, not disposing leaves the class’s intended cleanup path unused and makes it easy for future edits to accidentally retain _pinUvAuthToken longer than expected. Prefer using var credMgmt = ... (and apply similarly to the other constructions in this file).
            pinToken = await clientPin.GetPinUvAuthTokenUsingPinAsync(
                pinUtf8.ToArray(),
                PinUvAuthTokenPermissions.CredentialManagement,
                cancellationToken: cancellationToken);

            var credMgmt = new Fido2.CredentialManagement.CredentialManagement(
                session, protocol, pinToken);

            var metadata = await credMgmt.GetCredentialsMetadataAsync(cancellationToken);

Comment thread src/OpenPgp/examples/OpenPgpTool/Cli/Commands/OpenPgpCommand.cs
Comment thread src/Fido2/examples/FidoTool/Cli/Prompts/FidoPinHelper.cs
Comment thread src/Fido2/examples/FidoTool/Cli/Prompts/FidoPinHelper.cs
Comment thread src/Fido2/examples/FidoTool/FidoExamples/PinManagement.cs Outdated
Comment thread src/Fido2/examples/FidoTool/FidoExamples/PinManagement.cs
Comment thread src/Fido2/examples/FidoTool/FidoExamples/CredentialManagement.cs Outdated
Comment thread src/Piv/src/PivSession.Metadata.cs
Comment thread src/Core/src/SmartCard/Scp/ScpInitializer.cs Outdated
Comment thread src/Core/src/SmartCard/Scp/ScpProcessor.cs
Comment thread src/Fido2/src/CredentialManagement/CredentialManagement.cs Outdated
DennisDyallo and others added 3 commits April 9, 2026 13:45
- Fix CredentialManagement.Dispose() to zero only the token slice, not the
  entire backing array (segment.AsSpan(offset, count) vs segment.Array)
- Remove unnecessary .ToArray() PIN copies in FIDO2 example files — all
  ClientPin methods accept ReadOnlyMemory<byte> directly, eliminating
  untracked plaintext PIN heap copies in PinManagement, MakeCredential,
  GetAssertion, ConfigManagement, BioEnrollment, CredentialManagement
- Remove .ToArray() for credentialId/userId in CredentialManagement examples
  (PublicKeyCredentialDescriptor/UserEntity also accept ReadOnlyMemory<byte>)
- Fix FidoPinHelper.GetPin/GetNewPin: encode CLI arg directly into pooled
  buffer via Encoding.UTF8.GetBytes(string, Span<byte>) to avoid untracked
  intermediate byte[] copy of PIN material
- Fix OpenPgpCommand.GetCredential: same direct-encode pattern to avoid
  untracked byte[] copy of credential material
- Remove two remaining hex-dump log calls in OtpHidProtocol.cs
  (WaitForReadyToRead report dump, SendFrameAsync report dump) —
  replaced with byte-count metadata only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…l menus

All PIV PinManagement example methods accept ReadOnlyMemory<byte>.
The .Memory.Span.ToArray() chain was triply redundant:
- .Memory already gives ReadOnlyMemory<byte> (what the callee expects)
- .Span converts to stack ref struct (cannot be stored anyway)
- .ToArray() allocates an untracked heap copy of PIN/key bytes

That untracked byte[] was never zeroed, leaving PIN, PUK, and
management key material on the managed heap until GC.

Fixed in: PinManagementMenu (5 sites), KeyGenerationMenu (1),
CryptoMenu (2), CertificatesMenu (4) — 12 sites total.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
KdfIterSaltedS2k.ParseData() called .ToArray() on TLV-parsed values to
take ownership of SaltUser, SaltReset, SaltAdmin, InitialHashUser, and
InitialHashAdmin. These byte[] copies were never explicitly zeroed —
they persisted on the managed heap until GC, with no secure cleanup path.

InitialHashUser/Admin in particular can contain hashes derived from
user-set PINs depending on card state.

Fix:
- Kdf abstract class now implements IDisposable (virtual no-op default)
- KdfIterSaltedS2k.Dispose() zeroes all five ReadOnlyMemory<byte>
  backing arrays via MemoryMarshal.TryGetArray + ZeroMemory on the
  exact slice (offset/count), not the full pooled array
- OpenPgpSession.Dispose() now calls _kdf?.Dispose() before nulling,
  ensuring salt/hash material is zeroed when the session closes

Found via expanded 9-taxonomy security scan (second pass).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 72 out of 72 changed files in this pull request and generated 8 comments.

Comment thread src/Core/src/SmartCard/Scp/ScpInitializer.cs Outdated
Comment thread src/Core/src/SmartCard/Scp/ScpProcessor.cs
Comment thread src/Core/src/SmartCard/Scp/ScpProcessor.cs Outdated
Comment thread src/OpenPgp/src/IOpenPgpSession.cs
Comment thread src/Oath/src/IOathSession.cs
Comment thread src/Fido2/src/CredentialManagement/CredentialManagement.cs Outdated
Comment thread src/Piv/src/PivSession.Crypto.cs
Comment thread src/OpenPgp/examples/OpenPgpTool/Cli/Commands/OpenPgpCommand.cs
DennisDyallo and others added 5 commits April 9, 2026 14:11
Introduces scripts/security-audit.sh — a CI-compatible ripgrep audit tool
covering all 9 sensitivity taxonomies identified during the
worktree-security-remediation review (April 2026).

Taxonomies:
  T1  .ToArray() copies of sensitive buffers (untracked heap copies)
  T2  Encoding.UTF8.GetBytes() → intermediate byte[] (span overload preferred)
  T3  Convert.ToHexString() inside LogTrace/LogDebug (key material in logs)
  T4  ArrayPool.Return without prior ZeroMemory or clearArray:true
  T5  Early return before ZeroMemory — AGENT ONLY (requires control-flow analysis)
  T6  string pin/password/secret in public API (should be ReadOnlyMemory<byte>)
  T7  IDisposable missing on class with key material — AGENT ONLY
  T8  Console.Write in production source (debug artifacts)
  T9  Crypto disposables (AesCmac/IncrementalHash) created without 'using'

Usage:
  ./scripts/security-audit.sh          # production scope (src/*/src/**/*.cs)
  ./scripts/security-audit.sh --all    # include examples and tests
  ./scripts/security-audit.sh --help   # taxonomy descriptions with FP rates

Exit code equals number of findings (0 = clean, CI-safe).
Patterns validated against post-fix codebase — T2 and T8 return 0 hits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-phase security audit skill for YubiKit:
- Phase 1: scripts/security-audit.sh (mechanical grep, all 9 taxonomies)
- Phase 2: semantic agent prompt for T5/T7 (control-flow and IDisposable gaps)
- Phase 3: triage guide with fix patterns per taxonomy
- CI integration snippet (exit-code-as-finding-count)

Documents known acceptable findings (ScpProcessor, PcscProtocol AID log)
and cross-references security-guidelines and domain-build skills.

Invoke with: /security-audit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three new agent-only taxonomy entries distilled from Copilot's third review:

T10 — IDisposable not disposed on exception/failure paths
  Distinct from T7 (class lacks IDisposable) and T9 (created without 'using').
  Pattern: IDisposable IS used correctly but Dispose() is skipped on error paths.
  Example: ScpProcessor not disposed when EXTERNAL AUTHENTICATE fails.

T11 — Conditional buffer allocation not covered by ZeroMemory
  Distinct from T5 (ZeroMemory in wrong location).
  Pattern: byte[] allocated inside 'if (encrypt)', out of scope in finally.
  Example: encryptedData in ScpProcessor.cs:145 skips zeroing on encrypt=true paths.

T12 — Dispose() zeros caller-provided buffer (ownership violation)
  Inverted pattern: zeroing memory you don't own, corrupting caller's view.
  Example: CredentialManagement.Dispose() zeroes caller-provided pinUvAuthToken.

Also documents the ApduCommand.Data = data?.ToArray() infrastructure clone as
a known T1 exception (API limitation, not individual call-site fix).

All three are agent-only (require control-flow / ownership / data-flow analysis).
Both scripts/security-audit.sh --help and the /security-audit SKILL.md updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ommand buffer

T10: ScpInitializer.InitScp03Async — wrap EXTERNAL AUTHENTICATE transmit in
try/catch; call scpProcessor.Dispose() on any exception so ScpState session
keys are zeroed rather than leaking to the GC finalizer.

T11: ScpProcessor.TransmitAsync — move encryptedData declaration before the
try block so the finally guard can call ZeroMemory on the encrypted command
byte array when encrypt=true.

FIDO ClientPin.cs and PinUvAuthProtocolV2.cs — verified already correct;
pinUvAuthParam and ciphertext are both zeroed in their respective finally
blocks (fixes applied in earlier sessions, open Copilot threads are stale).

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…Dispose()

Dispose() was calling MemoryMarshal.TryGetArray + ZeroMemory on _pinUvAuthToken,
which is caller-provided memory (passed via constructor). This violates ownership:
the caller (e.g. ClientPin.GetPinTokenAsync()) retains the token and is responsible
for zeroing it. Zeroing here corrupts the caller's view of the buffer.

Remove the ZeroMemory call and add an ownership comment. Also drop the now-unused
System.Runtime.InteropServices and System.Security.Cryptography imports.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…reaking changes

PIV (T1): PivSession.Crypto.SignOrDecryptCoreAsync — add ZeroMemory(preparedData)
in the finally block alongside commandData. preparedData (from PrepareDataForCrypto)
contains the raw hash/plaintext being signed/decrypted; was leaving a heap copy
unzeroed after the APDU was built and sent.

Docs: DisposableBufferHandle — add XML ownership contract: this type takes
ownership of the provided buffer and zeros it on Dispose. Callers must not
pass caller-held memory that must survive beyond the handle scope.

Docs: IOpenPgpSession.VerifyPinAsync / IOathSession.DeriveKey — add <remarks>
breaking-change note explaining string→ReadOnlyMemory<byte> migration rationale
and how callers should convert and zero UTF-8 encoded PIN/password bytes.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 75 out of 75 changed files in this pull request and generated 8 comments.

Comment on lines +83 to +85
scpCommandData = macedData.Span.ToArray();
ApduCommand scpCommand =
new(cla, command.Ins, command.P1, command.P2, macedData.Span.ToArray(), command.Le);
new(cla, command.Ins, command.P1, command.P2, scpCommandData, command.Le);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ApduCommand(int ... , data) clones the provided data via data?.ToArray() (see src/Core/src/SmartCard/ApduCommand.cs:35). That means scpCommandData gets duplicated into an untracked array inside ApduCommand, defeating the ZeroMemory(scpCommandData) cleanup here and leaving sensitive APDU payload/MAC material on the heap until GC. Consider constructing the command via object initializer (new ApduCommand { ... Data = scpCommandData, Le = ... }) or adding an overload/path that avoids cloning when the caller manages lifetime/zeroing.

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +117
finalCommandData = macedData.Span.ToArray();
ApduCommand finalCommand =
new(cla, command.Ins, command.P1, command.P2, macedData.Span.ToArray(), command.Le);
Console.WriteLine(
$"[SCP DEBUG] Final command: CLA={cla:X2} INS={finalCommand.Ins:X2} P1={finalCommand.P1:X2} P2={finalCommand.P2:X2} Data={Convert.ToHexString(finalCommand.Data.Span)}");
new(cla, command.Ins, command.P1, command.P2, finalCommandData, command.Le);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: ApduCommand clones finalCommandData internally (ApduCommand.cs:35), so zeroing finalCommandData in the finally block does not clear the copy actually stored on the command struct. This leaves the wrapped SCP command (including MAC) resident on the heap until GC. Prefer an object-initializer ApduCommand to keep Data referencing finalCommandData so the ZeroMemory here is effective (or introduce a non-cloning constructor for sensitive call sites).

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +102
var command = new ApduCommand(0x00, 0x47, 0x00, (byte)slot, data);
var response = await _protocol.TransmitAndReceiveAsync(command, throwOnError: false, cancellationToken).ConfigureAwait(false);

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data is passed into ApduCommand via the (int,int,int,int, ReadOnlyMemory<byte>?, int) constructor, which clones data using data?.ToArray() (src/Core/src/SmartCard/ApduCommand.cs:35). As a result, ZeroMemory(data) does not clear the APDU’s internal copy, and the template bytes persist on the heap until GC. Prefer building the APDU with an object initializer (new ApduCommand { ... Data = data }) to avoid cloning when you intend to manage/zero the buffer yourself.

Copilot uses AI. Check for mistakes.
Comment on lines +95 to +100
var authCommand = new ApduCommand(
CLA_SECURE_MESSAGING,
INS_EXTERNAL_AUTHENTICATE,
SECURITY_LEVEL_CMAC_CDEC_RMAC_RENC,
0x00,
hostCryptogram);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hostCryptogram is zeroed in the finally block, but new ApduCommand(..., hostCryptogram) clones it via data?.ToArray() (see src/Core/src/SmartCard/ApduCommand.cs:35). That clone will retain the host cryptogram bytes until GC. Consider constructing the APDU via object initializer (new ApduCommand { ... Data = hostCryptogram }) or adding a non-cloning APDU construction path for sensitive payloads.

Suggested change
var authCommand = new ApduCommand(
CLA_SECURE_MESSAGING,
INS_EXTERNAL_AUTHENTICATE,
SECURITY_LEVEL_CMAC_CDEC_RMAC_RENC,
0x00,
hostCryptogram);
var authCommand = new ApduCommand
{
Cla = CLA_SECURE_MESSAGING,
Ins = INS_EXTERNAL_AUTHENTICATE,
P1 = SECURITY_LEVEL_CMAC_CDEC_RMAC_RENC,
P2 = 0x00,
Data = hostCryptogram
};

Copilot uses AI. Check for mistakes.
Comment on lines +185 to +189
// INS 0x87 (AUTHENTICATE), P1 = algorithm, P2 = slot
var command = new ApduCommand(0x00, 0x87, (byte)algorithm, (byte)slot, commandData);
var response = await _protocol.TransmitAndReceiveAsync(command, throwOnError: false, cancellationToken).ConfigureAwait(false);

if (!response.IsOK())
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commandData is cleared in the finally block, but it is also cloned into ApduCommand.Data via data?.ToArray() in the ApduCommand constructor (src/Core/src/SmartCard/ApduCommand.cs:35). This leaves the sign/decrypt payload resident in the APDU-owned copy until GC. Consider constructing the APDU via object initializer (new ApduCommand { ... Data = commandData }) or adding a non-cloning APDU construction path for sensitive payloads.

Copilot uses AI. Check for mistakes.
Comment on lines +402 to +405

// INS 0x87 (AUTHENTICATE), P1 = algorithm, P2 = slot
var command = new ApduCommand(0x00, 0x87, (byte)algorithm, (byte)slot, data);
var response = await _protocol.TransmitAndReceiveAsync(command, throwOnError: false, cancellationToken).ConfigureAwait(false);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data buffer is zeroed in the finally block, but new ApduCommand(..., data) clones it via data?.ToArray() (src/Core/src/SmartCard/ApduCommand.cs:35). Any sensitive bytes in the command payload will remain in the APDU-internal copy until GC. Prefer object-initializer APDU construction to avoid cloning when you intend to manage/zero the buffer.

Copilot uses AI. Check for mistakes.
Comment thread src/OpenPgp/src/Kdf.cs Outdated
Comment on lines +267 to +271
public override void Dispose()
{
ZeroProperty(SaltUser);
ZeroProperty(SaltReset);
ZeroProperty(SaltAdmin);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dispose() zeros the backing arrays for Salt*/InitialHash*, but these are public init properties. If a consumer constructs KdfIterSaltedS2k with caller-owned memory (or a slice), disposing could unexpectedly zero memory the instance does not own. Consider storing owned byte[] fields internally (clone on parse/assignment) and exposing ReadOnlyMemory<byte> views, or otherwise tracking ownership so Dispose() only clears buffers allocated by this instance.

Copilot uses AI. Check for mistakes.
Comment on lines +25 to +29
/// Creates options configured for FIDO2 PIN input (4-63 bytes UTF-8).
/// </summary>
public static CredentialReaderOptions ForFido2Pin() => new()
{
Prompt = "Enter FIDO2 PIN: ",
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says “4-63 bytes UTF-8”, but ConsoleCredentialReader enforces MinLength/MaxLength in characters (no encoded-byte-length validation). This mismatch can allow input that later fails ClientPin.ValidatePin when multi-byte characters exceed 63 bytes. Either update the doc to refer to characters, or add encoded-byte-length validation support to the credential reader/options.

Copilot uses AI. Check for mistakes.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…aled class with IDisposable

The previous readonly record struct copied its Data payload via .ToArray() in the
constructor, creating a heap clone that callers had no reference to and could not
zero — a T1 API limitation affecting every APDU that carries sensitive material
(cryptograms, PIN hashes, management keys, encrypted command data).

Changes:
- ApduCommand is now a sealed class implementing IDisposable
- Data is backed by a private byte[] field; ZeroData() calls ZeroMemory on it
- Dispose() calls ZeroData() + GC.SuppressFinalize — use 'using var' for sensitive APDUs
- init setters preserved — all object-initializer call sites unchanged
- ChainedApduTransmitter: replaced 'record with' expression with explicit constructor
- ScpProcessor: scpCommand and finalCommand declared before try block; ZeroData()
  called in finally to zero the ApduCommand-internal copies of MAC'd command data
- ScpInitializer: authCommand (carries hostCryptogram) now uses 'using var'
- ApduException: removed .Value nullable-struct dereference (now a reference type)
- CLAUDE.md: added architectural rule — sensitive byte payloads must live in classes
  with IDisposable, not structs (struct copies cannot be comprehensively zeroed)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 80 out of 80 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

src/Core/src/SmartCard/ChainedApduTransmitter.cs:48

  • ApduCommand now owns an internal clone of Data and should be disposed/zeroed when it can carry sensitive payloads. chainedCommand / finalCommand are created per chunk and never disposed, so chunk data may persist in heap copies until GC. Consider using var chainedCommand = ... / using var finalCommand = ... to clear internal buffers after each transmit.
            var chunk = data[offset..ShortApduMaxChunk];
            var chainedCommand = new ApduCommand(command.Cla | HasMoreData, command.Ins, command.P1, command.P2, chunk, command.Le);

            var result = await base.TransmitAsync(chainedCommand, useScp, cancellationToken).ConfigureAwait(false);
            if (result.SW != SWConstants.Success)
                return result;

            offset += ShortApduMaxChunk;
        }

        var finalChunk = data[offset..];
        var finalCommand = new ApduCommand(command.Cla, command.Ins, command.P1, command.P2, finalChunk, command.Le);
        return await base.TransmitAsync(finalCommand, useScp, cancellationToken).ConfigureAwait(false);

scripts/security-audit.sh:379

  • exit $TOTAL_FINDINGS can return values >255, which wrap modulo 256 in POSIX shells. With enough findings the script can exit 0 (or another misleading value), breaking CI semantics. Consider clamping to 255, or using exit 1 when findings > 0 while printing the count separately.
else
    echo "  ⚠  AUDIT FLAGGED — $TOTAL_FINDINGS finding(s) require review"
    echo "  Each finding above needs human verification before declaring clean."
    echo "  See --help for false-positive guidance per taxonomy."
    echo "  Note: T5, T7, T10, T11, T12 require agent-based analysis (see above)."
fi
echo "=============================================================================="

exit $TOTAL_FINDINGS

@@ -34,7 +34,7 @@ public override async Task<ApduResponse> TransmitAsync(ApduCommand command, bool
while (offset + ShortApduMaxChunk < data.Length)
{
var chunk = data[offset..ShortApduMaxChunk];
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chunk slicing is incorrect: data[offset..ShortApduMaxChunk] uses ShortApduMaxChunk as an absolute end index rather than offset + ShortApduMaxChunk. After the first iteration (offset == ShortApduMaxChunk), this becomes an empty slice and chaining will break. Use data[offset..(offset + ShortApduMaxChunk)] (or data.Slice(offset, ShortApduMaxChunk)).

Suggested change
var chunk = data[offset..ShortApduMaxChunk];
var chunk = data[offset..(offset + ShortApduMaxChunk)];

Copilot uses AI. Check for mistakes.
Comment on lines 88 to +110
@@ -97,54 +107,29 @@ public async Task<ApduResponse> TransmitAsync(ApduCommand command, bool useScp,
// Extended APDU has 3-byte Le, short APDU has 1-byte Le
macLength -= isExtendedApdu ? 3 : 1;

var mac = State.Mac(apduToMac[..macLength]);

Console.WriteLine(
$"[SCP DEBUG] Original command: CLA={command.Cla:X2} INS={command.Ins:X2} P1={command.P1:X2} P2={command.P2:X2}");
Console.WriteLine(
$"[SCP DEBUG] Original data ({commandData.Length} bytes): {Convert.ToHexString(commandData.Span)}");
Console.WriteLine($"[SCP DEBUG] MACed data length (with space): {macedData.Length} bytes");
Console.WriteLine(
$"[SCP DEBUG] APDU to MAC ({macLength} bytes): {Convert.ToHexString(apduToMac[..macLength])}");
Console.WriteLine($"[SCP DEBUG] Computed MAC: {Convert.ToHexString(mac.AsSpan())}");
mac = State.Mac(apduToMac[..macLength]);
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

formattedApdu is produced by Formatter.Format(...) / _apduFormatterExtended.Format(...), which allocates a new byte[] (see ApduFormatterShort/ApduFormatterExtended returning buffer.ToArray()). That array contains the full formatted APDU (including sensitive command data) but is never zeroed after MAC computation. Consider capturing the returned array and clearing it (e.g., via MemoryMarshal.TryGetArray(formattedApdu, ...) + ZeroMemory) in the finally block once mac has been computed.

Copilot uses AI. Check for mistakes.
Bug fix:
- ChainedApduTransmitter: data[offset..ShortApduMaxChunk] was wrong after
  first chunk (empty slice). Fixed to data[offset..(offset+ShortApduMaxChunk)].

Security (zeroing):
- ScpProcessor: capture formattedApdu backing array via MemoryMarshal and
  zero it in finally after MAC computation (was left unzeroed on heap)
- PivSession.Crypto: add 'using var' to ApduCommand in PerformCryptoAsync
  and ECDH method so internal data copy is zeroed before finally runs
- PivSession.KeyPairs: add 'using var' to ApduCommand in GenerateKeyPairAsync
  and ImportPrivateKeyAsync
- PivSession.Metadata: add 'using var' to ApduCommand in SetManagementKeyAsync,
  ChangePukAsync, and UnblockPinAsync

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@DennisDyallo
Copy link
Copy Markdown
Collaborator Author

Addressed all issues from the latest review round (15:40 and 14:51):

Bug fixed:

  • ChainedApduTransmitter: corrected data[offset..ShortApduMaxChunk]data[offset..(offset + ShortApduMaxChunk)] (was yielding empty slices after first chunk)

Security (zeroing formattedApdu backing array):

  • ScpProcessor: formattedApdu byte[] returned by Formatter.Format() now captured via MemoryMarshal.TryGetArray and zeroed in the finally block after MAC computation

Security (ApduCommand internal copy zeroing via using var):

  • PivSession.Crypto: PerformCryptoAsync + ECDH method — using var command ensures ZeroData() runs before finally
  • PivSession.KeyPairs: GenerateKeyPairAsync + ImportPrivateKeyAsync
  • PivSession.Metadata: SetManagementKeyAsync, ChangePukAsync, UnblockPinAsync

All 9 test suites pass. Commit: 51c7de8a

…eview

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
KdfIterSaltedS2k had public init properties backed by ReadOnlyMemory<byte>
but no ownership contract. Dispose() would zero whatever backing array was
provided — including caller-owned buffers (T12 ownership violation).

Fix: switch to private byte[] backing fields; init setters call .ToArray()
so KdfIterSaltedS2k always owns the arrays it zeroes on Dispose. Remove
MemoryMarshal from Dispose (direct byte[] ZeroMemory, simpler and safer).
Remove redundant .ToArray() from ParseData (init setter handles it).

Bug also caught: byte[]? null → ReadOnlyMemory<byte>? implicit conversion
yields Empty (non-null Nullable), breaking ?? fallback in GetSalt. Fixed
with explicit null guard in all four nullable property getters.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 80 out of 80 changed files in this pull request and generated 1 comment.

Comment thread src/OpenPgp/src/Kdf.cs Outdated
Comment on lines +266 to +295
@@ -273,4 +285,12 @@ internal static KdfIterSaltedS2k ParseData(IDictionary<int, ReadOnlyMemory<byte>
InitialHashUser = data.TryGetValue(0x87, out var hu) ? hu.ToArray() : null,
InitialHashAdmin = data.TryGetValue(0x88, out var ha) ? ha.ToArray() : null,
};

private static void ZeroProperty(ReadOnlyMemory<byte>? memory)
{
if (memory is { } m && MemoryMarshal.TryGetArray(m, out var segment) && segment.Array is not null)
{
CryptographicOperations.ZeroMemory(segment.AsSpan(segment.Offset, segment.Count));
}
}
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KdfIterSaltedS2k.Dispose() zeroes the backing arrays of SaltUser/SaltReset/SaltAdmin/InitialHash* via MemoryMarshal.TryGetArray(...). Because these properties are publicly init-settable ReadOnlyMemory<byte>, callers can pass memory they still own (e.g., a shared byte[] or slice), and disposing the KDF instance will silently zero the caller’s buffer (ownership violation). Also, TryGetArray will not zero non-array-backed memory (e.g., MemoryPool<byte>), so disposal is not reliably clearing sensitive data.

Consider making these values internally owned (copy into private byte[] fields on init/parse and expose as ReadOnlyMemory<byte>), or explicitly document ownership transfer and accept byte[] (not ReadOnlyMemory<byte>) for owned fields.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 80 out of 80 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

src/OpenPgp/src/OpenPgpSession.Pin.cs:208

  • VerifyPwAsync allocates derived PIN bytes (derivedBytes) and builds an ApduCommand from them. ApduCommand copies the payload internally (data.ToArray()), but this command instance is never disposed/ZeroData()'d, so the internal copy of the derived PIN remains on the heap even though derivedBytes is zeroed in the finally. Use using var command = ... (or try/finally calling command.Dispose() / command.ZeroData()) to ensure the ApduCommand internal buffer is cleared after transmission.
    src/OpenPgp/src/OpenPgpSession.Pin.cs:283
  • ChangePwAsync builds an ApduCommand from combined (derived PIN bytes). Even though combined is zeroed in the finally, ApduCommand clones the data internally and this command is never disposed/ZeroData()'d, leaving an extra heap copy of sensitive PIN material. Wrap the command in using var or dispose/ZeroData it in a finally after TransmitAsync returns (including exception paths).
    src/Core/src/SmartCard/ChainedApduTransmitter.cs:48
  • ChainedApduTransmitter creates new ApduCommand instances per chunk, which internally clone the chunk data. These intermediate commands are never disposed/ZeroData()'d, so their internal heap copies can persist (including the early-return path on non-success SW and any exception path). Consider wrapping chainedCommand/finalCommand in using var (or try/finally) so their internal payload buffers are cleared immediately after each transmission.
        while (offset + ShortApduMaxChunk < data.Length)
        {
            var chunk = data[offset..(offset + ShortApduMaxChunk)];
            var chainedCommand = new ApduCommand(command.Cla | HasMoreData, command.Ins, command.P1, command.P2, chunk, command.Le);

            var result = await base.TransmitAsync(chainedCommand, useScp, cancellationToken).ConfigureAwait(false);
            if (result.SW != SWConstants.Success)
                return result;

            offset += ShortApduMaxChunk;
        }

        var finalChunk = data[offset..];
        var finalCommand = new ApduCommand(command.Cla, command.Ins, command.P1, command.P2, finalChunk, command.Le);
        return await base.TransmitAsync(finalCommand, useScp, cancellationToken).ConfigureAwait(false);

DennisDyallo and others added 3 commits April 10, 2026 18:18
… memory

Replace the sealed class + IDisposable clone design with a readonly record
struct that stores ReadOnlyMemory<byte> directly. Callers own the buffer and
zero it themselves — the same contract all other .NET IO APIs use.

- ApduCommand is now readonly record struct (matches ApduResponse)
- No internal byte[] clone, no ZeroData(), no IDisposable
- Remove redundant ZeroData() calls from ScpProcessor.finally (source
  arrays are already explicitly zeroed in the same block)
- Strip 'using var' from ~15 ApduCommand construction sites across Piv,
  SecurityDomain, OpenPgp, Oath, YubiHsm, and SCP stack
- Fix ApduException.FromResponse for nullable struct (pattern match)
- Add equality-semantics NOTE: ReadOnlyMemory<byte> uses reference equality;
  do not use ApduCommand in Dictionary/HashSet or == comparisons

Reviewer notes: no HIGH issues. Two MEDIUM addressed — ScpInitializer
finally-block comment clarified; equality hazard documented inline.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 92 out of 92 changed files in this pull request and generated 3 comments.

Comment on lines +22 to 44
/// <remarks>
/// <para>
/// <b>Ownership and zeroing:</b> <see cref="ApduCommand"/> stores a reference to the caller-provided
/// <see cref="Data"/> buffer — it does <b>not</b> clone it. When the payload carries sensitive material
/// such as PIN bytes, key material, or cryptograms, the <b>caller</b> is responsible for zeroing the
/// source buffer after the command has been transmitted.
/// </para>
/// <code>
/// // ✅ Correct — caller zeroes the source buffer after transmission
/// var command = new ApduCommand(0x00, InsVerify, 0x00, 0x80, pinnedPin.AsMemory(0, 8));
/// await protocol.TransmitAndReceiveAsync(command, ct);
/// CryptographicOperations.ZeroMemory(pinnedPin); // zeroes the buffer command.Data referenced
///
/// // ✅ Non-sensitive commands need no special handling
/// var command = new ApduCommand(0x00, InsSelect, 0x04, 0x00, appId);
/// var response = await protocol.TransmitAndReceiveAsync(command, ct);
/// </code>
/// </remarks>
public readonly record struct ApduCommand
{
public ApduCommand()
{
}

private ApduCommand(byte cla, byte ins, byte p1, byte p2, ReadOnlyMemory<byte>? data = null, int le = 0)
{
Cla = cla;
Ins = ins;
P1 = p1;
P2 = p2;
Le = le;
Data = data?.ToArray() ?? ReadOnlyMemory<byte>.Empty;
}

public ApduCommand(int cla, int ins, int p1, int p2, ReadOnlyMemory<byte>? data = null, int le = 0)
: this(
ByteUtils.ValidateByte(cla, nameof(cla)),
ByteUtils.ValidateByte(ins, nameof(ins)),
ByteUtils.ValidateByte(p1, nameof(p1)),
ByteUtils.ValidateByte(p2, nameof(p2)),
data,
le)
/// <summary>Initializes a new <see cref="ApduCommand"/> with explicit header bytes and optional data payload.</summary>
public ApduCommand(int cla, int ins, int p1, int p2, ReadOnlyMemory<byte> data = default, int le = 0)
{
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description and CLAUDE.md both state that sensitive heap payloads should not live in a struct and that ApduCommand is the canonical sealed-class+IDisposable example, but ApduCommand is still a readonly record struct here. This is a mismatch that makes the guidance and the implementation inconsistent. Either convert ApduCommand to a sealed class with IDisposable/zeroing as described in the PR, or update the PR description + CLAUDE.md rule to match the new “caller-owned buffer, no internal clone” struct design (and clearly document the behavioral breaking change that Data is no longer cloned).

Copilot uses AI. Check for mistakes.
Comment thread CLAUDE.md Outdated
- ✅ ALWAYS dispose crypto objects: `using var aes = Aes.Create()`
- ❌ NEVER log PINs, keys, or sensitive payloads
- ❌ NEVER use timing-vulnerable comparisons (use `FixedTimeEquals`)
- ❌ NEVER put a heap-allocated sensitive payload (`byte[]`, `ReadOnlyMemory<byte>`) in a `struct` or `readonly record struct`. Structs are copied by value — you cannot zero all copies. Use a `sealed class` with `IDisposable`; call `ZeroMemory` in `Dispose()`. See `ApduCommand` as the canonical example.
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This security guideline cites ApduCommand as the canonical example of a sealed class with IDisposable for sensitive payloads, but the current implementation of ApduCommand is a readonly record struct. Please update this rule (or the ApduCommand implementation) so the guidance is accurate and doesn’t mislead future changes.

Suggested change
- ❌ NEVER put a heap-allocated sensitive payload (`byte[]`, `ReadOnlyMemory<byte>`) in a `struct` or `readonly record struct`. Structs are copied by value — you cannot zero all copies. Use a `sealed class` with `IDisposable`; call `ZeroMemory` in `Dispose()`. See `ApduCommand` as the canonical example.
- ❌ NEVER put a heap-allocated sensitive payload (`byte[]`, `ReadOnlyMemory<byte>`) in a `struct` or `readonly record struct`. Structs are copied by value — you cannot zero all copies. Use a `sealed class` with `IDisposable`; call `ZeroMemory` in `Dispose()`, and document the disposal requirements on the type.

Copilot uses AI. Check for mistakes.
Comment on lines 674 to 678
// TAG_PRIVATE_KEY with empty value signals on-device key generation.
// Python canonical: _put_credential(management_key, label, b"", EC_P256, credential_password)
// TODO: Dispose of all Tlv instances created here.
var data = TlvHelper.EncodeList(
[
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GenerateCredentialAsymmetricAsync builds TLVs containing the management key and credential password, but the created Tlv instances (Tlv is IDisposable and zeroes its internal encoded bytes) are not disposed, and the encoded data buffer from TlvHelper.EncodeList is not zeroed after transmission. This leaves sensitive material on the heap until GC. Please dispose the TLVs in a finally and zero the encoded data buffer after the APDU is sent (similar to the pattern used in PutCredentialAsymmetricAsync).

Copilot uses AI. Check for mistakes.
- Root CLAUDE.md: replace single anti-pattern rule with two rules —
  struct-owns-clone (bad) vs ReadOnlyMemory<byte> passthrough (safe);
  cite ApduCommand as the canonical passthrough example
- Root CLAUDE.md: fix Common Mistakes section — rename bad example from
  ApduCommand to SensitivePayload; update good example to show
  readonly record struct ApduCommand alongside sealed class SessionKey
- Core CLAUDE.md: add sensitive-command zeroing example to Sending APDUs

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@DennisDyallo DennisDyallo merged commit 59b629e into yubikit-applets Apr 10, 2026
1 check passed
@DennisDyallo DennisDyallo deleted the worktree-security-remediation branch April 10, 2026 18:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants