security(core,fido2,piv): fix sensitive data handling#447
security(core,fido2,piv): fix sensitive data handling#447DennisDyallo merged 26 commits intoyubikit-appletsfrom
Conversation
…, 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>
There was a problem hiding this comment.
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/ScpProcessorand 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. |
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>
There was a problem hiding this comment.
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
CredentialManagementnow implementsIDisposable, but instances here are not disposed. Even thoughpinTokenis zeroed separately, not disposing leaves the class’s intended cleanup path unused and makes it easy for future edits to accidentally retain_pinUvAuthTokenlonger than expected. Preferusing 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);
- 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>
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>
| 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); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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).
| var command = new ApduCommand(0x00, 0x47, 0x00, (byte)slot, data); | ||
| var response = await _protocol.TransmitAndReceiveAsync(command, throwOnError: false, cancellationToken).ConfigureAwait(false); | ||
|
|
There was a problem hiding this comment.
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.
| var authCommand = new ApduCommand( | ||
| CLA_SECURE_MESSAGING, | ||
| INS_EXTERNAL_AUTHENTICATE, | ||
| SECURITY_LEVEL_CMAC_CDEC_RMAC_RENC, | ||
| 0x00, | ||
| hostCryptogram); |
There was a problem hiding this comment.
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.
| 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 | |
| }; |
| // 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()) |
There was a problem hiding this comment.
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.
|
|
||
| // 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); |
There was a problem hiding this comment.
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.
| public override void Dispose() | ||
| { | ||
| ZeroProperty(SaltUser); | ||
| ZeroProperty(SaltReset); | ||
| ZeroProperty(SaltAdmin); |
There was a problem hiding this comment.
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.
| /// Creates options configured for FIDO2 PIN input (4-63 bytes UTF-8). | ||
| /// </summary> | ||
| public static CredentialReaderOptions ForFido2Pin() => new() | ||
| { | ||
| Prompt = "Enter FIDO2 PIN: ", |
There was a problem hiding this comment.
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.
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>
There was a problem hiding this comment.
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
ApduCommandnow owns an internal clone ofDataand should be disposed/zeroed when it can carry sensitive payloads.chainedCommand/finalCommandare created per chunk and never disposed, so chunk data may persist in heap copies until GC. Considerusing 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_FINDINGScan 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 usingexit 1when 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]; | |||
There was a problem hiding this comment.
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)).
| var chunk = data[offset..ShortApduMaxChunk]; | |
| var chunk = data[offset..(offset + ShortApduMaxChunk)]; |
| @@ -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]); | |||
There was a problem hiding this comment.
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.
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>
|
Addressed all issues from the latest review round (15:40 and 14:51): Bug fixed:
Security (zeroing formattedApdu backing array):
Security (ApduCommand internal copy zeroing via
All 9 test suites pass. Commit: |
…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>
| @@ -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)); | |||
| } | |||
| } | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = ...(ortry/finallycallingcommand.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 thoughcombinedis 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 inusing varor 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/finalCommandinusing var(ortry/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);
… 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>
| /// <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) | ||
| { |
There was a problem hiding this comment.
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).
| - ✅ 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. |
There was a problem hiding this comment.
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.
| - ❌ 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. |
| // 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( | ||
| [ |
There was a problem hiding this comment.
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).
- 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>
Summary
Post-Review Fixes (commits 2d6f2b4 – c0429bb)
T10 — IDisposable not disposed on exception/failure paths
T11 — Conditional buffer allocation not covered by ZeroMemory
T12 — Dispose() zeros caller-provided buffer (ownership violation)
T1 — preparedData and ApduCommand internal clones
Documentation and acknowledgements
Additional Copilot review fixes
Security Impact
Files Changed
Test plan
🤖 Generated with Claude Code